@thotischner/observability-mcp 1.7.0 → 1.8.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 (111) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/audit/log.d.ts +99 -0
  3. package/dist/audit/log.js +180 -0
  4. package/dist/audit/log.test.d.ts +1 -0
  5. package/dist/audit/log.test.js +147 -0
  6. package/dist/audit/middleware.d.ts +20 -0
  7. package/dist/audit/middleware.js +50 -0
  8. package/dist/auth/credentials.d.ts +18 -0
  9. package/dist/auth/credentials.js +26 -1
  10. package/dist/auth/credentials.test.js +26 -1
  11. package/dist/auth/local-users.d.ts +62 -0
  12. package/dist/auth/local-users.js +143 -0
  13. package/dist/auth/local-users.test.d.ts +1 -0
  14. package/dist/auth/local-users.test.js +80 -0
  15. package/dist/auth/middleware.d.ts +48 -0
  16. package/dist/auth/middleware.js +65 -0
  17. package/dist/auth/middleware.test.d.ts +1 -0
  18. package/dist/auth/middleware.test.js +90 -0
  19. package/dist/auth/oidc/client.d.ts +73 -0
  20. package/dist/auth/oidc/client.js +104 -0
  21. package/dist/auth/oidc/client.test.d.ts +1 -0
  22. package/dist/auth/oidc/client.test.js +121 -0
  23. package/dist/auth/oidc/discovery.d.ts +38 -0
  24. package/dist/auth/oidc/discovery.js +48 -0
  25. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  26. package/dist/auth/oidc/discovery.test.js +68 -0
  27. package/dist/auth/oidc/endpoints.d.ts +20 -0
  28. package/dist/auth/oidc/endpoints.js +124 -0
  29. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  30. package/dist/auth/oidc/endpoints.test.js +304 -0
  31. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  32. package/dist/auth/oidc/flow-cookie.js +142 -0
  33. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  34. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  35. package/dist/auth/oidc/index.d.ts +7 -0
  36. package/dist/auth/oidc/index.js +6 -0
  37. package/dist/auth/oidc/jwks.d.ts +36 -0
  38. package/dist/auth/oidc/jwks.js +69 -0
  39. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  40. package/dist/auth/oidc/jwks.test.js +65 -0
  41. package/dist/auth/oidc/jwt.d.ts +62 -0
  42. package/dist/auth/oidc/jwt.js +113 -0
  43. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  44. package/dist/auth/oidc/jwt.test.js +141 -0
  45. package/dist/auth/oidc/pkce.d.ts +19 -0
  46. package/dist/auth/oidc/pkce.js +43 -0
  47. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  48. package/dist/auth/oidc/pkce.test.js +55 -0
  49. package/dist/auth/oidc/runtime.d.ts +63 -0
  50. package/dist/auth/oidc/runtime.js +129 -0
  51. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  52. package/dist/auth/oidc/runtime.test.js +180 -0
  53. package/dist/auth/policy/engine.d.ts +48 -0
  54. package/dist/auth/policy/engine.js +73 -0
  55. package/dist/auth/policy/engine.test.d.ts +1 -0
  56. package/dist/auth/policy/engine.test.js +98 -0
  57. package/dist/auth/policy/loader.d.ts +35 -0
  58. package/dist/auth/policy/loader.js +100 -0
  59. package/dist/auth/policy/opa.d.ts +69 -0
  60. package/dist/auth/policy/opa.js +162 -0
  61. package/dist/auth/policy/opa.test.d.ts +1 -0
  62. package/dist/auth/policy/opa.test.js +158 -0
  63. package/dist/auth/rbac.d.ts +40 -0
  64. package/dist/auth/rbac.js +120 -0
  65. package/dist/auth/rbac.test.d.ts +1 -0
  66. package/dist/auth/rbac.test.js +121 -0
  67. package/dist/auth/session.d.ts +66 -0
  68. package/dist/auth/session.js +146 -0
  69. package/dist/auth/session.test.d.ts +1 -0
  70. package/dist/auth/session.test.js +90 -0
  71. package/dist/catalog/loader.d.ts +67 -0
  72. package/dist/catalog/loader.js +122 -0
  73. package/dist/catalog/loader.test.d.ts +1 -0
  74. package/dist/catalog/loader.test.js +108 -0
  75. package/dist/connectors/kubernetes.d.ts +1 -0
  76. package/dist/connectors/kubernetes.js +12 -2
  77. package/dist/connectors/topology-vocabulary.d.ts +41 -0
  78. package/dist/connectors/topology-vocabulary.js +120 -0
  79. package/dist/connectors/topology-vocabulary.test.d.ts +1 -0
  80. package/dist/connectors/topology-vocabulary.test.js +63 -0
  81. package/dist/context.d.ts +13 -1
  82. package/dist/context.js +5 -1
  83. package/dist/index.js +1012 -29
  84. package/dist/net/egress-policy.js +2 -0
  85. package/dist/openapi.js +440 -0
  86. package/dist/openapi.test.d.ts +1 -0
  87. package/dist/openapi.test.js +64 -0
  88. package/dist/policy/redact.d.ts +44 -0
  89. package/dist/policy/redact.js +144 -0
  90. package/dist/policy/redact.test.d.ts +1 -0
  91. package/dist/policy/redact.test.js +172 -0
  92. package/dist/products/loader.d.ts +84 -0
  93. package/dist/products/loader.js +216 -0
  94. package/dist/products/loader.test.d.ts +1 -0
  95. package/dist/products/loader.test.js +168 -0
  96. package/dist/quota/limiter.d.ts +72 -0
  97. package/dist/quota/limiter.js +105 -0
  98. package/dist/quota/limiter.test.d.ts +1 -0
  99. package/dist/quota/limiter.test.js +119 -0
  100. package/dist/quota/token-budget.d.ts +119 -0
  101. package/dist/quota/token-budget.js +297 -0
  102. package/dist/quota/token-budget.test.d.ts +1 -0
  103. package/dist/quota/token-budget.test.js +215 -0
  104. package/dist/tenancy/context.d.ts +45 -0
  105. package/dist/tenancy/context.js +97 -0
  106. package/dist/tenancy/context.test.d.ts +1 -0
  107. package/dist/tenancy/context.test.js +72 -0
  108. package/dist/tenancy/migration.test.d.ts +7 -0
  109. package/dist/tenancy/migration.test.js +75 -0
  110. package/dist/ui/index.html +1454 -88
  111. package/package.json +20 -3
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Conservative PII / secret redaction for tool outputs that may contain
3
+ * arbitrary log payloads.
4
+ *
5
+ * Scope of this module: pure string redaction with deterministic
6
+ * patterns. Returns the rewritten string plus a per-category count so
7
+ * callers can surface a "redacted N matches" hint to the user / agent
8
+ * without leaking what was matched. Designed to be safe-by-default
9
+ * (over-redact rather than under-redact) and explicit (each category
10
+ * tagged in the replacement marker, e.g. `[redacted-email]`).
11
+ *
12
+ * Bypass today is process-wide only: set `OMCP_REDACTION=off` if the
13
+ * upstream is already PII-clean. A per-request `redaction:bypass` RBAC
14
+ * permission for interactive admin sessions is on the roadmap — see
15
+ * docs/access-control.md "Why are my logs returning [redacted-email]?"
16
+ * and docs/redaction.md for the current state.
17
+ */
18
+ // Patterns chosen for low false-positive on operational log text:
19
+ // - Email: standard local@domain.tld with limited TLD chars.
20
+ // - IPv4: strict 0-255 quads to avoid matching version numbers etc.
21
+ // - IPv6: full / compressed; we accept the common forms only.
22
+ // - Bearer: "Authorization: Bearer <token>" — pulls the token out.
23
+ // - JWT: 3-part base64url joined by dots.
24
+ // - Generic API-key: long alnum + base64-ish run after a key= marker.
25
+ const PATTERNS = [
26
+ // High-confidence cloud-vendor secrets go first — their prefixes are
27
+ // distinctive enough that they don't conflict with generic patterns.
28
+ // - AWS access key id: 16-32 chars after AKIA/ASIA/AROA prefix.
29
+ // - Slack tokens: xoxa-/xoxb-/xoxp-/xoxr-/xoxs- + 10+ chars.
30
+ // - GitHub PAT: github_pat_<base62 segments> or ghp_/gho_/ghs_/ghu_/ghr_ + 36 chars.
31
+ // - PEM private-key blocks: greedy match across newlines.
32
+ { category: "aws-key", re: /\b(?:AKIA|ASIA|AROA)[0-9A-Z]{16,20}\b/g },
33
+ { category: "slack-token", re: /\bxox[abprsu]-[A-Za-z0-9-]{10,}\b/g },
34
+ { category: "gh-pat", re: /\b(?:github_pat_[A-Za-z0-9_]{40,}|gh[opsuru]_[A-Za-z0-9]{36})\b/g },
35
+ { category: "private-key", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
36
+ // emails before other patterns so they don't get eaten partially
37
+ { category: "email", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
38
+ { category: "jwt", re: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g },
39
+ { category: "bearer", re: /\b[Bb]earer\s+[A-Za-z0-9._\-+/=]{12,}\b/g },
40
+ { category: "api-key", re: /\b(?:api[_-]?key|x-api-key|token|secret)[=:]\s*['"]?[A-Za-z0-9._\-+/=]{16,}['"]?/gi },
41
+ // ipv6 — covers full, mid-compressed, leading "::loopback" / "::ffff:v4"
42
+ // mapped forms, and "::1". Trailing-only `xxxx::` shapes are rare in
43
+ // operational logs and intentionally not covered. MUST run before
44
+ // ipv4 so that the IPv4-mapped form (`::ffff:192.168.1.42`) is
45
+ // classified as IPv6 rather than having ipv4 eat the dotted tail
46
+ // and leave a half-redacted `::ffff:[redacted-ipv4]` token.
47
+ { category: "ipv6", re: /\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){1,6}(?::[0-9a-fA-F]{1,4}){1,6}\b|::1\b|::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g },
48
+ { category: "ipv4", re: /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g },
49
+ ];
50
+ function emptyCounts() {
51
+ return {
52
+ email: 0, ipv4: 0, ipv6: 0, bearer: 0, jwt: 0, "api-key": 0,
53
+ "aws-key": 0, "slack-token": 0, "private-key": 0, "gh-pat": 0,
54
+ "credit-card": 0,
55
+ };
56
+ }
57
+ /** Luhn check — accepts digits-only string of 13–19 chars. Used to
58
+ * keep the credit-card redaction from over-matching random digit
59
+ * strings (order IDs, timestamps, etc.). */
60
+ function luhn(digits) {
61
+ let sum = 0;
62
+ let alt = false;
63
+ for (let i = digits.length - 1; i >= 0; i--) {
64
+ let n = digits.charCodeAt(i) - 48;
65
+ if (n < 0 || n > 9)
66
+ return false;
67
+ if (alt) {
68
+ n *= 2;
69
+ if (n > 9)
70
+ n -= 9;
71
+ }
72
+ sum += n;
73
+ alt = !alt;
74
+ }
75
+ return sum % 10 === 0;
76
+ }
77
+ /** Run all patterns in a deterministic order; later patterns won't
78
+ * re-match content already replaced by an earlier one (the marker
79
+ * starts with `[redacted-` which none of the patterns match). */
80
+ export function redactText(input) {
81
+ const matches = emptyCounts();
82
+ let text = input;
83
+ for (const { category, re } of PATTERNS) {
84
+ text = text.replace(re, () => {
85
+ matches[category] += 1;
86
+ return `[redacted-${category}]`;
87
+ });
88
+ }
89
+ // Credit-card pass runs last so an inner-substring of a longer
90
+ // already-redacted token can't accidentally match. Luhn-validated
91
+ // so order numbers / timestamps / random digit strings stay intact.
92
+ text = text.replace(/\b(?:\d[ -]?){12,18}\d\b/g, (match) => {
93
+ const digits = match.replace(/[ -]/g, "");
94
+ if (digits.length < 13 || digits.length > 19)
95
+ return match;
96
+ if (!luhn(digits))
97
+ return match;
98
+ matches["credit-card"] += 1;
99
+ return "[redacted-credit-card]";
100
+ });
101
+ let total = 0;
102
+ for (const k of Object.keys(matches))
103
+ total += matches[k];
104
+ return { text, matches, totalMatches: total };
105
+ }
106
+ /** Maximum nesting depth the walker will descend into. Operational
107
+ * log payloads are essentially flat (objects of strings + a few
108
+ * nested arrays); a pathologically deep structure is almost certainly
109
+ * a bug or an attack, and stack-overflowing the auth path is worse
110
+ * than truncating. The cap is generous — well above anything a
111
+ * Prometheus / Loki record would ever produce. */
112
+ export const MAX_REDACT_DEPTH = 64;
113
+ /** Walk an arbitrary parsed-JSON value and redact every string leaf,
114
+ * accumulating match counts. Non-string leaves and structural keys are
115
+ * left untouched. Returns a new value (does not mutate input). Bails
116
+ * out below `MAX_REDACT_DEPTH` levels of nesting and returns the raw
117
+ * sub-tree untouched at that point. */
118
+ export function redactValue(input) {
119
+ const counts = emptyCounts();
120
+ function walk(v, depth) {
121
+ if (depth > MAX_REDACT_DEPTH)
122
+ return v;
123
+ if (typeof v === "string") {
124
+ const r = redactText(v);
125
+ for (const k of Object.keys(counts))
126
+ counts[k] += r.matches[k];
127
+ return r.text;
128
+ }
129
+ if (Array.isArray(v))
130
+ return v.map((x) => walk(x, depth + 1));
131
+ if (v && typeof v === "object") {
132
+ const out = {};
133
+ for (const [k, vv] of Object.entries(v))
134
+ out[k] = walk(vv, depth + 1);
135
+ return out;
136
+ }
137
+ return v;
138
+ }
139
+ const value = walk(input, 0);
140
+ let total = 0;
141
+ for (const k of Object.keys(counts))
142
+ total += counts[k];
143
+ return { value, matches: counts, totalMatches: total };
144
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,172 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { redactText, redactValue } from "./redact.js";
4
+ test("redactText — emails are redacted, counted", () => {
5
+ const r = redactText("alert from alice@example.com to bob@corp.co.uk");
6
+ assert.equal(r.matches.email, 2);
7
+ assert.equal(r.totalMatches, 2);
8
+ assert.match(r.text, /\[redacted-email\].*\[redacted-email\]/);
9
+ });
10
+ test("redactText — IPv4 quads redacted, version numbers left alone", () => {
11
+ const r = redactText("client 192.168.1.42 connected to 10.0.0.1; version 1.2.3.4");
12
+ // "1.2.3.4" technically matches as IPv4 — that's fine, it's a valid IPv4
13
+ // and our threat model errs on the side of over-redaction.
14
+ assert.ok(r.matches.ipv4 >= 2);
15
+ assert.match(r.text, /\[redacted-ipv4\]/);
16
+ });
17
+ test("redactText — bearer tokens stripped", () => {
18
+ const r = redactText('GET /api/foo Authorization: Bearer abcdef1234567890XYZ');
19
+ assert.equal(r.matches.bearer, 1);
20
+ assert.match(r.text, /\[redacted-bearer\]/);
21
+ assert.doesNotMatch(r.text, /abcdef1234567890XYZ/);
22
+ });
23
+ test("redactText — JWTs detected by eyJ prefix + three-part shape", () => {
24
+ const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abcdefghijk-_ABC";
25
+ const r = redactText(`token=${jwt} for user`);
26
+ assert.ok(r.matches.jwt >= 1);
27
+ assert.doesNotMatch(r.text, /eyJ/);
28
+ });
29
+ test("redactText — api-key / cloud-token style assignments", () => {
30
+ // r1: generic prefix-based api-key match.
31
+ // r2: x-api-key with an opaque body — falls through to api-key.
32
+ // r3: token= with a Slack-shape value — the new slack-token pattern
33
+ // wins because it runs before api-key; either outcome is fine,
34
+ // the contract is "the secret is gone after one pass".
35
+ const r1 = redactText('api_key="abc123def456ghi789jkl"');
36
+ const r2 = redactText("x-api-key: sk_test_abcdefghijklmnopqrstuvwxyz");
37
+ const r3 = redactText("token=xoxb-1234567890-abcdefghijklm");
38
+ assert.ok(r1.totalMatches >= 1, "expected r1 to be redacted somewhere");
39
+ assert.ok(r2.totalMatches >= 1, "expected r2 to be redacted somewhere");
40
+ assert.ok(r3.totalMatches >= 1, "expected r3 to be redacted somewhere");
41
+ assert.doesNotMatch(r1.text, /abc123def456ghi789jkl/);
42
+ assert.doesNotMatch(r2.text, /sk_test_abcdefghijklmnopqrstuvwxyz/);
43
+ assert.doesNotMatch(r3.text, /xoxb-1234567890/);
44
+ });
45
+ test("redactText — leaves harmless text alone", () => {
46
+ const r = redactText("the order-service replied with 200 OK after 45ms");
47
+ assert.equal(r.totalMatches, 0);
48
+ assert.equal(r.text, "the order-service replied with 200 OK after 45ms");
49
+ });
50
+ test("redactText — long digit strings without Luhn don't trigger credit-card", () => {
51
+ // 16-digit telemetry sequence (UNIX nanos + service id) — should pass through.
52
+ const r = redactText("ts=1717000000000000000 seq=4242424242424242 user=test");
53
+ // 4242424242424242 IS Luhn-valid (Visa test number), so that counts as a hit;
54
+ // but ts=1717... starts with a non-bordered digit run that includes "1717" pattern.
55
+ // The assertion: only the Luhn-valid 16-digit string is counted as credit-card.
56
+ assert.equal(r.matches["credit-card"], 1);
57
+ });
58
+ test("redactText — already-redacted markers don't re-match in further passes", () => {
59
+ // Run the redactor twice; the second pass should be a no-op.
60
+ const first = redactText("contact alice@example.com");
61
+ const second = redactText(first.text);
62
+ assert.equal(second.totalMatches, 0);
63
+ });
64
+ test("redactValue — walks nested objects / arrays, mutates only strings", () => {
65
+ const input = {
66
+ user: "bob@corp.co.uk",
67
+ nested: {
68
+ ip: "10.0.0.1",
69
+ count: 42,
70
+ tags: ["audit", "client=alice@example.com"],
71
+ },
72
+ flag: true,
73
+ };
74
+ const r = redactValue(input);
75
+ const v = r.value;
76
+ assert.equal(v.user, "[redacted-email]");
77
+ assert.equal(v.nested.ip, "[redacted-ipv4]");
78
+ assert.equal(v.nested.count, 42);
79
+ assert.equal(v.nested.tags[0], "audit");
80
+ assert.equal(v.nested.tags[1], "client=[redacted-email]");
81
+ assert.equal(v.flag, true);
82
+ assert.equal(r.matches.email, 2);
83
+ assert.equal(r.matches.ipv4, 1);
84
+ assert.equal(r.totalMatches, 3);
85
+ });
86
+ test("redactText — AWS access key IDs (AKIA / ASIA / AROA) are redacted", () => {
87
+ const r1 = redactText("log: assumed role AKIAIOSFODNN7EXAMPLE today");
88
+ const r2 = redactText("temporary creds ASIAY34FZKBOKMUTVV7A logged");
89
+ const r3 = redactText("role-arn AROAIIAFOO2ZBADBCEXAMPLE");
90
+ assert.equal(r1.matches["aws-key"], 1);
91
+ assert.equal(r2.matches["aws-key"], 1);
92
+ assert.equal(r3.matches["aws-key"], 1);
93
+ assert.match(r1.text, /\[redacted-aws-key\]/);
94
+ assert.doesNotMatch(r1.text, /AKIAIOSFODNN7EXAMPLE/);
95
+ });
96
+ test("redactText — Slack tokens (xoxa / xoxb / xoxp / …) are redacted", () => {
97
+ const r = redactText("slack notify: token=xoxb-1234567890-abcdefghijklm result: ok");
98
+ assert.equal(r.matches["slack-token"], 1);
99
+ assert.doesNotMatch(r.text, /xoxb-1234567890/);
100
+ });
101
+ test("redactText — GitHub PATs are redacted (ghp_ / github_pat_)", () => {
102
+ const r1 = redactText("git remote set-url origin https://ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789@github.com/x/y.git");
103
+ // Use a 40-char body, which matches `[A-Za-z0-9_]{40,}` (note: includes underscore)
104
+ const r2 = redactText("token github_pat_ABCDEFGH_IJKLMNOPQRSTUVWXYZ012345678ABCDEFGHIJKLMNOP");
105
+ assert.equal(r1.matches["gh-pat"], 1);
106
+ assert.equal(r2.matches["gh-pat"], 1);
107
+ });
108
+ test("redactText — Luhn-valid credit-card numbers are redacted, invalid ones pass through", () => {
109
+ // Visa test number 4111 1111 1111 1111 (Luhn-valid)
110
+ const r1 = redactText("charge attempted on card 4111111111111111 for $42.00");
111
+ assert.equal(r1.matches["credit-card"], 1);
112
+ assert.match(r1.text, /\[redacted-credit-card\]/);
113
+ // Same number with separators
114
+ const r2 = redactText("card 4111-1111-1111-1111 declined");
115
+ assert.equal(r2.matches["credit-card"], 1);
116
+ assert.doesNotMatch(r2.text, /4111-1111-1111-1111/);
117
+ // Random 16 digits that DON'T pass Luhn → left alone (e.g. order ID)
118
+ const r3 = redactText("order 1234567890123456 created");
119
+ assert.equal(r3.matches["credit-card"], 0);
120
+ assert.match(r3.text, /1234567890123456/);
121
+ // Too short / too long stays as-is
122
+ const r4 = redactText("seq 123456789012 and 12345678901234567890");
123
+ assert.equal(r4.matches["credit-card"], 0);
124
+ });
125
+ test("redactText — PEM private-key blocks are redacted greedily", () => {
126
+ const pem = `-----BEGIN RSA PRIVATE KEY-----
127
+ MIIEpAIBAAKCAQEAwLPVKj…
128
+ -----END RSA PRIVATE KEY-----`;
129
+ const r = redactText(`config:\n${pem}\nend`);
130
+ assert.equal(r.matches["private-key"], 1);
131
+ assert.doesNotMatch(r.text, /MIIEpAIBAA/);
132
+ });
133
+ test("redactValue — pathologically deep nesting hits the depth cap, returns sub-tree untouched", () => {
134
+ // Build a structure deeper than MAX_REDACT_DEPTH (64). At the cap,
135
+ // the walker should return the remaining sub-tree as-is — no
136
+ // mutations beyond depth 64, and no stack overflow.
137
+ let leaf = "alice@example.com";
138
+ for (let i = 0; i < 200; i++)
139
+ leaf = { wrap: leaf };
140
+ // Wrap the deep sub-tree inside a shallow root so a string near
141
+ // the surface still gets redacted.
142
+ const r = redactValue({ shallow: "bob@example.com", deep: leaf });
143
+ const v = r.value;
144
+ assert.equal(v.shallow, "[redacted-email]", "shallow leaf still redacted");
145
+ assert.equal(r.matches.email, 1, "only the shallow email is redacted past the depth cap");
146
+ });
147
+ test("redactText — IPv6 addresses are redacted across full / compressed / mapped forms", () => {
148
+ // Full 8-group address
149
+ const r1 = redactText("peer 2001:0db8:85a3:0000:0000:8a2e:0370:7334 connected");
150
+ assert.ok(r1.matches.ipv6 >= 1, "expected full IPv6 to be redacted");
151
+ assert.doesNotMatch(r1.text, /2001:0db8/);
152
+ // Mid-compressed (single :: collapsing one zero run)
153
+ const r2 = redactText("client 2001:db8::8a2e:370:7334 disconnected");
154
+ assert.ok(r2.matches.ipv6 >= 1, "expected compressed IPv6 to be redacted");
155
+ // Loopback
156
+ const r3 = redactText("listening on ::1 port 8080");
157
+ assert.ok(r3.matches.ipv6 >= 1, "expected ::1 loopback to be redacted");
158
+ assert.doesNotMatch(r3.text, /::1\b/);
159
+ // IPv4-mapped IPv6
160
+ const r4 = redactText("source ::ffff:192.168.1.42 forwarded");
161
+ assert.ok(r4.matches.ipv6 >= 1, "expected ::ffff: mapped form to be redacted");
162
+ // Pure version string — must NOT match IPv6 (only two colons, not 7-group)
163
+ const r5 = redactText("server version 1.2.3 build 4");
164
+ assert.equal(r5.matches.ipv6, 0, "version string must not look like IPv6");
165
+ });
166
+ test("redactValue — null / undefined leaves are preserved", () => {
167
+ const r = redactValue({ a: null, b: undefined, c: "alice@example.com" });
168
+ const v = r.value;
169
+ assert.equal(v.a, null);
170
+ assert.equal(v.b, undefined);
171
+ assert.equal(v.c, "[redacted-email]");
172
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * MCP Products — curated, agent-facing collections of tools.
3
+ *
4
+ * A Product is a named bundle that ships with branding metadata
5
+ * (icon, description, version) plus a list of allowed MCP tools.
6
+ * The agent calling /mcp can be told which Product it belongs to
7
+ * (via a future header / arg, slice 2+), and the server can filter
8
+ * tools/list and tools/call responses accordingly.
9
+ *
10
+ * Today's surface (slice 1):
11
+ * - In-memory ProductsStore loaded from OMCP_PRODUCTS_FILE
12
+ * (YAML or JSON). Missing/empty file → empty catalog.
13
+ * - Strict validation: unknown action / unknown resource /
14
+ * unexpected keys reject loudly.
15
+ * - Hot-reload on next /api/products call (slice 2 wires the
16
+ * reload trigger; for now the file is read once at boot).
17
+ */
18
+ export interface Product {
19
+ /** Stable identifier — used in URLs, audit entries, /api/products/{id}. */
20
+ id: string;
21
+ /** Display name shown in the UI / agent dropdown. */
22
+ name: string;
23
+ /** One-sentence description. */
24
+ description?: string;
25
+ /** Allowed MCP tool names. Empty / undefined → all tools allowed. */
26
+ tools?: string[];
27
+ /** Operator-defined version label, e.g. "1.0.0" or "preview". */
28
+ version?: string;
29
+ /** Free-form branding metadata for the UI — icon URL, theme colour, etc. */
30
+ branding?: {
31
+ iconUrl?: string;
32
+ color?: string;
33
+ };
34
+ /** Lifecycle stage: published = visible to agents; staging = admin-only. */
35
+ status?: "published" | "staging";
36
+ /** Tenant this product belongs to. Omitted → "default". */
37
+ tenant?: string;
38
+ }
39
+ export interface ProductsFile {
40
+ products: Product[];
41
+ }
42
+ export declare class ProductsLoadError extends Error {
43
+ constructor(msg: string);
44
+ }
45
+ export declare function readProductsFile(path: string | undefined): Promise<ProductsFile>;
46
+ export declare function parseProductsText(text: string, origin: string): ProductsFile;
47
+ /** In-memory store with tenant- and status-aware queries. */
48
+ export declare class ProductsStore {
49
+ private file;
50
+ constructor(file?: ProductsFile);
51
+ /** Return the product list. When `tenant` is set, filters to that
52
+ * tenant (entries without a tenant field treated as "default").
53
+ * When `includeStaging` is false (default), staging products are
54
+ * hidden from the result — admins should pass true. */
55
+ list(opts?: {
56
+ tenant?: string;
57
+ includeStaging?: boolean;
58
+ }): Product[];
59
+ /** Lookup by id. Cross-tenant gets return undefined when `tenant` set. */
60
+ get(id: string, tenant?: string): Product | undefined;
61
+ count(tenant?: string): number;
62
+ replace(file: ProductsFile): void;
63
+ /** Upsert (replace if id exists, else append). Returns the new
64
+ * ProductsFile so the caller can persist it. */
65
+ upsert(product: Product): ProductsFile;
66
+ /** Remove by id. Returns true when the product existed, false
67
+ * otherwise. Caller persists the resulting file. */
68
+ delete(id: string): {
69
+ removed: boolean;
70
+ file: ProductsFile;
71
+ };
72
+ /** Snapshot of the current file (for tests / persistence). */
73
+ snapshot(): ProductsFile;
74
+ }
75
+ /** Validate a single product entry by routing it through the same
76
+ * parser as the file format. Throws ProductsLoadError on any
77
+ * shape problem. Used by PUT /api/products/:id so a typo / wrong
78
+ * type / unknown key gets the same loud rejection a malformed
79
+ * file would. */
80
+ export declare function validateProduct(input: unknown, origin?: string): Product;
81
+ /** Atomic write of the products file. Same tmp+rename pattern as
82
+ * the audit-chain + token-budget snapshot, so a crash mid-write
83
+ * leaves the previous file intact. */
84
+ export declare function writeProductsFile(path: string, file: ProductsFile): Promise<void>;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * MCP Products — curated, agent-facing collections of tools.
3
+ *
4
+ * A Product is a named bundle that ships with branding metadata
5
+ * (icon, description, version) plus a list of allowed MCP tools.
6
+ * The agent calling /mcp can be told which Product it belongs to
7
+ * (via a future header / arg, slice 2+), and the server can filter
8
+ * tools/list and tools/call responses accordingly.
9
+ *
10
+ * Today's surface (slice 1):
11
+ * - In-memory ProductsStore loaded from OMCP_PRODUCTS_FILE
12
+ * (YAML or JSON). Missing/empty file → empty catalog.
13
+ * - Strict validation: unknown action / unknown resource /
14
+ * unexpected keys reject loudly.
15
+ * - Hot-reload on next /api/products call (slice 2 wires the
16
+ * reload trigger; for now the file is read once at boot).
17
+ */
18
+ import { readFile, writeFile, rename } from "node:fs/promises";
19
+ import yaml from "js-yaml";
20
+ const EMPTY = { products: [] };
21
+ const VALID_STATUS = new Set(["published", "staging"]);
22
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
23
+ export class ProductsLoadError extends Error {
24
+ constructor(msg) {
25
+ super(msg);
26
+ this.name = "ProductsLoadError";
27
+ }
28
+ }
29
+ export async function readProductsFile(path) {
30
+ if (!path)
31
+ return EMPTY;
32
+ let text;
33
+ try {
34
+ text = await readFile(path, "utf8");
35
+ }
36
+ catch (e) {
37
+ const code = e.code;
38
+ if (code === "ENOENT")
39
+ return EMPTY;
40
+ console.warn(`[products] could not read ${path}: ${e.message} — starting with empty catalog`);
41
+ return EMPTY;
42
+ }
43
+ return parseProductsText(text, path);
44
+ }
45
+ export function parseProductsText(text, origin) {
46
+ let parsed;
47
+ try {
48
+ parsed = yaml.load(text);
49
+ }
50
+ catch (e) {
51
+ throw new ProductsLoadError(`${origin}: not valid YAML/JSON: ${e.message}`);
52
+ }
53
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
54
+ throw new ProductsLoadError(`${origin}: root must be an object with a 'products' array`);
55
+ }
56
+ const root = parsed;
57
+ const rawProducts = root.products;
58
+ if (!Array.isArray(rawProducts)) {
59
+ throw new ProductsLoadError(`${origin}: 'products' must be an array`);
60
+ }
61
+ const out = [];
62
+ const seenIds = new Set();
63
+ for (let i = 0; i < rawProducts.length; i++) {
64
+ const e = rawProducts[i];
65
+ if (!e || typeof e !== "object" || Array.isArray(e)) {
66
+ throw new ProductsLoadError(`${origin}: products[${i}] must be an object`);
67
+ }
68
+ const r = e;
69
+ if (typeof r.id !== "string" || !ID_RE.test(r.id)) {
70
+ throw new ProductsLoadError(`${origin}: products[${i}].id must be a string matching ${ID_RE}`);
71
+ }
72
+ if (seenIds.has(r.id)) {
73
+ throw new ProductsLoadError(`${origin}: duplicate product id '${r.id}'`);
74
+ }
75
+ seenIds.add(r.id);
76
+ if (typeof r.name !== "string" || !r.name) {
77
+ throw new ProductsLoadError(`${origin}: products[${i}].name must be a non-empty string`);
78
+ }
79
+ const p = { id: r.id, name: r.name };
80
+ if (r.description !== undefined) {
81
+ if (typeof r.description !== "string")
82
+ throw new ProductsLoadError(`${origin}: products[${i}].description must be a string`);
83
+ p.description = r.description;
84
+ }
85
+ if (r.tools !== undefined) {
86
+ if (!Array.isArray(r.tools) || !r.tools.every((t) => typeof t === "string")) {
87
+ throw new ProductsLoadError(`${origin}: products[${i}].tools must be an array of strings`);
88
+ }
89
+ p.tools = r.tools;
90
+ }
91
+ if (r.version !== undefined) {
92
+ if (typeof r.version !== "string")
93
+ throw new ProductsLoadError(`${origin}: products[${i}].version must be a string`);
94
+ p.version = r.version;
95
+ }
96
+ if (r.status !== undefined) {
97
+ if (typeof r.status !== "string" || !VALID_STATUS.has(r.status)) {
98
+ throw new ProductsLoadError(`${origin}: products[${i}].status must be one of ${[...VALID_STATUS].join(", ")}`);
99
+ }
100
+ p.status = r.status;
101
+ }
102
+ if (r.tenant !== undefined) {
103
+ if (typeof r.tenant !== "string")
104
+ throw new ProductsLoadError(`${origin}: products[${i}].tenant must be a string`);
105
+ p.tenant = r.tenant;
106
+ }
107
+ if (r.branding !== undefined) {
108
+ if (!r.branding || typeof r.branding !== "object" || Array.isArray(r.branding)) {
109
+ throw new ProductsLoadError(`${origin}: products[${i}].branding must be an object`);
110
+ }
111
+ const b = r.branding;
112
+ p.branding = {};
113
+ if (b.iconUrl !== undefined) {
114
+ if (typeof b.iconUrl !== "string")
115
+ throw new ProductsLoadError(`${origin}: products[${i}].branding.iconUrl must be a string`);
116
+ p.branding.iconUrl = b.iconUrl;
117
+ }
118
+ if (b.color !== undefined) {
119
+ if (typeof b.color !== "string")
120
+ throw new ProductsLoadError(`${origin}: products[${i}].branding.color must be a string`);
121
+ p.branding.color = b.color;
122
+ }
123
+ }
124
+ // Reject unexpected top-level keys — operator typo guard
125
+ for (const k of Object.keys(r)) {
126
+ if (!["id", "name", "description", "tools", "version", "branding", "status", "tenant"].includes(k)) {
127
+ throw new ProductsLoadError(`${origin}: products[${i}] has unexpected key '${k}'`);
128
+ }
129
+ }
130
+ out.push(p);
131
+ }
132
+ return { products: out };
133
+ }
134
+ /** In-memory store with tenant- and status-aware queries. */
135
+ export class ProductsStore {
136
+ file;
137
+ constructor(file = EMPTY) {
138
+ this.file = file;
139
+ }
140
+ /** Return the product list. When `tenant` is set, filters to that
141
+ * tenant (entries without a tenant field treated as "default").
142
+ * When `includeStaging` is false (default), staging products are
143
+ * hidden from the result — admins should pass true. */
144
+ list(opts = {}) {
145
+ return this.file.products.filter((p) => {
146
+ if (opts.tenant) {
147
+ const pt = p.tenant || "default";
148
+ if (pt !== opts.tenant)
149
+ return false;
150
+ }
151
+ if (!opts.includeStaging && p.status === "staging")
152
+ return false;
153
+ return true;
154
+ });
155
+ }
156
+ /** Lookup by id. Cross-tenant gets return undefined when `tenant` set. */
157
+ get(id, tenant) {
158
+ const p = this.file.products.find((x) => x.id === id);
159
+ if (!p)
160
+ return undefined;
161
+ if (tenant && (p.tenant || "default") !== tenant)
162
+ return undefined;
163
+ return p;
164
+ }
165
+ count(tenant) {
166
+ return this.list({ tenant, includeStaging: true }).length;
167
+ }
168
+ replace(file) {
169
+ this.file = file;
170
+ }
171
+ /** Upsert (replace if id exists, else append). Returns the new
172
+ * ProductsFile so the caller can persist it. */
173
+ upsert(product) {
174
+ const i = this.file.products.findIndex((p) => p.id === product.id);
175
+ const next = this.file.products.slice();
176
+ if (i >= 0)
177
+ next[i] = product;
178
+ else
179
+ next.push(product);
180
+ this.file = { products: next };
181
+ return this.file;
182
+ }
183
+ /** Remove by id. Returns true when the product existed, false
184
+ * otherwise. Caller persists the resulting file. */
185
+ delete(id) {
186
+ const i = this.file.products.findIndex((p) => p.id === id);
187
+ if (i < 0)
188
+ return { removed: false, file: this.file };
189
+ const next = this.file.products.slice();
190
+ next.splice(i, 1);
191
+ this.file = { products: next };
192
+ return { removed: true, file: this.file };
193
+ }
194
+ /** Snapshot of the current file (for tests / persistence). */
195
+ snapshot() {
196
+ return { products: this.file.products.slice() };
197
+ }
198
+ }
199
+ /** Validate a single product entry by routing it through the same
200
+ * parser as the file format. Throws ProductsLoadError on any
201
+ * shape problem. Used by PUT /api/products/:id so a typo / wrong
202
+ * type / unknown key gets the same loud rejection a malformed
203
+ * file would. */
204
+ export function validateProduct(input, origin = "input") {
205
+ const wrapped = parseProductsText(yaml.dump({ products: [input] }), origin);
206
+ return wrapped.products[0];
207
+ }
208
+ /** Atomic write of the products file. Same tmp+rename pattern as
209
+ * the audit-chain + token-budget snapshot, so a crash mid-write
210
+ * leaves the previous file intact. */
211
+ export async function writeProductsFile(path, file) {
212
+ const text = yaml.dump(file, { sortKeys: false, lineWidth: 100 });
213
+ const tmp = path + ".tmp";
214
+ await writeFile(tmp, text, "utf8");
215
+ await rename(tmp, path);
216
+ }
@@ -0,0 +1 @@
1
+ export {};