93 lines
2.6 KiB
TypeScript
93 lines
2.6 KiB
TypeScript
/**
|
|
* Per-tier rate limiter for /api/scan and other write endpoints.
|
|
*
|
|
* Bucket model:
|
|
* - free: 5 scans/minute, daily cap of 5 (the daily cap is enforced
|
|
* elsewhere in the route handler)
|
|
* - analyst: 30 scans/minute, unlimited daily
|
|
* - desk: 60 scans/minute, unlimited daily
|
|
*
|
|
* Storage:
|
|
* - In-memory ring buffer per key.
|
|
* - Process-local — good for single-instance Vercel deployments. For
|
|
* multi-instance later, swap to upstash/redis without changing the
|
|
* external interface.
|
|
*/
|
|
|
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
|
|
type Tier = 'free' | 'analyst' | 'desk';
|
|
|
|
const LIMITS: Record<Tier, number> = {
|
|
free: 5,
|
|
analyst: 30,
|
|
desk: 60,
|
|
};
|
|
|
|
const WINDOW_MS = 60_000;
|
|
|
|
interface Bucket {
|
|
hits: number[]; // ms timestamps
|
|
}
|
|
|
|
const buckets = new Map<string, Bucket>();
|
|
|
|
export function rateLimitCheck(key: string, tier: Tier): { ok: true } | { ok: false; retryAfter: number } {
|
|
const limit = LIMITS[tier] ?? LIMITS.free;
|
|
const now = Date.now();
|
|
const bucket = buckets.get(key) ?? { hits: [] };
|
|
|
|
// Drop hits older than the window
|
|
while (bucket.hits.length && now - bucket.hits[0] > WINDOW_MS) {
|
|
bucket.hits.shift();
|
|
}
|
|
|
|
if (bucket.hits.length >= limit) {
|
|
const oldest = bucket.hits[0];
|
|
const retryAfter = Math.max(1, Math.ceil((WINDOW_MS - (now - oldest)) / 1000));
|
|
buckets.set(key, bucket);
|
|
return { ok: false, retryAfter };
|
|
}
|
|
|
|
bucket.hits.push(now);
|
|
buckets.set(key, bucket);
|
|
|
|
// Opportunistic GC — keep the map small in long-running processes
|
|
if (buckets.size > 5000 && Math.random() < 0.01) {
|
|
pruneStale();
|
|
}
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
export function rateLimitResponse(retryAfter: number) {
|
|
return NextResponse.json(
|
|
{
|
|
error: "Slow down — you're reading faster than the model can think. Try again in a minute.",
|
|
retryAfter,
|
|
},
|
|
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a stable key from the incoming request. Prefers the auth bearer
|
|
* (per-user) and falls back to the forwarded IP for anonymous traffic.
|
|
*/
|
|
export function rateLimitKey(req: NextRequest): string {
|
|
const auth = req.headers.get('authorization');
|
|
if (auth) return `user:${auth.slice(-32)}`;
|
|
const fwd = req.headers.get('x-forwarded-for') || '';
|
|
const ip = fwd.split(',')[0].trim() || 'anon';
|
|
return `ip:${ip}`;
|
|
}
|
|
|
|
function pruneStale() {
|
|
const cutoff = Date.now() - WINDOW_MS * 2;
|
|
for (const [key, bucket] of buckets.entries()) {
|
|
if (!bucket.hits.length || bucket.hits[bucket.hits.length - 1] < cutoff) {
|
|
buckets.delete(key);
|
|
}
|
|
}
|
|
}
|