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

Merged
dominik.polakovics merged 1 commit from afk/98 into main 2026-07-10 16:41:19 +02:00

The complete standards Web Push (RFC 8292 VAPID) pipe with no trigger logic — the tracer bullet proving HTTPS, push-gateway egress, and iOS PWA enrollment before any notification source exists. No Apple/Google registration anywhere; lab authenticates to the gateways with its own generated keypair.

What's in the slice

  • VAPID key file, master-key semantics (internal/push/key.go, mirroring internal/vault): --vapid-key-file / LAB_VAPID_KEY_FILE, default <state-dir>/vapid.key; 64-hex P-256 scalar, generated on first start via exclusive link(2) publish (0600), refuses loose perms or malformed content, never overwrites. The vault's crash-safe write helpers moved to a new shared internal/fsx package so both key files ride one audited implementation.
  • push_subscriptions (migration 0007 in both dialect trees; parity test green): device-level trust — endpoint UNIQUE, upsert preserves id/created_at, no user FK, survives logout.
  • Async sender (internal/push/sender.go, webpush-go v1.4.0): fire-and-forget off the caller's goroutine, TTL 24 h, urgency high; gateway 404/410 deletes the row; every other failure — including an airgapped host — logs loudly (component=push, endpoint redacted to host) and never blocks or crashes the caller. This is the seam #99/#100 inject.
  • httpapi (/api/v1/push/*, requireAuth + tree-wide CSRF): public key, subscription list/create/delete, per-device send-test through the real sender; device label derived server-side from User-Agent.
  • Service worker: push renders title/body/tag and always shows a notification (iOS Safari revokes subscriptions of SWs that handle a push silently); notificationclick focuses-or-opens the payload route. The /api·/agent·probes network-only rule is byte-identical.
  • Settings → Notifications: gesture-bound Enable notifications on this device (iOS requirement), device list with per-device Remove (also unsubscribes the local browser) and Send test — the self-hoster's debugging surface for HTTPS/egress.
  • Docs: ops.md knob + requirements + rotation-strands-subscriptions section, NixOS module vapidKeyFile option, CONTEXT.md glossary, ADR-0039. nix vendorHash bumped for the new dependency.

Verification

  • Key lifecycle tests mirror the master-key suite one-for-one (incl. concurrent exactly-one-winner and out-of-range-scalar-as-malformed).
  • Hermetic fake-gateway tests at both layers: sender unit tests, and an httpapi test proving the handler→sender→store 410-reap loop through the real sender. Non-blocking pinned by test (send returns in <500 ms against a 2 s-slow gateway).
  • SW handlers covered hermetically in vitest (synthetic push/notificationclick events + network-only rule); new Playwright SW smoke covers real-browser registration, cache exclusions, the settings ready-state, and notification display (browser launch unavailable in this sandbox — spec validated via --list/tsc/eslint; runs in CI/dev).
  • End-to-end against the real -tags ui binary: first boot generates vapid.key 0600 + logs the public key, restart loads the same key, authenticated subscribe→list→send-test→delete round trip works, test-send returned 202 in 11 ms with the loud host-only error log.
  • nix flake check green (Go suite, golangci-lint, web vitest/eslint/prettier, nixos-module eval). go test ./... green except internal/tmuxx, which fails identically on pristine HEAD in this sandbox (tmux cannot daemonize here) and passes inside the nix check.

Closes #98

🤖 Generated with Claude Code

https://claude.ai/code/session_01QH2ooTz1YZRnYhXHBYP1oF

The complete standards Web Push (RFC 8292 VAPID) pipe with **no trigger logic** — the tracer bullet proving HTTPS, push-gateway egress, and iOS PWA enrollment before any notification source exists. No Apple/Google registration anywhere; lab authenticates to the gateways with its own generated keypair. ## What's in the slice - **VAPID key file, master-key semantics** (`internal/push/key.go`, mirroring `internal/vault`): `--vapid-key-file` / `LAB_VAPID_KEY_FILE`, default `<state-dir>/vapid.key`; 64-hex P-256 scalar, generated on first start via exclusive `link(2)` publish (0600), refuses loose perms or malformed content, never overwrites. The vault's crash-safe write helpers moved to a new shared `internal/fsx` package so both key files ride one audited implementation. - **`push_subscriptions`** (migration 0007 in both dialect trees; parity test green): device-level trust — endpoint UNIQUE, upsert preserves id/created_at, no user FK, survives logout. - **Async sender** (`internal/push/sender.go`, webpush-go v1.4.0): fire-and-forget off the caller's goroutine, TTL 24 h, urgency high; gateway 404/410 deletes the row; every other failure — including an airgapped host — logs loudly (`component=push`, endpoint redacted to host) and never blocks or crashes the caller. This is the seam #99/#100 inject. - **httpapi** (`/api/v1/push/*`, requireAuth + tree-wide CSRF): public key, subscription list/create/delete, per-device send-test through the real sender; device label derived server-side from User-Agent. - **Service worker**: `push` renders title/body/tag and always shows a notification (iOS Safari revokes subscriptions of SWs that handle a push silently); `notificationclick` focuses-or-opens the payload route. The `/api`·`/agent`·probes network-only rule is byte-identical. - **Settings → Notifications**: gesture-bound *Enable notifications on this device* (iOS requirement), device list with per-device *Remove* (also unsubscribes the local browser) and *Send test* — the self-hoster's debugging surface for HTTPS/egress. - **Docs**: ops.md knob + requirements + rotation-strands-subscriptions section, NixOS module `vapidKeyFile` option, CONTEXT.md glossary, ADR-0039. nix `vendorHash` bumped for the new dependency. ## Verification - Key lifecycle tests mirror the master-key suite one-for-one (incl. concurrent exactly-one-winner and out-of-range-scalar-as-malformed). - Hermetic fake-gateway tests at both layers: sender unit tests, and an httpapi test proving the handler→sender→store 410-reap loop through the real sender. Non-blocking pinned by test (send returns in <500 ms against a 2 s-slow gateway). - SW handlers covered hermetically in vitest (synthetic push/notificationclick events + network-only rule); new Playwright SW smoke covers real-browser registration, cache exclusions, the settings ready-state, and notification display (browser launch unavailable in this sandbox — spec validated via `--list`/tsc/eslint; runs in CI/dev). - End-to-end against the real `-tags ui` binary: first boot generates `vapid.key` 0600 + logs the public key, restart loads the same key, authenticated subscribe→list→send-test→delete round trip works, test-send returned 202 in 11 ms with the loud host-only error log. - `nix flake check` green (Go suite, golangci-lint, web vitest/eslint/prettier, nixos-module eval). `go test ./...` green except `internal/tmuxx`, which fails identically on pristine HEAD in this sandbox (tmux cannot daemonize here) and passes inside the nix check. Closes #98 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QH2ooTz1YZRnYhXHBYP1oF
feat(notify): Web Push plumbing end-to-end — VAPID key file, subscriptions, async sender, SW handlers, settings enrollment (#98)
All checks were successful
ci / native (pull_request) Successful in 8m39s
ci-nix / flake-check (pull_request) Successful in 15m30s
0e8099bee3
The complete standards Web Push (RFC 8292 VAPID) pipe with no trigger
logic — the tracer bullet proving HTTPS, push-gateway egress, and iOS
PWA enrollment before any notification source exists:

- internal/push: VAPID key file with the master key's exact contract
  (generate-on-absent 0600 via exclusive link(2) publish, refuse loose
  perms, never overwrite; --vapid-key-file / LAB_VAPID_KEY_FILE /
  <state-dir>/vapid.key), and a fire-and-forget sender (TTL 24h,
  urgency high; 404/410 reaps the row; everything else logs loudly and
  never blocks the caller). webpush-go v1.4.0 is the one new dep.
- internal/fsx: the vault's crash-safe write helpers extracted so both
  key files share one audited publish implementation.
- migrations 0007 (both dialects) + store CRUD: push_subscriptions is
  device-level trust — no user FK, survives logout, upsert by endpoint.
- httpapi: GET /api/v1/push/key, subscription list/create/delete, and
  per-device send-test riding the real sender; label derived from
  User-Agent server-side.
- web: sw.js push/notificationclick handlers (always shows a
  notification — iOS revokes silent-handling subscriptions; the
  network-only rule is untouched), settings Notifications card with
  gesture-bound enable, device list, Remove, Send test; hermetic SW
  vitest suite plus Playwright SW smoke (registration, cache
  exclusions, notification display).
- docs: ops.md knob/requirements/rotation section, NixOS module
  vapidKeyFile option, CONTEXT.md glossary terms, ADR-0039; nix
  vendorHash bumped for the new dep.

Closes #98

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QH2ooTz1YZRnYhXHBYP1oF
Sign in to join this conversation.
No reviewers
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!102
No description provided.