---
phase: 05-web-push-notifications
plan: 04
type: execute
wave: 3
depends_on: [05-01, 05-02]
files_modified:
  - apps/api/src/routes/push.ts
  - apps/api/src/index.ts
  - apps/api/tests/routes/push.test.ts
  - apps/pwa/vite.config.ts
  - apps/pwa/src/sw.ts
  - apps/pwa/src/hooks/usePushSubscription.ts
  - apps/pwa/src/components/PushPermissionPrompt.tsx
  - apps/pwa/src/components/InstallPrompt.tsx
  - apps/pwa/src/App.tsx
autonomous: false
requirements: [NOTIF-01, NOTIF-02, NOTIF-03]

must_haves:
  truths:
    - "A member can tap 'Enable Notifications' in the post-install prompt; the browser subscribes via pushManager.subscribe and POST /api/push/subscription persists a row scoped to their userId (D-08)"
    - 'The custom service worker shows a visible notification for EVERY push (including malformed payloads) via event.waitUntil(showNotification) — no silent pushes (D-11)'
    - 'notificationclick opens the deep-link URL from the payload (focus existing window or openWindow) (D-14)'
    - 'The /callback, /api/, /health navigation denylist is preserved after the generateSW to injectManifest migration (T-03-20)'
    - 'GET /api/push/vapid-public-key serves the public key; subscription POST/DELETE are scoped to the authenticated user (V4 access control)'
  artifacts:
    - path: 'apps/api/src/routes/push.ts'
      provides: 'pushRouter — GET /vapid-public-key, POST /subscription, DELETE /subscription'
      exports: ['pushRouter']
    - path: 'apps/pwa/src/sw.ts'
      provides: 'custom injectManifest SW: precache + push + notificationclick + nav denylist'
      contains: 'showNotification'
    - path: 'apps/pwa/src/hooks/usePushSubscription.ts'
      provides: 'subscribe/unsubscribe lifecycle (subscribe in tap handler only)'
      exports: ['usePushSubscription']
    - path: 'apps/pwa/src/components/PushPermissionPrompt.tsx'
      provides: 'post-install permission bottom sheet (D-08)'
      exports: ['PushPermissionPrompt']
  key_links:
    - from: 'apps/pwa/src/hooks/usePushSubscription.ts'
      to: '/api/push/subscription'
      via: 'fetch POST sub.toJSON() inside tap handler'
      pattern: 'api/push/subscription'
    - from: 'apps/api/src/index.ts'
      to: 'webpush.setVapidDetails'
      via: 'isMainModule startup before serve'
      pattern: 'setVapidDetails'
    - from: 'apps/pwa/src/sw.ts'
      to: 'showNotification'
      via: 'event.waitUntil in push handler'
      pattern: 'waitUntil'
---

<objective>
The first end-to-end vertical slice: a member installs the PWA, taps "Enable Notifications", the browser subscribes, the server persists the subscription, and a dispatched push displays a visible notification that deep-links on tap. This proves the full DB to API to SW to visible-notification stack before any trigger (reminder/list/event) is wired.

Purpose: After this plan a real user can grant permission and receive a push — the spine of all three NOTIF requirements and success criterion 4 (iOS reliability). It also performs the load-bearing, risky generateSW to injectManifest service-worker migration while preserving the OIDC /callback denylist (T-03-20).

Output: pushRouter (subscribe/unsubscribe/vapid-public-key) wired in index.ts with setVapidDetails at startup; custom sw.ts; usePushSubscription hook; PushPermissionPrompt mounted off the install flow.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@apps/api/src/routes/lists.ts
@apps/api/src/index.ts
@apps/api/src/lib/pushDispatcher.ts
@apps/pwa/vite.config.ts
@apps/pwa/src/components/InstallPrompt.tsx
@apps/pwa/src/App.tsx
@.planning/phases/05-web-push-notifications/05-RESEARCH.md
@.planning/phases/05-web-push-notifications/05-PATTERNS.md
@.planning/phases/05-web-push-notifications/05-UI-SPEC.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: Push subscription API + startup VAPID wiring</name>
  <read_first>
    - apps/api/src/routes/lists.ts (lines 20-34 imports; resolveUserId lines 57-69; createListSchema/zValidator; POST/DELETE handler shapes)
    - apps/api/src/index.ts (route mounts lines 62-66; isMainModule guard lines 107-117)
    - apps/api/tests/routes/push.test.ts (RED scaffold from Plan 05-01)
    - .planning/phases/05-web-push-notifications/05-PATTERNS.md (### apps/api/src/routes/push.ts — full handler pattern; Mount pattern)
    - .planning/phases/05-web-push-notifications/05-RESEARCH.md (### Security Domain — V2/V4/V5)
  </read_first>
  <action>
    Create apps/api/src/routes/push.ts exporting pushRouter = new Hono(). Copy resolveUserId verbatim from lists.ts (per project convention — duplicated per router, not extracted). Routes:
    GET /vapid-public-key returns c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' }) — the value is the non-secret public key; it sits under the /api OIDC guard (PWA fetches it post-login, acceptable for v1).
    POST /subscription with zValidator('json', subscribeSchema) where subscribeSchema = z.object({ endpoint: z.string().url().max(2048), keys: z.object({ p256dh: z.string().min(1).max(512), auth: z.string().min(1).max(256) }) }). Resolve userId (401 if null). Insert into pushSubscriptions { userId, endpoint, p256dh: keys.p256dh, auth: keys.auth } with .onDuplicateKeyUpdate({ set: { userId, p256dh, auth } }) (endpoint is the unique key — re-subscribe from the same device updates ownership). Return 201.
    DELETE /subscription: resolve userId (401 if null), db.delete(pushSubscriptions) WHERE eq(pushSubscriptions.userId, userId) — scoped to the caller only (V4: a member only deletes their OWN subscriptions). Return { ok: true }.
    In index.ts: import { pushRouter }; add app.route('/api/push', pushRouter) alongside the other /api mounts. Inside the isMainModule() guard, BEFORE serve(), call webpush.setVapidDetails(process.env.VAPID_SUBJECT, process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY) with default import webpush from 'web-push'. This is the only setVapidDetails call site (the dispatcher never calls it).
  </action>
  <verify>
    <automated>cd apps/api && pnpm exec vitest run tests/routes/push.test.ts && grep -q "setVapidDetails" src/index.ts && grep -q "api/push" src/index.ts</automated>
  </verify>
  <acceptance_criteria>
    push.test.ts green: POST persists user-scoped row, 401 unauth, DELETE removes only caller rows, GET returns publicKey. index.ts mounts /api/push and calls setVapidDetails once at startup.
  </acceptance_criteria>
  <done>Subscription API live and tested; VAPID configured at startup.</done>
</task>

<task type="auto">
  <name>Task 2: Service-worker migration to injectManifest (push + notificationclick + denylist)</name>
  <read_first>
    - apps/pwa/vite.config.ts (lines 8-39 current generateSW config — denylist lines 16-19 MUST be preserved)
    - .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 2 sw.ts; Pattern 3 vite.config; Pitfall 3/4 workbox deps + denylist; ### SW navigateFallback preservation; ### Event payload format)
    - .planning/phases/05-web-push-notifications/05-PATTERNS.md (### apps/pwa/src/sw.ts; ### apps/pwa/vite.config.ts)
    - .planning/phases/05-web-push-notifications/05-UI-SPEC.md (## Tap-to-Open Deep Links)
  </read_first>
  <action>
    Migrate apps/pwa/vite.config.ts from generateSW to injectManifest: replace the workbox:{} block with strategies:'injectManifest', srcDir:'src', filename:'sw.ts', injectManifest:{ globIgnores:['**/node_modules/**','**/callback**'] }. Keep registerType:'autoUpdate' and the manifest block byte-identical. Create apps/pwa/src/sw.ts:
    Declare self as ServiceWorkerGlobalScope. Import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'; { clientsClaim } from 'workbox-core'; { NavigationRoute, registerRoute } from 'workbox-routing'.
    self.skipWaiting(); clientsClaim() (reproduces autoUpdate).
    precacheAndRoute(self.__WB_MANIFEST).
    Re-implement the navigation denylist (T-03-20, Pitfall 4): const navHandler = createHandlerBoundToURL('/index.html'); registerRoute(new NavigationRoute(navHandler, { denylist: [/^\/callback/, /^\/api\//, /^\/health/] })).
    push handler: parse event.data.json(); support BOTH data.notification (declarative) and legacy top-level title/body/tag/data; derive title/body/tag/url; on ANY parse failure fall back to title 'FamilySync', body 'You have a new notification'. ALWAYS event.waitUntil(self.registration.showNotification(title, { body, tag, data: { url } })) — even on the malformed-payload branch (D-11: silent push = iOS subscription death).
    notificationclick handler: event.notification.close(); read url from notification.data.url (default '/'); event.waitUntil(matchAll({ type:'window', includeUncontrolled:true }) then focus a client already at url, else openWindow(url)).
  </action>
  <verify>
    <automated>cd apps/pwa && grep -q "injectManifest" vite.config.ts && grep -q "showNotification" src/sw.ts && grep -q "waitUntil" src/sw.ts && grep -q "callback" src/sw.ts && pnpm build 2>&1 | tail -3</automated>
  </verify>
  <acceptance_criteria>
    vite.config uses injectManifest; sw.ts builds; sw.ts contains showNotification + waitUntil in the push handler, the /callback,/api,/health denylist, and a notificationclick deep-link handler. `pnpm build` produces a sw.js with the precache manifest injected.
  </acceptance_criteria>
  <done>SW migrated; push + notificationclick + denylist preserved; build green.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: usePushSubscription hook + PushPermissionPrompt + desktop subscribe verification</name>
  <read_first>
    - apps/pwa/src/components/InstallPrompt.tsx (useAndroidInstallPrompt hook lines 76-105; WalkthroughSheet layout lines 121-269; isInstalled lines 54-59; readDismissed/persistDismissed lines 284-297)
    - apps/pwa/src/App.tsx (mount point)
    - .planning/phases/05-web-push-notifications/05-PATTERNS.md (### usePushSubscription.ts; ### PushPermissionPrompt.tsx)
    - .planning/phases/05-web-push-notifications/05-UI-SPEC.md (### Surface 1: Post-Install Permission Prompt — copy, states, a11y, localStorage key pushPermissionDismissed)
    - .claude/skills/playwright-cli/SKILL.md
  </read_first>
  <what-built>
    apps/pwa/src/hooks/usePushSubscription.ts: returns { subscribe, unsubscribe, permission }. subscribe(reg) MUST be callable synchronously from a tap handler with no await before pushManager.subscribe (iOS user-gesture requirement, D-08/Pitfall 2): fetch the VAPID public key (GET /api/push/vapid-public-key, cache in sessionStorage) ONCE earlier, then subscribe({ userVisibleOnly:true, applicationServerKey: urlBase64ToUint8Array(key) }) and POST sub.toJSON() to /api/push/subscription with credentials:'include'. unsubscribe(): getSubscription then sub.unsubscribe() + DELETE /api/push/subscription. Include a urlBase64ToUint8Array helper. localStorage key notificationsEnabled.
    apps/pwa/src/components/PushPermissionPrompt.tsx: WalkthroughSheet-style bottom sheet (zIndex 1000 sheet / 999 backdrop, NO backdrop-dismiss). Bell icon, heading "Stay in the loop", body "Get notified when events are coming up or your family makes changes.", primary CTA "Enable Notifications" (48px, var(--color-member-0)), secondary "Not now" (44px ghost). On Enable tap: call subscribe inside the onClick (no await before subscribe); show Loader2 spinner while awaiting; on granted close sheet; on denied close sheet. "Not now" sets localStorage.pushPermissionDismissed='1'. Render only when isInstalled() and Notification.permission==='default' and not dismissed.
    Mount: render PushPermissionPrompt from InstallPrompt.tsx after install confirms (D-08); mount in the App tree so it appears on the installed PWA.
  </what-built>
  <action>
    Implement the hook + component + mount per <what-built>. Then run a desktop Chromium verification with playwright-cli (push subscribe IS automatable on Chromium per CLAUDE.md / VALIDATION Manual-Only note — only iOS-standalone is device-only). Drive: load the app (dev-bypass), grant notification permission, trigger the Enable flow, assert a row lands in push_subscriptions and the prompt closes. Capture the playwright-cli output as evidence.
  </action>
  <how-to-verify>
    1. Build + serve the API (DEV_AUTH_BYPASS) and PWA per docs/deployment.md local-dev command.
    2. Use playwright-cli to open the app in Chromium, grant Notifications, click "Enable Notifications".
    3. Confirm: the prompt closes, GET subscribe POST returned 201, and a push_subscriptions row exists for the dev user.
    4. (Optional) dispatch a test push and confirm a visible notification + tap deep-link.
    Confirm the iOS-standalone path is deferred to the Phase 5 human gate (device-only).
  </how-to-verify>
  <resume-signal>Type "approved" or describe what failed</resume-signal>
  <verify>
    <automated>cd apps/pwa && grep -q "usePushSubscription" src/hooks/usePushSubscription.ts && grep -q "PushPermissionPrompt" src/components/PushPermissionPrompt.tsx && grep -q "pushManager.subscribe" src/hooks/usePushSubscription.ts && grep -q "PushPermissionPrompt" src/components/InstallPrompt.tsx && pnpm build 2>&1 | tail -2</automated>
  </verify>
  <acceptance_criteria>
    Hook subscribes inside a tap handler (no await before pushManager.subscribe); PushPermissionPrompt renders per UI-SPEC Surface 1 copy/states/a11y; mounted off the install flow; desktop playwright-cli subscribe verified end-to-end (row persisted).
  </acceptance_criteria>
  <done>Subscribe slice works end-to-end on desktop; iOS-standalone deferred to phase gate.</done>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary                              | Description                                                       |
| ------------------------------------- | ----------------------------------------------------------------- |
| browser → POST /api/push/subscription | untrusted subscription body crosses into the API                  |
| SW → push payload                     | push payload from the service is untrusted input parsed in the SW |
| SW → /callback navigation             | OIDC callback must reach the server, never the SW cache           |

## STRIDE Threat Register

| Threat ID | Category          | Component                                         | Disposition | Mitigation Plan                                                                                               |
| --------- | ----------------- | ------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------- |
| T-05-09   | Spoofing          | POST /subscription (user A subscribing as user B) | mitigate    | userId comes from the OIDC session via resolveUserId, never from the body                                     |
| T-05-10   | Input Validation  | subscription body                                 | mitigate    | zod subscribeSchema (endpoint url, p256dh/auth bounded) before insert                                         |
| T-05-11   | Tampering         | SW serving /callback from cache                   | mitigate    | NavigationRoute denylist /^\/callback/, /^\/api\//, /^\/health/ re-implemented in sw.ts (T-03-20 / Pitfall 4) |
| T-05-12   | Denial of Service | malformed push payload in SW                      | mitigate    | try/catch in push handler; ALWAYS showNotification (generic fallback) so iOS never sees a silent push         |
| T-05-13   | Access Control    | DELETE /subscription                              | mitigate    | scoped WHERE userId = caller; cannot delete another member's subscription                                     |

</threat_model>

<verification>
- push.test.ts green; index.ts mounts /api/push + setVapidDetails.
- `pnpm --filter @familysync/pwa build` produces a sw.js with precache manifest; denylist present.
- Desktop playwright-cli subscribe round-trip persists a push_subscriptions row.
</verification>

<success_criteria>

- Subscribe/unsubscribe/vapid-public-key API live and user-scoped.
- generateSW to injectManifest migration complete with denylist preserved.
- Every push shows a visible notification (incl. malformed); notificationclick deep-links.
- Post-install permission prompt matches UI-SPEC Surface 1 and subscribes on tap.
  </success_criteria>

<output>
Create `.planning/phases/05-web-push-notifications/05-04-SUMMARY.md` when done.
</output>
