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