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

140 lines
8.4 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: 02
type: tdd
wave: 2
depends_on: ["04-01"]
files_modified:
- apps/api/src/lib/listEmitter.ts
- apps/api/src/lib/listEmitter.test.ts
- apps/api/src/lib/listAccess.ts
- apps/api/src/lib/listAccess.test.ts
autonomous: true
requirements: [LIST-04]
user_setup: []
must_haves:
truths:
- "An event published for a list is delivered only to subscribers of that list's channel"
- "A subscriber to list A receives no events published for list B"
- "getAccessibleListIds(userId) returns owned list ids plus list ids shared via list_shares, and nothing else"
- "Unsubscribing stops further delivery to that handler"
artifacts:
- path: "apps/api/src/lib/listEmitter.ts"
provides: "in-memory scoped pub/sub: publishListEvent, subscribeListEvents"
exports: ["publishListEvent", "subscribeListEvents", "ListEvent"]
- path: "apps/api/src/lib/listAccess.ts"
provides: "getAccessibleListIds(userId) access-scope query"
exports: ["getAccessibleListIds"]
- path: "apps/api/src/lib/listEmitter.test.ts"
provides: "scoped fan-out correctness tests (D-04)"
contains: "describe"
key_links:
- from: "apps/api/src/lib/listEmitter.ts"
to: "node:events EventEmitter"
via: "module-level singleton keyed by list:${listId}"
pattern: "emit\\(`list:"
- from: "apps/api/src/lib/listAccess.ts"
to: "lists + list_shares tables"
via: "owner_id OR list_shares.user_id query"
pattern: "listShares"
---
<objective>
Build and test-first the load-bearing live-sync primitive: an in-memory, per-list-scoped event emitter (`listEmitter.ts`) plus the access-scope query (`listAccess.ts`) that together guarantee D-04 — a list's change events reach ONLY members with access to that list, never all connected clients and never non-shared members.
This is a dedicated TDD plan because it is pure, testable business logic (`expect(deliveredEvents).toEqual([...])`) and it is the single highest-correctness-risk seam in the phase (private-list leakage). The SSE endpoint (Plan 06) and the route fan-out triggers (Plans 0306) consume these two functions.
Purpose: Get scoped fan-out provably correct in isolation before any SSE wiring, with the negative test ("private-list events NOT delivered to a non-owner") proven green.
Output: `publishListEvent`/`subscribeListEvents` (in-memory EventEmitter singleton) and `getAccessibleListIds(userId)`, both fully unit-tested.
**Fan-out mechanism justification (D-18):** In-memory EventEmitter, not Redis. The API runs as a single Node process (no replicas), so Redis pub/sub adds a network hop, an ioredis dependency, and operational overhead for zero benefit. D-18 (N-member / multi-process-agnostic design) is satisfied by the abstraction boundary: callers use `publishListEvent`/`subscribeListEvents` and never touch the EventEmitter directly, so a future Redis swap is mechanical inside `listEmitter.ts`. ioredis is intentionally NOT installed in Phase 4.
</objective>
<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-VALIDATION.md
@apps/api/src/db/schema.ts
@apps/api/src/db/client.ts
</context>
<feature>
<name>Scoped in-memory list event fan-out + access-scope query (D-04)</name>
<files>
apps/api/src/lib/listEmitter.ts, apps/api/src/lib/listEmitter.test.ts,
apps/api/src/lib/listAccess.ts, apps/api/src/lib/listAccess.test.ts
</files>
<read_first>
- apps/api/src/lib/listEmitter.test.ts (RED stub from Plan 01 — convert to real assertions)
- apps/api/src/db/schema.ts (lists, listShares tables created in Plan 01)
- apps/api/src/routes/events.ts lines 1-110 (db query + drizzle and/or/eq conventions)
- .planning/phases/04-shared-lists-live-sync/04-RESEARCH.md Finding 1 + Finding 3 (verbatim patterns)
- .planning/phases/04-shared-lists-live-sync/04-PATTERNS.md §"apps/api/src/lib/listEmitter.ts"
</read_first>
<behavior>
listEmitter (pure, no DB):
- Test 1 (RED first): publishListEvent(1, ev) delivers ev to a handler subscribed via subscribeListEvents(1, h); handler called exactly once with ev.
- Test 2 (the D-04 negative, critical): a handler subscribed to list 1 receives NOTHING when publishListEvent(2, ev) is called. This is the "private-list events NOT emitted to a non-owner subscriber" assertion from 04-VALIDATION.md.
- Test 3: the unsubscribe function returned by subscribeListEvents stops delivery — after calling it, a subsequent publish to that list does not invoke the handler.
- Test 4: multiple handlers on the same list channel all receive the event.
- ListEvent type union: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted', shape { type, listId, payload }.
listAccess (DB-backed, uses the test DB harness from Plan 01):
- Test 5: getAccessibleListIds returns ids of lists the user OWNS.
- Test 6: getAccessibleListIds returns ids of lists shared to the user via list_shares.
- Test 7 (D-04): getAccessibleListIds does NOT return another user's private (non-shared, non-owned) list id.
- Test 8: result has no duplicates when a list is both owned and (erroneously) shared.
</behavior>
<implementation>
listEmitter.ts: module-level `new EventEmitter()` with setMaxListeners(200); channel key `list:${listId}`; publishListEvent emits, subscribeListEvents registers on() and returns an off() closure. Use the RESEARCH Finding 1 pattern verbatim.
listAccess.ts: `getAccessibleListIds(userId: number): Promise<number[]>` — select lists.id where lists.ownerId = userId, union select listShares.listId where listShares.userId = userId, dedupe into a number[]. Use drizzle eq from the events.ts pattern. (Implementation choice: either two selects merged in JS per RESEARCH Finding 3, or a single OR query joined to list_shares — either is acceptable; the tests assert behavior, not query shape.)
Follow RED → GREEN → REFACTOR: write the failing tests first (convert the Plan 01 stub), confirm they fail, implement minimally to green, refactor only if obvious.
</implementation>
</feature>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| publisher (route handler) → subscriber (SSE stream) | A leak here exposes one member's private list to another |
| API → MariaDB | access-scope query must not over-return list ids |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-02 | Information Disclosure | scoped fan-out leak (D-04) — load-bearing | mitigate | Per-list channel keying (`list:${listId}`) + getAccessibleListIds scoped to owner_id OR list_shares; proven by Test 2 (cross-list isolation) and Test 7 (private list excluded) |
| T-04-03 | Information Disclosure | getAccessibleListIds over-returning ids | mitigate | Test 7 asserts a non-owned, non-shared list id is absent; Test 8 asserts dedupe |
| T-04-04 | Denial of Service | EventEmitter max-listeners warning under many SSE connections | accept | setMaxListeners(200) headroom (100 members × 2 devices); single-process scale is bounded for a household app |
</threat_model>
<verification>
<automated>pnpm --filter @familysync/api exec vitest run src/lib/listEmitter.test.ts src/lib/listAccess.test.ts</automated>
- Test 2 (cross-list isolation) and Test 7 (private list excluded) MUST be present and green.
</verification>
<success_criteria>
- RED commit: failing listEmitter/listAccess tests (incl. the D-04 negative).
- GREEN commit: implementation passes all tests.
- REFACTOR commit (if any): tests still green.
- ioredis NOT introduced.
</success_criteria>
<artifacts_produced>
**Symbols/files this plan creates (exclude from drift verification):**
- `apps/api/src/lib/listEmitter.ts` exporting `publishListEvent(listId, event)`, `subscribeListEvents(listId, handler): () => void`, type `ListEvent`
- `apps/api/src/lib/listAccess.ts` exporting `getAccessibleListIds(userId): Promise<number[]>`
- Tests: `apps/api/src/lib/listEmitter.test.ts`, `apps/api/src/lib/listAccess.test.ts`
</artifacts_produced>
<output>
Create `.planning/phases/04-shared-lists-live-sync/04-02-SUMMARY.md` with RED/GREEN/REFACTOR notes and commit list.
</output>