@camstack/server 0.1.8 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -7
- package/src/__tests__/addon-install-e2e.test.ts +0 -1
- package/src/__tests__/addon-pages-e2e.test.ts +40 -18
- package/src/__tests__/addon-settings-router.spec.ts +6 -1
- package/src/__tests__/addon-upload.spec.ts +91 -29
- package/src/__tests__/agent-registry.spec.ts +26 -9
- package/src/__tests__/agent-status-page.spec.ts +1 -3
- package/src/__tests__/auth-session-cookie.test.ts +28 -1
- package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
- package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +24 -4
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +64 -15
- package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
- package/src/__tests__/cap-route-adapter.spec.ts +28 -15
- package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +14 -6
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +11 -6
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
- package/src/__tests__/cap-routers/harness.ts +11 -7
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
- package/src/__tests__/capability-e2e.test.ts +9 -11
- package/src/__tests__/cli-e2e.test.ts +80 -59
- package/src/__tests__/core-cap-bridge.spec.ts +3 -1
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
- package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
- package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
- package/src/__tests__/framework-allowlist.spec.ts +5 -4
- package/src/__tests__/https-e2e.test.ts +12 -6
- package/src/__tests__/lifecycle-e2e.test.ts +60 -11
- package/src/__tests__/live-events-subscription.spec.ts +17 -18
- package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
- package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +71 -17
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
- package/src/__tests__/native-cap-route.spec.ts +42 -19
- package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
- package/src/__tests__/singleton-contention.test.ts +23 -11
- package/src/__tests__/streaming-diagnostic.test.ts +156 -53
- package/src/__tests__/streaming-scale.test.ts +69 -35
- package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
- package/src/agent-status-page.ts +4 -3
- package/src/api/__tests__/addons-custom.spec.ts +22 -8
- package/src/api/__tests__/capabilities.router.test.ts +18 -9
- package/src/api/addon-upload.ts +46 -15
- package/src/api/addons-custom.router.ts +7 -6
- package/src/api/auth-whoami.ts +3 -1
- package/src/api/bridge-addons.router.ts +3 -1
- package/src/api/capabilities.router.ts +117 -78
- package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
- package/src/api/core/addon-settings.router.ts +4 -1
- package/src/api/core/agents.router.ts +52 -53
- package/src/api/core/auth.router.ts +55 -36
- package/src/api/core/bulk-update-coordinator.ts +25 -22
- package/src/api/core/cap-providers.ts +346 -202
- package/src/api/core/capabilities.router.ts +30 -23
- package/src/api/core/hwaccel.router.ts +37 -10
- package/src/api/core/live-events.router.ts +16 -9
- package/src/api/core/logs.router.ts +54 -25
- package/src/api/core/notifications.router.ts +2 -1
- package/src/api/core/repl.router.ts +1 -3
- package/src/api/core/settings-backend.router.ts +68 -70
- package/src/api/core/system-events.router.ts +41 -32
- package/src/api/health/health.routes.ts +7 -13
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
- package/src/api/oauth2/consent-page.ts +4 -3
- package/src/api/oauth2/oauth2-routes.ts +41 -12
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
- package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +10 -2
- package/src/api/trpc/cap-mount-helpers.ts +64 -55
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +593 -351
- package/src/api/trpc/generated-cap-routers.ts +3680 -579
- package/src/api/trpc/scope-access.ts +7 -7
- package/src/api/trpc/trpc.context.ts +7 -4
- package/src/api/trpc/trpc.middleware.ts +4 -2
- package/src/api/trpc/trpc.router.ts +79 -46
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +21 -6
- package/src/boot/boot-config.ts +103 -122
- package/src/boot/post-boot.service.ts +5 -3
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
- package/src/core/addon/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1163 -1305
- package/src/core/addon/addon-search.service.ts +2 -1
- package/src/core/addon/addon-settings-provider.ts +27 -7
- package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
- package/src/core/addon-pages/addon-pages.service.ts +3 -1
- package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
- package/src/core/agent/agent-registry.service.ts +60 -38
- package/src/core/auth/auth.service.spec.ts +6 -8
- package/src/core/config/config.service.spec.ts +1 -1
- package/src/core/events/event-bus.service.spec.ts +44 -21
- package/src/core/events/event-bus.service.ts +5 -1
- package/src/core/feature/feature.service.spec.ts +4 -1
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
- package/src/core/logging/logging.service.spec.ts +61 -21
- package/src/core/logging/logging.service.ts +12 -3
- package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
- package/src/core/moleculer/cap-call-fn.ts +5 -1
- package/src/core/moleculer/cap-route-authority.ts +18 -6
- package/src/core/moleculer/moleculer.service.ts +120 -32
- package/src/core/network/network-quality.service.spec.ts +6 -1
- package/src/core/notification/notification-wrapper.service.ts +1 -3
- package/src/core/notification/toast-wrapper.service.ts +1 -5
- package/src/core/repl/repl-engine.service.spec.ts +66 -39
- package/src/core/repl/repl-engine.service.ts +11 -12
- package/src/core/storage/storage-location-manager.spec.ts +12 -3
- package/src/core/streaming/stream-probe.service.ts +22 -13
- package/src/core/topology/topology-emitter.service.ts +5 -1
- package/src/launcher.ts +14 -9
- package/src/main.ts +602 -531
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
package/src/agent-status-page.ts
CHANGED
|
@@ -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 =
|
|
47
|
-
|
|
48
|
-
|
|
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':
|
|
46
|
-
|
|
47
|
-
case '
|
|
48
|
-
|
|
49
|
-
case '
|
|
50
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
38
|
-
)
|
|
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({
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
})
|
package/src/api/addon-upload.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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({
|
|
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(
|
|
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[] } })
|
|
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(
|
|
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
|
-
? (
|
|
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', {
|
|
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', {
|
|
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(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
package/src/api/auth-whoami.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
const mode = registry.getMode(input.capability)
|
|
68
|
-
if (!mode) return null
|
|
69
|
-
|
|
70
|
-
if (mode === 'singleton') {
|
|
71
|
-
const addonId = configService.get<string>(`capabilities.singleton.${input.capability}`)
|
|
72
|
-
return {
|
|
73
|
-
capability: input.capability,
|
|
74
|
-
mode: mode as 'singleton',
|
|
75
|
-
preference: addonId ? { addonId } : null,
|
|
76
|
-
}
|
|
77
|
-
}
|
|
63
|
+
getPreference: adminProcedure.input(z.object({ capability: z.string() })).query(({ input }) => {
|
|
64
|
+
const registry = addonRegistry.getCapabilityRegistry()
|
|
65
|
+
const mode = registry.getMode(input.capability)
|
|
66
|
+
if (!mode) return null
|
|
78
67
|
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
let disabled: string[] = []
|
|
82
|
-
if (raw) {
|
|
83
|
-
try {
|
|
84
|
-
const parsed = JSON.parse(raw) as { disabled?: string[] }
|
|
85
|
-
disabled = Array.isArray(parsed.disabled) ? parsed.disabled : []
|
|
86
|
-
} catch { /* ignore malformed */ }
|
|
87
|
-
}
|
|
68
|
+
if (mode === 'singleton') {
|
|
69
|
+
const addonId = configService.get<string>(`capabilities.singleton.${input.capability}`)
|
|
88
70
|
return {
|
|
89
71
|
capability: input.capability,
|
|
90
|
-
mode: mode as '
|
|
91
|
-
preference: {
|
|
72
|
+
mode: mode as 'singleton',
|
|
73
|
+
preference: addonId ? { addonId } : null,
|
|
92
74
|
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ─── Set preference (singleton switch or collection toggle) ───────
|
|
96
|
-
|
|
97
|
-
setPreference: adminProcedure
|
|
98
|
-
.input(setPreferenceInput)
|
|
99
|
-
.mutation(async ({ input }) => {
|
|
100
|
-
const registry = addonRegistry.getCapabilityRegistry()
|
|
101
|
-
|
|
102
|
-
if (input.mode === 'singleton') {
|
|
103
|
-
const { capability, addonId } = input
|
|
104
|
-
const caps = registry.listCapabilities()
|
|
105
|
-
const cap = caps.find((c) => c.name === capability)
|
|
106
|
-
if (!cap) throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` })
|
|
107
|
-
if (cap.mode !== 'singleton') throw new TRPCError({ code: 'BAD_REQUEST', message: `"${capability}" is not a singleton` })
|
|
108
|
-
if (!cap.providers.includes(addonId)) {
|
|
109
|
-
throw new TRPCError({ code: 'BAD_REQUEST', message: `Provider "${addonId}" is not registered for "${capability}"` })
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const requiresRestart = isInfraCapability(capability)
|
|
75
|
+
}
|
|
113
76
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
77
|
+
// collection
|
|
78
|
+
const raw = configService.get<string>(collectionPreferenceKey(input.capability))
|
|
79
|
+
let disabled: string[] = []
|
|
80
|
+
if (raw) {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(raw) as { disabled?: string[] }
|
|
83
|
+
disabled = Array.isArray(parsed.disabled) ? parsed.disabled : []
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore malformed */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
capability: input.capability,
|
|
90
|
+
mode: mode as 'collection',
|
|
91
|
+
preference: { disabled },
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
118
94
|
|
|
119
|
-
|
|
120
|
-
configService.set(`capabilities.singleton.${capability}`, addonId)
|
|
121
|
-
logger.info('Singleton preference set', { tags: { addonId }, meta: { capability, requiresRestart } })
|
|
95
|
+
// ─── Set preference (singleton switch or collection toggle) ───────
|
|
122
96
|
|
|
123
|
-
|
|
124
|
-
|
|
97
|
+
setPreference: adminProcedure.input(setPreferenceInput).mutation(async ({ input }) => {
|
|
98
|
+
const registry = addonRegistry.getCapabilityRegistry()
|
|
125
99
|
|
|
126
|
-
|
|
127
|
-
const { capability, addonId
|
|
100
|
+
if (input.mode === 'singleton') {
|
|
101
|
+
const { capability, addonId } = input
|
|
128
102
|
const caps = registry.listCapabilities()
|
|
129
103
|
const cap = caps.find((c) => c.name === capability)
|
|
130
|
-
if (!cap)
|
|
131
|
-
|
|
104
|
+
if (!cap)
|
|
105
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` })
|
|
106
|
+
if (cap.mode !== 'singleton')
|
|
107
|
+
throw new TRPCError({
|
|
108
|
+
code: 'BAD_REQUEST',
|
|
109
|
+
message: `"${capability}" is not a singleton`,
|
|
110
|
+
})
|
|
132
111
|
if (!cap.providers.includes(addonId)) {
|
|
133
|
-
throw new TRPCError({
|
|
112
|
+
throw new TRPCError({
|
|
113
|
+
code: 'BAD_REQUEST',
|
|
114
|
+
message: `Provider "${addonId}" is not registered for "${capability}"`,
|
|
115
|
+
})
|
|
134
116
|
}
|
|
135
117
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
118
|
+
const requiresRestart = isInfraCapability(capability)
|
|
119
|
+
|
|
120
|
+
if (!requiresRestart) {
|
|
121
|
+
// Hot-swap at runtime
|
|
122
|
+
await registry.setActiveSingleton(capability, addonId)
|
|
140
123
|
}
|
|
141
124
|
|
|
142
|
-
// Persist
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
125
|
+
// Persist preference
|
|
126
|
+
configService.set(`capabilities.singleton.${capability}`, addonId)
|
|
127
|
+
logger.info('Singleton preference set', {
|
|
128
|
+
tags: { addonId },
|
|
129
|
+
meta: { capability, requiresRestart },
|
|
130
|
+
})
|
|
146
131
|
|
|
147
|
-
return { success: true, requiresRestart
|
|
148
|
-
}
|
|
132
|
+
return { success: true, requiresRestart }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// collection toggle
|
|
136
|
+
const { capability, addonId, enabled } = input
|
|
137
|
+
const caps = registry.listCapabilities()
|
|
138
|
+
const cap = caps.find((c) => c.name === capability)
|
|
139
|
+
if (!cap)
|
|
140
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` })
|
|
141
|
+
if (cap.mode !== 'collection')
|
|
142
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: `"${capability}" is not a collection` })
|
|
143
|
+
if (!cap.providers.includes(addonId)) {
|
|
144
|
+
throw new TRPCError({
|
|
145
|
+
code: 'BAD_REQUEST',
|
|
146
|
+
message: `Provider "${addonId}" is not registered for "${capability}"`,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (enabled) {
|
|
151
|
+
registry.enableCollectionProvider(capability, addonId)
|
|
152
|
+
} else {
|
|
153
|
+
registry.disableCollectionProvider(capability, addonId)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Persist disabled list via the shared canonical writer.
|
|
157
|
+
const updatedCap = registry.listCapabilities().find((c) => c.name === capability)
|
|
158
|
+
persistCollectionDisabled(configService, capability, updatedCap?.disabledProviders ?? [])
|
|
159
|
+
logger.info('Collection provider toggled', {
|
|
160
|
+
tags: { addonId },
|
|
161
|
+
meta: { capability, enabled },
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
return { success: true, requiresRestart: false }
|
|
165
|
+
}),
|
|
149
166
|
|
|
150
167
|
// ─── Reset preference to default ──────────────────────────────────
|
|
151
168
|
|
|
@@ -154,11 +171,17 @@ export function createCapabilitiesRouter(
|
|
|
154
171
|
.mutation(({ input }) => {
|
|
155
172
|
const registry = addonRegistry.getCapabilityRegistry()
|
|
156
173
|
const mode = registry.getMode(input.capability)
|
|
157
|
-
if (!mode)
|
|
174
|
+
if (!mode)
|
|
175
|
+
throw new TRPCError({
|
|
176
|
+
code: 'NOT_FOUND',
|
|
177
|
+
message: `Unknown capability: ${input.capability}`,
|
|
178
|
+
})
|
|
158
179
|
|
|
159
180
|
if (mode === 'singleton') {
|
|
160
181
|
configService.set(`capabilities.singleton.${input.capability}`, null)
|
|
161
|
-
logger.info('Singleton preference reset (takes effect on restart)', {
|
|
182
|
+
logger.info('Singleton preference reset (takes effect on restart)', {
|
|
183
|
+
meta: { capability: input.capability },
|
|
184
|
+
})
|
|
162
185
|
return { success: true, requiresRestart: true }
|
|
163
186
|
}
|
|
164
187
|
|
|
@@ -171,7 +194,9 @@ export function createCapabilitiesRouter(
|
|
|
171
194
|
}
|
|
172
195
|
}
|
|
173
196
|
configService.set(`capabilities.collection.${input.capability}`, null)
|
|
174
|
-
logger.info('Collection preference reset (all providers re-enabled)', {
|
|
197
|
+
logger.info('Collection preference reset (all providers re-enabled)', {
|
|
198
|
+
meta: { capability: input.capability },
|
|
199
|
+
})
|
|
175
200
|
return { success: true, requiresRestart: false }
|
|
176
201
|
}),
|
|
177
202
|
|
|
@@ -187,7 +212,10 @@ export function createCapabilitiesRouter(
|
|
|
187
212
|
.mutation(({ input }) => {
|
|
188
213
|
const registry = addonRegistry.getCapabilityRegistry()
|
|
189
214
|
registry.setDeviceOverride(input.deviceId, input.capability, input.addonId)
|
|
190
|
-
logger.info('Device capability override set', {
|
|
215
|
+
logger.info('Device capability override set', {
|
|
216
|
+
tags: { deviceId: Number(input.deviceId), addonId: input.addonId },
|
|
217
|
+
meta: { capability: input.capability },
|
|
218
|
+
})
|
|
191
219
|
}),
|
|
192
220
|
|
|
193
221
|
clearDeviceCapability: adminProcedure
|
|
@@ -195,7 +223,10 @@ export function createCapabilitiesRouter(
|
|
|
195
223
|
.mutation(({ input }) => {
|
|
196
224
|
const registry = addonRegistry.getCapabilityRegistry()
|
|
197
225
|
registry.clearDeviceOverride(input.deviceId, input.capability)
|
|
198
|
-
logger.info('Device capability override cleared', {
|
|
226
|
+
logger.info('Device capability override cleared', {
|
|
227
|
+
tags: { deviceId: Number(input.deviceId) },
|
|
228
|
+
meta: { capability: input.capability },
|
|
229
|
+
})
|
|
199
230
|
}),
|
|
200
231
|
|
|
201
232
|
getDeviceCapabilities: adminProcedure
|
|
@@ -208,11 +239,16 @@ export function createCapabilitiesRouter(
|
|
|
208
239
|
}),
|
|
209
240
|
|
|
210
241
|
setDeviceCollectionFilter: adminProcedure
|
|
211
|
-
.input(
|
|
242
|
+
.input(
|
|
243
|
+
z.object({ deviceId: z.string(), capability: z.string(), addonIds: z.array(z.string()) }),
|
|
244
|
+
)
|
|
212
245
|
.mutation(({ input }) => {
|
|
213
246
|
const registry = addonRegistry.getCapabilityRegistry()
|
|
214
247
|
registry.setDeviceCollectionFilter(input.deviceId, input.capability, input.addonIds)
|
|
215
|
-
logger.info('Device collection filter set', {
|
|
248
|
+
logger.info('Device collection filter set', {
|
|
249
|
+
tags: { deviceId: Number(input.deviceId) },
|
|
250
|
+
meta: { capability: input.capability, addonIds: input.addonIds },
|
|
251
|
+
})
|
|
216
252
|
}),
|
|
217
253
|
|
|
218
254
|
clearDeviceCollectionFilter: adminProcedure
|
|
@@ -220,7 +256,10 @@ export function createCapabilitiesRouter(
|
|
|
220
256
|
.mutation(({ input }) => {
|
|
221
257
|
const registry = addonRegistry.getCapabilityRegistry()
|
|
222
258
|
registry.clearDeviceCollectionFilter(input.deviceId, input.capability)
|
|
223
|
-
logger.info('Device collection filter cleared', {
|
|
259
|
+
logger.info('Device collection filter cleared', {
|
|
260
|
+
tags: { deviceId: Number(input.deviceId) },
|
|
261
|
+
meta: { capability: input.capability },
|
|
262
|
+
})
|
|
224
263
|
}),
|
|
225
264
|
})
|
|
226
265
|
}
|