@camstack/server 0.1.7 → 0.2.0
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/package.json +11 -9
- package/src/__tests__/addon-install-e2e.test.ts +0 -1
- package/src/__tests__/addon-pages-e2e.test.ts +40 -18
- package/src/__tests__/addon-settings-router.spec.ts +6 -1
- package/src/__tests__/addon-upload.spec.ts +91 -29
- package/src/__tests__/agent-registry.spec.ts +26 -9
- package/src/__tests__/agent-status-page.spec.ts +1 -3
- package/src/__tests__/auth-session-cookie.test.ts +28 -1
- package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
- package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
- package/src/__tests__/cap-route-adapter.spec.ts +28 -15
- package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
- package/src/__tests__/cap-routers/harness.ts +11 -7
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
- package/src/__tests__/capability-e2e.test.ts +9 -11
- package/src/__tests__/cli-e2e.test.ts +80 -59
- package/src/__tests__/core-cap-bridge.spec.ts +3 -1
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
- package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
- package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
- package/src/__tests__/framework-allowlist.spec.ts +5 -4
- package/src/__tests__/https-e2e.test.ts +12 -6
- package/src/__tests__/lifecycle-e2e.test.ts +60 -11
- package/src/__tests__/live-events-subscription.spec.ts +17 -18
- package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
- package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
- package/src/__tests__/native-cap-route.spec.ts +42 -19
- package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
- package/src/__tests__/singleton-contention.test.ts +23 -11
- package/src/__tests__/streaming-diagnostic.test.ts +156 -53
- package/src/__tests__/streaming-scale.test.ts +69 -35
- package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
- package/src/agent-status-page.ts +4 -3
- package/src/api/__tests__/addons-custom.spec.ts +22 -8
- package/src/api/__tests__/capabilities.router.test.ts +18 -9
- package/src/api/addon-upload.ts +46 -15
- package/src/api/addons-custom.router.ts +7 -6
- package/src/api/auth-whoami.ts +3 -1
- package/src/api/bridge-addons.router.ts +3 -1
- package/src/api/capabilities.router.ts +117 -78
- package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/addon-settings.router.ts +4 -1
- package/src/api/core/agents.router.ts +52 -53
- package/src/api/core/auth.router.ts +55 -36
- package/src/api/core/bulk-update-coordinator.ts +25 -22
- package/src/api/core/cap-providers.ts +459 -166
- package/src/api/core/capabilities.router.ts +30 -23
- package/src/api/core/hwaccel.router.ts +37 -10
- package/src/api/core/live-events.router.ts +16 -9
- package/src/api/core/logs.router.ts +58 -25
- package/src/api/core/notifications.router.ts +2 -1
- package/src/api/core/repl.router.ts +1 -3
- package/src/api/core/settings-backend.router.ts +68 -70
- package/src/api/core/system-events.router.ts +41 -32
- package/src/api/health/health.routes.ts +7 -13
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
- package/src/api/oauth2/consent-page.ts +4 -3
- package/src/api/oauth2/oauth2-routes.ts +41 -12
- package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
- package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
- package/src/api/trpc/cap-mount-helpers.ts +64 -44
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/client-ip.ts +17 -0
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +801 -286
- package/src/api/trpc/generated-cap-routers.ts +5723 -719
- package/src/api/trpc/scope-access.ts +7 -7
- package/src/api/trpc/trpc.context.ts +7 -4
- package/src/api/trpc/trpc.middleware.ts +4 -2
- package/src/api/trpc/trpc.router.ts +117 -48
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
- package/src/boot/boot-config.ts +103 -122
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/boot/post-boot.service.ts +5 -3
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1212 -1267
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-search.service.ts +2 -1
- package/src/core/addon/addon-settings-provider.ts +27 -7
- package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
- package/src/core/addon-pages/addon-pages.service.ts +3 -1
- package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
- package/src/core/agent/agent-registry.service.ts +60 -38
- package/src/core/auth/auth.service.spec.ts +6 -8
- package/src/core/config/config.service.spec.ts +1 -1
- package/src/core/events/event-bus.service.spec.ts +44 -21
- package/src/core/events/event-bus.service.ts +5 -1
- package/src/core/feature/feature.service.spec.ts +4 -1
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
- package/src/core/logging/logging.service.spec.ts +61 -21
- package/src/core/logging/logging.service.ts +19 -5
- package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
- package/src/core/moleculer/cap-call-fn.ts +5 -1
- package/src/core/moleculer/cap-route-authority.ts +18 -6
- package/src/core/moleculer/moleculer.service.ts +145 -29
- package/src/core/network/network-quality.service.spec.ts +7 -1
- package/src/core/notification/notification-wrapper.service.ts +1 -3
- package/src/core/notification/toast-wrapper.service.ts +1 -5
- package/src/core/repl/repl-engine.service.spec.ts +66 -39
- package/src/core/repl/repl-engine.service.ts +11 -12
- package/src/core/storage/storage-location-manager.spec.ts +12 -3
- package/src/core/streaming/stream-probe.service.ts +22 -13
- package/src/core/topology/topology-emitter.service.ts +5 -1
- package/src/launcher.ts +14 -9
- package/src/main.ts +658 -495
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- package/src/core/storage/sql-schema.ts +0 -3
|
@@ -12,12 +12,17 @@ import type { IScopedLogger } from '@camstack/types'
|
|
|
12
12
|
const FRIGATE = process.env.FRIGATE_HOST ?? ''
|
|
13
13
|
|
|
14
14
|
const mockLogger: IScopedLogger = {
|
|
15
|
-
debug: () => {},
|
|
15
|
+
debug: () => {},
|
|
16
|
+
info: () => {},
|
|
17
|
+
warn: console.warn,
|
|
18
|
+
error: console.error,
|
|
16
19
|
child: () => mockLogger,
|
|
17
20
|
}
|
|
18
21
|
const mockLoggingService = { createLogger: () => mockLogger }
|
|
19
22
|
|
|
20
|
-
function wait(ms: number) {
|
|
23
|
+
function wait(ms: number) {
|
|
24
|
+
return new Promise((r) => setTimeout(r, ms))
|
|
25
|
+
}
|
|
21
26
|
|
|
22
27
|
/**
|
|
23
28
|
* Poll-based replacement for the removed `IStreamBroker.onDecodedFrame`
|
|
@@ -49,27 +54,29 @@ async function discoverSubStreams(): Promise<Array<{ name: string; url: string }
|
|
|
49
54
|
return new Promise((resolve, reject) => {
|
|
50
55
|
const req = http.get(`http://${FRIGATE}:5000/api/config`, (res) => {
|
|
51
56
|
let data = ''
|
|
52
|
-
res.on('data', (chunk: Buffer) => {
|
|
57
|
+
res.on('data', (chunk: Buffer) => {
|
|
58
|
+
data += chunk
|
|
59
|
+
})
|
|
53
60
|
res.on('end', () => {
|
|
54
61
|
try {
|
|
55
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment --
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment --
|
|
56
63
|
const config = JSON.parse(data)
|
|
57
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access --
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access --
|
|
58
65
|
const cameras = config.cameras || {}
|
|
59
66
|
const streams: Array<{ name: string; url: string }> = []
|
|
60
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any --
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any --
|
|
61
68
|
for (const [name, cam] of Object.entries(cameras) as [string, any][]) {
|
|
62
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access --
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access --
|
|
63
70
|
if (!cam.enabled && cam.enabled !== undefined) continue
|
|
64
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access --
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access --
|
|
65
72
|
const inputs = cam?.ffmpeg?.inputs || []
|
|
66
73
|
for (const inp of inputs) {
|
|
67
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
68
75
|
if (inp.roles?.includes('detect') && inp.path) {
|
|
69
76
|
let streamName: string
|
|
70
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
71
78
|
if (inp.path.includes('8554/')) {
|
|
72
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
73
80
|
streamName = inp.path.split('8554/').pop()!
|
|
74
81
|
} else {
|
|
75
82
|
streamName = `${name}_sub`
|
|
@@ -79,11 +86,16 @@ async function discoverSubStreams(): Promise<Array<{ name: string; url: string }
|
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
resolve(streams)
|
|
82
|
-
} catch (e) {
|
|
89
|
+
} catch (e) {
|
|
90
|
+
reject(e)
|
|
91
|
+
}
|
|
83
92
|
})
|
|
84
93
|
})
|
|
85
94
|
req.on('error', reject)
|
|
86
|
-
req.setTimeout(5000, () => {
|
|
95
|
+
req.setTimeout(5000, () => {
|
|
96
|
+
req.destroy()
|
|
97
|
+
reject(new Error('Timeout'))
|
|
98
|
+
})
|
|
87
99
|
})
|
|
88
100
|
}
|
|
89
101
|
|
|
@@ -92,7 +104,7 @@ describe.skipIf(!FRIGATE)('Streaming Scale Test (requires Frigate)', () => {
|
|
|
92
104
|
const manager = new StreamBrokerManager([new FfmpegDecoderProvider()], mockLogger)
|
|
93
105
|
|
|
94
106
|
afterAll(async () => {
|
|
95
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
96
108
|
await manager.destroyAll()
|
|
97
109
|
// Wait for ffmpeg processes to exit
|
|
98
110
|
await wait(2000)
|
|
@@ -106,11 +118,13 @@ describe.skipIf(!FRIGATE)('Streaming Scale Test (requires Frigate)', () => {
|
|
|
106
118
|
const workingStreams: typeof realStreams = []
|
|
107
119
|
for (const s of realStreams.slice(0, 5)) {
|
|
108
120
|
try {
|
|
109
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
110
122
|
const broker = await manager.createBroker(`probe-${s.name}`, {
|
|
111
|
-
type: 'rtsp',
|
|
123
|
+
type: 'rtsp',
|
|
124
|
+
url: s.url,
|
|
125
|
+
videoCodec: 'h264',
|
|
112
126
|
})
|
|
113
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
127
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
114
128
|
await (broker as any).start({ type: 'rtsp', url: s.url, videoCodec: 'h264' })
|
|
115
129
|
const ok = await new Promise<boolean>((resolve) => {
|
|
116
130
|
const timeout = setTimeout(() => resolve(false), 5000)
|
|
@@ -119,17 +133,25 @@ describe.skipIf(!FRIGATE)('Streaming Scale Test (requires Frigate)', () => {
|
|
|
119
133
|
clearTimeout(timeout)
|
|
120
134
|
void unsub?.()
|
|
121
135
|
resolve(true)
|
|
122
|
-
})
|
|
136
|
+
})
|
|
137
|
+
.then((fn) => {
|
|
138
|
+
unsub = fn
|
|
139
|
+
})
|
|
140
|
+
.catch(() => resolve(false))
|
|
123
141
|
})
|
|
124
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
142
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
125
143
|
await broker.stop()
|
|
126
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
127
145
|
await manager.destroyBroker(`probe-${s.name}`)
|
|
128
146
|
if (ok) workingStreams.push(s)
|
|
129
|
-
} catch {
|
|
147
|
+
} catch {
|
|
148
|
+
/* skip */
|
|
149
|
+
}
|
|
130
150
|
}
|
|
131
151
|
|
|
132
|
-
console.log(
|
|
152
|
+
console.log(
|
|
153
|
+
`\nDiscovered ${realStreams.length} sub-streams, ${workingStreams.length} responding`,
|
|
154
|
+
)
|
|
133
155
|
if (workingStreams.length === 0) {
|
|
134
156
|
console.log(' No working sub-streams, skipping')
|
|
135
157
|
return
|
|
@@ -148,7 +170,6 @@ describe.skipIf(!FRIGATE)('Streaming Scale Test (requires Frigate)', () => {
|
|
|
148
170
|
const scaleLevels = [5, 10, 20, 30]
|
|
149
171
|
|
|
150
172
|
for (const targetCount of scaleLevels) {
|
|
151
|
-
|
|
152
173
|
console.log(`\n=== Scale: ${targetCount} sub-streams @ 2fps ===`)
|
|
153
174
|
|
|
154
175
|
const memBefore = process.memoryUsage()
|
|
@@ -169,11 +190,13 @@ describe.skipIf(!FRIGATE)('Streaming Scale Test (requires Frigate)', () => {
|
|
|
169
190
|
perCamera[s.name] = { frames: 0, totalBytes: 0 }
|
|
170
191
|
|
|
171
192
|
try {
|
|
172
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
193
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
173
194
|
const broker = await manager.createBroker(brokerId, {
|
|
174
|
-
type: 'rtsp',
|
|
195
|
+
type: 'rtsp',
|
|
196
|
+
url: s.url,
|
|
197
|
+
videoCodec: 'h264',
|
|
175
198
|
})
|
|
176
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
199
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
177
200
|
await (broker as any).start({ type: 'rtsp', url: s.url, videoCodec: 'h264' })
|
|
178
201
|
|
|
179
202
|
// Wait for first frame with tight timeout
|
|
@@ -187,7 +210,11 @@ describe.skipIf(!FRIGATE)('Streaming Scale Test (requires Frigate)', () => {
|
|
|
187
210
|
clearTimeout(timeout)
|
|
188
211
|
void unsub?.()
|
|
189
212
|
resolve()
|
|
190
|
-
})
|
|
213
|
+
})
|
|
214
|
+
.then((fn) => {
|
|
215
|
+
unsub = fn
|
|
216
|
+
})
|
|
217
|
+
.catch(() => resolve())
|
|
191
218
|
})
|
|
192
219
|
|
|
193
220
|
// Subscribe for motion analysis
|
|
@@ -204,7 +231,9 @@ describe.skipIf(!FRIGATE)('Streaming Scale Test (requires Frigate)', () => {
|
|
|
204
231
|
await Promise.all(connectPromises)
|
|
205
232
|
|
|
206
233
|
const connectTime = Date.now() - startTime
|
|
207
|
-
console.log(
|
|
234
|
+
console.log(
|
|
235
|
+
` Connected ${targetCount - connectFailures}/${targetCount} in ${(connectTime / 1000).toFixed(1)}s`,
|
|
236
|
+
)
|
|
208
237
|
|
|
209
238
|
// Run for 5 seconds of actual streaming
|
|
210
239
|
const measureStart = Date.now()
|
|
@@ -232,7 +261,10 @@ describe.skipIf(!FRIGATE)('Streaming Scale Test (requires Frigate)', () => {
|
|
|
232
261
|
if (data.frames < 3) starvingCameras++ // less than 3 frames in 5s = starving
|
|
233
262
|
}
|
|
234
263
|
|
|
235
|
-
const cpuPct = (
|
|
264
|
+
const cpuPct = (
|
|
265
|
+
((cpuMeasure.user + cpuMeasure.system) / (measureDuration * 1_000_000)) *
|
|
266
|
+
100
|
|
267
|
+
).toFixed(1)
|
|
236
268
|
const rssMB = (memAfter.rss / 1024 / 1024).toFixed(0)
|
|
237
269
|
const heapMB = (memAfter.heapUsed / 1024 / 1024).toFixed(0)
|
|
238
270
|
const combinedFps = (totalFrames / measureDuration).toFixed(1)
|
|
@@ -249,26 +281,28 @@ describe.skipIf(!FRIGATE)('Streaming Scale Test (requires Frigate)', () => {
|
|
|
249
281
|
|
|
250
282
|
// Per-camera breakdown
|
|
251
283
|
console.log(` ---`)
|
|
252
|
-
const sorted = Object.entries(perCamera).
|
|
284
|
+
const sorted = Object.entries(perCamera).toSorted((a, b) => b[1].frames - a[1].frames)
|
|
253
285
|
for (const [name, data] of sorted) {
|
|
254
286
|
const fps = (data.frames / measureDuration).toFixed(1)
|
|
255
287
|
const avgKB = data.frames > 0 ? (data.totalBytes / data.frames / 1024).toFixed(0) : '-'
|
|
256
288
|
const status = data.frames >= 3 ? '✓' : data.frames > 0 ? '⚠' : '✗'
|
|
257
|
-
console.log(
|
|
289
|
+
console.log(
|
|
290
|
+
` ${status} ${name.padEnd(35)} ${fps.padStart(4)} fps ${avgKB.padStart(5)} KB/f (${data.frames} frames)`,
|
|
291
|
+
)
|
|
258
292
|
}
|
|
259
293
|
|
|
260
294
|
// Cleanup for next scale level
|
|
261
295
|
for (const id of brokerIds) {
|
|
262
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
296
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
263
297
|
const b = manager.getBroker(id)
|
|
264
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
298
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
265
299
|
if (b) await b.stop()
|
|
266
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
300
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access --
|
|
267
301
|
await manager.destroyBroker(id)
|
|
268
302
|
}
|
|
269
303
|
brokerIds.length = 0
|
|
270
304
|
unsubs.length = 0
|
|
271
|
-
Object.keys(perCamera).forEach(k => delete perCamera[k])
|
|
305
|
+
Object.keys(perCamera).forEach((k) => delete perCamera[k])
|
|
272
306
|
|
|
273
307
|
// Expect at least 80% of cameras producing
|
|
274
308
|
expect(activeCameras).toBeGreaterThanOrEqual(Math.floor(targetCount * 0.8))
|
|
@@ -227,7 +227,12 @@ describe('F3 — forked-addon routes + custom actions over UDS (backend wiring)'
|
|
|
227
227
|
target: 'routes',
|
|
228
228
|
})
|
|
229
229
|
expect(rawRoutes).toEqual([
|
|
230
|
-
{
|
|
230
|
+
{
|
|
231
|
+
method: 'GET',
|
|
232
|
+
path: '/:providerId/start',
|
|
233
|
+
access: 'public',
|
|
234
|
+
description: 'Begin OIDC redirect login flow',
|
|
235
|
+
},
|
|
231
236
|
{ method: 'GET', path: '/:providerId/callback', access: 'public' },
|
|
232
237
|
])
|
|
233
238
|
// Coarse reachability gate the route-mount fallback uses: the child is
|
package/src/agent-status-page.ts
CHANGED
|
@@ -43,9 +43,10 @@ export function renderAgentStatusPage(data: AgentStatusData): string {
|
|
|
43
43
|
const memUsedMB = data.memoryTotalMB - data.memoryFreeMB
|
|
44
44
|
const memPercent = Math.round((memUsedMB / data.memoryTotalMB) * 100)
|
|
45
45
|
|
|
46
|
-
const taskTypesList =
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
const taskTypesList =
|
|
47
|
+
data.taskTypes.length > 0
|
|
48
|
+
? data.taskTypes.map((t) => `<li>${escapeHtml(t)}</li>`).join('')
|
|
49
|
+
: '<li class="muted">No task handlers registered</li>'
|
|
49
50
|
|
|
50
51
|
return `<!DOCTYPE html>
|
|
51
52
|
<html lang="en">
|
|
@@ -42,12 +42,18 @@ describe('api.addons.custom', () => {
|
|
|
42
42
|
registry = new CustomActionRegistry()
|
|
43
43
|
registry.registerAddon('benchmark', catalog, async (action, input) => {
|
|
44
44
|
switch (action) {
|
|
45
|
-
case 'ping':
|
|
46
|
-
|
|
47
|
-
case '
|
|
48
|
-
|
|
49
|
-
case '
|
|
50
|
-
|
|
45
|
+
case 'ping':
|
|
46
|
+
return 'pong'
|
|
47
|
+
case 'echo':
|
|
48
|
+
return { msg: (input as { msg: string }).msg }
|
|
49
|
+
case 'adminOnly':
|
|
50
|
+
return 'ok'
|
|
51
|
+
case 'open':
|
|
52
|
+
return 'open'
|
|
53
|
+
case 'badOutput':
|
|
54
|
+
return 'WRONG' // will fail output validation
|
|
55
|
+
default:
|
|
56
|
+
throw new Error(`unknown action ${action}`)
|
|
51
57
|
}
|
|
52
58
|
})
|
|
53
59
|
router = buildRouter(registry)
|
|
@@ -61,7 +67,11 @@ describe('api.addons.custom', () => {
|
|
|
61
67
|
|
|
62
68
|
it('round-trips structured input through the action schema', async () => {
|
|
63
69
|
const caller = router.createCaller(makeCtx('admin'))
|
|
64
|
-
const result = await caller.custom({
|
|
70
|
+
const result = await caller.custom({
|
|
71
|
+
addonId: 'benchmark',
|
|
72
|
+
action: 'echo',
|
|
73
|
+
input: { msg: 'hi' },
|
|
74
|
+
})
|
|
65
75
|
expect(result).toEqual({ msg: 'hi' })
|
|
66
76
|
})
|
|
67
77
|
|
|
@@ -92,7 +102,11 @@ describe('api.addons.custom', () => {
|
|
|
92
102
|
|
|
93
103
|
it('enforces per-action auth: admin-only action allows admin', async () => {
|
|
94
104
|
const caller = router.createCaller(makeCtx('admin'))
|
|
95
|
-
const result = await caller.custom({
|
|
105
|
+
const result = await caller.custom({
|
|
106
|
+
addonId: 'benchmark',
|
|
107
|
+
action: 'adminOnly',
|
|
108
|
+
input: undefined,
|
|
109
|
+
})
|
|
96
110
|
expect(result).toBe('ok')
|
|
97
111
|
})
|
|
98
112
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
// server/backend/src/api/__tests__/capabilities.router.test.ts
|
|
3
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
4
3
|
import { CapabilityRegistry } from '@camstack/kernel'
|
|
@@ -24,7 +23,12 @@ describe('capabilities tRPC router logic', () => {
|
|
|
24
23
|
|
|
25
24
|
it('listCapabilities returns all declared capabilities', () => {
|
|
26
25
|
registry.declareCapability({ name: 'storage', scope: 'system', mode: 'singleton', methods: {} })
|
|
27
|
-
registry.declareCapability({
|
|
26
|
+
registry.declareCapability({
|
|
27
|
+
name: 'log-destination',
|
|
28
|
+
scope: 'system',
|
|
29
|
+
mode: 'collection',
|
|
30
|
+
methods: {},
|
|
31
|
+
})
|
|
28
32
|
|
|
29
33
|
const list = registry.listCapabilities()
|
|
30
34
|
expect(list).toHaveLength(2)
|
|
@@ -33,15 +37,20 @@ describe('capabilities tRPC router logic', () => {
|
|
|
33
37
|
})
|
|
34
38
|
|
|
35
39
|
it('setActiveSingleton throws for unknown capability', async () => {
|
|
36
|
-
await expect(
|
|
37
|
-
|
|
38
|
-
)
|
|
40
|
+
await expect(registry.setActiveSingleton('nonexistent', 'some-addon', true)).rejects.toThrow(
|
|
41
|
+
/[Uu]nknown/,
|
|
42
|
+
)
|
|
39
43
|
})
|
|
40
44
|
|
|
41
45
|
it('setActiveSingleton throws for collection capability', async () => {
|
|
42
|
-
registry.declareCapability({
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
registry.declareCapability({
|
|
47
|
+
name: 'log-destination',
|
|
48
|
+
scope: 'system',
|
|
49
|
+
mode: 'collection',
|
|
50
|
+
methods: {},
|
|
51
|
+
})
|
|
52
|
+
await expect(registry.setActiveSingleton('log-destination', 'winston', true)).rejects.toThrow(
|
|
53
|
+
/singleton/,
|
|
54
|
+
)
|
|
46
55
|
})
|
|
47
56
|
})
|
package/src/api/addon-upload.ts
CHANGED
|
@@ -72,7 +72,7 @@ interface AgentDeployResponse {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function isTarball(filename: string): boolean {
|
|
75
|
-
return TARBALL_EXTENSIONS.some(ext => filename.endsWith(ext))
|
|
75
|
+
return TARBALL_EXTENSIONS.some((ext) => filename.endsWith(ext))
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
@@ -162,7 +162,9 @@ export async function registerAddonUploadRoute(
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
if (!authOk) {
|
|
165
|
-
return reply
|
|
165
|
+
return reply
|
|
166
|
+
.status(403)
|
|
167
|
+
.send({ error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}` })
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
const data = await request.file()
|
|
@@ -178,12 +180,20 @@ export async function registerAddonUploadRoute(
|
|
|
178
180
|
// because the multipart plugin types it as `unknown`.
|
|
179
181
|
const nodeIdField = data.fields['nodeId']
|
|
180
182
|
const addonIdField = data.fields['addonId']
|
|
181
|
-
const nodeId =
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
183
|
+
const nodeId =
|
|
184
|
+
typeof nodeIdField === 'object' &&
|
|
185
|
+
nodeIdField !== null &&
|
|
186
|
+
'value' in nodeIdField &&
|
|
187
|
+
typeof nodeIdField.value === 'string'
|
|
188
|
+
? nodeIdField.value
|
|
189
|
+
: null
|
|
190
|
+
const addonIdHint =
|
|
191
|
+
typeof addonIdField === 'object' &&
|
|
192
|
+
addonIdField !== null &&
|
|
193
|
+
'value' in addonIdField &&
|
|
194
|
+
typeof addonIdField.value === 'string'
|
|
195
|
+
? addonIdField.value
|
|
196
|
+
: null
|
|
187
197
|
|
|
188
198
|
const buffer = await data.toBuffer()
|
|
189
199
|
|
|
@@ -192,7 +202,9 @@ export async function registerAddonUploadRoute(
|
|
|
192
202
|
// agent path would otherwise fail mid-extraction with no clean rollback.
|
|
193
203
|
const manifest = validateTarball(buffer, data.filename)
|
|
194
204
|
if (!manifest) {
|
|
195
|
-
return reply.status(400).send({
|
|
205
|
+
return reply.status(400).send({
|
|
206
|
+
error: 'Tarball missing or malformed package/package.json (name + version required)',
|
|
207
|
+
})
|
|
196
208
|
}
|
|
197
209
|
|
|
198
210
|
// Branch by deployment target. `nodeId === 'hub'` (or absent) installs on
|
|
@@ -200,7 +212,16 @@ export async function registerAddonUploadRoute(
|
|
|
200
212
|
// the package's addons (i.e. anything not marked hub-only). Any other
|
|
201
213
|
// explicit `nodeId` value routes only to that agent via `$agent.deploy`.
|
|
202
214
|
if (!nodeId || nodeId === 'hub') {
|
|
203
|
-
return installToHub(
|
|
215
|
+
return installToHub(
|
|
216
|
+
reply,
|
|
217
|
+
addonBridge,
|
|
218
|
+
addonRegistry,
|
|
219
|
+
addonPackageService,
|
|
220
|
+
moleculer,
|
|
221
|
+
logger,
|
|
222
|
+
data.filename,
|
|
223
|
+
buffer,
|
|
224
|
+
)
|
|
204
225
|
}
|
|
205
226
|
const agentAddonId = addonIdHint ?? manifest.name
|
|
206
227
|
return deployToAgent(reply, moleculer, nodeId, agentAddonId, buffer)
|
|
@@ -231,7 +252,8 @@ function packageHasAgentDeployable(addonsDir: string, packageName: string): bool
|
|
|
231
252
|
const pkgPath = path.join(addonsDir, packageName, 'package.json')
|
|
232
253
|
const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
|
233
254
|
if (parsed === null || typeof parsed !== 'object') return false
|
|
234
|
-
const camstack = (parsed as { camstack?: { addons?: readonly CamstackAddonDeclLike[] } })
|
|
255
|
+
const camstack = (parsed as { camstack?: { addons?: readonly CamstackAddonDeclLike[] } })
|
|
256
|
+
.camstack
|
|
235
257
|
const addons = camstack?.addons ?? []
|
|
236
258
|
return addons.some((a) => {
|
|
237
259
|
const placement = a.execution?.placement ?? 'hub-only'
|
|
@@ -250,7 +272,11 @@ interface AgentDeployResult {
|
|
|
250
272
|
}
|
|
251
273
|
|
|
252
274
|
interface MoleculerBrokerLike {
|
|
253
|
-
call(
|
|
275
|
+
call(
|
|
276
|
+
action: string,
|
|
277
|
+
params: Record<string, unknown>,
|
|
278
|
+
options: { nodeID: string; timeout: number },
|
|
279
|
+
): Promise<unknown>
|
|
254
280
|
registry?: {
|
|
255
281
|
getNodeList?: (opts: { onlyAvailable: boolean }) => readonly { id: string }[]
|
|
256
282
|
}
|
|
@@ -306,7 +332,7 @@ async function propagateToAgents(
|
|
|
306
332
|
reloadRaw !== null &&
|
|
307
333
|
typeof reloadRaw === 'object' &&
|
|
308
334
|
'loaded' in (reloadRaw as Record<string, unknown>)
|
|
309
|
-
? (
|
|
335
|
+
? (reloadRaw as { loaded: readonly string[] }).loaded
|
|
310
336
|
: []
|
|
311
337
|
results.push({ nodeId, success: true, loaded: reloaded })
|
|
312
338
|
} catch (err: unknown) {
|
|
@@ -418,7 +444,10 @@ async function installToHub(
|
|
|
418
444
|
for (const id of preInstallAddonIds) {
|
|
419
445
|
void addonRegistry.restartAddon(id).then((r) => {
|
|
420
446
|
if (!r.success) {
|
|
421
|
-
logger.warn('background restart failed', {
|
|
447
|
+
logger.warn('background restart failed', {
|
|
448
|
+
tags: { addonId: id },
|
|
449
|
+
meta: { error: r.error ?? 'unknown' },
|
|
450
|
+
})
|
|
422
451
|
} else {
|
|
423
452
|
logger.info('background restart OK', { tags: { addonId: id } })
|
|
424
453
|
}
|
|
@@ -426,7 +455,9 @@ async function installToHub(
|
|
|
426
455
|
}
|
|
427
456
|
if (propagatable) {
|
|
428
457
|
void propagateToAgents(moleculer, logger, result.name, buffer).then((agentResults) => {
|
|
429
|
-
logger.info('propagation done', {
|
|
458
|
+
logger.info('propagation done', {
|
|
459
|
+
meta: { packageName: result.name, agents: agentResults },
|
|
460
|
+
})
|
|
430
461
|
})
|
|
431
462
|
}
|
|
432
463
|
|
|
@@ -42,11 +42,13 @@ export interface AddonsCustomDeps {
|
|
|
42
42
|
export function createAddonsCustomProcedures(deps: AddonsCustomDeps) {
|
|
43
43
|
return {
|
|
44
44
|
custom: protectedProcedure
|
|
45
|
-
.input(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
.input(
|
|
46
|
+
z.object({
|
|
47
|
+
addonId: z.string().min(1),
|
|
48
|
+
action: z.string().min(1),
|
|
49
|
+
input: z.unknown(),
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
50
52
|
.output(z.unknown())
|
|
51
53
|
.mutation(async ({ input, ctx }) => {
|
|
52
54
|
const registry = deps.getCustomActionRegistry()
|
|
@@ -66,7 +68,6 @@ export function createAddonsCustomProcedures(deps: AddonsCustomDeps) {
|
|
|
66
68
|
// Validate input against the action's declared Zod schema.
|
|
67
69
|
const parsedInput = entry.spec.input.parse(input.input)
|
|
68
70
|
|
|
69
|
-
|
|
70
71
|
// Dispatch through the addon handler.
|
|
71
72
|
const result = await entry.handler(parsedInput)
|
|
72
73
|
|
package/src/api/auth-whoami.ts
CHANGED
|
@@ -78,7 +78,9 @@ export async function registerAuthWhoamiRoute(
|
|
|
78
78
|
}
|
|
79
79
|
const record = await userMgmt.validateScopedToken({ token })
|
|
80
80
|
if (!record) {
|
|
81
|
-
return reply
|
|
81
|
+
return reply
|
|
82
|
+
.status(401)
|
|
83
|
+
.send({ ok: false, error: 'Token not recognised (revoked, expired, or never issued)' })
|
|
82
84
|
}
|
|
83
85
|
const ok: WhoamiOk = {
|
|
84
86
|
ok: true,
|
|
@@ -45,7 +45,9 @@ export function createBridgeAddonsRouter(
|
|
|
45
45
|
}
|
|
46
46
|
await installer.install(input.packageName, input.version)
|
|
47
47
|
await bridge.reloadPackages()
|
|
48
|
-
const result = addonRegistry
|
|
48
|
+
const result = addonRegistry
|
|
49
|
+
? await addonRegistry.loadNewAddons()
|
|
50
|
+
: { loaded: [], failed: [] }
|
|
49
51
|
toastService?.broadcast({
|
|
50
52
|
title: 'Addon Installed',
|
|
51
53
|
message: `${input.packageName} installed successfully${result.loaded.length ? ` (${result.loaded.join(', ')})` : ''}`,
|