@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,61 @@
1
+ import type { AuditEntry } from "../log.js";
2
+ import type { AuditSink } from "./types.js";
3
+ /** Minimal subset of the AWS SDK S3Client we depend on. */
4
+ export interface S3ClientLike {
5
+ send(command: unknown): Promise<unknown>;
6
+ }
7
+ export interface S3SinkOptions {
8
+ /** Target bucket. Required. */
9
+ bucket: string;
10
+ /** Region — required for AWS, ignored by MinIO. Default us-east-1. */
11
+ region?: string;
12
+ /**
13
+ * Object key prefix. Default empty. The final key is
14
+ * `<prefix>/YYYY/MM/DD/HH/<minute>-<seqStart>-<seqEnd>.jsonl`.
15
+ * Trailing slash optional.
16
+ */
17
+ prefix?: string;
18
+ /**
19
+ * Override the AWS endpoint URL for S3-compatible backends
20
+ * (MinIO, R2, B2). Empty = use AWS regional endpoint.
21
+ */
22
+ endpoint?: string;
23
+ /** Force path-style addressing — required for MinIO + B2. */
24
+ forcePathStyle?: boolean;
25
+ /** Flush every N milliseconds. Default 60000 (1 minute). */
26
+ flushIntervalMs?: number;
27
+ /** Flush when buffer holds N entries. Default 1000. */
28
+ maxBufferSize?: number;
29
+ /** Optional path for unrecoverable batches. */
30
+ deadLetterFile?: string;
31
+ /** Inject a client for unit tests. */
32
+ client?: S3ClientLike;
33
+ }
34
+ export declare class S3Sink implements AuditSink {
35
+ readonly name = "s3";
36
+ private readonly bucket;
37
+ private readonly region;
38
+ private readonly prefix;
39
+ private readonly endpoint?;
40
+ private readonly forcePathStyle;
41
+ private readonly flushIntervalMs;
42
+ private readonly maxBufferSize;
43
+ private readonly deadLetterFile?;
44
+ private client;
45
+ private putCommand;
46
+ private sdkLoadError;
47
+ private buffer;
48
+ private flushTimer;
49
+ private writeQueue;
50
+ constructor(opts: S3SinkOptions);
51
+ /** Resolve the SDK lazily so the gateway still boots if @aws-sdk/client-s3
52
+ * isn't installed. Called on the first flush attempt only. */
53
+ private ensureClient;
54
+ write(entry: AuditEntry): Promise<void>;
55
+ flush(): Promise<void>;
56
+ /** Stop the timer + flush remaining. Called on SIGTERM. */
57
+ shutdown(): Promise<void>;
58
+ private flushNow;
59
+ private buildKey;
60
+ private deadLetter;
61
+ }
@@ -0,0 +1,179 @@
1
+ // S3Sink: ship audit entries to an S3-compatible object store
2
+ // (AWS S3, MinIO, Cloudflare R2, Backblaze B2, Wasabi, …).
3
+ //
4
+ // Buffer audit entries in memory; every flush window (default 60 s)
5
+ // or whenever the buffer crosses the cap (default 1000 entries),
6
+ // concatenate as JSONL and PUT one object under
7
+ // `<prefix>/YYYY/MM/DD/HH/<minute>-<seqStart>-<seqEnd>.jsonl`.
8
+ //
9
+ // Why per-minute rollups, not per-entry? S3 charges per PUT (5x per
10
+ // LIST / 1x per GET). An audit-heavy gateway generates 100s of
11
+ // entries/minute — per-entry PUT is wasteful on cost AND on SDK
12
+ // concurrency. One PUT/min keeps the bill low and still lets the
13
+ // operator scrape a minute-grained timeline in the storage console.
14
+ //
15
+ // Failures dead-letter to a local JSONL so a recovered S3 backend
16
+ // can be replayed by an external tool. The on-disk audit chain stays
17
+ // the authoritative master.
18
+ import { appendFile } from "node:fs/promises";
19
+ let _sdk = null;
20
+ async function loadSdk() {
21
+ if (_sdk)
22
+ return _sdk;
23
+ // @aws-sdk/client-s3 is an optional runtime dependency — operators
24
+ // only pull it in when they enable this sink. The specifier is
25
+ // resolved at runtime so TypeScript doesn't require the types at
26
+ // build time (matches the AWS-connector lazy-load pattern from Q1).
27
+ const specifier = "@aws-sdk/client-s3";
28
+ const s3 = (await import(specifier));
29
+ _sdk = { S3Client: s3.S3Client, PutObjectCommand: s3.PutObjectCommand };
30
+ return _sdk;
31
+ }
32
+ export class S3Sink {
33
+ name = "s3";
34
+ bucket;
35
+ region;
36
+ prefix;
37
+ endpoint;
38
+ forcePathStyle;
39
+ flushIntervalMs;
40
+ maxBufferSize;
41
+ deadLetterFile;
42
+ client = null;
43
+ putCommand = null;
44
+ sdkLoadError = null;
45
+ buffer = [];
46
+ flushTimer = null;
47
+ writeQueue = Promise.resolve();
48
+ constructor(opts) {
49
+ if (!opts.bucket)
50
+ throw new Error("S3Sink: bucket is required");
51
+ this.bucket = opts.bucket;
52
+ this.region = opts.region ?? process.env.AWS_REGION ?? "us-east-1";
53
+ // Normalise to "prefix/" form (trailing slash) when non-empty so
54
+ // we don't ever build `<prefix>YYYY/...` with a missing slash.
55
+ const rawPrefix = (opts.prefix ?? "").replace(/^\/+|\/+$/g, "");
56
+ this.prefix = rawPrefix ? `${rawPrefix}/` : "";
57
+ this.endpoint = opts.endpoint || undefined;
58
+ this.forcePathStyle = opts.forcePathStyle ?? false;
59
+ this.flushIntervalMs = opts.flushIntervalMs ?? 60_000;
60
+ this.maxBufferSize = opts.maxBufferSize ?? 1_000;
61
+ this.deadLetterFile = opts.deadLetterFile;
62
+ if (opts.client) {
63
+ this.client = opts.client;
64
+ // Tests inject a fake client AND need PutObjectCommand to be
65
+ // construct-able as a plain class. We stub here so the
66
+ // command-pattern API surface still works.
67
+ const Stub = class {
68
+ input;
69
+ constructor(input) { this.input = input; }
70
+ };
71
+ Object.defineProperty(Stub, "name", { value: "PutObjectCommand" });
72
+ this.putCommand = Stub;
73
+ }
74
+ }
75
+ /** Resolve the SDK lazily so the gateway still boots if @aws-sdk/client-s3
76
+ * isn't installed. Called on the first flush attempt only. */
77
+ async ensureClient() {
78
+ if (this.client && this.putCommand)
79
+ return true;
80
+ if (this.sdkLoadError)
81
+ return false;
82
+ try {
83
+ const sdk = await loadSdk();
84
+ const S3Client = sdk.S3Client;
85
+ this.client = new S3Client({
86
+ region: this.region,
87
+ endpoint: this.endpoint,
88
+ forcePathStyle: this.forcePathStyle,
89
+ });
90
+ this.putCommand = sdk.PutObjectCommand;
91
+ return true;
92
+ }
93
+ catch (err) {
94
+ this.sdkLoadError = err instanceof Error ? err.message : String(err);
95
+ console.warn("S3Sink: @aws-sdk/client-s3 not installed (%s) — entries will dead-letter", this.sdkLoadError);
96
+ return false;
97
+ }
98
+ }
99
+ async write(entry) {
100
+ this.buffer.push(entry);
101
+ if (!this.flushTimer && this.flushIntervalMs > 0) {
102
+ this.flushTimer = setInterval(() => { void this.flush(); }, this.flushIntervalMs);
103
+ if (this.flushTimer && typeof this.flushTimer.unref === "function") {
104
+ this.flushTimer.unref();
105
+ }
106
+ }
107
+ if (this.buffer.length >= this.maxBufferSize) {
108
+ // Buffer cap reached — flush in background, never block record().
109
+ this.writeQueue = this.writeQueue.then(() => this.flushNow());
110
+ }
111
+ }
112
+ async flush() {
113
+ this.writeQueue = this.writeQueue.then(() => this.flushNow());
114
+ await this.writeQueue;
115
+ }
116
+ /** Stop the timer + flush remaining. Called on SIGTERM. */
117
+ async shutdown() {
118
+ if (this.flushTimer) {
119
+ clearInterval(this.flushTimer);
120
+ this.flushTimer = null;
121
+ }
122
+ await this.flush();
123
+ }
124
+ // --- internals ----------------------------------------------------
125
+ async flushNow() {
126
+ if (this.buffer.length === 0)
127
+ return;
128
+ const batch = this.buffer.splice(0, this.buffer.length);
129
+ const ok = await this.ensureClient();
130
+ if (!ok) {
131
+ await this.deadLetter(batch);
132
+ return;
133
+ }
134
+ const body = batch.map((e) => JSON.stringify(e)).join("\n") + "\n";
135
+ const key = this.buildKey(batch);
136
+ try {
137
+ const cmd = new this.putCommand({
138
+ Bucket: this.bucket,
139
+ Key: key,
140
+ Body: body,
141
+ ContentType: "application/x-ndjson",
142
+ // Server-side encryption when running on AWS. MinIO + R2 + B2
143
+ // ignore the header gracefully.
144
+ ServerSideEncryption: "AES256",
145
+ });
146
+ await this.client.send(cmd);
147
+ }
148
+ catch (err) {
149
+ console.warn("S3Sink: PUT s3://%s/%s failed: %s — batch dead-letters", this.bucket, key, err instanceof Error ? err.message : String(err));
150
+ await this.deadLetter(batch);
151
+ }
152
+ }
153
+ buildKey(batch) {
154
+ // Derive the time bucket from the first entry's timestamp so a
155
+ // late-arriving entry stays grouped with its peers.
156
+ const t = new Date(batch[0]?.ts ?? new Date().toISOString());
157
+ const yyyy = String(t.getUTCFullYear());
158
+ const mm = String(t.getUTCMonth() + 1).padStart(2, "0");
159
+ const dd = String(t.getUTCDate()).padStart(2, "0");
160
+ const hh = String(t.getUTCHours()).padStart(2, "0");
161
+ const mi = String(t.getUTCMinutes()).padStart(2, "0");
162
+ const seqStart = batch[0]?.seq ?? 0;
163
+ const seqEnd = batch[batch.length - 1]?.seq ?? seqStart;
164
+ return `${this.prefix}${yyyy}/${mm}/${dd}/${hh}/${mi}-${seqStart}-${seqEnd}.jsonl`;
165
+ }
166
+ async deadLetter(batch) {
167
+ if (!this.deadLetterFile) {
168
+ console.warn("S3Sink: %d entries dropped (no deadLetterFile configured)", batch.length);
169
+ return;
170
+ }
171
+ try {
172
+ const body = batch.map((e) => JSON.stringify(e)).join("\n") + "\n";
173
+ await appendFile(this.deadLetterFile, body, "utf8");
174
+ }
175
+ catch (err) {
176
+ console.warn("S3Sink: DLQ write failed: %s", err instanceof Error ? err.message : String(err));
177
+ }
178
+ }
179
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,175 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, readFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { S3Sink } from "./s3.js";
7
+ function entry(seq, ts, action = "write") {
8
+ return {
9
+ seq,
10
+ ts,
11
+ actor: { sub: "alice@example.com", name: "alice" },
12
+ tenant: "default",
13
+ resource: "sources",
14
+ action,
15
+ method: "POST",
16
+ path: `/api/sources/s${seq}`,
17
+ status: 201,
18
+ ip: "10.0.0.7",
19
+ prevHash: "0".repeat(64),
20
+ hash: String(seq).padStart(64, "0"),
21
+ };
22
+ }
23
+ function fakeClient() {
24
+ const sent = [];
25
+ let throws = false;
26
+ const client = {
27
+ sent,
28
+ setThrow(v) { throws = v; },
29
+ async send(command) {
30
+ if (throws)
31
+ throw new Error("S3 unavailable (test)");
32
+ const input = command.input;
33
+ sent.push({ Bucket: input.Bucket, Key: input.Key, Body: input.Body });
34
+ return { $metadata: { httpStatusCode: 200 } };
35
+ },
36
+ };
37
+ return client;
38
+ }
39
+ test("constructor: bucket is required", () => {
40
+ assert.throws(() => new S3Sink({ bucket: "" }), /bucket is required/);
41
+ });
42
+ test("write+flush: one entry → one PUT with correct key shape", async () => {
43
+ const client = fakeClient();
44
+ const sink = new S3Sink({
45
+ bucket: "audit-bucket",
46
+ prefix: "omcp",
47
+ flushIntervalMs: 0,
48
+ client,
49
+ });
50
+ await sink.write(entry(1, "2026-06-06T15:23:00Z"));
51
+ await sink.flush();
52
+ assert.equal(client.sent.length, 1);
53
+ const put = client.sent[0];
54
+ assert.equal(put.Bucket, "audit-bucket");
55
+ assert.equal(put.Key, "omcp/2026/06/06/15/23-1-1.jsonl");
56
+ assert.equal(put.Body.trim().split("\n").length, 1);
57
+ });
58
+ test("multiple entries are batched into one PUT on flush", async () => {
59
+ const client = fakeClient();
60
+ const sink = new S3Sink({ bucket: "b", flushIntervalMs: 0, client });
61
+ await sink.write(entry(1, "2026-06-06T15:23:00Z"));
62
+ await sink.write(entry(2, "2026-06-06T15:23:01Z"));
63
+ await sink.write(entry(3, "2026-06-06T15:23:02Z"));
64
+ await sink.flush();
65
+ assert.equal(client.sent.length, 1);
66
+ const lines = client.sent[0].Body.trim().split("\n");
67
+ assert.equal(lines.length, 3);
68
+ // Key range reflects the seq span
69
+ assert.match(client.sent[0].Key, /23-1-3\.jsonl$/);
70
+ });
71
+ test("empty buffer flush is a no-op", async () => {
72
+ const client = fakeClient();
73
+ const sink = new S3Sink({ bucket: "b", flushIntervalMs: 0, client });
74
+ await sink.flush();
75
+ assert.equal(client.sent.length, 0);
76
+ });
77
+ test("maxBufferSize triggers an out-of-band flush", async () => {
78
+ const client = fakeClient();
79
+ const sink = new S3Sink({ bucket: "b", flushIntervalMs: 0, maxBufferSize: 2, client });
80
+ await sink.write(entry(1, "2026-06-06T15:23:00Z"));
81
+ await sink.write(entry(2, "2026-06-06T15:23:00Z"));
82
+ // The second write crosses the cap → background flush
83
+ await sink.flush();
84
+ assert.equal(client.sent.length, 1);
85
+ });
86
+ test("PUT failure dead-letters the batch", async () => {
87
+ const client = fakeClient();
88
+ client.setThrow(true);
89
+ const dlq = join(mkdtempSync(join(tmpdir(), "omcp-s3-dlq-")), "dlq.jsonl");
90
+ const sink = new S3Sink({
91
+ bucket: "b",
92
+ flushIntervalMs: 0,
93
+ client,
94
+ deadLetterFile: dlq,
95
+ });
96
+ await sink.write(entry(1, "2026-06-06T15:23:00Z"));
97
+ await sink.write(entry(2, "2026-06-06T15:23:01Z"));
98
+ await sink.flush();
99
+ // No successful PUT
100
+ assert.equal(client.sent.length, 0);
101
+ // DLQ file has both entries
102
+ const lines = readFileSync(dlq, "utf8").trim().split("\n");
103
+ assert.equal(lines.length, 2);
104
+ assert.match(lines[0], /"seq":1/);
105
+ });
106
+ test("missing SDK + no client → dead-letter (no throw)", async () => {
107
+ // Sink with neither an injected client nor the SDK installed.
108
+ // We can't really uninstall the SDK in tests, but constructing the
109
+ // sink without `client` AND without ever populating sdkLoadError
110
+ // exercises the load path. Validate that flush handles the "no
111
+ // client" state by relying on DLQ.
112
+ const dlq = join(mkdtempSync(join(tmpdir(), "omcp-s3-dlq2-")), "dlq.jsonl");
113
+ // Forcibly break the SDK loader by trying an obviously absent bucket
114
+ // path with a fake client whose `send` throws "MissingCredentials" —
115
+ // the deadLetter path catches it.
116
+ const sink = new S3Sink({
117
+ bucket: "b",
118
+ flushIntervalMs: 0,
119
+ deadLetterFile: dlq,
120
+ client: {
121
+ async send() { throw new Error("MissingCredentials"); },
122
+ },
123
+ });
124
+ await sink.write(entry(1, "2026-06-06T15:23:00Z"));
125
+ await sink.flush();
126
+ const lines = readFileSync(dlq, "utf8").trim().split("\n");
127
+ assert.equal(lines.length, 1);
128
+ });
129
+ test("prefix handles both with-trailing and without-trailing slash", async () => {
130
+ const c1 = fakeClient();
131
+ const s1 = new S3Sink({ bucket: "b", prefix: "audits", flushIntervalMs: 0, client: c1 });
132
+ await s1.write(entry(1, "2026-06-06T15:00:00Z"));
133
+ await s1.flush();
134
+ assert.match(c1.sent[0].Key, /^audits\/2026\//);
135
+ const c2 = fakeClient();
136
+ const s2 = new S3Sink({ bucket: "b", prefix: "/audits/", flushIntervalMs: 0, client: c2 });
137
+ await s2.write(entry(1, "2026-06-06T15:00:00Z"));
138
+ await s2.flush();
139
+ assert.match(c2.sent[0].Key, /^audits\/2026\//);
140
+ assert.doesNotMatch(c2.sent[0].Key, /^\/audits/);
141
+ const c3 = fakeClient();
142
+ const s3 = new S3Sink({ bucket: "b", flushIntervalMs: 0, client: c3 });
143
+ await s3.write(entry(1, "2026-06-06T15:00:00Z"));
144
+ await s3.flush();
145
+ // No prefix → key starts directly with YYYY
146
+ assert.match(c3.sent[0].Key, /^2026\//);
147
+ });
148
+ test("flush serialises — concurrent calls don't lose entries", async () => {
149
+ const client = fakeClient();
150
+ const sink = new S3Sink({ bucket: "b", flushIntervalMs: 0, client });
151
+ for (let i = 1; i <= 50; i++)
152
+ await sink.write(entry(i, "2026-06-06T15:00:00Z"));
153
+ // Multiple parallel flushes
154
+ await Promise.all([sink.flush(), sink.flush(), sink.flush()]);
155
+ // All 50 entries land in one (or at most a few) PUTs; total lines = 50
156
+ const allLines = client.sent.flatMap((p) => p.Body.trim().split("\n"));
157
+ assert.equal(allLines.length, 50);
158
+ });
159
+ test("shutdown stops the timer + flushes the remainder", async () => {
160
+ const client = fakeClient();
161
+ const sink = new S3Sink({ bucket: "b", flushIntervalMs: 1_000_000, client });
162
+ await sink.write(entry(1, "2026-06-06T15:00:00Z"));
163
+ await sink.write(entry(2, "2026-06-06T15:00:01Z"));
164
+ await sink.shutdown();
165
+ assert.equal(client.sent.length, 1);
166
+ assert.equal(client.sent[0].Body.trim().split("\n").length, 2);
167
+ });
168
+ test("uses entry's own ts for the key bucket (not wall-clock)", async () => {
169
+ const client = fakeClient();
170
+ const sink = new S3Sink({ bucket: "b", flushIntervalMs: 0, client });
171
+ // Entry from 5 minutes earlier — key must reflect THAT minute.
172
+ await sink.write(entry(1, "2026-06-06T15:18:00Z"));
173
+ await sink.flush();
174
+ assert.match(client.sent[0].Key, /15\/18-1-1\.jsonl$/);
175
+ });
@@ -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 {};