/** * Events router — windowed reads + write-back API surface. * * Architecture invariant (T-03-02, broker-boundary): * 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, 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). * - 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. */ 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 { signalOutboxDrain } from '../lib/outboxTrigger.js'; // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js'; export const eventsRouter = new Hono(); /** Shared-family calendar rose color (D-06). */ const SHARED_FAMILY_COLOR = '#F25C7A'; /** Maximum allowed date-window span to prevent DoS (T-02b-02). */ const MAX_WINDOW_DAYS = 90; // --------------------------------------------------------------------------- // Auth helper — shared by all write endpoints // // Resolution order (D-10, CR-06): // 1. Dev-bypass path: c.get('user') is set by devAuthBypass() middleware when // DEV_AUTH_BYPASS=true. Return its .id directly — no OIDC round-trip. // 2. OIDC path: call getAuth(c). If null → unauthenticated, return null. // Otherwise extract iss/sub/email and call upsertUser — which writes the // user row on first login and returns the existing row on subsequent calls. // Identity is keyed on oidc_iss + oidc_sub (D-10), never email. // 3. Callers emit 401 when resolveUserId returns null. // // IN-04: typed as Hono's Context instead of `any`. c.get('user') resolves through the // 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 { 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 ?? ''; // 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 user = await upsertUser(iss, sub, displayName); return user?.id ?? 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}$/), }); /** * Shared event field validation (V5 — bounded lengths, T-03-08). * * Field names match the PWA CreateEventPayload (apps/pwa/src/api/client.ts:119-128) * exactly — title/start/end — so no rename map is needed end-to-end (CR-01). * The stored payload JSON uses these same names; the outbox worker (plan 03-10) * reads title/start/end when building the VEVENT. */ const eventFieldsSchema = z.object({ title: z.string().min(1).max(255), allDay: z.boolean(), start: z.string().min(1).max(64), // ISO string or DATE (YYYY-MM-DD for allDay) end: 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(), // D-06: recurrence bounding (RRULE UNTIL / COUNT) // CR-01: validate the exact 'YYYY-MM-DD' shape (reusing eventsQuerySchema's regex) so a // ≤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 recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT // Phase 11: per-event reminder lead in minutes (CAL-13/CAL-14, D-08). // absent — field not present; outbox worker preserves existing VALARM (no-change, D-08) // null — explicit "None" → clear the VALARM on write-back // 0 — same-day all-day reminder (9 AM on event date); timed 0 = None (D-06) // positive int — N minutes before event start (timed) or N/1440 days before (all-day) reminderLeadMinutes: z.number().int().min(0).nullable().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) => { // 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 { 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); if (spanDays > MAX_WINDOW_DAYS || spanDays <= 0) { return c.json({ error: 'Date window must be between 1 and 90 days' }, 400); } try { // --- SQL pre-filter strategy (RESEARCH.md §Open Questions 3 / Pitfall 5) --- // // The WHERE clause must include THREE categories of events: // 1. Non-recurring timed events with dtstartUtc in [windowStart, windowEnd) // 2. Non-recurring all-day events with dtstartDate in [start, end) (DATE comparison) // 3. Recurring masters (hasRrule=true) with dtstartUtc < windowEnd // (a weekly meeting created 3 years ago can still have occurrences in the window) // // expandOccurrences() does the precise window-boundary check for all rows returned. // // Using raw SQL for the complex WHERE to keep the query readable and unambiguous. // Drizzle's `sql` tag parameterizes all values — no string interpolation. const rows = await db .select({ rawVevent: calendarEvents.rawVevent, calendarId: calendars.id, calendarName: calendars.displayName, isShared: calendars.isShared, userId: users.id, userColor: users.color, ownerName: users.displayName, // Phase 11 Plan 03: surface reminderLeadMinutes for edit-mode pre-population (D-06/D-10) reminderLeadMinutes: calendarEvents.reminderLeadMinutes, }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .innerJoin(users, eq(calendars.userId, users.id)) .where( and( // Ownership predicate (BUG 3 fix): restrict to calendars owned by the current user // OR shared-family calendars (isShared=true). Mirrors the /writable-calendars idiom // (~line 509) so both endpoints agree on the authoritative writable set (D-03). or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)), // Date-window pre-filter (RESEARCH.md §Open Questions 3 / Pitfall 5): or( // Recurring masters: may have occurrences inside the window even if dtstartUtc is old. // Two sub-cases: // (a) Timed recurring masters: dtstartUtc < windowEnd // (b) All-day recurring masters: dtstartUtc is NULL (DATE-only), use dtstartDate < end // A NULL dtstartUtc causes the timed comparison to be NULL/false, so (b) carries it. and( sql`${calendarEvents.hasRrule} = 1`, or( sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`, sql`${calendarEvents.dtstartDate} < ${end}`, ), ), // Non-recurring timed events: dtstartUtc falls in [windowStart, windowEnd) and( sql`${calendarEvents.hasRrule} = 0`, sql`${calendarEvents.dtstartUtc} IS NOT NULL`, sql`${calendarEvents.dtstartUtc} >= ${windowStartDate}`, sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`, ), // Non-recurring all-day events: dtstartDate falls in [start, end) — DATE comparison. // WR-01: gate on hasRrule = 0 so a recurring all-day master whose dtstartDate // lands in the window is NOT also matched here (it is already carried by the // recurring branch above). Without this gate the master is expanded twice and // every occurrence is duplicated on the wire. and( sql`${calendarEvents.hasRrule} = 0`, sql`${calendarEvents.dtstartDate} IS NOT NULL`, sql`${calendarEvents.dtstartDate} >= ${start}`, sql`${calendarEvents.dtstartDate} < ${end}`, ), ), ), ); // --- Expand each row into concrete occurrences --- const allOccurrences = rows.flatMap((row) => { const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor; return expandOccurrences( row.rawVevent, windowStartDate, windowEndDate, row.calendarId, row.calendarName ?? '', row.userId, row.ownerName ?? null, color, row.isShared, ); }); return c.json({ occurrences: allOccurrences }); } catch (err) { console.error('[events] DB query or expansion failed:', err); 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) => { const currentUserId = await resolveUserId(c); if (currentUserId === null) 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 the member's first personal calendar (D-01). // WR-02: add a deterministic ORDER BY + LIMIT. Without them, a member with // multiple personal calendars gets an arbitrary, query-to-query-unstable "first" // row. Ordering by id (insertion order) gives a stable default; limit(1) avoids // fetching the whole set just to take [0]. const [calRow] = await db .select({ url: calendars.url }) .from(calendars) .where(eq(calendars.userId, currentUserId)) .orderBy(calendars.id) .limit(1); 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 = `${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), }); // must stay AFTER the enqueue commit — worker selects committed pending rows only signalOutboxDrain(); 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 = await resolveUserId(c); if (currentUserId === null) 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 --- // Join calendarEvents → calendars so we can read calendars.url and calendars.userId // in the same query. Without the join, referencing calendars.* produces invalid SQL // (Drizzle throws at toSQL() time) → 503. Mirrors the GET / join idiom at line 153. // // CR-01: calendar_events is keyed (calendarId, uid), NOT uid alone (schema.ts:121). // With a shared Fastmail account (D-16) the SAME uid is cached once per member's // calendar, so a uid-only lookup returns 2+ rows and an arbitrary [0] (typically the // OTHER member's row). Scope the lookup to the acting member's writable set // (own calendars OR shared) so the etag/objectUrl/ownership we act on belong to the // right calendar. Order so the current user's OWN row ranks before a shared/other row // — when both a personal and a shared copy of the uid exist, the acting member's copy // is authoritative for the write target. limit(1) makes the pick deterministic. const [eventRow] = await db .select({ uid: calendarEvents.uid, etag: calendarEvents.etag, objectUrl: calendarEvents.objectUrl, calendarId: calendarEvents.calendarId, calendarUrl: calendars.url, userId: calendars.userId, rawVevent: calendarEvents.rawVevent, }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .where( and( eq(calendarEvents.uid, uid), or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)), ), ) .orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`) .limit(1); if (!eventRow) { return c.json({ error: 'Event not found' }, 404); } // Ownership check: must be the calendar owner or shared (T-03-06). // The WHERE above already restricts to the writable set, so any returned row is // either the user's own calendar or a shared one — re-verify defensively. 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 = `${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 // create lands under a brand-new uid that the worker can never look up the // original RRULE from. Unlike the same-calendar `update` branch — which reads // rawVevent and re-applies the stored RRULE — the create branch has no source // for it. Extract the RRULE from the source event here and stash it on the // create payload so the worker re-applies it, preventing a recurring series // from silently collapsing into a single occurrence on a calendar move. // 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; const createPayload = preservedRrule !== undefined ? { ...payload, _preservedRrule: preservedRrule } : payload; 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(createPayload), groupId, }); }); // must stay AFTER the enqueue commit — worker selects committed pending rows only signalOutboxDrain(); 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), }); // must stay AFTER the enqueue commit — worker selects committed pending rows only signalOutboxDrain(); 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 = await resolveUserId(c); if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401); const uid = c.req.param('uid'); try { // Look up the event — join calendars so calendars.url / calendars.userId are accessible. // Same innerJoin idiom as the GET / handler (line 153). Without this join, Drizzle // throws at toSQL() time → 503. // // CR-01: scope to the acting member's writable set and pick deterministically — a // shared Fastmail account (D-16) caches the same uid once per member's calendar, so a // uid-only lookup would otherwise act on an arbitrary member's etag/objectUrl. 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) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .where( and( eq(calendarEvents.uid, uid), or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)), ), ) .orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`) .limit(1); 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, }); // must stay AFTER the enqueue commit — worker selects committed pending rows only signalOutboxDrain(); 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 = await resolveUserId(c); if (currentUserId === null) 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). // // WR-04: rapid successive same-uid edits enqueue multiple outbox rows. A plain // "newest row" pick (ORDER BY createdAt DESC LIMIT 1) reports only the latest row's // status — so if the newest succeeds but an older row dead-lettered, the user sees // "Saved" while a queued write silently failed. Rank an unsettled/failed row ABOVE a // done row: a row in pending/failed/dead for the uid outranks a done row, and only // among same-priority rows do we fall back to newest-first. This surfaces a failure // for ANY row of the uid instead of masking it behind a later success. 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))) // status priority: failed/dead first, then pending, then done. .orderBy( sql`case ${calendarOutbox.status} when 'failed' then 0 when 'dead' then 0 when 'pending' then 1 else 2 end`, 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 = await resolveUserId(c); if (currentUserId === null) 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); } });