Files
CapaKraken/packages/api/src/trpc.ts
T
Hartmut f7407bd882 fix(api): add centralized Prisma → TRPCError middleware on protectedProcedure
P2002/P2025/P2003 now map to CONFLICT/NOT_FOUND/BAD_REQUEST with generic
messages. Raw Prisma error details no longer reach the client.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:58:00 +02:00

289 lines
9.8 KiB
TypeScript

import { prisma, Prisma } from "@capakraken/db";
import { resolvePermissions, PermissionKey, SystemRole } from "@capakraken/shared";
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
import { loggingMiddleware } from "./middleware/logging.js";
import { apiRateLimiter } from "./middleware/rate-limit.js";
// Minimal Session type to avoid next-auth peer-dep in this package
interface Session {
user?: { email?: string | null; name?: string | null; image?: string | null } | null;
expires: string;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export interface TRPCContext {
session: Session | null;
db: typeof prisma;
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
roleDefaults: Record<string, PermissionKey[]> | null;
requestId?: string;
}
// Cache role defaults for 60 seconds to avoid DB hit on every request
let _roleDefaultsCache: Record<string, PermissionKey[]> | null = null;
let _roleDefaultsCacheTime = 0;
const ROLE_DEFAULTS_TTL = 60_000;
export async function loadRoleDefaults(): Promise<Record<string, PermissionKey[]>> {
const now = Date.now();
if (_roleDefaultsCache && now - _roleDefaultsCacheTime < ROLE_DEFAULTS_TTL) {
return _roleDefaultsCache;
}
const configs = await prisma.systemRoleConfig.findMany({
select: { role: true, defaultPermissions: true },
});
const map: Record<string, PermissionKey[]> = {};
for (const c of configs) {
map[c.role] = c.defaultPermissions as PermissionKey[];
}
_roleDefaultsCache = map;
_roleDefaultsCacheTime = now;
return map;
}
/** Invalidate the role defaults cache (call after updating SystemRoleConfig) */
export function invalidateRoleDefaultsCache(): void {
_roleDefaultsCache = null;
_roleDefaultsCacheTime = 0;
}
export function createTRPCContext(opts: {
session: Session | null;
dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null;
roleDefaults?: Record<string, PermissionKey[]> | null;
}): TRPCContext {
return {
session: opts.session,
db: prisma,
dbUser: opts.dbUser ?? null,
roleDefaults: opts.roleDefaults ?? null,
};
}
// ─── tRPC Init ───────────────────────────────────────────────────────────────
const t = initTRPC.context<TRPCContext>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
// ─── Procedures ──────────────────────────────────────────────────────────────
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
/**
* Public procedure — no authentication required.
*/
export const publicProcedure = t.procedure;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const withLogging = t.middleware(loggingMiddleware as any);
/**
* Maps a known Prisma error to a TRPCError with a safe generic message.
* Exported for unit testing.
*/
export function mapPrismaError(error: Prisma.PrismaClientKnownRequestError): TRPCError {
switch (error.code) {
case "P2002": // Unique constraint
return new TRPCError({
code: "CONFLICT",
message: "A record with this value already exists.",
});
case "P2025": // Record not found
return new TRPCError({
code: "NOT_FOUND",
message: "The requested record was not found.",
});
case "P2003": // Foreign key constraint
return new TRPCError({
code: "BAD_REQUEST",
message: "A referenced record does not exist.",
});
default:
// Log the full error server-side but don't expose internals
// eslint-disable-next-line no-console
console.error("[Prisma error]", error.code, error.message);
return new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "A database error occurred.",
});
}
}
/**
* Middleware that catches PrismaClientKnownRequestError and maps it to a clean TRPCError
* so DB internals never reach the client.
*/
const withPrismaErrors = t.middleware(async ({ next }) => {
try {
return await next();
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw mapPrismaError(error);
}
throw error; // re-throw non-Prisma errors unchanged
}
});
const isE2eTestMode =
process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] !== "production";
if (process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] === "production") {
// eslint-disable-next-line no-console
console.warn("[SECURITY] E2E_TEST_MODE is set in production — rate limiting is NOT bypassed.");
}
/**
* Protected procedure — requires authenticated session AND a valid DB user record.
* This prevents stale sessions from accessing data after the DB user is deleted.
*/
export const protectedProcedure = t.procedure.use(withPrismaErrors).use(withLogging).use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
}
if (!ctx.dbUser) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "User account not found" });
}
// Rate limit by user ID
if (!isE2eTestMode) {
const rateLimitResult = await apiRateLimiter(ctx.dbUser.id);
if (!rateLimitResult.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`,
});
}
}
return next({
ctx: {
...ctx,
session: ctx.session,
user: ctx.session.user,
dbUser: ctx.dbUser,
},
});
});
/**
* Resource overview procedure — requires broad people-directory visibility.
* Accepts explicit permission grants, not just elevated roles.
*/
export const resourceOverviewProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
);
if (
!permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
&& !permissions.has(PermissionKey.MANAGE_RESOURCES)
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Resource overview access required",
});
}
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* Planning read procedure — requires the explicit broad planning read audience.
*/
export const planningReadProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
);
if (!permissions.has(PermissionKey.VIEW_PLANNING)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Planning read access required",
});
}
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* Manager procedure — requires MANAGER or ADMIN role.
*/
export const managerProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
const allowedRoles: string[] = [SystemRole.ADMIN, SystemRole.MANAGER];
if (!allowedRoles.includes(user.systemRole)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Manager or Admin role required" });
}
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
);
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* Controller procedure — requires CONTROLLER, MANAGER, or ADMIN role.
* Grants read-only access to financial and export data.
*/
export const controllerProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
const allowed: SystemRole[] = [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER];
if (!allowed.includes(user.systemRole as SystemRole)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Controller access required" });
}
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as import("@capakraken/shared").PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
);
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* Admin procedure — requires ADMIN role only.
*/
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user || user.systemRole !== SystemRole.ADMIN) {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" });
}
const permissions = resolvePermissions(SystemRole.ADMIN, null, ctx.roleDefaults ?? undefined);
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* requirePermission — throws FORBIDDEN if the ctx lacks the given permission.
*/
export function requirePermission(
ctx: { permissions: Set<PermissionKey> },
key: PermissionKey
): void {
if (!ctx.permissions.has(key)) {
throw new TRPCError({ code: "FORBIDDEN", message: `Permission required: ${key}` });
}
}