@thotischner/observability-mcp 1.7.1 → 3.0.0

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 (238) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/analysis/history.d.ts +70 -0
  3. package/dist/analysis/history.js +170 -0
  4. package/dist/analysis/history.test.d.ts +1 -0
  5. package/dist/analysis/history.test.js +141 -0
  6. package/dist/audit/log.d.ts +108 -0
  7. package/dist/audit/log.js +200 -0
  8. package/dist/audit/log.test.d.ts +1 -0
  9. package/dist/audit/log.test.js +147 -0
  10. package/dist/audit/middleware.d.ts +20 -0
  11. package/dist/audit/middleware.js +50 -0
  12. package/dist/audit/redaction-bypass.d.ts +67 -0
  13. package/dist/audit/redaction-bypass.js +64 -0
  14. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  15. package/dist/audit/redaction-bypass.test.js +72 -0
  16. package/dist/audit/sinks/types.d.ts +18 -0
  17. package/dist/audit/sinks/types.js +1 -0
  18. package/dist/audit/sinks/webhook.d.ts +45 -0
  19. package/dist/audit/sinks/webhook.js +111 -0
  20. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  21. package/dist/audit/sinks/webhook.test.js +162 -0
  22. package/dist/auth/credentials.d.ts +29 -0
  23. package/dist/auth/credentials.js +53 -1
  24. package/dist/auth/credentials.test.js +46 -1
  25. package/dist/auth/csrf.d.ts +26 -0
  26. package/dist/auth/csrf.js +128 -0
  27. package/dist/auth/csrf.test.d.ts +1 -0
  28. package/dist/auth/csrf.test.js +143 -0
  29. package/dist/auth/local-users.d.ts +68 -0
  30. package/dist/auth/local-users.js +154 -0
  31. package/dist/auth/local-users.test.d.ts +1 -0
  32. package/dist/auth/local-users.test.js +121 -0
  33. package/dist/auth/middleware.d.ts +49 -0
  34. package/dist/auth/middleware.js +65 -0
  35. package/dist/auth/middleware.test.d.ts +1 -0
  36. package/dist/auth/middleware.test.js +90 -0
  37. package/dist/auth/oidc/client.d.ts +73 -0
  38. package/dist/auth/oidc/client.js +104 -0
  39. package/dist/auth/oidc/client.test.d.ts +1 -0
  40. package/dist/auth/oidc/client.test.js +121 -0
  41. package/dist/auth/oidc/dcr.d.ts +70 -0
  42. package/dist/auth/oidc/dcr.js +160 -0
  43. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  44. package/dist/auth/oidc/dcr.test.js +109 -0
  45. package/dist/auth/oidc/discovery.d.ts +38 -0
  46. package/dist/auth/oidc/discovery.js +48 -0
  47. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  48. package/dist/auth/oidc/discovery.test.js +68 -0
  49. package/dist/auth/oidc/endpoints.d.ts +20 -0
  50. package/dist/auth/oidc/endpoints.js +168 -0
  51. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  52. package/dist/auth/oidc/endpoints.test.js +304 -0
  53. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  54. package/dist/auth/oidc/flow-cookie.js +142 -0
  55. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  56. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  57. package/dist/auth/oidc/index.d.ts +7 -0
  58. package/dist/auth/oidc/index.js +6 -0
  59. package/dist/auth/oidc/jwks.d.ts +36 -0
  60. package/dist/auth/oidc/jwks.js +69 -0
  61. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  62. package/dist/auth/oidc/jwks.test.js +65 -0
  63. package/dist/auth/oidc/jwt.d.ts +62 -0
  64. package/dist/auth/oidc/jwt.js +113 -0
  65. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  66. package/dist/auth/oidc/jwt.test.js +141 -0
  67. package/dist/auth/oidc/pkce.d.ts +19 -0
  68. package/dist/auth/oidc/pkce.js +43 -0
  69. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  70. package/dist/auth/oidc/pkce.test.js +55 -0
  71. package/dist/auth/oidc/profiles.d.ts +22 -0
  72. package/dist/auth/oidc/profiles.js +95 -0
  73. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  74. package/dist/auth/oidc/profiles.test.js +51 -0
  75. package/dist/auth/oidc/runtime.d.ts +66 -0
  76. package/dist/auth/oidc/runtime.js +142 -0
  77. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  78. package/dist/auth/oidc/runtime.test.js +181 -0
  79. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  80. package/dist/auth/policy/batch-dry-run.js +129 -0
  81. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  82. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  83. package/dist/auth/policy/engine.d.ts +64 -0
  84. package/dist/auth/policy/engine.js +87 -0
  85. package/dist/auth/policy/engine.test.d.ts +1 -0
  86. package/dist/auth/policy/engine.test.js +98 -0
  87. package/dist/auth/policy/loader.d.ts +45 -0
  88. package/dist/auth/policy/loader.js +137 -0
  89. package/dist/auth/policy/loader.test.d.ts +1 -0
  90. package/dist/auth/policy/loader.test.js +86 -0
  91. package/dist/auth/policy/opa.d.ts +69 -0
  92. package/dist/auth/policy/opa.js +173 -0
  93. package/dist/auth/policy/opa.test.d.ts +1 -0
  94. package/dist/auth/policy/opa.test.js +206 -0
  95. package/dist/auth/rbac.d.ts +62 -0
  96. package/dist/auth/rbac.js +162 -0
  97. package/dist/auth/rbac.test.d.ts +1 -0
  98. package/dist/auth/rbac.test.js +183 -0
  99. package/dist/auth/session.d.ts +66 -0
  100. package/dist/auth/session.js +146 -0
  101. package/dist/auth/session.test.d.ts +1 -0
  102. package/dist/auth/session.test.js +90 -0
  103. package/dist/catalog/loader.d.ts +67 -0
  104. package/dist/catalog/loader.js +122 -0
  105. package/dist/catalog/loader.test.d.ts +1 -0
  106. package/dist/catalog/loader.test.js +108 -0
  107. package/dist/cli/index.js +3 -0
  108. package/dist/cli/inspector-config.d.ts +9 -0
  109. package/dist/cli/inspector-config.js +28 -0
  110. package/dist/cli/inspector-config.test.d.ts +1 -0
  111. package/dist/cli/inspector-config.test.js +33 -0
  112. package/dist/cli/lib.d.ts +1 -1
  113. package/dist/cli/lib.js +1 -0
  114. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  115. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  116. package/dist/connectors/interface.d.ts +5 -1
  117. package/dist/connectors/loader.js +6 -4
  118. package/dist/connectors/loader.test.d.ts +1 -0
  119. package/dist/connectors/loader.test.js +78 -0
  120. package/dist/connectors/prometheus.test.js +31 -13
  121. package/dist/connectors/registry.d.ts +13 -0
  122. package/dist/connectors/registry.js +30 -0
  123. package/dist/connectors/registry.test.js +56 -2
  124. package/dist/context.d.ts +45 -1
  125. package/dist/context.js +40 -1
  126. package/dist/context.test.d.ts +1 -0
  127. package/dist/context.test.js +58 -0
  128. package/dist/federation/registry.d.ts +32 -0
  129. package/dist/federation/registry.js +77 -0
  130. package/dist/federation/registry.test.d.ts +1 -0
  131. package/dist/federation/registry.test.js +130 -0
  132. package/dist/federation/upstream.d.ts +60 -0
  133. package/dist/federation/upstream.js +114 -0
  134. package/dist/index.js +2124 -73
  135. package/dist/middleware/ssrfGuard.d.ts +15 -0
  136. package/dist/middleware/ssrfGuard.js +103 -0
  137. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  138. package/dist/middleware/ssrfGuard.test.js +81 -0
  139. package/dist/net/egress-policy.js +2 -0
  140. package/dist/observability/otel.d.ts +20 -0
  141. package/dist/observability/otel.js +118 -0
  142. package/dist/observability/otel.test.d.ts +1 -0
  143. package/dist/observability/otel.test.js +56 -0
  144. package/dist/openapi.js +654 -6
  145. package/dist/openapi.test.d.ts +1 -0
  146. package/dist/openapi.test.js +98 -0
  147. package/dist/policy/redact.d.ts +44 -0
  148. package/dist/policy/redact.js +144 -0
  149. package/dist/policy/redact.test.d.ts +1 -0
  150. package/dist/policy/redact.test.js +172 -0
  151. package/dist/postmortem/synthesizer.d.ts +83 -0
  152. package/dist/postmortem/synthesizer.js +205 -0
  153. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  154. package/dist/postmortem/synthesizer.test.js +141 -0
  155. package/dist/products/loader.d.ts +112 -0
  156. package/dist/products/loader.js +289 -0
  157. package/dist/products/loader.test.d.ts +1 -0
  158. package/dist/products/loader.test.js +257 -0
  159. package/dist/quota/charge.d.ts +28 -0
  160. package/dist/quota/charge.js +30 -0
  161. package/dist/quota/charge.test.d.ts +1 -0
  162. package/dist/quota/charge.test.js +83 -0
  163. package/dist/quota/limiter.d.ts +97 -0
  164. package/dist/quota/limiter.js +161 -0
  165. package/dist/quota/limiter.test.d.ts +1 -0
  166. package/dist/quota/limiter.test.js +205 -0
  167. package/dist/quota/token-budget.d.ts +119 -0
  168. package/dist/quota/token-budget.js +297 -0
  169. package/dist/quota/token-budget.test.d.ts +1 -0
  170. package/dist/quota/token-budget.test.js +215 -0
  171. package/dist/scim/group-role-map.d.ts +4 -0
  172. package/dist/scim/group-role-map.js +33 -0
  173. package/dist/scim/group-role-map.test.d.ts +1 -0
  174. package/dist/scim/group-role-map.test.js +33 -0
  175. package/dist/scim/routes.d.ts +15 -0
  176. package/dist/scim/routes.js +249 -0
  177. package/dist/scim/store.d.ts +37 -0
  178. package/dist/scim/store.js +178 -0
  179. package/dist/scim/store.test.d.ts +1 -0
  180. package/dist/scim/store.test.js +121 -0
  181. package/dist/scim/types.d.ts +73 -0
  182. package/dist/scim/types.js +29 -0
  183. package/dist/sdk/hooks.d.ts +77 -0
  184. package/dist/sdk/hooks.js +72 -0
  185. package/dist/sdk/hooks.test.d.ts +1 -0
  186. package/dist/sdk/hooks.test.js +159 -0
  187. package/dist/sdk/index.d.ts +2 -0
  188. package/dist/sdk/index.js +1 -0
  189. package/dist/sdk/manifest-schema.d.ts +17 -0
  190. package/dist/sdk/manifest-schema.js +21 -0
  191. package/dist/tenancy/context.d.ts +45 -0
  192. package/dist/tenancy/context.js +97 -0
  193. package/dist/tenancy/context.test.d.ts +1 -0
  194. package/dist/tenancy/context.test.js +72 -0
  195. package/dist/tenancy/migration.test.d.ts +7 -0
  196. package/dist/tenancy/migration.test.js +75 -0
  197. package/dist/tools/context-seam.test.js +6 -1
  198. package/dist/tools/detect-anomalies.d.ts +1 -1
  199. package/dist/tools/detect-anomalies.js +5 -4
  200. package/dist/tools/generate-postmortem.d.ts +35 -0
  201. package/dist/tools/generate-postmortem.js +191 -0
  202. package/dist/tools/get-anomaly-history.d.ts +35 -0
  203. package/dist/tools/get-anomaly-history.js +126 -0
  204. package/dist/tools/get-service-health.d.ts +1 -1
  205. package/dist/tools/get-service-health.js +4 -3
  206. package/dist/tools/list-services.d.ts +1 -1
  207. package/dist/tools/list-services.js +3 -2
  208. package/dist/tools/list-sources.d.ts +1 -1
  209. package/dist/tools/list-sources.js +6 -2
  210. package/dist/tools/query-logs.d.ts +1 -1
  211. package/dist/tools/query-logs.js +2 -2
  212. package/dist/tools/query-metrics.d.ts +1 -1
  213. package/dist/tools/query-metrics.js +19 -6
  214. package/dist/tools/query-traces.d.ts +47 -0
  215. package/dist/tools/query-traces.js +145 -0
  216. package/dist/tools/query-traces.test.d.ts +1 -0
  217. package/dist/tools/query-traces.test.js +110 -0
  218. package/dist/tools/registry-names.d.ts +35 -0
  219. package/dist/tools/registry-names.js +54 -0
  220. package/dist/tools/registry-names.test.d.ts +1 -0
  221. package/dist/tools/registry-names.test.js +61 -0
  222. package/dist/tools/topology.d.ts +3 -3
  223. package/dist/tools/topology.js +10 -6
  224. package/dist/topology/merge.d.ts +22 -0
  225. package/dist/topology/merge.js +178 -0
  226. package/dist/topology/merge.test.d.ts +1 -0
  227. package/dist/topology/merge.test.js +110 -0
  228. package/dist/transport/sessionStore.d.ts +66 -0
  229. package/dist/transport/sessionStore.js +138 -0
  230. package/dist/transport/sessionStore.test.d.ts +1 -0
  231. package/dist/transport/sessionStore.test.js +118 -0
  232. package/dist/transport/websocket.d.ts +35 -0
  233. package/dist/transport/websocket.js +133 -0
  234. package/dist/transport/websocket.test.d.ts +1 -0
  235. package/dist/transport/websocket.test.js +124 -0
  236. package/dist/types.d.ts +51 -0
  237. package/dist/ui/index.html +3083 -88
  238. package/package.json +32 -5
@@ -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,4 @@
1
+ export declare function parseScimGroupRoleMap(raw: string | undefined): Map<string, string>;
2
+ /** Map a user's group-display-names to the gateway's RBAC roles.
3
+ * Unknown groups are silently dropped (least-privilege). */
4
+ export declare function rolesForGroups(groupDisplayNames: string[], map: Map<string, string>): string[];
@@ -0,0 +1,33 @@
1
+ // Translate SCIM-provisioned groups into the gateway's RBAC roles.
2
+ // Operators configure the mapping via OMCP_SCIM_GROUP_ROLE_MAP:
3
+ //
4
+ // OMCP_SCIM_GROUP_ROLE_MAP="admins:admin,sre:operator,readers:viewer"
5
+ //
6
+ // A SCIM-managed user's groups[] (populated from group membership
7
+ // in the ScimStore) translates to a set of RBAC roles via this map,
8
+ // joining the OIDC group-mapping pattern from F6 so a federated
9
+ // IdP rolling Users + Groups via SCIM ends up with the same RBAC
10
+ // posture as a directly-claim-mapped login.
11
+ export function parseScimGroupRoleMap(raw) {
12
+ const out = new Map();
13
+ if (!raw)
14
+ return out;
15
+ for (const pair of raw.split(",")) {
16
+ const [groupName, role] = pair.split(":").map((s) => s.trim());
17
+ if (!groupName || !role)
18
+ continue;
19
+ out.set(groupName.toLowerCase(), role);
20
+ }
21
+ return out;
22
+ }
23
+ /** Map a user's group-display-names to the gateway's RBAC roles.
24
+ * Unknown groups are silently dropped (least-privilege). */
25
+ export function rolesForGroups(groupDisplayNames, map) {
26
+ const out = new Set();
27
+ for (const g of groupDisplayNames) {
28
+ const role = map.get(g.toLowerCase());
29
+ if (role)
30
+ out.add(role);
31
+ }
32
+ return [...out];
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseScimGroupRoleMap, rolesForGroups } from "./group-role-map.js";
4
+ test("parseScimGroupRoleMap: empty / undefined → empty map", () => {
5
+ assert.equal(parseScimGroupRoleMap(undefined).size, 0);
6
+ assert.equal(parseScimGroupRoleMap("").size, 0);
7
+ });
8
+ test("parseScimGroupRoleMap: comma-separated key:role pairs, lowercased keys", () => {
9
+ const m = parseScimGroupRoleMap("Admins:admin,SRE:operator,Readers:viewer");
10
+ assert.equal(m.get("admins"), "admin");
11
+ assert.equal(m.get("sre"), "operator");
12
+ assert.equal(m.get("readers"), "viewer");
13
+ });
14
+ test("parseScimGroupRoleMap: malformed entries silently dropped", () => {
15
+ const m = parseScimGroupRoleMap("admins:admin,no-colon,:emptyKey,validKey:validRole");
16
+ assert.equal(m.get("admins"), "admin");
17
+ assert.equal(m.get("validkey"), "validRole");
18
+ assert.equal(m.size, 2);
19
+ });
20
+ test("rolesForGroups: unknown groups dropped (least-privilege)", () => {
21
+ const map = parseScimGroupRoleMap("admins:admin,sre:operator");
22
+ const roles = rolesForGroups(["admins", "unknown-group"], map);
23
+ assert.deepEqual(roles, ["admin"]);
24
+ });
25
+ test("rolesForGroups: dedupes roles", () => {
26
+ const map = parseScimGroupRoleMap("admins:admin,sysadmins:admin");
27
+ const roles = rolesForGroups(["admins", "sysadmins"], map);
28
+ assert.deepEqual(roles, ["admin"]);
29
+ });
30
+ test("rolesForGroups: case-insensitive group lookup", () => {
31
+ const map = parseScimGroupRoleMap("Admins:admin");
32
+ assert.deepEqual(rolesForGroups(["ADMINS"], map), ["admin"]);
33
+ });
@@ -0,0 +1,15 @@
1
+ import type { Application } from "express";
2
+ import { ScimStore } from "./store.js";
3
+ export interface ScimRoutesDeps {
4
+ store: ScimStore;
5
+ bearerToken: string;
6
+ /** Audit hook called after every successful mutation. Best-effort. */
7
+ audit?: (event: {
8
+ actor: string;
9
+ action: string;
10
+ target: string;
11
+ result: "ok" | "error";
12
+ status: number;
13
+ }) => void;
14
+ }
15
+ export declare function registerScimRoutes(app: Application, deps: ScimRoutesDeps): void;