← back to Govarbitrage
src/lib/rate-limit.ts
77 lines
import { NextRequest, NextResponse } from "next/server";
// Lightweight in-memory sliding-window rate limiter. Per-process (correct for a
// single self-hosted `next start` instance). For multi-instance deployments,
// swap the store for Redis (INCR + PEXPIRE) — same interface. `now` is injectable
// for deterministic tests.
interface Bucket {
count: number;
resetAt: number;
}
const store = new Map<string, Bucket>();
let calls = 0;
function sweep(now: number) {
for (const [k, b] of store) if (b.resetAt <= now) store.delete(k);
}
export interface RateResult {
allowed: boolean;
remaining: number;
limit: number;
retryAfterSec: number;
}
export function rateLimit(key: string, limit: number, windowMs: number, now = Date.now()): RateResult {
// Opportunistic cleanup so the map can't grow unbounded.
if (++calls % 500 === 0) sweep(now);
let b = store.get(key);
if (!b || b.resetAt <= now) {
b = { count: 0, resetAt: now + windowMs };
store.set(key, b);
}
b.count++;
const allowed = b.count <= limit;
return {
allowed,
remaining: Math.max(0, limit - b.count),
limit,
retryAfterSec: Math.ceil((b.resetAt - now) / 1000),
};
}
/** Best-effort client IP from proxy headers (nginx sets x-forwarded-for). */
export function clientIp(req: NextRequest): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return req.headers.get("x-real-ip") || "unknown";
}
/**
* Build a rate-limit bucket key that prefers the UNFORGEABLE session subject
* over the spoofable client IP. A logged-in user is keyed on their verified
* `sub` so they can't reset their bucket (and run up the paid Places bill) by
* rotating X-Forwarded-For; only anonymous/last-resort callers fall back to IP.
* Pure + exported so the security invariant is unit-testable.
*/
export function sessionOrIpKey(prefix: string, userSub: string | null | undefined, ip: string): string {
return userSub ? `${prefix}:u:${userSub}` : `${prefix}:ip:${ip}`;
}
/** 429 response with a Retry-After header. */
export function tooManyRequests(r: RateResult, extraHeaders?: Record<string, string>): NextResponse {
return NextResponse.json(
{ error: "Too many requests. Please slow down." },
{ status: 429, headers: { "Retry-After": String(r.retryAfterSec), ...extraHeaders } },
);
}
/** Test-only: clear all buckets. */
export function __resetRateLimit() {
store.clear();
calls = 0;
}