Files
familysync/.planning/phases/01-foundation-broker-spike/01-04-SUMMARY.md
T
Lucas Berger 22a0f05170 docs(01-04): complete integration+gate plan summary; add CAL-08 decision template
- 01-04-SUMMARY.md: code-complete; Task 1 committed (48f90ce); Tasks 2+3 are live-gate checkpoints with exact human-action steps
- CAL-08-DECISION.md: template for human to fill in after running apps/api/src/broker/spike.ts against real Fastmail credentials
2026-06-04 11:18:04 -04:00

12 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions duration completed
01-foundation-broker-spike 04 integration
hono
sse
caldav
react
typescript
pwa
spike
pangolin
01-02 (OIDC middleware, meRouter, upsertUser, fetchMe)
01-03 (eventsRouter, startBrokerPoller, broker module)
sseRouter
GET /api/sse/heartbeat (streamSSE, 10s interval) — Pangolin SSE smoke test
Full bootstrap
/callback → /health → /api/* (oidcAuthMiddleware) → /api/me + /api/events + /api/sse → startBrokerPoller → static
CAL-08 spike script
apps/api/src/broker/spike.ts — enumerate Fastmail calendar collections
EventProof component
renders first cached event from /api/events (broker proof, CAL-01)
fetchEvents() typed PWA API client
Landing page
member name+color + broker-proof event (or empty state)
Phase 2+ (live integration confirmed; SSE transport decision for Phase 4)
added patterns
hono/streaming (streamSSE) — SSE streaming helper, already a peer dep of hono
ical.js@2.2.1 added to apps/pwa (already in apps/api; needed for EventProof summary parsing)
SSE auth: sseRouter mounted under /api/sse, behind oidcAuthMiddleware — no unauthenticated SSE access (T-04-01)
Bootstrap order: /callback → /health (pre-guard) → app.use(/api/*, oidcAuthMiddleware) → /api/me → /api/events → /api/sse → startBrokerPoller → serveStatic
Spike pattern: env-var credentials (FASTMAIL_EMAIL, FASTMAIL_APP_PASSWORD) → createFastmailClient → fetchCalendars → print URLs; never log password (T-04-04)
EventProof: tries ical.js parse for SUMMARY field; falls back to 'Untitled event' on parse failure — resilient to malformed VEVENT blobs
created modified
apps/api/src/routes/sse.ts (sseRouter, GET /heartbeat using streamSSE)
apps/api/src/broker/spike.ts (CAL-08 spike
enumerate Fastmail calendars)
apps/pwa/src/components/EventProof.tsx (React Query ['events'], renders first event or empty state)
.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md (decision template — human fills in after spike)
apps/api/src/index.ts (final bootstrap
eventsRouter + sseRouter mounted, startBrokerPoller called)
apps/pwa/src/api/client.ts (added fetchEvents() with typed CalendarEvent/EventsResponse)
apps/pwa/src/App.tsx (renders MemberBadge + EventProof on landing page)
apps/pwa/package.json (added ical.js@2.2.1 dependency)
ical.js added to PWA for VEVENT summary parsing in EventProof: EventProof parses rawVevent to extract the SUMMARY field for a human-readable event title; ical.js is already approved and used in the API — the same package at the same version
spike.ts uses tsx (dev-only runner) via pnpm exec: tsx is accepted per T-04-SC; spike is not imported by the API or Docker image
CAL-08-DECISION.md committed as a template now, human-filled after running spike: the file must contain 'Decision:' to satisfy the artifact spec; template pre-populates the structure
~3min (code tasks only; live verification pending) 2026-06-04

Phase 01 Plan 04: Integration + Gate Slice — Summary

Full Hono bootstrap wired (broker + all routes), SSE heartbeat endpoint for Pangolin smoke test, CAL-08 spike script, and EventProof landing page component. Code-complete. Live verification (CAL-08 spike + Authelia login + Pangolin SSE smoke) pending human action.

Performance

  • Duration: ~3 min (code tasks)
  • Started: 2026-06-04T15:13:15Z
  • Completed: 2026-06-04 (code-complete)
  • Tasks: 1 of 3 complete (Tasks 2 + 3 are live-gate checkpoints)
  • Files modified/created: 8

Accomplishments

Task 1 (implemented and committed):

  • apps/api/src/routes/sse.ts: sseRouter with GET /heartbeat using streamSSE — emits heartbeat events every 10s until stream.aborted; mounted under /api/sse behind oidcAuthMiddleware (T-04-01)
  • apps/api/src/index.ts: final bootstrap — /callback/health (pre-guard) → app.use('/api/*', oidcAuthMiddleware())/api/me/api/events/api/ssestartBrokerPoller()serveStatic
  • apps/api/src/broker/spike.ts: CAL-08 spike script — reads FASTMAIL_EMAIL + FASTMAIL_APP_PASSWORD from env, calls createFastmailClientfetchCalendars(), prints each calendar's URL, displayName, ctag, syncToken
  • apps/pwa/src/api/client.ts: fetchEvents() with CalendarEvent and EventsResponse types
  • apps/pwa/src/components/EventProof.tsx: React Query ['events']fetchEvents() → renders first event's title (ical.js-parsed SUMMARY) + date, or "No cached events yet" empty state
  • apps/pwa/src/App.tsx: renders MemberBadge (member name + color) and EventProof together on the landing page
  • All 24 API unit tests green; tsc --noEmit clean in both apps/api and apps/pwa

Task Commits

  1. Task 1: 48f90cefeat(01-04): wire broker + routes into bootstrap, add SSE endpoint, EventProof

Files Created/Modified

  • apps/api/src/routes/sse.tssseRouter, GET /heartbeat (streamSSE, 10s heartbeat, T-04-01)
  • apps/api/src/broker/spike.ts — CAL-08 spike: env creds → createFastmailClient → fetchCalendars → print URLs
  • apps/api/src/index.ts — final bootstrap wiring: all 3 API routes + poller start
  • apps/pwa/src/components/EventProof.tsx — broker-proof component, React Query, ical.js SUMMARY extraction
  • apps/pwa/src/api/client.tsfetchEvents() added with typed shapes
  • apps/pwa/src/App.tsx — MemberBadge + EventProof on landing page
  • apps/pwa/package.json — ical.js@2.2.1 added
  • .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md — decision template (fill in after spike)

Live Verification Pending (Tasks 2 + 3)

These are checkpoint:human-action tasks that require real infrastructure:

Task 2: CAL-08 Spike — Confirm app password reads shared + personal calendars

What to do:

  1. Generate a Fastmail app password for Lucas's account:

    • Fastmail Settings → Privacy & Security → App Passwords → scope: "Mail, Contacts & Calendars"
  2. Run the spike:

    cd apps/api
    FASTMAIL_EMAIL=lucas@fastmail.com \
    FASTMAIL_APP_PASSWORD=<app-password> \
    pnpm exec tsx src/broker/spike.ts
    
  3. Confirm in output: (a) shared family calendar collection URL appears; (b) Lucas's personal calendar URL appears. Record both.

  4. To insert the credential into the DB for the broker to use (one-off node script):

    # First ensure APP_PASSWORD_ENCRYPTION_KEY is set in .env
    cd apps/api
    APP_PASSWORD_ENCRYPTION_KEY=<64-char-hex> node -e "
      import('./src/broker/crypto.js').then(({ encryptPassword }) => {
        console.log(encryptPassword('<app-password>'))
      })
    "
    # Then insert the encrypted value into member_credentials for Lucas's user row
    
  5. Start the stack and verify at least one event lands in calendar_events:

    docker compose exec mariadb mariadb -ufamilysync -p<pw> familysync \
      -e "SELECT id, uid, all_day FROM calendar_events LIMIT 5;"
    
  6. Fill in .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md with:

    • Decision: GO or Decision: NO-GO + fallback
    • Discovered calendar URLs
    • Which ctag/syncToken field Fastmail actually returned
  7. Reload the app and confirm landing page shows one real cached event.

Resume signal: Type "approved" once CAL-08-DECISION.md records GO (or NO-GO + fallback) and at least one event row is cached.


Task 3: Live Deployment — Authelia login + Pangolin SSE smoke test

What to do:

  1. Register FamilySync OIDC client in Authelia (see 01-02-SUMMARY.md for full YAML):

    authelia crypto hash --sha512 <your-plain-client-secret>
    

    Add the client block to Authelia's configuration.yml and reload.

  2. Set env vars in .env:

    OIDC_AUTH_SECRET=<openssl rand -base64 32>
    OIDC_ISSUER=https://auth.<domain>
    OIDC_CLIENT_ID=familysync
    OIDC_CLIENT_SECRET=<plain-secret>
    OIDC_REDIRECT_URI=https://familysync.<domain>/callback
    OIDC_AUTH_EXTERNAL_URL=https://familysync.<domain>
    
  3. Expose FamilySync through Pangolin under the SAME parent domain as Authelia (same-site cookie requirement — Pitfall 1).

    docker compose up -d
    
  4. AUTH-01: From external network → open https://familysync. → confirm Authelia redirect → log in → see name + color + cached event.

  5. AUTH-02: Close browser, reopen URL → confirm no re-login.

  6. Repeat for second member (wife) → confirm distinct color (AUTH-03).

  7. SSE smoke test (D-08) — run from external network with a valid session cookie:

    curl -N -b "session=<cookie-value>" https://familysync.<domain>/api/sse/heartbeat
    

    Keep open 5+ minutes. Record: PASS (events keep arriving) or FAIL (stream cut by proxy).

  8. Record SSE smoke result in SUMMARY (update this file) for Phase 4 transport decision.

Resume signal: Type "approved" once both members log in over the public URL, sessions persist, and SSE smoke result (PASS/FAIL) is recorded.


SSE Smoke Test Result

Result: PENDING — to be filled in after Task 3.

If PASS: SSE confirmed for Phase 4 real-time list sync. If FAIL: investigate Pangolin idle-timeout config; note for Phase 4.

Deviations from Plan

[Rule 2 - Missing dependency] ical.js added to PWA for EventProof summary parsing

  • Found during: Task 1 (EventProof.tsx implementation)
  • Issue: EventProof.tsx parses rawVevent strings using ical.js to extract human-readable SUMMARY. ical.js was only in apps/api; EventProof runs in the browser.
  • Fix: Added ical.js@2.2.1 to apps/pwa/package.json. Same package, same version, already approved in the legitimacy audit.
  • Files modified: apps/pwa/package.json, pnpm-lock.yaml
  • Commit: 48f90ce

Known Stubs

CAL-08-DECISION.md is committed as a template with Decision: [PENDING]. The actual go/no-go decision must be filled in by the human after running the spike against real Fastmail credentials. This is the expected state for a code-complete + pending-live-verification plan.

Threat Surface Scan

All surfaces within Plan 04 threat model:

  • T-04-01 (SSE auth): sseRouter mounted under /api/sse behind oidcAuthMiddleware — confirmed
  • T-04-02 (SSE payload): heartbeat carries only { ts, id } — no user data or secrets
  • T-04-03 (same-site cookies): operator must expose FamilySync under same parent domain as Authelia — documented in Task 3 steps
  • T-04-04 (spike credentials): spike reads password from env, prints only calendar URLs, never echoes the password — confirmed in spike.ts
  • T-04-05 (client_secret): plain secret in OIDC_CLIENT_SECRET env; Authelia YAML holds hash — documented in Task 3 steps
  • T-04-SC (tsx dev runner): spike.ts not imported by any production module; dev-only

Self-Check: PASSED

  • apps/api/src/routes/sse.ts exists and exports sseRouter: FOUND
  • apps/api/src/broker/spike.ts exists: FOUND
  • apps/api/src/index.ts contains startBrokerPoller(): FOUND
  • apps/api/src/index.ts contains '/api/events' and '/api/sse': FOUND
  • apps/pwa/src/components/EventProof.tsx exists: FOUND
  • apps/pwa/src/api/client.ts contains fetchEvents: FOUND
  • Commit 48f90ce: FOUND
  • tsc --noEmit clean (apps/api): PASSED
  • tsc --noEmit clean (apps/pwa): PASSED
  • pnpm vitest run 24/24 tests green: PASSED

Phase: 01-foundation-broker-spike Completed (code): 2026-06-04 — Live verification pending