---
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>
