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
This commit is contained in:
@@ -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*
|
||||||
Reference in New Issue
Block a user