--- phase: 05-web-push-notifications plan: 01 type: execute wave: 1 depends_on: [] files_modified: - apps/api/package.json - apps/pwa/package.json - apps/api/src/db/schema.ts - apps/api/src/db/migrations/ - apps/api/test/setup.ts - apps/api/tests/lib/pushDispatcher.test.ts - apps/api/tests/lib/pushCoalescer.test.ts - apps/api/tests/broker/reminderScheduler.test.ts - apps/api/tests/lib/eventChangeDispatcher.test.ts - apps/api/tests/routes/push.test.ts - apps/api/tests/fixtures/vapid.ts - .env.example autonomous: false requirements: [NOTIF-01, NOTIF-02, NOTIF-03] user_setup: - service: web-push (VAPID — self-generated, no external account) why: 'Server signs push messages with a VAPID keypair; the private key must live in the API env, the public key is served to the PWA. No third-party account — the keypair is generated locally.' env_vars: - name: VAPID_PUBLIC_KEY source: 'Generated by `npx web-push generate-vapid-keys --json` (Task 2 runs this and prints the values)' - name: VAPID_PRIVATE_KEY source: 'Same command — paste into apps/api `.env` (NEVER commit; .env is gitignored)' - name: VAPID_SUBJECT source: 'A mailto: or https: contact URL, e.g. mailto:admin@familysync.bergerhouse.net' must_haves: truths: - 'web-push + @types/web-push are installed in apps/api; workbox-precaching/core/routing are devDeps in apps/pwa' - 'push_subscriptions table exists in MariaDB with (user_id FK cascade, endpoint unique, p256dh, auth) after migrate' - 'calendar_events has a title varchar(500) column after migrate (D-02/NOTIF-01 readable copy)' - 'A real generated VAPID keypair is recorded in .env (private) and .env.example documents the three env vars (public placeholder only)' - 'All Wave-0 RED test files exist and fail for the right reason (missing implementation, not import/syntax errors)' - 'test/setup.ts afterEach truncates push_subscriptions' artifacts: - path: 'apps/api/src/db/schema.ts' provides: 'pushSubscriptions table + calendarEvents.title column' contains: 'pushSubscriptions' - path: 'apps/api/src/db/migrations' provides: '0003 migration adding push_subscriptions + calendar_events.title' contains: 'push_subscriptions' - path: 'apps/api/tests/fixtures/vapid.ts' provides: 'Static test VAPID keypair fixture (no network) for unit tests' min_lines: 3 - path: 'apps/api/tests/lib/pushDispatcher.test.ts' provides: 'RED scaffold for 410/404 pruning' - path: 'apps/api/tests/lib/pushCoalescer.test.ts' provides: 'RED scaffold for list-change coalescing' - path: 'apps/api/tests/broker/reminderScheduler.test.ts' provides: 'RED scaffold for reminder scan (shared/timed/all-day filters)' - path: 'apps/api/tests/lib/eventChangeDispatcher.test.ts' provides: 'RED scaffold for event-change dispatch + description-only suppression' - path: 'apps/api/tests/routes/push.test.ts' provides: 'RED scaffold for subscription POST/DELETE + vapid-public-key' key_links: - from: 'apps/api/src/db/schema.ts' to: 'apps/api/test/setup.ts' via: 'pushSubscriptions export imported for truncation' pattern: 'pushSubscriptions' --- Wave-0 foundation for Phase 5 Web Push. Install the missing push dependencies (`web-push` server-side, `workbox-*` client-side build deps), generate the VAPID keypair, add the `push_subscriptions` table and the `calendar_events.title` column via the safe generate+migrate workflow, and lay down every RED test scaffold the later TDD/execute plans assert against. Purpose: Every downstream plan (dispatcher, coalescer, scheduler, event-change, subscribe slice) depends on these packages, this schema, and these test files existing first. Per RESEARCH §Codebase Ground-Truth: `web-push` and `workbox-precaching` are NOT installed; `calendar_events` has NO title column. This plan closes those gaps and nothing else builds without it. Output: Installed deps + legitimacy checkpoint, generated VAPID keypair documented in .env, migration 0003 applied to the live dev DB, five RED test files + a VAPID test fixture, and an updated test/setup truncation list. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/05-web-push-notifications/05-RESEARCH.md @.planning/phases/05-web-push-notifications/05-PATTERNS.md @apps/api/src/db/schema.ts @apps/api/test/setup.ts @apps/api/package.json @apps/pwa/package.json Task 1: [BLOCKING] Package legitimacy gate + install push dependencies - .planning/phases/05-web-push-notifications/05-RESEARCH.md (## Package Legitimacy Audit — web-push, @types/web-push, workbox-precaching all OK/Approved) - .planning/phases/05-web-push-notifications/05-PATTERNS.md (## Dependency Gaps table) - apps/api/package.json, apps/pwa/package.json (confirm absence) RESEARCH.md Package Legitimacy Audit verdicts (all "OK / Approved"): - web-push@3.6.7 — github.com/web-push-libs/web-push, 5.09M/wk - @types/web-push@3.6.4 — DefinitelyTyped, 1.68M/wk - workbox-precaching@7.4.1 — github.com/googlechrome/workbox, 7.92M/wk workbox-core and workbox-routing are siblings of workbox-precaching (same Workbox 7 suite, same publisher). Present the four packages (web-push, @types/web-push, workbox-precaching, workbox-core, workbox-routing) with their RESEARCH audit verdicts. These were audited as legitimate; this checkpoint exists because they are package-manager installs (threat T-05-SC). AFTER human approval, run: `pnpm --filter @familysync/api add web-push` `pnpm --filter @familysync/api add -D @types/web-push` `pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core workbox-routing` Do NOT run installs before approval. Confirm the five package names + registry links against npmjs.com if desired. Approve to proceed with install. Type "approved" to install, or name any package to reject node -e "const a=require('./apps/api/package.json');const p=require('./apps/pwa/package.json');if(!a.dependencies['web-push'])throw new Error('web-push missing');if(!a.devDependencies['@types/web-push'])throw new Error('@types/web-push missing');if(!p.devDependencies['workbox-precaching']||!p.devDependencies['workbox-core']||!p.devDependencies['workbox-routing'])throw new Error('workbox devDeps missing');console.log('deps ok')" web-push + @types/web-push in apps/api package.json; workbox-precaching/core/routing in apps/pwa devDependencies; lockfile updated. All five packages installed in the correct workspace and dependency type. Task 2: Generate VAPID keypair + record in env - .planning/phases/05-web-push-notifications/05-RESEARCH.md (### VAPID key generation; Pitfall 8 — public key delivered to PWA) - .env.example (existing env var documentation pattern) web-push CLI generates a URL-safe Base64 VAPID keypair. The private key signs push messages (server-only, in apps/api .env, never committed). The public key is served to the PWA via GET /api/push/vapid-public-key (Plan 05-04) — runtime delivery chosen over build-time VITE_ var to allow key rotation without a rebuild (resolves RESEARCH Open Question 2). Run `npx web-push generate-vapid-keys --json` and capture publicKey/privateKey. Append to apps/api `.env` (gitignored): VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT=mailto:admin@familysync.bergerhouse.net. Then update `.env.example` (committed) to DOCUMENT all three keys with placeholder values only — the real private key MUST NOT appear in .env.example or any committed file (threat T-05-01 Information Disclosure). The human pastes the generated keys into .env. Confirm apps/api/.env contains VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT with real values; confirm .env.example contains only placeholders. Type "done" once keys are in .env grep -q 'VAPID_PUBLIC_KEY' .env.example && grep -q 'VAPID_PRIVATE_KEY' .env.example && grep -q 'VAPID_SUBJECT' .env.example && echo "env.example documents VAPID" .env.example documents VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT with placeholder values; real keys live only in gitignored .env. No real private key in any tracked file. VAPID keypair generated; private key in .env only; .env.example documents the three vars. Task 3: [BLOCKING] Schema — push_subscriptions table + calendar_events.title, generate+migrate - apps/api/src/db/schema.ts (listShares lines 208-224 = FK+unique+index analog; calendarEvents lines 111-138 = title column target) - .planning/phases/05-web-push-notifications/05-PATTERNS.md (### apps/api/src/db/schema.ts — add pushSubscriptions table) - .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 4 schema; Pitfall 6 title column; Codebase Ground-Truth 1 migration workflow) In apps/api/src/db/schema.ts add the `pushSubscriptions` mysqlTable: id int PK autoincrement; userId int('user_id') notNull references users.id onDelete cascade; endpoint text notNull; p256dh text notNull; auth varchar('auth',{length:256}) notNull; createdAt timestamp defaultNow notNull; updatedAt timestamp defaultNow onUpdateNow. Constraints: unique('uniq_push_endpoint').on(endpoint) (one endpoint per device, globally unique) and index('idx_push_subscriptions_user_id').on(userId). Mirror the listShares structure exactly. Also add `title: varchar('title',{length:500})` (nullable) to the existing `calendarEvents` table after `rawVevent` — populated from VEVENT SUMMARY by sync.ts in Plan 05-07; readable reminder/change copy depends on it (D-02). Then generate and apply the migration: `pnpm --filter @familysync/api db:generate` then `pnpm --filter @familysync/api db:migrate`. Commit the generated 0003_*.sql file. DO NOT run `db:push` / `db:generate --push` — drizzle-kit push emits a false destructive truncate diff on this populated MariaDB (memory: drizzle-mariadb-push-unsafe; STATE D-Task5-DDL). This migrate step is mandatory: type/build checks pass from the schema config alone, so skipping it creates a false-positive verification state where the live DB lacks the table. grep -q "pushSubscriptions" apps/api/src/db/schema.ts && grep -q "title:.*varchar.*500" apps/api/src/db/schema.ts && ls apps/api/src/db/migrations/0003_*.sql && grep -li "push_subscriptions" apps/api/src/db/migrations/0003_*.sql schema.ts exports pushSubscriptions and calendarEvents has a title column; a 0003_*.sql migration containing CREATE TABLE push_subscriptions and ALTER calendar_events ADD title exists and has been applied via db:migrate (not db:push). push_subscriptions + calendar_events.title live in the dev DB; migration committed. Task 4: Wave-0 RED test scaffolds + VAPID fixture + setup truncation - apps/api/tests/routes/lists.test.ts (mock boilerplate lines 32-44; getApp lines 77-80; jsonRequest lines 86-92; seedUser lines 50-58) - apps/api/test/setup.ts (afterEach truncation pattern lines 27-37) - .planning/phases/05-web-push-notifications/05-RESEARCH.md (### Phase Requirements → Test Map; ### Wave 0 Gaps) - .planning/phases/05-web-push-notifications/05-VALIDATION.md (### Wave 0 Requirements) Create apps/api/tests/fixtures/vapid.ts exporting a static TEST_VAPID = { publicKey, privateKey, subject } keypair (generate one real pair with `npx web-push generate-vapid-keys --json` and inline it — test-only, no network at runtime). Create five RED test files, each importing the (not-yet-existing) implementation so they fail on a missing module/export, NOT on syntax: - tests/lib/pushDispatcher.test.ts — asserts dispatchPush prunes the subscription (DELETE from push_subscriptions) on statusCode 410 and 404, and does NOT delete on 201/transient errors (mock webpush.sendNotification). - tests/lib/pushCoalescer.test.ts — asserts a burst of N coalesceListPush calls within the window fires the dispatch ONCE with count=N (use vi.useFakeTimers); asserts the actor's own userId is passed as excludeUserId. - tests/broker/reminderScheduler.test.ts — asserts the scan SELECTs only shared (isShared=true) AND timed (allDay=false) events in the [now+14m, now+16m] window; asserts all-day and non-shared events are excluded (D-05/D-07); asserts the same (eventUid,minuteBucket) does not dispatch twice. - tests/lib/eventChangeDispatcher.test.ts — asserts dispatchEventChange fires for new/updated(time|date|title|location)/deleted events, does NOT fire for description-only changes (D-04), and excludes the actor's own subscriptions (D-03). - tests/routes/push.test.ts — asserts POST /api/push/subscription persists a row scoped to the authed user (401 when unauth), DELETE removes the caller's rows, GET /api/push/vapid-public-key returns { publicKey }. Use the lists.test.ts mock/getApp/seedUser/jsonRequest boilerplate verbatim. Update apps/api/test/setup.ts: import pushSubscriptions and add `await db.delete(pushSubscriptions)` inside the afterEach try block (before lists delete; no FK to lists). cd apps/api && pnpm exec vitest run tests/lib/pushDispatcher.test.ts tests/lib/pushCoalescer.test.ts tests/broker/reminderScheduler.test.ts tests/lib/eventChangeDispatcher.test.ts 2>&1 | grep -Eq "Cannot find module|is not a function|No test found|fail" && echo "RED ok"; grep -q "pushSubscriptions" ../../apps/api/test/setup.ts Five RED test files + tests/fixtures/vapid.ts exist; each test file fails on missing implementation (not syntax/import-of-test-lib errors); test/setup.ts truncates push_subscriptions. The dispatcher/coalescer/scheduler/eventChange/route implementations do NOT yet exist (those are Plans 05-02..05-07). RED scaffolds in place; later plans turn them GREEN. ## Trust Boundaries | Boundary | Description | | ----------------------- | -------------------------------------------------------- | | developer machine → git | VAPID private key must never cross into a committed file | | pnpm registry → repo | package installs are untrusted supply-chain input | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | | --------- | ---------------------- | -------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | | T-05-01 | Information Disclosure | VAPID_PRIVATE_KEY | mitigate | Private key only in gitignored .env; .env.example carries placeholders; verify gate greps .env.example, never .env | | T-05-SC | Tampering | npm installs (web-push, workbox-\*) | mitigate | RESEARCH legitimacy audit (all OK) + blocking-human checkpoint (Task 1) before install | | T-05-02 | Tampering | drizzle migration on populated MariaDB | mitigate | Use db:generate+db:migrate only; db:push forbidden (false truncate diff) | - `pnpm --filter @familysync/api typecheck` passes with the new schema export. - 0003 migration applied; `push_subscriptions` and `calendar_events.title` exist in the dev DB. - Five RED test files fail for missing-implementation reasons only. - web-push/@types/web-push installed (api); workbox-precaching/core/routing installed (pwa). - VAPID keypair generated; private key in .env; .env.example documents all three vars. - push_subscriptions table + calendar_events.title column migrated (generate+migrate, never push). - All Wave-0 RED scaffolds + VAPID fixture exist; setup.ts truncates push_subscriptions. Create `.planning/phases/05-web-push-notifications/05-01-SUMMARY.md` when done.