@uptimizr/collector-server 2.0.1 → 2.2.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.
Files changed (139) hide show
  1. package/AGENTS.md +187 -11
  2. package/README.md +91 -7
  3. package/dist/__tests__/support/registryRequests.d.ts +2 -1
  4. package/dist/__tests__/support/registryRequests.d.ts.map +1 -1
  5. package/dist/__tests__/support/registryRequests.js +17 -1
  6. package/dist/__tests__/support/registryRequests.js.map +1 -1
  7. package/dist/__tests__/support/subscriptionFixtures.d.ts +63 -0
  8. package/dist/__tests__/support/subscriptionFixtures.d.ts.map +1 -0
  9. package/dist/__tests__/support/subscriptionFixtures.js +17 -0
  10. package/dist/__tests__/support/subscriptionFixtures.js.map +1 -0
  11. package/dist/agentReport.d.ts +256 -0
  12. package/dist/agentReport.d.ts.map +1 -0
  13. package/dist/agentReport.js +861 -0
  14. package/dist/agentReport.js.map +1 -0
  15. package/dist/app.d.ts +13 -0
  16. package/dist/app.d.ts.map +1 -1
  17. package/dist/app.js +112 -5
  18. package/dist/app.js.map +1 -1
  19. package/dist/audit.d.ts +5 -1
  20. package/dist/audit.d.ts.map +1 -1
  21. package/dist/audit.js +24 -4
  22. package/dist/audit.js.map +1 -1
  23. package/dist/auth.d.ts +41 -1
  24. package/dist/auth.d.ts.map +1 -1
  25. package/dist/auth.js +47 -0
  26. package/dist/auth.js.map +1 -1
  27. package/dist/cli.js +133 -13
  28. package/dist/cli.js.map +1 -1
  29. package/dist/cliStore.d.ts +31 -2
  30. package/dist/cliStore.d.ts.map +1 -1
  31. package/dist/cliStore.js +34 -0
  32. package/dist/cliStore.js.map +1 -1
  33. package/dist/clickhouseStore.d.ts.map +1 -1
  34. package/dist/clickhouseStore.js +44 -2
  35. package/dist/clickhouseStore.js.map +1 -1
  36. package/dist/config.d.ts +49 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +21 -0
  39. package/dist/config.js.map +1 -1
  40. package/dist/connectionLimiter.d.ts +23 -0
  41. package/dist/connectionLimiter.d.ts.map +1 -0
  42. package/dist/connectionLimiter.js +24 -0
  43. package/dist/connectionLimiter.js.map +1 -0
  44. package/dist/duckdbStore.d.ts.map +1 -1
  45. package/dist/duckdbStore.js +44 -1
  46. package/dist/duckdbStore.js.map +1 -1
  47. package/dist/internalDispatch.d.ts +36 -0
  48. package/dist/internalDispatch.d.ts.map +1 -0
  49. package/dist/internalDispatch.js +50 -0
  50. package/dist/internalDispatch.js.map +1 -0
  51. package/dist/memoryStore.d.ts.map +1 -1
  52. package/dist/memoryStore.js +314 -1
  53. package/dist/memoryStore.js.map +1 -1
  54. package/dist/mssqlStore.d.ts.map +1 -1
  55. package/dist/mssqlStore.js +44 -2
  56. package/dist/mssqlStore.js.map +1 -1
  57. package/dist/postgresStore.d.ts.map +1 -1
  58. package/dist/postgresStore.js +44 -2
  59. package/dist/postgresStore.js.map +1 -1
  60. package/dist/projectMetadata.d.ts +82 -0
  61. package/dist/projectMetadata.d.ts.map +1 -0
  62. package/dist/projectMetadata.js +50 -0
  63. package/dist/projectMetadata.js.map +1 -0
  64. package/dist/routes/context.d.ts +135 -0
  65. package/dist/routes/context.d.ts.map +1 -0
  66. package/dist/routes/context.js +306 -0
  67. package/dist/routes/context.js.map +1 -0
  68. package/dist/routes/insights.d.ts +36 -0
  69. package/dist/routes/insights.d.ts.map +1 -0
  70. package/dist/routes/insights.js +810 -0
  71. package/dist/routes/insights.js.map +1 -0
  72. package/dist/routes/live.d.ts +8 -0
  73. package/dist/routes/live.d.ts.map +1 -1
  74. package/dist/routes/live.js +8 -7
  75. package/dist/routes/live.js.map +1 -1
  76. package/dist/routes/mcp.d.ts +36 -0
  77. package/dist/routes/mcp.d.ts.map +1 -0
  78. package/dist/routes/mcp.js +262 -0
  79. package/dist/routes/mcp.js.map +1 -0
  80. package/dist/routes/meta.d.ts +16 -2
  81. package/dist/routes/meta.d.ts.map +1 -1
  82. package/dist/routes/meta.js +699 -10
  83. package/dist/routes/meta.js.map +1 -1
  84. package/dist/routes/metadata.d.ts +31 -0
  85. package/dist/routes/metadata.d.ts.map +1 -0
  86. package/dist/routes/metadata.js +163 -0
  87. package/dist/routes/metadata.js.map +1 -0
  88. package/dist/routes/narrative.d.ts +36 -0
  89. package/dist/routes/narrative.d.ts.map +1 -0
  90. package/dist/routes/narrative.js +95 -0
  91. package/dist/routes/narrative.js.map +1 -0
  92. package/dist/routes/panels.d.ts +44 -0
  93. package/dist/routes/panels.d.ts.map +1 -0
  94. package/dist/routes/panels.js +108 -0
  95. package/dist/routes/panels.js.map +1 -0
  96. package/dist/routes/query-dsl.d.ts +50 -0
  97. package/dist/routes/query-dsl.d.ts.map +1 -0
  98. package/dist/routes/query-dsl.js +359 -0
  99. package/dist/routes/query-dsl.js.map +1 -0
  100. package/dist/routes/query.d.ts +36 -0
  101. package/dist/routes/query.d.ts.map +1 -1
  102. package/dist/routes/query.js +101 -13
  103. package/dist/routes/query.js.map +1 -1
  104. package/dist/routes/subscriptions.d.ts +52 -0
  105. package/dist/routes/subscriptions.d.ts.map +1 -0
  106. package/dist/routes/subscriptions.js +290 -0
  107. package/dist/routes/subscriptions.js.map +1 -0
  108. package/dist/store.d.ts +113 -2
  109. package/dist/store.d.ts.map +1 -1
  110. package/dist/subscriptions/cli.d.ts +36 -0
  111. package/dist/subscriptions/cli.d.ts.map +1 -0
  112. package/dist/subscriptions/cli.js +143 -0
  113. package/dist/subscriptions/cli.js.map +1 -0
  114. package/dist/subscriptions/delivery.d.ts +101 -0
  115. package/dist/subscriptions/delivery.d.ts.map +1 -0
  116. package/dist/subscriptions/delivery.js +165 -0
  117. package/dist/subscriptions/delivery.js.map +1 -0
  118. package/dist/subscriptions/evaluate.d.ts +119 -0
  119. package/dist/subscriptions/evaluate.d.ts.map +1 -0
  120. package/dist/subscriptions/evaluate.js +326 -0
  121. package/dist/subscriptions/evaluate.js.map +1 -0
  122. package/dist/subscriptions/scheduler.d.ts +97 -0
  123. package/dist/subscriptions/scheduler.d.ts.map +1 -0
  124. package/dist/subscriptions/scheduler.js +332 -0
  125. package/dist/subscriptions/scheduler.js.map +1 -0
  126. package/dist/subscriptions/stream.d.ts +40 -0
  127. package/dist/subscriptions/stream.d.ts.map +1 -0
  128. package/dist/subscriptions/stream.js +38 -0
  129. package/dist/subscriptions/stream.js.map +1 -0
  130. package/dist/version.d.ts +3 -0
  131. package/dist/version.d.ts.map +1 -0
  132. package/dist/version.js +25 -0
  133. package/dist/version.js.map +1 -0
  134. package/dist/webhookSignature.d.ts +64 -0
  135. package/dist/webhookSignature.d.ts.map +1 -0
  136. package/dist/webhookSignature.js +91 -0
  137. package/dist/webhookSignature.js.map +1 -0
  138. package/llms.txt +39 -2
  139. package/package.json +17 -14
@@ -22,14 +22,42 @@
22
22
  * in `app.ts`).
23
23
  *
24
24
  * Routes that are not registry metrics are described only when they are trivial
25
- * and honest to describe (`/health`, this route, and the scene-representation
26
- * listing). Ingestion (`POST /api/v1/collect`), the scene-proxy write
25
+ * and honest to describe (`/health`, this route, the scene-representation
26
+ * listing, and the `metadata` group of #310 — annotations, glossary, saved
27
+ * analyses — whose bodies are converted from the Zod contracts that validate
28
+ * them). Ingestion (`POST /api/v1/collect`), the scene-proxy write
27
29
  * (`PUT …/representation`), the retention-gated raw event stream and the live
28
30
  * SSE surface are deliberately omitted rather than half-described.
31
+ * listing, and the conditional-subscription resource). Ingestion
32
+ * (`POST /api/v1/collect`), the scene-proxy write (`PUT …/representation`), the
33
+ * retention-gated raw event stream and every SSE surface — the live endpoints
34
+ * and `GET /api/v1/subscriptions/stream` alike — are deliberately omitted rather
35
+ * than half-described: a hijacked `text/event-stream` response is not an
36
+ * operation with a JSON body, and pretending otherwise misleads a generated
37
+ * client.
38
+ *
39
+ * Conditional subscriptions (#311) are the one **write** surface described here.
40
+ * They earn it: they are a CRUD resource rather than a query, and the only place
41
+ * a caller needs a written contract for a request *body* — a closed Zod union
42
+ * that cannot be inferred from a querystring.
29
43
  */
30
44
  import { z } from "zod";
31
- import { SCHEMA_VERSION } from "@uptimizr/schema";
32
- import { FILTER_TARGETS, allMetrics, isResourceMetric, } from "@uptimizr/metrics";
45
+ import { LIMITS, SCHEMA_VERSION, annotationSchema, panelSpecV1Schema, queryV1Schema, savedAnalysisSchema, } from "@uptimizr/schema";
46
+ import { projectContextSchema } from "./context.js";
47
+ import { FILTER_TARGETS, metricCapability, allMetrics, isResourceMetric, } from "@uptimizr/metrics";
48
+ /**
49
+ * The project context document's JSON Schema, derived from the very Zod schema
50
+ * that serialises the response (`routes/context.ts`) rather than restated here —
51
+ * the same rule the registry rows follow, for the same reason.
52
+ */
53
+ function contextSchema() {
54
+ const schema = z.toJSONSchema(projectContextSchema, {
55
+ io: "output",
56
+ unrepresentable: "any",
57
+ });
58
+ delete schema.$schema;
59
+ return { title: "Project context", ...schema };
60
+ }
33
61
  /**
34
62
  * `info.version` of the served document: the major version of the HTTP API
35
63
  * (`/api/v1`). It is deliberately *not* the npm package version — the document
@@ -207,12 +235,21 @@ function vendorExtensions(metric) {
207
235
  ...(label ? { "x-uptimizr-label": label } : {}),
208
236
  "x-uptimizr-limits": metric.limits,
209
237
  "x-uptimizr-interpretation": metric.interpretation,
238
+ "x-uptimizr-capability": metricCapability(metric),
210
239
  "x-uptimizr-caveats": metric.caveats,
211
240
  "x-uptimizr-source-channels": metric.sourceChannels,
212
241
  "x-uptimizr-related": metric.related,
213
242
  ...(metric.comparable ? { "x-uptimizr-comparable": metric.comparable } : {}),
214
243
  };
215
244
  }
245
+ /** The `{id}` path parameter shared by every per-subscription operation. */
246
+ const SUBSCRIPTION_ID_PARAM = {
247
+ name: "id",
248
+ in: "path",
249
+ required: true,
250
+ schema: { type: "string", maxLength: 64 },
251
+ description: "Collector-assigned subscription id (`sub_…`).",
252
+ };
216
253
  /** The shared error responses every authenticated read can answer with. */
217
254
  const AUTHENTICATED_ERRORS = {
218
255
  "400": { $ref: "#/components/responses/BadRequest" },
@@ -250,6 +287,68 @@ function operationFor(metric, route) {
250
287
  ...vendorExtensions(metric),
251
288
  };
252
289
  }
290
+ /**
291
+ * The **query DSL** operations (ADR 0051 §3). Unlike the paths above, these are
292
+ * not generated from a registry entry — one route serves every metric — so the
293
+ * operation is described here and its request body is the `queryV1` Zod schema
294
+ * converted to JSON Schema, which is the same schema that validates the request.
295
+ *
296
+ * The response is deliberately loose: the row shape is the metric's, and the
297
+ * document already describes every metric's row under `components.schemas`.
298
+ */
299
+ function queryDslPath() {
300
+ const body = toJsonSchema(queryV1Schema, "input");
301
+ body.title = "Analytics query (v1)";
302
+ const description = "Run **any** registry metric in one validated request: pick the `metric`, bound it with a " +
303
+ "`range`, narrow it with the filters that metric declares, cap it with `limit`, and choose " +
304
+ "the result envelope with `format` (`table` by default).\n\n" +
305
+ "The grammar is **closed**: there is no raw SQL and no free-form expression, and the metric, " +
306
+ "dimension and filter vocabularies are exactly this document's. A metric that does not accept " +
307
+ "a filter, a dimension it is not keyed by, or a `limit` above its cap is a `400` naming what " +
308
+ "it does accept.\n\n" +
309
+ "**v1 is the delegated tier**: a query runs the metric's existing aggregation, so it is " +
310
+ "reachable at exactly the power of its canned endpoint. `compare`, `segment`, `order`, " +
311
+ "`explain`, `filters.event` and `filters.device` are part of the published grammar and are " +
312
+ "answered with `400 … not supported yet` until the generic group-by tier lands.";
313
+ const responses = {
314
+ "200": {
315
+ description: "The metric's rows (`format=full`), the rows plus a `meta` envelope (`table`), or a " +
316
+ "bounded digest (`summary`).",
317
+ content: { "application/json": { schema: { type: "object" } } },
318
+ },
319
+ ...AUTHENTICATED_ERRORS,
320
+ };
321
+ return {
322
+ "/api/v1/query": {
323
+ post: {
324
+ operationId: "query",
325
+ summary: "Run an analytics query",
326
+ description,
327
+ tags: ["meta"],
328
+ requestBody: { required: true, content: { "application/json": { schema: body } } },
329
+ responses,
330
+ },
331
+ get: {
332
+ operationId: "query_get",
333
+ summary: "Run an analytics query (GET form)",
334
+ description: "The same query as `POST /api/v1/query`, URL-encoded into a single `q` parameter, for " +
335
+ "GET-only clients.\n\n" +
336
+ description,
337
+ tags: ["meta"],
338
+ parameters: [
339
+ {
340
+ name: "q",
341
+ in: "query",
342
+ required: true,
343
+ description: "The query document, JSON-encoded and URL-encoded (at most 8 KiB).",
344
+ schema: { type: "string", minLength: 1, maxLength: 8192 },
345
+ },
346
+ ],
347
+ responses,
348
+ },
349
+ },
350
+ };
351
+ }
253
352
  /** The handful of non-metric routes that are trivial and honest to describe. */
254
353
  function staticPaths() {
255
354
  return {
@@ -291,6 +390,26 @@ function staticPaths() {
291
390
  },
292
391
  },
293
392
  },
393
+ "/api/v1/context": {
394
+ get: {
395
+ operationId: "project_context",
396
+ summary: "Project context document",
397
+ description: "Everything an agent needs to know about *this* project before it asks anything: the store engine and versions, data freshness and retention flags, which capture channels produce data, the scenes with their labels and named regions, the discovered custom-event/mesh/input vocabulary, the glossary and recent annotations, and which registry metrics are disabled because their capture channels are off (ADR 0051 §5). Bounded and cached briefly per project. Not a registry metric — it describes the project, not a row grain.",
398
+ tags: ["meta"],
399
+ parameters: [],
400
+ responses: {
401
+ "200": {
402
+ description: "The project context document.",
403
+ content: {
404
+ "application/json": {
405
+ schema: { $ref: "#/components/schemas/ProjectContext" },
406
+ },
407
+ },
408
+ },
409
+ ...AUTHENTICATED_ERRORS,
410
+ },
411
+ },
412
+ },
294
413
  "/api/v1/scene-representations": {
295
414
  get: {
296
415
  operationId: "list_scene_representations",
@@ -314,6 +433,492 @@ function staticPaths() {
314
433
  },
315
434
  },
316
435
  },
436
+ // --- Conditional subscriptions (#311, ADR 0051 §6 / sketch §F.1–F.3) ----
437
+ // Described here rather than derived, because a subscription is project
438
+ // *configuration* and not a metric: it has no rows, no grain and no window,
439
+ // so there is nothing in the registry to generate an operation from.
440
+ //
441
+ // The SSE stream stays omitted, for the reason the module note gives: a
442
+ // hijacked `text/event-stream` response is not honestly describable as a
443
+ // JSON operation, and a half-description is worse than the docs-site prose.
444
+ "/api/v1/subscriptions": {
445
+ get: {
446
+ operationId: "list_subscriptions",
447
+ summary: "Conditional subscriptions",
448
+ description: "The project's standing conditional subscriptions (ADR 0051 §6): what the collector watches for, how often it checks, where a firing is delivered, and how each one last went. Any webhook secret is masked. Needs the `query` capability.",
449
+ tags: ["subscriptions"],
450
+ parameters: [],
451
+ responses: {
452
+ "200": {
453
+ description: "One entry per subscription, oldest first.",
454
+ content: {
455
+ "application/json": {
456
+ schema: { type: "array", items: { $ref: "#/components/schemas/Subscription" } },
457
+ },
458
+ },
459
+ },
460
+ ...AUTHENTICATED_ERRORS,
461
+ },
462
+ },
463
+ post: {
464
+ operationId: "create_subscription",
465
+ summary: "Create a conditional subscription",
466
+ description: "Store a new subscription. Needs the `annotate` capability, because creating one is how a caller asks the collector to make an outbound request on its behalf. A webhook `secret` is write-only: it is accepted here and never returned again. A webhook URL must be http(s) and its host must appear in `COLLECTOR_WEBHOOK_ALLOWED_HOSTS`, or the request is refused.",
467
+ tags: ["subscriptions"],
468
+ requestBody: {
469
+ required: true,
470
+ content: {
471
+ "application/json": { schema: { $ref: "#/components/schemas/Subscription" } },
472
+ },
473
+ },
474
+ responses: {
475
+ "201": {
476
+ description: "The stored subscription, with any secret masked.",
477
+ content: {
478
+ "application/json": { schema: { $ref: "#/components/schemas/Subscription" } },
479
+ },
480
+ },
481
+ "409": {
482
+ description: "The project already holds the maximum number of subscriptions.",
483
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
484
+ },
485
+ ...AUTHENTICATED_ERRORS,
486
+ },
487
+ },
488
+ },
489
+ "/api/v1/subscriptions/{id}": {
490
+ get: {
491
+ operationId: "get_subscription",
492
+ summary: "One conditional subscription",
493
+ tags: ["subscriptions"],
494
+ parameters: [SUBSCRIPTION_ID_PARAM],
495
+ responses: {
496
+ "200": {
497
+ description: "The subscription.",
498
+ content: {
499
+ "application/json": { schema: { $ref: "#/components/schemas/Subscription" } },
500
+ },
501
+ },
502
+ "404": { $ref: "#/components/responses/NotFound" },
503
+ ...AUTHENTICATED_ERRORS,
504
+ },
505
+ },
506
+ patch: {
507
+ operationId: "set_subscription_enabled",
508
+ summary: "Enable or disable a subscription",
509
+ description: "The only patchable field is `enabled`. Everything else changes what the subscription *means*, so it is replaced rather than edited. Needs `annotate`.",
510
+ tags: ["subscriptions"],
511
+ parameters: [SUBSCRIPTION_ID_PARAM],
512
+ requestBody: {
513
+ required: true,
514
+ content: {
515
+ "application/json": {
516
+ schema: {
517
+ type: "object",
518
+ properties: { enabled: { type: "boolean" } },
519
+ required: ["enabled"],
520
+ },
521
+ },
522
+ },
523
+ },
524
+ responses: {
525
+ "200": {
526
+ description: "The updated subscription.",
527
+ content: {
528
+ "application/json": { schema: { $ref: "#/components/schemas/Subscription" } },
529
+ },
530
+ },
531
+ "404": { $ref: "#/components/responses/NotFound" },
532
+ ...AUTHENTICATED_ERRORS,
533
+ },
534
+ },
535
+ delete: {
536
+ operationId: "delete_subscription",
537
+ summary: "Delete a subscription and its firing log",
538
+ tags: ["subscriptions"],
539
+ parameters: [SUBSCRIPTION_ID_PARAM],
540
+ responses: {
541
+ "204": { description: "Deleted." },
542
+ "404": { $ref: "#/components/responses/NotFound" },
543
+ ...AUTHENTICATED_ERRORS,
544
+ },
545
+ },
546
+ },
547
+ "/api/v1/subscriptions/{id}/events": {
548
+ get: {
549
+ operationId: "list_subscription_events",
550
+ summary: "Recent firings of one subscription",
551
+ description: "The bounded firing log — the last 100 firings, newest first. Each row is { id, subscriptionId, projectId, at, payload }, where `payload` is the firing record plus the `format=summary` block that was delivered with it.",
552
+ tags: ["subscriptions"],
553
+ parameters: [
554
+ SUBSCRIPTION_ID_PARAM,
555
+ {
556
+ name: "limit",
557
+ in: "query",
558
+ required: false,
559
+ schema: { type: "integer", minimum: 1, maximum: 100 },
560
+ description: "Newest-first cap; clamped to the retained window.",
561
+ },
562
+ ],
563
+ responses: {
564
+ "200": {
565
+ description: "One entry per recorded firing.",
566
+ content: {
567
+ "application/json": { schema: { type: "array", items: { type: "object" } } },
568
+ },
569
+ },
570
+ "404": { $ref: "#/components/responses/NotFound" },
571
+ ...AUTHENTICATED_ERRORS,
572
+ },
573
+ },
574
+ },
575
+ "/api/v1/subscriptions/{id}/test": {
576
+ post: {
577
+ operationId: "test_subscription",
578
+ summary: "Evaluate a subscription once, now",
579
+ description: "Runs the predicate immediately and answers with the evaluation, including why it did **not** fire. A dry run by default; `deliver=true` records and delivers a real firing (honouring the cooldown), which is how a webhook receiver is proved to work without waiting for the condition to occur. Needs `annotate`.",
580
+ tags: ["subscriptions"],
581
+ parameters: [
582
+ SUBSCRIPTION_ID_PARAM,
583
+ {
584
+ name: "deliver",
585
+ in: "query",
586
+ required: false,
587
+ schema: { type: "boolean", default: false },
588
+ description: "Record and deliver the firing if the predicate is satisfied.",
589
+ },
590
+ ],
591
+ responses: {
592
+ "200": {
593
+ description: "The evaluation.",
594
+ content: { "application/json": { schema: { type: "object" } } },
595
+ },
596
+ "404": { $ref: "#/components/responses/NotFound" },
597
+ "409": {
598
+ description: "An evaluation of this subscription is already in flight.",
599
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
600
+ },
601
+ ...AUTHENTICATED_ERRORS,
602
+ },
603
+ },
604
+ },
605
+ };
606
+ }
607
+ /**
608
+ * The **metadata** paths (#310, ADR 0051 §5): annotations, glossary, saved
609
+ * analyses. They are not registry metrics — they store what people and agents
610
+ * write rather than aggregate what visitors did — so they are described by hand
611
+ * here, next to the other non-metric routes. The two request bodies are
612
+ * converted from the very Zod contracts that validate them, so the document
613
+ * cannot drift from the validator.
614
+ */
615
+ function metadataPaths() {
616
+ const jsonBody = (schema) => ({
617
+ required: true,
618
+ content: { "application/json": { schema } },
619
+ });
620
+ const rows = (name) => ({
621
+ "application/json": {
622
+ schema: { type: "array", items: { $ref: `#/components/schemas/${name}` } },
623
+ },
624
+ });
625
+ const one = (name) => ({
626
+ "application/json": { schema: { $ref: `#/components/schemas/${name}` } },
627
+ });
628
+ const writeErrors = {
629
+ ...AUTHENTICATED_ERRORS,
630
+ "409": { $ref: "#/components/responses/Conflict" },
631
+ };
632
+ const notFound = { "404": { $ref: "#/components/responses/NotFound" } };
633
+ return {
634
+ "/api/v1/annotations": {
635
+ get: {
636
+ operationId: "list_annotations",
637
+ summary: "Project annotations",
638
+ description: "Notes left on the project, newest first. `since`/`until` select the annotations whose period **overlaps** the window; a standing note (no period) always matches. Requires the `query` capability.",
639
+ tags: ["metadata"],
640
+ responses: {
641
+ "200": { description: "Matching annotations.", content: rows("Annotation") },
642
+ ...AUTHENTICATED_ERRORS,
643
+ },
644
+ },
645
+ post: {
646
+ operationId: "create_annotation",
647
+ summary: "Annotate something",
648
+ description: "Leave a note on the project, a scene, a mesh, a region, a metric or a period of time. Requires the **`annotate`** capability; the stored row records whether a person or an agent wrote it, and the write is recorded in the agent audit log.",
649
+ tags: ["metadata"],
650
+ requestBody: jsonBody(toJsonSchema(annotationSchema, "input")),
651
+ responses: {
652
+ "201": { description: "The stored annotation.", content: one("Annotation") },
653
+ ...writeErrors,
654
+ },
655
+ },
656
+ },
657
+ "/api/v1/annotations/{id}": {
658
+ delete: {
659
+ operationId: "delete_annotation",
660
+ summary: "Delete an annotation",
661
+ description: "Remove one annotation of this project. Requires the `annotate` capability. An id that does not exist — or belongs to another project — answers 404.",
662
+ tags: ["metadata"],
663
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
664
+ responses: { "204": { description: "Deleted." }, ...AUTHENTICATED_ERRORS, ...notFound },
665
+ },
666
+ },
667
+ "/api/v1/glossary": {
668
+ get: {
669
+ operationId: "list_glossary",
670
+ summary: "Project glossary",
671
+ description: "What this project's names mean, ordered by term — the vocabulary to read before interpreting mesh names, scene ids and custom events. Requires the `query` capability.",
672
+ tags: ["metadata"],
673
+ responses: {
674
+ "200": { description: "The whole glossary.", content: rows("GlossaryEntry") },
675
+ ...AUTHENTICATED_ERRORS,
676
+ },
677
+ },
678
+ },
679
+ "/api/v1/glossary/{term}": {
680
+ put: {
681
+ operationId: "define_term",
682
+ summary: "Define a term",
683
+ description: "Idempotent upsert: the term is the identity, so defining it again replaces the meaning. Requires the **`annotate`** capability.",
684
+ tags: ["metadata"],
685
+ parameters: [{ name: "term", in: "path", required: true, schema: { type: "string" } }],
686
+ requestBody: jsonBody({
687
+ type: "object",
688
+ properties: {
689
+ meaning: {
690
+ type: "string",
691
+ maxLength: LIMITS.maxGlossaryMeaningLength,
692
+ description: "What the term means in this project.",
693
+ },
694
+ },
695
+ required: ["meaning"],
696
+ }),
697
+ responses: {
698
+ "200": { description: "The stored entry.", content: one("GlossaryEntry") },
699
+ ...writeErrors,
700
+ },
701
+ },
702
+ delete: {
703
+ operationId: "delete_term",
704
+ summary: "Undefine a term",
705
+ description: "Remove one glossary entry. Requires the `annotate` capability.",
706
+ tags: ["metadata"],
707
+ parameters: [{ name: "term", in: "path", required: true, schema: { type: "string" } }],
708
+ responses: { "204": { description: "Deleted." }, ...AUTHENTICATED_ERRORS, ...notFound },
709
+ },
710
+ },
711
+ "/api/v1/analyses": {
712
+ get: {
713
+ operationId: "list_analyses",
714
+ summary: "Saved analyses",
715
+ description: "Questions worth re-asking and what was concluded from them, newest first. Requires the `query` capability.",
716
+ tags: ["metadata"],
717
+ responses: {
718
+ "200": { description: "Saved analyses.", content: rows("SavedAnalysis") },
719
+ ...AUTHENTICATED_ERRORS,
720
+ },
721
+ },
722
+ post: {
723
+ operationId: "save_analysis",
724
+ summary: "Save an analysis",
725
+ description: "Store a titled question plus the finding it produced. `query` is an opaque JSON document the collector stores but does not interpret. Requires the **`annotate`** capability.",
726
+ tags: ["metadata"],
727
+ requestBody: jsonBody(toJsonSchema(savedAnalysisSchema, "input")),
728
+ responses: {
729
+ "201": { description: "The stored analysis.", content: one("SavedAnalysis") },
730
+ ...writeErrors,
731
+ },
732
+ },
733
+ },
734
+ "/api/v1/analyses/{id}": {
735
+ delete: {
736
+ operationId: "delete_analysis",
737
+ summary: "Delete a saved analysis",
738
+ description: "Remove one saved analysis. Requires the `annotate` capability.",
739
+ tags: ["metadata"],
740
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
741
+ responses: { "204": { description: "Deleted." }, ...AUTHENTICATED_ERRORS, ...notFound },
742
+ },
743
+ },
744
+ };
745
+ }
746
+ /**
747
+ * The **panels** paths (#315, ADR 0051 §7): the declarative panel specs an
748
+ * agent pins to the dashboard. Like the metadata group these are not registry
749
+ * metrics, so they are described by hand here; the request body is converted
750
+ * from the very Zod contract that validates it, so the document cannot drift
751
+ * from the validator.
752
+ */
753
+ function panelPaths() {
754
+ const rows = {
755
+ "application/json": {
756
+ schema: { type: "array", items: { $ref: "#/components/schemas/PanelSpec" } },
757
+ },
758
+ };
759
+ const one = { "application/json": { schema: { $ref: "#/components/schemas/PanelSpec" } } };
760
+ const specBody = {
761
+ required: true,
762
+ content: { "application/json": { schema: toJsonSchema(panelSpecV1Schema, "input") } },
763
+ };
764
+ const idParam = [{ name: "id", in: "path", required: true, schema: { type: "string" } }];
765
+ const refusal = "A spec whose query the registry cannot answer — an unknown metric, a filter the metric " +
766
+ "does not accept — or whose `chart` does not suit the metric's grain, or whose `encoding` " +
767
+ "names a column the result does not carry, is refused with the validator's issue codes, so " +
768
+ "the spec can be fixed from the response rather than guessed at.";
769
+ return {
770
+ "/api/v1/panels": {
771
+ get: {
772
+ operationId: "list_panels",
773
+ summary: "Pinned panels",
774
+ description: "The project's declarative panel specs, **oldest first** — these are positions in a " +
775
+ "dashboard grid rather than a feed, so a newly pinned panel is appended instead of " +
776
+ "displacing the ones already there. Requires the `query` capability.",
777
+ tags: ["panels"],
778
+ responses: {
779
+ "200": { description: "The pinned panels.", content: rows },
780
+ ...AUTHENTICATED_ERRORS,
781
+ },
782
+ },
783
+ post: {
784
+ operationId: "pin_panel",
785
+ summary: "Pin a panel",
786
+ description: "Store a query, a chart and a one-line reading as a panel the dashboard renders on " +
787
+ "every load. The spec is **data, never code**: no module is loaded and nothing is " +
788
+ "evaluated, so pinning a panel does not widen the dashboard's trust boundary " +
789
+ "(ADR 0041). Requires the **`annotate`** capability; the stored row records whether a " +
790
+ "person or an agent pinned it, and the write is recorded in the agent audit log.\n\n" +
791
+ refusal,
792
+ tags: ["panels"],
793
+ requestBody: specBody,
794
+ responses: {
795
+ "201": { description: "The stored panel.", content: one },
796
+ ...AUTHENTICATED_ERRORS,
797
+ "409": { $ref: "#/components/responses/Conflict" },
798
+ },
799
+ },
800
+ },
801
+ "/api/v1/panels/{id}": {
802
+ put: {
803
+ operationId: "update_panel",
804
+ summary: "Replace a pinned panel's spec",
805
+ description: "A full replacement rather than a patch — the spec is one closed document, and half " +
806
+ "of one is not a panel. The row keeps its id, its place in the grid and its original " +
807
+ "authorship: an edit is not a new pin. Requires the `annotate` capability.\n\n" +
808
+ refusal,
809
+ tags: ["panels"],
810
+ parameters: idParam,
811
+ requestBody: specBody,
812
+ responses: {
813
+ "200": { description: "The stored panel.", content: one },
814
+ ...AUTHENTICATED_ERRORS,
815
+ "404": { $ref: "#/components/responses/NotFound" },
816
+ },
817
+ },
818
+ delete: {
819
+ operationId: "unpin_panel",
820
+ summary: "Unpin a panel",
821
+ description: "Remove one panel from the project's dashboard. Requires the `annotate` capability. " +
822
+ "An id that does not exist — or belongs to another project — answers 404.",
823
+ tags: ["panels"],
824
+ parameters: idParam,
825
+ responses: {
826
+ "204": { description: "Unpinned." },
827
+ ...AUTHENTICATED_ERRORS,
828
+ "404": { $ref: "#/components/responses/NotFound" },
829
+ },
830
+ },
831
+ },
832
+ };
833
+ }
834
+ /** The stored-row schemas of the three metadata tables. */
835
+ function metadataSchemas() {
836
+ const author = {
837
+ authorKind: {
838
+ type: "string",
839
+ enum: ["user", "agent"],
840
+ description: "Whether a person (a first-party UI session) or an agent wrote the row. Derived by the collector from the calling client, never from the payload.",
841
+ },
842
+ authorKeyId: {
843
+ type: ["string", "null"],
844
+ description: "Id of the API key that carried the write — never the key or its hash.",
845
+ },
846
+ };
847
+ return {
848
+ Annotation: {
849
+ type: "object",
850
+ title: "Annotation",
851
+ properties: {
852
+ id: { type: "string" },
853
+ projectId: { type: "string" },
854
+ targetKind: {
855
+ type: "string",
856
+ enum: ["project", "scene", "mesh", "region", "metric", "window"],
857
+ description: "What the note is about.",
858
+ },
859
+ targetId: {
860
+ type: ["string", "null"],
861
+ description: "The scene id, mesh name, region id or metric id the note points at.",
862
+ },
863
+ since: {
864
+ type: ["string", "null"],
865
+ description: "Start of the annotated period (ISO 8601).",
866
+ },
867
+ until: { type: ["string", "null"], description: "End of the annotated period (ISO 8601)." },
868
+ text: { type: "string", maxLength: LIMITS.maxAnnotationTextLength },
869
+ ...author,
870
+ createdAt: { type: "string" },
871
+ updatedAt: { type: "string" },
872
+ },
873
+ required: ["id", "projectId", "targetKind", "text", "createdAt", "updatedAt"],
874
+ },
875
+ GlossaryEntry: {
876
+ type: "object",
877
+ title: "Glossary entry",
878
+ properties: {
879
+ projectId: { type: "string" },
880
+ term: { type: "string", maxLength: LIMITS.maxGlossaryTermLength },
881
+ meaning: { type: "string", maxLength: LIMITS.maxGlossaryMeaningLength },
882
+ updatedAt: { type: "string" },
883
+ },
884
+ required: ["projectId", "term", "meaning", "updatedAt"],
885
+ },
886
+ SavedAnalysis: {
887
+ type: "object",
888
+ title: "Saved analysis",
889
+ properties: {
890
+ id: { type: "string" },
891
+ projectId: { type: "string" },
892
+ title: { type: "string", maxLength: LIMITS.maxSavedAnalysisTitleLength },
893
+ query: {
894
+ type: "object",
895
+ description: "The stored question, as an opaque JSON document the collector does not interpret.",
896
+ },
897
+ conclusion: {
898
+ type: ["string", "null"],
899
+ maxLength: LIMITS.maxSavedAnalysisConclusionLength,
900
+ },
901
+ ...author,
902
+ createdAt: { type: "string" },
903
+ },
904
+ required: ["id", "projectId", "title", "query", "createdAt"],
905
+ },
906
+ PanelSpec: {
907
+ type: "object",
908
+ title: "Pinned panel",
909
+ properties: {
910
+ id: { type: "string" },
911
+ projectId: { type: "string" },
912
+ spec: {
913
+ ...toJsonSchema(panelSpecV1Schema, "output"),
914
+ description: "The declarative spec the dashboard renders: a query, a chart, an optional encoding and the agent's one-line reading. Data, never code — nothing here is evaluated.",
915
+ },
916
+ ...author,
917
+ createdAt: { type: "string" },
918
+ updatedAt: { type: "string" },
919
+ },
920
+ required: ["id", "projectId", "spec", "createdAt", "updatedAt"],
921
+ },
317
922
  };
318
923
  }
319
924
  /** The reusable components: the API-key scheme, error responses, row schemas. */
@@ -325,6 +930,7 @@ function componentsFor(metrics) {
325
930
  properties: { error: { type: "string", description: "Human-readable failure reason." } },
326
931
  required: ["error"],
327
932
  },
933
+ ProjectContext: contextSchema(),
328
934
  SceneRepresentationSummary: {
329
935
  type: "object",
330
936
  title: "Scene representation summary",
@@ -345,6 +951,57 @@ function componentsFor(metrics) {
345
951
  },
346
952
  required: ["sceneId", "kind", "updatedAt"],
347
953
  },
954
+ ...metadataSchemas(),
955
+ Subscription: {
956
+ type: "object",
957
+ title: "Conditional subscription",
958
+ description: "A standing question (ADR 0051 §6): a registry metric, a window, a predicate over that window, and where a firing is delivered. A webhook secret is accepted on create and never returned — reads carry a masked placeholder.",
959
+ properties: {
960
+ id: { type: "string", description: "Collector-assigned (sub_…); absent on create." },
961
+ projectId: { type: "string" },
962
+ name: { type: "string", maxLength: 120 },
963
+ metric: { type: "string", description: "Registry metric id (`@uptimizr/metrics`)." },
964
+ filters: {
965
+ type: "object",
966
+ properties: { scene: { type: "string" } },
967
+ description: "Narrows the metric the subscription watches.",
968
+ },
969
+ evaluate: {
970
+ type: "object",
971
+ properties: {
972
+ every: { type: "string", description: "Evaluation interval, e.g. 5m. Minimum 1m." },
973
+ window: {
974
+ type: "string",
975
+ description: "Span each evaluation measures, e.g. 1h. Minimum 1h.",
976
+ },
977
+ bucket: { type: "string", enum: ["hour", "day"] },
978
+ },
979
+ required: ["every", "window"],
980
+ },
981
+ predicate: {
982
+ type: "object",
983
+ description: "Closed union discriminated on `kind`: threshold {column, op, value, minSample}, anomaly {sensitivity}, movers {pct, direction}, new_value {dimension}, presence {op, value}.",
984
+ properties: {
985
+ kind: {
986
+ type: "string",
987
+ enum: ["threshold", "anomaly", "movers", "new_value", "presence"],
988
+ },
989
+ },
990
+ required: ["kind"],
991
+ },
992
+ cooldown: { type: "string", description: "Quiet period after a firing; 0s for none." },
993
+ delivery: {
994
+ type: "array",
995
+ description: "{ kind: sse } and/or one { kind: webhook, url, secret? }.",
996
+ items: { type: "object" },
997
+ },
998
+ enabled: { type: "boolean", default: true },
999
+ lastFiredAt: { type: ["string", "null"] },
1000
+ lastError: { type: ["string", "null"] },
1001
+ failures: { type: "integer" },
1002
+ },
1003
+ required: ["name", "metric", "evaluate", "predicate", "delivery"],
1004
+ },
348
1005
  };
349
1006
  for (const metric of metrics)
350
1007
  schemas[metric.id] = rowSchema(metric);
@@ -355,17 +1012,25 @@ function componentsFor(metrics) {
355
1012
  type: "apiKey",
356
1013
  in: "header",
357
1014
  name: "x-api-key",
358
- description: "A project API key with the `query` capability. Reads are always scoped to the project the key resolves to; a client-supplied project id is ignored.",
1015
+ description: "A project API key with the `query` capability. Reads are always scoped to the project the key resolves to; a client-supplied project id is ignored. An operation whose `x-uptimizr-capability` is `query:raw` needs a key holding that capability as well, on a collector started with `ENABLE_RAW_SESSION_RETENTION` (ADR 0003 / ADR 0051 §7).",
359
1016
  },
360
1017
  },
361
1018
  responses: {
362
1019
  BadRequest: { description: "A parameter failed validation.", content: errorContent },
363
1020
  Unauthorized: { description: "Missing or unknown API key.", content: errorContent },
364
1021
  Forbidden: {
365
- description: "The key is ingest-only and may not read.",
1022
+ description: "The key does not hold the capability this operation requires — an ingest-only key " +
1023
+ "trying to read, a `query` key asking for a `query:raw` operation, or a key without " +
1024
+ "`annotate` trying to write metadata. A `query:raw` operation also answers 403 when " +
1025
+ "the collector was started without `ENABLE_RAW_SESSION_RETENTION` (ADR 0003). Check " +
1026
+ "each operation's `x-uptimizr-capability`.",
1027
+ content: errorContent,
1028
+ },
1029
+ NotFound: { description: "No such session, scene or metadata row.", content: errorContent },
1030
+ Conflict: {
1031
+ description: "The project has reached its cap for that metadata table. Delete a row and retry.",
366
1032
  content: errorContent,
367
1033
  },
368
- NotFound: { description: "No such session or scene.", content: errorContent },
369
1034
  TooManyRequests: { description: "Rate limit exceeded.", content: errorContent },
370
1035
  },
371
1036
  schemas,
@@ -380,7 +1045,12 @@ export function buildOpenApiDocument(routeSchemas = []) {
380
1045
  const byPath = new Map(routeSchemas.filter((route) => route.method === "GET").map((route) => [route.path, route]));
381
1046
  const metrics = allMetrics();
382
1047
  const served = metrics.filter((metric) => metric.endpoint != null);
383
- const paths = staticPaths();
1048
+ const paths = {
1049
+ ...staticPaths(),
1050
+ ...queryDslPath(),
1051
+ ...metadataPaths(),
1052
+ ...panelPaths(),
1053
+ };
384
1054
  for (const metric of served) {
385
1055
  const path = toOpenApiPath(metric.endpoint.path);
386
1056
  paths[path] ??= {};
@@ -400,8 +1070,15 @@ export function buildOpenApiDocument(routeSchemas = []) {
400
1070
  "(`grain`), what its columns mean (`units`), the capture channels that must be enabled " +
401
1071
  "for it to have data (`source-channels`), how far to trust it (`caveats`) and how to " +
402
1072
  "read it (`interpretation`).\n\n" +
403
- "The API is **read-only and aggregate-only**: there is no endpoint here that returns raw " +
404
- "per-session events or personal data (ADR 0003).",
1073
+ "The analytics API is **read-only**: no endpoint here ingests, mutates or returns " +
1074
+ "personal data, and nothing can write, alter or delete an event (ADR 0003, " +
1075
+ "ADR 0051 §9). It is aggregate-only with exactly one, doubly-gated exception — " +
1076
+ "`session_narrative`, a bounded compaction of one session, which requires both a " +
1077
+ "`query:raw` key and `ENABLE_RAW_SESSION_RETENTION`. The writable surface is the " +
1078
+ "`metadata` group — annotations, the glossary and saved analyses — together with " +
1079
+ "`panels`, the declarative specs pinned to the dashboard. Both store what a project's " +
1080
+ "own people and agents write, require the `annotate` capability, and are audited. " +
1081
+ "Check each operation's `x-uptimizr-capability`.",
405
1082
  license: { name: "Apache-2.0", identifier: "Apache-2.0" },
406
1083
  contact: { name: "Uptimizr", url: "https://uptimizr.com/docs/" },
407
1084
  },
@@ -413,6 +1090,18 @@ export function buildOpenApiDocument(routeSchemas = []) {
413
1090
  description: CATEGORY_DESCRIPTIONS[category] ?? category,
414
1091
  })),
415
1092
  { name: "meta", description: "Self-description and liveness." },
1093
+ {
1094
+ name: "metadata",
1095
+ description: "What people and agents leave behind: annotations, the project glossary, saved analyses. Reads need `query`; every write needs `annotate` and is audited.",
1096
+ },
1097
+ {
1098
+ name: "panels",
1099
+ description: "Declarative panel specs an agent pins to the dashboard (ADR 0051 §7): a query, a chart and a reading, stored as data and rendered with the panels the dashboard already ships — never as loaded code. Reads need `query`; every write needs `annotate` and is audited.",
1100
+ },
1101
+ {
1102
+ name: "subscriptions",
1103
+ description: "Conditional subscriptions: standing predicates over a metric, delivered over SSE and signed webhooks (ADR 0051 §6).",
1104
+ },
416
1105
  ],
417
1106
  paths,
418
1107
  components: componentsFor(served),