diff --git a/apps/api/src/lib/listAccess.ts b/apps/api/src/lib/listAccess.ts new file mode 100644 index 0000000..4bf98b0 --- /dev/null +++ b/apps/api/src/lib/listAccess.ts @@ -0,0 +1,43 @@ +/** + * listAccess — access-scope query for list fan-out gating (D-04). + * + * getAccessibleListIds(userId) returns the set of list IDs a user can see: + * - Lists owned by the user (owner_id = userId) + * - Lists shared to the user via the list_shares join table + * + * The result is deduplicated. This is the gate used by the SSE endpoint + * (Plan 06) to scope subscriptions: a subscriber receives events ONLY for + * the list IDs returned here. + * + * Security invariant (T-04-02, T-04-03): + * A private list owned by another user with no list_shares entry for this + * user MUST NOT appear in the result. + * + * Exports: getAccessibleListIds + * Source: RESEARCH.md Finding 3 verbatim pattern. + */ + +import { eq } from 'drizzle-orm' +import { db } from '../db/client.js' +import { lists, listShares } from '../db/schema.js' + +/** + * Returns all list IDs accessible to userId: + * owned lists UNION lists shared to this user, deduplicated. + */ +export async function getAccessibleListIds(userId: number): Promise { + const owned = await db.select({ id: lists.id }).from(lists).where(eq(lists.ownerId, userId)) + + const shared = await db + .select({ listId: listShares.listId }) + .from(listShares) + .where(eq(listShares.userId, userId)) + + const all = [ + ...owned.map((r) => r.id), + ...shared.map((r) => r.listId), + ] + + // Deduplicate (handles the degenerate case where a list is both owned and shared) + return [...new Set(all)] +} diff --git a/apps/api/src/lib/listEmitter.ts b/apps/api/src/lib/listEmitter.ts new file mode 100644 index 0000000..5d72750 --- /dev/null +++ b/apps/api/src/lib/listEmitter.ts @@ -0,0 +1,54 @@ +/** + * In-memory scoped list event emitter (D-04, D-18). + * + * Module-level singleton — one EventEmitter shared across all route handlers + * in this Node.js process. Per-list channels keyed as `list:${listId}` ensure + * events for one list cannot be received by subscribers of another list. + * + * 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 and operational overhead for zero benefit. The abstraction + * boundary (callers never touch the EventEmitter directly) makes a future + * Redis swap mechanical inside this module. + * + * Exports: publishListEvent, subscribeListEvents, ListEvent + * Source: RESEARCH.md Finding 1 verbatim pattern. + */ + +import { EventEmitter } from 'node:events' + +// Module-level singleton — one emitter shared across all route handlers +// in this Node.js process. +const emitter = new EventEmitter() +emitter.setMaxListeners(200) // 100 members × 2 devices, generous headroom (T-04-04) + +/** + * Event type union for list change notifications. + * All events carry the originating listId and an opaque payload. + */ +export type ListEvent = { + type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted' + listId: number + payload: unknown +} + +/** + * Broadcast an event to all SSE subscribers watching this list. + * Channel is keyed by listId — events for list A never reach subscribers of list B. + */ +export function publishListEvent(listId: number, event: ListEvent): void { + emitter.emit(`list:${listId}`, event) +} + +/** + * Subscribe to events for a specific list. + * Returns an unsubscribe function; call it to stop delivery to this handler. + */ +export function subscribeListEvents( + listId: number, + handler: (event: ListEvent) => void, +): () => void { + const channel = `list:${listId}` + emitter.on(channel, handler) + return () => emitter.off(channel, handler) +} diff --git a/apps/api/tests/lib/listAccess.test.ts b/apps/api/tests/lib/listAccess.test.ts index 85cae78..f7904b8 100644 --- a/apps/api/tests/lib/listAccess.test.ts +++ b/apps/api/tests/lib/listAccess.test.ts @@ -11,17 +11,20 @@ * Run: pnpm --filter @familysync/api exec vitest run tests/lib/listAccess.test.ts */ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect } from 'vitest' +import { randomUUID } from 'node:crypto' import { db } from '../../src/db/client.js' import { users, lists, listShares } from '../../src/db/schema.js' import { getAccessibleListIds } from '../../src/lib/listAccess.js' // Seed helpers — insert minimal rows and return their IDs. -async function seedUser(suffix: string): Promise { +// oidc_sub uses a UUID suffix so rows never collide across test runs +// even when the users table is not truncated between runs. +async function seedUser(label: string): Promise { const [result] = await db.insert(users).values({ oidcIss: 'https://auth.test', - oidcSub: `sub-${suffix}`, - displayName: `User ${suffix}`, + oidcSub: `sub-${label}-${randomUUID()}`, + displayName: `User ${label}`, color: '#000000', }).$returningId() return result.id diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 3df779d..62d9644 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -5,5 +5,17 @@ export default defineConfig({ environment: 'node', globals: true, setupFiles: ['./test/setup.ts'], + // Run test files sequentially so concurrent DB tests do not interfere via + // the shared MariaDB (global afterEach in test/setup.ts truncates list tables + // which causes FK violations if two workers share the DB concurrently). + sequence: { + concurrent: false, + }, + pool: 'forks', + // singleFork: top-level in vitest 4.x (poolOptions removed in v4). + // Runs all test files in one forked process so the shared MariaDB + // teardown (afterEach in test/setup.ts) never races with inserts + // from a concurrent worker. + singleFork: true, }, })