---
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>
