Skip to content

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's Key/LoadKey/GenerateKey mirror internal/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, and GenerateKey refuses 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, endpoint UNIQUE — 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's Broadcast/Send return immediately; the store read, JSON marshal, and the encrypted POST all run on background goroutines tracked by one sync.WaitGroup (Flush exists only for tests/shutdown — it is deliberately never wired into graceful shutdown, since blocking on an unreachable gateway up to sendTimeout would 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's push handler 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 call showNotification, 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's Payload is marshaled straight to JSON as the encrypted message body; the service worker renders title/body, passes tag to showNotification only when non-empty (an empty tag would coalesce unrelated notifications and drop all but the last), and stashes route — a PWA-internal path like /runs/abc — in notification.data for the notificationclick handler 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 of tag, not implemented in this slice: nothing calls Broadcast/Send yet 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 showNotification must 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.go is ~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 of internal/vault.
  • cmd/lab/main.go: loadOrGenerateVAPIDKey mirrors loadOrGenerateMasterKey; --vapid-key-file/LAB_VAPID_KEY_FILE join 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), and web/src/routes/Settings.tsx's Notifications card are the enrollment/debug surface.
  • web/public/sw.js gains the push/notificationclick handlers, additive to the existing offline-shell service worker.
  • Rotation/deletion consequence, operator-visible: replacing or deleting vapid.key invalidates every row in push_subscriptions at 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 to web.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/Send have exactly one caller today — the settings page's Send test, itself calling Send through handlePushSubscriptionTest. #99/#100 inject the sender as a seam (construct a Payload, call Broadcast/Send) and never see a gateway, a key, or a subscription row — this ADR's contract is the whole surface they get.