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