From 1ecca03f53e4004c16f9b62f32409a86f89b6518 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 18:39:05 -0400 Subject: [PATCH] docs(05): create phase 5 web-push plan (8 plans, 6 waves) --- .planning/ROADMAP.md | 32 ++- .../05-web-push-notifications/05-01-PLAN.md | 234 ++++++++++++++++++ .../05-web-push-notifications/05-02-PLAN.md | 108 ++++++++ .../05-web-push-notifications/05-03-PLAN.md | 106 ++++++++ .../05-web-push-notifications/05-04-PLAN.md | 206 +++++++++++++++ .../05-web-push-notifications/05-05-PLAN.md | 143 +++++++++++ .../05-web-push-notifications/05-06-PLAN.md | 118 +++++++++ .../05-web-push-notifications/05-07-PLAN.md | 123 +++++++++ .../05-web-push-notifications/05-08-PLAN.md | 171 +++++++++++++ .../05-VALIDATION.md | 48 ++-- 10 files changed, 1273 insertions(+), 16 deletions(-) create mode 100644 .planning/phases/05-web-push-notifications/05-01-PLAN.md create mode 100644 .planning/phases/05-web-push-notifications/05-02-PLAN.md create mode 100644 .planning/phases/05-web-push-notifications/05-03-PLAN.md create mode 100644 .planning/phases/05-web-push-notifications/05-04-PLAN.md create mode 100644 .planning/phases/05-web-push-notifications/05-05-PLAN.md create mode 100644 .planning/phases/05-web-push-notifications/05-06-PLAN.md create mode 100644 .planning/phases/05-web-push-notifications/05-07-PLAN.md create mode 100644 .planning/phases/05-web-push-notifications/05-08-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 3457260..9364c95 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 diff --git a/.planning/phases/05-web-push-notifications/05-01-PLAN.md b/.planning/phases/05-web-push-notifications/05-01-PLAN.md new file mode 100644 index 0000000..84a70fb --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-01-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: [BLOCKING] Package legitimacy gate + install push dependencies + + - .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) + + + 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). + + + 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. + + + Confirm the five package names + registry links against npmjs.com if desired. Approve to proceed with install. + + Type "approved" to install, or name any package to reject + + 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')" + + + web-push + @types/web-push in apps/api package.json; workbox-precaching/core/routing in apps/pwa devDependencies; lockfile updated. + + All five packages installed in the correct workspace and dependency type. + + + + Task 2: Generate VAPID keypair + record in env + + - .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) + + + 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). + + + 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. + + + Confirm apps/api/.env contains VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT with real values; confirm .env.example contains only placeholders. + + Type "done" once keys are in .env + + 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" + + + .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. + + VAPID keypair generated; private key in .env only; .env.example documents the three vars. + + + + Task 3: [BLOCKING] Schema — push_subscriptions table + calendar_events.title, generate+migrate + + - 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) + + + 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. + + + 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 + + + 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). + + push_subscriptions + calendar_events.title live in the dev DB; migration committed. + + + + Task 4: Wave-0 RED test scaffolds + VAPID fixture + setup truncation + + - 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) + + + 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). + + + 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 + + + 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). + + RED scaffolds in place; later plans turn them GREEN. + + + + + +## 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) | + + + +- `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. + + + +- 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. + + + +Create `.planning/phases/05-web-push-notifications/05-01-SUMMARY.md` when done. + diff --git a/.planning/phases/05-web-push-notifications/05-02-PLAN.md b/.planning/phases/05-web-push-notifications/05-02-PLAN.md new file mode 100644 index 0000000..67ab40f --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-02-PLAN.md @@ -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\\)" +--- + + +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. + + + +@.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 + + + + pushDispatcher — VAPID send + 410/404 prune + apps/api/src/lib/pushDispatcher.ts, apps/api/tests/lib/pushDispatcher.test.ts + + - 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) + + + - 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. + + + 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. + + + cd apps/api && pnpm exec vitest run tests/lib/pushDispatcher.test.ts + + + Test green: dual-format body asserted; 410 and 404 each trigger one db.delete; transient/success do not; no throw escapes dispatchPush. + + + + +## 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 | + + + +- RED commit precedes GREEN; pushDispatcher.test.ts green. +- `pnpm --filter @familysync/api typecheck` passes. + + + +- Failing test written and committed (RED). +- dispatchPush + buildPushBody implemented; test passes (GREEN). +- 410/404 prune verified; transient/success no-prune verified. + + + +Create `.planning/phases/05-web-push-notifications/05-02-SUMMARY.md` with RED/GREEN/REFACTOR commits. + diff --git a/.planning/phases/05-web-push-notifications/05-03-PLAN.md b/.planning/phases/05-web-push-notifications/05-03-PLAN.md new file mode 100644 index 0000000..d6c455f --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-03-PLAN.md @@ -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" +--- + + +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. + + + +@.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 + + + + pushCoalescer — per-(list,actor) debounce + apps/api/src/lib/pushCoalescer.ts, apps/api/tests/lib/pushCoalescer.test.ts + + - 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}") + + + - 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. + + + Module-level `const pending = new Map}>()`. 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. + + + cd apps/api && pnpm exec vitest run tests/lib/pushCoalescer.test.ts + + + Test green: N-burst → 1 dispatch count=N; "1 change" singular/plural; window reset; per-actor isolation; excludeUserId=actorId asserted. + + + + +## 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 | + + + +- RED precedes GREEN; pushCoalescer.test.ts green. +- `pnpm --filter @familysync/api typecheck` passes. + + + +- Failing test committed (RED). +- coalesceListPush implemented; test passes (GREEN). +- Burst→single, plural rules, window reset, per-actor isolation, self-suppression all verified. + + + +Create `.planning/phases/05-web-push-notifications/05-03-SUMMARY.md` with RED/GREEN commits. + diff --git a/.planning/phases/05-web-push-notifications/05-04-PLAN.md b/.planning/phases/05-web-push-notifications/05-04-PLAN.md new file mode 100644 index 0000000..5cb8d59 --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-04-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@apps/api/src/routes/lists.ts +@apps/api/src/index.ts +@apps/api/src/lib/pushDispatcher.ts +@apps/pwa/vite.config.ts +@apps/pwa/src/components/InstallPrompt.tsx +@apps/pwa/src/App.tsx +@.planning/phases/05-web-push-notifications/05-RESEARCH.md +@.planning/phases/05-web-push-notifications/05-PATTERNS.md +@.planning/phases/05-web-push-notifications/05-UI-SPEC.md + + + + + + Task 1: Push subscription API + startup VAPID wiring + + - apps/api/src/routes/lists.ts (lines 20-34 imports; resolveUserId lines 57-69; createListSchema/zValidator; POST/DELETE handler shapes) + - apps/api/src/index.ts (route mounts lines 62-66; isMainModule guard lines 107-117) + - apps/api/tests/routes/push.test.ts (RED scaffold from Plan 05-01) + - .planning/phases/05-web-push-notifications/05-PATTERNS.md (### apps/api/src/routes/push.ts — full handler pattern; Mount pattern) + - .planning/phases/05-web-push-notifications/05-RESEARCH.md (### Security Domain — V2/V4/V5) + + + Create apps/api/src/routes/push.ts exporting pushRouter = new Hono(). Copy resolveUserId verbatim from lists.ts (per project convention — duplicated per router, not extracted). Routes: + GET /vapid-public-key returns c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' }) — the value is the non-secret public key; it sits under the /api OIDC guard (PWA fetches it post-login, acceptable for v1). + POST /subscription with zValidator('json', subscribeSchema) where subscribeSchema = z.object({ endpoint: z.string().url().max(2048), keys: z.object({ p256dh: z.string().min(1).max(512), auth: z.string().min(1).max(256) }) }). Resolve userId (401 if null). Insert into pushSubscriptions { userId, endpoint, p256dh: keys.p256dh, auth: keys.auth } with .onDuplicateKeyUpdate({ set: { userId, p256dh, auth } }) (endpoint is the unique key — re-subscribe from the same device updates ownership). Return 201. + DELETE /subscription: resolve userId (401 if null), db.delete(pushSubscriptions) WHERE eq(pushSubscriptions.userId, userId) — scoped to the caller only (V4: a member only deletes their OWN subscriptions). Return { ok: true }. + In index.ts: import { pushRouter }; add app.route('/api/push', pushRouter) alongside the other /api mounts. Inside the isMainModule() guard, BEFORE serve(), call webpush.setVapidDetails(process.env.VAPID_SUBJECT, process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY) with default import webpush from 'web-push'. This is the only setVapidDetails call site (the dispatcher never calls it). + + + cd apps/api && pnpm exec vitest run tests/routes/push.test.ts && grep -q "setVapidDetails" src/index.ts && grep -q "api/push" src/index.ts + + + push.test.ts green: POST persists user-scoped row, 401 unauth, DELETE removes only caller rows, GET returns publicKey. index.ts mounts /api/push and calls setVapidDetails once at startup. + + Subscription API live and tested; VAPID configured at startup. + + + + Task 2: Service-worker migration to injectManifest (push + notificationclick + denylist) + + - apps/pwa/vite.config.ts (lines 8-39 current generateSW config — denylist lines 16-19 MUST be preserved) + - .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 2 sw.ts; Pattern 3 vite.config; Pitfall 3/4 workbox deps + denylist; ### SW navigateFallback preservation; ### Event payload format) + - .planning/phases/05-web-push-notifications/05-PATTERNS.md (### apps/pwa/src/sw.ts; ### apps/pwa/vite.config.ts) + - .planning/phases/05-web-push-notifications/05-UI-SPEC.md (## Tap-to-Open Deep Links) + + + Migrate apps/pwa/vite.config.ts from generateSW to injectManifest: replace the workbox:{} block with strategies:'injectManifest', srcDir:'src', filename:'sw.ts', injectManifest:{ globIgnores:['**/node_modules/**','**/callback**'] }. Keep registerType:'autoUpdate' and the manifest block byte-identical. Create apps/pwa/src/sw.ts: + Declare self as ServiceWorkerGlobalScope. Import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'; { clientsClaim } from 'workbox-core'; { NavigationRoute, registerRoute } from 'workbox-routing'. + self.skipWaiting(); clientsClaim() (reproduces autoUpdate). + precacheAndRoute(self.__WB_MANIFEST). + Re-implement the navigation denylist (T-03-20, Pitfall 4): const navHandler = createHandlerBoundToURL('/index.html'); registerRoute(new NavigationRoute(navHandler, { denylist: [/^\/callback/, /^\/api\//, /^\/health/] })). + push handler: parse event.data.json(); support BOTH data.notification (declarative) and legacy top-level title/body/tag/data; derive title/body/tag/url; on ANY parse failure fall back to title 'FamilySync', body 'You have a new notification'. ALWAYS event.waitUntil(self.registration.showNotification(title, { body, tag, data: { url } })) — even on the malformed-payload branch (D-11: silent push = iOS subscription death). + notificationclick handler: event.notification.close(); read url from notification.data.url (default '/'); event.waitUntil(matchAll({ type:'window', includeUncontrolled:true }) then focus a client already at url, else openWindow(url)). + + + cd apps/pwa && grep -q "injectManifest" vite.config.ts && grep -q "showNotification" src/sw.ts && grep -q "waitUntil" src/sw.ts && grep -q "callback" src/sw.ts && pnpm build 2>&1 | tail -3 + + + vite.config uses injectManifest; sw.ts builds; sw.ts contains showNotification + waitUntil in the push handler, the /callback,/api,/health denylist, and a notificationclick deep-link handler. `pnpm build` produces a sw.js with the precache manifest injected. + + SW migrated; push + notificationclick + denylist preserved; build green. + + + + Task 3: usePushSubscription hook + PushPermissionPrompt + desktop subscribe verification + + - apps/pwa/src/components/InstallPrompt.tsx (useAndroidInstallPrompt hook lines 76-105; WalkthroughSheet layout lines 121-269; isInstalled lines 54-59; readDismissed/persistDismissed lines 284-297) + - apps/pwa/src/App.tsx (mount point) + - .planning/phases/05-web-push-notifications/05-PATTERNS.md (### usePushSubscription.ts; ### PushPermissionPrompt.tsx) + - .planning/phases/05-web-push-notifications/05-UI-SPEC.md (### Surface 1: Post-Install Permission Prompt — copy, states, a11y, localStorage key pushPermissionDismissed) + - .claude/skills/playwright-cli/SKILL.md + + + apps/pwa/src/hooks/usePushSubscription.ts: returns { subscribe, unsubscribe, permission }. subscribe(reg) MUST be callable synchronously from a tap handler with no await before pushManager.subscribe (iOS user-gesture requirement, D-08/Pitfall 2): fetch the VAPID public key (GET /api/push/vapid-public-key, cache in sessionStorage) ONCE earlier, then subscribe({ userVisibleOnly:true, applicationServerKey: urlBase64ToUint8Array(key) }) and POST sub.toJSON() to /api/push/subscription with credentials:'include'. unsubscribe(): getSubscription then sub.unsubscribe() + DELETE /api/push/subscription. Include a urlBase64ToUint8Array helper. localStorage key notificationsEnabled. + apps/pwa/src/components/PushPermissionPrompt.tsx: WalkthroughSheet-style bottom sheet (zIndex 1000 sheet / 999 backdrop, NO backdrop-dismiss). Bell icon, heading "Stay in the loop", body "Get notified when events are coming up or your family makes changes.", primary CTA "Enable Notifications" (48px, var(--color-member-0)), secondary "Not now" (44px ghost). On Enable tap: call subscribe inside the onClick (no await before subscribe); show Loader2 spinner while awaiting; on granted close sheet; on denied close sheet. "Not now" sets localStorage.pushPermissionDismissed='1'. Render only when isInstalled() and Notification.permission==='default' and not dismissed. + Mount: render PushPermissionPrompt from InstallPrompt.tsx after install confirms (D-08); mount in the App tree so it appears on the installed PWA. + + + Implement the hook + component + mount per . Then run a desktop Chromium verification with playwright-cli (push subscribe IS automatable on Chromium per CLAUDE.md / VALIDATION Manual-Only note — only iOS-standalone is device-only). Drive: load the app (dev-bypass), grant notification permission, trigger the Enable flow, assert a row lands in push_subscriptions and the prompt closes. Capture the playwright-cli output as evidence. + + + 1. Build + serve the API (DEV_AUTH_BYPASS) and PWA per docs/deployment.md local-dev command. + 2. Use playwright-cli to open the app in Chromium, grant Notifications, click "Enable Notifications". + 3. Confirm: the prompt closes, GET subscribe POST returned 201, and a push_subscriptions row exists for the dev user. + 4. (Optional) dispatch a test push and confirm a visible notification + tap deep-link. + Confirm the iOS-standalone path is deferred to the Phase 5 human gate (device-only). + + Type "approved" or describe what failed + + cd apps/pwa && grep -q "usePushSubscription" src/hooks/usePushSubscription.ts && grep -q "PushPermissionPrompt" src/components/PushPermissionPrompt.tsx && grep -q "pushManager.subscribe" src/hooks/usePushSubscription.ts && grep -q "PushPermissionPrompt" src/components/InstallPrompt.tsx && pnpm build 2>&1 | tail -2 + + + Hook subscribes inside a tap handler (no await before pushManager.subscribe); PushPermissionPrompt renders per UI-SPEC Surface 1 copy/states/a11y; mounted off the install flow; desktop playwright-cli subscribe verified end-to-end (row persisted). + + Subscribe slice works end-to-end on desktop; iOS-standalone deferred to phase gate. + + + + + +## 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 | + + + +- 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. + + + +- 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. + + + +Create `.planning/phases/05-web-push-notifications/05-04-SUMMARY.md` when done. + diff --git a/.planning/phases/05-web-push-notifications/05-05-PLAN.md b/.planning/phases/05-web-push-notifications/05-05-PLAN.md new file mode 100644 index 0000000..2da77d5 --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-05-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: listChangeDispatcher — access-scoped, self-suppressed fan-out + + - 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) + + + - 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. + + + 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. + + + cd apps/api && pnpm exec vitest run tests/lib/listChangeDispatcher.test.ts + + + Test green: burst→one push count=N to the non-actor accessible member; actor suppressed; unrelated member excluded; empty audience no-op. + + Access-scoped, self-suppressed, coalesced list-change dispatch implemented + tested. + + + + Task 2: Hook notifyListChange into list/item mutations (reorder excluded) + + - 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) + + + 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. + + + cd apps/api && grep -q "notifyListChange" src/routes/lists.ts && pnpm exec vitest run tests/routes/lists.test.ts + + + 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. + + List mutations push (coalesced) for the other member; reorder stays silent. + + + + + +## 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) | + + + +- listChangeDispatcher.test.ts + lists.test.ts green. +- `pnpm --filter @familysync/api typecheck` passes. + + + +- 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. + + + +Create `.planning/phases/05-web-push-notifications/05-05-SUMMARY.md` when done. + diff --git a/.planning/phases/05-web-push-notifications/05-06-PLAN.md b/.planning/phases/05-web-push-notifications/05-06-PLAN.md new file mode 100644 index 0000000..29fe3a3 --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-06-PLAN.md @@ -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" +--- + + +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. + + + +@.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 + + + + reminderScheduler — shared-timed-event 15-min reminder scan + apps/api/src/broker/reminderScheduler.ts, apps/api/tests/broker/reminderScheduler.test.ts + + - 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}") + + + - 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). + + + Module-level `const sentReminders = new Set()` (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`). + + + cd apps/api && pnpm exec vitest run tests/broker/reminderScheduler.test.ts && grep -q "startReminderScheduler" src/index.ts + + + 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. + + + + +## 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 | + + + +- RED precedes GREEN; reminderScheduler.test.ts green. +- index.ts wires startReminderScheduler in the isMainModule guard. +- `pnpm --filter @familysync/api typecheck` passes. + + + +- 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. + + + +Create `.planning/phases/05-web-push-notifications/05-06-SUMMARY.md` with RED/GREEN commits. + diff --git a/.planning/phases/05-web-push-notifications/05-07-PLAN.md b/.planning/phases/05-web-push-notifications/05-07-PLAN.md new file mode 100644 index 0000000..7d9f66d --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-07-PLAN.md @@ -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" +--- + + +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. + + + +@.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 + + + + eventChangeDispatcher + syncCalendar change detection + 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 + + - 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) + + + - 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). + + + 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. + + + 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 + + + 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. + + + + +## 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 | + + + +- 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. + + + +- 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. + + + +Create `.planning/phases/05-web-push-notifications/05-07-SUMMARY.md` with RED/GREEN commits. + diff --git a/.planning/phases/05-web-push-notifications/05-08-PLAN.md b/.planning/phases/05-web-push-notifications/05-08-PLAN.md new file mode 100644 index 0000000..7b07bab --- /dev/null +++ b/.planning/phases/05-web-push-notifications/05-08-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: usePushSubscription health-check + permission state (D-10) + + - 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) + + + 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. + + + cd apps/pwa && grep -q "getSubscription" src/hooks/usePushSubscription.ts && grep -q "permission" src/hooks/usePushSubscription.ts && pnpm build 2>&1 | tail -2 + + + 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. + + Silent dead-subscription recovery implemented (D-10 reliability half). + + + + Task 2: SettingsSheet (master toggle) + AppNav avatar button + + - 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) + + + 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