feat(rate-limit): per-user limiters for OCR, AI, and exports

Adds three named rate limiters to the existing Redis sliding-window
catalog and a withRateLimit wrapper that composes inside withAuth.
Wires the OCR limiter into the receipt-scan endpoint so a runaway
client can't burn through the AI budget in a tight loop.

- ocr: 10/min/user
- ai: 60/min/user (reserved for future server-side AI surfaces)
- exports: 30/hour/user (reserved for GDPR bundle, PDF, CSV exports)

429 responses include X-RateLimit-* headers and a Retry-After hint.

Tests: 771/771 vitest (was 766) — +5 rate-limit tests covering catalog
shape, sliding window, cross-prefix isolation, cross-user isolation,
and resetAt timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 19:56:01 +02:00
parent e7d23b254c
commit 9dfa04094b
4 changed files with 233 additions and 83 deletions

View File

@@ -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);
}
}),
}),
),
);

View File

@@ -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);
};
}

View File

@@ -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<string, RateLimitConfig>;
export type RateLimiterName = keyof typeof rateLimiters;

View File

@@ -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);
});
});