60d267fa0a
- event-bus: wrap each subscriber.fn call in try/catch so one throwing subscriber cannot kill delivery to all others - event-bus: log Redis parse errors instead of swallowing them silently; add .catch() on Redis publish promise for async fallback to local delivery - pruning.ts: new runPruning() deletes expired invite tokens, expired password-reset tokens, and read notifications older than 90 days - settings.runPruning: expose pruning as adminProcedure mutation - trpc.ts: E2E_TEST_MODE rate-limit bypass is now a no-op in production (NODE_ENV=production); logs a startup warning if misconfigured Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
242 lines
8.3 KiB
TypeScript
242 lines
8.3 KiB
TypeScript
import { 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);
|
|
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(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}` });
|
|
}
|
|
}
|