@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.
Files changed (135) hide show
  1. package/package.json +11 -9
  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 +206 -0
  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 +292 -0
  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 +177 -0
  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 +137 -0
  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 +265 -5
  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/__tests__/integration-markers.spec.ts +10 -0
  60. package/src/api/core/addon-settings.router.ts +4 -1
  61. package/src/api/core/agents.router.ts +52 -53
  62. package/src/api/core/auth.router.ts +55 -36
  63. package/src/api/core/bulk-update-coordinator.ts +25 -22
  64. package/src/api/core/cap-providers.ts +459 -166
  65. package/src/api/core/capabilities.router.ts +30 -23
  66. package/src/api/core/hwaccel.router.ts +37 -10
  67. package/src/api/core/live-events.router.ts +16 -9
  68. package/src/api/core/logs.router.ts +58 -25
  69. package/src/api/core/notifications.router.ts +2 -1
  70. package/src/api/core/repl.router.ts +1 -3
  71. package/src/api/core/settings-backend.router.ts +68 -70
  72. package/src/api/core/system-events.router.ts +41 -32
  73. package/src/api/health/health.routes.ts +7 -13
  74. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  75. package/src/api/oauth2/consent-page.ts +4 -3
  76. package/src/api/oauth2/oauth2-routes.ts +41 -12
  77. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  78. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  79. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  80. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
  81. package/src/api/trpc/cap-mount-helpers.ts +64 -44
  82. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  83. package/src/api/trpc/client-ip.ts +17 -0
  84. package/src/api/trpc/core-cap-bridge.ts +3 -1
  85. package/src/api/trpc/generated-cap-mounts.ts +801 -286
  86. package/src/api/trpc/generated-cap-routers.ts +5723 -719
  87. package/src/api/trpc/scope-access.ts +7 -7
  88. package/src/api/trpc/trpc.context.ts +7 -4
  89. package/src/api/trpc/trpc.middleware.ts +4 -2
  90. package/src/api/trpc/trpc.router.ts +117 -48
  91. package/src/auth/session-cookie.ts +10 -0
  92. package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
  93. package/src/boot/boot-config.ts +103 -122
  94. package/src/boot/integration-id-backfill.ts +109 -0
  95. package/src/boot/post-boot.service.ts +5 -3
  96. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  97. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  98. package/src/core/addon/addon-call-gateway.ts +20 -6
  99. package/src/core/addon/addon-package.service.ts +183 -89
  100. package/src/core/addon/addon-registry.service.ts +1212 -1267
  101. package/src/core/addon/addon-row-manifest.ts +29 -0
  102. package/src/core/addon/addon-search.service.ts +2 -1
  103. package/src/core/addon/addon-settings-provider.ts +27 -7
  104. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  105. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  106. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  107. package/src/core/agent/agent-registry.service.ts +60 -38
  108. package/src/core/auth/auth.service.spec.ts +6 -8
  109. package/src/core/config/config.service.spec.ts +1 -1
  110. package/src/core/events/event-bus.service.spec.ts +44 -21
  111. package/src/core/events/event-bus.service.ts +5 -1
  112. package/src/core/feature/feature.service.spec.ts +4 -1
  113. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  114. package/src/core/logging/logging.service.spec.ts +61 -21
  115. package/src/core/logging/logging.service.ts +19 -5
  116. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  117. package/src/core/moleculer/cap-call-fn.ts +5 -1
  118. package/src/core/moleculer/cap-route-authority.ts +18 -6
  119. package/src/core/moleculer/moleculer.service.ts +145 -29
  120. package/src/core/network/network-quality.service.spec.ts +7 -1
  121. package/src/core/notification/notification-wrapper.service.ts +1 -3
  122. package/src/core/notification/toast-wrapper.service.ts +1 -5
  123. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  124. package/src/core/repl/repl-engine.service.ts +11 -12
  125. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  126. package/src/core/streaming/stream-probe.service.ts +22 -13
  127. package/src/core/topology/topology-emitter.service.ts +5 -1
  128. package/src/launcher.ts +14 -9
  129. package/src/main.ts +658 -495
  130. package/src/manual-boot.ts +133 -154
  131. package/tsconfig.json +20 -8
  132. package/src/core/storage/settings-store.spec.ts +0 -213
  133. package/src/core/storage/settings-store.ts +0 -2
  134. package/src/core/storage/sql-schema.spec.ts +0 -140
  135. 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
- .input(z.object({ capability: z.string() }))
65
- .query(({ input }) => {
66
- const registry = addonRegistry.getCapabilityRegistry()
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
- // collection
80
- const raw = configService.get<string>(collectionPreferenceKey(input.capability))
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 'collection',
91
- preference: { disabled },
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
- if (!requiresRestart) {
115
- // Hot-swap at runtime
116
- await registry.setActiveSingleton(capability, addonId)
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
- // Persist preference
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
- return { success: true, requiresRestart }
124
- }
97
+ setPreference: adminProcedure.input(setPreferenceInput).mutation(async ({ input }) => {
98
+ const registry = addonRegistry.getCapabilityRegistry()
125
99
 
126
- // collection toggle
127
- const { capability, addonId, enabled } = input
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) throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` })
131
- if (cap.mode !== 'collection') throw new TRPCError({ code: 'BAD_REQUEST', message: `"${capability}" is not a collection` })
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({ code: 'BAD_REQUEST', message: `Provider "${addonId}" is not registered for "${capability}"` })
112
+ throw new TRPCError({
113
+ code: 'BAD_REQUEST',
114
+ message: `Provider "${addonId}" is not registered for "${capability}"`,
115
+ })
134
116
  }
135
117
 
136
- if (enabled) {
137
- registry.enableCollectionProvider(capability, addonId)
138
- } else {
139
- registry.disableCollectionProvider(capability, addonId)
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 disabled list via the shared canonical writer.
143
- const updatedCap = registry.listCapabilities().find((c) => c.name === capability)
144
- persistCollectionDisabled(configService, capability, updatedCap?.disabledProviders ?? [])
145
- logger.info('Collection provider toggled', { tags: { addonId }, meta: { capability, enabled } })
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: false }
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) throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${input.capability}` })
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)', { meta: { capability: input.capability } })
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)', { meta: { capability: input.capability } })
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', { tags: { deviceId: Number(input.deviceId), addonId: input.addonId }, meta: { capability: input.capability } })
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', { tags: { deviceId: Number(input.deviceId) }, meta: { capability: input.capability } })
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(z.object({ deviceId: z.string(), capability: z.string(), addonIds: z.array(z.string()) }))
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', { tags: { deviceId: Number(input.deviceId) }, meta: { capability: input.capability, addonIds: input.addonIds } })
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', { tags: { deviceId: Number(input.deviceId) }, meta: { capability: input.capability } })
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> = {}): { 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.
@@ -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, { ...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
  }