@camstack/server 0.1.3

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 (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,92 @@
1
+ import * as path from 'node:path'
2
+ import * as fs from 'node:fs'
3
+ import { LoggingService } from '../logging/logging.service'
4
+ import { CapabilityService } from '../capability/capability.service'
5
+ import { AddonRegistryService } from '../addon/addon-registry.service'
6
+ import type { IScopedLogger, IAddonWidgetsSourceProvider } from '@camstack/types'
7
+
8
+ /**
9
+ * Strip the `@<nodeId>` suffix the CapabilityBridge appends to
10
+ * collection-provider registry keys for cross-node addons. The widget
11
+ * static-file route always receives the bare manifest id.
12
+ */
13
+ function stripNodeSuffix(registryKey: string): string {
14
+ const at = registryKey.indexOf('@')
15
+ return at === -1 ? registryKey : registryKey.slice(0, at)
16
+ }
17
+
18
+ /**
19
+ * AddonWidgetsService — server-side helper that backs the static file
20
+ * route `/api/addon-widgets/:addonId/*` (registered in `main.ts`).
21
+ *
22
+ * Mirrors `AddonPagesService` exactly. The public listing surface
23
+ * (`addonWidgets.listWidgets` on the AppRouter) lives in the
24
+ * `addon-widgets-aggregator` builtin which walks every
25
+ * `addon-widgets-source` collection provider and stamps versioned
26
+ * `bundleUrl`s. Both ends flow through codegen.
27
+ *
28
+ * What stays here: filesystem path resolution + traversal protection
29
+ * for the static file route. The registered-provider check uses
30
+ * `CapabilityService` (so unknown / unregistered addons can't be
31
+ * probed via path traversal tricks); the on-disk path comes from
32
+ * `AddonRegistryService.getAddonInstallPath()` so bundled addons
33
+ * (where one npm package ships multiple addon entries under
34
+ * `dist/<entryId>/`) resolve to the right sub-folder instead of the
35
+ * pre-bundle `addons/@camstack/addon-<id>/dist/` layout.
36
+ */
37
+ export class AddonWidgetsService {
38
+ private readonly logger: IScopedLogger
39
+
40
+ constructor(
41
+ private readonly loggingService: LoggingService,
42
+ private readonly caps: CapabilityService,
43
+ private readonly registry: AddonRegistryService,
44
+ ) {
45
+ this.logger = this.loggingService.createLogger('AddonWidgetsService')
46
+ }
47
+
48
+ /**
49
+ * Resolve the filesystem path to an addon's widget bundle file.
50
+ * Returns null if the addon is not registered, the file doesn't exist,
51
+ * or the path would escape the addon directory (path traversal protection).
52
+ */
53
+ resolveBundle(addonId: string, filePath: string): string | null {
54
+ // The collection cap doesn't carry an `id` on its provider, so we
55
+ // attribute via `getCollectionEntries` (returns `[addonId, provider]`).
56
+ //
57
+ // For cross-node addons the CapabilityBridge registers the provider
58
+ // under a node-qualified key `<addonId>@<nodeId>` (see
59
+ // `moleculer.service.ts`). The bundle is hub-resident and keyed by
60
+ // the bare manifest id, so the route always carries the bare
61
+ // `addonId` — match it against each registry key with the
62
+ // `@<nodeId>` suffix stripped.
63
+ const entries = this.caps.getCollectionEntries<IAddonWidgetsSourceProvider>('addon-widgets-source')
64
+ const isRegistered = entries.some(([id]) => stripNodeSuffix(id) === addonId)
65
+ if (!isRegistered) {
66
+ this.logger.warn('Bundle resolve failed: addon not registered as widget provider', { tags: { addonId } })
67
+ return null
68
+ }
69
+
70
+ const installPath = this.registry.getAddonInstallPath(addonId)
71
+ if (!installPath) {
72
+ this.logger.warn('Bundle resolve failed: addon install path not found', { tags: { addonId } })
73
+ return null
74
+ }
75
+
76
+ const resolvedBase = path.resolve(installPath.distDir)
77
+ const resolvedFile = path.resolve(installPath.distDir, filePath)
78
+
79
+ // Path traversal protection
80
+ if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
81
+ this.logger.warn('Path traversal denied for addon', { tags: { addonId }, meta: { filePath } })
82
+ return null
83
+ }
84
+
85
+ if (!fs.existsSync(resolvedFile)) {
86
+ this.logger.debug('Bundle file not found', { meta: { resolvedFile } })
87
+ return null
88
+ }
89
+
90
+ return resolvedFile
91
+ }
92
+ }
@@ -0,0 +1,507 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import * as os from 'node:os'
3
+ import { EventCategory, resolveAddonPlacement } from '@camstack/types'
4
+ import type {
5
+ AgentListItem,
6
+ CameraRoleAssignment,
7
+ AgentCapability,
8
+ IMetricsProvider,
9
+ } from '@camstack/types'
10
+ import { EventBusService } from '../events/event-bus.service'
11
+ import { MoleculerService } from '../moleculer/moleculer.service'
12
+ import { CapabilityService } from '../capability/capability.service'
13
+ import type { AddonRegistryService } from '../addon/addon-registry.service'
14
+
15
+ /** Per-call timeout for `$agent.*` RPC during reconciliation. */
16
+ const AGENT_RECONCILE_RPC_TIMEOUT_MS = 8_000
17
+
18
+ /** Shape of one addon entry in the `$agent.status` response. */
19
+ interface AgentStatusAddon {
20
+ readonly id: string
21
+ readonly status?: string
22
+ readonly version?: string
23
+ readonly packageName?: string
24
+ }
25
+
26
+ /**
27
+ * Narrow typed view of the Moleculer broker — its own `.d.ts` chains
28
+ * through eventemitter2 and resolves to `error`, tripping the
29
+ * `no-unsafe-*` rules. One documented cast through this interface
30
+ * (see the `broker` getter) keeps every call site type-safe.
31
+ */
32
+ interface ReconcileBrokerLike {
33
+ call(
34
+ action: string,
35
+ params?: Record<string, unknown>,
36
+ options?: { nodeID?: string; timeout?: number },
37
+ ): Promise<unknown>
38
+ localBus: {
39
+ on(event: string, handler: (payload: { node: { id: string } }) => void): void
40
+ }
41
+ }
42
+
43
+ export class AgentRegistryService {
44
+ // Role assignments: key = `${cameraId}:${role}`
45
+ private readonly assignments: Map<string, CameraRoleAssignment> = new Map()
46
+ private readonly bootTimestamp = Date.now()
47
+
48
+ /**
49
+ * Hub addon registry — the source of truth for which addons are
50
+ * installed and their `execution.placement`. Injected via
51
+ * `setAddonRegistry()` rather than the constructor because
52
+ * `AddonRegistryService` is built AFTER this service in the boot
53
+ * order (see `manual-boot.ts`).
54
+ */
55
+ private addonRegistry: AddonRegistryService | null = null
56
+
57
+ constructor(
58
+ private readonly eventBus: EventBusService,
59
+ private readonly moleculer: MoleculerService,
60
+ private readonly capabilityService: CapabilityService,
61
+ ) {}
62
+
63
+ /** Wire the hub addon registry once it has been constructed. */
64
+ setAddonRegistry(addonRegistry: AddonRegistryService): void {
65
+ this.addonRegistry = addonRegistry
66
+ }
67
+
68
+ /** Typed view of the Moleculer broker — single documented cast. */
69
+ private get broker(): ReconcileBrokerLike {
70
+ return this.moleculer.broker as unknown as ReconcileBrokerLike
71
+ }
72
+
73
+ onModuleInit(): void {
74
+ const classifyNode = (id: string): 'agent' | 'worker' | null => {
75
+ if (id === 'hub') return null
76
+ if (id.includes('/')) return 'worker'
77
+ return 'agent'
78
+ }
79
+
80
+ // D3: reconcile placement when an agent completes the $hub.registerNode
81
+ // handshake — the manifest is authoritative and complete at that point,
82
+ // no grace delay needed. MoleculerService fires this callback from its
83
+ // onRegisterNode dep for every bare-ID agent node.
84
+ this.moleculer.setOnAgentRegistered((agentId) => {
85
+ void this.reconcileAgentAddons(agentId)
86
+ })
87
+
88
+ this.broker.localBus.on('$node.connected', ({ node }: { node: { id: string } }) => {
89
+ const kind = classifyNode(node.id)
90
+ if (!kind) return
91
+ if (kind === 'agent') {
92
+ console.log(`[agent-registry] Agent connected: ${node.id}`)
93
+ this.eventBus.emit({
94
+ id: randomUUID(),
95
+ timestamp: new Date(),
96
+ source: { type: 'core', id: 'agent-registry' },
97
+ category: EventCategory.AgentOnline,
98
+ data: { agentId: node.id },
99
+ })
100
+ } else {
101
+ this.eventBus.emit({
102
+ id: randomUUID(),
103
+ timestamp: new Date(),
104
+ source: { type: 'core', id: 'agent-registry' },
105
+ category: EventCategory.WorkerOnline,
106
+ data: { workerId: node.id },
107
+ })
108
+ }
109
+ })
110
+
111
+ this.broker.localBus.on('$node.disconnected', ({ node }: { node: { id: string } }) => {
112
+ const kind = classifyNode(node.id)
113
+ if (!kind) return
114
+ if (kind === 'agent') {
115
+ console.log(`[agent-registry] Agent disconnected: ${node.id}`)
116
+ this.eventBus.emit({
117
+ id: randomUUID(),
118
+ timestamp: new Date(),
119
+ source: { type: 'core', id: 'agent-registry' },
120
+ category: EventCategory.AgentOffline,
121
+ data: { agentId: node.id },
122
+ })
123
+ } else {
124
+ this.eventBus.emit({
125
+ id: randomUUID(),
126
+ timestamp: new Date(),
127
+ source: { type: 'core', id: 'agent-registry' },
128
+ category: EventCategory.WorkerOffline,
129
+ data: { workerId: node.id },
130
+ })
131
+ }
132
+ })
133
+
134
+ // Retroactive scan: emit lifecycle events for nodes already
135
+ // connected when the listener registers. Classifies each as
136
+ // agent or worker — same logic as the live handlers above.
137
+ try {
138
+ const registry = (this.moleculer.broker as unknown as Record<string, unknown>).registry as
139
+ | { getNodeList?: (opts: { onlyAvailable: boolean }) => readonly { id: string }[] }
140
+ | undefined
141
+ const existing = registry?.getNodeList?.({ onlyAvailable: true }) ?? []
142
+ for (const { id } of existing) {
143
+ const kind = classifyNode(id)
144
+ if (!kind) continue
145
+ if (kind === 'agent') {
146
+ this.eventBus.emit({
147
+ id: randomUUID(),
148
+ timestamp: new Date(),
149
+ source: { type: 'core', id: 'agent-registry' },
150
+ category: EventCategory.AgentOnline,
151
+ data: { agentId: id },
152
+ })
153
+ } else {
154
+ this.eventBus.emit({
155
+ id: randomUUID(),
156
+ timestamp: new Date(),
157
+ source: { type: 'core', id: 'agent-registry' },
158
+ category: EventCategory.WorkerOnline,
159
+ data: { workerId: id },
160
+ })
161
+ }
162
+ }
163
+ } catch {
164
+ // Registry shape varies across Moleculer versions — never fatal.
165
+ }
166
+ }
167
+
168
+ // ---- Placement reconciliation ----
169
+
170
+ /**
171
+ * Boot-time reconciliation pass. Agents already connected when the hub
172
+ * starts never fire a fresh `$node.connected` event, so the per-connect
173
+ * trigger would miss them — this method walks the current node list once
174
+ * and reconciles every connected agent. Must be invoked AFTER
175
+ * `AddonRegistryService.onModuleInit()` so the hub's installed-addon set
176
+ * is populated. Per-agent failures are isolated — one unreachable agent
177
+ * must not abort the pass or hub boot.
178
+ */
179
+ async reconcileConnectedAgents(): Promise<void> {
180
+ const registry = (this.moleculer.broker as unknown as Record<string, unknown>).registry as
181
+ | { getNodeList?: (opts: { onlyAvailable: boolean }) => readonly { id: string }[] }
182
+ | undefined
183
+ const nodes = registry?.getNodeList?.({ onlyAvailable: true }) ?? []
184
+ const agentIds = nodes
185
+ .map((n) => n.id)
186
+ .filter((id) => id !== 'hub' && !id.includes('/'))
187
+ if (agentIds.length === 0) return
188
+ console.log(`[agent-registry] Boot reconcile: ${agentIds.length} connected agent(s)`)
189
+ for (const agentId of agentIds) {
190
+ await this.reconcileAgentAddons(agentId)
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Reconcile a single agent's deployed addons against the hub's installed
196
+ * set + placements. An addon running on the agent is STALE — and must be
197
+ * undeployed — when it is either:
198
+ * - not installed on the hub at all, or
199
+ * - installed but declared `execution.placement: 'hub-only'`.
200
+ *
201
+ * `agent-only` / `any-node` addons that ARE installed on the hub are
202
+ * legitimate agent residents and left untouched.
203
+ *
204
+ * Matching is by addon DECLARATION id: `$agent.status` reports
205
+ * `addons[].id` (the decl id), and the hub's `listAddons()` rows expose
206
+ * the same decl id at `manifest.id`. Package names are NOT used for the
207
+ * match — a single package can ship multiple addons with distinct ids
208
+ * and placements.
209
+ *
210
+ * All errors are caught and logged so a single bad agent never breaks
211
+ * the caller (connect handler or boot pass).
212
+ */
213
+ async reconcileAgentAddons(agentId: string): Promise<void> {
214
+ if (!this.addonRegistry) {
215
+ console.warn(`[agent-registry] Reconcile skipped for ${agentId}: addon registry not wired`)
216
+ return
217
+ }
218
+ try {
219
+ const broker = this.broker
220
+ const statusRaw = await broker.call('$agent.status', {}, {
221
+ nodeID: agentId,
222
+ timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS,
223
+ })
224
+ const agentAddons = this.extractAgentAddons(statusRaw)
225
+ if (agentAddons.length === 0) return
226
+
227
+ // Build the hub's placement map: decl id → placement. Absence from
228
+ // this map means "not installed on the hub".
229
+ const hubPlacements = new Map<string, ReturnType<typeof resolveAddonPlacement>>()
230
+ for (const row of this.addonRegistry.listAddons()) {
231
+ const declId = row.manifest.id
232
+ if (typeof declId !== 'string') continue
233
+ const decl = row.declaration ?? row.manifest
234
+ hubPlacements.set(declId, resolveAddonPlacement(decl))
235
+ }
236
+
237
+ const stale = agentAddons.filter((addon) => {
238
+ const placement = hubPlacements.get(addon.id)
239
+ // Not installed on the hub → stale.
240
+ if (placement === undefined) return true
241
+ // Installed but pinned to the hub → must not run on an agent.
242
+ return placement === 'hub-only'
243
+ })
244
+
245
+ if (stale.length === 0) {
246
+ console.log(`[agent-registry] Reconcile ${agentId}: no stale addons (${agentAddons.length} checked)`)
247
+ return
248
+ }
249
+
250
+ for (const addon of stale) {
251
+ const reason = hubPlacements.has(addon.id)
252
+ ? 'placement is hub-only'
253
+ : 'not installed on hub'
254
+ try {
255
+ await broker.call('$agent.undeploy', { addonId: addon.id }, {
256
+ nodeID: agentId,
257
+ timeout: AGENT_RECONCILE_RPC_TIMEOUT_MS,
258
+ })
259
+ console.log(`[agent-registry] Reconcile ${agentId}: undeployed stale addon "${addon.id}" (${reason})`)
260
+ this.eventBus.emit({
261
+ id: randomUUID(),
262
+ timestamp: new Date(),
263
+ source: { type: 'core', id: 'agent-registry' },
264
+ category: EventCategory.AddonUninstalled,
265
+ data: { addonId: addon.id, agentId, reason },
266
+ })
267
+ } catch (err) {
268
+ console.error(
269
+ `[agent-registry] Reconcile ${agentId}: failed to undeploy "${addon.id}":`,
270
+ err instanceof Error ? err.message : String(err),
271
+ )
272
+ }
273
+ }
274
+ } catch (err) {
275
+ console.error(
276
+ `[agent-registry] Reconcile failed for agent ${agentId}:`,
277
+ err instanceof Error ? err.message : String(err),
278
+ )
279
+ }
280
+ }
281
+
282
+ /** Narrow the `$agent.status` response down to its addon list. */
283
+ private extractAgentAddons(statusRaw: unknown): readonly AgentStatusAddon[] {
284
+ if (statusRaw === null || typeof statusRaw !== 'object') return []
285
+ const addons = (statusRaw as { addons?: unknown }).addons
286
+ if (!Array.isArray(addons)) return []
287
+ const result: AgentStatusAddon[] = []
288
+ for (const entry of addons) {
289
+ if (entry === null || typeof entry !== 'object') continue
290
+ const id = (entry as { id?: unknown }).id
291
+ if (typeof id !== 'string' || id.length === 0) continue
292
+ result.push({ id })
293
+ }
294
+ return result
295
+ }
296
+
297
+ /** Log an agent rename (name changes are persisted via $agent.rename RPC). */
298
+ updateAgentName(nodeId: string, name: string): void {
299
+ console.log(`[agent-registry] Agent renamed: "${nodeId}" → "${name}"`)
300
+ }
301
+
302
+ async listNodes(): Promise<readonly AgentListItem[]> {
303
+ // Get child processes for hub via $process.list
304
+ let hubProcesses: readonly unknown[] = []
305
+ try {
306
+ const processes = await this.broker.call('$process.list') as readonly Record<string, unknown>[]
307
+ hubProcesses = processes.map((p) => ({
308
+ pid: (p.pid as number) ?? 0,
309
+ name: (p.name as string) ?? '',
310
+ command: 'moleculer-service',
311
+ state: (p.state as 'running' | 'stopped' | 'crashed') ?? 'running',
312
+ cpuPercent: (p.cpuPercent as number) ?? 0,
313
+ memoryRss: (p.memoryRss as number) ?? 0,
314
+ uptimeSeconds: (p.uptimeSeconds as number) ?? 0,
315
+ addonIds: (p.addonIds as readonly string[] | undefined) ?? [],
316
+ groupId: (p.groupId as string | undefined) ?? null,
317
+ }))
318
+ } catch {
319
+ // $process service may not be ready yet
320
+ }
321
+
322
+ const hubEntry = await this.buildHubEntry(hubProcesses)
323
+
324
+ const remoteEntries: AgentListItem[] = []
325
+ const registry = (this.moleculer.broker as unknown as Record<string, unknown>).registry as
326
+ | { getNodeList?: (opts: { onlyAvailable: boolean }) => Array<{ id: string }> }
327
+ | undefined
328
+ const nodes = registry?.getNodeList?.({ onlyAvailable: true }) ?? []
329
+
330
+ for (const node of nodes) {
331
+ const nodeId = node.id
332
+ // Skip hub (already included) and child processes (contain '/')
333
+ if (nodeId === 'hub' || nodeId.includes('/')) continue
334
+
335
+ try {
336
+ const status = await this.broker.call('$agent.status', {}, {
337
+ nodeID: nodeId,
338
+ timeout: 5000,
339
+ }) as Record<string, unknown>
340
+
341
+ // Get real sub-process stats from the agent's $process.list
342
+ let subProcesses: readonly unknown[] = []
343
+ try {
344
+ const processes = await this.broker.call('$process.list', {}, {
345
+ nodeID: nodeId,
346
+ timeout: 5000,
347
+ }) as readonly Record<string, unknown>[]
348
+ subProcesses = processes.map((p) => ({
349
+ pid: (p.pid as number) ?? 0,
350
+ name: (p.name as string) ?? '',
351
+ command: 'moleculer-service',
352
+ state: (p.state as 'running' | 'stopped' | 'crashed') ?? 'running',
353
+ cpuPercent: (p.cpuPercent as number) ?? 0,
354
+ memoryRss: (p.memoryRss as number) ?? 0,
355
+ uptimeSeconds: (p.uptimeSeconds as number) ?? 0,
356
+ addonIds: (p.addonIds as readonly string[] | undefined) ?? [],
357
+ groupId: (p.groupId as string | undefined) ?? null,
358
+ }))
359
+ } catch {
360
+ // Fall back to addon list from $agent.status (no stats)
361
+ subProcesses = (status.addons as readonly Record<string, unknown>[] | undefined)?.map((a) => ({
362
+ pid: 0,
363
+ name: (a.id as string) ?? '',
364
+ command: 'moleculer-service',
365
+ state: ((a.status as string) ?? 'running') as 'running' | 'stopped' | 'crashed',
366
+ cpuPercent: 0,
367
+ memoryRss: 0,
368
+ uptimeSeconds: 0,
369
+ })) ?? []
370
+ }
371
+
372
+ // Extract addon IDs from $agent.status
373
+ const agentAddons: readonly string[] = (status.addons as readonly { id: string }[] | undefined)
374
+ ?.map(a => a.id) ?? []
375
+
376
+ const hostname = typeof status.hostname === 'string' ? status.hostname : null
377
+ const agentName = typeof status.name === 'string' ? status.name : nodeId
378
+ remoteEntries.push({
379
+ info: {
380
+ id: nodeId,
381
+ name: agentName,
382
+ hostname: hostname ?? nodeId,
383
+ capabilities: [],
384
+ platform: (status.platform as string) ?? 'unknown',
385
+ arch: (status.arch as string) ?? 'unknown',
386
+ cpuCores: (status.cpuCores as number) ?? 0,
387
+ memoryMB: (status.totalMemoryMB as number) ?? 0,
388
+ cpuModel: status.cpuModel as string | undefined,
389
+ },
390
+ localIps: Array.isArray(status.localIps) ? status.localIps as string[] : [],
391
+ status: {
392
+ activeCameras: 0,
393
+ cpuPercent: (status.cpuPercent as number) ?? 0,
394
+ memoryPercent: (status.memoryPercent as number) ?? 0,
395
+ fps: {},
396
+ errors: [],
397
+ },
398
+ connectedSince: typeof status.uptime === 'number'
399
+ ? Date.now() - (status.uptime as number) * 1000
400
+ : Date.now(),
401
+ isHub: false,
402
+ subProcesses,
403
+ agentAddons,
404
+ })
405
+
406
+ } catch {
407
+ // Skip nodes without $agent service
408
+ }
409
+ }
410
+
411
+ // TODO(D3 follow-up): offline-agent history dropped with knownAgents.
412
+ // Previously, agents that disconnected were kept in a shadow map and
413
+ // surfaced here as offline rows. listNodes now reflects only live
414
+ // broker.registry nodes.
415
+
416
+ return [hubEntry, ...remoteEntries]
417
+ }
418
+
419
+ private async buildHubEntry(subProcesses: readonly unknown[] = []): Promise<AgentListItem> {
420
+ const cpus = os.cpus()
421
+
422
+ // Get live metrics from the metrics-provider capability (NativeMetricsProvider).
423
+ // The cap contract only exposes async snapshots; the cached read is cheap
424
+ // because the addon's background sampler keeps the snapshot warm.
425
+ let cpuPercent = 0
426
+ let memoryPercent = 0
427
+ const registry = this.capabilityService.getRegistry()
428
+ if (registry) {
429
+ const metricsProvider = registry.getSingleton<IMetricsProvider>('metrics-provider')
430
+ if (metricsProvider) {
431
+ const snapshot = await metricsProvider.getCached()
432
+ if (snapshot) {
433
+ cpuPercent = snapshot.cpu.total
434
+ memoryPercent = snapshot.memory.percent
435
+ }
436
+ }
437
+ }
438
+
439
+ return {
440
+ info: {
441
+ id: 'hub',
442
+ name: 'Hub',
443
+ hostname: os.hostname(),
444
+ capabilities: [],
445
+ platform: os.platform(),
446
+ arch: os.arch(),
447
+ cpuCores: cpus.length,
448
+ memoryMB: Math.round(os.totalmem() / 1024 / 1024),
449
+ cpuModel: cpus[0]?.model,
450
+ },
451
+ status: {
452
+ activeCameras: 0,
453
+ cpuPercent,
454
+ memoryPercent,
455
+ fps: {},
456
+ errors: [],
457
+ },
458
+ connectedSince: this.bootTimestamp,
459
+ isHub: true,
460
+ subProcesses,
461
+ }
462
+ }
463
+
464
+ // ---- Role assignments ----
465
+
466
+ getAssignments(cameraId?: number): CameraRoleAssignment[] {
467
+ const all = [...this.assignments.values()]
468
+ if (cameraId !== undefined) return all.filter((a) => a.cameraId === cameraId)
469
+ return all
470
+ }
471
+
472
+ setAssignment(assignment: CameraRoleAssignment): void {
473
+ const key = `${assignment.cameraId}:${assignment.role}`
474
+ this.assignments.set(key, { ...assignment })
475
+ }
476
+
477
+ removeAssignment(cameraId: number, role: AgentCapability): void {
478
+ const key = `${cameraId}:${role}`
479
+ this.assignments.delete(key)
480
+ }
481
+
482
+ activateBackup(cameraId: number, role: AgentCapability): void {
483
+ const primaryKey = `${cameraId}:${role}`
484
+ const primary = this.assignments.get(primaryKey)
485
+
486
+ const backup = [...this.assignments.values()].find(
487
+ (a) => a.cameraId === cameraId && a.role === role && a.priority === 'backup',
488
+ )
489
+
490
+ if (!backup) return
491
+
492
+ if (primary) {
493
+ this.assignments.delete(primaryKey)
494
+ }
495
+
496
+ const promoted: CameraRoleAssignment = { ...backup, priority: 'primary' }
497
+ this.assignments.set(primaryKey, promoted)
498
+
499
+ this.eventBus.emit({
500
+ id: randomUUID(),
501
+ timestamp: new Date(),
502
+ source: { type: 'core', id: 'agent-registry' },
503
+ category: EventCategory.AgentBackupActivated,
504
+ data: { cameraId, role, agentId: promoted.agentId },
505
+ })
506
+ }
507
+ }
@@ -0,0 +1,88 @@
1
+
2
+ import { describe, it, expect, beforeEach } from 'vitest'
3
+ import { AuthService } from './auth.service'
4
+ import type { ConfigService } from '../config/config.service'
5
+ import type { TokenPayload } from '@camstack/types'
6
+
7
+ function createMockConfig(overrides: Record<string, unknown> = {}): ConfigService {
8
+ return {
9
+ get: (path: string) => overrides[path],
10
+ } as unknown as ConfigService
11
+ }
12
+
13
+ describe('AuthService', () => {
14
+ let service: AuthService
15
+
16
+ beforeEach(() => {
17
+ service = new AuthService(createMockConfig({ 'auth.jwtSecret': 'test-secret' }))
18
+ })
19
+
20
+ describe('JWT sign/verify', () => {
21
+ it('should sign and verify a token (roundtrip)', () => {
22
+ const payload: Omit<TokenPayload, 'iat' | 'exp'> = {
23
+ userId: 'user-1',
24
+ username: 'admin',
25
+ role: 'admin',
26
+ allowedProviders: '*',
27
+ allowedDevices: {},
28
+ }
29
+
30
+ const token = service.signToken(payload)
31
+
32
+ const decoded = service.verifyToken(token)
33
+
34
+
35
+ expect(decoded.userId).toBe('user-1')
36
+
37
+ expect(decoded.username).toBe('admin')
38
+
39
+ expect(decoded.role).toBe('admin')
40
+
41
+ expect(decoded.allowedProviders).toBe('*')
42
+
43
+ expect(decoded.iat).toBeDefined()
44
+
45
+ expect(decoded.exp).toBeDefined()
46
+ })
47
+
48
+ it('should reject invalid tokens', () => {
49
+ expect(() => service.verifyToken('invalid.token.here')).toThrow()
50
+ })
51
+ })
52
+
53
+ describe('password hashing', () => {
54
+ it('should hash and compare passwords correctly', async () => {
55
+ const password = 'my-secure-password'
56
+ const hash = await service.hashPassword(password)
57
+
58
+ expect(hash).not.toBe(password)
59
+ expect(await service.comparePassword(password, hash)).toBe(true)
60
+ })
61
+
62
+ it('should reject wrong passwords', async () => {
63
+ const hash = await service.hashPassword('correct-password')
64
+ expect(await service.comparePassword('wrong-password', hash)).toBe(false)
65
+ })
66
+ })
67
+
68
+ describe('API key generation', () => {
69
+ it('should generate an API key with token, hash, and prefix', () => {
70
+ const key = service.generateApiKey()
71
+
72
+ expect(key.token).toHaveLength(64) // 32 bytes hex
73
+ expect(key.hash).toHaveLength(64) // SHA-256 hex
74
+ expect(key.prefix).toHaveLength(8)
75
+ expect(key.token.startsWith(key.prefix)).toBe(true)
76
+ })
77
+
78
+ it('should validate API key token against hash (correct)', () => {
79
+ const key = service.generateApiKey()
80
+ expect(service.validateApiKey(key.token, key.hash)).toBe(true)
81
+ })
82
+
83
+ it('should reject wrong API key token', () => {
84
+ const key = service.generateApiKey()
85
+ expect(service.validateApiKey('wrong-token', key.hash)).toBe(false)
86
+ })
87
+ })
88
+ })
@@ -0,0 +1,8 @@
1
+ import { AuthManager } from '@camstack/core'
2
+ import { ConfigService } from '../config/config.service'
3
+
4
+ export class AuthService extends AuthManager {
5
+ constructor(config: ConfigService) {
6
+ super(config)
7
+ }
8
+ }