@thotischner/observability-mcp 1.4.0 → 1.5.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 (68) hide show
  1. package/dist/analysis/anomaly.d.ts +89 -0
  2. package/dist/analysis/anomaly.js +235 -0
  3. package/dist/analysis/anomaly.test.js +149 -1
  4. package/dist/analysis/backtest.d.ts +31 -0
  5. package/dist/analysis/backtest.js +206 -0
  6. package/dist/analysis/backtest.test.d.ts +1 -0
  7. package/dist/analysis/backtest.test.js +34 -0
  8. package/dist/analysis/correlator.d.ts +35 -0
  9. package/dist/analysis/correlator.js +95 -0
  10. package/dist/analysis/correlator.test.js +60 -1
  11. package/dist/analysis/health.d.ts +2 -3
  12. package/dist/analysis/index.d.ts +32 -0
  13. package/dist/analysis/index.js +29 -0
  14. package/dist/analysis/library.test.d.ts +1 -0
  15. package/dist/analysis/library.test.js +44 -0
  16. package/dist/auth/credentials.d.ts +29 -0
  17. package/dist/auth/credentials.js +76 -0
  18. package/dist/auth/credentials.test.d.ts +1 -0
  19. package/dist/auth/credentials.test.js +57 -0
  20. package/dist/cli/index.d.ts +2 -0
  21. package/dist/cli/index.js +370 -0
  22. package/dist/cli/lib.d.ts +95 -0
  23. package/dist/cli/lib.js +185 -0
  24. package/dist/cli/lib.test.d.ts +1 -0
  25. package/dist/cli/lib.test.js +134 -0
  26. package/dist/connectors/hub.d.ts +48 -0
  27. package/dist/connectors/hub.js +51 -0
  28. package/dist/connectors/hub.test.d.ts +1 -0
  29. package/dist/connectors/hub.test.js +52 -0
  30. package/dist/connectors/install.d.ts +24 -0
  31. package/dist/connectors/install.js +100 -0
  32. package/dist/connectors/install.test.d.ts +1 -0
  33. package/dist/connectors/install.test.js +58 -0
  34. package/dist/connectors/loader.d.ts +5 -0
  35. package/dist/connectors/loader.js +54 -2
  36. package/dist/connectors/loki.js +11 -4
  37. package/dist/connectors/loki.test.js +27 -0
  38. package/dist/connectors/verify.d.ts +19 -0
  39. package/dist/connectors/verify.js +87 -0
  40. package/dist/connectors/verify.test.d.ts +1 -0
  41. package/dist/connectors/verify.test.js +63 -0
  42. package/dist/context.d.ts +27 -0
  43. package/dist/context.js +18 -0
  44. package/dist/index.js +322 -34
  45. package/dist/net/egress-policy.d.ts +31 -0
  46. package/dist/net/egress-policy.js +37 -0
  47. package/dist/net/egress-policy.test.d.ts +1 -0
  48. package/dist/net/egress-policy.test.js +52 -0
  49. package/dist/sdk/index.d.ts +6 -0
  50. package/dist/sdk/manifest-schema.d.ts +1 -0
  51. package/dist/sdk/manifest-schema.js +11 -0
  52. package/dist/tools/context-seam.test.d.ts +1 -0
  53. package/dist/tools/context-seam.test.js +23 -0
  54. package/dist/tools/detect-anomalies.d.ts +2 -1
  55. package/dist/tools/detect-anomalies.js +47 -11
  56. package/dist/tools/get-service-health.d.ts +2 -1
  57. package/dist/tools/get-service-health.js +2 -1
  58. package/dist/tools/handlers.test.js +73 -0
  59. package/dist/tools/list-services.d.ts +2 -1
  60. package/dist/tools/list-services.js +2 -1
  61. package/dist/tools/list-sources.d.ts +2 -1
  62. package/dist/tools/list-sources.js +2 -1
  63. package/dist/tools/query-logs.d.ts +2 -1
  64. package/dist/tools/query-logs.js +2 -1
  65. package/dist/tools/query-metrics.d.ts +2 -1
  66. package/dist/tools/query-metrics.js +9 -1
  67. package/dist/ui/index.html +119 -4
  68. package/package.json +18 -5
@@ -23,9 +23,14 @@ export declare class PluginLoader {
23
23
  private connectors;
24
24
  private pluginsDir;
25
25
  private disabled;
26
+ private verify;
27
+ private trustRootPath?;
28
+ private trustRoot?;
26
29
  constructor(opts?: {
27
30
  pluginsDir?: string;
28
31
  disabled?: string[];
32
+ verify?: boolean;
33
+ trustRoot?: string;
29
34
  });
30
35
  load(): Promise<void>;
31
36
  list(): LoadedConnector[];
@@ -6,6 +6,7 @@ import { PrometheusConnector } from "./prometheus.js";
6
6
  import { LokiConnector } from "./loki.js";
7
7
  import { sanitizeForLog } from "../util/sanitize.js";
8
8
  import { instrumentConnector } from "../metrics/instrument-connector.js";
9
+ import { loadTrustRoot, verifyIntegrity, verifyManifestSignature, PluginVerificationError, } from "./verify.js";
9
10
  /**
10
11
  * Resolves which connector implementations the server should know about,
11
12
  * applying three sources in order (later overrides earlier):
@@ -20,6 +21,13 @@ export class PluginLoader {
20
21
  connectors = new Map();
21
22
  pluginsDir;
22
23
  disabled;
24
+ // Fail-closed verification for filesystem plugins. Builtins are part
25
+ // of the trusted image and are never gated. Default off so existing
26
+ // deployments are unchanged; recommended on in prod/airgapped (the
27
+ // Helm chart sets it).
28
+ verify;
29
+ trustRootPath;
30
+ trustRoot;
23
31
  constructor(opts = {}) {
24
32
  this.pluginsDir = opts.pluginsDir
25
33
  ?? process.env.PLUGINS_DIR
@@ -30,9 +38,25 @@ export class PluginLoader {
30
38
  .map((s) => s.trim())
31
39
  .filter(Boolean);
32
40
  this.disabled = new Set([...(opts.disabled ?? []), ...envDisabled]);
41
+ this.verify = opts.verify ?? /^(1|true|yes)$/i.test(process.env.VERIFY_PLUGINS ?? "");
42
+ this.trustRootPath = opts.trustRoot ?? process.env.PLUGIN_TRUST_ROOT;
33
43
  }
34
44
  async load() {
35
45
  this.loadBuiltins();
46
+ if (this.verify) {
47
+ if (!this.trustRootPath) {
48
+ console.warn("VERIFY_PLUGINS is on but PLUGIN_TRUST_ROOT is unset — refusing to load any filesystem plugins (fail-closed). Builtins remain available.");
49
+ return;
50
+ }
51
+ try {
52
+ this.trustRoot = loadTrustRoot(this.trustRootPath);
53
+ console.log("Plugin verification enabled; trust root loaded from %s", sanitizeForLog(this.trustRootPath));
54
+ }
55
+ catch (err) {
56
+ console.warn("VERIFY_PLUGINS is on but trust root failed to load (%s) — refusing to load any filesystem plugins (fail-closed). Builtins remain available.", sanitizeForLog(String(err)));
57
+ return;
58
+ }
59
+ }
36
60
  await this.loadFilesystem();
37
61
  }
38
62
  list() {
@@ -105,10 +129,13 @@ export class PluginLoader {
105
129
  if (!marker || marker.kind !== "connector" || !marker.name)
106
130
  return;
107
131
  let manifest;
132
+ let manifestPath;
133
+ let manifestBytes;
108
134
  if (marker.manifest) {
109
- const manifestPath = resolve(pluginRoot, marker.manifest);
135
+ manifestPath = resolve(pluginRoot, marker.manifest);
110
136
  if (existsSync(manifestPath)) {
111
- const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
137
+ manifestBytes = readFileSync(manifestPath);
138
+ const raw = JSON.parse(manifestBytes.toString("utf8"));
112
139
  const parsed = manifestSchema.safeParse(raw);
113
140
  if (!parsed.success) {
114
141
  const issues = parsed.error.issues
@@ -130,6 +157,31 @@ export class PluginLoader {
130
157
  console.warn("Plugin %s missing entry file %s", sanitizeForLog(marker.name), sanitizeForLog(entryFile));
131
158
  return;
132
159
  }
160
+ // Fail-closed verification gate. A plugin only loads under
161
+ // VERIFY_PLUGINS if it ships a manifest whose `integrity` matches
162
+ // the entry file AND a detached `<manifest>.sig` that verifies
163
+ // against the trust root. Everything is local — airgapped-safe.
164
+ if (this.verify) {
165
+ if (!manifest || !manifestPath || !manifestBytes) {
166
+ console.warn("VERIFY_PLUGINS: plugin %s has no manifest.json — skipping (fail-closed)", sanitizeForLog(marker.name));
167
+ return;
168
+ }
169
+ const sigPath = manifestPath + ".sig";
170
+ if (!existsSync(sigPath)) {
171
+ console.warn("VERIFY_PLUGINS: plugin %s missing manifest signature %s — skipping (fail-closed)", sanitizeForLog(marker.name), sanitizeForLog(marker.manifest + ".sig"));
172
+ return;
173
+ }
174
+ try {
175
+ verifyManifestSignature(manifestBytes, readFileSync(sigPath), this.trustRoot);
176
+ verifyIntegrity(entryPath, manifest.integrity);
177
+ }
178
+ catch (err) {
179
+ const detail = err instanceof PluginVerificationError ? err.message : String(err);
180
+ console.warn("VERIFY_PLUGINS: plugin %s failed verification (%s) — skipping (fail-closed)", sanitizeForLog(marker.name), sanitizeForLog(detail));
181
+ return;
182
+ }
183
+ console.log("VERIFY_PLUGINS: plugin %s signature + integrity OK", sanitizeForLog(marker.name));
184
+ }
133
185
  const mod = await import(pathToFileURL(entryPath).href);
134
186
  const factory = mod.default ?? mod.createConnector;
135
187
  if (typeof factory !== "function") {
@@ -54,13 +54,19 @@ export class LokiConnector {
54
54
  }
55
55
  async disconnect() { }
56
56
  async listServices() {
57
- // Probe each candidate label and merge values. Loki streams may identify
58
- // services via service_name, service, job, app, or container depending on
59
- // the shipper configuration. Walking all candidates ensures historical
60
- // streams remain reachable when label conventions change over time.
57
+ // Candidate labels are ordered by preference (service_name, service,
58
+ // job, app, container). The FIRST label that yields any values wins —
59
+ // we do not union across labels. Unioning duplicated every service:
60
+ // one real container is simultaneously `service="api-gateway"` and
61
+ // `container="myproj-api-gateway-1"`, and a co-located shipper can add
62
+ // unrelated `container` values (e.g. other compose/k8s containers on
63
+ // the same Docker host). The ordered fallback still keeps streams
64
+ // reachable on backends that only carry a low-priority label.
61
65
  const seen = new Map();
62
66
  for (const label of this.serviceLabels) {
63
67
  const values = await this.getLabelValues(label);
68
+ if (values.length === 0)
69
+ continue;
64
70
  for (const raw of values) {
65
71
  // Docker's loki.source.docker writes container names with a leading '/'
66
72
  // (Docker API Names[0] convention). Strip it for display so the name
@@ -75,6 +81,7 @@ export class LokiConnector {
75
81
  });
76
82
  }
77
83
  }
84
+ break; // first non-empty label is authoritative
78
85
  }
79
86
  return Array.from(seen.values());
80
87
  }
@@ -108,4 +108,31 @@ describe("LokiConnector", () => {
108
108
  assert.equal(proto.escapeLogQLRegex("error`test`"), "error\\`test\\`");
109
109
  });
110
110
  });
111
+ describe("listServices", () => {
112
+ function withLabelValues(map) {
113
+ const c = new LokiConnector();
114
+ c.serviceLabels = ["service_name", "service", "job", "app", "container"];
115
+ c.getLabelValues = async (label) => map[label] ?? [];
116
+ return c;
117
+ }
118
+ it("first non-empty label wins — does NOT union container aliases", async () => {
119
+ const c = withLabelValues({
120
+ service: ["api-gateway", "payment-service"],
121
+ // co-located shipper noise that must NOT leak in:
122
+ container: ["myproj-api-gateway-1", "k8s_POD_api-gateway_demo_x"],
123
+ });
124
+ const names = (await c.listServices()).map((s) => s.name).sort();
125
+ assert.deepEqual(names, ["api-gateway", "payment-service"]);
126
+ });
127
+ it("falls back to a lower-priority label only when higher ones are empty", async () => {
128
+ const c = withLabelValues({ container: ["/svc-a", "/svc-b"] });
129
+ const svcs = await c.listServices();
130
+ assert.deepEqual(svcs.map((s) => s.name).sort(), ["svc-a", "svc-b"]);
131
+ assert.equal(svcs[0].labels.discoveredVia, "container");
132
+ });
133
+ it("returns empty when no candidate label has values", async () => {
134
+ const c = withLabelValues({});
135
+ assert.deepEqual(await c.listServices(), []);
136
+ });
137
+ });
111
138
  });
@@ -0,0 +1,19 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ export declare class PluginVerificationError extends Error {
3
+ }
4
+ /** Parse a PEM public key into a KeyObject. Throws PluginVerificationError. */
5
+ export declare function loadTrustRoot(pemPath: string): KeyObject;
6
+ /** "sha256-<base64>" digest of a buffer, matching the manifest `integrity` form. */
7
+ export declare function sha256Integrity(data: Buffer): string;
8
+ /**
9
+ * Constant-time-ish compare of the entry file against the manifest's
10
+ * declared integrity digest. Throws on mismatch.
11
+ */
12
+ export declare function verifyIntegrity(entryPath: string, integrity: string | undefined): void;
13
+ /**
14
+ * Verify a detached signature over the raw manifest bytes against the
15
+ * trust root. Signature is read as base64 (whitespace tolerated, e.g. a
16
+ * `manifest.json.sig` produced by `openssl dgst -sign ... | base64`).
17
+ * Throws PluginVerificationError if the signature does not verify.
18
+ */
19
+ export declare function verifyManifestSignature(manifestBytes: Buffer, signatureBytes: Buffer, trustRoot: KeyObject): void;
@@ -0,0 +1,87 @@
1
+ import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ // Airgapped-friendly plugin verification.
4
+ //
5
+ // No network, no external binary (no cosign): a plugin is trusted when
6
+ // 1. its entry file hashes to the manifest's `integrity` digest, and
7
+ // 2. the manifest bytes carry a detached signature that verifies
8
+ // against a locally-configured trust-root public key.
9
+ //
10
+ // Ed25519 and RSA/EC (PKCS#8 / SPKI PEM) trust roots are supported.
11
+ export class PluginVerificationError extends Error {
12
+ }
13
+ /** Parse a PEM public key into a KeyObject. Throws PluginVerificationError. */
14
+ export function loadTrustRoot(pemPath) {
15
+ let pem;
16
+ try {
17
+ pem = readFileSync(pemPath, "utf8");
18
+ }
19
+ catch (err) {
20
+ throw new PluginVerificationError(`trust root unreadable at ${pemPath}: ${String(err)}`);
21
+ }
22
+ try {
23
+ return createPublicKey(pem);
24
+ }
25
+ catch (err) {
26
+ throw new PluginVerificationError(`trust root is not a valid PEM public key: ${String(err)}`);
27
+ }
28
+ }
29
+ /** "sha256-<base64>" digest of a buffer, matching the manifest `integrity` form. */
30
+ export function sha256Integrity(data) {
31
+ return "sha256-" + createHash("sha256").update(data).digest("base64");
32
+ }
33
+ /**
34
+ * Constant-time-ish compare of the entry file against the manifest's
35
+ * declared integrity digest. Throws on mismatch.
36
+ */
37
+ export function verifyIntegrity(entryPath, integrity) {
38
+ if (!integrity) {
39
+ throw new PluginVerificationError("manifest has no integrity digest");
40
+ }
41
+ let bytes;
42
+ try {
43
+ bytes = readFileSync(entryPath);
44
+ }
45
+ catch (err) {
46
+ throw new PluginVerificationError(`entry file unreadable: ${String(err)}`);
47
+ }
48
+ const actual = sha256Integrity(bytes);
49
+ if (actual.length !== integrity.length || actual !== integrity) {
50
+ throw new PluginVerificationError(`entry file integrity mismatch (manifest=${integrity} actual=${actual})`);
51
+ }
52
+ }
53
+ /**
54
+ * Verify a detached signature over the raw manifest bytes against the
55
+ * trust root. Signature is read as base64 (whitespace tolerated, e.g. a
56
+ * `manifest.json.sig` produced by `openssl dgst -sign ... | base64`).
57
+ * Throws PluginVerificationError if the signature does not verify.
58
+ */
59
+ export function verifyManifestSignature(manifestBytes, signatureBytes, trustRoot) {
60
+ const sig = decodeSignature(signatureBytes);
61
+ // Ed25519/Ed448 take algorithm=null; RSA/EC sign over a SHA-256 digest.
62
+ const keyType = trustRoot.asymmetricKeyType;
63
+ const algorithm = keyType === "ed25519" || keyType === "ed448" ? null : "sha256";
64
+ let ok = false;
65
+ try {
66
+ ok = cryptoVerify(algorithm, manifestBytes, trustRoot, sig);
67
+ }
68
+ catch (err) {
69
+ throw new PluginVerificationError(`signature verification errored: ${String(err)}`);
70
+ }
71
+ if (!ok) {
72
+ throw new PluginVerificationError("manifest signature does not match trust root");
73
+ }
74
+ }
75
+ // Accept either raw DER bytes or a base64/armored .sig file.
76
+ function decodeSignature(raw) {
77
+ const text = raw.toString("utf8").trim();
78
+ if (/^[A-Za-z0-9+/\s=]+$/.test(text) && text.length > 0) {
79
+ const compact = text.replace(/\s+/g, "");
80
+ const b = Buffer.from(compact, "base64");
81
+ // Round-trip check: if it re-encodes cleanly it was really base64.
82
+ if (b.length > 0 && b.toString("base64").replace(/=+$/, "") === compact.replace(/=+$/, "")) {
83
+ return b;
84
+ }
85
+ }
86
+ return raw;
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { generateKeyPairSync, sign as cryptoSign, createPublicKey } from "node:crypto";
4
+ import { mkdtempSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { sha256Integrity, verifyIntegrity, verifyManifestSignature, loadTrustRoot, PluginVerificationError, } from "./verify.js";
8
+ function tmp() {
9
+ return mkdtempSync(join(tmpdir(), "verify-"));
10
+ }
11
+ test("sha256Integrity produces sha256-<base64> and round-trips", () => {
12
+ const digest = sha256Integrity(Buffer.from("hello"));
13
+ assert.match(digest, /^sha256-[A-Za-z0-9+/]+=*$/);
14
+ assert.equal(sha256Integrity(Buffer.from("hello")), digest);
15
+ assert.notEqual(sha256Integrity(Buffer.from("hellp")), digest);
16
+ });
17
+ test("verifyIntegrity passes on match, throws on mismatch and missing", () => {
18
+ const dir = tmp();
19
+ const entry = join(dir, "index.js");
20
+ writeFileSync(entry, "export default () => ({});\n");
21
+ const good = sha256Integrity(Buffer.from("export default () => ({});\n"));
22
+ assert.doesNotThrow(() => verifyIntegrity(entry, good));
23
+ assert.throws(() => verifyIntegrity(entry, "sha256-AAAA"), PluginVerificationError);
24
+ assert.throws(() => verifyIntegrity(entry, undefined), PluginVerificationError);
25
+ assert.throws(() => verifyIntegrity(join(dir, "nope.js"), good), PluginVerificationError);
26
+ });
27
+ test("Ed25519: valid signature verifies, tampered manifest is rejected", () => {
28
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
29
+ const manifest = Buffer.from('{"name":"prometheus","schemaVersion":1}');
30
+ const sig = cryptoSign(null, manifest, privateKey);
31
+ assert.doesNotThrow(() => verifyManifestSignature(manifest, sig, publicKey));
32
+ // base64-armored signature (the form `openssl ... | base64` emits)
33
+ const armored = Buffer.from(sig.toString("base64"));
34
+ assert.doesNotThrow(() => verifyManifestSignature(manifest, armored, publicKey));
35
+ // tampered bytes
36
+ assert.throws(() => verifyManifestSignature(Buffer.from('{"name":"evil"}'), sig, publicKey), PluginVerificationError);
37
+ });
38
+ test("RSA trust root path (algorithm=sha256) verifies", () => {
39
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
40
+ const manifest = Buffer.from('{"name":"loki"}');
41
+ const sig = cryptoSign("sha256", manifest, privateKey);
42
+ assert.doesNotThrow(() => verifyManifestSignature(manifest, sig, publicKey));
43
+ });
44
+ test("signature from a different key is rejected", () => {
45
+ const a = generateKeyPairSync("ed25519");
46
+ const b = generateKeyPairSync("ed25519");
47
+ const manifest = Buffer.from("payload");
48
+ const sig = cryptoSign(null, manifest, a.privateKey);
49
+ assert.throws(() => verifyManifestSignature(manifest, sig, b.publicKey), PluginVerificationError);
50
+ });
51
+ test("loadTrustRoot parses PEM and rejects garbage", () => {
52
+ const dir = tmp();
53
+ const { publicKey } = generateKeyPairSync("ed25519");
54
+ const pem = publicKey.export({ type: "spki", format: "pem" });
55
+ const p = join(dir, "trust.pem");
56
+ writeFileSync(p, pem);
57
+ const loaded = loadTrustRoot(p);
58
+ assert.equal(loaded.export({ type: "spki", format: "pem" }), createPublicKey(pem).export({ type: "spki", format: "pem" }));
59
+ const bad = join(dir, "bad.pem");
60
+ writeFileSync(bad, "not a key");
61
+ assert.throws(() => loadTrustRoot(bad), PluginVerificationError);
62
+ assert.throws(() => loadTrustRoot(join(dir, "missing.pem")), PluginVerificationError);
63
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Request-scoped context threaded from the transport boundary (HTTP `/mcp`,
3
+ * stdio, and the internal REST/dashboard call sites) into every tool handler.
4
+ *
5
+ * Today it carries only an anonymous principal and a correlation id — it is a
6
+ * deliberate pass-through that does not change behaviour. It is the single
7
+ * seam that later access-control / scoping / audit work attaches to, so those
8
+ * features become additive rather than a cross-cutting rewrite.
9
+ */
10
+ export interface RequestContext {
11
+ /** Stable id for the calling principal. "anonymous" when no auth configured. */
12
+ principalId: string;
13
+ /** How the principal was authenticated. */
14
+ auth: "anonymous" | "apikey";
15
+ /**
16
+ * Coarse per-credential source allow-list (single-tenant primitive). When
17
+ * set, the principal may only target these source names. Rich role-based
18
+ * scoping (tools/services/lookback/read-only) is a separate concern.
19
+ */
20
+ allowedSources?: string[];
21
+ /** Correlates all tool calls within one transport request/session. */
22
+ correlationId: string;
23
+ }
24
+ /** Default all-access anonymous context — preserves current behaviour. */
25
+ export declare function defaultContext(): RequestContext;
26
+ /** Context for an authenticated API-key principal. */
27
+ export declare function principalContext(principalId: string, allowedSources?: string[]): RequestContext;
@@ -0,0 +1,18 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /** Default all-access anonymous context — preserves current behaviour. */
3
+ export function defaultContext() {
4
+ return {
5
+ principalId: "anonymous",
6
+ auth: "anonymous",
7
+ correlationId: randomUUID(),
8
+ };
9
+ }
10
+ /** Context for an authenticated API-key principal. */
11
+ export function principalContext(principalId, allowedSources) {
12
+ return {
13
+ principalId,
14
+ auth: "apikey",
15
+ allowedSources: allowedSources && allowedSources.length > 0 ? allowedSources : undefined,
16
+ correlationId: randomUUID(),
17
+ };
18
+ }