startBrokerPoller (node-cron 5-min) with ctag change detection
startBrokerPoller
path
provides
exports
apps/api/src/routes/events.ts
GET /api/events → cached events from DB
eventsRouter
from
to
via
pattern
apps/api/src/broker/poller.ts
apps/api/src/broker/crypto.ts
decryptPassword before client creation
decryptPassword(
from
to
via
pattern
apps/api/src/broker/sync.ts
apps/api/src/db/client.ts
calendarEvents upsert
calendarEvents
from
to
via
pattern
apps/api/src/routes/events.ts
apps/api/src/db/client.ts
cache read (no live CalDAV)
from ['"].*db/client
Deliver the CalDAV broker vertical slice: AES-256-GCM encryption for Fastmail app passwords, a tsdav-based broker that discovers calendars (PROPFIND) and fetches events (REPORT), an ical.js sync that caches VEVENTs into MariaDB with correct all-day DATE handling, a 5-minute node-cron poller with ctag change detection, and `GET /api/events` reading the cache.
After this plan the backend can read a real Fastmail calendar (given a stored credential) and serve cached events from /api/events — the data half of "see a real cached event on the landing page." The final wiring (broker startup + events route mount in index.ts, event display in the PWA) lands in Plan 04 to keep this plan parallel with the auth slice.
Purpose: CAL-01 — read shared Fastmail calendar via broker and cache locally. The broker module is the sole holder of Fastmail I/O (hard boundary); nothing else imports tsdav or credentials.
Output: crypto helper, broker client/sync/poller, /api/events router, all unit-tested.
New route paths: GET /api/events (mounted in Plan 04).
New env vars: APP_PASSWORD_ENCRYPTION_KEY (64-char hex = 32 bytes).
</artifacts_produced>
Task 1: AES-256-GCM app-password encryption (CAL-01 security)
apps/api/src/broker/crypto.ts, apps/api/tests/broker/crypto.test.ts, .env.example
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 4: AES-GCM App-Password Encryption" — full encrypt/decrypt + key generation)
- .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-04 encrypted-at-rest, key from env, backend-only)
- apps/api/tests/broker/crypto.test.ts (RED stub from Plan 01 — fill GREEN here)
- encryptPassword(plaintext) then decryptPassword(result) returns the original plaintext (lossless roundtrip)
- Two encryptPassword calls on the same plaintext produce DIFFERENT ciphertext (fresh random 96-bit IV each time)
- decryptPassword throws if the auth tag is tampered (GCM integrity)
- The stored payload is JSON with iv, authTag, ciphertext (all hex)
Create `src/broker/crypto.ts` per RESEARCH Pattern 4: read `APP_PASSWORD_ENCRYPTION_KEY` (64-char hex → 32-byte Buffer). `encryptPassword(plaintext)`: randomBytes(12) IV, createCipheriv('aes-256-gcm', KEY, iv), update+final, getAuthTag, return JSON.stringify({iv, authTag, ciphertext} as hex). `decryptPassword(stored)`: parse JSON, createDecipheriv, setAuthTag, update+final → utf8. Use `node:crypto` (built into Node 22) — do NOT hand-roll a cipher. Never log plaintext or the key.
Add `APP_PASSWORD_ENCRYPTION_KEY` to `.env.example` with a comment showing the generator: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
Fill `tests/broker/crypto.test.ts` GREEN: set a fixed test key in the test, assert (a) roundtrip lossless; (b) two encrypts of same plaintext differ; (c) tampering authTag causes decrypt to throw.
cd apps/api && pnpm vitest run tests/broker/crypto.test.ts --reporter=verbose
- `src/broker/crypto.ts` exports `encryptPassword` and `decryptPassword` using `node:crypto` aes-256-gcm
- tests/broker/crypto.test.ts passes: roundtrip, IV-uniqueness, tamper-detection
- `.env.example` lists `APP_PASSWORD_ENCRYPTION_KEY` with the generator comment
- grep: no `console.log` of plaintext or KEY in crypto.ts
crypto.test.ts green; encrypt/decrypt lossless; IV unique; tamper throws.
Task 2: Broker client + sync (ical.js parse, all-day DATE) + events route (CAL-01)
apps/api/src/broker/client.ts, apps/api/src/broker/sync.ts, apps/api/src/routes/events.ts, apps/api/tests/broker/sync.test.ts
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 3: CalDAV Broker (tsdav)" — createFastmailClient, fetchCalendars, fetchCalendarObjects, syncCalendar with ical.js; § "Pitfall 1" principal URL; § "Pitfall 2/3" all-day DATE)
- .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-09 CalDAV-only via tsdav, app password; D-13 raw VEVENT blob + dtstart_utc, all-day as DATE, only cache server-returned objects)
- apps/api/src/db/schema.ts (calendars, calendarEvents from Plan 01)
- apps/api/tests/broker/sync.test.ts (RED stub from Plan 01 — fill GREEN here)
- syncCalendar given a timed VEVENT writes dtstart_utc (timestamp), dtstart_date NULL, all_day false
- syncCalendar given an all-day VEVENT writes dtstart_date (YYYY-MM-DD), dtstart_utc NULL, all_day true (NEVER coerce DATE to DATETIME — Pitfall 3)
- re-syncing the same UID updates the existing row (onDuplicateKeyUpdate on calendar_id+uid) — no duplicate
- only server-returned objects are cached (raw VEVENT blob stored verbatim — D-13)
Create `src/broker/client.ts` exporting `createFastmailClient(email, appPassword)` per RESEARCH Pattern 3: `createDAVClient({ serverUrl: 'https://caldav.fastmail.com', credentials: {username: email, password: appPassword}, authMethod: 'Basic', defaultAccountType: 'caldav' })`. Note Pitfall 1: tsdav discovery resolves the principal URL `https://caldav.fastmail.com/dav/principals/user/{email}/`. Create one client per credential (Pitfall 3 — discovery is a round-trip); broker module owns lifetime.
Create `src/broker/sync.ts` exporting `syncCalendar(client, davCal, userId)`: upsert the `calendars` row (url, displayName, ctag, syncToken, lastSyncedAt; onDuplicateKeyUpdate). `fetchCalendarObjects` → for each obj: `ICAL.parse` → Component → getFirstSubcomponent('vevent'); read dtstart (ICAL.Time), uid; `allDay = dtstart.isDate`. Upsert into `calendarEvents` keyed on calendar_id+uid: rawVevent = obj.data verbatim, etag, allDay, and per D-13/Pitfall 3 — if allDay: dtstart_date = dtstart.toString().slice(0,10), dtstart_utc = null; else dtstart_utc = dtstart.toJSDate(), dtstart_date = null. Defensive ctag/syncToken: `davCal.ctag ?? davCal.syncToken ?? null` (Pitfall 6).
Create `src/routes/events.ts` exporting `eventsRouter` (Hono): GET / reads from `calendarEvents` via `db` (cache only — NEVER call Fastmail per request, ARCHITECTURE anti-pattern), returns the rows (id, uid, allDay, dtstart_utc, dtstart_date, raw_vevent or a minimal shape). This router is mounted in Plan 04.
Fill `tests/broker/sync.test.ts` GREEN with a mocked tsdav client returning captured raw VEVENT strings (timed + all-day fixtures — Wave 0 fixture requirement). Assert the dtstart_utc vs dtstart_date split, all_day flag, and UID-upsert idempotency.
cd apps/api && pnpm vitest run tests/broker/sync.test.ts --reporter=verbose && pnpm exec tsc --noEmit
- `src/broker/client.ts` exports `createFastmailClient`; serverUrl is caldav.fastmail.com, authMethod 'Basic'
- `src/broker/sync.ts` exports `syncCalendar`; stores rawVevent verbatim and splits all-day → dtstart_date, timed → dtstart_utc
- tests/broker/sync.test.ts passes: timed event → dtstart_utc set + dtstart_date null; all-day → dtstart_date set + dtstart_utc null + all_day true; same-UID re-sync updates not duplicates
- `src/routes/events.ts` exports `eventsRouter`; reads from db only (no `createFastmailClient` import in the route)
- `pnpm exec tsc --noEmit` exits 0
sync.test.ts green; all-day DATE handling correct; events route reads cache only; tsc clean.
Task 3: node-cron poller with ctag change detection (CAL-01)
apps/api/src/broker/poller.ts, apps/api/tests/broker/poller.test.ts
- .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Fetching calendars + ctag polling" poller code; § "Pitfall 4" node-cron v4; § "Pitfall 6" ctag/syncToken null defensiveness)
- .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-13 sync-token with ctag fallback from day one; broker is hard boundary)
- apps/api/src/broker/sync.ts (syncCalendar from Task 2)
- apps/api/src/broker/crypto.ts (decryptPassword from Task 1)
- apps/api/tests/broker/poller.test.ts (RED stub from Plan 01 — fill GREEN here)
- The poll function loads ALL member_credentials (N-credential per-member app password model — D-02), decrypts each app password, creates a client, fetches calendars
- For a calendar whose current ctag equals the stored ctag, syncCalendar is NOT called (skip — no DB write)
- For a calendar with a changed/absent ctag, syncCalendar IS called
- Decryption happens via decryptPassword before client creation (credentials never logged)
Create `src/broker/poller.ts` exporting `startBrokerPoller()` (and an internal `runPoll()` exported for tests). Per RESEARCH poller pattern: `schedule('*/5 * * * *', runPoll)` using node-cron v4 (Pitfall 4 — basic 5-field cron API is stable). `runPoll`: select all `memberCredentials`; for each, `decryptPassword`, `createFastmailClient`, `fetchCalendars`; for each davCal, compare `davCal.ctag ?? davCal.syncToken ?? null` to the stored calendars row ctag — if equal and non-null, `continue` (skip); else `syncCalendar`. Make `runPoll` injectable/testable (accept the db + client factory or use module mocks) so the unit test can assert skip-on-unchanged without hitting Fastmail.
Fill `tests/broker/poller.test.ts` GREEN: mock fetchCalendars to return a calendar with a known ctag matching a stored row → assert syncCalendar spy NOT called; then a changed ctag → assert syncCalendar IS called.
cd apps/api && pnpm vitest run tests/broker/poller.test.ts --reporter=verbose
- `src/broker/poller.ts` exports `startBrokerPoller`; uses node-cron `schedule('*/5 * * * *', ...)`
- poller decrypts via `decryptPassword` before creating a client (grep: `decryptPassword(`)
- tests/broker/poller.test.ts passes: unchanged ctag → no syncCalendar; changed ctag → syncCalendar called
- grep: no logging of decrypted password or app password in poller.ts
poller.test.ts green; ctag skip logic correct; credentials decrypted not logged.
<threat_model>
Trust Boundaries
Boundary
Description
member_credentials (DB) → broker
App passwords stored encrypted; only broker/crypto.ts decrypts; never leaves backend
Broker → Fastmail CalDAV
Outbound Basic auth over TLS; sole holder of Fastmail I/O
Hono /api/events → browser
Returns only cached event data; never credentials or raw app passwords
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-03-01
Information Disclosure
Fastmail app password at rest
mitigate
AES-256-GCM with 96-bit IV + auth tag (crypto.ts); key from APP_PASSWORD_ENCRYPTION_KEY env, never committed/logged (ASVS V6)
T-03-02
Information Disclosure
App password leaking via /api/events
mitigate
events route reads only calendar_events (event data); never joins/returns member_credentials; broker is the only credential reader (D-04)
T-03-03
Tampering
Encrypted-credential integrity
mitigate
GCM auth tag verified on decrypt; tampered ciphertext throws, never silently used
T-03-04
Information Disclosure
Credentials in logs
mitigate
No console logging of decrypted passwords or the encryption key in client.ts / poller.ts
T-03-05
Tampering
Caching client-side event versions
mitigate
Only server-returned objects cached (raw VEVENT verbatim — D-13, Pitfall 14); no write-back in Phase 1
T-03-SC
Tampering
tsdav / ical.js / node-cron installs
accept
All [OK] in RESEARCH § Package Legitimacy Audit (tsdav 3+ yrs official repo, ical.js Mozilla-maintained, node-cron 8+ yrs); no [ASSUMED]/[SUS]/[SLOP]
</threat_model>
- `pnpm exec tsc --noEmit` clean
- crypto.test.ts, sync.test.ts, poller.test.ts all green
- broker module is the only importer of tsdav / credentials (grep: tsdav imported only under src/broker/)
- /api/events reads cache only (no createFastmailClient import in routes/events.ts)
<success_criteria>
App passwords encrypted at rest (AES-256-GCM), lossless roundtrip, tamper-detecting
Broker discovers calendars and syncs VEVENTs into the cache with correct all-day DATE handling
Poller skips unchanged calendars (ctag detection)
/api/events serves cached events without a live Fastmail call
All three unit test files green
</success_criteria>
Create `.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md` when done.