Account notifications
Catalog and visibility
src/alerts/alertCatalog.ts composes the single live catalog: each source carries
localized strings and a map of kinds to audience and group. Core declares conversations,
account and administrator-only system kinds beside CORE_ALERT_STRINGS. Plugins
declare alertKinds beside manifest alerts, with kind.<id> labels; their localized
manifest label and glyph identify the plugin group. Headless plugins use the same loader.
GET /auth/me/alert-kinds returns localized groups in conversations, account, system
order, followed by plugins sorted by localized label. Empty groups are absent.
Administrator kinds require the live administrator role; plugin kinds require the
existing isPluginAllowedForUser grant. PATCH validation uses the same visible-kind
computation. Discovery grants no authority to raise alerts.
Account preferences and delivery
The account's user_settings key and revision resource are both alerts.
AlertKindPrefs = { muted: Record<string, string[]> } stores only muted identities;
{} and { muted: {} } mean all on, including future kinds. The strict codec throws
on invalid data, empty source/kind ids, duplicate kinds and stored empty arrays.
Canonical writes omit empty sources.
GET settings returns { muted, revision }. PATCH requires { muted, expectedRevision }
and replaces only visible mutes, retaining absent plugins, ungranted plugins and hidden
administrator kinds. Unknown/invisible submitted kinds return unknown_alert_kind;
revision CAS rejects concurrent writes with 409 and current. Reads bind values and revisions
to one repeatable-read snapshot; a successful PATCH returns its own CAS revision, not a later writer's. No legacy reader remains.
The coordinated migration maps old push-only preferences to all-on mutes and bumps
changed revisions; old off preferences additionally remove device subscriptions so
phone silence survives without hiding the bell. Run the migration before using the new codec.
Every producer supplies a required kind. AlertService.raise rejects undeclared kinds
and scope/audience mismatches before writing or starting a hold. Each resolved recipient's
live mutes are read before the row write: a muted kind creates no row, SSE invalidation
or push, and logs muted by preference: user=<id> kind=<source>:<kind>.
The result's recipients counts unmuted row recipients. Existing create/escalate/reopen
transitions decide push, without a second preference check. Severity still controls bell
presentation, escalation, retention and problem severity, never push eligibility.
Bundled Sandbox's project disk kind is a real consumer. Existing hold clocks remain
process-local and bounded, and recipient resolution still uses current account/project
stores. A missing push transport leaves bell delivery intact.
Turn completion uses sendTurnDone in src/push/turnDoneSender.ts. Ordinary reply
previews, restart interruption and undelivered delegated-result notices share
core:conversation.reply; the sender gates them before transport and localizes typed
notices through buildTurnDone. Existing watching/membership rules choose recipients.
Voice rings stay outside the kind picker and use their callback preference.
Push transport and ownership
All push payloads carry notificationKind for sender diagnostics: alert
<source>:<kind>, core:conversation.reply, voice.ring or voice.ring_end.
This is separate from the service worker's payload discriminator kind.
Delivery returns the service's statusCode. The sender logs:
push sent: status=<n> host=<hostname> kind=<kind> user=<id>push endpoint pruned: status=404|410 host=<hostname> user=<id>push send failed: status=<n|?> host=<hostname> user=<id> reason=<reason>push skipped: no device user=<id> kind=<kind>push skipped: VAPID keys missing kind=<kind>(warning)
Failures retain push.delivery_failed and typed audience classification. Reasons remove
control characters, redact the subscription endpoint and keys and clip to 200 code points;
raw SDK errors never enter printable log extras. Endpoint removal stays on 404/410.
Each device is attempted concurrently; a rejected delivery does not cancel others.
POST /push/subscribe upserts endpoint ownership to the authenticated account and
refuses impersonation with 403 impersonation_forbidden before the upsert. The VAPID
public key is public, private signing keys stay server-side. The service worker receives
localized ring.missedTitle and ring_end.title so expired/ended calls can replace the
ring quietly under its existing tag instead of violating WebKit's visible-push contract.
No subscriptions means no delivery; no keys means an explicit unavailable warning.
Browser device state and sync
Preferences are per account; a device has only its own switch, which is its push
subscription. web/lib/pushClient.ts reads one typed, feature-detected state per device:
unsupported-ios-tab (an iPhone or iPad Safari tab without PushManager; push needs the
Home Screen app), ios-update-required, unsupported, denied, off, on and
needs-reenable. Account → Notifications shows it in the This device card and the kind
picker in the Notifications card (Settings).
The authenticated shell runs syncPushSubscription() once per auth generation: it registers
/sw.js, and when permission is granted and a subscription exists it upserts that subscription,
replacing it first when its application-server key differs from the current VAPID key. A
replacement the browser refuses without a gesture leaves the device needs-reenable until the
user turns it on again. iOS never fires pushsubscriptionchange, so this start-up sync is what
repairs an iPhone; other browsers also renew in the service worker's pushsubscriptionchange
handler. An impersonated session skips the sync and shows the switch disabled, and the daemon
refuses its /push/subscribe independently.
The upsert moves an endpoint to the signed-in account, so a browser shared by two accounts
notifies whoever signed in last. Because the sync restores a device the server no longer lists
while the browser still holds its subscription, the migration's removal of legacy off
devices holds only for browsers that no longer hold one; the release preflight lists such
accounts with their device counts.