@uptimizr/collector-server 1.1.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/AGENTS.md +193 -0
  2. package/README.md +112 -8
  3. package/dist/__tests__/support/registryRequests.d.ts +47 -0
  4. package/dist/__tests__/support/registryRequests.d.ts.map +1 -0
  5. package/dist/__tests__/support/registryRequests.js +93 -0
  6. package/dist/__tests__/support/registryRequests.js.map +1 -0
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +34 -3
  9. package/dist/app.js.map +1 -1
  10. package/dist/audit.d.ts +22 -0
  11. package/dist/audit.d.ts.map +1 -0
  12. package/dist/audit.js +96 -0
  13. package/dist/audit.js.map +1 -0
  14. package/dist/auth.d.ts +64 -0
  15. package/dist/auth.d.ts.map +1 -0
  16. package/dist/auth.js +71 -0
  17. package/dist/auth.js.map +1 -0
  18. package/dist/cli.js +279 -0
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cliStore.d.ts +20 -2
  21. package/dist/cliStore.d.ts.map +1 -1
  22. package/dist/cliStore.js +20 -8
  23. package/dist/cliStore.js.map +1 -1
  24. package/dist/clickhouseStore.d.ts.map +1 -1
  25. package/dist/clickhouseStore.js +7 -1
  26. package/dist/clickhouseStore.js.map +1 -1
  27. package/dist/config.d.ts +13 -0
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +2 -0
  30. package/dist/config.js.map +1 -1
  31. package/dist/duckdbStore.d.ts.map +1 -1
  32. package/dist/duckdbStore.js +7 -1
  33. package/dist/duckdbStore.js.map +1 -1
  34. package/dist/liveToken.d.ts +14 -5
  35. package/dist/liveToken.d.ts.map +1 -1
  36. package/dist/liveToken.js +32 -6
  37. package/dist/liveToken.js.map +1 -1
  38. package/dist/memoryStore.d.ts +8 -3
  39. package/dist/memoryStore.d.ts.map +1 -1
  40. package/dist/memoryStore.js +57 -2
  41. package/dist/memoryStore.js.map +1 -1
  42. package/dist/mssqlStore.d.ts.map +1 -1
  43. package/dist/mssqlStore.js +7 -1
  44. package/dist/mssqlStore.js.map +1 -1
  45. package/dist/postgresStore.d.ts.map +1 -1
  46. package/dist/postgresStore.js +7 -1
  47. package/dist/postgresStore.js.map +1 -1
  48. package/dist/routes/live.d.ts.map +1 -1
  49. package/dist/routes/live.js +31 -26
  50. package/dist/routes/live.js.map +1 -1
  51. package/dist/routes/meta.d.ts +67 -0
  52. package/dist/routes/meta.d.ts.map +1 -0
  53. package/dist/routes/meta.js +436 -0
  54. package/dist/routes/meta.js.map +1 -0
  55. package/dist/routes/query.d.ts.map +1 -1
  56. package/dist/routes/query.js +805 -108
  57. package/dist/routes/query.js.map +1 -1
  58. package/dist/store.d.ts +32 -5
  59. package/dist/store.d.ts.map +1 -1
  60. package/llms.txt +38 -0
  61. package/package.json +11 -7
@@ -1,7 +1,9 @@
1
1
  import { Readable } from "node:stream";
2
2
  import { z } from "zod";
3
- import { sceneProxySchema, funnelStepsSchema, funnelStepSchema } from "@uptimizr/schema";
4
- import { defaultCellSizeForBounds } from "@uptimizr/db";
3
+ import { sceneProxySchema, sceneRegionsSchema, regionIdSchema, funnelStepsSchema, funnelStepSchema, } from "@uptimizr/schema";
4
+ import { defaultCellSizeForBounds, resultEnvelopeSchema, resultFormatSchema, summarizeRows, tableResult, } from "@uptimizr/db";
5
+ import { allMetrics } from "@uptimizr/metrics";
6
+ import { requireCapability } from "../auth.js";
5
7
  /** Developer-assigned scene/area filter (ADR 0010). */
6
8
  const sceneFilter = z
7
9
  .string()
@@ -20,6 +22,19 @@ const sessionFilter = z.string().min(1).max(128).optional();
20
22
  * {@link cameraTypeForMode} before it reaches the aggregation layer.
21
23
  */
22
24
  const cameraModeFilter = z.enum(["viewer", "first-person"]).optional();
25
+ /**
26
+ * Result envelope (ADR 0051 §2, design sketch §B.1). Accepted by every
27
+ * registry-served aggregate endpoint, and declared as the `format` filter on
28
+ * each of their registry entries — the registry-route contract test asserts the
29
+ * two stay in step.
30
+ *
31
+ * Omitted or `full` means the response is the bare rows, byte-for-byte what the
32
+ * endpoint has always returned: the dashboard never sends `format`, so it cannot
33
+ * be affected by this. `table` and `summary` are shaped by a single
34
+ * `preSerialization` hook at the bottom of this plugin, so no handler has to
35
+ * know the envelope exists.
36
+ */
37
+ const formatFilter = resultFormatSchema.optional();
23
38
  /** Map the dashboard camera-mode toggle to the stored `cameraType` value. */
24
39
  function cameraTypeForMode(mode) {
25
40
  if (mode === "first-person")
@@ -29,10 +44,19 @@ function cameraTypeForMode(mode) {
29
44
  return undefined;
30
45
  }
31
46
  /**
32
- * World-space region filter (ADR 0040 §4): a `minX,minY,minZ,maxX,maxY,maxZ`
33
- * comma list naming an axis-aligned box to drill into. Validated to six finite
34
- * numbers with `max >= min` on every axis, then handed to the aggregation layer
35
- * as a {@link WorldAabb}. Omit for the whole scene.
47
+ * World-space region filter (ADR 0040 §4, extended by ADR 0051 §2). Accepts
48
+ * either form:
49
+ *
50
+ * - a `minX,minY,minZ,maxX,maxY,maxZ` comma list naming an ad-hoc axis-aligned
51
+ * box to drill into (validated to six finite numbers with `max >= min` on
52
+ * every axis) — the original drill-down the dashboard sends; or
53
+ * - the **id of a registered scene region** (`region=checkout-counter`), the
54
+ * shared vocabulary humans and agents use to talk about places. It is
55
+ * resolved server-side to the stored bounds by {@link resolveRegionFilter},
56
+ * so the aggregation layer only ever sees a {@link WorldAabb}.
57
+ *
58
+ * The two are unambiguous: a box always contains commas, a region id never can.
59
+ * Omit for the whole scene.
36
60
  */
37
61
  const regionFilter = z
38
62
  .string()
@@ -40,6 +64,18 @@ const regionFilter = z
40
64
  .transform((val, ctx) => {
41
65
  if (val == null)
42
66
  return undefined;
67
+ if (!val.includes(",")) {
68
+ const id = regionIdSchema.safeParse(val);
69
+ if (!id.success) {
70
+ ctx.addIssue({
71
+ code: z.ZodIssueCode.custom,
72
+ message: "region must be a registered region id (1-64 chars of [A-Za-z0-9._:-]) " +
73
+ "or 6 comma-separated finite numbers: minX,minY,minZ,maxX,maxY,maxZ",
74
+ });
75
+ return z.NEVER;
76
+ }
77
+ return { kind: "id", id: id.data };
78
+ }
43
79
  const parts = val.split(",").map((s) => Number(s.trim()));
44
80
  if (parts.length !== 6 || parts.some((n) => !Number.isFinite(n))) {
45
81
  ctx.addIssue({
@@ -56,8 +92,38 @@ const regionFilter = z
56
92
  });
57
93
  return z.NEVER;
58
94
  }
59
- return [minX, minY, minZ, maxX, maxY, maxZ];
95
+ return { kind: "box", bounds: [minX, minY, minZ, maxX, maxY, maxZ] };
60
96
  });
97
+ function isRegionError(value) {
98
+ return typeof value === "object" && value !== null && "error" in value;
99
+ }
100
+ /**
101
+ * Resolve a parsed `region` filter to the world box the aggregations take.
102
+ *
103
+ * A box passes straight through. A **region id** is looked up in the scene
104
+ * registry: regions are keyed by `(project, scene, region)`, so the request must
105
+ * also name the `scene` the region belongs to, and an id the project has never
106
+ * registered is a client error (`400`) rather than a silently unfiltered query —
107
+ * an agent must never be told "no hits in the entrance" when it merely misspelt
108
+ * the region. Returns the box, `undefined` when no filter was given, or an error
109
+ * object the caller turns into a `400`.
110
+ */
111
+ async function resolveRegionFilter(store, projectId, scene, filter) {
112
+ if (filter == null)
113
+ return undefined;
114
+ if (filter.kind === "box")
115
+ return filter.bounds;
116
+ if (scene == null) {
117
+ return {
118
+ error: "region names a registered region, so the request must also pass a scene",
119
+ };
120
+ }
121
+ const regions = await store.getSceneRegions(projectId, scene);
122
+ const match = regions.find((r) => r.regionId === filter.id);
123
+ if (!match)
124
+ return { error: `unknown region "${filter.id}" in scene "${scene}"` };
125
+ return match.bounds;
126
+ }
61
127
  /**
62
128
  * Resolve the effective voxel `cellSize` for a spatial heatmap (ADR 0040 §1).
63
129
  * When the caller pins a `cellSize` it wins. Otherwise the resolution is driven
@@ -66,7 +132,26 @@ const regionFilter = z
66
132
  * legible instead of dissolving into a few coarse blocks. Returns `undefined`
67
133
  * when no extent is known, letting the aggregation fall back to its fixed default.
68
134
  */
69
- async function resolveSpatialCellSize(store, projectId, opts) {
135
+ async function resolveSpatialCellSize(store, projectId, opts, request) {
136
+ const resolved = await computeSpatialCellSize(store, projectId, opts);
137
+ if (request != null && resolved != null)
138
+ resolvedCellSizes.set(request, resolved);
139
+ return resolved;
140
+ }
141
+ /**
142
+ * Cell sizes a spatial route resolved for the request in flight.
143
+ *
144
+ * The `format=summary` hook runs after the handler and needs the **effective**
145
+ * cell size to turn a cluster's grid extent into a world-space `region` drill
146
+ * hint (ADR 0040 §1/§4). The handler is the only place that knows it — it may
147
+ * have been derived from the scene's registered bounds — so it is recorded here
148
+ * on the way past. A `WeakMap` rather than a request decorator keeps this an
149
+ * implementation detail of this plugin and cannot leak: the entry dies with the
150
+ * request object.
151
+ */
152
+ const resolvedCellSizes = new WeakMap();
153
+ /** The resolution rule itself; see {@link resolveSpatialCellSize}. */
154
+ async function computeSpatialCellSize(store, projectId, opts) {
70
155
  if (opts.cellSize != null)
71
156
  return opts.cellSize;
72
157
  if (opts.region != null)
@@ -84,6 +169,7 @@ const rangeQuery = z.object({
84
169
  until: z.coerce.number().int().optional(),
85
170
  bins: z.coerce.number().int().positive().max(500).optional(),
86
171
  limit: z.coerce.number().int().positive().max(1000).optional(),
172
+ format: formatFilter,
87
173
  });
88
174
  /** Sessions-list params: a time range, result cap, and camera-mode filter. */
89
175
  const sessionsQueryParams = rangeQuery.extend({ cameraMode: cameraModeFilter });
@@ -153,6 +239,7 @@ const worldHeatmapQueryParams = z.object({
153
239
  source: sourceFilter,
154
240
  cameraMode: cameraModeFilter,
155
241
  region: regionFilter,
242
+ format: formatFilter,
156
243
  });
157
244
  /** World heatmap totals params (ADR 0040 §3): same filters as the world heatmap, no `limit`. */
158
245
  const worldStatsQueryParams = z.object({
@@ -163,6 +250,7 @@ const worldStatsQueryParams = z.object({
163
250
  source: sourceFilter,
164
251
  cameraMode: cameraModeFilter,
165
252
  region: regionFilter,
253
+ format: formatFilter,
166
254
  });
167
255
  /**
168
256
  * Boundary-touch heatmap params (#157, ADR 0048): a positive voxel `cellSize`
@@ -178,6 +266,7 @@ const boundaryHeatmapQueryParams = z.object({
178
266
  scene: sceneFilter,
179
267
  session: sessionFilter,
180
268
  region: regionFilter,
269
+ format: formatFilter,
181
270
  });
182
271
  /** Boundary-touch heatmap totals params (ADR 0040 §3): boundary filters, no `limit`. */
183
272
  const boundaryStatsQueryParams = z.object({
@@ -187,6 +276,7 @@ const boundaryStatsQueryParams = z.object({
187
276
  scene: sceneFilter,
188
277
  session: sessionFilter,
189
278
  region: regionFilter,
279
+ format: formatFilter,
190
280
  });
191
281
  /**
192
282
  * Gaze heatmap params (ADR 0030): a positive voxel `cellSize` plus scene/session
@@ -202,6 +292,7 @@ const gazeHeatmapQueryParams = z.object({
202
292
  session: sessionFilter,
203
293
  cameraMode: cameraModeFilter,
204
294
  region: regionFilter,
295
+ format: formatFilter,
205
296
  });
206
297
  /** Gaze heatmap totals params (ADR 0040 §3): same filters as the gaze heatmap, no `limit`. */
207
298
  const gazeStatsQueryParams = z.object({
@@ -212,6 +303,7 @@ const gazeStatsQueryParams = z.object({
212
303
  session: sessionFilter,
213
304
  cameraMode: cameraModeFilter,
214
305
  region: regionFilter,
306
+ format: formatFilter,
215
307
  });
216
308
  /**
217
309
  * Floor-plan camera-position heatmap params (ADR 0026): a ground-plane bin
@@ -226,6 +318,7 @@ const cameraPositionQueryParams = z.object({
226
318
  session: sessionFilter,
227
319
  cameraMode: cameraModeFilter,
228
320
  region: regionFilter,
321
+ format: formatFilter,
229
322
  });
230
323
  /**
231
324
  * Aggregate desire-line params (#73, ADR 0037): a ground-bin `cellSize` plus
@@ -239,6 +332,7 @@ const aggregatePathQueryParams = z.object({
239
332
  limit: z.coerce.number().int().positive().max(50000).optional(),
240
333
  scene: sceneFilter,
241
334
  cameraMode: cameraModeFilter,
335
+ format: formatFilter,
242
336
  });
243
337
  /** Session-trajectory params: a time range, scene filter, and point cap. */
244
338
  const trajectoryQueryParams = z.object({
@@ -246,6 +340,7 @@ const trajectoryQueryParams = z.object({
246
340
  until: z.coerce.number().int().optional(),
247
341
  limit: z.coerce.number().int().positive().max(10000).optional(),
248
342
  scene: sceneFilter,
343
+ format: formatFilter,
249
344
  });
250
345
  /** Path param for a single session's trajectory. */
251
346
  const sessionPathParams = z.object({
@@ -260,6 +355,7 @@ const clickRayQueryParams = z.object({
260
355
  scene: sceneFilter,
261
356
  source: sourceFilter,
262
357
  session: sessionFilter,
358
+ format: formatFilter,
263
359
  });
264
360
  /**
265
361
  * Flow params: camera-direction bins joined to clicked meshes (§7.5), plus the
@@ -281,6 +377,7 @@ const scenesQueryParams = z.object({
281
377
  since: z.coerce.number().int().optional(),
282
378
  until: z.coerce.number().int().optional(),
283
379
  limit: z.coerce.number().int().positive().max(1000).optional(),
380
+ format: formatFilter,
284
381
  });
285
382
  /** Time-series params: range + scene + optional event-type filter + bucket interval (seconds). */
286
383
  const timeseriesQueryParams = z.object({
@@ -292,12 +389,14 @@ const timeseriesQueryParams = z.object({
292
389
  .string()
293
390
  .regex(/^[a-z_]{1,40}$/)
294
391
  .optional(),
392
+ format: formatFilter,
295
393
  });
296
394
  /** Event-type-counts params: range + optional scene filter. */
297
395
  const eventCountsQueryParams = z.object({
298
396
  since: z.coerce.number().int().optional(),
299
397
  until: z.coerce.number().int().optional(),
300
398
  scene: sceneFilter,
399
+ format: formatFilter,
301
400
  });
302
401
  /**
303
402
  * Funnel params (#78, ADR 0038): a time range, optional scene/camera-mode scope,
@@ -312,6 +411,7 @@ const funnelQueryParams = z.object({
312
411
  scene: sceneFilter,
313
412
  cameraMode: cameraModeFilter,
314
413
  steps: z.string().min(1).max(8192),
414
+ format: formatFilter,
315
415
  });
316
416
  /**
317
417
  * Scene-retention params (#147): a time range plus an optional result cap. No
@@ -322,6 +422,7 @@ const sceneRetentionQueryParams = z.object({
322
422
  since: z.coerce.number().int().optional(),
323
423
  until: z.coerce.number().int().optional(),
324
424
  limit: z.coerce.number().int().positive().max(10000).optional(),
425
+ format: formatFilter,
325
426
  });
326
427
  /**
327
428
  * Load → bounce funnel params (#152): a time range, optional scene scope, and
@@ -353,6 +454,7 @@ const loadBounceQueryParams = z.object({
353
454
  }
354
455
  return parts;
355
456
  }),
457
+ format: formatFilter,
356
458
  });
357
459
  /**
358
460
  * Variant-leaderboard params (#150): a time range, optional scene/camera-mode
@@ -370,6 +472,7 @@ const variantLeaderboardQueryParams = z.object({
370
472
  variant: z.string().min(1).max(2048).optional(),
371
473
  conversion: z.string().min(1).max(2048).optional(),
372
474
  limit: z.coerce.number().int().positive().max(500).optional(),
475
+ format: formatFilter,
373
476
  });
374
477
  /** Scene-coverage params: voxel `cellSize` + scene/session filters + result cap. */
375
478
  const coverageQueryParams = z.object({
@@ -379,6 +482,7 @@ const coverageQueryParams = z.object({
379
482
  limit: z.coerce.number().int().positive().max(10000).optional(),
380
483
  scene: sceneFilter,
381
484
  session: sessionFilter,
485
+ format: formatFilter,
382
486
  });
383
487
  /**
384
488
  * Spatial FPS heatmap params (#145): voxel `cellSize` + scene/session filters + a
@@ -402,6 +506,7 @@ const errorHeatmapQueryParams = z.object({
402
506
  severity: z.string().min(1).max(64).optional(),
403
507
  category: z.string().min(1).max(64).optional(),
404
508
  errorKind: z.string().min(1).max(64).optional(),
509
+ format: formatFilter,
405
510
  });
406
511
  /** Camera-distance params: reference `center` (3 coords) + `bucketSize` + filters. */
407
512
  const cameraDistanceQueryParams = z.object({
@@ -414,6 +519,7 @@ const cameraDistanceQueryParams = z.object({
414
519
  limit: z.coerce.number().int().positive().max(1000).optional(),
415
520
  scene: sceneFilter,
416
521
  session: sessionFilter,
522
+ format: formatFilter,
417
523
  });
418
524
  /** Navigation-stats params: idle/active `moveThreshold` + scene/session filters. */
419
525
  const navigationQueryParams = z.object({
@@ -423,6 +529,7 @@ const navigationQueryParams = z.object({
423
529
  limit: z.coerce.number().int().positive().max(1000).optional(),
424
530
  scene: sceneFilter,
425
531
  session: sessionFilter,
532
+ format: formatFilter,
426
533
  });
427
534
  /** Backtrack-ratio params: coarse-grid `cellSize` + scene/session filters. */
428
535
  const backtrackQueryParams = z.object({
@@ -432,6 +539,7 @@ const backtrackQueryParams = z.object({
432
539
  limit: z.coerce.number().int().positive().max(1000).optional(),
433
540
  scene: sceneFilter,
434
541
  session: sessionFilter,
542
+ format: formatFilter,
435
543
  });
436
544
  /** XR rotation-rate params: `rapidTurn` (rad) threshold + scene/session filters. */
437
545
  const xrRotationQueryParams = z.object({
@@ -441,6 +549,7 @@ const xrRotationQueryParams = z.object({
441
549
  limit: z.coerce.number().int().positive().max(1000).optional(),
442
550
  scene: sceneFilter,
443
551
  session: sessionFilter,
552
+ format: formatFilter,
444
553
  });
445
554
  /** Path param for a single scene's representation. */
446
555
  const sceneParams = z.object({
@@ -451,43 +560,251 @@ const putRepresentationBody = z.object({
451
560
  proxy: sceneProxySchema,
452
561
  label: z.string().max(200).optional(),
453
562
  });
563
+ // --- Response schemas, from the metric registry ---------------------------
564
+ //
565
+ // Every canned read endpoint is a registry metric (ADR 0051 §1), and the registry
566
+ // already declares its output `row` — the same schema the agent tool catalog and
567
+ // the generated OpenAPI document are derived from. Attaching it here as the 200
568
+ // response schema makes the Zod type provider serialise through it, so the shape
569
+ // the docs promise and the shape the wire carries cannot drift apart.
570
+ //
571
+ // Two consequences worth knowing before adding a route:
572
+ //
573
+ // - `z.object()` **strips** keys it does not declare, so a column a handler
574
+ // returns but the registry does not describe would vanish. The test
575
+ // `__tests__/queryResponseSchemas.test.ts` calls every one of these endpoints
576
+ // against a seeded DuckDB store and asserts the serialised body is deep-equal
577
+ // to what the store produced, so a missing declaration fails the build rather
578
+ // than a dashboard panel.
579
+ // - Serialisation is strict, so a `null` in a column the registry declares
580
+ // non-nullable is a 500. The same test also runs every endpoint against an
581
+ // **empty** project, which is where SQL's "aggregate over no rows is NULL"
582
+ // shows up.
583
+ //
584
+ // Since ADR 0051 §2 each schema is the **union** of the three `format` envelopes
585
+ // (`resultEnvelopeSchema`), with the untouched `full` shape first — so a request
586
+ // that does not ask for an envelope is parsed by exactly the schema it was
587
+ // parsed by before, and its bytes cannot drift.
588
+ /** Registry metrics indexed by the collector path that serves them. */
589
+ const METRIC_BY_PATH = new Map(allMetrics()
590
+ .filter((metric) => Boolean(metric.endpoint))
591
+ .map((metric) => [metric.endpoint.path, metric]));
592
+ /** The registry row schema for the metric served on `path`. */
593
+ function rowFor(path) {
594
+ const metric = METRIC_BY_PATH.get(path);
595
+ if (!metric) {
596
+ // Startup-time failure: a route claiming to serve a metric must have one.
597
+ throw new Error(`no registry metric is registered for ${path}`);
598
+ }
599
+ return metric.row;
600
+ }
601
+ /**
602
+ * 200 response schema for a metric that returns a list of rows.
603
+ *
604
+ * Typed as `z.ZodType<unknown[]>` rather than `z.ZodArray<z.ZodObject>`: a bare
605
+ * `ZodObject` infers `Record<string, unknown>`, and the hand-written row
606
+ * *interfaces* in `@uptimizr/db` (`MeshCountRow`, …) have no index signature, so
607
+ * the provider would reject every handler that returns one. Widening keeps the
608
+ * runtime schema — which is what serialises — exactly as declared; the
609
+ * handler-shape check is done for real, against real rows, by
610
+ * `__tests__/queryResponseSchemas.test.ts`.
611
+ */
612
+ function rowsFor(path) {
613
+ const row = rowFor(path);
614
+ return resultEnvelopeSchema(z.array(row), row);
615
+ }
616
+ /**
617
+ * 200 response schema for the three spatial `stats` routes, which return the metric's
618
+ * single row plus the **effective** `cellSize` the collector resolved for the
619
+ * request (ADR 0040 §1) so the caller can label its own grid. The echo is a
620
+ * property of the route, not of the aggregation, so it is added here rather than
621
+ * put in the registry row.
622
+ */
623
+ function statsRowFor(path) {
624
+ const row = rowFor(path).extend({ cellSize: z.number() });
625
+ return resultEnvelopeSchema(row, row);
626
+ }
627
+ /**
628
+ * 200 response schema for the two **resource** metrics, which return a single
629
+ * record rather than a list of aggregate rows. Widened for the same reason as
630
+ * {@link rowsFor}: `SessionMeta` / `SceneRepresentation` are declared interfaces
631
+ * with no index signature.
632
+ */
633
+ function singleRowFor(path) {
634
+ return rowFor(path);
635
+ }
636
+ /**
637
+ * Filters carried in the path rather than the querystring, keyed by the filter
638
+ * ids the registry declares for them (`endpoint.pathParams`, in `:` order) — so
639
+ * a session trajectory's `:sessionId` is echoed back as `session`, the name the
640
+ * rest of the query surface uses.
641
+ */
642
+ function pathFilters(metric, request) {
643
+ const params = (request.params ?? {});
644
+ const segments = (metric.endpoint?.path ?? "")
645
+ .split("/")
646
+ .filter((segment) => segment.startsWith(":"))
647
+ .map((segment) => segment.slice(1));
648
+ const ids = metric.endpoint?.pathParams ?? [];
649
+ const filters = {};
650
+ segments.forEach((segment, index) => {
651
+ const id = ids[index];
652
+ const value = params[segment];
653
+ if (id != null && value != null)
654
+ filters[id] = value;
655
+ });
656
+ return filters;
657
+ }
658
+ /**
659
+ * Everything the summariser needs to know about the request that produced the
660
+ * rows: the window and filters to echo, the row cap that was in force (so
661
+ * `truncated` is honest), and the effective spatial `cellSize` — the resolved
662
+ * one where a handler derived it from the scene bounds, else whatever the caller
663
+ * pinned.
664
+ */
665
+ function summaryContextFor(metric, request) {
666
+ const query = (request.query ?? {});
667
+ const cellSize = resolvedCellSizes.get(request) ?? query.cellSize;
668
+ return {
669
+ range: {
670
+ since: typeof query.since === "number" ? query.since : null,
671
+ until: typeof query.until === "number" ? query.until : null,
672
+ },
673
+ filters: { ...query, ...pathFilters(metric, request) },
674
+ limit: typeof query.limit === "number" ? query.limit : undefined,
675
+ cellSize: typeof cellSize === "number" ? cellSize : undefined,
676
+ };
677
+ }
678
+ /** `{ error }` body the read routes send for a miss. Declared so `reply.code(404)` stays typed. */
679
+ const notFoundResponse = z.object({ error: z.string() });
680
+ /**
681
+ * `{ error, details? }` body sent when a JSON-encoded query parameter (a funnel
682
+ * or variant predicate) fails its schema. Declared alongside the 200 so
683
+ * `reply.code(400)` stays typed once a route has a response schema.
684
+ */
685
+ const badRequestResponse = z.object({ error: z.string(), details: z.unknown().optional() });
686
+ /**
687
+ * Body for declaring a scene's regions (ADR 0051 §2): the scene's whole set.
688
+ * The write replaces what is stored, so an empty array clears the scene — the
689
+ * caller declares what the regions *are* rather than patching them one by one.
690
+ * `sceneRegionsSchema` bounds the count, the label/description lengths, the box
691
+ * shape, and rejects duplicate ids at the edge.
692
+ */
693
+ const putRegionsBody = z.object({
694
+ regions: sceneRegionsSchema,
695
+ });
454
696
  /**
455
697
  * Authenticate a read request with a project API key (`x-api-key`). Returns the
456
- * resolved project id, or sends a 401 and returns `null`. Reads are always scoped
457
- * to the authenticated project — any client-supplied project id is ignored.
698
+ * resolved project id, or sends a 401/403 and returns `null`. Reads are always
699
+ * scoped to the authenticated project — any client-supplied project id is
700
+ * ignored — and require a `query`-capable key.
458
701
  */
459
702
  async function authProject(request, reply, store) {
460
- const key = request.headers["x-api-key"];
461
- if (typeof key !== "string" || key.length === 0) {
462
- await reply.code(401).send({ error: "missing api key" });
463
- return null;
464
- }
465
- const projectId = await store.resolveApiKey(key);
466
- if (!projectId) {
467
- await reply.code(401).send({ error: "invalid api key" });
468
- return null;
469
- }
470
- // Read endpoints require a `query`-capable key (ingest-only keys cannot read).
471
- if (projectId.capability !== "query") {
472
- await reply.code(403).send({ error: "api key not permitted to read" });
473
- return null;
474
- }
475
- return projectId.projectId;
703
+ const resolved = await requireCapability(request, reply, store, "query");
704
+ return resolved?.projectId ?? null;
476
705
  }
706
+ /**
707
+ * Audit-log query params: an optional epoch-ms range and a bounded row cap.
708
+ */
709
+ const auditQueryParams = z.object({
710
+ since: z.coerce.number().int().optional(),
711
+ until: z.coerce.number().int().optional(),
712
+ limit: z.coerce.number().int().positive().max(1000).optional(),
713
+ });
477
714
  /**
478
715
  * Query API. All aggregations are computed at query time (v1). Every route is
479
716
  * scoped to the authenticated project.
480
717
  */
481
718
  export const queryRoutes = async (app, { store, config }) => {
482
719
  const r = app.withTypeProvider();
483
- r.get("/api/v1/sessions", { schema: { querystring: sessionsQueryParams } }, async (req, reply) => {
720
+ // --- format=table | summary (ADR 0051 §2, design sketch §B.1) ------------
721
+ //
722
+ // The whole envelope feature lives in this one hook. It runs after a handler
723
+ // has produced its rows and before the response schema serialises them, which
724
+ // is the only place that can see *every* registry endpoint's result without
725
+ // any of the 67 handlers knowing the feature exists — exactly the "keep the
726
+ // Fastify layer thin" rule (ADR 0005). The shaping itself is
727
+ // `@uptimizr/db`'s: `tableResult` / `summarizeRows` are pure, registry-driven
728
+ // functions with no store or request in sight.
729
+ //
730
+ // Four conditions have to hold before anything is touched, and the first of
731
+ // them is the promise that this feature is invisible by default: no `format`,
732
+ // or `format=full`, returns the payload untouched, so the dashboard — which
733
+ // never sends the parameter — cannot be affected. The non-metric routes below
734
+ // (`/whoami`, `/audit`) describe the *calling key* rather than project
735
+ // telemetry; they are not in `METRIC_BY_PATH`, so the hook passes their
736
+ // payloads straight through (asserted in `resultFormat.test.ts`).
737
+ app.addHook("preSerialization", async (request, reply, payload) => {
738
+ if (reply.statusCode !== 200)
739
+ return payload;
740
+ const format = request.query?.format;
741
+ if (format == null || format === "full")
742
+ return payload;
743
+ const metric = METRIC_BY_PATH.get(request.routeOptions.url ?? "");
744
+ // Not a registry metric (the raw event stream, which has carried its own
745
+ // `format=json|ndjson` since long before this; `/whoami`; `/audit`), or one
746
+ // of the two **resource** reads. A resource is a single stored record rather
747
+ // than an aggregation: it declares no filters, no querystring schema — so a
748
+ // stray `?format=` would arrive unvalidated — and has nothing to summarise.
749
+ if (metric == null || metric.builder == null)
750
+ return payload;
751
+ // A single-row endpoint (the spatial `stats` routes) still summarises as a
752
+ // one-row result; `null` never happens on a 200 but costs nothing to guard.
753
+ const rows = (Array.isArray(payload) ? payload : payload == null ? [] : [payload]);
754
+ const context = summaryContextFor(metric, request);
755
+ const shaped = format === "table"
756
+ ? tableResult(metric, rows, context)
757
+ : summarizeRows(metric, rows, context);
758
+ // `null` only comes back for an unknown metric, which `METRIC_BY_PATH`
759
+ // already excluded — fall back to the raw rows rather than fail the request.
760
+ return shaped ?? payload;
761
+ });
762
+ // --- Key identity + audit (#309, ADR 0051 §7) ----------------------------
763
+ // What is this key allowed to do? Agent clients (and the MCP server) call it
764
+ // first so they can register only the tools their capabilities permit. It
765
+ // reports the key's *id*, never the key itself.
766
+ r.get("/api/v1/whoami", async (req, reply) => {
767
+ const resolved = await requireCapability(req, reply, store, "query");
768
+ if (!resolved)
769
+ return reply;
770
+ return {
771
+ projectId: resolved.projectId,
772
+ keyId: resolved.keyId,
773
+ capabilities: resolved.capabilities,
774
+ label: resolved.label,
775
+ rateLimit: resolved.rateLimit ?? {
776
+ max: config.rateLimitMax,
777
+ windowMs: config.rateLimitWindowMs,
778
+ },
779
+ /** Whether {@link rateLimit} is the key's own budget or the collector default. */
780
+ rateLimitSource: resolved.rateLimit ? "key" : "default",
781
+ };
782
+ });
783
+ // The project's agent-audit trail, newest first. Reading it needs the ordinary
784
+ // `query` capability — it is the project owner's window onto what every agent
785
+ // key has asked for. Rows carry no key material and bounded, redacted params.
786
+ r.get("/api/v1/audit", { schema: { querystring: auditQueryParams } }, async (req, reply) => {
787
+ const projectId = await authProject(req, reply, store);
788
+ if (!projectId)
789
+ return reply;
790
+ const rows = await store.listAudit(projectId, req.query);
791
+ return rows.map((row) => ({ ...row, at: row.at.toISOString() }));
792
+ });
793
+ r.get("/api/v1/sessions", {
794
+ schema: { querystring: sessionsQueryParams, response: { 200: rowsFor("/api/v1/sessions") } },
795
+ }, async (req, reply) => {
484
796
  const projectId = await authProject(req, reply, store);
485
797
  if (!projectId)
486
798
  return reply;
487
799
  const { cameraMode, ...rest } = req.query;
488
800
  return store.listSessions(projectId, { ...rest, cameraType: cameraTypeForMode(cameraMode) });
489
801
  });
490
- r.get("/api/v1/heatmaps/pointer", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
802
+ r.get("/api/v1/heatmaps/pointer", {
803
+ schema: {
804
+ querystring: pointerHeatmapQueryParams,
805
+ response: { 200: rowsFor("/api/v1/heatmaps/pointer") },
806
+ },
807
+ }, async (req, reply) => {
491
808
  const projectId = await authProject(req, reply, store);
492
809
  if (!projectId)
493
810
  return reply;
@@ -503,16 +820,24 @@ export const queryRoutes = async (app, { store, config }) => {
503
820
  return reply;
504
821
  return store.meshUvHeatmap(projectId, req.query);
505
822
  });
506
- r.get("/api/v1/heatmaps/world", { schema: { querystring: worldHeatmapQueryParams } }, async (req, reply) => {
823
+ r.get("/api/v1/heatmaps/world", {
824
+ schema: {
825
+ querystring: worldHeatmapQueryParams,
826
+ response: { 200: rowsFor("/api/v1/heatmaps/world"), 400: badRequestResponse },
827
+ },
828
+ }, async (req, reply) => {
507
829
  const projectId = await authProject(req, reply, store);
508
830
  if (!projectId)
509
831
  return reply;
510
- const { cameraMode, region, cellSize, ...rest } = req.query;
832
+ const { cameraMode, region: regionParam, cellSize, ...rest } = req.query;
833
+ const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
834
+ if (isRegionError(region))
835
+ return reply.code(400).send(region);
511
836
  const resolved = await resolveSpatialCellSize(store, projectId, {
512
837
  cellSize,
513
838
  scene: rest.scene,
514
839
  region,
515
- });
840
+ }, req);
516
841
  return store.worldHeatmap(projectId, {
517
842
  ...rest,
518
843
  region,
@@ -524,16 +849,24 @@ export const queryRoutes = async (app, { store, config }) => {
524
849
  // the truncated top-N voxels, so the viewer can report coverage/cold-spots and
525
850
  // "showing top N of M cells". Echoes the effective (possibly bounds-derived)
526
851
  // cellSize so the caller can label its own grid.
527
- r.get("/api/v1/heatmaps/world/stats", { schema: { querystring: worldStatsQueryParams } }, async (req, reply) => {
852
+ r.get("/api/v1/heatmaps/world/stats", {
853
+ schema: {
854
+ querystring: worldStatsQueryParams,
855
+ response: { 200: statsRowFor("/api/v1/heatmaps/world/stats"), 400: badRequestResponse },
856
+ },
857
+ }, async (req, reply) => {
528
858
  const projectId = await authProject(req, reply, store);
529
859
  if (!projectId)
530
860
  return reply;
531
- const { cameraMode, region, cellSize, ...rest } = req.query;
861
+ const { cameraMode, region: regionParam, cellSize, ...rest } = req.query;
862
+ const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
863
+ if (isRegionError(region))
864
+ return reply.code(400).send(region);
532
865
  const resolved = await resolveSpatialCellSize(store, projectId, {
533
866
  cellSize,
534
867
  scene: rest.scene,
535
868
  region,
536
- });
869
+ }, req);
537
870
  const stats = await store.worldHeatmapStats(projectId, {
538
871
  ...rest,
539
872
  region,
@@ -544,16 +877,24 @@ export const queryRoutes = async (app, { store, config }) => {
544
877
  });
545
878
  // World-space gaze heatmap (ADR 0030) — the "what did people actually look at"
546
879
  // sibling of the world (click) heatmap: voxel-binned `camera_sample` gaze hits.
547
- r.get("/api/v1/heatmaps/gaze", { schema: { querystring: gazeHeatmapQueryParams } }, async (req, reply) => {
880
+ r.get("/api/v1/heatmaps/gaze", {
881
+ schema: {
882
+ querystring: gazeHeatmapQueryParams,
883
+ response: { 200: rowsFor("/api/v1/heatmaps/gaze"), 400: badRequestResponse },
884
+ },
885
+ }, async (req, reply) => {
548
886
  const projectId = await authProject(req, reply, store);
549
887
  if (!projectId)
550
888
  return reply;
551
- const { cameraMode, region, cellSize, ...rest } = req.query;
889
+ const { cameraMode, region: regionParam, cellSize, ...rest } = req.query;
890
+ const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
891
+ if (isRegionError(region))
892
+ return reply.code(400).send(region);
552
893
  const resolved = await resolveSpatialCellSize(store, projectId, {
553
894
  cellSize,
554
895
  scene: rest.scene,
555
896
  region,
556
- });
897
+ }, req);
557
898
  return store.gazeHeatmap(projectId, {
558
899
  ...rest,
559
900
  region,
@@ -562,16 +903,24 @@ export const queryRoutes = async (app, { store, config }) => {
562
903
  });
563
904
  });
564
905
  // Gaze heatmap totals (ADR 0040 §3) — gaze sibling of the world stats route.
565
- r.get("/api/v1/heatmaps/gaze/stats", { schema: { querystring: gazeStatsQueryParams } }, async (req, reply) => {
906
+ r.get("/api/v1/heatmaps/gaze/stats", {
907
+ schema: {
908
+ querystring: gazeStatsQueryParams,
909
+ response: { 200: statsRowFor("/api/v1/heatmaps/gaze/stats"), 400: badRequestResponse },
910
+ },
911
+ }, async (req, reply) => {
566
912
  const projectId = await authProject(req, reply, store);
567
913
  if (!projectId)
568
914
  return reply;
569
- const { cameraMode, region, cellSize, ...rest } = req.query;
915
+ const { cameraMode, region: regionParam, cellSize, ...rest } = req.query;
916
+ const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
917
+ if (isRegionError(region))
918
+ return reply.code(400).send(region);
570
919
  const resolved = await resolveSpatialCellSize(store, projectId, {
571
920
  cellSize,
572
921
  scene: rest.scene,
573
922
  region,
574
- });
923
+ }, req);
575
924
  const stats = await store.gazeHeatmapStats(projectId, {
576
925
  ...rest,
577
926
  region,
@@ -580,7 +929,12 @@ export const queryRoutes = async (app, { store, config }) => {
580
929
  });
581
930
  return { cellSize: resolved ?? 0.5, cells: stats.cells, hits: stats.hits };
582
931
  });
583
- r.get("/api/v1/heatmaps/camera", { schema: { querystring: cameraHeatmapQueryParams } }, async (req, reply) => {
932
+ r.get("/api/v1/heatmaps/camera", {
933
+ schema: {
934
+ querystring: cameraHeatmapQueryParams,
935
+ response: { 200: rowsFor("/api/v1/heatmaps/camera") },
936
+ },
937
+ }, async (req, reply) => {
584
938
  const projectId = await authProject(req, reply, store);
585
939
  if (!projectId)
586
940
  return reply;
@@ -591,7 +945,12 @@ export const queryRoutes = async (app, { store, config }) => {
591
945
  // saw, bucketed across sessions (0–25 / 25–50 / 50–75 / 75–100%). Derived from
592
946
  // the same `camera_sample` direction grid as the view-direction dome; no schema
593
947
  // change. Shares the camera-heatmap params (bins + scene/session + cameraMode).
594
- r.get("/api/v1/coverage/view-histogram", { schema: { querystring: cameraHeatmapQueryParams } }, async (req, reply) => {
948
+ r.get("/api/v1/coverage/view-histogram", {
949
+ schema: {
950
+ querystring: cameraHeatmapQueryParams,
951
+ response: { 200: rowsFor("/api/v1/coverage/view-histogram") },
952
+ },
953
+ }, async (req, reply) => {
595
954
  const projectId = await authProject(req, reply, store);
596
955
  if (!projectId)
597
956
  return reply;
@@ -603,18 +962,33 @@ export const queryRoutes = async (app, { store, config }) => {
603
962
  });
604
963
  // Floor-plan camera-position heatmap (ADR 0026) — the first-person analog of
605
964
  // the 2D pointer heatmap: where visitors stand/dwell on the X/Z ground plane.
606
- r.get("/api/v1/heatmaps/position", { schema: { querystring: cameraPositionQueryParams } }, async (req, reply) => {
965
+ r.get("/api/v1/heatmaps/position", {
966
+ schema: {
967
+ querystring: cameraPositionQueryParams,
968
+ response: { 200: rowsFor("/api/v1/heatmaps/position"), 400: badRequestResponse },
969
+ },
970
+ }, async (req, reply) => {
607
971
  const projectId = await authProject(req, reply, store);
608
972
  if (!projectId)
609
973
  return reply;
610
- const { cameraMode, ...rest } = req.query;
974
+ const { cameraMode, region: regionParam, ...rest } = req.query;
975
+ const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
976
+ if (isRegionError(region))
977
+ return reply.code(400).send(region);
611
978
  return store.cameraPositionHeatmap(projectId, {
612
979
  ...rest,
980
+ region,
613
981
  cameraType: cameraTypeForMode(cameraMode),
614
982
  });
615
983
  });
616
984
  // One session's ordered walked path (ADR 0026) — camera positions, oldest first.
617
- r.get("/api/v1/sessions/:sessionId/trajectory", { schema: { params: sessionPathParams, querystring: trajectoryQueryParams } }, async (req, reply) => {
985
+ r.get("/api/v1/sessions/:sessionId/trajectory", {
986
+ schema: {
987
+ params: sessionPathParams,
988
+ querystring: trajectoryQueryParams,
989
+ response: { 200: rowsFor("/api/v1/sessions/:sessionId/trajectory") },
990
+ },
991
+ }, async (req, reply) => {
618
992
  const projectId = await authProject(req, reply, store);
619
993
  if (!projectId)
620
994
  return reply;
@@ -623,7 +997,12 @@ export const queryRoutes = async (app, { store, config }) => {
623
997
  // Aggregate desire lines (#73, ADR 0037) — every session's camera path binned
624
998
  // onto the ground grid and returned as ordered, session-keyed points; the
625
999
  // dashboard overlays many low-opacity poly-lines into a crowd-level route map.
626
- r.get("/api/v1/paths", { schema: { querystring: aggregatePathQueryParams } }, async (req, reply) => {
1000
+ r.get("/api/v1/paths", {
1001
+ schema: {
1002
+ querystring: aggregatePathQueryParams,
1003
+ response: { 200: rowsFor("/api/v1/paths") },
1004
+ },
1005
+ }, async (req, reply) => {
627
1006
  const projectId = await authProject(req, reply, store);
628
1007
  if (!projectId)
629
1008
  return reply;
@@ -634,7 +1013,12 @@ export const queryRoutes = async (app, { store, config }) => {
634
1013
  });
635
1014
  });
636
1015
  // View-gated click rays (design §7.2/§7.3) — camera-origin → hit rays per voxel/mesh.
637
- r.get("/api/v1/heatmaps/click-rays", { schema: { querystring: clickRayQueryParams } }, async (req, reply) => {
1016
+ r.get("/api/v1/heatmaps/click-rays", {
1017
+ schema: {
1018
+ querystring: clickRayQueryParams,
1019
+ response: { 200: rowsFor("/api/v1/heatmaps/click-rays") },
1020
+ },
1021
+ }, async (req, reply) => {
638
1022
  const projectId = await authProject(req, reply, store);
639
1023
  if (!projectId)
640
1024
  return reply;
@@ -642,7 +1026,12 @@ export const queryRoutes = async (app, { store, config }) => {
642
1026
  });
643
1027
  // Aggregate gaze→mesh flow links (design §7.5): direction bins → clicked meshes.
644
1028
  // Position-aware mode (§7.8): a standpoint voxel dimension for walkable scenes.
645
- r.get("/api/v1/heatmaps/flow", { schema: { querystring: flowHeatmapQueryParams } }, async (req, reply) => {
1029
+ r.get("/api/v1/heatmaps/flow", {
1030
+ schema: {
1031
+ querystring: flowHeatmapQueryParams,
1032
+ response: { 200: rowsFor("/api/v1/heatmaps/flow") },
1033
+ },
1034
+ }, async (req, reply) => {
646
1035
  const projectId = await authProject(req, reply, store);
647
1036
  if (!projectId)
648
1037
  return reply;
@@ -656,7 +1045,12 @@ export const queryRoutes = async (app, { store, config }) => {
656
1045
  : undefined,
657
1046
  });
658
1047
  });
659
- r.get("/api/v1/meshes/top", { schema: { querystring: sessionScopedRangeQuery } }, async (req, reply) => {
1048
+ r.get("/api/v1/meshes/top", {
1049
+ schema: {
1050
+ querystring: sessionScopedRangeQuery,
1051
+ response: { 200: rowsFor("/api/v1/meshes/top") },
1052
+ },
1053
+ }, async (req, reply) => {
660
1054
  const projectId = await authProject(req, reply, store);
661
1055
  if (!projectId)
662
1056
  return reply;
@@ -665,7 +1059,12 @@ export const queryRoutes = async (app, { store, config }) => {
665
1059
  // Per-mesh source split (#74) — the most-interacted-mesh tally broken out by
666
1060
  // the input `source` that drove each interaction; the leaderboard reads both
667
1061
  // rank (sum across sources) and the per-row breakdown from this one query.
668
- r.get("/api/v1/meshes/sources", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
1062
+ r.get("/api/v1/meshes/sources", {
1063
+ schema: {
1064
+ querystring: pointerHeatmapQueryParams,
1065
+ response: { 200: rowsFor("/api/v1/meshes/sources") },
1066
+ },
1067
+ }, async (req, reply) => {
669
1068
  const projectId = await authProject(req, reply, store);
670
1069
  if (!projectId)
671
1070
  return reply;
@@ -674,7 +1073,12 @@ export const queryRoutes = async (app, { store, config }) => {
674
1073
  // Per-mesh interaction trend (#74) — the most-interacted-mesh tally bucketed
675
1074
  // into fixed `interval`-second windows so the leaderboard can draw a per-mesh
676
1075
  // sparkline and a rising/falling delta over the active range.
677
- r.get("/api/v1/meshes/trend", { schema: { querystring: meshTrendQueryParams } }, async (req, reply) => {
1076
+ r.get("/api/v1/meshes/trend", {
1077
+ schema: {
1078
+ querystring: meshTrendQueryParams,
1079
+ response: { 200: rowsFor("/api/v1/meshes/trend") },
1080
+ },
1081
+ }, async (req, reply) => {
678
1082
  const projectId = await authProject(req, reply, store);
679
1083
  if (!projectId)
680
1084
  return reply;
@@ -682,7 +1086,12 @@ export const queryRoutes = async (app, { store, config }) => {
682
1086
  });
683
1087
  // Object dwell ranking (#37) — per-mesh attention from `mesh_visibility`
684
1088
  // summaries (total visible/centered time, peak screen fraction).
685
- r.get("/api/v1/meshes/dwell", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1089
+ r.get("/api/v1/meshes/dwell", {
1090
+ schema: {
1091
+ querystring: heatmapQueryParams,
1092
+ response: { 200: rowsFor("/api/v1/meshes/dwell") },
1093
+ },
1094
+ }, async (req, reply) => {
686
1095
  const projectId = await authProject(req, reply, store);
687
1096
  if (!projectId)
688
1097
  return reply;
@@ -692,7 +1101,12 @@ export const queryRoutes = async (app, { store, config }) => {
692
1101
  // cross-referenced against `mesh_interaction` + `hover_dwell` engagement.
693
1102
  // Surfaces objects that render but are never noticed (the inverse of the
694
1103
  // most-interacted / part-popularity leaderboards).
695
- r.get("/api/v1/meshes/blind-spots", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1104
+ r.get("/api/v1/meshes/blind-spots", {
1105
+ schema: {
1106
+ querystring: heatmapQueryParams,
1107
+ response: { 200: rowsFor("/api/v1/meshes/blind-spots") },
1108
+ },
1109
+ }, async (req, reply) => {
696
1110
  const projectId = await authProject(req, reply, store);
697
1111
  if (!projectId)
698
1112
  return reply;
@@ -701,7 +1115,12 @@ export const queryRoutes = async (app, { store, config }) => {
701
1115
  // Interaction-kind breakdown (#72, ADR 0023) — per-mesh counts of each
702
1116
  // interaction kind (hover / pick / click / drag / …) from `mesh_interaction`
703
1117
  // events; how an audience acts on objects, not just which ones draw attention.
704
- r.get("/api/v1/meshes/kinds", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
1118
+ r.get("/api/v1/meshes/kinds", {
1119
+ schema: {
1120
+ querystring: pointerHeatmapQueryParams,
1121
+ response: { 200: rowsFor("/api/v1/meshes/kinds") },
1122
+ },
1123
+ }, async (req, reply) => {
705
1124
  const projectId = await authProject(req, reply, store);
706
1125
  if (!projectId)
707
1126
  return reply;
@@ -710,7 +1129,12 @@ export const queryRoutes = async (app, { store, config }) => {
710
1129
  // Reachability report (#151) — per-mesh histogram of the standpoint→interaction
711
1130
  // distance (ASOF-joins `mesh_interaction` world points to the nearest preceding
712
1131
  // `camera_sample`). Far bands flag meshes/UI reached from an uncomfortable range.
713
- r.get("/api/v1/meshes/reachability", { schema: { querystring: reachabilityQueryParams } }, async (req, reply) => {
1132
+ r.get("/api/v1/meshes/reachability", {
1133
+ schema: {
1134
+ querystring: reachabilityQueryParams,
1135
+ response: { 200: rowsFor("/api/v1/meshes/reachability") },
1136
+ },
1137
+ }, async (req, reply) => {
714
1138
  const projectId = await authProject(req, reply, store);
715
1139
  if (!projectId)
716
1140
  return reply;
@@ -718,7 +1142,12 @@ export const queryRoutes = async (app, { store, config }) => {
718
1142
  });
719
1143
  // Dead-click rate (#46) — total clicks vs. clicks that hit empty space; a 3D
720
1144
  // discoverability signal. The consumer derives the rate from the two counts.
721
- r.get("/api/v1/clicks/dead", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
1145
+ r.get("/api/v1/clicks/dead", {
1146
+ schema: {
1147
+ querystring: pointerHeatmapQueryParams,
1148
+ response: { 200: rowsFor("/api/v1/clicks/dead") },
1149
+ },
1150
+ }, async (req, reply) => {
722
1151
  const projectId = await authProject(req, reply, store);
723
1152
  if (!projectId)
724
1153
  return reply;
@@ -726,7 +1155,12 @@ export const queryRoutes = async (app, { store, config }) => {
726
1155
  });
727
1156
  // Rage clicks (#47) — rapid repeated clicks on the same mesh within a short
728
1157
  // window; a frustration signal derived from the click stream.
729
- r.get("/api/v1/clicks/rage", { schema: { querystring: rageClickQueryParams } }, async (req, reply) => {
1158
+ r.get("/api/v1/clicks/rage", {
1159
+ schema: {
1160
+ querystring: rageClickQueryParams,
1161
+ response: { 200: rowsFor("/api/v1/clicks/rage") },
1162
+ },
1163
+ }, async (req, reply) => {
730
1164
  const projectId = await authProject(req, reply, store);
731
1165
  if (!projectId)
732
1166
  return reply;
@@ -734,7 +1168,12 @@ export const queryRoutes = async (app, { store, config }) => {
734
1168
  });
735
1169
  // Hover hesitation (#48) — per-mesh dwell spent hovering an object without
736
1170
  // clicking it; flags things that look interactive but aren't.
737
- r.get("/api/v1/hover/dwell", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
1171
+ r.get("/api/v1/hover/dwell", {
1172
+ schema: {
1173
+ querystring: pointerHeatmapQueryParams,
1174
+ response: { 200: rowsFor("/api/v1/hover/dwell") },
1175
+ },
1176
+ }, async (req, reply) => {
738
1177
  const projectId = await authProject(req, reply, store);
739
1178
  if (!projectId)
740
1179
  return reply;
@@ -742,7 +1181,12 @@ export const queryRoutes = async (app, { store, config }) => {
742
1181
  });
743
1182
  // Compile stalls (#42) — per-phase shader/pipeline compilation hitches; the
744
1183
  // felt first-interaction jank that `frame_perf` averages away.
745
- r.get("/api/v1/perf/compile-stalls", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1184
+ r.get("/api/v1/perf/compile-stalls", {
1185
+ schema: {
1186
+ querystring: heatmapQueryParams,
1187
+ response: { 200: rowsFor("/api/v1/perf/compile-stalls") },
1188
+ },
1189
+ }, async (req, reply) => {
746
1190
  const projectId = await authProject(req, reply, store);
747
1191
  if (!projectId)
748
1192
  return reply;
@@ -757,7 +1201,12 @@ export const queryRoutes = async (app, { store, config }) => {
757
1201
  return reply;
758
1202
  return store.arPlacementTimeToPlace(projectId, req.query);
759
1203
  });
760
- r.get("/api/v1/ar/placement/attempts", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1204
+ r.get("/api/v1/ar/placement/attempts", {
1205
+ schema: {
1206
+ querystring: heatmapQueryParams,
1207
+ response: { 200: rowsFor("/api/v1/ar/placement/attempts") },
1208
+ },
1209
+ }, async (req, reply) => {
761
1210
  const projectId = await authProject(req, reply, store);
762
1211
  if (!projectId)
763
1212
  return reply;
@@ -769,7 +1218,9 @@ export const queryRoutes = async (app, { store, config }) => {
769
1218
  return reply;
770
1219
  return store.arPlacementSurfaces(projectId, req.query);
771
1220
  });
772
- r.get("/api/v1/perf", { schema: { querystring: sessionScopedRangeQuery } }, async (req, reply) => {
1221
+ r.get("/api/v1/perf", {
1222
+ schema: { querystring: sessionScopedRangeQuery, response: { 200: rowsFor("/api/v1/perf") } },
1223
+ }, async (req, reply) => {
773
1224
  const projectId = await authProject(req, reply, store);
774
1225
  if (!projectId)
775
1226
  return reply;
@@ -778,7 +1229,12 @@ export const queryRoutes = async (app, { store, config }) => {
778
1229
  // Render-scale truth (#71, ADR 0021) — the FPS headline paired with the
779
1230
  // resolution the engine actually rendered at, so "good FPS" can be read against
780
1231
  // the render scale an adaptive renderer bought it with.
781
- r.get("/api/v1/perf/render-scale", { schema: { querystring: sessionScopedRangeQuery } }, async (req, reply) => {
1232
+ r.get("/api/v1/perf/render-scale", {
1233
+ schema: {
1234
+ querystring: sessionScopedRangeQuery,
1235
+ response: { 200: rowsFor("/api/v1/perf/render-scale") },
1236
+ },
1237
+ }, async (req, reply) => {
782
1238
  const projectId = await authProject(req, reply, store);
783
1239
  if (!projectId)
784
1240
  return reply;
@@ -786,7 +1242,12 @@ export const queryRoutes = async (app, { store, config }) => {
786
1242
  });
787
1243
  // Resource footprint (#44) — GPU / memory cost summary (avg + peak texture/
788
1244
  // geometry bytes, triangles/vertices, JS heap) the scene asked of the device.
789
- r.get("/api/v1/perf/resources", { schema: { querystring: sessionScopedRangeQuery } }, async (req, reply) => {
1245
+ r.get("/api/v1/perf/resources", {
1246
+ schema: {
1247
+ querystring: sessionScopedRangeQuery,
1248
+ response: { 200: rowsFor("/api/v1/perf/resources") },
1249
+ },
1250
+ }, async (req, reply) => {
790
1251
  const projectId = await authProject(req, reply, store);
791
1252
  if (!projectId)
792
1253
  return reply;
@@ -795,7 +1256,12 @@ export const queryRoutes = async (app, { store, config }) => {
795
1256
  // FPS distribution (#81, ADR 0028 §1) — per-session p05/p50/p95 FPS summarized
796
1257
  // across sessions (median-of-medians); the distribution-honest headline that
797
1258
  // replaces the volume-chart mean.
798
- r.get("/api/v1/perf/distribution", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1259
+ r.get("/api/v1/perf/distribution", {
1260
+ schema: {
1261
+ querystring: heatmapQueryParams,
1262
+ response: { 200: rowsFor("/api/v1/perf/distribution") },
1263
+ },
1264
+ }, async (req, reply) => {
799
1265
  const projectId = await authProject(req, reply, store);
800
1266
  if (!projectId)
801
1267
  return reply;
@@ -803,7 +1269,12 @@ export const queryRoutes = async (app, { store, config }) => {
803
1269
  });
804
1270
  // FPS histogram (#81, ADR 0028 §1) — per-session median FPS bucketed into
805
1271
  // `bucket`-wide bins; one session = one data point.
806
- r.get("/api/v1/perf/fps-histogram", { schema: { querystring: fpsHistogramQueryParams } }, async (req, reply) => {
1272
+ r.get("/api/v1/perf/fps-histogram", {
1273
+ schema: {
1274
+ querystring: fpsHistogramQueryParams,
1275
+ response: { 200: rowsFor("/api/v1/perf/fps-histogram") },
1276
+ },
1277
+ }, async (req, reply) => {
807
1278
  const projectId = await authProject(req, reply, store);
808
1279
  if (!projectId)
809
1280
  return reply;
@@ -811,7 +1282,12 @@ export const queryRoutes = async (app, { store, config }) => {
811
1282
  });
812
1283
  // Frame-time percentiles (#81, ADR 0028 §1) — per-session median frame time and
813
1284
  // worst-window p95 (ms), summarized across sessions.
814
- r.get("/api/v1/perf/frame-time", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1285
+ r.get("/api/v1/perf/frame-time", {
1286
+ schema: {
1287
+ querystring: heatmapQueryParams,
1288
+ response: { 200: rowsFor("/api/v1/perf/frame-time") },
1289
+ },
1290
+ }, async (req, reply) => {
815
1291
  const projectId = await authProject(req, reply, store);
816
1292
  if (!projectId)
817
1293
  return reply;
@@ -819,7 +1295,9 @@ export const queryRoutes = async (app, { store, config }) => {
819
1295
  });
820
1296
  // Jank rate (#81, ADR 0028 §1) — per-session long-frames-per-window rate,
821
1297
  // reported as the median and worst-decile session.
822
- r.get("/api/v1/perf/jank", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1298
+ r.get("/api/v1/perf/jank", {
1299
+ schema: { querystring: heatmapQueryParams, response: { 200: rowsFor("/api/v1/perf/jank") } },
1300
+ }, async (req, reply) => {
823
1301
  const projectId = await authProject(req, reply, store);
824
1302
  if (!projectId)
825
1303
  return reply;
@@ -828,7 +1306,12 @@ export const queryRoutes = async (app, { store, config }) => {
828
1306
  // Perf-correlated churn (#144) — of the sessions that ended in range, how many
829
1307
  // ended within `windowMs` of an FPS dip or a compile_stall, with the cause
830
1308
  // attributed. Does a stutter actually cost sessions?
831
- r.get("/api/v1/perf/churn", { schema: { querystring: perfChurnQueryParams } }, async (req, reply) => {
1309
+ r.get("/api/v1/perf/churn", {
1310
+ schema: {
1311
+ querystring: perfChurnQueryParams,
1312
+ response: { 200: rowsFor("/api/v1/perf/churn") },
1313
+ },
1314
+ }, async (req, reply) => {
832
1315
  const projectId = await authProject(req, reply, store);
833
1316
  if (!projectId)
834
1317
  return reply;
@@ -837,14 +1320,24 @@ export const queryRoutes = async (app, { store, config }) => {
837
1320
  // FPS by device class (#82, ADR 0028 §2; #11, ADR 0041) — per-session median FPS
838
1321
  // attributed to the `session_start.device` block (backend / mobile / GPU
839
1322
  // renderer) plus the coarse browser/OS derived from the User-Agent at ingestion.
840
- r.get("/api/v1/perf/by-device", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1323
+ r.get("/api/v1/perf/by-device", {
1324
+ schema: {
1325
+ querystring: heatmapQueryParams,
1326
+ response: { 200: rowsFor("/api/v1/perf/by-device") },
1327
+ },
1328
+ }, async (req, reply) => {
841
1329
  const projectId = await authProject(req, reply, store);
842
1330
  if (!projectId)
843
1331
  return reply;
844
1332
  return store.perfByDevice(projectId, req.query);
845
1333
  });
846
1334
  // FPS by scene (#82, ADR 0028 §1) — per-session median FPS grouped by scene.
847
- r.get("/api/v1/perf/by-scene", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1335
+ r.get("/api/v1/perf/by-scene", {
1336
+ schema: {
1337
+ querystring: heatmapQueryParams,
1338
+ response: { 200: rowsFor("/api/v1/perf/by-scene") },
1339
+ },
1340
+ }, async (req, reply) => {
848
1341
  const projectId = await authProject(req, reply, store);
849
1342
  if (!projectId)
850
1343
  return reply;
@@ -852,7 +1345,12 @@ export const queryRoutes = async (app, { store, config }) => {
852
1345
  });
853
1346
  // Resource-footprint percentiles (#83, ADR 0028 §1) — per-session p50/p95 of JS
854
1347
  // heap, texture bytes, and triangle count, summarized across sessions.
855
- r.get("/api/v1/perf/resource-percentiles", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1348
+ r.get("/api/v1/perf/resource-percentiles", {
1349
+ schema: {
1350
+ querystring: heatmapQueryParams,
1351
+ response: { 200: rowsFor("/api/v1/perf/resource-percentiles") },
1352
+ },
1353
+ }, async (req, reply) => {
856
1354
  const projectId = await authProject(req, reply, store);
857
1355
  if (!projectId)
858
1356
  return reply;
@@ -860,7 +1358,12 @@ export const queryRoutes = async (app, { store, config }) => {
860
1358
  });
861
1359
  // Stability incidents (#83) — context-loss and compile-stall counts over the
862
1360
  // range; the hard failures `frame_perf` cannot show.
863
- r.get("/api/v1/perf/stability", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1361
+ r.get("/api/v1/perf/stability", {
1362
+ schema: {
1363
+ querystring: heatmapQueryParams,
1364
+ response: { 200: rowsFor("/api/v1/perf/stability") },
1365
+ },
1366
+ }, async (req, reply) => {
864
1367
  const projectId = await authProject(req, reply, store);
865
1368
  if (!projectId)
866
1369
  return reply;
@@ -870,7 +1373,12 @@ export const queryRoutes = async (app, { store, config }) => {
870
1373
  // incident counts crossed by (severity, category, backend) over the range,
871
1374
  // folding discrete markers and per-session rollups into the same counters.
872
1375
  // Capture is off by default, so an empty result is the common (clean) case.
873
- r.get("/api/v1/graphics-diagnostics", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1376
+ r.get("/api/v1/graphics-diagnostics", {
1377
+ schema: {
1378
+ querystring: heatmapQueryParams,
1379
+ response: { 200: rowsFor("/api/v1/graphics-diagnostics") },
1380
+ },
1381
+ }, async (req, reply) => {
874
1382
  const projectId = await authProject(req, reply, store);
875
1383
  if (!projectId)
876
1384
  return reply;
@@ -881,11 +1389,20 @@ export const queryRoutes = async (app, { store, config }) => {
881
1389
  // things break (clustering around geometry, shader-heavy areas, a level
882
1390
  // region). severity/category narrow to engine diagnostics, errorKind to JS
883
1391
  // errors. Only events carrying a best-effort position participate.
884
- r.get("/api/v1/heatmaps/errors", { schema: { querystring: errorHeatmapQueryParams } }, async (req, reply) => {
1392
+ r.get("/api/v1/heatmaps/errors", {
1393
+ schema: {
1394
+ querystring: errorHeatmapQueryParams,
1395
+ response: { 200: rowsFor("/api/v1/heatmaps/errors"), 400: badRequestResponse },
1396
+ },
1397
+ }, async (req, reply) => {
885
1398
  const projectId = await authProject(req, reply, store);
886
1399
  if (!projectId)
887
1400
  return reply;
888
- return store.errorHeatmap(projectId, req.query);
1401
+ const { region: regionParam, ...rest } = req.query;
1402
+ const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
1403
+ if (isRegionError(region))
1404
+ return reply.code(400).send(region);
1405
+ return store.errorHeatmap(projectId, { ...rest, region });
889
1406
  });
890
1407
  // Guardian/boundary-touch heatmap (#157, ADR 0048) — voxel-binned world
891
1408
  // `position` of `xr_boundary_proximity` events: *where* in the play space the
@@ -894,30 +1411,46 @@ export const queryRoutes = async (app, { store, config }) => {
894
1411
  // position + duration each approach carries participate (ADR 0003 / ADR 0048).
895
1412
  // Reuses the world-heatmap voxel path; cellSize resolves from scene/region
896
1413
  // bounds when unset so the stats endpoint can echo it.
897
- r.get("/api/v1/heatmaps/boundary", { schema: { querystring: boundaryHeatmapQueryParams } }, async (req, reply) => {
1414
+ r.get("/api/v1/heatmaps/boundary", {
1415
+ schema: {
1416
+ querystring: boundaryHeatmapQueryParams,
1417
+ response: { 200: rowsFor("/api/v1/heatmaps/boundary"), 400: badRequestResponse },
1418
+ },
1419
+ }, async (req, reply) => {
898
1420
  const projectId = await authProject(req, reply, store);
899
1421
  if (!projectId)
900
1422
  return reply;
901
- const { region, cellSize, ...rest } = req.query;
1423
+ const { region: regionParam, cellSize, ...rest } = req.query;
1424
+ const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
1425
+ if (isRegionError(region))
1426
+ return reply.code(400).send(region);
902
1427
  const resolved = await resolveSpatialCellSize(store, projectId, {
903
1428
  cellSize,
904
1429
  scene: rest.scene,
905
1430
  region,
906
- });
1431
+ }, req);
907
1432
  return store.boundaryHeatmap(projectId, { ...rest, region, cellSize: resolved });
908
1433
  });
909
1434
  // Boundary-touch heatmap totals (ADR 0040 §3) — true occupied-cell + contact
910
1435
  // counts behind the truncated top-N voxels, echoing the effective cellSize.
911
- r.get("/api/v1/heatmaps/boundary/stats", { schema: { querystring: boundaryStatsQueryParams } }, async (req, reply) => {
1436
+ r.get("/api/v1/heatmaps/boundary/stats", {
1437
+ schema: {
1438
+ querystring: boundaryStatsQueryParams,
1439
+ response: { 200: statsRowFor("/api/v1/heatmaps/boundary/stats"), 400: badRequestResponse },
1440
+ },
1441
+ }, async (req, reply) => {
912
1442
  const projectId = await authProject(req, reply, store);
913
1443
  if (!projectId)
914
1444
  return reply;
915
- const { region, cellSize, ...rest } = req.query;
1445
+ const { region: regionParam, cellSize, ...rest } = req.query;
1446
+ const region = await resolveRegionFilter(store, projectId, rest.scene, regionParam);
1447
+ if (isRegionError(region))
1448
+ return reply.code(400).send(region);
916
1449
  const resolved = await resolveSpatialCellSize(store, projectId, {
917
1450
  cellSize,
918
1451
  scene: rest.scene,
919
1452
  region,
920
- });
1453
+ }, req);
921
1454
  const stats = await store.boundaryHeatmapStats(projectId, {
922
1455
  ...rest,
923
1456
  region,
@@ -929,7 +1462,12 @@ export const queryRoutes = async (app, { store, config }) => {
929
1462
  // crossed by (api, backend, api_version, shading_language) from
930
1463
  // `session_start.graphics`. Always-on (unlike diagnostics), so a populated
931
1464
  // result is the common case.
932
- r.get("/api/v1/rendering-technology", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1465
+ r.get("/api/v1/rendering-technology", {
1466
+ schema: {
1467
+ querystring: heatmapQueryParams,
1468
+ response: { 200: rowsFor("/api/v1/rendering-technology") },
1469
+ },
1470
+ }, async (req, reply) => {
933
1471
  const projectId = await authProject(req, reply, store);
934
1472
  if (!projectId)
935
1473
  return reply;
@@ -938,7 +1476,12 @@ export const queryRoutes = async (app, { store, config }) => {
938
1476
  // Capability changes (#49) — per-transition fallback/recovery counts (e.g. how
939
1477
  // many sessions fell back WebGPU→WebGL2); explains perf / visual-fidelity
940
1478
  // variance across the user base. App-reported via reportCapabilityChange.
941
- r.get("/api/v1/capabilities", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1479
+ r.get("/api/v1/capabilities", {
1480
+ schema: {
1481
+ querystring: heatmapQueryParams,
1482
+ response: { 200: rowsFor("/api/v1/capabilities") },
1483
+ },
1484
+ }, async (req, reply) => {
942
1485
  const projectId = await authProject(req, reply, store);
943
1486
  if (!projectId)
944
1487
  return reply;
@@ -947,7 +1490,12 @@ export const queryRoutes = async (app, { store, config }) => {
947
1490
  // Camera gestures (ADR 0025) — per-kind navigation breakdown (orbit / pan /
948
1491
  // dolly / zoom / roll / fly) separating deliberate viewpoint movement from
949
1492
  // object selection; reveals how an audience explores the scene.
950
- r.get("/api/v1/camera-gestures", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
1493
+ r.get("/api/v1/camera-gestures", {
1494
+ schema: {
1495
+ querystring: pointerHeatmapQueryParams,
1496
+ response: { 200: rowsFor("/api/v1/camera-gestures") },
1497
+ },
1498
+ }, async (req, reply) => {
951
1499
  const projectId = await authProject(req, reply, store);
952
1500
  if (!projectId)
953
1501
  return reply;
@@ -955,7 +1503,9 @@ export const queryRoutes = async (app, { store, config }) => {
955
1503
  });
956
1504
  // Scene coverage / dead zones (derived, scene-metrics §B) — occupied
957
1505
  // camera-position voxels; coverage % is layered in against the scene AABB.
958
- r.get("/api/v1/coverage", { schema: { querystring: coverageQueryParams } }, async (req, reply) => {
1506
+ r.get("/api/v1/coverage", {
1507
+ schema: { querystring: coverageQueryParams, response: { 200: rowsFor("/api/v1/coverage") } },
1508
+ }, async (req, reply) => {
959
1509
  const projectId = await authProject(req, reply, store);
960
1510
  if (!projectId)
961
1511
  return reply;
@@ -963,7 +1513,12 @@ export const queryRoutes = async (app, { store, config }) => {
963
1513
  });
964
1514
  // Spatial FPS heatmap (#145) — where FPS is bad in world space: frame_perf
965
1515
  // samples voxel-binned by their captured camera position (avg/min FPS per cell).
966
- r.get("/api/v1/heatmaps/perf", { schema: { querystring: perfHeatmapQueryParams } }, async (req, reply) => {
1516
+ r.get("/api/v1/heatmaps/perf", {
1517
+ schema: {
1518
+ querystring: perfHeatmapQueryParams,
1519
+ response: { 200: rowsFor("/api/v1/heatmaps/perf") },
1520
+ },
1521
+ }, async (req, reply) => {
967
1522
  const projectId = await authProject(req, reply, store);
968
1523
  if (!projectId)
969
1524
  return reply;
@@ -971,7 +1526,12 @@ export const queryRoutes = async (app, { store, config }) => {
971
1526
  });
972
1527
  // Camera distance / zoom distribution (derived, scene-metrics §B) — histogram of
973
1528
  // camera-to-center distance. The center defaults to the origin when omitted.
974
- r.get("/api/v1/camera/distance", { schema: { querystring: cameraDistanceQueryParams } }, async (req, reply) => {
1529
+ r.get("/api/v1/camera/distance", {
1530
+ schema: {
1531
+ querystring: cameraDistanceQueryParams,
1532
+ response: { 200: rowsFor("/api/v1/camera/distance") },
1533
+ },
1534
+ }, async (req, reply) => {
975
1535
  const projectId = await authProject(req, reply, store);
976
1536
  if (!projectId)
977
1537
  return reply;
@@ -983,7 +1543,12 @@ export const queryRoutes = async (app, { store, config }) => {
983
1543
  });
984
1544
  // Navigation effort / friction (derived, scene-metrics §B) — per-session travel
985
1545
  // distance with active-vs-idle segmentation.
986
- r.get("/api/v1/navigation", { schema: { querystring: navigationQueryParams } }, async (req, reply) => {
1546
+ r.get("/api/v1/navigation", {
1547
+ schema: {
1548
+ querystring: navigationQueryParams,
1549
+ response: { 200: rowsFor("/api/v1/navigation") },
1550
+ },
1551
+ }, async (req, reply) => {
987
1552
  const projectId = await authProject(req, reply, store);
988
1553
  if (!projectId)
989
1554
  return reply;
@@ -992,7 +1557,12 @@ export const queryRoutes = async (app, { store, config }) => {
992
1557
  // Path-retrace / backtracking ratio (#153) — per-scene leaderboard of how often
993
1558
  // visitors re-walk a coarse grid cell (the confusion / unclear-signage signal),
994
1559
  // derived from the same camera-position stream as desire lines.
995
- r.get("/api/v1/backtrack", { schema: { querystring: backtrackQueryParams } }, async (req, reply) => {
1560
+ r.get("/api/v1/backtrack", {
1561
+ schema: {
1562
+ querystring: backtrackQueryParams,
1563
+ response: { 200: rowsFor("/api/v1/backtrack") },
1564
+ },
1565
+ }, async (req, reply) => {
996
1566
  const projectId = await authProject(req, reply, store);
997
1567
  if (!projectId)
998
1568
  return reply;
@@ -1000,7 +1570,12 @@ export const queryRoutes = async (app, { store, config }) => {
1000
1570
  });
1001
1571
  // XR motion-sickness proxy (#50, scene-metrics §F) — per-session head/view
1002
1572
  // rotation rate over the camera pose stream; rapid rotation flags discomfort.
1003
- r.get("/api/v1/xr/rotation", { schema: { querystring: xrRotationQueryParams } }, async (req, reply) => {
1573
+ r.get("/api/v1/xr/rotation", {
1574
+ schema: {
1575
+ querystring: xrRotationQueryParams,
1576
+ response: { 200: rowsFor("/api/v1/xr/rotation") },
1577
+ },
1578
+ }, async (req, reply) => {
1004
1579
  const projectId = await authProject(req, reply, store);
1005
1580
  if (!projectId)
1006
1581
  return reply;
@@ -1008,7 +1583,9 @@ export const queryRoutes = async (app, { store, config }) => {
1008
1583
  });
1009
1584
  // XR input-source usage (#50, scene-metrics §F) — hand vs. controller (vs.
1010
1585
  // gaze) split read from `source` on the interaction events.
1011
- r.get("/api/v1/xr/sources", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1586
+ r.get("/api/v1/xr/sources", {
1587
+ schema: { querystring: heatmapQueryParams, response: { 200: rowsFor("/api/v1/xr/sources") } },
1588
+ }, async (req, reply) => {
1012
1589
  const projectId = await authProject(req, reply, store);
1013
1590
  if (!projectId)
1014
1591
  return reply;
@@ -1016,7 +1593,12 @@ export const queryRoutes = async (app, { store, config }) => {
1016
1593
  });
1017
1594
  // XR session abandonment (#50, scene-metrics §F) — per XR session, its time
1018
1595
  // bounds and event/interaction counts; a short span signals headset drop-off.
1019
- r.get("/api/v1/xr/abandonment", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1596
+ r.get("/api/v1/xr/abandonment", {
1597
+ schema: {
1598
+ querystring: heatmapQueryParams,
1599
+ response: { 200: rowsFor("/api/v1/xr/abandonment") },
1600
+ },
1601
+ }, async (req, reply) => {
1020
1602
  const projectId = await authProject(req, reply, store);
1021
1603
  if (!projectId)
1022
1604
  return reply;
@@ -1025,7 +1607,12 @@ export const queryRoutes = async (app, { store, config }) => {
1025
1607
  // XR locomotion & comfort (#148) — per XR session, its locomotion-style mix
1026
1608
  // (fly / navigate / teleport + duration) and wall-clock span, so heavy
1027
1609
  // locomotion can be correlated with early exits (a discomfort proxy).
1028
- r.get("/api/v1/xr/locomotion", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1610
+ r.get("/api/v1/xr/locomotion", {
1611
+ schema: {
1612
+ querystring: heatmapQueryParams,
1613
+ response: { 200: rowsFor("/api/v1/xr/locomotion") },
1614
+ },
1615
+ }, async (req, reply) => {
1029
1616
  const projectId = await authProject(req, reply, store);
1030
1617
  if (!projectId)
1031
1618
  return reply;
@@ -1036,7 +1623,12 @@ export const queryRoutes = async (app, { store, config }) => {
1036
1623
  // the near-boundary zone. A room-scale comfort signal beside VR locomotion;
1037
1624
  // frequent contact means the physical space didn't fit the experience. Built
1038
1625
  // from `xr_boundary_proximity` (one event per approach) — no room geometry.
1039
- r.get("/api/v1/xr/boundary-contacts", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1626
+ r.get("/api/v1/xr/boundary-contacts", {
1627
+ schema: {
1628
+ querystring: heatmapQueryParams,
1629
+ response: { 200: rowsFor("/api/v1/xr/boundary-contacts") },
1630
+ },
1631
+ }, async (req, reply) => {
1040
1632
  const projectId = await authProject(req, reply, store);
1041
1633
  if (!projectId)
1042
1634
  return reply;
@@ -1045,7 +1637,12 @@ export const queryRoutes = async (app, { store, config }) => {
1045
1637
  // XR tracking quality (#155, ADR 0048) — per XR session that reported a
1046
1638
  // tracking transition, how much of the session ran with degraded / lost
1047
1639
  // spatial tracking, split by hand vs. controller (a tracking-quality timeline).
1048
- r.get("/api/v1/xr/tracking", { schema: { querystring: heatmapQueryParams } }, async (req, reply) => {
1640
+ r.get("/api/v1/xr/tracking", {
1641
+ schema: {
1642
+ querystring: heatmapQueryParams,
1643
+ response: { 200: rowsFor("/api/v1/xr/tracking") },
1644
+ },
1645
+ }, async (req, reply) => {
1049
1646
  const projectId = await authProject(req, reply, store);
1050
1647
  if (!projectId)
1051
1648
  return reply;
@@ -1054,7 +1651,12 @@ export const queryRoutes = async (app, { store, config }) => {
1054
1651
  // Input-source breakdown (ADR 0011) — per (event_type, source), how many
1055
1652
  // interactions came from each input source (mouse / touch / xr-controller /
1056
1653
  // hand / …) and across how many sessions. Turns `source` into an insight.
1057
- r.get("/api/v1/interactions/sources", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
1654
+ r.get("/api/v1/interactions/sources", {
1655
+ schema: {
1656
+ querystring: pointerHeatmapQueryParams,
1657
+ response: { 200: rowsFor("/api/v1/interactions/sources") },
1658
+ },
1659
+ }, async (req, reply) => {
1058
1660
  const projectId = await authProject(req, reply, store);
1059
1661
  if (!projectId)
1060
1662
  return reply;
@@ -1063,7 +1665,12 @@ export const queryRoutes = async (app, { store, config }) => {
1063
1665
  // Most-used shortcuts / actions (#75, ADR 0023) — rank `input_action` events by
1064
1666
  // their app-level `action` label, split by `source` (keyboard / gamepad / …).
1065
1667
  // Pairs with the input-source breakdown for the input-modality panel.
1066
- r.get("/api/v1/input-actions/top", { schema: { querystring: pointerHeatmapQueryParams } }, async (req, reply) => {
1668
+ r.get("/api/v1/input-actions/top", {
1669
+ schema: {
1670
+ querystring: pointerHeatmapQueryParams,
1671
+ response: { 200: rowsFor("/api/v1/input-actions/top") },
1672
+ },
1673
+ }, async (req, reply) => {
1067
1674
  const projectId = await authProject(req, reply, store);
1068
1675
  if (!projectId)
1069
1676
  return reply;
@@ -1077,14 +1684,24 @@ export const queryRoutes = async (app, { store, config }) => {
1077
1684
  return store.scenes(projectId, req.query);
1078
1685
  });
1079
1686
  // Event-volume time-series (the 4th dimension) — bucketed event counts + FPS.
1080
- r.get("/api/v1/timeseries", { schema: { querystring: timeseriesQueryParams } }, async (req, reply) => {
1687
+ r.get("/api/v1/timeseries", {
1688
+ schema: {
1689
+ querystring: timeseriesQueryParams,
1690
+ response: { 200: rowsFor("/api/v1/timeseries") },
1691
+ },
1692
+ }, async (req, reply) => {
1081
1693
  const projectId = await authProject(req, reply, store);
1082
1694
  if (!projectId)
1083
1695
  return reply;
1084
1696
  return store.timeseries(projectId, req.query);
1085
1697
  });
1086
1698
  // Per-event-type counts over the range — powers the scene health panel.
1087
- r.get("/api/v1/event-counts", { schema: { querystring: eventCountsQueryParams } }, async (req, reply) => {
1699
+ r.get("/api/v1/event-counts", {
1700
+ schema: {
1701
+ querystring: eventCountsQueryParams,
1702
+ response: { 200: rowsFor("/api/v1/event-counts") },
1703
+ },
1704
+ }, async (req, reply) => {
1088
1705
  const projectId = await authProject(req, reply, store);
1089
1706
  if (!projectId)
1090
1707
  return reply;
@@ -1095,7 +1712,12 @@ export const queryRoutes = async (app, { store, config }) => {
1095
1712
  // of step predicates, validated against the shared `funnelStepsSchema`; the
1096
1713
  // OSS dashboard is a passive viewer, so steps are supplied by the caller
1097
1714
  // (CLI / hosted), never authored or persisted here.
1098
- r.get("/api/v1/funnel", { schema: { querystring: funnelQueryParams } }, async (req, reply) => {
1715
+ r.get("/api/v1/funnel", {
1716
+ schema: {
1717
+ querystring: funnelQueryParams,
1718
+ response: { 200: rowsFor("/api/v1/funnel"), 400: badRequestResponse },
1719
+ },
1720
+ }, async (req, reply) => {
1099
1721
  const projectId = await authProject(req, reply, store);
1100
1722
  if (!projectId)
1101
1723
  return reply;
@@ -1108,7 +1730,9 @@ export const queryRoutes = async (app, { store, config }) => {
1108
1730
  }
1109
1731
  const result = funnelStepsSchema.safeParse(parsed);
1110
1732
  if (!result.success) {
1111
- return reply.code(400).send({ error: "invalid funnel steps", details: result.error.issues });
1733
+ return reply
1734
+ .code(400)
1735
+ .send({ error: "invalid funnel steps", details: result.error.issues });
1112
1736
  }
1113
1737
  const { since, until, scene, cameraMode } = req.query;
1114
1738
  return store.funnel(projectId, {
@@ -1122,7 +1746,12 @@ export const queryRoutes = async (app, { store, config }) => {
1122
1746
  // Canned scene/level retention funnel (#147) — session counts flowing scene →
1123
1747
  // scene in observed order, built directly from `scene_change` markers with no
1124
1748
  // caller-authored steps (the zero-config complement to `/api/v1/funnel`).
1125
- r.get("/api/v1/scene-retention", { schema: { querystring: sceneRetentionQueryParams } }, async (req, reply) => {
1749
+ r.get("/api/v1/scene-retention", {
1750
+ schema: {
1751
+ querystring: sceneRetentionQueryParams,
1752
+ response: { 200: rowsFor("/api/v1/scene-retention") },
1753
+ },
1754
+ }, async (req, reply) => {
1126
1755
  const projectId = await authProject(req, reply, store);
1127
1756
  if (!projectId)
1128
1757
  return reply;
@@ -1134,7 +1763,12 @@ export const queryRoutes = async (app, { store, config }) => {
1134
1763
  // `bands` are optional caller-supplied ascending ms boundaries; the store
1135
1764
  // applies a sensible default when omitted. Derived from existing events — no
1136
1765
  // schema change.
1137
- r.get("/api/v1/load-bounce", { schema: { querystring: loadBounceQueryParams } }, async (req, reply) => {
1766
+ r.get("/api/v1/load-bounce", {
1767
+ schema: {
1768
+ querystring: loadBounceQueryParams,
1769
+ response: { 200: rowsFor("/api/v1/load-bounce") },
1770
+ },
1771
+ }, async (req, reply) => {
1138
1772
  const projectId = await authProject(req, reply, store);
1139
1773
  if (!projectId)
1140
1774
  return reply;
@@ -1147,7 +1781,12 @@ export const queryRoutes = async (app, { store, config }) => {
1147
1781
  // the next switch/conversion. `variant`/`conversion` are JSON-encoded step
1148
1782
  // predicates validated against the shared `funnelStepSchema`; like the funnel,
1149
1783
  // OSS is a passive viewer and the caller supplies them (ADR 0038).
1150
- r.get("/api/v1/variant-leaderboard", { schema: { querystring: variantLeaderboardQueryParams } }, async (req, reply) => {
1784
+ r.get("/api/v1/variant-leaderboard", {
1785
+ schema: {
1786
+ querystring: variantLeaderboardQueryParams,
1787
+ response: { 200: rowsFor("/api/v1/variant-leaderboard"), 400: badRequestResponse },
1788
+ },
1789
+ }, async (req, reply) => {
1151
1790
  const projectId = await authProject(req, reply, store);
1152
1791
  if (!projectId)
1153
1792
  return reply;
@@ -1200,12 +1839,20 @@ export const queryRoutes = async (app, { store, config }) => {
1200
1839
  querystring: z.object({ format: z.enum(["json", "ndjson"]).optional() }),
1201
1840
  },
1202
1841
  }, async (req, reply) => {
1203
- const projectId = await authProject(req, reply, store);
1204
- if (!projectId)
1842
+ // Raw per-session data needs BOTH halves of the gate (ADR 0051 §7):
1843
+ // retention enabled on the collector AND `query:raw` on the key. A plain
1844
+ // `query` key is authenticated (so an unknown key still gets a 401) but
1845
+ // refused the raw stream even when retention is on.
1846
+ const resolved = await requireCapability(req, reply, store, "query");
1847
+ if (!resolved)
1205
1848
  return reply;
1206
1849
  if (!config.enableRawSessionRetention) {
1207
1850
  return reply.code(403).send({ error: "raw session retention is disabled" });
1208
1851
  }
1852
+ if (!resolved.capabilities.includes("query:raw")) {
1853
+ return reply.code(403).send({ error: "api key not permitted to read raw session data" });
1854
+ }
1855
+ const projectId = resolved.projectId;
1209
1856
  const accept = req.headers.accept ?? "";
1210
1857
  const wantsNdjson = req.query.format === "ndjson" || accept.includes("application/x-ndjson");
1211
1858
  if (!wantsNdjson) {
@@ -1223,7 +1870,12 @@ export const queryRoutes = async (app, { store, config }) => {
1223
1870
  });
1224
1871
  // Coarse session descriptor (device/scene/anonymized user). Not raw data, so it
1225
1872
  // is not gated by raw-session retention.
1226
- r.get("/api/v1/sessions/:id/meta", { schema: { params: z.object({ id: z.string().min(1) }) } }, async (req, reply) => {
1873
+ r.get("/api/v1/sessions/:id/meta", {
1874
+ schema: {
1875
+ params: z.object({ id: z.string().min(1) }),
1876
+ response: { 200: singleRowFor("/api/v1/sessions/:id/meta"), 404: notFoundResponse },
1877
+ },
1878
+ }, async (req, reply) => {
1227
1879
  const projectId = await authProject(req, reply, store);
1228
1880
  if (!projectId)
1229
1881
  return reply;
@@ -1252,7 +1904,15 @@ export const queryRoutes = async (app, { store, config }) => {
1252
1904
  return store.putSceneProxy(projectId, req.body.proxy, req.body.label);
1253
1905
  });
1254
1906
  // Fetch one scene's representation (including the proxy blob), or 404.
1255
- r.get("/api/v1/scenes/:sceneId/representation", { schema: { params: sceneParams } }, async (req, reply) => {
1907
+ r.get("/api/v1/scenes/:sceneId/representation", {
1908
+ schema: {
1909
+ params: sceneParams,
1910
+ response: {
1911
+ 200: singleRowFor("/api/v1/scenes/:sceneId/representation"),
1912
+ 404: notFoundResponse,
1913
+ },
1914
+ },
1915
+ }, async (req, reply) => {
1256
1916
  const projectId = await authProject(req, reply, store);
1257
1917
  if (!projectId)
1258
1918
  return reply;
@@ -1261,5 +1921,42 @@ export const queryRoutes = async (app, { store, config }) => {
1261
1921
  return reply.code(404).send({ error: "scene representation not found" });
1262
1922
  return representation;
1263
1923
  });
1924
+ // --- Scene regions (ADR 0051 §2 / sketch §B.2) ---------------------------
1925
+ //
1926
+ // Named, labelled boxes that give a scene a vocabulary for *where* — so a
1927
+ // spatial answer can say "the checkout counter" instead of a voxel centre, and
1928
+ // `?region=<id>` can drill a heatmap into that place by name.
1929
+ //
1930
+ // AUTH: the two **reads** take a `query`-capable key, like every other read.
1931
+ // The **write** takes an `annotate`-capable key (#309, ADR 0051 §5/§7) — the
1932
+ // dedicated metadata-write capability, so a read-only key handed to an agent
1933
+ // cannot redraw the project's spatial vocabulary. `annotate` is a write
1934
+ // capability only: mint `query,annotate` for a client that both declares
1935
+ // regions and reads them back.
1936
+ // List every region the project has declared, across all scenes (names only,
1937
+ // no boxes) — the whole spatial vocabulary in one read, for a region picker or
1938
+ // an agent's project context. The sibling of `/scene-representations`.
1939
+ r.get("/api/v1/scene-regions", async (req, reply) => {
1940
+ const projectId = await authProject(req, reply, store);
1941
+ if (!projectId)
1942
+ return reply;
1943
+ return store.listSceneRegions(projectId);
1944
+ });
1945
+ // Declare a scene's regions, replacing whatever was stored for that scene.
1946
+ r.put("/api/v1/scenes/:sceneId/regions", { schema: { params: sceneParams, body: putRegionsBody } }, async (req, reply) => {
1947
+ const resolved = await requireCapability(req, reply, store, "annotate");
1948
+ if (!resolved)
1949
+ return reply;
1950
+ return store.putSceneRegions(resolved.projectId, req.params.sceneId, req.body.regions);
1951
+ });
1952
+ // Read one scene's regions. An unregistered scene is an empty set, not a 404 —
1953
+ // "this scene has no regions" is a normal answer, and the caller need not know
1954
+ // whether the scene was ever registered.
1955
+ r.get("/api/v1/scenes/:sceneId/regions", { schema: { params: sceneParams } }, async (req, reply) => {
1956
+ const projectId = await authProject(req, reply, store);
1957
+ if (!projectId)
1958
+ return reply;
1959
+ return store.getSceneRegions(projectId, req.params.sceneId);
1960
+ });
1264
1961
  };
1265
1962
  //# sourceMappingURL=query.js.map