forked from pyrimid-ai/pyrimid
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmiddleware.ts
More file actions
92 lines (77 loc) · 3.01 KB
/
middleware.ts
File metadata and controls
92 lines (77 loc) · 3.01 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { NextRequest, NextResponse } from 'next/server';
// ═══════════════════════════════════════════════════════════
// RATE LIMITER — In-memory sliding window per IP
// Vercel serverless: each instance has its own map, so this
// is best-effort (not globally synchronized). Good enough
// for single-region deployments.
// ═══════════════════════════════════════════════════════════
interface RateEntry {
count: number;
windowStart: number;
}
const rateLimits: Record<string, { maxReqs: number; windowMs: number }> = {
'/api/v1/catalog': { maxReqs: 60, windowMs: 60_000 },
'/api/v1/stats': { maxReqs: 60, windowMs: 60_000 },
'/api/mcp': { maxReqs: 120, windowMs: 60_000 },
};
const store = new Map<string, RateEntry>();
// Cleanup stale entries every 5 minutes
let lastCleanup = Date.now();
function cleanup() {
const now = Date.now();
if (now - lastCleanup < 300_000) return;
lastCleanup = now;
for (const [key, entry] of store) {
if (now - entry.windowStart > 120_000) store.delete(key);
}
}
function getClientIP(req: NextRequest): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
req.headers.get('x-real-ip') ||
'unknown'
);
}
export function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
// Only rate-limit API routes
const limitConfig = rateLimits[path];
if (!limitConfig) return NextResponse.next();
cleanup();
const ip = getClientIP(req);
const key = `${ip}:${path}`;
const now = Date.now();
let entry = store.get(key);
if (!entry || now - entry.windowStart > limitConfig.windowMs) {
entry = { count: 0, windowStart: now };
store.set(key, entry);
}
entry.count++;
if (entry.count > limitConfig.maxReqs) {
const retryAfter = Math.ceil((entry.windowStart + limitConfig.windowMs - now) / 1000);
return NextResponse.json(
{
error: 'rate_limit_exceeded',
message: `Too many requests. Limit: ${limitConfig.maxReqs} per ${limitConfig.windowMs / 1000}s. Retry after ${retryAfter}s.`,
retry_after: retryAfter,
},
{
status: 429,
headers: {
'Retry-After': String(retryAfter),
'X-RateLimit-Limit': String(limitConfig.maxReqs),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(Math.ceil((entry.windowStart + limitConfig.windowMs) / 1000)),
},
}
);
}
const response = NextResponse.next();
response.headers.set('X-RateLimit-Limit', String(limitConfig.maxReqs));
response.headers.set('X-RateLimit-Remaining', String(limitConfig.maxReqs - entry.count));
response.headers.set('X-RateLimit-Reset', String(Math.ceil((entry.windowStart + limitConfig.windowMs) / 1000)));
return response;
}
export const config = {
matcher: ['/api/v1/:path*', '/api/mcp'],
};