@uptimizr/collector-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +169 -0
  3. package/dist/app.d.ts +22 -0
  4. package/dist/app.d.ts.map +1 -0
  5. package/dist/app.js +96 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +148 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/config.d.ts +59 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +55 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/csp.d.ts +18 -0
  16. package/dist/csp.d.ts.map +1 -0
  17. package/dist/csp.js +91 -0
  18. package/dist/csp.js.map +1 -0
  19. package/dist/duckdbStore.d.ts +14 -0
  20. package/dist/duckdbStore.d.ts.map +1 -0
  21. package/dist/duckdbStore.js +69 -0
  22. package/dist/duckdbStore.js.map +1 -0
  23. package/dist/enrich.d.ts +8 -0
  24. package/dist/enrich.d.ts.map +1 -0
  25. package/dist/enrich.js +9 -0
  26. package/dist/enrich.js.map +1 -0
  27. package/dist/liveBus.d.ts +86 -0
  28. package/dist/liveBus.d.ts.map +1 -0
  29. package/dist/liveBus.js +195 -0
  30. package/dist/liveBus.js.map +1 -0
  31. package/dist/liveToken.d.ts +11 -0
  32. package/dist/liveToken.d.ts.map +1 -0
  33. package/dist/liveToken.js +44 -0
  34. package/dist/liveToken.js.map +1 -0
  35. package/dist/memoryStore.d.ts +22 -0
  36. package/dist/memoryStore.d.ts.map +1 -0
  37. package/dist/memoryStore.js +207 -0
  38. package/dist/memoryStore.js.map +1 -0
  39. package/dist/routes/collect.d.ts +25 -0
  40. package/dist/routes/collect.d.ts.map +1 -0
  41. package/dist/routes/collect.js +55 -0
  42. package/dist/routes/collect.js.map +1 -0
  43. package/dist/routes/live.d.ts +18 -0
  44. package/dist/routes/live.d.ts.map +1 -0
  45. package/dist/routes/live.js +179 -0
  46. package/dist/routes/live.js.map +1 -0
  47. package/dist/routes/query.d.ts +14 -0
  48. package/dist/routes/query.d.ts.map +1 -0
  49. package/dist/routes/query.js +610 -0
  50. package/dist/routes/query.js.map +1 -0
  51. package/dist/serve.d.ts +18 -0
  52. package/dist/serve.d.ts.map +1 -0
  53. package/dist/serve.js +48 -0
  54. package/dist/serve.js.map +1 -0
  55. package/dist/server.d.ts +3 -0
  56. package/dist/server.d.ts.map +1 -0
  57. package/dist/server.js +12 -0
  58. package/dist/server.js.map +1 -0
  59. package/dist/store.d.ts +263 -0
  60. package/dist/store.d.ts.map +1 -0
  61. package/dist/store.js +2 -0
  62. package/dist/store.js.map +1 -0
  63. package/dist/visitor.d.ts +12 -0
  64. package/dist/visitor.d.ts.map +1 -0
  65. package/dist/visitor.js +18 -0
  66. package/dist/visitor.js.map +1 -0
  67. package/package.json +64 -0
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or Derivative
95
+ Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and do
117
+ not modify the License. You may add Your own attribution notices
118
+ within Derivative Works that You distribute, alongside or as an
119
+ addendum to the NOTICE text from the Work, provided that such
120
+ additional attribution notices cannot be construed as modifying
121
+ the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions for
125
+ use, reproduction, or distribution of Your modifications, or for any
126
+ such Derivative Works as a whole, provided Your use, reproduction,
127
+ and distribution of the Work otherwise complies with the conditions
128
+ stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Uptimizr Contributors
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # @uptimizr/collector-server
2
+
3
+ The public-facing **ingestion + query API** (Fastify) for the OSS data-collector.
4
+ All client input is untrusted and validated against [`@uptimizr/schema`](../../packages/schema)
5
+ at the boundary. Route handlers stay thin; storage logic lives in
6
+ [`@uptimizr/db`](../../packages/db) behind a swappable `CollectorStore`.
7
+
8
+ ## Self-host (npm)
9
+
10
+ Run the collector straight from npm — no repo clone, no Docker, no external
11
+ database (the OSS default store is a single DuckDB file). The store is
12
+ created and migrated automatically.
13
+
14
+ ```bash
15
+ # 1. One-time setup: generates a visitor-hash secret, creates the DuckDB store,
16
+ # mints a first project + API key, and writes a local .env.
17
+ npx -p @uptimizr/collector-server uptimizr init "My Project"
18
+
19
+ # 2. Start the ingestion + query API (reads the generated .env; 0.0.0.0:4318).
20
+ npx -p @uptimizr/collector-server uptimizr serve
21
+ ```
22
+
23
+ `init` prints a **`projectId`** and a one-time **API key**. Give the `projectId`
24
+ and this server's URL (the **`endpoint`**) to your client SDK (e.g.
25
+ `@uptimizr/babylon`); use the **API key** (`x-api-key`) for the query routes /
26
+ dashboard. Mint more projects later with
27
+ `npx -p @uptimizr/collector-server uptimizr new-project "<name>"`.
28
+
29
+ ### All-in-one: serve the dashboard too
30
+
31
+ The collector can also serve a pre-built static dashboard from its own origin, so
32
+ a single process handles ingestion, queries, **and** the UI:
33
+
34
+ ```bash
35
+ # Build the dashboard as a static bundle (emits oss/apps/dashboard/out).
36
+ pnpm --filter @uptimizr/dashboard build:static
37
+
38
+ # Point the collector at it and start (relative paths are resolved from CWD).
39
+ export COLLECTOR_DASHBOARD_DIR="./oss/apps/dashboard/out"
40
+ npx -p @uptimizr/collector-server uptimizr serve
41
+ ```
42
+
43
+ The dashboard then loads at the collector's URL (e.g. `http://localhost:4318`) and
44
+ defaults its collector target to that same origin — no build-time URL to bake.
45
+ Deep links (`/projects/:id/...`) are served the SPA entry so refresh / shared
46
+ links resolve. Leave `COLLECTOR_DASHBOARD_DIR` unset to keep the collector
47
+ headless.
48
+
49
+ ### Manual setup (without the CLI)
50
+
51
+ Prefer to wire it yourself? The CLI is optional — set the environment directly:
52
+
53
+ ```bash
54
+ # A daily-rotating secret for the cookieless visitor hash is REQUIRED.
55
+ export VISITOR_HASH_SECRET="$(openssl rand -hex 32)"
56
+ # Browser origins allowed to call the collector (your 3D app + any tools):
57
+ export COLLECTOR_CORS_ORIGINS="https://your-app.example.com"
58
+ # Where the DuckDB file lives (created if missing):
59
+ export DUCKDB_PATH="./uptimizr.duckdb"
60
+
61
+ # Mint a project + API key (prints PROJECT_ID and a utk_… key once).
62
+ npx -p @uptimizr/db uptimizr-db-new-project "My Project"
63
+
64
+ # Start the ingestion + query API (defaults to 0.0.0.0:4318).
65
+ npx -p @uptimizr/collector-server uptimizr-collector
66
+ ```
67
+
68
+ Full configuration is in [Configuration](#configuration) below. For a production
69
+ deployment, run a single instance behind your own TLS / reverse proxy and persist
70
+ the DuckDB file on a volume (DuckDB is single-writer — one collector process per
71
+ file; back up by copying the file).
72
+
73
+ > Installing as a dependency instead of via `npx`? `npm install @uptimizr/collector-server`
74
+ > exposes the `uptimizr` CLI (`init` / `serve` / `new-project` / `migrate`) plus
75
+ > the legacy `uptimizr-collector` bin; `@uptimizr/db` exposes
76
+ > `uptimizr-db-new-project` / `uptimizr-db-migrate`.
77
+
78
+ ## Endpoints
79
+
80
+ ### Ingestion
81
+
82
+ - `POST /api/v1/collect` — accepts a batched `collectRequest`. Validates → enriches
83
+ (server-set cookieless `visitorId = hash(ip + ua + dailySalt)`, raw IP never stored)
84
+ → inserts into the configured `CollectorStore` (DuckDB by default).
85
+
86
+ ### Query (require `x-api-key`)
87
+
88
+ Aggregations are computed **at query time** (v1) — including the heatmap/perf
89
+ aggregates, which run directly in the OSS DuckDB store. Every route is scoped to
90
+ the project the API key resolves to.
91
+
92
+ - `GET /api/v1/sessions`
93
+ - `GET /api/v1/heatmaps/pointer`
94
+ - `GET /api/v1/heatmaps/camera`
95
+ - `GET /api/v1/meshes/top`
96
+ - `GET /api/v1/perf`
97
+ - `GET /api/v1/sessions/:id/events` — ordered replay timeline, **gated by**
98
+ `ENABLE_RAW_SESSION_RETENTION` (returns `403` when disabled).
99
+
100
+ Shared query params: `since`, `until` (epoch ms), `bins`, `limit`.
101
+
102
+ - `GET /health` — liveness probe.
103
+
104
+ ## Security
105
+
106
+ `@fastify/helmet`, `@fastify/cors` (restricted to `COLLECTOR_CORS_ORIGINS`), and
107
+ `@fastify/rate-limit`. Secrets and raw IPs are never logged. The server fails fast
108
+ if `VISITOR_HASH_SECRET` is missing.
109
+
110
+ ### Authentication: which endpoints need a key
111
+
112
+ | Endpoint group | Auth | Why |
113
+ | ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
114
+ | `POST /api/v1/collect` | **None (keyless)** | Runs in untrusted browsers; a key shipped to the client is not a secret. Ingestion is open by design and protected by validation + rate limits instead. |
115
+ | All query routes (`/api/v1/*`) | `x-api-key` | Read access is scoped to the project the key resolves to. |
116
+ | `GET /health` | None | Liveness probe. |
117
+
118
+ ### Threat model for keyless ingestion
119
+
120
+ Because `POST /api/v1/collect` accepts unauthenticated input, every request is treated as hostile:
121
+
122
+ - **Validated at the boundary.** Bodies are parsed against [`@uptimizr/schema`](../../packages/schema);
123
+ invalid batches are rejected with `400`. The raw IP is used only to derive the cookieless
124
+ visitor hash and is never stored.
125
+ - **Bounded payloads.** The schema caps batch size and every free-text / collection field (see
126
+ the [_Ingestion payload bounds_](../../packages/schema/README.md#ingestion-payload-bounds)
127
+ table). An oversized field rejects the batch, so a single request can't smuggle a huge blob or
128
+ exhaust memory/storage. Connectors truncate locally before sending.
129
+ - **Rate limited.** `@fastify/rate-limit` (`COLLECTOR_RATE_LIMIT_MAX` /
130
+ `COLLECTOR_RATE_LIMIT_WINDOW_MS`) caps requests per client.
131
+ - **Residual risk.** A keyless endpoint can still receive spoofed or spammy events scoped to a
132
+ known `projectId`. This is an accepted trade-off for cookieless, client-side capture; deployers
133
+ who need stronger guarantees can front the collector with their own auth/WAF and tighten the
134
+ CORS allowlist.
135
+
136
+ ## Configuration
137
+
138
+ Environment-driven (see [`.env.example`](../../../.env.example)): `COLLECTOR_HOST`,
139
+ `COLLECTOR_PORT`, `COLLECTOR_CORS_ORIGINS`, `VISITOR_HASH_SECRET`,
140
+ `ENABLE_RAW_SESSION_RETENTION`, `COLLECTOR_DASHBOARD_DIR` (optional; serve a
141
+ static dashboard all-in-one — see [above](#all-in-one-serve-the-dashboard-too)).
142
+
143
+ The storage backend is chosen with `COLLECTOR_STORE`:
144
+
145
+ - `duckdb` **(default)** — the OSS single-file store (events **and** metadata in one
146
+ DuckDB file at `DUCKDB_PATH`, default `./data/uptimizr.duckdb`). No
147
+ external database service to run. DuckDB is single-writer, so run one collector
148
+ instance per file; back up by copying the file.
149
+ - `memory` — a dependency-free in-memory store for local dev / E2E only (seed its
150
+ project/key via `COLLECTOR_MEMORY_PROJECT_ID` / `COLLECTOR_MEMORY_API_KEY`).
151
+
152
+ The two-store ClickHouse + Postgres path is an optional scale tier and is not required to self-host the OSS collector.
153
+
154
+ ## Develop
155
+
156
+ ```bash
157
+ pnpm --filter @uptimizr/collector-server dev # tsx watch
158
+ pnpm --filter @uptimizr/collector-server test # vitest (inject + fake store)
159
+ pnpm --filter @uptimizr/collector-server build
160
+ ```
161
+
162
+ The data layer is abstracted by `CollectorStore`, so tests run against a fake store
163
+ with `app.inject()` — no live database required. Local end-to-end runs use the
164
+ default DuckDB store (a single file, no service to start); the ClickHouse + Postgres
165
+ stack in `infra/docker` backs the optional scale tier only.
166
+
167
+ ## License
168
+
169
+ [Apache-2.0](./LICENSE) © Uptimizr.
package/dist/app.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
+ import { type LiveBus } from "./liveBus.js";
5
+ export interface BuildAppDeps {
6
+ store: CollectorStore;
7
+ config: CollectorConfig;
8
+ /**
9
+ * In-process live event bus (ADR 0032). Injectable for tests; a default
10
+ * in-process bus is created from `config.liveWindowMs` when omitted.
11
+ */
12
+ liveBus?: LiveBus;
13
+ /** Pass `true` (or Fastify logger options) to enable request logging. */
14
+ logger?: boolean;
15
+ }
16
+ /**
17
+ * Build the collector Fastify instance. Pure factory — takes its dependencies so
18
+ * it can be exercised with `app.inject()` and a fake store in tests, and so the
19
+ * store/DB stays swappable.
20
+ */
21
+ export declare function buildApp(deps: BuildAppDeps): Promise<FastifyInstance>;
22
+ //# sourceMappingURL=app.d.ts.map
@@ -0,0 +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;AAM3D,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,CA8D3E"}
package/dist/app.js ADDED
@@ -0,0 +1,96 @@
1
+ import cors from "@fastify/cors";
2
+ import helmet from "@fastify/helmet";
3
+ import rateLimit from "@fastify/rate-limit";
4
+ import fastifyStatic from "@fastify/static";
5
+ import Fastify, {} from "fastify";
6
+ import { serializerCompiler, validatorCompiler, } from "fastify-type-provider-zod";
7
+ import { createLiveBus } from "./liveBus.js";
8
+ import { buildDashboardCsp } from "./csp.js";
9
+ import { collectRoutes } from "./routes/collect.js";
10
+ import { liveRoutes } from "./routes/live.js";
11
+ import { queryRoutes } from "./routes/query.js";
12
+ /**
13
+ * Strip the live-SSE `?token=` from a logged URL. The token is a short-lived
14
+ * bearer credential; it must never land in access logs (or proxy logs).
15
+ */
16
+ function redactToken(url) {
17
+ return url.replace(/([?&]token=)[^&]*/gi, "$1[redacted]");
18
+ }
19
+ /**
20
+ * Request logger options that (a) never log the raw client IP / remote address
21
+ * (privacy model — ADR 0003) and (b) redact the live-SSE token from URLs. Used
22
+ * when `logger: true`; explicit logger options are passed through untouched.
23
+ */
24
+ function loggerOptions(logger) {
25
+ if (logger !== true)
26
+ return logger ?? false;
27
+ return {
28
+ serializers: {
29
+ req(request) {
30
+ return { method: request.method, url: redactToken(request.url) };
31
+ },
32
+ },
33
+ };
34
+ }
35
+ /**
36
+ * Build the collector Fastify instance. Pure factory — takes its dependencies so
37
+ * it can be exercised with `app.inject()` and a fake store in tests, and so the
38
+ * store/DB stays swappable.
39
+ */
40
+ export async function buildApp(deps) {
41
+ const { store, config } = deps;
42
+ const liveBus = deps.liveBus ?? createLiveBus({ windowMs: config.liveWindowMs });
43
+ const app = Fastify({
44
+ logger: loggerOptions(deps.logger),
45
+ // Honor `X-Forwarded-*` only when explicitly configured, so the visitor hash
46
+ // and rate-limit bucket key on the real client IP behind a TLS proxy.
47
+ trustProxy: config.trustProxy,
48
+ // Cap request bodies; ingestion batches are small JSON documents.
49
+ bodyLimit: config.bodyLimit,
50
+ }).withTypeProvider();
51
+ app.setValidatorCompiler(validatorCompiler);
52
+ app.setSerializerCompiler(serializerCompiler);
53
+ // When serving the bundled static dashboard, apply a Content-Security-Policy
54
+ // tuned for a Next.js static export: inline bootstrap scripts are pinned by
55
+ // SHA-256 hash (a static export cannot mint per-request nonces), and the rest
56
+ // is locked down. `COLLECTOR_CSP=off` reverts to no policy as an escape hatch.
57
+ const contentSecurityPolicy = config.dashboardDir && config.cspMode === "strict"
58
+ ? buildDashboardCsp(config.dashboardDir, config.corsOrigins)
59
+ : false;
60
+ await app.register(helmet, config.dashboardDir ? { contentSecurityPolicy } : {});
61
+ await app.register(cors, {
62
+ origin: config.corsOrigins.length > 0 ? config.corsOrigins : false,
63
+ // @fastify/cors defaults `methods` to GET,HEAD,POST — which omits PUT and so
64
+ // breaks the browser preflight for scene-proxy registration
65
+ // (PUT /api/v1/scenes/:id/representation). List the verbs the HTTP API uses.
66
+ methods: ["GET", "HEAD", "POST", "PUT"],
67
+ });
68
+ await app.register(rateLimit, {
69
+ max: config.rateLimitMax,
70
+ timeWindow: config.rateLimitWindowMs,
71
+ });
72
+ app.get("/health", async () => ({ status: "ok" }));
73
+ await app.register(collectRoutes, { store, config, liveBus });
74
+ await app.register(liveRoutes, { store, config, liveBus });
75
+ await app.register(queryRoutes, { store, config });
76
+ // All-in-one: serve a pre-built static dashboard from `dashboardDir`. The API
77
+ // routes above (`/health`, `/api/v1/*`) are matched first; everything else
78
+ // falls through to the static files. Unmatched GET navigations (the SPA deep
79
+ // links `/projects/:id`...) are served `index.html` so refresh/shared links
80
+ // resolve client-side.
81
+ if (config.dashboardDir) {
82
+ await app.register(fastifyStatic, {
83
+ root: config.dashboardDir,
84
+ prefix: "/",
85
+ wildcard: false,
86
+ });
87
+ app.setNotFoundHandler((req, reply) => {
88
+ if (req.method === "GET" && !req.url.startsWith("/api/v1/") && req.url !== "/health") {
89
+ return reply.type("text/html").sendFile("index.html");
90
+ }
91
+ return reply.code(404).send({ error: "Not Found" });
92
+ });
93
+ }
94
+ return app;
95
+ }
96
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from "node:crypto";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { createDuckdbClient, duckdbCreateApiKey, duckdbCreateProject, migrateDuckdb, readDbSettings, } from "@uptimizr/db";
6
+ /**
7
+ * Unified `uptimizr` CLI (ADR 0029) — collapses the multi-step npm self-host
8
+ * (`export VISITOR_HASH_SECRET` → `uptimizr-db-new-project` → `uptimizr-collector`)
9
+ * into `uptimizr init && uptimizr serve`.
10
+ *
11
+ * Subcommands:
12
+ * - `init` — generate a visitor-hash secret, create the DuckDB
13
+ * store, mint a first project + API key, and write `.env`.
14
+ * - `serve` (default) — run the ingestion + query API (see {@link serve}).
15
+ * - `new-project <name>` — mint an additional project + API key.
16
+ * - `migrate` — apply DuckDB store migrations.
17
+ *
18
+ * The OSS store is DuckDB — no Docker, no external database (ADR 0020).
19
+ */
20
+ const ENV_FILE = resolve(process.cwd(), ".env");
21
+ /** Load a local `.env` (Node 22 built-in) so `serve`/`migrate` see config without a wrapper. */
22
+ function loadLocalEnv() {
23
+ if (existsSync(ENV_FILE)) {
24
+ process.loadEnvFile(ENV_FILE);
25
+ }
26
+ }
27
+ /** Join positional args into a project name, dropping a stray `--` separator. */
28
+ function nameArg(args) {
29
+ return args
30
+ .filter((a) => a !== "--")
31
+ .join(" ")
32
+ .trim();
33
+ }
34
+ function renderEnv(secret) {
35
+ return `${[
36
+ "# Uptimizr collector configuration (generated by 'uptimizr init').",
37
+ "# OSS default store is DuckDB — no Docker, no external database.",
38
+ "",
39
+ `VISITOR_HASH_SECRET=${secret}`,
40
+ "COLLECTOR_STORE=duckdb",
41
+ "DUCKDB_PATH=./data/uptimizr.duckdb",
42
+ "COLLECTOR_PORT=4318",
43
+ ].join("\n")}\n`;
44
+ }
45
+ /** Persist a generated secret so `serve` works on the next run. */
46
+ function ensureSecretPersisted(secret, generated) {
47
+ if (!existsSync(ENV_FILE)) {
48
+ writeFileSync(ENV_FILE, renderEnv(secret));
49
+ console.error(`✓ wrote ${ENV_FILE}`);
50
+ return;
51
+ }
52
+ if (!generated)
53
+ return;
54
+ const content = readFileSync(ENV_FILE, "utf8");
55
+ if (/^VISITOR_HASH_SECRET=/m.test(content))
56
+ return;
57
+ const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
58
+ writeFileSync(ENV_FILE, `${content}${sep}VISITOR_HASH_SECRET=${secret}\n`);
59
+ console.error(`✓ added VISITOR_HASH_SECRET to ${ENV_FILE}`);
60
+ }
61
+ async function cmdInit(name) {
62
+ loadLocalEnv();
63
+ let secret = process.env.VISITOR_HASH_SECRET;
64
+ const generated = !secret;
65
+ if (!secret) {
66
+ secret = randomBytes(32).toString("hex");
67
+ process.env.VISITOR_HASH_SECRET = secret;
68
+ }
69
+ const db = await createDuckdbClient(readDbSettings().duckdb.path);
70
+ await migrateDuckdb(db);
71
+ const project = await duckdbCreateProject(db, name);
72
+ const { key } = await duckdbCreateApiKey(db, project.id);
73
+ await db.close();
74
+ ensureSecretPersisted(secret, generated);
75
+ const port = Number(process.env.COLLECTOR_PORT ?? 4318);
76
+ console.error("\n✓ Uptimizr is ready.");
77
+ console.error(` Project: ${project.id} (${project.name})`);
78
+ console.error(` API key: ${key} (shown once — put it in your app config)`);
79
+ console.error(` Endpoint: http://localhost:${port}/api/v1`);
80
+ console.error("\nNext: uptimizr serve");
81
+ }
82
+ async function cmdNewProject(name) {
83
+ loadLocalEnv();
84
+ const db = await createDuckdbClient(readDbSettings().duckdb.path);
85
+ await migrateDuckdb(db);
86
+ const project = await duckdbCreateProject(db, name);
87
+ const { key } = await duckdbCreateApiKey(db, project.id);
88
+ await db.close();
89
+ console.error(`✓ project created: ${project.id} (${project.name})`);
90
+ console.error(` API key (shown once): ${key}`);
91
+ process.stdout.write(`${JSON.stringify({ projectId: project.id, name: project.name, apiKey: key })}\n`);
92
+ }
93
+ async function cmdMigrate() {
94
+ loadLocalEnv();
95
+ const db = await createDuckdbClient(readDbSettings().duckdb.path);
96
+ await migrateDuckdb(db);
97
+ await db.close();
98
+ console.error("✓ migrations applied");
99
+ }
100
+ function printUsage() {
101
+ console.error([
102
+ "uptimizr — self-host the OSS 3D-analytics collector (DuckDB, no Docker).",
103
+ "",
104
+ "Usage:",
105
+ " uptimizr init [name] generate a secret, create the store, mint a project + key, write .env",
106
+ " uptimizr serve run the ingestion + query API (default)",
107
+ " uptimizr new-project <name> mint an additional project + API key",
108
+ " uptimizr migrate apply store migrations",
109
+ " uptimizr help show this help",
110
+ "",
111
+ "Quick start: uptimizr init && uptimizr serve",
112
+ ].join("\n"));
113
+ }
114
+ async function main() {
115
+ const [cmd, ...rest] = process.argv.slice(2);
116
+ switch (cmd) {
117
+ case undefined:
118
+ case "serve": {
119
+ loadLocalEnv();
120
+ const { serve } = await import("./serve.js");
121
+ await serve();
122
+ return;
123
+ }
124
+ case "init":
125
+ await cmdInit(nameArg(rest) || "Default Project");
126
+ return;
127
+ case "new-project":
128
+ await cmdNewProject(nameArg(rest) || "Project");
129
+ return;
130
+ case "migrate":
131
+ await cmdMigrate();
132
+ return;
133
+ case "help":
134
+ case "--help":
135
+ case "-h":
136
+ printUsage();
137
+ return;
138
+ default:
139
+ console.error(`Unknown command: ${cmd}\n`);
140
+ printUsage();
141
+ process.exit(1);
142
+ }
143
+ }
144
+ main().catch((err) => {
145
+ console.error(err);
146
+ process.exit(1);
147
+ });
148
+ //# sourceMappingURL=cli.js.map