@uptimizr/collector-server 1.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -8
- package/dist/__tests__/support/registryRequests.d.ts +47 -0
- package/dist/__tests__/support/registryRequests.d.ts.map +1 -0
- package/dist/__tests__/support/registryRequests.js +93 -0
- package/dist/__tests__/support/registryRequests.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +34 -3
- package/dist/app.js.map +1 -1
- package/dist/audit.d.ts +22 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +96 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth.d.ts +64 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +71 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.js +279 -0
- package/dist/cli.js.map +1 -1
- package/dist/cliStore.d.ts +20 -2
- package/dist/cliStore.d.ts.map +1 -1
- package/dist/cliStore.js +20 -8
- package/dist/cliStore.js.map +1 -1
- package/dist/clickhouseStore.d.ts.map +1 -1
- package/dist/clickhouseStore.js +7 -1
- package/dist/clickhouseStore.js.map +1 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/duckdbStore.d.ts.map +1 -1
- package/dist/duckdbStore.js +7 -1
- package/dist/duckdbStore.js.map +1 -1
- package/dist/liveToken.d.ts +14 -5
- package/dist/liveToken.d.ts.map +1 -1
- package/dist/liveToken.js +32 -6
- package/dist/liveToken.js.map +1 -1
- package/dist/memoryStore.d.ts +8 -3
- package/dist/memoryStore.d.ts.map +1 -1
- package/dist/memoryStore.js +57 -2
- package/dist/memoryStore.js.map +1 -1
- package/dist/mssqlStore.d.ts.map +1 -1
- package/dist/mssqlStore.js +7 -1
- package/dist/mssqlStore.js.map +1 -1
- package/dist/postgresStore.d.ts.map +1 -1
- package/dist/postgresStore.js +7 -1
- package/dist/postgresStore.js.map +1 -1
- package/dist/routes/live.d.ts.map +1 -1
- package/dist/routes/live.js +31 -26
- package/dist/routes/live.js.map +1 -1
- package/dist/routes/meta.d.ts +67 -0
- package/dist/routes/meta.d.ts.map +1 -0
- package/dist/routes/meta.js +436 -0
- package/dist/routes/meta.js.map +1 -0
- package/dist/routes/query.d.ts.map +1 -1
- package/dist/routes/query.js +805 -108
- package/dist/routes/query.js.map +1 -1
- package/dist/store.d.ts +32 -5
- package/dist/store.d.ts.map +1 -1
- package/package.json +8 -6
package/README.md
CHANGED
|
@@ -25,7 +25,10 @@ npx -p @uptimizr/collector-server uptimizr serve
|
|
|
25
25
|
and this server's URL (the **`endpoint`**) to your client SDK (e.g.
|
|
26
26
|
`@uptimizr/babylon`); use the **API key** (`x-api-key`) for the query routes /
|
|
27
27
|
dashboard. Mint more projects later with
|
|
28
|
-
`npx -p @uptimizr/collector-server uptimizr new-project "<name>"
|
|
28
|
+
`npx -p @uptimizr/collector-server uptimizr new-project "<name>"`, or add a key
|
|
29
|
+
to an existing project with
|
|
30
|
+
`npx -p @uptimizr/collector-server uptimizr new-key <projectId> [--capabilities …] [--label …]`
|
|
31
|
+
— see [API keys and capabilities](#api-keys-and-capabilities).
|
|
29
32
|
|
|
30
33
|
`init`, `new-project` and `migrate` target the store selected by
|
|
31
34
|
`COLLECTOR_STORE`, read through the same connection variables `serve` uses — so
|
|
@@ -41,6 +44,34 @@ npx -p @uptimizr/collector-server uptimizr init "My Project" # schema + first
|
|
|
41
44
|
npx -p @uptimizr/collector-server uptimizr serve
|
|
42
45
|
```
|
|
43
46
|
|
|
47
|
+
### Naming places in a scene: `uptimizr regions`
|
|
48
|
+
|
|
49
|
+
A scene can carry **regions** — named, labelled world-space boxes ("the
|
|
50
|
+
entrance", "the checkout counter") that give spatial results a vocabulary and let
|
|
51
|
+
any spatial query be drilled into a place with `?region=<id>`. Declare them from
|
|
52
|
+
a JSON file straight against the store, without a running collector:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
cat > regions.json <<'JSON'
|
|
56
|
+
[
|
|
57
|
+
{ "id": "entrance", "label": "Entrance", "bounds": [-5, 0, -5, 5, 3, 0] },
|
|
58
|
+
{ "id": "counter", "label": "Checkout counter", "bounds": [-1, 0, 1, 1, 2, 3] }
|
|
59
|
+
]
|
|
60
|
+
JSON
|
|
61
|
+
|
|
62
|
+
npx -p @uptimizr/collector-server uptimizr regions set lobby --file regions.json --project "$PROJECT_ID"
|
|
63
|
+
npx -p @uptimizr/collector-server uptimizr regions get lobby --project "$PROJECT_ID"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The file is either a bare array or the `{ "regions": [...] }` envelope the HTTP
|
|
67
|
+
endpoint takes, so one file works with both. `--project` may be replaced by
|
|
68
|
+
`UPTIMIZR_PROJECT_ID`. The write **replaces** the scene's whole set, so leaving a
|
|
69
|
+
region out removes it and `[]` clears them. The CLI talks to the store directly,
|
|
70
|
+
so it needs no API key. Over HTTP the same thing is
|
|
71
|
+
`PUT /api/v1/scenes/:sceneId/regions` (see the integration guide), which takes
|
|
72
|
+
an `annotate`-capable key, and from a client build `registerRegions` in
|
|
73
|
+
`@uptimizr/sdk-core`.
|
|
74
|
+
|
|
44
75
|
### All-in-one: serve the dashboard too
|
|
45
76
|
|
|
46
77
|
The collector can also serve a pre-built static dashboard from its own origin, so
|
|
@@ -92,6 +123,18 @@ file; back up by copying the file).
|
|
|
92
123
|
|
|
93
124
|
## Endpoints
|
|
94
125
|
|
|
126
|
+
### Self-description
|
|
127
|
+
|
|
128
|
+
- `GET /api/v1/openapi.json` — an **OpenAPI 3.1** document for the whole read API,
|
|
129
|
+
generated from the semantic metric registry (ADR 0051) and this server's own
|
|
130
|
+
route table: one path per endpoint, every parameter carrying the schema that
|
|
131
|
+
actually validates it, and a response schema per metric. The semantics OpenAPI
|
|
132
|
+
cannot express ride along as `x-uptimizr-*` extensions — the result `grain`,
|
|
133
|
+
per-column `units`, `caveats`, `interpretation`, the capture channels that feed
|
|
134
|
+
the metric, and its row `limits`. **Unauthenticated**: it is documentation and
|
|
135
|
+
contains no project data. Generate a typed client with
|
|
136
|
+
`npx openapi-typescript <collector>/api/v1/openapi.json -o collector.d.ts`.
|
|
137
|
+
|
|
95
138
|
### Ingestion
|
|
96
139
|
|
|
97
140
|
- `POST /api/v1/collect` — accepts a batched `collectRequest`. Validates → rejects
|
|
@@ -127,20 +170,38 @@ the project the API key resolves to.
|
|
|
127
170
|
`/xr/sources`, `/xr/abandonment`, `/xr/locomotion`.
|
|
128
171
|
- Scene representations: `PUT /api/v1/scenes/:sceneId/representation`,
|
|
129
172
|
`GET /api/v1/scenes/:sceneId/representation`.
|
|
130
|
-
- `GET /api/v1/sessions/:id/events` — ordered replay timeline
|
|
131
|
-
|
|
132
|
-
|
|
173
|
+
- `GET /api/v1/sessions/:id/events` — ordered replay timeline. Raw per-session
|
|
174
|
+
data, so it is **gated twice**: `ENABLE_RAW_SESSION_RETENTION` must be on
|
|
175
|
+
**and** the key must hold `query:raw` (`403` otherwise). Supports buffered JSON
|
|
176
|
+
or NDJSON streaming (`Accept: application/x-ndjson` / `?format=ndjson`).
|
|
177
|
+
- Key identity + audit: `GET /api/v1/whoami` (the calling key's project, key id,
|
|
178
|
+
capabilities, label and effective rate limit) and `GET /api/v1/audit`
|
|
179
|
+
(`since`/`until`/`limit`) — see
|
|
180
|
+
[API keys and capabilities](#api-keys-and-capabilities).
|
|
133
181
|
|
|
134
182
|
Live endpoints:
|
|
135
183
|
|
|
136
|
-
- `POST /api/v1/live/token` — exchange a query API key for a short-lived live
|
|
184
|
+
- `POST /api/v1/live/token` — exchange a query API key for a short-lived live
|
|
185
|
+
token. The key's capability set is carried inside the signed token, so the
|
|
186
|
+
per-session follow can enforce `query:raw` without a header `EventSource`
|
|
187
|
+
cannot send.
|
|
137
188
|
- `GET /api/v1/live/presence`, `/live/stream`, `/live/sessions/:id` — SSE streams
|
|
138
|
-
authenticated with `?token=...`; per-session
|
|
139
|
-
retention.
|
|
189
|
+
authenticated with `?token=...`; the per-session follow is gated by raw
|
|
190
|
+
retention **and** `query:raw`, exactly like the replay timeline.
|
|
140
191
|
|
|
141
192
|
Common query params include `since`, `until` (epoch ms), `bins`, `limit`, `scene`,
|
|
142
193
|
`session`, `cameraMode`, `source`, and spatial `cellSize` / `region` where supported.
|
|
143
194
|
|
|
195
|
+
Every aggregate endpoint also accepts `format=full | table | summary` (ADR 0051 §2).
|
|
196
|
+
It filters nothing — it picks the result envelope. `full` is the default and returns
|
|
197
|
+
the bare rows unchanged (what the dashboard uses); `table` adds a `meta` envelope
|
|
198
|
+
(metric, range, applied filters, sample size, row count, `truncated`, limits); and
|
|
199
|
+
`summary` returns a bounded digest — ranked top rows, a first/last/min/max/trend
|
|
200
|
+
series, or merged spatial clusters, with shares, the metric's caveats and a
|
|
201
|
+
templated `reading` sentence — capped at the registry's `maxSummaryRows`, which is
|
|
202
|
+
what makes a 500-bin heatmap affordable for an LLM. See
|
|
203
|
+
[Result formats](https://uptimizr.com/docs/api/query/#result-formats).
|
|
204
|
+
|
|
144
205
|
- `GET /health` — liveness probe.
|
|
145
206
|
|
|
146
207
|
## Security
|
|
@@ -158,6 +219,46 @@ if `VISITOR_HASH_SECRET` is missing.
|
|
|
158
219
|
| `POST /api/v1/live/token` | `x-api-key` | Exchanges a project query key for a short-lived SSE token. |
|
|
159
220
|
| Live SSE routes (`/api/v1/live/*` `GET`s) | `?token=...` | Browser `EventSource` cannot attach custom headers, so live streams use short-lived bearer tokens. |
|
|
160
221
|
| `GET /health` | None | Liveness probe. |
|
|
222
|
+
| `GET /api/v1/openapi.json` | None | API documentation, not data — a client needs it before it has a key. Rate-limited like every other route. |
|
|
223
|
+
|
|
224
|
+
### API keys and capabilities
|
|
225
|
+
|
|
226
|
+
A key carries a **set of capabilities** (ADR 0051 §7), not a single role:
|
|
227
|
+
|
|
228
|
+
| Capability | Grants |
|
|
229
|
+
| ----------- | --------------------------------------------------------------------------------------------------------- |
|
|
230
|
+
| `query` | The aggregate analytics API, the scene registry, the live token exchange and `GET /api/v1/audit`. |
|
|
231
|
+
| `query:raw` | Raw per-session streams: `GET /api/v1/sessions/:id/events` and `GET /api/v1/live/sessions/:id`. |
|
|
232
|
+
| `annotate` | The project **metadata** write path (annotations, glossary, saved analyses, panel specs). Never events. |
|
|
233
|
+
| `ingest` | Reserved for server-side write paths. Public ingestion is keyless, so issued keys are normally read keys. |
|
|
234
|
+
|
|
235
|
+
Keys default to `query`, including those from `uptimizr init` / `uptimizr new-project`:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
uptimizr new-key <projectId> --capabilities query,annotate \
|
|
239
|
+
--label "weekly-report-agent" --rate-limit-max 120 --rate-limit-window-ms 60000
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
> **Breaking change.** `query:raw` is new, and the raw per-session endpoints now require **both**
|
|
243
|
+
> `ENABLE_RAW_SESSION_RETENTION` **and** `query:raw` — previously retention alone was enough for
|
|
244
|
+
> any `query` key. Existing keys keep working for every aggregate endpoint; a key that drives
|
|
245
|
+
> session replay or live-follow must be re-minted with `--capabilities query,query:raw`.
|
|
246
|
+
|
|
247
|
+
`--rate-limit-max` / `--rate-limit-window-ms` give a key its own request budget, bucketed on the
|
|
248
|
+
key id rather than the client IP; keys without one fall back to `COLLECTOR_RATE_LIMIT_*`.
|
|
249
|
+
Ingestion keeps its separate `COLLECTOR_INGEST_RATE_LIMIT_*` budget.
|
|
250
|
+
|
|
251
|
+
### Agent audit log
|
|
252
|
+
|
|
253
|
+
Every authenticated request made with a key that is not the dashboard's own session is recorded
|
|
254
|
+
(`keyId`, `surface`, route pattern, bounded+redacted `params`, `rowCount`, `durationMs`,
|
|
255
|
+
`status`), readable at `GET /api/v1/audit` with any `query` key. Refusals are recorded too. A key
|
|
256
|
+
never appears in a row — the subject is the key's **id** — and `params` drops credential-shaped
|
|
257
|
+
fields and is capped at 512 bytes. Writes happen after the response is flushed, so the audit log
|
|
258
|
+
can never block or fail a request. "The dashboard's own session" is a request carrying
|
|
259
|
+
`x-uptimizr-client: dashboard` (a volume filter, not a security boundary — set
|
|
260
|
+
`AUDIT_DASHBOARD_REQUESTS=1` to record everything). Rows expire after `AUDIT_RETENTION_DAYS`
|
|
261
|
+
(default `30`; `0` keeps them forever).
|
|
161
262
|
|
|
162
263
|
### Threat model for keyless ingestion
|
|
163
264
|
|
|
@@ -188,7 +289,10 @@ Environment-driven (see [`.env.example`](../../../.env.example)):
|
|
|
188
289
|
`ENABLE_RAW_SESSION_RETENTION`, `LIVE_TOKEN_SECRET`, `LIVE_TOKEN_TTL_MS`,
|
|
189
290
|
`LIVE_WINDOW_MS`, `LIVE_MAX_CONNECTIONS`, `LIVE_PRESENCE_INTERVAL_MS`.
|
|
190
291
|
- Rate limits: `COLLECTOR_RATE_LIMIT_MAX`, `COLLECTOR_RATE_LIMIT_WINDOW_MS`,
|
|
191
|
-
`COLLECTOR_INGEST_RATE_LIMIT_MAX`, `COLLECTOR_INGEST_RATE_LIMIT_WINDOW_MS
|
|
292
|
+
`COLLECTOR_INGEST_RATE_LIMIT_MAX`, `COLLECTOR_INGEST_RATE_LIMIT_WINDOW_MS`
|
|
293
|
+
(a key's own budget overrides the first pair).
|
|
294
|
+
- Agent audit: `AUDIT_RETENTION_DAYS` (default `30`, `0` = keep forever),
|
|
295
|
+
`AUDIT_DASHBOARD_REQUESTS` (default off).
|
|
192
296
|
- All-in-one dashboard: `COLLECTOR_DASHBOARD_DIR` (optional; see
|
|
193
297
|
[above](#all-in-one-serve-the-dashboard-too)), `COLLECTOR_CSP` (`strict` or `off`).
|
|
194
298
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for the suites that sweep **every** registry endpoint
|
|
3
|
+
* (`queryResponseSchemas.test.ts`, `resultFormat.test.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Both need the same three things: a collector config, a scene proxy so the
|
|
6
|
+
* `scene_representation` resource has something to return, and the rule for
|
|
7
|
+
* turning a registry `endpoint.path` into a request URL. Keeping them here means
|
|
8
|
+
* a new endpoint that needs a required parameter is taught about once.
|
|
9
|
+
*
|
|
10
|
+
* Not a test file — the filename has no `.test.` segment, so Vitest does not
|
|
11
|
+
* collect it.
|
|
12
|
+
*/
|
|
13
|
+
import type { MetricDefinition } from "@uptimizr/metrics";
|
|
14
|
+
import type { CollectorConfig } from "../../config.js";
|
|
15
|
+
/** A collector config with every gate open and every secret a test secret. */
|
|
16
|
+
export declare const TEST_CONFIG: CollectorConfig;
|
|
17
|
+
/** Fixture scene proxy, so the `scene_representation` resource has a hit. */
|
|
18
|
+
export declare const TEST_PROXY: {
|
|
19
|
+
version: 1;
|
|
20
|
+
sceneId: string;
|
|
21
|
+
kind: "aabb";
|
|
22
|
+
bounds: [number, number, number, number, number, number];
|
|
23
|
+
upAxis: "y";
|
|
24
|
+
unitScale: number;
|
|
25
|
+
meshes: {
|
|
26
|
+
name: string;
|
|
27
|
+
aabb: [number, number, number, number, number, number];
|
|
28
|
+
}[];
|
|
29
|
+
meshCount: number;
|
|
30
|
+
contentHash: string;
|
|
31
|
+
capturedAt: number;
|
|
32
|
+
};
|
|
33
|
+
/** Path params every registry endpoint that declares one can be satisfied with. */
|
|
34
|
+
export declare const PATH_PARAM_VALUES: Readonly<Record<string, string>>;
|
|
35
|
+
/**
|
|
36
|
+
* Query parameters an endpoint needs beyond the shared range. Only the genuinely
|
|
37
|
+
* required ones: `mesh` for the per-mesh UV heatmap and `steps` for the funnel.
|
|
38
|
+
*/
|
|
39
|
+
export declare const REQUIRED_QUERY: Readonly<Record<string, Record<string, string>>>;
|
|
40
|
+
/** The two resource reads, which legitimately 404 when nothing is registered. */
|
|
41
|
+
export declare const RESOURCE_METRICS: ReadonlySet<string>;
|
|
42
|
+
/**
|
|
43
|
+
* Fill a registry path's `:params` and append the query string for a request.
|
|
44
|
+
* `extra` adds (or overrides) query parameters — `format`, for instance.
|
|
45
|
+
*/
|
|
46
|
+
export declare function requestUrl(metric: MetricDefinition, extra?: Readonly<Record<string, string>>): string;
|
|
47
|
+
//# sourceMappingURL=registryRequests.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registryRequests.d.ts","sourceRoot":"","sources":["../../../src/__tests__/support/registryRequests.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAE1D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,8EAA8E;AAC9E,eAAO,MAAM,WAAW,EAAE,eAqBzB,CAAC;AAEF,6EAA6E;AAC7E,eAAO,MAAM,UAAU;;;;YAIW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;;;;;cAM5C,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;;;;;CAMrF,CAAC;AAEF,mFAAmF;AACnF,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAI9D,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,cAAc,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAK3E,CAAC;AAEF,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,EAAE,WAAW,CAAC,MAAM,CAG/C,CAAC;AAEH;;;GAGG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,gBAAgB,EACxB,KAAK,GAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAM,GAC3C,MAAM,CAYR"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for the suites that sweep **every** registry endpoint
|
|
3
|
+
* (`queryResponseSchemas.test.ts`, `resultFormat.test.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Both need the same three things: a collector config, a scene proxy so the
|
|
6
|
+
* `scene_representation` resource has something to return, and the rule for
|
|
7
|
+
* turning a registry `endpoint.path` into a request URL. Keeping them here means
|
|
8
|
+
* a new endpoint that needs a required parameter is taught about once.
|
|
9
|
+
*
|
|
10
|
+
* Not a test file — the filename has no `.test.` segment, so Vitest does not
|
|
11
|
+
* collect it.
|
|
12
|
+
*/
|
|
13
|
+
import { PARITY_RANGE } from "@uptimizr/db";
|
|
14
|
+
/** A collector config with every gate open and every secret a test secret. */
|
|
15
|
+
export const TEST_CONFIG = {
|
|
16
|
+
host: "127.0.0.1",
|
|
17
|
+
port: 0,
|
|
18
|
+
corsOrigins: [],
|
|
19
|
+
visitorHashSecret: "test-secret",
|
|
20
|
+
enableRawSessionRetention: false,
|
|
21
|
+
liveWindowMs: 30_000,
|
|
22
|
+
liveTokenSecret: "test-live-secret",
|
|
23
|
+
liveTokenSecretIsDedicated: true,
|
|
24
|
+
liveTokenTtlMs: 900_000,
|
|
25
|
+
liveMaxConnections: 200,
|
|
26
|
+
livePresenceIntervalMs: 2_000,
|
|
27
|
+
rateLimitMax: 1000,
|
|
28
|
+
rateLimitWindowMs: 60_000,
|
|
29
|
+
ingestRateLimitMax: 1000,
|
|
30
|
+
ingestRateLimitWindowMs: 60_000,
|
|
31
|
+
trustProxy: false,
|
|
32
|
+
bodyLimit: 1_048_576,
|
|
33
|
+
cspMode: "strict",
|
|
34
|
+
auditRetentionDays: 30,
|
|
35
|
+
auditDashboardRequests: false,
|
|
36
|
+
};
|
|
37
|
+
/** Fixture scene proxy, so the `scene_representation` resource has a hit. */
|
|
38
|
+
export const TEST_PROXY = {
|
|
39
|
+
version: 1,
|
|
40
|
+
sceneId: "lobby",
|
|
41
|
+
kind: "aabb",
|
|
42
|
+
bounds: [-2, 0, -2, 2, 3, 2],
|
|
43
|
+
upAxis: "y",
|
|
44
|
+
unitScale: 1,
|
|
45
|
+
meshes: [
|
|
46
|
+
{
|
|
47
|
+
name: "floor",
|
|
48
|
+
aabb: [-2, 0, -2, 2, 0.1, 2],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
meshCount: 1,
|
|
52
|
+
contentHash: "abc123",
|
|
53
|
+
capturedAt: 1_750_000_000_000,
|
|
54
|
+
};
|
|
55
|
+
/** Path params every registry endpoint that declares one can be satisfied with. */
|
|
56
|
+
export const PATH_PARAM_VALUES = {
|
|
57
|
+
":sessionId": "s1",
|
|
58
|
+
":id": "s1",
|
|
59
|
+
":sceneId": "lobby",
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Query parameters an endpoint needs beyond the shared range. Only the genuinely
|
|
63
|
+
* required ones: `mesh` for the per-mesh UV heatmap and `steps` for the funnel.
|
|
64
|
+
*/
|
|
65
|
+
export const REQUIRED_QUERY = {
|
|
66
|
+
"/api/v1/heatmaps/mesh-uv": { mesh: "box" },
|
|
67
|
+
"/api/v1/funnel": {
|
|
68
|
+
steps: JSON.stringify([{ type: "session_start" }, { type: "pointer_click" }]),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
/** The two resource reads, which legitimately 404 when nothing is registered. */
|
|
72
|
+
export const RESOURCE_METRICS = new Set([
|
|
73
|
+
"session_meta",
|
|
74
|
+
"scene_representation",
|
|
75
|
+
]);
|
|
76
|
+
/**
|
|
77
|
+
* Fill a registry path's `:params` and append the query string for a request.
|
|
78
|
+
* `extra` adds (or overrides) query parameters — `format`, for instance.
|
|
79
|
+
*/
|
|
80
|
+
export function requestUrl(metric, extra = {}) {
|
|
81
|
+
let path = metric.endpoint.path;
|
|
82
|
+
for (const [token, value] of Object.entries(PATH_PARAM_VALUES)) {
|
|
83
|
+
path = path.replace(token, value);
|
|
84
|
+
}
|
|
85
|
+
const params = new URLSearchParams({
|
|
86
|
+
since: String(PARITY_RANGE.since),
|
|
87
|
+
until: String(PARITY_RANGE.until),
|
|
88
|
+
...(REQUIRED_QUERY[metric.endpoint.path] ?? {}),
|
|
89
|
+
...extra,
|
|
90
|
+
});
|
|
91
|
+
return `${path}?${params.toString()}`;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=registryRequests.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registryRequests.js","sourceRoot":"","sources":["../../../src/__tests__/support/registryRequests.ts"],"names":[],"mappings":"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"}
|
package/dist/app.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAIA,OAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,SAAS,CAAC;AAMxD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAiB,KAAK,OAAO,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAIA,OAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,SAAS,CAAC;AAMxD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAiB,KAAK,OAAO,EAAE,MAAM,cAAc,CAAC;AAS3D,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,cAAc,CAAC;IACtB,MAAM,EAAE,eAAe,CAAC;IACxB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,yEAAyE;IACzE,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AA0BD;;;;GAIG;AACH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC,CAuG3E"}
|
package/dist/app.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import cors from "@fastify/cors";
|
|
2
2
|
import helmet from "@fastify/helmet";
|
|
3
|
-
import rateLimit from "@fastify/rate-limit";
|
|
3
|
+
import rateLimit, { normalizeIP } from "@fastify/rate-limit";
|
|
4
4
|
import fastifyStatic from "@fastify/static";
|
|
5
5
|
import Fastify, {} from "fastify";
|
|
6
6
|
import { serializerCompiler, validatorCompiler, } from "fastify-type-provider-zod";
|
|
7
7
|
import { createLiveBus } from "./liveBus.js";
|
|
8
|
+
import { attachApiKey } from "./auth.js";
|
|
9
|
+
import { registerAuditHooks, startAuditRetention } from "./audit.js";
|
|
8
10
|
import { buildDashboardCsp } from "./csp.js";
|
|
9
11
|
import { collectRoutes } from "./routes/collect.js";
|
|
10
12
|
import { liveRoutes } from "./routes/live.js";
|
|
13
|
+
import { collectRouteSchemas, metaRoutes } from "./routes/meta.js";
|
|
11
14
|
import { queryRoutes } from "./routes/query.js";
|
|
12
15
|
/**
|
|
13
16
|
* Strip the live-SSE `?token=` from a logged URL. The token is a short-lived
|
|
@@ -74,14 +77,42 @@ export async function buildApp(deps) {
|
|
|
74
77
|
// satisfies the preflight that sendBeacon forces.
|
|
75
78
|
credentials: true,
|
|
76
79
|
});
|
|
80
|
+
// Resolve `x-api-key` once, before the rate limiter runs, so (a) a key with
|
|
81
|
+
// its own budget is throttled per key rather than per client IP and (b) the
|
|
82
|
+
// handlers and the audit hook reuse one metadata lookup per request (#309).
|
|
83
|
+
// Instance-level `onRequest` hooks run before the route-level hook the
|
|
84
|
+
// rate-limit plugin installs, so registration order here is load-bearing.
|
|
85
|
+
app.decorateRequest("resolvedKey", null);
|
|
86
|
+
app.decorateRequest("auditRowCount", null);
|
|
87
|
+
app.addHook("onRequest", async (request) => {
|
|
88
|
+
await attachApiKey(request, store);
|
|
89
|
+
});
|
|
77
90
|
await app.register(rateLimit, {
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
// A key carrying its own `rate_limit_max` / `rate_limit_window_ms` is
|
|
92
|
+
// bucketed on the key id with those values; everything else (including
|
|
93
|
+
// keyless ingest) keeps the global per-client-IP budget.
|
|
94
|
+
max: (request) => request.resolvedKey?.rateLimit?.max ?? config.rateLimitMax,
|
|
95
|
+
timeWindow: (request) => request.resolvedKey?.rateLimit?.windowMs ?? config.rateLimitWindowMs,
|
|
96
|
+
// `normalizeIP` is exactly what the plugin's own default key generator uses,
|
|
97
|
+
// so requests without a per-key budget keep their existing IPv6-aware bucket.
|
|
98
|
+
keyGenerator: (request) => request.resolvedKey?.rateLimit ? `key:${request.resolvedKey.keyId}` : normalizeIP(request.ip),
|
|
80
99
|
});
|
|
100
|
+
// Audit every authenticated, non-dashboard request (ADR 0051 §7). Registered
|
|
101
|
+
// after the rate limiter so a throttled request is still recorded.
|
|
102
|
+
registerAuditHooks(app, store, config);
|
|
103
|
+
const stopAuditRetention = startAuditRetention(app, store, config);
|
|
104
|
+
app.addHook("onClose", async () => stopAuditRetention());
|
|
105
|
+
// Record every route's Zod schemas as they are registered, so the generated
|
|
106
|
+
// OpenAPI document describes each parameter with the *same* schema that
|
|
107
|
+
// validates the request (ADR 0051 §1). The hook must be installed before the
|
|
108
|
+
// route plugins below; the array it fills is complete once `app.ready()` has
|
|
109
|
+
// resolved, which is always before `metaRoutes` serves its first request.
|
|
110
|
+
const routeSchemas = collectRouteSchemas(app);
|
|
81
111
|
app.get("/health", async () => ({ status: "ok" }));
|
|
82
112
|
await app.register(collectRoutes, { store, config, liveBus });
|
|
83
113
|
await app.register(liveRoutes, { store, config, liveBus });
|
|
84
114
|
await app.register(queryRoutes, { store, config });
|
|
115
|
+
await app.register(metaRoutes, { routeSchemas });
|
|
85
116
|
// All-in-one: serve a pre-built static dashboard from `dashboardDir`. The API
|
|
86
117
|
// routes above (`/health`, `/api/v1/*`) are matched first; everything else
|
|
87
118
|
// falls through to the static files. Unmatched GET navigations (the SPA deep
|
package/dist/app.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,eAAe,CAAC;AACjC,OAAO,MAAM,MAAM,iBAAiB,CAAC;AACrC,OAAO,SAAS,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
|
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import type { CollectorConfig } from "./config.js";
|
|
3
|
+
import type { CollectorStore } from "./store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Register the audit hooks on `app`:
|
|
6
|
+
*
|
|
7
|
+
* - `preSerialization` captures the row count when a handler returned an array
|
|
8
|
+
* (streamed/hijacked responses simply have none);
|
|
9
|
+
* - `onResponse` writes the row for authenticated, non-dashboard requests.
|
|
10
|
+
*/
|
|
11
|
+
export declare function registerAuditHooks(app: FastifyInstance, store: CollectorStore, config: CollectorConfig): void;
|
|
12
|
+
/**
|
|
13
|
+
* Start the retention sweep: delete audit rows older than
|
|
14
|
+
* `config.auditRetentionDays` now, then every {@link PRUNE_INTERVAL_MS}. The
|
|
15
|
+
* delete is idempotent, so a restart loop or several collector instances
|
|
16
|
+
* sharing one database cost nothing. Returns a stop function; the timer is
|
|
17
|
+
* `unref`'d so it never keeps the process alive.
|
|
18
|
+
*
|
|
19
|
+
* `auditRetentionDays === 0` disables the sweep (keep rows indefinitely).
|
|
20
|
+
*/
|
|
21
|
+
export declare function startAuditRetention(app: FastifyInstance, store: CollectorStore, config: CollectorConfig): () => void;
|
|
22
|
+
//# sourceMappingURL=audit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE/C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAwBjD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,eAAe,EACpB,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,eAAe,GACtB,IAAI,CAoCN;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,eAAe,EACpB,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,eAAe,GACtB,MAAM,IAAI,CAkBZ"}
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { serializeAuditParams } from "@uptimizr/db";
|
|
2
|
+
import { isDashboardRequest } from "./auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* Agent audit log plumbing (#309, ADR 0051 §7).
|
|
5
|
+
*
|
|
6
|
+
* Every authenticated request made with a key that is not the dashboard's own
|
|
7
|
+
* session is recorded: which key, which endpoint, the (bounded, redacted)
|
|
8
|
+
* parameters, the row count, the duration and the status.
|
|
9
|
+
*
|
|
10
|
+
* Two invariants hold everywhere in here:
|
|
11
|
+
*
|
|
12
|
+
* - **It never blocks the response.** The row is written from an `onResponse`
|
|
13
|
+
* hook, after the reply has been flushed.
|
|
14
|
+
* - **It never fails a request.** Every store call is fire-and-forget with its
|
|
15
|
+
* rejection swallowed into a log line; an unavailable audit table degrades
|
|
16
|
+
* the log, not the API.
|
|
17
|
+
*/
|
|
18
|
+
/** How often the retention sweep runs. Daily is plenty for a 30-day window. */
|
|
19
|
+
const PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
20
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
21
|
+
/**
|
|
22
|
+
* Register the audit hooks on `app`:
|
|
23
|
+
*
|
|
24
|
+
* - `preSerialization` captures the row count when a handler returned an array
|
|
25
|
+
* (streamed/hijacked responses simply have none);
|
|
26
|
+
* - `onResponse` writes the row for authenticated, non-dashboard requests.
|
|
27
|
+
*/
|
|
28
|
+
export function registerAuditHooks(app, store, config) {
|
|
29
|
+
app.addHook("preSerialization", async (request, _reply, payload) => {
|
|
30
|
+
if (Array.isArray(payload))
|
|
31
|
+
request.auditRowCount = payload.length;
|
|
32
|
+
return payload;
|
|
33
|
+
});
|
|
34
|
+
app.addHook("onResponse", async (request, reply) => {
|
|
35
|
+
const resolved = request.resolvedKey;
|
|
36
|
+
if (!resolved)
|
|
37
|
+
return;
|
|
38
|
+
if (!config.auditDashboardRequests && isDashboardRequest(request))
|
|
39
|
+
return;
|
|
40
|
+
// `reply.elapsedTime` is the ms between the request arriving and the
|
|
41
|
+
// response being sent — exactly the duration the audit log wants.
|
|
42
|
+
const durationMs = Math.round(reply.elapsedTime);
|
|
43
|
+
try {
|
|
44
|
+
void store
|
|
45
|
+
.recordAudit({
|
|
46
|
+
projectId: resolved.projectId,
|
|
47
|
+
keyId: resolved.keyId,
|
|
48
|
+
surface: "http",
|
|
49
|
+
// The route pattern (`/api/v1/sessions/:id/events`), not the raw URL:
|
|
50
|
+
// it groups cleanly and cannot carry a querystring credential.
|
|
51
|
+
toolOrPath: request.routeOptions.url ?? new URL(request.url, "http://x").pathname,
|
|
52
|
+
params: serializeAuditParams(request.query),
|
|
53
|
+
rowCount: request.auditRowCount,
|
|
54
|
+
durationMs,
|
|
55
|
+
status: reply.statusCode,
|
|
56
|
+
})
|
|
57
|
+
.catch((err) => {
|
|
58
|
+
request.log.warn({ err }, "failed to write agent audit row");
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// A synchronous throw from the store (or a store that does not implement
|
|
63
|
+
// the audit surface at all) must never surface on the request path.
|
|
64
|
+
request.log.warn({ err }, "failed to write agent audit row");
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Start the retention sweep: delete audit rows older than
|
|
70
|
+
* `config.auditRetentionDays` now, then every {@link PRUNE_INTERVAL_MS}. The
|
|
71
|
+
* delete is idempotent, so a restart loop or several collector instances
|
|
72
|
+
* sharing one database cost nothing. Returns a stop function; the timer is
|
|
73
|
+
* `unref`'d so it never keeps the process alive.
|
|
74
|
+
*
|
|
75
|
+
* `auditRetentionDays === 0` disables the sweep (keep rows indefinitely).
|
|
76
|
+
*/
|
|
77
|
+
export function startAuditRetention(app, store, config) {
|
|
78
|
+
if (config.auditRetentionDays <= 0)
|
|
79
|
+
return () => { };
|
|
80
|
+
const sweep = () => {
|
|
81
|
+
const cutoff = Date.now() - config.auditRetentionDays * MS_PER_DAY;
|
|
82
|
+
try {
|
|
83
|
+
void store.pruneAudit(cutoff).catch((err) => {
|
|
84
|
+
app.log.warn({ err }, "agent audit retention sweep failed");
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
app.log.warn({ err }, "agent audit retention sweep failed");
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
sweep();
|
|
92
|
+
const timer = setInterval(sweep, PRUNE_INTERVAL_MS);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
return () => clearInterval(timer);
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=audit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit.js","sourceRoot":"","sources":["../src/audit.ts"],"names":[],"mappings":"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"}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
2
|
+
import type { ApiKeyCapability, ResolvedApiKey } from "@uptimizr/db";
|
|
3
|
+
import type { CollectorStore } from "./store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Request-boundary authentication and capability checks for the collector
|
|
6
|
+
* (#309, ADR 0051 §7).
|
|
7
|
+
*
|
|
8
|
+
* A key resolves to a **capability set**, not a single capability, so the same
|
|
9
|
+
* plumbing serves reads (`query`), raw per-session access (`query:raw`, which
|
|
10
|
+
* additionally requires `ENABLE_RAW_SESSION_RETENTION` — ADR 0003) and the
|
|
11
|
+
* metadata write path (`annotate`, whose endpoints arrive with #310).
|
|
12
|
+
*
|
|
13
|
+
* Resolution happens once per request, in an `onRequest` hook, so that:
|
|
14
|
+
*
|
|
15
|
+
* 1. the rate limiter can key on the API key id and honour the key's own
|
|
16
|
+
* per-key budget before any handler runs, and
|
|
17
|
+
* 2. handlers and the audit hook read the already-resolved key instead of
|
|
18
|
+
* hitting the metadata store again.
|
|
19
|
+
*/
|
|
20
|
+
declare module "fastify" {
|
|
21
|
+
interface FastifyRequest {
|
|
22
|
+
/**
|
|
23
|
+
* What this request's `x-api-key` resolved to, or `null` when
|
|
24
|
+
* unauthenticated. This is the key's **record** — project, key id,
|
|
25
|
+
* capabilities, rate limit — and deliberately never the key itself, which
|
|
26
|
+
* is read from the header and discarded.
|
|
27
|
+
*/
|
|
28
|
+
resolvedKey: ResolvedApiKey | null;
|
|
29
|
+
/** Rows in the response body, when it serialized to an array (audit log). */
|
|
30
|
+
auditRowCount: number | null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Header a first-party UI sets to identify itself (see `isDashboardRequest`). */
|
|
34
|
+
export declare const CLIENT_HEADER = "x-uptimizr-client";
|
|
35
|
+
/**
|
|
36
|
+
* Whether this request is "the dashboard's own session" — the requests the
|
|
37
|
+
* audit log deliberately skips so an agent's activity is not buried under a
|
|
38
|
+
* dashboard's panel refreshes.
|
|
39
|
+
*
|
|
40
|
+
* It is identified by the `x-uptimizr-client: dashboard` header that
|
|
41
|
+
* `@uptimizr/react`'s `CollectorApi` sets by default; the in-browser assistant
|
|
42
|
+
* and any other agent client send a different value (or none) and are audited.
|
|
43
|
+
*
|
|
44
|
+
* This is a **volume filter, not a security boundary**: anyone holding the key
|
|
45
|
+
* could send the header, and anyone holding the key can already do everything
|
|
46
|
+
* the key allows. Set `AUDIT_DASHBOARD_REQUESTS=1` to record every
|
|
47
|
+
* authenticated request without exception.
|
|
48
|
+
*/
|
|
49
|
+
export declare function isDashboardRequest(request: FastifyRequest): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the request's `x-api-key` into {@link FastifyRequest.resolvedKey}.
|
|
52
|
+
* Never replies and never throws: an absent, unknown or revoked key simply
|
|
53
|
+
* leaves `resolvedKey` null, and the helpers below turn that into a 401.
|
|
54
|
+
*/
|
|
55
|
+
export declare function attachApiKey(request: FastifyRequest, store: CollectorStore): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Require an authenticated key holding `capability`. Sends a 401 (no/invalid
|
|
58
|
+
* key) or 403 (key lacks the capability) and returns `null` on refusal.
|
|
59
|
+
*
|
|
60
|
+
* Reads are always scoped to the authenticated project — a client-supplied
|
|
61
|
+
* project id is never trusted.
|
|
62
|
+
*/
|
|
63
|
+
export declare function requireCapability(request: FastifyRequest, reply: FastifyReply, store: CollectorStore, capability: ApiKeyCapability): Promise<ResolvedApiKey | null>;
|
|
64
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACrE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,cAAc;QACtB;;;;;WAKG;QACH,WAAW,EAAE,cAAc,GAAG,IAAI,CAAC;QACnC,6EAA6E;QAC7E,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;KAC9B;CACF;AAED,kFAAkF;AAClF,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAEjD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAEnE;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,cAAc,EAAE,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAQhG;AAUD;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,cAAc,EACvB,KAAK,EAAE,YAAY,EACnB,KAAK,EAAE,cAAc,EACrB,UAAU,EAAE,gBAAgB,GAC3B,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAmBhC"}
|