From fac3a21332ebbe18f6b2e0bad395f101e2dadc39 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 20:41:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(03-09):=20convert=20resolveUserId=20to=20a?= =?UTF-8?q?sync=20=E2=80=94=20real=20OIDC=20iss/sub=E2=86=92users.id=20via?= =?UTF-8?q?=20upsertUser=20(CR-06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import upsertUser from auth/user.js - resolveUserId now async: dev-bypass path unchanged; OIDC path calls getAuth then upsertUser(iss, sub, email) to resolve DB user id - All 5 handlers (create, edit, delete, sync-status, writable-calendars) updated to await resolveUserId and 401 only when it returns null - Remove all inline 'For now return 401' stubs and redundant getAuth calls - grep confirms 0 'For now return 401' stubs remain; upsertUser imported+called --- apps/api/src/routes/events.ts | 72 ++++++++++++++++------------------- 1 file changed, 32 insertions(+), 40 deletions(-) diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 18f2436..ae6e743 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -30,6 +30,7 @@ import { db } from '../db/client.js' import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js' import { expandOccurrences } from '../broker/expand.js' import { getAuth } from '../auth/middleware.js' +import { upsertUser } from '../auth/user.js' // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js' @@ -43,15 +44,30 @@ const MAX_WINDOW_DAYS = 90 // --------------------------------------------------------------------------- // 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. -// --------------------------------------------------------------------------- +// +// 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. +// // eslint-disable-next-line @typescript-eslint/no-explicit-any -function resolveUserId(c: any): number | null { +async function resolveUserId(c: any): Promise { const devUser = c.get('user') as { id: number } | undefined if (devUser) return devUser.id - return null + + const auth = await getAuth(c) + if (!auth) return null + + const iss = (auth.iss as string | undefined) ?? '' + const sub = auth.sub ?? '' + const email = typeof auth.email === 'string' ? auth.email : undefined + + const user = await upsertUser(iss, sub, email) + return user?.id ?? null } // --------------------------------------------------------------------------- @@ -196,16 +212,8 @@ 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) => { - // 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 currentUserId = await resolveUserId(c) + if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) const payload = c.req.valid('json') @@ -273,12 +281,8 @@ 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 = 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 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') @@ -377,12 +381,8 @@ 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 = 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 currentUserId = await resolveUserId(c) + if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) const uid = c.req.param('uid') @@ -442,12 +442,8 @@ 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 = 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 currentUserId = await resolveUserId(c) + if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401) const { uid } = c.req.valid('query') @@ -495,12 +491,8 @@ eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), asy // 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) - } + 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.