feat(03-03): implement write API surface — create/edit/delete + sync-status + writable-calendars

- POST /create: validates with zod, checks calendar ownership (D-03/T-03-06), enqueues pending outbox row, returns 202 with uid
- PATCH /:uid/edit: looks up event, checks ownership, enqueues update row; uses db.transaction for edit-as-move calendar pair (D-04)
- DELETE /:uid: looks up event, checks ownership, enqueues delete row with server-side etag (T-03-10)
- GET /sync-status: returns outbox status scoped to currentUser only (T-03-07/D-09)
- GET /writable-calendars: returns own personal + shared calendars, never other member's personal (D-03/T-03-11)
- Auth via dev-bypass (c.get('user')) + getAuth(c) fallback; 401 if neither
- No tsdav import — broker boundary enforced (D-12)
- All 69 events tests GREEN; tsc --noEmit clean
This commit is contained in:
Lucas Berger
2026-06-05 17:58:01 -04:00
parent e14c5dab69
commit 0a8222329e
+402 -20
View File
@@ -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<typeof resolveUserId>[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<typeof resolveUserId>[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<typeof resolveUserId>[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=<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<typeof resolveUserId>[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<typeof resolveUserId>[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)
}
})