docs(01-foundation-broker-spike): create phase plan (4 plans, 3 waves) + SKELETON + validation map
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user