Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 11 additions & 14 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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;
Expand Down
111 changes: 111 additions & 0 deletions src/ratelimit-config.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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<string, unknown>)[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/
);
});
});
});
10 changes: 5 additions & 5 deletions src/store/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
rateLimitPerWindow: 60,
rateLimitWindowMs: 60_000,
rateLimitPerWindow: DEFAULT_RATE_LIMIT_PER_WINDOW,
rateLimitWindowMs: DEFAULT_RATE_LIMIT_WINDOW_MS,
bulkMaxItems: 100,
eventLogCap: 10_000,
};
Expand Down Expand Up @@ -43,6 +46,3 @@ export const webhookStore = new Map<string, WebhookRecord>();

/** Rate-limiter windows keyed by source IP. */
export const rateBuckets = new Map<string, number[]>();

export const RATE_LIMIT_PER_WINDOW = 60;
export const RATE_LIMIT_WINDOW_MS = 60_000;