feat(notify): Web Push plumbing end-to-end — VAPID key file, subscriptions, sender, service-worker handlers, settings enrollment #98

Closed
opened 2026-07-10 15:11:21 +02:00 by dominik.polakovics · 2 comments

What to build

The complete Web Push (VAPID, RFC 8292) pipe with no trigger logic: the tracer bullet that proves HTTPS, push-gateway egress, and iOS PWA enrollment before any notification source exists. Standards-based Web Push only — no Apple/Google developer registration; the server authenticates to the gateways (web.push.apple.com, fcm.googleapis.com, Mozilla autopush) with a lab-generated VAPID keypair.

  • VAPID key file with master-key semantics (mirror internal/vault/masterkey.go + cmd/lab/main.go wiring): --vapid-key-file / LAB_VAPID_KEY_FILE, default <state-dir>/vapid.key, generated on first start when absent (atomic exclusive write, 0600), refuses lax permissions on load, never overwrites an existing file.
  • push_subscriptions table (migration): endpoint (unique), p256dh/auth keys, created-at, a user-agent-derived device label.
  • Authenticated subscribe/unsubscribe API for the operator. Subscriptions survive logout (device-level trust); removal is explicit via the device list.
  • Async sender (e.g. webpush-go): fire-and-forget off the caller's goroutine, TTL ~24h, urgency high. A 404/410 gateway response deletes the subscription row; any other failure logs loudly and never blocks or crashes the caller. An unreachable gateway (airgapped host) degrades to a loud log.
  • Service worker: push handler renders title/body/tag from the JSON payload; notificationclick focuses an existing client or opens the payload's route. The existing rule stands: /api, /agent, probes never intercepted.
  • Settings UI — Notifications block: Enable notifications on this device (must run from a user gesture — iOS requirement), device list with per-device Remove and per-device Send test button (push failures are silent; the test button is how self-hosters debug HTTPS/egress).
  • Docs (ops.md): the VAPID key knob; rotation strands every subscription (each device must re-enable); requirements — HTTPS UI, outbound HTTPS to push gateways, iOS 16.4+ with the PWA added to the Home Screen.

Acceptance criteria

  • VAPID key lifecycle tests mirror the master key: generate-on-absent, refuse group/other-readable perms, refuse overwrite, flag/env/default precedence
  • Subscribe stores a row; Remove deletes it; a 404/410 gateway response on send deletes it (hermetic test against a fake push gateway)
  • Sender never blocks the caller: send path is async and a gateway timeout/refusal only logs
  • Service worker shows a notification on push and routes notificationclick to the payload route; /api and /agent remain un-intercepted (extend the existing PWA smoke)
  • Settings block: enable from a user gesture, device list renders subscriptions with labels, Send test round-trips through the real sender path
  • ops.md documents the key knob, rotation consequence, and the HTTPS/egress/iOS-16.4 requirements

Blocked by

None - can start immediately

## What to build The complete Web Push (VAPID, RFC 8292) pipe with **no trigger logic**: the tracer bullet that proves HTTPS, push-gateway egress, and iOS PWA enrollment before any notification source exists. Standards-based Web Push only — no Apple/Google developer registration; the server authenticates to the gateways (web.push.apple.com, fcm.googleapis.com, Mozilla autopush) with a lab-generated VAPID keypair. - **VAPID key file with master-key semantics** (mirror `internal/vault/masterkey.go` + `cmd/lab/main.go` wiring): `--vapid-key-file` / `LAB_VAPID_KEY_FILE`, default `<state-dir>/vapid.key`, generated on first start when absent (atomic exclusive write, 0600), refuses lax permissions on load, never overwrites an existing file. - **`push_subscriptions` table** (migration): endpoint (unique), p256dh/auth keys, created-at, a user-agent-derived device label. - **Authenticated subscribe/unsubscribe API** for the operator. Subscriptions survive logout (device-level trust); removal is explicit via the device list. - **Async sender** (e.g. webpush-go): fire-and-forget off the caller's goroutine, TTL ~24h, urgency high. A 404/410 gateway response deletes the subscription row; any other failure logs loudly and never blocks or crashes the caller. An unreachable gateway (airgapped host) degrades to a loud log. - **Service worker**: `push` handler renders title/body/tag from the JSON payload; `notificationclick` focuses an existing client or opens the payload's route. The existing rule stands: `/api`, `/agent`, probes never intercepted. - **Settings UI — Notifications block**: *Enable notifications on this device* (must run from a user gesture — iOS requirement), device list with per-device *Remove* and per-device *Send test* button (push failures are silent; the test button is how self-hosters debug HTTPS/egress). - **Docs (ops.md)**: the VAPID key knob; rotation strands every subscription (each device must re-enable); requirements — HTTPS UI, outbound HTTPS to push gateways, iOS 16.4+ with the PWA added to the Home Screen. ## Acceptance criteria - [ ] VAPID key lifecycle tests mirror the master key: generate-on-absent, refuse group/other-readable perms, refuse overwrite, flag/env/default precedence - [ ] Subscribe stores a row; Remove deletes it; a 404/410 gateway response on send deletes it (hermetic test against a fake push gateway) - [ ] Sender never blocks the caller: send path is async and a gateway timeout/refusal only logs - [ ] Service worker shows a notification on push and routes `notificationclick` to the payload route; `/api` and `/agent` remain un-intercepted (extend the existing PWA smoke) - [ ] Settings block: enable from a user gesture, device list renders subscriptions with labels, Send test round-trips through the real sender path - [ ] ops.md documents the key knob, rotation consequence, and the HTTPS/egress/iOS-16.4 requirements ## Blocked by None - can start immediately
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Build the complete standards-based Web Push (VAPID, RFC 8292) pipeline — key lifecycle, subscription storage, async sender, service-worker handlers, settings enrollment — with no trigger logic. This is the tracer bullet for the notification stack (#99, #100, #101 build on it); the settings page's Send test button is the only notification source in this slice.

Current behavior:
lab has no push capability at all:

  • The PWA service worker handles only install/activate/fetch. It has no push or notificationclick handlers — these are net-new. It does already enforce a network-only rule that never intercepts /api/*, /agent/*, /healthz, /readyz, or /metrics; that exact rule must survive unchanged.
  • No web-push library exists in the Go module — adding one (e.g. webpush-go) is a new dependency, which may also require updating the nix build's vendor hash.
  • There is a master-key file implementation in the vault package whose semantics are the mandated pattern to mirror: generate-on-absent as a 0600 file via an atomic exclusive write, refuse to load a group/other-readable file, never overwrite an existing file, wired with flag > env > default precedence.
  • Database migrations are goose files maintained in two parallel dialect trees (sqlite and postgres); a parity test asserts both trees carry identical version sets, so a new table needs the same-numbered migration in both.
  • Authenticated operator endpoints are registered through a requireAuth-style wrapper, with CSRF protection on mutations.
  • The global settings page (SolidJS) has no Notifications block. The ops doc documents operator knobs (including the master-key file knob whose wording is the template to follow) but nothing about push.
  • The existing Playwright smoke does not exercise the service worker — there is no SW smoke coverage to "extend"; this slice creates it.

Desired behavior:

  • VAPID key: a --vapid-key-file flag / LAB_VAPID_KEY_FILE env var, defaulting to <state-dir>/vapid.key, holding a lab-generated VAPID keypair with the master-key file semantics described above. The server authenticates to the public push gateways (web.push.apple.com, fcm.googleapis.com, Mozilla autopush) with this key — no Apple/Google developer registration anywhere.
  • Subscription storage: a push_subscriptions table keyed by unique endpoint, storing the p256dh and auth keys, a created-at timestamp, and a device label derived from the enrolling user agent.
  • Subscribe/unsubscribe API: authenticated endpoints for the operator's browser to register and remove a subscription. Subscriptions are device-level trust: they survive logout and are removed only explicitly via the device list.
  • Async sender: sends happen off the caller's goroutine (fire-and-forget), TTL ~24h, urgency high. A 404 or 410 from the gateway deletes that subscription row. Any other failure — including an unreachable gateway on an airgapped host — logs loudly and never blocks, delays, or crashes the caller.
  • Service worker: a push handler renders title/body/tag from the JSON payload; a notificationclick handler focuses an existing client on the payload's route or opens a new one. The network-only rule for /api, /agent, and probe paths stays intact.
  • Settings — Notifications block: an Enable notifications on this device action that runs the permission request from a user gesture (iOS requirement), a device list showing each subscription's label with per-device Remove, and a per-device Send test button that round-trips through the real sender path (push failures are otherwise silent; this is how self-hosters debug HTTPS/egress).
  • Ops doc: document the VAPID key knob (matching the master-key knob's wording), that key rotation strands every subscription (each device must re-enable), and the requirements: HTTPS-served UI, outbound HTTPS to the push gateways, iOS 16.4+ with the PWA added to the Home Screen.

Key interfaces:

  • VAPID key loader/generator mirroring the vault master-key contract (same error behaviors, same precedence wiring in the main entrypoint).
  • push_subscriptions migration added to both dialect trees with the same version number.
  • New authenticated routes following the existing requireAuth + CSRF pattern.
  • A sender type the trigger slices (#99/#100) can later inject as a seam: it should accept a payload (title, body, tag, route) and a target-all-subscriptions send, so trigger code never touches gateway details.
  • Service-worker payload contract: JSON with title, body, tag, and a PWA-internal route for click-through.

Acceptance criteria:

  • VAPID key lifecycle tests mirror the master-key tests: generate-on-absent, refuse group/other-readable permissions, refuse overwrite, flag/env/default precedence
  • Subscribe stores a row; Remove deletes it; a 404/410 gateway response on send deletes it (hermetic test against a fake push gateway)
  • Sender never blocks the caller: the send path is async, and a gateway timeout or refusal only produces a loud log
  • Migration parity test passes with the new table present in both dialects
  • Service worker shows a notification on push and notificationclick routes to the payload's route; /api, /agent, and probe paths remain un-intercepted (new SW smoke coverage — none exists today)
  • Settings block: enable runs from a user gesture, the device list renders subscriptions with labels, Send test round-trips through the real sender path
  • Ops doc documents the key knob, the rotation consequence, and the HTTPS / gateway-egress / iOS 16.4 + Home Screen requirements

Out of scope:

  • Any trigger logic — the needs-input seam (#99) and done-signal seam (#100) are separate slices; nothing in this slice calls the sender except the Send test button
  • Terse/content-free notification mode (#101)
  • Per-device notification filtering (v1 is all-devices-get-everything)
  • Apple/Google developer registration or vendor push SDKs (standards Web Push only)
  • Focus/"am-I-looking" suppression — deliberately rejected: a service worker that receives a push without showing a notification gets its subscription revoked by iOS Safari
  • Real-device iOS verification — the operator does this via Send test; hermetic tests use a fake gateway
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Build the complete standards-based Web Push (VAPID, RFC 8292) pipeline — key lifecycle, subscription storage, async sender, service-worker handlers, settings enrollment — with **no trigger logic**. This is the tracer bullet for the notification stack (#99, #100, #101 build on it); the settings page's *Send test* button is the only notification source in this slice. **Current behavior:** lab has no push capability at all: - The PWA service worker handles only `install`/`activate`/`fetch`. It has **no `push` or `notificationclick` handlers** — these are net-new. It does already enforce a network-only rule that never intercepts `/api/*`, `/agent/*`, `/healthz`, `/readyz`, or `/metrics`; that exact rule must survive unchanged. - No web-push library exists in the Go module — adding one (e.g. webpush-go) is a new dependency, which may also require updating the nix build's vendor hash. - There is a master-key file implementation in the vault package whose semantics are the mandated pattern to mirror: generate-on-absent as a 0600 file via an atomic exclusive write, refuse to load a group/other-readable file, never overwrite an existing file, wired with flag > env > default precedence. - Database migrations are goose files maintained in two parallel dialect trees (sqlite and postgres); a parity test asserts both trees carry identical version sets, so a new table needs the same-numbered migration in both. - Authenticated operator endpoints are registered through a `requireAuth`-style wrapper, with CSRF protection on mutations. - The global settings page (SolidJS) has no Notifications block. The ops doc documents operator knobs (including the master-key file knob whose wording is the template to follow) but nothing about push. - The existing Playwright smoke does **not** exercise the service worker — there is no SW smoke coverage to "extend"; this slice creates it. **Desired behavior:** - **VAPID key**: a `--vapid-key-file` flag / `LAB_VAPID_KEY_FILE` env var, defaulting to `<state-dir>/vapid.key`, holding a lab-generated VAPID keypair with the master-key file semantics described above. The server authenticates to the public push gateways (web.push.apple.com, fcm.googleapis.com, Mozilla autopush) with this key — no Apple/Google developer registration anywhere. - **Subscription storage**: a `push_subscriptions` table keyed by unique endpoint, storing the p256dh and auth keys, a created-at timestamp, and a device label derived from the enrolling user agent. - **Subscribe/unsubscribe API**: authenticated endpoints for the operator's browser to register and remove a subscription. Subscriptions are device-level trust: they survive logout and are removed only explicitly via the device list. - **Async sender**: sends happen off the caller's goroutine (fire-and-forget), TTL ~24h, urgency high. A 404 or 410 from the gateway deletes that subscription row. Any other failure — including an unreachable gateway on an airgapped host — logs loudly and never blocks, delays, or crashes the caller. - **Service worker**: a `push` handler renders title/body/tag from the JSON payload; a `notificationclick` handler focuses an existing client on the payload's route or opens a new one. The network-only rule for `/api`, `/agent`, and probe paths stays intact. - **Settings — Notifications block**: an *Enable notifications on this device* action that runs the permission request from a user gesture (iOS requirement), a device list showing each subscription's label with per-device *Remove*, and a per-device *Send test* button that round-trips through the real sender path (push failures are otherwise silent; this is how self-hosters debug HTTPS/egress). - **Ops doc**: document the VAPID key knob (matching the master-key knob's wording), that key rotation strands every subscription (each device must re-enable), and the requirements: HTTPS-served UI, outbound HTTPS to the push gateways, iOS 16.4+ with the PWA added to the Home Screen. **Key interfaces:** - VAPID key loader/generator mirroring the vault master-key contract (same error behaviors, same precedence wiring in the main entrypoint). - `push_subscriptions` migration added to **both** dialect trees with the same version number. - New authenticated routes following the existing `requireAuth` + CSRF pattern. - A sender type the trigger slices (#99/#100) can later inject as a seam: it should accept a payload (title, body, tag, route) and a target-all-subscriptions send, so trigger code never touches gateway details. - Service-worker payload contract: JSON with title, body, tag, and a PWA-internal route for click-through. **Acceptance criteria:** - [ ] VAPID key lifecycle tests mirror the master-key tests: generate-on-absent, refuse group/other-readable permissions, refuse overwrite, flag/env/default precedence - [ ] Subscribe stores a row; Remove deletes it; a 404/410 gateway response on send deletes it (hermetic test against a fake push gateway) - [ ] Sender never blocks the caller: the send path is async, and a gateway timeout or refusal only produces a loud log - [ ] Migration parity test passes with the new table present in both dialects - [ ] Service worker shows a notification on push and `notificationclick` routes to the payload's route; `/api`, `/agent`, and probe paths remain un-intercepted (new SW smoke coverage — none exists today) - [ ] Settings block: enable runs from a user gesture, the device list renders subscriptions with labels, *Send test* round-trips through the real sender path - [ ] Ops doc documents the key knob, the rotation consequence, and the HTTPS / gateway-egress / iOS 16.4 + Home Screen requirements **Out of scope:** - Any trigger logic — the needs-input seam (#99) and done-signal seam (#100) are separate slices; nothing in this slice calls the sender except the *Send test* button - Terse/content-free notification mode (#101) - Per-device notification filtering (v1 is all-devices-get-everything) - Apple/Google developer registration or vendor push SDKs (standards Web Push only) - Focus/"am-I-looking" suppression — deliberately rejected: a service worker that receives a push without showing a notification gets its subscription revoked by iOS Safari - Real-device iOS verification — the operator does this via *Send test*; hermetic tests use a fake gateway
Author
Owner

This was generated by AI while landing a PR.

Land-PR audit — PR #102 (afk/98) → VERDICT: PASS

Verification signal relied on: Forgejo Actions CI, both checks green (not re-run):

  • ci / native — success (8m39s)
  • ci-nix / flake-check — success (15m30s), so the nix Go suite, golangci-lint, web vitest/eslint/prettier, and nixos-module eval all passed.

Mergeability: clean merge into main, no conflicts.

Conventions: Conventional Commits title (feat(notify): …); Closes #98 present; AFK branch afk/98.

Diff review (correctness against intent):

  • internal/push/key.go — VAPID key file mirrors the vault master-key contract: refuses group/other-readable perms, never overwrites (exclusive link(2), race → exactly one winner), rejects hex-valid-but-out-of-range scalars via ecdh.P256().NewPrivateKey.
  • internal/fsx — the vault crash-safe write helpers extracted verbatim (byte-identical temp+fsync+rename/link+dir-fsync); vault + materialize call sites updated; behavior-preserving.
  • internal/push/sender.go — fire-and-forget on a tracked WaitGroup, hard panic-recover per send, background contexts (survive the request), 30s timeout, 404/410 endpoint reaping, endpoint logged host-only (path is a bearer capability).
  • internal/httpapi/push.gorequireAuth + tree-wide CSRF, nil-push-safe route mounting, endpoint http(s)/length validation, server-derived device label (not client-trusted), keys never echoed back.
  • internal/store/pushsubscriptions.go + migration 0007 — endpoint-UNIQUE upsert preserving id/created_at, idempotent by-endpoint delete for the reap race; sqlite/postgres parity.
  • web/public/sw.jspush always shows a notification even on malformed payload (avoids the iOS Safari silent-push subscription-revocation trap); empty-tag guard; notificationclick focus-or-open deep link.

No blockers; no changes requested. Awaiting the maintainer's free-text merge go-ahead.

> *This was generated by AI while landing a PR.* **Land-PR audit — PR #102 (`afk/98`) → VERDICT: PASS** **Verification signal relied on:** Forgejo Actions CI, both checks green (not re-run): - `ci / native` — success (8m39s) - `ci-nix / flake-check` — success (15m30s), so the nix Go suite, golangci-lint, web vitest/eslint/prettier, and nixos-module eval all passed. **Mergeability:** clean merge into `main`, no conflicts. **Conventions:** Conventional Commits title (`feat(notify): …`); `Closes #98` present; AFK branch `afk/98`. **Diff review (correctness against intent):** - `internal/push/key.go` — VAPID key file mirrors the vault master-key contract: refuses group/other-readable perms, never overwrites (exclusive `link(2)`, race → exactly one winner), rejects hex-valid-but-out-of-range scalars via `ecdh.P256().NewPrivateKey`. - `internal/fsx` — the vault crash-safe write helpers extracted verbatim (byte-identical temp+fsync+rename/link+dir-fsync); vault + materialize call sites updated; behavior-preserving. - `internal/push/sender.go` — fire-and-forget on a tracked `WaitGroup`, hard panic-recover per send, background contexts (survive the request), 30s timeout, 404/410 endpoint reaping, endpoint logged host-only (path is a bearer capability). - `internal/httpapi/push.go` — `requireAuth` + tree-wide CSRF, nil-`push`-safe route mounting, endpoint http(s)/length validation, server-derived device label (not client-trusted), keys never echoed back. - `internal/store/pushsubscriptions.go` + migration 0007 — endpoint-UNIQUE upsert preserving id/created_at, idempotent by-endpoint delete for the reap race; sqlite/postgres parity. - `web/public/sw.js` — `push` always shows a notification even on malformed payload (avoids the iOS Safari silent-push subscription-revocation trap); empty-tag guard; `notificationclick` focus-or-open deep link. No blockers; no changes requested. Awaiting the maintainer's free-text merge go-ahead.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#98
No description provided.