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.
210 lines
16 KiB
Markdown
210 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>
|