style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+116 -117
View File
@@ -21,29 +21,29 @@
* Mounted under /api/* in index.ts — behind oidcAuthMiddleware.
*/
import { randomUUID } from 'node:crypto'
import { Hono } from 'hono'
import type { Context } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { and, or, eq, desc } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js'
import { expandOccurrences } from '../broker/expand.js'
import { extractRruleString } from '../broker/vevent.js'
import { getAuth } from '../auth/middleware.js'
import { upsertUser, deriveDisplayName } from '../auth/user.js'
import { randomUUID } from 'node:crypto';
import { Hono } from 'hono';
import type { Context } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { and, or, eq, desc } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
import { db } from '../db/client.js';
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js';
import { expandOccurrences } from '../broker/expand.js';
import { extractRruleString } from '../broker/vevent.js';
import { getAuth } from '../auth/middleware.js';
import { upsertUser, deriveDisplayName } from '../auth/user.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'
import '../auth/devBypass.js';
export const eventsRouter = new Hono()
export const eventsRouter = new Hono();
/** Shared-family calendar rose color (D-06). */
const SHARED_FAMILY_COLOR = '#F25C7A'
const SHARED_FAMILY_COLOR = '#F25C7A';
/** Maximum allowed date-window span to prevent DoS (T-02b-02). */
const MAX_WINDOW_DAYS = 90
const MAX_WINDOW_DAYS = 90;
// ---------------------------------------------------------------------------
// Auth helper — shared by all write endpoints
@@ -61,22 +61,22 @@ const MAX_WINDOW_DAYS = 90
// ContextVariableMap augmentation in auth/devBypass.ts (typed as the DEV_USER shape),
// and getAuth(c) accepts a Context — so no `any` / eslint-disable is needed here.
async function resolveUserId(c: Context): Promise<number | null> {
const devUser = c.get('user') as { id: number } | undefined
if (devUser) return devUser.id
const devUser = c.get('user') as { id: number } | undefined;
if (devUser) return devUser.id;
const auth = await getAuth(c)
if (!auth) return null
const auth = await getAuth(c);
if (!auth) return null;
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
const iss = (auth.iss as string | undefined) ?? '';
const sub = auth.sub ?? '';
// Derive displayName via the shared helper (name → preferred_username → email
// → sub fallback) so the write-path upsert agrees with me.ts and never
// overwrites a correctly-derived name with a worse one.
const displayName = deriveDisplayName(auth)
const displayName = deriveDisplayName(auth);
const user = await upsertUser(iss, sub, displayName)
return user?.id ?? null
const user = await upsertUser(iss, sub, displayName);
return user?.id ?? null;
}
// ---------------------------------------------------------------------------
@@ -87,7 +87,7 @@ async function resolveUserId(c: Context): Promise<number | null> {
const eventsQuerySchema = z.object({
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
});
/**
* Shared event field validation (V5 — bounded lengths, T-03-08).
@@ -111,14 +111,17 @@ const eventFieldsSchema = z.object({
// ≤10-char non-date string cannot survive .replace(/-/g,'') and inject extra ';'-delimited
// RRULE parts when spliced into the UNTIL template (outboxWorker.assembleRruleString).
// int().min(1) prevents zero/negative counts.
recurrenceUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), // 'YYYY-MM-DD' → RRULE UNTIL
recurrenceUntil: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
})
});
/** sync-status query params. */
const syncStatusQuerySchema = z.object({
uid: z.string().min(1).max(512),
})
});
// ---------------------------------------------------------------------------
// GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD
@@ -132,17 +135,17 @@ const syncStatusQuerySchema = z.object({
// ---------------------------------------------------------------------------
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
// Resolve the current user first — only return events for owned + shared calendars (T-03-06).
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const { start, end } = c.req.valid('query')
const { start, end } = c.req.valid('query');
// --- Window span guard (T-02b-02) ---
const windowStartDate = new Date(start + 'T00:00:00Z')
const windowEndDate = new Date(end + 'T00:00:00Z')
const spanDays = (windowEndDate.getTime() - windowStartDate.getTime()) / (1000 * 60 * 60 * 24)
const windowStartDate = new Date(start + 'T00:00:00Z');
const windowEndDate = new Date(end + 'T00:00:00Z');
const spanDays = (windowEndDate.getTime() - windowStartDate.getTime()) / (1000 * 60 * 60 * 24);
if (spanDays > MAX_WINDOW_DAYS || spanDays <= 0) {
return c.json({ error: 'Date window must be between 1 and 90 days' }, 400)
return c.json({ error: 'Date window must be between 1 and 90 days' }, 400);
}
try {
@@ -211,11 +214,11 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
),
),
),
)
);
// --- Expand each row into concrete occurrences ---
const allOccurrences = rows.flatMap((row) => {
const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor
const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor;
return expandOccurrences(
row.rawVevent,
windowStartDate,
@@ -226,15 +229,15 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
row.ownerName ?? null,
color,
row.isShared,
)
})
);
});
return c.json({ occurrences: allOccurrences })
return c.json({ occurrences: allOccurrences });
} catch (err) {
console.error('[events] DB query or expansion failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events] DB query or expansion failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// POST /api/events/create
@@ -244,16 +247,16 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
// Does NOT build a VEVENT and does NOT call Fastmail — that is the worker's job (D-12).
// ---------------------------------------------------------------------------
eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const payload = c.req.valid('json')
const payload = c.req.valid('json');
try {
// --- Resolve target calendar (D-03 / T-03-06) ---
// If calendarUrl given: assert the calendar is owned by the current user OR is shared.
// If not given: use the first personal calendar (D-01 last-used is a frontend concern).
let targetCalendarUrl: string
let targetCalendarUrl: string;
if (payload.calendarUrl) {
// Look up the calendar — it must be owned by the current user or be shared.
@@ -265,12 +268,12 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
eq(calendars.url, payload.calendarUrl),
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
),
)
);
if (!calRow) {
return c.json({ error: 'Calendar not found or access denied' }, 403)
return c.json({ error: 'Calendar not found or access denied' }, 403);
}
targetCalendarUrl = calRow.url
targetCalendarUrl = calRow.url;
} else {
// Default to the member's first personal calendar (D-01).
// WR-02: add a deterministic ORDER BY + LIMIT. Without them, a member with
@@ -282,16 +285,16 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
.from(calendars)
.where(eq(calendars.userId, currentUserId))
.orderBy(calendars.id)
.limit(1)
.limit(1);
if (!calRow) {
return c.json({ error: 'No writable calendar found for user' }, 422)
return c.json({ error: 'No writable calendar found for user' }, 422);
}
targetCalendarUrl = calRow.url
targetCalendarUrl = calRow.url;
}
// Generate a UID for the new event (Node.js 22 built-in)
const uid = `${randomUUID()}@familysync`
const uid = `${randomUUID()}@familysync`;
// Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail.
await db.insert(calendarOutbox).values({
@@ -301,14 +304,14 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
uid,
calendarUrl: targetCalendarUrl,
payload: JSON.stringify(payload),
})
});
return c.json({ uid }, 202)
return c.json({ uid }, 202);
} catch (err) {
console.error('[events/create] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events/create] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// PATCH /api/events/:uid/edit
@@ -319,11 +322,11 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
// ---------------------------------------------------------------------------
eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const uid = c.req.param('uid')
const payload = c.req.valid('json')
const uid = c.req.param('uid');
const payload = c.req.valid('json');
try {
// --- Look up the event and verify ownership ---
@@ -358,10 +361,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
),
)
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
.limit(1)
.limit(1);
if (!eventRow) {
return c.json({ error: 'Event not found' }, 404)
return c.json({ error: 'Event not found' }, 404);
}
// Ownership check: must be the calendar owner or shared (T-03-06).
@@ -372,20 +375,20 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
const [calRow] = await db
.select({ isShared: calendars.isShared })
.from(calendars)
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)))
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));
if (!calRow) {
return c.json({ error: 'Access denied' }, 403)
return c.json({ error: 'Access denied' }, 403);
}
}
const newCalendarUrl = payload.calendarUrl ?? eventRow.calendarUrl
const isCalendarMove = newCalendarUrl !== eventRow.calendarUrl
const newCalendarUrl = payload.calendarUrl ?? eventRow.calendarUrl;
const isCalendarMove = newCalendarUrl !== eventRow.calendarUrl;
if (isCalendarMove) {
// D-04: edit-as-move — insert delete+create pair in one transaction (D-04 / Pitfall 5)
const newUid = `${randomUUID()}@familysync`
const groupId = randomUUID()
const newUid = `${randomUUID()}@familysync`;
const groupId = randomUUID();
// CR-01: carry the existing RRULE through the move. The edit payload omits
// `recurrence` (the occurrence contract does not expose it, D-03), and the
@@ -398,13 +401,9 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
// Only stash when the edit did NOT carry an explicit recurrence: an explicit
// value (including 'none') is a deliberate user change and must win.
const preservedRrule =
payload.recurrence === undefined
? extractRruleString(eventRow.rawVevent ?? '')
: undefined
payload.recurrence === undefined ? extractRruleString(eventRow.rawVevent ?? '') : undefined;
const createPayload =
preservedRrule !== undefined
? { ...payload, _preservedRrule: preservedRrule }
: payload
preservedRrule !== undefined ? { ...payload, _preservedRrule: preservedRrule } : payload;
await db.transaction(async (tx) => {
// Delete from old calendar
@@ -417,7 +416,7 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
calendarObjectUrl: eventRow.objectUrl ?? undefined,
etag: eventRow.etag ?? undefined,
groupId,
})
});
// Create on new calendar
await tx.insert(calendarOutbox).values({
userId: currentUserId,
@@ -427,10 +426,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
calendarUrl: newCalendarUrl,
payload: JSON.stringify(createPayload),
groupId,
})
})
});
});
return c.json({ uid: newUid }, 202)
return c.json({ uid: newUid }, 202);
}
// Same calendar — simple update row
@@ -443,14 +442,14 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
calendarObjectUrl: eventRow.objectUrl ?? undefined,
etag: eventRow.etag ?? undefined,
payload: JSON.stringify(payload),
})
});
return c.json({ uid }, 202)
return c.json({ uid }, 202);
} catch (err) {
console.error('[events/edit] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events/edit] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// DELETE /api/events/:uid
@@ -459,10 +458,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
// ---------------------------------------------------------------------------
eventsRouter.delete('/:uid', async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const uid = c.req.param('uid')
const uid = c.req.param('uid');
try {
// Look up the event — join calendars so calendars.url / calendars.userId are accessible.
@@ -490,10 +489,10 @@ eventsRouter.delete('/:uid', async (c) => {
),
)
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
.limit(1)
.limit(1);
if (!eventRow) {
return c.json({ error: 'Event not found' }, 404)
return c.json({ error: 'Event not found' }, 404);
}
// Ownership check (T-03-06)
@@ -501,10 +500,10 @@ eventsRouter.delete('/:uid', async (c) => {
const [calRow] = await db
.select({ isShared: calendars.isShared })
.from(calendars)
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)))
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));
if (!calRow) {
return c.json({ error: 'Access denied' }, 403)
return c.json({ error: 'Access denied' }, 403);
}
}
@@ -517,14 +516,14 @@ eventsRouter.delete('/:uid', async (c) => {
calendarUrl: eventRow.calendarUrl ?? '',
calendarObjectUrl: eventRow.objectUrl ?? undefined,
etag: eventRow.etag ?? undefined,
})
});
return c.json({ uid }, 202)
return c.json({ uid }, 202);
} catch (err) {
console.error('[events/delete] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events/delete] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// GET /api/events/sync-status?uid=<uid>
@@ -534,10 +533,10 @@ eventsRouter.delete('/:uid', async (c) => {
// Returns { uid, status: 'done' } when no outbox row exists (nothing pending = settled).
// ---------------------------------------------------------------------------
eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const { uid } = c.req.valid('query')
const { uid } = c.req.valid('query');
try {
// Scope strictly to current member's rows (T-03-07 — never leak another member's outbox).
@@ -562,24 +561,24 @@ eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), asy
sql`case ${calendarOutbox.status} when 'failed' then 0 when 'dead' then 0 when 'pending' then 1 else 2 end`,
desc(calendarOutbox.createdAt),
)
.limit(1)
.limit(1);
if (!rows.length) {
// No outbox row → nothing pending = settled as done
return c.json({ uid, status: 'done' })
return c.json({ uid, status: 'done' });
}
const row = rows[0]
const row = rows[0];
return c.json({
uid: row.uid,
status: row.status,
...(row.lastError != null ? { error: row.lastError } : {}),
})
});
} catch (err) {
console.error('[events/sync-status] DB query failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events/sync-status] DB query failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// GET /api/events/writable-calendars
@@ -595,8 +594,8 @@ eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), asy
// Response: { calendars: [{ url, displayName, color, isShared }] }
// ---------------------------------------------------------------------------
eventsRouter.get('/writable-calendars', async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
try {
// D-03 writable set: own personal calendars + shared Family calendar.
@@ -609,7 +608,7 @@ eventsRouter.get('/writable-calendars', async (c) => {
isShared: calendars.isShared,
})
.from(calendars)
.where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)))
.where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)));
return c.json({
calendars: rows.map((row) => ({
@@ -618,9 +617,9 @@ eventsRouter.get('/writable-calendars', async (c) => {
color: row.color ?? '',
isShared: row.isShared,
})),
})
});
} catch (err) {
console.error('[events/writable-calendars] DB query failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events/writable-calendars] DB query failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
+9 -9
View File
@@ -1,8 +1,8 @@
import { Hono } from 'hono'
import { db } from '../db/client.js'
import { sql } from 'drizzle-orm'
import { Hono } from 'hono';
import { db } from '../db/client.js';
import { sql } from 'drizzle-orm';
export const healthRouter = new Hono()
export const healthRouter = new Hono();
/**
* GET /health — unauthenticated endpoint that proves a real DB read+write round-trip.
@@ -17,11 +17,11 @@ healthRouter.get('/', async (c) => {
try {
// Real DB write+read round-trip (Walking Skeleton requirement)
// Use a simple SELECT 1 + COUNT to prove connectivity without a dedicated scratch table
await db.execute(sql`SELECT 1`)
await db.execute(sql`SELECT 1`);
return c.json({ ok: true, db: 'up' })
return c.json({ ok: true, db: 'up' });
} catch (err) {
console.error('[health] DB round-trip failed:', err)
return c.json({ ok: false, db: 'down' }, 503)
console.error('[health] DB round-trip failed:', err);
return c.json({ ok: false, db: 'down' }, 503);
}
})
});
+207 -215
View File
@@ -17,23 +17,23 @@
* 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'
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 '../auth/devBypass.js';
import { publishListEvent } from '../lib/listEmitter.js'
import { notifyListChange } from '../lib/listChangeDispatcher.js'
import { publishListEvent } from '../lib/listEmitter.js';
import { notifyListChange } from '../lib/listChangeDispatcher.js';
export const listsRouter = new Hono()
export const listsRouter = new Hono();
/**
* listItemsRouter — single-item mutation routes.
@@ -45,7 +45,7 @@ export const listsRouter = new Hono()
* Separate from listsRouter (mounted at /api/lists) per the RESEARCH.md
* architecture diagram.
*/
export const listItemsRouter = new Hono()
export const listItemsRouter = new Hono();
// ---------------------------------------------------------------------------
// Auth helper — copied verbatim from events.ts per project convention.
@@ -56,17 +56,17 @@ export const listItemsRouter = new Hono()
// 2. OIDC path: call getAuth(c). If null → unauthenticated, return null.
// ---------------------------------------------------------------------------
async function resolveUserId(c: Context): Promise<number | null> {
const devUser = c.get('user') as { id: number } | undefined
if (devUser) return devUser.id
const devUser = c.get('user') as { id: number } | undefined;
if (devUser) return devUser.id;
const auth = await getAuth(c)
if (!auth) return null
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
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;
}
// ---------------------------------------------------------------------------
@@ -79,7 +79,7 @@ async function resolveUserId(c: Context): Promise<number | null> {
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).
@@ -92,14 +92,14 @@ const patchListSchema = z
.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).
@@ -115,7 +115,7 @@ const patchItemSchema = z
.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)
@@ -124,19 +124,18 @@ const patchItemSchema = z
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)
): 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 }
return { allowed: false, notFound: true };
}
if (listRow.ownerId === userId) {
return { allowed: true, isOwner: true, listRow }
return { allowed: true, isOwner: true, listRow };
}
// Check list_shares
@@ -144,13 +143,13 @@ async function checkListAccess(
.select({ listId: listShares.listId })
.from(listShares)
.where(and(eq(listShares.listId, listId), eq(listShares.userId, userId)))
.limit(1)
.limit(1);
if (shareRow) {
return { allowed: true, isOwner: false, listRow }
return { allowed: true, isOwner: false, listRow };
}
return { allowed: false, notFound: false }
return { allowed: false, notFound: false };
}
// ---------------------------------------------------------------------------
@@ -162,35 +161,29 @@ async function checkListAccess(
// 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)
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))
.where(eq(lists.ownerId, currentUserId));
const shared = await db
.select({ listId: listShares.listId })
.from(listShares)
.where(eq(listShares.userId, currentUserId))
.where(eq(listShares.userId, currentUserId));
const accessibleIds = [...new Set([
...owned.map((r) => r.id),
...shared.map((r) => r.listId),
])]
const accessibleIds = [...new Set([...owned.map((r) => r.id), ...shared.map((r) => r.listId)])];
if (accessibleIds.length === 0) {
return c.json({ lists: [] })
return c.json({ lists: [] });
}
// Fetch list rows for accessible IDs
const listRows = await db
.select()
.from(lists)
.where(inArray(lists.id, accessibleIds))
const listRows = await db.select().from(lists).where(inArray(lists.id, accessibleIds));
// Compute item counts per list
const countRows = await db
@@ -201,24 +194,24 @@ listsRouter.get('/', async (c) => {
})
.from(listItems)
.where(inArray(listItems.listId, accessibleIds))
.groupBy(listItems.listId, listItems.checked)
.groupBy(listItems.listId, listItems.checked);
// Build counts map: { listId -> { active, done } }
const countsMap = new Map<number, { active: number; done: number }>()
const countsMap = new Map<number, { active: number; done: number }>();
for (const row of countRows) {
if (!countsMap.has(row.listId)) {
countsMap.set(row.listId, { active: 0, done: 0 })
countsMap.set(row.listId, { active: 0, done: 0 });
}
const entry = countsMap.get(row.listId)!
const entry = countsMap.get(row.listId)!;
if (row.checked) {
entry.done += Number(row.count)
entry.done += Number(row.count);
} else {
entry.active += Number(row.count)
entry.active += Number(row.count);
}
}
const result = listRows.map((list) => {
const counts = countsMap.get(list.id) ?? { active: 0, done: 0 }
const counts = countsMap.get(list.id) ?? { active: 0, done: 0 };
return {
id: list.id,
name: list.name,
@@ -228,15 +221,15 @@ listsRouter.get('/', async (c) => {
doneCount: counts.done,
createdAt: list.createdAt,
updatedAt: list.updatedAt,
}
})
};
});
return c.json({ lists: result })
return c.json({ lists: result });
} catch (err) {
console.error('[lists/GET /] DB query failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[lists/GET /] DB query failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// POST /api/lists
@@ -248,19 +241,19 @@ listsRouter.get('/', async (c) => {
// 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 currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const { name, isShared } = c.req.valid('json')
const { name, isShared } = c.req.valid('json');
try {
// Insert the list
const [inserted] = await db
.insert(lists)
.values({ ownerId: currentUserId, name, isShared })
.$returningId()
.$returningId();
const listId = inserted.id
const listId = inserted.id;
// Auto-populate list_shares for all other members when isShared=true (D-01, D-02, OQ-3)
if (isShared) {
@@ -268,24 +261,22 @@ listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
const otherUsers = await db
.select({ id: users.id })
.from(users)
.where(sql`${users.id} != ${currentUserId}`)
.where(sql`${users.id} != ${currentUserId}`);
if (otherUsers.length > 0) {
await db.insert(listShares).values(
otherUsers.map((u) => ({ listId, userId: u.id })),
)
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)
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 } })
publishListEvent(listId, {
type: 'list:updated',
listId,
payload: { id: listId, name: newList.name },
});
return c.json(
{
@@ -299,12 +290,12 @@ listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
updatedAt: newList.updatedAt,
},
201,
)
);
} catch (err) {
console.error('[lists/POST /] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[lists/POST /] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// PATCH /api/lists/:id
@@ -318,35 +309,35 @@ listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
// 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 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')
const listId = Number(c.req.param('id'));
const patch = c.req.valid('json');
try {
const access = await checkListAccess(listId, currentUserId)
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)
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)
return c.json({ error: 'Only the list owner can change sharing settings' }, 403);
}
const prevIsShared = access.listRow.isShared
const newIsShared = patch.isShared ?? prevIsShared
const prevIsShared = access.listRow.isShared;
const newIsShared = patch.isShared ?? prevIsShared;
// Apply field updates
const updateValues: Partial<typeof lists.$inferInsert> = {}
if (patch.name !== undefined) updateValues.name = patch.name
if (patch.isShared !== undefined) updateValues.isShared = patch.isShared
const updateValues: Partial<typeof lists.$inferInsert> = {};
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))
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) {
@@ -355,13 +346,13 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
const otherUsers = await db
.select({ id: users.id })
.from(users)
.where(sql`${users.id} != ${access.listRow.ownerId}`)
.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 })
await db.insert(listShares).values({ listId, userId: u.id });
} catch {
// Duplicate key — share already exists, skip
}
@@ -369,23 +360,21 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
}
} else {
// true → false: remove all non-owner shares
await db
.delete(listShares)
.where(eq(listShares.listId, listId))
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)
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 } })
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)
notifyListChange(listId, currentUserId);
return c.json({
id: updated.id,
@@ -394,12 +383,12 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
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)
console.error('[lists/PATCH /:id] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// DELETE /api/lists/:id
@@ -410,38 +399,38 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
// 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 currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const listId = Number(c.req.param('id'))
const listId = Number(c.req.param('id'));
try {
const access = await checkListAccess(listId, currentUserId)
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)
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)
return c.json({ error: 'Access denied' }, 403);
}
await db.delete(lists).where(eq(lists.id, listId))
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 } })
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)
notifyListChange(listId, currentUserId);
return c.json({ id: listId })
return c.json({ id: listId });
} catch (err) {
console.error('[lists/DELETE /:id] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[lists/DELETE /:id] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ===========================================================================
// Item routes (LIST-02)
@@ -465,17 +454,17 @@ listsRouter.delete('/:id', async (c) => {
// 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 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')
const listId = Number(c.req.param('id'));
const { text } = c.req.valid('json');
try {
const access = await checkListAccess(listId, currentUserId)
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)
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)
@@ -484,25 +473,29 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
.from(listItems)
.where(and(eq(listItems.listId, listId), eq(listItems.checked, false)))
.orderBy(sql`${listItems.rank} DESC`)
.limit(1)
.limit(1);
const newRank = rankForAppend(lastActive?.rank ?? null)
const newRank = rankForAppend(lastActive?.rank ?? null);
const [inserted] = await db
.insert(listItems)
.values({ listId, text, rank: newRank })
.$returningId()
.$returningId();
const [newItem] = await db
.select()
.from(listItems)
.where(eq(listItems.id, inserted.id))
.limit(1)
.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 } })
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)
notifyListChange(listId, currentUserId);
return c.json(
{
@@ -515,12 +508,12 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
updatedAt: newItem.updatedAt,
},
201,
)
);
} catch (err) {
console.error('[lists/POST /:id/items] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[lists/POST /:id/items] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// GET /api/lists/:id/items
@@ -529,23 +522,23 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
// 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 currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const listId = Number(c.req.param('id'))
const listId = Number(c.req.param('id'));
try {
const access = await checkListAccess(listId, currentUserId)
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)
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))
.orderBy(asc(listItems.rank));
const result = items.map((item) => ({
id: item.id,
@@ -555,14 +548,14 @@ listsRouter.get('/:id/items', async (c) => {
rank: item.rank,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
}))
}));
return c.json({ items: result })
return c.json({ items: result });
} catch (err) {
console.error('[lists/GET /:id/items] DB query failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[lists/GET /:id/items] DB query failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// PATCH /api/list-items/:itemId
@@ -577,79 +570,74 @@ listsRouter.get('/:id/items', async (c) => {
// 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 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')
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)
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)
if (!item) return c.json({ error: 'Not found' }, 404);
const access = await checkListAccess(item.listId, currentUserId)
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)
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
} = {}
checked?: boolean;
text?: string;
rank?: string;
updatedAt?: Date;
} = {};
if (patch.checked !== undefined) {
updateValues.checked = patch.checked
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}`,
))
.where(
and(
eq(listItems.listId, item.listId),
eq(listItems.checked, false),
sql`${listItems.id} != ${itemId}`,
),
)
.orderBy(sql`${listItems.rank} DESC`)
.limit(1)
.limit(1);
updateValues.rank = rankForAppend(lastActive?.rank ?? null)
updateValues.rank = rankForAppend(lastActive?.rank ?? null);
}
} else if (patch.text !== undefined) {
updateValues.text = patch.text
updateValues.text = patch.text;
} else if (patch.position !== undefined) {
updateValues.rank = patch.position
updateValues.rank = patch.position;
}
await db
.update(listItems)
.set(updateValues)
.where(eq(listItems.id, itemId))
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)
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 } })
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)
notifyListChange(item.listId, currentUserId);
}
return c.json({
@@ -660,12 +648,12 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c)
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)
console.error('[lists/PATCH /list-items/:itemId] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// DELETE /api/list-items/:itemId
@@ -676,10 +664,10 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c)
// 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 currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const itemId = Number(c.req.param('itemId'))
const itemId = Number(c.req.param('itemId'));
try {
// Fetch item to get listId for access check
@@ -687,27 +675,31 @@ listItemsRouter.delete('/:itemId', async (c) => {
.select({ id: listItems.id, listId: listItems.listId })
.from(listItems)
.where(eq(listItems.id, itemId))
.limit(1)
.limit(1);
if (!item) return c.json({ error: 'Not found' }, 404)
if (!item) return c.json({ error: 'Not found' }, 404);
const access = await checkListAccess(item.listId, currentUserId)
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)
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))
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 } })
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)
notifyListChange(item.listId, currentUserId);
return c.json({ id: itemId })
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)
console.error('[lists/DELETE /list-items/:itemId] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
+16 -16
View File
@@ -19,18 +19,18 @@
* No credential or refresh-token data is included in the response (T-02-04).
*/
import { Hono } from 'hono'
import { getAuth } from '../auth/middleware.js'
import { upsertUser, deriveDisplayName } from '../auth/user.js'
import { Hono } from 'hono';
import { getAuth } from '../auth/middleware.js';
import { upsertUser, deriveDisplayName } from '../auth/user.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'
import '../auth/devBypass.js';
export const meRouter = new Hono()
export const meRouter = new Hono();
meRouter.get('/', async (c) => {
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
// Return the injected dev identity directly — no DB round-trip, no OIDC session needed.
const devUser = c.get('user')
const devUser = c.get('user');
if (devUser) {
return c.json({
user: {
@@ -38,30 +38,30 @@ meRouter.get('/', async (c) => {
displayName: devUser.displayName,
color: devUser.color,
},
})
});
}
// Normal OIDC path: getAuth returns null only if the session is invalid.
// oidcAuthMiddleware on /api/* redirects unauthenticated requests before this handler
// is reached, so null here indicates a genuine session error.
const auth = await getAuth(c)
const auth = await getAuth(c);
if (!auth) {
return c.json({ error: 'Unauthorized' }, 401)
return c.json({ error: 'Unauthorized' }, 401);
}
// iss and sub are the stable identity fields — identity is always keyed on iss+sub (D-10).
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
const iss = (auth.iss as string | undefined) ?? '';
const sub = auth.sub ?? '';
// Derive the best available display name from OIDC claims (name →
// preferred_username → email → sub fallback). Shared helper keeps every
// upsert call site in agreement (see deriveDisplayName).
const displayName = deriveDisplayName(auth)
const displayName = deriveDisplayName(auth);
const user = await upsertUser(iss, sub, displayName)
const user = await upsertUser(iss, sub, displayName);
if (!user) {
return c.json({ error: 'Could not resolve user' }, 500)
return c.json({ error: 'Could not resolve user' }, 500);
}
return c.json({
@@ -70,5 +70,5 @@ meRouter.get('/', async (c) => {
displayName: user.displayName,
color: user.color,
},
})
})
});
});
+38 -38
View File
@@ -12,19 +12,19 @@
* Mounted under /api/push in index.ts.
*/
import { Hono } from 'hono'
import type { Context } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { pushSubscriptions } from '../db/schema.js'
import { getAuth } from '../auth/middleware.js'
import { upsertUser, deriveDisplayName } from '../auth/user.js'
import { Hono } from 'hono';
import type { Context } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { pushSubscriptions } from '../db/schema.js';
import { getAuth } from '../auth/middleware.js';
import { upsertUser, deriveDisplayName } from '../auth/user.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'
import '../auth/devBypass.js';
export const pushRouter = new Hono()
export const pushRouter = new Hono();
// ---------------------------------------------------------------------------
// Auth helper — Duplicated per router (not extracted to shared module).
@@ -35,17 +35,17 @@ export const pushRouter = new Hono()
// 2. OIDC path: call getAuth(c). If null → unauthenticated, return null.
// ---------------------------------------------------------------------------
async function resolveUserId(c: Context): Promise<number | null> {
const devUser = c.get('user') as { id: number } | undefined
if (devUser) return devUser.id
const devUser = c.get('user') as { id: number } | undefined;
if (devUser) return devUser.id;
const auth = await getAuth(c)
if (!auth) return null
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
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;
}
// ---------------------------------------------------------------------------
@@ -63,7 +63,7 @@ const subscribeSchema = z.object({
p256dh: z.string().min(1).max(512),
auth: z.string().min(1).max(256),
}),
})
});
// ---------------------------------------------------------------------------
// GET /api/push/vapid-public-key
@@ -76,8 +76,8 @@ const subscribeSchema = z.object({
// subscribe to push anyway).
// ---------------------------------------------------------------------------
pushRouter.get('/vapid-public-key', (c) => {
return c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' })
})
return c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' });
});
// ---------------------------------------------------------------------------
// POST /api/push/subscription
@@ -90,10 +90,10 @@ pushRouter.get('/vapid-public-key', (c) => {
// T-05-10 — zod subscribeSchema validates all fields before DB write.
// ---------------------------------------------------------------------------
pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c) => {
const userId = await resolveUserId(c)
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
const userId = await resolveUserId(c);
if (!userId) return c.json({ error: 'Unauthorized' }, 401);
const body = c.req.valid('json')
const body = c.req.valid('json');
try {
await db
@@ -110,17 +110,17 @@ pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c)
p256dh: body.keys.p256dh,
auth: body.keys.auth,
},
})
});
return c.json({ ok: true }, 201)
return c.json({ ok: true }, 201);
} catch (err) {
console.error(
'[push/POST /subscription] DB operation failed:',
err instanceof Error ? err.message : String(err),
)
return c.json({ error: 'Service unavailable' }, 503)
);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// DELETE /api/push/subscription
@@ -129,17 +129,17 @@ pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c)
// Scoped to caller only — cannot delete another member's subscriptions (T-05-13).
// ---------------------------------------------------------------------------
pushRouter.delete('/subscription', async (c) => {
const userId = await resolveUserId(c)
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
const userId = await resolveUserId(c);
if (!userId) return c.json({ error: 'Unauthorized' }, 401);
try {
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId))
return c.json({ ok: true })
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId));
return c.json({ ok: true });
} catch (err) {
console.error(
'[push/DELETE /subscription] DB operation failed:',
err instanceof Error ? err.message : String(err),
)
return c.json({ error: 'Service unavailable' }, 503)
);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
+39 -39
View File
@@ -11,34 +11,34 @@
* RESEARCH Pattern 5 (heartbeat) + Finding 1 (lists scoped fan-out)
*/
import { Hono } from 'hono'
import type { Context } from 'hono'
import { streamSSE } from 'hono/streaming'
import { getAuth } from '../auth/middleware.js'
import { upsertUser, deriveDisplayName } from '../auth/user.js'
import { subscribeListEvents } from '../lib/listEmitter.js'
import { getAccessibleListIds } from '../lib/listAccess.js'
import { Hono } from 'hono';
import type { Context } from 'hono';
import { streamSSE } from 'hono/streaming';
import { getAuth } from '../auth/middleware.js';
import { upsertUser, deriveDisplayName } from '../auth/user.js';
import { subscribeListEvents } from '../lib/listEmitter.js';
import { getAccessibleListIds } from '../lib/listAccess.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'
import '../auth/devBypass.js';
export const sseRouter = new Hono()
export const sseRouter = new Hono();
// ---------------------------------------------------------------------------
// Auth helper — same pattern as lists.ts (per-router duplication convention).
// Resolution order: dev-bypass user first, then OIDC.
// ---------------------------------------------------------------------------
async function resolveUserId(c: Context): Promise<number | null> {
const devUser = c.get('user') as { id: number } | undefined
if (devUser) return devUser.id
const devUser = c.get('user') as { id: number } | undefined;
if (devUser) return devUser.id;
const auth = await getAuth(c)
if (!auth) return null
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
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;
}
/**
@@ -48,17 +48,17 @@ async function resolveUserId(c: Context): Promise<number | null> {
*/
sseRouter.get('/heartbeat', (c) => {
return streamSSE(c, async (stream) => {
let id = 0
let id = 0;
while (!stream.aborted) {
await stream.writeSSE({
data: JSON.stringify({ ts: new Date().toISOString(), id }),
event: 'heartbeat',
id: String(id++),
})
await stream.sleep(10_000)
});
await stream.sleep(10_000);
}
})
})
});
});
/**
* GET /lists
@@ -83,13 +83,13 @@ sseRouter.get('/heartbeat', (c) => {
* D-12: PWA refetchInterval: 30000 polling fallback always active.
*/
sseRouter.get('/lists', async (c) => {
const userId = await resolveUserId(c)
if (userId === null) return c.json({ error: 'Unauthorized' }, 401)
const userId = await resolveUserId(c);
if (userId === null) return c.json({ error: 'Unauthorized' }, 401);
const accessibleListIds = await getAccessibleListIds(userId)
const accessibleListIds = await getAccessibleListIds(userId);
return streamSSE(c, async (stream) => {
const unsubscribers: Array<() => void> = []
const unsubscribers: Array<() => void> = [];
// Subscribe to each accessible list's channel (D-04 — scoped, not global)
for (const listId of accessibleListIds) {
@@ -97,31 +97,31 @@ sseRouter.get('/lists', async (c) => {
// The handler signature is void-returning; wrap the async write in void+catch.
// writeSSE errors are non-fatal — the SSE loop detects stream.aborted and cleans up.
void (async () => {
if (stream.aborted) return
if (stream.aborted) return;
await stream.writeSSE({
data: JSON.stringify(event),
event: event.type,
id: `${listId}-${Date.now()}`,
})
});
})().catch((err: unknown) => {
console.error('[sse] writeSSE error:', err)
})
})
unsubscribers.push(unsub)
console.error('[sse] writeSSE error:', err);
});
});
unsubscribers.push(unsub);
}
// 30s heartbeat — keeps Pangolin connection alive (smoke-tested in Phase 1)
let tick = 0
let tick = 0;
while (!stream.aborted) {
await stream.writeSSE({
data: JSON.stringify({ ts: new Date().toISOString() }),
event: 'heartbeat',
id: String(tick++),
})
await stream.sleep(30_000)
});
await stream.sleep(30_000);
}
// Cleanup all subscriptions on client disconnect
unsubscribers.forEach((unsub) => unsub())
})
})
unsubscribers.forEach((unsub) => unsub());
});
});