import { initTRPC, TRPCError } from '@trpc/server' import superjson from 'superjson' import { ZodError } from 'zod' import type { Prisma } from '@prisma/client' import type { Context } from './context' import type { UserRole } from '@prisma/client' /** * Initialize tRPC with context type and configuration */ const t = initTRPC.context().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, } }, }) /** * Export reusable router and procedure helpers */ export const router = t.router export const publicProcedure = t.procedure export const middleware = t.middleware export const createCallerFactory = t.createCallerFactory // ============================================================================= // Middleware // ============================================================================= /** * Middleware to require authenticated user */ const isAuthenticated = middleware(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You must be logged in to perform this action', }) } return next({ ctx: { ...ctx, user: ctx.session.user, }, }) }) /** * Helper to check if a user has any of the specified roles. * Checks the roles array first, falls back to [role] for stale JWT tokens. */ export function userHasRole(user: { role: UserRole; roles?: UserRole[] }, ...checkRoles: UserRole[]): boolean { const userRoles = user.roles?.length ? user.roles : [user.role] return checkRoles.some(r => userRoles.includes(r)) } /** * Middleware to require specific role(s) */ const hasRole = (...roles: UserRole[]) => middleware(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You must be logged in to perform this action', }) } // Use roles array, fallback to [role] for stale JWT tokens const userRoles = ctx.session.user.roles?.length ? ctx.session.user.roles : [ctx.session.user.role] if (!roles.some(r => userRoles.includes(r))) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have permission to perform this action', }) } return next({ ctx: { ...ctx, user: ctx.session.user, }, }) }) // ============================================================================= // Mutation Audit Logging // ============================================================================= /** Fields that must never appear in audit log input snapshots. */ const SENSITIVE_KEYS = new Set([ 'password', 'passwordHash', 'currentPassword', 'newPassword', 'confirmPassword', 'token', 'secret', 'apiKey', 'accessKey', 'secretKey', 'creditCard', 'cvv', 'ssn', ]) /** Max depth / size for serialized input to avoid bloating the audit table. */ const MAX_INPUT_DEPTH = 4 const MAX_STRING_LENGTH = 500 const MAX_ARRAY_LENGTH = 20 /** * Recursively sanitize an input object for safe storage in audit logs. * - Strips sensitive fields (passwords, tokens, secrets) * - Truncates long strings and arrays * - Limits nesting depth */ function sanitizeInput(value: unknown, depth = 0): unknown { if (depth > MAX_INPUT_DEPTH) return '[nested]' if (value === null || value === undefined) return value if (typeof value === 'boolean' || typeof value === 'number') return value if (typeof value === 'string') { return value.length > MAX_STRING_LENGTH ? value.slice(0, MAX_STRING_LENGTH) + '...' : value } if (value instanceof Date) return value.toISOString() if (Array.isArray(value)) { const truncated = value.slice(0, MAX_ARRAY_LENGTH).map(v => sanitizeInput(v, depth + 1)) if (value.length > MAX_ARRAY_LENGTH) truncated.push(`[+${value.length - MAX_ARRAY_LENGTH} more]`) return truncated } if (typeof value === 'object') { const result: Record = {} for (const [key, val] of Object.entries(value as Record)) { if (SENSITIVE_KEYS.has(key)) { result[key] = '[REDACTED]' } else { result[key] = sanitizeInput(val, depth + 1) } } return result } return String(value) } /** * Middleware that automatically logs all successful mutations for non-SUPER_ADMIN users. * Captures: procedure path, sanitized input, user role, IP, user agent. * Failures are silently caught — audit logging never breaks the calling operation. */ const withMutationAudit = middleware(async ({ ctx, next, path, type, getRawInput }) => { const result = await next() // Only log mutations, only on success if (type !== 'mutation' || !result.ok) return result // Must have an authenticated user const user = ctx.session?.user if (!user?.id) return result // Skip SUPER_ADMIN — they have their own manual audit trail if (user.role === 'SUPER_ADMIN') return result try { // Extract router name and procedure name from path (e.g., "evaluation.submit") const dotIndex = path.indexOf('.') const routerName = dotIndex > 0 ? path.slice(0, dotIndex) : path const procedureName = dotIndex > 0 ? path.slice(dotIndex + 1) : path // Convert procedure path to readable action (e.g., "evaluation.submit" → "EVALUATION_SUBMIT") const action = path.replace(/\./g, '_').replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase() // Get and sanitize the raw input let sanitizedInput: unknown = undefined try { const rawInput = await getRawInput() if (rawInput !== undefined) { sanitizedInput = sanitizeInput(rawInput) } } catch { // getRawInput can fail if input was already consumed; ignore } // Capitalize first letter of router name for entityType const entityType = routerName.charAt(0).toUpperCase() + routerName.slice(1) // Try to extract entityId from common input patterns const inputObj = (typeof sanitizedInput === 'object' && sanitizedInput !== null) ? sanitizedInput as Record : undefined const entityId = inputObj?.id ?? inputObj?.userId ?? inputObj?.projectId ?? inputObj?.roundId ?? inputObj?.competitionId ?? inputObj?.editionId ?? inputObj?.targetUserId ?? inputObj?.sessionId ?? inputObj?.awardId await ctx.prisma.auditLog.create({ data: { userId: user.id, action, entityType, entityId: entityId ? String(entityId) : undefined, detailsJson: { procedure: path, procedureName, role: user.role, roles: user.roles, input: sanitizedInput, } as Prisma.InputJsonValue, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) } catch (error) { // Never break the calling operation on audit failure console.error('[MutationAudit] Failed to log:', path, error) } return result }) // ============================================================================= // Procedure Types // ============================================================================= /** * Protected procedure - requires authenticated user * Mutations are automatically audit-logged for non-SUPER_ADMIN users. */ export const protectedProcedure = t.procedure.use(isAuthenticated).use(withMutationAudit) /** * Admin procedure - requires SUPER_ADMIN or PROGRAM_ADMIN role * PROGRAM_ADMIN mutations are audit-logged; SUPER_ADMIN mutations are skipped. */ export const adminProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')) .use(withMutationAudit) /** * Super admin procedure - requires SUPER_ADMIN role * No automatic mutation audit (super admins have manual audit trail). */ export const superAdminProcedure = t.procedure.use(hasRole('SUPER_ADMIN')) /** * Jury procedure - requires JURY_MEMBER role * All mutations are automatically audit-logged. */ export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER')).use(withMutationAudit) /** * Mentor procedure - requires MENTOR role (or admin) * MENTOR and PROGRAM_ADMIN mutations are audit-logged. */ export const mentorProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR')) .use(withMutationAudit) /** * Observer procedure - requires OBSERVER role (read-only access) * Mutations (if any) are audit-logged for OBSERVER and PROGRAM_ADMIN. */ export const observerProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER')) .use(withMutationAudit) /** * Award master procedure - requires AWARD_MASTER role (or admin) * AWARD_MASTER and PROGRAM_ADMIN mutations are audit-logged. */ export const awardMasterProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER')) .use(withMutationAudit) /** * Audience procedure - requires any authenticated user * All mutations are automatically audit-logged. */ export const audienceProcedure = t.procedure.use(isAuthenticated).use(withMutationAudit)