/** * @mandujs/core/middleware/rate-limit * * Sliding-window rate limiter (Phase 6.1) with pluggable stores. Ships two * backends out of the box: * * - {@link createInMemoryStore} — process-local `Map`, zero deps, dies on * restart. The right choice for a single-process dev server or a small * production deployment where a dropped-on-restart limiter is acceptable. * - {@link createSqliteStore} — shared-file SQLite via `@mandujs/core/db`, * WAL mode (per RFC 0001 Appendix D.4). Survives restarts and covers * same-host multi-process; still NOT a distributed limiter (no * multi-host coordination — add Redis in a follow-up phase if needed). * * ## Algorithm — sliding window via fixed bucket * * Each key owns a bucket `{ windowStart, count }`. On each hit: * * - If `now - windowStart >= windowMs` → bucket rolls over: the new window * starts at `now` with `count = 1`. * - Else → `count++` inside the existing window. * * The request is `allowed` when `count <= limit`. `resetAt = windowStart + * windowMs`; `retryAfterSeconds = ceil((resetAt - now) / 1000)` when blocked. * * This is *not* a rolling-queue implementation (which would track every hit's * timestamp). For typical bursty traffic the observable behaviour is the * same — 1/ε the memory, 1/ε the work per hit, and no edge cases at the * window boundary. Token-bucket is explicitly deferred to v2 as an * alternative algorithm once a real-world use case justifies it. * * ## Usage * * ### As middleware * * ```ts * import { rateLimit } from "@mandujs/core/middleware/rate-limit"; * * export default Mandu.filling() * .use(rateLimit({ limit: 60, windowMs: 60_000 })) * .post((ctx) => ctx.ok({ ok: true })); * ``` * * ### As a guard for non-HTTP call sites * * The auth flows in Phase 5.3 (`verify.send`, `reset.send`) documented their * lack of rate limiting explicitly. Wrap them with the guard: * * ```ts * const sendGuard = createRateLimitGuard({ limit: 1, windowMs: 60_000 }); * // later, at a POST handler: * await sendGuard.enforce(`verify:${userId}`); * await verify.send(userId, email); * ``` * * Throwing {@link RateLimitError} carries the full {@link RateLimitResult} * so the caller can shape its own 429 response. * * @module middleware/rate-limit */ import type { ManduContext } from "../../filling/context"; // ─── Public types ─────────────────────────────────────────────────────────── /** Outcome of a single `hit()` on a rate-limit store. */ export interface RateLimitResult { /** Whether the hit stayed within the configured budget. */ allowed: boolean; /** * Remaining budget in the current window, after this hit. Always clamped * to `>= 0` — a blocked hit reports `0`. */ remaining: number; /** Unix ms at which the current window ends and a fresh one begins. */ resetAt: number; /** * `Math.ceil((resetAt - now) / 1000)` when blocked, `0` when allowed. * Consumed directly as the `Retry-After` header value on 429 responses. */ retryAfterSeconds: number; } /** * Pluggable backing store. Each call to {@link hit} atomically records a * single request against `key` and returns the resulting state — there is no * separate "read then increment" path to avoid race conditions. */ export interface RateLimitStore { /** * Record one hit against `key` with `limit` / `windowMs` in effect. MUST * be atomic: concurrent callers racing on the same key must observe a * strictly-increasing count up to the rollover, never a lost update. */ hit(key: string, limit: number, windowMs: number): Promise; /** * Delete entries whose window is older than `olderThanMs` ago. Returns * the number of entries purged. Safe to call on a hot store — the cron * schedulers may do so on a fixed tick. */ gcNow(olderThanMs: number): Promise; /** Release any held resources. Optional; safe to omit for in-memory stores. */ close?(): Promise; } /** Construction options for {@link rateLimit}. */ export interface RateLimitMiddlewareOptions { /** Maximum hits per window. Required. Must be a positive integer. */ limit: number; /** Window duration in ms. Required. Must be a positive integer. */ windowMs: number; /** Store backend. Default: a fresh in-memory store (per middleware instance). */ store?: RateLimitStore; /** * Compute the rate-limit key for the current request. Returning `null` * skips limiting for this request — the middleware passes through * without touching the store. * * Default: first entry of `x-forwarded-for` header → `x-real-ip` → * `"unknown"`. Production behind a trusted proxy should set XFF; otherwise * use a session-user-id key via a custom `keyFn` to limit authenticated * traffic per account instead of per (shared) IP. */ keyFn?: (ctx: ManduContext) => string | null; /** * Predicate to bypass limiting for specific requests. Return `true` to * skip entirely — the store is not consulted, no headers are emitted. * Default: never skips. */ skip?: (ctx: ManduContext) => boolean; /** * Build the 429 response body on block. Default: JSON * `{ error: "rate_limited", retryAfterSeconds }`. Callers can override to * match their app's error envelope. */ handler?: (ctx: ManduContext, result: RateLimitResult) => Response; } /** Middleware signature matching `csrf.ts` / `session.ts`. */ export type RateLimitMiddleware = ( ctx: ManduContext, ) => Promise; /** Construction options for {@link createRateLimitGuard}. */ export interface RateLimitGuardOptions { limit: number; windowMs: number; /** Store backend. Default: a fresh in-memory store (per guard instance). */ store?: RateLimitStore; } /** * Imperative rate-limit handle for non-middleware call sites. Use * {@link RateLimitGuard.enforce} to wrap sensitive operations like * `verify.send(userId)` that don't live behind HTTP middleware. */ export interface RateLimitGuard { /** * Record one hit and return the full result. Never throws — inspect * `result.allowed` to branch. */ check(key: string): Promise; /** * Record one hit and throw {@link RateLimitError} when blocked. Resolves * silently when allowed. Ideal for `await guard.enforce(...)` prologues. */ enforce(key: string): Promise; } /** * Error thrown by {@link RateLimitGuard.enforce} when a hit is blocked. * Carries the full {@link RateLimitResult} so the caller can format the * response with accurate `Retry-After` / `X-RateLimit-Reset` information. */ export class RateLimitError extends Error { /** Public so handlers can derive `Retry-After` without downcasting. */ readonly result: RateLimitResult; constructor(result: RateLimitResult, message?: string) { super( message ?? `rate_limited: retry after ${result.retryAfterSeconds}s (reset at ${new Date( result.resetAt, ).toISOString()})`, ); this.name = "RateLimitError"; this.result = result; } } // ─── Constants ────────────────────────────────────────────────────────────── const DEFAULT_KEY_UNKNOWN = "unknown"; /** * Clock injector. Kept module-scoped (not per-store) so tests can freeze time * across both the middleware and any stores it calls during a single * scenario. Override via {@link _setClockForTests}; production callers never * touch this. * * @internal */ let __now: () => number = () => Date.now(); /** * Replace the module-level clock. Test-only hook; not exported from the * package surface. * * @internal */ export function _setClockForTests(fn: (() => number) | null): void { __now = fn ?? (() => Date.now()); } // ─── Key derivation ───────────────────────────────────────────────────────── /** * Default key derivation. Reads the first hop of `x-forwarded-for` (the * client IP when a trusted reverse proxy is in front), falling back to * `x-real-ip`, and finally a literal `"unknown"` bucket. * * The `"unknown"` fallback is intentionally a single shared bucket: when the * edge has not forwarded either header, we cannot tell callers apart, and a * hostile client could otherwise bypass the limit by simply stripping the * header. Shared throttling keeps the limiter safe but may be harsh on * no-proxy dev setups — set a custom `keyFn` that uses a session id, API * key, or user id for production traffic. */ function defaultKeyFn(ctx: ManduContext): string { const xff = ctx.request.headers.get("x-forwarded-for"); if (typeof xff === "string" && xff.length > 0) { // XFF is a comma-separated chain; the client is the first entry. const first = xff.split(",")[0]?.trim(); if (first && first.length > 0) return first; } const realIp = ctx.request.headers.get("x-real-ip"); if (typeof realIp === "string" && realIp.length > 0) return realIp.trim(); return DEFAULT_KEY_UNKNOWN; } // ─── Middleware factory ───────────────────────────────────────────────────── function assertPositiveInt(name: string, value: number): void { if ( typeof value !== "number" || !Number.isFinite(value) || value <= 0 || Math.floor(value) !== value ) { throw new TypeError( `[@mandujs/core/middleware/rate-limit] '${name}' must be a positive integer; got ${String( value, )}.`, ); } } /** * Sliding-window rate-limit middleware. * * Behaviour: * 1. If `skip(ctx)` returns `true`, the middleware returns void immediately * — no store access, no headers. * 2. Otherwise, `keyFn(ctx)` produces a key. `null` skips the store (useful * for "only limit authenticated traffic" policies). * 3. `store.hit(key, limit, windowMs)` records and evaluates. * 4. On block: returns 429 with `Retry-After` + `X-RateLimit-*` headers and * a JSON body (or whatever `handler` returns). The caller's handler * pipeline is short-circuited. * 5. On allow: returns void. No response mutation is performed here — the * middleware surface has no afterHandle hook on this variant. Callers * who want `X-RateLimit-*` headers on allowed responses should use * {@link rateLimitPlugin} (exposes beforeHandle + afterHandle) instead. */ export function rateLimit( options: RateLimitMiddlewareOptions, ): RateLimitMiddleware { if (!options || typeof options !== "object") { throw new TypeError( "[@mandujs/core/middleware/rate-limit] rateLimit: options object required.", ); } assertPositiveInt("limit", options.limit); assertPositiveInt("windowMs", options.windowMs); const limit = options.limit; const windowMs = options.windowMs; const store = options.store ?? createInMemoryStore(); const keyFn = options.keyFn ?? defaultKeyFn; const skip = options.skip; const handler = options.handler ?? defaultBlockedHandler; return async (ctx: ManduContext): Promise => { if (skip && skip(ctx)) { return; } const key = keyFn(ctx); if (key === null) { // Caller's key function explicitly opts out for this request. return; } const result = await store.hit(key, limit, windowMs); if (!result.allowed) { const res = handler(ctx, result); // The caller's `handler` may return a pre-built Response that already // carries rate-limit headers. We only stamp them when absent so a // custom handler that wants to hide the Retry-After (rare) can do so. return applyRateLimitHeaders(res, limit, result); } // Allowed: pass through. Callers who need `X-RateLimit-*` headers on // successful responses should layer {@link rateLimitPlugin} on top of // the filling chain — this plain-middleware variant cannot mutate the // outgoing Response without an afterHandle hook. return; }; } /** * Default 429 response body. Intentionally small — exposes only what a * well-behaved client legitimately needs. `resetAt` is Unix-ms so clients * don't have to negotiate timezone interpretation. */ function defaultBlockedHandler( _ctx: ManduContext, result: RateLimitResult, ): Response { return Response.json( { error: "rate_limited", retryAfterSeconds: result.retryAfterSeconds, resetAt: result.resetAt, }, { status: 429 }, ); } /** * Stamp `Retry-After` + `X-RateLimit-*` headers on a blocked response. The * `X-RateLimit-Reset` value is Unix-seconds (not ms) to match the informal * convention used by GitHub / Twitter / Stripe — see GitHub's API docs. * * Preserves any header the caller's handler has already set (checked with * `Headers.has`) so custom handlers can override values at will. */ function applyRateLimitHeaders( response: Response, limit: number, result: RateLimitResult, ): Response { const headers = new Headers(response.headers); if (!headers.has("Retry-After")) { headers.set("Retry-After", String(result.retryAfterSeconds)); } if (!headers.has("X-RateLimit-Limit")) { headers.set("X-RateLimit-Limit", String(limit)); } if (!headers.has("X-RateLimit-Remaining")) { headers.set("X-RateLimit-Remaining", String(result.remaining)); } if (!headers.has("X-RateLimit-Reset")) { headers.set( "X-RateLimit-Reset", String(Math.floor(result.resetAt / 1000)), ); } return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } // ─── Guard (imperative) ───────────────────────────────────────────────────── /** * Construct an imperative rate-limit guard. Use when the protected operation * is NOT an HTTP handler — e.g. an outbound email from a server-side action. * * Every guard owns its own store by default, so two guards with the same * `{ limit, windowMs }` are independent. Share a store explicitly when two * guards must consume the same budget. */ export function createRateLimitGuard( options: RateLimitGuardOptions, ): RateLimitGuard { if (!options || typeof options !== "object") { throw new TypeError( "[@mandujs/core/middleware/rate-limit] createRateLimitGuard: options object required.", ); } assertPositiveInt("limit", options.limit); assertPositiveInt("windowMs", options.windowMs); const limit = options.limit; const windowMs = options.windowMs; const store = options.store ?? createInMemoryStore(); return { async check(key: string): Promise { if (typeof key !== "string" || key.length === 0) { throw new TypeError( "[@mandujs/core/middleware/rate-limit] check: key must be a non-empty string.", ); } return await store.hit(key, limit, windowMs); }, async enforce(key: string): Promise { if (typeof key !== "string" || key.length === 0) { throw new TypeError( "[@mandujs/core/middleware/rate-limit] enforce: key must be a non-empty string.", ); } const result = await store.hit(key, limit, windowMs); if (!result.allowed) { throw new RateLimitError(result); } }, }; } // ─── In-memory store ──────────────────────────────────────────────────────── interface Bucket { windowStart: number; count: number; } /** * Process-local in-memory store. No external dependencies, no persistence * across restarts. Concurrency-safe within a single event loop (Map reads * and writes are not preempted mid-operation in JS); no locking needed. * * Not safe across processes or hosts — use {@link createSqliteStore} for * same-host multi-process, or a distributed store (future Redis backend) * for multi-host. */ export function createInMemoryStore(): RateLimitStore { const buckets = new Map(); let closed = false; return { async hit( key: string, limit: number, windowMs: number, ): Promise { if (closed) { throw new Error( "[@mandujs/core/middleware/rate-limit] in-memory store is closed.", ); } const now = __now(); const existing = buckets.get(key); let bucket: Bucket; if (!existing || now - existing.windowStart >= windowMs) { // Window rollover (or first hit). Start a fresh window anchored at // `now` — the simplest model that still reports a precise resetAt. bucket = { windowStart: now, count: 1 }; } else { // Still inside the current window — increment. bucket = { windowStart: existing.windowStart, count: existing.count + 1 }; } buckets.set(key, bucket); const resetAt = bucket.windowStart + windowMs; const allowed = bucket.count <= limit; // `remaining` reports post-hit budget; clamped at 0 so a blocked hit // never reports negative remaining (would confuse clients rendering a // progress indicator). const remaining = Math.max(0, limit - bucket.count); const retryAfterSeconds = allowed ? 0 : Math.max(1, Math.ceil((resetAt - now) / 1000)); return { allowed, remaining, resetAt, retryAfterSeconds }; }, async gcNow(olderThanMs: number): Promise { if (closed) return 0; if (typeof olderThanMs !== "number" || olderThanMs < 0) { throw new TypeError( "[@mandujs/core/middleware/rate-limit] gcNow: olderThanMs must be a non-negative number.", ); } const now = __now(); let deleted = 0; // Iterating + deleting from a Map during traversal is safe per the // ES spec — entries visited before deletion yield, already-visited // entries are skipped. We still collect keys into a throwaway array // to keep the hot-path clean across engines. const stale: string[] = []; for (const [key, bucket] of buckets) { if (now - bucket.windowStart > olderThanMs) { stale.push(key); } } for (const key of stale) { buckets.delete(key); deleted++; } return deleted; }, async close(): Promise { if (closed) return; closed = true; buckets.clear(); }, }; } // ─── SQLite store (re-export) ─────────────────────────────────────────────── // The SQLite store lives in its own module so callers who never need it // don't pay the import cost of `@mandujs/core/db`. export { createSqliteStore, type SqliteRateLimitStoreOptions, } from "./sqlite-store";