@thotischner/observability-mcp 1.8.1 → 3.0.0
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/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 +129 -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.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -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 +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +1188 -120
- 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/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/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/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -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/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 +2 -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 +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- 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 +10 -6
- 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/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 +1729 -100
- package/package.json +13 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { synthesizePostmortem, } from "./synthesizer.js";
|
|
4
|
+
function input(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
service: "payment",
|
|
7
|
+
window: "1h",
|
|
8
|
+
tenant: "default",
|
|
9
|
+
fromIso: "2026-06-06T00:00:00.000Z",
|
|
10
|
+
toIso: "2026-06-06T01:00:00.000Z",
|
|
11
|
+
anomalies: [],
|
|
12
|
+
blastRadius: { nodes: [], edges: [] },
|
|
13
|
+
traces: [],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function anomaly(ts, score, method = "mad", severity = "warn", signal) {
|
|
18
|
+
return { ts, service: "payment", score, method, severity, signal };
|
|
19
|
+
}
|
|
20
|
+
test("synthesizePostmortem: empty input returns synopsis + 'no anomalies' follow-up", () => {
|
|
21
|
+
const r = synthesizePostmortem(input());
|
|
22
|
+
assert.match(r.synopsis, /No anomalies recorded/);
|
|
23
|
+
assert.equal(r.sections.timeline.length, 0);
|
|
24
|
+
assert.equal(r.sections.followUps.length, 1);
|
|
25
|
+
assert.match(r.sections.followUps[0], /OMCP_ANOMALY_HISTORY_REMOTE_WRITE/);
|
|
26
|
+
});
|
|
27
|
+
test("synthesizePostmortem: timeline is sorted by ts ascending", () => {
|
|
28
|
+
const r = synthesizePostmortem(input({
|
|
29
|
+
anomalies: [
|
|
30
|
+
anomaly("2026-06-06T00:30:00Z", 0.5),
|
|
31
|
+
anomaly("2026-06-06T00:10:00Z", 0.4),
|
|
32
|
+
anomaly("2026-06-06T00:50:00Z", 0.9),
|
|
33
|
+
],
|
|
34
|
+
}));
|
|
35
|
+
assert.deepEqual(r.sections.timeline.map((t) => t.ts), ["2026-06-06T00:10:00Z", "2026-06-06T00:30:00Z", "2026-06-06T00:50:00Z"]);
|
|
36
|
+
});
|
|
37
|
+
test("synthesizePostmortem: contributing signals aggregated by signal label + ranked by mean score desc", () => {
|
|
38
|
+
const r = synthesizePostmortem(input({
|
|
39
|
+
anomalies: [
|
|
40
|
+
anomaly("2026-06-06T00:10Z", 0.5, "mad", "warn", "request_latency"),
|
|
41
|
+
anomaly("2026-06-06T00:20Z", 0.4, "mad", "warn", "request_latency"),
|
|
42
|
+
anomaly("2026-06-06T00:30Z", 0.95, "seasonality", "critical", "error_rate"),
|
|
43
|
+
],
|
|
44
|
+
}));
|
|
45
|
+
const sigs = r.sections.contributingSignals;
|
|
46
|
+
assert.equal(sigs.length, 2);
|
|
47
|
+
// error_rate (0.95 mean) ranks above request_latency (0.45 mean)
|
|
48
|
+
assert.equal(sigs[0].signal, "error_rate");
|
|
49
|
+
assert.equal(sigs[0].count, 1);
|
|
50
|
+
assert.equal(sigs[0].meanScore, 0.95);
|
|
51
|
+
assert.equal(sigs[1].signal, "request_latency");
|
|
52
|
+
assert.equal(sigs[1].count, 2);
|
|
53
|
+
assert.equal(sigs[1].meanScore, 0.45);
|
|
54
|
+
});
|
|
55
|
+
test("synthesizePostmortem: missing signal label falls back to method", () => {
|
|
56
|
+
const r = synthesizePostmortem(input({ anomalies: [anomaly("2026-06-06T00:10Z", 0.6, "correlator")] }));
|
|
57
|
+
assert.equal(r.sections.contributingSignals[0].signal, "correlator");
|
|
58
|
+
});
|
|
59
|
+
test("synthesizePostmortem: critical peak triggers a follow-up mentioning the threshold", () => {
|
|
60
|
+
const r = synthesizePostmortem(input({ anomalies: [anomaly("2026-06-06T00:30Z", 0.95)] }));
|
|
61
|
+
assert.ok(r.sections.followUps.some((f) => /Peak anomaly score 0\.95/.test(f)));
|
|
62
|
+
});
|
|
63
|
+
test("synthesizePostmortem: errors-in-traces triggers errorsOnly drill-in suggestion", () => {
|
|
64
|
+
const r = synthesizePostmortem(input({
|
|
65
|
+
anomalies: [anomaly("2026-06-06T00:10Z", 0.6)],
|
|
66
|
+
traces: [
|
|
67
|
+
{ traceId: "aaa", rootName: "GET /pay", rootService: "payment", durationMs: 800, hasError: true },
|
|
68
|
+
],
|
|
69
|
+
}));
|
|
70
|
+
assert.ok(r.sections.followUps.some((f) => /errorsOnly=true/.test(f)));
|
|
71
|
+
});
|
|
72
|
+
test("synthesizePostmortem: large blast radius triggers stale-topology hint", () => {
|
|
73
|
+
const nodes = Array.from({ length: 7 }, (_, i) => ({ id: `n${i}`, kind: "pod", name: `n${i}`, root: i === 0 }));
|
|
74
|
+
const r = synthesizePostmortem(input({
|
|
75
|
+
anomalies: [anomaly("2026-06-06T00:10Z", 0.6)],
|
|
76
|
+
blastRadius: { nodes, edges: [{ from: "n0", to: "n1", relation: "CALLS" }] },
|
|
77
|
+
}));
|
|
78
|
+
assert.ok(r.sections.followUps.some((f) => /7 nodes/.test(f) && /stale topology/i.test(f)));
|
|
79
|
+
});
|
|
80
|
+
test("synthesizePostmortem: clean window returns a 'stable, consider closing' follow-up", () => {
|
|
81
|
+
// The "all signals stable" branch fires only when:
|
|
82
|
+
// anomalies present (not zero)
|
|
83
|
+
// peak < 0.9
|
|
84
|
+
// no error traces
|
|
85
|
+
// blast radius <= 5
|
|
86
|
+
// no log highlights
|
|
87
|
+
const r = synthesizePostmortem(input({
|
|
88
|
+
anomalies: [anomaly("2026-06-06T00:10Z", 0.3)],
|
|
89
|
+
blastRadius: { nodes: [{ id: "n0", kind: "pod", name: "n0", root: true }], edges: [] },
|
|
90
|
+
}));
|
|
91
|
+
assert.ok(r.sections.followUps.some((f) => /stable for this window/.test(f)));
|
|
92
|
+
});
|
|
93
|
+
test("synthesizePostmortem: markdown contains every section header in order", () => {
|
|
94
|
+
const r = synthesizePostmortem(input({
|
|
95
|
+
anomalies: [anomaly("2026-06-06T00:10Z", 0.7)],
|
|
96
|
+
blastRadius: {
|
|
97
|
+
nodes: [{ id: "p", kind: "deployment", name: "payment", root: true }],
|
|
98
|
+
edges: [{ from: "p", to: "rds", relation: "READS_FROM" }],
|
|
99
|
+
},
|
|
100
|
+
traces: [{ traceId: "t", rootName: "GET /pay", rootService: "payment", durationMs: 200, hasError: false }],
|
|
101
|
+
logHighlights: ["payment-service: 12 5xx in window"],
|
|
102
|
+
}));
|
|
103
|
+
for (const heading of [
|
|
104
|
+
"# Post-mortem — payment",
|
|
105
|
+
"## Synopsis",
|
|
106
|
+
"## Anomaly timeline",
|
|
107
|
+
"## Blast radius at peak",
|
|
108
|
+
"## Contributing signals (ranked)",
|
|
109
|
+
"## Related traces",
|
|
110
|
+
"## Log highlights",
|
|
111
|
+
"## Suggested follow-ups",
|
|
112
|
+
]) {
|
|
113
|
+
assert.ok(r.markdown.includes(heading), `markdown missing section: ${heading}`);
|
|
114
|
+
}
|
|
115
|
+
// The order check — anomaly timeline should appear before blast radius
|
|
116
|
+
assert.ok(r.markdown.indexOf("## Anomaly timeline") < r.markdown.indexOf("## Blast radius at peak"));
|
|
117
|
+
});
|
|
118
|
+
test("synthesizePostmortem: timeline > 20 rows is truncated with an ellipsis row", () => {
|
|
119
|
+
const anomalies = Array.from({ length: 25 }, (_, i) => anomaly(`2026-06-06T00:${String(i).padStart(2, "0")}:00Z`, 0.5 + i * 0.01));
|
|
120
|
+
const r = synthesizePostmortem(input({ anomalies }));
|
|
121
|
+
// The structured section has all 25
|
|
122
|
+
assert.equal(r.sections.timeline.length, 25);
|
|
123
|
+
// The markdown table is capped at 20 data rows + an ellipsis row
|
|
124
|
+
// — count rows specifically inside the Anomaly timeline section
|
|
125
|
+
// (other sections also use | ` ... | tables and would inflate a
|
|
126
|
+
// global grep).
|
|
127
|
+
const md = r.markdown;
|
|
128
|
+
const timelineStart = md.indexOf("## Anomaly timeline");
|
|
129
|
+
const blastStart = md.indexOf("## Blast radius at peak");
|
|
130
|
+
const timelineSection = md.slice(timelineStart, blastStart);
|
|
131
|
+
const tableRows = timelineSection.split("\n").filter((l) => l.startsWith("| `")).length;
|
|
132
|
+
assert.equal(tableRows, 20);
|
|
133
|
+
assert.match(timelineSection, /_5 more rows_/);
|
|
134
|
+
});
|
|
135
|
+
test("synthesizePostmortem: report carries the input window + iso bounds back into the structured shape", () => {
|
|
136
|
+
const r = synthesizePostmortem(input({ window: "6h" }));
|
|
137
|
+
assert.equal(r.service, "payment");
|
|
138
|
+
assert.equal(r.window, "6h");
|
|
139
|
+
assert.equal(r.fromIso, "2026-06-06T00:00:00.000Z");
|
|
140
|
+
assert.equal(r.toIso, "2026-06-06T01:00:00.000Z");
|
|
141
|
+
});
|
|
@@ -12,8 +12,13 @@
|
|
|
12
12
|
* (YAML or JSON). Missing/empty file → empty catalog.
|
|
13
13
|
* - Strict validation: unknown action / unknown resource /
|
|
14
14
|
* unexpected keys reject loudly.
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
15
|
+
* - Mtime-poll hot-reload: callers (e.g. each /api/products
|
|
16
|
+
* handler) `await store.maybeReload()` before reading. If the
|
|
17
|
+
* file mtime advanced since the last load, the store re-parses
|
|
18
|
+
* and atomically swaps the in-memory file; parse errors keep
|
|
19
|
+
* the previous good state and log loudly. One `stat()` call per
|
|
20
|
+
* reload-aware request — too cheap to matter vs. the network
|
|
21
|
+
* round-trip, no FSWatcher platform fragility (WSL / NFS).
|
|
17
22
|
*/
|
|
18
23
|
export interface Product {
|
|
19
24
|
/** Stable identifier — used in URLs, audit entries, /api/products/{id}. */
|
|
@@ -47,7 +52,30 @@ export declare function parseProductsText(text: string, origin: string): Product
|
|
|
47
52
|
/** In-memory store with tenant- and status-aware queries. */
|
|
48
53
|
export declare class ProductsStore {
|
|
49
54
|
private file;
|
|
50
|
-
|
|
55
|
+
/** Optional source file path. When set, `maybeReload()` polls its
|
|
56
|
+
* mtime and re-parses on change. Mutations via upsert/delete update
|
|
57
|
+
* `lastMtimeMs` after the caller persists, so the store does not
|
|
58
|
+
* reload its own writes. */
|
|
59
|
+
private path?;
|
|
60
|
+
private lastMtimeMs;
|
|
61
|
+
constructor(file?: ProductsFile, opts?: {
|
|
62
|
+
path?: string;
|
|
63
|
+
initialMtimeMs?: number;
|
|
64
|
+
});
|
|
65
|
+
/** Re-read the source file if its mtime has advanced since the last
|
|
66
|
+
* load. No-op when no path was supplied at construction. Parse or
|
|
67
|
+
* IO errors are logged and the previous good state is kept — the
|
|
68
|
+
* invariant is "the store always reflects a valid catalogue", so a
|
|
69
|
+
* broken edit on disk never takes the running server down. */
|
|
70
|
+
maybeReload(): Promise<{
|
|
71
|
+
reloaded: boolean;
|
|
72
|
+
}>;
|
|
73
|
+
/** Re-stat the source file and pin the mtime cursor to its current
|
|
74
|
+
* value. Call this after a successful write so the store does not
|
|
75
|
+
* treat its own change as an external reload trigger. Best-effort:
|
|
76
|
+
* if the stat fails, the next maybeReload() will simply reload the
|
|
77
|
+
* file once and find it identical. */
|
|
78
|
+
pinMtimeAfterWrite(): Promise<void>;
|
|
51
79
|
/** Return the product list. When `tenant` is set, filters to that
|
|
52
80
|
* tenant (entries without a tenant field treated as "default").
|
|
53
81
|
* When `includeStaging` is false (default), staging products are
|
package/dist/products/loader.js
CHANGED
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
* (YAML or JSON). Missing/empty file → empty catalog.
|
|
13
13
|
* - Strict validation: unknown action / unknown resource /
|
|
14
14
|
* unexpected keys reject loudly.
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
15
|
+
* - Mtime-poll hot-reload: callers (e.g. each /api/products
|
|
16
|
+
* handler) `await store.maybeReload()` before reading. If the
|
|
17
|
+
* file mtime advanced since the last load, the store re-parses
|
|
18
|
+
* and atomically swaps the in-memory file; parse errors keep
|
|
19
|
+
* the previous good state and log loudly. One `stat()` call per
|
|
20
|
+
* reload-aware request — too cheap to matter vs. the network
|
|
21
|
+
* round-trip, no FSWatcher platform fragility (WSL / NFS).
|
|
17
22
|
*/
|
|
18
|
-
import { readFile, writeFile, rename } from "node:fs/promises";
|
|
23
|
+
import { readFile, writeFile, rename, stat } from "node:fs/promises";
|
|
19
24
|
import yaml from "js-yaml";
|
|
20
25
|
const EMPTY = { products: [] };
|
|
21
26
|
const VALID_STATUS = new Set(["published", "staging"]);
|
|
@@ -134,8 +139,76 @@ export function parseProductsText(text, origin) {
|
|
|
134
139
|
/** In-memory store with tenant- and status-aware queries. */
|
|
135
140
|
export class ProductsStore {
|
|
136
141
|
file;
|
|
137
|
-
|
|
142
|
+
/** Optional source file path. When set, `maybeReload()` polls its
|
|
143
|
+
* mtime and re-parses on change. Mutations via upsert/delete update
|
|
144
|
+
* `lastMtimeMs` after the caller persists, so the store does not
|
|
145
|
+
* reload its own writes. */
|
|
146
|
+
path;
|
|
147
|
+
lastMtimeMs = 0;
|
|
148
|
+
constructor(file = EMPTY, opts = {}) {
|
|
138
149
|
this.file = file;
|
|
150
|
+
this.path = opts.path;
|
|
151
|
+
this.lastMtimeMs = opts.initialMtimeMs ?? 0;
|
|
152
|
+
}
|
|
153
|
+
/** Re-read the source file if its mtime has advanced since the last
|
|
154
|
+
* load. No-op when no path was supplied at construction. Parse or
|
|
155
|
+
* IO errors are logged and the previous good state is kept — the
|
|
156
|
+
* invariant is "the store always reflects a valid catalogue", so a
|
|
157
|
+
* broken edit on disk never takes the running server down. */
|
|
158
|
+
async maybeReload() {
|
|
159
|
+
if (!this.path)
|
|
160
|
+
return { reloaded: false };
|
|
161
|
+
let mtimeMs;
|
|
162
|
+
try {
|
|
163
|
+
const s = await stat(this.path);
|
|
164
|
+
mtimeMs = s.mtimeMs;
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
const code = e.code;
|
|
168
|
+
// File gone (ENOENT) — keep last good state. Re-creating the
|
|
169
|
+
// file will land in this branch's else on the next call when
|
|
170
|
+
// stat succeeds again with a fresh mtime.
|
|
171
|
+
if (code !== "ENOENT") {
|
|
172
|
+
console.warn(`[products] hot-reload stat(${this.path}) failed: ${e.message} — keeping previous catalogue`);
|
|
173
|
+
}
|
|
174
|
+
return { reloaded: false };
|
|
175
|
+
}
|
|
176
|
+
if (mtimeMs <= this.lastMtimeMs)
|
|
177
|
+
return { reloaded: false };
|
|
178
|
+
let next;
|
|
179
|
+
try {
|
|
180
|
+
next = await readProductsFile(this.path);
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
// readProductsFile downgrades IO errors to EMPTY but lets
|
|
184
|
+
// parse errors (ProductsLoadError) propagate — so a broken
|
|
185
|
+
// YAML edit lands here, and we explicitly do NOT swap state.
|
|
186
|
+
console.warn(`[products] hot-reload of ${this.path} failed: ${e.message} — keeping previous catalogue`);
|
|
187
|
+
// Bump the mtime cursor anyway so we don't re-log the same
|
|
188
|
+
// failure on every subsequent request until the operator fixes
|
|
189
|
+
// the file (next save advances mtime past this value).
|
|
190
|
+
this.lastMtimeMs = mtimeMs;
|
|
191
|
+
return { reloaded: false };
|
|
192
|
+
}
|
|
193
|
+
this.file = next;
|
|
194
|
+
this.lastMtimeMs = mtimeMs;
|
|
195
|
+
return { reloaded: true };
|
|
196
|
+
}
|
|
197
|
+
/** Re-stat the source file and pin the mtime cursor to its current
|
|
198
|
+
* value. Call this after a successful write so the store does not
|
|
199
|
+
* treat its own change as an external reload trigger. Best-effort:
|
|
200
|
+
* if the stat fails, the next maybeReload() will simply reload the
|
|
201
|
+
* file once and find it identical. */
|
|
202
|
+
async pinMtimeAfterWrite() {
|
|
203
|
+
if (!this.path)
|
|
204
|
+
return;
|
|
205
|
+
try {
|
|
206
|
+
const s = await stat(this.path);
|
|
207
|
+
this.lastMtimeMs = s.mtimeMs;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Silent — see method JSDoc.
|
|
211
|
+
}
|
|
139
212
|
}
|
|
140
213
|
/** Return the product list. When `tenant` is set, filters to that
|
|
141
214
|
* tenant (entries without a tenant field treated as "default").
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { parseProductsText, ProductsStore, ProductsLoadError } from "./loader.js";
|
|
3
|
+
import { parseProductsText, ProductsStore, ProductsLoadError, readProductsFile } from "./loader.js";
|
|
4
4
|
test("parseProductsText — empty/minimal products array", () => {
|
|
5
5
|
const f = parseProductsText("products: []", "test");
|
|
6
6
|
assert.deepEqual(f.products, []);
|
|
@@ -166,3 +166,92 @@ test("ProductsLoadError is the throw class", () => {
|
|
|
166
166
|
}
|
|
167
167
|
assert.fail("expected throw");
|
|
168
168
|
});
|
|
169
|
+
test("ProductsStore.maybeReload — picks up out-of-band edits on next call", async () => {
|
|
170
|
+
const { mkdtemp, rm, writeFile, utimes } = await import("node:fs/promises");
|
|
171
|
+
const { tmpdir } = await import("node:os");
|
|
172
|
+
const { join } = await import("node:path");
|
|
173
|
+
const dir = await mkdtemp(join(tmpdir(), "omcp-products-reload-"));
|
|
174
|
+
try {
|
|
175
|
+
const file = join(dir, "products.yaml");
|
|
176
|
+
await writeFile(file, "products:\n - id: a\n name: A\n", "utf8");
|
|
177
|
+
const initial = await readProductsFile(file);
|
|
178
|
+
const store = new ProductsStore(initial, { path: file });
|
|
179
|
+
await store.pinMtimeAfterWrite();
|
|
180
|
+
assert.equal(store.list().length, 1);
|
|
181
|
+
assert.equal(store.list()[0].id, "a");
|
|
182
|
+
// Simulate an out-of-band edit. Bump mtime explicitly because
|
|
183
|
+
// some filesystems (WSL → 9P) round mtime to the second, so a
|
|
184
|
+
// back-to-back write can land in the same second and look
|
|
185
|
+
// unchanged to stat().
|
|
186
|
+
await writeFile(file, "products:\n - id: a\n name: A\n - id: b\n name: B\n", "utf8");
|
|
187
|
+
const future = new Date(Date.now() + 5_000);
|
|
188
|
+
await utimes(file, future, future);
|
|
189
|
+
const { reloaded } = await store.maybeReload();
|
|
190
|
+
assert.equal(reloaded, true);
|
|
191
|
+
assert.equal(store.list().length, 2);
|
|
192
|
+
// A second call with no further edit is a no-op.
|
|
193
|
+
const r2 = await store.maybeReload();
|
|
194
|
+
assert.equal(r2.reloaded, false);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
await rm(dir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
test("ProductsStore.maybeReload — broken YAML on disk keeps previous good state", async () => {
|
|
201
|
+
const { mkdtemp, rm, writeFile, utimes } = await import("node:fs/promises");
|
|
202
|
+
const { tmpdir } = await import("node:os");
|
|
203
|
+
const { join } = await import("node:path");
|
|
204
|
+
const dir = await mkdtemp(join(tmpdir(), "omcp-products-broken-"));
|
|
205
|
+
try {
|
|
206
|
+
const file = join(dir, "products.yaml");
|
|
207
|
+
await writeFile(file, "products:\n - id: a\n name: A\n", "utf8");
|
|
208
|
+
const store = new ProductsStore(await readProductsFile(file), { path: file });
|
|
209
|
+
await store.pinMtimeAfterWrite();
|
|
210
|
+
// Corrupt the file with an unknown top-level key — fails the
|
|
211
|
+
// strict typo guard inside parseProductsText.
|
|
212
|
+
await writeFile(file, "products:\n - id: a\n name: A\n junk: true\n", "utf8");
|
|
213
|
+
const future = new Date(Date.now() + 5_000);
|
|
214
|
+
await utimes(file, future, future);
|
|
215
|
+
const { reloaded } = await store.maybeReload();
|
|
216
|
+
// We did NOT swap state — caller sees the previous good catalogue.
|
|
217
|
+
assert.equal(reloaded, false);
|
|
218
|
+
assert.equal(store.list().length, 1);
|
|
219
|
+
assert.equal(store.list()[0].name, "A");
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
await rm(dir, { recursive: true, force: true });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
test("ProductsStore.maybeReload — no path = no-op", async () => {
|
|
226
|
+
const store = new ProductsStore({ products: [{ id: "a", name: "A" }] });
|
|
227
|
+
const r = await store.maybeReload();
|
|
228
|
+
assert.equal(r.reloaded, false);
|
|
229
|
+
assert.equal(store.list().length, 1);
|
|
230
|
+
});
|
|
231
|
+
test("ProductsStore.pinMtimeAfterWrite — own writes do not trigger a redundant reload", async () => {
|
|
232
|
+
const { mkdtemp, rm, writeFile, utimes } = await import("node:fs/promises");
|
|
233
|
+
const { tmpdir } = await import("node:os");
|
|
234
|
+
const { join } = await import("node:path");
|
|
235
|
+
const { writeProductsFile } = await import("./loader.js");
|
|
236
|
+
const dir = await mkdtemp(join(tmpdir(), "omcp-products-pin-"));
|
|
237
|
+
try {
|
|
238
|
+
const file = join(dir, "products.yaml");
|
|
239
|
+
await writeFile(file, "products:\n - id: a\n name: A\n", "utf8");
|
|
240
|
+
const store = new ProductsStore(await readProductsFile(file), { path: file });
|
|
241
|
+
await store.pinMtimeAfterWrite();
|
|
242
|
+
// Simulate the server-side mutate-then-persist path.
|
|
243
|
+
store.upsert({ id: "b", name: "B" });
|
|
244
|
+
// Move mtime forward so writeProductsFile genuinely advances it
|
|
245
|
+
// past our cursor (1-second-resolution FS guard).
|
|
246
|
+
const future = new Date(Date.now() + 5_000);
|
|
247
|
+
await writeProductsFile(file, store.snapshot());
|
|
248
|
+
await utimes(file, future, future);
|
|
249
|
+
await store.pinMtimeAfterWrite();
|
|
250
|
+
const { reloaded } = await store.maybeReload();
|
|
251
|
+
assert.equal(reloaded, false, "own write must not re-trigger maybeReload");
|
|
252
|
+
assert.equal(store.list().length, 2);
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await rm(dir, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helper that turns a TokenBudget decision into either the
|
|
3
|
+
* original tool result (when allowed / uncapped) or a structured
|
|
4
|
+
* error payload distinguishing the two budget-denial cases:
|
|
5
|
+
*
|
|
6
|
+
* - OMCP_TOKEN_BUDGET_EXCEEDED — cumulative trailing-24h
|
|
7
|
+
* usage would push the principal past its cap. Waiting helps;
|
|
8
|
+
* `retryAfterSeconds` says how long until enough buckets drop
|
|
9
|
+
* off to fit the request.
|
|
10
|
+
*
|
|
11
|
+
* - OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET — this single response is
|
|
12
|
+
* larger than the entire daily cap. Waiting does NOT help — the
|
|
13
|
+
* agent must narrow the query or the operator must raise the
|
|
14
|
+
* cap. `retryAfterSeconds` is 0 here so retry-with-backoff loops
|
|
15
|
+
* terminate instead of churning.
|
|
16
|
+
*
|
|
17
|
+
* Extracted from the createMcpServer closure in index.ts purely for
|
|
18
|
+
* unit-testability. Behaviour is identical to the previous inline
|
|
19
|
+
* version.
|
|
20
|
+
*/
|
|
21
|
+
import type { CheckResult } from "./token-budget.js";
|
|
22
|
+
export interface ToolResult {
|
|
23
|
+
content: Array<{
|
|
24
|
+
text: string;
|
|
25
|
+
[k: string]: unknown;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export declare function applyBudgetDecision<T extends ToolResult>(result: T, decision: CheckResult, tokens: number, toolName: string): T;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function applyBudgetDecision(result, decision, tokens, toolName) {
|
|
2
|
+
if (decision.allowed || decision.limit === 0)
|
|
3
|
+
return result;
|
|
4
|
+
// A request larger than the entire daily cap can never succeed by
|
|
5
|
+
// waiting — distinct error code so the agent doesn't spin.
|
|
6
|
+
const requestExceedsCap = tokens > decision.limit;
|
|
7
|
+
const errBody = {
|
|
8
|
+
error: requestExceedsCap ? "OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET" : "OMCP_TOKEN_BUDGET_EXCEEDED",
|
|
9
|
+
tool: toolName,
|
|
10
|
+
used: decision.used,
|
|
11
|
+
limit: decision.limit,
|
|
12
|
+
requested: tokens,
|
|
13
|
+
retryAfterSeconds: requestExceedsCap ? 0 : decision.retryAfterSeconds,
|
|
14
|
+
freedAtRetry: decision.freedAtRetry,
|
|
15
|
+
message: requestExceedsCap
|
|
16
|
+
? `This single response (~${tokens} tokens) is larger than the entire daily budget (${decision.limit}). Retrying won't help — narrow the query (smaller window / lower limit / more selective filter) or raise OMCP_TOOL_DAILY_TOKENS.`
|
|
17
|
+
: `Daily token budget exceeded (${decision.used}/${decision.limit} tokens used in the trailing 24h; this call would have added ~${tokens}). Try again in ~${Math.ceil(decision.retryAfterSeconds / 3600)}h or raise OMCP_TOOL_DAILY_TOKENS.`,
|
|
18
|
+
};
|
|
19
|
+
// Preserve any additional content entries (e.g. a future tool
|
|
20
|
+
// returning [text, image]) — only the text payload of the first
|
|
21
|
+
// entry is replaced with the error JSON; everything after passes
|
|
22
|
+
// through unchanged.
|
|
23
|
+
return {
|
|
24
|
+
...result,
|
|
25
|
+
content: [
|
|
26
|
+
{ ...result.content[0], text: JSON.stringify(errBody) },
|
|
27
|
+
...result.content.slice(1),
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { applyBudgetDecision } from "./charge.js";
|
|
4
|
+
function decision(over) {
|
|
5
|
+
return {
|
|
6
|
+
allowed: false,
|
|
7
|
+
used: 0,
|
|
8
|
+
limit: 1000,
|
|
9
|
+
retryAfterSeconds: 3600,
|
|
10
|
+
freedAtRetry: 100,
|
|
11
|
+
...over,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const sampleResult = () => ({ content: [{ text: "original tool output" }] });
|
|
15
|
+
test("applyBudgetDecision — passes the result through when allowed", () => {
|
|
16
|
+
const r = sampleResult();
|
|
17
|
+
const out = applyBudgetDecision(r, decision({ allowed: true }), 50, "query_logs");
|
|
18
|
+
assert.equal(out, r, "exact passthrough when allowed");
|
|
19
|
+
});
|
|
20
|
+
test("applyBudgetDecision — passes through when uncapped (limit === 0)", () => {
|
|
21
|
+
const r = sampleResult();
|
|
22
|
+
const out = applyBudgetDecision(r, decision({ allowed: false, limit: 0 }), 50_000, "query_logs");
|
|
23
|
+
assert.equal(out.content[0].text, "original tool output");
|
|
24
|
+
});
|
|
25
|
+
test("applyBudgetDecision — cumulative exceed emits OMCP_TOKEN_BUDGET_EXCEEDED", () => {
|
|
26
|
+
// Tokens fit a single request (<= limit) but cumulative pushes over.
|
|
27
|
+
const r = sampleResult();
|
|
28
|
+
const out = applyBudgetDecision(r, decision({ used: 950, limit: 1000, retryAfterSeconds: 7200, freedAtRetry: 200 }), 100, "query_logs");
|
|
29
|
+
const body = JSON.parse(out.content[0].text);
|
|
30
|
+
assert.equal(body.error, "OMCP_TOKEN_BUDGET_EXCEEDED");
|
|
31
|
+
assert.equal(body.tool, "query_logs");
|
|
32
|
+
assert.equal(body.used, 950);
|
|
33
|
+
assert.equal(body.limit, 1000);
|
|
34
|
+
assert.equal(body.requested, 100);
|
|
35
|
+
assert.equal(body.retryAfterSeconds, 7200);
|
|
36
|
+
assert.equal(body.freedAtRetry, 200);
|
|
37
|
+
assert.match(body.message, /Daily token budget exceeded/);
|
|
38
|
+
assert.match(body.message, /Try again in ~2h/);
|
|
39
|
+
});
|
|
40
|
+
test("applyBudgetDecision — single request > limit emits the DISTINCT OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET", () => {
|
|
41
|
+
// The whole point of the distinct code: an agent that sees this
|
|
42
|
+
// must NOT retry — waiting can never fit a request larger than the
|
|
43
|
+
// entire daily cap. retryAfterSeconds is forced to 0 so naive
|
|
44
|
+
// backoff loops terminate.
|
|
45
|
+
const r = sampleResult();
|
|
46
|
+
const out = applyBudgetDecision(r, decision({ used: 0, limit: 1000, retryAfterSeconds: 3600, freedAtRetry: 0 }), 5000, // request > limit
|
|
47
|
+
"query_metrics");
|
|
48
|
+
const body = JSON.parse(out.content[0].text);
|
|
49
|
+
assert.equal(body.error, "OMCP_TOKEN_REQUEST_EXCEEDS_BUDGET");
|
|
50
|
+
assert.equal(body.tool, "query_metrics");
|
|
51
|
+
assert.equal(body.requested, 5000);
|
|
52
|
+
assert.equal(body.limit, 1000);
|
|
53
|
+
assert.equal(body.retryAfterSeconds, 0, "retry-loop killer: 0 instead of inherited 3600");
|
|
54
|
+
assert.match(body.message, /larger than the entire daily budget/);
|
|
55
|
+
assert.match(body.message, /Retrying won't help/);
|
|
56
|
+
});
|
|
57
|
+
test("applyBudgetDecision — boundary: request == limit is NOT the request-exceeds-cap code", () => {
|
|
58
|
+
// A request exactly equal to the cap can theoretically succeed on
|
|
59
|
+
// an empty bucket — it's the cumulative-exceeded path, not the
|
|
60
|
+
// unconditional-deny path.
|
|
61
|
+
const r = sampleResult();
|
|
62
|
+
const out = applyBudgetDecision(r, decision({ used: 100, limit: 1000 }), 1000, "query_logs");
|
|
63
|
+
const body = JSON.parse(out.content[0].text);
|
|
64
|
+
assert.equal(body.error, "OMCP_TOKEN_BUDGET_EXCEEDED");
|
|
65
|
+
});
|
|
66
|
+
test("applyBudgetDecision — preserves additional content entries past the first", () => {
|
|
67
|
+
const r = {
|
|
68
|
+
content: [
|
|
69
|
+
{ text: "first", extraField: 42 },
|
|
70
|
+
{ text: "second" },
|
|
71
|
+
{ text: "third" },
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
const out = applyBudgetDecision(r, decision({}), 10, "t");
|
|
75
|
+
assert.equal(out.content.length, 3);
|
|
76
|
+
// First entry's text replaced; its other fields (extraField) preserved.
|
|
77
|
+
const body = JSON.parse(out.content[0].text);
|
|
78
|
+
assert.equal(body.error, "OMCP_TOKEN_BUDGET_EXCEEDED");
|
|
79
|
+
assert.equal(out.content[0].extraField, 42);
|
|
80
|
+
// Trailing entries pass through verbatim.
|
|
81
|
+
assert.equal(out.content[1].text, "second");
|
|
82
|
+
assert.equal(out.content[2].text, "third");
|
|
83
|
+
});
|
package/dist/quota/limiter.d.ts
CHANGED
|
@@ -25,19 +25,40 @@
|
|
|
25
25
|
* - unset / empty / non-numeric → DEFAULT_LIMIT_PER_MIN (60)
|
|
26
26
|
* - `"0"` → DEFAULT_LIMIT_PER_MIN (limit=0 would deny every request,
|
|
27
27
|
* which is almost never what an operator setting "0" wants — they
|
|
28
|
-
* either mean "default" or "disable";
|
|
29
|
-
*
|
|
28
|
+
* either mean "default" or "disable"; this function maps it to the
|
|
29
|
+
* default so they aren't accidentally locked out, and the explicit
|
|
30
|
+
* disable path is one of the UNLIMITED_TOKENS instead)
|
|
31
|
+
* - `"off"` / `"none"` / `"unlimited"` / `"disabled"` / `"false"`
|
|
32
|
+
* (case-insensitive) → Number.POSITIVE_INFINITY, which the
|
|
33
|
+
* `count >= limit` comparison in check() always allows. JSON
|
|
34
|
+
* serialisation renders Infinity as `null`; consumers can treat
|
|
35
|
+
* a null limit as "uncapped".
|
|
30
36
|
* - negative → DEFAULT_LIMIT_PER_MIN (limit=-1 with the current
|
|
31
37
|
* `count >= limit` check would also deny every request)
|
|
32
38
|
* - any positive integer ≥ 1 → that value
|
|
33
39
|
*/
|
|
34
40
|
export declare function resolveToolRatePerMin(raw: string | undefined): number;
|
|
35
41
|
export interface LimiterConfig {
|
|
36
|
-
/**
|
|
42
|
+
/** Default cap per identity per window. Defaults to 60. */
|
|
37
43
|
limit?: number;
|
|
38
44
|
/** Window length in milliseconds. Defaults to 60_000. */
|
|
39
45
|
windowMs?: number;
|
|
46
|
+
/** Optional per-identity override. Returns the cap for the named
|
|
47
|
+
* identity, or undefined to fall back to the default `limit`.
|
|
48
|
+
* Useful for the OMCP_KEY_RATE_PER_MIN credential-level override
|
|
49
|
+
* (`agent=600;ci=240`) — admin gives a noisy automation a higher
|
|
50
|
+
* quota without affecting every other caller. Returning Infinity
|
|
51
|
+
* disables the cap for that identity (matches the global
|
|
52
|
+
* unlimited-token contract). */
|
|
53
|
+
limitFor?: (identity: string) => number | undefined;
|
|
40
54
|
}
|
|
55
|
+
/** Parse OMCP_KEY_RATE_PER_MIN — `name=count;name2=count2`. Same
|
|
56
|
+
* shape as parseKeyTenants / parseKeyProducts so operators have one
|
|
57
|
+
* syntactic model across all per-credential overrides. Unknown
|
|
58
|
+
* counts (non-numeric / ≤ 0) silently skip. Magic disable tokens
|
|
59
|
+
* (off/none/unlimited/disabled/false) map to Infinity, same as the
|
|
60
|
+
* global OMCP_TOOL_RATE_PER_MIN. */
|
|
61
|
+
export declare function parseKeyRateLimits(raw: string | undefined): Map<string, number>;
|
|
41
62
|
export interface CheckResult {
|
|
42
63
|
/** True when the call is allowed (and the timestamp recorded). */
|
|
43
64
|
allowed: boolean;
|
|
@@ -52,10 +73,14 @@ export interface CheckResult {
|
|
|
52
73
|
retryAfterSeconds: number;
|
|
53
74
|
}
|
|
54
75
|
export declare class IdentityRateLimiter {
|
|
55
|
-
private readonly
|
|
76
|
+
private readonly defaultLimit;
|
|
56
77
|
private readonly windowMs;
|
|
78
|
+
private readonly limitFor?;
|
|
57
79
|
private readonly buckets;
|
|
58
80
|
constructor(cfg?: LimiterConfig);
|
|
81
|
+
/** Resolved cap for one identity: the per-identity override wins
|
|
82
|
+
* when defined; otherwise the process-wide default applies. */
|
|
83
|
+
private resolveLimit;
|
|
59
84
|
/** Record-and-test a call for the given identity. Returns the
|
|
60
85
|
* decision plus enough context to render a 429 with Retry-After. */
|
|
61
86
|
check(identity: string, now?: number): CheckResult;
|