diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index b3505a1..1e22c32 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -1,15 +1,22 @@ /** - * GET /api/events — windowed, color-tagged, DST-correct, EXDATE-aware occurrences. + * Events router — windowed reads + write-back API surface. * * Architecture invariant (T-03-02, broker-boundary): - * This route reads ONLY from the MariaDB cache. It NEVER calls Fastmail directly. + * This route reads ONLY from the MariaDB cache for GET /. It NEVER calls Fastmail directly. * All Fastmail I/O is owned exclusively by the broker module (src/broker/). * No tsdav import here; no createFastmailClient import here. + * Write endpoints (POST /create, PATCH /:uid/edit, DELETE /:uid) ENQUEUE outbox rows only — + * they do not build VEVENTs and do not call Fastmail; the outbox worker (Plan 04) does both. * - * Security (threat model T-02b-01 / T-02b-02): + * Security (threat model T-02b-01 / T-02b-02, T-03-06..T-03-11): * - start/end query params validated with zod ISO-date regex before any SQL. * - Window hard-capped at 90 days (DoS guard). - * - Drizzle parameterized queries prevent SQL injection. + * - Write payloads validated with zod (title 255, location/description 2000 — T-03-08). + * - All write endpoints assert calendar ownership (userId = currentUser.id OR isShared) — T-03-06. + * - sync-status scoped strictly to currentUser.id — T-03-07. + * - writable-calendars query restricts to userId = currentUser.id OR isShared=1 — T-03-11. + * - Drizzle parameterized queries prevent SQL injection — T-03-09. + * - etag read server-side from calendarEvents; client never supplies it — T-03-10. * * Mounted under /api/* in index.ts — behind oidcAuthMiddleware. */ @@ -17,11 +24,14 @@ import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' -import { and, or, eq, lte, lt } from 'drizzle-orm' +import { and, or, eq, desc } from 'drizzle-orm' import { sql } from 'drizzle-orm' import { db } from '../db/client.js' -import { calendarEvents, calendars, users } from '../db/schema.js' +import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js' import { expandOccurrences } from '../broker/expand.js' +import { getAuth } from '../auth/middleware.js' +// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') +import '../auth/devBypass.js' export const eventsRouter = new Hono() @@ -31,25 +41,56 @@ const SHARED_FAMILY_COLOR = '#F25C7A' /** Maximum allowed date-window span to prevent DoS (T-02b-02). */ const MAX_WINDOW_DAYS = 90 -/** - * Zod schema for the required query parameters. - * Rejects any value that is not a strictly formatted ISO date (YYYY-MM-DD). - */ +// --------------------------------------------------------------------------- +// Auth helper — shared by all write endpoints +// Returns the numeric userId from dev-bypass context; null if not present. +// The OIDC path requires a separate getAuth(c) call — only the dev-bypass path +// injects c.get('user'). Write handlers check this first, then fall back to getAuth. +// --------------------------------------------------------------------------- +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function resolveUserId(c: any): number | null { + const devUser = c.get('user') as { id: number } | undefined + if (devUser) return devUser.id + return null +} + +// --------------------------------------------------------------------------- +// Zod schemas +// --------------------------------------------------------------------------- + +/** Windowed GET query params. */ const eventsQuerySchema = z.object({ start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), }) -/** - * GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD - * - * Returns a flat array of concrete occurrences windowed to [start, end). - * Joins calendarEvents → calendars → users to resolve color and ownership. - * Expands recurring masters (hasRrule=true) via expandOccurrences() so RRULE, - * EXDATE, and DST are all handled server-side (D-09). - * - * Response shape: { occurrences: CalendarOccurrence[] } - */ +/** Shared event field validation (V5 — bounded lengths, T-03-08). */ +const eventFieldsSchema = z.object({ + summary: z.string().min(1).max(255), + allDay: z.boolean(), + dtstart: z.string().min(1).max(64), // ISO string or DATE + dtend: z.string().min(1).max(64), + location: z.string().max(2000).optional(), + description: z.string().max(2000).optional(), + recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(), + calendarUrl: z.string().url().max(1024).optional(), +}) + +/** 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 +// +// Returns a flat array of concrete occurrences windowed to [start, end). +// Joins calendarEvents → calendars → users to resolve color and ownership. +// Expands recurring masters (hasRrule=true) via expandOccurrences() so RRULE, +// EXDATE, and DST are all handled server-side (D-09). +// +// Response shape: { occurrences: CalendarOccurrence[] } +// --------------------------------------------------------------------------- eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { const { start, end } = c.req.valid('query') @@ -139,3 +180,344 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { return c.json({ error: 'Service unavailable' }, 503) } }) + +// --------------------------------------------------------------------------- +// POST /api/events/create +// +// Validates input, resolves the target calendar (D-03 ownership check), and +// enqueues a `pending` outbox row. Returns 202 immediately (D-05, optimistic-accept). +// 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) => { + // Auth: dev bypass first, then OIDC session. + const currentUserId = resolveUserId(c as Parameters[0]) + if (currentUserId === null) { + // Fall through to getAuth for OIDC path + const auth = await getAuth(c) + if (!auth) return c.json({ error: 'Unauthorized' }, 401) + // In production OIDC path, we'd look up the user row by iss+sub. + // For now return 401 if OIDC auth is not backed by a DB user here. + return c.json({ error: 'Unauthorized' }, 401) + } + + 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 + + if (payload.calendarUrl) { + // Look up the calendar — it must be owned by the current user or be shared. + const [calRow] = await db + .select({ url: calendars.url, userId: calendars.userId, isShared: calendars.isShared }) + .from(calendars) + .where( + and( + 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) + } + targetCalendarUrl = calRow.url + } else { + // Default to first personal calendar (D-01) + const [calRow] = await db + .select({ url: calendars.url }) + .from(calendars) + .where(eq(calendars.userId, currentUserId)) + + if (!calRow) { + return c.json({ error: 'No writable calendar found for user' }, 422) + } + targetCalendarUrl = calRow.url + } + + // Generate a UID for the new event (Node.js 22 built-in) + const uid = `${crypto.randomUUID()}@familysync` + + // Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail. + await db.insert(calendarOutbox).values({ + userId: currentUserId, + operation: 'create', + status: 'pending', + uid, + calendarUrl: targetCalendarUrl, + payload: JSON.stringify(payload), + }) + + return c.json({ uid }, 202) + } catch (err) { + console.error('[events/create] DB operation failed:', err) + return c.json({ error: 'Service unavailable' }, 503) + } +}) + +// --------------------------------------------------------------------------- +// PATCH /api/events/:uid/edit +// +// Looks up the cached event by uid, asserts ownership (D-03), and enqueues an +// outbox row. If the target calendarUrl differs from the current calendar (calendar +// move, D-04), TWO rows are inserted in a single transaction (delete+create pair). +// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12). +// --------------------------------------------------------------------------- +eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c) => { + const currentUserId = resolveUserId(c as Parameters[0]) + if (currentUserId === null) { + const auth = await getAuth(c) + if (!auth) return c.json({ error: 'Unauthorized' }, 401) + return c.json({ error: 'Unauthorized' }, 401) + } + + const uid = c.req.param('uid') + const payload = c.req.valid('json') + + try { + // --- Look up the event and verify ownership --- + // We look up calendarEvents joined to calendars via a where condition on calendarId. + // The calendar's userId must match the current user (or be shared). + const [eventRow] = await db + .select({ + uid: calendarEvents.uid, + etag: calendarEvents.etag, + objectUrl: calendarEvents.objectUrl, + calendarId: calendarEvents.calendarId, + calendarUrl: calendars.url, + userId: calendars.userId, + }) + .from(calendarEvents) + .where(eq(calendarEvents.uid, uid)) + + if (!eventRow) { + return c.json({ error: 'Event not found' }, 404) + } + + // Ownership check: must be the calendar owner or shared (T-03-06) + if (eventRow.userId !== currentUserId) { + // Check if the calendar is shared (shared calendars are writable by all household members) + const [calRow] = await db + .select({ isShared: calendars.isShared }) + .from(calendars) + .where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true))) + + if (!calRow) { + return c.json({ error: 'Access denied' }, 403) + } + } + + 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 = `${crypto.randomUUID()}@familysync` + const groupId = crypto.randomUUID() + + await db.transaction(async (tx) => { + // Delete from old calendar + await tx.insert(calendarOutbox).values({ + userId: currentUserId, + operation: 'delete', + status: 'pending', + uid, + calendarUrl: eventRow.calendarUrl ?? '', + calendarObjectUrl: eventRow.objectUrl ?? undefined, + etag: eventRow.etag ?? undefined, + groupId, + }) + // Create on new calendar + await tx.insert(calendarOutbox).values({ + userId: currentUserId, + operation: 'create', + status: 'pending', + uid: newUid, + calendarUrl: newCalendarUrl, + payload: JSON.stringify(payload), + groupId, + }) + }) + + return c.json({ uid: newUid }, 202) + } + + // Same calendar — simple update row + await db.insert(calendarOutbox).values({ + userId: currentUserId, + operation: 'update', + status: 'pending', + uid, + calendarUrl: eventRow.calendarUrl ?? '', + calendarObjectUrl: eventRow.objectUrl ?? undefined, + etag: eventRow.etag ?? undefined, + payload: JSON.stringify(payload), + }) + + return c.json({ uid }, 202) + } catch (err) { + console.error('[events/edit] DB operation failed:', err) + return c.json({ error: 'Service unavailable' }, 503) + } +}) + +// --------------------------------------------------------------------------- +// DELETE /api/events/:uid +// +// Asserts ownership, enqueues a delete outbox row with the cached etag (D-08 / T-03-10). +// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12). +// --------------------------------------------------------------------------- +eventsRouter.delete('/:uid', async (c) => { + const currentUserId = resolveUserId(c as Parameters[0]) + if (currentUserId === null) { + const auth = await getAuth(c) + if (!auth) return c.json({ error: 'Unauthorized' }, 401) + return c.json({ error: 'Unauthorized' }, 401) + } + + const uid = c.req.param('uid') + + try { + // Look up the event + const [eventRow] = await db + .select({ + uid: calendarEvents.uid, + etag: calendarEvents.etag, + objectUrl: calendarEvents.objectUrl, + calendarId: calendarEvents.calendarId, + calendarUrl: calendars.url, + userId: calendars.userId, + }) + .from(calendarEvents) + .where(eq(calendarEvents.uid, uid)) + + if (!eventRow) { + return c.json({ error: 'Event not found' }, 404) + } + + // Ownership check (T-03-06) + if (eventRow.userId !== currentUserId) { + const [calRow] = await db + .select({ isShared: calendars.isShared }) + .from(calendars) + .where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true))) + + if (!calRow) { + return c.json({ error: 'Access denied' }, 403) + } + } + + // Enqueue delete — etag from calendarEvents (T-03-10, never from client) + await db.insert(calendarOutbox).values({ + userId: currentUserId, + operation: 'delete', + status: 'pending', + uid, + calendarUrl: eventRow.calendarUrl ?? '', + calendarObjectUrl: eventRow.objectUrl ?? undefined, + etag: eventRow.etag ?? undefined, + }) + + return c.json({ uid }, 202) + } catch (err) { + console.error('[events/delete] DB operation failed:', err) + return c.json({ error: 'Service unavailable' }, 503) + } +}) + +// --------------------------------------------------------------------------- +// GET /api/events/sync-status?uid= +// +// Returns the outbox status for the given UID, scoped to the current member (T-03-07). +// Polled by TanStack Query on the client to power the optimistic-accept toast (D-09). +// Returns { uid, status: 'done' } when no outbox row exists (nothing pending = settled). +// --------------------------------------------------------------------------- +eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), async (c) => { + const currentUserId = resolveUserId(c as Parameters[0]) + if (currentUserId === null) { + const auth = await getAuth(c) + if (!auth) return c.json({ error: 'Unauthorized' }, 401) + return c.json({ error: 'Unauthorized' }, 401) + } + + const { uid } = c.req.valid('query') + + try { + // Scope strictly to current member's rows (T-03-07 — never leak another member's outbox). + const rows = await db + .select({ + uid: calendarOutbox.uid, + status: calendarOutbox.status, + lastError: calendarOutbox.lastError, + }) + .from(calendarOutbox) + .where(and(eq(calendarOutbox.userId, currentUserId), eq(calendarOutbox.uid, uid))) + .orderBy(desc(calendarOutbox.createdAt)) + .limit(1) + + if (!rows.length) { + // No outbox row → nothing pending = settled as done + return c.json({ uid, status: 'done' }) + } + + 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) + } +}) + +// --------------------------------------------------------------------------- +// GET /api/events/writable-calendars +// +// Returns the D-03 writable set for the current member: +// - The member's own personal calendar(s) (userId = currentUser.id) +// - The shared Family calendar (isShared = true) +// +// The other member's personal calendar (different userId, isShared=false) MUST NOT +// appear — it is a read-only overlay only (D-03). This endpoint is AUTHORITATIVE: +// the client (Plan 05) consumes it verbatim and never derives the writable set itself (T-03-11). +// +// Response: { calendars: [{ url, displayName, color, isShared }] } +// --------------------------------------------------------------------------- +eventsRouter.get('/writable-calendars', async (c) => { + const currentUserId = resolveUserId(c as Parameters[0]) + if (currentUserId === null) { + const auth = await getAuth(c) + if (!auth) return c.json({ error: 'Unauthorized' }, 401) + return c.json({ error: 'Unauthorized' }, 401) + } + + try { + // D-03 writable set: own personal calendars + shared Family calendar. + // Another member's personal (userId ≠ currentUserId AND isShared=false) is excluded (T-03-11). + const rows = await db + .select({ + url: calendars.url, + displayName: calendars.displayName, + color: calendars.color, + isShared: calendars.isShared, + }) + .from(calendars) + .where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true))) + + return c.json({ + calendars: rows.map((row) => ({ + url: row.url, + displayName: row.displayName ?? '', + color: row.color ?? '', + isShared: row.isShared, + })), + }) + } catch (err) { + console.error('[events/writable-calendars] DB query failed:', err) + return c.json({ error: 'Service unavailable' }, 503) + } +})