--- phase: 04-shared-lists-live-sync plan: "06" subsystem: api-routes, api-sse, pwa-hooks, pwa-components tags: [live-sync, sse, fan-out, D-04, D-10, D-11, D-12, tdd, list-04, scoped-sse, bounded-backoff] dependency_graph: requires: - 04-02 (listEmitter.ts + listAccess.ts — fan-out primitives) - 04-03 (listsRouter CRUD with SSE seam comments) - 04-04 (listItemsRouter item CRUD with SSE seam comments) - 04-05 (drag-to-reorder; ListDetail established) provides: - GET /api/sse/lists — scoped SSE stream (D-04, T-04-01, T-04-02) - publishListEvent triggers in lists.ts (item:added/updated/deleted, list:updated/deleted) - useListSSE — bounded-backoff EventSource wrapper (D-10/D-11) - LiveSyncIndicator — connected/reconnecting/disconnected status component - ListDetail wired with useListSSE + LiveSyncIndicator + refetchInterval polling (D-12) affects: - apps/api/src/routes/lists.ts (publishListEvent fan-out wired at all 5 mutations) - apps/api/src/routes/sse.ts (GET /lists endpoint added) - apps/api/tests/routes/lists.test.ts (LIST-04 spy-based fan-out tests + D-04 scoped tests) - apps/pwa/src/hooks/useListSSE.ts (new) - apps/pwa/src/hooks/useListSSE.test.ts (stubs replaced with 8 real assertions) - apps/pwa/src/components/LiveSyncIndicator.tsx (new) - apps/pwa/src/routes/ListDetail.tsx (useListSSE + LiveSyncIndicator wired) tech_stack: added: [] patterns: - In-memory EventEmitter fan-out via subscribeListEvents inside streamSSE (per RESEARCH Finding 1) - resolveUserId duplicated in sse.ts per per-router convention (matches events.ts + lists.ts) - BACKOFF_STEPS_MS=[250,500,1000,2000,4000,8000]; MAX_ATTEMPTS=6; close-before-retry (Pitfall 3) - refs (not state) for esRef/attemptsRef/timerRef to avoid re-render loops - LiveSyncIndicator: role=status (connected/reconnecting) + role=alert (disconnected) - refetchInterval:30000 polling fallback always active regardless of SSE state (D-12) key_files: created: - apps/pwa/src/hooks/useListSSE.ts - apps/pwa/src/components/LiveSyncIndicator.tsx modified: - apps/api/src/routes/lists.ts (publishListEvent fan-out at 5 mutation handlers) - apps/api/src/routes/sse.ts (GET /lists scoped endpoint added; resolveUserId helper added) - apps/api/tests/routes/lists.test.ts (9 new LIST-04 tests: 5 fan-out spy + 4 D-04 scoped) - apps/pwa/src/hooks/useListSSE.test.ts (stubs → 8 real assertions; MockEventSource class) - apps/pwa/src/routes/ListDetail.tsx (useListSSE + setSyncState + LiveSyncIndicator) decisions: - "SSE connection lives in ListDetail per plan spec; hoisting to Lists route level deferred to Phase 5 (acceptable for Phase 4 per RESEARCH note)" - "publishListEvent carries minimal payload (id, listId, minimal fields) — client uses only to trigger invalidateQueries/refetch (D-10)" - "resolveUserId duplicated in sse.ts (not extracted to shared module) — matches per-router convention established in events.ts + lists.ts" - "getAccessibleListIds called once at SSE connection time (D-03/D-10) — new shares visible after reconnect, acceptable per D-10" metrics: duration: "~11 minutes" completed: "2026-06-09" task_count: 2 file_count: 7 --- # Phase 4 Plan 6: Live-Sync SSE Vertical Slice Summary **One-liner:** Scoped GET /api/sse/lists fan-out endpoint + publishListEvent triggers in all 5 mutation handlers + bounded-backoff useListSSE hook + LiveSyncIndicator — LIST-04 live co-edit within seconds, D-04 no-leak proven at route layer. ## TDD Gate Compliance | Gate | Commit | Status | |------|--------|--------| | RED — 5 fan-out spy tests (API) + module-not-found (PWA hook) | 5a8d1ef | PASS — 5 API tests fail (subscribeListEvents receives 0 events; publishListEvent commented out); PWA test file fails (useListSSE.ts not created) | | GREEN — fan-out wired + SSE endpoint + hook + indicator | 1652a68 | PASS — all 54 API tests pass; all 8 PWA hook tests pass | | REFACTOR | (skipped) | Implementation was clean on first pass | ## Tasks Completed | Task | Name | Commit | Files | |------|------|--------|-------| | RED | Failing tests: LIST-04 fan-out spy + D-04 scoped (API) + useListSSE.test.ts (PWA) | 5a8d1ef | tests/routes/lists.test.ts, hooks/useListSSE.test.ts | | GREEN | fan-out in lists.ts + /api/sse/lists in sse.ts + useListSSE.ts + LiveSyncIndicator + ListDetail wiring | 1652a68 | lists.ts, sse.ts, useListSSE.ts, LiveSyncIndicator.tsx, ListDetail.tsx, useListSSE.test.ts | ## Deviations from Plan None. Plan executed exactly as written. ## Playwright Browser Check Ran against `http://localhost:5173/lists/890` (list id 890, Groceries) with API on port 3000 (DEV_AUTH_BYPASS=true): 1. `/lists/890` renders ListDetail with "Nothing here yet" + green dot (LiveSyncIndicator, connected state) in top-right header — PASS 2. Add "milk" → item appears in active items list with checkbox + GripVertical handle — PASS 3. LiveSyncIndicator green dot visible throughout — SSE connection maintained — PASS 4. Added "eggs" item via API (simulating second-user write) → appeared in browser within ~1 second WITHOUT manual refresh — PASS (live co-edit proven: SSE fan-out delivered `item:added` event, React Query invalidated + refetched) 5. SSE stream verified: `curl -N http://localhost:3000/api/sse/lists` received `event: heartbeat` + `event: item:added` with correct `{type, listId, payload}` shape **Live co-edit confirmed single-context (same dev user): API write → SSE event → React Query invalidation → browser update within ~1 second.** Note: Two-context cross-member test (two separate authenticated users) requires the full Authelia/Pangolin production topology. With DEV_AUTH_BYPASS (single dev user id=1), a true two-user isolation test would require two separate dev servers. D-04 no-leak invariant is proven at the route/subscription layer by the `getAccessibleListIds` tests (accessible-list gating confirmed green). ## Verification Results ### API Tests - `tests/routes/lists.test.ts`: 54 passed (0 failed) - LIST-04 fan-out spy tests (5): all GREEN — subscribeListEvents receives events after each mutation - D-04 scoped subscription tests (4): all GREEN — private list excluded from getAccessibleListIds for non-owner; shared list included - All prior LIST-01/02/03 tests: 45 passing (no regressions) ### PWA Tests - `src/hooks/useListSSE.test.ts`: 8 passed (0 failed) - D-10 reconnect invalidation — GREEN - D-11 bounded backoff exhaustion (MAX_ATTEMPTS=6) — GREEN - D-11 backoff reset on successful reconnect — GREEN - Pitfall 3 cleanup (close + clearTimeout on unmount) — GREEN - Pitfall 7 withCredentials:true — GREEN ### TypeScript - `pnpm --filter @familysync/api typecheck` — PASS - `pnpm --filter @familysync/pwa exec tsc --noEmit` — PASS ### SSE Endpoint Verification - `GET /api/sse/lists`: responds with `event: heartbeat` + `event: item:added` per fan-out trigger — PASS - `event: item:added` data shape: `{type, listId, payload:{id, listId, text}}` — PASS (minimal payload per D-10) ## Known Stubs | File | Stub | Reason | |------|------|--------| | `apps/pwa/src/routes/ListDetail.tsx:379` | List heading shows "List" (not list name) | Pre-existing from Plan 04-04; fetchListItems returns items only; Plan 05/06 spec noted enrichment from ['lists'] cache; non-blocking for LIST-04 | This stub does not prevent the plan's goal (live co-edit). It was explicitly called out as pre-existing in the Plan 04-04 SUMMARY. ## Threat Surface Scan All threats from the plan's threat model are mitigated: | Threat ID | Status | Notes | |-----------|--------|-------| | T-04-02 (Info Disclosure — D-04 scoped fan-out leak) | Mitigated | /api/sse/lists subscribes ONLY to getAccessibleListIds channels; 4 route-layer tests assert private list excluded from non-owner's accessible set | | T-04-01 (Spoofing/AuthZ — unauthenticated SSE subscription) | Mitigated | resolveUserId → 401 on null; same OIDC guard as /api/sse/heartbeat; withCredentials:true sends session cookie | | T-04-11 (DoS — EventSource reconnect storm) | Mitigated | es.close() before setTimeout; MAX_ATTEMPTS=6 → 'disconnected' state stops retrying; Pitfall 3 test confirms no post-unmount reconnects | | T-04-12 (Info Disclosure — over-broad payload) | Mitigated | Payload carries minimal {type, listId, id} only; client uses only to invalidate/refetch (D-10); no sensitive data in SSE payload | No new threat surface beyond the plan's trust boundaries. ## Self-Check: PASSED - `apps/api/src/routes/lists.ts` (publishListEvent imports + 5 fan-out calls) — FOUND - `apps/api/src/routes/sse.ts` (GET /lists endpoint) — FOUND - `apps/pwa/src/hooks/useListSSE.ts` — FOUND - `apps/pwa/src/components/LiveSyncIndicator.tsx` — FOUND - `apps/pwa/src/routes/ListDetail.tsx` (useListSSE + LiveSyncIndicator wired) — FOUND - Commit 5a8d1ef (RED) — FOUND - Commit 1652a68 (GREEN) — FOUND