/** * Lists router — list CRUD with scoped access control (LIST-01). * * Security (threat model T-04-02, T-04-05, T-04-07, T-04-08): * - All endpoints resolve currentUserId via resolveUserId (returns null → 401). * - GET /: scoped WHERE owner_id = caller OR id IN list_shares.userId = caller (T-04-02, D-04). * - POST /: auto-populates list_shares for all other members when isShared=true (D-01, D-02). * list_shares are server-managed only — no client-writable shares endpoint (T-04-08). * - PATCH /:id / DELETE /:id: ownership OR share membership verified before mutation (T-04-05). * - zod patchListSchema whitelists name/isShared only (T-04-07). * - Drizzle parameterized queries prevent SQL injection. * * Fan-out seam (Plan 06): * publishListEvent calls are left as commented seams below each mutation. * Plan 06 adds live-sync SSE triggers once the SSE endpoint exists. * * Mounted under /api/* in index.ts — behind oidcAuthMiddleware (or dev-bypass). */ import { Hono } from 'hono' import type { Context } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' import { and, asc, eq, inArray, sql } from 'drizzle-orm' import { db } from '../db/client.js' import { lists, listShares, listItems, users } from '../db/schema.js' import { getAuth } from '../auth/middleware.js' import { upsertUser, deriveDisplayName } from '../auth/user.js' import { rankForAppend } from '../lib/rank.js' // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js' import { publishListEvent } from '../lib/listEmitter.js' import { notifyListChange } from '../lib/listChangeDispatcher.js' export const listsRouter = new Hono() /** * listItemsRouter — single-item mutation routes. * * Mounted at /api (not /api/lists) in index.ts so the URLs resolve as: * PATCH /api/list-items/:itemId * DELETE /api/list-items/:itemId * * Separate from listsRouter (mounted at /api/lists) per the RESEARCH.md * architecture diagram. */ export const listItemsRouter = new Hono() // --------------------------------------------------------------------------- // Auth helper — copied verbatim from events.ts per project convention. // Duplicated per router (not extracted to shared module). // // Resolution order (D-10): // 1. Dev-bypass path: c.get('user') is set by devAuthBypass() when DEV_AUTH_BYPASS=true. // 2. OIDC path: call getAuth(c). If null → unauthenticated, return null. // --------------------------------------------------------------------------- async function resolveUserId(c: Context): Promise { const devUser = c.get('user') as { id: number } | undefined if (devUser) return devUser.id const auth = await getAuth(c) if (!auth) return null const iss = (auth.iss as string | undefined) ?? '' const sub = auth.sub ?? '' const displayName = deriveDisplayName(auth) const user = await upsertUser(iss, sub, displayName) return user?.id ?? null } // --------------------------------------------------------------------------- // Zod schemas // --------------------------------------------------------------------------- /** * Create list — name (1..255), isShared defaults to true per D-01. */ const createListSchema = z.object({ name: z.string().min(1).max(255), isShared: z.boolean().default(true), }) /** * Patch list — name and/or isShared; at least one field required (T-04-07). */ const patchListSchema = z .object({ name: z.string().min(1).max(255), isShared: z.boolean(), }) .partial() .refine((obj) => Object.keys(obj).length >= 1, { message: 'PATCH must update at least one field (name or isShared)', }) /** * Create item — text 1..500 (T-04-06 XSS: plain-text only, no HTML). */ const createItemSchema = z.object({ text: z.string().min(1).max(500), }) /** * Per-field PATCH for list items (D-08, T-04-07). * Exactly one field must be present — enforces single-field last-write-wins. * Fields: checked (boolean), text (string), position (fractional rank string). */ const patchItemSchema = z .object({ checked: z.boolean(), text: z.string().min(1).max(500), position: z.string().min(1).max(255), }) .partial() .refine((obj) => Object.keys(obj).length === 1, { message: 'PATCH must update exactly one field (checked, text, or position)', }) // --------------------------------------------------------------------------- // Helper: check list access (owner OR sharee) // Returns: { allowed: true, isOwner: boolean } | { allowed: false } // --------------------------------------------------------------------------- async function checkListAccess( listId: number, userId: number, ): Promise<{ allowed: true; isOwner: boolean; listRow: typeof lists.$inferSelect } | { allowed: false; notFound: boolean }> { const [listRow] = await db .select() .from(lists) .where(eq(lists.id, listId)) .limit(1) if (!listRow) { return { allowed: false, notFound: true } } if (listRow.ownerId === userId) { return { allowed: true, isOwner: true, listRow } } // Check list_shares const [shareRow] = await db .select({ listId: listShares.listId }) .from(listShares) .where(and(eq(listShares.listId, listId), eq(listShares.userId, userId))) .limit(1) if (shareRow) { return { allowed: true, isOwner: false, listRow } } return { allowed: false, notFound: false } } // --------------------------------------------------------------------------- // GET /api/lists // // Scoped response: returns only lists the current user owns or has been shared. // Includes item counts (activeCount, doneCount) per list for the card badge. // // Security: T-04-02 — WHERE owner_id = caller OR id IN list_shares.userId = caller. // --------------------------------------------------------------------------- listsRouter.get('/', async (c) => { const currentUserId = await resolveUserId(c) if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) try { // Collect all list IDs accessible to this user: owned + shared const owned = await db .select({ id: lists.id }) .from(lists) .where(eq(lists.ownerId, currentUserId)) const shared = await db .select({ listId: listShares.listId }) .from(listShares) .where(eq(listShares.userId, currentUserId)) const accessibleIds = [...new Set([ ...owned.map((r) => r.id), ...shared.map((r) => r.listId), ])] if (accessibleIds.length === 0) { return c.json({ lists: [] }) } // Fetch list rows for accessible IDs const listRows = await db .select() .from(lists) .where(inArray(lists.id, accessibleIds)) // Compute item counts per list const countRows = await db .select({ listId: listItems.listId, checked: listItems.checked, count: sql`COUNT(*)`, }) .from(listItems) .where(inArray(listItems.listId, accessibleIds)) .groupBy(listItems.listId, listItems.checked) // Build counts map: { listId -> { active, done } } const countsMap = new Map() for (const row of countRows) { if (!countsMap.has(row.listId)) { countsMap.set(row.listId, { active: 0, done: 0 }) } const entry = countsMap.get(row.listId)! if (row.checked) { entry.done += Number(row.count) } else { entry.active += Number(row.count) } } const result = listRows.map((list) => { const counts = countsMap.get(list.id) ?? { active: 0, done: 0 } return { id: list.id, name: list.name, isShared: list.isShared, ownerId: list.ownerId, activeCount: counts.active, doneCount: counts.done, createdAt: list.createdAt, updatedAt: list.updatedAt, } }) return c.json({ lists: result }) } catch (err) { console.error('[lists/GET /] DB query failed:', err) return c.json({ error: 'Service unavailable' }, 503) } }) // --------------------------------------------------------------------------- // POST /api/lists // // Create a named list. If isShared=true, auto-insert list_shares rows for // ALL other members in the users table (YAGNI auto-share per D-01/D-02/OQ-3). // Creator is the owner — no self-share row. // // Security: T-04-08 — shares are server-managed only; no client endpoint for shares. // --------------------------------------------------------------------------- listsRouter.post('/', zValidator('json', createListSchema), async (c) => { const currentUserId = await resolveUserId(c) if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) const { name, isShared } = c.req.valid('json') try { // Insert the list const [inserted] = await db .insert(lists) .values({ ownerId: currentUserId, name, isShared }) .$returningId() const listId = inserted.id // Auto-populate list_shares for all other members when isShared=true (D-01, D-02, OQ-3) if (isShared) { // Fetch all users EXCEPT the creator const otherUsers = await db .select({ id: users.id }) .from(users) .where(sql`${users.id} != ${currentUserId}`) if (otherUsers.length > 0) { await db.insert(listShares).values( otherUsers.map((u) => ({ listId, userId: u.id })), ) } } // Re-fetch the newly created list to return canonical shape const [newList] = await db .select() .from(lists) .where(eq(lists.id, listId)) .limit(1) // Fan-out: notify accessible subscribers that this list was created/updated (LIST-04) publishListEvent(listId, { type: 'list:updated', listId, payload: { id: listId, name: newList.name } }) return c.json( { id: newList.id, name: newList.name, isShared: newList.isShared, ownerId: newList.ownerId, activeCount: 0, doneCount: 0, createdAt: newList.createdAt, updatedAt: newList.updatedAt, }, 201, ) } catch (err) { console.error('[lists/POST /] DB operation failed:', err) return c.json({ error: 'Service unavailable' }, 503) } }) // --------------------------------------------------------------------------- // PATCH /api/lists/:id // // Update name and/or isShared. Caller must be owner or sharee. // Visibility change reconciles list_shares: // false → true: insert shares for all other members // true → false: delete all non-owner shares // // Security: T-04-05 — ownership/share check before mutation. // T-04-07 — zod whitelists name/isShared only. // --------------------------------------------------------------------------- listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => { const currentUserId = await resolveUserId(c) if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) const listId = Number(c.req.param('id')) const patch = c.req.valid('json') try { const access = await checkListAccess(listId, currentUserId) if (!access.allowed) { if (access.notFound) return c.json({ error: 'Not found' }, 404) return c.json({ error: 'Access denied' }, 403) } // T-04-08 / T-04-05: owner-only guard for isShared mutations. // A sharee may rename a list (patch.name) but must never mutate list_shares. if (patch.isShared !== undefined && !access.isOwner) { return c.json({ error: 'Only the list owner can change sharing settings' }, 403) } const prevIsShared = access.listRow.isShared const newIsShared = patch.isShared ?? prevIsShared // Apply field updates const updateValues: Partial = {} if (patch.name !== undefined) updateValues.name = patch.name if (patch.isShared !== undefined) updateValues.isShared = patch.isShared await db.update(lists).set(updateValues).where(eq(lists.id, listId)) // Owner-only: reconcile list_shares on visibility change if (patch.isShared !== undefined && patch.isShared !== prevIsShared) { if (newIsShared) { // false → true: insert shares for all other members (except owner) const otherUsers = await db .select({ id: users.id }) .from(users) .where(sql`${users.id} != ${access.listRow.ownerId}`) if (otherUsers.length > 0) { // Use INSERT IGNORE semantics by catching duplicate key errors gracefully for (const u of otherUsers) { try { await db.insert(listShares).values({ listId, userId: u.id }) } catch { // Duplicate key — share already exists, skip } } } } else { // true → false: remove all non-owner shares await db .delete(listShares) .where(eq(listShares.listId, listId)) } } // Re-fetch and return the updated list const [updated] = await db .select() .from(lists) .where(eq(lists.id, listId)) .limit(1) // Fan-out: notify accessible subscribers that this list metadata changed (LIST-04) publishListEvent(listId, { type: 'list:updated', listId, payload: { id: listId, name: updated.name } }) // Push: coalesced list-change notification to other accessible members (NOTIF-02) notifyListChange(listId, currentUserId) return c.json({ id: updated.id, name: updated.name, isShared: updated.isShared, ownerId: updated.ownerId, createdAt: updated.createdAt, updatedAt: updated.updatedAt, }) } catch (err) { console.error('[lists/PATCH /:id] DB operation failed:', err) return c.json({ error: 'Service unavailable' }, 503) } }) // --------------------------------------------------------------------------- // DELETE /api/lists/:id // // Owner-only delete (safe default per plan spec). Cascade handles items+shares // via the FK onDelete: 'cascade' constraints in schema.ts. // // Security: T-04-05 — only the owner can delete; non-owner/non-sharee → 403. // --------------------------------------------------------------------------- listsRouter.delete('/:id', async (c) => { const currentUserId = await resolveUserId(c) if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) const listId = Number(c.req.param('id')) try { const access = await checkListAccess(listId, currentUserId) if (!access.allowed) { if (access.notFound) return c.json({ error: 'Not found' }, 404) return c.json({ error: 'Access denied' }, 403) } // Owner-only delete (plan spec: "owner-only delete is the safe default") if (!access.isOwner) { return c.json({ error: 'Access denied' }, 403) } await db.delete(lists).where(eq(lists.id, listId)) // Fan-out: notify accessible subscribers that this list was deleted (LIST-04) publishListEvent(listId, { type: 'list:deleted', listId, payload: { id: listId } }) // Push: coalesced list-change notification to other accessible members (NOTIF-02) // Note: list is already deleted from DB; notifyListChange handles missing list gracefully. notifyListChange(listId, currentUserId) return c.json({ id: listId }) } catch (err) { console.error('[lists/DELETE /:id] DB operation failed:', err) return c.json({ error: 'Service unavailable' }, 503) } }) // =========================================================================== // Item routes (LIST-02) // // Route paths (per RESEARCH.md architecture diagram): // Items-by-list: /:id/items (nested under lists) // Single-item ops: /list-items/:itemId (at listsRouter root, mounts under /api) // // Security: T-04-05 — all item handlers verify list membership (owner OR sharee). // T-04-06 — item text rendered plain-text in UI; no HTML/dangerouslySetInnerHTML. // T-04-07 — patchItemSchema enforces exactly one field per D-08. // T-04-09 — DELETE is final; PATCH on deleted item 404s (no upsert path). // =========================================================================== // --------------------------------------------------------------------------- // POST /api/lists/:id/items // // Add an item to the list, assigning a fractional rank at the bottom of the // active section. Rank is generateKeyBetween(lastActiveRank, null) per D-13. // // Security: list access check before insert. // --------------------------------------------------------------------------- listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) => { const currentUserId = await resolveUserId(c) if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) const listId = Number(c.req.param('id')) const { text } = c.req.valid('json') try { const access = await checkListAccess(listId, currentUserId) if (!access.allowed) { if (access.notFound) return c.json({ error: 'Not found' }, 404) return c.json({ error: 'Access denied' }, 403) } // Find the last active item's rank (unchecked, sorted DESC by rank, limit 1) const [lastActive] = await db .select({ rank: listItems.rank }) .from(listItems) .where(and(eq(listItems.listId, listId), eq(listItems.checked, false))) .orderBy(sql`${listItems.rank} DESC`) .limit(1) const newRank = rankForAppend(lastActive?.rank ?? null) const [inserted] = await db .insert(listItems) .values({ listId, text, rank: newRank }) .$returningId() const [newItem] = await db .select() .from(listItems) .where(eq(listItems.id, inserted.id)) .limit(1) // Fan-out: notify accessible subscribers that an item was added (LIST-04) publishListEvent(listId, { type: 'item:added', listId, payload: { id: newItem.id, listId, text: newItem.text } }) // Push: coalesced list-change notification to other accessible members (NOTIF-02) notifyListChange(listId, currentUserId) return c.json( { id: newItem.id, listId: newItem.listId, text: newItem.text, checked: newItem.checked, rank: newItem.rank, createdAt: newItem.createdAt, updatedAt: newItem.updatedAt, }, 201, ) } catch (err) { console.error('[lists/POST /:id/items] DB operation failed:', err) return c.json({ error: 'Service unavailable' }, 503) } }) // --------------------------------------------------------------------------- // GET /api/lists/:id/items // // Return all items for the list, ordered by rank ASC. // Access-gated: owner OR sharee only (T-04-05). // --------------------------------------------------------------------------- listsRouter.get('/:id/items', async (c) => { const currentUserId = await resolveUserId(c) if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) const listId = Number(c.req.param('id')) try { const access = await checkListAccess(listId, currentUserId) if (!access.allowed) { if (access.notFound) return c.json({ error: 'Not found' }, 404) return c.json({ error: 'Access denied' }, 403) } const items = await db .select() .from(listItems) .where(eq(listItems.listId, listId)) .orderBy(asc(listItems.rank)) const result = items.map((item) => ({ id: item.id, listId: item.listId, text: item.text, checked: item.checked, rank: item.rank, createdAt: item.createdAt, updatedAt: item.updatedAt, })) return c.json({ items: result }) } catch (err) { console.error('[lists/GET /:id/items] DB query failed:', err) return c.json({ error: 'Service unavailable' }, 503) } }) // --------------------------------------------------------------------------- // PATCH /api/list-items/:itemId // // Per-field last-write-wins update (D-08). Exactly one of: checked, text, position. // // Special case: { checked: false } (uncheck) recomputes rank to append-to-active-bottom // in the same write — per Open Question 2 resolution. // // Security: T-04-05 — item must belong to a list the caller can access. // T-04-07 — patchItemSchema enforces exactly-one-field. // T-04-09 — if item row is missing (deleted), 404 (no upsert). // --------------------------------------------------------------------------- listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c) => { const currentUserId = await resolveUserId(c) if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) const itemId = Number(c.req.param('itemId')) const patch = c.req.valid('json') try { // Fetch the item to get its listId for access verification const [item] = await db .select() .from(listItems) .where(eq(listItems.id, itemId)) .limit(1) // T-04-09: if deleted, 404 (no resurrection) if (!item) return c.json({ error: 'Not found' }, 404) const access = await checkListAccess(item.listId, currentUserId) if (!access.allowed) { if (access.notFound) return c.json({ error: 'Not found' }, 404) return c.json({ error: 'Access denied' }, 403) } // Build the update payload — single-field write with updatedAt=NOW() const updateValues: { checked?: boolean text?: string rank?: string updatedAt?: Date } = {} if (patch.checked !== undefined) { updateValues.checked = patch.checked // Open Question 2: uncheck → recompute rank to active-bottom if (patch.checked === false) { const [lastActive] = await db .select({ rank: listItems.rank }) .from(listItems) .where(and( eq(listItems.listId, item.listId), eq(listItems.checked, false), sql`${listItems.id} != ${itemId}`, )) .orderBy(sql`${listItems.rank} DESC`) .limit(1) updateValues.rank = rankForAppend(lastActive?.rank ?? null) } } else if (patch.text !== undefined) { updateValues.text = patch.text } else if (patch.position !== undefined) { updateValues.rank = patch.position } await db .update(listItems) .set(updateValues) .where(eq(listItems.id, itemId)) // Re-fetch to return the updated row const [updated] = await db .select() .from(listItems) .where(eq(listItems.id, itemId)) .limit(1) // Fan-out: notify accessible subscribers that an item was updated (LIST-04) publishListEvent(item.listId, { type: 'item:updated', listId: item.listId, payload: { id: updated.id, listId: updated.listId } }) // Push: coalesced list-change notification — only for meaningful changes (D-01). // Reorder (position) patches do NOT trigger a push; only checked/text changes do. if (patch.position === undefined) { notifyListChange(item.listId, currentUserId) } return c.json({ id: updated.id, listId: updated.listId, text: updated.text, checked: updated.checked, rank: updated.rank, createdAt: updated.createdAt, updatedAt: updated.updatedAt, }) } catch (err) { console.error('[lists/PATCH /list-items/:itemId] DB operation failed:', err) return c.json({ error: 'Service unavailable' }, 503) } }) // --------------------------------------------------------------------------- // DELETE /api/list-items/:itemId // // Delete an individual item instantly — no confirmation (D-06). // Delete-wins semantics (D-09): no rollback path, no resurrection via in-flight edits. // // Security: T-04-05 — list access check (owner OR sharee) before delete. // --------------------------------------------------------------------------- listItemsRouter.delete('/:itemId', async (c) => { const currentUserId = await resolveUserId(c) if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) const itemId = Number(c.req.param('itemId')) try { // Fetch item to get listId for access check const [item] = await db .select({ id: listItems.id, listId: listItems.listId }) .from(listItems) .where(eq(listItems.id, itemId)) .limit(1) if (!item) return c.json({ error: 'Not found' }, 404) const access = await checkListAccess(item.listId, currentUserId) if (!access.allowed) { if (access.notFound) return c.json({ error: 'Not found' }, 404) return c.json({ error: 'Access denied' }, 403) } // Delete-wins (D-09): delete is final; no rollback path. await db.delete(listItems).where(eq(listItems.id, itemId)) // Fan-out: notify accessible subscribers that an item was deleted (LIST-04) publishListEvent(item.listId, { type: 'item:deleted', listId: item.listId, payload: { id: itemId } }) // Push: coalesced list-change notification to other accessible members (NOTIF-02) notifyListChange(item.listId, currentUserId) return c.json({ id: itemId }) } catch (err) { console.error('[lists/DELETE /list-items/:itemId] DB operation failed:', err) return c.json({ error: 'Service unavailable' }, 503) } })