Files
familysync/.planning/phases/01-foundation-broker-spike/01-04-PLAN.md
T

193 lines
15 KiB
Markdown

---
phase: 01-foundation-broker-spike
plan: 04
type: execute
wave: 3
depends_on: ["01-02", "01-03"]
files_modified:
- 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
autonomous: false
requirements: [CAL-08, CAL-01, AUTH-01, AUTH-02]
must_haves:
truths:
- "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)"
artifacts:
- path: "apps/api/src/routes/sse.ts"
provides: "GET /api/sse/heartbeat (streamSSE) — Pangolin pass-through smoke test"
exports: ["sseRouter"]
- path: "apps/api/src/broker/spike.ts"
provides: "CAL-08 spike script: createFastmailClient → fetchCalendars → print URLs"
- path: ".planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md"
provides: "Documented go/no-go for personal-calendar overlay"
contains: "Decision:"
- path: "apps/pwa/src/components/EventProof.tsx"
provides: "Renders one cached event from /api/events"
key_links:
- from: "apps/api/src/index.ts"
to: "apps/api/src/broker/poller.ts"
via: "startBrokerPoller on boot"
pattern: "startBrokerPoller\\(\\)"
- from: "apps/api/src/index.ts"
to: "apps/api/src/routes/events.ts"
via: "app.route /api/events"
pattern: "/api/events"
- from: "apps/pwa/src/components/EventProof.tsx"
to: "/api/events"
via: "React Query fetch"
pattern: "api/events"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
</context>
<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>
<tasks>
<task type="auto">
<name>Task 1: Wire broker + routes into bootstrap, add SSE smoke endpoint, render event proof</name>
<files>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</files>
<read_first>
- .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)
</read_first>
<action>
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).
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>tsc clean; full route mount + poller boot; SSE heartbeat endpoint; landing page shows member + cached event proof.</done>
</task>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 2: [BLOCKING] CAL-08 spike — confirm app password reads shared + personal calendars, record go/no-go</name>
<what-built>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.</what-built>
<how-to-verify>
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=<key> FASTMAIL_EMAIL=<lucas@fastmail> FASTMAIL_APP_PASSWORD=<app pw> 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<pw> 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.
</how-to-verify>
<resume-signal>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.</resume-signal>
</task>
<task type="checkpoint:human-action" gate="blocking">
<name>Task 3: [BLOCKING] Live deployment — Authelia login (both members) + Pangolin SSE smoke test</name>
<what-built>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.</what-built>
<how-to-verify>
1. Register the FamilySync OIDC client in Authelia using the YAML captured in 01-02-SUMMARY (client_id familysync, redirect_uris https://familysync.<domain>/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 <secret>` 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.<domain> → 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.<domain>/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).
</how-to-verify>
<resume-signal>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.</resume-signal>
</task>
</tasks>
<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>
<verification>
- `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
</verification>
<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>
<output>
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.
</output>