Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ GRACEFUL_SHUTDOWN_TIMEOUT_MS=10000
# ── Rate limiting ───────────────────────────────────────────────────────────
# [optional] Shared store for multi-instance deployments (in-memory otherwise)
REDIS_URL=redis://localhost:6379
# Publisher registration (POST /publishers): 10 registrations per IP per hour
RATE_LIMIT_REGISTER_IP_MAX=10
RATE_LIMIT_REGISTER_IP_WINDOW_MS=3600000
RATE_LIMIT_VERIFY_IP_MAX=10
RATE_LIMIT_VERIFY_IP_WINDOW_MS=60000
RATE_LIMIT_VERIFY_WALLET_MAX=5
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,23 @@ const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex
Failures are retried with exponential backoff (`WEBHOOK_MAX_ATTEMPTS`).
Private and loopback URLs are rejected.

## Rate limiting

All rate limits use a sliding-window algorithm. Responses include `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` headers. Blocked requests return `429` with `{ "error": "Too many requests", "code": "RATE_LIMITED", "retryAfterSeconds": N }` and a `Retry-After` header.

| Limiter | Scope | Default | Env vars |
|---|---|---|---|
| Registration | IP | 10 per hour | `RATE_LIMIT_REGISTER_IP_MAX`, `RATE_LIMIT_REGISTER_IP_WINDOW_MS` |
| Verify-content | IP | 10 / min | `RATE_LIMIT_VERIFY_IP_MAX`, `RATE_LIMIT_VERIFY_IP_WINDOW_MS` |
| Verify-content | Wallet | 5 / hour | `RATE_LIMIT_VERIFY_WALLET_MAX`, `RATE_LIMIT_VERIFY_WALLET_WINDOW_MS` |
| Publish resource | IP | 20 / min | `RATE_LIMIT_PUBLISH_IP_MAX`, `RATE_LIMIT_PUBLISH_IP_WINDOW_MS` |
| Publish resource | Wallet | 10 / hour | `RATE_LIMIT_PUBLISH_WALLET_MAX`, `RATE_LIMIT_PUBLISH_WALLET_WINDOW_MS` |
| Publisher API routes | Per publisher | 60 RPM | `PUBLISHER_RATE_LIMIT_RPM_DEFAULT`, `PUBLISHER_RATE_LIMIT_MAX_RPM` |

**Registration rate limit** (`POST /publishers`): at most `RATE_LIMIT_REGISTER_IP_MAX` (default **10**) registrations per IP within `RATE_LIMIT_REGISTER_IP_WINDOW_MS` (default **1 hour**). This prevents bulk account and API-key creation from a single IP address.

Publishers can inspect and override their own per-publisher limit via `PATCH /publishers/me/rate-limit` (up to `PUBLISHER_RATE_LIMIT_MAX_RPM`).

## Environment variables

See [`.env.example`](./.env.example) for the full list and descriptions.
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ const envSchema = z.object({
VERIFICATION_COMPLETION_COST_PER_1M: z.coerce.number().nonnegative().default(15),

// Rate limiting (verify-content + publish)
RATE_LIMIT_REGISTER_IP_MAX: z.coerce.number().default(10),
RATE_LIMIT_REGISTER_IP_WINDOW_MS: z.coerce.number().default(3_600_000),
RATE_LIMIT_VERIFY_IP_MAX: z.coerce.number().default(10),
RATE_LIMIT_VERIFY_IP_WINDOW_MS: z.coerce.number().default(60_000),
RATE_LIMIT_VERIFY_WALLET_MAX: z.coerce.number().default(5),
Expand Down
7 changes: 7 additions & 0 deletions src/middleware/rateLimiters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export const verifyIpRateLimit: RequestHandler = createIpRateLimiter(
"verify_ip",
);

export const registerIpRateLimit: RequestHandler = createIpRateLimiter(
store,
"register",
config.RATE_LIMIT_REGISTER_IP_MAX,
config.RATE_LIMIT_REGISTER_IP_WINDOW_MS,
"register_ip",
);
export const verifyWalletRateLimit: RequestHandler = createWalletRateLimiter(
store,
"verify",
Expand Down
199 changes: 199 additions & 0 deletions src/routes/publishers.register-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* Tests for the IP rate limit on POST /publishers (#38).
*
* Split into two layers:
* 1. Unit tests — exercise `createIpRateLimiter` directly with a controlled
* in-memory store and a fake handler, so window/clock/limit are all
* under our control.
* 2. Integration test — verify that publishers.ts wires the limiter; the
* limiter is replaced with a middleware that rejects on demand so the
* integration test is deterministic without touching real config.
*/
import { describe, it, expect, vi } from "vitest";
import express from "express";
import request from "supertest";
import { createIpRateLimiter, RATE_LIMITED } from "../middleware/rateLimit.js";
import { MemorySlidingWindowStore } from "../lib/rateLimit/stores.js";

// ── 1. Unit tests — createIpRateLimiter behaviour ─────────────────────────

describe("createIpRateLimiter — registration window", () => {
function buildApp(max: number, windowMs: number, clockFn?: () => number) {
const store = new MemorySlidingWindowStore(clockFn);
const app = express();
app.use(createIpRateLimiter(store, "register", max, windowMs, "register_ip"));
app.post("/publishers", (_req, res) => res.status(201).json({ ok: true }));
return app;
}

it("allows requests up to the limit and sets RateLimit-* headers", async () => {
const app = buildApp(3, 60_000);

const r1 = await request(app).post("/publishers").send({});
expect(r1.status).toBe(201);
expect(r1.headers["ratelimit-limit"]).toBe("3");
expect(Number(r1.headers["ratelimit-remaining"])).toBe(2);

const r2 = await request(app).post("/publishers").send({});
expect(r2.status).toBe(201);
expect(Number(r2.headers["ratelimit-remaining"])).toBe(1);

const r3 = await request(app).post("/publishers").send({});
expect(r3.status).toBe(201);
expect(Number(r3.headers["ratelimit-remaining"])).toBe(0);
});

it("returns 429 after the limit is exceeded", async () => {
const app = buildApp(2, 60_000);

await request(app).post("/publishers").send({}); // 1 — ok
await request(app).post("/publishers").send({}); // 2 — ok (limit reached)

const res = await request(app).post("/publishers").send({}); // 3 — blocked
expect(res.status).toBe(429);
expect(res.body).toMatchObject({
error: "Too many requests",
code: RATE_LIMITED,
retryAfterSeconds: expect.any(Number),
});
});

it("429 response includes Retry-After and RateLimit-Remaining=0 headers", async () => {
const app = buildApp(1, 60_000);

await request(app).post("/publishers").send({});
const res = await request(app).post("/publishers").send({});

expect(res.status).toBe(429);
expect(Number(res.headers["retry-after"])).toBeGreaterThan(0);
expect(res.headers["ratelimit-remaining"]).toBe("0");
expect(Number(res.headers["ratelimit-reset"])).toBeGreaterThan(0);
});

it("resets the counter after the window elapses", async () => {
let ts = 0;
const windowMs = 60_000;
const app = buildApp(1, windowMs, () => ts);

const r1 = await request(app).post("/publishers").send({});
expect(r1.status).toBe(201);

const blocked = await request(app).post("/publishers").send({});
expect(blocked.status).toBe(429);

// Advance clock past the window — the old request drops out of the
// sliding window, so the limit resets.
ts += windowMs + 1;

const r3 = await request(app).post("/publishers").send({});
expect(r3.status).toBe(201);
});

it("tracks counters per IP independently (independent stores)", async () => {
// Confirm that two independent stores share no state (each represents a
// different server instance or IP namespace in this test).
const store1 = new MemorySlidingWindowStore();
const app1 = express();
app1.use(createIpRateLimiter(store1, "register", 1, 60_000, "register_ip"));
app1.post("/publishers", (_req, res) => res.status(201).json({ ok: true }));

const store2 = new MemorySlidingWindowStore();
const app2 = express();
app2.use(createIpRateLimiter(store2, "register", 1, 60_000, "register_ip"));
app2.post("/publishers", (_req, res) => res.status(201).json({ ok: true }));

// Exhaust app1.
await request(app1).post("/publishers").send({});
const blocked = await request(app1).post("/publishers").send({});
expect(blocked.status).toBe(429);

// app2 has its own store — still has capacity.
const ok = await request(app2).post("/publishers").send({});
expect(ok.status).toBe(201);
});
});

// ── 2. Integration test — publishers router wires the limiter ──────────────
//
// Replace rateLimiters with a controlled implementation so we can verify the
// real route returns 429 when registerIpRateLimit fires.

vi.mock("../db/schema.js", () => ({
publishers: { __table: "publishers" },
resources: { __table: "resources" },
payments: { __table: "payments" },
}));

vi.mock("../db/client.js", () => ({
db: {
select: () => ({ from: () => ({ where: () => Promise.resolve([]) }) }),
},
}));

vi.mock("../config.js", () => ({
config: {
BASE_URL: "http://localhost:4021",
PUBLISHER_RATE_LIMIT_RPM_DEFAULT: 60,
PUBLISHER_RATE_LIMIT_MAX_RPM: 10_000,
PUBLISHER_RATE_LIMIT_WINDOW_MS: 60_000,
RATE_LIMIT_REGISTER_IP_MAX: 10,
RATE_LIMIT_REGISTER_IP_WINDOW_MS: 3_600_000,
},
}));

vi.mock("../services/publisherService.js", () => ({
registerPublisher: vi.fn().mockResolvedValue({
publisher: {
id: "pub-1",
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
createdAt: new Date("2026-01-01"),
},
apiKey: "mv_freshkey",
}),
getPublisherResources: vi.fn().mockResolvedValue([]),
rotateApiKey: vi.fn().mockResolvedValue("mv_newkey"),
}));

// Build the registerIpRateLimit inside the factory (no outer-scope variables).
vi.mock("../middleware/rateLimiters.js", async () => {
const { createIpRateLimiter: factory } = await import("../middleware/rateLimit.js");
const { MemorySlidingWindowStore: Store } = await import("../lib/rateLimit/stores.js");
// Limit of 2 so the integration test triggers 429 after two requests.
const integrationStore = new Store();
return {
registerIpRateLimit: factory(integrationStore, "register", 2, 60_000, "register_ip"),
publisherRateLimit: (_req: unknown, _res: unknown, next: () => void) => next(),
};
});

import publisherRouter from "./publishers.js";
import { errorHandler } from "../middleware/errorHandler.js";

function createIntegrationApp() {
const app = express();
app.use(express.json());
app.use(publisherRouter);
app.use(errorHandler);
return app;
}

const validBody = {
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
};

describe("POST /publishers — registerIpRateLimit wired into route (#38)", () => {
it("returns 429 once the per-IP limit is reached on the real route", async () => {
const app = createIntegrationApp();

await request(app).post("/publishers").send(validBody); // 1 — ok
await request(app).post("/publishers").send(validBody); // 2 — ok (limit reached)

const res = await request(app).post("/publishers").send(validBody); // 3 — blocked
expect(res.status).toBe(429);
expect(res.body.code).toBe(RATE_LIMITED);
});
});
23 changes: 13 additions & 10 deletions src/routes/publishers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,16 @@ describe("POST /publishers — registration (#294)", () => {
mockRegisterPublisher.mockReset();
});

// A real Stellar ed25519 public key (G..., 56 chars) so the schema passes.
const VALID_WALLET = "GDEJJLXV2CNHF5HEGX34MU4G6L4PRVUAGD4ANF77ZDYTQJKSDSDYVVI6";

it("registers a publisher and returns the one-time API key", async () => {
mockRegisterPublisher.mockResolvedValue({
publisher: {
id: "pub-1",
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
walletAddress: VALID_WALLET,
createdAt: new Date("2026-01-01"),
},
apiKey: "mv_freshkey",
Expand All @@ -111,29 +114,29 @@ describe("POST /publishers — registration (#294)", () => {
const res = await request(createTestApp()).post("/publishers").send({
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
walletAddress: VALID_WALLET,
});

expect(res.status).toBe(201);
expect(res.body).toMatchObject({
id: "pub-1",
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
walletAddress: VALID_WALLET,
apiKey: "mv_freshkey",
});
expect(mockRegisterPublisher).toHaveBeenCalledWith({
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
walletAddress: VALID_WALLET,
});
});

it("returns 400 for an invalid email", async () => {
const res = await request(createTestApp()).post("/publishers").send({
name: "Alice",
email: "not-an-email",
walletAddress: "GALICE",
walletAddress: VALID_WALLET,
});

expect(res.status).toBe(400);
Expand All @@ -143,7 +146,7 @@ describe("POST /publishers — registration (#294)", () => {
it("returns 400 when a required field is missing", async () => {
const res = await request(createTestApp()).post("/publishers").send({
email: "alice@example.com",
walletAddress: "GALICE",
walletAddress: VALID_WALLET,
});

expect(res.status).toBe(400);
Expand All @@ -154,7 +157,7 @@ describe("POST /publishers — registration (#294)", () => {
const res = await request(createTestApp()).post("/publishers").send({
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
walletAddress: VALID_WALLET,
isAdmin: true,
});

Expand All @@ -170,7 +173,7 @@ describe("POST /publishers — registration (#294)", () => {
const res = await request(createTestApp()).post("/publishers").send({
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
walletAddress: VALID_WALLET,
});

expect(res.status).toBe(409);
Expand All @@ -187,7 +190,7 @@ describe("POST /publishers — registration (#294)", () => {
const res = await request(createTestApp()).post("/publishers").send({
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
walletAddress: VALID_WALLET,
});

expect(res.status).toBe(409);
Expand All @@ -200,7 +203,7 @@ describe("POST /publishers — registration (#294)", () => {
const res = await request(createTestApp()).post("/publishers").send({
name: "Alice",
email: "alice@example.com",
walletAddress: "GALICE",
walletAddress: VALID_WALLET,
});

expect(res.status).toBe(500);
Expand Down
4 changes: 2 additions & 2 deletions src/routes/publishers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Router, type Router as RouterType } from "express";
import type { z } from "zod/v4";
import { AppError } from "../lib/errors.js";
import { apiKeyAuth } from "../middleware/apiKeyAuth.js";
import { publisherRateLimit } from "../middleware/rateLimiters.js";
import { publisherRateLimit, registerIpRateLimit } from "../middleware/rateLimiters.js";
import { validate } from "../middleware/validate.js";
import {
publisherRegisterSchema,
Expand All @@ -21,7 +21,7 @@ const router: RouterType = Router();
type UpdateWebhookConfig = z.infer<typeof updateWebhookConfigSchema>;

// POST /publishers — register a new publisher (public)
router.post("/publishers", validate(publisherRegisterSchema), async (req, res) => {
router.post("/publishers", registerIpRateLimit, validate(publisherRegisterSchema), async (req, res) => {
try {
const { publisher, apiKey } = await registerPublisher(req.body);
res.status(201).json({
Expand Down
1 change: 1 addition & 0 deletions src/routes/publishers.webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ vi.mock("../middleware/apiKeyAuth.js", () => ({

vi.mock("../middleware/rateLimiters.js", () => ({
publisherRateLimit: (_req: any, _res: any, next: any) => next(),
registerIpRateLimit: (_req: any, _res: any, next: any) => next(),
}));

vi.mock("../services/publisherService.js", () => ({
Expand Down