---
phase: 01-foundation-broker-spike
plan: 03
type: execute
wave: 2
depends_on: ['01-01']
files_modified:
  - apps/api/src/broker/crypto.ts
  - apps/api/src/broker/client.ts
  - apps/api/src/broker/sync.ts
  - apps/api/src/broker/poller.ts
  - apps/api/src/routes/events.ts
  - apps/api/tests/broker/crypto.test.ts
  - apps/api/tests/broker/sync.test.ts
  - apps/api/tests/broker/poller.test.ts
  - .env.example
autonomous: true
requirements: [CAL-01]

must_haves:
  truths:
    - 'App passwords are encrypted at rest with AES-256-GCM (key from env) and decrypt losslessly; never exposed to the frontend'
    - 'The broker creates a Fastmail CalDAV client (Basic auth, app password) and fetches calendars via PROPFIND'
    - 'syncCalendar parses VEVENTs with ical.js and upserts them into calendar_events, storing all-day events as DATE (dtstart_date) never DATETIME'
    - "The poller skips DB writes when a calendar's ctag is unchanged (sync-token with ctag fallback)"
    - 'GET /api/events returns cached events from MariaDB (never a live Fastmail call per request)'
  artifacts:
    - path: 'apps/api/src/broker/crypto.ts'
      provides: 'encryptPassword/decryptPassword (AES-256-GCM, key from APP_PASSWORD_ENCRYPTION_KEY)'
      exports: ['encryptPassword', 'decryptPassword']
    - path: 'apps/api/src/broker/client.ts'
      provides: 'createFastmailClient(email, appPassword) → tsdav DAVClient'
      exports: ['createFastmailClient']
    - path: 'apps/api/src/broker/sync.ts'
      provides: 'syncCalendar: REPORT → ical.js → calendar_events upsert'
      exports: ['syncCalendar']
    - path: 'apps/api/src/broker/poller.ts'
      provides: 'startBrokerPoller (node-cron 5-min) with ctag change detection'
      exports: ['startBrokerPoller']
    - path: 'apps/api/src/routes/events.ts'
      provides: 'GET /api/events → cached events from DB'
      exports: ['eventsRouter']
  key_links:
    - from: 'apps/api/src/broker/poller.ts'
      to: 'apps/api/src/broker/crypto.ts'
      via: 'decryptPassword before client creation'
      pattern: "decryptPassword\\("
    - from: 'apps/api/src/broker/sync.ts'
      to: 'apps/api/src/db/client.ts'
      via: 'calendarEvents upsert'
      pattern: 'calendarEvents'
    - from: 'apps/api/src/routes/events.ts'
      to: 'apps/api/src/db/client.ts'
      via: 'cache read (no live CalDAV)'
      pattern: 'from [''"].*db/client'
---

<objective>
Deliver the CalDAV broker vertical slice: AES-256-GCM encryption for Fastmail app passwords, a tsdav-based broker that discovers calendars (PROPFIND) and fetches events (REPORT), an ical.js sync that caches VEVENTs into MariaDB with correct all-day DATE handling, a 5-minute node-cron poller with ctag change detection, and `GET /api/events` reading the cache.

After this plan the backend can read a real Fastmail calendar (given a stored credential) and serve cached events from `/api/events` — the data half of "see a real cached event on the landing page." The final wiring (broker startup + events route mount in index.ts, event display in the PWA) lands in Plan 04 to keep this plan parallel with the auth slice.

Purpose: CAL-01 — read shared Fastmail calendar via broker and cache locally. The broker module is the sole holder of Fastmail I/O (hard boundary); nothing else imports tsdav or credentials.
Output: crypto helper, broker client/sync/poller, /api/events router, all unit-tested.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@./CLAUDE.md
@.planning/phases/01-foundation-broker-spike/01-CONTEXT.md
@.planning/phases/01-foundation-broker-spike/01-RESEARCH.md
@.planning/phases/01-foundation-broker-spike/01-01-SUMMARY.md
</context>

<artifacts_produced>

## Artifacts this phase produces (Plan 03)

New files: `apps/api/src/broker/crypto.ts`, `apps/api/src/broker/client.ts`, `apps/api/src/broker/sync.ts`, `apps/api/src/broker/poller.ts`, `apps/api/src/routes/events.ts`.

New exported symbols: `encryptPassword`, `decryptPassword` (broker/crypto.ts), `createFastmailClient` (broker/client.ts), `syncCalendar` (broker/sync.ts), `startBrokerPoller` (broker/poller.ts), `eventsRouter` (routes/events.ts).

New route paths: `GET /api/events` (mounted in Plan 04).

New env vars: `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex = 32 bytes).
</artifacts_produced>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: AES-256-GCM app-password encryption (CAL-01 security)</name>
  <files>apps/api/src/broker/crypto.ts, apps/api/tests/broker/crypto.test.ts, .env.example</files>
  <read_first>
    - .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 4: AES-GCM App-Password Encryption" — full encrypt/decrypt + key generation)
    - .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-04 encrypted-at-rest, key from env, backend-only)
    - apps/api/tests/broker/crypto.test.ts (RED stub from Plan 01 — fill GREEN here)
  </read_first>
  <behavior>
    - encryptPassword(plaintext) then decryptPassword(result) returns the original plaintext (lossless roundtrip)
    - Two encryptPassword calls on the same plaintext produce DIFFERENT ciphertext (fresh random 96-bit IV each time)
    - decryptPassword throws if the auth tag is tampered (GCM integrity)
    - The stored payload is JSON with iv, authTag, ciphertext (all hex)
  </behavior>
  <action>
    Create `src/broker/crypto.ts` per RESEARCH Pattern 4: read `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex → 32-byte Buffer). `encryptPassword(plaintext)`: randomBytes(12) IV, createCipheriv('aes-256-gcm', KEY, iv), update+final, getAuthTag, return JSON.stringify({iv, authTag, ciphertext} as hex). `decryptPassword(stored)`: parse JSON, createDecipheriv, setAuthTag, update+final → utf8. Use `node:crypto` (built into Node 22) — do NOT hand-roll a cipher. Never log plaintext or the key.

    Add `APP_PASSWORD_ENCRYPTION_KEY` to `.env.example` with a comment showing the generator: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.

    Fill `tests/broker/crypto.test.ts` GREEN: set a fixed test key in the test, assert (a) roundtrip lossless; (b) two encrypts of same plaintext differ; (c) tampering authTag causes decrypt to throw.

  </action>
  <verify>
    <automated>cd apps/api && pnpm vitest run tests/broker/crypto.test.ts --reporter=verbose</automated>
  </verify>
  <acceptance_criteria>
    - `src/broker/crypto.ts` exports `encryptPassword` and `decryptPassword` using `node:crypto` aes-256-gcm
    - tests/broker/crypto.test.ts passes: roundtrip, IV-uniqueness, tamper-detection
    - `.env.example` lists `APP_PASSWORD_ENCRYPTION_KEY` with the generator comment
    - grep: no `console.log` of plaintext or KEY in crypto.ts
  </acceptance_criteria>
  <done>crypto.test.ts green; encrypt/decrypt lossless; IV unique; tamper throws.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Broker client + sync (ical.js parse, all-day DATE) + events route (CAL-01)</name>
  <files>apps/api/src/broker/client.ts, apps/api/src/broker/sync.ts, apps/api/src/routes/events.ts, apps/api/tests/broker/sync.test.ts</files>
  <read_first>
    - .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 3: CalDAV Broker (tsdav)" — createFastmailClient, fetchCalendars, fetchCalendarObjects, syncCalendar with ical.js; § "Pitfall 1" principal URL; § "Pitfall 2/3" all-day DATE)
    - .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-09 CalDAV-only via tsdav, app password; D-13 raw VEVENT blob + dtstart_utc, all-day as DATE, only cache server-returned objects)
    - apps/api/src/db/schema.ts (calendars, calendarEvents from Plan 01)
    - apps/api/tests/broker/sync.test.ts (RED stub from Plan 01 — fill GREEN here)
  </read_first>
  <behavior>
    - syncCalendar given a timed VEVENT writes dtstart_utc (timestamp), dtstart_date NULL, all_day false
    - syncCalendar given an all-day VEVENT writes dtstart_date (YYYY-MM-DD), dtstart_utc NULL, all_day true (NEVER coerce DATE to DATETIME — Pitfall 3)
    - re-syncing the same UID updates the existing row (onDuplicateKeyUpdate on calendar_id+uid) — no duplicate
    - only server-returned objects are cached (raw VEVENT blob stored verbatim — D-13)
  </behavior>
  <action>
    Create `src/broker/client.ts` exporting `createFastmailClient(email, appPassword)` per RESEARCH Pattern 3: `createDAVClient({ serverUrl: 'https://caldav.fastmail.com', credentials: {username: email, password: appPassword}, authMethod: 'Basic', defaultAccountType: 'caldav' })`. Note Pitfall 1: tsdav discovery resolves the principal URL `https://caldav.fastmail.com/dav/principals/user/{email}/`. Create one client per credential (Pitfall 3 — discovery is a round-trip); broker module owns lifetime.

    Create `src/broker/sync.ts` exporting `syncCalendar(client, davCal, userId)`: upsert the `calendars` row (url, displayName, ctag, syncToken, lastSyncedAt; onDuplicateKeyUpdate). `fetchCalendarObjects` → for each obj: `ICAL.parse` → Component → getFirstSubcomponent('vevent'); read dtstart (ICAL.Time), uid; `allDay = dtstart.isDate`. Upsert into `calendarEvents` keyed on calendar_id+uid: rawVevent = obj.data verbatim, etag, allDay, and per D-13/Pitfall 3 — if allDay: dtstart_date = dtstart.toString().slice(0,10), dtstart_utc = null; else dtstart_utc = dtstart.toJSDate(), dtstart_date = null. Defensive ctag/syncToken: `davCal.ctag ?? davCal.syncToken ?? null` (Pitfall 6).

    Create `src/routes/events.ts` exporting `eventsRouter` (Hono): GET / reads from `calendarEvents` via `db` (cache only — NEVER call Fastmail per request, ARCHITECTURE anti-pattern), returns the rows (id, uid, allDay, dtstart_utc, dtstart_date, raw_vevent or a minimal shape). This router is mounted in Plan 04.

    Fill `tests/broker/sync.test.ts` GREEN with a mocked tsdav client returning captured raw VEVENT strings (timed + all-day fixtures — Wave 0 fixture requirement). Assert the dtstart_utc vs dtstart_date split, all_day flag, and UID-upsert idempotency.

  </action>
  <verify>
    <automated>cd apps/api && pnpm vitest run tests/broker/sync.test.ts --reporter=verbose && pnpm exec tsc --noEmit</automated>
  </verify>
  <acceptance_criteria>
    - `src/broker/client.ts` exports `createFastmailClient`; serverUrl is caldav.fastmail.com, authMethod 'Basic'
    - `src/broker/sync.ts` exports `syncCalendar`; stores rawVevent verbatim and splits all-day → dtstart_date, timed → dtstart_utc
    - tests/broker/sync.test.ts passes: timed event → dtstart_utc set + dtstart_date null; all-day → dtstart_date set + dtstart_utc null + all_day true; same-UID re-sync updates not duplicates
    - `src/routes/events.ts` exports `eventsRouter`; reads from db only (no `createFastmailClient` import in the route)
    - `pnpm exec tsc --noEmit` exits 0
  </acceptance_criteria>
  <done>sync.test.ts green; all-day DATE handling correct; events route reads cache only; tsc clean.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: node-cron poller with ctag change detection (CAL-01)</name>
  <files>apps/api/src/broker/poller.ts, apps/api/tests/broker/poller.test.ts</files>
  <read_first>
    - .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Fetching calendars + ctag polling" poller code; § "Pitfall 4" node-cron v4; § "Pitfall 6" ctag/syncToken null defensiveness)
    - .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-13 sync-token with ctag fallback from day one; broker is hard boundary)
    - apps/api/src/broker/sync.ts (syncCalendar from Task 2)
    - apps/api/src/broker/crypto.ts (decryptPassword from Task 1)
    - apps/api/tests/broker/poller.test.ts (RED stub from Plan 01 — fill GREEN here)
  </read_first>
  <behavior>
    - The poll function loads ALL member_credentials (N-credential per-member app password model — D-02), decrypts each app password, creates a client, fetches calendars
    - For a calendar whose current ctag equals the stored ctag, syncCalendar is NOT called (skip — no DB write)
    - For a calendar with a changed/absent ctag, syncCalendar IS called
    - Decryption happens via decryptPassword before client creation (credentials never logged)
  </behavior>
  <action>
    Create `src/broker/poller.ts` exporting `startBrokerPoller()` (and an internal `runPoll()` exported for tests). Per RESEARCH poller pattern: `schedule('*/5 * * * *', runPoll)` using node-cron v4 (Pitfall 4 — basic 5-field cron API is stable). `runPoll`: select all `memberCredentials`; for each, `decryptPassword`, `createFastmailClient`, `fetchCalendars`; for each davCal, compare `davCal.ctag ?? davCal.syncToken ?? null` to the stored calendars row ctag — if equal and non-null, `continue` (skip); else `syncCalendar`. Make `runPoll` injectable/testable (accept the db + client factory or use module mocks) so the unit test can assert skip-on-unchanged without hitting Fastmail.

    Fill `tests/broker/poller.test.ts` GREEN: mock fetchCalendars to return a calendar with a known ctag matching a stored row → assert syncCalendar spy NOT called; then a changed ctag → assert syncCalendar IS called.

  </action>
  <verify>
    <automated>cd apps/api && pnpm vitest run tests/broker/poller.test.ts --reporter=verbose</automated>
  </verify>
  <acceptance_criteria>
    - `src/broker/poller.ts` exports `startBrokerPoller`; uses node-cron `schedule('*/5 * * * *', ...)`
    - poller decrypts via `decryptPassword` before creating a client (grep: `decryptPassword(`)
    - tests/broker/poller.test.ts passes: unchanged ctag → no syncCalendar; changed ctag → syncCalendar called
    - grep: no logging of decrypted password or app password in poller.ts
  </acceptance_criteria>
  <done>poller.test.ts green; ctag skip logic correct; credentials decrypted not logged.</done>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary                         | Description                                                                          |
| -------------------------------- | ------------------------------------------------------------------------------------ |
| member_credentials (DB) → broker | App passwords stored encrypted; only broker/crypto.ts decrypts; never leaves backend |
| Broker → Fastmail CalDAV         | Outbound Basic auth over TLS; sole holder of Fastmail I/O                            |
| Hono /api/events → browser       | Returns only cached event data; never credentials or raw app passwords               |

## STRIDE Threat Register

| Threat ID | Category               | Component                            | Disposition | Mitigation Plan                                                                                                                                       |
| --------- | ---------------------- | ------------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-03-01   | Information Disclosure | Fastmail app password at rest        | mitigate    | AES-256-GCM with 96-bit IV + auth tag (crypto.ts); key from APP_PASSWORD_ENCRYPTION_KEY env, never committed/logged (ASVS V6)                         |
| T-03-02   | Information Disclosure | App password leaking via /api/events | mitigate    | events route reads only calendar_events (event data); never joins/returns member_credentials; broker is the only credential reader (D-04)             |
| T-03-03   | Tampering              | Encrypted-credential integrity       | mitigate    | GCM auth tag verified on decrypt; tampered ciphertext throws, never silently used                                                                     |
| T-03-04   | Information Disclosure | Credentials in logs                  | mitigate    | No console logging of decrypted passwords or the encryption key in client.ts / poller.ts                                                              |
| T-03-05   | Tampering              | Caching client-side event versions   | mitigate    | Only server-returned objects cached (raw VEVENT verbatim — D-13, Pitfall 14); no write-back in Phase 1                                                |
| T-03-SC   | Tampering              | tsdav / ical.js / node-cron installs | accept      | All [OK] in RESEARCH § Package Legitimacy Audit (tsdav 3+ yrs official repo, ical.js Mozilla-maintained, node-cron 8+ yrs); no [ASSUMED]/[SUS]/[SLOP] |

</threat_model>

<verification>
- `pnpm exec tsc --noEmit` clean
- crypto.test.ts, sync.test.ts, poller.test.ts all green
- broker module is the only importer of tsdav / credentials (grep: tsdav imported only under src/broker/)
- /api/events reads cache only (no createFastmailClient import in routes/events.ts)
</verification>

<success_criteria>

- App passwords encrypted at rest (AES-256-GCM), lossless roundtrip, tamper-detecting
- Broker discovers calendars and syncs VEVENTs into the cache with correct all-day DATE handling
- Poller skips unchanged calendars (ctag detection)
- /api/events serves cached events without a live Fastmail call
- All three unit test files green
  </success_criteria>

<output>
Create `.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md` when done.
</output>
