import { createTRPCContext, loadRoleDefaults } from "@capakraken/api"; import { appRouter } from "@capakraken/api/router"; import { prisma } from "@capakraken/db"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { getToken } from "next-auth/jwt"; import type { NextRequest } from "next/server"; import { auth } from "~/server/auth.js"; function extractClientIp(req: NextRequest): string | null { const forwarded = req.headers.get("x-forwarded-for"); if (forwarded) { const first = forwarded.split(",")[0]?.trim(); if (first) return first; } const realIp = req.headers.get("x-real-ip"); if (realIp) return realIp.trim(); return null; } // Hard cap on tRPC request body size to prevent memory/CPU amplification from // a single oversized payload. Stream uploads (files, reports) don't go through // tRPC. 2 MiB is comfortably above any legitimate tRPC batch call. const MAX_TRPC_BODY_BYTES = 2 * 1024 * 1024; // Throttle lastActiveAt updates: max once per 60s per user const lastActiveCache = new Map(); const ACTIVITY_THROTTLE_MS = 60_000; function trackActivity(userId: string) { const now = Date.now(); const last = lastActiveCache.get(userId) ?? 0; if (now - last < ACTIVITY_THROTTLE_MS) return; lastActiveCache.set(userId, now); prisma.user .update({ where: { id: userId }, data: { lastActiveAt: new Date(now) }, }) .catch(() => { /* ignore */ }); } const handler = async (req: NextRequest) => { // Reject oversized bodies before we touch auth, DB, or the router. A tRPC // mutation should never exceed MAX_TRPC_BODY_BYTES. Content-Length is // advisory — also guard against chunked requests below via length check // on the cloned body. if (req.method !== "GET") { const declaredLength = req.headers.get("content-length"); if (declaredLength) { const parsed = Number(declaredLength); if (Number.isFinite(parsed) && parsed > MAX_TRPC_BODY_BYTES) { return new Response(JSON.stringify({ error: "Request body too large" }), { status: 413, headers: { "Content-Type": "application/json" }, }); } } } const session = await auth(); // Validate active session registry on every authenticated request. // Sessions kicked by concurrent-session limits or manual logout are rejected immediately. // Fail-open: if the table doesn't exist yet (pending migration) the check is skipped. // In E2E test mode the jwt callback skips registration, so skip validation too. // // We decode the JWT directly (not session.user.jti) because the session // token is client-visible and therefore must not carry internal // session-revocation identifiers — see security ticket #41. const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true"; if (session?.user && !isE2eTestMode) { const secret = process.env["AUTH_SECRET"] ?? process.env["NEXTAUTH_SECRET"] ?? ""; const cookieName = (process.env["AUTH_URL"] ?? "").startsWith("https://") || process.env["VERCEL"] === "1" ? "__Host-authjs.session-token" : "authjs.session-token"; const jwt = secret ? await getToken({ req, secret, salt: cookieName }) : null; const jti = (jwt?.["sid"] as string | undefined) ?? undefined; if (jti) { try { const activeSession = await prisma.activeSession.findUnique({ where: { jti } }); if (!activeSession) { return new Response(JSON.stringify({ error: "Session revoked" }), { status: 401, headers: { "Content-Type": "application/json" }, }); } } catch { // Table may not exist yet (migration pending) — skip validation rather than crashing. } } } const dbUser = session?.user?.email ? await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true, systemRole: true, permissionOverrides: true }, }) : null; // Track user activity (throttled, fire-and-forget) if (dbUser) trackActivity(dbUser.id); // Load configurable role defaults (cached, 60s TTL) const roleDefaults = await loadRoleDefaults(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const options: any = { endpoint: "/api/trpc", req, router: appRouter, createContext: () => createTRPCContext({ session, dbUser, roleDefaults, clientIp: extractClientIp(req) }), }; if (process.env["NODE_ENV"] === "development") { options.onError = ({ path, error, }: { path?: string; error: { message: string; code?: string }; }) => { const label = `tRPC ${path ?? ""}`; if (error.code === "NOT_FOUND") { console.warn(`⚠️ ${label}: ${error.message}`); return; } console.error(`❌ ${label}: ${error.message}`); }; } return fetchRequestHandler(options); }; export { handler as GET, handler as POST };