docs(05): create phase 5 web-push plan (8 plans, 6 waves)
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user