@thotischner/observability-mcp 1.7.0 → 1.8.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/config/products.yaml.example +48 -0
- package/dist/audit/log.d.ts +99 -0
- package/dist/audit/log.js +180 -0
- package/dist/audit/log.test.d.ts +1 -0
- package/dist/audit/log.test.js +147 -0
- package/dist/audit/middleware.d.ts +20 -0
- package/dist/audit/middleware.js +50 -0
- package/dist/auth/credentials.d.ts +18 -0
- package/dist/auth/credentials.js +26 -1
- package/dist/auth/credentials.test.js +26 -1
- package/dist/auth/local-users.d.ts +62 -0
- package/dist/auth/local-users.js +143 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +80 -0
- package/dist/auth/middleware.d.ts +48 -0
- package/dist/auth/middleware.js +65 -0
- package/dist/auth/middleware.test.d.ts +1 -0
- package/dist/auth/middleware.test.js +90 -0
- package/dist/auth/oidc/client.d.ts +73 -0
- package/dist/auth/oidc/client.js +104 -0
- package/dist/auth/oidc/client.test.d.ts +1 -0
- package/dist/auth/oidc/client.test.js +121 -0
- package/dist/auth/oidc/discovery.d.ts +38 -0
- package/dist/auth/oidc/discovery.js +48 -0
- package/dist/auth/oidc/discovery.test.d.ts +1 -0
- package/dist/auth/oidc/discovery.test.js +68 -0
- package/dist/auth/oidc/endpoints.d.ts +20 -0
- package/dist/auth/oidc/endpoints.js +124 -0
- package/dist/auth/oidc/endpoints.test.d.ts +7 -0
- package/dist/auth/oidc/endpoints.test.js +304 -0
- package/dist/auth/oidc/flow-cookie.d.ts +57 -0
- package/dist/auth/oidc/flow-cookie.js +142 -0
- package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
- package/dist/auth/oidc/flow-cookie.test.js +0 -0
- package/dist/auth/oidc/index.d.ts +7 -0
- package/dist/auth/oidc/index.js +6 -0
- package/dist/auth/oidc/jwks.d.ts +36 -0
- package/dist/auth/oidc/jwks.js +69 -0
- package/dist/auth/oidc/jwks.test.d.ts +1 -0
- package/dist/auth/oidc/jwks.test.js +65 -0
- package/dist/auth/oidc/jwt.d.ts +62 -0
- package/dist/auth/oidc/jwt.js +113 -0
- package/dist/auth/oidc/jwt.test.d.ts +1 -0
- package/dist/auth/oidc/jwt.test.js +141 -0
- package/dist/auth/oidc/pkce.d.ts +19 -0
- package/dist/auth/oidc/pkce.js +43 -0
- package/dist/auth/oidc/pkce.test.d.ts +1 -0
- package/dist/auth/oidc/pkce.test.js +55 -0
- package/dist/auth/oidc/runtime.d.ts +63 -0
- package/dist/auth/oidc/runtime.js +129 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +180 -0
- package/dist/auth/policy/engine.d.ts +48 -0
- package/dist/auth/policy/engine.js +73 -0
- package/dist/auth/policy/engine.test.d.ts +1 -0
- package/dist/auth/policy/engine.test.js +98 -0
- package/dist/auth/policy/loader.d.ts +35 -0
- package/dist/auth/policy/loader.js +100 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +162 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +158 -0
- package/dist/auth/rbac.d.ts +40 -0
- package/dist/auth/rbac.js +120 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +121 -0
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +146 -0
- package/dist/auth/session.test.d.ts +1 -0
- package/dist/auth/session.test.js +90 -0
- package/dist/catalog/loader.d.ts +67 -0
- package/dist/catalog/loader.js +122 -0
- package/dist/catalog/loader.test.d.ts +1 -0
- package/dist/catalog/loader.test.js +108 -0
- package/dist/connectors/kubernetes.d.ts +1 -0
- package/dist/connectors/kubernetes.js +12 -2
- package/dist/connectors/topology-vocabulary.d.ts +41 -0
- package/dist/connectors/topology-vocabulary.js +120 -0
- package/dist/connectors/topology-vocabulary.test.d.ts +1 -0
- package/dist/connectors/topology-vocabulary.test.js +63 -0
- package/dist/context.d.ts +13 -1
- package/dist/context.js +5 -1
- package/dist/index.js +1012 -29
- package/dist/net/egress-policy.js +2 -0
- package/dist/openapi.js +440 -0
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +64 -0
- package/dist/policy/redact.d.ts +44 -0
- package/dist/policy/redact.js +144 -0
- package/dist/policy/redact.test.d.ts +1 -0
- package/dist/policy/redact.test.js +172 -0
- package/dist/products/loader.d.ts +84 -0
- package/dist/products/loader.js +216 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +168 -0
- package/dist/quota/limiter.d.ts +72 -0
- package/dist/quota/limiter.js +105 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +119 -0
- package/dist/quota/token-budget.d.ts +119 -0
- package/dist/quota/token-budget.js +297 -0
- package/dist/quota/token-budget.test.d.ts +1 -0
- package/dist/quota/token-budget.test.js +215 -0
- package/dist/tenancy/context.d.ts +45 -0
- package/dist/tenancy/context.js +97 -0
- package/dist/tenancy/context.test.d.ts +1 -0
- package/dist/tenancy/context.test.js +72 -0
- package/dist/tenancy/migration.test.d.ts +7 -0
- package/dist/tenancy/migration.test.js +75 -0
- package/dist/ui/index.html +1454 -88
- package/package.json +20 -3
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseProductsText, ProductsStore, ProductsLoadError } from "./loader.js";
|
|
4
|
+
test("parseProductsText — empty/minimal products array", () => {
|
|
5
|
+
const f = parseProductsText("products: []", "test");
|
|
6
|
+
assert.deepEqual(f.products, []);
|
|
7
|
+
});
|
|
8
|
+
test("parseProductsText — happy path with full shape", () => {
|
|
9
|
+
const yaml = `
|
|
10
|
+
products:
|
|
11
|
+
- id: ops-bundle
|
|
12
|
+
name: Operations Bundle
|
|
13
|
+
description: Tools for incident response.
|
|
14
|
+
tools: [query_logs, query_metrics, get_service_health]
|
|
15
|
+
version: 1.2.0
|
|
16
|
+
status: published
|
|
17
|
+
branding:
|
|
18
|
+
iconUrl: https://example.test/icon.png
|
|
19
|
+
color: "#3178c6"
|
|
20
|
+
- id: dev-bundle
|
|
21
|
+
name: Developer Bundle
|
|
22
|
+
tools: [query_logs]
|
|
23
|
+
status: staging
|
|
24
|
+
`;
|
|
25
|
+
const f = parseProductsText(yaml, "test");
|
|
26
|
+
assert.equal(f.products.length, 2);
|
|
27
|
+
assert.equal(f.products[0].id, "ops-bundle");
|
|
28
|
+
assert.deepEqual(f.products[0].tools, ["query_logs", "query_metrics", "get_service_health"]);
|
|
29
|
+
assert.equal(f.products[0].status, "published");
|
|
30
|
+
assert.equal(f.products[0].branding?.color, "#3178c6");
|
|
31
|
+
assert.equal(f.products[1].status, "staging");
|
|
32
|
+
});
|
|
33
|
+
test("parseProductsText — rejects malformed root / non-array products", () => {
|
|
34
|
+
assert.throws(() => parseProductsText("[]", "t"), /root must be an object/);
|
|
35
|
+
assert.throws(() => parseProductsText("products: notalist", "t"), /'products' must be an array/);
|
|
36
|
+
});
|
|
37
|
+
test("parseProductsText — rejects bad id / missing name / duplicate id", () => {
|
|
38
|
+
assert.throws(() => parseProductsText("products:\n - id: '..bad'\n name: x", "t"), /id must be a string matching/);
|
|
39
|
+
assert.throws(() => parseProductsText("products:\n - id: ok\n name: ''", "t"), /name must be a non-empty string/);
|
|
40
|
+
assert.throws(() => parseProductsText("products:\n - id: dup\n name: A\n - id: dup\n name: B", "t"), /duplicate product id 'dup'/);
|
|
41
|
+
});
|
|
42
|
+
test("parseProductsText — rejects unknown status / wrong types", () => {
|
|
43
|
+
assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n status: archived", "t"), /status must be one of/);
|
|
44
|
+
assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n tools: 'string-not-array'", "t"), /tools must be an array/);
|
|
45
|
+
assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n version: 42", "t"), /version must be a string/);
|
|
46
|
+
});
|
|
47
|
+
test("parseProductsText — rejects unexpected top-level keys (typo guard)", () => {
|
|
48
|
+
assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n toolss: []", "t"), /unexpected key 'toolss'/);
|
|
49
|
+
});
|
|
50
|
+
test("parseProductsText — rejects malformed branding shape", () => {
|
|
51
|
+
assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n branding: notobject", "t"), /branding must be an object/);
|
|
52
|
+
assert.throws(() => parseProductsText("products:\n - id: x\n name: X\n branding:\n iconUrl: 42", "t"), /branding.iconUrl must be a string/);
|
|
53
|
+
});
|
|
54
|
+
test("ProductsStore — list / get / count happy paths", () => {
|
|
55
|
+
const store = new ProductsStore({
|
|
56
|
+
products: [
|
|
57
|
+
{ id: "a", name: "A", status: "published" },
|
|
58
|
+
{ id: "b", name: "B", status: "staging" },
|
|
59
|
+
{ id: "c", name: "C" }, // no explicit status → not "staging" → visible by default
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
// Default: staging hidden
|
|
63
|
+
assert.equal(store.list().length, 2);
|
|
64
|
+
// Include staging
|
|
65
|
+
assert.equal(store.list({ includeStaging: true }).length, 3);
|
|
66
|
+
// get unfiltered
|
|
67
|
+
assert.equal(store.get("a")?.name, "A");
|
|
68
|
+
assert.equal(store.get("missing"), undefined);
|
|
69
|
+
// count includes everything
|
|
70
|
+
assert.equal(store.count(), 3);
|
|
71
|
+
});
|
|
72
|
+
test("ProductsStore — tenant filter scopes list / get / count", () => {
|
|
73
|
+
const store = new ProductsStore({
|
|
74
|
+
products: [
|
|
75
|
+
{ id: "acme-ops", name: "Acme Ops", tenant: "acme" },
|
|
76
|
+
{ id: "bigco-ops", name: "BigCo Ops", tenant: "bigco" },
|
|
77
|
+
{ id: "shared", name: "Shared" }, // no tenant → "default"
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
// Tenant-scoped
|
|
81
|
+
assert.equal(store.list({ tenant: "acme" }).length, 1);
|
|
82
|
+
assert.equal(store.get("acme-ops", "acme")?.name, "Acme Ops");
|
|
83
|
+
assert.equal(store.get("bigco-ops", "acme"), undefined, "cross-tenant get returns undefined");
|
|
84
|
+
assert.equal(store.count("default"), 1, "no-tenant entry counts under 'default'");
|
|
85
|
+
});
|
|
86
|
+
test("ProductsStore — staging hidden by default within a tenant filter", () => {
|
|
87
|
+
const store = new ProductsStore({
|
|
88
|
+
products: [
|
|
89
|
+
{ id: "p1", name: "P1", tenant: "acme", status: "published" },
|
|
90
|
+
{ id: "p2", name: "P2", tenant: "acme", status: "staging" },
|
|
91
|
+
],
|
|
92
|
+
});
|
|
93
|
+
assert.equal(store.list({ tenant: "acme" }).length, 1, "staging is hidden");
|
|
94
|
+
assert.equal(store.list({ tenant: "acme", includeStaging: true }).length, 2);
|
|
95
|
+
});
|
|
96
|
+
test("ProductsStore.upsert — replaces existing, appends new", () => {
|
|
97
|
+
const store = new ProductsStore({
|
|
98
|
+
products: [
|
|
99
|
+
{ id: "a", name: "Original" },
|
|
100
|
+
{ id: "b", name: "Second" },
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
// Replace existing
|
|
104
|
+
store.upsert({ id: "a", name: "Replaced" });
|
|
105
|
+
assert.equal(store.get("a")?.name, "Replaced");
|
|
106
|
+
assert.equal(store.count(), 2);
|
|
107
|
+
// Append new
|
|
108
|
+
store.upsert({ id: "c", name: "New" });
|
|
109
|
+
assert.equal(store.count(), 3);
|
|
110
|
+
assert.equal(store.get("c")?.name, "New");
|
|
111
|
+
});
|
|
112
|
+
test("ProductsStore.delete — returns removed flag + survivors", () => {
|
|
113
|
+
const store = new ProductsStore({
|
|
114
|
+
products: [{ id: "a", name: "A" }, { id: "b", name: "B" }],
|
|
115
|
+
});
|
|
116
|
+
const r1 = store.delete("a");
|
|
117
|
+
assert.equal(r1.removed, true);
|
|
118
|
+
assert.equal(store.count(), 1);
|
|
119
|
+
// Re-delete is a no-op
|
|
120
|
+
const r2 = store.delete("a");
|
|
121
|
+
assert.equal(r2.removed, false);
|
|
122
|
+
// Unknown id
|
|
123
|
+
const r3 = store.delete("nope");
|
|
124
|
+
assert.equal(r3.removed, false);
|
|
125
|
+
});
|
|
126
|
+
test("validateProduct — accepts a valid entry, rejects bad shape via same parser", async () => {
|
|
127
|
+
// Happy path
|
|
128
|
+
const p = await import("./loader.js").then((m) => m.validateProduct({ id: "x", name: "X" }));
|
|
129
|
+
assert.equal(p.name, "X");
|
|
130
|
+
// Bad shape uses the loader's strict rules
|
|
131
|
+
const { validateProduct } = await import("./loader.js");
|
|
132
|
+
assert.throws(() => validateProduct({ id: "x", name: "X", unknownKey: 1 }), /unexpected key 'unknownKey'/);
|
|
133
|
+
assert.throws(() => validateProduct({ id: "..bad", name: "X" }), /id must be a string matching/);
|
|
134
|
+
});
|
|
135
|
+
test("writeProductsFile + readProductsFile — atomic round-trip", async () => {
|
|
136
|
+
const { mkdtemp, rm } = await import("node:fs/promises");
|
|
137
|
+
const { tmpdir } = await import("node:os");
|
|
138
|
+
const { join } = await import("node:path");
|
|
139
|
+
const { writeProductsFile, readProductsFile } = await import("./loader.js");
|
|
140
|
+
const dir = await mkdtemp(join(tmpdir(), "omcp-products-"));
|
|
141
|
+
try {
|
|
142
|
+
const file = join(dir, "products.yaml");
|
|
143
|
+
await writeProductsFile(file, {
|
|
144
|
+
products: [
|
|
145
|
+
{ id: "a", name: "A", status: "published" },
|
|
146
|
+
{ id: "b", name: "B", tools: ["query_logs"], tenant: "acme" },
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
const reloaded = await readProductsFile(file);
|
|
150
|
+
assert.equal(reloaded.products.length, 2);
|
|
151
|
+
assert.equal(reloaded.products[0].status, "published");
|
|
152
|
+
assert.equal(reloaded.products[1].tenant, "acme");
|
|
153
|
+
assert.deepEqual(reloaded.products[1].tools, ["query_logs"]);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
await rm(dir, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
test("ProductsLoadError is the throw class", () => {
|
|
160
|
+
try {
|
|
161
|
+
parseProductsText("not-json", "t");
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
assert.ok(e instanceof ProductsLoadError);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
assert.fail("expected throw");
|
|
168
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-identity sliding-window rate limiter for the MCP tool surface.
|
|
3
|
+
*
|
|
4
|
+
* The bearer-token credential resolved by `auth/credentials.ts`
|
|
5
|
+
* (`OMCP_API_KEYS`) names every distinct caller; this limiter caps how
|
|
6
|
+
* many MCP tool calls each named caller can make per minute. Anonymous
|
|
7
|
+
* MCP traffic (when no `OMCP_API_KEYS` is set) bypasses the per-identity
|
|
8
|
+
* cap — the existing express-rate-limit IP gate at the /mcp transport
|
|
9
|
+
* still applies.
|
|
10
|
+
*
|
|
11
|
+
* The window is sliding: each call records its timestamp under the
|
|
12
|
+
* identity's key, and `check()` first prunes entries older than the
|
|
13
|
+
* configured window before counting. Memory bound is O(callers × N)
|
|
14
|
+
* where N is the per-window cap — a few KB even for a busy deployment.
|
|
15
|
+
*
|
|
16
|
+
* Persistence is out of scope here. A future revision can plug a
|
|
17
|
+
* Redis-backed store via the same interface.
|
|
18
|
+
*/
|
|
19
|
+
/** Resolve `OMCP_TOOL_RATE_PER_MIN` (or any equivalent caller-supplied
|
|
20
|
+
* string) into the per-identity cap used by the limiter and reported
|
|
21
|
+
* by `/api/info` + `/api/usage`. Single source of truth, so the three
|
|
22
|
+
* call sites don't drift.
|
|
23
|
+
*
|
|
24
|
+
* Behaviour:
|
|
25
|
+
* - unset / empty / non-numeric → DEFAULT_LIMIT_PER_MIN (60)
|
|
26
|
+
* - `"0"` → DEFAULT_LIMIT_PER_MIN (limit=0 would deny every request,
|
|
27
|
+
* which is almost never what an operator setting "0" wants — they
|
|
28
|
+
* either mean "default" or "disable"; we treat it as "default" and
|
|
29
|
+
* leave the explicit disable path on the roadmap)
|
|
30
|
+
* - negative → DEFAULT_LIMIT_PER_MIN (limit=-1 with the current
|
|
31
|
+
* `count >= limit` check would also deny every request)
|
|
32
|
+
* - any positive integer ≥ 1 → that value
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveToolRatePerMin(raw: string | undefined): number;
|
|
35
|
+
export interface LimiterConfig {
|
|
36
|
+
/** Cap per identity per window. Defaults to 60. */
|
|
37
|
+
limit?: number;
|
|
38
|
+
/** Window length in milliseconds. Defaults to 60_000. */
|
|
39
|
+
windowMs?: number;
|
|
40
|
+
}
|
|
41
|
+
export interface CheckResult {
|
|
42
|
+
/** True when the call is allowed (and the timestamp recorded). */
|
|
43
|
+
allowed: boolean;
|
|
44
|
+
/** Number of calls already made in the current window (after counting this one if allowed). */
|
|
45
|
+
count: number;
|
|
46
|
+
/** Configured per-window cap. */
|
|
47
|
+
limit: number;
|
|
48
|
+
/** Window length in ms. */
|
|
49
|
+
windowMs: number;
|
|
50
|
+
/** Seconds until the oldest in-window record falls off and a new
|
|
51
|
+
* slot opens. 0 when allowed. */
|
|
52
|
+
retryAfterSeconds: number;
|
|
53
|
+
}
|
|
54
|
+
export declare class IdentityRateLimiter {
|
|
55
|
+
private readonly limit;
|
|
56
|
+
private readonly windowMs;
|
|
57
|
+
private readonly buckets;
|
|
58
|
+
constructor(cfg?: LimiterConfig);
|
|
59
|
+
/** Record-and-test a call for the given identity. Returns the
|
|
60
|
+
* decision plus enough context to render a 429 with Retry-After. */
|
|
61
|
+
check(identity: string, now?: number): CheckResult;
|
|
62
|
+
/** Read-only snapshot — useful for /api/usage and tests. */
|
|
63
|
+
inspect(identity: string, now?: number): {
|
|
64
|
+
count: number;
|
|
65
|
+
limit: number;
|
|
66
|
+
windowMs: number;
|
|
67
|
+
};
|
|
68
|
+
/** All identities we've ever seen — for /api/usage aggregation. */
|
|
69
|
+
knownIdentities(): string[];
|
|
70
|
+
/** For testing — reset every identity's bucket. */
|
|
71
|
+
reset(): void;
|
|
72
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-identity sliding-window rate limiter for the MCP tool surface.
|
|
3
|
+
*
|
|
4
|
+
* The bearer-token credential resolved by `auth/credentials.ts`
|
|
5
|
+
* (`OMCP_API_KEYS`) names every distinct caller; this limiter caps how
|
|
6
|
+
* many MCP tool calls each named caller can make per minute. Anonymous
|
|
7
|
+
* MCP traffic (when no `OMCP_API_KEYS` is set) bypasses the per-identity
|
|
8
|
+
* cap — the existing express-rate-limit IP gate at the /mcp transport
|
|
9
|
+
* still applies.
|
|
10
|
+
*
|
|
11
|
+
* The window is sliding: each call records its timestamp under the
|
|
12
|
+
* identity's key, and `check()` first prunes entries older than the
|
|
13
|
+
* configured window before counting. Memory bound is O(callers × N)
|
|
14
|
+
* where N is the per-window cap — a few KB even for a busy deployment.
|
|
15
|
+
*
|
|
16
|
+
* Persistence is out of scope here. A future revision can plug a
|
|
17
|
+
* Redis-backed store via the same interface.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_LIMIT_PER_MIN = 60;
|
|
20
|
+
const DEFAULT_WINDOW_MS = 60_000;
|
|
21
|
+
/** Resolve `OMCP_TOOL_RATE_PER_MIN` (or any equivalent caller-supplied
|
|
22
|
+
* string) into the per-identity cap used by the limiter and reported
|
|
23
|
+
* by `/api/info` + `/api/usage`. Single source of truth, so the three
|
|
24
|
+
* call sites don't drift.
|
|
25
|
+
*
|
|
26
|
+
* Behaviour:
|
|
27
|
+
* - unset / empty / non-numeric → DEFAULT_LIMIT_PER_MIN (60)
|
|
28
|
+
* - `"0"` → DEFAULT_LIMIT_PER_MIN (limit=0 would deny every request,
|
|
29
|
+
* which is almost never what an operator setting "0" wants — they
|
|
30
|
+
* either mean "default" or "disable"; we treat it as "default" and
|
|
31
|
+
* leave the explicit disable path on the roadmap)
|
|
32
|
+
* - negative → DEFAULT_LIMIT_PER_MIN (limit=-1 with the current
|
|
33
|
+
* `count >= limit` check would also deny every request)
|
|
34
|
+
* - any positive integer ≥ 1 → that value
|
|
35
|
+
*/
|
|
36
|
+
export function resolveToolRatePerMin(raw) {
|
|
37
|
+
if (raw === undefined || raw === "")
|
|
38
|
+
return DEFAULT_LIMIT_PER_MIN;
|
|
39
|
+
const n = Number(raw);
|
|
40
|
+
if (!Number.isFinite(n) || n < 1)
|
|
41
|
+
return DEFAULT_LIMIT_PER_MIN;
|
|
42
|
+
return Math.floor(n);
|
|
43
|
+
}
|
|
44
|
+
export class IdentityRateLimiter {
|
|
45
|
+
limit;
|
|
46
|
+
windowMs;
|
|
47
|
+
// identity → ring of millisecond timestamps, newest at the end.
|
|
48
|
+
buckets = new Map();
|
|
49
|
+
constructor(cfg = {}) {
|
|
50
|
+
this.limit = cfg.limit ?? DEFAULT_LIMIT_PER_MIN;
|
|
51
|
+
this.windowMs = cfg.windowMs ?? DEFAULT_WINDOW_MS;
|
|
52
|
+
}
|
|
53
|
+
/** Record-and-test a call for the given identity. Returns the
|
|
54
|
+
* decision plus enough context to render a 429 with Retry-After. */
|
|
55
|
+
check(identity, now = Date.now()) {
|
|
56
|
+
const cutoff = now - this.windowMs;
|
|
57
|
+
const bucket = this.buckets.get(identity) ?? [];
|
|
58
|
+
// Drop expired entries from the front of the bucket.
|
|
59
|
+
let i = 0;
|
|
60
|
+
while (i < bucket.length && bucket[i] <= cutoff)
|
|
61
|
+
i++;
|
|
62
|
+
const fresh = i === 0 ? bucket : bucket.slice(i);
|
|
63
|
+
if (fresh.length >= this.limit) {
|
|
64
|
+
// Compute when the oldest in-window record drops off.
|
|
65
|
+
const retryAfterMs = fresh[0] + this.windowMs - now;
|
|
66
|
+
// Don't store the call we just denied — that would push the
|
|
67
|
+
// window forward and starve the next legitimate request.
|
|
68
|
+
this.buckets.set(identity, fresh);
|
|
69
|
+
return {
|
|
70
|
+
allowed: false,
|
|
71
|
+
count: fresh.length,
|
|
72
|
+
limit: this.limit,
|
|
73
|
+
windowMs: this.windowMs,
|
|
74
|
+
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
fresh.push(now);
|
|
78
|
+
this.buckets.set(identity, fresh);
|
|
79
|
+
return {
|
|
80
|
+
allowed: true,
|
|
81
|
+
count: fresh.length,
|
|
82
|
+
limit: this.limit,
|
|
83
|
+
windowMs: this.windowMs,
|
|
84
|
+
retryAfterSeconds: 0,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Read-only snapshot — useful for /api/usage and tests. */
|
|
88
|
+
inspect(identity, now = Date.now()) {
|
|
89
|
+
const cutoff = now - this.windowMs;
|
|
90
|
+
const bucket = this.buckets.get(identity) ?? [];
|
|
91
|
+
let count = 0;
|
|
92
|
+
for (const t of bucket)
|
|
93
|
+
if (t > cutoff)
|
|
94
|
+
count++;
|
|
95
|
+
return { count, limit: this.limit, windowMs: this.windowMs };
|
|
96
|
+
}
|
|
97
|
+
/** All identities we've ever seen — for /api/usage aggregation. */
|
|
98
|
+
knownIdentities() {
|
|
99
|
+
return Array.from(this.buckets.keys());
|
|
100
|
+
}
|
|
101
|
+
/** For testing — reset every identity's bucket. */
|
|
102
|
+
reset() {
|
|
103
|
+
this.buckets.clear();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { IdentityRateLimiter, resolveToolRatePerMin } from "./limiter.js";
|
|
4
|
+
test("resolveToolRatePerMin — unset / empty / non-numeric returns default 60", () => {
|
|
5
|
+
assert.equal(resolveToolRatePerMin(undefined), 60);
|
|
6
|
+
assert.equal(resolveToolRatePerMin(""), 60);
|
|
7
|
+
assert.equal(resolveToolRatePerMin("not-a-number"), 60);
|
|
8
|
+
assert.equal(resolveToolRatePerMin("NaN"), 60);
|
|
9
|
+
});
|
|
10
|
+
test("resolveToolRatePerMin — zero / negative falls back to default (limit=0 would deny every call)", () => {
|
|
11
|
+
// Footgun pin: "0" looks like "disable" but the limiter treats it as
|
|
12
|
+
// "instantly over-cap". Treat as default so operators don't lock
|
|
13
|
+
// themselves out by mistake.
|
|
14
|
+
assert.equal(resolveToolRatePerMin("0"), 60);
|
|
15
|
+
assert.equal(resolveToolRatePerMin("-1"), 60);
|
|
16
|
+
assert.equal(resolveToolRatePerMin("-1000"), 60);
|
|
17
|
+
});
|
|
18
|
+
test("resolveToolRatePerMin — positive integer passes through; decimals floored", () => {
|
|
19
|
+
assert.equal(resolveToolRatePerMin("1"), 1);
|
|
20
|
+
assert.equal(resolveToolRatePerMin("120"), 120);
|
|
21
|
+
assert.equal(resolveToolRatePerMin("240"), 240);
|
|
22
|
+
assert.equal(resolveToolRatePerMin("60.7"), 60);
|
|
23
|
+
});
|
|
24
|
+
test("allows up to the configured limit, then denies", () => {
|
|
25
|
+
const lim = new IdentityRateLimiter({ limit: 3, windowMs: 60_000 });
|
|
26
|
+
const t = 1_700_000_000_000;
|
|
27
|
+
assert.equal(lim.check("alice", t + 0).allowed, true);
|
|
28
|
+
assert.equal(lim.check("alice", t + 100).allowed, true);
|
|
29
|
+
assert.equal(lim.check("alice", t + 200).allowed, true);
|
|
30
|
+
const denied = lim.check("alice", t + 300);
|
|
31
|
+
assert.equal(denied.allowed, false);
|
|
32
|
+
assert.equal(denied.count, 3);
|
|
33
|
+
assert.equal(denied.limit, 3);
|
|
34
|
+
assert.ok(denied.retryAfterSeconds >= 1);
|
|
35
|
+
});
|
|
36
|
+
test("sliding window: expired entries free up slots", () => {
|
|
37
|
+
const lim = new IdentityRateLimiter({ limit: 2, windowMs: 10_000 });
|
|
38
|
+
const t = 1_700_000_000_000;
|
|
39
|
+
lim.check("alice", t + 0);
|
|
40
|
+
lim.check("alice", t + 5_000);
|
|
41
|
+
// At t+9s alice is still at the cap.
|
|
42
|
+
assert.equal(lim.check("alice", t + 9_000).allowed, false);
|
|
43
|
+
// At t+11s the first entry has aged out → one slot opens.
|
|
44
|
+
const after = lim.check("alice", t + 11_000);
|
|
45
|
+
assert.equal(after.allowed, true);
|
|
46
|
+
assert.equal(after.count, 2);
|
|
47
|
+
});
|
|
48
|
+
test("identities are isolated from each other", () => {
|
|
49
|
+
const lim = new IdentityRateLimiter({ limit: 1, windowMs: 60_000 });
|
|
50
|
+
const t = 1_700_000_000_000;
|
|
51
|
+
assert.equal(lim.check("alice", t).allowed, true);
|
|
52
|
+
assert.equal(lim.check("alice", t).allowed, false);
|
|
53
|
+
// bob has his own fresh bucket.
|
|
54
|
+
assert.equal(lim.check("bob", t).allowed, true);
|
|
55
|
+
});
|
|
56
|
+
test("retryAfterSeconds points at the oldest in-window record's expiry", () => {
|
|
57
|
+
const lim = new IdentityRateLimiter({ limit: 1, windowMs: 30_000 });
|
|
58
|
+
const t = 1_700_000_000_000;
|
|
59
|
+
lim.check("alice", t);
|
|
60
|
+
const denied = lim.check("alice", t + 5_000);
|
|
61
|
+
assert.equal(denied.allowed, false);
|
|
62
|
+
// 30s window started at t, so expiry is t+30s → 25s from t+5s.
|
|
63
|
+
assert.equal(denied.retryAfterSeconds, 25);
|
|
64
|
+
});
|
|
65
|
+
test("denied calls do NOT push the window forward", () => {
|
|
66
|
+
const lim = new IdentityRateLimiter({ limit: 1, windowMs: 10_000 });
|
|
67
|
+
const t = 1_700_000_000_000;
|
|
68
|
+
lim.check("alice", t);
|
|
69
|
+
// Multiple denies — none of them should reset the oldest-timestamp.
|
|
70
|
+
for (let i = 1; i < 10; i++)
|
|
71
|
+
lim.check("alice", t + i * 100);
|
|
72
|
+
// Still expecting expiry at t+10s, not pushed forward by the denies.
|
|
73
|
+
const justAfterExpiry = lim.check("alice", t + 10_001);
|
|
74
|
+
assert.equal(justAfterExpiry.allowed, true);
|
|
75
|
+
});
|
|
76
|
+
test("inspect: returns counts without consuming a slot", () => {
|
|
77
|
+
const lim = new IdentityRateLimiter({ limit: 5, windowMs: 60_000 });
|
|
78
|
+
const t = 1_700_000_000_000;
|
|
79
|
+
lim.check("alice", t);
|
|
80
|
+
lim.check("alice", t);
|
|
81
|
+
const ins = lim.inspect("alice", t);
|
|
82
|
+
assert.equal(ins.count, 2);
|
|
83
|
+
assert.equal(ins.limit, 5);
|
|
84
|
+
// Subsequent check still has room for 3 more.
|
|
85
|
+
assert.equal(lim.check("alice", t).allowed, true);
|
|
86
|
+
});
|
|
87
|
+
test("knownIdentities — returns every identity that has been checked", () => {
|
|
88
|
+
const lim = new IdentityRateLimiter({ limit: 5, windowMs: 60_000 });
|
|
89
|
+
const t = 1_700_000_000_000;
|
|
90
|
+
lim.check("alice", t);
|
|
91
|
+
lim.check("bob", t);
|
|
92
|
+
lim.check("alice", t);
|
|
93
|
+
const ids = lim.knownIdentities().sort();
|
|
94
|
+
assert.deepEqual(ids, ["alice", "bob"]);
|
|
95
|
+
});
|
|
96
|
+
test("inspect on an unknown identity returns count=0", () => {
|
|
97
|
+
const lim = new IdentityRateLimiter({ limit: 5, windowMs: 60_000 });
|
|
98
|
+
const ins = lim.inspect("never-seen");
|
|
99
|
+
assert.equal(ins.count, 0);
|
|
100
|
+
assert.equal(ins.limit, 5);
|
|
101
|
+
});
|
|
102
|
+
test("reset clears all buckets", () => {
|
|
103
|
+
const lim = new IdentityRateLimiter({ limit: 1, windowMs: 60_000 });
|
|
104
|
+
const t = 1_700_000_000_000;
|
|
105
|
+
lim.check("alice", t);
|
|
106
|
+
lim.check("bob", t);
|
|
107
|
+
lim.reset();
|
|
108
|
+
assert.equal(lim.check("alice", t).allowed, true);
|
|
109
|
+
assert.equal(lim.check("bob", t).allowed, true);
|
|
110
|
+
});
|
|
111
|
+
test("default limit applies when constructed with no args", () => {
|
|
112
|
+
const lim = new IdentityRateLimiter();
|
|
113
|
+
// Exhaust the default 60/min cap.
|
|
114
|
+
const t = 1_700_000_000_000;
|
|
115
|
+
for (let i = 0; i < 60; i++) {
|
|
116
|
+
assert.equal(lim.check("alice", t + i).allowed, true);
|
|
117
|
+
}
|
|
118
|
+
assert.equal(lim.check("alice", t + 60).allowed, false);
|
|
119
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-identity token-budget tracker.
|
|
3
|
+
*
|
|
4
|
+
* The MCP transport gets per-call sliding-window cap from
|
|
5
|
+
* `IdentityRateLimiter`. Operators with paid-tier LLM agents want a
|
|
6
|
+
* second axis: a daily token quota that limits the number of tokens
|
|
7
|
+
* a credential can pull through the tool layer in a 24-hour rolling
|
|
8
|
+
* window. This module is the data-plane half of that knob.
|
|
9
|
+
*
|
|
10
|
+
* Token estimation:
|
|
11
|
+
* The MCP tool response (and the agent's request args) cross the
|
|
12
|
+
* boundary as JSON text. We don't tokenize with a real tokenizer
|
|
13
|
+
* here — pulling in tiktoken / gpt-tokenizer would add a non-trivial
|
|
14
|
+
* wasm/dep that the airgapped-friendly posture wants to avoid. The
|
|
15
|
+
* estimate uses a deliberate over-approximation:
|
|
16
|
+
* tokens ≈ ceil(chars / 4) * 1.05
|
|
17
|
+
* which tends to over-count by ~5% vs. cl100k_base on prose payloads
|
|
18
|
+
* and ~15% on dense code/JSON. Under-counting is the worse error
|
|
19
|
+
* mode for budget control, so the rounding direction is intentional.
|
|
20
|
+
*
|
|
21
|
+
* Window:
|
|
22
|
+
* 24h rolling, bucketed at 1-hour resolution to keep memory bounded.
|
|
23
|
+
* Each bucket records (hour-aligned timestamp, tokens). On every
|
|
24
|
+
* `check()` we drop buckets older than 24h and sum the rest.
|
|
25
|
+
*
|
|
26
|
+
* Persistence is OUT OF SCOPE for this slice (planned for E6/3). The
|
|
27
|
+
* in-memory tracker is constructed fresh at boot; restart-survival
|
|
28
|
+
* requires the persistence layer.
|
|
29
|
+
*/
|
|
30
|
+
/** Estimate tokens from a string. Intentionally over-counts. */
|
|
31
|
+
export declare function estimateTokens(text: string): number;
|
|
32
|
+
/** Estimate tokens for an arbitrary JSON-serialisable value. */
|
|
33
|
+
export declare function estimateTokensFor(v: unknown): number;
|
|
34
|
+
export interface TokenBudgetConfig {
|
|
35
|
+
/** Daily cap in tokens per identity. 0 / undefined / negative
|
|
36
|
+
* disables the cap (the limiter never denies). */
|
|
37
|
+
dailyLimit?: number;
|
|
38
|
+
/** Override Date.now for tests. */
|
|
39
|
+
now?: () => number;
|
|
40
|
+
/** Optional path to a JSON snapshot file. When set, the tracker
|
|
41
|
+
* loads buckets on bootstrap() and atomically rewrites the
|
|
42
|
+
* snapshot on a debounced timer after each charge — so a server
|
|
43
|
+
* restart picks up the rolling 24h window where it left off.
|
|
44
|
+
* Unset → in-memory only (fine for demo / single-instance). */
|
|
45
|
+
filePath?: string;
|
|
46
|
+
/** Debounce window in ms for snapshot writes; default 1000. Tests
|
|
47
|
+
* pass 0 to flush synchronously between assertions. */
|
|
48
|
+
flushDebounceMs?: number;
|
|
49
|
+
}
|
|
50
|
+
export interface CheckResult {
|
|
51
|
+
allowed: boolean;
|
|
52
|
+
/** Tokens used in the trailing 24h window AFTER this call was
|
|
53
|
+
* counted (when allowed) — or as of now (when denied). */
|
|
54
|
+
used: number;
|
|
55
|
+
/** Configured daily cap. 0 means uncapped. */
|
|
56
|
+
limit: number;
|
|
57
|
+
/** Seconds until ENOUGH buckets drop off to fit the denied request.
|
|
58
|
+
* Walks the bucket list oldest-first and stops at the first
|
|
59
|
+
* timestamp where dropping every bucket older would free
|
|
60
|
+
* >= (used + tokens - limit) tokens. Rounded up. 0 when allowed
|
|
61
|
+
* (or when uncapped). */
|
|
62
|
+
retryAfterSeconds: number;
|
|
63
|
+
/** How many tokens will be available again at retryAfterSeconds.
|
|
64
|
+
* Useful for HTTP 429 bodies + Retry-After hints. 0 when allowed. */
|
|
65
|
+
freedAtRetry: number;
|
|
66
|
+
}
|
|
67
|
+
/** Per-identity 24h-rolling token budget with 1h buckets. */
|
|
68
|
+
export declare class TokenBudget {
|
|
69
|
+
private readonly limit;
|
|
70
|
+
private readonly now;
|
|
71
|
+
private readonly buckets;
|
|
72
|
+
private readonly filePath;
|
|
73
|
+
private readonly debounceMs;
|
|
74
|
+
private flushTimer;
|
|
75
|
+
private writeQueue;
|
|
76
|
+
private bootstrapped;
|
|
77
|
+
constructor(cfg?: TokenBudgetConfig);
|
|
78
|
+
/** Load a prior snapshot from disk (when filePath is set).
|
|
79
|
+
* Safe to call multiple times — bootstraps once and caches. */
|
|
80
|
+
bootstrap(): Promise<void>;
|
|
81
|
+
/** Record-and-test: does adding `tokens` keep `identity` under the
|
|
82
|
+
* daily cap? When `allowed`, the tokens are persisted into the
|
|
83
|
+
* bucket; when denied, they are NOT recorded (so a single huge
|
|
84
|
+
* request can't push the bucket arbitrarily over the cap and
|
|
85
|
+
* starve the rest of the window). */
|
|
86
|
+
check(identity: string, tokens: number, now?: number): CheckResult;
|
|
87
|
+
/** Read-only snapshot for /api/usage. */
|
|
88
|
+
inspect(identity: string, now?: number): {
|
|
89
|
+
used: number;
|
|
90
|
+
limit: number;
|
|
91
|
+
windowMs: number;
|
|
92
|
+
};
|
|
93
|
+
/** All identities the tracker has ever seen — for /api/usage aggregation. */
|
|
94
|
+
knownIdentities(): string[];
|
|
95
|
+
/** For tests — clear everything. */
|
|
96
|
+
reset(): void;
|
|
97
|
+
/** Internal: append `tokens` to the current hour's bucket for
|
|
98
|
+
* `identity`. Creates a new bucket when the hour boundary rolls. */
|
|
99
|
+
private record;
|
|
100
|
+
/** Debounce a snapshot write. No-op when filePath isn't configured. */
|
|
101
|
+
private scheduleFlush;
|
|
102
|
+
/** Write the current bucket state to disk atomically (tmp + rename).
|
|
103
|
+
* Public so a graceful shutdown can `await tokenBudget.flushNow()`. */
|
|
104
|
+
flushNow(): Promise<void>;
|
|
105
|
+
/** Internal: drop buckets older than 24h and return the remainder. */
|
|
106
|
+
private pruneOld;
|
|
107
|
+
private usedInWindow;
|
|
108
|
+
/** Walk the bucket list oldest-first until enough tokens would have
|
|
109
|
+
* dropped off to fit a request needing `needed` extra headroom.
|
|
110
|
+
* Returns the wait in ms + the cumulative freed tokens at that
|
|
111
|
+
* point. When `needed` exceeds the entire window's content (the
|
|
112
|
+
* caller wants more than the cap), returns the time until the
|
|
113
|
+
* newest bucket drops + everything freed. */
|
|
114
|
+
private nextEnoughHeadroom;
|
|
115
|
+
}
|
|
116
|
+
/** Parse OMCP_TOOL_DAILY_TOKENS into a daily limit. Mirrors the
|
|
117
|
+
* resolveToolRatePerMin pattern: unset / empty / non-numeric /
|
|
118
|
+
* zero / negative → uncapped (0). Positive integers pass through. */
|
|
119
|
+
export declare function resolveDailyTokenLimit(raw: string | undefined): number;
|