drupal-mcp-connector 0.8.0 → 0.9.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/CHANGELOG.md +51 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/index.js +37 -38
- package/src/lib/backends/jsonapi.js +13 -4
- package/src/lib/http-handler.js +78 -0
- package/src/lib/rate-limit.js +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.1] - 2026-06-15
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- Validate and URL-encode JSON:API path segments. The entity `id` is now checked
|
|
14
|
+
with `validateUuid` and `entityType`/`bundle` with `validateMachineName` (both
|
|
15
|
+
previously unused), and every segment is `encodeURIComponent`'d. This closes a
|
|
16
|
+
path-traversal vector where a crafted `id` (e.g. `../../user/user/<uuid>`) could
|
|
17
|
+
reach a different resource type and bypass the connector's entity-type/PII
|
|
18
|
+
policy. (Drupal core permissions were always still enforced.)
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- A [Threat Model](docs/threat-model.md) documenting trust boundaries, threats &
|
|
22
|
+
mitigations, residual risks (drush SQL bridge; why filter-field names are not
|
|
23
|
+
machine-name-validated), and the 1.0 security-pass results (`npm audit` clean,
|
|
24
|
+
adversarial review).
|
|
25
|
+
|
|
26
|
+
## [0.9.0] - 2026-06-15
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- Docs: an [MCP client setup guide](docs/mcp-clients.md) with copy-paste config
|
|
30
|
+
**and per-platform management commands** for Claude (Code `claude mcp …` /
|
|
31
|
+
Desktop), Grok (Build `grok mcp …` + API Remote MCP Tools), and OpenAI (Codex
|
|
32
|
+
`codex mcp …` + ChatGPT/Responses API), plus generic stdio and remote-HTTP
|
|
33
|
+
patterns and the local-vs-remote reachability/secret tradeoffs.
|
|
34
|
+
- Test coverage for the Streamable-HTTP transport's request routing (bearer-auth
|
|
35
|
+
gate, session open/reuse, `/health`, 404) via an extracted, unit-tested
|
|
36
|
+
`http-handler` module.
|
|
37
|
+
- Regression tests confirming **non-content-moderation Drupal sites are
|
|
38
|
+
unaffected** by the moderation fallback: a plain create sends `status` and
|
|
39
|
+
succeeds on the first request with no retry (the fallback only engages on the
|
|
40
|
+
specific moderated-entity 403).
|
|
41
|
+
- Optional built-in **rate limiting** for the HTTPS transport: set
|
|
42
|
+
`MCP_RATE_LIMIT` (per-IP requests per window) and `MCP_RATE_WINDOW_SEC`
|
|
43
|
+
(default 60). Over-limit `/mcp` requests get `429` + `Retry-After`; the check
|
|
44
|
+
runs before auth (throttling brute force) and never limits `/health`. Off by
|
|
45
|
+
default. (#4)
|
|
46
|
+
- Reference deployment for the HTTPS transport: a `Dockerfile` (+ `.dockerignore`),
|
|
47
|
+
systemd unit, launchd plist + launcher, a Caddy reverse-proxy example, and a
|
|
48
|
+
[Deployment guide](docs/deployment.md) with a pre-exposure checklist.
|
|
49
|
+
- [Versioning & Stability policy](docs/versioning.md) defining the stable public
|
|
50
|
+
surface (tool names/inputs, resource/prompt URIs, config + env vars, transports,
|
|
51
|
+
presets), the deprecation process, MCP-protocol negotiation behavior, and Node
|
|
52
|
+
support — the contract that 1.0 will lock.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
- Refactor: the HTTP transport's request handler is extracted from `index.js`
|
|
56
|
+
into `src/lib/http-handler.js` (no behavior change), making the routing/auth
|
|
57
|
+
path unit-testable and ready for additional middleware (e.g. rate limiting).
|
|
58
|
+
|
|
10
59
|
## [0.8.0] - 2026-06-15
|
|
11
60
|
|
|
12
61
|
### Added
|
|
@@ -180,6 +229,8 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
|
|
|
180
229
|
- User tools gained explicit PII-access assertions.
|
|
181
230
|
- Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
|
|
182
231
|
|
|
232
|
+
[0.9.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.1
|
|
233
|
+
[0.9.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.0
|
|
183
234
|
[0.8.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.8.0
|
|
184
235
|
[0.7.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.7.1
|
|
185
236
|
[0.7.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.7.0
|
package/README.md
CHANGED
|
@@ -170,13 +170,17 @@ Governance keys off the authenticated account's role and OAuth scopes — not re
|
|
|
170
170
|
| Doc | Description |
|
|
171
171
|
|-----|-------------|
|
|
172
172
|
| [Getting Started](docs/getting-started.md) | Full setup: DDEV/Lando, Simple OAuth, multi-site, transports |
|
|
173
|
+
| [MCP Clients](docs/mcp-clients.md) | Wire the connector into Claude (Code/Desktop), Grok (Build/API), and OpenAI (Codex/ChatGPT) — copy-paste config per client |
|
|
173
174
|
| [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
|
|
174
175
|
| [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
|
|
175
176
|
| [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
|
|
176
177
|
| [Tools Reference](docs/tools-reference.md) | Full reference for all 66 tools |
|
|
177
178
|
| [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
|
|
178
179
|
| [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
|
|
180
|
+
| [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
|
|
181
|
+
| [Deployment](docs/deployment.md) | Run the HTTPS transport in production: Docker, systemd, launchd, reverse proxy, pre-exposure checklist |
|
|
179
182
|
| [Integration Contract](docs/integration-contract.md) | The connector ↔ Drupal-governance contract (identity, OAuth scopes, compatibility) |
|
|
183
|
+
| [Versioning & Stability](docs/versioning.md) | Semver policy: the stable surface, deprecation process, MCP protocol + Node support |
|
|
180
184
|
| [Whitepaper](docs/whitepaper.md) | Vision, personas, and use cases |
|
|
181
185
|
|
|
182
186
|
---
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* MCP_AUTH_TOKEN Bearer token required on /mcp in https mode (warns if unset)
|
|
19
19
|
* MCP_BIND_HOST Bind address for https mode when TLS is present
|
|
20
20
|
* (default: "0.0.0.0"; ignored without TLS, which forces loopback)
|
|
21
|
+
* MCP_RATE_LIMIT Max /mcp requests per window per client IP (0/unset = off)
|
|
22
|
+
* MCP_RATE_WINDOW_SEC Rate-limit window in seconds (default: 60)
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
import { createServer as createHttpsServer } from "https";
|
|
@@ -36,6 +38,8 @@ import { CallToolRequestSchema,
|
|
|
36
38
|
|
|
37
39
|
import { getSiteConfig, listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
|
|
38
40
|
import { makeBearerCheck } from "./lib/http-auth.js";
|
|
41
|
+
import { createMcpRequestHandler } from "./lib/http-handler.js";
|
|
42
|
+
import { createRateLimiter } from "./lib/rate-limit.js";
|
|
39
43
|
import { resolveSecurityConfig, assertNotReadOnly,
|
|
40
44
|
assertDestructiveAllowed, assertGraphqlMutationAllowed,
|
|
41
45
|
SecurityError } from "./lib/security.js";
|
|
@@ -439,47 +443,42 @@ if (transport === "stdio") {
|
|
|
439
443
|
// Map of sessionId → transport for multi-client support
|
|
440
444
|
const sessions = new Map();
|
|
441
445
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
446
|
+
// Create + connect a new Streamable-HTTP transport, registering it in the
|
|
447
|
+
// session map on initialize and pruning it on close.
|
|
448
|
+
async function openSession() {
|
|
449
|
+
const mcpTransport = new StreamableHTTPServerTransport({
|
|
450
|
+
sessionIdGenerator: () => randomUUID(),
|
|
451
|
+
onsessioninitialized: (id) => sessions.set(id, mcpTransport),
|
|
452
|
+
});
|
|
453
|
+
mcpTransport.onclose = () => sessions.delete(mcpTransport.sessionId);
|
|
454
|
+
await server.connect(mcpTransport);
|
|
455
|
+
return mcpTransport;
|
|
456
|
+
}
|
|
449
457
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
await sessions.get(sessionId).handleRequest(req, res);
|
|
473
|
-
|
|
474
|
-
} else if (req.url === "/health") {
|
|
475
|
-
res.writeHead(200, { "Content-Type": "application/json" })
|
|
476
|
-
.end(JSON.stringify({ status: "ok", tools: allDefinitions.length }));
|
|
477
|
-
|
|
478
|
-
} else {
|
|
479
|
-
res.writeHead(404).end("Not found");
|
|
480
|
-
}
|
|
458
|
+
// Optional fixed-window rate limiting on /mcp, keyed by client IP. Off unless
|
|
459
|
+
// MCP_RATE_LIMIT > 0. Counts are per-process; for multi-replica deployments
|
|
460
|
+
// prefer rate limiting at the reverse proxy.
|
|
461
|
+
const rateLimit = Number(process.env.MCP_RATE_LIMIT || 0);
|
|
462
|
+
const rateWindowSec = Number(process.env.MCP_RATE_WINDOW_SEC || 60);
|
|
463
|
+
const rateLimiter = rateLimit > 0
|
|
464
|
+
? createRateLimiter({ limit: rateLimit, windowMs: rateWindowSec * 1000 })
|
|
465
|
+
: null;
|
|
466
|
+
if (rateLimiter) {
|
|
467
|
+
console.error(
|
|
468
|
+
`[drupal-mcp-connector] Rate limiting: ${rateLimit} req / ${rateWindowSec}s per client IP on /mcp.`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const requestHandler = createMcpRequestHandler({
|
|
473
|
+
checkAuth,
|
|
474
|
+
sessions,
|
|
475
|
+
openSession,
|
|
476
|
+
toolCount: allDefinitions.length,
|
|
477
|
+
rateLimiter,
|
|
481
478
|
});
|
|
482
479
|
|
|
480
|
+
const nodeServer = createNodeServer(requestHandler);
|
|
481
|
+
|
|
483
482
|
const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
|
|
484
483
|
// Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
|
|
485
484
|
// bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { drupalFetch, drupalUploadFile } from "../drupal-fetch.js";
|
|
11
|
+
import { validateUuid, validateMachineName } from "../validate.js";
|
|
11
12
|
import { Backend } from "./backend-interface.js";
|
|
12
13
|
import {
|
|
13
14
|
makeCanonicalEntity,
|
|
@@ -132,7 +133,12 @@ export class JsonApiBackend extends Backend {
|
|
|
132
133
|
* @returns {string} e.g. "/jsonapi/node/article".
|
|
133
134
|
*/
|
|
134
135
|
resourcePath(entityType, bundle) {
|
|
135
|
-
|
|
136
|
+
// Validate before interpolating into the URL: machine names cannot contain
|
|
137
|
+
// path separators or `..`, so this blocks path-traversal to other resources
|
|
138
|
+
// (e.g. id="../../user/user/…"). Encoding is belt-and-suspenders.
|
|
139
|
+
validateMachineName(entityType, "entityType");
|
|
140
|
+
validateMachineName(bundle, "bundle");
|
|
141
|
+
return `/jsonapi/${encodeURIComponent(entityType)}/${encodeURIComponent(bundle)}`;
|
|
136
142
|
}
|
|
137
143
|
|
|
138
144
|
/**
|
|
@@ -219,7 +225,8 @@ export class JsonApiBackend extends Backend {
|
|
|
219
225
|
* @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null.
|
|
220
226
|
*/
|
|
221
227
|
async getEntity({ entityType, bundle, id }) {
|
|
222
|
-
|
|
228
|
+
validateUuid(id);
|
|
229
|
+
const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`);
|
|
223
230
|
return data?.data ? this.toCanonical(data.data) : null;
|
|
224
231
|
}
|
|
225
232
|
|
|
@@ -275,12 +282,13 @@ export class JsonApiBackend extends Backend {
|
|
|
275
282
|
* @returns {Promise<import("../canonical.js").CanonicalEntity>} The updated entity.
|
|
276
283
|
*/
|
|
277
284
|
async updateEntity({ entityType, bundle, id, attributes = {}, relationships }) {
|
|
285
|
+
validateUuid(id);
|
|
278
286
|
const buildPayload = (attrs) => {
|
|
279
287
|
const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
|
|
280
288
|
if (relationships) payload.data.relationships = relationships;
|
|
281
289
|
return payload;
|
|
282
290
|
};
|
|
283
|
-
const data = await this.writeWithModerationFallback(`${this.resourcePath(entityType, bundle)}/${id}`, "PATCH", buildPayload, attributes);
|
|
291
|
+
const data = await this.writeWithModerationFallback(`${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`, "PATCH", buildPayload, attributes);
|
|
284
292
|
return this.toCanonical(data.data);
|
|
285
293
|
}
|
|
286
294
|
|
|
@@ -290,7 +298,8 @@ export class JsonApiBackend extends Backend {
|
|
|
290
298
|
* @returns {Promise<void>}
|
|
291
299
|
*/
|
|
292
300
|
async deleteEntity({ entityType, bundle, id }) {
|
|
293
|
-
|
|
301
|
+
validateUuid(id);
|
|
302
|
+
await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
294
303
|
}
|
|
295
304
|
|
|
296
305
|
/**
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP request handler for the Streamable-HTTP MCP transport.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from index.js so the routing/auth/health/404 behavior is unit
|
|
5
|
+
* testable without standing up a real server or the MCP SDK. The entry point
|
|
6
|
+
* wires the concrete dependencies (bearer check, session map, session factory);
|
|
7
|
+
* tests inject stubs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the `(req, res)` handler for the MCP HTTP endpoint.
|
|
12
|
+
*
|
|
13
|
+
* Routes:
|
|
14
|
+
* - `POST /mcp` — bearer-gated; reuses a session by `Mcp-Session-Id` or opens
|
|
15
|
+
* a new one, then delegates to the transport's `handleRequest`.
|
|
16
|
+
* - `GET /mcp` — bearer-gated; requires a known session, else 400.
|
|
17
|
+
* - `GET /health`— unauthenticated liveness probe (`{status, tools}`).
|
|
18
|
+
* - everything else — 404.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} deps
|
|
21
|
+
* @param {(authHeader: any) => boolean} deps.checkAuth Bearer predicate (see http-auth.js).
|
|
22
|
+
* @param {Map<string, {handleRequest: Function, sessionId?: string}>} deps.sessions Session id → transport.
|
|
23
|
+
* @param {() => Promise<{handleRequest: Function}>} deps.openSession Create+connect a new transport.
|
|
24
|
+
* @param {number} deps.toolCount Tool count reported by /health.
|
|
25
|
+
* @param {?{check: (key: string) => {allowed: boolean, retryAfterSec: number}}} [deps.rateLimiter]
|
|
26
|
+
* Optional rate limiter (see rate-limit.js). Omit/null to disable.
|
|
27
|
+
* @param {(req: import("http").IncomingMessage) => string} [deps.clientKey]
|
|
28
|
+
* Maps a request to a rate-limit key (default: client IP).
|
|
29
|
+
* @returns {(req: import("http").IncomingMessage, res: import("http").ServerResponse) => Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
export function createMcpRequestHandler({
|
|
32
|
+
checkAuth, sessions, openSession, toolCount,
|
|
33
|
+
rateLimiter = null,
|
|
34
|
+
clientKey = (req) => req.socket?.remoteAddress || "unknown",
|
|
35
|
+
}) {
|
|
36
|
+
return async function handle(req, res) {
|
|
37
|
+
if (req.url === "/mcp" && (req.method === "POST" || req.method === "GET")) {
|
|
38
|
+
// Rate limit BEFORE auth so repeated bad-token attempts are throttled too.
|
|
39
|
+
if (rateLimiter) {
|
|
40
|
+
const verdict = rateLimiter.check(clientKey(req));
|
|
41
|
+
if (!verdict.allowed) {
|
|
42
|
+
res.writeHead(429, { "Retry-After": String(verdict.retryAfterSec) }).end("Too Many Requests");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Auth gate: only the /mcp endpoint requires a token; /health stays open.
|
|
47
|
+
if (!checkAuth(req.headers["authorization"])) {
|
|
48
|
+
res.writeHead(401, { "WWW-Authenticate": "Bearer" }).end("Unauthorized");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (req.method === "POST" && req.url === "/mcp") {
|
|
54
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
55
|
+
const transport = (sessionId && sessions.get(sessionId)) || (await openSession());
|
|
56
|
+
await transport.handleRequest(req, res);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (req.method === "GET" && req.url === "/mcp") {
|
|
61
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
62
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
63
|
+
res.writeHead(400).end("Missing or unknown MCP-Session-Id");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await sessions.get(sessionId).handleRequest(req, res);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (req.url === "/health") {
|
|
71
|
+
res.writeHead(200, { "Content-Type": "application/json" })
|
|
72
|
+
.end(JSON.stringify({ status: "ok", tools: toolCount }));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
res.writeHead(404).end("Not found");
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, dependency-free fixed-window rate limiter for the HTTPS transport.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in: with a falsy/non-positive `limit` it's a no-op (always allows), so the
|
|
5
|
+
* default behavior is unchanged. Counts are kept in-memory per process and keyed
|
|
6
|
+
* by an arbitrary string (the caller chooses, e.g. client IP). Suited to a
|
|
7
|
+
* single-process connector fronting one Drupal; for multi-replica deployments
|
|
8
|
+
* put a shared limiter in the reverse proxy instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} RateVerdict
|
|
13
|
+
* @property {boolean} allowed Whether the request may proceed.
|
|
14
|
+
* @property {number} remaining Requests left in the current window (Infinity when disabled).
|
|
15
|
+
* @property {number} retryAfterSec Seconds until the window resets (0 when allowed).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a fixed-window rate limiter.
|
|
20
|
+
* @param {object} opts
|
|
21
|
+
* @param {?number} opts.limit Max requests per window per key. Falsy/<=0 disables limiting.
|
|
22
|
+
* @param {number} [opts.windowMs] Window length in ms (default 60_000).
|
|
23
|
+
* @param {() => number} [opts.now] Clock (injectable for tests; default Date.now).
|
|
24
|
+
* @param {number} [opts.maxKeys] Soft cap on tracked keys before expired ones are pruned (default 10_000).
|
|
25
|
+
* @returns {{ check: (key: string) => RateVerdict, size: () => number }}
|
|
26
|
+
*/
|
|
27
|
+
export function createRateLimiter({ limit, windowMs = 60_000, now = () => Date.now(), maxKeys = 10_000 } = {}) {
|
|
28
|
+
const enabled = typeof limit === "number" && limit > 0;
|
|
29
|
+
/** @type {Map<string, {count: number, resetAt: number}>} */
|
|
30
|
+
const buckets = new Map();
|
|
31
|
+
|
|
32
|
+
function prune(t) {
|
|
33
|
+
for (const [k, b] of buckets) {
|
|
34
|
+
if (t >= b.resetAt) buckets.delete(k);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
check(key) {
|
|
40
|
+
if (!enabled) return { allowed: true, remaining: Infinity, retryAfterSec: 0 };
|
|
41
|
+
const t = now();
|
|
42
|
+
let b = buckets.get(key);
|
|
43
|
+
if (!b || t >= b.resetAt) {
|
|
44
|
+
if (buckets.size >= maxKeys) prune(t);
|
|
45
|
+
b = { count: 0, resetAt: t + windowMs };
|
|
46
|
+
buckets.set(key, b);
|
|
47
|
+
}
|
|
48
|
+
b.count += 1;
|
|
49
|
+
if (b.count > limit) {
|
|
50
|
+
return { allowed: false, remaining: 0, retryAfterSec: Math.ceil((b.resetAt - t) / 1000) };
|
|
51
|
+
}
|
|
52
|
+
return { allowed: true, remaining: limit - b.count, retryAfterSec: 0 };
|
|
53
|
+
},
|
|
54
|
+
size() { return buckets.size; },
|
|
55
|
+
};
|
|
56
|
+
}
|