---
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/tests/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/tests/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 03–06) 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/tests/lib/listEmitter.test.ts,
    apps/api/src/lib/listAccess.ts, apps/api/src/lib/listAccess.test.ts
  </files>
  <read_first>
    - apps/api/tests/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 tests/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/tests/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>
