Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | user_setup | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-shared-lists-live-sync | 06 | execute | 5 |
|
|
true |
|
|
MVP slice: this is the final capability that makes the lists "shared and live" rather than single-user. All CRUD/reorder built in Plans 03–05 becomes collaborative.
Purpose: Connect the proven scoped fan-out primitive (Plan 02) to real route writes and to a robust client (bounded backoff per D-11, full-refetch-on-reconnect per D-10, polling fallback per D-12), and prove the load-bearing no-leak invariant at the HTTP/route layer. Output: /api/sse/lists endpoint; publishListEvent triggers in lists.ts; useListSSE hook; LiveSyncIndicator; ListDetail consumes the hook and renders the indicator.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/04-shared-lists-live-sync/04-CONTEXT.md @.planning/phases/04-shared-lists-live-sync/04-RESEARCH.md @.planning/phases/04-shared-lists-live-sync/04-PATTERNS.md @.planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md @.planning/phases/04-shared-lists-live-sync/04-VALIDATION.md @apps/api/src/routes/sse.ts Task 1: Scoped /api/sse/lists endpoint + fan-out triggers on every write (LIST-04, D-04/D-10) apps/api/src/routes/sse.ts, apps/api/src/routes/lists.ts, apps/api/tests/routes/lists.test.ts - apps/api/src/routes/sse.ts (existing /heartbeat streamSSE pattern — extend) - apps/api/src/routes/lists.ts (item/list write handlers from Plans 03–04 — add emit seams) - apps/api/src/lib/listEmitter.ts (publishListEvent, subscribeListEvents — Plan 02) - apps/api/src/lib/listAccess.ts (getAccessibleListIds — Plan 02) - apps/api/tests/routes/lists.test.ts (LIST-04 stub incl. private-list no-leak) - .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/routes/sse.ts" (the /lists endpoint pattern verbatim) - .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 1 + Finding 3 - Test: a successful POST item / PATCH item / DELETE item / list:update / list:delete causes publishListEvent to fire with the matching ListEvent type for that listId. (LIST-04) - Test (D-04, load-bearing): an event published for member A's PRIVATE list is NOT delivered to member B's /api/sse/lists subscription — B's accessible-list set (getAccessibleListIds) excludes it, so B never subscribes to that channel. This is the "private-list events NOT emitted to a non-owner subscriber" assertion in 04-VALIDATION.md, asserted at the route/subscription layer (Plan 02 proved it at the emitter layer). - Test: a member subscribed via /api/sse/lists DOES receive events for a list shared with them. - Test: the endpoint returns 401 when unauthenticated. Extend sseRouter (sse.ts) with `GET /lists` following the 04-PATTERNS pattern: resolveUserId → 401 on null; const accessibleListIds = await getAccessibleListIds(userId); inside streamSSE, for each accessible listId call subscribeListEvents(listId, handler) where the handler writes an SSE event (event: event.type, data: JSON.stringify(event)) when !stream.aborted; run a 30s heartbeat loop; on exit call every unsubscribe. (resolveUserId: reuse the lists.ts copy or import a shared helper consistently — match the existing duplication convention.)Add publishListEvent fan-out triggers in lists.ts after every successful write (the seams left in Plans 03–04): item:added after POST item, item:updated after PATCH item, item:deleted after DELETE item, list:updated after PATCH list, list:deleted after DELETE list. Each carries { type, listId, payload } with the minimal payload needed; the client uses events only to trigger invalidate/refetch (D-10), so payload need not be the full row.
Mount: /api/sse/lists is already under /api/sse (sseRouter mounted in index.ts) — no index.ts change needed beyond what exists. Confirm it sits behind the OIDC/dev-bypass guard.
Create LiveSyncIndicator.tsx per UI-SPEC: connected = 8px green dot (var(--color-member-1)), reconnecting = pulsing muted dot + "Reconnecting…", disconnected = red dot + "Updates paused"; role="status" with the aria-labels from UI-SPEC; role="alert" for the disconnected state.
Wire into ListDetail: call useListSSE({ listId, onStateChange: setSyncState }) and render LiveSyncIndicator in the header. Keep refetchInterval:30000 as the always-on polling fallback (D-12) so data stays fresh even when SSE is 'disconnected'. (Consider hoisting the single SSE connection so it does not reconnect on every list navigation — acceptable to keep it in ListDetail for Phase 4 per RESEARCH note; document the choice.)
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| API publisher → SSE subscribers | the load-bearing leak boundary (D-04) |
| browser EventSource → /api/sse/lists | session cookie must cross (withCredentials); endpoint behind OIDC |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-04-02 | Information Disclosure | scoped-fan-out leak (D-04) — load-bearing | mitigate | /api/sse/lists subscribes ONLY to getAccessibleListIds channels; route-layer test asserts member B never receives member A's private-list events |
| T-04-01 | Spoofing/AuthZ | unauthenticated SSE subscription | mitigate | resolveUserId → 401; endpoint behind OIDC middleware; EventSource sends session cookie via withCredentials (Pitfall 7) |
| T-04-11 | Denial of Service | EventSource reconnect storm | mitigate | es.close() on error + manual bounded-backoff setTimeout; give-up after MAX_ATTEMPTS (Pitfall 3) |
| T-04-12 | Information Disclosure | over-broad event payload exposing other lists' data | mitigate | Payload carries only { type, listId, minimal } and is per-list-channel scoped; client uses it solely to trigger invalidate/refetch (D-10) |
</threat_model>
pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts && pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit - `playwright-cli` two-context live-update check. - D-04 route-layer no-leak test green.<success_criteria>
- LIST-04 satisfied: live co-edit within seconds, surviving a brief reconnect.
- D-04 no-leak proven at both emitter (Plan 02) and route (this plan) layers.
- D-10 full-refetch-on-reconnect, D-11 bounded backoff + paused indicator, D-12 polling fallback all in place. </success_criteria>
<artifacts_produced> Symbols/files this plan creates (exclude from drift verification):
GET /api/sse/listsendpoint on sseRouter (apps/api/src/routes/sse.ts)publishListEvent(...)fan-out triggers in apps/api/src/routes/lists.ts (item:added/updated/deleted, list:updated/deleted)apps/pwa/src/hooks/useListSSE.tsexportinguseListSSE(bounded-backoff EventSource wrapper)apps/pwa/src/components/LiveSyncIndicator.tsx- ListDetail wiring of useListSSE + LiveSyncIndicator </artifacts_produced>