@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
@@ -0,0 +1,1124 @@
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
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.INTEGRATION_CAP_MARKERS = void 0;
37
+ exports.buildSystemProvider = buildSystemProvider;
38
+ exports.buildNetworkQualityProvider = buildNetworkQualityProvider;
39
+ exports.buildToastProvider = buildToastProvider;
40
+ exports.computeTopology = computeTopology;
41
+ exports.buildNodesProvider = buildNodesProvider;
42
+ exports.buildIntegrationsProvider = buildIntegrationsProvider;
43
+ exports.buildAddonsProvider = buildAddonsProvider;
44
+ /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 800-line provider-factory module. The flagged sites delegate into Moleculer/EventBus/IntegrationRegistry surfaces typed as `unknown` to break circular dependencies; runtime contracts are validated by the cap-mount-helper layer above. Tracked separately. */
45
+ /**
46
+ * Capability provider factories for the Phase E core caps —
47
+ * `system`, `network-quality`, `toast`, `nodes`, `integrations`,
48
+ * `addons`. Each factory builds a fresh provider object that
49
+ * fulfils the cap's `InferProvider<...>` contract by delegating
50
+ * to the existing backend services.
51
+ *
52
+ * Why factories instead of static singletons?
53
+ * - `addons.custom` needs per-request `ctx.user` for per-action
54
+ * auth checks. The cap-router codegen already passes `ctx`
55
+ * into `getProvider(ctx)`, so closing over it is cheap and
56
+ * keeps the auth surface tight.
57
+ * - The other caps don't need ctx, but we keep the signature
58
+ * uniform for symmetry.
59
+ *
60
+ * No addon "owns" these surfaces — they manage cluster state
61
+ * (cluster topology, integrations, addon packages) or expose
62
+ * server-level singletons (toast bus, network-quality tracker,
63
+ * feature flags + retention controls).
64
+ *
65
+ * Phase E (2026-05-06): replaces the hand-written core routers in
66
+ * `server/backend/src/api/core/{system,network-quality,toast,
67
+ * nodes,integrations,addons}.router.ts`.
68
+ */
69
+ const os = __importStar(require("node:os"));
70
+ const node_child_process_1 = require("node:child_process");
71
+ const node_util_1 = require("node:util");
72
+ const node_crypto_1 = require("node:crypto");
73
+ const server_1 = require("@trpc/server");
74
+ const types_1 = require("@camstack/types");
75
+ const kernel_1 = require("@camstack/kernel");
76
+ const integration_id_backfill_1 = require("../../boot/integration-id-backfill");
77
+ const collection_preference_js_1 = require("./collection-preference.js");
78
+ const bulk_update_coordinator_js_1 = require("./bulk-update-coordinator.js");
79
+ const addon_package_service_js_1 = require("../../core/addon/addon-package.service.js");
80
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
81
+ // ── system ──────────────────────────────────────────────────────────
82
+ function getRetention(registry) {
83
+ return (registry?.getSingleton('analysis-data-persistence')?.retention ?? null);
84
+ }
85
+ function buildSystemProvider(feature, registry) {
86
+ return {
87
+ info: async () => feature.getManifest(),
88
+ health: async () => ({ status: 'ok', uptime: process.uptime() }),
89
+ featureFlags: async () => feature.getManifest(),
90
+ networkAddresses: async () => {
91
+ const ifaces = os.networkInterfaces();
92
+ const result = [];
93
+ for (const [name, addrs] of Object.entries(ifaces)) {
94
+ for (const addr of addrs ?? []) {
95
+ result.push({
96
+ name,
97
+ address: addr.address,
98
+ family: addr.family,
99
+ internal: addr.internal,
100
+ });
101
+ }
102
+ }
103
+ return result;
104
+ },
105
+ getRetentionConfig: async () => getRetention(registry)?.getConfig() ?? null,
106
+ setRetentionConfig: async (input) => {
107
+ getRetention(registry)?.setConfig(input);
108
+ return null;
109
+ },
110
+ forceRetentionCleanup: async () => {
111
+ await getRetention(registry)?.forceCleanup();
112
+ },
113
+ };
114
+ }
115
+ // ── network-quality ─────────────────────────────────────────────────
116
+ function buildNetworkQualityProvider(nq) {
117
+ return {
118
+ getDeviceStats: async (input) => nq.getDeviceStats(input.deviceId),
119
+ getAllStats: async () => nq.getAllStats(),
120
+ reportClientStats: async (input) => {
121
+ nq.reportClientStats(input.deviceId, {
122
+ rttMs: input.rttMs,
123
+ jitterMs: input.jitterMs,
124
+ estimatedBandwidthKbps: input.estimatedBandwidthKbps,
125
+ packetLossPercent: input.packetLossPercent,
126
+ });
127
+ },
128
+ };
129
+ }
130
+ // ── toast ───────────────────────────────────────────────────────────
131
+ //
132
+ // Per-request factory: each subscription opens its own connectionId.
133
+ // The factory captures `ctx.user.id` so the underlying ToastService
134
+ // can scope deliveries (broadcast vs. per-user). The cap defines
135
+ // `onToast` as a subscription — the codegen wires it through the
136
+ // `iterableSubscription` adapter and pushes raw `Toast` payloads.
137
+ function buildToastProvider(toastService, ctx) {
138
+ return {
139
+ onToast: (_input, push) => {
140
+ if (!toastService)
141
+ return () => { };
142
+ const userId = ctx.user?.id ?? 'anonymous';
143
+ const connectionId = (0, node_crypto_1.randomUUID)();
144
+ const unsubscribe = toastService.subscribe(connectionId, userId, (toast) => push(toast));
145
+ return unsubscribe ?? (() => { });
146
+ },
147
+ };
148
+ }
149
+ function getLocalIps() {
150
+ const interfaces = os.networkInterfaces();
151
+ const ips = [];
152
+ for (const ifaces of Object.values(interfaces)) {
153
+ if (!ifaces)
154
+ continue;
155
+ for (const iface of ifaces) {
156
+ if (iface.internal)
157
+ continue;
158
+ ips.push(iface.address);
159
+ }
160
+ }
161
+ return ips;
162
+ }
163
+ /**
164
+ * Pure (well, async) topology computation — same shape returned by the
165
+ * `nodes.topology` cap procedure. Extracted so the topology emitter
166
+ * service can produce identical snapshots without going through tRPC.
167
+ */
168
+ async function computeTopology(agentRegistry, addonRegistry) {
169
+ const nodes = await agentRegistry.listNodes();
170
+ const allAddons = addonRegistry?.listAddons() ?? [];
171
+ const getInGroupAddonIds = (node) => {
172
+ const subs = (node.subProcesses ?? []);
173
+ return subs.flatMap((p) => p.addonIds ?? []);
174
+ };
175
+ const addonCaps = new Map();
176
+ for (const a of allAddons) {
177
+ const id = a.manifest?.id ?? '';
178
+ const caps = a.declaration?.capabilities?.map((c) => (typeof c === 'string' ? c : c.name)) ?? [];
179
+ addonCaps.set(id, caps);
180
+ }
181
+ const addonCategory = new Map();
182
+ for (const a of allAddons) {
183
+ const id = a.manifest?.id ?? '';
184
+ const category = a.declaration?.category ?? 'system';
185
+ addonCategory.set(id, category);
186
+ }
187
+ return nodes.map((node) => {
188
+ const inGroupAddonIds = new Set(getInGroupAddonIds(node));
189
+ const agentAddonIds = node.agentAddons ?? [];
190
+ const allNodeAddons = node.isHub
191
+ ? allAddons.map((a) => {
192
+ const id = a.manifest?.id ?? 'unknown';
193
+ return { id, capabilities: [...(addonCaps.get(id) ?? [])], status: 'running' };
194
+ })
195
+ : agentAddonIds.map((addonId) => ({
196
+ id: addonId,
197
+ capabilities: [...(addonCaps.get(addonId) ?? [])],
198
+ status: 'running',
199
+ }));
200
+ const inProcessAddons = allNodeAddons.filter((a) => !inGroupAddonIds.has(a.id));
201
+ const isolatedProcesses = (node.subProcesses ?? []);
202
+ const mainProcessServices = inProcessAddons.map((a) => ({
203
+ addonId: a.id,
204
+ capabilities: a.capabilities,
205
+ status: a.status,
206
+ }));
207
+ const mainProcess = node.isHub
208
+ ? {
209
+ pid: process.pid,
210
+ name: 'hub (core)',
211
+ state: 'running',
212
+ cpuPercent: node.status?.cpuPercent ?? 0,
213
+ memoryRss: process.memoryUsage().rss,
214
+ uptimeSeconds: Math.floor(process.uptime()),
215
+ services: mainProcessServices,
216
+ }
217
+ : {
218
+ pid: 0,
219
+ name: `${node.info.id} (core)`,
220
+ state: 'running',
221
+ cpuPercent: node.status?.cpuPercent ?? 0,
222
+ memoryRss: 0,
223
+ uptimeSeconds: Math.floor((Date.now() - node.connectedSince) / 1000),
224
+ services: mainProcessServices,
225
+ };
226
+ const childProcesses = isolatedProcesses.map((p) => {
227
+ const memberIds = p.addonIds && p.addonIds.length > 0 ? p.addonIds : [p.name];
228
+ return {
229
+ pid: p.pid,
230
+ name: p.name,
231
+ state: p.state,
232
+ cpuPercent: p.cpuPercent,
233
+ memoryRss: p.memoryRss,
234
+ uptimeSeconds: p.uptimeSeconds,
235
+ groupId: p.groupId ?? p.name,
236
+ services: memberIds.map((addonId) => ({
237
+ addonId,
238
+ capabilities: [...(addonCaps.get(addonId) ?? [])],
239
+ status: p.state,
240
+ })),
241
+ };
242
+ });
243
+ // Aggregate node-local addons by category. `allNodeAddons` already
244
+ // contains the per-node addon roster (hub uses every installed
245
+ // addon; agents use their assigned agentAddons subset).
246
+ const byCategory = new Map();
247
+ const procByAddon = new Map();
248
+ for (const p of (node.subProcesses ?? [])) {
249
+ for (const addonId of p.addonIds ?? []) {
250
+ procByAddon.set(addonId, {
251
+ cpuPercent: p.cpuPercent,
252
+ memoryRss: p.memoryRss,
253
+ state: p.state,
254
+ });
255
+ }
256
+ }
257
+ for (const a of allNodeAddons) {
258
+ const category = addonCategory.get(a.id) ?? 'system';
259
+ const procInfo = procByAddon.get(a.id);
260
+ const entry = byCategory.get(category) ?? { category, total: 0, healthy: 0, addons: [] };
261
+ const status = procInfo?.state ?? a.status;
262
+ entry.total += 1;
263
+ if (status === 'running')
264
+ entry.healthy += 1;
265
+ entry.addons.push({
266
+ id: a.id,
267
+ status,
268
+ cpuPercent: procInfo?.cpuPercent ?? 0,
269
+ memoryRss: procInfo?.memoryRss ?? 0,
270
+ });
271
+ byCategory.set(category, entry);
272
+ }
273
+ const categoriesProjection = [...byCategory.values()];
274
+ return {
275
+ id: node.info.id,
276
+ name: node.info.name,
277
+ hostname: node.isHub ? os.hostname() : (node.info.hostname ?? node.info.id),
278
+ platform: node.info.platform ?? 'unknown',
279
+ arch: node.info.arch ?? 'unknown',
280
+ cpuModel: node.info.cpuModel ?? null,
281
+ cpuCores: node.info.cpuCores ?? 0,
282
+ memoryMB: node.info.memoryMB ?? 0,
283
+ engines: [...(node.info.pythonRuntimes ?? [])],
284
+ isHub: node.isHub,
285
+ isOnline: node.isOnline !== false,
286
+ cpuPercent: node.status?.cpuPercent ?? 0,
287
+ memoryPercent: node.status?.memoryPercent ?? 0,
288
+ uptime: Date.now() - node.connectedSince,
289
+ lastSeen: new Date().toISOString(),
290
+ localIps: node.isHub ? getLocalIps() : (node.localIps ?? []),
291
+ addons: allNodeAddons,
292
+ processes: [mainProcess, ...childProcesses],
293
+ categories: categoriesProjection,
294
+ };
295
+ });
296
+ }
297
+ function buildNodesProvider(agentRegistry, moleculer, addonRegistry) {
298
+ const broker = moleculer.broker;
299
+ return {
300
+ topology: async () => computeTopology(agentRegistry, addonRegistry),
301
+ deployAddon: async () => {
302
+ // Placeholder — actual deployment orchestration TBD
303
+ return { success: true };
304
+ },
305
+ undeployAddon: async (input) => {
306
+ await broker.call('$agent.undeploy', {
307
+ addonId: input.addonId,
308
+ }, { nodeID: input.nodeId, timeout: 30_000 });
309
+ return { success: true };
310
+ },
311
+ restartAddon: async (input) => {
312
+ const isHubLocal = input.nodeId === 'hub' || input.nodeId.startsWith('hub/');
313
+ if (isHubLocal && addonRegistry) {
314
+ const result = await addonRegistry.restartAddon(input.addonId);
315
+ if (typeof result === 'object' && result !== null && 'success' in result) {
316
+ return result;
317
+ }
318
+ return { success: true };
319
+ }
320
+ const agentNodeId = input.nodeId.includes('/') ? input.nodeId.split('/')[0] : input.nodeId;
321
+ await broker.call('$agent.restart', {
322
+ addonId: input.addonId,
323
+ }, { nodeID: agentNodeId, timeout: 30_000 });
324
+ return { success: true };
325
+ },
326
+ restartProcess: async (input) => {
327
+ return (await broker.call('$process.restart', {
328
+ name: input.processName,
329
+ }, { nodeID: input.nodeId, timeout: 30_000 }));
330
+ },
331
+ restartNode: async (input) => {
332
+ return (await broker.call('$process.restartAll', {}, {
333
+ nodeID: input.nodeId,
334
+ timeout: 60_000,
335
+ }));
336
+ },
337
+ shutdownNode: async (input) => {
338
+ if (input.nodeId === 'hub') {
339
+ setTimeout(() => process.exit(0), 500);
340
+ return { success: true };
341
+ }
342
+ await broker.call('$agent.shutdown', {}, {
343
+ nodeID: input.nodeId,
344
+ timeout: 10_000,
345
+ });
346
+ return { success: true };
347
+ },
348
+ renameNode: async (input) => {
349
+ const trimmed = input.name.trim();
350
+ if (input.nodeId === 'hub') {
351
+ const key = `node-display-name:${input.nodeId}`;
352
+ await broker.call('settings-store.set', {
353
+ collection: 'system-settings',
354
+ key,
355
+ value: trimmed,
356
+ });
357
+ }
358
+ else {
359
+ await broker.call('$agent.rename', {
360
+ name: trimmed,
361
+ }, { nodeID: input.nodeId, timeout: 10_000 });
362
+ agentRegistry.updateAgentName(input.nodeId, trimmed);
363
+ }
364
+ return { nodeId: input.nodeId, name: trimmed };
365
+ },
366
+ getNodeAddons: async (input) => {
367
+ // Hub branch: read straight from the local registry. Surfaces every
368
+ // loaded addon with its package name + version so the per-node Addons
369
+ // tab can render the same shape regardless of whether the target is
370
+ // hub or agent.
371
+ if (input.nodeId === 'hub') {
372
+ const rows = addonRegistry?.listAddons() ?? [];
373
+ return rows.map((r) => ({
374
+ id: r.manifest.id,
375
+ status: r.process?.state ?? 'running',
376
+ version: r.manifest.packageVersion,
377
+ packageName: r.manifest.packageName,
378
+ }));
379
+ }
380
+ // Agent branch: forward to `$agent.status` and pick out its `addons`
381
+ // field. Done as a direct call (not via `agentRegistry`'s cached
382
+ // listing) so the UI always sees fresh data when it opens the tab
383
+ // — the cache otherwise lags the cluster heartbeat interval.
384
+ try {
385
+ const status = await broker.call('$agent.status', {}, { nodeID: input.nodeId, timeout: 5_000 });
386
+ return (status.addons ?? []).map((a) => ({
387
+ id: a.id,
388
+ status: a.status,
389
+ ...(a.version !== undefined ? { version: a.version } : {}),
390
+ ...(a.packageName !== undefined ? { packageName: a.packageName } : {}),
391
+ }));
392
+ }
393
+ catch {
394
+ return [];
395
+ }
396
+ },
397
+ clusterAddonStatus: async () => {
398
+ const hubAddons = addonRegistry?.listAllAddons() ?? [];
399
+ const hubMap = new Map(hubAddons.map((a) => [a.manifest.id, a]));
400
+ const nodes = await agentRegistry.listNodes();
401
+ const remoteNodes = nodes.filter((n) => !n.isHub && n.connectedSince > 0);
402
+ const remoteStatuses = await Promise.all(remoteNodes.map(async (node) => {
403
+ try {
404
+ const status = (await broker.call('$agent.status', {}, {
405
+ nodeID: node.info.id,
406
+ timeout: 5_000,
407
+ }));
408
+ return {
409
+ nodeId: node.info.id,
410
+ name: node.info.name,
411
+ online: true,
412
+ addons: status.addons ?? [],
413
+ };
414
+ }
415
+ catch {
416
+ return {
417
+ nodeId: node.info.id,
418
+ name: node.info.name,
419
+ online: false,
420
+ addons: [],
421
+ };
422
+ }
423
+ }));
424
+ const result = {};
425
+ for (const [addonId, hubAddon] of hubMap) {
426
+ const hubVersion = hubAddon.manifest.packageVersion;
427
+ const addonNodes = [
428
+ { nodeId: 'hub', name: 'hub', version: hubVersion, status: 'running', synced: true },
429
+ ];
430
+ for (const remote of remoteStatuses) {
431
+ const remoteAddon = remote.addons.find((a) => a.id === addonId);
432
+ if (remoteAddon) {
433
+ const remoteVersion = remoteAddon.version ?? 'unknown';
434
+ addonNodes.push({
435
+ nodeId: remote.nodeId,
436
+ name: remote.name,
437
+ version: remoteVersion,
438
+ status: remote.online ? remoteAddon.status : 'offline',
439
+ synced: remoteVersion === hubVersion,
440
+ });
441
+ }
442
+ }
443
+ result[addonId] = { hubVersion, nodes: addonNodes };
444
+ }
445
+ return result;
446
+ },
447
+ getCapUsageGraph: async (input) => {
448
+ const reg = (0, kernel_1.getCapUsageRegistry)();
449
+ return reg.getGraph({ windowSeconds: input.windowSeconds, nowMs: Date.now() });
450
+ },
451
+ setProcessLogLevel: async (input) => {
452
+ // E2: for hub-local children, try the UDS path first (fire-and-forget);
453
+ // fall back to the Moleculer $node-mgmt.setLogLevel action for remote
454
+ // nodes (agents) that still run a per-node broker. The UDS path is always
455
+ // attempted for hub/<runner> nodeIds even when the Moleculer path would
456
+ // also work — both are safe to run in parallel during Phase E.
457
+ const reachedViaUds = moleculer.setChildLogLevelByNodeId(input.nodeId, input.level);
458
+ if (!reachedViaUds) {
459
+ await broker.call('$node-mgmt.setLogLevel', {
460
+ level: input.level,
461
+ }, { nodeID: input.nodeId, timeout: 5_000 });
462
+ }
463
+ return { success: true };
464
+ },
465
+ executeQuery: async (input) => {
466
+ return await broker.call(`${input.addonId}.queryable.query`, { queryName: input.queryName, params: input.params ?? {} }, { nodeID: input.nodeId, timeout: 30_000 });
467
+ },
468
+ };
469
+ }
470
+ function requireIntegrationRegistry(ar) {
471
+ const reg = ar.getIntegrationRegistry();
472
+ if (!reg)
473
+ throw new Error('IntegrationRegistry not available');
474
+ return reg;
475
+ }
476
+ function isDeviceProvider(value) {
477
+ return (value !== null &&
478
+ typeof value === 'object' &&
479
+ typeof Reflect.get(value, 'discoverDevices') === 'function');
480
+ }
481
+ function getDeviceProvider(ar, addonId) {
482
+ const provider = ar.getCapabilityRegistry().getProviderByAddon('device-provider', addonId);
483
+ return isDeviceProvider(provider) ? provider : null;
484
+ }
485
+ /**
486
+ * Marker caps that flag an addon as a creatable integration type:
487
+ * - `device-provider` — classic providers (Reolink/ONVIF/Frigate)
488
+ * that expose `createDevice` + `discoverDevices` via their
489
+ * device-provider cap.
490
+ * - `device-adoption` — integration-style providers (Home Assistant
491
+ * and future siblings) that materialise devices via a generic
492
+ * adoption cap instead of a manual create-form. The picker treats
493
+ * them the same way; the wizard's discovery step routes through the
494
+ * specific cap based on the addon's declared surface.
495
+ *
496
+ * Exported so the integration-markers spec can assert the recognised set
497
+ * without booting the whole provider factory.
498
+ */
499
+ exports.INTEGRATION_CAP_MARKERS = new Set(['device-provider', 'device-adoption']);
500
+ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
501
+ const logger = loggingService.createLogger('integrations');
502
+ const withProcessState = (i) => ({
503
+ ...i,
504
+ processState: ar.listAddons().find((a) => a.manifest.id === i.addonId)?.process?.state ?? 'unknown',
505
+ });
506
+ return {
507
+ list: async () => {
508
+ const integrations = await requireIntegrationRegistry(ar).listIntegrations();
509
+ return integrations.map(withProcessState);
510
+ },
511
+ get: async (input) => {
512
+ const integration = await requireIntegrationRegistry(ar).getIntegration(input.id);
513
+ if (!integration)
514
+ throw new Error(`Integration "${input.id}" not found`);
515
+ return withProcessState(integration);
516
+ },
517
+ getByAddonId: async (input) => requireIntegrationRegistry(ar).getIntegrationByAddonId(input.addonId),
518
+ create: async (input) => {
519
+ const { skipRestart, ...payload } = input;
520
+ const reg = requireIntegrationRegistry(ar);
521
+ logger.info('request', {
522
+ tags: { addonId: input.addonId },
523
+ meta: { phase: 'create', name: input.name },
524
+ });
525
+ const addon = ar.listAddons().find((a) => a.manifest.id === input.addonId);
526
+ const instanceMode = addon?.declaration?.instanceMode ?? addon?.manifest?.instanceMode ?? 'multiple';
527
+ if (instanceMode === 'unique') {
528
+ const existing = (await reg.listIntegrations()).filter((i) => i.addonId === input.addonId);
529
+ if (existing.length > 0) {
530
+ logger.warn('rejected duplicate unique', {
531
+ tags: { addonId: input.addonId, integrationId: existing[0].id },
532
+ meta: { phase: 'create' },
533
+ });
534
+ throw new Error(`Addon "${input.addonId}" is unique-instance and already has an integration (${existing[0].id})`);
535
+ }
536
+ }
537
+ const integration = await reg.createIntegration(payload);
538
+ logger.info('persisted', {
539
+ tags: { integrationId: integration.id, addonId: integration.addonId },
540
+ meta: { phase: 'create' },
541
+ });
542
+ const hasSettings = input.settings != null && Object.keys(input.settings).length > 0;
543
+ if (!skipRestart && hasSettings) {
544
+ logger.info('settings present — restarting addon', {
545
+ tags: { addonId: input.addonId },
546
+ meta: { phase: 'create' },
547
+ });
548
+ await ar.restartAddon(input.addonId);
549
+ logger.info('addon restart complete', {
550
+ tags: { addonId: input.addonId },
551
+ meta: { phase: 'create' },
552
+ });
553
+ }
554
+ else {
555
+ logger.info('skipping restart (no settings or skipRestart=true)', {
556
+ meta: { phase: 'create' },
557
+ });
558
+ }
559
+ return integration;
560
+ },
561
+ update: async (input) => {
562
+ const reg = requireIntegrationRegistry(ar);
563
+ const previous = await reg.getIntegration(input.id);
564
+ if (!previous) {
565
+ logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'update' } });
566
+ throw new Error(`Integration "${input.id}" not found`);
567
+ }
568
+ const { id, ...updates } = input;
569
+ const changedFields = Object.keys(updates).filter((k) => updates[k] !== undefined);
570
+ logger.info('request', {
571
+ tags: { integrationId: input.id, addonId: previous.addonId },
572
+ meta: { phase: 'update', fields: changedFields },
573
+ });
574
+ const result = await reg.updateIntegration(id, updates);
575
+ if (!result)
576
+ throw new Error(`Integration "${id}" not found`);
577
+ const enabledChanged = input.enabled !== undefined && input.enabled !== previous.enabled;
578
+ if (enabledChanged) {
579
+ const category = input.enabled ? 'integration.enabled' : 'integration.disabled';
580
+ logger.info('enabled state changed', {
581
+ tags: { integrationId: result.id, addonId: result.addonId },
582
+ meta: { phase: 'update', enabled: input.enabled },
583
+ });
584
+ eb.emit({
585
+ id: `integration-${category}-${Date.now()}`,
586
+ timestamp: new Date(),
587
+ source: { type: 'integration', id: result.id },
588
+ category,
589
+ data: {
590
+ integrationId: result.id,
591
+ addonId: result.addonId,
592
+ },
593
+ });
594
+ }
595
+ if (input.name !== undefined && input.name !== previous.name) {
596
+ logger.info('renamed', {
597
+ tags: { integrationId: result.id },
598
+ meta: { phase: 'update', previousName: previous.name, newName: input.name },
599
+ });
600
+ }
601
+ const infoChanged = input.info !== undefined;
602
+ if (infoChanged) {
603
+ logger.info('info changed — restarting addon', {
604
+ tags: { addonId: result.addonId },
605
+ meta: { phase: 'update' },
606
+ });
607
+ await ar.restartAddon(result.addonId);
608
+ logger.info('addon restart complete', {
609
+ tags: { addonId: result.addonId },
610
+ meta: { phase: 'update' },
611
+ });
612
+ }
613
+ else {
614
+ logger.info('no restart needed (only enabled/name changed)', { meta: { phase: 'update' } });
615
+ }
616
+ return result;
617
+ },
618
+ delete: async (input) => {
619
+ const reg = requireIntegrationRegistry(ar);
620
+ logger.info('request', { tags: { integrationId: input.id }, meta: { phase: 'delete' } });
621
+ const integration = await reg.getIntegration(input.id);
622
+ if (!integration) {
623
+ logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'delete' } });
624
+ throw new Error(`Integration "${input.id}" not found`);
625
+ }
626
+ logger.info('removing', {
627
+ tags: { integrationId: input.id, addonId: integration.addonId },
628
+ meta: { phase: 'delete', name: integration.name },
629
+ });
630
+ // Cascade-delete every live device whose integrationId matches.
631
+ // Best-effort: a device-removal hiccup must not abort the integration
632
+ // delete — log a warning and continue so the record + event always fire.
633
+ const dm = capabilityRegistry?.getSingleton('device-manager') ?? null;
634
+ // Claim legacy un-tagged devices BEFORE the cascade. Devices created
635
+ // before stamping (or whose provider never stamps, e.g. `provider-rtsp`)
636
+ // carry no integrationId, so `removeByIntegration` (which matches on
637
+ // integrationId) would leave them orphaned forever once their integration
638
+ // is gone. While the integration record still exists, stamp the
639
+ // unambiguous ones (addons hosting exactly one integration) so the cascade
640
+ // below removes them too. Best-effort: never abort the delete.
641
+ if (dm?.listAll && dm?.setIntegrationId) {
642
+ try {
643
+ const [integrations, devices] = await Promise.all([
644
+ reg.listIntegrations(),
645
+ dm.listAll({}),
646
+ ]);
647
+ const stamps = (0, integration_id_backfill_1.planDeleteTimeStamps)(input.id, integrations.map((i) => ({ id: i.id, addonId: i.addonId })), devices.map((d) => ({
648
+ id: d.id,
649
+ addonId: d.addonId,
650
+ parentDeviceId: d.parentDeviceId,
651
+ ...(d.integrationId !== undefined ? { integrationId: d.integrationId } : {}),
652
+ })));
653
+ for (const stamp of stamps) {
654
+ await dm.setIntegrationId({
655
+ deviceId: stamp.deviceId,
656
+ integrationId: stamp.integrationId,
657
+ });
658
+ }
659
+ if (stamps.length > 0) {
660
+ logger.info('claimed legacy un-tagged devices for cascade', {
661
+ tags: { integrationId: input.id, addonId: integration.addonId },
662
+ meta: { phase: 'delete', claimed: stamps.length },
663
+ });
664
+ }
665
+ }
666
+ catch (err) {
667
+ logger.warn('legacy device claim failed (best-effort — continuing)', {
668
+ tags: { integrationId: input.id },
669
+ meta: { phase: 'delete', error: (0, types_1.errMsg)(err) },
670
+ });
671
+ }
672
+ }
673
+ if (dm?.removeByIntegration) {
674
+ try {
675
+ const result = await dm.removeByIntegration({ integrationId: input.id });
676
+ logger.info('cascade-removed devices', {
677
+ tags: { integrationId: input.id },
678
+ meta: { phase: 'delete', removed: result.removed },
679
+ });
680
+ }
681
+ catch (err) {
682
+ logger.warn('device cascade-remove failed (best-effort — continuing)', {
683
+ tags: { integrationId: input.id },
684
+ meta: { phase: 'delete', error: (0, types_1.errMsg)(err) },
685
+ });
686
+ }
687
+ }
688
+ else {
689
+ logger.warn('device-manager not available — skipping cascade device removal', {
690
+ tags: { integrationId: input.id },
691
+ meta: { phase: 'delete' },
692
+ });
693
+ }
694
+ await reg.deleteIntegration(input.id);
695
+ eb.emit({
696
+ id: `integration-deleted-${Date.now()}`,
697
+ timestamp: new Date(),
698
+ source: { type: 'integration', id: input.id },
699
+ category: types_1.EventCategory.IntegrationDeleted,
700
+ data: {
701
+ integrationId: input.id,
702
+ addonId: integration.addonId,
703
+ },
704
+ });
705
+ logger.info('completed (no restart)', {
706
+ tags: { integrationId: input.id },
707
+ meta: { phase: 'delete' },
708
+ });
709
+ return { success: true, deletedId: input.id };
710
+ },
711
+ getSettings: async (input) => {
712
+ const reg = requireIntegrationRegistry(ar);
713
+ const integration = await reg.getIntegration(input.id);
714
+ if (!integration)
715
+ throw new Error(`Integration "${input.id}" not found`);
716
+ return reg.getIntegrationSettings(input.id);
717
+ },
718
+ setSettings: async (input) => {
719
+ const reg = requireIntegrationRegistry(ar);
720
+ const integration = await reg.getIntegration(input.id);
721
+ if (!integration) {
722
+ logger.warn('not found', {
723
+ tags: { integrationId: input.id },
724
+ meta: { phase: 'setSettings' },
725
+ });
726
+ throw new Error(`Integration "${input.id}" not found`);
727
+ }
728
+ const settingsKeys = Object.keys(input.settings);
729
+ logger.info('request', {
730
+ tags: { integrationId: input.id, addonId: integration.addonId },
731
+ meta: { phase: 'setSettings', keys: settingsKeys },
732
+ });
733
+ await reg.setIntegrationSettings(input.id, input.settings);
734
+ logger.info('persisted — restarting addon', {
735
+ tags: { addonId: integration.addonId },
736
+ meta: { phase: 'setSettings' },
737
+ });
738
+ await ar.restartAddon(integration.addonId);
739
+ logger.info('addon restart complete', {
740
+ tags: { addonId: integration.addonId },
741
+ meta: { phase: 'setSettings' },
742
+ });
743
+ return { success: true };
744
+ },
745
+ getAvailableTypes: async () => {
746
+ const reg = requireIntegrationRegistry(ar);
747
+ const addons = ar.listAddons();
748
+ // Hide failed-to-load packages from the picker. Fix #7 surfaces them in
749
+ // the Addons page so the operator can uninstall, but creating an
750
+ // integration against an addon that didn't load produces an orphaned
751
+ // row that `createFilteredRegistry` then filters out — silent data
752
+ // loss from the operator's POV. Filter at the source instead.
753
+ //
754
+ // Markers that flag an addon as a creatable integration type live
755
+ // in the module-level `INTEGRATION_CAP_MARKERS` set (exported so the
756
+ // integration-markers spec can assert the recognised caps).
757
+ const providerAddons = addons.filter((a) => a.process?.state !== 'failed' &&
758
+ a.manifest.capabilities?.some((c) => {
759
+ const name = typeof c === 'string' ? c : c.name;
760
+ return typeof name === 'string' && exports.INTEGRATION_CAP_MARKERS.has(name);
761
+ }));
762
+ const integrations = await reg.listIntegrations();
763
+ return providerAddons.map((addon) => {
764
+ const m = addon.manifest;
765
+ const d = addon.declaration;
766
+ const icon = d?.icon ?? m.icon;
767
+ const color = d?.color ?? m.color ?? '#78716c';
768
+ const instanceMode = d?.instanceMode ?? m.instanceMode ?? 'multiple';
769
+ const existing = integrations.filter((i) => i.addonId === m.id);
770
+ const provider = getDeviceProvider(ar, m.id);
771
+ const discoveryMode = provider?.discoveryMode ?? 'manual';
772
+ // Branch by CAP, not by addon name. Surface which integration-marker
773
+ // cap the addon declared so the wizard routes `device-adoption`
774
+ // (Approach A: pick/create a broker, store `{ brokerId }`) vs the
775
+ // legacy `device-provider` config → discovery flow. A `device-adoption`
776
+ // marker wins when both are present (an integration-style addon may
777
+ // also expose a `device-provider` shim); the broker step is the
778
+ // intended entry point for it.
779
+ const capNames = (m.capabilities ?? []).map((c) => (typeof c === 'string' ? c : c.name));
780
+ const kind = capNames.includes('device-adoption')
781
+ ? 'device-adoption'
782
+ : 'device-provider';
783
+ // For device-adoption addons, the broker kind to create/link comes
784
+ // from the addon manifest (`brokerKind`). Null for device-provider
785
+ // addons, which carry no broker.
786
+ const brokerKind = kind === 'device-adoption' ? (d?.brokerKind ?? m.brokerKind ?? null) : null;
787
+ const supportsLocationImport = kind === 'device-adoption'
788
+ ? (d?.supportsLocationImport ?? m.supportsLocationImport ?? false)
789
+ : false;
790
+ return {
791
+ addonId: m.id,
792
+ name: m.name ?? m.id,
793
+ description: m.description ?? '',
794
+ iconUrl: icon ? `/api/addon-assets/${m.id}/${icon}` : null,
795
+ color,
796
+ instanceMode,
797
+ discoveryMode,
798
+ kind,
799
+ brokerKind,
800
+ supportsLocationImport,
801
+ existingInstances: existing.map((i) => ({
802
+ id: i.id,
803
+ name: i.name,
804
+ })),
805
+ canAdd: instanceMode === 'multiple' || existing.length === 0,
806
+ };
807
+ });
808
+ },
809
+ testConnection: async (input) => {
810
+ // Broker-backed integrations (Approach A) carry their connection
811
+ // identity as a `brokerId` in settings — testing is a broker
812
+ // concern now, so delegate to the addon's `broker` cap. The broker
813
+ // already owns the real semantic check (HA opens a temporary WS
814
+ // handshake; MQTT pings the bridge). We translate the broker's
815
+ // discriminated result (`{ok:true,latencyMs}|{ok:false,error}`) into
816
+ // the integrations `{success, error?}` output shape. Falls back to
817
+ // the default RTSP/ffprobe path below for legacy device-provider
818
+ // addons (Reolink/Frigate/ONVIF) that probe a stream URL.
819
+ const registry = ar.getCapabilityRegistry();
820
+ const brokerId = input.settings['brokerId'];
821
+ if (typeof brokerId === 'string' && brokerId.length > 0) {
822
+ const brokerProvider = registry.getProviderByAddonId('broker', input.addonId);
823
+ if (!brokerProvider) {
824
+ return {
825
+ success: false,
826
+ error: `Broker provider for addon '${input.addonId}' is not available`,
827
+ };
828
+ }
829
+ try {
830
+ const result = await brokerProvider.testConnection({ id: brokerId });
831
+ return result.ok ? { success: true } : { success: false, error: result.error };
832
+ }
833
+ catch (err) {
834
+ return { success: false, error: (0, types_1.errMsg)(err) };
835
+ }
836
+ }
837
+ // Default — RTSP/Frigate/ONVIF legacy path: probe the stream URL.
838
+ const url = String(input.settings['main_stream_url'] ?? input.settings['url'] ?? '').trim();
839
+ if (!url)
840
+ return { success: false, error: 'No stream URL provided' };
841
+ try {
842
+ const { stdout } = await execFileAsync('ffprobe', [
843
+ '-v',
844
+ 'error',
845
+ '-rtsp_transport',
846
+ 'tcp',
847
+ '-timeout',
848
+ '3000000',
849
+ '-show_entries',
850
+ 'stream=codec_name,width,height',
851
+ '-of',
852
+ 'json',
853
+ url,
854
+ ], { timeout: 5000 });
855
+ const parsed = (0, types_1.asJsonObject)(JSON.parse(stdout));
856
+ const streams = (0, types_1.asJsonArray)(parsed?.streams);
857
+ return streams.length > 0
858
+ ? { success: true }
859
+ : { success: false, error: 'No streams found at URL' };
860
+ }
861
+ catch (err) {
862
+ return {
863
+ success: false,
864
+ error: `Connection failed: ${(0, types_1.errMsg)(err)}`,
865
+ };
866
+ }
867
+ },
868
+ };
869
+ }
870
+ // ── addons ──────────────────────────────────────────────────────────
871
+ //
872
+ // `addons.custom` enforces per-action auth dynamically. The cap-router
873
+ // codegen passes ctx into `getProvider(ctx)` so we can close over
874
+ // `ctx.user` and reject before dispatching.
875
+ function ensureCustomActionAuth(ctx, level) {
876
+ if (level === 'public')
877
+ return;
878
+ if (!ctx.user) {
879
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
880
+ }
881
+ if (level === 'protected')
882
+ return;
883
+ if (level === 'admin') {
884
+ if (!ctx.user.isAdmin) {
885
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: 'custom action requires admin' });
886
+ }
887
+ return;
888
+ }
889
+ }
890
+ /** A node id that refers to the hub itself (top-level or a hub group runner). */
891
+ function isHubNode(nodeId) {
892
+ return nodeId === 'hub' || nodeId.startsWith('hub/');
893
+ }
894
+ /**
895
+ * Read an agent's installed npm packages via `$agent.status`. The
896
+ * agent reports its addon roster (id + status + version + packageName);
897
+ * we keep only entries that carry both a package name and a version so
898
+ * the hub can diff them against npm.
899
+ */
900
+ async function fetchAgentInstalledPackages(broker, nodeId) {
901
+ const status = await broker.call('$agent.status', {}, { nodeID: nodeId, timeout: 5_000 });
902
+ const out = [];
903
+ for (const a of status.addons ?? []) {
904
+ if (typeof a.packageName === 'string' && typeof a.version === 'string') {
905
+ out.push({ name: a.packageName, version: a.version });
906
+ }
907
+ }
908
+ return out;
909
+ }
910
+ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx, eb) {
911
+ const broker = moleculer.broker;
912
+ // Adapt the hub EventBusService (which takes a full SystemEvent object) to
913
+ // the IBulkUpdateEventBus interface (which takes (category, payload) pairs).
914
+ // Using `import { EventCategory }` from @camstack/types avoids a new import
915
+ // — it is already resolved in the generated-cap-routers layer above. The
916
+ // `eb.emit` overload that takes a TypedSystemEvent is the type-safe path.
917
+ const bulkEventBus = {
918
+ emit: (category, payload) => {
919
+ eb.emit({
920
+ id: (0, node_crypto_1.randomUUID)(),
921
+ timestamp: new Date(),
922
+ source: { type: 'core', id: 'bulk-update-coordinator' },
923
+ category,
924
+ data: payload,
925
+ });
926
+ },
927
+ };
928
+ const bulkCoordinator = new bulk_update_coordinator_js_1.BulkUpdateCoordinator({
929
+ eventBus: bulkEventBus,
930
+ updateAddon: async (i) => {
931
+ await ps.updatePackage(i.name, i.version);
932
+ },
933
+ updateFrameworkPackage: async (i) => {
934
+ await ps.updateFrameworkPackage({
935
+ packageName: i.packageName,
936
+ version: i.version,
937
+ deferRestart: i.deferRestart,
938
+ });
939
+ },
940
+ restartServer: async () => {
941
+ await ps.restartServer(ctx.user?.username ?? ctx.user?.id);
942
+ },
943
+ logger: ls.createLogger('bulk-update'),
944
+ });
945
+ const frameworkAllowSet = new Set(addon_package_service_js_1.FRAMEWORK_PACKAGE_ALLOWLIST);
946
+ return {
947
+ list: async () => {
948
+ const rollbackable = ps.getRollbackablePackages();
949
+ const healthByPackage = new Map();
950
+ for (const h of ar.getAddonHealthSnapshot()) {
951
+ healthByPackage.set(h.packageName, h);
952
+ }
953
+ return ar.listAllAddons().map((item) => ({
954
+ ...item,
955
+ hasBackup: rollbackable.has(item.manifest.packageName),
956
+ health: healthByPackage.get(item.manifest.packageName) ?? null,
957
+ }));
958
+ },
959
+ getLogs: async (input) => ls.query({
960
+ tags: { addonId: input.addonId },
961
+ limit: input.limit,
962
+ level: input.level,
963
+ }),
964
+ listPackages: async () => ps.listInstalled(),
965
+ installPackage: async (input) => ps.installAndLoad(input.packageName, input.version),
966
+ installFromWorkspace: async (input) => ps.installFromWorkspaceAndLoad(input.packageName),
967
+ isWorkspaceAvailable: async () => ps.isWorkspaceAvailable(),
968
+ listWorkspacePackages: async () => ps.listWorkspacePackages(),
969
+ uninstallPackage: async (input) => ps.uninstallAndReload(input.packageName),
970
+ reloadPackages: async () => ps.reloadPackages(),
971
+ searchAvailable: async (input) => {
972
+ const results = await ps.searchNpm(input?.query);
973
+ const installedIds = new Set(ps.listInstalled().map((p) => p.name));
974
+ return results.map((r) => ({
975
+ ...r,
976
+ installed: installedIds.has(r.name),
977
+ }));
978
+ },
979
+ listUpdates: async (input) => {
980
+ const nodeId = input.nodeId;
981
+ const updates = nodeId === undefined || isHubNode(nodeId)
982
+ ? await ps.checkUpdates()
983
+ : await ps.checkUpdatesForInstalled(await fetchAgentInstalledPackages(broker, nodeId));
984
+ return updates.map((u) => ({ ...u, isSystem: frameworkAllowSet.has(u.name) }));
985
+ },
986
+ updatePackage: async (input) => {
987
+ const nodeId = input.nodeId;
988
+ if (nodeId === undefined || isHubNode(nodeId)) {
989
+ return ps.updatePackage(input.name, input.version);
990
+ }
991
+ // Agent target: the hub packs the resolved version and ships the
992
+ // tarball over `$agent.deploy` — the agent has no npm runtime.
993
+ const packed = await ps.packPackage(input.name, input.version);
994
+ await broker.call('$agent.deploy', { addonId: input.name, bundle: packed.buffer }, { nodeID: nodeId, timeout: 120_000 });
995
+ await broker.call('$agent.reload', {}, { nodeID: nodeId, timeout: 120_000 });
996
+ return { success: true, name: input.name, version: packed.version, nodeId };
997
+ },
998
+ rollbackPackage: async (input) => ps.rollbackPackage(input.name),
999
+ forceRefresh: async (input) => {
1000
+ const nodeId = input.nodeId;
1001
+ if (nodeId === undefined || isHubNode(nodeId))
1002
+ return ps.checkUpdates(true);
1003
+ // Agent rosters carry no hub-side cache — the diff is always live.
1004
+ const installed = await fetchAgentInstalledPackages(broker, nodeId);
1005
+ return ps.checkUpdatesForInstalled(installed);
1006
+ },
1007
+ restartServer: async () => ps.restartServer(ctx.user?.username ?? ctx.user?.id),
1008
+ getLastRestart: async () => {
1009
+ // Avoid pulling PostBootService through the cap-providers tree —
1010
+ // dynamic import keeps the wiring loose and lets us read the
1011
+ // static cache without a constructor dependency.
1012
+ const mod = await Promise.resolve().then(() => __importStar(require('../../boot/post-boot.service.js')));
1013
+ return mod.PostBootService.getLastRestart();
1014
+ },
1015
+ listFrameworkPackages: async () => ps.listFrameworkPackages(),
1016
+ listCapabilityProviders: async (input) => {
1017
+ const registry = ar.getCapabilityRegistry();
1018
+ const caps = registry.listCapabilities();
1019
+ const found = caps.find((c) => c.name === input.capName);
1020
+ if (!found)
1021
+ return [];
1022
+ const mode = found.mode === 'collection' ? 'collection' : 'singleton';
1023
+ const disabled = new Set(found.disabledProviders);
1024
+ return found.providers.map((addonId) => ({
1025
+ addonId,
1026
+ mode,
1027
+ isActive: mode === 'collection' ? !disabled.has(addonId) : found.activeProvider === addonId,
1028
+ }));
1029
+ },
1030
+ setCapabilityProviderEnabled: async (input) => {
1031
+ const registry = ar.getCapabilityRegistry();
1032
+ const caps = registry.listCapabilities();
1033
+ const found = caps.find((c) => c.name === input.capName);
1034
+ if (!found) {
1035
+ throw new server_1.TRPCError({
1036
+ code: 'NOT_FOUND',
1037
+ message: `Unknown capability: ${input.capName}`,
1038
+ });
1039
+ }
1040
+ if (found.mode !== 'collection') {
1041
+ throw new server_1.TRPCError({
1042
+ code: 'BAD_REQUEST',
1043
+ message: `Capability "${input.capName}" is not a collection`,
1044
+ });
1045
+ }
1046
+ if (!found.providers.includes(input.addonId)) {
1047
+ throw new server_1.TRPCError({
1048
+ code: 'BAD_REQUEST',
1049
+ message: `Provider "${input.addonId}" is not registered for "${input.capName}"`,
1050
+ });
1051
+ }
1052
+ if (input.enabled) {
1053
+ registry.enableCollectionProvider(input.capName, input.addonId);
1054
+ }
1055
+ else {
1056
+ registry.disableCollectionProvider(input.capName, input.addonId);
1057
+ }
1058
+ // Persist the new disabled-set so the choice survives a hub reboot.
1059
+ // Reuses the same `capabilities.collection.<cap>` key/format the
1060
+ // `capabilities` core router writes — via the shared canonical writer.
1061
+ const updated = registry.listCapabilities().find((c) => c.name === input.capName);
1062
+ (0, collection_preference_js_1.persistCollectionDisabled)(configService, input.capName, updated?.disabledProviders ?? []);
1063
+ return { success: true };
1064
+ },
1065
+ updateFrameworkPackage: async (input) => ps.updateFrameworkPackage({
1066
+ packageName: input.packageName,
1067
+ ...(input.version !== undefined ? { version: input.version } : {}),
1068
+ ...(ctx.user?.username !== undefined
1069
+ ? { requestedBy: ctx.user.username }
1070
+ : ctx.user?.id !== undefined
1071
+ ? { requestedBy: ctx.user.id }
1072
+ : {}),
1073
+ ...(input.deferRestart !== undefined ? { deferRestart: input.deferRestart } : {}),
1074
+ }),
1075
+ getVersions: async (input) => ps.getPackageVersions(input.name),
1076
+ restartAddon: async (input) => ar.restartAddon(input.addonId),
1077
+ retryLoad: async (input) => {
1078
+ await ar.retryAddonLoad(input.packageName);
1079
+ return { success: true };
1080
+ },
1081
+ getAutoUpdateSettings: async () => ps.getAutoUpdateSettings(),
1082
+ setAutoUpdateSettings: async (input) => ps.setAutoUpdateSettings(input.channel, input.intervalSeconds),
1083
+ getAddonAutoUpdate: async (input) => ps.getAddonAutoUpdate(input.addonId),
1084
+ setAddonAutoUpdate: async (input) => ps.setAddonAutoUpdate(input.addonId, input.channel),
1085
+ applyAutoUpdateToAll: async (input) => {
1086
+ await ps.setAutoUpdateSettings(input.channel);
1087
+ for (const pkg of ps.listInstalled()) {
1088
+ await ps.setAddonAutoUpdate(pkg.name, input.channel);
1089
+ }
1090
+ return { success: true };
1091
+ },
1092
+ startBulkUpdate: async (input) => bulkCoordinator.start(input),
1093
+ getBulkUpdateState: async ({ id }) => bulkCoordinator.get(id),
1094
+ cancelBulkUpdate: async ({ id }) => bulkCoordinator.cancel(id),
1095
+ listActiveBulkUpdates: async ({ nodeId }) => bulkCoordinator.list(nodeId),
1096
+ custom: async (input) => {
1097
+ const registry = ar.getCustomActionRegistry();
1098
+ const entry = registry.resolve(input.addonId, input.action);
1099
+ if (!entry) {
1100
+ throw new server_1.TRPCError({
1101
+ code: 'NOT_FOUND',
1102
+ message: `addon '${input.addonId}' has no custom action '${input.action}'`,
1103
+ });
1104
+ }
1105
+ ensureCustomActionAuth(ctx, entry.spec.auth);
1106
+ const parsedInput = entry.spec.input.parse(input.input);
1107
+ const result = await entry.handler(parsedInput);
1108
+ return entry.spec.output.parse(result);
1109
+ },
1110
+ onAddonLogs: (input, push) => {
1111
+ const unsubscribe = ls.subscribe({ tags: { addonId: input.addonId }, level: input.level }, (entry) => {
1112
+ push({
1113
+ timestamp: entry.timestamp instanceof Date
1114
+ ? entry.timestamp.toISOString()
1115
+ : String(entry.timestamp),
1116
+ level: entry.level,
1117
+ message: entry.message,
1118
+ scope: entry.scope,
1119
+ });
1120
+ });
1121
+ return unsubscribe ?? (() => { });
1122
+ },
1123
+ };
1124
+ }