Files
familysync/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-03-SUMMARY.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

9.2 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed duration completed
01-foundation-broker-spike 03 api
caldav
ical.js
tsdav
node-cron
aes-256-gcm
drizzle
mariadb
vitest
phase provides
01-foundation-broker-spike/01-01 db singleton, Drizzle schema (users, memberCredentials, calendars, calendarEvents)
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
01-04 (spike — mounts events route, calls startBrokerPoller from index.ts, live Fastmail test)
added patterns
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)
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)
created modified
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)
.env.example (APP_PASSWORD_ENCRYPTION_KEY with generator comment)
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)
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
CAL-01
~multi-session 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 tests04d7c23 (test)
  2. Task 1 GREEN — crypto.ts implementationd6d9120 (feat)
  3. Task 2 RED — syncCalendar tests (initial)ae21541 (test)
  4. Task 2 RED refinement — richer db mock90b9929 (test)
  5. Task 2 GREEN — client + sync + events routedd02207 (feat)
  6. Task 3 RED — poller ctag tests1b3ea4e (test)
  7. Task 3 GREEN — poller implementation23b8e53 (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