@frontmcp/guard 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +395 -0
- package/concurrency/index.d.ts +2 -0
- package/concurrency/semaphore.d.ts +26 -0
- package/concurrency/types.d.ts +22 -0
- package/errors/errors.d.ts +54 -0
- package/errors/index.d.ts +1 -0
- package/esm/index.mjs +554 -0
- package/esm/package.json +61 -0
- package/index.d.ts +14 -0
- package/index.js +600 -0
- package/ip-filter/index.d.ts +2 -0
- package/ip-filter/ip-filter.d.ts +21 -0
- package/ip-filter/types.d.ts +29 -0
- package/manager/guard.factory.d.ts +15 -0
- package/manager/guard.manager.d.ts +47 -0
- package/manager/index.d.ts +3 -0
- package/manager/types.d.ts +45 -0
- package/package.json +61 -0
- package/partition-key/index.d.ts +2 -0
- package/partition-key/partition-key.resolver.d.ts +14 -0
- package/partition-key/types.d.ts +27 -0
- package/rate-limit/index.d.ts +2 -0
- package/rate-limit/rate-limiter.d.ts +25 -0
- package/rate-limit/types.d.ts +25 -0
- package/schemas/index.d.ts +1 -0
- package/schemas/schemas.d.ts +157 -0
- package/timeout/index.d.ts +2 -0
- package/timeout/timeout.d.ts +9 -0
- package/timeout/types.d.ts +10 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard Error Classes
|
|
3
|
+
*
|
|
4
|
+
* Standalone error hierarchy — no dependency on SDK error classes.
|
|
5
|
+
* Consumers (e.g., @frontmcp/sdk) can catch GuardError and re-throw
|
|
6
|
+
* as protocol-specific errors if needed.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Base error class for all guard errors.
|
|
10
|
+
* Carries a machine-readable code and HTTP status code.
|
|
11
|
+
*/
|
|
12
|
+
export declare class GuardError extends Error {
|
|
13
|
+
readonly code: string;
|
|
14
|
+
readonly statusCode: number;
|
|
15
|
+
constructor(message: string, code: string, statusCode: number);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Thrown when execution exceeds its configured timeout.
|
|
19
|
+
*/
|
|
20
|
+
export declare class ExecutionTimeoutError extends GuardError {
|
|
21
|
+
readonly entityName: string;
|
|
22
|
+
readonly timeoutMs: number;
|
|
23
|
+
constructor(entityName: string, timeoutMs: number);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Thrown when a concurrency limit is reached.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ConcurrencyLimitError extends GuardError {
|
|
29
|
+
readonly entityName: string;
|
|
30
|
+
readonly maxConcurrent: number;
|
|
31
|
+
constructor(entityName: string, maxConcurrent: number);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Thrown when a request waited in the concurrency queue but timed out.
|
|
35
|
+
*/
|
|
36
|
+
export declare class QueueTimeoutError extends GuardError {
|
|
37
|
+
readonly entityName: string;
|
|
38
|
+
readonly queueTimeoutMs: number;
|
|
39
|
+
constructor(entityName: string, queueTimeoutMs: number);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Thrown when a client IP is on the deny list.
|
|
43
|
+
*/
|
|
44
|
+
export declare class IpBlockedError extends GuardError {
|
|
45
|
+
readonly clientIp: string;
|
|
46
|
+
constructor(clientIp: string);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Thrown when a client IP is not on the allow list (when default action is deny).
|
|
50
|
+
*/
|
|
51
|
+
export declare class IpNotAllowedError extends GuardError {
|
|
52
|
+
readonly clientIp: string;
|
|
53
|
+
constructor(clientIp: string);
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GuardError, ExecutionTimeoutError, ConcurrencyLimitError, QueueTimeoutError, IpBlockedError, IpNotAllowedError, } from './errors';
|
package/esm/index.mjs
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
// libs/guard/src/errors/errors.ts
|
|
2
|
+
var GuardError = class extends Error {
|
|
3
|
+
constructor(message, code, statusCode) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = this.constructor.name;
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.statusCode = statusCode;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var ExecutionTimeoutError = class extends GuardError {
|
|
11
|
+
constructor(entityName, timeoutMs) {
|
|
12
|
+
super(`Execution of "${entityName}" timed out after ${timeoutMs}ms`, "EXECUTION_TIMEOUT", 408);
|
|
13
|
+
this.entityName = entityName;
|
|
14
|
+
this.timeoutMs = timeoutMs;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var ConcurrencyLimitError = class extends GuardError {
|
|
18
|
+
constructor(entityName, maxConcurrent) {
|
|
19
|
+
super(`Concurrency limit reached for "${entityName}" (max: ${maxConcurrent})`, "CONCURRENCY_LIMIT", 429);
|
|
20
|
+
this.entityName = entityName;
|
|
21
|
+
this.maxConcurrent = maxConcurrent;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var QueueTimeoutError = class extends GuardError {
|
|
25
|
+
constructor(entityName, queueTimeoutMs) {
|
|
26
|
+
super(
|
|
27
|
+
`Queue timeout for "${entityName}" after waiting ${queueTimeoutMs}ms for a concurrency slot`,
|
|
28
|
+
"QUEUE_TIMEOUT",
|
|
29
|
+
429
|
|
30
|
+
);
|
|
31
|
+
this.entityName = entityName;
|
|
32
|
+
this.queueTimeoutMs = queueTimeoutMs;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var IpBlockedError = class extends GuardError {
|
|
36
|
+
constructor(clientIp) {
|
|
37
|
+
super(`IP address "${clientIp}" is blocked`, "IP_BLOCKED", 403);
|
|
38
|
+
this.clientIp = clientIp;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var IpNotAllowedError = class extends GuardError {
|
|
42
|
+
constructor(clientIp) {
|
|
43
|
+
super(`IP address "${clientIp}" is not allowed`, "IP_NOT_ALLOWED", 403);
|
|
44
|
+
this.clientIp = clientIp;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// libs/guard/src/schemas/schemas.ts
|
|
49
|
+
import { z } from "zod";
|
|
50
|
+
var partitionKeySchema = z.union([
|
|
51
|
+
z.enum(["ip", "session", "userId", "global"]),
|
|
52
|
+
z.custom(
|
|
53
|
+
(val) => typeof val === "function"
|
|
54
|
+
)
|
|
55
|
+
]);
|
|
56
|
+
var rateLimitConfigSchema = z.object({
|
|
57
|
+
maxRequests: z.number().int().positive(),
|
|
58
|
+
windowMs: z.number().int().positive().optional().default(6e4),
|
|
59
|
+
partitionBy: partitionKeySchema.optional().default("global")
|
|
60
|
+
});
|
|
61
|
+
var concurrencyConfigSchema = z.object({
|
|
62
|
+
maxConcurrent: z.number().int().positive(),
|
|
63
|
+
queueTimeoutMs: z.number().int().nonnegative().optional().default(0),
|
|
64
|
+
partitionBy: partitionKeySchema.optional().default("global")
|
|
65
|
+
});
|
|
66
|
+
var timeoutConfigSchema = z.object({
|
|
67
|
+
executeMs: z.number().int().positive()
|
|
68
|
+
});
|
|
69
|
+
var ipFilterConfigSchema = z.object({
|
|
70
|
+
allowList: z.array(z.string()).optional(),
|
|
71
|
+
denyList: z.array(z.string()).optional(),
|
|
72
|
+
defaultAction: z.enum(["allow", "deny"]).optional().default("allow"),
|
|
73
|
+
trustProxy: z.boolean().optional().default(false),
|
|
74
|
+
trustedProxyDepth: z.number().int().positive().optional().default(1)
|
|
75
|
+
});
|
|
76
|
+
var guardConfigSchema = z.object({
|
|
77
|
+
enabled: z.boolean(),
|
|
78
|
+
storage: z.looseObject({}).optional(),
|
|
79
|
+
keyPrefix: z.string().optional().default("mcp:guard:"),
|
|
80
|
+
global: rateLimitConfigSchema.optional(),
|
|
81
|
+
globalConcurrency: concurrencyConfigSchema.optional(),
|
|
82
|
+
defaultRateLimit: rateLimitConfigSchema.optional(),
|
|
83
|
+
defaultConcurrency: concurrencyConfigSchema.optional(),
|
|
84
|
+
defaultTimeout: timeoutConfigSchema.optional(),
|
|
85
|
+
ipFilter: ipFilterConfigSchema.optional()
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// libs/guard/src/partition-key/partition-key.resolver.ts
|
|
89
|
+
function resolvePartitionKey(partitionBy, context) {
|
|
90
|
+
if (!partitionBy || partitionBy === "global") {
|
|
91
|
+
return "global";
|
|
92
|
+
}
|
|
93
|
+
const ctx = context ?? { sessionId: "anonymous" };
|
|
94
|
+
if (typeof partitionBy === "function") {
|
|
95
|
+
return partitionBy(ctx);
|
|
96
|
+
}
|
|
97
|
+
switch (partitionBy) {
|
|
98
|
+
case "ip":
|
|
99
|
+
return ctx.clientIp ?? "unknown-ip";
|
|
100
|
+
case "session":
|
|
101
|
+
return ctx.sessionId;
|
|
102
|
+
case "userId":
|
|
103
|
+
return ctx.userId ?? ctx.sessionId;
|
|
104
|
+
default:
|
|
105
|
+
return "global";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function buildStorageKey(entityName, partitionKey, suffix) {
|
|
109
|
+
const parts = [entityName, partitionKey];
|
|
110
|
+
if (suffix) {
|
|
111
|
+
parts.push(suffix);
|
|
112
|
+
}
|
|
113
|
+
return parts.join(":");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// libs/guard/src/rate-limit/rate-limiter.ts
|
|
117
|
+
var SlidingWindowRateLimiter = class {
|
|
118
|
+
constructor(storage) {
|
|
119
|
+
this.storage = storage;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Check whether a request is allowed under the rate limit.
|
|
123
|
+
* If allowed, the counter is atomically incremented.
|
|
124
|
+
*/
|
|
125
|
+
async check(key, maxRequests, windowMs) {
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const currentWindowStart = Math.floor(now / windowMs) * windowMs;
|
|
128
|
+
const previousWindowStart = currentWindowStart - windowMs;
|
|
129
|
+
const currentKey = `${key}:${currentWindowStart}`;
|
|
130
|
+
const previousKey = `${key}:${previousWindowStart}`;
|
|
131
|
+
const [currentRaw, previousRaw] = await this.storage.mget([currentKey, previousKey]);
|
|
132
|
+
const currentCount = parseInt(currentRaw ?? "0", 10) || 0;
|
|
133
|
+
const previousCount = parseInt(previousRaw ?? "0", 10) || 0;
|
|
134
|
+
const elapsed = now - currentWindowStart;
|
|
135
|
+
const weight = 1 - elapsed / windowMs;
|
|
136
|
+
const estimatedCount = previousCount * weight + currentCount;
|
|
137
|
+
if (estimatedCount >= maxRequests) {
|
|
138
|
+
return {
|
|
139
|
+
allowed: false,
|
|
140
|
+
remaining: 0,
|
|
141
|
+
resetMs: windowMs - elapsed,
|
|
142
|
+
retryAfterMs: windowMs - elapsed
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
await this.storage.incr(currentKey);
|
|
146
|
+
const ttlSeconds = Math.ceil(windowMs * 2 / 1e3);
|
|
147
|
+
await this.storage.expire(currentKey, ttlSeconds);
|
|
148
|
+
const remaining = Math.max(0, Math.floor(maxRequests - estimatedCount - 1));
|
|
149
|
+
return {
|
|
150
|
+
allowed: true,
|
|
151
|
+
remaining,
|
|
152
|
+
resetMs: windowMs - elapsed
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Reset the rate limit counters for a key.
|
|
157
|
+
*/
|
|
158
|
+
async reset(key, windowMs) {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
const currentWindowStart = Math.floor(now / windowMs) * windowMs;
|
|
161
|
+
const previousWindowStart = currentWindowStart - windowMs;
|
|
162
|
+
await this.storage.mdelete([`${key}:${currentWindowStart}`, `${key}:${previousWindowStart}`]);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// libs/guard/src/concurrency/semaphore.ts
|
|
167
|
+
import { randomUUID } from "@frontmcp/utils";
|
|
168
|
+
var DEFAULT_TICKET_TTL_SECONDS = 300;
|
|
169
|
+
var MIN_POLL_INTERVAL_MS = 100;
|
|
170
|
+
var MAX_POLL_INTERVAL_MS = 1e3;
|
|
171
|
+
var DistributedSemaphore = class {
|
|
172
|
+
constructor(storage, ticketTtlSeconds = DEFAULT_TICKET_TTL_SECONDS) {
|
|
173
|
+
this.storage = storage;
|
|
174
|
+
this.ticketTtlSeconds = ticketTtlSeconds;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Attempt to acquire a concurrency slot.
|
|
178
|
+
*
|
|
179
|
+
* @returns A SemaphoreTicket if acquired, or null if rejected
|
|
180
|
+
* @throws QueueTimeoutError if queued and timeout expires
|
|
181
|
+
*/
|
|
182
|
+
async acquire(key, maxConcurrent, queueTimeoutMs, entityName) {
|
|
183
|
+
const ticket = await this.tryAcquire(key, maxConcurrent);
|
|
184
|
+
if (ticket) return ticket;
|
|
185
|
+
if (queueTimeoutMs <= 0) return null;
|
|
186
|
+
return this.waitForSlot(key, maxConcurrent, queueTimeoutMs, entityName);
|
|
187
|
+
}
|
|
188
|
+
async tryAcquire(key, maxConcurrent) {
|
|
189
|
+
const countKey = `${key}:count`;
|
|
190
|
+
const newCount = await this.storage.incr(countKey);
|
|
191
|
+
if (newCount <= maxConcurrent) {
|
|
192
|
+
const ticketId = randomUUID();
|
|
193
|
+
const ticketKey = `${key}:ticket:${ticketId}`;
|
|
194
|
+
await this.storage.set(ticketKey, String(Date.now()), {
|
|
195
|
+
ttlSeconds: this.ticketTtlSeconds
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
ticket: ticketId,
|
|
199
|
+
release: () => this.release(key, ticketId)
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
await this.storage.decr(countKey);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
async release(key, ticketId) {
|
|
206
|
+
const countKey = `${key}:count`;
|
|
207
|
+
const ticketKey = `${key}:ticket:${ticketId}`;
|
|
208
|
+
await this.storage.delete(ticketKey);
|
|
209
|
+
const newCount = await this.storage.decr(countKey);
|
|
210
|
+
if (newCount < 0) {
|
|
211
|
+
await this.storage.set(countKey, "0");
|
|
212
|
+
}
|
|
213
|
+
if (this.storage.supportsPubSub()) {
|
|
214
|
+
try {
|
|
215
|
+
await this.storage.publish(`${key}:released`, ticketId);
|
|
216
|
+
} catch {
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async waitForSlot(key, maxConcurrent, queueTimeoutMs, entityName) {
|
|
221
|
+
const deadline = Date.now() + queueTimeoutMs;
|
|
222
|
+
let pollInterval = MIN_POLL_INTERVAL_MS;
|
|
223
|
+
let unsubscribe;
|
|
224
|
+
let notified = false;
|
|
225
|
+
if (this.storage.supportsPubSub()) {
|
|
226
|
+
try {
|
|
227
|
+
unsubscribe = await this.storage.subscribe(`${key}:released`, () => {
|
|
228
|
+
notified = true;
|
|
229
|
+
});
|
|
230
|
+
} catch {
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
while (Date.now() < deadline) {
|
|
235
|
+
const ticket = await this.tryAcquire(key, maxConcurrent);
|
|
236
|
+
if (ticket) return ticket;
|
|
237
|
+
const remainingMs = deadline - Date.now();
|
|
238
|
+
if (remainingMs <= 0) break;
|
|
239
|
+
const waitMs = Math.min(pollInterval, remainingMs);
|
|
240
|
+
if (notified) {
|
|
241
|
+
notified = false;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
await sleep(waitMs);
|
|
245
|
+
pollInterval = Math.min(pollInterval * 2, MAX_POLL_INTERVAL_MS);
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
if (unsubscribe) {
|
|
249
|
+
try {
|
|
250
|
+
await unsubscribe();
|
|
251
|
+
} catch {
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
throw new QueueTimeoutError(entityName, queueTimeoutMs);
|
|
256
|
+
}
|
|
257
|
+
async getActiveCount(key) {
|
|
258
|
+
const countKey = `${key}:count`;
|
|
259
|
+
const raw = await this.storage.get(countKey);
|
|
260
|
+
return Math.max(0, parseInt(raw ?? "0", 10) || 0);
|
|
261
|
+
}
|
|
262
|
+
async forceReset(key) {
|
|
263
|
+
const countKey = `${key}:count`;
|
|
264
|
+
await this.storage.delete(countKey);
|
|
265
|
+
const ticketKeys = await this.storage.keys(`${key}:ticket:*`);
|
|
266
|
+
if (ticketKeys.length > 0) {
|
|
267
|
+
await this.storage.mdelete(ticketKeys);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
function sleep(ms) {
|
|
272
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// libs/guard/src/timeout/timeout.ts
|
|
276
|
+
async function withTimeout(fn, timeoutMs, entityName) {
|
|
277
|
+
const controller = new AbortController();
|
|
278
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
279
|
+
try {
|
|
280
|
+
return await Promise.race([
|
|
281
|
+
fn(),
|
|
282
|
+
new Promise((_, reject) => {
|
|
283
|
+
controller.signal.addEventListener("abort", () => {
|
|
284
|
+
reject(new ExecutionTimeoutError(entityName, timeoutMs));
|
|
285
|
+
});
|
|
286
|
+
})
|
|
287
|
+
]);
|
|
288
|
+
} finally {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// libs/guard/src/ip-filter/ip-filter.ts
|
|
294
|
+
var IpFilter = class {
|
|
295
|
+
constructor(config) {
|
|
296
|
+
this.allowRules = (config.allowList ?? []).map(parseCidr);
|
|
297
|
+
this.denyRules = (config.denyList ?? []).map(parseCidr);
|
|
298
|
+
this.defaultAction = config.defaultAction ?? "allow";
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Check if a client IP is allowed.
|
|
302
|
+
*/
|
|
303
|
+
check(clientIp) {
|
|
304
|
+
const parsed = parseIp(clientIp);
|
|
305
|
+
if (parsed === null) {
|
|
306
|
+
return { allowed: this.defaultAction === "allow", reason: "default" };
|
|
307
|
+
}
|
|
308
|
+
for (const rule of this.denyRules) {
|
|
309
|
+
if (matchesCidr(parsed, rule)) {
|
|
310
|
+
return { allowed: false, reason: "denylisted", matchedRule: rule.raw };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (this.allowRules.length > 0) {
|
|
314
|
+
for (const rule of this.allowRules) {
|
|
315
|
+
if (matchesCidr(parsed, rule)) {
|
|
316
|
+
return { allowed: true, reason: "allowlisted", matchedRule: rule.raw };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (this.defaultAction === "deny") {
|
|
320
|
+
return { allowed: false, reason: "default" };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return { allowed: this.defaultAction === "allow", reason: "default" };
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Check if an IP is on the allow list (bypasses rate limiting).
|
|
327
|
+
*/
|
|
328
|
+
isAllowListed(clientIp) {
|
|
329
|
+
const parsed = parseIp(clientIp);
|
|
330
|
+
if (parsed === null) return false;
|
|
331
|
+
return this.allowRules.some((rule) => matchesCidr(parsed, rule));
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
function parseIp(ip) {
|
|
335
|
+
const trimmed = ip.trim();
|
|
336
|
+
if (trimmed.includes(".") && !trimmed.includes(":")) {
|
|
337
|
+
const value = parseIpv4(trimmed);
|
|
338
|
+
if (value === null) return null;
|
|
339
|
+
return { value, isV6: false };
|
|
340
|
+
}
|
|
341
|
+
if (trimmed.includes(":")) {
|
|
342
|
+
const value = parseIpv6(trimmed);
|
|
343
|
+
if (value === null) return null;
|
|
344
|
+
return { value, isV6: true };
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
function parseIpv4(ip) {
|
|
349
|
+
const parts = ip.split(".");
|
|
350
|
+
if (parts.length !== 4) return null;
|
|
351
|
+
let result = 0n;
|
|
352
|
+
for (const part of parts) {
|
|
353
|
+
const num = parseInt(part, 10);
|
|
354
|
+
if (isNaN(num) || num < 0 || num > 255) return null;
|
|
355
|
+
result = result << 8n | BigInt(num);
|
|
356
|
+
}
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
function parseIpv6(ip) {
|
|
360
|
+
const v4MappedMatch = ip.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
361
|
+
if (v4MappedMatch) {
|
|
362
|
+
const v4 = parseIpv4(v4MappedMatch[1]);
|
|
363
|
+
if (v4 === null) return null;
|
|
364
|
+
return 0xffff00000000n | v4;
|
|
365
|
+
}
|
|
366
|
+
let expanded = ip;
|
|
367
|
+
if (expanded.includes("::")) {
|
|
368
|
+
const halves = expanded.split("::");
|
|
369
|
+
if (halves.length > 2) return null;
|
|
370
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
371
|
+
const right = halves[1] ? halves[1].split(":") : [];
|
|
372
|
+
const missing = 8 - left.length - right.length;
|
|
373
|
+
if (missing < 0) return null;
|
|
374
|
+
const middle = Array(missing).fill("0");
|
|
375
|
+
expanded = [...left, ...middle, ...right].join(":");
|
|
376
|
+
}
|
|
377
|
+
const groups = expanded.split(":");
|
|
378
|
+
if (groups.length !== 8) return null;
|
|
379
|
+
let result = 0n;
|
|
380
|
+
for (const group of groups) {
|
|
381
|
+
const num = parseInt(group, 16);
|
|
382
|
+
if (isNaN(num) || num < 0 || num > 65535) return null;
|
|
383
|
+
result = result << 16n | BigInt(num);
|
|
384
|
+
}
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
function parseCidr(cidr) {
|
|
388
|
+
const [ipPart, prefixPart] = cidr.split("/");
|
|
389
|
+
const parsed = parseIp(ipPart);
|
|
390
|
+
if (parsed === null) {
|
|
391
|
+
return { raw: cidr, ip: 0n, mask: 0n, isV6: false, valid: false };
|
|
392
|
+
}
|
|
393
|
+
const maxBits = parsed.isV6 ? 128 : 32;
|
|
394
|
+
const prefixLen = prefixPart !== void 0 ? parseInt(prefixPart, 10) : maxBits;
|
|
395
|
+
if (isNaN(prefixLen) || prefixLen < 0 || prefixLen > maxBits) {
|
|
396
|
+
return { raw: cidr, ip: 0n, mask: 0n, isV6: parsed.isV6, valid: false };
|
|
397
|
+
}
|
|
398
|
+
const mask = prefixLen === 0 ? 0n : (1n << BigInt(maxBits)) - 1n << BigInt(maxBits - prefixLen);
|
|
399
|
+
return {
|
|
400
|
+
raw: cidr,
|
|
401
|
+
ip: parsed.value & mask,
|
|
402
|
+
mask,
|
|
403
|
+
isV6: parsed.isV6,
|
|
404
|
+
valid: true
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function matchesCidr(ip, rule) {
|
|
408
|
+
if (!rule.valid) return false;
|
|
409
|
+
if (ip.isV6 !== rule.isV6) return false;
|
|
410
|
+
return (ip.value & rule.mask) === rule.ip;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// libs/guard/src/manager/guard.manager.ts
|
|
414
|
+
var DEFAULT_WINDOW_MS = 6e4;
|
|
415
|
+
var GuardManager = class {
|
|
416
|
+
constructor(storage, config) {
|
|
417
|
+
this.storage = storage;
|
|
418
|
+
this.config = config;
|
|
419
|
+
this.rateLimiter = new SlidingWindowRateLimiter(storage);
|
|
420
|
+
this.semaphore = new DistributedSemaphore(storage);
|
|
421
|
+
if (config.ipFilter) {
|
|
422
|
+
this.ipFilter = new IpFilter(config.ipFilter);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// ============================================
|
|
426
|
+
// IP Filtering
|
|
427
|
+
// ============================================
|
|
428
|
+
/**
|
|
429
|
+
* Check if a client IP is allowed by the IP filter.
|
|
430
|
+
* Returns undefined if no IP filter is configured.
|
|
431
|
+
*/
|
|
432
|
+
checkIpFilter(clientIp) {
|
|
433
|
+
if (!this.ipFilter || !clientIp) return void 0;
|
|
434
|
+
return this.ipFilter.check(clientIp);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Check if a client IP is on the allow list (bypasses rate limiting).
|
|
438
|
+
*/
|
|
439
|
+
isIpAllowListed(clientIp) {
|
|
440
|
+
if (!this.ipFilter || !clientIp) return false;
|
|
441
|
+
return this.ipFilter.isAllowListed(clientIp);
|
|
442
|
+
}
|
|
443
|
+
// ============================================
|
|
444
|
+
// Rate Limiting
|
|
445
|
+
// ============================================
|
|
446
|
+
/**
|
|
447
|
+
* Check per-entity rate limit.
|
|
448
|
+
* Merges entity config with app-level defaults (entity takes precedence).
|
|
449
|
+
*/
|
|
450
|
+
async checkRateLimit(entityName, entityConfig, context) {
|
|
451
|
+
const config = entityConfig ?? this.config.defaultRateLimit;
|
|
452
|
+
if (!config) {
|
|
453
|
+
return { allowed: true, remaining: Infinity, resetMs: 0 };
|
|
454
|
+
}
|
|
455
|
+
const partitionKey = resolvePartitionKey(config.partitionBy, context);
|
|
456
|
+
const storageKey = buildStorageKey(entityName, partitionKey, "rl");
|
|
457
|
+
const windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
|
|
458
|
+
return this.rateLimiter.check(storageKey, config.maxRequests, windowMs);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Check global rate limit.
|
|
462
|
+
*/
|
|
463
|
+
async checkGlobalRateLimit(context) {
|
|
464
|
+
const config = this.config.global;
|
|
465
|
+
if (!config) {
|
|
466
|
+
return { allowed: true, remaining: Infinity, resetMs: 0 };
|
|
467
|
+
}
|
|
468
|
+
const partitionKey = resolvePartitionKey(config.partitionBy, context);
|
|
469
|
+
const storageKey = buildStorageKey("__global__", partitionKey, "rl");
|
|
470
|
+
const windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
|
|
471
|
+
return this.rateLimiter.check(storageKey, config.maxRequests, windowMs);
|
|
472
|
+
}
|
|
473
|
+
// ============================================
|
|
474
|
+
// Concurrency Control
|
|
475
|
+
// ============================================
|
|
476
|
+
/**
|
|
477
|
+
* Acquire a concurrency slot for an entity.
|
|
478
|
+
*/
|
|
479
|
+
async acquireSemaphore(entityName, entityConfig, context) {
|
|
480
|
+
const config = entityConfig ?? this.config.defaultConcurrency;
|
|
481
|
+
if (!config) return null;
|
|
482
|
+
const partitionKey = resolvePartitionKey(config.partitionBy, context);
|
|
483
|
+
const storageKey = buildStorageKey(entityName, partitionKey, "sem");
|
|
484
|
+
const queueTimeoutMs = config.queueTimeoutMs ?? 0;
|
|
485
|
+
return this.semaphore.acquire(storageKey, config.maxConcurrent, queueTimeoutMs, entityName);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Acquire a global concurrency slot.
|
|
489
|
+
*/
|
|
490
|
+
async acquireGlobalSemaphore(context) {
|
|
491
|
+
const config = this.config.globalConcurrency;
|
|
492
|
+
if (!config) return null;
|
|
493
|
+
const partitionKey = resolvePartitionKey(config.partitionBy, context);
|
|
494
|
+
const storageKey = buildStorageKey("__global__", partitionKey, "sem");
|
|
495
|
+
const queueTimeoutMs = config.queueTimeoutMs ?? 0;
|
|
496
|
+
return this.semaphore.acquire(storageKey, config.maxConcurrent, queueTimeoutMs, "__global__");
|
|
497
|
+
}
|
|
498
|
+
// ============================================
|
|
499
|
+
// Lifecycle
|
|
500
|
+
// ============================================
|
|
501
|
+
async destroy() {
|
|
502
|
+
await this.storage.disconnect();
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// libs/guard/src/manager/guard.factory.ts
|
|
507
|
+
import { createStorage, createMemoryStorage } from "@frontmcp/utils";
|
|
508
|
+
async function createGuardManager(args) {
|
|
509
|
+
const { config, logger } = args;
|
|
510
|
+
const keyPrefix = config.keyPrefix ?? "mcp:guard:";
|
|
511
|
+
let storage;
|
|
512
|
+
if (config.storage) {
|
|
513
|
+
storage = await createStorage(config.storage);
|
|
514
|
+
} else {
|
|
515
|
+
logger?.warn(
|
|
516
|
+
"GuardManager: No storage config provided, using in-memory storage (not suitable for distributed deployments)"
|
|
517
|
+
);
|
|
518
|
+
storage = createMemoryStorage();
|
|
519
|
+
}
|
|
520
|
+
await storage.connect();
|
|
521
|
+
const namespacedStorage = storage.namespace(keyPrefix);
|
|
522
|
+
logger?.info("GuardManager initialized", {
|
|
523
|
+
keyPrefix,
|
|
524
|
+
hasGlobalRateLimit: !!config.global,
|
|
525
|
+
hasGlobalConcurrency: !!config.globalConcurrency,
|
|
526
|
+
hasDefaultRateLimit: !!config.defaultRateLimit,
|
|
527
|
+
hasDefaultConcurrency: !!config.defaultConcurrency,
|
|
528
|
+
hasDefaultTimeout: !!config.defaultTimeout,
|
|
529
|
+
hasIpFilter: !!config.ipFilter
|
|
530
|
+
});
|
|
531
|
+
return new GuardManager(namespacedStorage, config);
|
|
532
|
+
}
|
|
533
|
+
export {
|
|
534
|
+
ConcurrencyLimitError,
|
|
535
|
+
DistributedSemaphore,
|
|
536
|
+
ExecutionTimeoutError,
|
|
537
|
+
GuardError,
|
|
538
|
+
GuardManager,
|
|
539
|
+
IpBlockedError,
|
|
540
|
+
IpFilter,
|
|
541
|
+
IpNotAllowedError,
|
|
542
|
+
QueueTimeoutError,
|
|
543
|
+
SlidingWindowRateLimiter,
|
|
544
|
+
buildStorageKey,
|
|
545
|
+
concurrencyConfigSchema,
|
|
546
|
+
createGuardManager,
|
|
547
|
+
guardConfigSchema,
|
|
548
|
+
ipFilterConfigSchema,
|
|
549
|
+
partitionKeySchema,
|
|
550
|
+
rateLimitConfigSchema,
|
|
551
|
+
resolvePartitionKey,
|
|
552
|
+
timeoutConfigSchema,
|
|
553
|
+
withTimeout
|
|
554
|
+
};
|
package/esm/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@frontmcp/guard",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Rate limiting, concurrency control, timeout, IP filtering, and traffic guard utilities for FrontMCP",
|
|
5
|
+
"author": "AgentFront <info@agentfront.dev>",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"rate-limiting",
|
|
9
|
+
"concurrency",
|
|
10
|
+
"semaphore",
|
|
11
|
+
"timeout",
|
|
12
|
+
"ip-filter",
|
|
13
|
+
"guard",
|
|
14
|
+
"throttle",
|
|
15
|
+
"distributed",
|
|
16
|
+
"redis",
|
|
17
|
+
"typescript"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/agentfront/frontmcp.git",
|
|
22
|
+
"directory": "libs/guard"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/agentfront/frontmcp/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/agentfront/frontmcp/blob/main/libs/guard/README.md",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "../index.js",
|
|
30
|
+
"module": "./index.mjs",
|
|
31
|
+
"types": "../index.d.ts",
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"exports": {
|
|
34
|
+
"./package.json": "../package.json",
|
|
35
|
+
".": {
|
|
36
|
+
"require": {
|
|
37
|
+
"types": "../index.d.ts",
|
|
38
|
+
"default": "../index.js"
|
|
39
|
+
},
|
|
40
|
+
"import": {
|
|
41
|
+
"types": "../index.d.ts",
|
|
42
|
+
"default": "./index.mjs"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"./esm": null
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22.0.0"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@frontmcp/utils": "1.0.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"zod": "^4.0.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^24.0.0",
|
|
58
|
+
"typescript": "^5.0.0",
|
|
59
|
+
"zod": "^4.0.0"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @frontmcp/guard
|
|
3
|
+
*
|
|
4
|
+
* Rate limiting, concurrency control, timeout, IP filtering,
|
|
5
|
+
* and traffic guard utilities.
|
|
6
|
+
*/
|
|
7
|
+
export * from './errors';
|
|
8
|
+
export * from './schemas';
|
|
9
|
+
export * from './partition-key';
|
|
10
|
+
export * from './rate-limit';
|
|
11
|
+
export * from './concurrency';
|
|
12
|
+
export * from './timeout';
|
|
13
|
+
export * from './ip-filter';
|
|
14
|
+
export * from './manager';
|