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