diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 9cd70d0f..a19dbe97 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -16,8 +16,7 @@ import { getServiceClient } from '@/lib/supabase'; import { verifyUser } from '@/lib/api-utils'; import { jsonSuccess, jsonError, jsonUnauthorized, HTTP_STATUS } from '@/lib/api'; import { SYSTEM_PROMPTS } from '@/lib/constants'; -import { checkRateLimit } from '@/lib/rate-limit'; -import { getClientIp } from '@/lib/request'; +import { enforceRateLimit } from '@/lib/rate-limit'; import { generateEmbeddingWithTimeout, getUserLLMSettings, @@ -37,15 +36,8 @@ export async function POST(request: NextRequest) { try { // Rate limit per user/IP - const ip = getClientIp(request); - const { isRateLimited } = await checkRateLimit(`chat:${ip}`, 20, 60); - if (isRateLimited) { - return jsonError( - 'Too many requests. Please slow down.', - 'RATE_LIMIT', - HTTP_STATUS.RATE_LIMIT, - ); - } + const limited = await enforceRateLimit(request, 'chat'); + if (limited) return limited; logger.log('[Chat API] Verifying user...'); const user = await verifyUser(request); if (!user) { diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index ad290d92..ff6860b2 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -11,8 +11,7 @@ import { import { DOMAIN_ERRORS } from '@/lib/constants'; import { isSupabaseConfigured, getServiceClient } from '@/lib/supabase'; import { logger } from '@/lib/logger'; -import { checkRateLimit } from '@/lib/rate-limit'; -import { getClientIp } from '@/lib/request'; +import { enforceRateLimit } from '@/lib/rate-limit'; const ContactSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -24,15 +23,8 @@ const ContactSchema = z.object({ export async function POST(req: NextRequest) { try { // Rate limit by IP (5 per 10 minutes) - const ip = getClientIp(req); - const { isRateLimited } = await checkRateLimit(`contact:${ip}`, 5, 600); - if (isRateLimited) { - return jsonError( - 'Too many requests. Please try later.', - 'RATE_LIMIT', - HTTP_STATUS.RATE_LIMIT, - ); - } + const limited = await enforceRateLimit(req, 'contact'); + if (limited) return limited; // Validate input const validation = await validateBody(req, ContactSchema); if (hasValidationError(validation)) { diff --git a/app/api/custom-bots/[id]/chat/route.ts b/app/api/custom-bots/[id]/chat/route.ts index 2509a0bd..2f19337c 100644 --- a/app/api/custom-bots/[id]/chat/route.ts +++ b/app/api/custom-bots/[id]/chat/route.ts @@ -14,6 +14,7 @@ import { type NextRequest } from 'next/server'; import { z } from 'zod'; import { generateLLMResponse } from '@/lib/llm-client'; import { logger } from '@/lib/logger'; +import { enforceRateLimit } from '@/lib/rate-limit'; import { getServiceClient } from '@/lib/supabase'; import { verifyUser } from '@/lib/api-utils'; import { @@ -59,6 +60,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const user = await verifyUser(request); const { id: botId } = await params; + // Anonymous callers may chat with public bots, and the call is billed to the + // bot OWNER's API key — so limit per bot, not just per IP. + const limited = await enforceRateLimit(request, 'custom-bot-chat', botId); + if (limited) return limited; + const supabase = getServiceClient(); // Get the custom bot diff --git a/app/api/demo/chat/route.ts b/app/api/demo/chat/route.ts index 41751b40..b9dc02c4 100644 --- a/app/api/demo/chat/route.ts +++ b/app/api/demo/chat/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { jsonError, jsonValidationError, formatZodErrors, HTTP_STATUS } from '@/lib/api'; import { generateWithBestProvider, type ModelProvider } from '@/lib/llm-client'; import { logger } from '@/lib/logger'; +import { enforceRateLimit } from '@/lib/rate-limit'; import { sanitizeSystemPrompt, sanitizeUserMessage, @@ -423,6 +424,10 @@ const ChatRequestSchema = z.object({ export async function POST(request: NextRequest) { try { + // Public, unauthenticated endpoint that spends LLM budget — limit before any work. + const limited = await enforceRateLimit(request, 'demo-chat'); + if (limited) return limited; + const body = await request.json(); const { message, includeContext, systemPrompt, additionalContext } = ChatRequestSchema.parse(body); diff --git a/app/api/demo/document-chat/route.ts b/app/api/demo/document-chat/route.ts index 7e264739..0a5c4d4c 100644 --- a/app/api/demo/document-chat/route.ts +++ b/app/api/demo/document-chat/route.ts @@ -10,8 +10,7 @@ import { type NextRequest } from 'next/server'; import { generateLLMResponse } from '@/lib/llm-client'; import { jsonSuccess, jsonError, HTTP_STATUS } from '@/lib/api'; -import { checkRateLimit } from '@/lib/rate-limit'; -import { getClientIp } from '@/lib/request'; +import { enforceRateLimit } from '@/lib/rate-limit'; import { sanitizeUserMessage, sanitizePromptContent } from '@/lib/prompt-sanitizer'; import { logger } from '@/lib/logger'; @@ -28,15 +27,8 @@ export async function POST(request: NextRequest) { try { // Rate limit per IP (stricter since no auth) - const ip = getClientIp(request); - const { isRateLimited } = await checkRateLimit(`demo-doc-chat:${ip}`, 15, 60); - if (isRateLimited) { - return jsonError( - 'Too many requests. Please wait a moment.', - 'RATE_LIMIT', - HTTP_STATUS.RATE_LIMIT, - ); - } + const limited = await enforceRateLimit(request, 'demo-doc-chat'); + if (limited) return limited; const body = await request.json(); const { message, documents } = body; diff --git a/app/api/demo/parse-pdf/route.ts b/app/api/demo/parse-pdf/route.ts index 0cc04a79..879bddf6 100644 --- a/app/api/demo/parse-pdf/route.ts +++ b/app/api/demo/parse-pdf/route.ts @@ -9,8 +9,7 @@ import { type NextRequest, NextResponse } from 'next/server'; import { PDFParse } from 'pdf-parse'; -import { checkRateLimit } from '@/lib/rate-limit'; -import { getClientIp } from '@/lib/request'; +import { enforceRateLimit } from '@/lib/rate-limit'; import { VALIDATION } from '@/lib/constants'; // Extend function timeout for PDF parsing @@ -19,14 +18,8 @@ export const maxDuration = 30; export async function POST(request: NextRequest) { try { // Rate limit per IP (stricter since no auth) - const ip = getClientIp(request); - const { isRateLimited } = await checkRateLimit(`demo-pdf-parse:${ip}`, 10, 60); - if (isRateLimited) { - return NextResponse.json( - { success: false, error: 'Too many requests. Please wait a moment.' }, - { status: 429 }, - ); - } + const limited = await enforceRateLimit(request, 'demo-pdf-parse'); + if (limited) return limited; const formData = await request.formData(); const file = formData.get('file') as File | null; diff --git a/app/api/professional-chat/route.ts b/app/api/professional-chat/route.ts index 9c953fcf..fdd91fc7 100644 --- a/app/api/professional-chat/route.ts +++ b/app/api/professional-chat/route.ts @@ -14,8 +14,7 @@ import { logger } from '@/lib/logger'; import { getServiceClient } from '@/lib/supabase'; import { verifyUser } from '@/lib/api-utils'; import { jsonSuccess, jsonError, HTTP_STATUS } from '@/lib/api'; -import { checkRateLimit } from '@/lib/rate-limit'; -import { getClientIp } from '@/lib/request'; +import { enforceRateLimit } from '@/lib/rate-limit'; import { PROFESSIONAL_DOCUMENT_ACCESS, type DocumentCategory } from '@/types/document'; import { sanitizeSystemPrompt, @@ -37,15 +36,8 @@ export async function POST(request: NextRequest) { try { // Rate limit per IP - const ip = getClientIp(request); - const { isRateLimited } = await checkRateLimit(`professional-chat:${ip}`, 15, 60); - if (isRateLimited) { - return jsonError( - 'Too many requests. Please slow down.', - 'RATE_LIMIT', - HTTP_STATUS.RATE_LIMIT, - ); - } + const limited = await enforceRateLimit(request, 'professional-chat'); + if (limited) return limited; const body = await request.json(); const { diff --git a/app/api/quick-chat/route.ts b/app/api/quick-chat/route.ts index 493f4a9b..041d9bbf 100644 --- a/app/api/quick-chat/route.ts +++ b/app/api/quick-chat/route.ts @@ -11,8 +11,7 @@ import { type NextRequest } from 'next/server'; import { generateLLMResponse } from '@/lib/llm-client'; import { logger } from '@/lib/logger'; import { jsonSuccess, jsonError, HTTP_STATUS } from '@/lib/api'; -import { checkRateLimit } from '@/lib/rate-limit'; -import { getClientIp } from '@/lib/request'; +import { enforceRateLimit } from '@/lib/rate-limit'; import { sanitizeSystemPrompt, sanitizeUserMessage, @@ -29,15 +28,8 @@ export async function POST(request: NextRequest) { try { // Rate limit per IP (stricter since no auth) - const ip = getClientIp(request); - const { isRateLimited } = await checkRateLimit(`quick-chat:${ip}`, 10, 60); - if (isRateLimited) { - return jsonError( - 'Too many requests. Please slow down.', - 'RATE_LIMIT', - HTTP_STATUS.RATE_LIMIT, - ); - } + const limited = await enforceRateLimit(request, 'quick-chat'); + if (limited) return limited; const body = await request.json(); const { message, systemPrompt, additionalContext, conversationHistory } = body; diff --git a/app/api/rebuild/route.ts b/app/api/rebuild/route.ts index 420ceea9..473359c9 100644 --- a/app/api/rebuild/route.ts +++ b/app/api/rebuild/route.ts @@ -1,16 +1,12 @@ import { type NextRequest } from 'next/server'; import { revalidatePath } from 'next/cache'; -import { checkRateLimit } from '@/lib/rate-limit'; +import { enforceRateLimit } from '@/lib/rate-limit'; import { jsonSuccess, jsonError, HTTP_STATUS } from '@/lib/api'; export async function GET(request: NextRequest) { try { - // Rate limit: 5 requests per 10 minutes per IP - const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'; - const { isRateLimited } = await checkRateLimit(`rebuild:${ip}`, 5, 600); - if (isRateLimited) { - return jsonError('Too many requests', 'RATE_LIMIT', HTTP_STATUS.RATE_LIMIT); - } + const limited = await enforceRateLimit(request, 'rebuild'); + if (limited) return limited; // Revalidate the blog pages revalidatePath('/blog'); diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 799de8a6..bd725b20 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -5,6 +5,9 @@ * Works correctly across serverless function instances. */ +import type { NextRequest, NextResponse } from 'next/server'; +import { jsonRateLimitError } from '@/lib/api'; +import { getClientIp } from '@/lib/request'; import { getServiceClient, isSupabaseConfigured } from '@/lib/supabase'; export interface RateLimitResult { @@ -52,3 +55,54 @@ export async function checkRateLimit( return { isRateLimited: false, remaining: maxRequests }; } } + +// ============================================================================ +// Route-level enforcement +// ============================================================================ + +/** + * Every rate-limited bucket in the product, with its budget. + * + * SSOT: limits live here, not as magic numbers scattered across route files. + * A route names a bucket; it does not get to invent a number. + */ +export const RATE_LIMITS = { + chat: { max: 20, windowSeconds: 60 }, + 'professional-chat': { max: 15, windowSeconds: 60 }, + 'quick-chat': { max: 10, windowSeconds: 60 }, + 'demo-chat': { max: 15, windowSeconds: 60 }, + 'demo-doc-chat': { max: 15, windowSeconds: 60 }, + 'demo-pdf-parse': { max: 10, windowSeconds: 60 }, + 'custom-bot-chat': { max: 15, windowSeconds: 60 }, + contact: { max: 5, windowSeconds: 600 }, + rebuild: { max: 5, windowSeconds: 600 }, +} as const; + +export type RateLimitBucket = keyof typeof RATE_LIMITS; + +/** + * Enforce a bucket's limit for the caller, scoped per client IP. + * + * Returns a ready-to-return 429 when the caller is over budget, or null when + * the request may proceed — so a route reads: + * + * const limited = await enforceRateLimit(request, 'demo-chat'); + * if (limited) return limited; + * + * `scope` narrows the key further (e.g. a bot id), so one hot resource cannot + * exhaust another's budget. + */ +export async function enforceRateLimit( + request: NextRequest, + bucket: RateLimitBucket, + scope?: string, +): Promise { + const { max, windowSeconds } = RATE_LIMITS[bucket]; + const ip = getClientIp(request); + const key = scope ? `${bucket}:${scope}:${ip}` : `${bucket}:${ip}`; + + const { isRateLimited } = await checkRateLimit(key, max, windowSeconds); + if (!isRateLimited) return null; + + return jsonRateLimitError('Too many requests. Please slow down.'); +} diff --git a/tests/__tests__/api/professional-chat.test.ts b/tests/__tests__/api/professional-chat.test.ts index b6fad9a7..d8d9a047 100644 --- a/tests/__tests__/api/professional-chat.test.ts +++ b/tests/__tests__/api/professional-chat.test.ts @@ -6,10 +6,10 @@ * - Supabase (document search, user context) * - LLM (generateLLMResponse) * - Embeddings (generateEmbedding) - * - Rate limiting (checkRateLimit) + * - Rate limiting (enforceRateLimit) */ -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; // Mock dependencies before importing route jest.mock('@/lib/api-utils', () => ({ @@ -30,7 +30,7 @@ jest.mock('@/lib/embeddings', () => ({ })); jest.mock('@/lib/rate-limit', () => ({ - checkRateLimit: jest.fn(() => Promise.resolve({ isRateLimited: false, remaining: 10 })), + enforceRateLimit: jest.fn(() => Promise.resolve(null)), })); jest.mock('@/lib/chat', () => ({ @@ -48,11 +48,11 @@ jest.mock('@/lib/context', () => ({ import { POST } from '@/app/api/professional-chat/route'; import { verifyUser } from '@/lib/api-utils'; import { generateLLMResponse } from '@/lib/llm-client'; -import { checkRateLimit } from '@/lib/rate-limit'; +import { enforceRateLimit } from '@/lib/rate-limit'; const mockVerifyUser = verifyUser as jest.MockedFunction; const mockGenerateLLM = generateLLMResponse as jest.MockedFunction; -const mockCheckRateLimit = checkRateLimit as jest.MockedFunction; +const mockEnforceRateLimit = enforceRateLimit as jest.MockedFunction; function makeRequest(body: Record): NextRequest { return new NextRequest('http://localhost:3000/api/professional-chat', { @@ -65,7 +65,7 @@ function makeRequest(body: Record): NextRequest { describe('POST /api/professional-chat', () => { beforeEach(() => { jest.clearAllMocks(); - mockCheckRateLimit.mockResolvedValue({ isRateLimited: false, remaining: 10 }); + mockEnforceRateLimit.mockResolvedValue(null); mockVerifyUser.mockResolvedValue(null); mockGenerateLLM.mockResolvedValue({ content: 'Test response from AI', @@ -110,7 +110,12 @@ describe('POST /api/professional-chat', () => { }); it('returns 429 when rate limited', async () => { - mockCheckRateLimit.mockResolvedValue({ isRateLimited: true, remaining: 0 }); + mockEnforceRateLimit.mockResolvedValue( + NextResponse.json( + { success: false, error: 'Too many requests. Please slow down.', code: 'RATE_LIMIT' }, + { status: 429 }, + ), + ); const req = makeRequest({ message: 'Hello', diff --git a/tests/__tests__/api/rate-limit-coverage.test.ts b/tests/__tests__/api/rate-limit-coverage.test.ts new file mode 100644 index 00000000..e990b6f1 --- /dev/null +++ b/tests/__tests__/api/rate-limit-coverage.test.ts @@ -0,0 +1,75 @@ +/** + * Guard: every route that spends money must be rate limited. + * + * Two routes once shipped without a limit — app/api/demo/chat (public and + * unauthenticated) and app/api/custom-bots/[id]/chat (anonymous callers, billed + * to the bot OWNER's key). Nothing failed, because nothing was checking. This + * test is that check: it reads the route files and fails on the next omission + * rather than after the next bill. + */ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const API_DIR = join(process.cwd(), 'app', 'api'); + +/** Routes that call an LLM provider and therefore cost money per request. */ +const LLM_IMPORT = /from '@\/lib\/llm-client'/; + +/** Routes deliberately exempt, each with the reason it is safe. */ +const EXEMPT: Record = { + 'consultations/route.ts': 'API-key gated and rate limited on a global key (see lib/rate-limit)', +}; + +function routeFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...routeFiles(full)); + else if (entry === 'route.ts') out.push(full); + } + return out; +} + +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); +} + +describe('rate limit coverage', () => { + const files = routeFiles(API_DIR); + + it('finds the API routes', () => { + expect(files.length).toBeGreaterThan(20); + }); + + it('every LLM-calling route enforces a rate limit', () => { + const unprotected: string[] = []; + + for (const file of files) { + const rel = file.slice(API_DIR.length + 1); + if (EXEMPT[rel]) continue; + + const source = stripComments(readFileSync(file, 'utf-8')); + if (!LLM_IMPORT.test(source)) continue; + + const enforced = /enforceRateLimit\s*\(/.test(source); + if (!enforced) unprotected.push(rel); + } + + expect(unprotected).toEqual([]); + }); + + it('rate limits come from the shared SSOT, not inline magic numbers', () => { + const offenders: string[] = []; + + for (const file of files) { + const source = stripComments(readFileSync(file, 'utf-8')); + // checkRateLimit takes raw (key, max, window) — routes must not call it + // directly, or the budget stops living in one place. + if (/\bcheckRateLimit\s*\(/.test(source)) { + offenders.push(file.slice(API_DIR.length + 1)); + } + } + + expect(offenders).toEqual(Object.keys(EXEMPT).filter((f) => offenders.includes(f))); + }); +});