12 KiB
phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
| phase | plan | subsystem | tags | dependency_graph | tech_stack | key_files | decisions | metrics | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-web-push-notifications | 04 | api/push-routes, pwa/sw, pwa/hooks, pwa/components |
|
|
|
|
|
|
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),resolveUserIdguard (T-05-09), upserts on endpoint unique constraint, returns 201DELETE /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, BEFOREserve(): callwebpush.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 producesw.js(notsw.mjs) matchingregisterSW.jsregistrationinjectManifest.globIgnores: ['**/node_modules/**', '**/callback**']registerType: 'autoUpdate'andmanifestblock preserved byte-identical
Created apps/pwa/src/sw.ts:
self.skipWaiting()+clientsClaim()— reproduces autoUpdate behaviorprecacheAndRoute(self.__WB_MANIFEST)— app shell precacheNavigationRoutewith denylist[/^\/callback/, /^\/api\//, /^\/health/](T-03-20, T-05-11 preserved)pushhandler: 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)notificationclickhandler:event.notification.close(), matchAll → focus existing window at URL oropenWindow(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), callspushManager.subscribe({userVisibleOnly:true, applicationServerKey}), POSTssub.toJSON()to/api/push/subscriptionunsubscribe()—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 useEffecturlBase64ToUint8Arrayuses explicitnew ArrayBuffer()to satisfy TSUint8Array<ArrayBuffer>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 inuseEffectwhile visible
Mount points:
App.tsx:<PushPermissionPrompt />as sibling of<BottomTabBar>— covers installed-PWA pathInstallPrompt.tsx:justInstalledflag (fromappinstalledevent) renders<PushPermissionPrompt>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 CreatedDELETE /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)calledvi.getMockImplementationwhich 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=unauthcache 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
.envVAPID_PRIVATE_KEY is truncated (41 chars vs expected 43) due to a multiline formatting issue. The startup guard passed the truthiness check butweb-pushthrew "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, producingsw.mjs. ButregisterSW.jsalways 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 producessw.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)producesUint8Array<ArrayBufferLike>butapplicationServerKeyexpectsArrayBufferView<ArrayBuffer>— SharedArrayBuffer not assignable to ArrayBuffer - Fix: Changed to
const buffer = new ArrayBuffer(rawData.length); const outputArray = new Uint8Array(buffer)which types asUint8Array<ArrayBuffer> - 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:
- iOS Safari Home Screen install — pushManager.subscribe requires Home Screen launch
- iOS pushManager.subscribe user-gesture gate — tap handler requirement only verifiable on device
- iOS push message delivery via APNs — requires valid VAPID keys + device-registered endpoint + APNs routing
- Standalone mode detection on iOS —
navigator.standalone === trueonly 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:
- apps/api/src/routes/push.ts — exists
- apps/pwa/src/sw.ts — exists
- apps/pwa/src/hooks/usePushSubscription.ts — exists
- apps/pwa/src/components/PushPermissionPrompt.tsx — exists
Commits verified:
f6f1374: feat(05-04): push subscription API + VAPID startup wiringe5953eb: feat(05-04): SW migration to injectManifest with push + notificationclick + denylistbf8f63b: feat(05-04): usePushSubscription hook + PushPermissionPrompt + App mountd816f79: 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