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