diff --git a/src/app/api/v1/expenses/scan-receipt/route.ts b/src/app/api/v1/expenses/scan-receipt/route.ts index b75f5ed..b252a88 100644 --- a/src/app/api/v1/expenses/scan-receipt/route.ts +++ b/src/app/api/v1/expenses/scan-receipt/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; -import { withAuth, withPermission } from '@/lib/api/helpers'; +import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers'; import { errorResponse } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service'; @@ -22,92 +22,96 @@ const EMPTY: ParsedReceipt = { }; export const POST = withAuth( - withPermission('expenses', 'create', async (req, ctx) => { - try { - const formData = await req.formData(); - const file = formData.get('file') as File | null; - if (!file) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }); - } - const buffer = Buffer.from(await file.arrayBuffer()); - const mimeType = file.type || 'image/jpeg'; - - const config = await getResolvedOcrConfig(ctx.portId); - // Tesseract.js (in-browser) is the default. The server only invokes - // an AI provider when (a) the port admin has flipped `aiEnabled` on - // and (b) a key resolves. Otherwise the client falls back to its - // local Tesseract result. - if (!config.aiEnabled) { - return NextResponse.json({ - data: { parsed: EMPTY, source: 'manual', reason: 'ai-disabled' }, - }); - } - if (!config.apiKey) { - return NextResponse.json({ - data: { parsed: EMPTY, source: 'manual', reason: 'no-ocr-configured' }, - }); - } - - // Per-port budget gate — refuse the call before we spend tokens - // when the port has already hit its hard cap, or when the request - // would push it past the cap. Soft-cap warnings ride along on the - // success response so the UI can show a banner without blocking. - const budget = await checkBudget({ - portId: ctx.portId, - estimatedTokens: OCR_ESTIMATED_TOKENS, - }); - if (!budget.ok) { - return NextResponse.json({ - data: { - parsed: EMPTY, - source: 'manual', - reason: 'budget-exceeded', - providerError: `AI budget reached (${budget.usedTokens}/${budget.capTokens} tokens this period).`, - }, - }); - } - + withPermission( + 'expenses', + 'create', + withRateLimit('ocr', async (req, ctx) => { try { - const result = await runOcr({ - provider: config.provider, - model: config.model, - apiKey: config.apiKey, - imageBuffer: buffer, - mimeType, - }); - await recordAiUsage({ + const formData = await req.formData(); + const file = formData.get('file') as File | null; + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + const buffer = Buffer.from(await file.arrayBuffer()); + const mimeType = file.type || 'image/jpeg'; + + const config = await getResolvedOcrConfig(ctx.portId); + // Tesseract.js (in-browser) is the default. The server only invokes + // an AI provider when (a) the port admin has flipped `aiEnabled` on + // and (b) a key resolves. Otherwise the client falls back to its + // local Tesseract result. + if (!config.aiEnabled) { + return NextResponse.json({ + data: { parsed: EMPTY, source: 'manual', reason: 'ai-disabled' }, + }); + } + if (!config.apiKey) { + return NextResponse.json({ + data: { parsed: EMPTY, source: 'manual', reason: 'no-ocr-configured' }, + }); + } + + // Per-port budget gate — refuse the call before we spend tokens + // when the port has already hit its hard cap, or when the request + // would push it past the cap. Soft-cap warnings ride along on the + // success response so the UI can show a banner without blocking. + const budget = await checkBudget({ portId: ctx.portId, - userId: ctx.userId, - feature: OCR_FEATURE, - provider: config.provider, - model: config.model, - inputTokens: result.usage.inputTokens, - outputTokens: result.usage.outputTokens, - requestId: result.usage.requestId, + estimatedTokens: OCR_ESTIMATED_TOKENS, }); - return NextResponse.json({ - data: { - parsed: result.parsed, - source: 'ai', + if (!budget.ok) { + return NextResponse.json({ + data: { + parsed: EMPTY, + source: 'manual', + reason: 'budget-exceeded', + providerError: `AI budget reached (${budget.usedTokens}/${budget.capTokens} tokens this period).`, + }, + }); + } + + try { + const result = await runOcr({ provider: config.provider, model: config.model, - softCapWarning: budget.softCap, - }, - }); - } catch (err) { - logger.error({ err, provider: config.provider }, 'OCR provider call failed'); - // Provider hiccup — degrade to manual entry rather than 500-ing. - return NextResponse.json({ - data: { - parsed: EMPTY, - source: 'manual', - reason: 'provider-error', - providerError: err instanceof Error ? err.message.slice(0, 200) : 'Unknown error', - }, - }); + apiKey: config.apiKey, + imageBuffer: buffer, + mimeType, + }); + await recordAiUsage({ + portId: ctx.portId, + userId: ctx.userId, + feature: OCR_FEATURE, + provider: config.provider, + model: config.model, + inputTokens: result.usage.inputTokens, + outputTokens: result.usage.outputTokens, + requestId: result.usage.requestId, + }); + return NextResponse.json({ + data: { + parsed: result.parsed, + source: 'ai', + provider: config.provider, + model: config.model, + softCapWarning: budget.softCap, + }, + }); + } catch (err) { + logger.error({ err, provider: config.provider }, 'OCR provider call failed'); + // Provider hiccup — degrade to manual entry rather than 500-ing. + return NextResponse.json({ + data: { + parsed: EMPTY, + source: 'manual', + reason: 'provider-error', + providerError: err instanceof Error ? err.message.slice(0, 200) : 'Unknown error', + }, + }); + } + } catch (error) { + return errorResponse(error); } - } catch (error) { - return errorResponse(error); - } - }), + }), + ), ); diff --git a/src/lib/api/helpers.ts b/src/lib/api/helpers.ts index e69e639..a5a12c0 100644 --- a/src/lib/api/helpers.ts +++ b/src/lib/api/helpers.ts @@ -8,6 +8,12 @@ import { type RolePermissions } from '@/lib/db/schema/users'; import { createAuditLog } from '@/lib/audit'; import { errorResponse } from '@/lib/errors'; import { logger } from '@/lib/logger'; +import { + checkRateLimit, + rateLimiters, + rateLimitHeaders, + type RateLimiterName, +} from '@/lib/rate-limit'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -245,3 +251,47 @@ export function withPermission( return handler(req, ctx, params); }; } + +// ─── withRateLimit ─────────────────────────────────────────────────────────── + +/** + * Wraps a route handler with a per-user rate-limit gate. Compose inside + * withAuth so the userId is available — falls back to IP for anonymous + * routes (we don't currently expose any). + * + * 429 responses include `X-RateLimit-Limit` / `Remaining` / `Reset` headers + * and a `Retry-After` hint. + * + * ```ts + * export const POST = withAuth( + * withPermission('expenses', 'create', + * withRateLimit('ocr', handler) + * ) + * ); + * ``` + */ +export function withRateLimit(name: RateLimiterName, handler: RouteHandler): RouteHandler { + const config = rateLimiters[name]; + return async (req, ctx, params) => { + const identifier = `${ctx.userId}`; + const result = await checkRateLimit(identifier, config); + if (!result.allowed) { + const retryAfterSec = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000)); + logger.warn( + { userId: ctx.userId, limiter: name, limit: result.limit }, + 'Rate limit exceeded', + ); + return NextResponse.json( + { error: 'Rate limit exceeded', retryAfter: retryAfterSec }, + { + status: 429, + headers: { + ...rateLimitHeaders(result), + 'Retry-After': String(retryAfterSec), + }, + }, + ); + } + return handler(req, ctx, params); + }; +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index fe0695d..dfb654e 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -77,4 +77,12 @@ export const rateLimiters = { upload: { windowMs: 60 * 1000, max: 10, keyPrefix: 'upload' }, /** Bulk operations: 5 per minute. */ bulk: { windowMs: 60 * 1000, max: 5, keyPrefix: 'bulk' }, + /** Receipt scanner: 10 OCR runs per minute per user. */ + ocr: { windowMs: 60 * 1000, max: 10, keyPrefix: 'ocr' }, + /** Server-side AI calls (summary, embeddings, etc): 60 per minute per user. */ + ai: { windowMs: 60 * 1000, max: 60, keyPrefix: 'ai' }, + /** Data exports (GDPR bundle, PDF, CSV): 30 per hour per user. */ + exports: { windowMs: 60 * 60 * 1000, max: 30, keyPrefix: 'export' }, } as const satisfies Record; + +export type RateLimiterName = keyof typeof rateLimiters; diff --git a/tests/integration/rate-limit.test.ts b/tests/integration/rate-limit.test.ts new file mode 100644 index 0000000..d233e4a --- /dev/null +++ b/tests/integration/rate-limit.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; + +import { redis } from '@/lib/redis'; +import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; + +const TEST_USER = `rl-test-${crypto.randomUUID()}`; + +async function clearTestKeys() { + // Wipe any keys created during this test run. + for (const cfg of Object.values(rateLimiters)) { + await redis.del(`rl:${cfg.keyPrefix}:${TEST_USER}`); + } +} + +beforeEach(async () => { + await clearTestKeys(); +}); + +afterAll(async () => { + await clearTestKeys(); + await redis.quit(); +}); + +describe('rate-limit catalog', () => { + it('exposes the canonical names with the documented limits', () => { + expect(rateLimiters.ocr).toMatchObject({ max: 10, windowMs: 60_000 }); + expect(rateLimiters.ai).toMatchObject({ max: 60, windowMs: 60_000 }); + expect(rateLimiters.exports).toMatchObject({ max: 30, windowMs: 60 * 60_000 }); + }); +}); + +describe('checkRateLimit (sliding window)', () => { + it('allows up to `max` calls in a window then refuses', async () => { + const cfg = rateLimiters.ocr; + let lastResult; + for (let i = 0; i < cfg.max; i++) { + lastResult = await checkRateLimit(TEST_USER, cfg); + expect(lastResult.allowed).toBe(true); + } + expect(lastResult!.remaining).toBe(0); + + const overflow = await checkRateLimit(TEST_USER, cfg); + expect(overflow.allowed).toBe(false); + expect(overflow.remaining).toBe(0); + expect(overflow.limit).toBe(cfg.max); + }); + + it('isolates limits across keyPrefixes (ocr vs ai vs exports)', async () => { + // Burn through ocr limit. + for (let i = 0; i < rateLimiters.ocr.max; i++) { + await checkRateLimit(TEST_USER, rateLimiters.ocr); + } + const ocrOverflow = await checkRateLimit(TEST_USER, rateLimiters.ocr); + expect(ocrOverflow.allowed).toBe(false); + + // ai limit is independent. + const ai = await checkRateLimit(TEST_USER, rateLimiters.ai); + expect(ai.allowed).toBe(true); + + // exports limit is independent. + const exp = await checkRateLimit(TEST_USER, rateLimiters.exports); + expect(exp.allowed).toBe(true); + }); + + it('isolates limits across users (one user hitting cap does not affect another)', async () => { + const otherUser = `rl-test-${crypto.randomUUID()}`; + try { + for (let i = 0; i < rateLimiters.ocr.max; i++) { + await checkRateLimit(TEST_USER, rateLimiters.ocr); + } + const overflowSelf = await checkRateLimit(TEST_USER, rateLimiters.ocr); + expect(overflowSelf.allowed).toBe(false); + + const otherFirst = await checkRateLimit(otherUser, rateLimiters.ocr); + expect(otherFirst.allowed).toBe(true); + expect(otherFirst.remaining).toBe(rateLimiters.ocr.max - 1); + } finally { + await redis.del(`rl:${rateLimiters.ocr.keyPrefix}:${otherUser}`); + } + }); + + it('reports a sensible resetAt in the future', async () => { + const before = Date.now(); + const r = await checkRateLimit(TEST_USER, rateLimiters.ocr); + expect(r.resetAt).toBeGreaterThanOrEqual(before + rateLimiters.ocr.windowMs - 1000); + expect(r.resetAt).toBeLessThanOrEqual(Date.now() + rateLimiters.ocr.windowMs + 1000); + }); +});