17471af7f8
CI / Architecture Guardrails (push) Successful in 3m38s
CI / Assistant Split Regression (push) Successful in 4m40s
CI / Lint (push) Successful in 5m17s
CI / Typecheck (push) Successful in 5m46s
CI / Build (push) Successful in 7m1s
CI / Unit Tests (push) Failing after 9m41s
CI / Release Images (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / E2E Tests (push) Has started running
Closes #51 (ESLint rule + conventions doc remain as follow-up). Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
139 lines
4.9 KiB
TypeScript
139 lines
4.9 KiB
TypeScript
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<string, number>();
|
|
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 ?? "<no-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 };
|