Files
familysync/.planning/milestones/v1.0-phases/05-web-push-notifications/05-04-PLAN.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-web-push-notifications 04 execute 3
05-01
05-02
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
false
NOTIF-01
NOTIF-02
NOTIF-03
truths artifacts key_links
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)
path provides exports
apps/api/src/routes/push.ts pushRouter — GET /vapid-public-key, POST /subscription, DELETE /subscription
pushRouter
path provides contains
apps/pwa/src/sw.ts custom injectManifest SW: precache + push + notificationclick + nav denylist showNotification
path provides exports
apps/pwa/src/hooks/usePushSubscription.ts subscribe/unsubscribe lifecycle (subscribe in tap handler only)
usePushSubscription
path provides exports
apps/pwa/src/components/PushPermissionPrompt.tsx post-install permission bottom sheet (D-08)
PushPermissionPrompt
from to via pattern
apps/pwa/src/hooks/usePushSubscription.ts /api/push/subscription fetch POST sub.toJSON() inside tap handler api/push/subscription
from to via pattern
apps/api/src/index.ts webpush.setVapidDetails isMainModule startup before serve setVapidDetails
from to via pattern
apps/pwa/src/sw.ts showNotification event.waitUntil in push handler waitUntil
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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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 Task 1: Push subscription API + startup VAPID wiring - 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) 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). 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 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. Subscription API live and tested; VAPID configured at startup. Task 2: Service-worker migration to injectManifest (push + notificationclick + denylist) - 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) 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)). 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 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. SW migrated; push + notificationclick + denylist preserved; build green. Task 3: usePushSubscription hook + PushPermissionPrompt + desktop subscribe verification - 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 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. Implement the hook + component + mount per . 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. 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). Type "approved" or describe what failed 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 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). Subscribe slice works end-to-end on desktop; iOS-standalone deferred to phase gate.

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

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

<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>
Create `.planning/phases/05-web-push-notifications/05-04-SUMMARY.md` when done.