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