@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.
- package/dist/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +9 -0
- package/dist/audit/log.js +20 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- package/dist/audit/sinks/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -0
- package/dist/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/endpoints.js +44 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +144 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +55 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +32 -0
- package/dist/context.js +35 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +54 -0
- package/dist/federation/registry.js +122 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +206 -0
- package/dist/federation/upstream.d.ts +86 -0
- package/dist/federation/upstream.js +162 -0
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +1435 -126
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +215 -7
- package/dist/openapi.test.js +34 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -0
- package/dist/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -0
- package/dist/scim/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -0
- package/dist/scim/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +40 -0
- package/dist/scim/routes.js +395 -0
- package/dist/scim/store.d.ts +76 -0
- package/dist/scim/store.js +196 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- package/dist/sdk/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -0
- package/dist/sdk/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +15 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +26 -5
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +33 -11
- package/dist/tools/topology.test.js +45 -0
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -0
- package/dist/transport/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -0
- package/dist/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +2529 -145
- package/package.json +13 -3
package/dist/openapi.js
CHANGED
|
@@ -23,6 +23,10 @@ const SOURCE_SCHEMA = {
|
|
|
23
23
|
},
|
|
24
24
|
tls: { type: "object", additionalProperties: true },
|
|
25
25
|
signalType: { type: "string", enum: ["metrics", "logs", "traces"] },
|
|
26
|
+
tenant: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Tenant this source belongs to. Omitted = global (visible to every tenant). Tagged sources are visible only inside their named tenant; cross-tenant probes return 404 with no existence leak.",
|
|
29
|
+
},
|
|
26
30
|
},
|
|
27
31
|
additionalProperties: true,
|
|
28
32
|
};
|
|
@@ -60,21 +64,27 @@ export function buildOpenApiSpec(version) {
|
|
|
60
64
|
"/api/sources": {
|
|
61
65
|
get: {
|
|
62
66
|
tags: ["sources"],
|
|
63
|
-
summary: "List configured sources with live health.",
|
|
67
|
+
summary: "List configured sources with live health (tenant-scoped).",
|
|
68
|
+
description: "Non-admin callers see only their own tenant's sources + globals (untagged). Admins (users:delete) see every source; pass ?tenant=X for an admin drill-down to that tenant + globals. Anonymous mode bypasses scoping (single-tenant default).",
|
|
69
|
+
parameters: [
|
|
70
|
+
{ name: "tenant", in: "query", schema: { type: "string" }, description: "Admin-only tenant drill-down (silently ignored for non-admins, who are scoped to their own tenant)." },
|
|
71
|
+
],
|
|
64
72
|
responses: {
|
|
65
73
|
"200": {
|
|
66
|
-
description: "Sources with status, latency, signal type.",
|
|
74
|
+
description: "Sources with status, latency, signal type. The `tenant` field is present when the source is tagged.",
|
|
67
75
|
content: { "application/json": { schema: { type: "array", items: SOURCE_SCHEMA } } },
|
|
68
76
|
},
|
|
69
77
|
},
|
|
70
78
|
},
|
|
71
79
|
post: {
|
|
72
80
|
tags: ["sources"],
|
|
73
|
-
summary: "Add a new source.",
|
|
81
|
+
summary: "Add a new source (tenant-aware).",
|
|
82
|
+
description: "Body may include `tenant` to tag the source. Non-admins may only create within their own tenant; setting body.tenant to another value returns 403. Admins may leave tenant unset (global) or set any value.",
|
|
74
83
|
requestBody: { required: true, content: { "application/json": { schema: SOURCE_SCHEMA } } },
|
|
75
84
|
responses: {
|
|
76
85
|
"201": { description: "Source created." },
|
|
77
86
|
"400": { description: "Validation error." },
|
|
87
|
+
"403": { description: "Non-admin attempting to create in another tenant." },
|
|
78
88
|
"409": { description: "Source with that name already exists." },
|
|
79
89
|
},
|
|
80
90
|
},
|
|
@@ -107,14 +117,23 @@ export function buildOpenApiSpec(version) {
|
|
|
107
117
|
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
|
|
108
118
|
put: {
|
|
109
119
|
tags: ["sources"],
|
|
110
|
-
summary: "Replace an existing source.",
|
|
120
|
+
summary: "Replace an existing source (tenant-aware).",
|
|
121
|
+
description: "Non-admin probes of a cross-tenant source return 404 (no existence leak — same posture as /api/products). Non-admins attempting to reassign body.tenant return 403. Admins may move sources between tenants.",
|
|
111
122
|
requestBody: { required: true, content: { "application/json": { schema: SOURCE_SCHEMA } } },
|
|
112
|
-
responses: {
|
|
123
|
+
responses: {
|
|
124
|
+
"200": { description: "Updated." },
|
|
125
|
+
"403": { description: "Non-admin attempting tenant reassignment." },
|
|
126
|
+
"404": { description: "Not found (or hidden by tenant scope)." },
|
|
127
|
+
},
|
|
113
128
|
},
|
|
114
129
|
delete: {
|
|
115
130
|
tags: ["sources"],
|
|
116
131
|
summary: "Remove a source.",
|
|
117
|
-
|
|
132
|
+
description: "Cross-tenant deletes return 404 (no existence leak).",
|
|
133
|
+
responses: {
|
|
134
|
+
"204": { description: "Removed." },
|
|
135
|
+
"404": { description: "Not found (or hidden by tenant scope)." },
|
|
136
|
+
},
|
|
118
137
|
},
|
|
119
138
|
},
|
|
120
139
|
"/api/source-types": {
|
|
@@ -129,6 +148,35 @@ export function buildOpenApiSpec(version) {
|
|
|
129
148
|
},
|
|
130
149
|
},
|
|
131
150
|
},
|
|
151
|
+
"/api/tools/registry": {
|
|
152
|
+
get: {
|
|
153
|
+
tags: ["products"],
|
|
154
|
+
summary: "MCP tool catalogue — name + category + one-line summary, used by the Products picker.",
|
|
155
|
+
description: "Static metadata derived from REGISTERED_TOOLS. The Products modal pulls this to populate a multi-select picker grouped by category (discovery / query / diagnose / topology); the server-side typo guard (PR #343) stays as defence-in-depth.",
|
|
156
|
+
responses: {
|
|
157
|
+
"200": {
|
|
158
|
+
description: "Tool registry.",
|
|
159
|
+
content: { "application/json": { schema: {
|
|
160
|
+
type: "object",
|
|
161
|
+
properties: {
|
|
162
|
+
tools: {
|
|
163
|
+
type: "array",
|
|
164
|
+
items: {
|
|
165
|
+
type: "object",
|
|
166
|
+
required: ["name", "category", "summary"],
|
|
167
|
+
properties: {
|
|
168
|
+
name: { type: "string" },
|
|
169
|
+
category: { type: "string", enum: ["discovery", "query", "diagnose", "topology"] },
|
|
170
|
+
summary: { type: "string" },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
} } },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
132
180
|
"/api/services": {
|
|
133
181
|
get: {
|
|
134
182
|
tags: ["services"],
|
|
@@ -478,11 +526,12 @@ export function buildOpenApiSpec(version) {
|
|
|
478
526
|
"/api/policy": {
|
|
479
527
|
get: {
|
|
480
528
|
tags: ["auth"],
|
|
481
|
-
summary: "Read-only view of the active RBAC policy (admin-only). Dry-run probe with ?resource=&action=&roles
|
|
529
|
+
summary: "Read-only view of the active RBAC policy (admin-only). Dry-run probe with ?resource=&action=&roles=[&tenant=].",
|
|
482
530
|
parameters: [
|
|
483
531
|
{ name: "roles", in: "query", required: false, schema: { type: "string" }, description: "Comma-separated role names to probe. Defaults to none (treated as anonymous → always denied)." },
|
|
484
532
|
{ name: "resource", in: "query", required: false, schema: { type: "string" }, description: "Resource to probe. Pair with `action` to enter dry-run mode." },
|
|
485
533
|
{ name: "action", in: "query", required: false, schema: { type: "string" }, description: "Action to probe. Pair with `resource` to enter dry-run mode." },
|
|
534
|
+
{ name: "tenant", in: "query", required: false, schema: { type: "string" }, description: "Tenant to probe under (dry-run only). Defaults to the caller's session tenant; admins may override to probe verdicts for any tenant — exactly how to debug tenant-conditional Rego rules under the OPA engine." },
|
|
486
535
|
],
|
|
487
536
|
responses: {
|
|
488
537
|
"200": {
|
|
@@ -493,6 +542,7 @@ export function buildOpenApiSpec(version) {
|
|
|
493
542
|
type: "object",
|
|
494
543
|
properties: {
|
|
495
544
|
engine: { type: "string", description: "Identifier of the active engine: 'builtin', 'file:<path>', 'opa:<url>'." },
|
|
545
|
+
tenantAware: { type: "boolean", description: "True when the active engine honours session.tenant on .evaluate() — i.e. OPA. Built-in / file-loaded engines ignore tenant ctx (false)." },
|
|
496
546
|
policy: { type: "object", additionalProperties: true },
|
|
497
547
|
roles: { type: "array", items: { type: "string" } },
|
|
498
548
|
note: { type: "string" },
|
|
@@ -502,6 +552,7 @@ export function buildOpenApiSpec(version) {
|
|
|
502
552
|
roles: { type: "array", items: { type: "string" } },
|
|
503
553
|
resource: { type: "string" },
|
|
504
554
|
action: { type: "string" },
|
|
555
|
+
tenant: { type: "string", description: "Tenant the probe ran under (echoed from ?tenant= or the caller's session)." },
|
|
505
556
|
allowed: { type: "boolean" },
|
|
506
557
|
reason: { type: "string" },
|
|
507
558
|
},
|
|
@@ -515,6 +566,97 @@ export function buildOpenApiSpec(version) {
|
|
|
515
566
|
},
|
|
516
567
|
},
|
|
517
568
|
},
|
|
569
|
+
"/api/policy/roles/{name}": {
|
|
570
|
+
put: {
|
|
571
|
+
tags: ["auth"],
|
|
572
|
+
summary: "Upsert a role in the file-backed RBAC policy (admin-only, file engine only).",
|
|
573
|
+
description: "Writes through OMCP_RBAC_POLICY_FILE atomically. 409 if the active engine isn't `file:…` or the env var is unset. Permissions are validated against VALID_RESOURCES + VALID_ACTIONS; unknown values return 422 with code OMCP_POLICY_UNKNOWN_RESOURCE / OMCP_POLICY_UNKNOWN_ACTION. On success the in-memory PolicyEngine is hot-swapped — existing gates pick up the new policy without a server restart.",
|
|
574
|
+
parameters: [
|
|
575
|
+
{ name: "name", in: "path", required: true, schema: { type: "string" } },
|
|
576
|
+
],
|
|
577
|
+
requestBody: {
|
|
578
|
+
required: true,
|
|
579
|
+
content: { "application/json": { schema: {
|
|
580
|
+
type: "object",
|
|
581
|
+
required: ["permissions"],
|
|
582
|
+
properties: { permissions: { type: "array", items: { type: "object", properties: {
|
|
583
|
+
resource: { type: "string" },
|
|
584
|
+
action: { type: "string" },
|
|
585
|
+
} } } },
|
|
586
|
+
} } },
|
|
587
|
+
},
|
|
588
|
+
responses: {
|
|
589
|
+
"200": { description: "Role saved + hot-swapped." },
|
|
590
|
+
"400": { description: "Body shape invalid OR role name pattern rejected." },
|
|
591
|
+
"403": { description: "Missing users:delete permission (admin-only)." },
|
|
592
|
+
"409": { description: "Active engine isn't `file:…`, or OMCP_RBAC_POLICY_FILE unset (codes: OMCP_POLICY_ENGINE_NOT_FILE / OMCP_POLICY_FILE_NOT_SET)." },
|
|
593
|
+
"422": { description: "Unknown resource or action (codes: OMCP_POLICY_UNKNOWN_RESOURCE / OMCP_POLICY_UNKNOWN_ACTION)." },
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
"/api/users/{username}/roles": {
|
|
598
|
+
put: {
|
|
599
|
+
tags: ["auth"],
|
|
600
|
+
summary: "Update a local user's role assignments (admin-only, file-backed).",
|
|
601
|
+
description: "Writes through OMCP_USERS_FILE. Roles are validated against the active policy engine's role catalogue; unknown role names return 422 with OMCP_USER_UNKNOWN_ROLE. The in-memory user store is refreshed atomically after the file write so the next login picks up the new roles without a server restart.",
|
|
602
|
+
parameters: [
|
|
603
|
+
{ name: "username", in: "path", required: true, schema: { type: "string" } },
|
|
604
|
+
],
|
|
605
|
+
requestBody: {
|
|
606
|
+
required: true,
|
|
607
|
+
content: { "application/json": { schema: {
|
|
608
|
+
type: "object",
|
|
609
|
+
required: ["roles"],
|
|
610
|
+
properties: { roles: { type: "array", items: { type: "string" } } },
|
|
611
|
+
} } },
|
|
612
|
+
},
|
|
613
|
+
responses: {
|
|
614
|
+
"200": { description: "Roles updated." },
|
|
615
|
+
"400": { description: "Body must be { roles: string[] }." },
|
|
616
|
+
"403": { description: "Missing users:delete permission (admin-only)." },
|
|
617
|
+
"404": { description: "User not found OR users file unreadable." },
|
|
618
|
+
"409": { description: "OMCP_USERS_FILE is not configured — basic-mode user roles can't be edited via the API." },
|
|
619
|
+
"422": { description: "tools[] references unknown role names. Body includes `unknown` + `available`; error code OMCP_USER_UNKNOWN_ROLE." },
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
"/api/subjects": {
|
|
624
|
+
get: {
|
|
625
|
+
tags: ["auth"],
|
|
626
|
+
summary: "Aggregated subjects view — local users + API-key names + OIDC group mappings (admin-only).",
|
|
627
|
+
description: "Read-only catalogue of the principals an OMCP deployment knows about. Three independent sources: OMCP_USERS_FILE (users), OMCP_API_KEYS (apiKeys), OMCP_OIDC_ROLE_MAP (oidcGroups). Tokens + password hashes are never returned — only metadata.",
|
|
628
|
+
responses: {
|
|
629
|
+
"200": {
|
|
630
|
+
description: "Subjects payload.",
|
|
631
|
+
content: { "application/json": { schema: {
|
|
632
|
+
type: "object",
|
|
633
|
+
properties: {
|
|
634
|
+
users: { type: "array", items: { type: "object", properties: {
|
|
635
|
+
username: { type: "string" }, name: { type: "string" },
|
|
636
|
+
roles: { type: "array", items: { type: "string" } },
|
|
637
|
+
tenant: { type: "string" },
|
|
638
|
+
} } },
|
|
639
|
+
apiKeys: { type: "array", items: { type: "object", properties: {
|
|
640
|
+
name: { type: "string" }, tenant: { type: "string" },
|
|
641
|
+
productId: { type: "string" },
|
|
642
|
+
bypassRedaction: { type: "boolean" },
|
|
643
|
+
allowedSources: { type: "array", items: { type: "string" } },
|
|
644
|
+
} } },
|
|
645
|
+
oidcGroups: { type: "array", items: { type: "object", properties: {
|
|
646
|
+
claim: { type: "string" }, role: { type: "string" },
|
|
647
|
+
} } },
|
|
648
|
+
sources: { type: "object", properties: {
|
|
649
|
+
users: { type: ["string", "null"] },
|
|
650
|
+
apiKeys: { type: ["string", "null"] },
|
|
651
|
+
oidcGroups: { type: ["string", "null"] },
|
|
652
|
+
} },
|
|
653
|
+
},
|
|
654
|
+
} } },
|
|
655
|
+
},
|
|
656
|
+
"403": { description: "Missing users:delete permission (admin-only)." },
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
518
660
|
"/api/products": {
|
|
519
661
|
get: {
|
|
520
662
|
tags: ["products"],
|
|
@@ -542,6 +684,25 @@ export function buildOpenApiSpec(version) {
|
|
|
542
684
|
"403": { description: "Missing products:read permission." },
|
|
543
685
|
},
|
|
544
686
|
},
|
|
687
|
+
post: {
|
|
688
|
+
tags: ["products"],
|
|
689
|
+
summary: "Create a new product (strict create — 409 on conflict; PUT for upsert).",
|
|
690
|
+
description: "Strict create-only variant of PUT. Same tenancy + typo-guard posture. Returns 409 when a product with body.id already exists. Body must include id + name; tools[] entries must reference registered MCP tool names (typo → 422).",
|
|
691
|
+
requestBody: {
|
|
692
|
+
required: true,
|
|
693
|
+
content: { "application/json": { schema: { type: "object", additionalProperties: true } } },
|
|
694
|
+
},
|
|
695
|
+
responses: {
|
|
696
|
+
"201": { description: "Created.", content: { "application/json": { schema: { type: "object", properties: {
|
|
697
|
+
product: { type: "object", additionalProperties: true },
|
|
698
|
+
persisted: { type: "boolean" },
|
|
699
|
+
} } } } },
|
|
700
|
+
"400": { description: "Body invalid (missing id / shape rejected by validateProduct)." },
|
|
701
|
+
"403": { description: "Missing products:write permission, or non-admin attempting to create in another tenant." },
|
|
702
|
+
"409": { description: "Product with that id already exists — use PUT to update." },
|
|
703
|
+
"422": { description: "tools[] references unknown tool names. Body includes `unknown` + `available`; error code OMCP_PRODUCT_UNKNOWN_TOOL." },
|
|
704
|
+
},
|
|
705
|
+
},
|
|
545
706
|
},
|
|
546
707
|
"/api/products/{id}": {
|
|
547
708
|
get: {
|
|
@@ -577,6 +738,7 @@ export function buildOpenApiSpec(version) {
|
|
|
577
738
|
"400": { description: "Body shape invalid (validateProduct rejected — typo, unknown key, wrong type, ...)." },
|
|
578
739
|
"403": { description: "Missing products:write permission, or non-admin attempting to write into another tenant." },
|
|
579
740
|
"404": { description: "Existing product belongs to a different tenant (non-admin)." },
|
|
741
|
+
"422": { description: "tools[] references unknown tool names. Body includes `unknown` + `available`; error code OMCP_PRODUCT_UNKNOWN_TOOL." },
|
|
580
742
|
},
|
|
581
743
|
},
|
|
582
744
|
delete: {
|
|
@@ -592,6 +754,52 @@ export function buildOpenApiSpec(version) {
|
|
|
592
754
|
},
|
|
593
755
|
},
|
|
594
756
|
},
|
|
757
|
+
"/api/products/{id}/preview": {
|
|
758
|
+
get: {
|
|
759
|
+
tags: ["products"],
|
|
760
|
+
summary: "Agent preview — the filtered tools/list a credential bound to this product would receive.",
|
|
761
|
+
description: "Same tenancy + staging filter as GET /api/products/{id}. Returns the product's branding/identity metadata + the registered MCP tools after applying its tools allow-list. The UI uses this for the per-card 'Preview as agent' affordance.",
|
|
762
|
+
parameters: [
|
|
763
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
|
764
|
+
],
|
|
765
|
+
responses: {
|
|
766
|
+
"200": {
|
|
767
|
+
description: "Preview payload.",
|
|
768
|
+
content: { "application/json": { schema: {
|
|
769
|
+
type: "object",
|
|
770
|
+
required: ["product", "unrestricted", "tools"],
|
|
771
|
+
properties: {
|
|
772
|
+
product: {
|
|
773
|
+
type: "object",
|
|
774
|
+
properties: {
|
|
775
|
+
id: { type: "string" },
|
|
776
|
+
name: { type: "string" },
|
|
777
|
+
version: { type: "string" },
|
|
778
|
+
branding: { type: "object", additionalProperties: true },
|
|
779
|
+
tenant: { type: "string" },
|
|
780
|
+
status: { type: "string" },
|
|
781
|
+
},
|
|
782
|
+
},
|
|
783
|
+
unrestricted: { type: "boolean", description: "True when the product has no tools allow-list — the bound agent sees every registered tool." },
|
|
784
|
+
tools: {
|
|
785
|
+
type: "array",
|
|
786
|
+
items: {
|
|
787
|
+
type: "object",
|
|
788
|
+
properties: {
|
|
789
|
+
name: { type: "string" },
|
|
790
|
+
category: { type: "string", enum: ["discovery", "query", "diagnose", "topology"] },
|
|
791
|
+
summary: { type: "string" },
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
} } },
|
|
797
|
+
},
|
|
798
|
+
"404": { description: "Not found (or hidden by tenant / staging scope)." },
|
|
799
|
+
"403": { description: "Missing products:read permission." },
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
},
|
|
595
803
|
"/api/catalog": {
|
|
596
804
|
get: {
|
|
597
805
|
tags: ["catalog"],
|
package/dist/openapi.test.js
CHANGED
|
@@ -13,6 +13,7 @@ test("openapi — every user-visible /api path is documented", () => {
|
|
|
13
13
|
"/api/sources/{name}",
|
|
14
14
|
"/api/sources/{name}/metrics",
|
|
15
15
|
"/api/source-types",
|
|
16
|
+
"/api/tools/registry",
|
|
16
17
|
"/api/settings",
|
|
17
18
|
"/api/health-thresholds",
|
|
18
19
|
"/api/me",
|
|
@@ -24,9 +25,13 @@ test("openapi — every user-visible /api path is documented", () => {
|
|
|
24
25
|
"/api/audit",
|
|
25
26
|
"/api/usage",
|
|
26
27
|
"/api/policy",
|
|
28
|
+
"/api/subjects",
|
|
29
|
+
"/api/users/{username}/roles",
|
|
30
|
+
"/api/policy/roles/{name}",
|
|
27
31
|
"/api/catalog",
|
|
28
32
|
"/api/products",
|
|
29
33
|
"/api/products/{id}",
|
|
34
|
+
"/api/products/{id}/preview",
|
|
30
35
|
"/api/info",
|
|
31
36
|
"/api/openapi.json",
|
|
32
37
|
];
|
|
@@ -62,3 +67,32 @@ test("openapi — info.version is the version string the caller passed in", () =
|
|
|
62
67
|
const spec = buildOpenApiSpec("9.9.9-test");
|
|
63
68
|
assert.equal(spec.info?.version, "9.9.9-test");
|
|
64
69
|
});
|
|
70
|
+
test("openapi — SOURCE_SCHEMA exposes the tenant field (tenant-aware sources contract)", () => {
|
|
71
|
+
// Source entries gained a `tenant` field when per-tenant connector
|
|
72
|
+
// scoping shipped. The spec is the contract operators write
|
|
73
|
+
// generated clients against — drift = broken downstream clients.
|
|
74
|
+
const spec = buildOpenApiSpec("test-1.0.0");
|
|
75
|
+
// SOURCE_SCHEMA is inlined into both `items` of GET /api/sources and
|
|
76
|
+
// the requestBody of POST/PUT — pick the GET response, the canonical
|
|
77
|
+
// read path.
|
|
78
|
+
const sources = spec.paths?.["/api/sources"]?.get;
|
|
79
|
+
const items = sources.responses["200"].content["application/json"].schema.items;
|
|
80
|
+
assert.ok(items.properties.tenant, "SOURCE_SCHEMA must document `tenant` (added when per-tenant scoping shipped)");
|
|
81
|
+
});
|
|
82
|
+
test("openapi — /api/sources GET documents the admin `?tenant=` drill-down query param", () => {
|
|
83
|
+
const spec = buildOpenApiSpec("test-1.0.0");
|
|
84
|
+
const get = spec.paths?.["/api/sources"]?.get;
|
|
85
|
+
const params = get.parameters || [];
|
|
86
|
+
const tenantParam = params.find((p) => p.name === "tenant" && p.in === "query");
|
|
87
|
+
assert.ok(tenantParam, "GET /api/sources must document the admin `?tenant=` drill-down param");
|
|
88
|
+
});
|
|
89
|
+
test("openapi — /api/policy GET documents the `?tenant=` probe param + `tenantAware` snapshot field", () => {
|
|
90
|
+
const spec = buildOpenApiSpec("test-1.0.0");
|
|
91
|
+
const get = spec.paths?.["/api/policy"]?.get;
|
|
92
|
+
const params = get.parameters || [];
|
|
93
|
+
assert.ok(params.some((p) => p.name === "tenant" && p.in === "query"), "GET /api/policy must document the `?tenant=` probe param");
|
|
94
|
+
const schema = get.responses["200"].content["application/json"].schema;
|
|
95
|
+
assert.ok(schema.properties.tenantAware, "snapshot must document the `tenantAware` field");
|
|
96
|
+
const dryRun = schema.properties.dryRun;
|
|
97
|
+
assert.ok(dryRun?.properties?.tenant, "dryRun must echo the `tenant` field so operators see which tenant the verdict ran under");
|
|
98
|
+
});
|
package/dist/policy/redact.js
CHANGED
|
@@ -31,7 +31,7 @@ const PATTERNS = [
|
|
|
31
31
|
// - PEM private-key blocks: greedy match across newlines.
|
|
32
32
|
{ category: "aws-key", re: /\b(?:AKIA|ASIA|AROA)[0-9A-Z]{16,20}\b/g },
|
|
33
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[
|
|
34
|
+
{ category: "gh-pat", re: /\b(?:github_pat_[A-Za-z0-9_]{40,}|gh[oprsu]_[A-Za-z0-9]{36})\b/g },
|
|
35
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
36
|
// emails before other patterns so they don't get eaten partially
|
|
37
37
|
{ category: "email", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PostmortemReport } from "./synthesizer.js";
|
|
2
|
+
export interface StoredPostmortem {
|
|
3
|
+
id: string;
|
|
4
|
+
/** RFC-3339 timestamp of when the report was generated. */
|
|
5
|
+
ts: string;
|
|
6
|
+
/** Subject identity that called generate_postmortem. */
|
|
7
|
+
createdBy: string;
|
|
8
|
+
/** Tenant the report belongs to. */
|
|
9
|
+
tenant: string;
|
|
10
|
+
/** The shipped report shape — service + window + synopsis +
|
|
11
|
+
* markdown + sections. */
|
|
12
|
+
report: PostmortemReport;
|
|
13
|
+
}
|
|
14
|
+
export declare class PostmortemStore {
|
|
15
|
+
private readonly path;
|
|
16
|
+
private entries;
|
|
17
|
+
private bootstrapped;
|
|
18
|
+
constructor(path: string);
|
|
19
|
+
load(): Promise<void>;
|
|
20
|
+
/** List entries, newest-first. Optionally scoped to a tenant. */
|
|
21
|
+
list(tenant?: string): StoredPostmortem[];
|
|
22
|
+
get(id: string, tenant?: string): StoredPostmortem | undefined;
|
|
23
|
+
/** Append a freshly-generated report. Returns the stored entry
|
|
24
|
+
* with its assigned id + ts. */
|
|
25
|
+
append(input: {
|
|
26
|
+
report: PostmortemReport;
|
|
27
|
+
createdBy: string;
|
|
28
|
+
tenant: string;
|
|
29
|
+
}): Promise<StoredPostmortem>;
|
|
30
|
+
/** Delete one entry by id. Atomic rewrite. Returns whether
|
|
31
|
+
* anything was removed. */
|
|
32
|
+
delete(id: string, tenant?: string): Promise<boolean>;
|
|
33
|
+
private rewrite;
|
|
34
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// PostmortemStore — file-backed JSONL persistence for
|
|
2
|
+
// generate_postmortem output.
|
|
3
|
+
//
|
|
4
|
+
// Design notes:
|
|
5
|
+
// - One JSON object per line (append-only). Cheap to tail, cheap
|
|
6
|
+
// to scan, surives crashes mid-write (the partial line is just
|
|
7
|
+
// ignored on load).
|
|
8
|
+
// - load() reads the whole file into an in-memory array (in
|
|
9
|
+
// practice operators don't accumulate thousands; the tool
|
|
10
|
+
// produces one report per incident).
|
|
11
|
+
// - delete() rewrites the file atomically (tmp + rename). Same
|
|
12
|
+
// pattern as the SCIM store from F21.
|
|
13
|
+
//
|
|
14
|
+
// The schema is intentionally narrow — we store what the tool's
|
|
15
|
+
// PostmortemReport already returns plus an id, ts, and createdBy.
|
|
16
|
+
import { readFile, writeFile, mkdir, rename, appendFile } from "node:fs/promises";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
export class PostmortemStore {
|
|
20
|
+
path;
|
|
21
|
+
entries = [];
|
|
22
|
+
bootstrapped = null;
|
|
23
|
+
constructor(path) {
|
|
24
|
+
this.path = path;
|
|
25
|
+
}
|
|
26
|
+
async load() {
|
|
27
|
+
if (this.bootstrapped)
|
|
28
|
+
return this.bootstrapped;
|
|
29
|
+
this.bootstrapped = (async () => {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(this.path, "utf8");
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const line of raw.split("\n")) {
|
|
34
|
+
const t = line.trim();
|
|
35
|
+
if (!t)
|
|
36
|
+
continue;
|
|
37
|
+
try {
|
|
38
|
+
const obj = JSON.parse(t);
|
|
39
|
+
if (obj && typeof obj.id === "string")
|
|
40
|
+
out.push(obj);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Partial / corrupt line — skip, don't fail load. The
|
|
44
|
+
// operator can purge by re-saving with delete().
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.entries = out;
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err.code === "ENOENT") {
|
|
51
|
+
this.entries = [];
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.warn(`[postmortem-store] failed to load ${this.path}: ${err.message} — starting empty`);
|
|
55
|
+
this.entries = [];
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
return this.bootstrapped;
|
|
59
|
+
}
|
|
60
|
+
/** List entries, newest-first. Optionally scoped to a tenant. */
|
|
61
|
+
list(tenant) {
|
|
62
|
+
const src = tenant ? this.entries.filter((e) => e.tenant === tenant) : this.entries;
|
|
63
|
+
return src.slice().sort((a, b) => b.ts.localeCompare(a.ts));
|
|
64
|
+
}
|
|
65
|
+
get(id, tenant) {
|
|
66
|
+
const e = this.entries.find((x) => x.id === id);
|
|
67
|
+
if (!e)
|
|
68
|
+
return undefined;
|
|
69
|
+
if (tenant && e.tenant !== tenant)
|
|
70
|
+
return undefined;
|
|
71
|
+
return e;
|
|
72
|
+
}
|
|
73
|
+
/** Append a freshly-generated report. Returns the stored entry
|
|
74
|
+
* with its assigned id + ts. */
|
|
75
|
+
async append(input) {
|
|
76
|
+
const entry = {
|
|
77
|
+
id: randomUUID(),
|
|
78
|
+
ts: new Date().toISOString(),
|
|
79
|
+
createdBy: input.createdBy,
|
|
80
|
+
tenant: input.tenant,
|
|
81
|
+
report: input.report,
|
|
82
|
+
};
|
|
83
|
+
this.entries.push(entry);
|
|
84
|
+
await mkdir(dirname(this.path), { recursive: true }).catch(() => undefined);
|
|
85
|
+
// Append-only — atomic enough for a JSONL (one write = one
|
|
86
|
+
// syscall; partial writes are skipped on load).
|
|
87
|
+
await appendFile(this.path, JSON.stringify(entry) + "\n", { mode: 0o600 });
|
|
88
|
+
return entry;
|
|
89
|
+
}
|
|
90
|
+
/** Delete one entry by id. Atomic rewrite. Returns whether
|
|
91
|
+
* anything was removed. */
|
|
92
|
+
async delete(id, tenant) {
|
|
93
|
+
const before = this.entries.length;
|
|
94
|
+
this.entries = this.entries.filter((e) => {
|
|
95
|
+
if (e.id !== id)
|
|
96
|
+
return true;
|
|
97
|
+
if (tenant && e.tenant !== tenant)
|
|
98
|
+
return true;
|
|
99
|
+
return false;
|
|
100
|
+
});
|
|
101
|
+
if (this.entries.length === before)
|
|
102
|
+
return false;
|
|
103
|
+
await this.rewrite();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
async rewrite() {
|
|
107
|
+
await mkdir(dirname(this.path), { recursive: true }).catch(() => undefined);
|
|
108
|
+
const body = this.entries.map((e) => JSON.stringify(e)).join("\n") + (this.entries.length ? "\n" : "");
|
|
109
|
+
const tmp = `${this.path}.tmp`;
|
|
110
|
+
await writeFile(tmp, body, { mode: 0o600 });
|
|
111
|
+
await rename(tmp, this.path);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, statSync, existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { PostmortemStore } from "./store.js";
|
|
7
|
+
function tmpStore() {
|
|
8
|
+
return join(mkdtempSync(join(tmpdir(), "pmstore-")), "postmortems.jsonl");
|
|
9
|
+
}
|
|
10
|
+
function fakeReport(service = "payment") {
|
|
11
|
+
return {
|
|
12
|
+
service,
|
|
13
|
+
window: "1h",
|
|
14
|
+
fromIso: "2026-06-06T00:00:00.000Z",
|
|
15
|
+
toIso: "2026-06-06T01:00:00.000Z",
|
|
16
|
+
synopsis: "test",
|
|
17
|
+
markdown: "# Test",
|
|
18
|
+
sections: {
|
|
19
|
+
timeline: [],
|
|
20
|
+
blastRadius: { nodes: [], edgeCount: 0 },
|
|
21
|
+
topTraces: [],
|
|
22
|
+
contributingSignals: [],
|
|
23
|
+
followUps: [],
|
|
24
|
+
logHighlights: [],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
test("PostmortemStore: load() on missing file → empty list", async () => {
|
|
29
|
+
const s = new PostmortemStore(tmpStore());
|
|
30
|
+
await s.load();
|
|
31
|
+
assert.deepEqual(s.list(), []);
|
|
32
|
+
});
|
|
33
|
+
test("PostmortemStore: append issues UUID + ISO ts, persists JSONL", async () => {
|
|
34
|
+
const path = tmpStore();
|
|
35
|
+
const s = new PostmortemStore(path);
|
|
36
|
+
await s.load();
|
|
37
|
+
const stored = await s.append({ report: fakeReport(), createdBy: "alice", tenant: "default" });
|
|
38
|
+
assert.match(stored.id, /^[0-9a-f-]{36}$/);
|
|
39
|
+
assert.match(stored.ts, /^\d{4}-\d{2}-\d{2}T/);
|
|
40
|
+
assert.equal(stored.report.service, "payment");
|
|
41
|
+
// disk format is one JSON per line
|
|
42
|
+
const raw = readFileSync(path, "utf8");
|
|
43
|
+
assert.equal(raw.split("\n").filter((l) => l).length, 1);
|
|
44
|
+
assert.equal(statSync(path).mode & 0o777, 0o600);
|
|
45
|
+
});
|
|
46
|
+
test("PostmortemStore: list() returns newest-first", async () => {
|
|
47
|
+
const s = new PostmortemStore(tmpStore());
|
|
48
|
+
await s.load();
|
|
49
|
+
await s.append({ report: fakeReport("a"), createdBy: "u", tenant: "t" });
|
|
50
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
51
|
+
await s.append({ report: fakeReport("b"), createdBy: "u", tenant: "t" });
|
|
52
|
+
const out = s.list();
|
|
53
|
+
assert.equal(out[0].report.service, "b");
|
|
54
|
+
assert.equal(out[1].report.service, "a");
|
|
55
|
+
});
|
|
56
|
+
test("PostmortemStore: list(tenant) scopes correctly", async () => {
|
|
57
|
+
const s = new PostmortemStore(tmpStore());
|
|
58
|
+
await s.load();
|
|
59
|
+
await s.append({ report: fakeReport("a"), createdBy: "u", tenant: "alpha" });
|
|
60
|
+
await s.append({ report: fakeReport("b"), createdBy: "u", tenant: "beta" });
|
|
61
|
+
assert.equal(s.list("alpha").length, 1);
|
|
62
|
+
assert.equal(s.list("alpha")[0].report.service, "a");
|
|
63
|
+
});
|
|
64
|
+
test("PostmortemStore: get() by id, tenant-scoped", async () => {
|
|
65
|
+
const s = new PostmortemStore(tmpStore());
|
|
66
|
+
await s.load();
|
|
67
|
+
const e = await s.append({ report: fakeReport(), createdBy: "u", tenant: "alpha" });
|
|
68
|
+
assert.equal(s.get(e.id)?.id, e.id);
|
|
69
|
+
assert.equal(s.get(e.id, "alpha")?.id, e.id);
|
|
70
|
+
// wrong tenant → undefined
|
|
71
|
+
assert.equal(s.get(e.id, "beta"), undefined);
|
|
72
|
+
assert.equal(s.get("nope"), undefined);
|
|
73
|
+
});
|
|
74
|
+
test("PostmortemStore: delete() rewrites file + scoped by tenant", async () => {
|
|
75
|
+
const path = tmpStore();
|
|
76
|
+
const s = new PostmortemStore(path);
|
|
77
|
+
await s.load();
|
|
78
|
+
const e1 = await s.append({ report: fakeReport("a"), createdBy: "u", tenant: "alpha" });
|
|
79
|
+
await s.append({ report: fakeReport("b"), createdBy: "u", tenant: "beta" });
|
|
80
|
+
// wrong tenant → no-op
|
|
81
|
+
assert.equal(await s.delete(e1.id, "beta"), false);
|
|
82
|
+
assert.equal(s.list().length, 2);
|
|
83
|
+
// correct tenant → removed
|
|
84
|
+
assert.equal(await s.delete(e1.id, "alpha"), true);
|
|
85
|
+
assert.equal(s.list().length, 1);
|
|
86
|
+
// disk reflects the rewrite
|
|
87
|
+
const raw = readFileSync(path, "utf8");
|
|
88
|
+
assert.equal(raw.split("\n").filter((l) => l).length, 1);
|
|
89
|
+
});
|
|
90
|
+
test("PostmortemStore: delete() missing id → false", async () => {
|
|
91
|
+
const s = new PostmortemStore(tmpStore());
|
|
92
|
+
await s.load();
|
|
93
|
+
assert.equal(await s.delete("nope"), false);
|
|
94
|
+
});
|
|
95
|
+
test("PostmortemStore: round-trip through disk (load after append sees entries)", async () => {
|
|
96
|
+
const path = tmpStore();
|
|
97
|
+
const a = new PostmortemStore(path);
|
|
98
|
+
await a.load();
|
|
99
|
+
await a.append({ report: fakeReport("a"), createdBy: "u", tenant: "default" });
|
|
100
|
+
await a.append({ report: fakeReport("b"), createdBy: "u", tenant: "default" });
|
|
101
|
+
const b = new PostmortemStore(path);
|
|
102
|
+
await b.load();
|
|
103
|
+
assert.equal(b.list().length, 2);
|
|
104
|
+
});
|
|
105
|
+
test("PostmortemStore: load skips corrupt lines", async () => {
|
|
106
|
+
const path = tmpStore();
|
|
107
|
+
// Hand-write a file with one good + one garbage line
|
|
108
|
+
const a = new PostmortemStore(path);
|
|
109
|
+
await a.load();
|
|
110
|
+
await a.append({ report: fakeReport(), createdBy: "u", tenant: "default" });
|
|
111
|
+
const fs = await import("node:fs/promises");
|
|
112
|
+
await fs.appendFile(path, "{not valid json\n");
|
|
113
|
+
const b = new PostmortemStore(path);
|
|
114
|
+
await b.load();
|
|
115
|
+
// Good entry survived; corrupt line ignored.
|
|
116
|
+
assert.equal(b.list().length, 1);
|
|
117
|
+
assert.ok(existsSync(path));
|
|
118
|
+
});
|