SPB Git

Repository Public MIT

ratelimit.ts 2.2 KB 56 lines typescript

1import { ApiError } from "./errors";23/**4 * In-process fixed-window limiter. The registry runs as a single process in V1 (see ADR-0004);5 * a multi-instance deployment must move this to Postgres or Redis.6 */7interface Window {8  count: number;9  resetAt: number;10}11const g = globalThis as unknown as { __skillsRl?: Map<string, Window> };12const store = (g.__skillsRl ??= new Map());1314export const BUCKETS = {15  "anon-read": { limit: 240, windowSec: 60 },16  search: { limit: 90, windowSec: 60 },17  download: { limit: 120, windowSec: 60 },18  media: { limit: 600, windowSec: 60 },19  login: { limit: 8, windowSec: 900 },20  "token-write": { limit: 120, windowSec: 60 },21  upload: { limit: 30, windowSec: 3600 },22  publish: { limit: 30, windowSec: 3600 },23  report: { limit: 5, windowSec: 3600 },24  mcp: { limit: 240, windowSec: 60 },25  "auth-fail": { limit: 20, windowSec: 600 },26} as const;27export type Bucket = keyof typeof BUCKETS;2829export function rateLimit(bucket: Bucket, key: string, cost = 1): { remaining: number; limit: number; resetSec: number } {30  if (process.env.SKILLS_DISABLE_RATE_LIMIT === "1" && process.env.NODE_ENV !== "production") return { remaining: 1, limit: 1, resetSec: 0 };31  const cfg = BUCKETS[bucket];32  const now = Date.now();33  const k = `${bucket}:${key}`;34  let w = store.get(k);35  if (!w || w.resetAt <= now) {36    w = { count: 0, resetAt: now + cfg.windowSec * 1000 };37    store.set(k, w);38  }39  w.count += cost;40  const resetSec = Math.max(1, Math.ceil((w.resetAt - now) / 1000));41  if (w.count > cfg.limit) {42    throw new ApiError(429, "rate_limited", `Too many requests. Retry in ${resetSec} seconds.`, undefined, { "Retry-After": String(resetSec) });43  }44  if (store.size > 50000) for (const [key2, v] of store) if (v.resetAt <= now) store.delete(key2);45  return { remaining: cfg.limit - w.count, limit: cfg.limit, resetSec };46}4748/** Peek without consuming: used to refuse work for keys that are already over an abuse threshold. */49export function isLimited(bucket: Bucket, key: string): boolean {50  const w = store.get(`${bucket}:${key}`);51  return !!w && w.resetAt > Date.now() && w.count >= BUCKETS[bucket].limit;52}53export function resetRateLimits() {54  store.clear();55}56
Files main
Menu

Theme