From b3fcfd0bf1cee8b6dbff3f976c633116937adcaa Mon Sep 17 00:00:00 2001 From: "Zachariah K. Sharma" Date: Sun, 14 Jun 2026 12:47:52 -0600 Subject: [PATCH] fix: guard session user id and batch availability reads --- .../app/api/scheduler/availability/route.ts | 2 +- .../app/api/scheduler/connections/route.ts | 2 +- .../app/api/scheduler/meetings/route.ts | 4 +- .../app/api/scheduler/schedule/route.ts | 4 +- .../scheduler/app/api/scheduler/team/route.ts | 2 +- apps/scheduler/lib/scheduler/auth.ts | 17 +++++-- .../scheduler/lib/scheduler/legacy-request.ts | 5 ++ apps/scheduler/lib/scheduler/service.ts | 48 +++++++++++++++++-- 8 files changed, 69 insertions(+), 15 deletions(-) diff --git a/apps/scheduler/app/api/scheduler/availability/route.ts b/apps/scheduler/app/api/scheduler/availability/route.ts index b1ef388380..6adb644d21 100644 --- a/apps/scheduler/app/api/scheduler/availability/route.ts +++ b/apps/scheduler/app/api/scheduler/availability/route.ts @@ -35,7 +35,7 @@ export async function GET(request: NextRequest) { }); const result = await getAvailability( - Number(auth.session.user.id), + auth.userId, input.attendeeIds, input.rangeStart, input.rangeEnd, diff --git a/apps/scheduler/app/api/scheduler/connections/route.ts b/apps/scheduler/app/api/scheduler/connections/route.ts index c4be65ab34..7785b6a913 100644 --- a/apps/scheduler/app/api/scheduler/connections/route.ts +++ b/apps/scheduler/app/api/scheduler/connections/route.ts @@ -8,5 +8,5 @@ export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { const auth = await requireSchedulerSession(request); if ("response" in auth) return auth.response; - return NextResponse.json(await getConnections(Number(auth.session.user.id))); + return NextResponse.json(await getConnections(auth.userId)); } diff --git a/apps/scheduler/app/api/scheduler/meetings/route.ts b/apps/scheduler/app/api/scheduler/meetings/route.ts index 801425d71a..9985e45f22 100644 --- a/apps/scheduler/app/api/scheduler/meetings/route.ts +++ b/apps/scheduler/app/api/scheduler/meetings/route.ts @@ -9,7 +9,7 @@ export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { const auth = await requireSchedulerSession(request); if ("response" in auth) return auth.response; - return NextResponse.json(await listMeetings(Number(auth.session.user.id))); + return NextResponse.json(await listMeetings(auth.userId)); } export async function POST(request: NextRequest) { @@ -24,7 +24,7 @@ export async function POST(request: NextRequest) { } try { - const result = await createMeeting(Number(auth.session.user.id), body); + const result = await createMeeting(auth.userId, body); if (result.status === "not_implemented") { return NextResponse.json(result, { status: 501 }); } diff --git a/apps/scheduler/app/api/scheduler/schedule/route.ts b/apps/scheduler/app/api/scheduler/schedule/route.ts index 91c6bc3c34..e9bd450f53 100644 --- a/apps/scheduler/app/api/scheduler/schedule/route.ts +++ b/apps/scheduler/app/api/scheduler/schedule/route.ts @@ -9,7 +9,7 @@ export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { const auth = await requireSchedulerSession(request); if ("response" in auth) return auth.response; - return NextResponse.json(await getSchedule(Number(auth.session.user.id))); + return NextResponse.json(await getSchedule(auth.userId)); } export async function PUT(request: NextRequest) { @@ -24,7 +24,7 @@ export async function PUT(request: NextRequest) { } try { - const schedule = await updateSchedule(Number(auth.session.user.id), body); + const schedule = await updateSchedule(auth.userId, body); return NextResponse.json(schedule); } catch (error) { if (error instanceof ZodError) { diff --git a/apps/scheduler/app/api/scheduler/team/route.ts b/apps/scheduler/app/api/scheduler/team/route.ts index 848374ec43..fb6f02be09 100644 --- a/apps/scheduler/app/api/scheduler/team/route.ts +++ b/apps/scheduler/app/api/scheduler/team/route.ts @@ -8,5 +8,5 @@ export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { const auth = await requireSchedulerSession(request); if ("response" in auth) return auth.response; - return NextResponse.json(await getTeamContext(Number(auth.session.user.id))); + return NextResponse.json(await getTeamContext(auth.userId)); } diff --git a/apps/scheduler/lib/scheduler/auth.ts b/apps/scheduler/lib/scheduler/auth.ts index 476f434c31..2dc864e3cd 100644 --- a/apps/scheduler/lib/scheduler/auth.ts +++ b/apps/scheduler/lib/scheduler/auth.ts @@ -9,8 +9,11 @@ export async function getSchedulerSession(request: NextRequest) { } /** - * Returns either `{ session }` for an authenticated viewer, or `{ response }` - * carrying a 401 the route handler should return directly. + * Returns either `{ session, userId }` for an authenticated viewer, or `{ response }` + * carrying a 401 the route handler should return directly. The `userId` is the + * session user id coerced and validated to a finite positive integer, so callers + * never re-coerce a possibly-undefined value into a `NaN` that would silently flow + * into a Prisma query. */ export async function requireSchedulerSession(request: NextRequest) { const session = await getSchedulerSession(request); @@ -19,5 +22,13 @@ export async function requireSchedulerSession(request: NextRequest) { response: NextResponse.json({ error: "AUTHENTIK_LOGIN_REQUIRED" }, { status: 401 }), }; } - return { session }; + + const userId = Number(session.user.id); + if (!Number.isInteger(userId) || userId <= 0) { + return { + response: NextResponse.json({ error: "INVALID_SESSION_USER_ID" }, { status: 401 }), + }; + } + + return { session, userId }; } diff --git a/apps/scheduler/lib/scheduler/legacy-request.ts b/apps/scheduler/lib/scheduler/legacy-request.ts index 5a5fc8ea8e..361c499bba 100644 --- a/apps/scheduler/lib/scheduler/legacy-request.ts +++ b/apps/scheduler/lib/scheduler/legacy-request.ts @@ -5,6 +5,11 @@ import type { NextRequest } from "next/server"; * Adapts an App Router `NextRequest` into the `NextApiRequest`-shaped object that * Cal's `getServerSession` (via `next-auth/jwt` `getToken`) expects: a plain cookies * map, a plain headers map, plus `method` and `url`. + * + * Contract: `getToken`/`getServerSession` read ONLY `cookies`, `headers`, `method`, + * and `url` from the request. The `as unknown as NextApiRequest` cast is sound only + * while that holds — if a future Cal/next-auth version reads other `NextApiRequest` + * fields (e.g. `body`, `query`), this adapter must be extended to provide them. */ export function buildLegacyRequest(request: NextRequest): NextApiRequest { const cookies = Object.fromEntries( diff --git a/apps/scheduler/lib/scheduler/service.ts b/apps/scheduler/lib/scheduler/service.ts index 6e1c19e2e5..3134342f92 100644 --- a/apps/scheduler/lib/scheduler/service.ts +++ b/apps/scheduler/lib/scheduler/service.ts @@ -155,6 +155,43 @@ async function loadSchedule(userId: number): Promise { return buildSchedule(userId, schedule, user.timeZone ?? DEFAULT_TIME_ZONE); } +/** + * Batched form of `loadSchedule` for the availability hot path: two queries total + * (users + schedules) regardless of attendee count, instead of ~2N. Output order and + * shape mirror `attendeeIds.map(loadSchedule)`. + */ +async function loadSchedules(userIds: number[]): Promise { + if (userIds.length === 0) { + return []; + } + + const [users, schedules] = await Promise.all([ + prisma.user.findMany({ where: { id: { in: userIds } }, select: USER_SELECT }), + prisma.schedule.findMany({ + where: { userId: { in: userIds } }, + orderBy: { id: "asc" }, + select: { id: true, userId: true, timeZone: true, ...SCHEDULE_INCLUDE }, + }), + ]); + + const usersById = new Map(users.map((user) => [user.id, user])); + // Keep the first schedule per user, mirroring loadSchedule's findFirst(orderBy id asc). + const scheduleByUser = new Map(); + for (const schedule of schedules) { + if (!scheduleByUser.has(schedule.userId)) { + scheduleByUser.set(schedule.userId, schedule); + } + } + + return userIds.map((userId) => { + const user = usersById.get(userId); + if (!user) { + throw new Error(`Scheduler user not found: ${userId}`); + } + return buildSchedule(userId, scheduleByUser.get(userId) ?? null, user.timeZone ?? DEFAULT_TIME_ZONE); + }); +} + export async function getSchedule(userId: number): Promise { if (isDemoMode()) { return demoSchedules.find((schedule) => schedule.userId === userId) ?? demoSchedules[0]; @@ -235,7 +272,7 @@ export async function updateSchedule(userId: number, body: unknown): Promise { + await prisma.$transaction(async (tx) => { const schedule = existing ? await tx.schedule.update({ where: { id: existing.id }, @@ -253,10 +290,9 @@ export async function updateSchedule(userId: number, body: unknown): Promise ({ ...row, scheduleId: schedule.id })), }); } - return schedule.id; }); - void scheduleId; + // Read back the canonical persisted state so callers see exactly what was stored. return loadSchedule(userId); } @@ -360,8 +396,10 @@ export async function getAvailability( return { busy: filterBusyBlocksForViewer(relevant, viewerId), mutualSlots }; } - const busyBlocks = await bookingsToBusyBlocks(attendeeIds, rangeStart, rangeEnd); - const schedules = await Promise.all(attendeeIds.map((id) => loadSchedule(id))); + const [busyBlocks, schedules] = await Promise.all([ + bookingsToBusyBlocks(attendeeIds, rangeStart, rangeEnd), + loadSchedules(attendeeIds), + ]); const mutualSlots = computeMutualSlots({ schedules,