ADR-0020: Installable web app and browser push ​
Status: accepted (2026-10-15). Builds on the notification channels (ADR-0016), the outbox (ADR-0005), key rotation (ADR-0008) and the stored grants of MCP access tokens (ADR-0018).
Context ​
People want CodeLotse on their phone: see what needs them and be told when a review failed, a fix is ready or approvals are waiting. A native app needs app-store accounts, a native sign-in with device tokens and a push relay that holds Apple's and Google's credentials, because a self-hosted instance cannot send to an app whose keys it does not own. The web app already runs on phones and the notification pipeline already exists.
Decision ​
Installable web app instead of a native app ​
apps/web ships a web app manifest, icons and a service worker (/sw.js). Phones and desktops can install it from the browser (Android, desktop Chrome and Edge) or with Add to Home Screen (iOS and iPadOS 16.4 or later, where web push only works for installed apps). Sign-in stays the existing cookie session; nothing about authentication changes. The service worker has no fetch handler and caches nothing, so tenant data never lands in a browser cache. A native app stays an option; it would need its own ADR for device sessions and a push gateway.
Web Push straight from the instance ​
The worker sends standard Web Push (RFC 8030) with message encryption (RFC 8291, aes128gcm) and VAPID (RFC 8292), implemented with node:crypto in @quavon/publisher (src/notify/webpush.ts). The web-push package is MPL-2.0 and not allowed by the license policy. Every instance holds its own VAPID key (QUAVON_WEBPUSH_PRIVATE_KEY, QUAVON_WEBPUSH_SUBJECT); without it push is off. The installer generates a key; Helm takes it from a secret.
The push service of the person's browser (Apple, Google, Mozilla or Microsoft) only sees the subscription address, the size and timing of messages and the encrypted payload. It cannot read the content.
Personal subscriptions with stored grants ​
A push subscription belongs to one person, one tenant and one browser (push_subscriptions, unique per tenant and endpoint hash). People manage their own subscriptions on Notifications on this device (/api/me/tenants/:tenantId/push-subscriptions, permission tenant:view, cookie sessions only); there is no admin view of other people's devices. The endpoint and its keys are personal data and a capability, so they are stored encrypted with the context push_subscriptions:<id> and covered by keys rotate. Views show the host and a hash only. A browser that is re-subscribed by another person on a shared device moves to that person. At most ten subscriptions per person and tenant.
Who receives an event is decided from the memberships CodeLotse already stores for GitHub and OIDC users (tenant_memberships, repo_memberships), the same source that MCP access tokens use:
- The membership must have been refreshed within seven days (
PUSH_GRANT_MAX_AGE_MS). Using the web app or signing in refreshes it. Someone removed from the organisation or the identity provider stops receiving pushes at most seven days later, and at once when the stored membership is removed. Seven days instead of the 24 hours of MCP tokens because a notification that only arrives while someone also uses the web app is of little use, and a push carries less than an MCP answer. budget_alertandapprovals_waitinggo to tenant admins only.review_failed,fix_readyandci_analysis_postedgo to tenant admins and to people with a role on the repository.- The rule is checked when the event is recorded and again right before sending.
Assignment events are not pushed. This keeps push outside the works-council scope of ADR-0014: no message is about a person, and nobody is told about a colleague's work.
Delivery ​
submitNotification inserts one push_deliveries row per eligible subscription in the transaction of the event (unique per subscription and dedupe key) and enqueues the push job through the outbox with the key push:<delivery id>. The worker sends with a 10-second timeout, no redirects, at most 64 KiB of the answer, TTL of one day and a Topic so a newer message replaces an older one. HTTP 404 and 410 mean the browser dropped the subscription, which is then deleted. 429, 5xx and network errors are retried like notification channels; other 4xx fail at once. The approvals digest runs per subscription with its own last_digest_at, at most hourly. Deliveries follow the tenant data retention.
Content ​
The payload is a PushMessage: title, body, an in-app path and a tag. The text is rendered by the same renderer as the other channels, so it carries metadata only (event, repository, pull request number and title, counts, budget amounts) and never code, finding text, model output or logs. The path is always a path inside the instance (/t/<tenant>/…); the service worker refuses anything else and opens or focuses the app at that path. The secret scanner runs before sending.
Egress ​
Subscription endpoints must be https, without a port, credentials or fragment, on fcm.googleapis.com, updates.push.services.mozilla.com, web.push.apple.com or a host ending in .notify.windows.com. This is checked by zod when subscribing and again before every send; requests go through the guarded fetch in both editions. Air-gapped instances cannot reach push services, so push stays off there.
Consequences ​
- Phones get CodeLotse without an app store, for self-hosted and hosted instances alike, and every instance controls its own push key.
- iOS users must install the app to their home screen before they can enable push; the screen explains this.
- The push services of browser vendors become recipients of encrypted payloads and subscription metadata. They are chosen by the person's browser, not by Quavon, and are listed under "other recipients" in the sub-processor list.
- A person who never opens CodeLotse for seven days stops getting pushes until they do.
- A native app later can reuse the subscriptions, eligibility rule, renderer and events; it adds device sessions and a gateway.