Skip to content

Commit a4be956

Browse files
authored
Feat/rate limiting (#950)
* feat: higher pool * feat: rate limiting
1 parent 9a85592 commit a4be956

5 files changed

Lines changed: 125 additions & 11 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ NEO4J_PASSWORD=your-neo4j-password
55

66
PUBLIC_EVAULT_SERVER_URI=http://localhost:4000
77

8+
# Rate limiting (requests per minute)
9+
RATE_LIMIT_PER_PLATFORM=250
10+
RATE_LIMIT_PER_IP=500
11+
812
REGISTRY_ENTROPY_KEY_JWK='{"kty":"EC","use":"sig","alg":"ES256","kid":"entropy-key-1","crv":"P-256","x":"your-x-value","y":"your-y-value","d":"your-d-value"}'
913
ENCRYPTION_PASSWORD="your-encryption-password"
1014
W3ID="@your-w3id"

infrastructure/evault-core/src/core/db/db.service.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1289,20 +1289,13 @@ export class DbService {
12891289
: "AND m.id < $cursorId";
12901290
}
12911291

1292-
// Get total count (without pagination)
1292+
// Run count + main query in a single session to reduce pool pressure
12931293
const countQuery = `
12941294
MATCH (m:MetaEnvelope)
12951295
WHERE ${conditions.join(" AND ")}
12961296
${searchCondition}
12971297
RETURN count(m) AS total
12981298
`;
1299-
const countResult = await this.runQueryInternal(countQuery, params);
1300-
const totalCount =
1301-
countResult.records[0]?.get("total")?.toNumber?.() ??
1302-
countResult.records[0]?.get("total") ??
1303-
0;
1304-
1305-
// Build main query with pagination
13061299
const orderDirection = isBackward ? "DESC" : "ASC";
13071300
const mainQuery = `
13081301
MATCH (m:MetaEnvelope)
@@ -1315,9 +1308,22 @@ export class DbService {
13151308
MATCH (m)-[:LINKS_TO]->(e:Envelope)
13161309
RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(e) AS envelopes
13171310
`;
1318-
13191311
params.limitPlusOne = neo4j.int(limit + 1);
1320-
const result = await this.runQueryInternal(mainQuery, params);
1312+
1313+
const session = this.driver.session();
1314+
let countResult;
1315+
let result;
1316+
try {
1317+
countResult = await session.run(countQuery, params);
1318+
result = await session.run(mainQuery, params);
1319+
} finally {
1320+
await session.close();
1321+
}
1322+
1323+
const totalCount =
1324+
countResult.records[0]?.get("total")?.toNumber?.() ??
1325+
countResult.records[0]?.get("total") ??
1326+
0;
13211327

13221328
// Process results
13231329
let records = result.records;

infrastructure/evault-core/src/core/db/retry-neo4j.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ export async function connectWithRetry(
2323
const driver = neo4j.driver(
2424
uri,
2525
neo4j.auth.basic(user, password),
26-
{ encrypted: "ENCRYPTION_OFF" }, // or { encrypted: false }
26+
{
27+
encrypted: "ENCRYPTION_OFF",
28+
maxConnectionPoolSize: 300,
29+
connectionAcquisitionTimeout: 120_000,
30+
maxConnectionLifetime: 30 * 60 * 1000,
31+
connectionTimeout: 30_000,
32+
},
2733
);
2834
await driver.getServerInfo();
2935
console.log("Connected to Neo4j!");
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { decodeJwt } from "jose";
2+
3+
const WINDOW_MS = 60_000; // 1-minute window
4+
const MAX_REQUESTS_PER_PLATFORM =
5+
Number(process.env.RATE_LIMIT_PER_PLATFORM) || 250;
6+
const MAX_REQUESTS_PER_IP = Number(process.env.RATE_LIMIT_PER_IP) || 500;
7+
8+
interface RateRecord {
9+
count: number;
10+
windowStart: number;
11+
}
12+
13+
const platformRecords = new Map<string, RateRecord>();
14+
const ipRecords = new Map<string, RateRecord>();
15+
16+
function check(
17+
map: Map<string, RateRecord>,
18+
key: string,
19+
limit: number,
20+
): { allowed: boolean; retryAfterSeconds: number } {
21+
const now = Date.now();
22+
let rec = map.get(key);
23+
24+
if (!rec || now - rec.windowStart > WINDOW_MS) {
25+
rec = { count: 0, windowStart: now };
26+
map.set(key, rec);
27+
}
28+
29+
rec.count++;
30+
31+
if (rec.count > limit) {
32+
const retryAfterSeconds = Math.ceil(
33+
(rec.windowStart + WINDOW_MS - now) / 1000,
34+
);
35+
return { allowed: false, retryAfterSeconds };
36+
}
37+
38+
return { allowed: true, retryAfterSeconds: 0 };
39+
}
40+
41+
function extractPlatform(token: string): string | null {
42+
try {
43+
const payload = decodeJwt(token);
44+
return (payload as any).platform ?? null;
45+
} catch {
46+
return null;
47+
}
48+
}
49+
50+
export function checkGlobalRateLimit(
51+
token: string | null,
52+
ip: string,
53+
): { allowed: boolean; retryAfterSeconds: number } {
54+
if (token) {
55+
const platform = extractPlatform(token);
56+
if (platform) {
57+
const result = check(
58+
platformRecords,
59+
platform,
60+
MAX_REQUESTS_PER_PLATFORM,
61+
);
62+
if (!result.allowed) return result;
63+
}
64+
}
65+
66+
return check(ipRecords, ip, MAX_REQUESTS_PER_IP);
67+
}
68+
69+
// Periodically clean up stale entries to prevent memory growth
70+
setInterval(() => {
71+
const now = Date.now();
72+
for (const [key, rec] of platformRecords) {
73+
if (now - rec.windowStart > WINDOW_MS) platformRecords.delete(key);
74+
}
75+
for (const [key, rec] of ipRecords) {
76+
if (now - rec.windowStart > WINDOW_MS) ipRecords.delete(key);
77+
}
78+
}, 60_000);

infrastructure/evault-core/src/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ProvisioningService } from "./services/ProvisioningService";
1313
import { VerificationService } from "./services/VerificationService";
1414
import { createHmacSignature } from "./utils/hmac";
1515

16+
import { checkGlobalRateLimit } from "./core/http/global-rate-limiter";
1617
import fastifyCors from "@fastify/cors";
1718
import fastify, {
1819
type FastifyInstance,
@@ -176,6 +177,25 @@ const initializeEVault = async (
176177
credentials: true,
177178
});
178179

180+
// Global rate limiting by platform token identity (IP fallback)
181+
fastifyServer.addHook("onRequest", async (request, reply) => {
182+
const authHeader = request.headers.authorization;
183+
const token = authHeader?.startsWith("Bearer ")
184+
? authHeader.substring(7)
185+
: null;
186+
const ip = request.ip;
187+
const { allowed, retryAfterSeconds } = checkGlobalRateLimit(token, ip);
188+
if (!allowed) {
189+
reply
190+
.code(429)
191+
.header("Retry-After", String(retryAfterSeconds))
192+
.send({
193+
error: "Too Many Requests",
194+
retryAfterSeconds,
195+
});
196+
}
197+
});
198+
179199
// Register HTTP routes with provisioning service if available
180200
await registerHttpRoutes(
181201
fastifyServer,

0 commit comments

Comments
 (0)