diff --git a/CHANGELOG.md b/CHANGELOG.md index 4558479..17b277e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,168 @@ # PiCloud Changelog +## v1.1.8 — User Management (unreleased) + +Per-app data-plane user management — a `users::*` SDK for scripts +plus an admin HTTP surface and dashboard tab — together with three +load-bearing follow-ups from v1.1.7 (dropping the plaintext realtime +signing-key column, `--all-targets` clippy discipline, and adding a +session-based realtime SSE auth mode). + +**UPGRADE PATH IS LOAD-BEARING.** v1.1.8 requires v1.1.7 to have +been applied first. Operators upgrading directly from v1.1.6 or +earlier MUST apply v1.1.7 (and let its startup encryption sweep +complete) before applying v1.1.8 — migration 0032's guard refuses +to drop the plaintext `realtime_signing_key` column while any row +still needs encryption. + +### Added — `users::*` SDK (data-plane user management) + +- **`users::create(#{...})` / `users::get(id)` / `users::find_by_email(...)` + / `users::update(id, #{...})` / `users::delete(id)` / + `users::list(#{ "$limit", cursor })`** — CRUD on per-app end-users + (distinct from the control-plane `admin_users` table). Identity tuple + is `(app_id, user_id)`; uniqueness is on `(app_id, lower(email))` so + the same email can exist across two apps but not twice within one. + Password hash is Argon2id PHC (same algorithm as `admin_users`); + the public AppUser record never carries the hash. +- **`users::login(email, password)`** returns a session token string, + or `()` on bad credentials. The bad-email and bad-password branches + share wall-clock cost via a timing-flat dummy Argon2id verify on + email miss. **Required for security**, not polish. +- **`users::verify(token)`** returns the user on success (bumping the + sliding TTL), `()` on missing / expired / revoked / cross-app. +- **`users::logout(token)`** revokes the session row. +- `migrations/0026_app_users.sql` + `0027_app_user_sessions.sql`. + +### Added — Sessions + +- `app_user_sessions` table with SHA-256 token hash, sliding TTL + (`PICLOUD_APP_USER_SESSION_TTL_HOURS`, default 24h), hard cap + (`PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS`, default 720h = 30d), + and explicit `revoked_at` for logout / admin revoke-all / password- + reset side-effects. Lookups reject revoked/expired rows + immediately, before the weekly GC sweep runs. + +### Added — Email verification + +- **`users::send_verification_email(user_id, #{ link_base, from, + subject, body_template })`** mints a 32-byte token, stores its + SHA-256 in `app_user_email_verifications`, and dispatches via + `email::send` with `{link}` in the body substituted for + `link_base?token=`. `EmailError::NotConfigured` propagates as + `UsersError::EmailNotConfigured` so scripts already handling the + v1.1.7 email-disabled mode don't need new branches. +- **`users::verify_email(token)`** atomically consumes the one-shot + token, sets `email_verified_at = NOW()`, emits + `users::email_verified`. +- `migrations/0028_app_user_email_verifications.sql`. + +### Added — Password reset + +- **`users::request_password_reset(email, #{ template })`** returns + `Ok(())` regardless of whether the email matched — no + existence-leak signal in script-land. Email-not-configured does + surface (operators need to know); other email failures are silently + swallowed and logged server-side. +- **`users::complete_password_reset(token, new_password)`** atomically + consumes the token, updates the Argon2id hash, and **revokes every + active session** for the user (stale tokens shouldn't ride out the + reset). TTL `PICLOUD_APP_USER_PASSWORD_RESET_TTL_HOURS` (default 1h). +- `migrations/0029_app_user_password_resets.sql`. + +### Added — Invitations + +- **`users::invite(email, #{ template?, display_name?, roles? })`** + gated on `AppUsersAdmin`. Omitting the template skips the email + send so an admin can issue invitations for out-of-band delivery. +- **`users::accept_invite(token, password, display_name?)`** atomically + consumes the invitation, creates the user with pre-staged roles + applied, and mints a fresh session — caller can return both the + user and the session token in one round trip. TTL + `PICLOUD_APP_USER_INVITATION_TTL_DAYS` (default 7d). +- `migrations/0030_app_user_invitations.sql`. + +### Added — Per-app roles + +- **`users::add_role(id, role)` / `users::remove_role(id, role)` / + `users::has_role(id, role)`** — string-tagged, per-app. The script + app decides what `"admin"` / `"editor"` / `"viewer"` mean. Stored + in `app_user_roles` (composite PK so `add` is idempotent). +- The full `AppUser` record returned by every `users::*` getter now + carries a `roles` array. +- `migrations/0031_app_user_roles.sql`. +- **Role permission matrices / hierarchy / role registry are + explicitly v1.2 work** — v1.1.8 does not pre-commit to a model + that would lock in a wrong API. + +### Added — Admin HTTP surface + +- `GET / POST / PATCH / DELETE /api/v1/admin/apps/{id}/users` and + `/users/{user_id}` — list, get, create, update, delete. +- `POST /users/{user_id}/reset-password` — returns a one-shot reset + token in the response body (TTL 1h). No email is sent — that's + the SDK's `users::request_password_reset`. +- `POST /users/{user_id}/revoke-sessions` — bulk revoke for that + user. +- `GET / POST / DELETE /api/v1/admin/apps/{id}/invitations` and + `/invitations/{invite_id}` — list, create, revoke pending. +- Capability gating: `AppUsersRead` for reads, `AppUsersWrite` for + CRUD, `AppUsersAdmin` for admin-mediated verbs + invitations. All + three map to existing scopes (`script:read` / `script:write`) — + **no new scope** (seven-scope commitment). +- DTOs never include password hashes, session tokens, or unrotated + reset tokens. + +### Added — Dashboard Users tab + +- `apps/[slug]/users/+page.svelte` — list, create form, edit modal, + revoke-sessions, reset-password (one-shot token displayed once), + delete. Sub-route `users/invitations/+page.svelte` for pending + invitations (list, create with optional inline email template, + revoke). +- The main app-detail tab strip gains a Users entry above Files. +- `@picloud/dashboard` 0.13.0 → 0.14.0. + +### Changed — Realtime: drop plaintext signing-key column (F1) + +- `migrations/0032_drop_plaintext_realtime_signing_key.sql` — + guard query + `ALTER TABLE app_secrets DROP COLUMN IF EXISTS + realtime_signing_key`. The guard refuses to apply if any row still + has plaintext but no encrypted counterpart. +- v1.1.7's `migrate_plaintext_keys` startup task and its call from + `build_app` are deleted; the read path now consults only the + encrypted columns. + +### Changed — Realtime: `auth_mode = 'session'` (F3) + +- `migrations/0033_topics_auth_mode_session.sql` widens the + `topics_auth_mode_check` CHECK to allow `'session'` alongside + `'public'` and `'token'`. +- `RealtimeAuthorityImpl` gains a `UsersService` dependency; the + Session branch of `authorize_subscribe` delegates to + `verify_session_for_realtime(app_id, token)` (same DB lookup + + sliding-TTL bump as the SDK's `users::verify`, but app_id is + taken from the topic row, not a script). +- Dashboard Topics tab gains `session` as a third radio option. +- `token` (HMAC) validator is unchanged; both coexist per topic. + +### Notes + +- New env vars (all with documented defaults): + - `PICLOUD_APP_USER_SESSION_TTL_HOURS` (24) + - `PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS` (720 = 30d) + - `PICLOUD_APP_USER_VERIFICATION_TTL_HOURS` (48) + - `PICLOUD_APP_USER_PASSWORD_RESET_TTL_HOURS` (1) + - `PICLOUD_APP_USER_INVITATION_TTL_DAYS` (7) +- SDK schema 1.8 → 1.9 (additive surface: `users::*` + `session` + topic auth mode). +- Workspace version 1.1.7 → 1.1.8. +- `@picloud/client` does NOT bump in v1.1.8 — its `auth.login` / + `auth.logout` helpers already call dev-defined endpoints; + nothing to change in the client. +- New weekly GC sweep (`spawn_app_user_token_gc`) prunes expired / + revoked / consumed rows across the four new token tables. + ## v1.1.7 — Configuration & Email (unreleased) The operational-config layer: **encrypted per-app secrets**, **outbound