diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts index 989af07..88a882d 100644 --- a/src/app/api/trpc/[trpc]/route.ts +++ b/src/app/api/trpc/[trpc]/route.ts @@ -1,7 +1,7 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch' import { appRouter } from '@/server/routers/_app' import { createContext } from '@/server/context' -import { checkRateLimit } from '@/lib/rate-limit' +import { checkRateLimit, isCeremonyTraffic } from '@/lib/rate-limit' // Allow long-running operations (AI filtering, bulk imports) // This affects Next.js serverless functions; for self-hosted, Nginx timeout also matters @@ -9,6 +9,9 @@ export const maxDuration = 300 // 5 minutes const RATE_LIMIT = 100 // requests per window const RATE_WINDOW_MS = 60 * 1000 // 1 minute +// Ceremony-day polling: whole venues share one IP (NAT) and every screen +// polls a few cheap public reads — see CEREMONY_PROCEDURES in lib/rate-limit. +const CEREMONY_RATE_LIMIT = 6000 function getClientIp(req: Request): string { return ( @@ -20,7 +23,10 @@ function getClientIp(req: Request): string { const handler = (req: Request) => { const ip = getClientIp(req) - const { success, remaining, resetAt } = checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS) + const ceremony = isCeremonyTraffic(new URL(req.url).pathname) + const { success, remaining, resetAt } = ceremony + ? checkRateLimit(`trpc-ceremony:${ip}`, CEREMONY_RATE_LIMIT, RATE_WINDOW_MS) + : checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS) if (!success) { return new Response(JSON.stringify({ error: 'Too many requests' }), { diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index ed02805..48da71e 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -46,6 +46,39 @@ export function checkRateLimit( return { success: true, remaining: limit - entry.count, resetAt: entry.resetAt } } +/** + * Ceremony-day procedures: cheap, read-mostly public endpoints polled by every + * screen in the venue. Hundreds of phones share one IP behind the venue NAT, + * so the standard 100/min-per-IP budget would 429 the audience mid-vote. + * Requests consisting ONLY of these procedures get a separate, much larger + * per-IP bucket. Vote integrity is enforced server-side (token + IP cap on + * AudienceFavoriteVote), not by the rate limiter. + */ +const CEREMONY_PROCEDURES = new Set([ + 'liveVoting.getCeremonyState', + 'liveVoting.getAudienceWindow', + 'liveVoting.getAudienceContextByRound', + 'liveVoting.registerAudienceVoter', + 'liveVoting.castFavoriteVote', + 'liveVoting.getSessionForVotingByRound', + 'liveVoting.getPublicResults', + 'liveVoting.getAudienceSession', + 'liveVoting.getSessionForVoting', + 'live.getCursor', + 'live.getMyNotes', + 'live.saveNote', + 'live.getMyCeremonyContext', +]) + +/** True when every (possibly batched) procedure in the tRPC URL is ceremony traffic. */ +export function isCeremonyTraffic(pathname: string): boolean { + const path = pathname.replace(/^\/api\/trpc\/?/, '') + if (!path) return false + return path + .split(',') + .every((p) => CEREMONY_PROCEDURES.has(decodeURIComponent(p))) +} + // Clean up stale entries every 5 minutes to prevent memory leaks if (typeof setInterval !== 'undefined') { setInterval(() => { diff --git a/tests/unit/ceremony-rate-limit.test.ts b/tests/unit/ceremony-rate-limit.test.ts new file mode 100644 index 0000000..cb0fa3e --- /dev/null +++ b/tests/unit/ceremony-rate-limit.test.ts @@ -0,0 +1,39 @@ +/** + * Ceremony traffic classifier — these requests bypass the 100/min-per-IP tRPC + * budget (whole venues share one IP). A single non-ceremony procedure in a + * batch must fall back to the strict bucket. + */ +import { describe, it, expect } from 'vitest' +import { isCeremonyTraffic } from '@/lib/rate-limit' + +describe('isCeremonyTraffic', () => { + it('accepts single ceremony procedures', () => { + expect(isCeremonyTraffic('/api/trpc/liveVoting.getCeremonyState')).toBe(true) + expect(isCeremonyTraffic('/api/trpc/liveVoting.getAudienceWindow')).toBe(true) + expect(isCeremonyTraffic('/api/trpc/liveVoting.castFavoriteVote')).toBe(true) + expect(isCeremonyTraffic('/api/trpc/live.getCursor')).toBe(true) + }) + + it('accepts batched requests of only ceremony procedures', () => { + expect( + isCeremonyTraffic('/api/trpc/live.getCursor,liveVoting.getSessionForVotingByRound') + ).toBe(true) + }) + + it('rejects any batch containing a non-ceremony procedure', () => { + expect(isCeremonyTraffic('/api/trpc/live.getCursor,user.me')).toBe(false) + expect(isCeremonyTraffic('/api/trpc/liveVoting.getResults')).toBe(false) + expect(isCeremonyTraffic('/api/trpc/deliberation.submitVote')).toBe(false) + }) + + it('rejects admin/state-changing live procedures', () => { + expect(isCeremonyTraffic('/api/trpc/live.startPresentation')).toBe(false) + expect(isCeremonyTraffic('/api/trpc/liveVoting.openAudienceWindow')).toBe(false) + expect(isCeremonyTraffic('/api/trpc/liveVoting.revealNext')).toBe(false) + }) + + it('rejects empty and malformed paths', () => { + expect(isCeremonyTraffic('/api/trpc/')).toBe(false) + expect(isCeremonyTraffic('/api/trpc')).toBe(false) + }) +})