Files
familysync/.planning/milestones/v1.0-phases/01-foundation-broker-spike/01-04-PLAN.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

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-foundation-broker-spike 04 execute 3
01-02
01-03
apps/api/src/routes/sse.ts
apps/api/src/index.ts
apps/api/src/broker/spike.ts
apps/pwa/src/App.tsx
apps/pwa/src/components/EventProof.tsx
apps/pwa/src/api/client.ts
.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md
false
CAL-08
CAL-01
AUTH-01
AUTH-02
truths artifacts key_links
The full app is wired: broker poller starts on boot, /api/events + /api/me + /api/sse are mounted behind OIDC, /health public
The landing page shows the logged-in member (name + color) AND one real cached Fastmail event as broker proof
A CAL-08 spike confirms Lucas's app password reads BOTH the shared family calendar and his personal calendar; the go/no-go decision is recorded in a committed doc
GET /api/sse/heartbeat streams events over the real Pangolin tunnel for 5+ minutes without the proxy closing the stream (smoke test result recorded)
Both members can authenticate through Authelia over the public URL and land on the shell (verified live)
path provides exports
apps/api/src/routes/sse.ts GET /api/sse/heartbeat (streamSSE) — Pangolin pass-through smoke test
sseRouter
path provides
apps/api/src/broker/spike.ts CAL-08 spike script: createFastmailClient → fetchCalendars → print URLs
path provides contains
.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md Documented go/no-go for personal-calendar overlay Decision:
path provides
apps/pwa/src/components/EventProof.tsx Renders one cached event from /api/events
from to via pattern
apps/api/src/index.ts apps/api/src/broker/poller.ts startBrokerPoller on boot startBrokerPoller()
from to via pattern
apps/api/src/index.ts apps/api/src/routes/events.ts app.route /api/events /api/events
from to via pattern
apps/pwa/src/components/EventProof.tsx /api/events React Query fetch api/events
Close the Phase 1 walking skeleton end to end: mount the events + SSE routers and start the broker poller in the Hono bootstrap, render one real cached Fastmail event next to the logged-in member on the landing page (broker proof), run the CAL-08 personal-calendar spike and record a go/no-go decision, and verify the whole stack live through the real Pangolin tunnel (Authelia login + SSE pass-through smoke test).

After this plan a real user reaches the public URL, authenticates through Authelia, and sees a single screen confirming: their name, their assigned color, and one real event read from Fastmail — the complete proof that auth + broker + cache + tunnel all work together. This satisfies all five Phase 1 success criteria.

Purpose: This is the integration + gate slice. CAL-08 is the project's highest-risk go/no-go; the SSE smoke test de-risks Phase 4 transport. Both are decided here, with documented outcomes. Output: fully wired app, landing page with member + event proof, CAL-08 decision doc, SSE smoke-test result, live deployment confirmation.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @./CLAUDE.md @.planning/phases/01-foundation-broker-spike/01-CONTEXT.md @.planning/phases/01-foundation-broker-spike/01-RESEARCH.md @.planning/phases/01-foundation-broker-spike/01-VALIDATION.md @.planning/phases/01-foundation-broker-spike/01-02-SUMMARY.md @.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md

<artifacts_produced>

Artifacts this phase produces (Plan 04)

New files: apps/api/src/routes/sse.ts, apps/api/src/broker/spike.ts, apps/pwa/src/components/EventProof.tsx, .planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md.

New exported symbols: sseRouter (routes/sse.ts), EventProof component, fetchEvents (added to pwa/src/api/client.ts).

New route paths: GET /api/sse/heartbeat; mounts (from Plans 02/03) finalized in index.ts: /api/me, /api/events, /api/sse.

Modified: apps/api/src/index.ts (mount events/sse routers, startBrokerPoller on boot), apps/pwa/src/App.tsx (render EventProof), apps/pwa/src/api/client.ts (fetchEvents). </artifacts_produced>

Task 1: Wire broker + routes into bootstrap, add SSE smoke endpoint, render event proof apps/api/src/routes/sse.ts, apps/api/src/index.ts, apps/pwa/src/api/client.ts, apps/pwa/src/components/EventProof.tsx, apps/pwa/src/App.tsx - .planning/phases/01-foundation-broker-spike/01-RESEARCH.md (§ "Pattern 5: Pangolin SSE Smoke Test" streamSSE; § "Hono app bootstrap with all middleware" — full mount order) - .planning/phases/01-foundation-broker-spike/01-CONTEXT.md (D-08 SSE smoke test; Claude's Discretion: landing page = thin shell showing one cached event as broker proof) - apps/api/src/index.ts (current bootstrap from Plans 01/02) - apps/api/src/routes/events.ts (eventsRouter from Plan 03), apps/api/src/broker/poller.ts (startBrokerPoller from Plan 03), apps/api/src/routes/me.ts (meRouter from Plan 02) Create `src/routes/sse.ts` exporting `sseRouter` per RESEARCH Pattern 5: GET /heartbeat using `streamSSE` from 'hono/streaming' — emit a `heartbeat` event every 10s with `{ ts, id }` until `stream.aborted`. Mount under `/api/sse` (so it sits behind oidcAuthMiddleware per the SSE-auth threat).
Update `src/index.ts` to the final bootstrap (RESEARCH "Hono app bootstrap"): order = `/callback` (processOAuthCallback) → `/health` (public, Plan 01) → `app.use('/api/*', oidcAuthMiddleware())` → `app.route('/api/me', meRouter)` → `app.route('/api/events', eventsRouter)` → `app.route('/api/sse', sseRouter)` → `startBrokerPoller()` → serveStatic(./public) → `serve({ port: 3000 })`. Confirm /health stays before the /api guard.

PWA: add `fetchEvents()` to `src/api/client.ts` (GET /api/events, credentials include). Create `src/components/EventProof.tsx`: React Query `['events']`, render the first event's title/date (parse from the returned shape) or an empty-state "No cached events yet". Update `App.tsx` to render member (name + color, from Plan 02) AND `<EventProof />` together — the single broker-proof landing screen (Claude's Discretion landing page).
cd apps/api && pnpm exec tsc --noEmit && grep -q "startBrokerPoller()" src/index.ts && grep -q "'/api/events'" src/index.ts && grep -q "'/api/sse'" src/index.ts && grep -q "streamSSE" src/routes/sse.ts - `src/routes/sse.ts` exports `sseRouter` using `streamSSE`; heartbeat every 10s - `src/index.ts` mounts /api/me, /api/events, /api/sse behind oidcAuthMiddleware, keeps /callback and /health before the guard, and calls `startBrokerPoller()` on boot - `apps/pwa/src/components/EventProof.tsx` fetches `/api/events` and renders an event or empty state - `apps/pwa/src/App.tsx` renders both the member (name+color) and EventProof - `pnpm exec tsc --noEmit` exits 0; `pnpm vitest run` (all prior unit tests) green tsc clean; full route mount + poller boot; SSE heartbeat endpoint; landing page shows member + cached event proof. Task 2: [BLOCKING] CAL-08 spike — confirm app password reads shared + personal calendars, record go/no-go The CAL-08 spike script (apps/api/src/broker/spike.ts) using createFastmailClient → fetchCalendars to enumerate calendar collections for Lucas's account. Phase 1 uses ONLY Lucas's app password (D-03); the wife's Fastmail-hosted personal calendar (D-01) is added in Phase 2 — CAL-08 is structurally proven by the per-member N-credential model without her credential present. 1. Generate a Fastmail app password for Lucas's account (Fastmail Settings → Privacy & Security → App Passwords → "Mail, Contacts & Calendars" scope). NOTE: this is a human-only step — no API exists to mint a Fastmail app password. 2. Insert it encrypted into member_credentials for Lucas (use a one-off node script calling encryptPassword from Plan 03, or run the spike script with the app password passed via env for the read-only enumeration). 3. Run the spike: `cd apps/api && APP_PASSWORD_ENCRYPTION_KEY= FASTMAIL_EMAIL= FASTMAIL_APP_PASSWORD= pnpm exec tsx src/broker/spike.ts` (the script must print every returned davCal.url + displayName + whether ctag/syncToken is present). 4. Confirm in the output: (a) the SHARED family calendar collection appears; (b) Lucas's PERSONAL calendar collection appears. Record both URLs. 5. Trigger one real sync (let the poller run or call runPoll once) and confirm at least one event row lands in calendar_events: `docker compose exec mariadb mariadb -ufamilysync -p familysync -e "SELECT id, uid, all_day FROM calendar_events LIMIT 5;"`. 6. Write `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` with: `Decision: GO` (expected per D-05) or `Decision: NO-GO` + the fallback (shared-family-only for v1, CAL-08 → v1.x). Include the discovered calendar URLs and which ctag/syncToken fields Fastmail actually returned (resolves RESEARCH Open Questions 1 + Assumptions A1/A2/A3). 7. Reload the app over its URL and confirm the landing page shows one real cached event. Type "approved" once CAL-08-DECISION.md records GO (or NO-GO + fallback), at least one event row is cached, and the landing page shows it — or describe the failure. Task 3: [BLOCKING] Live deployment — Authelia login (both members) + Pangolin SSE smoke test The full stack deployed through the real Pangolin tunnel + Authelia from day one (D-07 — validate OIDC redirect/HTTPS/same-site cookies in the real topology): Authelia OIDC login, /api/me identity+color, and the /api/sse/heartbeat endpoint. 1. Register the FamilySync OIDC client in Authelia using the YAML captured in 01-02-SUMMARY (client_id familysync, redirect_uris https://familysync./callback, require_pkce true S256, client_secret_basic, grant types authorization_code+refresh_token, scopes openid/profile/email — NO groups). Hash the secret with `authelia crypto hash --sha512 ` for the YAML; put the PLAIN secret in the app's OIDC_CLIENT_SECRET env (Pitfall 7). Reload Authelia. 2. Expose FamilySync through Pangolin under the SAME parent domain as Authelia (Pitfall 1 — same-site cookies). Set OIDC_AUTH_EXTERNAL_URL + OIDC_REDIRECT_URI to the public URL. Bring up the stack: `docker compose up -d`. 3. AUTH-01: From an external network, open https://familysync. → confirm redirect to Authelia authorize endpoint → log in → land on the shell with name + color, NO Fastmail credential prompt. 4. AUTH-02: Fully close the browser, reopen the URL → confirm no re-login (session persisted). Optionally wait past the access-token refresh interval and confirm /api/me still 200s. 5. Repeat step 3 for the SECOND member (wife) — confirm she gets a DISTINCT color (AUTH-03 across members). 6. SSE smoke (D-08): from an external network run `curl -N https://familysync./api/sse/heartbeat` (with an authenticated session cookie, since SSE is behind /api/*). Keep it open 5+ minutes; confirm heartbeat events keep arriving and the proxy does not cut the stream. Record PASS/FAIL in the SUMMARY (FAIL → note Pangolin idle-timeout for Phase 4; per RESEARCH Open Question 3). Type "approved" once both members can log in over the public URL with distinct stable colors, sessions persist across restart, and the SSE smoke result (PASS/FAIL) is recorded — or describe the failure.

<threat_model>

Trust Boundaries

Boundary Description
Public internet → Pangolin → Hono Untrusted; only authenticated /api/* requests proceed; /health + /callback are the only pre-auth routes
SSE stream (browser ↔ /api/sse) Long-lived; must remain behind OIDC, must not leak data beyond heartbeat
Spike credential handling Lucas's app password used once for enumeration; stored encrypted, never logged/committed

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-04-01 Elevation of Privilege /api/sse/heartbeat mitigate Mounted under /api/* behind oidcAuthMiddleware; no unauthenticated SSE access (ASVS V4)
T-04-02 Information Disclosure SSE payload accept Heartbeat carries only { ts, id } — no user data or secrets
T-04-03 Spoofing Same-site session cookie behind Pangolin mitigate FamilySync + Authelia share parent domain (Pitfall 1/17); OIDC_AUTH_EXTERNAL_URL set so redirect_uri matches
T-04-04 Information Disclosure Fastmail app password during spike mitigate Passed via env for one-off enumeration or stored encrypted via Plan 03 crypto; never echoed to logs or committed; spike output prints only calendar URLs/displayNames, never the password
T-04-05 Tampering client_secret plain vs hashed mitigate Plain secret only in OIDC_CLIENT_SECRET env (Pitfall 7); Authelia YAML holds the pbkdf2-sha512 hash; .env never committed
T-04-SC Tampering tsx (dev runner for spike) accept tsx is a widely-used TypeScript runner; spike script is dev-only, not shipped in the Docker image

</threat_model>

- `pnpm exec tsc --noEmit` clean; `pnpm vitest run` full suite green (all Wave 0 tests now filled) - index.ts mounts all routers + starts poller; /health public, /api/* guarded - CAL-08-DECISION.md committed with `Decision: GO|NO-GO` + calendar URLs + ctag/syncToken findings - Live: both members log in over Pangolin with distinct stable colors; session persists across restart - Live: landing page shows member + one real cached event - SSE smoke result (PASS/FAIL) recorded in SUMMARY

<success_criteria>

  • SC1 (AUTH-01): both members authenticate via Authelia over the public URL, land on home, no Fastmail prompt
  • SC2 (AUTH-02): sessions persist across browser restart
  • SC3 (AUTH-03): each member has a stable distinct color
  • SC4 (CAL-01): broker fetched + cached ≥1 real event; shown on the landing page
  • SC5 (CAL-08): go/no-go decision documented in CAL-08-DECISION.md
  • Bonus (D-08): Pangolin SSE pass-through smoke result recorded for Phase 4 </success_criteria>
Create `.planning/phases/01-foundation-broker-spike/01-04-SUMMARY.md` when done. Include the SSE smoke-test result and a pointer to CAL-08-DECISION.md.