@camstack/server 0.1.8 → 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 (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
@@ -43,9 +43,10 @@ export function renderAgentStatusPage(data: AgentStatusData): string {
43
43
  const memUsedMB = data.memoryTotalMB - data.memoryFreeMB
44
44
  const memPercent = Math.round((memUsedMB / data.memoryTotalMB) * 100)
45
45
 
46
- const taskTypesList = data.taskTypes.length > 0
47
- ? data.taskTypes.map((t) => `<li>${escapeHtml(t)}</li>`).join('')
48
- : '<li class="muted">No task handlers registered</li>'
46
+ const taskTypesList =
47
+ data.taskTypes.length > 0
48
+ ? data.taskTypes.map((t) => `<li>${escapeHtml(t)}</li>`).join('')
49
+ : '<li class="muted">No task handlers registered</li>'
49
50
 
50
51
  return `<!DOCTYPE html>
51
52
  <html lang="en">
@@ -42,12 +42,18 @@ describe('api.addons.custom', () => {
42
42
  registry = new CustomActionRegistry()
43
43
  registry.registerAddon('benchmark', catalog, async (action, input) => {
44
44
  switch (action) {
45
- case 'ping': return 'pong'
46
- case 'echo': return { msg: (input as { msg: string }).msg }
47
- case 'adminOnly': return 'ok'
48
- case 'open': return 'open'
49
- case 'badOutput': return 'WRONG' // will fail output validation
50
- default: throw new Error(`unknown action ${action}`)
45
+ case 'ping':
46
+ return 'pong'
47
+ case 'echo':
48
+ return { msg: (input as { msg: string }).msg }
49
+ case 'adminOnly':
50
+ return 'ok'
51
+ case 'open':
52
+ return 'open'
53
+ case 'badOutput':
54
+ return 'WRONG' // will fail output validation
55
+ default:
56
+ throw new Error(`unknown action ${action}`)
51
57
  }
52
58
  })
53
59
  router = buildRouter(registry)
@@ -61,7 +67,11 @@ describe('api.addons.custom', () => {
61
67
 
62
68
  it('round-trips structured input through the action schema', async () => {
63
69
  const caller = router.createCaller(makeCtx('admin'))
64
- const result = await caller.custom({ addonId: 'benchmark', action: 'echo', input: { msg: 'hi' } })
70
+ const result = await caller.custom({
71
+ addonId: 'benchmark',
72
+ action: 'echo',
73
+ input: { msg: 'hi' },
74
+ })
65
75
  expect(result).toEqual({ msg: 'hi' })
66
76
  })
67
77
 
@@ -92,7 +102,11 @@ describe('api.addons.custom', () => {
92
102
 
93
103
  it('enforces per-action auth: admin-only action allows admin', async () => {
94
104
  const caller = router.createCaller(makeCtx('admin'))
95
- const result = await caller.custom({ addonId: 'benchmark', action: 'adminOnly', input: undefined })
105
+ const result = await caller.custom({
106
+ addonId: 'benchmark',
107
+ action: 'adminOnly',
108
+ input: undefined,
109
+ })
96
110
  expect(result).toBe('ok')
97
111
  })
98
112
 
@@ -1,4 +1,3 @@
1
-
2
1
  // server/backend/src/api/__tests__/capabilities.router.test.ts
3
2
  import { describe, it, expect, vi, beforeEach } from 'vitest'
4
3
  import { CapabilityRegistry } from '@camstack/kernel'
@@ -24,7 +23,12 @@ describe('capabilities tRPC router logic', () => {
24
23
 
25
24
  it('listCapabilities returns all declared capabilities', () => {
26
25
  registry.declareCapability({ name: 'storage', scope: 'system', mode: 'singleton', methods: {} })
27
- registry.declareCapability({ name: 'log-destination', scope: 'system', mode: 'collection', methods: {} })
26
+ registry.declareCapability({
27
+ name: 'log-destination',
28
+ scope: 'system',
29
+ mode: 'collection',
30
+ methods: {},
31
+ })
28
32
 
29
33
  const list = registry.listCapabilities()
30
34
  expect(list).toHaveLength(2)
@@ -33,15 +37,20 @@ describe('capabilities tRPC router logic', () => {
33
37
  })
34
38
 
35
39
  it('setActiveSingleton throws for unknown capability', async () => {
36
- await expect(
37
- registry.setActiveSingleton('nonexistent', 'some-addon', true),
38
- ).rejects.toThrow(/[Uu]nknown/)
40
+ await expect(registry.setActiveSingleton('nonexistent', 'some-addon', true)).rejects.toThrow(
41
+ /[Uu]nknown/,
42
+ )
39
43
  })
40
44
 
41
45
  it('setActiveSingleton throws for collection capability', async () => {
42
- registry.declareCapability({ name: 'log-destination', scope: 'system', mode: 'collection', methods: {} })
43
- await expect(
44
- registry.setActiveSingleton('log-destination', 'winston', true),
45
- ).rejects.toThrow(/singleton/)
46
+ registry.declareCapability({
47
+ name: 'log-destination',
48
+ scope: 'system',
49
+ mode: 'collection',
50
+ methods: {},
51
+ })
52
+ await expect(registry.setActiveSingleton('log-destination', 'winston', true)).rejects.toThrow(
53
+ /singleton/,
54
+ )
46
55
  })
47
56
  })
@@ -72,7 +72,7 @@ interface AgentDeployResponse {
72
72
  }
73
73
 
74
74
  function isTarball(filename: string): boolean {
75
- return TARBALL_EXTENSIONS.some(ext => filename.endsWith(ext))
75
+ return TARBALL_EXTENSIONS.some((ext) => filename.endsWith(ext))
76
76
  }
77
77
 
78
78
  /**
@@ -162,7 +162,9 @@ export async function registerAddonUploadRoute(
162
162
  }
163
163
  }
164
164
  if (!authOk) {
165
- return reply.status(403).send({ error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}` })
165
+ return reply
166
+ .status(403)
167
+ .send({ error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}` })
166
168
  }
167
169
 
168
170
  const data = await request.file()
@@ -178,12 +180,20 @@ export async function registerAddonUploadRoute(
178
180
  // because the multipart plugin types it as `unknown`.
179
181
  const nodeIdField = data.fields['nodeId']
180
182
  const addonIdField = data.fields['addonId']
181
- const nodeId = typeof nodeIdField === 'object' && nodeIdField !== null && 'value' in nodeIdField && typeof nodeIdField.value === 'string'
182
- ? nodeIdField.value
183
- : null
184
- const addonIdHint = typeof addonIdField === 'object' && addonIdField !== null && 'value' in addonIdField && typeof addonIdField.value === 'string'
185
- ? addonIdField.value
186
- : null
183
+ const nodeId =
184
+ typeof nodeIdField === 'object' &&
185
+ nodeIdField !== null &&
186
+ 'value' in nodeIdField &&
187
+ typeof nodeIdField.value === 'string'
188
+ ? nodeIdField.value
189
+ : null
190
+ const addonIdHint =
191
+ typeof addonIdField === 'object' &&
192
+ addonIdField !== null &&
193
+ 'value' in addonIdField &&
194
+ typeof addonIdField.value === 'string'
195
+ ? addonIdField.value
196
+ : null
187
197
 
188
198
  const buffer = await data.toBuffer()
189
199
 
@@ -192,7 +202,9 @@ export async function registerAddonUploadRoute(
192
202
  // agent path would otherwise fail mid-extraction with no clean rollback.
193
203
  const manifest = validateTarball(buffer, data.filename)
194
204
  if (!manifest) {
195
- return reply.status(400).send({ error: 'Tarball missing or malformed package/package.json (name + version required)' })
205
+ return reply.status(400).send({
206
+ error: 'Tarball missing or malformed package/package.json (name + version required)',
207
+ })
196
208
  }
197
209
 
198
210
  // Branch by deployment target. `nodeId === 'hub'` (or absent) installs on
@@ -200,7 +212,16 @@ export async function registerAddonUploadRoute(
200
212
  // the package's addons (i.e. anything not marked hub-only). Any other
201
213
  // explicit `nodeId` value routes only to that agent via `$agent.deploy`.
202
214
  if (!nodeId || nodeId === 'hub') {
203
- return installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, data.filename, buffer)
215
+ return installToHub(
216
+ reply,
217
+ addonBridge,
218
+ addonRegistry,
219
+ addonPackageService,
220
+ moleculer,
221
+ logger,
222
+ data.filename,
223
+ buffer,
224
+ )
204
225
  }
205
226
  const agentAddonId = addonIdHint ?? manifest.name
206
227
  return deployToAgent(reply, moleculer, nodeId, agentAddonId, buffer)
@@ -231,7 +252,8 @@ function packageHasAgentDeployable(addonsDir: string, packageName: string): bool
231
252
  const pkgPath = path.join(addonsDir, packageName, 'package.json')
232
253
  const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
233
254
  if (parsed === null || typeof parsed !== 'object') return false
234
- const camstack = (parsed as { camstack?: { addons?: readonly CamstackAddonDeclLike[] } }).camstack
255
+ const camstack = (parsed as { camstack?: { addons?: readonly CamstackAddonDeclLike[] } })
256
+ .camstack
235
257
  const addons = camstack?.addons ?? []
236
258
  return addons.some((a) => {
237
259
  const placement = a.execution?.placement ?? 'hub-only'
@@ -250,7 +272,11 @@ interface AgentDeployResult {
250
272
  }
251
273
 
252
274
  interface MoleculerBrokerLike {
253
- call(action: string, params: Record<string, unknown>, options: { nodeID: string; timeout: number }): Promise<unknown>
275
+ call(
276
+ action: string,
277
+ params: Record<string, unknown>,
278
+ options: { nodeID: string; timeout: number },
279
+ ): Promise<unknown>
254
280
  registry?: {
255
281
  getNodeList?: (opts: { onlyAvailable: boolean }) => readonly { id: string }[]
256
282
  }
@@ -306,7 +332,7 @@ async function propagateToAgents(
306
332
  reloadRaw !== null &&
307
333
  typeof reloadRaw === 'object' &&
308
334
  'loaded' in (reloadRaw as Record<string, unknown>)
309
- ? ((reloadRaw as { loaded: readonly string[] }).loaded)
335
+ ? (reloadRaw as { loaded: readonly string[] }).loaded
310
336
  : []
311
337
  results.push({ nodeId, success: true, loaded: reloaded })
312
338
  } catch (err: unknown) {
@@ -418,7 +444,10 @@ async function installToHub(
418
444
  for (const id of preInstallAddonIds) {
419
445
  void addonRegistry.restartAddon(id).then((r) => {
420
446
  if (!r.success) {
421
- logger.warn('background restart failed', { tags: { addonId: id }, meta: { error: r.error ?? 'unknown' } })
447
+ logger.warn('background restart failed', {
448
+ tags: { addonId: id },
449
+ meta: { error: r.error ?? 'unknown' },
450
+ })
422
451
  } else {
423
452
  logger.info('background restart OK', { tags: { addonId: id } })
424
453
  }
@@ -426,7 +455,9 @@ async function installToHub(
426
455
  }
427
456
  if (propagatable) {
428
457
  void propagateToAgents(moleculer, logger, result.name, buffer).then((agentResults) => {
429
- logger.info('propagation done', { meta: { packageName: result.name, agents: agentResults } })
458
+ logger.info('propagation done', {
459
+ meta: { packageName: result.name, agents: agentResults },
460
+ })
430
461
  })
431
462
  }
432
463
 
@@ -42,11 +42,13 @@ export interface AddonsCustomDeps {
42
42
  export function createAddonsCustomProcedures(deps: AddonsCustomDeps) {
43
43
  return {
44
44
  custom: protectedProcedure
45
- .input(z.object({
46
- addonId: z.string().min(1),
47
- action: z.string().min(1),
48
- input: z.unknown(),
49
- }))
45
+ .input(
46
+ z.object({
47
+ addonId: z.string().min(1),
48
+ action: z.string().min(1),
49
+ input: z.unknown(),
50
+ }),
51
+ )
50
52
  .output(z.unknown())
51
53
  .mutation(async ({ input, ctx }) => {
52
54
  const registry = deps.getCustomActionRegistry()
@@ -66,7 +68,6 @@ export function createAddonsCustomProcedures(deps: AddonsCustomDeps) {
66
68
  // Validate input against the action's declared Zod schema.
67
69
  const parsedInput = entry.spec.input.parse(input.input)
68
70
 
69
-
70
71
  // Dispatch through the addon handler.
71
72
  const result = await entry.handler(parsedInput)
72
73
 
@@ -78,7 +78,9 @@ export async function registerAuthWhoamiRoute(
78
78
  }
79
79
  const record = await userMgmt.validateScopedToken({ token })
80
80
  if (!record) {
81
- return reply.status(401).send({ ok: false, error: 'Token not recognised (revoked, expired, or never issued)' })
81
+ return reply
82
+ .status(401)
83
+ .send({ ok: false, error: 'Token not recognised (revoked, expired, or never issued)' })
82
84
  }
83
85
  const ok: WhoamiOk = {
84
86
  ok: true,
@@ -45,7 +45,9 @@ export function createBridgeAddonsRouter(
45
45
  }
46
46
  await installer.install(input.packageName, input.version)
47
47
  await bridge.reloadPackages()
48
- const result = addonRegistry ? await addonRegistry.loadNewAddons() : { loaded: [], failed: [] }
48
+ const result = addonRegistry
49
+ ? await addonRegistry.loadNewAddons()
50
+ : { loaded: [], failed: [] }
49
51
  toastService?.broadcast({
50
52
  title: 'Addon Installed',
51
53
  message: `${input.packageName} installed successfully${result.loaded.length ? ` (${result.loaded.join(', ')})` : ''}`,
@@ -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
  }