docs(05): create phase 5 web-push plan (8 plans, 6 waves)

This commit is contained in:
Lucas Berger
2026-06-09 18:39:05 -04:00
parent 6a8b6e994c
commit 1ecca03f53
10 changed files with 1273 additions and 16 deletions
+30 -2
View File
@@ -185,7 +185,35 @@ Plans:
3. When the other member modifies a shared list (adds, checks off, or deletes an item), the first member receives a push notification identifying the list and the change
4. After an extended period of app inactivity, push notifications are still delivered (subscription health-check prevents silent revocation on iOS)
**Plans**: TBD
**Plans**: 8 plans (6 waves)
Plans:
**Wave 1**
- [ ] 05-01-PLAN.md — Foundation: install web-push + workbox deps (legitimacy gate), generate VAPID keypair, push_subscriptions table + calendar_events.title generate+migrate [BLOCKING], Wave-0 RED scaffolds (D-11/D-12)
**Wave 2** *(blocked on Wave 1)*
- [ ] 05-02-PLAN.md — TDD: pushDispatcher (VAPID send + dual-format payload + 410/404 prune) (D-11)
- [ ] 05-03-PLAN.md — TDD: pushCoalescer (per-list/actor debounce, generic copy, self-suppress) (D-01/D-02/D-03)
**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)
**Wave 4** *(blocked on Wave 3)*
- [ ] 05-05-PLAN.md — NOTIF-02 list-change slice: listChangeDispatcher + hook coalescer into mutations, reorder-silent (D-01/D-02/D-03)
- [ ] 05-06-PLAN.md — TDD: NOTIF-01 reminderScheduler — shared-timed 15-min scan (query-enforced D-05), all-day excl, dedup, empty-set safe (D-05/D-06/D-07)
**Wave 5** *(blocked on Wave 4)*
- [ ] 05-07-PLAN.md — TDD: NOTIF-03 eventChangeDispatcher + syncCalendar diff/title/onChanges hook (poller + outbox), meaningful-only, actor-suppressed (D-02/D-03/D-04/D-13)
**Wave 6** *(blocked on Wave 3)*
- [ ] 05-08-PLAN.md — Settings + reliability: master toggle (D-09) + silent re-subscribe (D-10) + PermissionDeniedBanner + avatar→Settings sheet
**UI hint**: yes
### Phase 6: UX Polish
@@ -218,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 | 0/? | Not started | - |
| 5. Web Push Notifications | 0/8 | Planned | - |
| 6. UX Polish | 0/? | Not started | - |
## Backlog
@@ -0,0 +1,234 @@
---
phase: 05-web-push-notifications
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/package.json
- apps/pwa/package.json
- apps/api/src/db/schema.ts
- apps/api/src/db/migrations/
- apps/api/test/setup.ts
- apps/api/tests/lib/pushDispatcher.test.ts
- apps/api/tests/lib/pushCoalescer.test.ts
- apps/api/tests/broker/reminderScheduler.test.ts
- apps/api/tests/lib/eventChangeDispatcher.test.ts
- apps/api/tests/routes/push.test.ts
- apps/api/tests/fixtures/vapid.ts
- .env.example
autonomous: false
requirements: [NOTIF-01, NOTIF-02, NOTIF-03]
user_setup:
- service: web-push (VAPID — self-generated, no external account)
why: "Server signs push messages with a VAPID keypair; the private key must live in the API env, the public key is served to the PWA. No third-party account — the keypair is generated locally."
env_vars:
- name: VAPID_PUBLIC_KEY
source: "Generated by `npx web-push generate-vapid-keys --json` (Task 2 runs this and prints the values)"
- name: VAPID_PRIVATE_KEY
source: "Same command — paste into apps/api `.env` (NEVER commit; .env is gitignored)"
- name: VAPID_SUBJECT
source: "A mailto: or https: contact URL, e.g. mailto:admin@familysync.bergerhouse.net"
must_haves:
truths:
- "web-push + @types/web-push are installed in apps/api; workbox-precaching/core/routing are devDeps in apps/pwa"
- "push_subscriptions table exists in MariaDB with (user_id FK cascade, endpoint unique, p256dh, auth) after migrate"
- "calendar_events has a title varchar(500) column after migrate (D-02/NOTIF-01 readable copy)"
- "A real generated VAPID keypair is recorded in .env (private) and .env.example documents the three env vars (public placeholder only)"
- "All Wave-0 RED test files exist and fail for the right reason (missing implementation, not import/syntax errors)"
- "test/setup.ts afterEach truncates push_subscriptions"
artifacts:
- path: "apps/api/src/db/schema.ts"
provides: "pushSubscriptions table + calendarEvents.title column"
contains: "pushSubscriptions"
- path: "apps/api/src/db/migrations"
provides: "0003 migration adding push_subscriptions + calendar_events.title"
contains: "push_subscriptions"
- path: "apps/api/tests/fixtures/vapid.ts"
provides: "Static test VAPID keypair fixture (no network) for unit tests"
min_lines: 3
- path: "apps/api/tests/lib/pushDispatcher.test.ts"
provides: "RED scaffold for 410/404 pruning"
- path: "apps/api/tests/lib/pushCoalescer.test.ts"
provides: "RED scaffold for list-change coalescing"
- path: "apps/api/tests/broker/reminderScheduler.test.ts"
provides: "RED scaffold for reminder scan (shared/timed/all-day filters)"
- path: "apps/api/tests/lib/eventChangeDispatcher.test.ts"
provides: "RED scaffold for event-change dispatch + description-only suppression"
- path: "apps/api/tests/routes/push.test.ts"
provides: "RED scaffold for subscription POST/DELETE + vapid-public-key"
key_links:
- from: "apps/api/src/db/schema.ts"
to: "apps/api/test/setup.ts"
via: "pushSubscriptions export imported for truncation"
pattern: "pushSubscriptions"
---
<objective>
Wave-0 foundation for Phase 5 Web Push. Install the missing push dependencies (`web-push` server-side, `workbox-*` client-side build deps), generate the VAPID keypair, add the `push_subscriptions` table and the `calendar_events.title` column via the safe generate+migrate workflow, and lay down every RED test scaffold the later TDD/execute plans assert against.
Purpose: Every downstream plan (dispatcher, coalescer, scheduler, event-change, subscribe slice) depends on these packages, this schema, and these test files existing first. Per RESEARCH §Codebase Ground-Truth: `web-push` and `workbox-precaching` are NOT installed; `calendar_events` has NO title column. This plan closes those gaps and nothing else builds without it.
Output: Installed deps + legitimacy checkpoint, generated VAPID keypair documented in .env, migration 0003 applied to the live dev DB, five RED test files + a VAPID test fixture, and an updated test/setup truncation list.
</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
@.planning/STATE.md
@.planning/phases/05-web-push-notifications/05-RESEARCH.md
@.planning/phases/05-web-push-notifications/05-PATTERNS.md
@apps/api/src/db/schema.ts
@apps/api/test/setup.ts
@apps/api/package.json
@apps/pwa/package.json
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 1: [BLOCKING] Package legitimacy gate + install push dependencies</name>
<read_first>
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (## Package Legitimacy Audit — web-push, @types/web-push, workbox-precaching all OK/Approved)
- .planning/phases/05-web-push-notifications/05-PATTERNS.md (## Dependency Gaps table)
- apps/api/package.json, apps/pwa/package.json (confirm absence)
</read_first>
<what-built>
RESEARCH.md Package Legitimacy Audit verdicts (all "OK / Approved"):
- web-push@3.6.7 — github.com/web-push-libs/web-push, 5.09M/wk
- @types/web-push@3.6.4 — DefinitelyTyped, 1.68M/wk
- workbox-precaching@7.4.1 — github.com/googlechrome/workbox, 7.92M/wk
workbox-core and workbox-routing are siblings of workbox-precaching (same Workbox 7 suite, same publisher).
</what-built>
<action>
Present the four packages (web-push, @types/web-push, workbox-precaching, workbox-core, workbox-routing) with their RESEARCH audit verdicts. These were audited as legitimate; this checkpoint exists because they are package-manager installs (threat T-05-SC). AFTER human approval, run:
`pnpm --filter @familysync/api add web-push`
`pnpm --filter @familysync/api add -D @types/web-push`
`pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core workbox-routing`
Do NOT run installs before approval.
</action>
<how-to-verify>
Confirm the five package names + registry links against npmjs.com if desired. Approve to proceed with install.
</how-to-verify>
<resume-signal>Type "approved" to install, or name any package to reject</resume-signal>
<verify>
<automated>node -e "const a=require('./apps/api/package.json');const p=require('./apps/pwa/package.json');if(!a.dependencies['web-push'])throw new Error('web-push missing');if(!a.devDependencies['@types/web-push'])throw new Error('@types/web-push missing');if(!p.devDependencies['workbox-precaching']||!p.devDependencies['workbox-core']||!p.devDependencies['workbox-routing'])throw new Error('workbox devDeps missing');console.log('deps ok')"</automated>
</verify>
<acceptance_criteria>
web-push + @types/web-push in apps/api package.json; workbox-precaching/core/routing in apps/pwa devDependencies; lockfile updated.
</acceptance_criteria>
<done>All five packages installed in the correct workspace and dependency type.</done>
</task>
<task type="checkpoint:human-action" gate="blocking-human">
<name>Task 2: Generate VAPID keypair + record in env</name>
<read_first>
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (### VAPID key generation; Pitfall 8 — public key delivered to PWA)
- .env.example (existing env var documentation pattern)
</read_first>
<what-built>
web-push CLI generates a URL-safe Base64 VAPID keypair. The private key signs push messages (server-only, in apps/api .env, never committed). The public key is served to the PWA via GET /api/push/vapid-public-key (Plan 05-04) — runtime delivery chosen over build-time VITE_ var to allow key rotation without a rebuild (resolves RESEARCH Open Question 2).
</what-built>
<action>
Run `npx web-push generate-vapid-keys --json` and capture publicKey/privateKey. Append to apps/api `.env` (gitignored): VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT=mailto:admin@familysync.bergerhouse.net. Then update `.env.example` (committed) to DOCUMENT all three keys with placeholder values only — the real private key MUST NOT appear in .env.example or any committed file (threat T-05-01 Information Disclosure). The human pastes the generated keys into .env.
</action>
<how-to-verify>
Confirm apps/api/.env contains VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT with real values; confirm .env.example contains only placeholders.
</how-to-verify>
<resume-signal>Type "done" once keys are in .env</resume-signal>
<verify>
<automated>grep -q 'VAPID_PUBLIC_KEY' .env.example && grep -q 'VAPID_PRIVATE_KEY' .env.example && grep -q 'VAPID_SUBJECT' .env.example && echo "env.example documents VAPID"</automated>
</verify>
<acceptance_criteria>
.env.example documents VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT with placeholder values; real keys live only in gitignored .env. No real private key in any tracked file.
</acceptance_criteria>
<done>VAPID keypair generated; private key in .env only; .env.example documents the three vars.</done>
</task>
<task type="auto">
<name>Task 3: [BLOCKING] Schema — push_subscriptions table + calendar_events.title, generate+migrate</name>
<read_first>
- apps/api/src/db/schema.ts (listShares lines 208-224 = FK+unique+index analog; calendarEvents lines 111-138 = title column target)
- .planning/phases/05-web-push-notifications/05-PATTERNS.md (### apps/api/src/db/schema.ts — add pushSubscriptions table)
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 4 schema; Pitfall 6 title column; Codebase Ground-Truth 1 migration workflow)
</read_first>
<action>
In apps/api/src/db/schema.ts add the `pushSubscriptions` mysqlTable: id int PK autoincrement; userId int('user_id') notNull references users.id onDelete cascade; endpoint text notNull; p256dh text notNull; auth varchar('auth',{length:256}) notNull; createdAt timestamp defaultNow notNull; updatedAt timestamp defaultNow onUpdateNow. Constraints: unique('uniq_push_endpoint').on(endpoint) (one endpoint per device, globally unique) and index('idx_push_subscriptions_user_id').on(userId). Mirror the listShares structure exactly. Also add `title: varchar('title',{length:500})` (nullable) to the existing `calendarEvents` table after `rawVevent` — populated from VEVENT SUMMARY by sync.ts in Plan 05-07; readable reminder/change copy depends on it (D-02). Then generate and apply the migration:
`pnpm --filter @familysync/api db:generate` then `pnpm --filter @familysync/api db:migrate`. Commit the generated 0003_*.sql file. DO NOT run `db:push` / `db:generate --push` — drizzle-kit push emits a false destructive truncate diff on this populated MariaDB (memory: drizzle-mariadb-push-unsafe; STATE D-Task5-DDL). This migrate step is mandatory: type/build checks pass from the schema config alone, so skipping it creates a false-positive verification state where the live DB lacks the table.
</action>
<verify>
<automated>grep -q "pushSubscriptions" apps/api/src/db/schema.ts && grep -q "title:.*varchar.*500" apps/api/src/db/schema.ts && ls apps/api/src/db/migrations/0003_*.sql && grep -li "push_subscriptions" apps/api/src/db/migrations/0003_*.sql</automated>
</verify>
<acceptance_criteria>
schema.ts exports pushSubscriptions and calendarEvents has a title column; a 0003_*.sql migration containing CREATE TABLE push_subscriptions and ALTER calendar_events ADD title exists and has been applied via db:migrate (not db:push).
</acceptance_criteria>
<done>push_subscriptions + calendar_events.title live in the dev DB; migration committed.</done>
</task>
<task type="auto">
<name>Task 4: Wave-0 RED test scaffolds + VAPID fixture + setup truncation</name>
<read_first>
- apps/api/tests/routes/lists.test.ts (mock boilerplate lines 32-44; getApp lines 77-80; jsonRequest lines 86-92; seedUser lines 50-58)
- apps/api/test/setup.ts (afterEach truncation pattern lines 27-37)
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (### Phase Requirements → Test Map; ### Wave 0 Gaps)
- .planning/phases/05-web-push-notifications/05-VALIDATION.md (### Wave 0 Requirements)
</read_first>
<action>
Create apps/api/tests/fixtures/vapid.ts exporting a static TEST_VAPID = { publicKey, privateKey, subject } keypair (generate one real pair with `npx web-push generate-vapid-keys --json` and inline it — test-only, no network at runtime). Create five RED test files, each importing the (not-yet-existing) implementation so they fail on a missing module/export, NOT on syntax:
- tests/lib/pushDispatcher.test.ts — asserts dispatchPush prunes the subscription (DELETE from push_subscriptions) on statusCode 410 and 404, and does NOT delete on 201/transient errors (mock webpush.sendNotification).
- tests/lib/pushCoalescer.test.ts — asserts a burst of N coalesceListPush calls within the window fires the dispatch ONCE with count=N (use vi.useFakeTimers); asserts the actor's own userId is passed as excludeUserId.
- tests/broker/reminderScheduler.test.ts — asserts the scan SELECTs only shared (isShared=true) AND timed (allDay=false) events in the [now+14m, now+16m] window; asserts all-day and non-shared events are excluded (D-05/D-07); asserts the same (eventUid,minuteBucket) does not dispatch twice.
- tests/lib/eventChangeDispatcher.test.ts — asserts dispatchEventChange fires for new/updated(time|date|title|location)/deleted events, does NOT fire for description-only changes (D-04), and excludes the actor's own subscriptions (D-03).
- tests/routes/push.test.ts — asserts POST /api/push/subscription persists a row scoped to the authed user (401 when unauth), DELETE removes the caller's rows, GET /api/push/vapid-public-key returns { publicKey }. Use the lists.test.ts mock/getApp/seedUser/jsonRequest boilerplate verbatim.
Update apps/api/test/setup.ts: import pushSubscriptions and add `await db.delete(pushSubscriptions)` inside the afterEach try block (before lists delete; no FK to lists).
</action>
<verify>
<automated>cd apps/api && pnpm exec vitest run tests/lib/pushDispatcher.test.ts tests/lib/pushCoalescer.test.ts tests/broker/reminderScheduler.test.ts tests/lib/eventChangeDispatcher.test.ts 2>&1 | grep -Eq "Cannot find module|is not a function|No test found|fail" && echo "RED ok"; grep -q "pushSubscriptions" ../../apps/api/test/setup.ts</automated>
</verify>
<acceptance_criteria>
Five RED test files + tests/fixtures/vapid.ts exist; each test file fails on missing implementation (not syntax/import-of-test-lib errors); test/setup.ts truncates push_subscriptions. The dispatcher/coalescer/scheduler/eventChange/route implementations do NOT yet exist (those are Plans 05-02..05-07).
</acceptance_criteria>
<done>RED scaffolds in place; later plans turn them GREEN.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| developer machine → git | VAPID private key must never cross into a committed file |
| pnpm registry → repo | package installs are untrusted supply-chain input |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-01 | Information Disclosure | VAPID_PRIVATE_KEY | mitigate | Private key only in gitignored .env; .env.example carries placeholders; verify gate greps .env.example, never .env |
| T-05-SC | Tampering | npm installs (web-push, workbox-*) | mitigate | RESEARCH legitimacy audit (all OK) + blocking-human checkpoint (Task 1) before install |
| T-05-02 | Tampering | drizzle migration on populated MariaDB | mitigate | Use db:generate+db:migrate only; db:push forbidden (false truncate diff) |
</threat_model>
<verification>
- `pnpm --filter @familysync/api typecheck` passes with the new schema export.
- 0003 migration applied; `push_subscriptions` and `calendar_events.title` exist in the dev DB.
- Five RED test files fail for missing-implementation reasons only.
</verification>
<success_criteria>
- web-push/@types/web-push installed (api); workbox-precaching/core/routing installed (pwa).
- VAPID keypair generated; private key in .env; .env.example documents all three vars.
- push_subscriptions table + calendar_events.title column migrated (generate+migrate, never push).
- All Wave-0 RED scaffolds + VAPID fixture exist; setup.ts truncates push_subscriptions.
</success_criteria>
<output>
Create `.planning/phases/05-web-push-notifications/05-01-SUMMARY.md` when done.
</output>
@@ -0,0 +1,108 @@
---
phase: 05-web-push-notifications
plan: 02
type: tdd
wave: 2
depends_on: [05-01]
files_modified:
- apps/api/src/lib/pushDispatcher.ts
- apps/api/tests/lib/pushDispatcher.test.ts
autonomous: true
requirements: [NOTIF-01, NOTIF-02, NOTIF-03]
must_haves:
truths:
- "dispatchPush sends a VAPID-signed push via webpush.sendNotification with the dual-format payload"
- "On a 410 or 404 from the push service, the subscription row is deleted from push_subscriptions (D-11 prune)"
- "On 201/transient errors the subscription is NOT deleted; the error is logged and dispatch continues"
- "The payload body carries both web_push:8030 + notification{} (iOS 18.4+ declarative) AND legacy title/body/tag/data (iOS 16.4-18.3 + Android)"
artifacts:
- path: "apps/api/src/lib/pushDispatcher.ts"
provides: "dispatchPush(subscription, notification, dbRowId) — single send + prune helper"
exports: ["dispatchPush", "buildPushBody"]
min_lines: 30
key_links:
- from: "apps/api/src/lib/pushDispatcher.ts"
to: "push_subscriptions table"
via: "db.delete on 410/404"
pattern: "delete\\(pushSubscriptions\\)"
---
<objective>
TDD the server-side push dispatch primitive: `dispatchPush` signs and sends one notification via `web-push`, builds the iOS-compatible dual-format payload, and prunes a dead subscription (410/404) from the DB. This is the single send path every trigger (reminder, list-change, event-change) calls.
Purpose: Centralising VAPID signing + 410/404 pruning in one tested helper means the three triggers never re-implement crypto or expiry handling. RESEARCH "Don't Hand-Roll" mandates web-push for signing; Pitfall 1/D-11 mandate prune-on-410.
Output: `apps/api/src/lib/pushDispatcher.ts` with `dispatchPush` + `buildPushBody`, turning the Plan 05-01 RED scaffold GREEN.
</objective>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@apps/api/src/lib/listEmitter.ts
@apps/api/src/db/schema.ts
@apps/api/tests/fixtures/vapid.ts
@.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>
<feature>
<name>pushDispatcher — VAPID send + 410/404 prune</name>
<files>apps/api/src/lib/pushDispatcher.ts, apps/api/tests/lib/pushDispatcher.test.ts</files>
<read_first>
- apps/api/src/lib/listEmitter.ts (module-singleton export idiom)
- apps/api/src/db/schema.ts (pushSubscriptions columns)
- apps/api/tests/fixtures/vapid.ts (TEST_VAPID keypair)
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 1 dispatchPush; Pitfall 7 default import; ### Event payload format)
- .planning/phases/05-web-push-notifications/05-UI-SPEC.md (## Notification Content Contract — exact title/body/tag/data templates)
</read_first>
<behavior>
- buildPushBody({title, body, tag, navigate}) → JSON string containing web_push:8030, notification:{title,body,navigate}, AND top-level title/body/tag/data:{url:navigate}. Cases: a reminder payload {title:"Dentist", body:"Starts in 15 min", tag:"reminder-uid", navigate:"/calendar?date=…&event=uid"} round-trips both formats.
- dispatchPush(sub, notification, dbRowId): calls webpush.sendNotification(webPushSub, body, {TTL:300, urgency:'normal'}) where webPushSub = {endpoint, keys:{p256dh, auth}}.
- On thrown err with statusCode===410 → db.delete(pushSubscriptions) where id=dbRowId. Same for 404.
- On statusCode 500/429/network (transient) → NO delete; console.error('[pushDispatcher] …', statusCode, message); resolve (never throw to caller).
- On success (no throw) → no delete, no error.
Test with webpush mocked (vi.mock('web-push')) and db mocked; assert delete called exactly on 410/404 and not otherwise.
</behavior>
<implementation>
Default import `import webpush from 'web-push'` (Pitfall 7 — CommonJS). Do NOT call setVapidDetails at module scope (that happens in index.ts at startup, Plan 05-04) — the dispatcher only calls sendNotification. Export buildPushBody and dispatchPush. Use the eq(pushSubscriptions.id, dbRowId) delete. Log with the '[pushDispatcher]' prefix matching poller.ts convention. Catch unknown, read (err as {statusCode?:number}).statusCode.
</implementation>
<verify>
<automated>cd apps/api && pnpm exec vitest run tests/lib/pushDispatcher.test.ts</automated>
</verify>
<acceptance_criteria>
Test green: dual-format body asserted; 410 and 404 each trigger one db.delete; transient/success do not; no throw escapes dispatchPush.
</acceptance_criteria>
</feature>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| API → push service (APNs/FCM) | server signs with VAPID private key; response status is untrusted |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-03 | Cryptography misuse | VAPID signing | mitigate | Use web-push library only; never hand-roll (RESEARCH Don't Hand-Roll) |
| T-05-04 | Denial of Service | malformed push response / per-sub crash | mitigate | dispatchPush catches per-subscription; one failed send never aborts a fan-out loop |
| T-05-05 | Information Disclosure | error logs | mitigate | Log statusCode + err.message only, never the subscription keys or payload body |
</threat_model>
<verification>
- RED commit precedes GREEN; pushDispatcher.test.ts green.
- `pnpm --filter @familysync/api typecheck` passes.
</verification>
<success_criteria>
- Failing test written and committed (RED).
- dispatchPush + buildPushBody implemented; test passes (GREEN).
- 410/404 prune verified; transient/success no-prune verified.
</success_criteria>
<output>
Create `.planning/phases/05-web-push-notifications/05-02-SUMMARY.md` with RED/GREEN/REFACTOR commits.
</output>
@@ -0,0 +1,106 @@
---
phase: 05-web-push-notifications
plan: 03
type: tdd
wave: 2
depends_on: [05-01]
files_modified:
- apps/api/src/lib/pushCoalescer.ts
- apps/api/tests/lib/pushCoalescer.test.ts
autonomous: true
requirements: [NOTIF-02]
must_haves:
truths:
- "A burst of N coalesceListPush calls for the same (listId, actorId) within the window fires exactly ONE dispatch with count=N (D-01)"
- "The coalesced dispatch passes the actor's userId as excludeUserId so the actor is never notified of their own change (D-03)"
- "The coalesced notification copy is generic: title 'ActorName updated ListName', body 'N change(s)' — no item text (D-02)"
- "A new burst after the window fired starts a fresh count (timer/map entry cleared)"
artifacts:
- path: "apps/api/src/lib/pushCoalescer.ts"
provides: "coalesceListPush(listId, actorId, actorName, listName, dispatch, windowMs) — per-(list,actor) debounce"
exports: ["coalesceListPush"]
min_lines: 25
key_links:
- from: "apps/api/src/lib/pushCoalescer.ts"
to: "dispatch callback"
via: "setTimeout fires once per window with excludeUserId=actorId"
pattern: "setTimeout"
---
<objective>
TDD the list-change coalescing debounce (D-01): collapse a rapid burst of edits to one list by one member into a single push, naming the actor + list + change count (D-02/D-03 generic copy). Reorder changes are excluded upstream (Plan 05-05 does not call this for position changes).
Purpose: Lists are the chattier, lower-stakes source. Without coalescing a grocery burst would fire one push per keystroke-save. The debounce is pure in-memory logic (single process, D-12) and is the load-bearing anti-spam primitive for NOTIF-02.
Output: `apps/api/src/lib/pushCoalescer.ts` with `coalesceListPush`, turning the Plan 05-01 RED scaffold GREEN.
</objective>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@apps/api/src/lib/listEmitter.ts
@.planning/phases/05-web-push-notifications/05-RESEARCH.md
@.planning/phases/05-web-push-notifications/05-UI-SPEC.md
</context>
<feature>
<name>pushCoalescer — per-(list,actor) debounce</name>
<files>apps/api/src/lib/pushCoalescer.ts, apps/api/tests/lib/pushCoalescer.test.ts</files>
<read_first>
- apps/api/src/lib/listEmitter.ts (module-level Map singleton idiom)
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 6 pushCoalescer; D-01 window 30-60s)
- .planning/phases/05-web-push-notifications/05-UI-SPEC.md (## Notification Content Contract → List change: title "{ActorName} updated {ListName}", body "{N} change{s}", tag "list-change-{listId}", data.url "/lists/{listId}")
</read_first>
<behavior>
- coalesceListPush(listId, actorId, actorName, listName, dispatch, windowMs=45000): keyed by `${listId}:${actorId}`.
- First call: count=1, sets a setTimeout(windowMs).
- Subsequent calls within window: count++, clearTimeout + reset timer (sliding window).
- On timer fire: delete the map entry, call dispatch(payload, actorId) where payload = { title:`${actorName} updated ${listName}`, body:`${count} change${count===1?'':'s'}`, tag:`list-change-${listId}`, navigate:`/lists/${listId}` }.
- Cases (vi.useFakeTimers):
- 3 calls within window then advance time → dispatch called once, body "3 changes", excludeUserId=actorId.
- 1 call then advance → body "1 change".
- burst, advance past window, second burst, advance → dispatch called twice, each fresh count.
- two different actorIds on the same list → two independent entries → two dispatches.
</behavior>
<implementation>
Module-level `const pending = new Map<string, {count:number; timer: ReturnType<typeof setTimeout>}>()`. dispatch is injected (Plan 05-05 passes a closure over dispatchPush+subscription-fan-out) so the coalescer stays pure and testable. The `excludeUserId=actorId` argument is how D-03 self-suppression is plumbed; the caller's fan-out filters `WHERE userId != excludeUserId`. Export coalesceListPush only.
</implementation>
<verify>
<automated>cd apps/api && pnpm exec vitest run tests/lib/pushCoalescer.test.ts</automated>
</verify>
<acceptance_criteria>
Test green: N-burst → 1 dispatch count=N; "1 change" singular/plural; window reset; per-actor isolation; excludeUserId=actorId asserted.
</acceptance_criteria>
</feature>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| in-process | coalescer holds no external input; actorName/listName come from trusted DB rows |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-06 | Information Disclosure | list-change copy | mitigate | D-02 generic copy — no item text in payload; only actor name + count + list name |
| T-05-07 | Spoofing | actor self-notification | mitigate | excludeUserId=actorId threaded to the fan-out (D-03); caller filters userId != actorId |
| T-05-08 | Denial of Service | unbounded pending map | accept | Two-person household, per-(list,actor) keys bounded; entries self-delete on fire |
</threat_model>
<verification>
- RED precedes GREEN; pushCoalescer.test.ts green.
- `pnpm --filter @familysync/api typecheck` passes.
</verification>
<success_criteria>
- Failing test committed (RED).
- coalesceListPush implemented; test passes (GREEN).
- Burst→single, plural rules, window reset, per-actor isolation, self-suppression all verified.
</success_criteria>
<output>
Create `.planning/phases/05-web-push-notifications/05-03-SUMMARY.md` with RED/GREEN commits.
</output>
@@ -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>
@@ -0,0 +1,143 @@
---
phase: 05-web-push-notifications
plan: 05
type: execute
wave: 4
depends_on: [05-02, 05-03, 05-04]
files_modified:
- apps/api/src/lib/listChangeDispatcher.ts
- apps/api/src/routes/lists.ts
- apps/api/tests/routes/lists.test.ts
- apps/api/tests/lib/listChangeDispatcher.test.ts
autonomous: true
requirements: [NOTIF-02]
must_haves:
truths:
- "When member A adds/checks-off/deletes/renames an item or list, the OTHER member receives a coalesced push naming the actor + list + change count (NOTIF-02, D-01/D-02/D-03)"
- "Reorder (position) PATCHes do NOT trigger any push (D-01)"
- "The actor never receives a push for their own change — fan-out filters userId != actorId (D-03)"
- "List-change pushes are scoped: only members who can access the list (owner or list_shares) get the push — never broadcast to all members"
artifacts:
- path: "apps/api/src/lib/listChangeDispatcher.ts"
provides: "notifyListChange(listId, actorId) — resolves actor name + list name + accessible subscriptions, calls coalesceListPush"
exports: ["notifyListChange"]
min_lines: 25
key_links:
- from: "apps/api/src/routes/lists.ts"
to: "apps/api/src/lib/listChangeDispatcher.ts"
via: "notifyListChange called at each meaningful mutation (not reorder)"
pattern: "notifyListChange"
- from: "apps/api/src/lib/listChangeDispatcher.ts"
to: "apps/api/src/lib/pushCoalescer.ts"
via: "coalesceListPush with accessible-subscription dispatch + excludeUserId=actorId"
pattern: "coalesceListPush"
---
<objective>
Wire NOTIF-02: a member modifying a shared list pushes a coalesced, generic, actor-attributed notification to the OTHER member. This is the list-change vertical slice on top of the push spine (05-04) and the coalescer (05-03).
Purpose: List edits are the chattiest source; D-01 coalescing + D-02 generic copy + D-03 self-suppression turn a grocery burst into a single clean ping. The dispatch hooks the SAME mutation points as the existing publishListEvent SSE fan-out, scoped to list access (never a broadcast).
Output: listChangeDispatcher.ts (notifyListChange) called from the list/item mutation handlers; reorder excluded; tests prove burst→one push, reorder-silent, self-suppression, and access scoping.
</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/lib/pushCoalescer.ts
@apps/api/src/lib/pushDispatcher.ts
@apps/api/src/db/schema.ts
@apps/api/tests/routes/lists.test.ts
@.planning/phases/05-web-push-notifications/05-RESEARCH.md
@.planning/phases/05-web-push-notifications/05-UI-SPEC.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: listChangeDispatcher — access-scoped, self-suppressed fan-out</name>
<read_first>
- apps/api/src/routes/lists.ts (checkListAccess lines 123-153; GET access scoping lines 168-183 — owner + list_shares union; resolveUserId)
- apps/api/src/lib/pushCoalescer.ts (coalesceListPush signature)
- apps/api/src/lib/pushDispatcher.ts (dispatchPush)
- apps/api/src/db/schema.ts (lists, listShares, users, pushSubscriptions)
- .planning/phases/05-web-push-notifications/05-UI-SPEC.md (list-change copy template)
</read_first>
<behavior>
- notifyListChange(listId, actorId): resolves actorName (users.displayName via deriveDisplayName fallback) and listName (lists.name); calls coalesceListPush(listId, actorId, actorName, listName, dispatch). The injected dispatch(payload, excludeUserId) computes the accessible audience = {list owner} {list_shares.userId} MINUS excludeUserId, loads their push_subscriptions, and calls dispatchPush per subscription.
- Self-suppression: excludeUserId === actorId → actor's own subscriptions are never sent to.
- Access scoping: a member with no owner/share relationship to the list is never in the audience.
- Empty audience (no other accessible members or no subscriptions) → no dispatch, no crash.
Tests (listChangeDispatcher.test.ts, real DB per lists.test.ts harness): seed two users, a shared list, push_subscriptions for both; call notifyListChange(listId, actorA) thrice within window, advance fake timers → exactly one dispatchPush to userB (mock dispatchPush), body "3 changes", actorA never dispatched to. Seed a third unrelated user with no access → never dispatched.
</behavior>
<action>
Create apps/api/src/lib/listChangeDispatcher.ts exporting notifyListChange(listId, actorId). Reuse the owner + list_shares union access query idiom from lists.ts GET (lines 168-183) to build the audience. Mock dispatchPush in tests (vi.mock) to assert recipients without network. Use vi.useFakeTimers to drive the coalescer window. Log errors with '[listChangeDispatcher]' prefix; one failed send must not abort the loop.
</action>
<verify>
<automated>cd apps/api && pnpm exec vitest run tests/lib/listChangeDispatcher.test.ts</automated>
</verify>
<acceptance_criteria>
Test green: burst→one push count=N to the non-actor accessible member; actor suppressed; unrelated member excluded; empty audience no-op.
</acceptance_criteria>
<done>Access-scoped, self-suppressed, coalesced list-change dispatch implemented + tested.</done>
</task>
<task type="auto">
<name>Task 2: Hook notifyListChange into list/item mutations (reorder excluded)</name>
<read_first>
- apps/api/src/routes/lists.ts (every publishListEvent call site: POST /:id/items line 497, PATCH /list-items/:itemId line 640, DELETE /list-items/:itemId line 691, POST / line 287, PATCH /:id line 385, DELETE /:id line 431)
- apps/api/tests/routes/lists.test.ts (existing harness for the reorder-silent assertion)
- .planning/phases/05-web-push-notifications/05-CONTEXT.md (D-01 reorder does NOT push)
</read_first>
<action>
In apps/api/src/routes/lists.ts, after each MEANINGFUL mutation's publishListEvent call, add notifyListChange(listId, currentUserId): item added (POST /:id/items), item checked/unchecked or text edited (PATCH /list-items/:itemId — but NOT when the patch was a position change), item deleted (DELETE /list-items/:itemId), list renamed (PATCH /:id), list deleted (DELETE /:id). For POST / (list created) — a fresh empty list is not a "change to a shared list" worth pinging; do NOT notify on list create (matches D-01 spirit; the create already auto-shares silently). CRITICAL (D-01): in PATCH /list-items/:itemId, when patch.position !== undefined (reorder), do NOT call notifyListChange — only checked/text changes notify. notifyListChange is fire-and-forget (do not await in a way that blocks the response; call it and catch).
Extend apps/api/tests/routes/lists.test.ts: assert that a position-only PATCH does NOT enqueue a list-change push (spy notifyListChange or the coalescer), and that a checked PATCH does.
</action>
<verify>
<automated>cd apps/api && grep -q "notifyListChange" src/routes/lists.ts && pnpm exec vitest run tests/routes/lists.test.ts</automated>
</verify>
<acceptance_criteria>
notifyListChange called on add/check/text-edit/delete/rename/list-delete; NOT called on reorder (position) or list-create; lists.test.ts proves reorder-silent vs check-notifies; existing list tests still green.
</acceptance_criteria>
<done>List mutations push (coalesced) for the other member; reorder stays silent.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| list mutation → push audience | the audience must be derived from list access, not the request |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-14 | Information Disclosure | list-change push to a non-member | mitigate | audience = owner list_shares only (same scope as SSE / GET /api/lists); never all users |
| T-05-15 | Information Disclosure | item text in payload | mitigate | D-02 generic copy — coalescer payload carries no item text, only actor + list name + count |
| T-05-16 | Spoofing | actor notified of own change | mitigate | excludeUserId = actorId; fan-out filters userId != actorId (D-03) |
</threat_model>
<verification>
- listChangeDispatcher.test.ts + lists.test.ts green.
- `pnpm --filter @familysync/api typecheck` passes.
</verification>
<success_criteria>
- Meaningful list/item mutations push a coalesced, generic, actor-attributed notification to accessible non-actor members.
- Reorder and list-create push nothing.
- Audience strictly scoped to list access; actor suppressed.
</success_criteria>
<output>
Create `.planning/phases/05-web-push-notifications/05-05-SUMMARY.md` when done.
</output>
@@ -0,0 +1,118 @@
---
phase: 05-web-push-notifications
plan: 06
type: tdd
wave: 4
depends_on: [05-02, 05-04]
files_modified:
- apps/api/src/broker/reminderScheduler.ts
- apps/api/src/index.ts
- apps/api/tests/broker/reminderScheduler.test.ts
autonomous: true
requirements: [NOTIF-01]
must_haves:
truths:
- "Every minute the scheduler scans for SHARED (isShared=true) TIMED (allDay=false) events whose dtstartUtc is in [now+14min, now+16min] and dispatches a reminder to ALL members' subscriptions (NOTIF-01, D-05/D-06)"
- "All-day events get no reminder (D-07); non-shared events get no reminder (D-05)"
- "The same (eventUid, minuteBucket) never fires twice — in-memory dedup Set prevents the window-boundary double-fire (RESEARCH Pitfall 5 / Open Question 3)"
- "Reminder copy uses the event title: title '{EventTitle}', body 'Starts in 15 min' (D-02, depends on calendar_events.title)"
- "An empty shared-calendar set (Family calendar not yet created per D-16) produces zero sends and no crash"
artifacts:
- path: "apps/api/src/broker/reminderScheduler.ts"
provides: "startReminderScheduler() + runReminderCheck() — node-cron 1-min shared-timed-event scan + dispatch"
exports: ["startReminderScheduler", "runReminderCheck"]
min_lines: 40
key_links:
- from: "apps/api/src/broker/reminderScheduler.ts"
to: "apps/api/src/lib/pushDispatcher.ts"
via: "dispatchPush per subscription for each due shared timed event"
pattern: "dispatchPush"
- from: "apps/api/src/index.ts"
to: "startReminderScheduler"
via: "isMainModule startup guard"
pattern: "startReminderScheduler"
---
<objective>
TDD NOTIF-01: a node-cron scheduler fires once per minute, finds shared Family-calendar timed events starting in ~15 minutes, and pushes a reminder to all members. Reminders are SHARED-calendar-only by design (D-05) — native device calendars cover personal events; FamilySync owns the cross-ecosystem shared coordination gap.
Purpose: This is the reminder vertical slice. The shared+timed+window filter (enforced in the QUERY, not the copy — D-05 is the most consequential locked decision) and the dedup Set are the load-bearing correctness guarantees. The path must no-op gracefully when no shared calendar exists yet (D-16 deferral).
Output: reminderScheduler.ts (startReminderScheduler + runReminderCheck) wired into index.ts's isMainModule guard, turning the Plan 05-01 RED scaffold GREEN.
</objective>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@apps/api/src/broker/poller.ts
@apps/api/src/index.ts
@apps/api/src/db/schema.ts
@apps/api/src/lib/pushDispatcher.ts
@.planning/phases/05-web-push-notifications/05-RESEARCH.md
@.planning/phases/05-web-push-notifications/05-UI-SPEC.md
</context>
<feature>
<name>reminderScheduler — shared-timed-event 15-min reminder scan</name>
<files>apps/api/src/broker/reminderScheduler.ts, apps/api/tests/broker/reminderScheduler.test.ts</files>
<read_first>
- apps/api/src/broker/poller.ts (startBrokerPoller cron shape lines 83-89; per-item try/catch lines 69-76)
- apps/api/src/index.ts (isMainModule guard lines 107-117 — where startReminderScheduler + setVapidDetails are wired alongside startBrokerPoller/startOutboxWorker)
- apps/api/src/db/schema.ts (calendars.isShared, calendarEvents.allDay/dtstartUtc/uid/title, pushSubscriptions)
- apps/api/src/lib/pushDispatcher.ts (dispatchPush + buildPushBody)
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 5 scheduler; Pitfall 5 dedup; Pitfall 6 title column; ### Reminder dedup in-memory Set)
- .planning/phases/05-web-push-notifications/05-UI-SPEC.md (### Event reminder copy: title "{EventTitle}", body "Starts in 15 min", tag "reminder-{eventUid}", data.url "/calendar?date={YYYY-MM-DD}&event={eventUid}")
</read_first>
<behavior>
- runReminderCheck(now=new Date()): SELECT calendarEvents JOIN calendars WHERE calendars.isShared=true AND calendarEvents.allDay=false AND dtstartUtc BETWEEN now+14min AND now+16min. For each due event not already in the dedup Set (key `${uid}:${minuteBucket}` where minuteBucket = floor(now ms / 60000)): load ALL push_subscriptions (shared event → notify every member), dispatchPush a reminder payload built via buildPushBody({ title: event.title ?? event.uid, body:'Starts in 15 min', tag:`reminder-${uid}`, navigate:`/calendar?date=${yyyyMmDd(dtstartUtc)}&event=${uid}` }), then add the key to the Set.
- Cases (vi.useFakeTimers, real DB harness, dispatchPush mocked):
- shared timed event at now+15m → dispatched to both members' subscriptions.
- all-day event at now+15m → NOT dispatched (D-07).
- non-shared (isShared=false) timed event at now+15m → NOT dispatched (D-05).
- same event, two consecutive minute ticks both inside the window → dispatched ONCE (dedup).
- no shared calendars / no due events → zero dispatchPush calls, no throw (D-16 empty case).
- event.title null → falls back to uid in the title (still sends).
</behavior>
<implementation>
Module-level `const sentReminders = new Set<string>()` (single-process dedup per D-12; lost on restart — acceptable for a two-person household). startReminderScheduler() wraps runReminderCheck in schedule('* * * * *', …).catch(...) exactly like startBrokerPoller. Per-event and per-subscription try/catch with '[broker/reminderScheduler]' prefix (poller idiom) so one bad event/subscription never aborts the cycle. yyyyMmDd derives the calendar date from dtstartUtc in UTC for the deep-link. In index.ts add startReminderScheduler() inside the existing isMainModule() guard, after startOutboxWorker() and after the setVapidDetails call (Plan 05-04 added setVapidDetails; if 05-04 and 05-06 land in the same drain, ensure setVapidDetails precedes the scheduler). Export runReminderCheck for the test (inject `now`).
</implementation>
<verify>
<automated>cd apps/api && pnpm exec vitest run tests/broker/reminderScheduler.test.ts && grep -q "startReminderScheduler" src/index.ts</automated>
</verify>
<acceptance_criteria>
Test green: shared+timed in window → dispatched to all members; all-day excluded; non-shared excluded; dedup single-fire; empty set no-op; title fallback. index.ts starts the scheduler in the isMainModule guard.
</acceptance_criteria>
</feature>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| reminder query → push audience | reminder eligibility is decided by the SQL WHERE, not by any request |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-17 | Information Disclosure | reminder leaking a personal-calendar event | mitigate | D-05 enforced in the QUERY: WHERE calendars.isShared = true — personal events are never selected, not merely hidden in copy |
| T-05-18 | Denial of Service | duplicate reminder storm at window boundary | mitigate | in-memory dedup Set keyed (uid, minuteBucket); per-event try/catch isolates failures |
| T-05-19 | Denial of Service | one bad subscription aborting the cycle | mitigate | per-subscription try/catch; dispatchPush already swallows + prunes 410/404 |
</threat_model>
<verification>
- RED precedes GREEN; reminderScheduler.test.ts green.
- index.ts wires startReminderScheduler in the isMainModule guard.
- `pnpm --filter @familysync/api typecheck` passes.
</verification>
<success_criteria>
- Failing test committed (RED).
- runReminderCheck + startReminderScheduler implemented; test passes (GREEN).
- D-05 shared-only (query-enforced), D-07 all-day-excluded, dedup, empty-set no-op, title fallback all verified.
</success_criteria>
<output>
Create `.planning/phases/05-web-push-notifications/05-06-SUMMARY.md` with RED/GREEN commits.
</output>
@@ -0,0 +1,123 @@
---
phase: 05-web-push-notifications
plan: 07
type: tdd
wave: 5
depends_on: [05-02, 05-04, 05-06]
files_modified:
- apps/api/src/lib/eventChangeDispatcher.ts
- apps/api/src/broker/sync.ts
- apps/api/src/broker/poller.ts
- apps/api/src/broker/outboxWorker.ts
- apps/api/tests/lib/eventChangeDispatcher.test.ts
- apps/api/tests/broker/sync.test.ts
autonomous: true
requirements: [NOTIF-03]
must_haves:
truths:
- "When the other member adds or meaningfully changes an event, the first member receives a push with the event title + action (NOTIF-03, D-02/D-03)"
- "Meaningful = new event, deletion, or change to time/date/title/location; description-only edits are silent (D-04)"
- "The actor (the member whose sync detected/wrote the change) is never notified of their own change (D-03)"
- "calendar_events.title is populated from VEVENT SUMMARY during sync so reminder + change copy show a readable title (NOTIF-01 dependency closed)"
- "syncCalendar exposes detected changes via a callback consumed by both the poller (external changes) and the outbox resync (this-member writes)"
artifacts:
- path: "apps/api/src/lib/eventChangeDispatcher.ts"
provides: "dispatchEventChange(change, actorUserId) — builds copy, fans out to non-actor members"
exports: ["dispatchEventChange", "isMeaningfulChange"]
min_lines: 35
- path: "apps/api/src/broker/sync.ts"
provides: "syncCalendar populates title + emits added/updated/deleted change records via onChanges callback"
contains: "title"
key_links:
- from: "apps/api/src/broker/sync.ts"
to: "apps/api/src/lib/eventChangeDispatcher.ts"
via: "onChanges callback dispatches detected event changes"
pattern: "onChanges"
- from: "apps/api/src/lib/eventChangeDispatcher.ts"
to: "apps/api/src/lib/pushDispatcher.ts"
via: "dispatchPush to each non-actor member subscription"
pattern: "dispatchPush"
---
<objective>
TDD NOTIF-03: when the other member adds or meaningfully changes a calendar event, push a specific, actor-attributed notification (title + action). Detect changes inside syncCalendar by diffing old vs new rows; surface them via an onChanges callback consumed by the poller (external changes) and the outbox resync (this-member writes). Populate calendar_events.title from VEVENT SUMMARY in the same pass (closes the NOTIF-01 title dependency).
Purpose: syncCalendar currently does a silent upsert with no change signal (RESEARCH Open Question 1). Adding a diff-and-callback is the chosen hook strategy: meaningful-field filtering (D-04) and actor self-suppression (D-03) are the correctness guarantees. Event copy is SPECIFIC (D-02) unlike list copy.
Output: eventChangeDispatcher.ts (dispatchEventChange + isMeaningfulChange); syncCalendar diff + title population + onChanges; poller + outboxWorker pass the dispatch callback. Turns the Plan 05-01 RED scaffold GREEN.
</objective>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@apps/api/src/broker/sync.ts
@apps/api/src/broker/poller.ts
@apps/api/src/broker/outboxWorker.ts
@apps/api/src/db/schema.ts
@apps/api/src/lib/pushDispatcher.ts
@.planning/phases/05-web-push-notifications/05-RESEARCH.md
@.planning/phases/05-web-push-notifications/05-UI-SPEC.md
</context>
<feature>
<name>eventChangeDispatcher + syncCalendar change detection</name>
<files>apps/api/src/lib/eventChangeDispatcher.ts, apps/api/src/broker/sync.ts, apps/api/src/broker/poller.ts, apps/api/src/broker/outboxWorker.ts, apps/api/tests/lib/eventChangeDispatcher.test.ts, apps/api/tests/broker/sync.test.ts</files>
<read_first>
- apps/api/src/broker/sync.ts (full upsert + prune flow; ICAL parse lines 82-112; the onDuplicateKeyUpdate at lines 114-138; prune lines 148-154)
- apps/api/src/broker/poller.ts (syncCalendar call line 67)
- apps/api/src/broker/outboxWorker.ts (triggerTargetedResync → syncCalendar line 171)
- apps/api/src/db/schema.ts (calendarEvents fields incl. new title; pushSubscriptions; users)
- apps/api/src/lib/pushDispatcher.ts (dispatchPush/buildPushBody)
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (Open Question 1 hook strategy; D-04 meaningful fields)
- .planning/phases/05-web-push-notifications/05-UI-SPEC.md (### Event change copy — new/updated/deleted title+body templates, time format rule)
</read_first>
<behavior>
- isMeaningfulChange(oldRow, newRow): true when dtstartUtc, dtstartDate, allDay, title, or LOCATION changed; false when only the description (or only etag/updatedAt) changed. Extract title + location via ICAL SUMMARY/LOCATION from rawVevent for comparison. New event (no oldRow) → meaningful (added). Pruned uid (oldRow, no newRow) → meaningful (deleted).
- syncCalendar gains an optional onChanges?: (changes: EventChange[]) => void param. Before each upsert, SELECT the existing row for (calendarId, uid); after, classify added/updated/deleted; populate the new `title` column from the parsed SUMMARY on every upsert; collect EventChange = { kind:'added'|'updated'|'deleted', uid, title, dtstartUtc, allDay }; at the end call onChanges(changes) when provided and non-empty.
- dispatchEventChange(change, actorUserId): skip all-day-only reminder paths (this is change-notify, all-day events DO get change notifications — only reminders exclude all-day). Build copy per UI-SPEC: added → title "{ActorName} added an event", body "{EventTitle} · {when}"; updated → "{ActorName} updated an event"; deleted → "{ActorName} removed an event", body "{EventTitle}". navigate /calendar?date=…&event=uid (or /calendar for delete). Fan out to ALL members EXCEPT actorUserId, loading their push_subscriptions, dispatchPush each. {when} formatted from dtstartUtc in member-local tz per UI-SPEC time format rule.
- poller passes onChanges = (changes) => changes.forEach(ch => dispatchEventChange(ch, cred.userId)) — actor = the member whose credential synced (external write arriving). outboxWorker's triggerTargetedResync passes onChanges with actor = the userId who wrote (so the OTHER member is notified).
Cases: new event via sync → push to non-actor; time change → push; title change → push; location change → push; description-only change → NO push (D-04); actor excluded; all-day new event → push (change-notify allows all-day).
</behavior>
<implementation>
Define EventChange + EventChangeKind in eventChangeDispatcher.ts (or a small shared type). For the old-vs-new diff in syncCalendar, do a per-uid SELECT before upsert (the loop already runs per object; one extra indexed lookup on (calendarId, uid) is cheap). Parse SUMMARY/LOCATION with ICAL.Component the same way dtstart is parsed. Mock dispatchPush in eventChangeDispatcher.test.ts; test syncCalendar diff classification in sync.test.ts with a mocked db + onChanges spy (or real DB harness). Log with '[eventChangeDispatcher]'. Keep dispatch fire-and-forget; sync correctness must never depend on push success.
</implementation>
<verify>
<automated>cd apps/api && pnpm exec vitest run tests/lib/eventChangeDispatcher.test.ts tests/broker/sync.test.ts && grep -q "onChanges" src/broker/sync.ts && grep -q "title" src/broker/sync.ts</automated>
</verify>
<acceptance_criteria>
Tests green: added/time/title/location → push to non-actor; description-only → silent (D-04); actor excluded (D-03); deleted → "removed" copy; title column populated; onChanges consumed by poller + outbox.
</acceptance_criteria>
</feature>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| sync diff → push audience | change eligibility + actor identity come from the sync context, not a request |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-20 | Spoofing | actor notified of own event change | mitigate | dispatchEventChange excludes actorUserId; poller/outbox supply the correct actor |
| T-05-21 | Denial of Service | description-edit spam | mitigate | isMeaningfulChange filters description-only edits (D-04) — no push |
| T-05-22 | Information Disclosure | event change in notification code calling Fastmail | mitigate | D-13: notification code reads MariaDB cache only; no tsdav in eventChangeDispatcher |
</threat_model>
<verification>
- RED precedes GREEN; eventChangeDispatcher.test.ts + sync.test.ts green.
- poller.ts + outboxWorker.ts pass onChanges to syncCalendar.
- `pnpm --filter @familysync/api typecheck` passes; existing sync/poller/outbox tests still green.
</verification>
<success_criteria>
- Failing tests committed (RED).
- eventChangeDispatcher + syncCalendar diff/title/onChanges implemented (GREEN).
- Meaningful-only (D-04), actor-suppressed (D-03), specific copy (D-02), title populated — all verified.
</success_criteria>
<output>
Create `.planning/phases/05-web-push-notifications/05-07-SUMMARY.md` with RED/GREEN commits.
</output>
@@ -0,0 +1,171 @@
---
phase: 05-web-push-notifications
plan: 08
type: execute
wave: 6
depends_on: [05-04]
files_modified:
- apps/pwa/src/hooks/usePushSubscription.ts
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/components/PermissionDeniedBanner.tsx
- apps/pwa/src/components/AppNav.tsx
- apps/pwa/src/App.tsx
autonomous: false
requirements: [NOTIF-01, NOTIF-02, NOTIF-03]
must_haves:
truths:
- "A single master on/off toggle in a Settings sheet (opened from the avatar) enables/disables all FamilySync push notifications (D-09)"
- "On app open, if OS permission is still granted but the push subscription is missing/expired, the app silently re-subscribes — no user action (D-10)"
- "If the OS permission itself was revoked (denied) and notifications were previously enabled, a persistent permission-denied banner appears with OS-specific re-enable instructions (D-10)"
- "The avatar in AppNav (phone + desktop) is a real button opening the Settings sheet (a11y: aria-label, 44px target)"
artifacts:
- path: "apps/pwa/src/components/SettingsSheet.tsx"
provides: "Settings bottom sheet with the master NotificationToggle (D-09)"
exports: ["SettingsSheet"]
- path: "apps/pwa/src/components/PermissionDeniedBanner.tsx"
provides: "persistent OS-revoked banner with re-enable instructions (D-10)"
exports: ["PermissionDeniedBanner"]
key_links:
- from: "apps/pwa/src/hooks/usePushSubscription.ts"
to: "pushManager.getSubscription"
via: "mount health-check → silent re-subscribe when permission granted but no subscription"
pattern: "getSubscription"
- from: "apps/pwa/src/components/AppNav.tsx"
to: "apps/pwa/src/components/SettingsSheet.tsx"
via: "avatar button onClick opens settings"
pattern: "onOpenSettings"
---
<objective>
The opt-out + reliability surface: a single master notifications toggle (D-09), silent dead-subscription recovery on app open (D-10), and a permission-denied banner for the OS-revoked case (D-10). Completes the user-facing half of the mandatory iOS health-check (success criterion 4) and the lone settings control.
Purpose: D-10's silent re-subscribe is what keeps subscriptions alive across inactivity without bothering the non-technical member; the banner only surfaces when the OS itself revoked permission (the one case the app cannot silently fix). D-09's single toggle is the entire settings surface for v1.
Output: usePushSubscription gains the mount health-check + permission state; SettingsSheet (avatar-triggered) with the master toggle; PermissionDeniedBanner; AppNav avatar promoted to a button.
</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/pwa/src/hooks/usePushSubscription.ts
@apps/pwa/src/components/InstallPrompt.tsx
@apps/pwa/src/components/AppNav.tsx
@apps/pwa/src/components/CreateListSheet.tsx
@apps/pwa/src/App.tsx
@.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: usePushSubscription health-check + permission state (D-10)</name>
<read_first>
- apps/pwa/src/hooks/usePushSubscription.ts (the subscribe/unsubscribe built in Plan 05-04)
- apps/pwa/src/components/InstallPrompt.tsx (useAndroidInstallPrompt useEffect pattern lines 76-105; readDismissed/persistDismissed lines 284-297)
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 7 usePushSubscription; D-10 silent re-subscribe)
- .planning/phases/05-web-push-notifications/05-CONTEXT.md (D-10)
</read_first>
<action>
Extend apps/pwa/src/hooks/usePushSubscription.ts: add a mount useEffect that runs the D-10 health-check — if Notification.permission==='granted', await navigator.serviceWorker.ready, getSubscription(); if none exists AND localStorage.notificationsEnabled !== '0', silently re-subscribe (call the existing subscribe path WITHOUT a tap gesture — allowed because permission is already granted, no OS dialog). Expose `permission` (current Notification.permission) and an `isSubscribed` flag, and a `setEnabled(on:boolean)` that on→off calls unsubscribe()+localStorage.notificationsEnabled='0', and off→on (permission granted) silently subscribes / (permission default) requires the tap-handler subscribe path / (permission denied) is a no-op (caller shows the denied hint). Do NOT call the tap-gated subscribe inside the health-check useEffect — only the already-granted silent path.
</action>
<verify>
<automated>cd apps/pwa && grep -q "getSubscription" src/hooks/usePushSubscription.ts && grep -q "permission" src/hooks/usePushSubscription.ts && pnpm build 2>&1 | tail -2</automated>
</verify>
<acceptance_criteria>
Hook exposes permission + isSubscribed + setEnabled; mount health-check silently re-subscribes only when permission is granted and a subscription is missing and notifications weren't explicitly disabled (D-10). No tap-gated subscribe in the effect.
</acceptance_criteria>
<done>Silent dead-subscription recovery implemented (D-10 reliability half).</done>
</task>
<task type="auto">
<name>Task 2: SettingsSheet (master toggle) + AppNav avatar button</name>
<read_first>
- apps/pwa/src/components/CreateListSheet.tsx (sheet open/close + Escape pattern lines 28-60; z-index 300/301)
- apps/pwa/src/components/AppNav.tsx (PhoneNav avatar lines 73-102; DesktopNav "Calendars" section-label idiom lines 173-184)
- apps/pwa/src/components/InstallPrompt.tsx (44px button pattern; X close button)
- .planning/phases/05-web-push-notifications/05-PATTERNS.md (### SettingsSheet.tsx; ### AppNav.tsx promote avatar to button)
- .planning/phases/05-web-push-notifications/05-UI-SPEC.md (### Surface 2 — full layout, copy, toggle behavior + initial state, toggle states table)
</read_first>
<action>
Create apps/pwa/src/components/SettingsSheet.tsx: bottom sheet (role="dialog", aria-modal, aria-label="Settings", borderRadius 12px 12px 0 0, padding var(--space-6), zIndex 301, backdrop 300 click-to-close, Escape closes — copy the CreateListSheet lifecycle). Contents per UI-SPEC Surface 2: heading row "Settings" + X (aria-label "Close settings"); section label "Notifications" (uppercase, muted, letter-spacing 0.06em); a toggle row — Bell icon + column ("FamilySync Notifications" / "Reminders, event changes, list updates") + an inline role="switch" toggle (aria-checked, aria-label, 44px target; on=track var(--color-member-0), off=track var(--color-border), disabled+opacity 0.5 when permission==='denied'). Wire the toggle to usePushSubscription setEnabled + permission. Initial state: on when localStorage.notificationsEnabled!=='0' AND permission==='granted' AND isSubscribed; off otherwise. Toggling on while permission==='default' must call the tap-gated subscribe inside the switch's onClick (no await before pushManager.subscribe). When permission==='denied' show the inline permission-denied hint (AlertCircle + "Notifications are blocked…" + "How to enable" link) and the toggle stays disabled. Use a Loader2 spinner while a subscribe is in flight. All copy verbatim from UI-SPEC Copywriting Contract.
Modify apps/pwa/src/components/AppNav.tsx: promote the PhoneNav avatar div (and the DesktopNav equivalent) to a <button onClick={onOpenSettings} aria-label={`${displayName} — open settings`}> with a 44px target wrapping the 32px color circle (per PATTERNS AppNav section). Thread an onOpenSettings prop.
</action>
<verify>
<automated>cd apps/pwa && grep -q 'role="switch"' src/components/SettingsSheet.tsx && grep -q "FamilySync Notifications" src/components/SettingsSheet.tsx && grep -q "onOpenSettings" src/components/AppNav.tsx && pnpm build 2>&1 | tail -2</automated>
</verify>
<acceptance_criteria>
SettingsSheet matches UI-SPEC Surface 2 (copy, toggle states, a11y); avatar is a button opening it; toggle on/off drives setEnabled; permission-denied disables the toggle and shows the hint.
</acceptance_criteria>
<done>Master notifications toggle (D-09) live; avatar opens settings.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: PermissionDeniedBanner + App mount + desktop verification</name>
<read_first>
- apps/pwa/src/components/InstallPrompt.tsx (banner layout lines 321-406; WalkthroughSheet for the iOS re-enable instructions sheet)
- apps/pwa/src/App.tsx (mount tree)
- .planning/phases/05-web-push-notifications/05-UI-SPEC.md (### Surface 3 — banner copy, when-shown rule, "How to enable" iOS/Android instruction steps)
- .claude/skills/playwright-cli/SKILL.md
</read_first>
<what-built>
apps/pwa/src/components/PermissionDeniedBanner.tsx: persistent role="alert" banner (InstallPrompt banner layout) shown ONLY when Notification.permission==='denied' AND localStorage.notificationsEnabled was previously '1' (D-10 — silent re-subscribe covers expired subscriptions; this banner is the OS-revoked case only). AlertCircle (var(--color-destructive)) + "Notifications blocked" / "Re-enable in your browser settings." + "How to enable" inline link. No dismiss button. "How to enable" opens an OS-specific instruction sheet (iOS 4-step / Android 4-step, copy verbatim from UI-SPEC Copywriting Contract).
App.tsx mounts PermissionDeniedBanner (below AppNav, above content) and SettingsSheet; AppNav receives onOpenSettings to drive the sheet's open state.
</what-built>
<action>
Implement PermissionDeniedBanner + mount it and SettingsSheet in App.tsx, wiring AppNav's onOpenSettings to the SettingsSheet open state. Then verify on desktop Chromium with playwright-cli: with notifications enabled then permission revoked, confirm the banner appears and "How to enable" opens the instruction sheet; with permission granted, confirm no banner. Capture playwright-cli evidence.
</action>
<how-to-verify>
1. Serve API (dev-bypass) + PWA.
2. playwright-cli: grant then revoke Notifications; confirm the banner renders with the exact copy and "How to enable" opens the sheet.
3. Confirm the banner is absent when permission is granted or was never enabled.
4. iOS-standalone banner behavior + real push delivery remain on the device-only Phase 5 human gate.
</how-to-verify>
<resume-signal>Type "approved" or describe what failed</resume-signal>
<verify>
<automated>cd apps/pwa && grep -q 'role="alert"' src/components/PermissionDeniedBanner.tsx && grep -q "Notifications blocked" src/components/PermissionDeniedBanner.tsx && grep -q "PermissionDeniedBanner" src/App.tsx && grep -q "SettingsSheet" src/App.tsx && pnpm build 2>&1 | tail -2</automated>
</verify>
<acceptance_criteria>
Banner shows only in the OS-revoked-after-enabled case with verbatim UI-SPEC copy + working "How to enable" sheet; absent otherwise; SettingsSheet + banner mounted in App; desktop playwright-cli verified.
</acceptance_criteria>
<done>Permission-denied banner + settings mounted; opt-out + reliability surface complete on desktop.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client permission state → UI | Notification.permission + localStorage drive which surface shows; no server trust involved |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-23 | Tampering | silent re-subscribe without permission | mitigate | health-check only re-subscribes when Notification.permission==='granted'; never forces an OS dialog |
| T-05-24 | Information Disclosure | XSS via copy | mitigate | all copy is plain-text JSX children (no dangerouslySetInnerHTML), matching existing InstallPrompt convention |
| T-05-25 | Repudiation | toggle off leaves stale server subscription | mitigate | setEnabled off calls DELETE /api/push/subscription (Plan 05-04) so the server prunes the row |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa build` green.
- Desktop playwright-cli: banner shows on revoke, hidden when granted; settings toggle drives subscribe/unsubscribe.
</verification>
<success_criteria>
- Single master toggle (D-09) in an avatar-opened Settings sheet.
- Silent re-subscribe on app open when permission still granted (D-10).
- Permission-denied banner only in the OS-revoked-after-enabled case (D-10), with re-enable instructions.
</success_criteria>
<output>
Create `.planning/phases/05-web-push-notifications/05-08-SUMMARY.md` when done.
</output>
@@ -1,8 +1,8 @@
---
phase: 5
slug: web-push-notifications
status: draft
nyquist_compliant: false
status: planned
nyquist_compliant: true
wave_0_complete: false
created: 2026-06-09
---
@@ -40,17 +40,36 @@ created: 2026-06-09
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| TBD | — | | NOTIF-01/02/03 | — | — | unit | `pnpm --filter @familysync/api test` | ❌ W0 | ⬜ pending |
| 05-01-T1 | 05-01 | 1 | NOTIF-01/02/03 | T-05-SC | package legitimacy gate before install | check | `node -e "...deps present..."` | | ⬜ pending |
| 05-01-T2 | 05-01 | 1 | NOTIF-01 | T-05-01 | VAPID private key never committed | check | `grep VAPID_* .env.example` | ✅ | ⬜ pending |
| 05-01-T3 | 05-01 | 1 | NOTIF-01/02/03 | T-05-02 | safe generate+migrate (no db:push) | check | `grep pushSubscriptions schema.ts; ls migrations/0003_*.sql` | ✅ | ⬜ pending |
| 05-01-T4 | 05-01 | 1 | NOTIF-01/02/03 | — | RED scaffolds + setup truncation | unit | `vitest run tests/lib/* tests/broker/* tests/routes/push.test.ts` (RED) | ✅ | ⬜ pending |
| 05-02-F1 | 05-02 | 2 | NOTIF-01/02/03 | T-05-03/05 | VAPID send + 410/404 prune | unit | `vitest run tests/lib/pushDispatcher.test.ts` | ❌ W0 | ⬜ pending |
| 05-03-F1 | 05-03 | 2 | NOTIF-02 | T-05-06/07 | coalesce burst, suppress actor | unit | `vitest run tests/lib/pushCoalescer.test.ts` | ❌ W0 | ⬜ pending |
| 05-04-T1 | 05-04 | 3 | NOTIF-01/02/03 | T-05-09/10/13 | user-scoped subscribe/unsubscribe | integration | `vitest run tests/routes/push.test.ts` | ❌ W0 | ⬜ pending |
| 05-04-T2 | 05-04 | 3 | NOTIF-01/02/03 | T-05-11/12 | SW waitUntil + denylist | build/grep | `pnpm --filter @familysync/pwa build` + sw.ts greps | ✅ | ⬜ pending |
| 05-04-T3 | 05-04 | 3 | NOTIF-01/02/03 | T-05-09 | tap-gated subscribe (desktop) | human-verify (playwright-cli) | playwright-cli subscribe round-trip | ✅ | ⬜ pending |
| 05-05-T1 | 05-05 | 4 | NOTIF-02 | T-05-14/15/16 | access-scoped, self-suppressed | unit | `vitest run tests/lib/listChangeDispatcher.test.ts` | ❌ W0 | ⬜ pending |
| 05-05-T2 | 05-05 | 4 | NOTIF-02 | T-05-15 | reorder-silent, check-notifies | unit | `vitest run tests/routes/lists.test.ts` | partial | ⬜ pending |
| 05-06-F1 | 05-06 | 4 | NOTIF-01 | T-05-17/18/19 | shared-only (query), all-day excl, dedup | unit | `vitest run tests/broker/reminderScheduler.test.ts` | ❌ W0 | ⬜ pending |
| 05-07-F1 | 05-07 | 5 | NOTIF-03 | T-05-20/21/22 | meaningful-only, actor-suppressed | unit | `vitest run tests/lib/eventChangeDispatcher.test.ts tests/broker/sync.test.ts` | ❌ W0 | ⬜ pending |
| 05-08-T1 | 05-08 | 6 | NOTIF-01/02/03 | T-05-23 | silent re-subscribe (granted only) | build/grep | `pnpm --filter @familysync/pwa build` + hook greps | ✅ | ⬜ pending |
| 05-08-T2 | 05-08 | 6 | NOTIF-01/02/03 | T-05-25 | master toggle drives DELETE | build/grep | SettingsSheet greps + build | ✅ | ⬜ pending |
| 05-08-T3 | 05-08 | 6 | NOTIF-01/02/03 | T-05-24 | denied-banner OS-revoked-only (desktop) | human-verify (playwright-cli) | playwright-cli banner show/hide | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
*Nyquist: no run of 3+ consecutive tasks lacks an automated verify. Every task carries an `<automated>` block; UI-only tasks pair a build/grep gate with a desktop playwright-cli human-verify (iOS-standalone is the only genuinely device-only check, deferred to the phase gate).*
---
## Wave 0 Requirements
- [ ] `web-push` + `@types/web-push` installed in `apps/api` before any push-dispatch task
- [ ] Test stubs for reminder scheduler, push-dispatch (VAPID sign + send), and subscription pruning (410/404)
- [ ] VAPID test keypair fixture for unit tests (no network)
- [ ] `web-push` + `@types/web-push` installed in `apps/api` before any push-dispatch task (Plan 05-01 Task 1)
- [ ] `workbox-precaching` / `workbox-core` / `workbox-routing` installed in `apps/pwa` before the SW migration (Plan 05-01 Task 1)
- [ ] Test stubs: reminderScheduler, pushDispatcher (410/404 prune), pushCoalescer, eventChangeDispatcher, push route (Plan 05-01 Task 4)
- [ ] VAPID test keypair fixture for unit tests — no network (`apps/api/tests/fixtures/vapid.ts`, Plan 05-01 Task 4)
- [ ] `push_subscriptions` added to `test/setup.ts` afterEach truncation (Plan 05-01 Task 4)
*Existing vitest infrastructure covers the rest.*
@@ -62,18 +81,19 @@ created: 2026-06-09
|----------|-------------|------------|-------------------|
| iOS standalone-PWA push delivery + visible notification | NOTIF-01/02/03 | iOS Safari standalone push cannot be driven by playwright-cli (device-only) | Install PWA on iPhone (Home Screen), grant permission, trigger event reminder + list change, confirm visible notification |
| iOS subscription survives inactivity (health-check) | NOTIF (success criterion 4) | Requires real APNs + elapsed time on device | Leave PWA idle, fire push after extended inactivity, confirm still delivered |
| iOS permission-denied banner + re-enable flow | NOTIF (D-10) | iOS standalone Settings deep-link is device-only | Revoke notifications in iOS Settings, confirm banner + instruction sheet |
*Desktop/Chromium push flows (permission prompt, subscribe, dispatch, notificationclick deep-link) ARE automatable via playwright-cli.*
*Desktop/Chromium push flows (permission prompt, subscribe, dispatch, notificationclick deep-link, settings toggle, denied banner) ARE automatable via playwright-cli — Plans 05-04 Task 3 and 05-08 Task 3.*
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 30s
- [ ] `nyquist_compliant: true` set in frontmatter
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 30s
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
**Approval:** planned