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:
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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 { errorResponse } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
||||||
@@ -22,7 +22,10 @@ const EMPTY: ParsedReceipt = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('expenses', 'create', async (req, ctx) => {
|
withPermission(
|
||||||
|
'expenses',
|
||||||
|
'create',
|
||||||
|
withRateLimit('ocr', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
const file = formData.get('file') as File | null;
|
const file = formData.get('file') as File | null;
|
||||||
@@ -110,4 +113,5 @@ export const POST = withAuth(
|
|||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import { type RolePermissions } from '@/lib/db/schema/users';
|
|||||||
import { createAuditLog } from '@/lib/audit';
|
import { createAuditLog } from '@/lib/audit';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
rateLimiters,
|
||||||
|
rateLimitHeaders,
|
||||||
|
type RateLimiterName,
|
||||||
|
} from '@/lib/rate-limit';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -245,3 +251,47 @@ export function withPermission(
|
|||||||
return handler(req, ctx, params);
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,4 +77,12 @@ export const rateLimiters = {
|
|||||||
upload: { windowMs: 60 * 1000, max: 10, keyPrefix: 'upload' },
|
upload: { windowMs: 60 * 1000, max: 10, keyPrefix: 'upload' },
|
||||||
/** Bulk operations: 5 per minute. */
|
/** Bulk operations: 5 per minute. */
|
||||||
bulk: { windowMs: 60 * 1000, max: 5, keyPrefix: 'bulk' },
|
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>;
|
} as const satisfies Record<string, RateLimitConfig>;
|
||||||
|
|
||||||
|
export type RateLimiterName = keyof typeof rateLimiters;
|
||||||
|
|||||||
88
tests/integration/rate-limit.test.ts
Normal file
88
tests/integration/rate-limit.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user