From 191cea70cce6da25c17b37a91dd3ccb72a47e5c2 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 4 Jun 2026 11:10:02 -0400 Subject: [PATCH] docs(01-03): complete CalDAV broker slice plan summary - covers all 3 tasks: AES-256-GCM crypto, broker client/sync/events, ctag poller - documents D-13 dtstart split decision and ctag null-defence pattern - notes live Fastmail integration + personal-calendar ACL deferred to plan 04 --- .../01-03-SUMMARY.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 .planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md diff --git a/.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md b/.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md new file mode 100644 index 0000000..db261c5 --- /dev/null +++ b/.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md @@ -0,0 +1,161 @@ +--- +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*