@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.
- package/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- 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
|
+
})
|