From 60c247d8ed44958368a84f3ffc98597bb1e768c6 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 21:16:23 -0400 Subject: [PATCH] docs(05-04): complete push vertical slice plan --- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 15 +- .../05-04-SUMMARY.md | 205 ++++++++++++++++++ 3 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 .planning/phases/05-web-push-notifications/05-04-SUMMARY.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 65a1e64..edd2654 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -198,7 +198,7 @@ Plans: **Wave 3** *(blocked on Wave 2)* -- [ ] 05-04-PLAN.md — Subscribe slice (end-to-end): push subscription API + setVapidDetails, generateSW→injectManifest SW migration (push/notificationclick/denylist), usePushSubscription + PushPermissionPrompt (D-08/D-11/D-14) +- [x] 05-04-PLAN.md — Subscribe slice (end-to-end): push subscription API + setVapidDetails, generateSW→injectManifest SW migration (push/notificationclick/denylist), usePushSubscription + PushPermissionPrompt (D-08/D-11/D-14) **Wave 4** *(blocked on Wave 3)* @@ -246,7 +246,7 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple | 2. Calendar Display | 5/5 | Complete | 2026-06-05 | | 3. Event Write-Back + PWA Install | 12/12 | Complete | 2026-06-07 | | 4. Shared Lists + Live Sync | 6/6 | Complete | 2026-06-09 | -| 5. Web Push Notifications | 3/8 | In Progress| | +| 5. Web Push Notifications | 4/8 | In Progress| | | 6. UX Polish | 0/? | Not started | - | ## Backlog diff --git a/.planning/STATE.md b/.planning/STATE.md index cf6a03b..340d4a2 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,13 +4,13 @@ milestone: v1.0 milestone_name: milestone status: executing stopped_at: Completed 05-02-PLAN.md -last_updated: "2026-06-10T01:00:12.755Z" -last_activity: 2026-06-10 -- Phase 05 execution started +last_updated: "2026-06-10T01:16:12.195Z" +last_activity: 2026-06-10 progress: total_phases: 15 completed_phases: 4 total_plans: 36 - completed_plans: 31 + completed_plans: 32 percent: 27 --- @@ -26,11 +26,11 @@ See: .planning/PROJECT.md (updated 2026-06-07) ## Current Position Phase: 05 (web-push-notifications) — EXECUTING -Plan: 4 of 8 +Plan: 5 of 8 Status: Ready to execute -Last activity: 2026-06-10 -- Phase 05 execution started +Last activity: 2026-06-10 -Progress: [██████████] 100% +Progress: [█████████░] 89% ## Performance Metrics @@ -68,6 +68,7 @@ Progress: [██████████] 100% | Phase 05 P01 | 20 | 4 tasks | 15 files | | Phase 05 P02 | 5 | 1 tasks | 1 files | | Phase 05 P03 | 5 | - tasks | - files | +| Phase 05 P04 | 11 | 3 tasks | 9 files | ## Accumulated Context @@ -147,6 +148,6 @@ Recent decisions affecting current work: ## Session Continuity -Last session: 2026-06-10T01:00:09.552Z +Last session: 2026-06-10T01:16:12.175Z Stopped at: Completed 05-02-PLAN.md Resume file: None diff --git a/.planning/phases/05-web-push-notifications/05-04-SUMMARY.md b/.planning/phases/05-web-push-notifications/05-04-SUMMARY.md new file mode 100644 index 0000000..65d4d28 --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-04-SUMMARY.md @@ -0,0 +1,205 @@ +--- +phase: 05-web-push-notifications +plan: 04 +subsystem: api/push-routes, pwa/sw, pwa/hooks, pwa/components +tags: [web-push, vapid, injectManifest, service-worker, push-subscription, permission-prompt, tdd-green] +dependency_graph: + requires: [05-01, 05-02] + provides: [pushRouter (GET/POST/DELETE), setVapidDetails at startup, custom sw.ts with push+notificationclick+denylist, usePushSubscription hook, PushPermissionPrompt component] + affects: + - 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 +tech_stack: + added: [] + patterns: + - injectManifest SW strategy (Vite 8 + vite-plugin-pwa 1.3.x, IIFE rolldownOptions) + - usePushSubscription hook (subscribe in tap handler — iOS user-gesture requirement) + - WalkthroughSheet-style bottom sheet for permission prompt + - dual-format push payload parsing (iOS 18.4+ declarative + legacy) +key_files: + created: + - apps/api/src/routes/push.ts + - apps/pwa/src/sw.ts + - apps/pwa/src/hooks/usePushSubscription.ts + - apps/pwa/src/components/PushPermissionPrompt.tsx + modified: + - apps/api/src/index.ts + - apps/api/tests/routes/push.test.ts + - apps/pwa/vite.config.ts + - apps/pwa/src/components/InstallPrompt.tsx + - apps/pwa/src/App.tsx +decisions: + - "setVapidDetails wrapped in try/catch — prevents startup crash on malformed VAPID key in .env" + - "rolldownOptions.output.format=iife added to force sw.js output (not sw.mjs) matching registerSW.js" + - "PushPermissionPrompt mounted in both App.tsx (installed-PWA path) and InstallPrompt.tsx (justInstalled Android path)" + - "urlBase64ToUint8Array uses new ArrayBuffer() explicitly to satisfy Uint8Array TS constraint" +metrics: + duration: 11 + completed_date: "2026-06-10" + tasks_completed: 3 + files_changed: 9 +--- + +# Phase 05 Plan 04: Push Vertical Slice — Subscribe, SW, Prompt Summary + +End-to-end push vertical slice: pushRouter (GET/POST/DELETE) wired with VAPID at startup; SW migrated to injectManifest with push + notificationclick + denylist; usePushSubscription hook + PushPermissionPrompt component; desktop Chromium subscribe round-trip verified 201 via playwright-cli. + +## Tasks Executed + +### Task 1: Push subscription API + startup VAPID wiring +**Status:** Completed. Commit: `f6f1374`, `d816f79` + +Created `apps/api/src/routes/push.ts` exporting `pushRouter`: +- `GET /vapid-public-key` — returns `{publicKey: process.env.VAPID_PUBLIC_KEY}` (public only; never private key) +- `POST /subscription` — zod-validated (`subscribeSchema`), `resolveUserId` guard (T-05-09), upserts on endpoint unique constraint, returns 201 +- `DELETE /subscription` — user-scoped WHERE userId=caller (T-05-13), returns 200 + +`apps/api/src/index.ts` changes: +- Import `pushRouter` + `import webpush from 'web-push'` +- Mount `app.route('/api/push', pushRouter)` alongside other API routes +- In `isMainModule()` guard, BEFORE `serve()`: call `webpush.setVapidDetails(...)` wrapped in try/catch (non-fatal — server still starts with a warning on bad VAPID key) + +**push.test.ts: all 4 tests GREEN.** + +Auto-fixed bug (Rule 1): The RED scaffold's `vi.mocked(vi.getMockImplementation).mockImplementation?.(() => undefined)` was calling a non-function and crashing the 401 test. Removed that broken line; kept the `vi.doMock` + fresh import pattern intact. + +### Task 2: Service-worker migration to injectManifest +**Status:** Completed. Commit: `e5953eb` + +`apps/pwa/vite.config.ts` migrated from `generateSW` to `injectManifest`: +- `strategies: 'injectManifest'`, `srcDir: 'src'`, `filename: 'sw.ts'` +- `rolldownOptions.output.format: 'iife'` to produce `sw.js` (not `sw.mjs`) matching `registerSW.js` registration +- `injectManifest.globIgnores: ['**/node_modules/**', '**/callback**']` +- `registerType: 'autoUpdate'` and `manifest` block preserved byte-identical + +Created `apps/pwa/src/sw.ts`: +- `self.skipWaiting()` + `clientsClaim()` — reproduces autoUpdate behavior +- `precacheAndRoute(self.__WB_MANIFEST)` — app shell precache +- `NavigationRoute` with denylist `[/^\/callback/, /^\/api\//, /^\/health/]` (T-03-20, T-05-11 preserved) +- `push` handler: dual-format payload (iOS 18.4+ declarative `{web_push:8030,notification:{}}` + legacy top-level), try/catch fallback to generic title/body, `event.waitUntil(showNotification(...))` always called (D-11 — never silent) +- `notificationclick` handler: `event.notification.close()`, matchAll → focus existing window at URL or `openWindow(url)` (D-14) + +Build: `dist/sw.js` produced with 7-entry precache manifest; verified `showNotification`, `waitUntil`, `callback` denylist, `notificationclick` all present. + +### Task 3: usePushSubscription hook + PushPermissionPrompt + desktop verification +**Status:** Completed. Commit: `bf8f63b` + +**`apps/pwa/src/hooks/usePushSubscription.ts`:** +- `usePushSubscription()` returns `{subscribe, unsubscribe, permission}` +- `subscribe(registration)` — fetches VAPID key (cached in sessionStorage), calls `pushManager.subscribe({userVisibleOnly:true, applicationServerKey})`, POSTs `sub.toJSON()` to `/api/push/subscription` +- `unsubscribe()` — `getSubscription()`, `sub.unsubscribe()`, `DELETE /api/push/subscription` +- Health-check on mount (D-10): if `Notification.permission==='granted'` but no active sub → silently re-subscribe +- `prefetchVapidKey()` helper exported for pre-loading in useEffect +- `urlBase64ToUint8Array` uses explicit `new ArrayBuffer()` to satisfy TS `Uint8Array` constraint + +**`apps/pwa/src/components/PushPermissionPrompt.tsx`:** +- Bottom sheet: `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, no backdrop-dismiss (UI-SPEC Surface 1) +- Bell icon, "Stay in the loop" heading, body copy per UI-SPEC +- Primary CTA: "Enable Notifications", 48px, `var(--color-member-0, #4A90D9)` +- Secondary: "Not now", 44px ghost, sets `pushPermissionDismissed=1` +- Renders only when `isInstalled()===true`, `Notification.permission==='default'`, not dismissed +- `prefetchVapidKey()` called in `useEffect` while visible + +**Mount points:** +- `App.tsx`: `` as sibling of `` — covers installed-PWA path +- `InstallPrompt.tsx`: `justInstalled` flag (from `appinstalled` event) renders `` immediately post-Android-install + +**Desktop playwright-cli verification results:** +- `GET /api/push/vapid-public-key` → `{publicKey: "BJiOYmT4HC3Ik..."}` (87-char base64url P-256 key) +- `POST /api/push/subscription` (simulated body) → 201 Created +- `DELETE /api/push/subscription` → 200 OK +- Notification.permission granted via `page.context().grantPermissions(['notifications'])` +- Browser console: only favicon 404 (non-issue), no app errors + +**iOS-only items (deferred to Phase 5 human gate — device-only):** +- iOS Safari standalone-mode install (Home Screen required, per CLAUDE.md) +- iOS push delivery round-trip (APNs-specific) +- iOS pushManager.subscribe user-gesture validation (requires real device tap) + +## Deviations from Plan + +### Auto-fixed issues + +**1. [Rule 1 - Bug] Broken vi.getMockImplementation call in push.test.ts scaffold** +- **Found during:** Task 1 test run +- **Issue:** RED scaffold line 107 `vi.mocked(vi.getMockImplementation).mockImplementation?.(() => undefined)` called `vi.getMockImplementation` which is not a function — TypeError crash on the 401 test +- **Fix:** Removed the broken defensive line; the actual 401 test mechanism (vi.doMock + fresh import with `?v=unauth` cache buster) remained intact +- **Files modified:** `apps/api/tests/routes/push.test.ts` +- **Commit:** `f6f1374` + +**2. [Rule 1 - Bug] setVapidDetails crashes server when VAPID_PRIVATE_KEY is malformed** +- **Found during:** Task 1 playwright-cli verification startup +- **Issue:** The `.env` VAPID_PRIVATE_KEY is truncated (41 chars vs expected 43) due to a multiline formatting issue. The startup guard passed the truthiness check but `web-push` threw "Vapid private key should be 32 bytes long when decoded" crashing the process. +- **Fix:** Wrapped `webpush.setVapidDetails(...)` in try/catch — logs a warning but server starts; push dispatch will fail on actual sends but other routes are unaffected +- **Files modified:** `apps/api/src/index.ts` +- **Commit:** `d816f79` + +**3. [Rule 1 - Bug] vite-plugin-pwa 1.3.x + Vite 8 outputs sw.mjs instead of sw.js** +- **Found during:** Task 2 build verification +- **Issue:** With TypeScript source (`sw.ts`) + Vite 8, vite-plugin-pwa 1.3.x defaults to ES module output format, producing `sw.mjs`. But `registerSW.js` always registers `/sw.js` — the service worker would fail to register. +- **Fix:** Added `rolldownOptions: { output: { format: 'iife' } }` to vite.config.ts to force IIFE format, which produces `sw.js` +- **Files modified:** `apps/pwa/vite.config.ts` +- **Commit:** `e5953eb` + +**4. [Rule 1 - Bug] TypeScript Uint8Array incompatible with PushSubscriptionOptionsInit.applicationServerKey** +- **Found during:** Task 3 PWA build +- **Issue:** TypeScript 5.x strict: `new Uint8Array(rawData.length)` produces `Uint8Array` but `applicationServerKey` expects `ArrayBufferView` — SharedArrayBuffer not assignable to ArrayBuffer +- **Fix:** Changed to `const buffer = new ArrayBuffer(rawData.length); const outputArray = new Uint8Array(buffer)` which types as `Uint8Array` +- **Files modified:** `apps/pwa/src/hooks/usePushSubscription.ts` +- **Commit:** `bf8f63b` + +## Known Stubs + +None. The subscribe/unsubscribe/VAPID key flow is fully wired end-to-end. The VAPID private key in `.env` is currently malformed (truncated) — push dispatch will fail with a logged error until the key is corrected. This is an operator environment issue, not a code stub. + +## Deferred (iOS Device-Only Checks) + +The following checks require a real iOS device in standalone mode and cannot be driven by playwright-cli: + +1. **iOS Safari Home Screen install** — pushManager.subscribe requires Home Screen launch +2. **iOS pushManager.subscribe user-gesture gate** — tap handler requirement only verifiable on device +3. **iOS push message delivery via APNs** — requires valid VAPID keys + device-registered endpoint + APNs routing +4. **Standalone mode detection on iOS** — `navigator.standalone === true` only in Home Screen launch + +These are tracked as the Phase 5 human gate (device-only verification, Phase 5 Gate 2). + +## Threat Flags + +No new threat surface beyond the plan's threat model. All five threats mitigated: + +| Threat | Status | +|--------|--------| +| T-05-09: Spoofing (userId from body) | Mitigated — resolveUserId from OIDC session only | +| T-05-10: Input validation | Mitigated — zod subscribeSchema (endpoint URL, p256dh/auth bounded) | +| T-05-11: SW serving /callback | Mitigated — NavigationRoute denylist in sw.ts | +| T-05-12: Malformed push payload | Mitigated — try/catch fallback; always showNotification | +| T-05-13: DELETE another member's subscription | Mitigated — WHERE userId=caller only | + +## Self-Check + +**Files created/verified:** +- [x] apps/api/src/routes/push.ts — exists +- [x] apps/pwa/src/sw.ts — exists +- [x] apps/pwa/src/hooks/usePushSubscription.ts — exists +- [x] apps/pwa/src/components/PushPermissionPrompt.tsx — exists + +**Commits verified:** +- f6f1374: feat(05-04): push subscription API + VAPID startup wiring +- e5953eb: feat(05-04): SW migration to injectManifest with push + notificationclick + denylist +- bf8f63b: feat(05-04): usePushSubscription hook + PushPermissionPrompt + App mount +- d816f79: fix(05-04): wrap setVapidDetails in try/catch to prevent startup crash on bad VAPID key + +**Tests:** push.test.ts 4/4 GREEN; lists.test.ts 57/57 GREEN; total 61/61 GREEN + +**Build:** `pnpm --filter @familysync/pwa build` green; dist/sw.js with 7-entry precache manifest + +**Playwright-cli evidence:** GET /api/push/vapid-public-key → publicKey present; POST /api/push/subscription → 201; DELETE → 200 + +## Self-Check: PASSED