@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.
Files changed (111) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/audit/log.d.ts +99 -0
  3. package/dist/audit/log.js +180 -0
  4. package/dist/audit/log.test.d.ts +1 -0
  5. package/dist/audit/log.test.js +147 -0
  6. package/dist/audit/middleware.d.ts +20 -0
  7. package/dist/audit/middleware.js +50 -0
  8. package/dist/auth/credentials.d.ts +18 -0
  9. package/dist/auth/credentials.js +26 -1
  10. package/dist/auth/credentials.test.js +26 -1
  11. package/dist/auth/local-users.d.ts +62 -0
  12. package/dist/auth/local-users.js +143 -0
  13. package/dist/auth/local-users.test.d.ts +1 -0
  14. package/dist/auth/local-users.test.js +80 -0
  15. package/dist/auth/middleware.d.ts +48 -0
  16. package/dist/auth/middleware.js +65 -0
  17. package/dist/auth/middleware.test.d.ts +1 -0
  18. package/dist/auth/middleware.test.js +90 -0
  19. package/dist/auth/oidc/client.d.ts +73 -0
  20. package/dist/auth/oidc/client.js +104 -0
  21. package/dist/auth/oidc/client.test.d.ts +1 -0
  22. package/dist/auth/oidc/client.test.js +121 -0
  23. package/dist/auth/oidc/discovery.d.ts +38 -0
  24. package/dist/auth/oidc/discovery.js +48 -0
  25. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  26. package/dist/auth/oidc/discovery.test.js +68 -0
  27. package/dist/auth/oidc/endpoints.d.ts +20 -0
  28. package/dist/auth/oidc/endpoints.js +124 -0
  29. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  30. package/dist/auth/oidc/endpoints.test.js +304 -0
  31. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  32. package/dist/auth/oidc/flow-cookie.js +142 -0
  33. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  34. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  35. package/dist/auth/oidc/index.d.ts +7 -0
  36. package/dist/auth/oidc/index.js +6 -0
  37. package/dist/auth/oidc/jwks.d.ts +36 -0
  38. package/dist/auth/oidc/jwks.js +69 -0
  39. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  40. package/dist/auth/oidc/jwks.test.js +65 -0
  41. package/dist/auth/oidc/jwt.d.ts +62 -0
  42. package/dist/auth/oidc/jwt.js +113 -0
  43. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  44. package/dist/auth/oidc/jwt.test.js +141 -0
  45. package/dist/auth/oidc/pkce.d.ts +19 -0
  46. package/dist/auth/oidc/pkce.js +43 -0
  47. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  48. package/dist/auth/oidc/pkce.test.js +55 -0
  49. package/dist/auth/oidc/runtime.d.ts +63 -0
  50. package/dist/auth/oidc/runtime.js +129 -0
  51. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  52. package/dist/auth/oidc/runtime.test.js +180 -0
  53. package/dist/auth/policy/engine.d.ts +48 -0
  54. package/dist/auth/policy/engine.js +73 -0
  55. package/dist/auth/policy/engine.test.d.ts +1 -0
  56. package/dist/auth/policy/engine.test.js +98 -0
  57. package/dist/auth/policy/loader.d.ts +35 -0
  58. package/dist/auth/policy/loader.js +100 -0
  59. package/dist/auth/policy/opa.d.ts +69 -0
  60. package/dist/auth/policy/opa.js +162 -0
  61. package/dist/auth/policy/opa.test.d.ts +1 -0
  62. package/dist/auth/policy/opa.test.js +158 -0
  63. package/dist/auth/rbac.d.ts +40 -0
  64. package/dist/auth/rbac.js +120 -0
  65. package/dist/auth/rbac.test.d.ts +1 -0
  66. package/dist/auth/rbac.test.js +121 -0
  67. package/dist/auth/session.d.ts +66 -0
  68. package/dist/auth/session.js +146 -0
  69. package/dist/auth/session.test.d.ts +1 -0
  70. package/dist/auth/session.test.js +90 -0
  71. package/dist/catalog/loader.d.ts +67 -0
  72. package/dist/catalog/loader.js +122 -0
  73. package/dist/catalog/loader.test.d.ts +1 -0
  74. package/dist/catalog/loader.test.js +108 -0
  75. package/dist/connectors/kubernetes.d.ts +1 -0
  76. package/dist/connectors/kubernetes.js +12 -2
  77. package/dist/connectors/topology-vocabulary.d.ts +41 -0
  78. package/dist/connectors/topology-vocabulary.js +120 -0
  79. package/dist/connectors/topology-vocabulary.test.d.ts +1 -0
  80. package/dist/connectors/topology-vocabulary.test.js +63 -0
  81. package/dist/context.d.ts +13 -1
  82. package/dist/context.js +5 -1
  83. package/dist/index.js +1012 -29
  84. package/dist/net/egress-policy.js +2 -0
  85. package/dist/openapi.js +440 -0
  86. package/dist/openapi.test.d.ts +1 -0
  87. package/dist/openapi.test.js +64 -0
  88. package/dist/policy/redact.d.ts +44 -0
  89. package/dist/policy/redact.js +144 -0
  90. package/dist/policy/redact.test.d.ts +1 -0
  91. package/dist/policy/redact.test.js +172 -0
  92. package/dist/products/loader.d.ts +84 -0
  93. package/dist/products/loader.js +216 -0
  94. package/dist/products/loader.test.d.ts +1 -0
  95. package/dist/products/loader.test.js +168 -0
  96. package/dist/quota/limiter.d.ts +72 -0
  97. package/dist/quota/limiter.js +105 -0
  98. package/dist/quota/limiter.test.d.ts +1 -0
  99. package/dist/quota/limiter.test.js +119 -0
  100. package/dist/quota/token-budget.d.ts +119 -0
  101. package/dist/quota/token-budget.js +297 -0
  102. package/dist/quota/token-budget.test.d.ts +1 -0
  103. package/dist/quota/token-budget.test.js +215 -0
  104. package/dist/tenancy/context.d.ts +45 -0
  105. package/dist/tenancy/context.js +97 -0
  106. package/dist/tenancy/context.test.d.ts +1 -0
  107. package/dist/tenancy/context.test.js +72 -0
  108. package/dist/tenancy/migration.test.d.ts +7 -0
  109. package/dist/tenancy/migration.test.js +75 -0
  110. package/dist/ui/index.html +1454 -88
  111. package/package.json +20 -3
@@ -0,0 +1,297 @@
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
+ import { readFile, writeFile, rename } from "node:fs/promises";
31
+ const HOUR_MS = 60 * 60 * 1000;
32
+ const WINDOW_MS = 24 * HOUR_MS;
33
+ /** Estimate tokens from a string. Intentionally over-counts. */
34
+ export function estimateTokens(text) {
35
+ if (!text)
36
+ return 0;
37
+ // Chars/4 is the cl100k rule-of-thumb. Multiplied by 1.05 to push
38
+ // the estimate slightly above the real value — quota enforcement
39
+ // wants false-positives over false-negatives.
40
+ return Math.ceil((text.length / 4) * 1.05);
41
+ }
42
+ /** Estimate tokens for an arbitrary JSON-serialisable value. */
43
+ export function estimateTokensFor(v) {
44
+ if (v === undefined || v === null)
45
+ return 0;
46
+ if (typeof v === "string")
47
+ return estimateTokens(v);
48
+ try {
49
+ return estimateTokens(JSON.stringify(v));
50
+ }
51
+ catch {
52
+ return 0;
53
+ }
54
+ }
55
+ /** Per-identity 24h-rolling token budget with 1h buckets. */
56
+ export class TokenBudget {
57
+ limit;
58
+ now;
59
+ buckets = new Map();
60
+ filePath;
61
+ debounceMs;
62
+ flushTimer = null;
63
+ writeQueue = Promise.resolve();
64
+ bootstrapped = null;
65
+ constructor(cfg = {}) {
66
+ this.limit = cfg.dailyLimit && cfg.dailyLimit > 0 ? Math.floor(cfg.dailyLimit) : 0;
67
+ this.now = cfg.now ?? Date.now;
68
+ this.filePath = cfg.filePath;
69
+ this.debounceMs = cfg.flushDebounceMs ?? 1000;
70
+ }
71
+ /** Load a prior snapshot from disk (when filePath is set).
72
+ * Safe to call multiple times — bootstraps once and caches. */
73
+ async bootstrap() {
74
+ if (!this.filePath)
75
+ return;
76
+ if (this.bootstrapped)
77
+ return this.bootstrapped;
78
+ this.bootstrapped = (async () => {
79
+ let raw;
80
+ try {
81
+ raw = await readFile(this.filePath, "utf8");
82
+ }
83
+ catch (e) {
84
+ // ENOENT on a first run is fine; everything else is worth a
85
+ // warning so an operator notices a missing-mount / perms
86
+ // issue immediately rather than discovering quotas reset
87
+ // silently on next restart.
88
+ if (e.code !== "ENOENT") {
89
+ console.warn(`[token-budget] could not read snapshot ${this.filePath}: ${e.message} — starting fresh`);
90
+ }
91
+ return;
92
+ }
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(raw);
96
+ }
97
+ catch (e) {
98
+ console.warn(`[token-budget] snapshot ${this.filePath} is not valid JSON (${e.message}) — starting fresh, prior 24h charges are lost`);
99
+ return;
100
+ }
101
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
102
+ console.warn(`[token-budget] snapshot ${this.filePath} root is not a JSON object — starting fresh`);
103
+ return;
104
+ }
105
+ const now = this.now();
106
+ const cutoff = now - WINDOW_MS;
107
+ for (const [identity, raw] of Object.entries(parsed)) {
108
+ if (!Array.isArray(raw))
109
+ continue;
110
+ const kept = [];
111
+ for (const b of raw) {
112
+ if (!b || typeof b !== "object")
113
+ continue;
114
+ const at = b.at;
115
+ const tokens = b.tokens;
116
+ if (typeof at !== "number" || typeof tokens !== "number" || tokens <= 0)
117
+ continue;
118
+ if (at < cutoff)
119
+ continue;
120
+ kept.push({ at, tokens });
121
+ }
122
+ kept.sort((a, b) => a.at - b.at);
123
+ if (kept.length > 0)
124
+ this.buckets.set(identity, kept);
125
+ }
126
+ })();
127
+ return this.bootstrapped;
128
+ }
129
+ /** Record-and-test: does adding `tokens` keep `identity` under the
130
+ * daily cap? When `allowed`, the tokens are persisted into the
131
+ * bucket; when denied, they are NOT recorded (so a single huge
132
+ * request can't push the bucket arbitrarily over the cap and
133
+ * starve the rest of the window). */
134
+ check(identity, tokens, now = this.now()) {
135
+ if (this.limit <= 0) {
136
+ // Uncapped → always allow, still track usage for /api/usage.
137
+ this.record(identity, tokens, now);
138
+ return { allowed: true, used: this.usedInWindow(identity, now), limit: 0, retryAfterSeconds: 0, freedAtRetry: 0 };
139
+ }
140
+ const safeTokens = tokens > 0 ? Math.floor(tokens) : 0;
141
+ const existing = this.usedInWindow(identity, now);
142
+ if (existing + safeTokens > this.limit) {
143
+ const needed = existing + safeTokens - this.limit;
144
+ const { waitMs, freed } = this.nextEnoughHeadroom(identity, now, needed);
145
+ return {
146
+ allowed: false,
147
+ used: existing,
148
+ limit: this.limit,
149
+ retryAfterSeconds: waitMs > 0 ? Math.max(1, Math.ceil(waitMs / 1000)) : 1,
150
+ freedAtRetry: freed,
151
+ };
152
+ }
153
+ this.record(identity, safeTokens, now);
154
+ return {
155
+ allowed: true,
156
+ used: existing + safeTokens,
157
+ limit: this.limit,
158
+ retryAfterSeconds: 0,
159
+ freedAtRetry: 0,
160
+ };
161
+ }
162
+ /** Read-only snapshot for /api/usage. */
163
+ inspect(identity, now = this.now()) {
164
+ return { used: this.usedInWindow(identity, now), limit: this.limit, windowMs: WINDOW_MS };
165
+ }
166
+ /** All identities the tracker has ever seen — for /api/usage aggregation. */
167
+ knownIdentities() {
168
+ return Array.from(this.buckets.keys());
169
+ }
170
+ /** For tests — clear everything. */
171
+ reset() {
172
+ this.buckets.clear();
173
+ }
174
+ /** Internal: append `tokens` to the current hour's bucket for
175
+ * `identity`. Creates a new bucket when the hour boundary rolls. */
176
+ record(identity, tokens, now) {
177
+ if (tokens <= 0)
178
+ return;
179
+ const hourAt = Math.floor(now / HOUR_MS) * HOUR_MS;
180
+ const fresh = this.pruneOld(identity, now);
181
+ const last = fresh[fresh.length - 1];
182
+ if (last && last.at === hourAt) {
183
+ last.tokens += tokens;
184
+ }
185
+ else {
186
+ fresh.push({ at: hourAt, tokens });
187
+ }
188
+ this.buckets.set(identity, fresh);
189
+ this.scheduleFlush();
190
+ }
191
+ /** Debounce a snapshot write. No-op when filePath isn't configured. */
192
+ scheduleFlush() {
193
+ if (!this.filePath)
194
+ return;
195
+ if (this.flushTimer)
196
+ return;
197
+ if (this.debounceMs === 0) {
198
+ // Tests pass 0 so the write happens before the next assertion.
199
+ void this.flushNow();
200
+ return;
201
+ }
202
+ this.flushTimer = setTimeout(() => {
203
+ this.flushTimer = null;
204
+ void this.flushNow();
205
+ }, this.debounceMs);
206
+ // Don't keep the event loop alive for the snapshot flush —
207
+ // the in-memory state is the truth; the file is a recovery
208
+ // aid. Process shutdown without one final flush loses at
209
+ // most one debounce window of charge data.
210
+ if (typeof this.flushTimer.unref === "function")
211
+ this.flushTimer.unref();
212
+ }
213
+ /** Write the current bucket state to disk atomically (tmp + rename).
214
+ * Public so a graceful shutdown can `await tokenBudget.flushNow()`. */
215
+ async flushNow() {
216
+ if (!this.filePath)
217
+ return;
218
+ const path = this.filePath;
219
+ // Build the snapshot synchronously so we capture a consistent
220
+ // point-in-time view of the map, then write asynchronously.
221
+ const snapshot = {};
222
+ for (const [id, buckets] of this.buckets) {
223
+ if (buckets.length > 0)
224
+ snapshot[id] = buckets;
225
+ }
226
+ const body = JSON.stringify(snapshot);
227
+ this.writeQueue = this.writeQueue.then(async () => {
228
+ try {
229
+ const tmp = path + ".tmp";
230
+ await writeFile(tmp, body, "utf8");
231
+ await rename(tmp, path);
232
+ }
233
+ catch {
234
+ // Best-effort: persistence is recovery insurance, not the
235
+ // source of truth. A failed write doesn't poison in-memory
236
+ // state.
237
+ }
238
+ });
239
+ return this.writeQueue;
240
+ }
241
+ /** Internal: drop buckets older than 24h and return the remainder. */
242
+ pruneOld(identity, now) {
243
+ const cutoff = now - WINDOW_MS;
244
+ const buckets = this.buckets.get(identity) ?? [];
245
+ let i = 0;
246
+ while (i < buckets.length && buckets[i].at < cutoff)
247
+ i++;
248
+ if (i === 0)
249
+ return buckets;
250
+ const kept = buckets.slice(i);
251
+ this.buckets.set(identity, kept);
252
+ return kept;
253
+ }
254
+ usedInWindow(identity, now) {
255
+ const fresh = this.pruneOld(identity, now);
256
+ let total = 0;
257
+ for (const b of fresh)
258
+ total += b.tokens;
259
+ return total;
260
+ }
261
+ /** Walk the bucket list oldest-first until enough tokens would have
262
+ * dropped off to fit a request needing `needed` extra headroom.
263
+ * Returns the wait in ms + the cumulative freed tokens at that
264
+ * point. When `needed` exceeds the entire window's content (the
265
+ * caller wants more than the cap), returns the time until the
266
+ * newest bucket drops + everything freed. */
267
+ nextEnoughHeadroom(identity, now, needed) {
268
+ const fresh = this.pruneOld(identity, now);
269
+ if (fresh.length === 0)
270
+ return { waitMs: 0, freed: 0 };
271
+ let freed = 0;
272
+ for (const b of fresh) {
273
+ freed += b.tokens;
274
+ if (freed >= needed) {
275
+ const dropAt = b.at + WINDOW_MS;
276
+ return { waitMs: Math.max(0, dropAt - now), freed };
277
+ }
278
+ }
279
+ // Even dropping every bucket isn't enough — the request alone
280
+ // exceeds the daily cap. Return the time until the newest bucket
281
+ // drops so the caller knows the window will be empty by then; the
282
+ // request will still get rejected on size if it exceeds limit.
283
+ const newest = fresh[fresh.length - 1];
284
+ return { waitMs: Math.max(0, newest.at + WINDOW_MS - now), freed };
285
+ }
286
+ }
287
+ /** Parse OMCP_TOOL_DAILY_TOKENS into a daily limit. Mirrors the
288
+ * resolveToolRatePerMin pattern: unset / empty / non-numeric /
289
+ * zero / negative → uncapped (0). Positive integers pass through. */
290
+ export function resolveDailyTokenLimit(raw) {
291
+ if (raw === undefined || raw === "")
292
+ return 0;
293
+ const n = Number(raw);
294
+ if (!Number.isFinite(n) || n <= 0)
295
+ return 0;
296
+ return Math.floor(n);
297
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,215 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { TokenBudget, estimateTokens, estimateTokensFor, resolveDailyTokenLimit } from "./token-budget.js";
7
+ test("estimateTokens — empty/null/undefined → 0", () => {
8
+ assert.equal(estimateTokens(""), 0);
9
+ });
10
+ test("estimateTokens — over-counts by design (~5% above chars/4)", () => {
11
+ // 100 chars → cl100k actual is ~22-25; our estimate is ceil(100/4 * 1.05) = 27.
12
+ // We want > chars/4 so quota enforcement errs on the strict side.
13
+ const t = estimateTokens("x".repeat(100));
14
+ assert.ok(t >= 26, `expected ≥26, got ${t}`);
15
+ assert.ok(t <= 30, `expected ≤30, got ${t}`);
16
+ });
17
+ test("estimateTokensFor — handles non-string values via JSON serialisation", () => {
18
+ assert.equal(estimateTokensFor(null), 0);
19
+ assert.equal(estimateTokensFor(undefined), 0);
20
+ assert.ok(estimateTokensFor({ a: 1, b: "hello" }) > 0);
21
+ assert.ok(estimateTokensFor([1, 2, 3]) > 0);
22
+ });
23
+ test("estimateTokensFor — circular / non-serialisable returns 0 (don't crash)", () => {
24
+ const a = {};
25
+ a.self = a;
26
+ assert.equal(estimateTokensFor(a), 0);
27
+ });
28
+ test("TokenBudget — uncapped allows everything but still tracks usage", () => {
29
+ const t = 1_700_000_000_000;
30
+ const b = new TokenBudget({ dailyLimit: 0, now: () => t });
31
+ const r = b.check("alice", 999_999);
32
+ assert.equal(r.allowed, true);
33
+ assert.equal(r.limit, 0);
34
+ assert.equal(b.inspect("alice").used, 999_999);
35
+ });
36
+ test("TokenBudget — allows up to the daily cap, denies the request that would exceed", () => {
37
+ const t = 1_700_000_000_000;
38
+ const b = new TokenBudget({ dailyLimit: 1000, now: () => t });
39
+ assert.equal(b.check("alice", 600).allowed, true);
40
+ assert.equal(b.check("alice", 300).allowed, true);
41
+ // 600 + 300 = 900; +200 would push to 1100 → deny
42
+ const denied = b.check("alice", 200);
43
+ assert.equal(denied.allowed, false);
44
+ assert.equal(denied.used, 900, "denied request must NOT have been recorded");
45
+ assert.equal(denied.limit, 1000);
46
+ assert.ok(denied.retryAfterSeconds > 0);
47
+ // Subsequent small request within remaining headroom still works
48
+ assert.equal(b.check("alice", 50).allowed, true);
49
+ });
50
+ test("TokenBudget — 24h rolling: buckets older than 24h drop off", () => {
51
+ let now = 1_700_000_000_000;
52
+ const b = new TokenBudget({ dailyLimit: 1000, now: () => now });
53
+ b.check("alice", 800); // bucket at hour 0
54
+ now += 23 * 60 * 60 * 1000; // +23h: still in window
55
+ assert.equal(b.inspect("alice").used, 800);
56
+ now += 2 * 60 * 60 * 1000; // +25h total: bucket from hour 0 drops
57
+ assert.equal(b.inspect("alice").used, 0);
58
+ // Full daily budget available again
59
+ assert.equal(b.check("alice", 1000).allowed, true);
60
+ });
61
+ test("TokenBudget — denied request returns retryAfter ≈ time until enough buckets drop to fit the request", () => {
62
+ let now = 1_700_000_000_000;
63
+ const b = new TokenBudget({ dailyLimit: 100, now: () => now });
64
+ b.check("alice", 100); // fully consumed at hour 0
65
+ now += 60 * 60 * 1000; // +1h
66
+ const denied = b.check("alice", 1);
67
+ assert.equal(denied.allowed, false);
68
+ // Need 1 free; oldest bucket (100 tokens) drops at hour 24 → ~23h wait.
69
+ const expectedSeconds = 23 * 60 * 60;
70
+ assert.ok(Math.abs(denied.retryAfterSeconds - expectedSeconds) < 3600, `expected ~${expectedSeconds}s, got ${denied.retryAfterSeconds}s`);
71
+ // freedAtRetry exposes how much will be available
72
+ assert.equal(denied.freedAtRetry, 100);
73
+ });
74
+ test("TokenBudget — retryAfter walks enough buckets to fit a LARGER request", () => {
75
+ let now = 1_700_000_000_000;
76
+ const HOUR = 60 * 60 * 1000;
77
+ const b = new TokenBudget({ dailyLimit: 1000, now: () => now });
78
+ // Three 300-token calls spread across 3 different hours.
79
+ b.check("alice", 300, now); // bucket hour 0
80
+ b.check("alice", 300, now + HOUR); // bucket hour 1
81
+ b.check("alice", 400, now + 2 * HOUR); // bucket hour 2 — total 1000
82
+ now += 3 * HOUR;
83
+ // Now request 700 more. Need 700 free. Dropping bucket@hour0 (300)
84
+ // only frees 300 — not enough. Dropping bucket@hour1 (300 more)
85
+ // gets to 600 — still not enough. Dropping bucket@hour2 (400 more)
86
+ // gets to 1000 — fits 700 with headroom.
87
+ const denied = b.check("alice", 700);
88
+ assert.equal(denied.allowed, false);
89
+ // Must wait until bucket@hour1 drops (at hour 1 + 24 = hour 25),
90
+ // we are at hour 3 → 22h wait? No — we need bucket@hour1 to drop to
91
+ // get freed=600, still not enough. Need bucket@hour2 → drops at
92
+ // hour 26, we're at hour 3 → 23h wait, with 1000 freed by then.
93
+ const expectedSeconds = 23 * 60 * 60;
94
+ assert.ok(Math.abs(denied.retryAfterSeconds - expectedSeconds) < 3600, `expected ~${expectedSeconds}s, got ${denied.retryAfterSeconds}s`);
95
+ assert.equal(denied.freedAtRetry, 1000, "all three buckets must have dropped to fit the 700 request");
96
+ });
97
+ test("TokenBudget — per-identity isolation (alice's bucket doesn't affect bob)", () => {
98
+ const t = 1_700_000_000_000;
99
+ const b = new TokenBudget({ dailyLimit: 1000, now: () => t });
100
+ b.check("alice", 1000); // fully consumed
101
+ assert.equal(b.check("alice", 1).allowed, false);
102
+ assert.equal(b.check("bob", 500).allowed, true);
103
+ assert.equal(b.inspect("bob").used, 500);
104
+ });
105
+ test("TokenBudget — hour bucket aggregation: 3 calls in the same hour share one bucket", () => {
106
+ let now = 1_700_000_000_000;
107
+ const b = new TokenBudget({ dailyLimit: 10000, now: () => now });
108
+ b.check("alice", 100, now);
109
+ b.check("alice", 200, now + 5_000);
110
+ b.check("alice", 50, now + 10_000);
111
+ assert.equal(b.inspect("alice").used, 350);
112
+ });
113
+ test("TokenBudget — knownIdentities surfaces every identity seen", () => {
114
+ const t = 1_700_000_000_000;
115
+ const b = new TokenBudget({ dailyLimit: 0, now: () => t });
116
+ b.check("a", 1);
117
+ b.check("b", 1);
118
+ b.check("a", 1);
119
+ assert.deepEqual(b.knownIdentities().sort(), ["a", "b"]);
120
+ });
121
+ test("TokenBudget — zero/negative tokens silently dropped", () => {
122
+ const t = 1_700_000_000_000;
123
+ const b = new TokenBudget({ dailyLimit: 1000, now: () => t });
124
+ b.check("alice", 0);
125
+ b.check("alice", -10);
126
+ assert.equal(b.inspect("alice").used, 0);
127
+ });
128
+ test("resolveDailyTokenLimit — unset/empty/zero/negative/non-numeric → 0 (uncapped)", () => {
129
+ assert.equal(resolveDailyTokenLimit(undefined), 0);
130
+ assert.equal(resolveDailyTokenLimit(""), 0);
131
+ assert.equal(resolveDailyTokenLimit("0"), 0);
132
+ assert.equal(resolveDailyTokenLimit("-100"), 0);
133
+ assert.equal(resolveDailyTokenLimit("not-a-number"), 0);
134
+ assert.equal(resolveDailyTokenLimit("NaN"), 0);
135
+ });
136
+ test("resolveDailyTokenLimit — positive integers pass through; decimals floored", () => {
137
+ assert.equal(resolveDailyTokenLimit("50000"), 50000);
138
+ assert.equal(resolveDailyTokenLimit("1"), 1);
139
+ assert.equal(resolveDailyTokenLimit("1234.7"), 1234);
140
+ });
141
+ test("TokenBudget persistence — flushNow writes a snapshot that bootstrap() reads back", async () => {
142
+ const dir = await mkdtemp(join(tmpdir(), "omcp-token-"));
143
+ const file = join(dir, "budget.json");
144
+ try {
145
+ const t = 1_700_000_000_000;
146
+ const b1 = new TokenBudget({ dailyLimit: 1000, filePath: file, flushDebounceMs: 0, now: () => t });
147
+ b1.check("alice", 300);
148
+ b1.check("bob", 700);
149
+ await b1.flushNow();
150
+ const text = await readFile(file, "utf8");
151
+ const parsed = JSON.parse(text);
152
+ assert.equal(parsed.alice[0].tokens, 300);
153
+ assert.equal(parsed.bob[0].tokens, 700);
154
+ // A fresh tracker pointed at the same file picks up the buckets.
155
+ const b2 = new TokenBudget({ dailyLimit: 1000, filePath: file, flushDebounceMs: 0, now: () => t });
156
+ await b2.bootstrap();
157
+ assert.equal(b2.inspect("alice").used, 300);
158
+ assert.equal(b2.inspect("bob").used, 700);
159
+ }
160
+ finally {
161
+ await rm(dir, { recursive: true, force: true });
162
+ }
163
+ });
164
+ test("TokenBudget persistence — bootstrap drops entries older than 24h", async () => {
165
+ const dir = await mkdtemp(join(tmpdir(), "omcp-token-"));
166
+ const file = join(dir, "budget.json");
167
+ try {
168
+ const t0 = 1_700_000_000_000;
169
+ const b1 = new TokenBudget({ dailyLimit: 1000, filePath: file, flushDebounceMs: 0, now: () => t0 });
170
+ b1.check("alice", 500);
171
+ await b1.flushNow();
172
+ // Restart 30h later — the alice entry should drop on bootstrap.
173
+ const tLater = t0 + 30 * 60 * 60 * 1000;
174
+ const b2 = new TokenBudget({ dailyLimit: 1000, filePath: file, flushDebounceMs: 0, now: () => tLater });
175
+ await b2.bootstrap();
176
+ assert.equal(b2.inspect("alice").used, 0, "expired buckets must drop on bootstrap");
177
+ }
178
+ finally {
179
+ await rm(dir, { recursive: true, force: true });
180
+ }
181
+ });
182
+ test("TokenBudget persistence — corrupt snapshot is tolerated (start fresh, don't crash)", async () => {
183
+ const dir = await mkdtemp(join(tmpdir(), "omcp-token-"));
184
+ const file = join(dir, "budget.json");
185
+ try {
186
+ // Write something that's not valid JSON.
187
+ const fs = await import("node:fs/promises");
188
+ await fs.writeFile(file, "{not: json", "utf8");
189
+ const b = new TokenBudget({ dailyLimit: 1000, filePath: file, flushDebounceMs: 0 });
190
+ await b.bootstrap();
191
+ // Tracker should be empty; subsequent operations should work fine.
192
+ assert.equal(b.inspect("alice").used, 0);
193
+ b.check("alice", 100);
194
+ assert.equal(b.inspect("alice").used, 100);
195
+ }
196
+ finally {
197
+ await rm(dir, { recursive: true, force: true });
198
+ }
199
+ });
200
+ test("TokenBudget persistence — debounced flush eventually writes (default 1s)", async () => {
201
+ const dir = await mkdtemp(join(tmpdir(), "omcp-token-"));
202
+ const file = join(dir, "budget.json");
203
+ try {
204
+ const b = new TokenBudget({ dailyLimit: 1000, filePath: file, flushDebounceMs: 50 });
205
+ b.check("alice", 42);
206
+ // Wait past the debounce window
207
+ await new Promise((r) => setTimeout(r, 120));
208
+ const text = await readFile(file, "utf8");
209
+ const parsed = JSON.parse(text);
210
+ assert.equal(parsed.alice[0].tokens, 42);
211
+ }
212
+ finally {
213
+ await rm(dir, { recursive: true, force: true });
214
+ }
215
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Multi-tenant context primitives.
3
+ *
4
+ * Every request lands in EXACTLY ONE tenant. Identities resolve to a
5
+ * tenant via one of three paths:
6
+ *
7
+ * 1. Anonymous (no auth, the demo / single-operator path) → DEFAULT_TENANT.
8
+ * 2. Basic-mode local user → user file's optional `tenant` field;
9
+ * missing → DEFAULT_TENANT (so existing single-tenant deployments
10
+ * keep working without any config change).
11
+ * 3. OIDC session → OMCP_OIDC_TENANT_CLAIM (default `tenant`);
12
+ * empty / missing claim → DEFAULT_TENANT.
13
+ * 4. MCP credential (bearer token) → optional per-credential
14
+ * `tenant` field assigned via OMCP_KEY_TENANTS env, mirroring
15
+ * OMCP_KEY_SOURCES / OMCP_KEY_BYPASS_REDACTION shape.
16
+ *
17
+ * The constant `DEFAULT_TENANT` is the universal escape hatch — any
18
+ * non-multi-tenant deployment behaves as if everything is in
19
+ * tenant `default`, identical to the pre-E7 single-namespace world.
20
+ *
21
+ * Cross-tenant requests return 404 (not 403) per the plan — leaking
22
+ * existence by status code defeats half the point of isolation.
23
+ */
24
+ export declare const DEFAULT_TENANT = "default";
25
+ /** Maximum tenant identifier length. Defence-in-depth against a
26
+ * hostile claim payload pushing arbitrary KB-sized strings through
27
+ * every cookie. Operators with longer names should pick shorter
28
+ * ones; the audit chain still hashes the full string but the
29
+ * cookie payload + log lines stay bounded. */
30
+ export declare const MAX_TENANT_LENGTH = 64;
31
+ /** Normalise + validate a tenant identifier. Returns the trimmed,
32
+ * lower-cased string when valid; DEFAULT_TENANT for empty or
33
+ * invalid input (silent fallback rather than crash — an OIDC claim
34
+ * with junk should drop the user into the safe default, not 500
35
+ * the whole flow). */
36
+ export declare function normaliseTenant(raw: unknown): string;
37
+ /** Walk a dotted-path claim out of an arbitrary claim set, then
38
+ * normalise. Used for OIDC sessions where the tenant lives at e.g.
39
+ * `app.tenant_id` rather than the top level. */
40
+ export declare function tenantFromClaim(claims: Record<string, unknown>, claimPath: string): string;
41
+ /** Parse OMCP_KEY_TENANTS="ci=acme;agent=bigco" into a name → tenant
42
+ * map. Mirrors parseKeySources in auth/credentials.ts so the operator
43
+ * cognitive load stays low. Invalid tenant strings normalise to
44
+ * DEFAULT_TENANT silently. */
45
+ export declare function parseKeyTenants(raw: string | undefined): Map<string, string>;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Multi-tenant context primitives.
3
+ *
4
+ * Every request lands in EXACTLY ONE tenant. Identities resolve to a
5
+ * tenant via one of three paths:
6
+ *
7
+ * 1. Anonymous (no auth, the demo / single-operator path) → DEFAULT_TENANT.
8
+ * 2. Basic-mode local user → user file's optional `tenant` field;
9
+ * missing → DEFAULT_TENANT (so existing single-tenant deployments
10
+ * keep working without any config change).
11
+ * 3. OIDC session → OMCP_OIDC_TENANT_CLAIM (default `tenant`);
12
+ * empty / missing claim → DEFAULT_TENANT.
13
+ * 4. MCP credential (bearer token) → optional per-credential
14
+ * `tenant` field assigned via OMCP_KEY_TENANTS env, mirroring
15
+ * OMCP_KEY_SOURCES / OMCP_KEY_BYPASS_REDACTION shape.
16
+ *
17
+ * The constant `DEFAULT_TENANT` is the universal escape hatch — any
18
+ * non-multi-tenant deployment behaves as if everything is in
19
+ * tenant `default`, identical to the pre-E7 single-namespace world.
20
+ *
21
+ * Cross-tenant requests return 404 (not 403) per the plan — leaking
22
+ * existence by status code defeats half the point of isolation.
23
+ */
24
+ export const DEFAULT_TENANT = "default";
25
+ /** Maximum tenant identifier length. Defence-in-depth against a
26
+ * hostile claim payload pushing arbitrary KB-sized strings through
27
+ * every cookie. Operators with longer names should pick shorter
28
+ * ones; the audit chain still hashes the full string but the
29
+ * cookie payload + log lines stay bounded. */
30
+ export const MAX_TENANT_LENGTH = 64;
31
+ /** Pattern: alphanumeric + `-` + `_` + `.`. Mirrors what most CI
32
+ * identifiers accept. Rejects `/`, space, control chars (which
33
+ * could break filesystem layouts in slice 2). */
34
+ const VALID_TENANT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
35
+ /** Normalise + validate a tenant identifier. Returns the trimmed,
36
+ * lower-cased string when valid; DEFAULT_TENANT for empty or
37
+ * invalid input (silent fallback rather than crash — an OIDC claim
38
+ * with junk should drop the user into the safe default, not 500
39
+ * the whole flow). */
40
+ export function normaliseTenant(raw) {
41
+ if (typeof raw !== "string")
42
+ return DEFAULT_TENANT;
43
+ const trimmed = raw.trim().toLowerCase();
44
+ if (trimmed.length === 0)
45
+ return DEFAULT_TENANT;
46
+ if (trimmed.length > MAX_TENANT_LENGTH)
47
+ return DEFAULT_TENANT;
48
+ if (!VALID_TENANT_RE.test(trimmed))
49
+ return DEFAULT_TENANT;
50
+ return trimmed;
51
+ }
52
+ /** Walk a dotted-path claim out of an arbitrary claim set, then
53
+ * normalise. Used for OIDC sessions where the tenant lives at e.g.
54
+ * `app.tenant_id` rather than the top level. */
55
+ export function tenantFromClaim(claims, claimPath) {
56
+ if (!claimPath)
57
+ return DEFAULT_TENANT;
58
+ const parts = claimPath.split(".");
59
+ let cur = claims;
60
+ for (const p of parts) {
61
+ if (cur && typeof cur === "object" && !Array.isArray(cur) && p in cur) {
62
+ cur = cur[p];
63
+ }
64
+ else {
65
+ return DEFAULT_TENANT;
66
+ }
67
+ }
68
+ // Arrays: take the first string-shaped entry (the same posture as
69
+ // resolveRoles in auth/oidc/runtime.ts). Operators wanting per-call
70
+ // multi-tenancy should use one tenant claim per token, not a list.
71
+ if (Array.isArray(cur)) {
72
+ for (const v of cur)
73
+ if (typeof v === "string")
74
+ return normaliseTenant(v);
75
+ return DEFAULT_TENANT;
76
+ }
77
+ return normaliseTenant(cur);
78
+ }
79
+ /** Parse OMCP_KEY_TENANTS="ci=acme;agent=bigco" into a name → tenant
80
+ * map. Mirrors parseKeySources in auth/credentials.ts so the operator
81
+ * cognitive load stays low. Invalid tenant strings normalise to
82
+ * DEFAULT_TENANT silently. */
83
+ export function parseKeyTenants(raw) {
84
+ const out = new Map();
85
+ if (!raw)
86
+ return out;
87
+ for (const entry of raw.split(";").map((s) => s.trim()).filter(Boolean)) {
88
+ const eq = entry.indexOf("=");
89
+ if (eq <= 0)
90
+ continue;
91
+ const name = entry.slice(0, eq).trim();
92
+ const tenant = normaliseTenant(entry.slice(eq + 1).trim());
93
+ if (name)
94
+ out.set(name, tenant);
95
+ }
96
+ return out;
97
+ }
@@ -0,0 +1 @@
1
+ export {};