@camstack/server 0.1.3

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 (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
package/src/main.ts ADDED
@@ -0,0 +1,1049 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument -- pre-existing lint debt across this 800+ line bootstrap module. The flagged sites cross typed boundaries (Fastify request typing, AddonRouteRegistry, AuthService inherited methods) where the projectService context can't trace inheritance chains. Tracked separately; do not amend in unrelated edits. */
2
+ import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
3
+ import { applyWSSHandler } from "@trpc/server/adapters/ws";
4
+ import type { CreateWSSContextFnOptions } from "@trpc/server/adapters/ws";
5
+ import fastifyStatic from "@fastify/static";
6
+ import fastifyCookie from "@fastify/cookie";
7
+ import { WebSocketServer } from "ws";
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { execSync } from "node:child_process";
11
+ import { LoggingService } from "./core/logging/logging.service";
12
+ import { EventBusService } from "./core/events/event-bus.service";
13
+ import { ConfigService } from "./core/config/config.service";
14
+ import { AuthService } from "./core/auth/auth.service";
15
+
16
+ // Boot-time capability declaration runs over the auto-generated
17
+ // `ALL_CAPABILITY_DEFINITIONS` array — every `*.cap.ts` file that ships
18
+ // with `@camstack/types` is included automatically. Adding a new cap
19
+ // requires no edit to this file: drop the `*.cap.ts` and re-run
20
+ // `npx tsx scripts/generate-capability-router-types.ts`. The previous
21
+ // hand-curated list silently dropped caps (zones, zone-rules,
22
+ // zone-analytics, audio-metrics — see git for the regression).
23
+ import { ALL_CAPABILITY_DEFINITIONS } from "@camstack/types";
24
+ import { StreamProbeService } from "./core/streaming/stream-probe.service";
25
+ import { FeatureService } from "./core/feature/feature.service";
26
+ import { AgentRegistryService } from "./core/agent/agent-registry.service";
27
+ import { MoleculerService } from "./core/moleculer/moleculer.service";
28
+ import { AddonRegistryService } from "./core/addon/addon-registry.service";
29
+ import { AddonPackageService } from "./core/addon/addon-package.service";
30
+ import { ReplEngineService } from "./core/repl/repl-engine.service";
31
+ import { NetworkQualityService } from "./core/network/network-quality.service";
32
+ import { StorageService } from "./core/storage/storage.service";
33
+ import { AddonBridgeService } from "./core/addon-bridge/addon-bridge.service";
34
+ import { AddonPagesService } from "./core/addon-pages/addon-pages.service";
35
+ import { AddonWidgetsService } from "./core/addon-widgets/addon-widgets.service";
36
+ import { buildAppRouter } from "./api/trpc/trpc.router";
37
+ import { buildCoreCapService } from "./api/trpc/core-cap-bridge";
38
+ import {
39
+ createTrpcContext,
40
+ createWsTrpcContext,
41
+ } from "./api/trpc/trpc.context";
42
+ import { registerAddonUploadRoute } from "./api/addon-upload";
43
+ import { registerAuthWhoamiRoute } from "./api/auth-whoami";
44
+ import { buildSessionCookie, clearSessionCookie, SESSION_COOKIE, shouldRedirectToLogin, loginRedirectUrl } from "./auth/session-cookie.js";
45
+ import { registerHealthRoutes } from "./api/health/health.routes";
46
+ import { registerOauth2Routes } from "./api/oauth2/oauth2-routes.js";
47
+ import {
48
+ AddonRouteRegistry,
49
+ } from "@camstack/core";
50
+ import type { FastifyRequest, FastifyReply } from "fastify";
51
+ import { loadBootstrapConfig, setupInfra } from "./boot/boot-config";
52
+ import { bootManual } from "./manual-boot";
53
+
54
+ import { PostBootService } from "./boot/post-boot.service";
55
+
56
+ // ---- Process-level error handlers ----
57
+
58
+ process.on("uncaughtException", (err) => {
59
+ console.error(
60
+ "[uncaughtException] Unhandled exception — server will continue:",
61
+ err,
62
+ );
63
+ });
64
+
65
+ process.on("unhandledRejection", (reason) => {
66
+ console.error(
67
+ "[unhandledRejection] Unhandled promise rejection — server will continue:",
68
+ reason,
69
+ );
70
+ });
71
+
72
+ // ---- Graceful shutdown ----
73
+
74
+ // Kill all child processes immediately on signal, then let Fastify clean up.
75
+ // This is critical because tsx watch (dev) force-kills the main process after a short
76
+ // timeout — if we wait for Fastify OnModuleDestroy, children become orphans.
77
+ let shutdownStarted = false;
78
+ function immediateChildCleanup(signal: string) {
79
+ if (shutdownStarted) return;
80
+ shutdownStarted = true;
81
+
82
+ console.log(`[shutdown] Received ${signal} — killing child processes immediately…`);
83
+
84
+ // Synchronously kill all children of this process via the OS.
85
+ // This is fast and ensures no orphans even if Fastify shutdown is slow.
86
+ try {
87
+ const myPid = process.pid;
88
+ const isMac = process.platform === "darwin";
89
+ // pkill sends SIGTERM to all processes whose parent is our PID
90
+ const cmd = isMac
91
+ ? `pkill -TERM -P ${myPid} 2>/dev/null; true`
92
+ : `kill -- -${myPid} 2>/dev/null; true`;
93
+ execSync(cmd, { timeout: 3000 });
94
+ console.log("[shutdown] Child processes signalled");
95
+ } catch {
96
+ // Best effort — children may have already exited
97
+ }
98
+
99
+ // Safety: force exit after 10s if Fastify shutdown stalls
100
+ const timer = setTimeout(() => {
101
+ console.error("[shutdown] Graceful shutdown timed out after 10s — forcing exit");
102
+ process.exit(1);
103
+ }, 10_000);
104
+ timer.unref();
105
+ }
106
+
107
+ process.on("SIGTERM", () => immediateChildCleanup("SIGTERM"));
108
+ process.on("SIGINT", () => immediateChildCleanup("SIGINT"));
109
+
110
+ // ---- Orphan cleanup ----
111
+
112
+ /**
113
+ * Kill leftover camstack processes from a previous crash.
114
+ * Targets: coreml_inference.py (Python engines).
115
+ * Only kills processes whose parent is PID 1 (orphaned) to avoid killing children of a live server.
116
+ */
117
+ function cleanupOrphanProcesses(): void {
118
+ const isMac = process.platform === "darwin";
119
+ const isLinux = process.platform === "linux";
120
+ if (!isMac && !isLinux) return;
121
+
122
+ let killed = 0;
123
+
124
+ try {
125
+ // Find orphaned coreml_inference.py processes (ppid=1 means orphaned)
126
+ const cmd = isMac
127
+ ? "ps -eo pid,ppid,command | grep -E 'coreml_inference\\.py' | grep -v grep"
128
+ : "ps -eo pid,ppid,cmd | grep -E 'coreml_inference\\.py' | grep -v grep";
129
+
130
+ const output = execSync(cmd, { encoding: "utf8", timeout: 5000 }).trim();
131
+ if (!output) return;
132
+
133
+ for (const line of output.split("\n")) {
134
+ const parts = line.trim().split(/\s+/);
135
+ const pid = parseInt(parts[0]!, 10);
136
+ const ppid = parseInt(parts[1]!, 10);
137
+
138
+ // Only kill orphans (ppid=1) — never kill children of a running server
139
+ if (ppid !== 1 || isNaN(pid) || pid === process.pid) continue;
140
+
141
+ try {
142
+ process.kill(pid, "SIGTERM");
143
+ killed++;
144
+ } catch {
145
+ // Process may have already exited
146
+ }
147
+ }
148
+ } catch {
149
+ // grep returns exit code 1 when no matches — that's fine
150
+ }
151
+
152
+ if (killed > 0) {
153
+ console.log(`[cleanup] Killed ${killed} orphaned camstack process(es) from a previous run`);
154
+ }
155
+ }
156
+
157
+ // ---- Bootstrap ----
158
+
159
+ async function bootstrap() {
160
+ // Clean up orphaned processes from previous crashes before starting
161
+ cleanupOrphanProcesses();
162
+
163
+ // SPA fallback — set later when admin UI is resolved, used by addon route catch-all
164
+ let spaIndexHtml: string | null = null;
165
+
166
+ // --- Phase 1 + 2: Load config, setup storage locations, TLS ---
167
+ const configPath =
168
+ process.env.CONFIG_PATH ??
169
+ path.join(process.cwd(), "camstack-data", "config.yaml");
170
+ const bootstrapConfig = loadBootstrapConfig(configPath);
171
+ const infra = await setupInfra(configPath, bootstrapConfig);
172
+
173
+ const { dataPath, tlsOptions } = infra;
174
+ const port = infra.bootstrapConfig.server.port;
175
+ const host = infra.bootstrapConfig.server.host;
176
+
177
+ // --- Phase 3: Create app + tRPC register + listen ---
178
+ const fastifyOpts: Record<string, unknown> = tlsOptions
179
+ ? { https: tlsOptions }
180
+ : {};
181
+ const app = await bootManual({ infra, fastifyOpts });
182
+ app.enableShutdownHooks();
183
+ app.enableCors();
184
+
185
+ const fastify = app.getHttpAdapter().getInstance();
186
+ await fastify.register(fastifyCookie);
187
+
188
+ // Make LocationManager available to StorageService before lifecycle hooks run
189
+ const storageService = app.get(StorageService);
190
+ storageService.setLocationManager(infra.locationManager);
191
+
192
+ // ConfigService — SettingsStore is wired later by the settings-store capability consumer
193
+ const config = app.get(ConfigService);
194
+
195
+ // Register addon upload route (multipart — must be registered before tRPC).
196
+ // We pass AddonRegistryService so the handler can resolve the
197
+ // `user-management` cap singleton at request time (the only working
198
+ // path to validate `cst_*` scoped tokens — see addon-upload.ts).
199
+ try {
200
+ const uploadAuthService = app.get(AuthService);
201
+ const uploadAddonBridge = app.get(AddonBridgeService);
202
+ const uploadMoleculer = app.get(MoleculerService);
203
+ const uploadAddonRegistry = app.get(AddonRegistryService);
204
+ const uploadLogger = app.get(LoggingService).createLogger("addon-upload");
205
+ await registerAddonUploadRoute(
206
+ fastify,
207
+ uploadAddonBridge,
208
+ uploadAuthService,
209
+ uploadMoleculer,
210
+ uploadAddonRegistry,
211
+ uploadLogger,
212
+ );
213
+ console.log(
214
+ "[bootstrap] Addon upload route registered at POST /api/addons/upload",
215
+ );
216
+ // Companion endpoint: /api/auth/whoami — validates JWT or cst_*
217
+ // scoped tokens, returns the resolved identity + scope summary.
218
+ // Mirrors the addon-upload auth chain so the CLI can ping for
219
+ // token-still-valid without consuming a real cap.
220
+ await registerAuthWhoamiRoute(
221
+ fastify,
222
+ uploadAuthService,
223
+ uploadAddonRegistry,
224
+ );
225
+ console.log(
226
+ "[bootstrap] Auth whoami route registered at GET /api/auth/whoami",
227
+ );
228
+ } catch (err) {
229
+ console.warn("[bootstrap] Failed to register addon upload route:", err);
230
+ }
231
+
232
+ // Register tRPC plugin on Fastify BEFORE listen.
233
+ // If registration fails, start in degraded mode: serve a health warning endpoint.
234
+ // Instantiate new core services
235
+ const loggingService = app.get(LoggingService);
236
+ const addonRouteRegistry = new AddonRouteRegistry();
237
+
238
+ // Use Fastify-managed notification/toast wrappers (globally provided by NotificationModule)
239
+ const { NotificationServiceWrapper } =
240
+ await import("./core/notification/notification-wrapper.service");
241
+ const { ToastServiceWrapper } =
242
+ await import("./core/notification/toast-wrapper.service");
243
+ const notificationWrapper = app.get(NotificationServiceWrapper);
244
+ const toastWrapper = app.get(ToastServiceWrapper);
245
+ // Expose the underlying core services for the tRPC router
246
+ const notificationService = notificationWrapper.service;
247
+ const toastService = toastWrapper.service;
248
+
249
+ // Wire AddonRouteRegistry and NotificationService
250
+ const addonRegistry = app.get(AddonRegistryService);
251
+ addonRegistry.setAddonRouteRegistry(addonRouteRegistry);
252
+
253
+ // ── Configure the CapabilityRegistry (created in AddonRegistryService constructor) ──
254
+ const capabilityRegistry = addonRegistry.getCapabilityRegistry();
255
+ capabilityRegistry.setConfigManager(config);
256
+
257
+ // Declare every shipped capability BEFORE app.init() so addon
258
+ // registerProvider() calls (in-process or via the Moleculer bridge
259
+ // from forked workers / agents) find their definition during
260
+ // onModuleInit. The list comes from the codegen — see the import
261
+ // comment above for the rationale.
262
+ for (const capDef of ALL_CAPABILITY_DEFINITIONS) {
263
+ capabilityRegistry.declareCapability(capDef);
264
+ }
265
+
266
+ // Hub-internal `sso-bridge` provider — gives auth-provider addons
267
+ // (OIDC, SAML, magic-link, …) a typed gateway to mint an HMAC-signed
268
+ // bridge token before redirecting to `/api/auth/sso/finish`. Without
269
+ // this, the finish endpoint would have to trust unsigned query params
270
+ // (anyone could craft `?isAdmin=1`). Backed by AuthService.
271
+ const authServiceForBridge = app.get(AuthService);
272
+ capabilityRegistry.registerProvider('sso-bridge', '$hub', {
273
+ signBridgeToken: async ({ claims, ttlSec }: { claims: { userId: string; username: string; isAdmin: boolean; provider: string; email?: string; displayName?: string }; ttlSec?: number }) => {
274
+ const token = authServiceForBridge.signSsoBridgeToken(claims, ttlSec ?? 300);
275
+ return { token };
276
+ },
277
+ verifyBridgeToken: async ({ token }: { token: string }) => {
278
+ return authServiceForBridge.verifySsoBridgeToken(token);
279
+ },
280
+ });
281
+
282
+ // Wire registry into NotificationService for proxy-based output resolution
283
+ notificationService.setRegistry(capabilityRegistry);
284
+
285
+ // Hub metrics and sub-process info now come from Moleculer service discovery
286
+ // and the distributed metrics-provider capability — no manual wiring needed.
287
+
288
+ let trpcRegistered = false;
289
+ let appRouter: ReturnType<typeof buildAppRouter> | null = null;
290
+
291
+ // Run app.init() FIRST so onModuleInit hooks complete (PipelineWiring, AddonBridge, etc.)
292
+ // before we build the tRPC router which depends on their results.
293
+ await app.init();
294
+ console.log("[bootstrap] app.init() complete — all onModuleInit hooks have run");
295
+
296
+ // Mark registry as ready — providers from app.init() are now registered
297
+ capabilityRegistry.ready();
298
+
299
+ // Register log-receiver service for agent log forwarding (after app.init
300
+ // so Moleculer re-advertises the service list to the network)
301
+ const moleculer = app.get(MoleculerService);
302
+ moleculer.registerLogReceiver();
303
+
304
+ // ── Health routes (hub self + agent forwarding) ──────────────────
305
+ // Registered after app.init() so the AgentRegistryService is wired and
306
+ // the Moleculer broker is ready to forward `$agent.health` calls.
307
+ try {
308
+ const hubVersion = readHubVersion();
309
+ registerHealthRoutes(fastify, {
310
+ moleculer,
311
+ agentRegistry: app.get(AgentRegistryService),
312
+ hubVersion,
313
+ });
314
+ console.log(`[bootstrap] Health routes registered (hub v${hubVersion})`);
315
+ } catch (err) {
316
+ console.warn("[bootstrap] Failed to register health routes:", err);
317
+ }
318
+
319
+ // Seed the device-name cache the log formatter consults so logs
320
+ // emitted before the first DeviceRegistered already resolve names.
321
+ // Subsequent registers/unregisters flow through the event-bus
322
+ // subscription installed in `LoggingService.attachDeviceNameStream`.
323
+ try {
324
+ const dm = capabilityRegistry.getSingleton('device-manager') as {
325
+ listAll?: (input: { addonId?: string }) => Promise<readonly { id: number; name: string }[]>
326
+ } | null
327
+ if (dm?.listAll) {
328
+ const devices = await dm.listAll({})
329
+ loggingService.setDeviceNames(devices.map((d) => ({ id: d.id, name: d.name })))
330
+ loggingService.createLogger('logging').info('device-name cache seeded from device-manager', {
331
+ meta: { count: devices.length, ids: devices.map((d) => d.id) },
332
+ })
333
+ } else {
334
+ loggingService.createLogger('logging').warn('device-name cache seed skipped — device-manager not available')
335
+ }
336
+ } catch (err) {
337
+ console.warn('[bootstrap] device-name cache seed skipped:', err instanceof Error ? err.message : err)
338
+ }
339
+
340
+ try {
341
+ const authService = app.get(AuthService);
342
+
343
+ appRouter = buildAppRouter({
344
+ authService,
345
+ configService: config,
346
+ featureService: app.get(FeatureService),
347
+
348
+ loggingService,
349
+ eventBus: app.get(EventBusService),
350
+ agentRegistry: app.get(AgentRegistryService),
351
+ moleculer: app.get(MoleculerService),
352
+ addonRegistry,
353
+ replEngine: app.get(ReplEngineService),
354
+ networkQualityService: app.get(NetworkQualityService),
355
+ addonBridge: app.get(AddonBridgeService),
356
+ addonPackageService: app.get(AddonPackageService),
357
+ notificationService,
358
+ toastService,
359
+ capabilityRegistry,
360
+ streamProbe: app.get(StreamProbeService),
361
+ });
362
+
363
+ await fastify.register(fastifyTRPCPlugin, {
364
+ prefix: "/trpc",
365
+ trpcOptions: {
366
+ router: appRouter,
367
+ createContext: ({ req }: { req: FastifyRequest }) =>
368
+ createTrpcContext(req, authService, addonRegistry),
369
+ onError: ({ path, error }: { path: string | undefined; error: { code: string; message: string; cause?: unknown } }) => {
370
+ const trpcLogger = app.get(LoggingService).createLogger("tRPC");
371
+ trpcLogger.warn('tRPC error', { meta: { code: error.code, path: path ?? '?', message: error.message } });
372
+ if (error.cause) trpcLogger.warn('tRPC error cause', { meta: { cause: error.cause instanceof Error ? error.cause.message : JSON.stringify(error.cause) } });
373
+ },
374
+ },
375
+ });
376
+ trpcRegistered = true;
377
+
378
+ // Mount the `$core-caps` Moleculer service so forked addons /
379
+ // remote agents can reach the hub's core (non-addon) tRPC routers
380
+ // through `ctx.api.<coreCap>`. Without it those calls hang in
381
+ // `brokerTransportLink`'s unbounded discovery wait.
382
+ try {
383
+ moleculer.registerCoreCapService(buildCoreCapService(appRouter));
384
+ console.log("[bootstrap] core-cap mesh bridge registered ($core-caps)");
385
+ } catch (err) {
386
+ console.warn("[bootstrap] Failed to register core-cap mesh bridge:", err);
387
+ }
388
+
389
+ // Register addon-pages static file endpoint
390
+ const addonPagesService = app.get(AddonPagesService);
391
+ fastify.get<{ Params: { addonId: string; "*": string } }>(
392
+ "/api/addon-pages/:addonId/*",
393
+ async (request, reply) => {
394
+ const { addonId } = request.params;
395
+ const filePath = request.params["*"];
396
+ if (!filePath) {
397
+ return reply.status(400).send("Missing file path");
398
+ }
399
+ const resolved = addonPagesService.resolveBundle(addonId, filePath);
400
+ if (!resolved) {
401
+ return reply.status(404).send("Not found");
402
+ }
403
+ const ext = path.extname(resolved).toLowerCase();
404
+ const contentType =
405
+ ext === ".js" || ext === ".mjs"
406
+ ? "application/javascript"
407
+ : ext === ".css"
408
+ ? "text/css"
409
+ : ext === ".json"
410
+ ? "application/json"
411
+ : ext === ".html"
412
+ ? "text/html"
413
+ : "application/octet-stream";
414
+ const stream = fs.createReadStream(resolved);
415
+ return reply.type(contentType).send(stream);
416
+ },
417
+ );
418
+
419
+ // Register addon-widgets static file endpoint — same shape as
420
+ // addon-pages but reads bundles from each addon's
421
+ // `dist/widgets.mjs` (declared per-widget in `addon-widgets-source`
422
+ // metadata). Path-traversal protection + cap-registration check
423
+ // both live in `AddonWidgetsService.resolveBundle`.
424
+ const addonWidgetsService = app.get(AddonWidgetsService);
425
+ fastify.get<{ Params: { addonId: string; "*": string } }>(
426
+ "/api/addon-widgets/:addonId/*",
427
+ async (request, reply) => {
428
+ const { addonId } = request.params;
429
+ const filePath = request.params["*"];
430
+ if (!filePath) {
431
+ return reply.status(400).send("Missing file path");
432
+ }
433
+ const resolved = addonWidgetsService.resolveBundle(addonId, filePath);
434
+ if (!resolved) {
435
+ return reply.status(404).send("Not found");
436
+ }
437
+ const ext = path.extname(resolved).toLowerCase();
438
+ const contentType =
439
+ ext === ".js" || ext === ".mjs"
440
+ ? "application/javascript"
441
+ : ext === ".css"
442
+ ? "text/css"
443
+ : ext === ".json"
444
+ ? "application/json"
445
+ : ext === ".html"
446
+ ? "text/html"
447
+ : "application/octet-stream";
448
+ const stream = fs.createReadStream(resolved);
449
+ return reply.type(contentType).send(stream);
450
+ },
451
+ );
452
+
453
+ // Serve static assets (e.g. SVG icons) from an addon's package directory
454
+ fastify.get<{ Params: { addonId: string; "*": string } }>(
455
+ "/api/addon-assets/:addonId/*",
456
+ async (request, reply) => {
457
+ const { addonId } = request.params;
458
+ const assetPath = request.params["*"] ?? "";
459
+
460
+ const packageDir = addonRegistry.getAddonPackageDir(addonId);
461
+ if (!packageDir) {
462
+ return reply.code(404).send({ error: "Addon not found" });
463
+ }
464
+
465
+ const resolvedPackageDir = path.resolve(packageDir);
466
+ const filePath = path.resolve(resolvedPackageDir, assetPath);
467
+
468
+ // Security: prevent path traversal attacks
469
+ if (
470
+ !filePath.startsWith(resolvedPackageDir + path.sep) &&
471
+ filePath !== resolvedPackageDir
472
+ ) {
473
+ return reply.code(403).send({ error: "Access denied" });
474
+ }
475
+
476
+ if (!fs.existsSync(filePath)) {
477
+ return reply.code(404).send({ error: "Asset not found" });
478
+ }
479
+
480
+ const ext = path.extname(filePath).toLowerCase();
481
+ const contentTypes: Record<string, string> = {
482
+ ".svg": "image/svg+xml",
483
+ ".png": "image/png",
484
+ ".jpg": "image/jpeg",
485
+ ".jpeg": "image/jpeg",
486
+ ".json": "application/json",
487
+ ".webp": "image/webp",
488
+ };
489
+ const contentType = contentTypes[ext] ?? "application/octet-stream";
490
+
491
+ reply.header("content-type", contentType);
492
+ reply.header("cache-control", "public, max-age=86400");
493
+ return reply.send(fs.readFileSync(filePath));
494
+ },
495
+ );
496
+
497
+ // ── SSO finish endpoint ─────────────────────────────────────────
498
+ // OIDC / SAML / external auth providers redirect here after their
499
+ // own callback handler validated the IdP response. We trust the
500
+ // claims because:
501
+ // 1. The path is server-internal — providers run in-process and
502
+ // issue the redirect with their own validated state.
503
+ // 2. The query string is consumed once and immediately swapped
504
+ // for a JWT in the URL fragment (#token=...) which never hits
505
+ // the server logs and isn't replayable.
506
+ //
507
+ // The provider prefixes the redirect with `provider=auth-oidc` so
508
+ // we can attribute the session. The mint is currently best-effort
509
+ // — a future hardening should require the provider to also pass a
510
+ // nonce signed with the auth.jwtSecret so we can verify the redirect
511
+ // came from the addon's own callback handler.
512
+ fastify.get<{
513
+ Querystring: {
514
+ bridge?: string
515
+ }
516
+ }>(
517
+ "/api/auth/sso/finish",
518
+ async (request, reply) => {
519
+ // The auth-provider addon (OIDC, SAML, …) mints an HMAC-signed
520
+ // bridge token via the `sso-bridge` cap and 302s to here with
521
+ // `?bridge=<jwt>`. We verify the signature with the same JWT
522
+ // secret used elsewhere — only then do we trust the claims
523
+ // (including the `isAdmin` flag). This closes the prior gap
524
+ // where any client could call `?isAdmin=1` and become admin.
525
+ const bridge = request.query.bridge
526
+ if (!bridge) {
527
+ return reply.status(400).send({ error: "Missing bridge token" })
528
+ }
529
+ const ssoAuth = app.get(AuthService)
530
+ const claims = ssoAuth.verifySsoBridgeToken(bridge)
531
+ if (!claims) {
532
+ return reply.status(401).send({ error: "Invalid or expired bridge token" })
533
+ }
534
+
535
+ try {
536
+ const token = ssoAuth.signToken({
537
+ userId: claims.userId,
538
+ username: claims.username,
539
+ isAdmin: claims.isAdmin,
540
+ allowedProviders: '*',
541
+ allowedDevices: {},
542
+ })
543
+ // Redirect to the admin UI with the token in the URL fragment.
544
+ // Fragments don't hit the server log + don't get sent on
545
+ // subsequent requests — the SPA's auth-context picks the
546
+ // token up on mount and stores it in localStorage.
547
+ reply.code(302)
548
+ reply.header(
549
+ "Location",
550
+ `/admin/login#token=${encodeURIComponent(token)}&provider=${encodeURIComponent(claims.provider)}`,
551
+ )
552
+ return reply.send("")
553
+ } catch (err) {
554
+ return reply.status(500).send({
555
+ error: "SSO finish failed",
556
+ message: err instanceof Error ? err.message : String(err),
557
+ })
558
+ }
559
+ },
560
+ )
561
+
562
+ // ── Backup archive download (Phase 4 / Task 24) ────────────────
563
+ // Streams an archive at `<locationId>/<archiveId>` through the
564
+ // storage cap's chunked-download protocol straight to an HTTP
565
+ // response. The admin UI uses an authenticated `fetch` + Blob to
566
+ // fetch and trigger a save dialog (no cookie auth on this server,
567
+ // so we can't rely on `window.location.assign`).
568
+ fastify.get<{ Params: { locationId: string; archiveId: string } }>(
569
+ "/api/backup/download/:locationId/:archiveId",
570
+ async (request, reply) => {
571
+ const authHeader = request.headers.authorization;
572
+ if (!authHeader) {
573
+ return reply.status(401).send({ error: "Unauthorized" });
574
+ }
575
+ try {
576
+ const token = authHeader.replace("Bearer ", "");
577
+ const downloadAuth = app.get(AuthService);
578
+ const payload = downloadAuth.verifyToken(token);
579
+ if (!payload.isAdmin) {
580
+ return reply.status(403).send({ error: "Admin required" });
581
+ }
582
+ } catch {
583
+ return reply.status(401).send({ error: "Invalid token" });
584
+ }
585
+
586
+ const { locationId, archiveId } = request.params;
587
+ const backupSingleton = capabilityRegistry.getSingleton<{
588
+ listArchives: (input: { destinationId: string }) => Promise<readonly { id: string; filename: string; sizeBytes: number; label?: string }[]>
589
+ }>("backup");
590
+ if (!backupSingleton?.listArchives) {
591
+ return reply.status(503).send({ error: "Backup orchestrator unavailable" });
592
+ }
593
+ const archives = await backupSingleton.listArchives({ destinationId: locationId });
594
+ const archive = archives.find((a) => a.id === archiveId);
595
+ if (!archive) {
596
+ return reply.status(404).send({ error: "Archive not found" });
597
+ }
598
+
599
+ const storage = capabilityRegistry.getSingleton<{
600
+ beginDownload: (input: { location: string; relativePath: string }) => Promise<{ downloadId: string; sizeBytes: number }>
601
+ readChunk: (input: { downloadId: string; offset: number; length: number }) => Promise<Uint8Array>
602
+ endDownload: (input: { downloadId: string }) => Promise<void>
603
+ }>("storage");
604
+ if (!storage?.beginDownload) {
605
+ return reply.status(503).send({ error: "Storage cap unavailable" });
606
+ }
607
+
608
+ const downloadName = `${archive.label ?? archive.id}.tar.gz`;
609
+ // Sanitize: strip newlines and double-quotes which would break
610
+ // the Content-Disposition header. Whitespace is fine.
611
+ const safeName = downloadName.replace(/[\r\n"]/g, "_");
612
+ reply.header("content-type", "application/gzip");
613
+ reply.header("content-length", String(archive.sizeBytes));
614
+ reply.header(
615
+ "content-disposition",
616
+ `attachment; filename="${safeName}"`,
617
+ );
618
+
619
+ const { downloadId, sizeBytes } = await storage.beginDownload({
620
+ location: locationId,
621
+ relativePath: archive.filename,
622
+ });
623
+ const CHUNK = 8 * 1024 * 1024;
624
+ try {
625
+ let offset = 0;
626
+ // Stream the chunked response. Fastify's `reply.raw` is the
627
+ // Node.js writable; we write each chunk and `end()` once
628
+ // we've drained the source. Per-write back-pressure is
629
+ // handled inside the runtime — buffered writes pile up but
630
+ // never beyond the OS socket buffer.
631
+ while (offset < sizeBytes) {
632
+ const len = Math.min(CHUNK, sizeBytes - offset);
633
+ const chunk = await storage.readChunk({ downloadId, offset, length: len });
634
+ if (chunk.byteLength === 0) {
635
+ throw new Error(
636
+ `backup download: empty chunk at offset ${offset}/${sizeBytes}`,
637
+ );
638
+ }
639
+ reply.raw.write(chunk);
640
+ offset += chunk.byteLength;
641
+ }
642
+ reply.raw.end();
643
+ } catch (err) {
644
+ console.error("[backup-download] stream failed:", err);
645
+ // Headers may already be flushed — abort the socket if so.
646
+ if (!reply.raw.headersSent) {
647
+ return reply.status(500).send({ error: "Download failed" });
648
+ }
649
+ reply.raw.destroy(err instanceof Error ? err : new Error(String(err)));
650
+ } finally {
651
+ try {
652
+ await storage.endDownload({ downloadId });
653
+ } catch {
654
+ /* best-effort */
655
+ }
656
+ }
657
+ return reply;
658
+ },
659
+ );
660
+
661
+ // POST /api/auth/session — upgrade a tRPC-issued JWT to a browser cookie.
662
+ fastify.post<{ Body: { token?: string } }>('/api/auth/session', async (request, reply) => {
663
+ const token = request.body?.token
664
+ if (!token) return reply.status(400).send({ error: 'token required' })
665
+ let ttlSec: number
666
+ try {
667
+ const payload = authService.verifyToken(token) // throws on invalid/expired
668
+ const expSec = typeof payload.exp === 'number' ? payload.exp : 0
669
+ ttlSec = Math.max(0, expSec - Math.floor(Date.now() / 1000))
670
+ } catch {
671
+ return reply.status(401).send({ error: 'invalid token' })
672
+ }
673
+ const c = buildSessionCookie(token, ttlSec)
674
+ reply.setCookie(c.name, c.value, c.options)
675
+ return reply.send({ ok: true })
676
+ })
677
+
678
+ // DELETE /api/auth/session — clear the cookie on logout.
679
+ fastify.delete('/api/auth/session', async (_request, reply) => {
680
+ const c = clearSessionCookie()
681
+ reply.setCookie(c.name, c.value, c.options)
682
+ return reply.send({ ok: true })
683
+ })
684
+
685
+ // Addon HTTP API route catch-all: /addon/:addonId/*
686
+ // Only handles non-GET or routes that actually exist in the addon route registry.
687
+ // GET requests that don't match an addon route are SPA pages — handled by the /* fallback.
688
+ fastify.all<{
689
+ Params: { addonId: string; "*": string }
690
+ Querystring: Record<string, string>
691
+ Headers: Record<string, string>
692
+ }>("/addon/:addonId/*", async (request, reply) => {
693
+ const { addonId } = request.params;
694
+ const subPath = request.params["*"] ?? "";
695
+ const method = request.method;
696
+ const fullPath = `/addon/${addonId}/${subPath}`;
697
+ const query: Record<string, string> = request.query ?? {};
698
+ const headers: Record<string, string> = {};
699
+ for (const [k, v] of Object.entries(request.headers)) {
700
+ if (typeof v === "string") headers[k] = v;
701
+ else if (Array.isArray(v)) headers[k] = v.join(",");
702
+ }
703
+
704
+ const match = addonRouteRegistry.matchRoute(method, fullPath);
705
+ if (!match) {
706
+ if (method === "GET" && spaIndexHtml) {
707
+ return reply.type("text/html").send(fs.createReadStream(spaIndexHtml));
708
+ }
709
+ return reply.status(404).send({ error: "Not found" });
710
+ }
711
+
712
+ // Auth check based on route.access
713
+ if (match.route.access !== "public") {
714
+ const authHeader = request.headers.authorization;
715
+ // Browser navigation has no Authorization header — fall back to the
716
+ // session cookie, and if that is missing bounce to the login page.
717
+ const cookieToken = request.cookies?.[SESSION_COOKIE];
718
+ if (!authHeader && !cookieToken) {
719
+ if (shouldRedirectToLogin(request.method, request.headers.accept)) {
720
+ const qs = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '';
721
+ return reply.redirect(loginRedirectUrl(fullPath + qs));
722
+ }
723
+ return reply.status(401).send({ error: "Unauthorized" });
724
+ }
725
+ const token = authHeader ? authHeader.replace("Bearer ", "") : cookieToken!;
726
+ if (token.startsWith("cst_")) {
727
+ const userMgmt = capabilityRegistry?.getSingleton('user-management');
728
+ if (!userMgmt) return reply.status(503).send({ error: "User management not available" });
729
+ const scopedToken = await userMgmt.validateScopedToken({ token });
730
+ if (!scopedToken) {
731
+ return reply.status(401).send({ error: "Invalid token" });
732
+ }
733
+ // v2 model: scoped tokens grant by cap-category/cap-name/addon.
734
+ // For addon-route REST endpoints we match the `addon` scope
735
+ // type only — generic route-prefix scopes were dropped with
736
+ // the caps-only refactor.
737
+ const scopeMatch = scopedToken.scopes.some((scope) => {
738
+ return scope.type === 'addon' && scope.target === addonId;
739
+ });
740
+ if (!scopeMatch) {
741
+ return reply.status(403).send({ error: "Token scope mismatch" });
742
+ }
743
+ // Build addon request with scoped token context
744
+ const addonRequest = {
745
+ params: match.params,
746
+ query,
747
+ body: request.body,
748
+ headers,
749
+ scopedToken: {
750
+ id: scopedToken.id,
751
+ userId: scopedToken.userId,
752
+ scopes: scopedToken.scopes,
753
+ },
754
+ };
755
+ const addonReply = buildAddonReply(reply);
756
+ return match.route.handler(addonRequest, addonReply);
757
+ } else {
758
+ try {
759
+ const payload = authService.verifyToken(token);
760
+ if (match.route.access === "admin" && !payload.isAdmin) {
761
+ return reply.status(403).send({ error: "Admin required" });
762
+ }
763
+ const addonRequest = {
764
+ params: match.params,
765
+ query,
766
+ body: request.body,
767
+ headers,
768
+ user: {
769
+ id: payload.userId ?? "unknown",
770
+ username: payload.username ?? "unknown",
771
+ isAdmin: payload.isAdmin,
772
+ },
773
+ };
774
+ const addonReply = buildAddonReply(reply);
775
+ return match.route.handler(addonRequest, addonReply);
776
+ } catch {
777
+ return reply.status(401).send({ error: "Invalid token" });
778
+ }
779
+ }
780
+ }
781
+
782
+ // Public route — no auth required
783
+ const addonRequest = {
784
+ params: match.params,
785
+ query,
786
+ body: request.body,
787
+ headers,
788
+ };
789
+ const addonReply = buildAddonReply(reply);
790
+ return match.route.handler(addonRequest, addonReply);
791
+ });
792
+
793
+ // ── OAuth2 authorization endpoints ─────────────────────────────────────
794
+ // Mounted under /api/oauth2/* so they sit inside the universal "/api/"
795
+ // namespace already excluded from the SPA catch-all everywhere.
796
+ // getRegistry is a closure so it always resolves the live registry at
797
+ // request time (safe even if called before ready()).
798
+ registerOauth2Routes(fastify, {
799
+ getRegistry: () => capabilityRegistry,
800
+ verifyToken: (t) => authService.verifyToken(t),
801
+ publicHubUrl: () => process.env.CAMSTACK_PUBLIC_ORIGIN ?? `https://localhost:${port}`,
802
+ });
803
+ console.log('[bootstrap] OAuth2 routes registered at /api/oauth2/*');
804
+
805
+ // Attach tRPC WebSocket handler using noServer mode to avoid
806
+ // Fastify intercepting the upgrade request with a 400 response.
807
+ const wss = new WebSocketServer({ noServer: true });
808
+ applyWSSHandler({
809
+ wss,
810
+ router: appRouter,
811
+ createContext: (opts: CreateWSSContextFnOptions) => createWsTrpcContext(opts, authService, addonRegistry),
812
+ onError: ({ path, error }: { path: string | undefined; error: { code: string; message: string; cause?: unknown } }) => {
813
+ const trpcLogger = app.get(LoggingService).createLogger("tRPC:ws");
814
+ trpcLogger.warn('tRPC error', { meta: { code: error.code, path: path ?? '?', message: error.message } });
815
+ if (error.cause) trpcLogger.warn('tRPC error cause', { meta: { cause: error.cause instanceof Error ? error.cause.message : JSON.stringify(error.cause) } });
816
+ },
817
+ });
818
+
819
+ // Manually handle HTTP upgrade for /trpc path.
820
+ // Must use 'upgrade' on the raw Node HTTP server BEFORE Fastify processes it.
821
+ const httpServer = fastify.server;
822
+ // Remove any existing upgrade listeners that might conflict
823
+ const existingUpgradeListeners = httpServer.listeners("upgrade");
824
+ httpServer.removeAllListeners("upgrade");
825
+
826
+ httpServer.on("upgrade", (request, socket, head) => {
827
+ const pathname = (request.url ?? "").split("?")[0];
828
+ if (pathname === "/trpc") {
829
+ wss.handleUpgrade(request, socket, head, (ws) => {
830
+ wss.emit("connection", ws, request);
831
+ });
832
+ } else {
833
+ // Re-emit for other upgrade handlers (agent WS, etc.)
834
+ // `EventEmitter.listeners()` returns `Function[]` with no call
835
+ // signature — use Reflect.apply to invoke without a cast.
836
+ for (const listener of existingUpgradeListeners) {
837
+ Reflect.apply(listener, httpServer, [request, socket, head]);
838
+ }
839
+ }
840
+ });
841
+ } catch (err) {
842
+ console.error(
843
+ "[bootstrap] tRPC registration failed — starting in degraded mode:",
844
+ err,
845
+ );
846
+ // Register a fallback health endpoint that signals degraded state
847
+ fastify.get("/trpc/health", async (_req: FastifyRequest, reply: FastifyReply) => {
848
+ reply.status(503).send({
849
+ status: "degraded",
850
+ message: "tRPC router failed to initialize. Check server logs.",
851
+ });
852
+ });
853
+ }
854
+
855
+ // Wire the app router into AddonRegistry so addons get context.api
856
+ if (appRouter) {
857
+ await addonRegistry.setAppRouter(appRouter);
858
+ console.log("[bootstrap] AddonRegistry wired with tRPC direct caller");
859
+ }
860
+
861
+ // ScopedTokenManager and admin user creation handled by local-auth addon.
862
+
863
+ // Serve admin UI static files from the admin-ui singleton capability.
864
+ // Always enabled — in dev mode Vite runs on its own port and doesn't interfere.
865
+ //
866
+ // The admin-ui addon runs in its own dedicated runner subprocess and
867
+ // finishes registering 1-3s after bootstrap; poll briefly for it
868
+ // before giving up to avoid the 'admin-ui capability not registered —
869
+ // no static file serving' warn that left the SPA unserved until next
870
+ // restart.
871
+ try {
872
+ const addonRegistry = app.get(AddonRegistryService);
873
+ const capRegistry = addonRegistry.getCapabilityRegistry();
874
+ // The admin-ui addon runs in its own dedicated runner subprocess
875
+ // (one addon, one process — base-layer D2), so `getSingleton`
876
+ // returns a RemoteProxy whose method calls cross the broker — every
877
+ // call is async. The canonical cap shape returns `{staticDir}`/
878
+ // `{version}` objects so plain (in-process) and remote-proxied
879
+ // providers share the same Promise<object> signature.
880
+ type AdminUI = {
881
+ getStaticDir(): Promise<{ readonly staticDir: string }>;
882
+ getVersion(): Promise<{ readonly version: string }>;
883
+ };
884
+ let adminUI = capRegistry?.getSingleton<AdminUI>("admin-ui");
885
+ // CAMSTACK_SKIP_ADMIN_UI_WAIT — bypass the 60s poll. Used by the
886
+ // e2e harness, which doesn't need the SPA served and spawns hubs
887
+ // with strict boot timeouts. Production keeps the poll so cold
888
+ // boots wait for the forked admin-ui group to register.
889
+ const skipAdminUIWait = process.env['CAMSTACK_SKIP_ADMIN_UI_WAIT'] === '1';
890
+ if (!adminUI && capRegistry && !skipAdminUIWait) {
891
+ // Forked-addon spawn + Moleculer registration + tRPC hydration
892
+ // can take ~15-20s on cold boot, especially when several runners
893
+ // are spawning in parallel. The previous 10s window was racing
894
+ // the admin-ui runner's boot — fastify-static didn't register and
895
+ // every `GET /` returned 404. Bump to 60s; we only pay this
896
+ // wait once at boot.
897
+ const ADMIN_UI_WAIT_MS = 60_000;
898
+ const POLL_MS = 200;
899
+ const deadline = Date.now() + ADMIN_UI_WAIT_MS;
900
+ while (!adminUI && Date.now() < deadline) {
901
+ await new Promise<void>(r => setTimeout(r, POLL_MS));
902
+ adminUI = capRegistry.getSingleton<AdminUI>("admin-ui");
903
+ }
904
+ }
905
+ if (adminUI) {
906
+ const { staticDir } = await adminUI.getStaticDir();
907
+ const indexPath = path.join(staticDir, "index.html");
908
+ if (fs.existsSync(staticDir) && fs.existsSync(indexPath)) {
909
+ spaIndexHtml = indexPath;
910
+ await fastify.register(fastifyStatic, {
911
+ root: staticDir,
912
+ prefix: "/",
913
+ wildcard: false,
914
+ decorateReply: false,
915
+ });
916
+ // Dev diagnostic: serve webrtc-test.html from dataPath if it exists.
917
+ const webrtcTestPath = path.join(dataPath, "webrtc-test.html");
918
+ if (fs.existsSync(webrtcTestPath)) {
919
+ fastify.get("/webrtc-test.html", async (_request, reply) => {
920
+ return reply.type("text/html").send(fs.createReadStream(webrtcTestPath));
921
+ });
922
+ }
923
+
924
+ // SPA fallback: catch-all route that serves index.html for non-API paths.
925
+ // Uses a wildcard route instead of setNotFoundHandler.
926
+ fastify.get("/*", async (request, reply) => {
927
+ const url = request.url;
928
+ if (
929
+ url.startsWith("/trpc") ||
930
+ url.startsWith("/api/") ||
931
+ url.startsWith("/agent") ||
932
+ url.startsWith("/health")
933
+ ) {
934
+ return reply.callNotFound();
935
+ }
936
+ // A request whose last path segment has a file extension is a
937
+ // static asset, not a SPA navigation route. If it reached here,
938
+ // `fastify-static` has no route for it — the file is missing.
939
+ // Return 404 instead of the SPA `index.html`: serving HTML under
940
+ // an asset URL makes upstream caches (Cloudflare, the browser)
941
+ // pin `text/html` for a `.js`/`.css` URL, which then fails the
942
+ // module MIME check long after the file is actually available.
943
+ const pathOnly = url.split("?")[0] ?? url;
944
+ if (/\.[a-zA-Z0-9]+$/.test(pathOnly)) {
945
+ return reply.callNotFound();
946
+ }
947
+ return reply.type("text/html").send(fs.createReadStream(spaIndexHtml!));
948
+ });
949
+ const { version } = await adminUI.getVersion();
950
+ console.log(
951
+ `[bootstrap] Admin UI served from: ${staticDir} (v${version})`,
952
+ );
953
+ } else {
954
+ console.warn(
955
+ `[bootstrap] Admin UI dist not found at: ${staticDir} — run 'npm run build' in addon-admin-ui`,
956
+ );
957
+ }
958
+ } else {
959
+ console.warn(
960
+ "[bootstrap] admin-ui capability not registered — no static file serving",
961
+ );
962
+ }
963
+ } catch (err) {
964
+ console.error(
965
+ "[bootstrap] Failed to set up admin UI static serving:",
966
+ err,
967
+ );
968
+ }
969
+
970
+ try {
971
+ await app.listen(port, host);
972
+ } catch (listenErr: unknown) {
973
+ if (
974
+ listenErr !== null
975
+ && typeof listenErr === 'object'
976
+ && 'code' in listenErr
977
+ && listenErr.code === "EADDRINUSE"
978
+ ) {
979
+ console.error(
980
+ `[bootstrap] FATAL: Port ${port} is already in use. Stop the other process or change server.port in config.yaml.`,
981
+ );
982
+ process.exit(1);
983
+ }
984
+ throw listenErr;
985
+ }
986
+
987
+ const logger = app.get(LoggingService).createLogger("System");
988
+ const protocol = tlsOptions ? "https" : "http";
989
+ logger.info(
990
+ 'CamStack server listening',
991
+ { meta: { protocol, host, port, trpcRegistered } },
992
+ );
993
+
994
+ // Post-boot: fork workers, register device streams, emit system.boot
995
+ const postBoot = app.get(PostBootService);
996
+ await postBoot.run({ port, host, dataPath, trpcRegistered });
997
+ }
998
+
999
+ /**
1000
+ * Read the hub's own package version (best-effort) for /health responses.
1001
+ */
1002
+ function readHubVersion(): string {
1003
+ try {
1004
+ const pkgPath = path.resolve(__dirname, "..", "package.json");
1005
+ if (fs.existsSync(pkgPath)) {
1006
+ const raw = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
1007
+ if (typeof raw.version === "string") return raw.version;
1008
+ }
1009
+ } catch {
1010
+ // best-effort
1011
+ }
1012
+ return "unknown";
1013
+ }
1014
+
1015
+ /**
1016
+ * Build an AddonHttpReply wrapper around a Fastify reply.
1017
+ */
1018
+ function buildAddonReply(reply: FastifyReply) {
1019
+ const wrapper = {
1020
+ status(code: number) {
1021
+ reply.status(code);
1022
+ return wrapper;
1023
+ },
1024
+ code(code: number) {
1025
+ reply.code(code);
1026
+ return wrapper;
1027
+ },
1028
+ send(data: unknown) {
1029
+ reply.send(data);
1030
+ },
1031
+ redirect(url: string) {
1032
+ reply.redirect(url);
1033
+ },
1034
+ header(name: string, value: string) {
1035
+ reply.header(name, value);
1036
+ return wrapper;
1037
+ },
1038
+ type(mime: string) {
1039
+ reply.type(mime);
1040
+ return wrapper;
1041
+ },
1042
+ };
1043
+ return wrapper;
1044
+ }
1045
+
1046
+ bootstrap().catch((err) => {
1047
+ console.error("Bootstrap failed:", err);
1048
+ process.exit(1);
1049
+ });