Files
familysync/.planning/phases/04-shared-lists-live-sync/04-06-PLAN.md
T

195 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 04-shared-lists-live-sync
plan: 06
type: execute
wave: 5
depends_on: ["04-02", "04-04", "04-05"]
files_modified:
- apps/api/src/routes/sse.ts
- apps/api/src/routes/lists.ts
- apps/api/src/routes/lists.test.ts
- apps/pwa/src/hooks/useListSSE.ts
- apps/pwa/src/hooks/useListSSE.test.ts
- apps/pwa/src/components/LiveSyncIndicator.tsx
- apps/pwa/src/routes/ListDetail.tsx
autonomous: true
requirements: [LIST-04]
user_setup: []
must_haves:
truths:
- "When one member adds/checks/deletes/reorders an item, the other member's open list updates within seconds without a manual refresh"
- "A private list's events are never delivered to a member who is not its owner (D-04)"
- "On SSE reconnect the client full-refetches the affected list (D-10)"
- "After capped backoff is exhausted, the UI shows an 'Updates paused' indicator and stops hammering (D-11); polling keeps data fresh (D-12)"
artifacts:
- path: "apps/api/src/routes/sse.ts"
provides: "GET /api/sse/lists scoped SSE stream"
contains: "/lists"
- path: "apps/pwa/src/hooks/useListSSE.ts"
provides: "bounded-backoff EventSource wrapper invalidating React Query"
exports: ["useListSSE"]
- path: "apps/pwa/src/components/LiveSyncIndicator.tsx"
provides: "connected/reconnecting/disconnected indicator"
min_lines: 20
key_links:
- from: "apps/api/src/routes/lists.ts"
to: "publishListEvent"
via: "fan-out trigger after every successful item/list write"
pattern: "publishListEvent"
- from: "apps/api/src/routes/sse.ts"
to: "subscribeListEvents + getAccessibleListIds"
via: "scoped per-list subscription inside streamSSE"
pattern: "subscribeListEvents|getAccessibleListIds"
- from: "apps/pwa/src/hooks/useListSSE.ts"
to: "/api/sse/lists"
via: "EventSource(withCredentials) → invalidateQueries"
pattern: "EventSource"
---
<objective>
Deliver the live-sync vertical slice (LIST-04, success criterion 3): wire the scoped SSE endpoint (`GET /api/sse/lists`), emit fan-out events from every list/item write (consuming the Plan 02 emitter), and add the bounded-backoff EventSource client hook + LiveSyncIndicator so one member's edits appear for the other within seconds — surviving a brief reconnect — without leaking private-list events (D-04).
MVP slice: this is the final capability that makes the lists "shared and live" rather than single-user. All CRUD/reorder built in Plans 0305 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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Scoped /api/sse/lists endpoint + fan-out triggers on every write (LIST-04, D-04/D-10)</name>
<files>apps/api/src/routes/sse.ts, apps/api/src/routes/lists.ts, apps/api/src/routes/lists.test.ts</files>
<read_first>
- apps/api/src/routes/sse.ts (existing /heartbeat streamSSE pattern — extend)
- apps/api/src/routes/lists.ts (item/list write handlers from Plans 0304 — add emit seams)
- apps/api/src/lib/listEmitter.ts (publishListEvent, subscribeListEvents — Plan 02)
- apps/api/src/lib/listAccess.ts (getAccessibleListIds — Plan 02)
- apps/api/src/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
</read_first>
<behavior>
- 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.
</behavior>
<action>
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 0304): 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.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts && grep -q "publishListEvent" apps/api/src/routes/lists.ts && grep -q "/lists" apps/api/src/routes/sse.ts && pnpm --filter @familysync/api typecheck</automated>
</verify>
<acceptance_criteria>
- GET /api/sse/lists subscribes only to getAccessibleListIds channels; 401 when unauthenticated.
- Every list/item write emits the correct ListEvent via publishListEvent.
- The D-04 route-layer no-leak test (private list of member A not delivered to member B) is present and green.
- typecheck passes.
</acceptance_criteria>
<done>Scoped SSE stream live; writes fan out to accessible subscribers only; no-leak invariant proven at the route layer.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: useListSSE bounded-backoff hook + LiveSyncIndicator + ListDetail wiring (LIST-04, D-10/D-11/D-12)</name>
<files>apps/pwa/src/hooks/useListSSE.ts, apps/pwa/src/hooks/useListSSE.test.ts, apps/pwa/src/components/LiveSyncIndicator.tsx, apps/pwa/src/routes/ListDetail.tsx</files>
<read_first>
- apps/pwa/src/hooks/useListSSE.test.ts (D-11 bounded-backoff RED stub from Plan 01)
- apps/pwa/src/routes/ListDetail.tsx (already has refetchInterval:30000 from Plan 04)
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 4 (EventSource wrapper verbatim) + Pitfall 3 + Pitfall 7
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"useListSSE.ts"
- .planning/phases/04-shared-lists-live-sync/04-UI-SPEC.md §"LiveSyncIndicator", §"Live Sync + Reconnect"
</read_first>
<behavior>
- Test (D-11): with a mocked EventSource that always errors, the hook retries on the backoff schedule 250→500→1000→2000→4000→cap 8000ms and, after the capped attempts are exhausted (≥6), transitions to 'disconnected' and STOPS scheduling further reconnects.
- Test: on a successful (mocked) open, the hook resets the attempt counter, reports 'connected', and invalidates ['list', listId] (full refetch on reconnect, D-10).
- Test: on a received list-change event, the hook invalidates ['list', listId].
- Test: the hook closes the EventSource and clears timers on unmount (no reconnect storm — Pitfall 3).
</behavior>
<action>
Create apps/pwa/src/hooks/useListSSE.ts using the RESEARCH Finding 4 pattern verbatim: refs for the EventSource/attempt-count/timer (not state), connect() in useCallback, BACKOFF_STEPS_MS=[250,500,1000,2000,4000,8000], MAX_ATTEMPTS=length; new EventSource('/api/sse/lists',{withCredentials:true}); on open → reset attempts, onStateChange('connected'), invalidateQueries(['list',listId]); on each list-change event type → invalidateQueries(['list',listId]); on error → es.close(), if attempts≥MAX → onStateChange('disconnected') and stop, else onStateChange('reconnecting') and setTimeout(connect, backoff[attempt++]); cleanup closes es + clears timer on unmount. Convert the Plan 01 stub into these real assertions (mock EventSource).
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.)
</action>
<verify>
<automated>pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit && grep -q "useListSSE" apps/pwa/src/routes/ListDetail.tsx</automated>
</verify>
<acceptance_criteria>
- useListSSE.test.ts: bounded-backoff exhaustion test (D-11) and reconnect-invalidate test (D-10) are real and green.
- Hook uses withCredentials:true and closes EventSource on error before scheduling retry (no storm).
- LiveSyncIndicator renders connected/reconnecting/disconnected with correct ARIA.
- ListDetail consumes the hook + renders the indicator; refetchInterval polling fallback retained.
- PWA typecheck passes.
- Browser check (`playwright-cli`, two contexts where feasible): in context A add an item; context B's open list reflects it within a few seconds without manual refresh. Record in SUMMARY. (Cross-device/iOS-standalone live co-edit remains a device-only manual check per 04-VALIDATION.md.)
</acceptance_criteria>
<done>Live co-edit works: one member's edits appear for the other within seconds, with bounded reconnect + visible paused state + polling fallback.</done>
</task>
</tasks>
<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>
<verification>
<automated>pnpm --filter @familysync/api exec vitest run src/routes/lists.test.ts && pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts && pnpm --filter @familysync/pwa exec tsc --noEmit</automated>
- `playwright-cli` two-context live-update check.
- D-04 route-layer no-leak test green.
</verification>
<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/lists` endpoint 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.ts` exporting `useListSSE` (bounded-backoff EventSource wrapper)
- `apps/pwa/src/components/LiveSyncIndicator.tsx`
- ListDetail wiring of useListSSE + LiveSyncIndicator
</artifacts_produced>
<output>
Create `.planning/phases/04-shared-lists-live-sync/04-06-SUMMARY.md` when done.
</output>