From 160333c2f9a7cda0993b33680e4d43c842b053fe Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 10 Jun 2026 18:45:15 +0200 Subject: [PATCH] =?UTF-8?q?fix(finale):=20ceremony-day=20rate-limit=20buck?= =?UTF-8?q?et=20=E2=80=94=20venue=20NAT=20would=20429=20audience=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requests consisting only of cheap public ceremony reads (+ token-gated vote casts) get a 6000/min per-IP budget instead of 100/min. Vote integrity is enforced by the token + AudienceFavoriteVote IP cap, not the rate limiter. Found live: big-screen polling hit 429 within minutes in verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/trpc/[trpc]/route.ts | 10 +++++-- src/lib/rate-limit.ts | 33 ++++++++++++++++++++++ tests/unit/ceremony-rate-limit.test.ts | 39 ++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tests/unit/ceremony-rate-limit.test.ts 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) + }) +})