Navigation
ELOWEN · Documentation

Last updated: October 27, 2026

Browse documentation · Account notifications
Developer reference

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.