---
phase: 01-foundation-broker-spike
plan: '03'
subsystem: api
tags: [caldav, ical.js, tsdav, node-cron, aes-256-gcm, drizzle, mariadb, vitest]

# Dependency graph
requires:
  - phase: 01-foundation-broker-spike/01-01
    provides: db singleton, Drizzle schema (users, memberCredentials, calendars, calendarEvents)
provides:
  - AES-256-GCM encryptPassword / decryptPassword helpers (broker/crypto.ts)
  - tsdav DAVClient factory createFastmailClient (broker/client.ts)
  - syncCalendar: REPORT → ical.js → MariaDB upsert with D-13 all-day DATE handling (broker/sync.ts)
  - startBrokerPoller / runPoll: 5-min node-cron ctag change-detection poller (broker/poller.ts)
  - GET /api/events router reading the calendarEvents cache (routes/events.ts)
  - 24 unit tests green across 5 test files
affects:
  - 01-04 (spike — mounts events route, calls startBrokerPoller from index.ts, live Fastmail test)

# Tech tracking
tech-stack:
  added:
    - ical.js@2.2.1 (VEVENT parsing, isDate all-day detection)
    - tsdav@2.2.2 (CalDAV PROPFIND + REPORT via createDAVClient)
    - node-cron@4.2.1 (5-min background schedule)
    - node:crypto (built-in AES-256-GCM, no additional package)
  patterns:
    - AES-256-GCM with 96-bit random IV + auth tag for credential encryption at rest; key from env (T-03-01)
    - D-13 all-day split: isDate=true → dtstartDate (Date@00:00Z), dtstartUtc=null; timed → dtstartUtc, dtstartDate=null
    - ctag/syncToken null-defensive: davCal.ctag ?? davCal.syncToken ?? null (Pitfall 6)
    - runPoll exported for Vitest injection via vi.mock; startBrokerPoller wraps in cron schedule
    - broker module is the sole importer of tsdav and credentials (hard boundary per D-09)

key-files:
  created:
    - apps/api/src/broker/crypto.ts (encryptPassword / decryptPassword, AES-256-GCM, node:crypto)
    - apps/api/src/broker/client.ts (createFastmailClient, FastmailClient type alias)
    - apps/api/src/broker/sync.ts (syncCalendar: upsert calendars + calendarEvents, ical.js parse)
    - apps/api/src/broker/poller.ts (startBrokerPoller / runPoll, node-cron, ctag detection)
    - apps/api/src/routes/events.ts (eventsRouter: GET / reads calendarEvents cache)
    - apps/api/tests/broker/crypto.test.ts (roundtrip, IV-uniqueness, tamper-detection)
    - apps/api/tests/broker/sync.test.ts (timed/all-day split, UID idempotency, db mock)
    - apps/api/tests/broker/poller.test.ts (ctag skip, ctag change, first sync, decrypt failure, multi-credential)
  modified:
    - .env.example (APP_PASSWORD_ENCRYPTION_KEY with generator comment)

key-decisions:
  - 'Store dtstartDate as JS Date at T00:00:00Z (not raw string): Drizzle date column expects a Date or null; ical.js toString().slice(0,10) gives the YYYY-MM-DD, appending T00:00:00Z avoids TZ ambiguity'
  - 'Export runPoll separately from startBrokerPoller: lets tests invoke one poll cycle synchronously with vi.mock injected deps, avoiding real cron schedule in tests'
  - 'ctag skip condition: null ctag means first sync (no row stored) → must always sync; only skip when both sides have a non-null matching ctag'
  - "Per-credential try/catch in runPoll: one corrupted or expired credential must not block other members' calendars from syncing"
  - 'events route imports db but never tsdav or crypto: enforces broker hard boundary (T-03-02)'

patterns-established:
  - 'Pattern: broker boundary — tsdav and credentials are imported exclusively under apps/api/src/broker/; routes never touch Fastmail I/O'
  - 'Pattern: D-13 dtstart split — use ical.js ICAL.Time.isDate to route all-day vs timed into separate nullable columns'
  - 'Pattern: ctag null-defence — always use davCal.ctag ?? davCal.syncToken ?? null; Fastmail may return either field'

requirements-completed: [CAL-01]

# Metrics
duration: ~multi-session
completed: '2026-06-04'
---

# Phase 01 Plan 03: CalDAV Broker Slice — Summary

**AES-256-GCM credential encryption, tsdav CalDAV broker (PROPFIND + REPORT), ical.js VEVENT sync with D-13 all-day DATE handling, node-cron 5-min ctag poller, and /api/events cache route — 24 tests green across all 5 api test files.**

## Performance

- **Duration:** Multi-session (interrupted + resumed)
- **Completed:** 2026-06-04
- **Tasks:** 3 of 3 complete
- **Files modified:** 9

## Accomplishments

- AES-256-GCM helpers encrypt app passwords at rest with 96-bit random IV; GCM auth tag detects tampering; key comes from APP_PASSWORD_ENCRYPTION_KEY env (T-03-01)
- tsdav broker: createFastmailClient performs service-discovery round-trip once per credential; syncCalendar issues REPORT, parses each VCALENDAR with ical.js, upserts calendars + calendarEvents with correct D-13 all-day DATE split
- 5-min node-cron poller: loads all member_credentials, decrypts each password, fetches calendars, skips syncCalendar when ctag is unchanged (no DB write, no extra Fastmail round-trip); handles per-credential errors gracefully
- GET /api/events serves the calendarEvents cache — no live Fastmail call per request; broker boundary enforced (no tsdav import in routes)
- 24 unit tests pass (crypto: 3, sync: 6, poller: 5, health: 2, user: 8)

## Task Commits

Each task committed with TDD RED → GREEN cycle:

1. **Task 1 RED — AES-256-GCM crypto tests** — `04d7c23` (test)
2. **Task 1 GREEN — crypto.ts implementation** — `d6d9120` (feat)
3. **Task 2 RED — syncCalendar tests (initial)** — `ae21541` (test)
4. **Task 2 RED refinement — richer db mock** — `90b9929` (test)
5. **Task 2 GREEN — client + sync + events route** — `dd02207` (feat)
6. **Task 3 RED — poller ctag tests** — `1b3ea4e` (test)
7. **Task 3 GREEN — poller implementation** — `23b8e53` (feat)

## Files Created/Modified

- `apps/api/src/broker/crypto.ts` — encryptPassword / decryptPassword using node:crypto aes-256-gcm; 96-bit IV; JSON payload {iv, authTag, ciphertext} as hex
- `apps/api/src/broker/client.ts` — createFastmailClient(email, appPassword) → tsdav DAVClient; FastmailClient type alias
- `apps/api/src/broker/sync.ts` — syncCalendar: upserts calendars row, fetches REPORT objects, ical.js parses VEVENTs, upserts calendarEvents with D-13 split; onDuplicateKeyUpdate on calendarId+uid
- `apps/api/src/broker/poller.ts` — startBrokerPoller (node-cron _/5 _ \* \* \*) + runPoll (exported for tests); per-credential try/catch
- `apps/api/src/routes/events.ts` — eventsRouter GET / reads from db.select().from(calendarEvents); no tsdav import
- `apps/api/tests/broker/crypto.test.ts` — roundtrip, IV uniqueness, tamper-throws
- `apps/api/tests/broker/sync.test.ts` — timed dtstart_utc, all-day dtstart_date, same-UID idempotency
- `apps/api/tests/broker/poller.test.ts` — unchanged ctag skip, changed ctag sync, first-sync, decrypt-failure resilience, multi-credential
- `.env.example` — APP_PASSWORD_ENCRYPTION_KEY with generator comment

## Decisions Made

- Store dtstartDate as `new Date(isoDate + 'T00:00:00Z')` rather than a raw string: Drizzle's date column serialises a JS Date correctly to a DATE field without time component ambiguity
- Export `runPoll` from poller.ts alongside `startBrokerPoller`: test isolation requires a synchronous one-shot cycle; cron wrapping is a one-liner in `startBrokerPoller`
- ctag skip condition is `currentCtag !== null && currentCtag === knownCtag`: a null ctag on either side means unknown or first sync — must call syncCalendar
- Per-credential `try/catch` in `runPoll`: one bad credential (expired password, network error) must not prevent other members' calendars from syncing

## Deviations from Plan

None — plan executed exactly as written. The implementation was drafted by the interrupted agent before session end; it was verified by running the full test suite (24/24 passing) with no fixes required.

## Issues Encountered

None — the draft poller.ts written by the interrupted agent passed all tests on first run after resumption.

## User Setup Required

Add to `.env`:

```
APP_PASSWORD_ENCRYPTION_KEY=<64-char hex>  # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```

No external service configuration required for this plan. Live Fastmail integration (real credentials, real PROPFIND) is deferred to Plan 04.

## Known Stubs

- `GET /api/events` is implemented but not yet mounted in `index.ts` — mounting happens in Plan 04 alongside broker startup wiring.
- Live Fastmail CalDAV integration (real PROPFIND against broker@fastmail.com) and personal-calendar ACL spike are deferred to Plan 04.

## Threat Surface Scan

No new surface beyond the plan's threat model:

- T-03-01: AES-256-GCM with 96-bit IV + auth tag — implemented in crypto.ts
- T-03-02: /api/events reads cache only, no tsdav import in routes/events.ts
- T-03-03: GCM auth tag verified on decrypt; tampered ciphertext throws (test asserts this)
- T-03-04: No console.log of decrypted password or encryption key in poller.ts or client.ts
- T-03-05: Only server-returned objects cached (rawVevent = obj.data verbatim)

## Self-Check

- `apps/api/src/broker/crypto.ts` exists: FOUND
- `apps/api/src/broker/client.ts` exists: FOUND
- `apps/api/src/broker/sync.ts` exists: FOUND
- `apps/api/src/broker/poller.ts` exists: FOUND
- `apps/api/src/routes/events.ts` exists: FOUND
- Commits 04d7c23, d6d9120, ae21541, 90b9929, dd02207, 1b3ea4e, 23b8e53: all in git log

## Self-Check: PASSED

---

_Phase: 01-foundation-broker-spike_
_Completed: 2026-06-04_
