diff --git a/README.md b/README.md index 337ee4f..ba4a9e7 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,15 @@ agentpay-backend/ stroops, `priceStroops`, `billedStroops`, `/api/v1/billing/*`, and why `POST /api/v1/settle` drains backend counters without moving funds. +## Runtime configuration + +`GET /api/v1/config` returns the current process-local runtime settings. Use +`PATCH /api/v1/config` to update positive integer values such as +`rateLimitPerWindow` and `rateLimitWindowMs` without restarting the backend. +The in-process rate limiter reads those live values on every request, prunes +request buckets with the active window length, and computes `Retry-After` from +the active window. + ## Quickstart Start a local backend on `http://localhost:3001` with the checked-in diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 53859a7..0352867 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -5,13 +5,7 @@ import express, { type Request, type Response, } from "express"; -import { - apiKeyStore, - pauseState, - rateBuckets, - RATE_LIMIT_PER_WINDOW, - RATE_LIMIT_WINDOW_MS, -} from "../store/state.js"; +import { apiKeyStore, config, pauseState, rateBuckets } from "../store/state.js"; import type { AgentPayRequest } from "../types.js"; /** @@ -120,19 +114,22 @@ function pauseGuardMiddleware(req: Request, res: Response, next: NextFunction): }); } -/** In-process IP rate limiter matching the original 60/min behavior. */ +/** In-process IP rate limiter backed by the live runtime config. */ function rateLimitMiddleware(req: Request, res: Response, next: NextFunction): void { if (process.env.NODE_ENV === "test") return next(); + const rateLimitWindowMs = config.rateLimitWindowMs; + const rateLimitPerWindow = config.rateLimitPerWindow; const ip = req.ip ?? req.socket.remoteAddress ?? "unknown"; const now = Date.now(); - const bucket = (rateBuckets.get(ip) ?? []).filter( - (t) => now - t < RATE_LIMIT_WINDOW_MS - ); - if (bucket.length >= RATE_LIMIT_PER_WINDOW) { - res.setHeader("Retry-After", "60"); + const bucket = (rateBuckets.get(ip) ?? []).filter((t) => now - t < rateLimitWindowMs); + if (bucket.length >= rateLimitPerWindow) { + res.setHeader( + "Retry-After", + String(Math.max(1, Math.ceil(rateLimitWindowMs / 1000))) + ); res.status(429).json({ error: "rate_limited", - message: `more than ${RATE_LIMIT_PER_WINDOW} requests per ${RATE_LIMIT_WINDOW_MS / 1000}s`, + message: `more than ${rateLimitPerWindow} requests per ${rateLimitWindowMs / 1000}s`, requestId: (req as AgentPayRequest).id, }); return; diff --git a/src/ratelimit-config.test.ts b/src/ratelimit-config.test.ts new file mode 100644 index 0000000..958a52e --- /dev/null +++ b/src/ratelimit-config.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert"; +import request from "supertest"; +import { createApp } from "./index.js"; +import { config, rateBuckets } from "./store/state.js"; + +const defaultConfig = { + rateLimitPerWindow: 60, + rateLimitWindowMs: 60_000, + bulkMaxItems: 100, + eventLogCap: 10_000, +}; + +const originalNodeEnv = process.env.NODE_ENV; + +beforeEach(() => { + rateBuckets.clear(); + Object.assign(config, defaultConfig); + process.env.NODE_ENV = originalNodeEnv; +}); + +async function withLimiterEnabled(fn: () => Promise): Promise { + const previousNodeEnv = process.env.NODE_ENV; + const previousLog = console.log; + process.env.NODE_ENV = "production"; + console.log = () => undefined; + try { + return await fn(); + } finally { + process.env.NODE_ENV = previousNodeEnv; + console.log = previousLog; + } +} + +function stringBodyField(body: unknown, field: string): string { + assert.ok(body && typeof body === "object", "response body must be an object"); + const value = (body as Record)[field]; + assert.strictEqual(typeof value, "string", `${field} must be a string`); + return value as string; +} + +void describe("live rate limit config", () => { + void it("honours a lowered rateLimitPerWindow value immediately", async () => { + const app = createApp(); + await request(app).patch("/api/v1/config").send({ + rateLimitPerWindow: 1, + rateLimitWindowMs: 60_000, + }); + + await withLimiterEnabled(async () => { + const first = await request(app).get("/api/v1/version"); + assert.strictEqual(first.status, 200); + + const limited = await request(app) + .get("/api/v1/version") + .set("X-Request-Id", "rate-limited-live-config"); + assert.strictEqual(limited.status, 429); + assert.strictEqual(limited.body.error, "rate_limited"); + assert.strictEqual(limited.body.requestId, "rate-limited-live-config"); + assert.strictEqual(limited.headers["retry-after"], "60"); + assert.match( + stringBodyField(limited.body, "message"), + /more than 1 requests per 60s/ + ); + }); + }); + + void it("uses the live rateLimitWindowMs when pruning buckets", async () => { + const app = createApp(); + await request(app).patch("/api/v1/config").send({ + rateLimitPerWindow: 1, + rateLimitWindowMs: 1, + }); + + await withLimiterEnabled(async () => { + const first = await request(app).get("/api/v1/version"); + assert.strictEqual(first.status, 200); + + const bucketKey = Array.from(rateBuckets.keys())[0]; + assert.ok(bucketKey, "expected the first request to create a rate bucket"); + rateBuckets.set( + bucketKey, + Array.from({ length: 60 }, () => Date.now() - 2) + ); + + const second = await request(app).get("/api/v1/version"); + assert.strictEqual(second.status, 200); + }); + }); + + void it("computes Retry-After from the live rateLimitWindowMs value", async () => { + const app = createApp(); + await request(app).patch("/api/v1/config").send({ + rateLimitPerWindow: 1, + rateLimitWindowMs: 2_500, + }); + + await withLimiterEnabled(async () => { + const first = await request(app).get("/api/v1/version"); + assert.strictEqual(first.status, 200); + + const limited = await request(app).get("/api/v1/version"); + assert.strictEqual(limited.status, 429); + assert.strictEqual(limited.headers["retry-after"], "3"); + assert.match( + stringBodyField(limited.body, "message"), + /more than 1 requests per 2\.5s/ + ); + }); + }); +}); diff --git a/src/store/state.ts b/src/store/state.ts index 6c573f4..55002ee 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -12,10 +12,13 @@ export type WebhookRecord = { url: string; events: string[]; createdAt: number } /** Mirrors the on-chain pause flag for write-gated endpoints. */ export const pauseState = { paused: false }; +const DEFAULT_RATE_LIMIT_PER_WINDOW = 60; +const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000; + /** Runtime-tunable in-memory configuration returned by /api/v1/config. */ export const config: Record = { - rateLimitPerWindow: 60, - rateLimitWindowMs: 60_000, + rateLimitPerWindow: DEFAULT_RATE_LIMIT_PER_WINDOW, + rateLimitWindowMs: DEFAULT_RATE_LIMIT_WINDOW_MS, bulkMaxItems: 100, eventLogCap: 10_000, }; @@ -43,6 +46,3 @@ export const webhookStore = new Map(); /** Rate-limiter windows keyed by source IP. */ export const rateBuckets = new Map(); - -export const RATE_LIMIT_PER_WINDOW = 60; -export const RATE_LIMIT_WINDOW_MS = 60_000;