@camstack/server 0.1.8 → 0.2.1

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