@camstack/server 0.1.7 → 0.2.0

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