Skip to content

Commit 1520d46

Browse files
Merge PR #745 (admin, -X theirs)
2 parents c92c15f + 5a1d555 commit 1520d46

6 files changed

Lines changed: 201 additions & 6 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ ANON_RATE_LIMIT_MAX=60
111111
# Token-bucket refill window in milliseconds (default: 60000)
112112
# INVITES_RATE_LIMIT_WINDOW_MS=60000
113113

114+
# ── Exports rate limiting (per user, token bucket) ──────────
115+
116+
# Max number of consecutive /api/exports requests allowed in the window (default: 60)
117+
# EXPORTS_RATE_LIMIT_CAPACITY=60
118+
119+
# Token-bucket refill window in milliseconds (default: 60000)
120+
# EXPORTS_RATE_LIMIT_WINDOW_MS=60000
121+
114122
# ── Login IP rate limiting ────────────────────────────────────────────────────
115123

116124
# Sliding window for IP-based login rate limiting (ms)

src/config/env-schema.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,9 @@ const baseSchema = z.object({
8585
INVITES_RATE_LIMIT_CAPACITY: z.coerce.number().int().positive().default(60),
8686
INVITES_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
8787

88-
// ── Fingerprint rate limiting (per user, token bucket) ────────
89-
FINGERPRINT_RATE_LIMIT_CAPACITY: z.coerce.number().int().positive().default(60),
90-
FINGERPRINT_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
88+
// ── Exports rate limiting (per user, token bucket) ────────
89+
EXPORTS_RATE_LIMIT_CAPACITY: z.coerce.number().int().positive().default(60),
90+
EXPORTS_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
9191

9292
// ── Settle confirmer ──────────────────────────────────────
9393
SETTLE_CONFIRMER_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(5_000),

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { adminRateLimitInspectRouter } from "./routes/admin/rate-limit/inspect";
7373
import { quotaRequestsRouter } from "./routes/quota/requests";
7474
import { startSlowQueryAlerter } from "./workers/slowQueryAlerter";
7575
import { reportsRouter } from "./routes/reports";
76+
import { exportsRouter } from "./routes/exports";
7677
import { fingerprintRouter } from "./routes/fingerprint";
7778
import { alertsRouter } from "./routes/alerts";
7879
import { gracefulShutdown } from "./lifecycle/shutdown";
@@ -218,6 +219,7 @@ export function createApp(_options: CreateAppOptions = {}): express.Express {
218219
app.use("/api/admin/schema-versions", adminSchemaVersionsRouter);
219220
app.use("/api/admin/rate-limit", adminRateLimitInspectRouter);
220221
app.use("/api/reports", reportsRouter);
222+
app.use("/api/exports", exportsRouter);
221223
app.use("/api/fingerprint", fingerprintRouter);
222224
app.use("/api/alerts", alertsRouter);
223225
app.use("/api/referrals", referralsRouter);

src/routes/devicesRevoke.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Router } from "express";
2+
import { z } from "zod";
23
import { and, eq, isNull } from "drizzle-orm";
34
import { db } from "../db";
45
import { refreshTokens } from "../db/schema";
@@ -8,7 +9,7 @@ import { logger } from "../config/logger";
89
import { RouteErrorFactory } from "../errors";
910
import { securityHeaders } from "../middleware/securityHeaders";
1011

11-
const paramsSchema = z.object({ id: z.string().uuid({ message: "invalid device id" }) });
12+
const deviceIdParamSchema = z.object({ id: z.string().uuid({ message: "invalid device id" }) });
1213

1314
export const devicesRevokeRouter = Router({ mergeParams: true });
1415

src/routes/exports.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Router, type Request, type Response, type NextFunction } from "express"
1616
import { requireAuth } from "../middleware/requireAuth";
1717
import { createPerUserTokenBucketLimiter } from "../middleware/rateLimit";
1818
import { exportsPredictionsRouter } from "./exports/predictions";
19+
import { env } from "../config/env";
1920
import { logger } from "../config/logger";
2021

2122
// In-flight request tracking for graceful shutdown drain
@@ -77,8 +78,8 @@ export function createExportsRouter(options: ExportsRouterOptions = {}): Router
7778
router.use(requireAuth);
7879
router.use(
7980
createPerUserTokenBucketLimiter({
80-
capacity: options.rateLimit?.capacity ?? 60,
81-
refillWindowMs: options.rateLimit?.refillWindowMs ?? 60 * 1000,
81+
capacity: options.rateLimit?.capacity ?? env.EXPORTS_RATE_LIMIT_CAPACITY,
82+
refillWindowMs: options.rateLimit?.refillWindowMs ?? env.EXPORTS_RATE_LIMIT_WINDOW_MS,
8283
}),
8384
);
8485

tests/exports.rateLimit.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
process.env.NODE_ENV = "test";
2+
process.env.LOG_LEVEL = "silent";
3+
process.env.DATABASE_URL = "postgres://localhost/test";
4+
process.env.JWT_SECRET = "a-very-long-test-secret-at-least-32-bytes!!";
5+
process.env.JWT_ISSUER = "predictify";
6+
process.env.JWT_AUDIENCE = "predictify-app";
7+
8+
jest.mock("pg", () => {
9+
const Pool = jest.fn().mockImplementation(() => ({
10+
connect: jest.fn(),
11+
query: jest.fn(),
12+
end: jest.fn(),
13+
}));
14+
return { Pool };
15+
});
16+
17+
const mockValues = jest.fn().mockResolvedValue(undefined);
18+
const mockInsert = jest.fn(() => ({ values: mockValues }));
19+
const mockLimit = jest.fn();
20+
const mockOffset = jest.fn();
21+
22+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
23+
const queryBuilder: any = {};
24+
queryBuilder.from = jest.fn().mockReturnValue(queryBuilder);
25+
queryBuilder.where = jest.fn().mockReturnValue(queryBuilder);
26+
queryBuilder.orderBy = jest.fn().mockReturnValue(queryBuilder);
27+
queryBuilder.limit = jest.fn().mockImplementation((val) => {
28+
if (val === 1) {
29+
return mockLimit();
30+
}
31+
return queryBuilder;
32+
});
33+
queryBuilder.offset = jest.fn().mockImplementation((val) => mockOffset(val));
34+
35+
const mockSelect = jest.fn().mockReturnValue(queryBuilder);
36+
37+
const mockDb = {
38+
select: mockSelect,
39+
insert: mockInsert,
40+
};
41+
42+
jest.mock("drizzle-orm/node-postgres", () => ({
43+
drizzle: jest.fn(() => mockDb),
44+
}));
45+
46+
import request from "supertest";
47+
import jwt from "jsonwebtoken";
48+
import express from "express";
49+
import { createExportsRouter } from "../src/routes/exports";
50+
import { errorHandler } from "../src/middleware/errorHandler";
51+
52+
const TEST_SECRET = "a-very-long-test-secret-at-least-32-bytes!!";
53+
const TEST_ISSUER = "predictify";
54+
const TEST_AUDIENCE = "predictify-app";
55+
const TEST_USER_ID = "11111111-1111-1111-1111-111111111111";
56+
const TEST_STELLAR = "GABC1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF12";
57+
58+
function signToken(_userId = TEST_USER_ID, stellarAddress = TEST_STELLAR): string {
59+
return jwt.sign({ sub: stellarAddress }, TEST_SECRET, {
60+
algorithm: "HS256",
61+
issuer: TEST_ISSUER,
62+
audience: TEST_AUDIENCE,
63+
expiresIn: 3600,
64+
});
65+
}
66+
67+
describe("Rate limiting on /api/exports", () => {
68+
const RATE_LIMIT_CAPACITY = 2;
69+
70+
function makeApp() {
71+
const app = express();
72+
app.use(express.json());
73+
app.use(
74+
"/api/exports",
75+
createExportsRouter({ rateLimit: { capacity: RATE_LIMIT_CAPACITY, refillWindowMs: 60000 } }),
76+
);
77+
app.use(errorHandler);
78+
return app;
79+
}
80+
81+
beforeEach(() => {
82+
jest.clearAllMocks();
83+
mockLimit.mockReset();
84+
mockOffset.mockReset();
85+
});
86+
87+
it("allows requests up to the token bucket capacity", async () => {
88+
mockLimit.mockResolvedValue([{ id: TEST_USER_ID, stellarAddress: TEST_STELLAR }]);
89+
mockOffset.mockResolvedValue([]);
90+
const app = makeApp();
91+
92+
for (let i = 0; i < RATE_LIMIT_CAPACITY; i++) {
93+
const res = await request(app)
94+
.get("/api/exports/predictions?format=json")
95+
.set("Authorization", `Bearer ${signToken()}`);
96+
97+
expect(res.status).toBe(200);
98+
expect(Number(res.headers["ratelimit-remaining"])).toBeGreaterThanOrEqual(0);
99+
}
100+
});
101+
102+
it("returns 429 with Retry-After when token bucket is exhausted", async () => {
103+
mockLimit.mockResolvedValue([{ id: TEST_USER_ID, stellarAddress: TEST_STELLAR }]);
104+
mockOffset.mockResolvedValue([]);
105+
const app = makeApp();
106+
107+
for (let i = 0; i < RATE_LIMIT_CAPACITY; i++) {
108+
await request(app)
109+
.get("/api/exports/predictions?format=json")
110+
.set("Authorization", `Bearer ${signToken()}`);
111+
}
112+
113+
const res = await request(app)
114+
.get("/api/exports/predictions?format=json")
115+
.set("Authorization", `Bearer ${signToken()}`);
116+
117+
expect(res.status).toBe(429);
118+
expect(res.body.error.code).toBe("rate_limit_exceeded");
119+
expect(res.body.error.message).toBe("Too many requests");
120+
expect(res.body.error.retryAfter).toBeDefined();
121+
expect(typeof res.body.error.retryAfter).toBe("number");
122+
expect(res.body.error.retryAfter).toBeGreaterThan(0);
123+
expect(res.body.error.resetAt).toBeDefined();
124+
expect(res.headers["retry-after"]).toBeDefined();
125+
expect(Number(res.headers["retry-after"])).toBeGreaterThan(0);
126+
expect(res.headers["ratelimit-remaining"]).toBe("0");
127+
});
128+
129+
it("returns 429 with proper error envelope", async () => {
130+
mockLimit.mockResolvedValue([{ id: TEST_USER_ID, stellarAddress: TEST_STELLAR }]);
131+
mockOffset.mockResolvedValue([]);
132+
const app = makeApp();
133+
134+
for (let i = 0; i < RATE_LIMIT_CAPACITY; i++) {
135+
await request(app)
136+
.get("/api/exports/predictions?format=json")
137+
.set("Authorization", `Bearer ${signToken()}`);
138+
}
139+
140+
const res = await request(app)
141+
.get("/api/exports/predictions?format=json")
142+
.set("Authorization", `Bearer ${signToken()}`);
143+
144+
expect(res.status).toBe(429);
145+
expect(res.body).toMatchObject({
146+
error: {
147+
code: "rate_limit_exceeded",
148+
message: "Too many requests",
149+
},
150+
});
151+
expect(typeof res.body.error.retryAfter).toBe("number");
152+
expect(typeof res.body.error.resetAt).toBe("string");
153+
});
154+
155+
it("allows requests from a different user after first user is rate limited", async () => {
156+
mockLimit.mockResolvedValue([{ id: TEST_USER_ID, stellarAddress: TEST_STELLAR }]);
157+
mockOffset.mockResolvedValue([]);
158+
const app = makeApp();
159+
160+
for (let i = 0; i < RATE_LIMIT_CAPACITY; i++) {
161+
await request(app)
162+
.get("/api/exports/predictions?format=json")
163+
.set("Authorization", `Bearer ${signToken()}`);
164+
}
165+
166+
const firstUserRes = await request(app)
167+
.get("/api/exports/predictions?format=json")
168+
.set("Authorization", `Bearer ${signToken()}`);
169+
expect(firstUserRes.status).toBe(429);
170+
171+
const secondStellar = "GDEF9876543210ABCDEF9876543210ABCDEF9876543210ABCDEF98";
172+
const secondUserId = "22222222-2222-2222-2222-222222222222";
173+
174+
mockLimit.mockResolvedValue([{ id: secondUserId, stellarAddress: secondStellar }]);
175+
176+
const secondUserRes = await request(app)
177+
.get("/api/exports/predictions?format=json")
178+
.set("Authorization", `Bearer ${signToken(secondUserId, secondStellar)}`);
179+
180+
expect(secondUserRes.status).toBe(200);
181+
expect(Number(secondUserRes.headers["ratelimit-remaining"])).toBe(RATE_LIMIT_CAPACITY - 1);
182+
});
183+
});

0 commit comments

Comments
 (0)