Skip to content

feat(operator): opt-in Web Push notifications (flag-gated)#188

Merged
marcusrbrown merged 17 commits into
mainfrom
feat/operator-push-notifications
Jul 10, 2026
Merged

feat(operator): opt-in Web Push notifications (flag-gated)#188
marcusrbrown merged 17 commits into
mainfrom
feat/operator-push-notifications

Conversation

@marcusrbrown

Copy link
Copy Markdown
Collaborator

Adds opt-in operator Web Push notifications to the dashboard PWA, behind the DASHBOARD_OPERATOR_PUSH_ENABLED flag (default off). Closes #108.

Operators can subscribe a device to receive notifications for pending approvals and failed run outcomes. The dashboard stays a thin browser client — it does not persist or proxy push subscriptions; the Gateway owns subscription lifecycle, VAPID config/dispatch, opt-in state, dead-sub cleanup, and retention/export/delete.

What's in scope

  • Consent surface on the dashboard home: an opt-in card with states for unsupported, iOS install-first, not-requested, denied, subscribed, stale-key/resubscribe, and setup-failure. Copy never exposes keys or payloads.
  • Operator push client (web/src/push/): fetches the VAPID key, subscribes/unsubscribes via the Gateway push routes, and sends transient presence — all same-origin /operator/push/*, reverse-proxied to the Gateway (never mounted by the dashboard).
  • Service worker: push always renders a safe notification with a defensive payload parse; notificationclick opens/focuses / (never deep-links to action-bearing state); no cached operator data is read or written.
  • Logout teardown: best-effort browser unsubscribe() + Gateway unsubscribe + runtime-cache purge; in-flight subscribes are aborted.
  • Permission-revocation reconcile: a denied permission reconciles Gateway state via unsubscribe.
  • Local fixture harness: the push routes are exercised end-to-end under fixture mode so the whole flow is browser-testable without a live Gateway.

Security posture

  • Read-only-by-construction and redaction invariants are untouched; no write code path to GitHub.
  • Push subscriptions are never stored or forwarded by the dashboard. VAPID is treated as a distinct credential domain — no VAPID public key is hardcoded; it comes from the authenticated Gateway route.
  • Service-worker routing stays deny-by-default; /auth/* is never cached, and cross-origin/redirected responses can't poison the monitoring cache.
  • Notification copy is asserted leak-free (no keys, tokens, repo names, or payloads).

Verification

  • Server suite green (1834 tests); check-types, lint, and build:web clean; the web → src import-boundary guard passes; a real Docker build succeeds.
  • End-to-end browser check against the fixture harness: enable → Alerts Active with a genuine fcm.googleapis.com PushManager subscription → disable → back to enable, zero console errors.

Known limitations (follow-ups filed)

@fro-bot fro-bot added the agent: working Agent is currently working on this label Jul 10, 2026

@fro-bot fro-bot left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the diff directly (server auth/routing, operator-client, fixture harness, service worker, subscribe/reconcile orchestration, consent UI) and ran the suite locally (pnpm build:web && pnpm exec vitest run → 1834/1834 passing, pnpm check-types clean, pnpm lint clean).

Verdict: PASS

Blocking issues

None.

Non-blocking concerns

  • web/src/shell/AppShell.tsx: push teardown on logout (teardownPushOnLogout) runs via Promise.allSettled alongside the logout POST and is bounded by buildPushClient's internal 10s AbortSignal.timeout, so it can't hang navigation — good, just flagging for awareness since it's a new failure mode surface (swallowed errors are intentional per the comment).
  • src/gateway/operator-client.ts subscribePush/unsubscribePush CSRF-400 retry reuses the same stale CSRF token (mirrors the pre-existing decideRunApproval pattern) rather than refreshing first, unlike the browser-side web/src/push/subscribe.ts client which does refresh CSRF before retrying. This is consistent with existing server-side conventions in this file, not a regression, but worth confirming the Gateway's CSRF TTL makes the server-side blind retry actually useful.

Missing tests

None — coverage is unusually thorough: CSRF/idempotency retry paths, endpoint-hash correlation, drift-matrix reconcile (reconcile.test.ts), safe-copy notification mapping (no payload leak assertions in sw-notification.test.ts and notifications-copy.test.ts), the no-dashboard-proxy 404 invariant (operator-ui.test.ts), and production-bundle fixture-literal-leak checks (static-assets.test.ts) are all present and passing.

Risk assessment: LOW

Rationale:

  • The dashboard never mounts /operator/push/* — confirmed by reading server.ts (the push flag only injects a <meta> tag) and by the passing push routes — no-dashboard-proxy 404 invariant test.
  • Service worker push handling is defensively parsed and renders only fixed, allowlisted copy (sw-notification.ts); notificationclick never reads payload data as a navigation target (open-redirect guard).
  • Fixture harness push routes are dev-only, gated by NODE_ENV + loopback-bind double guard (unchanged pattern from existing fixture infra), never echo raw endpoint/keys, and are excluded from production bundles by an explicit literal-scan test.
  • operator-client.ts path/ID validation (traversal, protocol-relative, scheme-prefixed) and CSRF/idempotency guards are unchanged in shape from the pre-existing decideRunApproval/launchRun pattern, just extended correctly to the push endpoints.
  • No new dependencies were added; no auth-flow or route-ordering changes outside the flag-gated additions.
  • No breaking changes: all new routes/fields are additive and flag-gated (default off).

Run Summary
Field Value
Event pull_request
Repository fro-bot/dashboard
Run ID 29115361300
Cache hit
Session ses_0b2aad9aaffeacigXY3FLY2VNp

@fro-bot fro-bot removed the agent: working Agent is currently working on this label Jul 10, 2026
@marcusrbrown marcusrbrown merged commit 2df67de into main Jul 10, 2026
7 checks passed
@marcusrbrown marcusrbrown deleted the feat/operator-push-notifications branch July 10, 2026 20:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Operator dashboard PWA: opt-in push notifications / background sync (deferred from #107)

2 participants