Files
familysync/.planning/milestones/v1.0-phases/05-web-push-notifications/05-04-PLAN.md
T

207 lines
16 KiB
Markdown

---
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>