@thotischner/observability-mcp 1.8.1 → 3.0.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 (204) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/s3.d.ts +61 -0
  12. package/dist/audit/sinks/s3.js +179 -0
  13. package/dist/audit/sinks/s3.test.d.ts +1 -0
  14. package/dist/audit/sinks/s3.test.js +175 -0
  15. package/dist/audit/sinks/types.d.ts +18 -0
  16. package/dist/audit/sinks/types.js +1 -0
  17. package/dist/audit/sinks/webhook.d.ts +45 -0
  18. package/dist/audit/sinks/webhook.js +111 -0
  19. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  20. package/dist/audit/sinks/webhook.test.js +162 -0
  21. package/dist/auth/credentials.d.ts +11 -0
  22. package/dist/auth/credentials.js +27 -0
  23. package/dist/auth/credentials.test.js +21 -1
  24. package/dist/auth/csrf.d.ts +26 -0
  25. package/dist/auth/csrf.js +128 -0
  26. package/dist/auth/csrf.test.d.ts +1 -0
  27. package/dist/auth/csrf.test.js +143 -0
  28. package/dist/auth/local-users.d.ts +6 -0
  29. package/dist/auth/local-users.js +11 -0
  30. package/dist/auth/local-users.test.js +41 -0
  31. package/dist/auth/middleware.d.ts +7 -6
  32. package/dist/auth/oidc/dcr.d.ts +70 -0
  33. package/dist/auth/oidc/dcr.js +160 -0
  34. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  35. package/dist/auth/oidc/dcr.test.js +109 -0
  36. package/dist/auth/oidc/endpoints.js +44 -0
  37. package/dist/auth/oidc/profiles.d.ts +22 -0
  38. package/dist/auth/oidc/profiles.js +95 -0
  39. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  40. package/dist/auth/oidc/profiles.test.js +51 -0
  41. package/dist/auth/oidc/runtime.d.ts +3 -0
  42. package/dist/auth/oidc/runtime.js +16 -3
  43. package/dist/auth/oidc/runtime.test.js +1 -0
  44. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  45. package/dist/auth/policy/batch-dry-run.js +144 -0
  46. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  47. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  48. package/dist/auth/policy/engine.d.ts +20 -4
  49. package/dist/auth/policy/engine.js +16 -2
  50. package/dist/auth/policy/loader.d.ts +11 -1
  51. package/dist/auth/policy/loader.js +37 -0
  52. package/dist/auth/policy/loader.test.d.ts +1 -0
  53. package/dist/auth/policy/loader.test.js +86 -0
  54. package/dist/auth/policy/opa.d.ts +5 -5
  55. package/dist/auth/policy/opa.js +25 -14
  56. package/dist/auth/policy/opa.test.js +48 -0
  57. package/dist/auth/rbac.d.ts +23 -1
  58. package/dist/auth/rbac.js +43 -1
  59. package/dist/auth/rbac.test.js +62 -0
  60. package/dist/cli/index.js +3 -0
  61. package/dist/cli/inspector-config.d.ts +9 -0
  62. package/dist/cli/inspector-config.js +28 -0
  63. package/dist/cli/inspector-config.test.d.ts +1 -0
  64. package/dist/cli/inspector-config.test.js +33 -0
  65. package/dist/cli/lib.d.ts +1 -1
  66. package/dist/cli/lib.js +1 -0
  67. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  68. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  69. package/dist/connectors/interface.d.ts +5 -1
  70. package/dist/connectors/loader.d.ts +8 -0
  71. package/dist/connectors/loader.js +55 -4
  72. package/dist/connectors/loader.test.d.ts +1 -0
  73. package/dist/connectors/loader.test.js +78 -0
  74. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  75. package/dist/connectors/manifest-hooks.test.js +206 -0
  76. package/dist/connectors/prometheus.test.js +31 -13
  77. package/dist/connectors/registry.d.ts +13 -0
  78. package/dist/connectors/registry.js +30 -0
  79. package/dist/connectors/registry.test.js +56 -2
  80. package/dist/context.d.ts +32 -0
  81. package/dist/context.js +35 -0
  82. package/dist/context.test.d.ts +1 -0
  83. package/dist/context.test.js +58 -0
  84. package/dist/federation/registry.d.ts +54 -0
  85. package/dist/federation/registry.js +122 -0
  86. package/dist/federation/registry.test.d.ts +1 -0
  87. package/dist/federation/registry.test.js +206 -0
  88. package/dist/federation/upstream.d.ts +86 -0
  89. package/dist/federation/upstream.js +162 -0
  90. package/dist/federation/upstream.test.d.ts +1 -0
  91. package/dist/federation/upstream.test.js +118 -0
  92. package/dist/index.js +1435 -126
  93. package/dist/metrics/self.d.ts +1 -0
  94. package/dist/metrics/self.js +8 -0
  95. package/dist/middleware/ssrfGuard.d.ts +15 -0
  96. package/dist/middleware/ssrfGuard.js +103 -0
  97. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  98. package/dist/middleware/ssrfGuard.test.js +81 -0
  99. package/dist/observability/otel.d.ts +20 -0
  100. package/dist/observability/otel.js +118 -0
  101. package/dist/observability/otel.test.d.ts +1 -0
  102. package/dist/observability/otel.test.js +56 -0
  103. package/dist/openapi.js +215 -7
  104. package/dist/openapi.test.js +34 -0
  105. package/dist/policy/redact.js +1 -1
  106. package/dist/postmortem/store.d.ts +34 -0
  107. package/dist/postmortem/store.js +113 -0
  108. package/dist/postmortem/store.test.d.ts +1 -0
  109. package/dist/postmortem/store.test.js +118 -0
  110. package/dist/postmortem/synthesizer.d.ts +83 -0
  111. package/dist/postmortem/synthesizer.js +205 -0
  112. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  113. package/dist/postmortem/synthesizer.test.js +141 -0
  114. package/dist/products/loader.d.ts +31 -3
  115. package/dist/products/loader.js +77 -4
  116. package/dist/products/loader.test.js +90 -1
  117. package/dist/quota/charge.d.ts +28 -0
  118. package/dist/quota/charge.js +30 -0
  119. package/dist/quota/charge.test.d.ts +1 -0
  120. package/dist/quota/charge.test.js +83 -0
  121. package/dist/quota/limiter.d.ts +29 -4
  122. package/dist/quota/limiter.js +64 -8
  123. package/dist/quota/limiter.test.js +86 -0
  124. package/dist/scim/compliance.test.d.ts +1 -0
  125. package/dist/scim/compliance.test.js +169 -0
  126. package/dist/scim/factory.test.d.ts +1 -0
  127. package/dist/scim/factory.test.js +54 -0
  128. package/dist/scim/group-role-map.d.ts +4 -0
  129. package/dist/scim/group-role-map.js +33 -0
  130. package/dist/scim/group-role-map.test.d.ts +1 -0
  131. package/dist/scim/group-role-map.test.js +33 -0
  132. package/dist/scim/patch-ops.test.d.ts +1 -0
  133. package/dist/scim/patch-ops.test.js +100 -0
  134. package/dist/scim/redis-store.d.ts +38 -0
  135. package/dist/scim/redis-store.js +178 -0
  136. package/dist/scim/redis-store.test.d.ts +1 -0
  137. package/dist/scim/redis-store.test.js +138 -0
  138. package/dist/scim/routes.d.ts +40 -0
  139. package/dist/scim/routes.js +395 -0
  140. package/dist/scim/store.d.ts +76 -0
  141. package/dist/scim/store.js +196 -0
  142. package/dist/scim/store.test.d.ts +1 -0
  143. package/dist/scim/store.test.js +121 -0
  144. package/dist/scim/types.d.ts +73 -0
  145. package/dist/scim/types.js +29 -0
  146. package/dist/sdk/hook-wrappers.d.ts +39 -0
  147. package/dist/sdk/hook-wrappers.js +113 -0
  148. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  149. package/dist/sdk/hook-wrappers.test.js +204 -0
  150. package/dist/sdk/hooks.d.ts +77 -0
  151. package/dist/sdk/hooks.js +72 -0
  152. package/dist/sdk/hooks.test.d.ts +1 -0
  153. package/dist/sdk/hooks.test.js +159 -0
  154. package/dist/sdk/index.d.ts +15 -0
  155. package/dist/sdk/index.js +1 -0
  156. package/dist/sdk/manifest-schema.d.ts +17 -0
  157. package/dist/sdk/manifest-schema.js +21 -0
  158. package/dist/tools/context-seam.test.js +6 -1
  159. package/dist/tools/detect-anomalies.d.ts +12 -1
  160. package/dist/tools/detect-anomalies.js +26 -5
  161. package/dist/tools/generate-postmortem.d.ts +35 -0
  162. package/dist/tools/generate-postmortem.js +191 -0
  163. package/dist/tools/get-anomaly-history.d.ts +35 -0
  164. package/dist/tools/get-anomaly-history.js +126 -0
  165. package/dist/tools/get-service-health.d.ts +1 -1
  166. package/dist/tools/get-service-health.js +4 -3
  167. package/dist/tools/list-services.d.ts +1 -1
  168. package/dist/tools/list-services.js +3 -2
  169. package/dist/tools/list-sources.d.ts +1 -1
  170. package/dist/tools/list-sources.js +6 -2
  171. package/dist/tools/query-logs.d.ts +1 -1
  172. package/dist/tools/query-logs.js +2 -2
  173. package/dist/tools/query-metrics.d.ts +1 -1
  174. package/dist/tools/query-metrics.js +19 -6
  175. package/dist/tools/query-traces.d.ts +47 -0
  176. package/dist/tools/query-traces.js +145 -0
  177. package/dist/tools/query-traces.test.d.ts +1 -0
  178. package/dist/tools/query-traces.test.js +110 -0
  179. package/dist/tools/registry-names.d.ts +35 -0
  180. package/dist/tools/registry-names.js +54 -0
  181. package/dist/tools/registry-names.test.d.ts +1 -0
  182. package/dist/tools/registry-names.test.js +61 -0
  183. package/dist/tools/topology.d.ts +3 -3
  184. package/dist/tools/topology.js +33 -11
  185. package/dist/tools/topology.test.js +45 -0
  186. package/dist/topology/merge.d.ts +22 -0
  187. package/dist/topology/merge.js +178 -0
  188. package/dist/topology/merge.test.d.ts +1 -0
  189. package/dist/topology/merge.test.js +110 -0
  190. package/dist/transport/sessionStore.d.ts +66 -0
  191. package/dist/transport/sessionStore.js +138 -0
  192. package/dist/transport/sessionStore.test.d.ts +1 -0
  193. package/dist/transport/sessionStore.test.js +118 -0
  194. package/dist/transport/transportSessionMap.d.ts +70 -0
  195. package/dist/transport/transportSessionMap.js +128 -0
  196. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  197. package/dist/transport/transportSessionMap.test.js +111 -0
  198. package/dist/transport/websocket.d.ts +35 -0
  199. package/dist/transport/websocket.js +133 -0
  200. package/dist/transport/websocket.test.d.ts +1 -0
  201. package/dist/transport/websocket.test.js +124 -0
  202. package/dist/types.d.ts +51 -0
  203. package/dist/ui/index.html +2529 -145
  204. package/package.json +13 -3
@@ -0,0 +1,162 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, readFileSync, existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { WebhookSink } from "./webhook.js";
7
+ function sampleEntry(seq = 1) {
8
+ return {
9
+ ts: "2026-06-05T20:00:00.000Z",
10
+ seq,
11
+ actor: { sub: "alice", name: "alice" },
12
+ tenant: "default",
13
+ resource: "sources",
14
+ action: "write",
15
+ method: "POST",
16
+ path: "/api/sources",
17
+ status: 200,
18
+ prevHash: "0".repeat(64),
19
+ hash: "ff".repeat(32),
20
+ };
21
+ }
22
+ function tmpDLQ() {
23
+ return join(mkdtempSync(join(tmpdir(), "webhook-dlq-")), "dlq.jsonl");
24
+ }
25
+ function fakeFetchSequence(statuses) {
26
+ let i = 0;
27
+ const calls = { count: 0 };
28
+ const fetchImpl = (async () => {
29
+ calls.count++;
30
+ const next = statuses[Math.min(i, statuses.length - 1)];
31
+ i++;
32
+ return {
33
+ ok: next.ok,
34
+ status: next.status ?? (next.ok ? 200 : 500),
35
+ statusText: next.ok ? "OK" : "Server Error",
36
+ text: async () => next.text ?? "",
37
+ };
38
+ });
39
+ return { fetchImpl, calls };
40
+ }
41
+ test("WebhookSink: happy path POSTs once and flushes cleanly", async () => {
42
+ const { fetchImpl, calls } = fakeFetchSequence([{ ok: true }]);
43
+ const sink = new WebhookSink({
44
+ url: "https://example.com/audit",
45
+ fetchImpl,
46
+ sleepImpl: async () => undefined,
47
+ });
48
+ await sink.write(sampleEntry());
49
+ await sink.flush();
50
+ assert.equal(calls.count, 1);
51
+ });
52
+ test("WebhookSink: retries with backoff on 500, succeeds on attempt 3", async () => {
53
+ const { fetchImpl, calls } = fakeFetchSequence([
54
+ { ok: false, status: 500 },
55
+ { ok: false, status: 503 },
56
+ { ok: true },
57
+ ]);
58
+ let sleeps = 0;
59
+ const sink = new WebhookSink({
60
+ url: "https://example.com/audit",
61
+ fetchImpl,
62
+ sleepImpl: async () => {
63
+ sleeps++;
64
+ },
65
+ initialBackoffMs: 10,
66
+ maxBackoffMs: 100,
67
+ maxAttempts: 5,
68
+ });
69
+ await sink.write(sampleEntry());
70
+ await sink.flush();
71
+ assert.equal(calls.count, 3);
72
+ assert.equal(sleeps, 2, "slept once between each failed attempt and the next");
73
+ });
74
+ test("WebhookSink: exhausts retries and writes to DLQ", async () => {
75
+ const { fetchImpl, calls } = fakeFetchSequence([{ ok: false, status: 500 }]);
76
+ const dlq = tmpDLQ();
77
+ const sink = new WebhookSink({
78
+ url: "https://example.com/audit",
79
+ fetchImpl,
80
+ sleepImpl: async () => undefined,
81
+ initialBackoffMs: 1,
82
+ maxAttempts: 3,
83
+ deadLetterFile: dlq,
84
+ });
85
+ await sink.write(sampleEntry(42));
86
+ await sink.flush();
87
+ assert.equal(calls.count, 3, "tried maxAttempts times");
88
+ assert.ok(existsSync(dlq), "DLQ file written");
89
+ const written = readFileSync(dlq, "utf8");
90
+ assert.match(written, /"seq":42/);
91
+ });
92
+ test("WebhookSink: write() does not throw even when underlying request fails", async () => {
93
+ const fetchImpl = (async () => {
94
+ throw new Error("network down");
95
+ });
96
+ const sink = new WebhookSink({
97
+ url: "https://example.com/audit",
98
+ fetchImpl,
99
+ sleepImpl: async () => undefined,
100
+ maxAttempts: 2,
101
+ initialBackoffMs: 1,
102
+ });
103
+ // The contract: write() returns successfully (does NOT throw) even
104
+ // when delivery fails — the failure is logged and DLQ-handled.
105
+ await assert.doesNotReject(async () => {
106
+ await sink.write(sampleEntry());
107
+ await sink.flush();
108
+ });
109
+ });
110
+ test("WebhookSink: bearer token forwarded as Authorization header", async () => {
111
+ let seenAuth;
112
+ const fetchImpl = (async (_url, init) => {
113
+ const headers = init.headers;
114
+ seenAuth = headers["authorization"];
115
+ return { ok: true, status: 200, statusText: "OK", text: async () => "" };
116
+ });
117
+ const sink = new WebhookSink({
118
+ url: "https://example.com/audit",
119
+ token: "secret-abc",
120
+ fetchImpl,
121
+ sleepImpl: async () => undefined,
122
+ });
123
+ await sink.write(sampleEntry());
124
+ await sink.flush();
125
+ assert.equal(seenAuth, "Bearer secret-abc");
126
+ });
127
+ test("WebhookSink: write order is preserved across retries (serialized queue)", async () => {
128
+ const seen = [];
129
+ let nextOk = false;
130
+ const fetchImpl = (async (_url, init) => {
131
+ const body = JSON.parse(init.body);
132
+ seen.push(body.seq);
133
+ const wasOk = nextOk;
134
+ nextOk = !wasOk; // alternate: first fails, second ok, etc.
135
+ return {
136
+ ok: wasOk,
137
+ status: wasOk ? 200 : 500,
138
+ statusText: "x",
139
+ text: async () => "",
140
+ };
141
+ });
142
+ const sink = new WebhookSink({
143
+ url: "https://example.com/audit",
144
+ fetchImpl,
145
+ sleepImpl: async () => undefined,
146
+ initialBackoffMs: 1,
147
+ maxAttempts: 4,
148
+ });
149
+ // Fire two writes concurrently; they must be delivered in order
150
+ // because the internal queue serializes them.
151
+ const p1 = sink.write(sampleEntry(1));
152
+ const p2 = sink.write(sampleEntry(2));
153
+ await Promise.all([p1, p2]);
154
+ await sink.flush();
155
+ // First entry's POSTs should all precede the second entry's POSTs.
156
+ const firstIdx = seen.indexOf(1);
157
+ const secondIdx = seen.indexOf(2);
158
+ assert.ok(firstIdx >= 0 && secondIdx > firstIdx, `order broken: ${seen.join(",")}`);
159
+ });
160
+ test("WebhookSink: throws on missing url", () => {
161
+ assert.throws(() => new WebhookSink({ url: "" }), /url is required/);
162
+ });
@@ -21,6 +21,13 @@
21
21
  * # land in the "default" tenant — identical to the
22
22
  * # pre-E7 single-namespace world. See docs/tenancy.md
23
23
  * # (slice 5) for the cross-cutting model.
24
+ * OMCP_KEY_PRODUCTS="agent=ops-bundle;ci=dev-bundle"
25
+ * # optional per-key Product binding. When set, the
26
+ * # /mcp tools/list response is filtered to the named
27
+ * # Product's `tools` allow-list (Product without a
28
+ * # tools list = no restriction). Unlisted keys see
29
+ * # every registered tool — back-compat with the
30
+ * # pre-Products world. See docs/products.md.
24
31
  *
25
32
  * Rich role-based access control (tools/services/lookback/read-only, the
26
33
  * full governance object) is intentionally NOT here — this is only the
@@ -37,6 +44,10 @@ export interface Credential {
37
44
  bypassRedaction?: boolean;
38
45
  /** Tenant this credential belongs to. Omitted → DEFAULT_TENANT. */
39
46
  tenant?: string;
47
+ /** Product id this credential is bound to. When set, /mcp tools/list
48
+ * is filtered to the Product's `tools` allow-list. Resolved against
49
+ * the credential's tenant so cross-tenant Products don't leak. */
50
+ productId?: string;
40
51
  }
41
52
  /** Parse credentials from env. Returns an empty list when unconfigured. */
42
53
  export declare function loadCredentials(env?: NodeJS.ProcessEnv): Credential[];
@@ -21,6 +21,13 @@
21
21
  * # land in the "default" tenant — identical to the
22
22
  * # pre-E7 single-namespace world. See docs/tenancy.md
23
23
  * # (slice 5) for the cross-cutting model.
24
+ * OMCP_KEY_PRODUCTS="agent=ops-bundle;ci=dev-bundle"
25
+ * # optional per-key Product binding. When set, the
26
+ * # /mcp tools/list response is filtered to the named
27
+ * # Product's `tools` allow-list (Product without a
28
+ * # tools list = no restriction). Unlisted keys see
29
+ * # every registered tool — back-compat with the
30
+ * # pre-Products world. See docs/products.md.
24
31
  *
25
32
  * Rich role-based access control (tools/services/lookback/read-only, the
26
33
  * full governance object) is intentionally NOT here — this is only the
@@ -39,6 +46,24 @@ function parseKeySources(raw) {
39
46
  }
40
47
  return m;
41
48
  }
49
+ /** Parse OMCP_KEY_PRODUCTS — `name=productId;name2=productId2`. Same
50
+ * shape as parseKeyTenants (single id per credential — Products are
51
+ * bundles, not bundles-of-bundles). */
52
+ function parseKeyProducts(raw) {
53
+ const m = new Map();
54
+ if (!raw)
55
+ return m;
56
+ for (const entry of raw.split(";").map((s) => s.trim()).filter(Boolean)) {
57
+ const eq = entry.indexOf("=");
58
+ if (eq <= 0)
59
+ continue;
60
+ const name = entry.slice(0, eq).trim();
61
+ const productId = entry.slice(eq + 1).trim();
62
+ if (name && productId)
63
+ m.set(name, productId);
64
+ }
65
+ return m;
66
+ }
42
67
  function parseBypassSet(raw) {
43
68
  if (!raw)
44
69
  return new Set();
@@ -52,6 +77,7 @@ export function loadCredentials(env = process.env) {
52
77
  const keySources = parseKeySources(env.OMCP_KEY_SOURCES);
53
78
  const bypassNames = parseBypassSet(env.OMCP_KEY_BYPASS_REDACTION);
54
79
  const keyTenants = parseKeyTenants(env.OMCP_KEY_TENANTS);
80
+ const keyProducts = parseKeyProducts(env.OMCP_KEY_PRODUCTS);
55
81
  const creds = [];
56
82
  for (const part of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
57
83
  const idx = part.indexOf(":");
@@ -65,6 +91,7 @@ export function loadCredentials(env = process.env) {
65
91
  allowedSources: keySources.get(name),
66
92
  bypassRedaction: bypassNames.has(name) || undefined,
67
93
  tenant: keyTenants.get(name) || undefined,
94
+ productId: keyProducts.get(name) || undefined,
68
95
  });
69
96
  }
70
97
  return creds;
@@ -11,7 +11,7 @@ describe("single-tenant auth primitive", () => {
11
11
  it("parses name:token and bare token", () => {
12
12
  const creds = loadCredentials({ OMCP_API_KEYS: "ci:tok_abc, tok_bare " });
13
13
  assert.equal(creds.length, 2);
14
- assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, tenant: undefined });
14
+ assert.deepEqual(creds[0], { name: "ci", token: "tok_abc", allowedSources: undefined, bypassRedaction: undefined, tenant: undefined, productId: undefined });
15
15
  assert.equal(creds[1].name, "key");
16
16
  assert.equal(creds[1].token, "tok_bare");
17
17
  });
@@ -48,6 +48,26 @@ describe("single-tenant auth primitive", () => {
48
48
  assert.equal(creds.find((c) => c.name === "ci")?.tenant, "bigcorp", "lowercased");
49
49
  assert.equal(creds.find((c) => c.name === "nobody")?.tenant, undefined);
50
50
  });
51
+ it("parses OMCP_KEY_PRODUCTS → assigns productId to named keys; unlisted stays undefined", () => {
52
+ const creds = loadCredentials({
53
+ OMCP_API_KEYS: "agent:tok1,ci:tok2,nobody:tok3",
54
+ OMCP_KEY_PRODUCTS: "agent=ops-bundle;ci=dev-bundle",
55
+ });
56
+ assert.equal(creds.find((c) => c.name === "agent")?.productId, "ops-bundle");
57
+ assert.equal(creds.find((c) => c.name === "ci")?.productId, "dev-bundle");
58
+ assert.equal(creds.find((c) => c.name === "nobody")?.productId, undefined);
59
+ });
60
+ it("OMCP_KEY_PRODUCTS — malformed entries (no =, empty value) silently skipped", () => {
61
+ const creds = loadCredentials({
62
+ OMCP_API_KEYS: "agent:tok1,ci:tok2,x:tok3",
63
+ // "noeq" lacks "=" → skip; "ci=" has empty value → skip;
64
+ // "agent=ops" parses cleanly.
65
+ OMCP_KEY_PRODUCTS: "agent=ops;noeq;ci=;x=dev",
66
+ });
67
+ assert.equal(creds.find((c) => c.name === "agent")?.productId, "ops");
68
+ assert.equal(creds.find((c) => c.name === "ci")?.productId, undefined);
69
+ assert.equal(creds.find((c) => c.name === "x")?.productId, "dev");
70
+ });
51
71
  it("extractToken handles Bearer and X-API-Key", () => {
52
72
  assert.equal(extractToken({ authorization: "Bearer abc" }), "abc");
53
73
  assert.equal(extractToken({ authorization: "bearer xyz " }), "xyz");
@@ -0,0 +1,26 @@
1
+ import type { Request, Response, NextFunction } from "express";
2
+ export declare const CSRF_COOKIE = "omcp-csrf";
3
+ export declare const CSRF_HEADER = "x-csrf-token";
4
+ export declare const CSRF_TOKEN_BYTES = 32;
5
+ export declare function newCsrfToken(): string;
6
+ export interface CsrfConfig {
7
+ /** Skip protection when an Authorization: Bearer header is present.
8
+ * Default true — bearer-token clients are non-browser by
9
+ * definition and cannot carry cookies, so they can't be a
10
+ * confused-deputy target. Set false to require CSRF on every
11
+ * state-changing call regardless of auth method. */
12
+ bypassBearer: boolean;
13
+ /** Set cookies with `Secure` flag. Default mirrors the existing
14
+ * session-cookie behaviour: only when the request is on https. */
15
+ secureCookie: (req: Request) => boolean;
16
+ }
17
+ export declare function csrfBypassFromEnv(env?: NodeJS.ProcessEnv): boolean;
18
+ /** Issue a fresh token cookie if the request doesn't already carry a
19
+ * valid one. The handler runs as a top-of-pipe middleware so every
20
+ * rendered page picks up a token the SPA can echo back. */
21
+ export declare function buildCsrfIssuer(cfg: CsrfConfig): (req: Request, res: Response, next: NextFunction) => void;
22
+ /** Reject state-changing requests that don't carry a matching
23
+ * X-CSRF-Token. Safe methods (GET/HEAD/OPTIONS) and bearer-auth
24
+ * requests (when bypassBearer is on) flow through. */
25
+ export declare function buildCsrfEnforcer(cfg: CsrfConfig): (req: Request, res: Response, next: NextFunction) => void;
26
+ export declare function constantTimeStringEquals(a: string, b: string): boolean;
@@ -0,0 +1,128 @@
1
+ // CSRF protection for the SPA — double-submit cookie pattern.
2
+ //
3
+ // The threat model the gateway protects against:
4
+ // - A browser session that has already authenticated against the
5
+ // SPA (carrying a session cookie) cannot have its credential
6
+ // borrowed by a third-party site to mutate state via a hidden
7
+ // form/XHR. The third-party site cannot read the CSRF cookie,
8
+ // so it cannot echo the token back in the X-CSRF-Token header,
9
+ // so the gateway rejects the request.
10
+ //
11
+ // The protection is intentionally narrow:
12
+ // - Bearer-token API clients (CI, agents, MCP clients) cannot
13
+ // set cookies and would never carry one. They bypass CSRF via
14
+ // OMCP_CSRF_BYPASS_BEARER=true (default ON since bearer auth
15
+ // is itself proof of intent — there's no browser confused-deputy
16
+ // scenario with a static API token in an Authorization header).
17
+ // - The /mcp endpoint is bearer-only in practice; the SPA only
18
+ // mutates state via /api/*.
19
+ //
20
+ // Token shape:
21
+ // - 32 random bytes, base64url encoded.
22
+ // - Issued lazily: every authenticated SPA page render that lacks
23
+ // a valid omcp-csrf cookie gets a fresh one.
24
+ // - Server-side validation: header X-CSRF-Token MUST equal cookie
25
+ // omcp-csrf on any state-changing /api/* request.
26
+ import { randomBytes, timingSafeEqual } from "node:crypto";
27
+ export const CSRF_COOKIE = "omcp-csrf";
28
+ export const CSRF_HEADER = "x-csrf-token";
29
+ export const CSRF_TOKEN_BYTES = 32;
30
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
31
+ export function newCsrfToken() {
32
+ return randomBytes(CSRF_TOKEN_BYTES).toString("base64url");
33
+ }
34
+ export function csrfBypassFromEnv(env = process.env) {
35
+ // Default ON; only the literal opt-out values disable it.
36
+ return !/^(0|false|no|off)$/i.test(env.OMCP_CSRF_BYPASS_BEARER ?? "true");
37
+ }
38
+ /** Issue a fresh token cookie if the request doesn't already carry a
39
+ * valid one. The handler runs as a top-of-pipe middleware so every
40
+ * rendered page picks up a token the SPA can echo back. */
41
+ export function buildCsrfIssuer(cfg) {
42
+ return (req, res, next) => {
43
+ const existing = readCookie(req, CSRF_COOKIE);
44
+ if (!existing) {
45
+ const token = newCsrfToken();
46
+ const flags = [
47
+ `${CSRF_COOKIE}=${token}`,
48
+ "Path=/",
49
+ "SameSite=Lax",
50
+ // Intentionally NOT HttpOnly — the SPA's fetch wrapper
51
+ // needs to read this cookie to echo it back in
52
+ // X-CSRF-Token. That's the whole point of double-submit:
53
+ // the value isn't a secret, the proof is "you can read
54
+ // this cookie from your own origin".
55
+ ];
56
+ if (cfg.secureCookie(req))
57
+ flags.push("Secure");
58
+ appendSetCookie(res, flags.join("; "));
59
+ }
60
+ next();
61
+ };
62
+ }
63
+ /** Reject state-changing requests that don't carry a matching
64
+ * X-CSRF-Token. Safe methods (GET/HEAD/OPTIONS) and bearer-auth
65
+ * requests (when bypassBearer is on) flow through. */
66
+ export function buildCsrfEnforcer(cfg) {
67
+ return (req, res, next) => {
68
+ if (SAFE_METHODS.has(req.method.toUpperCase())) {
69
+ next();
70
+ return;
71
+ }
72
+ if (cfg.bypassBearer) {
73
+ const auth = req.headers["authorization"];
74
+ if (typeof auth === "string" && /^Bearer\s+/i.test(auth)) {
75
+ next();
76
+ return;
77
+ }
78
+ // Also bypass if X-API-Key is set (matches /mcp's accepted shapes).
79
+ if (req.headers["x-api-key"]) {
80
+ next();
81
+ return;
82
+ }
83
+ }
84
+ const headerToken = req.headers[CSRF_HEADER];
85
+ const cookieToken = readCookie(req, CSRF_COOKIE);
86
+ if (typeof headerToken !== "string" ||
87
+ typeof cookieToken !== "string" ||
88
+ !constantTimeStringEquals(headerToken, cookieToken)) {
89
+ res
90
+ .status(403)
91
+ .json({
92
+ error: "csrf_token_mismatch",
93
+ message: "X-CSRF-Token header is missing or does not match the omcp-csrf cookie",
94
+ });
95
+ return;
96
+ }
97
+ next();
98
+ };
99
+ }
100
+ function readCookie(req, name) {
101
+ const raw = req.headers["cookie"];
102
+ if (typeof raw !== "string")
103
+ return undefined;
104
+ for (const part of raw.split(";")) {
105
+ const i = part.indexOf("=");
106
+ if (i < 0)
107
+ continue;
108
+ const k = part.slice(0, i).trim();
109
+ if (k === name)
110
+ return decodeURIComponent(part.slice(i + 1).trim());
111
+ }
112
+ return undefined;
113
+ }
114
+ function appendSetCookie(res, value) {
115
+ const existing = res.getHeader("Set-Cookie");
116
+ if (!existing) {
117
+ res.setHeader("Set-Cookie", value);
118
+ return;
119
+ }
120
+ res.setHeader("Set-Cookie", Array.isArray(existing) ? [...existing, value] : [String(existing), value]);
121
+ }
122
+ export function constantTimeStringEquals(a, b) {
123
+ if (a.length !== b.length)
124
+ return false;
125
+ const aBuf = Buffer.from(a);
126
+ const bBuf = Buffer.from(b);
127
+ return timingSafeEqual(aBuf, bBuf);
128
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,143 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import { buildCsrfIssuer, buildCsrfEnforcer, newCsrfToken, csrfBypassFromEnv, constantTimeStringEquals, CSRF_COOKIE, CSRF_HEADER, } from "./csrf.js";
5
+ class MockRes extends EventEmitter {
6
+ status_ = 200;
7
+ headers = {};
8
+ body;
9
+ status(code) {
10
+ this.status_ = code;
11
+ return this;
12
+ }
13
+ json(body) {
14
+ this.body = body;
15
+ return this;
16
+ }
17
+ setHeader(name, value) {
18
+ this.headers[name] = value;
19
+ }
20
+ getHeader(name) {
21
+ return this.headers[name];
22
+ }
23
+ }
24
+ function call(mw, req) {
25
+ const res = new MockRes();
26
+ let nexted = false;
27
+ mw(req, res, () => {
28
+ nexted = true;
29
+ });
30
+ return { res, nexted };
31
+ }
32
+ function defaultCfg(overrides = {}) {
33
+ return {
34
+ bypassBearer: overrides.bypassBearer ?? true,
35
+ secureCookie: () => false,
36
+ };
37
+ }
38
+ test("newCsrfToken: returns base64url, 32-byte (43-44 char) string", () => {
39
+ const t = newCsrfToken();
40
+ assert.match(t, /^[A-Za-z0-9_-]+$/);
41
+ assert.ok(t.length >= 42 && t.length <= 44, `unexpected length ${t.length}`);
42
+ assert.notEqual(newCsrfToken(), newCsrfToken(), "tokens must differ");
43
+ });
44
+ test("constantTimeStringEquals: matches equal, rejects different lengths + values", () => {
45
+ assert.equal(constantTimeStringEquals("abc", "abc"), true);
46
+ assert.equal(constantTimeStringEquals("abc", "abd"), false);
47
+ assert.equal(constantTimeStringEquals("abc", "abcd"), false);
48
+ assert.equal(constantTimeStringEquals("", ""), true);
49
+ });
50
+ test("csrfBypassFromEnv: defaults true, only literal off values opt out", () => {
51
+ assert.equal(csrfBypassFromEnv({}), true);
52
+ assert.equal(csrfBypassFromEnv({ OMCP_CSRF_BYPASS_BEARER: "true" }), true);
53
+ for (const v of ["0", "false", "no", "off", "FALSE", "Off"]) {
54
+ assert.equal(csrfBypassFromEnv({ OMCP_CSRF_BYPASS_BEARER: v }), false, v);
55
+ }
56
+ });
57
+ test("issuer: sets cookie when missing, no-op when present", () => {
58
+ const mw = buildCsrfIssuer(defaultCfg());
59
+ // Missing cookie -> set
60
+ const r1 = call(mw, { headers: {} });
61
+ assert.equal(r1.nexted, true);
62
+ const set = r1.res.getHeader("Set-Cookie");
63
+ assert.match(set, /^omcp-csrf=[A-Za-z0-9_-]+;/);
64
+ assert.match(set, /Path=\//);
65
+ assert.match(set, /SameSite=Lax/);
66
+ assert.doesNotMatch(set, /HttpOnly/);
67
+ // Present cookie -> no Set-Cookie emitted
68
+ const r2 = call(mw, { headers: { cookie: "omcp-csrf=abc" } });
69
+ assert.equal(r2.nexted, true);
70
+ assert.equal(r2.res.getHeader("Set-Cookie"), undefined);
71
+ });
72
+ test("issuer: Secure flag honors secureCookie callback", () => {
73
+ const mw = buildCsrfIssuer({ bypassBearer: true, secureCookie: () => true });
74
+ const r = call(mw, { headers: {} });
75
+ assert.match(r.res.getHeader("Set-Cookie"), /Secure/);
76
+ });
77
+ test("enforcer: GET/HEAD/OPTIONS always pass", () => {
78
+ const mw = buildCsrfEnforcer(defaultCfg());
79
+ for (const m of ["GET", "HEAD", "OPTIONS"]) {
80
+ const r = call(mw, { method: m, headers: {} });
81
+ assert.equal(r.nexted, true, m);
82
+ }
83
+ });
84
+ test("enforcer: bearer auth bypasses CSRF when bypassBearer=true", () => {
85
+ const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: true }));
86
+ const r = call(mw, {
87
+ method: "POST",
88
+ headers: { authorization: "Bearer abc.def.ghi" },
89
+ });
90
+ assert.equal(r.nexted, true);
91
+ });
92
+ test("enforcer: X-API-Key also bypasses when bypassBearer=true", () => {
93
+ const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: true }));
94
+ const r = call(mw, {
95
+ method: "POST",
96
+ headers: { "x-api-key": "abc" },
97
+ });
98
+ assert.equal(r.nexted, true);
99
+ });
100
+ test("enforcer: bypassBearer=false requires CSRF even for bearer clients", () => {
101
+ const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: false }));
102
+ const r = call(mw, {
103
+ method: "POST",
104
+ headers: { authorization: "Bearer abc" },
105
+ });
106
+ assert.equal(r.nexted, false);
107
+ assert.equal(r.res.status_, 403);
108
+ });
109
+ test("enforcer: cookie-session POST without header is rejected with 403", () => {
110
+ const mw = buildCsrfEnforcer(defaultCfg());
111
+ const r = call(mw, {
112
+ method: "POST",
113
+ headers: { cookie: "omcp-csrf=tok123" },
114
+ });
115
+ assert.equal(r.nexted, false);
116
+ assert.equal(r.res.status_, 403);
117
+ });
118
+ test("enforcer: cookie + matching header passes", () => {
119
+ const mw = buildCsrfEnforcer(defaultCfg());
120
+ const r = call(mw, {
121
+ method: "POST",
122
+ headers: { cookie: `${CSRF_COOKIE}=tok123`, [CSRF_HEADER]: "tok123" },
123
+ });
124
+ assert.equal(r.nexted, true);
125
+ });
126
+ test("enforcer: header != cookie is rejected (token mismatch attack)", () => {
127
+ const mw = buildCsrfEnforcer(defaultCfg());
128
+ const r = call(mw, {
129
+ method: "POST",
130
+ headers: { cookie: `${CSRF_COOKIE}=cookie-token`, [CSRF_HEADER]: "header-token" },
131
+ });
132
+ assert.equal(r.nexted, false);
133
+ assert.equal(r.res.status_, 403);
134
+ });
135
+ test("enforcer: missing cookie + header is rejected (no token at all)", () => {
136
+ const mw = buildCsrfEnforcer(defaultCfg());
137
+ const r = call(mw, {
138
+ method: "POST",
139
+ headers: {},
140
+ });
141
+ assert.equal(r.nexted, false);
142
+ assert.equal(r.res.status_, 403);
143
+ });
@@ -58,5 +58,11 @@ export declare function verifyPassword(plaintext: string, encoded: string): bool
58
58
  * anonymous mode cleanly.
59
59
  */
60
60
  export declare function readUsersFile(path: string): Promise<LocalUsersFile | null>;
61
+ /** Atomic write of the users file. Same tmp+rename pattern the
62
+ * products + token-budget snapshot writers use, so a crash mid-write
63
+ * leaves the previous file intact — never zero-byte. The file is
64
+ * the only persistent source of basic-mode credentials, so a
65
+ * half-write would lock every user out. */
66
+ export declare function writeUsersFile(path: string, file: LocalUsersFile): Promise<void>;
61
67
  /** Find a user by username (case-sensitive) and verify the supplied password. */
62
68
  export declare function authenticate(username: string, password: string, store: LocalUsersFile): LocalUser | null;
@@ -105,6 +105,17 @@ export async function readUsersFile(path) {
105
105
  return null;
106
106
  return parsed;
107
107
  }
108
+ /** Atomic write of the users file. Same tmp+rename pattern the
109
+ * products + token-budget snapshot writers use, so a crash mid-write
110
+ * leaves the previous file intact — never zero-byte. The file is
111
+ * the only persistent source of basic-mode credentials, so a
112
+ * half-write would lock every user out. */
113
+ export async function writeUsersFile(path, file) {
114
+ const text = JSON.stringify(file, null, 2) + "\n";
115
+ const tmp = path + ".tmp";
116
+ await fs.writeFile(tmp, text, { encoding: "utf8", mode: 0o600 });
117
+ await fs.rename(tmp, path);
118
+ }
108
119
  function isUsersFile(v) {
109
120
  if (!v || typeof v !== "object")
110
121
  return false;
@@ -78,3 +78,44 @@ test("authenticate — returns null for wrong password", () => {
78
78
  };
79
79
  assert.equal(authenticate("alice", "wrong", store), null);
80
80
  });
81
+ import { writeUsersFile } from "./local-users.js";
82
+ test("writeUsersFile — atomic round-trip preserves shape", async () => {
83
+ const { mkdtemp, rm } = await import("node:fs/promises");
84
+ const { tmpdir } = await import("node:os");
85
+ const { join } = await import("node:path");
86
+ const dir = await mkdtemp(join(tmpdir(), "omcp-users-"));
87
+ try {
88
+ const path = join(dir, "users.json");
89
+ const file = {
90
+ users: [
91
+ { username: "alice", name: "Alice", roles: ["operator", "viewer"], tenant: "acme", passwordHash: "scrypt$dummy" },
92
+ { username: "bob", name: "Bob", passwordHash: "scrypt$dummy2" },
93
+ ],
94
+ };
95
+ await writeUsersFile(path, file);
96
+ const back = await readUsersFile(path);
97
+ assert.ok(back);
98
+ assert.equal(back.users.length, 2);
99
+ assert.deepEqual(back.users[0].roles, ["operator", "viewer"]);
100
+ assert.equal(back.users[0].tenant, "acme");
101
+ assert.equal(back.users[1].passwordHash, "scrypt$dummy2");
102
+ }
103
+ finally {
104
+ await rm(dir, { recursive: true, force: true });
105
+ }
106
+ });
107
+ test("writeUsersFile — no .tmp leftover after success (atomic rename)", async () => {
108
+ const { mkdtemp, rm, readdir } = await import("node:fs/promises");
109
+ const { tmpdir } = await import("node:os");
110
+ const { join } = await import("node:path");
111
+ const dir = await mkdtemp(join(tmpdir(), "omcp-users-tmp-"));
112
+ try {
113
+ const path = join(dir, "users.json");
114
+ await writeUsersFile(path, { users: [{ username: "u", name: "U", passwordHash: "scrypt$x" }] });
115
+ const entries = await readdir(dir);
116
+ assert.deepEqual(entries.sort(), ["users.json"]);
117
+ }
118
+ finally {
119
+ await rm(dir, { recursive: true, force: true });
120
+ }
121
+ });