@thotischner/observability-mcp 1.8.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 (169) 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/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
@@ -0,0 +1,18 @@
1
+ import type { AuditEntry } from "../log.js";
2
+ /**
3
+ * A destination that receives every chained audit entry. The on-disk
4
+ * JSONL chain stays the authoritative master (so the hash chain is
5
+ * never split-brain across sinks); sinks are mirrors for SIEM /
6
+ * archive / webhook fan-out.
7
+ *
8
+ * write() must NEVER throw — sinks log+swallow internally. A sink that
9
+ * dies must not take down the management plane.
10
+ */
11
+ export interface AuditSink {
12
+ /** Stable identifier, used in logs and env selection. */
13
+ readonly name: string;
14
+ /** Persist one chained entry. Best-effort; failures are logged. */
15
+ write(entry: AuditEntry): Promise<void>;
16
+ /** Flush any buffered state. Called on SIGTERM and in tests. */
17
+ flush?(): Promise<void>;
18
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import type { AuditEntry } from "../log.js";
2
+ import type { AuditSink } from "./types.js";
3
+ export interface WebhookSinkOptions {
4
+ /** Receiver URL. Required. */
5
+ url: string;
6
+ /** Optional bearer token mounted into the Authorization header. */
7
+ token?: string;
8
+ /** Additional headers to merge into every request. */
9
+ headers?: Record<string, string>;
10
+ /** Initial backoff before the first retry (ms). Default 1_000. */
11
+ initialBackoffMs?: number;
12
+ /** Cap on individual-retry sleep (ms). Default 30_000. */
13
+ maxBackoffMs?: number;
14
+ /** Total attempts (including the first). Default 5. */
15
+ maxAttempts?: number;
16
+ /** Path to write entries that exhausted retries. Optional. */
17
+ deadLetterFile?: string;
18
+ /** Inject a fetch impl for tests; defaults to globalThis.fetch. */
19
+ fetchImpl?: typeof fetch;
20
+ /** Inject a sleep impl for tests; defaults to setTimeout-based. */
21
+ sleepImpl?: (ms: number) => Promise<void>;
22
+ /** Per-attempt request timeout (ms). Default 5_000. */
23
+ requestTimeoutMs?: number;
24
+ }
25
+ export declare class WebhookSink implements AuditSink {
26
+ readonly name = "webhook";
27
+ private readonly url;
28
+ private readonly token?;
29
+ private readonly headers;
30
+ private readonly initialBackoffMs;
31
+ private readonly maxBackoffMs;
32
+ private readonly maxAttempts;
33
+ private readonly deadLetterFile?;
34
+ private readonly fetchImpl;
35
+ private readonly sleepImpl;
36
+ private readonly requestTimeoutMs;
37
+ /** Outbound writes are queued so retries on one entry don't reorder
38
+ * later entries arriving in parallel. */
39
+ private writeQueue;
40
+ constructor(opts: WebhookSinkOptions);
41
+ write(entry: AuditEntry): Promise<void>;
42
+ flush(): Promise<void>;
43
+ private attemptWithRetries;
44
+ private attemptOnce;
45
+ }
@@ -0,0 +1,111 @@
1
+ // WebhookSink: POST every audit entry to an HTTP endpoint (Splunk
2
+ // HEC, generic SIEM ingestor, or any service that accepts a JSON
3
+ // body). Retries with exponential backoff; entries that exhaust their
4
+ // retries land in a dead-letter file on disk so an operator can
5
+ // replay them once the receiver recovers.
6
+ //
7
+ // The sink runs entirely in-process — no queue daemon, no broker, no
8
+ // extra runtime dep. Suitable for the OSS single-binary deployment.
9
+ import { appendFile } from "node:fs/promises";
10
+ export class WebhookSink {
11
+ name = "webhook";
12
+ url;
13
+ token;
14
+ headers;
15
+ initialBackoffMs;
16
+ maxBackoffMs;
17
+ maxAttempts;
18
+ deadLetterFile;
19
+ fetchImpl;
20
+ sleepImpl;
21
+ requestTimeoutMs;
22
+ /** Outbound writes are queued so retries on one entry don't reorder
23
+ * later entries arriving in parallel. */
24
+ writeQueue = Promise.resolve();
25
+ constructor(opts) {
26
+ if (!opts.url)
27
+ throw new Error("WebhookSink: url is required");
28
+ this.url = opts.url;
29
+ this.token = opts.token;
30
+ this.headers = opts.headers ?? {};
31
+ this.initialBackoffMs = opts.initialBackoffMs ?? 1_000;
32
+ this.maxBackoffMs = opts.maxBackoffMs ?? 30_000;
33
+ this.maxAttempts = opts.maxAttempts ?? 5;
34
+ this.deadLetterFile = opts.deadLetterFile;
35
+ this.fetchImpl = opts.fetchImpl ?? fetch;
36
+ this.sleepImpl =
37
+ opts.sleepImpl ??
38
+ ((ms) => new Promise((r) => setTimeout(r, ms).unref()));
39
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? 5_000;
40
+ }
41
+ async write(entry) {
42
+ this.writeQueue = this.writeQueue.then(() => this.attemptWithRetries(entry).catch((err) => {
43
+ // Final exhaustion path already wrote to DLQ if configured;
44
+ // log here so the operator sees the symptom even without DLQ.
45
+ console.warn("WebhookSink: dropping entry seq=%d after %d attempts: %s", entry.seq, this.maxAttempts, err instanceof Error ? err.message : String(err));
46
+ }));
47
+ // Returning before the request completes keeps record() latency
48
+ // independent of webhook receiver health.
49
+ return Promise.resolve();
50
+ }
51
+ async flush() {
52
+ await this.writeQueue;
53
+ }
54
+ async attemptWithRetries(entry) {
55
+ let backoff = this.initialBackoffMs;
56
+ let lastErr;
57
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
58
+ try {
59
+ await this.attemptOnce(entry);
60
+ return;
61
+ }
62
+ catch (err) {
63
+ lastErr = err;
64
+ if (attempt === this.maxAttempts)
65
+ break;
66
+ await this.sleepImpl(backoff);
67
+ backoff = Math.min(backoff * 2, this.maxBackoffMs);
68
+ }
69
+ }
70
+ if (this.deadLetterFile) {
71
+ try {
72
+ await appendFile(this.deadLetterFile, JSON.stringify(entry) + "\n", "utf8");
73
+ }
74
+ catch (writeErr) {
75
+ console.warn("WebhookSink: DLQ write failed: %s", writeErr instanceof Error ? writeErr.message : String(writeErr));
76
+ }
77
+ }
78
+ throw lastErr instanceof Error
79
+ ? lastErr
80
+ : new Error(String(lastErr ?? "webhook delivery failed"));
81
+ }
82
+ async attemptOnce(entry) {
83
+ const headers = {
84
+ "content-type": "application/json",
85
+ ...this.headers,
86
+ };
87
+ if (this.token)
88
+ headers["authorization"] = `Bearer ${this.token}`;
89
+ const ctl = new AbortController();
90
+ const t = setTimeout(() => ctl.abort(), this.requestTimeoutMs).unref?.();
91
+ try {
92
+ const res = await this.fetchImpl(this.url, {
93
+ method: "POST",
94
+ headers,
95
+ body: JSON.stringify(entry),
96
+ signal: ctl.signal,
97
+ });
98
+ if (!res.ok) {
99
+ // Read+discard so the connection releases. Limit body so a
100
+ // misbehaving receiver can't make the gateway page in MB of
101
+ // text per failure.
102
+ const snippet = (await res.text().catch(() => "")).slice(0, 200);
103
+ throw new Error(`webhook returned ${res.status} ${res.statusText}: ${snippet}`);
104
+ }
105
+ }
106
+ finally {
107
+ if (typeof t === "object" && t)
108
+ clearTimeout(t);
109
+ }
110
+ }
111
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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 {};