@thotischner/observability-mcp 3.0.1 → 3.1.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 (46) hide show
  1. package/dist/analysis/history.d.ts +36 -2
  2. package/dist/analysis/history.js +60 -2
  3. package/dist/analysis/history.test.js +46 -0
  4. package/dist/auth/csrf.d.ts +6 -0
  5. package/dist/auth/csrf.js +4 -0
  6. package/dist/auth/csrf.test.js +22 -0
  7. package/dist/auth/lockout.d.ts +72 -0
  8. package/dist/auth/lockout.js +134 -0
  9. package/dist/auth/lockout.test.d.ts +1 -0
  10. package/dist/auth/lockout.test.js +133 -0
  11. package/dist/auth/middleware.d.ts +5 -0
  12. package/dist/auth/middleware.js +6 -1
  13. package/dist/auth/middleware.test.js +31 -0
  14. package/dist/auth/password-policy.d.ts +52 -0
  15. package/dist/auth/password-policy.js +125 -0
  16. package/dist/auth/password-policy.test.d.ts +1 -0
  17. package/dist/auth/password-policy.test.js +111 -0
  18. package/dist/auth/revocation.d.ts +93 -0
  19. package/dist/auth/revocation.js +193 -0
  20. package/dist/auth/revocation.test.d.ts +1 -0
  21. package/dist/auth/revocation.test.js +136 -0
  22. package/dist/auth/session.d.ts +7 -0
  23. package/dist/auth/session.js +6 -0
  24. package/dist/auth/session.test.js +21 -0
  25. package/dist/conformance/mcp-2025-11-25.test.js +14 -0
  26. package/dist/connectors/interface.d.ts +5 -1
  27. package/dist/connectors/loki.d.ts +45 -1
  28. package/dist/connectors/loki.js +141 -8
  29. package/dist/connectors/loki.test.js +171 -1
  30. package/dist/index.js +244 -4
  31. package/dist/openapi.js +39 -0
  32. package/dist/openapi.test.js +1 -0
  33. package/dist/security/csp.d.ts +64 -0
  34. package/dist/security/csp.js +135 -0
  35. package/dist/security/csp.test.d.ts +1 -0
  36. package/dist/security/csp.test.js +97 -0
  37. package/dist/tools/query-logs-schema.test.d.ts +1 -0
  38. package/dist/tools/query-logs-schema.test.js +38 -0
  39. package/dist/tools/query-logs.d.ts +40 -0
  40. package/dist/tools/query-logs.js +69 -3
  41. package/dist/tools/validation.d.ts +13 -0
  42. package/dist/tools/validation.js +74 -0
  43. package/dist/tools/validation.test.js +54 -1
  44. package/dist/types.d.ts +48 -0
  45. package/dist/ui/index.html +42 -15
  46. package/package.json +1 -1
@@ -1,7 +1,177 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { LokiConnector } from "./loki.js";
3
+ import { LokiConnector, logqlLabelFilters, levelFromStatus, escapeLogQLValue, buildAggregateLogQL, parseDurationSeconds, defaultBucketSeconds, } from "./loki.js";
4
4
  const proto = LokiConnector.prototype;
5
+ function jsonRes(obj) {
6
+ return { ok: true, status: 200, statusText: "OK", json: async () => obj, text: async () => "" };
7
+ }
8
+ describe("Q-LOG1: logqlLabelFilters", () => {
9
+ it("returns empty string for undefined / empty", () => {
10
+ assert.equal(logqlLabelFilters(undefined), "");
11
+ assert.equal(logqlLabelFilters({}), "");
12
+ });
13
+ it("compiles a single filter", () => {
14
+ assert.equal(logqlLabelFilters({ method: "GET" }), ' | method="GET"');
15
+ });
16
+ it("compiles multiple filters, keys sorted for determinism", () => {
17
+ assert.equal(logqlLabelFilters({ status: "200", method: "GET", url: "/" }), ' | method="GET" | status="200" | url="/"');
18
+ });
19
+ it("escapes double quotes and backslashes in values", () => {
20
+ assert.equal(logqlLabelFilters({ path: 'a"b\\c' }), ' | path="a\\"b\\\\c"');
21
+ });
22
+ });
23
+ describe("Q-LOG1: levelFromStatus", () => {
24
+ it("maps 5xx → error", () => {
25
+ assert.equal(levelFromStatus(500), "error");
26
+ assert.equal(levelFromStatus("503"), "error");
27
+ assert.equal(levelFromStatus(599), "error");
28
+ });
29
+ it("maps 4xx → warn", () => {
30
+ assert.equal(levelFromStatus(404), "warn");
31
+ assert.equal(levelFromStatus("400"), "warn");
32
+ });
33
+ it("returns undefined for 2xx/3xx and non-numeric", () => {
34
+ assert.equal(levelFromStatus(200), undefined);
35
+ assert.equal(levelFromStatus(301), undefined);
36
+ assert.equal(levelFromStatus("abc"), undefined);
37
+ assert.equal(levelFromStatus(undefined), undefined);
38
+ });
39
+ });
40
+ describe("Q-LOG1: escapeLogQLValue", () => {
41
+ it("escapes backslash then quote", () => {
42
+ assert.equal(escapeLogQLValue('he said "hi"\\'), 'he said \\"hi\\"\\\\');
43
+ });
44
+ it("escapes control chars (newline/return/tab) into LogQL escape sequences", () => {
45
+ assert.equal(escapeLogQLValue("a\nb\rc\td"), "a\\nb\\rc\\td");
46
+ });
47
+ });
48
+ describe("Q-LOG1: queryLogs LogQL assembly", () => {
49
+ async function captureQuery(params) {
50
+ const conn = new LokiConnector();
51
+ await conn.connect({ name: "loki", type: "loki", url: "http://loki:3100", enabled: true });
52
+ let captured = "";
53
+ const orig = globalThis.fetch;
54
+ globalThis.fetch = (async (url) => {
55
+ const u = String(url);
56
+ if (u.includes("/label/") && u.includes("/values"))
57
+ return jsonRes({ data: ["payment"] });
58
+ if (u.includes("/query_range")) {
59
+ captured = decodeURIComponent((u.match(/query=([^&]+)/) || [])[1] || "");
60
+ return jsonRes({ data: { result: [] } });
61
+ }
62
+ return jsonRes({ data: [] });
63
+ });
64
+ try {
65
+ await conn.queryLogs({ service: "payment", duration: "5m", ...params });
66
+ }
67
+ finally {
68
+ globalThis.fetch = orig;
69
+ }
70
+ return captured;
71
+ }
72
+ it("AND's label filters after | json, with level and line filter", async () => {
73
+ const q = await captureQuery({ level: "error", labels: { method: "GET", status: "200" }, query: "timeout" });
74
+ assert.equal(q, '{service_name="payment"} | json | level="error" | method="GET" | status="200" |~ `timeout`');
75
+ });
76
+ it("works with labels only (no level/query)", async () => {
77
+ const q = await captureQuery({ labels: { environment: "prod" } });
78
+ assert.equal(q, '{service_name="payment"} | json | environment="prod"');
79
+ });
80
+ it("plain query (no labels) is unchanged from prior behaviour", async () => {
81
+ const q = await captureQuery({});
82
+ assert.equal(q, '{service_name="payment"} | json');
83
+ });
84
+ });
85
+ describe("Q-LOG2: parseDurationSeconds / defaultBucketSeconds", () => {
86
+ it("parses m/h/d", () => {
87
+ assert.equal(parseDurationSeconds("5m"), 300);
88
+ assert.equal(parseDurationSeconds("2h"), 7200);
89
+ assert.equal(parseDurationSeconds("1d"), 86400);
90
+ assert.equal(parseDurationSeconds("bad"), null);
91
+ });
92
+ it("buckets to ~60 points, floored at 60s", () => {
93
+ assert.equal(defaultBucketSeconds(3600), 60); // 1h → 60s
94
+ assert.equal(defaultBucketSeconds(86400), 1440); // 24h → 1440s
95
+ assert.equal(defaultBucketSeconds(60), 60); // tiny window floors at 60s
96
+ });
97
+ });
98
+ describe("Q-LOG2: buildAggregateLogQL", () => {
99
+ const PIPE = '{service_name="app"} | json | method="GET"';
100
+ it("count_over_time with by → sum by + range mode + step", () => {
101
+ const r = buildAggregateLogQL(PIPE, { op: "count_over_time", by: ["url"], step: "15m" }, "1h");
102
+ assert.equal(r.mode, "range");
103
+ assert.equal(r.step, "900s");
104
+ assert.equal(r.logql, `sum by (url) (count_over_time(${PIPE} [900s]))`);
105
+ });
106
+ it("count_over_time without by → bare count_over_time, default step", () => {
107
+ const r = buildAggregateLogQL(PIPE, { op: "count_over_time" }, "1h");
108
+ assert.equal(r.mode, "range");
109
+ assert.equal(r.step, "60s");
110
+ assert.equal(r.logql, `count_over_time(${PIPE} [60s])`);
111
+ });
112
+ it("sum → instant total per group over the whole window", () => {
113
+ const r = buildAggregateLogQL(PIPE, { op: "sum", by: ["status"] }, "1h");
114
+ assert.equal(r.mode, "instant");
115
+ assert.equal(r.logql, `sum by (status) (count_over_time(${PIPE} [3600s]))`);
116
+ });
117
+ it("topk → instant topk(k, sum by) with default k=10", () => {
118
+ const r = buildAggregateLogQL(PIPE, { op: "topk", by: ["url"] }, "1h");
119
+ assert.equal(r.mode, "instant");
120
+ assert.equal(r.logql, `topk(10, sum by (url) (count_over_time(${PIPE} [3600s])))`);
121
+ });
122
+ it("topk honours explicit k", () => {
123
+ const r = buildAggregateLogQL(PIPE, { op: "topk", by: ["url"], k: 3 }, "30m");
124
+ assert.equal(r.logql, `topk(3, sum by (url) (count_over_time(${PIPE} [1800s])))`);
125
+ });
126
+ });
127
+ describe("Q-LOG2: queryLogAggregate", () => {
128
+ async function run(agg) {
129
+ const conn = new LokiConnector();
130
+ await conn.connect({ name: "loki", type: "loki", url: "http://loki:3100", enabled: true });
131
+ let capturedUrl = "";
132
+ const orig = globalThis.fetch;
133
+ globalThis.fetch = (async (url) => {
134
+ const u = String(url);
135
+ if (u.includes("/label/") && u.includes("/values"))
136
+ return jsonRes({ data: ["app"] });
137
+ if (u.includes("/query_range")) {
138
+ capturedUrl = u;
139
+ return jsonRes({ data: { resultType: "matrix", result: [
140
+ { metric: { url: "/" }, values: [[1000, "3"], [1060, "5"]] },
141
+ ] } });
142
+ }
143
+ if (u.includes("/query")) {
144
+ capturedUrl = u;
145
+ return jsonRes({ data: { resultType: "vector", result: [
146
+ { metric: { url: "/a" }, value: [2000, "7"] },
147
+ { metric: { url: "/b" }, value: [2000, "12"] },
148
+ ] } });
149
+ }
150
+ return jsonRes({ data: [] });
151
+ });
152
+ try {
153
+ return await conn.queryLogAggregate({ service: "app", duration: "1h", ...agg });
154
+ }
155
+ finally {
156
+ globalThis.fetch = orig;
157
+ }
158
+ }
159
+ it("topk → instant vector parsed + sorted desc, note set", async () => {
160
+ const res = await run({ op: "topk", by: ["url"], k: 2 });
161
+ assert.equal(res.mode, "instant");
162
+ assert.equal(res.op, "topk");
163
+ assert.deepEqual(res.by, ["url"]);
164
+ assert.deepEqual(res.series.map((s) => [s.labels.url, s.value]), [["/b", 12], ["/a", 7]]);
165
+ assert.match(res.note, /limit/);
166
+ });
167
+ it("count_over_time → range matrix parsed into points", async () => {
168
+ const res = await run({ op: "count_over_time", by: ["url"], step: "1m" });
169
+ assert.equal(res.mode, "range");
170
+ assert.equal(res.step, "60s");
171
+ assert.equal(res.series.length, 1);
172
+ assert.deepEqual(res.series[0].points, [{ t: 1000000, value: 3 }, { t: 1060000, value: 5 }]);
173
+ });
174
+ });
5
175
  describe("LokiConnector", () => {
6
176
  describe("parseLine", () => {
7
177
  it("parses valid JSON", () => {
package/dist/index.js CHANGED
@@ -19,6 +19,10 @@ import { buildSessionAttacher, buildRequireSession, } from "./auth/middleware.js
19
19
  import { buildRequirePermissionFromEngine, hasPermission, listGrantedPermissions, DEFAULT_POLICY, } from "./auth/rbac.js";
20
20
  import { resolveOidcConfig, buildOidcRuntime } from "./auth/oidc/runtime.js";
21
21
  import { registerOidcRoutes } from "./auth/oidc/endpoints.js";
22
+ import { RevocationStore } from "./auth/revocation.js";
23
+ import { AccountLockout, lockoutConfigFromEnv, lockoutDisabledFromEnv, } from "./auth/lockout.js";
24
+ import { resolveSessionStore } from "./transport/sessionStore.js";
25
+ import { generateNonce, enforcedCsp, reportOnlyCsp, reportingEndpointsHeader, reportToHeader, summariseViolation, cspStrictReportFromEnv, CSP_NONCE_PLACEHOLDER, } from "./security/csp.js";
22
26
  import { createScimStore } from "./scim/store.js";
23
27
  import { registerScimRoutes } from "./scim/routes.js";
24
28
  import { BuiltinPolicyEngine } from "./auth/policy/engine.js";
@@ -433,6 +437,10 @@ async function main() {
433
437
  .string()
434
438
  .optional()
435
439
  .describe("Optional. Filter expression matched against the log message; regular expressions are supported. Omit to return all entries in the window."),
440
+ labels: z
441
+ .record(z.string(), z.string())
442
+ .optional()
443
+ .describe("Optional. Exact-match filters on backend-extracted log fields (e.g. {\"method\":\"GET\",\"status\":\"200\",\"url\":\"/\",\"environment\":\"prod\"}). All AND'd together and compiled to LogQL label filters applied after `| json`, so structured JSON fields become first-class selectors — far more reliable than regex on the raw message. Combine with `aggregate` to filter then group. Backends without label extraction ignore it."),
436
444
  duration: z
437
445
  .string()
438
446
  .optional()
@@ -441,12 +449,34 @@ async function main() {
441
449
  .enum(["error", "warn", "info", "debug"])
442
450
  .optional()
443
451
  .describe("Optional. Return only entries at this severity. Default: all levels."),
452
+ aggregate: z
453
+ .object({
454
+ op: z
455
+ .enum(["count_over_time", "sum", "topk"])
456
+ .describe("count_over_time = time series of counts per `step` bucket; sum = single total per group over the window; topk = the top `k` groups by total."),
457
+ by: z
458
+ .array(z.string())
459
+ .optional()
460
+ .describe("Label names to group by, e.g. [\"url\"] or [\"status\"]. Required for topk."),
461
+ k: z
462
+ .number()
463
+ .int()
464
+ .positive()
465
+ .optional()
466
+ .describe("For topk: how many top groups to return (1-1000)."),
467
+ step: z
468
+ .string()
469
+ .optional()
470
+ .describe("For count_over_time: bucket width as <number><unit> m|h|d (e.g. '15m'). Default auto-derived from duration."),
471
+ })
472
+ .optional()
473
+ .describe("Optional. Server-side aggregation pushed down to LogQL metric queries — returns grouped counts, not raw rows, so you get a number instead of a haystack (and never hit `limit`). Honours `labels`/`query` filters. Example: {\"op\":\"topk\",\"by\":[\"url\"],\"k\":10} for the busiest paths; {\"op\":\"count_over_time\",\"step\":\"15m\"} for a request-count time series."),
444
474
  limit: z
445
475
  .number()
446
476
  .int()
447
477
  .positive()
448
478
  .optional()
449
- .describe("Optional. Maximum number of log entries to return (most recent first). Default: 100."),
479
+ .describe("Optional. Maximum number of log entries to return (most recent first). Default: 100. Ignored when `aggregate` is set."),
450
480
  bypass_redaction: z
451
481
  .boolean()
452
482
  .optional()
@@ -723,7 +753,19 @@ async function main() {
723
753
  else if (requestedAuthMode !== "anonymous") {
724
754
  authMisconfig(`unknown OMCP_AUTH=${requestedAuthMode}`);
725
755
  }
726
- const authRuntime = { mode: authMode, session: sessionCfg, secretEphemeral, oidc: oidcRuntime };
756
+ // Session revocation blocklist (Q17). Only meaningful when sessions
757
+ // exist (basic / oidc); anonymous mode leaves it undefined so the
758
+ // middleware check is a pure no-op. OMCP_AUTH_REVOCATION_FILE persists
759
+ // the blocklist across restarts and shares it across replicas when it
760
+ // points at shared storage; unset = in-memory only.
761
+ let revocationStore;
762
+ if (authMode !== "anonymous") {
763
+ revocationStore = await RevocationStore.create({
764
+ path: process.env.OMCP_AUTH_REVOCATION_FILE?.trim() || undefined,
765
+ });
766
+ console.log(`[auth] session revocation blocklist active — backend=${revocationStore.persistent ? `file (${revocationStore.filePath})` : "memory"}, ${revocationStore.size} existing entr${revocationStore.size === 1 ? "y" : "ies"}`);
767
+ }
768
+ const authRuntime = { mode: authMode, session: sessionCfg, secretEphemeral, oidc: oidcRuntime, revocation: revocationStore };
727
769
  // --- HTTP server ---
728
770
  const app = express();
729
771
  // Trust-proxy: when set, Express will read req.ip / req.secure from
@@ -757,13 +799,38 @@ async function main() {
757
799
  // without the wildcard the body silently arrives empty and every
758
800
  // SCIM POST/PATCH 400s. The wildcard also future-proofs other
759
801
  // structured-suffix JSON content types.
760
- app.use(express.json({ limit: "1mb", type: ["application/json", "application/*+json"] }));
802
+ // application/csp-report is the legacy media type browsers use for CSP
803
+ // violation reports (the modern Reporting API uses application/reports+json,
804
+ // already covered by the wildcard). Without it the report body arrives empty.
805
+ app.use(express.json({ limit: "1mb", type: ["application/json", "application/*+json", "application/csp-report"] }));
806
+ // Q20 — resolve the opt-in strict Report-Only CSP toggle once at boot.
807
+ // Default off: with ~200 inline handlers the report-only policy would
808
+ // emit a [Report Only] console message per handler on every page load.
809
+ const cspStrictReport = cspStrictReportFromEnv();
810
+ if (cspStrictReport) {
811
+ console.log("[csp] strict report-only policy ON (OMCP_CSP_STRICT_REPORT) — inline-handler violations will be reported to /api/csp-violations");
812
+ }
761
813
  // Security headers
762
814
  app.use((req, res, next) => {
763
815
  res.setHeader("X-Content-Type-Options", "nosniff");
764
816
  res.setHeader("X-Frame-Options", "DENY");
765
817
  res.setHeader("X-XSS-Protection", "1; mode=block");
766
818
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
819
+ // Q20 — Content-Security-Policy. A per-request nonce is minted and
820
+ // stashed on res.locals so the UI handler can stamp it into the two
821
+ // inline <script> blocks. The enforced policy keeps the UI working
822
+ // (script-src 'unsafe-inline' for the ~200 inline handlers) and is
823
+ // always on; the strict report-only policy is opt-in (it surfaces the
824
+ // inline-handler debt but is console-noisy). Both report to
825
+ // /api/csp-violations.
826
+ const nonce = generateNonce();
827
+ res.locals.cspNonce = nonce;
828
+ res.setHeader("Content-Security-Policy", enforcedCsp());
829
+ if (cspStrictReport) {
830
+ res.setHeader("Content-Security-Policy-Report-Only", reportOnlyCsp(nonce));
831
+ }
832
+ res.setHeader("Reporting-Endpoints", reportingEndpointsHeader());
833
+ res.setHeader("Report-To", reportToHeader());
767
834
  // Dynamic API responses must never be served from the browser/proxy
768
835
  // cache: after a mutation (e.g. installing a connector) the UI
769
836
  // re-fetches these GETs immediately, and a heuristically-cached stale
@@ -814,6 +881,11 @@ async function main() {
814
881
  const csrfCfg = {
815
882
  bypassBearer: csrfBypassFromEnv(),
816
883
  secureCookie: (r) => r.secure || r.headers["x-forwarded-proto"] === "https",
884
+ // CSP violation reports are unauthenticated browser POSTs that by
885
+ // construction carry no cookie + no custom header — exempt them from
886
+ // CSRF. The endpoint only records a sanitised summary, so accepting it
887
+ // cross-site is harmless.
888
+ skip: (r) => r.method === "POST" && (r.path === "/api/csp-violations" || r.originalUrl.split("?")[0] === "/api/csp-violations"),
817
889
  };
818
890
  app.use(buildCsrfIssuer(csrfCfg));
819
891
  app.use("/api", buildCsrfEnforcer(csrfCfg));
@@ -944,6 +1016,36 @@ async function main() {
944
1016
  .catch((err) => console.warn("AuditLog flushSinks failed:", err));
945
1017
  });
946
1018
  const audit = (resource, action) => buildAuditMiddleware({ audit: mgmtAudit, resource, action });
1019
+ // Q20 — CSP violation report sink. Unauthenticated browser POST (exempt
1020
+ // from CSRF via csrfCfg.skip), tightly rate-limited so a misbehaving or
1021
+ // hostile client can't flood the audit log, and only a sanitised summary
1022
+ // (directive / blocked-uri / document-uri) is recorded. Always 204 so the
1023
+ // browser never retries. The report-only strict policy is what drives most
1024
+ // of these today (the inline-handler debt) — they roll into mgmtAudit so an
1025
+ // operator can watch the migration surface shrink.
1026
+ const cspReportRateLimit = rateLimit({
1027
+ windowMs: 60_000,
1028
+ max: 60,
1029
+ standardHeaders: true,
1030
+ legacyHeaders: false,
1031
+ message: { error: "rate limited" },
1032
+ });
1033
+ app.post("/api/csp-violations", cspReportRateLimit, (req, res) => {
1034
+ const summary = summariseViolation(req.body);
1035
+ if (summary) {
1036
+ void mgmtAudit.record({
1037
+ actor: { sub: "browser:csp" },
1038
+ tenant: "default",
1039
+ resource: "settings",
1040
+ action: "read",
1041
+ method: "POST",
1042
+ path: "/api/csp-violations",
1043
+ status: 204,
1044
+ target: `${summary.directive} blocked ${summary.blockedUri}`.slice(0, 256),
1045
+ }).catch(() => { });
1046
+ }
1047
+ res.status(204).end();
1048
+ });
947
1049
  // Plugin lifecycle hook registry — populated by the loader at boot
948
1050
  // (one entry per manifest `hooks[]` entry) and mutable at runtime
949
1051
  // when a connector is installed via /api/connectors/install. Each
@@ -1106,7 +1208,29 @@ async function main() {
1106
1208
  res.end(await selfRegistry.metrics());
1107
1209
  });
1108
1210
  }
1109
- // Serve Web UI
1211
+ // Serve Web UI. The index page is served dynamically so the per-request
1212
+ // CSP nonce can be stamped into its inline <script> blocks (the rest of
1213
+ // ui/ stays on express.static). Read once at boot; if the file is
1214
+ // missing we fall through to static, which 404s like before.
1215
+ let uiHtmlTemplate = null;
1216
+ try {
1217
+ uiHtmlTemplate = readFileSync(join(__dirname, "ui", "index.html"), "utf8");
1218
+ }
1219
+ catch {
1220
+ uiHtmlTemplate = null;
1221
+ }
1222
+ if (uiHtmlTemplate) {
1223
+ const template = uiHtmlTemplate;
1224
+ const serveIndex = (_req, res) => {
1225
+ const nonce = res.locals.cspNonce ?? "";
1226
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
1227
+ // Index is identity/nonce-specific — never let a proxy cache it.
1228
+ res.setHeader("Cache-Control", "no-store");
1229
+ res.send(template.split(CSP_NONCE_PLACEHOLDER).join(nonce));
1230
+ };
1231
+ app.get("/", serveIndex);
1232
+ app.get("/index.html", serveIndex);
1233
+ }
1110
1234
  app.use(express.static(join(__dirname, "ui")));
1111
1235
  // --- API endpoints for Web UI ---
1112
1236
  // List sources with health status — tenant-scoped.
@@ -1286,6 +1410,10 @@ async function main() {
1286
1410
  },
1287
1411
  permissions: listGrantedPermissions(sess.roles, policyEngineToMap(policyEngine)),
1288
1412
  exp: sess.exp,
1413
+ // The current session's revocation id. Surfaced so an admin can
1414
+ // copy it into POST /api/auth/revocations to kill a specific
1415
+ // session. Absent for legacy cookies issued before sid existed.
1416
+ sid: sess.sid,
1289
1417
  // When the user signed in via OIDC, surface the IdP issuer
1290
1418
  // URL so the UI can render an appropriate badge or link to
1291
1419
  // an IdP-side profile page. Empty / absent in basic mode.
@@ -1810,6 +1938,19 @@ async function main() {
1810
1938
  catch { /* ignore — first login will pick it up */ }
1811
1939
  }
1812
1940
  }
1941
+ // Q18 — per-username failed-login lockout with progressive backoff.
1942
+ // Complements the per-IP loginRateLimit above: that bounds a noisy
1943
+ // single source, this bounds a slow / distributed grind on one
1944
+ // account. Backed by the shared SessionStore so a Redis deployment
1945
+ // locks consistently across replicas (and self-cleans via TTL).
1946
+ // Basic mode only — OIDC delegates auth (and lockout) to the IdP.
1947
+ let lockout;
1948
+ if (authRuntime.mode === "basic" && !lockoutDisabledFromEnv()) {
1949
+ const lockoutStore = await resolveSessionStore();
1950
+ const lockoutCfg = lockoutConfigFromEnv();
1951
+ lockout = new AccountLockout(lockoutStore, lockoutCfg);
1952
+ console.log(`[auth] account lockout active — ${lockoutCfg.maxFailures} failures / ${lockoutCfg.windowSeconds}s → lock ${lockoutCfg.baseLockSeconds}s (×2 up to ${lockoutCfg.maxLockSeconds}s), backend=${lockoutStore.backend}`);
1953
+ }
1813
1954
  app.post("/api/auth/login", loginRateLimit, async (req, res) => {
1814
1955
  if (authRuntime.mode !== "basic" || !sessionCfg || !usersStore) {
1815
1956
  res.status(503).json({ error: "auth mode does not accept logins" });
@@ -1823,11 +1964,57 @@ async function main() {
1823
1964
  res.status(400).json({ error: "username and password are required" });
1824
1965
  return;
1825
1966
  }
1967
+ // Gate on the lock BEFORE the (expensive) scrypt verify so a locked
1968
+ // account can't be used to burn CPU. A locked account is a 429 with
1969
+ // Retry-After, never a credential oracle — the response is identical
1970
+ // whether or not the username exists.
1971
+ if (lockout) {
1972
+ const status = await lockout.check(username);
1973
+ if (status.locked) {
1974
+ res.setHeader("Retry-After", String(status.retryAfterSeconds ?? 0));
1975
+ res.status(429).json({
1976
+ error: "account temporarily locked due to repeated failed logins",
1977
+ retryAfterSeconds: status.retryAfterSeconds,
1978
+ });
1979
+ void mgmtAudit.record({
1980
+ actor: { sub: username },
1981
+ tenant: "default",
1982
+ resource: "users",
1983
+ action: "write",
1984
+ method: "POST",
1985
+ path: "/api/auth/login",
1986
+ status: 429,
1987
+ }).catch(() => { });
1988
+ return;
1989
+ }
1990
+ }
1826
1991
  const user = authenticate(username, password, usersStore);
1827
1992
  if (!user) {
1993
+ if (lockout) {
1994
+ const after = await lockout.recordFailure(username);
1995
+ if (after.locked) {
1996
+ res.setHeader("Retry-After", String(after.retryAfterSeconds ?? 0));
1997
+ res.status(429).json({
1998
+ error: "account temporarily locked due to repeated failed logins",
1999
+ retryAfterSeconds: after.retryAfterSeconds,
2000
+ });
2001
+ void mgmtAudit.record({
2002
+ actor: { sub: username },
2003
+ tenant: "default",
2004
+ resource: "users",
2005
+ action: "write",
2006
+ method: "POST",
2007
+ path: "/api/auth/login",
2008
+ status: 429,
2009
+ }).catch(() => { });
2010
+ return;
2011
+ }
2012
+ }
1828
2013
  res.status(401).json({ error: "invalid credentials" });
1829
2014
  return;
1830
2015
  }
2016
+ if (lockout)
2017
+ await lockout.recordSuccess(user.username);
1831
2018
  const { cookie } = issueSession({ sub: user.username, name: user.name, roles: user.roles, tenant: user.tenant }, sessionCfg);
1832
2019
  const secure = req.secure || (req.headers["x-forwarded-proto"] === "https");
1833
2020
  res.setHeader("Set-Cookie", setCookieHeader(cookie, sessionCfg, { secure }));
@@ -1855,6 +2042,34 @@ async function main() {
1855
2042
  registerOidcRoutes(app, { sessionCfg, oidc: oidcRuntime });
1856
2043
  console.log("[auth] OIDC endpoints registered: /api/auth/oidc/{login,callback,logout}");
1857
2044
  }
2045
+ // Q17 — session revocation blocklist. Admin-gated (same role tier as
2046
+ // user/role management). A revoked-but-unexpired cookie is rejected by
2047
+ // buildSessionAttacher on the next request. Revoke a single session by
2048
+ // `sid` (read it from /api/me or the audit log) or every current
2049
+ // session for a `sub` ("log this user out everywhere"). The blocklist
2050
+ // is the stateful complement to the otherwise-stateless cookie.
2051
+ app.post("/api/auth/revocations", need("users", "delete"), audit("users", "write"), async (req, res) => {
2052
+ if (!revocationStore) {
2053
+ res.status(503).json({ error: "revocation requires an auth mode (basic|oidc)" });
2054
+ return;
2055
+ }
2056
+ const body = (req.body || {});
2057
+ const sid = typeof body.sid === "string" && body.sid.trim() ? body.sid.trim() : undefined;
2058
+ const sub = typeof body.sub === "string" && body.sub.trim() ? body.sub.trim() : undefined;
2059
+ const reason = typeof body.reason === "string" ? body.reason.slice(0, 500) : undefined;
2060
+ if ((sid ? 1 : 0) + (sub ? 1 : 0) !== 1) {
2061
+ res.status(400).json({ error: "exactly one of `sid` or `sub` is required" });
2062
+ return;
2063
+ }
2064
+ const by = req.session?.sub;
2065
+ const entry = sid
2066
+ ? await revocationStore.revokeSession(sid, { reason, by })
2067
+ : await revocationStore.revokeSubject(sub, { reason, by });
2068
+ res.status(201).json({ ok: true, revocation: entry });
2069
+ });
2070
+ app.get("/api/auth/revocations", need("users", "delete"), (_req, res) => {
2071
+ res.json({ revocations: revocationStore ? revocationStore.list() : [] });
2072
+ });
1858
2073
  // Phase F21 / Q6: SCIM 2.0 — opt-in. OMCP_SCIM_TOKEN gates access.
1859
2074
  // The store backend is chosen by createScimStore from
1860
2075
  // OMCP_SCIM_BACKEND (file | redis). file (default) → OMCP_SCIM_STORE
@@ -2618,6 +2833,31 @@ async function main() {
2618
2833
  tools: filteredTools,
2619
2834
  });
2620
2835
  });
2836
+ // Q21 — per-service anomaly-score sparklines for the Health tab. Reads
2837
+ // the in-process ring of the anomaly-history sink (last hour), tenant-
2838
+ // scoped. MUST be registered before "/api/health/:service" so the
2839
+ // literal path isn't captured as a service name. `enabled` is true once
2840
+ // any score exists; the UI falls back to its client-side trend otherwise.
2841
+ app.get("/api/health/anomaly-sparklines", (req, res) => {
2842
+ const sess = req.session;
2843
+ const callerTenant = sess?.tenant || "default";
2844
+ // Anonymous (single-tenant) mode: no tenant filter, see everything.
2845
+ const tenant = sess ? callerTenant : undefined;
2846
+ const records = anomalyHistory.recent({ tenant });
2847
+ const series = {};
2848
+ for (const r of records) {
2849
+ const t = Date.parse(r.ts);
2850
+ if (!Number.isFinite(t))
2851
+ continue;
2852
+ (series[r.service] ??= []).push({ t, score: r.score });
2853
+ }
2854
+ res.json({
2855
+ enabled: records.length > 0,
2856
+ remoteWrite: anomalyHistory.isEnabled(),
2857
+ windowMs: anomalyHistory.windowMs,
2858
+ series,
2859
+ });
2860
+ });
2621
2861
  // Health endpoint for UI dashboard
2622
2862
  app.get("/api/health/:service", async (req, res) => {
2623
2863
  try {
package/dist/openapi.js CHANGED
@@ -394,6 +394,45 @@ export function buildOpenApiSpec(version) {
394
394
  responses: { "204": { description: "Cookie cleared." } },
395
395
  },
396
396
  },
397
+ "/api/auth/revocations": {
398
+ get: {
399
+ tags: ["auth"],
400
+ summary: "List session revocations (admin).",
401
+ description: "Returns the current revocation blocklist. Admin-gated (users:delete). Empty in anonymous mode.",
402
+ responses: {
403
+ "200": { description: "Array of revocation entries." },
404
+ "401": { description: "Authentication required." },
405
+ "403": { description: "Caller lacks the admin permission." },
406
+ },
407
+ },
408
+ post: {
409
+ tags: ["auth"],
410
+ summary: "Revoke a session or all of a subject's sessions (admin).",
411
+ description: "Adds an entry to the on-disk blocklist. Provide exactly one of `sid` (revoke one session — copy it from /api/me) or `sub` (log a user out everywhere — revokes every session issued so far; a fresh login afterwards is unaffected). The next request bearing a revoked cookie is treated as logged out.",
412
+ requestBody: {
413
+ required: true,
414
+ content: {
415
+ "application/json": {
416
+ schema: {
417
+ type: "object",
418
+ properties: {
419
+ sid: { type: "string", description: "Session id to revoke (from /api/me)." },
420
+ sub: { type: "string", description: "Subject whose current sessions to revoke." },
421
+ reason: { type: "string", description: "Optional free-text reason (truncated to 500 chars)." },
422
+ },
423
+ },
424
+ },
425
+ },
426
+ },
427
+ responses: {
428
+ "201": { description: "Revocation recorded; the entry is returned." },
429
+ "400": { description: "Neither or both of sid/sub supplied." },
430
+ "401": { description: "Authentication required." },
431
+ "403": { description: "Caller lacks the admin permission." },
432
+ "503": { description: "Server is in anonymous mode (no sessions to revoke)." },
433
+ },
434
+ },
435
+ },
397
436
  "/api/auth/oidc/login": {
398
437
  get: {
399
438
  tags: ["auth"],
@@ -19,6 +19,7 @@ test("openapi — every user-visible /api path is documented", () => {
19
19
  "/api/me",
20
20
  "/api/auth/login",
21
21
  "/api/auth/logout",
22
+ "/api/auth/revocations",
22
23
  "/api/auth/oidc/login",
23
24
  "/api/auth/oidc/callback",
24
25
  "/api/auth/oidc/logout",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Content-Security-Policy for the management-plane Web UI.
3
+ *
4
+ * Two policies ship together, by design:
5
+ *
6
+ * - **Enforced** (`Content-Security-Policy`): a real, non-breaking policy.
7
+ * It locks down everything the UI doesn't need — no remote scripts
8
+ * (`script-src 'self'`), no plugins (`object-src 'none'`), no `<base>`
9
+ * hijack (`base-uri 'self'`), no framing (`frame-ancestors 'none'`),
10
+ * and same-origin-only XHR via `connect-src 'self'`. It keeps
11
+ * `'unsafe-inline'` for `script-src` because the single-file UI uses
12
+ * ~200 inline event-handler attributes (`onclick=`, …) that a nonce
13
+ * cannot cover — a nonce in `script-src` would *disable* `'unsafe-inline'`
14
+ * in CSP3 and break every button. So the enforced policy is a genuine
15
+ * improvement over no CSP without regressing the UI.
16
+ *
17
+ * - **Report-Only** (`Content-Security-Policy-Report-Only`): the strict
18
+ * target policy — `script-src 'self' 'nonce-…'`, no `'unsafe-inline'`.
19
+ * The two legitimate inline `<script>` blocks carry the per-request
20
+ * nonce, so this policy flags ONLY the inline event-handler debt. It
21
+ * blocks nothing; it just reports, giving an actionable migration list
22
+ * (move the handlers to addEventListener) before a future slice can
23
+ * promote the strict policy to enforced.
24
+ *
25
+ * It is **opt-in** (`OMCP_CSP_STRICT_REPORT=true`): with ~200 inline
26
+ * handlers it would otherwise emit a `[Report Only]` console message
27
+ * per handler on every page load — noise an operator with devtools
28
+ * open shouldn't eat by default. Enable it when you're actively
29
+ * working the migration. The enforced policy + reporting endpoint are
30
+ * always on regardless.
31
+ *
32
+ * Both policies report to `/api/csp-violations` via the modern Reporting
33
+ * API (`Reporting-Endpoints` + `report-to`) and the legacy `report-uri`.
34
+ */
35
+ /** Placeholder substituted with the per-request nonce when serving the UI HTML. */
36
+ export declare const CSP_NONCE_PLACEHOLDER = "__CSP_NONCE__";
37
+ /** The named reporting group used in the Report-To / Reporting-Endpoints headers. */
38
+ export declare const CSP_REPORT_GROUP = "omcp-csp";
39
+ /** Where violation reports are POSTed. */
40
+ export declare const CSP_REPORT_PATH = "/api/csp-violations";
41
+ /** Fresh base64 nonce (128 bits). */
42
+ export declare function generateNonce(): string;
43
+ /** The enforced policy — non-breaking, keeps the UI working. */
44
+ export declare function enforcedCsp(): string;
45
+ /** The strict target policy, run in report-only mode against the nonce. */
46
+ export declare function reportOnlyCsp(nonce: string): string;
47
+ /** Whether the strict Report-Only policy is enabled. Default off — see
48
+ * the module header for why (console noise from ~200 inline handlers). */
49
+ export declare function cspStrictReportFromEnv(env?: NodeJS.ProcessEnv): boolean;
50
+ /** Value for the modern `Reporting-Endpoints` header. */
51
+ export declare function reportingEndpointsHeader(): string;
52
+ /** Value for the legacy `Report-To` header (Reporting API v0). */
53
+ export declare function reportToHeader(): string;
54
+ /**
55
+ * Normalise a posted CSP violation (either the legacy
56
+ * `application/csp-report` `{ "csp-report": {...} }` envelope or a modern
57
+ * Reporting-API `application/reports+json` array element) into a compact,
58
+ * log-safe summary. Returns null when the body isn't a recognisable report.
59
+ */
60
+ export declare function summariseViolation(body: unknown): {
61
+ directive: string;
62
+ blockedUri: string;
63
+ documentUri: string;
64
+ } | null;