@camstack/server 0.2.2 → 1.0.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 (234) hide show
  1. package/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
  2. package/dist/api/addon-upload.js +441 -0
  3. package/dist/api/addons-custom.router.js +91 -0
  4. package/dist/api/auth-whoami.js +55 -0
  5. package/dist/api/bridge-addons.router.js +109 -0
  6. package/dist/api/capabilities.router.js +229 -0
  7. package/dist/api/core/addon-settings.router.js +117 -0
  8. package/dist/api/core/agents.router.js +73 -0
  9. package/dist/api/core/auth.router.js +286 -0
  10. package/dist/api/core/bulk-update-coordinator.js +229 -0
  11. package/dist/api/core/cap-providers.js +1124 -0
  12. package/dist/api/core/capabilities.router.js +138 -0
  13. package/dist/api/core/collection-preference.js +17 -0
  14. package/dist/api/core/event-bus-proxy.router.js +45 -0
  15. package/dist/api/core/hwaccel.router.js +91 -0
  16. package/dist/api/core/live-events.router.js +61 -0
  17. package/dist/api/core/logs.router.js +172 -0
  18. package/dist/api/core/notifications.router.js +67 -0
  19. package/dist/api/core/repl.router.js +35 -0
  20. package/dist/api/core/settings-backend.router.js +121 -0
  21. package/dist/api/core/stream-probe.router.js +58 -0
  22. package/dist/api/core/system-events.router.js +100 -0
  23. package/dist/api/health/health.routes.js +68 -0
  24. package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
  25. package/dist/api/oauth2/oauth2-routes.js +219 -0
  26. package/dist/api/trpc/cap-mount-helpers.js +194 -0
  27. package/dist/api/trpc/cap-route-error-formatter.js +133 -0
  28. package/dist/api/trpc/client-ip.js +147 -0
  29. package/dist/api/trpc/core-cap-bridge.js +115 -0
  30. package/dist/api/trpc/generated-cap-mounts.js +388 -0
  31. package/dist/api/trpc/generated-cap-routers.js +7635 -0
  32. package/dist/api/trpc/scope-access.js +93 -0
  33. package/dist/api/trpc/trpc.context.js +184 -0
  34. package/dist/api/trpc/trpc.middleware.js +139 -0
  35. package/dist/api/trpc/trpc.router.js +188 -0
  36. package/dist/auth/session-cookie.js +47 -0
  37. package/dist/boot/boot-config.js +241 -0
  38. package/dist/boot/integration-id-backfill.js +76 -0
  39. package/dist/boot/post-boot.service.js +85 -0
  40. package/dist/core/addon/addon-call-gateway.js +99 -0
  41. package/dist/core/addon/addon-package.service.js +1560 -0
  42. package/dist/core/addon/addon-registry.service.js +2739 -0
  43. package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
  44. package/dist/core/addon/addon-search.service.js +62 -0
  45. package/dist/core/addon/addon-settings-provider.js +102 -0
  46. package/dist/core/addon/addon.tokens.js +5 -0
  47. package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
  48. package/dist/core/addon-pages/addon-pages.service.js +107 -0
  49. package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
  50. package/dist/core/agent/agent-registry.service.js +477 -0
  51. package/dist/core/auth/auth.service.js +10 -0
  52. package/dist/core/capability/capability.service.js +58 -0
  53. package/dist/core/config/config.schema.js +7 -0
  54. package/dist/core/config/config.service.js +10 -0
  55. package/dist/core/events/event-bus.service.js +83 -0
  56. package/dist/core/feature/feature.service.js +10 -0
  57. package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
  58. package/dist/core/logging/log-ring-buffer.js +6 -0
  59. package/dist/core/logging/logging.service.js +130 -0
  60. package/dist/core/logging/scoped-logger.js +6 -0
  61. package/dist/core/moleculer/cap-call-fn.js +50 -0
  62. package/dist/core/moleculer/cap-route-authority.js +122 -0
  63. package/dist/core/moleculer/moleculer.service.js +898 -0
  64. package/dist/core/network/network-quality.service.js +7 -0
  65. package/dist/core/notification/notification-wrapper.service.js +33 -0
  66. package/dist/core/notification/toast-wrapper.service.js +25 -0
  67. package/dist/core/provider/provider.tokens.js +4 -0
  68. package/dist/core/repl/repl-engine.service.js +140 -0
  69. package/dist/core/storage/fs-storage-backend.js +6 -0
  70. package/dist/core/storage/storage-location-manager.js +6 -0
  71. package/dist/core/storage/storage.service.js +7 -0
  72. package/dist/core/streaming/stream-probe.service.js +209 -0
  73. package/dist/core/topology/topology-emitter.service.js +106 -0
  74. package/dist/launcher.js +325 -0
  75. package/dist/main.js +1098 -0
  76. package/dist/manual-boot.js +227 -0
  77. package/package.json +5 -1
  78. package/src/__tests__/addon-install-e2e.test.ts +0 -74
  79. package/src/__tests__/addon-pages-e2e.test.ts +0 -200
  80. package/src/__tests__/addon-route-session.test.ts +0 -17
  81. package/src/__tests__/addon-settings-router.spec.ts +0 -67
  82. package/src/__tests__/addon-upload.spec.ts +0 -475
  83. package/src/__tests__/agent-registry.spec.ts +0 -179
  84. package/src/__tests__/agent-status-page.spec.ts +0 -82
  85. package/src/__tests__/auth-session-cookie.test.ts +0 -48
  86. package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
  87. package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
  88. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
  89. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
  90. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
  91. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
  92. package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
  93. package/src/__tests__/cap-route-adapter.spec.ts +0 -302
  94. package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
  95. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
  96. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
  97. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
  98. package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
  99. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
  100. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
  101. package/src/__tests__/cap-routers/harness.ts +0 -163
  102. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
  103. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
  104. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
  105. package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
  106. package/src/__tests__/capability-e2e.test.ts +0 -384
  107. package/src/__tests__/cli-e2e.test.ts +0 -150
  108. package/src/__tests__/core-cap-bridge.spec.ts +0 -91
  109. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
  110. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
  111. package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
  112. package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
  113. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
  114. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
  115. package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
  116. package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
  117. package/src/__tests__/framework-allowlist.spec.ts +0 -96
  118. package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
  119. package/src/__tests__/https-e2e.test.ts +0 -124
  120. package/src/__tests__/lifecycle-e2e.test.ts +0 -189
  121. package/src/__tests__/live-events-subscription.spec.ts +0 -149
  122. package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
  123. package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
  124. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
  125. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
  126. package/src/__tests__/native-cap-route.spec.ts +0 -427
  127. package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
  128. package/src/__tests__/post-boot-restart.spec.ts +0 -161
  129. package/src/__tests__/singleton-contention.test.ts +0 -499
  130. package/src/__tests__/streaming-diagnostic.test.ts +0 -615
  131. package/src/__tests__/streaming-scale.test.ts +0 -314
  132. package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
  133. package/src/__tests__/uds-log-ingest.spec.ts +0 -183
  134. package/src/api/__tests__/addons-custom.spec.ts +0 -148
  135. package/src/api/__tests__/capabilities.router.test.ts +0 -56
  136. package/src/api/addon-upload.ts +0 -529
  137. package/src/api/addons-custom.router.ts +0 -101
  138. package/src/api/auth-whoami.ts +0 -101
  139. package/src/api/bridge-addons.router.ts +0 -122
  140. package/src/api/capabilities.router.ts +0 -265
  141. package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
  142. package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
  143. package/src/api/core/addon-settings.router.ts +0 -127
  144. package/src/api/core/agents.router.ts +0 -86
  145. package/src/api/core/auth.router.ts +0 -322
  146. package/src/api/core/bulk-update-coordinator.ts +0 -305
  147. package/src/api/core/cap-providers.ts +0 -1339
  148. package/src/api/core/capabilities.router.ts +0 -149
  149. package/src/api/core/collection-preference.ts +0 -40
  150. package/src/api/core/event-bus-proxy.router.ts +0 -45
  151. package/src/api/core/hwaccel.router.ts +0 -108
  152. package/src/api/core/live-events.router.ts +0 -67
  153. package/src/api/core/logs.router.ts +0 -195
  154. package/src/api/core/notifications.router.ts +0 -66
  155. package/src/api/core/repl.router.ts +0 -39
  156. package/src/api/core/settings-backend.router.ts +0 -140
  157. package/src/api/core/stream-probe.router.ts +0 -57
  158. package/src/api/core/system-events.router.ts +0 -125
  159. package/src/api/health/health.routes.ts +0 -117
  160. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
  161. package/src/api/oauth2/oauth2-routes.ts +0 -281
  162. package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
  163. package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
  164. package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
  165. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
  166. package/src/api/trpc/cap-mount-helpers.ts +0 -245
  167. package/src/api/trpc/cap-route-error-formatter.ts +0 -171
  168. package/src/api/trpc/client-ip.ts +0 -147
  169. package/src/api/trpc/core-cap-bridge.ts +0 -154
  170. package/src/api/trpc/generated-cap-mounts.ts +0 -1240
  171. package/src/api/trpc/generated-cap-routers.ts +0 -11523
  172. package/src/api/trpc/scope-access.ts +0 -110
  173. package/src/api/trpc/trpc.context.ts +0 -258
  174. package/src/api/trpc/trpc.middleware.ts +0 -146
  175. package/src/api/trpc/trpc.router.ts +0 -389
  176. package/src/auth/session-cookie.ts +0 -54
  177. package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
  178. package/src/boot/boot-config.ts +0 -259
  179. package/src/boot/integration-id-backfill.ts +0 -109
  180. package/src/boot/post-boot.service.ts +0 -105
  181. package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
  182. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
  183. package/src/core/addon/addon-call-gateway.ts +0 -171
  184. package/src/core/addon/addon-package.service.ts +0 -1787
  185. package/src/core/addon/addon-registry.service.ts +0 -3130
  186. package/src/core/addon/addon-search.service.ts +0 -91
  187. package/src/core/addon/addon-settings-provider.ts +0 -220
  188. package/src/core/addon/addon.tokens.ts +0 -2
  189. package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
  190. package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
  191. package/src/core/addon-pages/addon-pages.service.ts +0 -82
  192. package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
  193. package/src/core/agent/agent-registry.service.ts +0 -529
  194. package/src/core/auth/auth.service.spec.ts +0 -86
  195. package/src/core/auth/auth.service.ts +0 -8
  196. package/src/core/capability/capability.service.ts +0 -66
  197. package/src/core/config/config.schema.ts +0 -3
  198. package/src/core/config/config.service.spec.ts +0 -175
  199. package/src/core/config/config.service.ts +0 -7
  200. package/src/core/events/event-bus.service.spec.ts +0 -235
  201. package/src/core/events/event-bus.service.ts +0 -89
  202. package/src/core/feature/feature.service.spec.ts +0 -99
  203. package/src/core/feature/feature.service.ts +0 -8
  204. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
  205. package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
  206. package/src/core/logging/log-ring-buffer.ts +0 -3
  207. package/src/core/logging/logging.service.spec.ts +0 -287
  208. package/src/core/logging/logging.service.ts +0 -143
  209. package/src/core/logging/scoped-logger.ts +0 -3
  210. package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
  211. package/src/core/moleculer/cap-call-fn.ts +0 -107
  212. package/src/core/moleculer/cap-route-authority.ts +0 -194
  213. package/src/core/moleculer/moleculer.service.ts +0 -1072
  214. package/src/core/network/network-quality.service.spec.ts +0 -53
  215. package/src/core/network/network-quality.service.ts +0 -5
  216. package/src/core/notification/notification-wrapper.service.ts +0 -34
  217. package/src/core/notification/toast-wrapper.service.ts +0 -27
  218. package/src/core/provider/provider.tokens.ts +0 -1
  219. package/src/core/repl/repl-engine.service.spec.ts +0 -444
  220. package/src/core/repl/repl-engine.service.ts +0 -155
  221. package/src/core/storage/fs-storage-backend.spec.ts +0 -70
  222. package/src/core/storage/fs-storage-backend.ts +0 -3
  223. package/src/core/storage/storage-location-manager.spec.ts +0 -130
  224. package/src/core/storage/storage-location-manager.ts +0 -3
  225. package/src/core/storage/storage.service.spec.ts +0 -73
  226. package/src/core/storage/storage.service.ts +0 -3
  227. package/src/core/streaming/stream-probe.service.ts +0 -221
  228. package/src/core/topology/topology-emitter.service.ts +0 -105
  229. package/src/launcher.ts +0 -314
  230. package/src/main.ts +0 -1245
  231. package/src/manual-boot.ts +0 -301
  232. package/tsconfig.build.json +0 -8
  233. package/tsconfig.json +0 -33
  234. package/vitest.config.ts +0 -26
package/src/main.ts DELETED
@@ -1,1245 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument -- pre-existing lint debt across this 800+ line bootstrap module. The flagged sites cross typed boundaries (Fastify request typing, AddonRouteRegistry, AuthService inherited methods) where the projectService context can't trace inheritance chains. Tracked separately; do not amend in unrelated edits. */
2
- import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'
3
- import { applyWSSHandler } from '@trpc/server/adapters/ws'
4
- import type { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws'
5
- import fastifyStatic from '@fastify/static'
6
- import fastifyCookie from '@fastify/cookie'
7
- import { WebSocketServer } from 'ws'
8
- import * as fs from 'node:fs'
9
- import * as path from 'node:path'
10
- import { execSync } from 'node:child_process'
11
- import { LoggingService } from './core/logging/logging.service'
12
- import { EventBusService } from './core/events/event-bus.service'
13
- import { ConfigService } from './core/config/config.service'
14
- import { AuthService } from './core/auth/auth.service'
15
-
16
- // Boot-time capability declaration runs over the auto-generated
17
- // `ALL_CAPABILITY_DEFINITIONS` array — every `*.cap.ts` file that ships
18
- // with `@camstack/types` is included automatically. Adding a new cap
19
- // requires no edit to this file: drop the `*.cap.ts` and re-run
20
- // `npx tsx scripts/generate-capability-router-types.ts`. The previous
21
- // hand-curated list silently dropped caps (zones, zone-rules,
22
- // zone-analytics, audio-metrics — see git for the regression).
23
- import { ALL_CAPABILITY_DEFINITIONS } from '@camstack/types'
24
- import { StreamProbeService } from './core/streaming/stream-probe.service'
25
- import { FeatureService } from './core/feature/feature.service'
26
- import { AgentRegistryService } from './core/agent/agent-registry.service'
27
- import { MoleculerService } from './core/moleculer/moleculer.service'
28
- import { AddonRegistryService } from './core/addon/addon-registry.service'
29
- import { AddonPackageService } from './core/addon/addon-package.service'
30
- import { ReplEngineService } from './core/repl/repl-engine.service'
31
- import { NetworkQualityService } from './core/network/network-quality.service'
32
- import { StorageService } from './core/storage/storage.service'
33
- import { AddonBridgeService } from './core/addon-bridge/addon-bridge.service'
34
- import { AddonPagesService } from './core/addon-pages/addon-pages.service'
35
- import { AddonWidgetsService } from './core/addon-widgets/addon-widgets.service'
36
- import { buildAppRouter } from './api/trpc/trpc.router'
37
- import { buildCoreCapService } from './api/trpc/core-cap-bridge'
38
- import { createTrpcContext, createWsTrpcContext } from './api/trpc/trpc.context'
39
- import { registerAddonUploadRoute } from './api/addon-upload'
40
- import { registerAuthWhoamiRoute } from './api/auth-whoami'
41
- import {
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
-
59
- // ---- Process-level error handlers ----
60
-
61
- process.on('uncaughtException', (err) => {
62
- console.error('[uncaughtException] Unhandled exception — server will continue:', err)
63
- })
64
-
65
- process.on('unhandledRejection', (reason) => {
66
- console.error('[unhandledRejection] Unhandled promise rejection — server will continue:', reason)
67
- })
68
-
69
- // ---- Graceful shutdown ----
70
-
71
- // Kill all child processes immediately on signal, then let Fastify clean up.
72
- // This is critical because tsx watch (dev) force-kills the main process after a short
73
- // timeout — if we wait for Fastify OnModuleDestroy, children become orphans.
74
- let shutdownStarted = false
75
- function immediateChildCleanup(signal: string) {
76
- if (shutdownStarted) return
77
- shutdownStarted = true
78
-
79
- console.log(`[shutdown] Received ${signal} — killing child processes immediately…`)
80
-
81
- // Synchronously kill all children of this process via the OS.
82
- // This is fast and ensures no orphans even if Fastify shutdown is slow.
83
- try {
84
- const myPid = process.pid
85
- const isMac = process.platform === 'darwin'
86
- // pkill sends SIGTERM to all processes whose parent is our PID
87
- const cmd = isMac
88
- ? `pkill -TERM -P ${myPid} 2>/dev/null; true`
89
- : `kill -- -${myPid} 2>/dev/null; true`
90
- execSync(cmd, { timeout: 3000 })
91
- console.log('[shutdown] Child processes signalled')
92
- } catch {
93
- // Best effort — children may have already exited
94
- }
95
-
96
- // Safety: force exit after 10s if Fastify shutdown stalls
97
- const timer = setTimeout(() => {
98
- console.error('[shutdown] Graceful shutdown timed out after 10s — forcing exit')
99
- process.exit(1)
100
- }, 10_000)
101
- timer.unref()
102
- }
103
-
104
- process.on('SIGTERM', () => immediateChildCleanup('SIGTERM'))
105
- process.on('SIGINT', () => immediateChildCleanup('SIGINT'))
106
-
107
- // ---- Orphan cleanup ----
108
-
109
- /**
110
- * Kill leftover camstack processes from a previous crash.
111
- * Targets: coreml_inference.py (Python engines).
112
- * Only kills processes whose parent is PID 1 (orphaned) to avoid killing children of a live server.
113
- */
114
- function cleanupOrphanProcesses(): void {
115
- const isMac = process.platform === 'darwin'
116
- const isLinux = process.platform === 'linux'
117
- if (!isMac && !isLinux) return
118
-
119
- let killed = 0
120
-
121
- try {
122
- // Find orphaned coreml_inference.py processes (ppid=1 means orphaned)
123
- const cmd = isMac
124
- ? "ps -eo pid,ppid,command | grep -E 'coreml_inference\\.py' | grep -v grep"
125
- : "ps -eo pid,ppid,cmd | grep -E 'coreml_inference\\.py' | grep -v grep"
126
-
127
- const output = execSync(cmd, { encoding: 'utf8', timeout: 5000 }).trim()
128
- if (!output) return
129
-
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)
134
-
135
- // Only kill orphans (ppid=1) — never kill children of a running server
136
- if (ppid !== 1 || isNaN(pid) || pid === process.pid) continue
137
-
138
- try {
139
- process.kill(pid, 'SIGTERM')
140
- killed++
141
- } catch {
142
- // Process may have already exited
143
- }
144
- }
145
- } catch {
146
- // grep returns exit code 1 when no matches — that's fine
147
- }
148
-
149
- if (killed > 0) {
150
- console.log(`[cleanup] Killed ${killed} orphaned camstack process(es) from a previous run`)
151
- }
152
- }
153
-
154
- // ---- Bootstrap ----
155
-
156
- async function bootstrap() {
157
- // Clean up orphaned processes from previous crashes before starting
158
- cleanupOrphanProcesses()
159
-
160
- // SPA fallback — set later when admin UI is resolved, used by addon route catch-all
161
- let spaIndexHtml: string | null = null
162
-
163
- // --- Phase 1 + 2: Load config, setup storage locations, TLS ---
164
- const configPath =
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)
168
-
169
- const { dataPath, tlsOptions } = infra
170
- const port = infra.bootstrapConfig.server.port
171
- const host = infra.bootstrapConfig.server.host
172
-
173
- // --- Phase 3: Create app + tRPC register + listen ---
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
- )
191
-
192
- // Make LocationManager available to StorageService before lifecycle hooks run
193
- const storageService = app.get(StorageService)
194
- storageService.setLocationManager(infra.locationManager)
195
-
196
- // ConfigService — SettingsStore is wired later by the settings-store capability consumer
197
- const config = app.get(ConfigService)
198
-
199
- // Register addon upload route (multipart — must be registered before tRPC).
200
- // We pass AddonRegistryService so the handler can resolve the
201
- // `user-management` cap singleton at request time (the only working
202
- // path to validate `cst_*` scoped tokens — see addon-upload.ts).
203
- try {
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')
210
- await registerAddonUploadRoute(
211
- fastify,
212
- uploadAddonBridge,
213
- uploadAuthService,
214
- uploadMoleculer,
215
- uploadAddonRegistry,
216
- uploadAddonPackage,
217
- uploadLogger,
218
- )
219
- console.log('[bootstrap] Addon upload route registered at POST /api/addons/upload')
220
- // Companion endpoint: /api/auth/whoami — validates JWT or cst_*
221
- // scoped tokens, returns the resolved identity + scope summary.
222
- // Mirrors the addon-upload auth chain so the CLI can ping for
223
- // token-still-valid without consuming a real cap.
224
- await registerAuthWhoamiRoute(fastify, uploadAuthService, uploadAddonRegistry)
225
- console.log('[bootstrap] Auth whoami route registered at GET /api/auth/whoami')
226
- } catch (err) {
227
- console.warn('[bootstrap] Failed to register addon upload route:', err)
228
- }
229
-
230
- // Register tRPC plugin on Fastify BEFORE listen.
231
- // If registration fails, start in degraded mode: serve a health warning endpoint.
232
- // Instantiate new core services
233
- const loggingService = app.get(LoggingService)
234
- const addonRouteRegistry = new AddonRouteRegistry()
235
- const dataPlaneRegistry = new DataPlaneRegistry()
236
-
237
- // Use Fastify-managed notification/toast wrappers (globally provided by NotificationModule)
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)
244
- // Expose the underlying core services for the tRPC router
245
- const notificationService = notificationWrapper.service
246
- const toastService = toastWrapper.service
247
-
248
- // Wire AddonRouteRegistry and NotificationService
249
- const addonRegistry = app.get(AddonRegistryService)
250
- addonRegistry.setAddonRouteRegistry(addonRouteRegistry)
251
- addonRegistry.setDataPlaneRegistry(dataPlaneRegistry)
252
-
253
- // ── Configure the CapabilityRegistry (created in AddonRegistryService constructor) ──
254
- const capabilityRegistry = addonRegistry.getCapabilityRegistry()
255
- capabilityRegistry.setConfigManager(config)
256
-
257
- // Declare every shipped capability BEFORE app.init() so addon
258
- // registerProvider() calls (in-process or via the Moleculer bridge
259
- // from forked workers / agents) find their definition during
260
- // onModuleInit. The list comes from the codegen — see the import
261
- // comment above for the rationale.
262
- for (const capDef of ALL_CAPABILITY_DEFINITIONS) {
263
- capabilityRegistry.declareCapability(capDef)
264
- }
265
-
266
- // Hub-internal `sso-bridge` provider — gives auth-provider addons
267
- // (OIDC, SAML, magic-link, …) a typed gateway to mint an HMAC-signed
268
- // bridge token before redirecting to `/api/auth/sso/finish`. Without
269
- // this, the finish endpoint would have to trust unsigned query params
270
- // (anyone could craft `?isAdmin=1`). Backed by AuthService.
271
- const authServiceForBridge = app.get(AuthService)
272
- capabilityRegistry.registerProvider('sso-bridge', '$hub', {
273
- signBridgeToken: async ({
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 }
289
- },
290
- verifyBridgeToken: async ({ token }: { token: string }) => {
291
- return authServiceForBridge.verifySsoBridgeToken(token)
292
- },
293
- })
294
-
295
- // Wire registry into NotificationService for proxy-based output resolution
296
- notificationService.setRegistry(capabilityRegistry)
297
-
298
- // Hub metrics and sub-process info now come from Moleculer service discovery
299
- // and the distributed metrics-provider capability — no manual wiring needed.
300
-
301
- let trpcRegistered = false
302
- let appRouter: ReturnType<typeof buildAppRouter> | null = null
303
-
304
- // Run app.init() FIRST so onModuleInit hooks complete (PipelineWiring, AddonBridge, etc.)
305
- // before we build the tRPC router which depends on their results.
306
- await app.init()
307
- console.log('[bootstrap] app.init() complete — all onModuleInit hooks have run')
308
-
309
- // Mark registry as ready — providers from app.init() are now registered
310
- capabilityRegistry.ready()
311
-
312
- // Register log-receiver service for agent log forwarding (after app.init
313
- // so Moleculer re-advertises the service list to the network)
314
- const moleculer = app.get(MoleculerService)
315
- moleculer.registerLogReceiver()
316
-
317
- // ── Health routes (hub self + agent forwarding) ──────────────────
318
- // Registered after app.init() so the AgentRegistryService is wired and
319
- // the Moleculer broker is ready to forward `$agent.health` calls.
320
- try {
321
- const hubVersion = readHubVersion()
322
- registerHealthRoutes(fastify, {
323
- moleculer,
324
- agentRegistry: app.get(AgentRegistryService),
325
- hubVersion,
326
- })
327
- console.log(`[bootstrap] Health routes registered (hub v${hubVersion})`)
328
- } catch (err) {
329
- console.warn('[bootstrap] Failed to register health routes:', err)
330
- }
331
-
332
- // Seed the device-name cache the log formatter consults so logs
333
- // emitted before the first DeviceRegistered already resolve names.
334
- // Subsequent registers/unregisters flow through the event-bus
335
- // subscription installed in `LoggingService.attachDeviceNameStream`.
336
- try {
337
- const dm = capabilityRegistry.getSingleton('device-manager') as {
338
- listAll?: (input: { addonId?: string }) => Promise<readonly { id: number; name: string }[]>
339
- } | null
340
- if (dm?.listAll) {
341
- const devices = await dm.listAll({})
342
- loggingService.setDeviceNames(devices.map((d) => ({ id: d.id, name: d.name })))
343
- loggingService.createLogger('logging').info('device-name cache seeded from device-manager', {
344
- meta: { count: devices.length, ids: devices.map((d) => d.id) },
345
- })
346
- } else {
347
- loggingService
348
- .createLogger('logging')
349
- .warn('device-name cache seed skipped — device-manager not available')
350
- }
351
- } catch (err) {
352
- console.warn(
353
- '[bootstrap] device-name cache seed skipped:',
354
- err instanceof Error ? err.message : err,
355
- )
356
- }
357
-
358
- try {
359
- const authService = app.get(AuthService)
360
-
361
- appRouter = buildAppRouter({
362
- authService,
363
- configService: config,
364
- featureService: app.get(FeatureService),
365
-
366
- loggingService,
367
- eventBus: app.get(EventBusService),
368
- agentRegistry: app.get(AgentRegistryService),
369
- moleculer: app.get(MoleculerService),
370
- addonRegistry,
371
- replEngine: app.get(ReplEngineService),
372
- networkQualityService: app.get(NetworkQualityService),
373
- addonBridge: app.get(AddonBridgeService),
374
- addonPackageService: app.get(AddonPackageService),
375
- notificationService,
376
- toastService,
377
- capabilityRegistry,
378
- streamProbe: app.get(StreamProbeService),
379
- })
380
-
381
- await fastify.register(fastifyTRPCPlugin, {
382
- prefix: '/trpc',
383
- trpcOptions: {
384
- router: appRouter,
385
- createContext: ({ req }: { req: FastifyRequest }) =>
386
- createTrpcContext(req, authService, addonRegistry),
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
- })
405
- },
406
- },
407
- })
408
- trpcRegistered = true
409
-
410
- // Mount the `$core-caps` Moleculer service so forked addons /
411
- // remote agents can reach the hub's core (non-addon) tRPC routers
412
- // through `ctx.api.<coreCap>`. Without it those calls hang in
413
- // `brokerTransportLink`'s unbounded discovery wait.
414
- try {
415
- moleculer.registerCoreCapService(buildCoreCapService(appRouter))
416
- console.log('[bootstrap] core-cap mesh bridge registered ($core-caps)')
417
- } catch (err) {
418
- console.warn('[bootstrap] Failed to register core-cap mesh bridge:', err)
419
- }
420
-
421
- // Register addon-pages static file endpoint
422
- const addonPagesService = app.get(AddonPagesService)
423
- fastify.get<{ Params: { addonId: string; '*': string } }>(
424
- '/api/addon-pages/:addonId/*',
425
- async (request, reply) => {
426
- const { addonId } = request.params
427
- const filePath = request.params['*']
428
- if (!filePath) {
429
- return reply.status(400).send('Missing file path')
430
- }
431
- const resolved = addonPagesService.resolveBundle(addonId, filePath)
432
- if (!resolved) {
433
- return reply.status(404).send('Not found')
434
- }
435
- const ext = path.extname(resolved).toLowerCase()
436
- const contentType =
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)
448
- },
449
- )
450
-
451
- // Register addon-widgets static file endpoint — same shape as
452
- // addon-pages but reads bundles from each addon's
453
- // `dist/widgets.mjs` (declared per-widget in `addon-widgets-source`
454
- // metadata). Path-traversal protection + cap-registration check
455
- // both live in `AddonWidgetsService.resolveBundle`.
456
- const addonWidgetsService = app.get(AddonWidgetsService)
457
- fastify.get<{ Params: { addonId: string; '*': string } }>(
458
- '/api/addon-widgets/:addonId/*',
459
- async (request, reply) => {
460
- const { addonId } = request.params
461
- const filePath = request.params['*']
462
- if (!filePath) {
463
- return reply.status(400).send('Missing file path')
464
- }
465
- const resolved = addonWidgetsService.resolveBundle(addonId, filePath)
466
- if (!resolved) {
467
- return reply.status(404).send('Not found')
468
- }
469
- const ext = path.extname(resolved).toLowerCase()
470
- const contentType =
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)
482
- },
483
- )
484
-
485
- // Serve static assets (e.g. SVG icons) from an addon's package directory
486
- fastify.get<{ Params: { addonId: string; '*': string } }>(
487
- '/api/addon-assets/:addonId/*',
488
- async (request, reply) => {
489
- const { addonId } = request.params
490
- const assetPath = request.params['*'] ?? ''
491
-
492
- const packageDir = addonRegistry.getAddonPackageDir(addonId)
493
- if (!packageDir) {
494
- return reply.code(404).send({ error: 'Addon not found' })
495
- }
496
-
497
- const resolvedPackageDir = path.resolve(packageDir)
498
- const filePath = path.resolve(resolvedPackageDir, assetPath)
499
-
500
- // Security: prevent path traversal attacks
501
- if (
502
- !filePath.startsWith(resolvedPackageDir + path.sep) &&
503
- filePath !== resolvedPackageDir
504
- ) {
505
- return reply.code(403).send({ error: 'Access denied' })
506
- }
507
-
508
- if (!fs.existsSync(filePath)) {
509
- return reply.code(404).send({ error: 'Asset not found' })
510
- }
511
-
512
- const ext = path.extname(filePath).toLowerCase()
513
- const contentTypes: Record<string, string> = {
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))
526
- },
527
- )
528
-
529
- // ── SSO finish endpoint ─────────────────────────────────────────
530
- // OIDC / SAML / external auth providers redirect here after their
531
- // own callback handler validated the IdP response. We trust the
532
- // claims because:
533
- // 1. The path is server-internal — providers run in-process and
534
- // issue the redirect with their own validated state.
535
- // 2. The query string is consumed once and immediately swapped
536
- // for a JWT in the URL fragment (#token=...) which never hits
537
- // the server logs and isn't replayable.
538
- //
539
- // The provider prefixes the redirect with `provider=auth-oidc` so
540
- // we can attribute the session. The mint is currently best-effort
541
- // — a future hardening should require the provider to also pass a
542
- // nonce signed with the auth.jwtSecret so we can verify the redirect
543
- // came from the addon's own callback handler.
544
- fastify.get<{
545
- Querystring: {
546
- bridge?: string
547
- }
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
- }
564
-
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
- })
590
-
591
- // ── Backup archive download (Phase 4 / Task 24) ────────────────
592
- // Streams an archive at `<locationId>/<archiveId>` through the
593
- // storage cap's chunked-download protocol straight to an HTTP
594
- // response. The admin UI uses an authenticated `fetch` + Blob to
595
- // fetch and trigger a save dialog (no cookie auth on this server,
596
- // so we can't rely on `window.location.assign`).
597
- fastify.get<{ Params: { locationId: string; archiveId: string } }>(
598
- '/api/backup/download/:locationId/:archiveId',
599
- async (request, reply) => {
600
- const authHeader = request.headers.authorization
601
- if (!authHeader) {
602
- return reply.status(401).send({ error: 'Unauthorized' })
603
- }
604
- try {
605
- const token = authHeader.replace('Bearer ', '')
606
- const downloadAuth = app.get(AuthService)
607
- const payload = downloadAuth.verifyToken(token)
608
- if (!payload.isAdmin) {
609
- return reply.status(403).send({ error: 'Admin required' })
610
- }
611
- } catch {
612
- return reply.status(401).send({ error: 'Invalid token' })
613
- }
614
-
615
- const { locationId, archiveId } = request.params
616
- const backupSingleton = capabilityRegistry.getSingleton<{
617
- listArchives: (input: {
618
- destinationId: string
619
- }) => Promise<
620
- readonly { id: string; filename: string; sizeBytes: number; label?: string }[]
621
- >
622
- }>('backup')
623
- if (!backupSingleton?.listArchives) {
624
- return reply.status(503).send({ error: 'Backup orchestrator unavailable' })
625
- }
626
- const archives = await backupSingleton.listArchives({ destinationId: locationId })
627
- const archive = archives.find((a) => a.id === archiveId)
628
- if (!archive) {
629
- return reply.status(404).send({ error: 'Archive not found' })
630
- }
631
-
632
- const storage = capabilityRegistry.getSingleton<{
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>
642
- endDownload: (input: { downloadId: string }) => Promise<void>
643
- }>('storage')
644
- if (!storage?.beginDownload) {
645
- return reply.status(503).send({ error: 'Storage cap unavailable' })
646
- }
647
-
648
- const downloadName = `${archive.label ?? archive.id}.tar.gz`
649
- // Sanitize: strip newlines and double-quotes which would break
650
- // the Content-Disposition header. Whitespace is fine.
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}"`)
655
-
656
- const { downloadId, sizeBytes } = await storage.beginDownload({
657
- location: locationId,
658
- relativePath: archive.filename,
659
- })
660
- const CHUNK = 8 * 1024 * 1024
661
- try {
662
- let offset = 0
663
- // Stream the chunked response. Fastify's `reply.raw` is the
664
- // Node.js writable; we write each chunk and `end()` once
665
- // we've drained the source. Per-write back-pressure is
666
- // handled inside the runtime — buffered writes pile up but
667
- // never beyond the OS socket buffer.
668
- while (offset < sizeBytes) {
669
- const len = Math.min(CHUNK, sizeBytes - offset)
670
- const chunk = await storage.readChunk({ downloadId, offset, length: len })
671
- if (chunk.byteLength === 0) {
672
- throw new Error(`backup download: empty chunk at offset ${offset}/${sizeBytes}`)
673
- }
674
- reply.raw.write(chunk)
675
- offset += chunk.byteLength
676
- }
677
- reply.raw.end()
678
- } catch (err) {
679
- console.error('[backup-download] stream failed:', err)
680
- // Headers may already be flushed — abort the socket if so.
681
- if (!reply.raw.headersSent) {
682
- return reply.status(500).send({ error: 'Download failed' })
683
- }
684
- reply.raw.destroy(err instanceof Error ? err : new Error(String(err)))
685
- } finally {
686
- try {
687
- await storage.endDownload({ downloadId })
688
- } catch {
689
- /* best-effort */
690
- }
691
- }
692
- return reply
693
- },
694
- )
695
-
696
- // POST /api/auth/session — upgrade a tRPC-issued JWT to a browser cookie.
697
- fastify.post<{ Body: { token?: string } }>('/api/auth/session', async (request, reply) => {
698
- const token = request.body?.token
699
- if (!token) return reply.status(400).send({ error: 'token required' })
700
- let ttlSec: number
701
- try {
702
- const payload = authService.verifyToken(token) // throws on invalid/expired
703
- const expSec = typeof payload.exp === 'number' ? payload.exp : 0
704
- ttlSec = Math.max(0, expSec - Math.floor(Date.now() / 1000))
705
- } catch {
706
- return reply.status(401).send({ error: 'invalid token' })
707
- }
708
- const c = buildSessionCookie(token, ttlSec)
709
- reply.setCookie(c.name, c.value, c.options)
710
- return reply.send({ ok: true })
711
- })
712
-
713
- // DELETE /api/auth/session — clear the cookie on logout.
714
- fastify.delete('/api/auth/session', async (_request, reply) => {
715
- const c = clearSessionCookie()
716
- reply.setCookie(c.name, c.value, c.options)
717
- return reply.send({ ok: true })
718
- })
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
-
748
- // Addon HTTP API route catch-all: /addon/:addonId/*
749
- // Only handles non-GET or routes that actually exist in the addon route registry.
750
- // GET requests that don't match an addon route are SPA pages — handled by the /* fallback.
751
- fastify.all<{
752
- Params: { addonId: string; '*': string }
753
- Querystring: Record<string, string>
754
- 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> = {}
762
- for (const [k, v] of Object.entries(request.headers)) {
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
819
- }
820
-
821
- const match = addonRouteRegistry.matchRoute(method, fullPath)
822
- if (!match) {
823
- if (method === 'GET' && spaIndexHtml) {
824
- return reply.type('text/html').send(fs.createReadStream(spaIndexHtml))
825
- }
826
- return reply.status(404).send({ error: 'Not found' })
827
- }
828
-
829
- // Auth check based on route.access
830
- if (match.route.access !== 'public') {
831
- const authHeader = request.headers.authorization
832
- // Browser navigation has no Authorization header — fall back to the
833
- // session cookie, and if that is missing bounce to the login page.
834
- const cookieToken = request.cookies?.[SESSION_COOKIE]
835
- if (!authHeader && !cookieToken) {
836
- if (shouldRedirectToLogin(request.method, request.headers.accept)) {
837
- const qs = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
838
- return reply.redirect(loginRedirectUrl(fullPath + qs))
839
- }
840
- return reply.status(401).send({ error: 'Unauthorized' })
841
- }
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 })
847
- if (!scopedToken) {
848
- return reply.status(401).send({ error: 'Invalid token' })
849
- }
850
- // v2 model: scoped tokens grant by cap-category/cap-name/addon.
851
- // For addon-route REST endpoints we match the `addon` scope
852
- // type only — generic route-prefix scopes were dropped with
853
- // the caps-only refactor.
854
- const scopeMatch = scopedToken.scopes.some((scope) => {
855
- return scope.type === 'addon' && scope.target === addonId
856
- })
857
- if (!scopeMatch) {
858
- return reply.status(403).send({ error: 'Token scope mismatch' })
859
- }
860
- // Build addon request with scoped token context
861
- const addonRequest = {
862
- params: match.params,
863
- query,
864
- body: request.body,
865
- headers,
866
- scopedToken: {
867
- id: scopedToken.id,
868
- userId: scopedToken.userId,
869
- scopes: scopedToken.scopes,
870
- },
871
- }
872
- const addonReply = buildAddonReply(reply)
873
- return match.route.handler(addonRequest, addonReply)
874
- } else {
875
- try {
876
- const payload = authService.verifyToken(token)
877
- if (match.route.access === 'admin' && !payload.isAdmin) {
878
- return reply.status(403).send({ error: 'Admin required' })
879
- }
880
- const addonRequest = {
881
- params: match.params,
882
- query,
883
- body: request.body,
884
- headers,
885
- user: {
886
- id: payload.userId ?? 'unknown',
887
- username: payload.username ?? 'unknown',
888
- isAdmin: payload.isAdmin,
889
- },
890
- }
891
- const addonReply = buildAddonReply(reply)
892
- return match.route.handler(addonRequest, addonReply)
893
- } catch {
894
- return reply.status(401).send({ error: 'Invalid token' })
895
- }
896
- }
897
- }
898
-
899
- // Public route — no auth required
900
- const addonRequest = {
901
- params: match.params,
902
- query,
903
- body: request.body,
904
- headers,
905
- }
906
- const addonReply = buildAddonReply(reply)
907
- return match.route.handler(addonRequest, addonReply)
908
- })
909
-
910
- // ── OAuth2 authorization endpoints ─────────────────────────────────────
911
- // Mounted under /api/oauth2/* so they sit inside the universal "/api/"
912
- // namespace already excluded from the SPA catch-all everywhere.
913
- // getRegistry is a closure so it always resolves the live registry at
914
- // request time (safe even if called before ready()).
915
- registerOauth2Routes(fastify, {
916
- getRegistry: () => capabilityRegistry,
917
- verifyToken: (t) => authService.verifyToken(t),
918
- publicHubUrl: () => process.env.CAMSTACK_PUBLIC_ORIGIN ?? `https://localhost:${port}`,
919
- })
920
- console.log('[bootstrap] OAuth2 routes registered at /api/oauth2/*')
921
-
922
- // Attach tRPC WebSocket handler using noServer mode to avoid
923
- // Fastify intercepting the upgrade request with a 400 response.
924
- const wss = new WebSocketServer({ noServer: true })
925
- applyWSSHandler({
926
- wss,
927
- router: appRouter,
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
- })
948
- },
949
- })
950
-
951
- // Manually handle HTTP upgrade for /trpc path.
952
- // Must use 'upgrade' on the raw Node HTTP server BEFORE Fastify processes it.
953
- const httpServer = fastify.server
954
- // Remove any existing upgrade listeners that might conflict
955
- const existingUpgradeListeners = httpServer.listeners('upgrade')
956
- httpServer.removeAllListeners('upgrade')
957
-
958
- httpServer.on('upgrade', (request, socket, head) => {
959
- const pathname = (request.url ?? '').split('?')[0]
960
- if (pathname === '/trpc') {
961
- wss.handleUpgrade(request, socket, head, (ws) => {
962
- wss.emit('connection', ws, request)
963
- })
964
- } else {
965
- // Re-emit for other upgrade handlers (agent WS, etc.)
966
- // `EventEmitter.listeners()` returns `Function[]` with no call
967
- // signature — use Reflect.apply to invoke without a cast.
968
- for (const listener of existingUpgradeListeners) {
969
- Reflect.apply(listener, httpServer, [request, socket, head])
970
- }
971
- }
972
- })
973
- } catch (err) {
974
- console.error('[bootstrap] tRPC registration failed — starting in degraded mode:', err)
975
- // Register a fallback health endpoint that signals degraded state
976
- fastify.get('/trpc/health', async (_req: FastifyRequest, reply: FastifyReply) => {
977
- reply.status(503).send({
978
- status: 'degraded',
979
- message: 'tRPC router failed to initialize. Check server logs.',
980
- })
981
- })
982
- }
983
-
984
- // Wire the app router into AddonRegistry so addons get context.api
985
- if (appRouter) {
986
- await addonRegistry.setAppRouter(appRouter)
987
- console.log('[bootstrap] AddonRegistry wired with tRPC direct caller')
988
- }
989
-
990
- // ScopedTokenManager and admin user creation handled by local-auth addon.
991
-
992
- // Serve admin UI static files from the admin-ui singleton capability.
993
- // Always enabled — in dev mode Vite runs on its own port and doesn't interfere.
994
- //
995
- // The admin-ui addon runs in its own dedicated runner subprocess and
996
- // finishes registering 1-3s after bootstrap; poll briefly for it
997
- // before giving up to avoid the 'admin-ui capability not registered —
998
- // no static file serving' warn that left the SPA unserved until next
999
- // restart.
1000
- try {
1001
- const addonRegistry = app.get(AddonRegistryService)
1002
- const capRegistry = addonRegistry.getCapabilityRegistry()
1003
- // The admin-ui addon runs in its own dedicated runner subprocess
1004
- // (one addon, one process — base-layer D2), so `getSingleton`
1005
- // returns a RemoteProxy whose method calls cross the broker — every
1006
- // call is async. The canonical cap shape returns `{staticDir}`/
1007
- // `{version}` objects so plain (in-process) and remote-proxied
1008
- // providers share the same Promise<object> signature.
1009
- type AdminUI = {
1010
- getStaticDir(): Promise<{ readonly staticDir: string }>
1011
- getVersion(): Promise<{ readonly version: string }>
1012
- }
1013
- let adminUI = capRegistry?.getSingleton<AdminUI>('admin-ui')
1014
- // CAMSTACK_SKIP_ADMIN_UI_WAIT — bypass the 60s poll. Used by the
1015
- // e2e harness, which doesn't need the SPA served and spawns hubs
1016
- // with strict boot timeouts. Production keeps the poll so cold
1017
- // boots wait for the forked admin-ui group to register.
1018
- const skipAdminUIWait = process.env['CAMSTACK_SKIP_ADMIN_UI_WAIT'] === '1'
1019
- if (!adminUI && capRegistry && !skipAdminUIWait) {
1020
- // Forked-addon spawn + Moleculer registration + tRPC hydration
1021
- // can take ~15-20s on cold boot, especially when several runners
1022
- // are spawning in parallel. The previous 10s window was racing
1023
- // the admin-ui runner's boot — fastify-static didn't register and
1024
- // every `GET /` returned 404. Bump to 60s; we only pay this
1025
- // wait once at boot.
1026
- const ADMIN_UI_WAIT_MS = 60_000
1027
- const POLL_MS = 200
1028
- const deadline = Date.now() + ADMIN_UI_WAIT_MS
1029
- while (!adminUI && Date.now() < deadline) {
1030
- await new Promise<void>((r) => setTimeout(r, POLL_MS))
1031
- adminUI = capRegistry.getSingleton<AdminUI>('admin-ui')
1032
- }
1033
- }
1034
- if (adminUI) {
1035
- const { staticDir } = await adminUI.getStaticDir()
1036
- const indexPath = path.join(staticDir, 'index.html')
1037
- if (fs.existsSync(staticDir) && fs.existsSync(indexPath)) {
1038
- spaIndexHtml = indexPath
1039
- // `serve: false` registers no route — it only decorates
1040
- // `reply.sendFile`, so the single SPA `/*` handler below owns all
1041
- // routing and serves each asset LIVE from the current `staticDir`.
1042
- // The old `wildcard: false` registered one route per file enumerated
1043
- // AT BOOT, so a redeployed admin-ui's new content-hashed assets had no
1044
- // route and 404'd until a hub restart. Live `sendFile` removes that.
1045
- await fastify.register(fastifyStatic, {
1046
- root: staticDir,
1047
- serve: false,
1048
- decorateReply: true,
1049
- })
1050
- // Dev diagnostic: serve webrtc-test.html from dataPath if it exists.
1051
- const webrtcTestPath = path.join(dataPath, 'webrtc-test.html')
1052
- if (fs.existsSync(webrtcTestPath)) {
1053
- fastify.get('/webrtc-test.html', async (_request, reply) => {
1054
- return reply.type('text/html').send(fs.createReadStream(webrtcTestPath))
1055
- })
1056
- }
1057
-
1058
- // SPA fallback + live static serving: this single catch-all owns every
1059
- // GET. Core API prefixes fall through to their own routers via
1060
- // `callNotFound`. Uses a wildcard route instead of setNotFoundHandler.
1061
- fastify.get('/*', async (request, reply) => {
1062
- const url = request.url
1063
- if (
1064
- url.startsWith('/trpc') ||
1065
- url.startsWith('/api/') ||
1066
- url.startsWith('/agent') ||
1067
- url.startsWith('/health')
1068
- ) {
1069
- return reply.callNotFound()
1070
- }
1071
- // A request whose last path segment has a file extension is a static
1072
- // asset: serve it LIVE from the current dist so a redeployed
1073
- // admin-ui's new content-hashed files are picked up without a hub
1074
- // restart. When the file is missing, 404 — never the SPA
1075
- // `index.html`: serving HTML under a `.js`/`.css` URL makes upstream
1076
- // caches (Cloudflare, the browser) pin `text/html`, which then fails
1077
- // the module MIME check long after the file is actually available.
1078
- const pathOnly = url.split('?')[0] ?? url
1079
- if (/\.[a-zA-Z0-9]+$/.test(pathOnly)) {
1080
- const rel = pathOnly.replace(/^\/+/, '')
1081
- const abs = path.join(staticDir, rel)
1082
- if ((abs === staticDir || abs.startsWith(staticDir + path.sep)) && fs.existsSync(abs)) {
1083
- // Cache policy that lets PWA updates actually propagate (the
1084
- // stale-bundle bug): the service worker + registration + manifest
1085
- // MUST be revalidated every load or a redeploy never reaches the
1086
- // client (the SW keeps serving the old precache). Content-hashed
1087
- // build assets (assets/index-<hash>.js) are immutable. Everything
1088
- // else gets a short cache.
1089
- const base = rel.split('/').pop() ?? rel
1090
- if (/^(sw\.js|registerSW\.js|workbox-.*\.js|manifest\.webmanifest)$/.test(base)) {
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')
1094
- } else {
1095
- reply.header('cache-control', 'no-cache')
1096
- }
1097
- return reply.sendFile(rel)
1098
- }
1099
- return reply.callNotFound()
1100
- }
1101
- // index.html (the SPA shell) must never be cached — it references the
1102
- // content-hashed bundles, so a stale copy pins the old app forever.
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})`)
1108
- } else {
1109
- console.warn(
1110
- `[bootstrap] Admin UI dist not found at: ${staticDir} — run 'npm run build' in addon-admin-ui`,
1111
- )
1112
- }
1113
- } else {
1114
- console.warn('[bootstrap] admin-ui capability not registered — no static file serving')
1115
- }
1116
- } catch (err) {
1117
- console.error('[bootstrap] Failed to set up admin UI static serving:', err)
1118
- }
1119
-
1120
- try {
1121
- await app.listen(port, host)
1122
- } catch (listenErr: unknown) {
1123
- if (
1124
- listenErr !== null &&
1125
- typeof listenErr === 'object' &&
1126
- 'code' in listenErr &&
1127
- listenErr.code === 'EADDRINUSE'
1128
- ) {
1129
- console.error(
1130
- `[bootstrap] FATAL: Port ${port} is already in use. Stop the other process or change server.port in config.yaml.`,
1131
- )
1132
- process.exit(1)
1133
- }
1134
- throw listenErr
1135
- }
1136
-
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 } })
1140
-
1141
- // Post-boot: fork workers, register device streams, emit system.boot
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
- }
1193
- }
1194
-
1195
- /**
1196
- * Read the hub's own package version (best-effort) for /health responses.
1197
- */
1198
- function readHubVersion(): string {
1199
- try {
1200
- const pkgPath = path.resolve(__dirname, '..', 'package.json')
1201
- if (fs.existsSync(pkgPath)) {
1202
- const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { version?: string }
1203
- if (typeof raw.version === 'string') return raw.version
1204
- }
1205
- } catch {
1206
- // best-effort
1207
- }
1208
- return 'unknown'
1209
- }
1210
-
1211
- /**
1212
- * Build an AddonHttpReply wrapper around a Fastify reply.
1213
- */
1214
- function buildAddonReply(reply: FastifyReply) {
1215
- const wrapper = {
1216
- status(code: number) {
1217
- reply.status(code)
1218
- return wrapper
1219
- },
1220
- code(code: number) {
1221
- reply.code(code)
1222
- return wrapper
1223
- },
1224
- send(data: unknown) {
1225
- reply.send(data)
1226
- },
1227
- redirect(url: string) {
1228
- reply.redirect(url)
1229
- },
1230
- header(name: string, value: string) {
1231
- reply.header(name, value)
1232
- return wrapper
1233
- },
1234
- type(mime: string) {
1235
- reply.type(mime)
1236
- return wrapper
1237
- },
1238
- }
1239
- return wrapper
1240
- }
1241
-
1242
- bootstrap().catch((err) => {
1243
- console.error('Bootstrap failed:', err)
1244
- process.exit(1)
1245
- })