@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
|
@@ -60,92 +60,109 @@ export function createCapabilitiesRouter(
|
|
|
60
60
|
|
|
61
61
|
// ─── Get current preference for a capability ──────────────────────
|
|
62
62
|
|
|
63
|
-
getPreference: adminProcedure
|
|
64
|
-
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
const mode = registry.getMode(input.capability)
|
|
68
|
-
if (!mode) return null
|
|
69
|
-
|
|
70
|
-
if (mode === 'singleton') {
|
|
71
|
-
const addonId = configService.get<string>(`capabilities.singleton.${input.capability}`)
|
|
72
|
-
return {
|
|
73
|
-
capability: input.capability,
|
|
74
|
-
mode: mode as 'singleton',
|
|
75
|
-
preference: addonId ? { addonId } : null,
|
|
76
|
-
}
|
|
77
|
-
}
|
|
63
|
+
getPreference: adminProcedure.input(z.object({ capability: z.string() })).query(({ input }) => {
|
|
64
|
+
const registry = addonRegistry.getCapabilityRegistry()
|
|
65
|
+
const mode = registry.getMode(input.capability)
|
|
66
|
+
if (!mode) return null
|
|
78
67
|
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
let disabled: string[] = []
|
|
82
|
-
if (raw) {
|
|
83
|
-
try {
|
|
84
|
-
const parsed = JSON.parse(raw) as { disabled?: string[] }
|
|
85
|
-
disabled = Array.isArray(parsed.disabled) ? parsed.disabled : []
|
|
86
|
-
} catch { /* ignore malformed */ }
|
|
87
|
-
}
|
|
68
|
+
if (mode === 'singleton') {
|
|
69
|
+
const addonId = configService.get<string>(`capabilities.singleton.${input.capability}`)
|
|
88
70
|
return {
|
|
89
71
|
capability: input.capability,
|
|
90
|
-
mode: mode as '
|
|
91
|
-
preference: {
|
|
72
|
+
mode: mode as 'singleton',
|
|
73
|
+
preference: addonId ? { addonId } : null,
|
|
92
74
|
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ─── Set preference (singleton switch or collection toggle) ───────
|
|
96
|
-
|
|
97
|
-
setPreference: adminProcedure
|
|
98
|
-
.input(setPreferenceInput)
|
|
99
|
-
.mutation(async ({ input }) => {
|
|
100
|
-
const registry = addonRegistry.getCapabilityRegistry()
|
|
101
|
-
|
|
102
|
-
if (input.mode === 'singleton') {
|
|
103
|
-
const { capability, addonId } = input
|
|
104
|
-
const caps = registry.listCapabilities()
|
|
105
|
-
const cap = caps.find((c) => c.name === capability)
|
|
106
|
-
if (!cap) throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` })
|
|
107
|
-
if (cap.mode !== 'singleton') throw new TRPCError({ code: 'BAD_REQUEST', message: `"${capability}" is not a singleton` })
|
|
108
|
-
if (!cap.providers.includes(addonId)) {
|
|
109
|
-
throw new TRPCError({ code: 'BAD_REQUEST', message: `Provider "${addonId}" is not registered for "${capability}"` })
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const requiresRestart = isInfraCapability(capability)
|
|
75
|
+
}
|
|
113
76
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
77
|
+
// collection
|
|
78
|
+
const raw = configService.get<string>(collectionPreferenceKey(input.capability))
|
|
79
|
+
let disabled: string[] = []
|
|
80
|
+
if (raw) {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(raw) as { disabled?: string[] }
|
|
83
|
+
disabled = Array.isArray(parsed.disabled) ? parsed.disabled : []
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore malformed */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
capability: input.capability,
|
|
90
|
+
mode: mode as 'collection',
|
|
91
|
+
preference: { disabled },
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
118
94
|
|
|
119
|
-
|
|
120
|
-
configService.set(`capabilities.singleton.${capability}`, addonId)
|
|
121
|
-
logger.info('Singleton preference set', { tags: { addonId }, meta: { capability, requiresRestart } })
|
|
95
|
+
// ─── Set preference (singleton switch or collection toggle) ───────
|
|
122
96
|
|
|
123
|
-
|
|
124
|
-
|
|
97
|
+
setPreference: adminProcedure.input(setPreferenceInput).mutation(async ({ input }) => {
|
|
98
|
+
const registry = addonRegistry.getCapabilityRegistry()
|
|
125
99
|
|
|
126
|
-
|
|
127
|
-
const { capability, addonId
|
|
100
|
+
if (input.mode === 'singleton') {
|
|
101
|
+
const { capability, addonId } = input
|
|
128
102
|
const caps = registry.listCapabilities()
|
|
129
103
|
const cap = caps.find((c) => c.name === capability)
|
|
130
|
-
if (!cap)
|
|
131
|
-
|
|
104
|
+
if (!cap)
|
|
105
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` })
|
|
106
|
+
if (cap.mode !== 'singleton')
|
|
107
|
+
throw new TRPCError({
|
|
108
|
+
code: 'BAD_REQUEST',
|
|
109
|
+
message: `"${capability}" is not a singleton`,
|
|
110
|
+
})
|
|
132
111
|
if (!cap.providers.includes(addonId)) {
|
|
133
|
-
throw new TRPCError({
|
|
112
|
+
throw new TRPCError({
|
|
113
|
+
code: 'BAD_REQUEST',
|
|
114
|
+
message: `Provider "${addonId}" is not registered for "${capability}"`,
|
|
115
|
+
})
|
|
134
116
|
}
|
|
135
117
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
118
|
+
const requiresRestart = isInfraCapability(capability)
|
|
119
|
+
|
|
120
|
+
if (!requiresRestart) {
|
|
121
|
+
// Hot-swap at runtime
|
|
122
|
+
await registry.setActiveSingleton(capability, addonId)
|
|
140
123
|
}
|
|
141
124
|
|
|
142
|
-
// Persist
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
125
|
+
// Persist preference
|
|
126
|
+
configService.set(`capabilities.singleton.${capability}`, addonId)
|
|
127
|
+
logger.info('Singleton preference set', {
|
|
128
|
+
tags: { addonId },
|
|
129
|
+
meta: { capability, requiresRestart },
|
|
130
|
+
})
|
|
146
131
|
|
|
147
|
-
return { success: true, requiresRestart
|
|
148
|
-
}
|
|
132
|
+
return { success: true, requiresRestart }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// collection toggle
|
|
136
|
+
const { capability, addonId, enabled } = input
|
|
137
|
+
const caps = registry.listCapabilities()
|
|
138
|
+
const cap = caps.find((c) => c.name === capability)
|
|
139
|
+
if (!cap)
|
|
140
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` })
|
|
141
|
+
if (cap.mode !== 'collection')
|
|
142
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: `"${capability}" is not a collection` })
|
|
143
|
+
if (!cap.providers.includes(addonId)) {
|
|
144
|
+
throw new TRPCError({
|
|
145
|
+
code: 'BAD_REQUEST',
|
|
146
|
+
message: `Provider "${addonId}" is not registered for "${capability}"`,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (enabled) {
|
|
151
|
+
registry.enableCollectionProvider(capability, addonId)
|
|
152
|
+
} else {
|
|
153
|
+
registry.disableCollectionProvider(capability, addonId)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Persist disabled list via the shared canonical writer.
|
|
157
|
+
const updatedCap = registry.listCapabilities().find((c) => c.name === capability)
|
|
158
|
+
persistCollectionDisabled(configService, capability, updatedCap?.disabledProviders ?? [])
|
|
159
|
+
logger.info('Collection provider toggled', {
|
|
160
|
+
tags: { addonId },
|
|
161
|
+
meta: { capability, enabled },
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
return { success: true, requiresRestart: false }
|
|
165
|
+
}),
|
|
149
166
|
|
|
150
167
|
// ─── Reset preference to default ──────────────────────────────────
|
|
151
168
|
|
|
@@ -154,11 +171,17 @@ export function createCapabilitiesRouter(
|
|
|
154
171
|
.mutation(({ input }) => {
|
|
155
172
|
const registry = addonRegistry.getCapabilityRegistry()
|
|
156
173
|
const mode = registry.getMode(input.capability)
|
|
157
|
-
if (!mode)
|
|
174
|
+
if (!mode)
|
|
175
|
+
throw new TRPCError({
|
|
176
|
+
code: 'NOT_FOUND',
|
|
177
|
+
message: `Unknown capability: ${input.capability}`,
|
|
178
|
+
})
|
|
158
179
|
|
|
159
180
|
if (mode === 'singleton') {
|
|
160
181
|
configService.set(`capabilities.singleton.${input.capability}`, null)
|
|
161
|
-
logger.info('Singleton preference reset (takes effect on restart)', {
|
|
182
|
+
logger.info('Singleton preference reset (takes effect on restart)', {
|
|
183
|
+
meta: { capability: input.capability },
|
|
184
|
+
})
|
|
162
185
|
return { success: true, requiresRestart: true }
|
|
163
186
|
}
|
|
164
187
|
|
|
@@ -171,7 +194,9 @@ export function createCapabilitiesRouter(
|
|
|
171
194
|
}
|
|
172
195
|
}
|
|
173
196
|
configService.set(`capabilities.collection.${input.capability}`, null)
|
|
174
|
-
logger.info('Collection preference reset (all providers re-enabled)', {
|
|
197
|
+
logger.info('Collection preference reset (all providers re-enabled)', {
|
|
198
|
+
meta: { capability: input.capability },
|
|
199
|
+
})
|
|
175
200
|
return { success: true, requiresRestart: false }
|
|
176
201
|
}),
|
|
177
202
|
|
|
@@ -187,7 +212,10 @@ export function createCapabilitiesRouter(
|
|
|
187
212
|
.mutation(({ input }) => {
|
|
188
213
|
const registry = addonRegistry.getCapabilityRegistry()
|
|
189
214
|
registry.setDeviceOverride(input.deviceId, input.capability, input.addonId)
|
|
190
|
-
logger.info('Device capability override set', {
|
|
215
|
+
logger.info('Device capability override set', {
|
|
216
|
+
tags: { deviceId: Number(input.deviceId), addonId: input.addonId },
|
|
217
|
+
meta: { capability: input.capability },
|
|
218
|
+
})
|
|
191
219
|
}),
|
|
192
220
|
|
|
193
221
|
clearDeviceCapability: adminProcedure
|
|
@@ -195,7 +223,10 @@ export function createCapabilitiesRouter(
|
|
|
195
223
|
.mutation(({ input }) => {
|
|
196
224
|
const registry = addonRegistry.getCapabilityRegistry()
|
|
197
225
|
registry.clearDeviceOverride(input.deviceId, input.capability)
|
|
198
|
-
logger.info('Device capability override cleared', {
|
|
226
|
+
logger.info('Device capability override cleared', {
|
|
227
|
+
tags: { deviceId: Number(input.deviceId) },
|
|
228
|
+
meta: { capability: input.capability },
|
|
229
|
+
})
|
|
199
230
|
}),
|
|
200
231
|
|
|
201
232
|
getDeviceCapabilities: adminProcedure
|
|
@@ -208,11 +239,16 @@ export function createCapabilitiesRouter(
|
|
|
208
239
|
}),
|
|
209
240
|
|
|
210
241
|
setDeviceCollectionFilter: adminProcedure
|
|
211
|
-
.input(
|
|
242
|
+
.input(
|
|
243
|
+
z.object({ deviceId: z.string(), capability: z.string(), addonIds: z.array(z.string()) }),
|
|
244
|
+
)
|
|
212
245
|
.mutation(({ input }) => {
|
|
213
246
|
const registry = addonRegistry.getCapabilityRegistry()
|
|
214
247
|
registry.setDeviceCollectionFilter(input.deviceId, input.capability, input.addonIds)
|
|
215
|
-
logger.info('Device collection filter set', {
|
|
248
|
+
logger.info('Device collection filter set', {
|
|
249
|
+
tags: { deviceId: Number(input.deviceId) },
|
|
250
|
+
meta: { capability: input.capability, addonIds: input.addonIds },
|
|
251
|
+
})
|
|
216
252
|
}),
|
|
217
253
|
|
|
218
254
|
clearDeviceCollectionFilter: adminProcedure
|
|
@@ -220,7 +256,10 @@ export function createCapabilitiesRouter(
|
|
|
220
256
|
.mutation(({ input }) => {
|
|
221
257
|
const registry = addonRegistry.getCapabilityRegistry()
|
|
222
258
|
registry.clearDeviceCollectionFilter(input.deviceId, input.capability)
|
|
223
|
-
logger.info('Device collection filter cleared', {
|
|
259
|
+
logger.info('Device collection filter cleared', {
|
|
260
|
+
tags: { deviceId: Number(input.deviceId) },
|
|
261
|
+
meta: { capability: input.capability },
|
|
262
|
+
})
|
|
224
263
|
}),
|
|
225
264
|
})
|
|
226
265
|
}
|
|
@@ -57,22 +57,35 @@ function makeUserMgmt(overrides: Partial<MockUserManagement> = {}): MockUserMana
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function makeConfig(overrides: Record<string, unknown> = {}): {
|
|
60
|
+
function makeConfig(overrides: Record<string, unknown> = {}): {
|
|
61
|
+
get<T>(p: string): T
|
|
62
|
+
update(s: string, d: Record<string, unknown>): void
|
|
63
|
+
} {
|
|
61
64
|
const store: Record<string, unknown> = { 'auth.jwtSecret': 'unit-test-secret', ...overrides }
|
|
62
65
|
return {
|
|
63
|
-
get<T>(path: string): T {
|
|
64
|
-
|
|
66
|
+
get<T>(path: string): T {
|
|
67
|
+
return store[path] as T
|
|
68
|
+
},
|
|
69
|
+
update(_section: string, _data: Record<string, unknown>): void {
|
|
70
|
+
/* no-op */
|
|
71
|
+
},
|
|
65
72
|
}
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
function makeRegistry(userMgmt: MockUserManagement): {
|
|
75
|
+
function makeRegistry(userMgmt: MockUserManagement): {
|
|
76
|
+
getSingleton: (name: string) => unknown | null
|
|
77
|
+
} {
|
|
69
78
|
return {
|
|
70
79
|
getSingleton: (name: string) => (name === 'user-management' ? userMgmt : null),
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
/** Invoke a tRPC procedure directly via the router's internal API. */
|
|
75
|
-
async function callProc(
|
|
84
|
+
async function callProc(
|
|
85
|
+
router: ReturnType<typeof createAuthRouter>,
|
|
86
|
+
name: 'login' | 'loginVerifyTotp',
|
|
87
|
+
input: unknown,
|
|
88
|
+
): Promise<unknown> {
|
|
76
89
|
const caller = router.createCaller({} as never)
|
|
77
90
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
91
|
const fn = (caller as any)[name]
|
|
@@ -97,7 +110,7 @@ describe('auth.router — TOTP gate', () => {
|
|
|
97
110
|
userMgmt.getTotpStatus.mockResolvedValueOnce({ enabled: false, confirmedAt: null })
|
|
98
111
|
|
|
99
112
|
const router = createAuthRouter(auth, registry as never)
|
|
100
|
-
const result = await callProc(router, 'login', { username: 'alice', password: 'pw' }) as {
|
|
113
|
+
const result = (await callProc(router, 'login', { username: 'alice', password: 'pw' })) as {
|
|
101
114
|
token: string
|
|
102
115
|
user: { id: string; username: string; isAdmin: boolean }
|
|
103
116
|
requiresTotp?: boolean
|
|
@@ -118,7 +131,7 @@ describe('auth.router — TOTP gate', () => {
|
|
|
118
131
|
userMgmt.getTotpStatus.mockResolvedValueOnce({ enabled: true, confirmedAt: Date.now() })
|
|
119
132
|
|
|
120
133
|
const router = createAuthRouter(auth, registry as never)
|
|
121
|
-
const result = await callProc(router, 'login', { username: 'alice', password: 'pw' }) as {
|
|
134
|
+
const result = (await callProc(router, 'login', { username: 'alice', password: 'pw' })) as {
|
|
122
135
|
token: string
|
|
123
136
|
user: { id: string; username: string; isAdmin: boolean }
|
|
124
137
|
requiresTotp?: boolean
|
|
@@ -137,13 +150,17 @@ describe('auth.router — TOTP gate', () => {
|
|
|
137
150
|
it('rejects invalid credentials', async () => {
|
|
138
151
|
userMgmt.validateCredentials.mockResolvedValueOnce(null)
|
|
139
152
|
const router = createAuthRouter(auth, registry as never)
|
|
140
|
-
await expect(
|
|
153
|
+
await expect(
|
|
154
|
+
callProc(router, 'login', { username: 'alice', password: 'wrong' }),
|
|
155
|
+
).rejects.toThrow('Invalid credentials')
|
|
141
156
|
})
|
|
142
157
|
|
|
143
158
|
it('throws when user-management cap is unregistered (boot failure)', async () => {
|
|
144
159
|
const emptyRegistry = { getSingleton: () => null } as never
|
|
145
160
|
const router = createAuthRouter(auth, emptyRegistry)
|
|
146
|
-
await expect(
|
|
161
|
+
await expect(
|
|
162
|
+
callProc(router, 'login', { username: 'alice', password: 'pw' }),
|
|
163
|
+
).rejects.toThrow(/user-management.*capability not registered/)
|
|
147
164
|
})
|
|
148
165
|
|
|
149
166
|
it('falls through to session-token branch when getTotpStatus method is absent (older user-mgmt builds)', async () => {
|
|
@@ -155,7 +172,9 @@ describe('auth.router — TOTP gate', () => {
|
|
|
155
172
|
listUsers: vi.fn(),
|
|
156
173
|
}
|
|
157
174
|
const router = createAuthRouter(auth, makeRegistry(legacyMgmt as never) as never)
|
|
158
|
-
const result = await callProc(router, 'login', { username: 'alice', password: 'pw' }) as {
|
|
175
|
+
const result = (await callProc(router, 'login', { username: 'alice', password: 'pw' })) as {
|
|
176
|
+
requiresTotp?: boolean
|
|
177
|
+
}
|
|
159
178
|
expect(result.requiresTotp).toBe(false)
|
|
160
179
|
})
|
|
161
180
|
})
|
|
@@ -173,7 +192,10 @@ describe('auth.router — TOTP gate', () => {
|
|
|
173
192
|
})
|
|
174
193
|
|
|
175
194
|
const router = createAuthRouter(auth, registry as never)
|
|
176
|
-
const result = await callProc(router, 'loginVerifyTotp', {
|
|
195
|
+
const result = (await callProc(router, 'loginVerifyTotp', {
|
|
196
|
+
challengeToken,
|
|
197
|
+
code: '123456',
|
|
198
|
+
})) as {
|
|
177
199
|
token: string
|
|
178
200
|
user: { id: string; username: string; isAdmin: boolean }
|
|
179
201
|
requiresTotp?: boolean
|
|
@@ -195,7 +217,11 @@ describe('auth.router — TOTP gate', () => {
|
|
|
195
217
|
|
|
196
218
|
it('rejects a forged challenge token (signed with wrong secret)', async () => {
|
|
197
219
|
const other = new AuthService(makeConfig({ 'auth.jwtSecret': 'attacker-secret' }) as never)
|
|
198
|
-
const forged = other.signTotpChallengeToken({
|
|
220
|
+
const forged = other.signTotpChallengeToken({
|
|
221
|
+
userId: 'u-1',
|
|
222
|
+
username: 'alice',
|
|
223
|
+
isAdmin: true,
|
|
224
|
+
})
|
|
199
225
|
const router = createAuthRouter(auth, registry as never)
|
|
200
226
|
await expect(
|
|
201
227
|
callProc(router, 'loginVerifyTotp', { challengeToken: forged, code: '000000' }),
|
|
@@ -218,7 +244,11 @@ describe('auth.router — TOTP gate', () => {
|
|
|
218
244
|
|
|
219
245
|
it('rejects a wrong 6-digit code', async () => {
|
|
220
246
|
userMgmt.verifyTotp.mockResolvedValueOnce({ valid: false })
|
|
221
|
-
const challengeToken = auth.signTotpChallengeToken({
|
|
247
|
+
const challengeToken = auth.signTotpChallengeToken({
|
|
248
|
+
userId: 'u-1',
|
|
249
|
+
username: 'alice',
|
|
250
|
+
isAdmin: false,
|
|
251
|
+
})
|
|
222
252
|
const router = createAuthRouter(auth, registry as never)
|
|
223
253
|
await expect(
|
|
224
254
|
callProc(router, 'loginVerifyTotp', { challengeToken, code: '000000' }),
|
|
@@ -229,7 +259,11 @@ describe('auth.router — TOTP gate', () => {
|
|
|
229
259
|
userMgmt.verifyTotp.mockResolvedValueOnce({ valid: true })
|
|
230
260
|
// The fresh listUsers() call returns nothing — user was deleted.
|
|
231
261
|
userMgmt.listUsers.mockResolvedValueOnce([])
|
|
232
|
-
const challengeToken = auth.signTotpChallengeToken({
|
|
262
|
+
const challengeToken = auth.signTotpChallengeToken({
|
|
263
|
+
userId: 'ghost',
|
|
264
|
+
username: 'alice',
|
|
265
|
+
isAdmin: false,
|
|
266
|
+
})
|
|
233
267
|
const router = createAuthRouter(auth, registry as never)
|
|
234
268
|
await expect(
|
|
235
269
|
callProc(router, 'loginVerifyTotp', { challengeToken, code: '123456' }),
|
|
@@ -238,14 +272,21 @@ describe('auth.router — TOTP gate', () => {
|
|
|
238
272
|
|
|
239
273
|
it('uses the FRESHLY fetched user record (scope changes pick up immediately)', async () => {
|
|
240
274
|
// Issue challenge for u-1 with empty scopes (the in-token snapshot).
|
|
241
|
-
const challengeToken = auth.signTotpChallengeToken({
|
|
275
|
+
const challengeToken = auth.signTotpChallengeToken({
|
|
276
|
+
userId: 'u-1',
|
|
277
|
+
username: 'alice',
|
|
278
|
+
isAdmin: false,
|
|
279
|
+
})
|
|
242
280
|
// Between the two legs, an admin granted u-1 a new scope.
|
|
243
281
|
userMgmt.verifyTotp.mockResolvedValueOnce({ valid: true })
|
|
244
282
|
userMgmt.listUsers.mockResolvedValueOnce([
|
|
245
283
|
makeUser({ scopes: [{ type: 'category', target: 'addon', access: ['view'] }] }),
|
|
246
284
|
])
|
|
247
285
|
const router = createAuthRouter(auth, registry as never)
|
|
248
|
-
const result = await callProc(router, 'loginVerifyTotp', {
|
|
286
|
+
const result = (await callProc(router, 'loginVerifyTotp', {
|
|
287
|
+
challengeToken,
|
|
288
|
+
code: '123456',
|
|
289
|
+
})) as { token: string }
|
|
249
290
|
const decoded = auth.verifyToken(result.token)
|
|
250
291
|
// The fresh scope is in the minted session JWT, not the snapshot
|
|
251
292
|
// from the (potentially stale) password leg.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { INTEGRATION_CAP_MARKERS } from '../cap-providers.js'
|
|
3
|
+
|
|
4
|
+
describe('integration cap markers', () => {
|
|
5
|
+
it('recognises device-adoption (not the old ha-discovery)', () => {
|
|
6
|
+
expect(INTEGRATION_CAP_MARKERS.has('device-adoption')).toBe(true)
|
|
7
|
+
expect(INTEGRATION_CAP_MARKERS.has('ha-discovery')).toBe(false)
|
|
8
|
+
expect(INTEGRATION_CAP_MARKERS.has('device-provider')).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -103,7 +103,10 @@ export function createAddonSettingsRouter(cfg: ConfigService) {
|
|
|
103
103
|
.output(SuccessSchema)
|
|
104
104
|
.mutation(({ input }) => {
|
|
105
105
|
const current = cfg.getAddonDevice(input.addonId, input.deviceId)
|
|
106
|
-
cfg.setAddonDevice(input.addonId, input.deviceId, {
|
|
106
|
+
cfg.setAddonDevice(input.addonId, input.deviceId, {
|
|
107
|
+
...current,
|
|
108
|
+
[input.field]: input.value,
|
|
109
|
+
})
|
|
107
110
|
return { success: true as const }
|
|
108
111
|
}),
|
|
109
112
|
|
|
@@ -9,51 +9,44 @@
|
|
|
9
9
|
import { z } from 'zod'
|
|
10
10
|
import type { AgentRegistryService } from '../../core/agent/agent-registry.service.js'
|
|
11
11
|
import type { MoleculerService } from '../../core/moleculer/moleculer.service.js'
|
|
12
|
-
import {
|
|
13
|
-
trpcRouter, adminProcedure,
|
|
14
|
-
} from '../trpc/trpc.middleware.js'
|
|
12
|
+
import { trpcRouter, adminProcedure } from '../trpc/trpc.middleware.js'
|
|
15
13
|
|
|
16
14
|
const AgentRoleSchema = z.enum(['decoder', 'transcoder', 'detector', 'recorder'])
|
|
17
15
|
|
|
18
|
-
export function createAgentsRouter(
|
|
19
|
-
ar: AgentRegistryService,
|
|
20
|
-
moleculer: MoleculerService,
|
|
21
|
-
) {
|
|
16
|
+
export function createAgentsRouter(ar: AgentRegistryService, moleculer: MoleculerService) {
|
|
22
17
|
return trpcRouter({
|
|
23
18
|
// ── Node listing (replaces listAgents / listConnected) ────────────
|
|
24
|
-
listNodes: adminProcedure
|
|
25
|
-
.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
...a,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}))
|
|
42
|
-
}),
|
|
19
|
+
listNodes: adminProcedure.input(z.void()).query(async () => {
|
|
20
|
+
const items = await ar.listNodes()
|
|
21
|
+
// Spread to mutable copies: AgentListItem uses readonly arrays; Zod output schema uses mutable.
|
|
22
|
+
return items.map((a) => ({
|
|
23
|
+
...a,
|
|
24
|
+
info: {
|
|
25
|
+
...a.info,
|
|
26
|
+
capabilities: [...a.info.capabilities],
|
|
27
|
+
},
|
|
28
|
+
status: {
|
|
29
|
+
...a.status,
|
|
30
|
+
fps: { ...a.status.fps },
|
|
31
|
+
errors: [...a.status.errors],
|
|
32
|
+
},
|
|
33
|
+
subProcesses: [...a.subProcesses],
|
|
34
|
+
}))
|
|
35
|
+
}),
|
|
43
36
|
|
|
44
37
|
// ── Capability discovery (via Moleculer service list) ─────────────
|
|
45
|
-
getAgentCapabilities: adminProcedure
|
|
46
|
-
.
|
|
47
|
-
.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
38
|
+
getAgentCapabilities: adminProcedure.input(z.void()).query(async () => {
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- moleculer.broker.call typing chain unresolvable; runtime shape validated by the cast below
|
|
40
|
+
const services = (await moleculer.broker.call('$node.services', {})) as Array<{
|
|
41
|
+
name: string
|
|
42
|
+
}>
|
|
43
|
+
const capSet = new Set<string>()
|
|
44
|
+
for (const svc of services) {
|
|
45
|
+
if (svc.name.startsWith('$')) continue
|
|
46
|
+
capSet.add(svc.name)
|
|
47
|
+
}
|
|
48
|
+
return [...capSet].toSorted()
|
|
49
|
+
}),
|
|
57
50
|
|
|
58
51
|
// ── Role assignments ──────────────────────────────────────────────
|
|
59
52
|
getAssignments: adminProcedure
|
|
@@ -61,27 +54,33 @@ export function createAgentsRouter(
|
|
|
61
54
|
.query(({ input }) => ar.getAssignments(input.cameraId)),
|
|
62
55
|
|
|
63
56
|
setAssignment: adminProcedure
|
|
64
|
-
.input(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
57
|
+
.input(
|
|
58
|
+
z.object({
|
|
59
|
+
cameraId: z.number(),
|
|
60
|
+
role: AgentRoleSchema,
|
|
61
|
+
agentId: z.string(),
|
|
62
|
+
priority: z.enum(['primary', 'backup', 'overflow']),
|
|
63
|
+
rtspUrl: z.string().optional(),
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
71
66
|
.mutation(({ input }) => ar.setAssignment(input as never)),
|
|
72
67
|
|
|
73
68
|
removeAssignment: adminProcedure
|
|
74
|
-
.input(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
.input(
|
|
70
|
+
z.object({
|
|
71
|
+
cameraId: z.number(),
|
|
72
|
+
role: AgentRoleSchema,
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
78
75
|
.mutation(({ input }) => ar.removeAssignment(input.cameraId, input.role as never)),
|
|
79
76
|
|
|
80
77
|
activateBackup: adminProcedure
|
|
81
|
-
.input(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
.input(
|
|
79
|
+
z.object({
|
|
80
|
+
cameraId: z.number(),
|
|
81
|
+
role: AgentRoleSchema,
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
85
84
|
.mutation(({ input }) => ar.activateBackup(input.cameraId, input.role as never)),
|
|
86
85
|
})
|
|
87
86
|
}
|