← 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;
}