Milestone v1.0: FamilySync MVP #1
@@ -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<number | null> {
|
||||
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<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 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<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 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<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 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<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 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<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 currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
|
||||
try {
|
||||
// D-03 writable set: own personal calendars + shared Family calendar.
|
||||
|
||||
Reference in New Issue
Block a user