@camstack/server 0.1.8 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -7
- 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 +24 -4
- 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 +64 -15
- 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 +14 -6
- 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 +11 -6
- 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 +71 -17
- 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/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 +346 -202
- 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 +54 -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__/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 +10 -2
- package/src/api/trpc/cap-mount-helpers.ts +64 -55
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +593 -351
- package/src/api/trpc/generated-cap-routers.ts +3680 -579
- 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 +79 -46
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +21 -6
- package/src/boot/boot-config.ts +103 -122
- 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/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1163 -1305
- 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 +12 -3
- 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 +120 -32
- package/src/core/network/network-quality.service.spec.ts +6 -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 +602 -531
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
|
@@ -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.
|
|
@@ -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
|
}
|
|
@@ -43,23 +43,22 @@ const AuthProviderSummarySchema = z.object({
|
|
|
43
43
|
})
|
|
44
44
|
|
|
45
45
|
/** Wire shape of the authenticated user returned by `auth.me`. */
|
|
46
|
-
const MeSchema = z
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
permissions: z.object({
|
|
46
|
+
const MeSchema = z
|
|
47
|
+
.object({
|
|
48
|
+
id: z.string(),
|
|
49
|
+
username: z.string(),
|
|
51
50
|
isAdmin: z.boolean(),
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
permissions: z.object({
|
|
52
|
+
isAdmin: z.boolean(),
|
|
53
|
+
allowedProviders: z.union([z.literal('*'), z.array(z.string())]),
|
|
54
|
+
allowedDevices: z.record(z.string(), z.unknown()),
|
|
55
|
+
}),
|
|
56
|
+
isApiKey: z.boolean(),
|
|
57
|
+
agentId: z.string().optional(),
|
|
58
|
+
})
|
|
59
|
+
.nullable()
|
|
58
60
|
|
|
59
|
-
export function createAuthRouter(
|
|
60
|
-
auth: AuthService,
|
|
61
|
-
registry: CapabilityRegistry | null,
|
|
62
|
-
) {
|
|
61
|
+
export function createAuthRouter(auth: AuthService, registry: CapabilityRegistry | null) {
|
|
63
62
|
return trpcRouter({
|
|
64
63
|
login: publicProcedure
|
|
65
64
|
.input(z.object({ username: z.string(), password: z.string() }))
|
|
@@ -73,13 +72,16 @@ export function createAuthRouter(
|
|
|
73
72
|
// common cause is a settings-store regression that breaks
|
|
74
73
|
// the `users` collection query path.
|
|
75
74
|
throw new Error(
|
|
76
|
-
'Login unavailable — `user-management` capability not registered. '
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
'Login unavailable — `user-management` capability not registered. ' +
|
|
76
|
+
'The `local-auth` addon failed to initialize; check server logs ' +
|
|
77
|
+
'for an error tagged `[hub/local-auth]`.',
|
|
79
78
|
)
|
|
80
79
|
}
|
|
81
80
|
|
|
82
|
-
const user = await userMgmt.validateCredentials({
|
|
81
|
+
const user = await userMgmt.validateCredentials({
|
|
82
|
+
username: input.username,
|
|
83
|
+
password: input.password,
|
|
84
|
+
})
|
|
83
85
|
if (!user) throw new Error('Invalid credentials')
|
|
84
86
|
|
|
85
87
|
// ── TOTP gate ────────────────────────────────────────────────
|
|
@@ -91,9 +93,10 @@ export function createAuthRouter(
|
|
|
91
93
|
// `kind: 'totp-challenge'` so it can't be replayed against
|
|
92
94
|
// protected endpoints (the auth middleware rejects anything
|
|
93
95
|
// without the standard session shape).
|
|
94
|
-
const totpStatus =
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
const totpStatus =
|
|
97
|
+
typeof userMgmt.getTotpStatus === 'function'
|
|
98
|
+
? await userMgmt.getTotpStatus({ userId: user.id })
|
|
99
|
+
: { enabled: false }
|
|
97
100
|
if (totpStatus.enabled) {
|
|
98
101
|
const challengeToken = auth.signTotpChallengeToken({
|
|
99
102
|
userId: user.id,
|
|
@@ -154,18 +157,20 @@ export function createAuthRouter(
|
|
|
154
157
|
if (!userMgmt) {
|
|
155
158
|
throw new Error('Login unavailable — `user-management` capability not registered')
|
|
156
159
|
}
|
|
157
|
-
const verify =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
const verify =
|
|
161
|
+
typeof userMgmt.verifyTotp === 'function'
|
|
162
|
+
? await userMgmt.verifyTotp({ userId: claims.userId, code: input.code.trim() })
|
|
163
|
+
: { valid: false }
|
|
160
164
|
if (!verify.valid) {
|
|
161
165
|
throw new Error('Invalid TOTP code')
|
|
162
166
|
}
|
|
163
167
|
// Re-fetch the user record so scopes / allowedDevices reflect
|
|
164
168
|
// any admin change made between the two legs. Without this we
|
|
165
169
|
// could mint a stale-scope session.
|
|
166
|
-
const fresh =
|
|
167
|
-
|
|
168
|
-
|
|
170
|
+
const fresh =
|
|
171
|
+
typeof userMgmt.listUsers === 'function'
|
|
172
|
+
? (await userMgmt.listUsers()).find((u) => u.id === claims.userId)
|
|
173
|
+
: null
|
|
169
174
|
if (!fresh) {
|
|
170
175
|
throw new Error('User no longer exists')
|
|
171
176
|
}
|
|
@@ -202,15 +207,20 @@ export function createAuthRouter(
|
|
|
202
207
|
// exists only to block third-party tampering — operators acting on
|
|
203
208
|
// themselves bypass it intentionally.
|
|
204
209
|
changeOwnPassword: protectedProcedure
|
|
205
|
-
.input(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
.input(
|
|
211
|
+
z.object({
|
|
212
|
+
currentPassword: z.string().min(1),
|
|
213
|
+
newPassword: z.string().min(8),
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
209
216
|
.output(z.object({ success: z.literal(true) }))
|
|
210
217
|
.mutation(async ({ input, ctx }) => {
|
|
211
218
|
const userMgmt = registry?.getSingleton('user-management') as
|
|
212
219
|
| {
|
|
213
|
-
validateCredentials: (i: {
|
|
220
|
+
validateCredentials: (i: {
|
|
221
|
+
username: string
|
|
222
|
+
password: string
|
|
223
|
+
}) => Promise<{ id: string } | null>
|
|
214
224
|
resetPassword: (i: { id: string; newPassword: string }) => Promise<{ success: true }>
|
|
215
225
|
}
|
|
216
226
|
| null
|
|
@@ -220,7 +230,10 @@ export function createAuthRouter(
|
|
|
220
230
|
// Re-validate the current password against the live store to
|
|
221
231
|
// confirm session identity → password ownership match. Stops a
|
|
222
232
|
// stolen session-token from rotating credentials silently.
|
|
223
|
-
const ok = await userMgmt.validateCredentials({
|
|
233
|
+
const ok = await userMgmt.validateCredentials({
|
|
234
|
+
username: ctx.user.username,
|
|
235
|
+
password: input.currentPassword,
|
|
236
|
+
})
|
|
224
237
|
if (!ok) throw new Error('Current password is incorrect')
|
|
225
238
|
await userMgmt.resetPassword({ id: ctx.user.id, newPassword: input.newPassword })
|
|
226
239
|
return { success: true as const }
|
|
@@ -231,7 +244,9 @@ export function createAuthRouter(
|
|
|
231
244
|
.output(z.object({ secret: z.string(), otpauthUrl: z.string() }))
|
|
232
245
|
.mutation(async ({ ctx }) => {
|
|
233
246
|
const userMgmt = registry?.getSingleton('user-management') as
|
|
234
|
-
| {
|
|
247
|
+
| {
|
|
248
|
+
setupTotp: (i: { userId: string }) => Promise<{ secret: string; otpauthUrl: string }>
|
|
249
|
+
}
|
|
235
250
|
| null
|
|
236
251
|
| undefined
|
|
237
252
|
if (!userMgmt) throw new Error('user-management capability not available')
|
|
@@ -270,7 +285,11 @@ export function createAuthRouter(
|
|
|
270
285
|
.output(z.object({ enabled: z.boolean(), confirmedAt: z.number().nullable() }))
|
|
271
286
|
.query(async ({ ctx }) => {
|
|
272
287
|
const userMgmt = registry?.getSingleton('user-management') as
|
|
273
|
-
| {
|
|
288
|
+
| {
|
|
289
|
+
getTotpStatus: (i: {
|
|
290
|
+
userId: string
|
|
291
|
+
}) => Promise<{ enabled: boolean; confirmedAt: number | null }>
|
|
292
|
+
}
|
|
274
293
|
| null
|
|
275
294
|
| undefined
|
|
276
295
|
if (!userMgmt || !ctx.user) return { enabled: false, confirmedAt: null }
|
|
@@ -29,7 +29,11 @@ export interface IBulkUpdateLogger {
|
|
|
29
29
|
export interface BulkUpdateCoordinatorDeps {
|
|
30
30
|
readonly eventBus: IBulkUpdateEventBus
|
|
31
31
|
readonly updateAddon: (input: { name: string; version: string }) => Promise<void>
|
|
32
|
-
readonly updateFrameworkPackage: (input: {
|
|
32
|
+
readonly updateFrameworkPackage: (input: {
|
|
33
|
+
packageName: string
|
|
34
|
+
version: string
|
|
35
|
+
deferRestart: boolean
|
|
36
|
+
}) => Promise<void>
|
|
33
37
|
readonly restartServer: (input: { confirm: true }) => Promise<void>
|
|
34
38
|
readonly logger: IBulkUpdateLogger
|
|
35
39
|
/** Injectable clock for tests. Default: `() => Date.now()`. */
|
|
@@ -77,7 +81,7 @@ export class BulkUpdateCoordinator {
|
|
|
77
81
|
|
|
78
82
|
const id = randomUUID()
|
|
79
83
|
|
|
80
|
-
const items: BulkUpdateItem[] = input.items.map(i => ({
|
|
84
|
+
const items: BulkUpdateItem[] = input.items.map((i) => ({
|
|
81
85
|
name: i.name,
|
|
82
86
|
isSystem: i.isSystem,
|
|
83
87
|
// fromVersion: the cap interface receives name+version+isSystem only;
|
|
@@ -110,7 +114,7 @@ export class BulkUpdateCoordinator {
|
|
|
110
114
|
// Emit initial state so clients see the bulk as started immediately
|
|
111
115
|
this.emit(state)
|
|
112
116
|
|
|
113
|
-
void this.runLoop(id, cancelFlag).catch(err => {
|
|
117
|
+
void this.runLoop(id, cancelFlag).catch((err) => {
|
|
114
118
|
this.deps.logger.error('BulkUpdateCoordinator: loop crashed unexpectedly', err)
|
|
115
119
|
})
|
|
116
120
|
|
|
@@ -133,9 +137,9 @@ export class BulkUpdateCoordinator {
|
|
|
133
137
|
|
|
134
138
|
list(nodeId?: string): readonly BulkUpdateState[] {
|
|
135
139
|
const all = [...this.states.keys()]
|
|
136
|
-
.map(id => this.get(id))
|
|
140
|
+
.map((id) => this.get(id)) // get() applies lazy-cleanup
|
|
137
141
|
.filter((s): s is BulkUpdateState => s !== null)
|
|
138
|
-
return nodeId === undefined ? all : all.filter(s => s.nodeId === nodeId)
|
|
142
|
+
return nodeId === undefined ? all : all.filter((s) => s.nodeId === nodeId)
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
cancel(id: string): { cancelled: boolean } {
|
|
@@ -148,7 +152,7 @@ export class BulkUpdateCoordinator {
|
|
|
148
152
|
if (state.completedAtMs !== undefined) return { cancelled: false }
|
|
149
153
|
|
|
150
154
|
flag.cancelled = true
|
|
151
|
-
this.mutate(id, s => ({ ...s, cancelled: true }))
|
|
155
|
+
this.mutate(id, (s) => ({ ...s, cancelled: true }))
|
|
152
156
|
return { cancelled: true }
|
|
153
157
|
}
|
|
154
158
|
|
|
@@ -160,24 +164,24 @@ export class BulkUpdateCoordinator {
|
|
|
160
164
|
// ── Phase 1: regular addons ──────────────────────────────────────
|
|
161
165
|
this.transitionPhase(id, 'regular')
|
|
162
166
|
|
|
163
|
-
for (const item of initial.items.filter(i => !i.isSystem)) {
|
|
167
|
+
for (const item of initial.items.filter((i) => !i.isSystem)) {
|
|
164
168
|
if (cancelFlag.cancelled) break
|
|
165
169
|
await this.processItem(id, item, false)
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
// ── Phase 2: system packages (deferRestart: true) ────────────────
|
|
169
|
-
if (!cancelFlag.cancelled && initial.items.some(i => i.isSystem)) {
|
|
173
|
+
if (!cancelFlag.cancelled && initial.items.some((i) => i.isSystem)) {
|
|
170
174
|
this.transitionPhase(id, 'system')
|
|
171
175
|
|
|
172
|
-
for (const item of initial.items.filter(i => i.isSystem)) {
|
|
176
|
+
for (const item of initial.items.filter((i) => i.isSystem)) {
|
|
173
177
|
if (cancelFlag.cancelled) break
|
|
174
178
|
await this.processItem(id, item, true)
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
// ── Phase 3: single restart ──────────────────────────────────
|
|
178
|
-
const anySystemPendingRestart = this.states
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
const anySystemPendingRestart = this.states
|
|
183
|
+
.get(id)!
|
|
184
|
+
.items.some((i) => i.isSystem && i.status === 'done-pending-restart')
|
|
181
185
|
|
|
182
186
|
if (anySystemPendingRestart && !cancelFlag.cancelled) {
|
|
183
187
|
this.transitionPhase(id, 'restarting')
|
|
@@ -214,7 +218,7 @@ export class BulkUpdateCoordinator {
|
|
|
214
218
|
|
|
215
219
|
private async processItem(id: string, item: BulkUpdateItem, isSystem: boolean): Promise<void> {
|
|
216
220
|
this.setItemStatus(id, item.name, 'updating', { startedAtMs: this.now() })
|
|
217
|
-
this.mutate(id, s => ({ ...s, current: item.name }))
|
|
221
|
+
this.mutate(id, (s) => ({ ...s, current: item.name }))
|
|
218
222
|
this.emit(this.states.get(id)!)
|
|
219
223
|
|
|
220
224
|
try {
|
|
@@ -234,7 +238,7 @@ export class BulkUpdateCoordinator {
|
|
|
234
238
|
this.setItemStatus(id, item.name, 'failed', { error: msg, completedAtMs: this.now() })
|
|
235
239
|
}
|
|
236
240
|
|
|
237
|
-
this.mutate(id, s => ({ ...s, current: null }))
|
|
241
|
+
this.mutate(id, (s) => ({ ...s, current: null }))
|
|
238
242
|
this.emit(this.states.get(id)!)
|
|
239
243
|
}
|
|
240
244
|
|
|
@@ -246,26 +250,25 @@ export class BulkUpdateCoordinator {
|
|
|
246
250
|
status: BulkUpdateItemStatus,
|
|
247
251
|
fields: { error?: string; startedAtMs?: number; completedAtMs?: number } = {},
|
|
248
252
|
): void {
|
|
249
|
-
this.mutate(id, s => {
|
|
250
|
-
const items = s.items.map(it =>
|
|
251
|
-
it.name === name ? { ...it, status, ...fields } : it,
|
|
252
|
-
)
|
|
253
|
+
this.mutate(id, (s) => {
|
|
254
|
+
const items = s.items.map((it) => (it.name === name ? { ...it, status, ...fields } : it))
|
|
253
255
|
// completed = all terminal states: done | done-pending-restart | failed
|
|
254
256
|
const completed = items.filter(
|
|
255
|
-
it =>
|
|
257
|
+
(it) =>
|
|
258
|
+
it.status === 'done' || it.status === 'done-pending-restart' || it.status === 'failed',
|
|
256
259
|
).length
|
|
257
|
-
const failed = items.filter(it => it.status === 'failed').length
|
|
260
|
+
const failed = items.filter((it) => it.status === 'failed').length
|
|
258
261
|
return { ...s, items, completed, failed }
|
|
259
262
|
})
|
|
260
263
|
}
|
|
261
264
|
|
|
262
265
|
private transitionPhase(id: string, phase: BulkUpdatePhase): void {
|
|
263
|
-
this.mutate(id, s => ({ ...s, phase }))
|
|
266
|
+
this.mutate(id, (s) => ({ ...s, phase }))
|
|
264
267
|
this.emit(this.states.get(id)!)
|
|
265
268
|
}
|
|
266
269
|
|
|
267
270
|
private completeBulk(id: string): void {
|
|
268
|
-
this.mutate(id, s => ({ ...s, completedAtMs: this.now(), current: null }))
|
|
271
|
+
this.mutate(id, (s) => ({ ...s, completedAtMs: this.now(), current: null }))
|
|
269
272
|
this.emit(this.states.get(id)!)
|
|
270
273
|
|
|
271
274
|
// Free the nodeId slot so a new bulk for the same node can be started
|