@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,121 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { hasPermission, buildRequirePermission, listGrantedPermissions, DEFAULT_POLICY, } from "./rbac.js";
4
+ function mkReq(roles) {
5
+ return {
6
+ session: roles
7
+ ? { sub: "u", name: "u", roles, iat: 0, exp: Date.now() / 1000 + 60 }
8
+ : undefined,
9
+ };
10
+ }
11
+ function mkRes() {
12
+ let statusCode = 0;
13
+ let body = null;
14
+ return {
15
+ status(c) { statusCode = c; return this; },
16
+ json(b) { body = b; return this; },
17
+ get statusCode() { return statusCode; },
18
+ get body() { return body; },
19
+ };
20
+ }
21
+ test("DEFAULT_POLICY — viewer reads but cannot write", () => {
22
+ assert.equal(hasPermission(["viewer"], "sources", "read"), true);
23
+ assert.equal(hasPermission(["viewer"], "sources", "write"), false);
24
+ assert.equal(hasPermission(["viewer"], "sources", "delete"), false);
25
+ });
26
+ test("DEFAULT_POLICY — operator writes sources + settings but never deletes", () => {
27
+ assert.equal(hasPermission(["operator"], "sources", "write"), true);
28
+ assert.equal(hasPermission(["operator"], "settings", "write"), true);
29
+ assert.equal(hasPermission(["operator"], "sources", "delete"), false);
30
+ });
31
+ test("DEFAULT_POLICY — admin can do everything across every resource", () => {
32
+ for (const resource of ["sources", "services", "health", "topology", "settings", "connectors", "audit", "catalog", "users", "products"]) {
33
+ for (const action of ["read", "write", "delete"]) {
34
+ assert.equal(hasPermission(["admin"], resource, action), true, `admin should ${action} ${resource}`);
35
+ }
36
+ }
37
+ });
38
+ test("DEFAULT_POLICY — only admin holds redaction:bypass", () => {
39
+ assert.equal(hasPermission(["admin"], "redaction", "bypass"), true);
40
+ assert.equal(hasPermission(["operator"], "redaction", "bypass"), false);
41
+ assert.equal(hasPermission(["viewer"], "redaction", "bypass"), false);
42
+ // Other actions on the redaction resource are intentionally NOT granted —
43
+ // there is no read/write/delete semantic, only bypass.
44
+ assert.equal(hasPermission(["admin"], "redaction", "read"), false);
45
+ assert.equal(hasPermission(["admin"], "redaction", "write"), false);
46
+ });
47
+ test("hasPermission — empty / missing roles grant nothing", () => {
48
+ assert.equal(hasPermission(undefined, "sources", "read"), false);
49
+ assert.equal(hasPermission([], "sources", "read"), false);
50
+ assert.equal(hasPermission(["unknown-role"], "sources", "read"), false);
51
+ });
52
+ test("hasPermission — union of roles is honoured", () => {
53
+ // A user assigned both viewer and operator gets the operator superset.
54
+ assert.equal(hasPermission(["viewer", "operator"], "sources", "write"), true);
55
+ });
56
+ test("hasPermission — custom policy overrides built-in", () => {
57
+ const custom = {
58
+ "incident-commander": [{ resource: "sources", action: "delete" }],
59
+ };
60
+ assert.equal(hasPermission(["incident-commander"], "sources", "delete", custom), true);
61
+ // Built-in admin is NOT in the custom policy → loses its grants.
62
+ assert.equal(hasPermission(["admin"], "sources", "delete", custom), false);
63
+ });
64
+ test("buildRequirePermission — anonymous always allows", () => {
65
+ const mw = buildRequirePermission({ mode: "anonymous" }, "sources", "write");
66
+ const res = mkRes();
67
+ let called = false;
68
+ mw(mkReq(), res, () => { called = true; });
69
+ assert.equal(called, true);
70
+ assert.equal(res.statusCode, 0);
71
+ });
72
+ test("buildRequirePermission — denies viewer on write", () => {
73
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
74
+ const mw = buildRequirePermission(runtime, "sources", "write");
75
+ const res = mkRes();
76
+ let called = false;
77
+ mw(mkReq(["viewer"]), res, () => { called = true; });
78
+ assert.equal(called, false);
79
+ assert.equal(res.statusCode, 403);
80
+ const body = res.body;
81
+ assert.equal(body.code, "OMCP_PERMISSION_DENIED");
82
+ });
83
+ test("buildRequirePermission — allows operator on write", () => {
84
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
85
+ const mw = buildRequirePermission(runtime, "sources", "write");
86
+ const res = mkRes();
87
+ let called = false;
88
+ mw(mkReq(["operator"]), res, () => { called = true; });
89
+ assert.equal(called, true);
90
+ assert.equal(res.statusCode, 0);
91
+ });
92
+ test("buildRequirePermission — denies missing session in basic mode", () => {
93
+ const runtime = { mode: "basic", session: { secret: "x".repeat(48) } };
94
+ const mw = buildRequirePermission(runtime, "sources", "read");
95
+ const res = mkRes();
96
+ let called = false;
97
+ mw(mkReq(), res, () => { called = true; });
98
+ assert.equal(called, false);
99
+ assert.equal(res.statusCode, 403);
100
+ });
101
+ test("listGrantedPermissions — deduplicates across overlapping roles", () => {
102
+ const p = listGrantedPermissions(["viewer", "operator"]);
103
+ const keys = p.map((g) => `${g.resource}:${g.action}`);
104
+ assert.equal(new Set(keys).size, keys.length, "expected no duplicates");
105
+ // sanity: includes both a viewer-only read and an operator-only write
106
+ assert.ok(p.some((g) => g.resource === "sources" && g.action === "read"));
107
+ assert.ok(p.some((g) => g.resource === "sources" && g.action === "write"));
108
+ });
109
+ test("listGrantedPermissions — admin lists every (resource, action) once", () => {
110
+ const p = listGrantedPermissions(["admin"]);
111
+ // 10 resources (added 'products' in the products-RBAC phase) * 3 actions
112
+ // = 30, plus the special redaction:bypass entry = 31.
113
+ assert.equal(p.length, 31);
114
+ assert.ok(p.some((g) => g.resource === "redaction" && g.action === "bypass"));
115
+ assert.ok(p.some((g) => g.resource === "products" && g.action === "delete"));
116
+ });
117
+ test("DEFAULT_POLICY shape — has the three built-in roles", () => {
118
+ assert.ok(DEFAULT_POLICY.viewer);
119
+ assert.ok(DEFAULT_POLICY.operator);
120
+ assert.ok(DEFAULT_POLICY.admin);
121
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Stateless signed-cookie sessions for the management plane (`/api/*`).
3
+ *
4
+ * The cookie value is `<base64url(payload)>.<base64url(hmacSha256(payload))>`.
5
+ * The payload is a small JSON object with the user's identity, the issued-at
6
+ * timestamp and the absolute expiry. No server-side store; rotating the
7
+ * secret invalidates every outstanding session.
8
+ *
9
+ * MCP transport authentication still uses {@link Credential} bearer tokens
10
+ * — see `./credentials.ts`. This module is exclusively for the browser /
11
+ * UI / `/api/*` plane.
12
+ */
13
+ export interface SessionPayload {
14
+ /** Stable user identifier (username for the local-users store, sub claim for OIDC, ...) */
15
+ sub: string;
16
+ /** Display name shown in the UI. May equal `sub`. */
17
+ name: string;
18
+ /** Email address when the identity provider supplied one. Optional —
19
+ * local-users-mode sessions usually omit this; OIDC sessions populate
20
+ * from the `email` claim when present + verified by the IdP. */
21
+ email?: string;
22
+ /** Tenant the identity belongs to. Omitted when single-tenant — the
23
+ * request handler defaults to "default" via normaliseTenant() so
24
+ * existing single-tenant deployments need no migration. */
25
+ tenant?: string;
26
+ /** Optional list of role identifiers — used by later phases for RBAC. */
27
+ roles?: string[];
28
+ /** Issued-at, seconds since epoch. */
29
+ iat: number;
30
+ /** Hard expiry, seconds since epoch. */
31
+ exp: number;
32
+ }
33
+ export interface SessionConfig {
34
+ /** Symmetric key. Must be at least 32 bytes. */
35
+ secret: string;
36
+ /** Cookie lifetime, seconds. Defaults to 12 hours. */
37
+ ttlSeconds?: number;
38
+ /** Cookie name. Defaults to `omcp_session`. */
39
+ cookieName?: string;
40
+ }
41
+ export declare const DEFAULT_SESSION_TTL_SECONDS: number;
42
+ export declare const DEFAULT_COOKIE_NAME = "omcp_session";
43
+ /** Create a signed cookie value for the given identity. */
44
+ export declare function issueSession(identity: Pick<SessionPayload, "sub" | "name" | "roles" | "email" | "tenant">, cfg: SessionConfig, now?: number): {
45
+ cookie: string;
46
+ payload: SessionPayload;
47
+ };
48
+ /** Reject cookies above this size before any crypto work — practical
49
+ * browser cookies stay well under 4 KB and a runaway input shouldn't
50
+ * even reach the HMAC step. Defense-in-depth; Express's
51
+ * `maxHttpHeaderSize` (16 KB by default) is the outer bound. */
52
+ export declare const MAX_COOKIE_BYTES = 4096;
53
+ /** Verify a cookie value. Returns the payload on success, null on any failure. */
54
+ export declare function verifySession(cookieValue: string | undefined | null, cfg: SessionConfig, now?: number): SessionPayload | null;
55
+ /** Render a Set-Cookie header value for an issued session. */
56
+ export declare function setCookieHeader(cookie: string, cfg: SessionConfig, opts?: {
57
+ secure?: boolean;
58
+ }): string;
59
+ /** Render a Set-Cookie header that immediately expires the session cookie. */
60
+ export declare function clearCookieHeader(cfg: SessionConfig, opts?: {
61
+ secure?: boolean;
62
+ }): string;
63
+ /** Parse the named cookie from a raw Cookie header. */
64
+ export declare function readCookie(cookieHeader: string | undefined | null, name?: string): string | null;
65
+ /** Generate a cryptographically strong fallback secret. Logged-once recommended. */
66
+ export declare function generateSecret(): string;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Stateless signed-cookie sessions for the management plane (`/api/*`).
3
+ *
4
+ * The cookie value is `<base64url(payload)>.<base64url(hmacSha256(payload))>`.
5
+ * The payload is a small JSON object with the user's identity, the issued-at
6
+ * timestamp and the absolute expiry. No server-side store; rotating the
7
+ * secret invalidates every outstanding session.
8
+ *
9
+ * MCP transport authentication still uses {@link Credential} bearer tokens
10
+ * — see `./credentials.ts`. This module is exclusively for the browser /
11
+ * UI / `/api/*` plane.
12
+ */
13
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
14
+ export const DEFAULT_SESSION_TTL_SECONDS = 12 * 60 * 60;
15
+ export const DEFAULT_COOKIE_NAME = "omcp_session";
16
+ function b64urlEncode(buf) {
17
+ return buf.toString("base64url");
18
+ }
19
+ function b64urlDecode(s) {
20
+ try {
21
+ return Buffer.from(s, "base64url");
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ function sign(secret, payload) {
28
+ return createHmac("sha256", secret).update(payload).digest("base64url");
29
+ }
30
+ /** Create a signed cookie value for the given identity. */
31
+ export function issueSession(identity, cfg, now = Math.floor(Date.now() / 1000)) {
32
+ assertSecret(cfg.secret);
33
+ const ttl = cfg.ttlSeconds ?? DEFAULT_SESSION_TTL_SECONDS;
34
+ const payload = {
35
+ sub: identity.sub,
36
+ name: identity.name,
37
+ email: identity.email,
38
+ tenant: identity.tenant,
39
+ roles: identity.roles,
40
+ iat: now,
41
+ exp: now + ttl,
42
+ };
43
+ const payloadStr = b64urlEncode(Buffer.from(JSON.stringify(payload)));
44
+ const sig = sign(cfg.secret, payloadStr);
45
+ return { cookie: `${payloadStr}.${sig}`, payload };
46
+ }
47
+ /** Reject cookies above this size before any crypto work — practical
48
+ * browser cookies stay well under 4 KB and a runaway input shouldn't
49
+ * even reach the HMAC step. Defense-in-depth; Express's
50
+ * `maxHttpHeaderSize` (16 KB by default) is the outer bound. */
51
+ export const MAX_COOKIE_BYTES = 4096;
52
+ /** Verify a cookie value. Returns the payload on success, null on any failure. */
53
+ export function verifySession(cookieValue, cfg, now = Math.floor(Date.now() / 1000)) {
54
+ if (!cookieValue)
55
+ return null;
56
+ if (cookieValue.length > MAX_COOKIE_BYTES)
57
+ return null;
58
+ assertSecret(cfg.secret);
59
+ const dot = cookieValue.indexOf(".");
60
+ if (dot <= 0 || dot === cookieValue.length - 1)
61
+ return null;
62
+ const payloadStr = cookieValue.slice(0, dot);
63
+ const sig = cookieValue.slice(dot + 1);
64
+ const expected = sign(cfg.secret, payloadStr);
65
+ // Constant-time compare on equal-length buffers; reject length mismatch first.
66
+ const a = Buffer.from(sig);
67
+ const b = Buffer.from(expected);
68
+ if (a.length !== b.length || !timingSafeEqual(a, b))
69
+ return null;
70
+ const raw = b64urlDecode(payloadStr);
71
+ if (!raw)
72
+ return null;
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(raw.toString("utf8"));
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ if (!isSessionPayload(parsed))
81
+ return null;
82
+ if (parsed.exp <= now)
83
+ return null;
84
+ return parsed;
85
+ }
86
+ function isSessionPayload(v) {
87
+ if (!v || typeof v !== "object")
88
+ return false;
89
+ const o = v;
90
+ if (typeof o.sub !== "string" || typeof o.name !== "string")
91
+ return false;
92
+ if (typeof o.iat !== "number" || typeof o.exp !== "number")
93
+ return false;
94
+ if (o.roles !== undefined && !(Array.isArray(o.roles) && o.roles.every((r) => typeof r === "string")))
95
+ return false;
96
+ if (o.email !== undefined && typeof o.email !== "string")
97
+ return false;
98
+ if (o.tenant !== undefined && typeof o.tenant !== "string")
99
+ return false;
100
+ return true;
101
+ }
102
+ function assertSecret(secret) {
103
+ if (!secret || secret.length < 32) {
104
+ throw new Error("session secret must be at least 32 characters");
105
+ }
106
+ }
107
+ /** Render a Set-Cookie header value for an issued session. */
108
+ export function setCookieHeader(cookie, cfg, opts = {}) {
109
+ const ttl = cfg.ttlSeconds ?? DEFAULT_SESSION_TTL_SECONDS;
110
+ const name = cfg.cookieName ?? DEFAULT_COOKIE_NAME;
111
+ const parts = [
112
+ `${name}=${cookie}`,
113
+ `Max-Age=${ttl}`,
114
+ "Path=/",
115
+ "HttpOnly",
116
+ "SameSite=Lax",
117
+ ];
118
+ if (opts.secure !== false)
119
+ parts.push("Secure");
120
+ return parts.join("; ");
121
+ }
122
+ /** Render a Set-Cookie header that immediately expires the session cookie. */
123
+ export function clearCookieHeader(cfg, opts = {}) {
124
+ const name = cfg.cookieName ?? DEFAULT_COOKIE_NAME;
125
+ const parts = [`${name}=`, "Max-Age=0", "Path=/", "HttpOnly", "SameSite=Lax"];
126
+ if (opts.secure !== false)
127
+ parts.push("Secure");
128
+ return parts.join("; ");
129
+ }
130
+ /** Parse the named cookie from a raw Cookie header. */
131
+ export function readCookie(cookieHeader, name = DEFAULT_COOKIE_NAME) {
132
+ if (!cookieHeader)
133
+ return null;
134
+ for (const part of cookieHeader.split(/;\s*/)) {
135
+ const eq = part.indexOf("=");
136
+ if (eq <= 0)
137
+ continue;
138
+ if (part.slice(0, eq) === name)
139
+ return part.slice(eq + 1);
140
+ }
141
+ return null;
142
+ }
143
+ /** Generate a cryptographically strong fallback secret. Logged-once recommended. */
144
+ export function generateSecret() {
145
+ return randomBytes(48).toString("base64url");
146
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { issueSession, verifySession, setCookieHeader, clearCookieHeader, readCookie, generateSecret, DEFAULT_COOKIE_NAME, } from "./session.js";
4
+ const secret = "a".repeat(48);
5
+ test("issueSession + verifySession — round-trips identity", () => {
6
+ const now = 1_700_000_000;
7
+ const { cookie, payload } = issueSession({ sub: "alice", name: "Alice", roles: ["operator"] }, { secret }, now);
8
+ assert.equal(payload.sub, "alice");
9
+ assert.equal(payload.exp, now + 12 * 60 * 60);
10
+ const verified = verifySession(cookie, { secret }, now + 1);
11
+ assert.ok(verified, "expected verified payload");
12
+ assert.equal(verified.sub, "alice");
13
+ assert.deepEqual(verified.roles, ["operator"]);
14
+ });
15
+ test("issueSession + verifySession — round-trips email when present", () => {
16
+ const now = 1_700_000_000;
17
+ const { cookie } = issueSession({ sub: "alice", name: "Alice", email: "alice@example.test", roles: ["operator"] }, { secret }, now);
18
+ const verified = verifySession(cookie, { secret }, now + 1);
19
+ assert.ok(verified);
20
+ assert.equal(verified.email, "alice@example.test");
21
+ });
22
+ test("issueSession + verifySession — omits email when caller doesn't supply one", () => {
23
+ const now = 1_700_000_000;
24
+ const { cookie } = issueSession({ sub: "alice", name: "Alice", roles: ["operator"] }, { secret }, now);
25
+ const verified = verifySession(cookie, { secret }, now + 1);
26
+ assert.ok(verified);
27
+ assert.equal(verified.email, undefined);
28
+ });
29
+ test("verifySession — rejects an expired cookie", () => {
30
+ const now = 1_700_000_000;
31
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret, ttlSeconds: 60 }, now);
32
+ assert.equal(verifySession(cookie, { secret }, now + 61), null);
33
+ });
34
+ test("verifySession — rejects tampered payload", () => {
35
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret });
36
+ const [payload, sig] = cookie.split(".");
37
+ const flipped = Buffer.from(payload, "base64url").toString("utf8").replace("alice", "mallory");
38
+ const evil = Buffer.from(flipped).toString("base64url") + "." + sig;
39
+ assert.equal(verifySession(evil, { secret }), null);
40
+ });
41
+ test("verifySession — rejects cookie signed with a different secret", () => {
42
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret });
43
+ assert.equal(verifySession(cookie, { secret: "b".repeat(48) }), null);
44
+ });
45
+ test("verifySession — null / empty / malformed cookies return null", () => {
46
+ assert.equal(verifySession(null, { secret }), null);
47
+ assert.equal(verifySession("", { secret }), null);
48
+ assert.equal(verifySession("no-dot-anywhere", { secret }), null);
49
+ assert.equal(verifySession(".trailing", { secret }), null);
50
+ assert.equal(verifySession("leading.", { secret }), null);
51
+ });
52
+ test("verifySession — rejects oversized cookies before any crypto work", () => {
53
+ const huge = "x".repeat(10_000) + "." + "y".repeat(10);
54
+ assert.equal(verifySession(huge, { secret }), null);
55
+ });
56
+ test("verifySession — rejects short secret at verify time too (fail-closed)", () => {
57
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret });
58
+ assert.throws(() => verifySession(cookie, { secret: "short" }));
59
+ });
60
+ test("issueSession — rejects secrets shorter than 32 chars", () => {
61
+ assert.throws(() => issueSession({ sub: "alice", name: "A" }, { secret: "short" }));
62
+ });
63
+ test("setCookieHeader / clearCookieHeader — render expected attributes", () => {
64
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret });
65
+ const setHdr = setCookieHeader(cookie, { secret });
66
+ assert.match(setHdr, /^omcp_session=/);
67
+ assert.match(setHdr, /HttpOnly/);
68
+ assert.match(setHdr, /SameSite=Lax/);
69
+ assert.match(setHdr, /Secure/);
70
+ assert.match(setHdr, /Max-Age=43200/);
71
+ const clearHdr = clearCookieHeader({ secret });
72
+ assert.match(clearHdr, /^omcp_session=;/);
73
+ assert.match(clearHdr, /Max-Age=0/);
74
+ });
75
+ test("setCookieHeader — `secure: false` omits Secure (dev / plain http)", () => {
76
+ const { cookie } = issueSession({ sub: "alice", name: "Alice" }, { secret });
77
+ const hdr = setCookieHeader(cookie, { secret }, { secure: false });
78
+ assert.doesNotMatch(hdr, /Secure/);
79
+ });
80
+ test("readCookie — extracts the named cookie from a Cookie header", () => {
81
+ assert.equal(readCookie("foo=bar; omcp_session=hello; baz=qux"), "hello");
82
+ assert.equal(readCookie("omcp_session=hello", DEFAULT_COOKIE_NAME), "hello");
83
+ assert.equal(readCookie(undefined), null);
84
+ assert.equal(readCookie("missing=1"), null);
85
+ });
86
+ test("generateSecret — always returns ≥ 32 chars of url-safe entropy", () => {
87
+ const s = generateSecret();
88
+ assert.ok(s.length >= 32);
89
+ assert.match(s, /^[A-Za-z0-9_-]+$/);
90
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Service catalog: an operator-curated map of service name → ownership /
3
+ * criticality / on-call metadata. Hooked into the existing /api/services
4
+ * and /api/health responses so the UI (and the agent through the MCP
5
+ * tools that derive from those endpoints) can see "this is owned by
6
+ * team-payments, criticality tier-1, on-call URL …" without having to
7
+ * cross-reference an external CMDB.
8
+ *
9
+ * On-disk format: JSON file (commit-friendly, easy to diff in PRs). Path
10
+ * defaults to `mcp-server/config/catalog.json`; override via
11
+ * `OMCP_SERVICE_CATALOG_FILE`. Missing or malformed file => no catalog
12
+ * (the server boots fine, enrichment is a no-op).
13
+ *
14
+ * Distinct from the enterprise-gate `OMCP_CATALOG` "product catalog"
15
+ * which lives behind the entitlement gate and governs MCP-tool grants —
16
+ * different trust model, different schema.
17
+ */
18
+ export type CriticalityTier = "tier-1" | "tier-2" | "tier-3" | "tier-4";
19
+ export type DataClassification = "public" | "internal" | "confidential" | "restricted";
20
+ export interface ServiceCatalogEntry {
21
+ /** Stable short owner identifier — team slug, squad, individual handle. */
22
+ owner?: string;
23
+ /** Human-readable description. One sentence. */
24
+ description?: string;
25
+ /** Page-the-team URL — Slack channel, on-call rota, runbook index. */
26
+ onCall?: string;
27
+ /** Operator-defined criticality bucket. */
28
+ tier?: CriticalityTier;
29
+ /** Data classification of what flows through the service. */
30
+ dataClassification?: DataClassification;
31
+ /** Free-form SLO label ("99.9% / month" / "99.5%"). Not parsed. */
32
+ slo?: string;
33
+ /** Optional list of relevant runbook URLs. */
34
+ runbooks?: string[];
35
+ /** Optional list of free-form tags. */
36
+ tags?: string[];
37
+ /** Tenant the entry belongs to. Omitted → "default". Used by
38
+ * multi-tenant deployments to scope what /api/catalog and the
39
+ * list_services / get_service_health tools surface per session. */
40
+ tenant?: string;
41
+ }
42
+ export interface ServiceCatalog {
43
+ /** Map service name → entry. Service-name key is the same string
44
+ * `list_services` returns; no fuzzy matching. */
45
+ services: Record<string, ServiceCatalogEntry>;
46
+ }
47
+ /** Read + validate a catalog file. Returns the empty catalog on any
48
+ * problem and (when configured) emits a single warn-level console.error
49
+ * so the operator notices but the server keeps booting. */
50
+ export declare function readCatalogFile(path: string | undefined): Promise<ServiceCatalog>;
51
+ /** Pure validator — useful in tests and when feeding in-memory data. */
52
+ export declare function validateCatalog(input: unknown): ServiceCatalog;
53
+ /** Lookup wrapper used by the enricher / API handlers. */
54
+ export declare class CatalogStore {
55
+ private catalog;
56
+ constructor(catalog?: ServiceCatalog);
57
+ /** Lookup. When `tenant` is set, returns undefined for entries
58
+ * belonging to a different tenant — so a cross-tenant
59
+ * enrichment never leaks owner / on-call / SLO bytes. Entries
60
+ * without a tenant field are treated as `"default"`. */
61
+ get(serviceName: string, tenant?: string): ServiceCatalogEntry | undefined;
62
+ /** Snapshot. When `tenant` is set, filters down to entries in that
63
+ * tenant; entries without a tenant field counted as `"default"`. */
64
+ list(tenant?: string): Record<string, ServiceCatalogEntry>;
65
+ count(tenant?: string): number;
66
+ replace(catalog: ServiceCatalog): void;
67
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Service catalog: an operator-curated map of service name → ownership /
3
+ * criticality / on-call metadata. Hooked into the existing /api/services
4
+ * and /api/health responses so the UI (and the agent through the MCP
5
+ * tools that derive from those endpoints) can see "this is owned by
6
+ * team-payments, criticality tier-1, on-call URL …" without having to
7
+ * cross-reference an external CMDB.
8
+ *
9
+ * On-disk format: JSON file (commit-friendly, easy to diff in PRs). Path
10
+ * defaults to `mcp-server/config/catalog.json`; override via
11
+ * `OMCP_SERVICE_CATALOG_FILE`. Missing or malformed file => no catalog
12
+ * (the server boots fine, enrichment is a no-op).
13
+ *
14
+ * Distinct from the enterprise-gate `OMCP_CATALOG` "product catalog"
15
+ * which lives behind the entitlement gate and governs MCP-tool grants —
16
+ * different trust model, different schema.
17
+ */
18
+ import { promises as fs } from "node:fs";
19
+ const EMPTY_CATALOG = { services: {} };
20
+ const VALID_TIERS = new Set(["tier-1", "tier-2", "tier-3", "tier-4"]);
21
+ const VALID_CLASS = new Set(["public", "internal", "confidential", "restricted"]);
22
+ /** Read + validate a catalog file. Returns the empty catalog on any
23
+ * problem and (when configured) emits a single warn-level console.error
24
+ * so the operator notices but the server keeps booting. */
25
+ export async function readCatalogFile(path) {
26
+ if (!path)
27
+ return EMPTY_CATALOG;
28
+ let raw;
29
+ try {
30
+ raw = await fs.readFile(path, "utf8");
31
+ }
32
+ catch {
33
+ return EMPTY_CATALOG;
34
+ }
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(raw);
38
+ }
39
+ catch (e) {
40
+ console.error(`[catalog] OMCP_SERVICE_CATALOG_FILE=${path} is not valid JSON: ${e.message}`);
41
+ return EMPTY_CATALOG;
42
+ }
43
+ return validateCatalog(parsed);
44
+ }
45
+ /** Pure validator — useful in tests and when feeding in-memory data. */
46
+ export function validateCatalog(input) {
47
+ if (!input || typeof input !== "object")
48
+ return EMPTY_CATALOG;
49
+ const obj = input;
50
+ const rawServices = obj.services;
51
+ if (!rawServices || typeof rawServices !== "object")
52
+ return EMPTY_CATALOG;
53
+ const out = {};
54
+ for (const [name, value] of Object.entries(rawServices)) {
55
+ if (!value || typeof value !== "object")
56
+ continue;
57
+ const e = value;
58
+ const entry = {};
59
+ if (typeof e.owner === "string")
60
+ entry.owner = e.owner;
61
+ if (typeof e.description === "string")
62
+ entry.description = e.description;
63
+ if (typeof e.onCall === "string")
64
+ entry.onCall = e.onCall;
65
+ if (typeof e.tier === "string" && VALID_TIERS.has(e.tier)) {
66
+ entry.tier = e.tier;
67
+ }
68
+ if (typeof e.dataClassification === "string" && VALID_CLASS.has(e.dataClassification)) {
69
+ entry.dataClassification = e.dataClassification;
70
+ }
71
+ if (typeof e.slo === "string")
72
+ entry.slo = e.slo;
73
+ if (Array.isArray(e.runbooks)) {
74
+ entry.runbooks = e.runbooks.filter((x) => typeof x === "string");
75
+ }
76
+ if (Array.isArray(e.tags)) {
77
+ entry.tags = e.tags.filter((x) => typeof x === "string");
78
+ }
79
+ if (typeof e.tenant === "string")
80
+ entry.tenant = e.tenant;
81
+ out[name] = entry;
82
+ }
83
+ return { services: out };
84
+ }
85
+ /** Lookup wrapper used by the enricher / API handlers. */
86
+ export class CatalogStore {
87
+ catalog;
88
+ constructor(catalog = EMPTY_CATALOG) {
89
+ this.catalog = catalog;
90
+ }
91
+ /** Lookup. When `tenant` is set, returns undefined for entries
92
+ * belonging to a different tenant — so a cross-tenant
93
+ * enrichment never leaks owner / on-call / SLO bytes. Entries
94
+ * without a tenant field are treated as `"default"`. */
95
+ get(serviceName, tenant) {
96
+ const e = this.catalog.services[serviceName];
97
+ if (!e)
98
+ return undefined;
99
+ if (!tenant)
100
+ return e;
101
+ const entryTenant = e.tenant || "default";
102
+ return entryTenant === tenant ? e : undefined;
103
+ }
104
+ /** Snapshot. When `tenant` is set, filters down to entries in that
105
+ * tenant; entries without a tenant field counted as `"default"`. */
106
+ list(tenant) {
107
+ if (!tenant)
108
+ return this.catalog.services;
109
+ const out = {};
110
+ for (const [k, v] of Object.entries(this.catalog.services)) {
111
+ if ((v.tenant || "default") === tenant)
112
+ out[k] = v;
113
+ }
114
+ return out;
115
+ }
116
+ count(tenant) {
117
+ return Object.keys(this.list(tenant)).length;
118
+ }
119
+ replace(catalog) {
120
+ this.catalog = catalog;
121
+ }
122
+ }
@@ -0,0 +1 @@
1
+ export {};