@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.
Files changed (125) hide show
  1. package/package.json +9 -7
  2. package/src/__tests__/addon-install-e2e.test.ts +0 -1
  3. package/src/__tests__/addon-pages-e2e.test.ts +40 -18
  4. package/src/__tests__/addon-settings-router.spec.ts +6 -1
  5. package/src/__tests__/addon-upload.spec.ts +91 -29
  6. package/src/__tests__/agent-registry.spec.ts +26 -9
  7. package/src/__tests__/agent-status-page.spec.ts +1 -3
  8. package/src/__tests__/auth-session-cookie.test.ts +28 -1
  9. package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
  10. package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
  11. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +24 -4
  12. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
  13. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
  14. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +64 -15
  15. package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
  16. package/src/__tests__/cap-route-adapter.spec.ts +28 -15
  17. package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
  18. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
  19. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +14 -6
  20. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
  21. package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
  22. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +11 -6
  23. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
  24. package/src/__tests__/cap-routers/harness.ts +11 -7
  25. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
  26. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
  27. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
  28. package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
  29. package/src/__tests__/capability-e2e.test.ts +9 -11
  30. package/src/__tests__/cli-e2e.test.ts +80 -59
  31. package/src/__tests__/core-cap-bridge.spec.ts +3 -1
  32. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
  33. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
  34. package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
  35. package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
  36. package/src/__tests__/framework-allowlist.spec.ts +5 -4
  37. package/src/__tests__/https-e2e.test.ts +12 -6
  38. package/src/__tests__/lifecycle-e2e.test.ts +60 -11
  39. package/src/__tests__/live-events-subscription.spec.ts +17 -18
  40. package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
  41. package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
  42. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +71 -17
  43. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
  44. package/src/__tests__/native-cap-route.spec.ts +42 -19
  45. package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
  46. package/src/__tests__/singleton-contention.test.ts +23 -11
  47. package/src/__tests__/streaming-diagnostic.test.ts +156 -53
  48. package/src/__tests__/streaming-scale.test.ts +69 -35
  49. package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
  50. package/src/agent-status-page.ts +4 -3
  51. package/src/api/__tests__/addons-custom.spec.ts +22 -8
  52. package/src/api/__tests__/capabilities.router.test.ts +18 -9
  53. package/src/api/addon-upload.ts +46 -15
  54. package/src/api/addons-custom.router.ts +7 -6
  55. package/src/api/auth-whoami.ts +3 -1
  56. package/src/api/bridge-addons.router.ts +3 -1
  57. package/src/api/capabilities.router.ts +117 -78
  58. package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
  59. package/src/api/core/addon-settings.router.ts +4 -1
  60. package/src/api/core/agents.router.ts +52 -53
  61. package/src/api/core/auth.router.ts +55 -36
  62. package/src/api/core/bulk-update-coordinator.ts +25 -22
  63. package/src/api/core/cap-providers.ts +346 -202
  64. package/src/api/core/capabilities.router.ts +30 -23
  65. package/src/api/core/hwaccel.router.ts +37 -10
  66. package/src/api/core/live-events.router.ts +16 -9
  67. package/src/api/core/logs.router.ts +54 -25
  68. package/src/api/core/notifications.router.ts +2 -1
  69. package/src/api/core/repl.router.ts +1 -3
  70. package/src/api/core/settings-backend.router.ts +68 -70
  71. package/src/api/core/system-events.router.ts +41 -32
  72. package/src/api/health/health.routes.ts +7 -13
  73. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  74. package/src/api/oauth2/consent-page.ts +4 -3
  75. package/src/api/oauth2/oauth2-routes.ts +41 -12
  76. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  77. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  78. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +10 -2
  79. package/src/api/trpc/cap-mount-helpers.ts +64 -55
  80. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  81. package/src/api/trpc/core-cap-bridge.ts +3 -1
  82. package/src/api/trpc/generated-cap-mounts.ts +593 -351
  83. package/src/api/trpc/generated-cap-routers.ts +3680 -579
  84. package/src/api/trpc/scope-access.ts +7 -7
  85. package/src/api/trpc/trpc.context.ts +7 -4
  86. package/src/api/trpc/trpc.middleware.ts +4 -2
  87. package/src/api/trpc/trpc.router.ts +79 -46
  88. package/src/auth/session-cookie.ts +10 -0
  89. package/src/boot/__tests__/integration-id-backfill.spec.ts +21 -6
  90. package/src/boot/boot-config.ts +103 -122
  91. package/src/boot/post-boot.service.ts +5 -3
  92. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  93. package/src/core/addon/addon-call-gateway.ts +20 -6
  94. package/src/core/addon/addon-package.service.ts +183 -89
  95. package/src/core/addon/addon-registry.service.ts +1163 -1305
  96. package/src/core/addon/addon-search.service.ts +2 -1
  97. package/src/core/addon/addon-settings-provider.ts +27 -7
  98. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  99. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  100. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  101. package/src/core/agent/agent-registry.service.ts +60 -38
  102. package/src/core/auth/auth.service.spec.ts +6 -8
  103. package/src/core/config/config.service.spec.ts +1 -1
  104. package/src/core/events/event-bus.service.spec.ts +44 -21
  105. package/src/core/events/event-bus.service.ts +5 -1
  106. package/src/core/feature/feature.service.spec.ts +4 -1
  107. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  108. package/src/core/logging/logging.service.spec.ts +61 -21
  109. package/src/core/logging/logging.service.ts +12 -3
  110. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  111. package/src/core/moleculer/cap-call-fn.ts +5 -1
  112. package/src/core/moleculer/cap-route-authority.ts +18 -6
  113. package/src/core/moleculer/moleculer.service.ts +120 -32
  114. package/src/core/network/network-quality.service.spec.ts +6 -1
  115. package/src/core/notification/notification-wrapper.service.ts +1 -3
  116. package/src/core/notification/toast-wrapper.service.ts +1 -5
  117. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  118. package/src/core/repl/repl-engine.service.ts +11 -12
  119. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  120. package/src/core/streaming/stream-probe.service.ts +22 -13
  121. package/src/core/topology/topology-emitter.service.ts +5 -1
  122. package/src/launcher.ts +14 -9
  123. package/src/main.ts +602 -531
  124. package/src/manual-boot.ts +133 -154
  125. 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> = {}): { get<T>(p: string): T; update(s: string, d: Record<string, unknown>): void } {
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 { return store[path] as T },
64
- update(_section: string, _data: Record<string, unknown>): void { /* no-op */ },
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): { getSingleton: (name: string) => unknown | null } {
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(router: ReturnType<typeof createAuthRouter>, name: 'login' | 'loginVerifyTotp', input: unknown): Promise<unknown> {
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(callProc(router, 'login', { username: 'alice', password: 'wrong' })).rejects.toThrow('Invalid credentials')
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(callProc(router, 'login', { username: 'alice', password: 'pw' })).rejects.toThrow(/user-management.*capability not registered/)
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 { requiresTotp?: boolean }
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', { challengeToken, code: '123456' }) as {
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({ userId: 'u-1', username: 'alice', isAdmin: true })
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({ userId: 'u-1', username: 'alice', isAdmin: false })
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({ userId: 'ghost', username: 'alice', isAdmin: false })
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({ userId: 'u-1', username: 'alice', isAdmin: false })
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', { challengeToken, code: '123456' }) as { token: string }
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, { ...current, [input.field]: input.value })
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
- .input(z.void())
26
- .query(async () => {
27
- const items = await ar.listNodes()
28
- // Spread to mutable copies: AgentListItem uses readonly arrays; Zod output schema uses mutable.
29
- return items.map(a => ({
30
- ...a,
31
- info: {
32
- ...a.info,
33
- capabilities: [...a.info.capabilities],
34
- },
35
- status: {
36
- ...a.status,
37
- fps: { ...a.status.fps },
38
- errors: [...a.status.errors],
39
- },
40
- subProcesses: [...a.subProcesses],
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
- .input(z.void())
47
- .query(async () => {
48
- // 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
49
- const services = await moleculer.broker.call('$node.services', {}) as Array<{ name: string }>
50
- const capSet = new Set<string>()
51
- for (const svc of services) {
52
- if (svc.name.startsWith('$')) continue
53
- capSet.add(svc.name)
54
- }
55
- return [...capSet].sort()
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(z.object({
65
- cameraId: z.number(),
66
- role: AgentRoleSchema,
67
- agentId: z.string(),
68
- priority: z.enum(['primary', 'backup', 'overflow']),
69
- rtspUrl: z.string().optional(),
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(z.object({
75
- cameraId: z.number(),
76
- role: AgentRoleSchema,
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(z.object({
82
- cameraId: z.number(),
83
- role: AgentRoleSchema,
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.object({
47
- id: z.string(),
48
- username: z.string(),
49
- isAdmin: z.boolean(),
50
- permissions: z.object({
46
+ const MeSchema = z
47
+ .object({
48
+ id: z.string(),
49
+ username: z.string(),
51
50
  isAdmin: z.boolean(),
52
- allowedProviders: z.union([z.literal('*'), z.array(z.string())]),
53
- allowedDevices: z.record(z.string(), z.unknown()),
54
- }),
55
- isApiKey: z.boolean(),
56
- agentId: z.string().optional(),
57
- }).nullable()
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
- + 'The `local-auth` addon failed to initialize; check server logs '
78
- + 'for an error tagged `[hub/local-auth]`.',
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({ username: input.username, password: input.password })
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 = typeof userMgmt.getTotpStatus === 'function'
95
- ? await userMgmt.getTotpStatus({ userId: user.id })
96
- : { enabled: false }
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 = typeof userMgmt.verifyTotp === 'function'
158
- ? await userMgmt.verifyTotp({ userId: claims.userId, code: input.code.trim() })
159
- : { valid: false }
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 = typeof userMgmt.listUsers === 'function'
167
- ? (await userMgmt.listUsers()).find((u) => u.id === claims.userId)
168
- : null
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(z.object({
206
- currentPassword: z.string().min(1),
207
- newPassword: z.string().min(8),
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: { username: string; password: string }) => Promise<{ id: string } | null>
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({ username: ctx.user.username, password: input.currentPassword })
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
- | { setupTotp: (i: { userId: string }) => Promise<{ secret: string; otpauthUrl: string }> }
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
- | { getTotpStatus: (i: { userId: string }) => Promise<{ enabled: boolean; confirmedAt: number | null }> }
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: { packageName: string; version: string; deferRestart: boolean }) => Promise<void>
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)) // get() applies lazy-cleanup
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.get(id)!.items.some(
179
- i => i.isSystem && i.status === 'done-pending-restart',
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 => it.status === 'done' || it.status === 'done-pending-restart' || it.status === 'failed',
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