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) } /** * Extract common fields from a sanitized input object for audit logging. */ function extractAuditFields(path: string, sanitizedInput: unknown) { 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() // 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 return { routerName, procedureName, action, entityType, entityId } } /** * Middleware that automatically logs all successful mutations for non-SUPER_ADMIN users. * During impersonation, logs both the target user ID and the real admin ID. * 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 (unless impersonating — then user.role is the target's role) const impersonation = user.impersonating as { originalId: string; originalRole: string; originalEmail: string } | undefined if (user.role === 'SUPER_ADMIN' && !impersonation) return result try { // 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 } const { procedureName, action, entityType, entityId } = extractAuditFields(path, sanitizedInput) // Build details payload — include impersonation info when active const details: Record = { procedure: path, procedureName, role: user.role, roles: user.roles, input: sanitizedInput, } if (impersonation) { details.impersonatedBy = { adminId: impersonation.originalId, adminEmail: impersonation.originalEmail, adminRole: impersonation.originalRole, } } await ctx.prisma.auditLog.create({ data: { // During impersonation, log as the real admin with target info in details userId: impersonation ? impersonation.originalId : user.id, action: impersonation ? `IMPERSONATED_${action}` : action, entityType, entityId: entityId ? String(entityId) : undefined, detailsJson: details 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 }) /** * Middleware that logs failed operations (FORBIDDEN, UNAUTHORIZED, NOT_FOUND errors). * Tracks permission denials and access violations per user for security monitoring. * Applied to all authenticated procedures. */ const withErrorAudit = middleware(async ({ ctx, next, path, type, getRawInput }) => { try { return await next() } catch (error) { // Only log TRPCErrors that indicate security/access issues if (error instanceof TRPCError) { const securityCodes = ['FORBIDDEN', 'UNAUTHORIZED', 'NOT_FOUND'] as const if (securityCodes.includes(error.code as typeof securityCodes[number])) { try { let sanitizedInput: unknown = undefined try { const rawInput = await getRawInput() if (rawInput !== undefined) { sanitizedInput = sanitizeInput(rawInput) } } catch { /* ignore */ } const { procedureName, entityType, entityId } = extractAuditFields(path, sanitizedInput) const user = ctx.session?.user const impersonation = user?.impersonating as { originalId: string; originalEmail: string } | undefined await ctx.prisma.auditLog.create({ data: { userId: impersonation?.originalId ?? user?.id ?? null, action: `ACCESS_DENIED_${error.code}`, entityType, entityId: entityId ? String(entityId) : undefined, detailsJson: { procedure: path, procedureName, type, errorCode: error.code, errorMessage: error.message, role: user?.role, input: sanitizedInput, ...(impersonation ? { impersonatedUserId: user?.id } : {}), } as Prisma.InputJsonValue, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) } catch (auditError) { console.error('[ErrorAudit] Failed to log:', path, auditError) } } } // Always re-throw the original error throw error } }) // ============================================================================= // Procedure Types // ============================================================================= /** * Protected procedure - requires authenticated user. * Mutations auto-audited, errors (FORBIDDEN/UNAUTHORIZED/NOT_FOUND) tracked. */ export const protectedProcedure = t.procedure .use(isAuthenticated) .use(withErrorAudit) .use(withMutationAudit) /** * Admin procedure - requires SUPER_ADMIN or PROGRAM_ADMIN role. * PROGRAM_ADMIN mutations are audit-logged; SUPER_ADMIN mutations skipped. * Errors tracked for all users. */ export const adminProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')) .use(withErrorAudit) .use(withMutationAudit) /** * Super admin procedure - requires SUPER_ADMIN role. * No automatic mutation audit. Errors still tracked. */ export const superAdminProcedure = t.procedure .use(hasRole('SUPER_ADMIN')) .use(withErrorAudit) /** * Jury procedure - requires JURY_MEMBER role. * All mutations auto-audited, errors tracked. */ export const juryProcedure = t.procedure .use(hasRole('JURY_MEMBER')) .use(withErrorAudit) .use(withMutationAudit) /** * Mentor procedure - requires MENTOR role (or admin). * MENTOR and PROGRAM_ADMIN mutations are audit-logged, errors tracked. */ export const mentorProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR')) .use(withErrorAudit) .use(withMutationAudit) /** * Observer procedure - requires OBSERVER role (read-only access). * Mutations (if any) audit-logged, errors tracked. */ export const observerProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER')) .use(withErrorAudit) .use(withMutationAudit) /** * Award master procedure - requires AWARD_MASTER role (or admin). * AWARD_MASTER and PROGRAM_ADMIN mutations audit-logged, errors tracked. */ export const awardMasterProcedure = t.procedure .use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER')) .use(withErrorAudit) .use(withMutationAudit) /** * Audience procedure - requires any authenticated user. * All mutations auto-audited, errors tracked. */ export const audienceProcedure = t.procedure .use(isAuthenticated) .use(withErrorAudit) .use(withMutationAudit)