@thotischner/observability-mcp 1.7.1 → 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/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,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 {};
|