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