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

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