fix: security hardening + performance refactoring (code review batch 1)

- IDOR fix: deliberation vote now verifies juryMemberId === ctx.user.id
- Rate limiting: tRPC middleware (100/min), AI endpoints (5/hr), auth IP-based (10/15min)
- 6 compound indexes added to Prisma schema
- N+1 eliminated in processRoundClose (batch updateMany/createMany)
- N+1 eliminated in batchCheckRequirementsAndTransition (3 batch queries)
- Service extraction: juror-reassignment.ts (578 lines)
- Dead code removed: award.ts, cohort.ts, decision.ts (680 lines)
- 35 bare catch blocks replaced across 16 files
- Fire-and-forget async calls fixed
- Notification false positive bug fixed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 16:18:24 +01:00
parent a8b8643936
commit b85a9b9a7b
32 changed files with 1032 additions and 1355 deletions

View File

@@ -4,6 +4,7 @@ import { ZodError } from 'zod'
import type { Prisma } from '@prisma/client'
import type { Context } from './context'
import type { UserRole } from '@prisma/client'
import { checkRateLimit } from '@/lib/rate-limit'
/**
* Initialize tRPC with context type and configuration
@@ -298,16 +299,62 @@ const withErrorAudit = middleware(async ({ ctx, next, path, type, getRawInput })
}
})
// =============================================================================
// Rate Limiting
// =============================================================================
/**
* General rate limiter: 100 mutations per minute per user.
* Applied to all authenticated mutation procedures.
*/
const withRateLimit = middleware(async ({ ctx, next, type }) => {
// Only rate-limit mutations — queries are read-only
if (type !== 'mutation') return next()
const userId = ctx.session?.user?.id
if (!userId) return next()
const result = checkRateLimit(`trpc:${userId}`, 100, 60_000)
if (!result.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Too many requests. Please try again shortly.',
})
}
return next()
})
/**
* Strict rate limiter for AI-triggering procedures: 5 per hour per user.
* Protects against runaway OpenAI API costs.
*/
const withAIRateLimit = middleware(async ({ ctx, next }) => {
const userId = ctx.session?.user?.id
if (!userId) return next()
const result = checkRateLimit(`ai:${userId}`, 5, 60 * 60 * 1000)
if (!result.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'AI operation rate limit exceeded. Maximum 5 per hour.',
})
}
return next()
})
// =============================================================================
// Procedure Types
// =============================================================================
/**
* Protected procedure - requires authenticated user.
* Mutations auto-audited, errors (FORBIDDEN/UNAUTHORIZED/NOT_FOUND) tracked.
* Mutations rate-limited (100/min), auto-audited, errors tracked.
*/
export const protectedProcedure = t.procedure
.use(isAuthenticated)
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -318,6 +365,7 @@ export const protectedProcedure = t.procedure
*/
export const adminProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -335,6 +383,7 @@ export const superAdminProcedure = t.procedure
*/
export const juryProcedure = t.procedure
.use(hasRole('JURY_MEMBER'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -344,6 +393,7 @@ export const juryProcedure = t.procedure
*/
export const mentorProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'MENTOR'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -353,6 +403,7 @@ export const mentorProcedure = t.procedure
*/
export const observerProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -362,6 +413,7 @@ export const observerProcedure = t.procedure
*/
export const awardMasterProcedure = t.procedure
.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER'))
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
@@ -371,5 +423,12 @@ export const awardMasterProcedure = t.procedure
*/
export const audienceProcedure = t.procedure
.use(isAuthenticated)
.use(withRateLimit)
.use(withErrorAudit)
.use(withMutationAudit)
/**
* AI rate limit middleware - apply to individual AI-triggering procedures.
* 5 operations per hour per user to protect OpenAI API costs.
*/
export { withAIRateLimit }