Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 3 additions & 11 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
14 changes: 3 additions & 11 deletions app/api/contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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)) {
Expand Down
6 changes: 6 additions & 0 deletions app/api/custom-bots/[id]/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/api/demo/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 3 additions & 11 deletions app/api/demo/document-chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down
13 changes: 3 additions & 10 deletions app/api/demo/parse-pdf/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
14 changes: 3 additions & 11 deletions app/api/professional-chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
14 changes: 3 additions & 11 deletions app/api/quick-chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
10 changes: 3 additions & 7 deletions app/api/rebuild/route.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
54 changes: 54 additions & 0 deletions lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<NextResponse | null> {
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.');
}
19 changes: 12 additions & 7 deletions tests/__tests__/api/professional-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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', () => ({
Expand All @@ -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<typeof verifyUser>;
const mockGenerateLLM = generateLLMResponse as jest.MockedFunction<typeof generateLLMResponse>;
const mockCheckRateLimit = checkRateLimit as jest.MockedFunction<typeof checkRateLimit>;
const mockEnforceRateLimit = enforceRateLimit as jest.MockedFunction<typeof enforceRateLimit>;

function makeRequest(body: Record<string, unknown>): NextRequest {
return new NextRequest('http://localhost:3000/api/professional-chat', {
Expand All @@ -65,7 +65,7 @@ function makeRequest(body: Record<string, unknown>): 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',
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading