feat(server): push device registration and notification settings API (TASK-3938) (#2269)

* feat(store): push device register/delete, SessionInfo.ID, one-tx session rotation (TASK-3938)

RegisterPushDevice is one transaction: delete rows on either unique key,
evict the oldest at the 20-device cap, insert. RotateUserSessions mints the
replacement, rebinds the caller's own devices, deletes the other sessions,
and fails closed to revoke-all.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* feat(server): push device and notification-settings routes, push_available (TASK-3938)

Session-only PUT/DELETE /me/push-devices/{connection_id}, GET/PUT
/me/notification-settings, push_available on /auth/session,
PAD_PUSH_GATEWAY_URL, and all credential-change rotations through
RotateUserSessions. docs/api.md updated.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* test(server): classify GET /me/notification-settings in the compress census (TASK-3938)

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* fix(server): the pinned-token refusal on notification settings takes BUG-3982's shape (TASK-3938 r1)

* fix(server): notification settings leave the pinned-token refusal to the BUG-3982 gate (TASK-3938 r1)

* test(store): concurrent registers at 19 devices hold the push-device cap (TASK-3938)

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* test(store): make the concurrent-register cap test able to fail on Postgres (TASK-3938)

Reuse sessions across rounds and widen the count-to-insert window with a
test-owned slow insert trigger, so removing the user-row lock overshoots
the cap on every run.

Claude-Session: https://claude.ai/code/session_019KLyQQPbv9eCe1d6PT2wHP

* docs(server): a workspace-pinned token on /me/push-devices gets the BUG-3982 refusal, not session_required (TASK-3938 r1)

* fix(server): push_available goes to a signed-in caller only (TASK-3938 r2)
This commit is contained in:
xarmian
2026-10-21 06:49:54 -04:00
committed by GitHub
parent 104f809e67
commit d76f06d199
13 changed files with 1612 additions and 44 deletions
+3 -1
View File
@@ -119,7 +119,9 @@ One browser tab's content writes to an item are ordered by the server, also **pe
- **Pagination.** `next_cursor` is an opaque token, present only when more rows follow; omit it to start. It carries a position (`v`, sort, view, workspace filter, `as_of`, the sort key and the item id) and no authority: every page re-runs the whole filtered pipeline and takes the rows strictly after the key, and `as_of` (set on page 1) is the time defers are tested at, so one expiring between pages does not reshuffle them; an `as_of` later than the server's now is clamped to now. A malformed cursor, or one minted for a different sort, view or `workspace`, is `400 invalid_cursor`. A row's `marker` is the item's state now, to send back when snoozing it.
- **`degraded`** lists, by slug, the admitted workspaces whose score provider failed (or whose attention decisions could not be read). It is set only on a provider ERROR, never from the decision provider's failing bit: that bit covers items the caller cannot see, and exposing it would leak one bit about them. The workspace's other rows still return.
- `GET /api/v1/me/ui-dismissals`, `PUT /api/v1/me/ui-dismissals/{key}` — one-time UI suggestions the caller dismissed (TASK-3452: the first-time tutorial cards on /console and the workspace launchpad), stored on the user (`users.ui_dismissals`, a JSON set) so a dismissal holds on every device. Keys are a closed set (`models.UIDismissalKeys`); any other answers 400. A column rather than a table by ruling, until a third kind of dismissal appears. Web client only
- `GET /api/v1/auth/session` — auth status (`setup_required`, `setup_method`, `auth_method`, `authenticated`, `email_configured`, `user`)
- `PUT|DELETE /api/v1/me/push-devices/{connection_id}` — register or remove the caller's push device (TASK-3938, PLAN-3908 phase 2). **Session-only**: a cookie or a `padsess_` bearer. An API token, an OAuth bearer (also with a session cookie riding along), a token with a cookie, and an app principal all get `403 session_required`; a workspace-pinned API token is refused before that by the BUG-3982 gate (`403 permission_denied`, `details.reason: "token_workspace_pinned"`); no credential is `401`. After that gate, `404 push_unavailable` when the server has no gateway (the app stops trying that server until its next start, and treats any other 404 as a retry, so the error code is a contract), then `400 validation_error` unless `connection_id` matches `^[A-Za-z0-9-]{1,64}$`. PUT body is strict: `{platform: "ios"|"android", token, app_version?, environment?}`; `token` is non-empty, at most 4096 bytes and NUL-free, `app_version` at most 64 characters, an unknown or repeated member is `400`. `environment` is accepted for client compatibility and IGNORED: every token is an FCM token and FCM routes APNs itself. Success is `200 {id, connection_id, platform, app_version, updated_at}`; DELETE is `204`, also when nothing was registered, and removes only the device bound to the caller's own session under that connection id. **Register is one transactional replace**: it deletes every row on EITHER unique key (the caller's session's own row, whatever its token, and this user's row for this token, whatever its session, even one whose session is still alive), evicts, then inserts, so re-registering after a re-sign-in or an FCM token rotation leaves exactly one row, on the caller's session. **Cap: 20 devices per user.** At the cap the device with the oldest `updated_at` (ties: `created_at`, then id) is evicted SILENTLY (a debug log of the device id, never the token); replacing the caller's own row evicts nothing. A session that is gone or expired between admission and the write is `401`. **A device lives and dies with its session**: logout, session expiry and the expired-session sweep delete the row (`push_devices.session_id` cascades), and so does deleting the account. **A credential change** (password change, 2FA disable, provider unlink) rotates sessions in one transaction (`Store.RotateUserSessions`): the CALLER's devices are re-bound to the replacement session and survive, while devices on the user's OTHER sessions die with those sessions, since the change exists to cut off stolen sessions and their push tokens go with them. If that transaction fails every session is revoked and the call answers 500 (fail closed). Self-hosted servers send nothing unless they configure a gateway.
- `GET|PUT /api/v1/me/notification-settings` — the caller's recipient-side push filters (TASK-3938): `{push_enabled, notify_assigned, notify_comments, notify_replies, notify_reminders, notify_mentions, include_agent_activity}`, seven booleans. GET returns the defaults (everything on except `include_agent_activity`) when the user never saved any. PUT is a full replace: all seven keys are required, and a missing, unknown, null or non-boolean one is `400`, so nothing is silently reset. Available to any authenticated person, an API token included; an app principal is `403`, and so is a token pinned to one workspace, refused by the BUG-3982 gate (`403 permission_denied`, `details.reason: "token_workspace_pinned"`) because these are account-level settings. Works without a push gateway.
- `GET /api/v1/auth/session` — auth status (`setup_required`, `setup_method`, `auth_method`, `authenticated`, `email_configured`, `push_available`, `user`). `push_available` is a JSON bool (TASK-3938): true only when this server has a push gateway (`PAD_PUSH_GATEWAY_URL` non-empty), and the app enables push only on JSON `true`. It is present only for a SIGNED-IN caller: absent for an anonymous caller and on the pre-auth setup state, since whether a gateway exists is server configuration an anonymous caller does not need (an absent value reads as off)
- `POST /api/v1/auth/bootstrap` — create the first admin account from localhost on a fresh instance
- `POST /api/v1/auth/register` — create account (admin-created or invitation-based after setup). An `invitation_code` signup claims the invitation in the same transaction as the membership (BUG-3438). If the invitation was declined, cancelled or replaced since the code was read: when the code was the signup's ADMISSION (registration is otherwise closed) the account is rolled back and the answer is `404 not_found` (`500` if the rollback itself fails, since the account then still holds the email); where the signup would have been admitted anyway (Pad Cloud self-serve, or an admin creating the account) the account is KEPT, joins nothing, and the 201 carries `invitation_not_joined: {code, message}` (`invitation_gone`, `inviter_scope_changed` when the inviter is now collection-restricted (BUG-3634) or no longer an owner (BUG-3770); `403` with that code in the admission case, or `invitation_not_applied` for another refused claim) instead of `accepted_invitation`. Additive, omitempty
- `POST /api/v1/auth/login` — email/password login (returns session token)