feat(03-09): convert resolveUserId to async — real OIDC iss/sub→users.id via upsertUser (CR-06)

- 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
This commit is contained in:
Lucas Berger
2026-06-05 20:41:20 -04:00
parent 6d1d338a45
commit fac3a21332
+32 -40
View File
@@ -30,6 +30,7 @@ import { db } from '../db/client.js'
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js' import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js'
import { expandOccurrences } from '../broker/expand.js' import { expandOccurrences } from '../broker/expand.js'
import { getAuth } from '../auth/middleware.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') // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js' import '../auth/devBypass.js'
@@ -43,15 +44,30 @@ const MAX_WINDOW_DAYS = 90
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Auth helper — shared by all write endpoints // 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 // Resolution order (D-10, CR-06):
// injects c.get('user'). Write handlers check this first, then fall back to getAuth. // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
function resolveUserId(c: any): number | null { async function resolveUserId(c: any): Promise<number | null> {
const devUser = c.get('user') as { id: number } | undefined const devUser = c.get('user') as { id: number } | undefined
if (devUser) return devUser.id 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). // 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) => { eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) => {
// Auth: dev bypass first, then OIDC session. const currentUserId = await resolveUserId(c)
const currentUserId = resolveUserId(c as Parameters<typeof resolveUserId>[0]) if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
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') 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). // Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c) => { eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c) => {
const currentUserId = resolveUserId(c as Parameters<typeof resolveUserId>[0]) const currentUserId = await resolveUserId(c)
if (currentUserId === null) { if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
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 uid = c.req.param('uid')
const payload = c.req.valid('json') 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). // Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
eventsRouter.delete('/:uid', async (c) => { eventsRouter.delete('/:uid', async (c) => {
const currentUserId = resolveUserId(c as Parameters<typeof resolveUserId>[0]) const currentUserId = await resolveUserId(c)
if (currentUserId === null) { if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
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 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). // Returns { uid, status: 'done' } when no outbox row exists (nothing pending = settled).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), async (c) => { eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), async (c) => {
const currentUserId = resolveUserId(c as Parameters<typeof resolveUserId>[0]) const currentUserId = await resolveUserId(c)
if (currentUserId === null) { if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
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') const { uid } = c.req.valid('query')
@@ -495,12 +491,8 @@ eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), asy
// Response: { calendars: [{ url, displayName, color, isShared }] } // Response: { calendars: [{ url, displayName, color, isShared }] }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
eventsRouter.get('/writable-calendars', async (c) => { eventsRouter.get('/writable-calendars', async (c) => {
const currentUserId = resolveUserId(c as Parameters<typeof resolveUserId>[0]) const currentUserId = await resolveUserId(c)
if (currentUserId === null) { if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const auth = await getAuth(c)
if (!auth) return c.json({ error: 'Unauthorized' }, 401)
return c.json({ error: 'Unauthorized' }, 401)
}
try { try {
// D-03 writable set: own personal calendars + shared Family calendar. // D-03 writable set: own personal calendars + shared Family calendar.