Web Push notifications ride a lab-generated VAPID keypair with master-key file semantics; device-level subscriptions; the service worker never suppresses a notification¶
Issue #98: an away operator needs to know an AFK run needs them or has finished, on desktop and on an iOS PWA, without lab depending on any vendor push service account. Self-hosters cannot do Apple/Google developer registrations as a deployment prerequisite — that would make notifications a gated feature instead of one nix build away. Standards Web Push (RFC 8292 VAPID) has no such gate: an application server proves its identity to a browser's push gateway with a self-signed JWT over a P-256 keypair it generates itself, and the browser subscribes directly against the operator's own public key. No API keys, no third-party console, no per-vendor onboarding.
The decisions, pinned:
- VAPID keypair carries the master key's file contract verbatim.
internal/push/key.go'sKey/LoadKey/GenerateKeymirrorinternal/vault's master-key handling exactly: 64 lowercase hex chars (a P-256 private scalar) in a 0600 file, auto-generated on first start if absent, refuses to start on loose permissions (&0o077 != 0) or malformed content, andGenerateKeyrefuses to overwrite an existing file (link(2)EEXIST — a lost race can never clobber a key and invalidate every subscription signed against it).--vapid-key-file/LAB_VAPID_KEY_FILE, default<state-dir>/vapid.key, follows--master-key-file's flag/env/default shape precisely (ADR-0006 established that pattern for the master key) — a second key file, same trust model, deliberately not folded into the vault (it is not a stored credential; it signs outbound JWTs). - Subscriptions are device-level trust, not user-level.
push_subscriptions(migration 0007) is one row per browser subscription — endpoint,p256dh,auth, a label,endpointUNIQUE — with no user/session foreign key. A subscription survives logout and is removed only explicitly (Settings → Notifications → Remove) or by the sender reaping it when the gateway itself declares the endpoint dead. This is the same trust shape as a device pairing, not a stored secret: subscription rows are plain columns, never vault-encrypted. - Async fire-and-forget sender, one failure mode reaps and every other just logs.
internal/push/sender.go'sBroadcast/Sendreturn immediately; the store read, JSON marshal, and the encrypted POST all run on background goroutines tracked by onesync.WaitGroup(Flushexists only for tests/shutdown — it is deliberately never wired into graceful shutdown, since blocking on an unreachable gateway up tosendTimeoutwould make shutdown worse, not safer). A 404/410 from the gateway is the one delivery outcome with a durable consequence: the subscription row is deleted on the sender's own background context. Every other outcome — non-2xx, transport error, timeout, an airgapped host that cannot reach the gateway at all — is logged loudly (component=push) and dropped; nothing retries, nothing queues. A trigger caller (#99/#100) gets no error return to handle because there is nothing actionable it could do with one. - The service worker always shows a notification, on purpose.
web/public/sw.js'spushhandler renders a minimal fallback (title: 'lab', empty body) even for absent or malformed payload data, rather than silently no-op-ing. This isn't generosity — iOS Safari revokes the push subscription of a service worker that receives a push and does not callshowNotification, so a silent drop would not just skip one notification, it would kill the device's subscription outright. The same fact is why focus/"is the operator already looking" suppression was rejected: the push handler runs with no reliable way to know if a client window is focused before deciding whether to show anything, and building that check would recreate exactly the silent-push trap iOS punishes. - Payload contract is
{title, body, tag, route}.internal/push/sender.go'sPayloadis marshaled straight to JSON as the encrypted message body; the service worker renderstitle/body, passestagtoshowNotificationonly when non-empty (an empty tag would coalesce unrelated notifications and drop all but the last), and stashesroute— a PWA-internal path like/runs/abc— innotification.datafor thenotificationclickhandler to navigate to. Tag-per-run coalescing (so a run's repeated pushes collapse to one lock-screen entry instead of stacking) is a planned use oftag, not implemented in this slice: nothing callsBroadcast/Sendyet except the settings page's Send test button.
Status¶
Accepted. Establishes the Web Push plumbing (internal/push, migration 0007, /api/v1/push/*, the SW handlers, the Settings "Notifications" card) as the base layer for #99/#100. No conflict with existing ADRs; extends ADR-0006's master-key file contract to a second, independent key file.
Considered options¶
- Vendor push (APNs direct, FCM console app). Rejected: both demand a developer account and per-deployment app registration as a prerequisite — exactly the onboarding tax standards Web Push exists to avoid. APNs direct also excludes every non-Apple platform; FCM's console app excludes Safari/iOS entirely (Apple only accepts standards Web Push for PWAs). VAPID reaches all three gateways (Apple, Google, Mozilla) through one identity.
- Suppress the notification when the operator's tab already has focus. Rejected: the push event has no reliable, race-free way to know client focus state before
showNotificationmust be called, and on iOS Safari not calling it revokes the subscription — the exact failure this ADR's "always show" rule prevents. Any future do-not-disturb behavior belongs at the trigger layer (deciding whether to send at all), never inside the SW's push handler. - Fold the VAPID key into the existing vault, encrypted under the master key. Rejected: the vault exists to protect secrets lab must recover on restore (git/forge credentials); the VAPID key is closer to a signing identity than a stored secret, self-hosters may reasonably want to rotate it independently of any credential rotation, and reusing the master key's exact file contract (rather than the vault) needed zero new machinery —
internal/push/key.gois ~130 lines mirroring code that already existed and was already audited. - User-scoped subscriptions (tied to the login session). Rejected: lab's login model has no durable per-browser identity to key on beyond the subscription itself, and device-level rows are simpler and match the actual failure mode — a subscription needs to survive a logout/login cycle on the same phone, and needs to die independently per device without touching others.
- Synchronous send with a caller-visible error/retry. Rejected: it would let one slow or unreachable gateway (airgapped host, rate-limited endpoint) block or fail the caller that scheduled the notification, for a channel whose entire purpose is best-effort. Fire-and-forget with loud logging gives the operator everything actionable (server logs) without giving any caller a failure mode to handle.
Consequences¶
internal/push:key.go(VAPID key file),sender.go(Sender,Payload,Broadcast/Send/Flush) are new, independent ofinternal/vault.cmd/lab/main.go:loadOrGenerateVAPIDKeymirrorsloadOrGenerateMasterKey;--vapid-key-file/LAB_VAPID_KEY_FILEjoin the flag/env surface (internal/config); the VAPID public key logs at startup (non-secret, handy for operators without hitting the API).migrations/{sqlite,postgres}/0007_push_subscriptions.sql,internal/httpapi/push.go(GET /api/v1/push/key,GET|POST /api/v1/push/subscriptions,DELETE /api/v1/push/subscriptions/{id},POST /api/v1/push/subscriptions/{id}/test), andweb/src/routes/Settings.tsx's Notifications card are the enrollment/debug surface.web/public/sw.jsgains thepush/notificationclickhandlers, additive to the existing offline-shell service worker.- Rotation/deletion consequence, operator-visible: replacing or deleting
vapid.keyinvalidates every row inpush_subscriptionsat once — the public key browsers subscribed against no longer matches. Sends do not error loudly per-device from the operator's point of view; they just stop. Each device must re-enable from Settings to resubscribe against the new key.docs/ops.md's Push notifications section and the state-dir backup guidance carry this forward. - Requirements outside lab's control, all documented in
docs/ops.md: the UI must be served over HTTPS (PushManager.subscribe()needs a secure context), the server needs outbound HTTPS toweb.push.apple.com/fcm.googleapis.com/ Mozilla's autopush, and iOS needs 16.4+ with lab added to the Home Screen (Web Push does not exist for a bare Safari tab) — enabling always rides the button's own user gesture, so none of this can be pre-provisioned. - No trigger logic ships in this slice.
Sender.Broadcast/Sendhave exactly one caller today — the settings page's Send test, itself callingSendthroughhandlePushSubscriptionTest. #99/#100 inject the sender as a seam (construct aPayload, callBroadcast/Send) and never see a gateway, a key, or a subscription row — this ADR's contract is the whole surface they get.