Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+92
View File
@@ -0,0 +1,92 @@
/**
* 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);
}
}
}