@camstack/server 0.1.3
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/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability admin router — fixed core API (not a capability).
|
|
3
|
+
*
|
|
4
|
+
* Lets admins inspect the capability registry and set system/device
|
|
5
|
+
* overrides. Wraps `CapabilityRegistry` directly (kernel primitive).
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
9
|
+
import type { ConfigService } from '../../core/config/config.service.js'
|
|
10
|
+
import { trpcRouter, adminProcedure } from '../trpc/trpc.middleware.js'
|
|
11
|
+
|
|
12
|
+
export function createCapabilitiesRouter(
|
|
13
|
+
registry: CapabilityRegistry | null,
|
|
14
|
+
config: ConfigService,
|
|
15
|
+
) {
|
|
16
|
+
return trpcRouter({
|
|
17
|
+
listCapabilities: adminProcedure
|
|
18
|
+
.input(z.void())
|
|
19
|
+
.query(() => registry?.listCapabilities() ?? []),
|
|
20
|
+
|
|
21
|
+
getCapability: adminProcedure
|
|
22
|
+
.input(z.object({ name: z.string() }))
|
|
23
|
+
.query(({ input }) => {
|
|
24
|
+
if (!registry) return null
|
|
25
|
+
return registry.listCapabilities().find(
|
|
26
|
+
(c: { name: string }) => c.name === input.name,
|
|
27
|
+
) ?? null
|
|
28
|
+
}),
|
|
29
|
+
|
|
30
|
+
setActiveSingleton: adminProcedure
|
|
31
|
+
.input(z.object({ capability: z.string(), addonId: z.string() }))
|
|
32
|
+
.output(z.void())
|
|
33
|
+
.mutation(async ({ input }) => {
|
|
34
|
+
if (!registry) throw new Error('Capability registry unavailable')
|
|
35
|
+
await registry.setActiveSingleton(input.capability, input.addonId)
|
|
36
|
+
config.update('capabilities', { [input.capability]: input.addonId })
|
|
37
|
+
}),
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Set the enabled set of providers for a collection capability in
|
|
41
|
+
* one shot. Providers already registered on the capability that are
|
|
42
|
+
* NOT in `enabledAddonIds` get disabled; the ones in the list are
|
|
43
|
+
* re-enabled. Other collections are untouched. Intended as the
|
|
44
|
+
* save endpoint for a bulk multi-select UI.
|
|
45
|
+
*/
|
|
46
|
+
setCollectionEnabledProviders: adminProcedure
|
|
47
|
+
.input(z.object({
|
|
48
|
+
capability: z.string(),
|
|
49
|
+
enabledAddonIds: z.array(z.string()),
|
|
50
|
+
}))
|
|
51
|
+
.output(z.void())
|
|
52
|
+
.mutation(({ input }) => {
|
|
53
|
+
if (!registry) throw new Error('Capability registry unavailable')
|
|
54
|
+
const caps = registry.listCapabilities()
|
|
55
|
+
const entry = caps.find((c) => c.name === input.capability)
|
|
56
|
+
if (!entry) throw new Error(`Capability "${input.capability}" not declared`)
|
|
57
|
+
if (entry.mode !== 'collection') {
|
|
58
|
+
throw new Error(`Capability "${input.capability}" is not a collection`)
|
|
59
|
+
}
|
|
60
|
+
const wanted = new Set(input.enabledAddonIds)
|
|
61
|
+
for (const providerId of entry.providers) {
|
|
62
|
+
if (wanted.has(providerId)) {
|
|
63
|
+
registry.enableCollectionProvider(input.capability, providerId)
|
|
64
|
+
} else {
|
|
65
|
+
registry.disableCollectionProvider(input.capability, providerId)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
|
|
70
|
+
setDeviceCapability: adminProcedure
|
|
71
|
+
.input(z.object({ deviceId: z.string(), capability: z.string(), addonId: z.string() }))
|
|
72
|
+
.output(z.void())
|
|
73
|
+
.mutation(({ input }) => {
|
|
74
|
+
if (!registry) throw new Error('Capability registry unavailable')
|
|
75
|
+
registry.setDeviceOverride(input.deviceId, input.capability, input.addonId)
|
|
76
|
+
}),
|
|
77
|
+
|
|
78
|
+
clearDeviceCapability: adminProcedure
|
|
79
|
+
.input(z.object({ deviceId: z.string(), capability: z.string() }))
|
|
80
|
+
.output(z.void())
|
|
81
|
+
.mutation(({ input }) => {
|
|
82
|
+
if (!registry) throw new Error('Capability registry unavailable')
|
|
83
|
+
registry.clearDeviceOverride(input.deviceId, input.capability)
|
|
84
|
+
}),
|
|
85
|
+
|
|
86
|
+
getDeviceCapabilities: adminProcedure
|
|
87
|
+
.input(z.object({ deviceId: z.string() }))
|
|
88
|
+
.output(z.record(z.string(), z.string()))
|
|
89
|
+
.query(({ input }) => {
|
|
90
|
+
if (!registry) return {}
|
|
91
|
+
const overrides = registry.getDeviceOverrides(input.deviceId)
|
|
92
|
+
const result: Record<string, string> = {}
|
|
93
|
+
for (const [cap, addonId] of overrides) {
|
|
94
|
+
result[cap] = addonId
|
|
95
|
+
}
|
|
96
|
+
return result
|
|
97
|
+
}),
|
|
98
|
+
|
|
99
|
+
setDeviceCollectionFilter: adminProcedure
|
|
100
|
+
.input(z.object({
|
|
101
|
+
deviceId: z.string(),
|
|
102
|
+
capability: z.string(),
|
|
103
|
+
addonIds: z.array(z.string()),
|
|
104
|
+
}))
|
|
105
|
+
.output(z.void())
|
|
106
|
+
.mutation(({ input }) => {
|
|
107
|
+
if (!registry) throw new Error('Capability registry unavailable')
|
|
108
|
+
registry.setDeviceCollectionFilter(input.deviceId, input.capability, input.addonIds)
|
|
109
|
+
}),
|
|
110
|
+
|
|
111
|
+
clearDeviceCollectionFilter: adminProcedure
|
|
112
|
+
.input(z.object({ deviceId: z.string(), capability: z.string() }))
|
|
113
|
+
.output(z.void())
|
|
114
|
+
.mutation(({ input }) => {
|
|
115
|
+
if (!registry) throw new Error('Capability registry unavailable')
|
|
116
|
+
registry.clearDeviceCollectionFilter(input.deviceId, input.capability)
|
|
117
|
+
}),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single canonical writer for the collection-capability enable/disable
|
|
3
|
+
* preference.
|
|
4
|
+
*
|
|
5
|
+
* A collection capability can have individual providers disabled by an
|
|
6
|
+
* operator. The disabled-set must survive a hub reboot — otherwise a
|
|
7
|
+
* provider an operator deliberately disabled (TURN relay, an auth or
|
|
8
|
+
* export backend, …) silently re-enables on the next restart.
|
|
9
|
+
*
|
|
10
|
+
* The persistence key/format is `capabilities.collection.<capName>` holding
|
|
11
|
+
* `JSON.stringify({ disabled: string[] })`. This module is the ONE place
|
|
12
|
+
* that writes that key so the format never drifts between the two callers
|
|
13
|
+
* (`capabilities` core router and the `addons` cap's
|
|
14
|
+
* `setCapabilityProviderEnabled`). The kernel `CapabilityRegistry` reads
|
|
15
|
+
* it back at boot through the `CollectionConfigReader` hook wired in
|
|
16
|
+
* `addon-registry.service.ts`.
|
|
17
|
+
*/
|
|
18
|
+
import type { ConfigService } from '../../core/config/config.service'
|
|
19
|
+
|
|
20
|
+
/** ConfigService key holding the persisted disabled-set for a collection cap. */
|
|
21
|
+
export function collectionPreferenceKey(capability: string): string {
|
|
22
|
+
return `capabilities.collection.${capability}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Persist the disabled-addonId set for a collection capability. Pass the
|
|
27
|
+
* authoritative set straight from `registry.listCapabilities()` after the
|
|
28
|
+
* enable/disable mutation so the stored value always mirrors the live
|
|
29
|
+
* registry state.
|
|
30
|
+
*/
|
|
31
|
+
export function persistCollectionDisabled(
|
|
32
|
+
configService: ConfigService,
|
|
33
|
+
capability: string,
|
|
34
|
+
disabled: readonly string[],
|
|
35
|
+
): void {
|
|
36
|
+
configService.set(
|
|
37
|
+
collectionPreferenceKey(capability),
|
|
38
|
+
JSON.stringify({ disabled: [...disabled] }),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventBus proxy router — allows forked workers to emit events to the
|
|
3
|
+
* hub's EventBus via tRPC.
|
|
4
|
+
*
|
|
5
|
+
* Workers call `trpc.eventBusProxy.emit.mutate(event)` from their
|
|
6
|
+
* `context.eventBus.emit()` implementation. The hub-side router
|
|
7
|
+
* deserializes the event and emits it on the real EventBus.
|
|
8
|
+
*
|
|
9
|
+
* Subscribe/getRecent are routed through the existing `live.onEvent`
|
|
10
|
+
* subscription and `events` query routers — no duplication needed here.
|
|
11
|
+
*
|
|
12
|
+
* Introduced in session 7 (EventBus wiring for forked addons).
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod'
|
|
15
|
+
import type { IEventBus } from '@camstack/types'
|
|
16
|
+
import { trpcRouter, protectedProcedure } from '../trpc/trpc.middleware.js'
|
|
17
|
+
|
|
18
|
+
const SystemEventInputSchema = z.object({
|
|
19
|
+
id: z.string(),
|
|
20
|
+
timestamp: z.string(), // ISO 8601 — converted to Date on emit
|
|
21
|
+
source: z.object({
|
|
22
|
+
type: z.string(),
|
|
23
|
+
id: z.string(),
|
|
24
|
+
}),
|
|
25
|
+
category: z.string(),
|
|
26
|
+
data: z.record(z.string(), z.unknown()),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export function createEventBusProxyRouter(eventBus: IEventBus) {
|
|
30
|
+
return trpcRouter({
|
|
31
|
+
emit: protectedProcedure
|
|
32
|
+
.input(SystemEventInputSchema)
|
|
33
|
+
.output(z.object({ ok: z.literal(true) }))
|
|
34
|
+
.mutation(({ input }) => {
|
|
35
|
+
eventBus.emit({
|
|
36
|
+
id: input.id,
|
|
37
|
+
timestamp: new Date(input.timestamp),
|
|
38
|
+
source: { type: input.source.type, id: input.source.id },
|
|
39
|
+
category: input.category,
|
|
40
|
+
data: input.data,
|
|
41
|
+
})
|
|
42
|
+
return { ok: true as const }
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hwaccel router — fixed core API (not a capability).
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around the per-node `$hwaccel.resolve` Moleculer
|
|
5
|
+
* service. The hub-local instance (same process) serves the default
|
|
6
|
+
* `resolve` call; the per-node `resolveForNode` action targets any
|
|
7
|
+
* node in the cluster — used by the admin UI pipeline / NodeDetail
|
|
8
|
+
* pages to show the hwaccel backends available on each agent.
|
|
9
|
+
*
|
|
10
|
+
* Cross-node behaviour: `resolveForNode({ nodeId })` forwards via
|
|
11
|
+
* `broker.call('$hwaccel.resolve', params, { nodeID })`. Every node
|
|
12
|
+
* (hub + forked worker + remote agent) registers `$hwaccel` at
|
|
13
|
+
* bootstrap, so any reachable nodeId works.
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod'
|
|
16
|
+
import type { ServiceBroker } from 'moleculer'
|
|
17
|
+
import type { HwAccelResolution } from '@camstack/types'
|
|
18
|
+
import { trpcRouter, adminProcedure } from '../trpc/trpc.middleware.js'
|
|
19
|
+
|
|
20
|
+
const HwAccelBackendSchema = z.enum([
|
|
21
|
+
'videotoolbox', 'cuda', 'nvdec', 'vaapi', 'qsv',
|
|
22
|
+
'd3d11va', 'dxva2', 'amf', 'vdpau', 'drm',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
const HwAccelPreferSchema = z.union([HwAccelBackendSchema, z.literal('none')]).nullable().optional()
|
|
26
|
+
|
|
27
|
+
const HwAccelResolutionSchema = z.object({
|
|
28
|
+
preferred: z.array(HwAccelBackendSchema).readonly(),
|
|
29
|
+
rationale: z.string(),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export function createHwAccelRouter(broker: ServiceBroker | null) {
|
|
33
|
+
return trpcRouter({
|
|
34
|
+
/** Probe the current hub process. */
|
|
35
|
+
resolve: adminProcedure
|
|
36
|
+
.input(z.object({ prefer: HwAccelPreferSchema }).optional())
|
|
37
|
+
.output(HwAccelResolutionSchema)
|
|
38
|
+
.query(async ({ input }) => {
|
|
39
|
+
if (!broker) throw new Error('Moleculer broker not available')
|
|
40
|
+
const params = { prefer: input?.prefer ?? null }
|
|
41
|
+
return broker.call('$hwaccel.resolve', params) as Promise<HwAccelResolution>
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
/** Probe a specific node in the cluster — used by per-agent UI cards. */
|
|
45
|
+
resolveForNode: adminProcedure
|
|
46
|
+
.input(z.object({ nodeId: z.string(), prefer: HwAccelPreferSchema }))
|
|
47
|
+
.output(HwAccelResolutionSchema)
|
|
48
|
+
.query(async ({ input }) => {
|
|
49
|
+
if (!broker) throw new Error('Moleculer broker not available')
|
|
50
|
+
const params = { prefer: input.prefer ?? null }
|
|
51
|
+
return broker.call('$hwaccel.resolve', params, { nodeID: input.nodeId }) as Promise<HwAccelResolution>
|
|
52
|
+
}),
|
|
53
|
+
|
|
54
|
+
/** List every node currently reachable and the hwaccel each resolves to. */
|
|
55
|
+
resolveAll: adminProcedure
|
|
56
|
+
.output(z.array(z.object({
|
|
57
|
+
nodeId: z.string(),
|
|
58
|
+
resolution: HwAccelResolutionSchema,
|
|
59
|
+
})))
|
|
60
|
+
.query(async () => {
|
|
61
|
+
if (!broker) throw new Error('Moleculer broker not available')
|
|
62
|
+
const registry = (broker as unknown as { registry: { getNodeList: (opts: { onlyAvailable: boolean }) => readonly { id: string }[] } }).registry
|
|
63
|
+
const nodes = registry.getNodeList({ onlyAvailable: true })
|
|
64
|
+
const results = await Promise.all(
|
|
65
|
+
nodes.map(async (n) => {
|
|
66
|
+
try {
|
|
67
|
+
const resolution = await broker.call('$hwaccel.resolve', { prefer: null }, { nodeID: n.id }) as HwAccelResolution
|
|
68
|
+
return { nodeId: n.id, resolution }
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
71
|
+
return {
|
|
72
|
+
nodeId: n.id,
|
|
73
|
+
resolution: { preferred: [] as const, rationale: `resolve failed: ${msg}` },
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}),
|
|
77
|
+
)
|
|
78
|
+
return results
|
|
79
|
+
}),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live events router — fixed core API (not a capability).
|
|
3
|
+
*
|
|
4
|
+
* Higher-level live-subscription API consumed by the admin UI dashboard.
|
|
5
|
+
* Wraps `EventBusService` with per-device permission enforcement.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import type { EventBusService } from '../../core/events/event-bus.service.js'
|
|
9
|
+
import type { AddonRegistryService } from '../../core/addon/addon-registry.service.js'
|
|
10
|
+
import { trpcRouter, protectedProcedure, iterableSubscription } from '../trpc/trpc.middleware.js'
|
|
11
|
+
|
|
12
|
+
export function createLiveEventsRouter(
|
|
13
|
+
eb: EventBusService,
|
|
14
|
+
ar: AddonRegistryService,
|
|
15
|
+
) {
|
|
16
|
+
return trpcRouter({
|
|
17
|
+
recentSystemEvents: protectedProcedure
|
|
18
|
+
.input(z.object({
|
|
19
|
+
category: z.string().optional(),
|
|
20
|
+
limit: z.number().optional(),
|
|
21
|
+
}).optional())
|
|
22
|
+
.query(({ input }) => eb.getRecent(input ?? {}, input?.limit ?? 50)),
|
|
23
|
+
|
|
24
|
+
onEvent: protectedProcedure
|
|
25
|
+
.input(z.object({ category: z.string().optional() }))
|
|
26
|
+
.subscription(({ input }) => {
|
|
27
|
+
return iterableSubscription<{ id: string; timestamp: Date; source: { type: string; id: string | number }; category: string; data: Record<string, unknown> }>((push) => {
|
|
28
|
+
const filter: Record<string, unknown> = {}
|
|
29
|
+
if (input.category) filter.category = input.category
|
|
30
|
+
return eb.subscribe(filter, push)
|
|
31
|
+
})
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
onDeviceEvent: protectedProcedure
|
|
35
|
+
.input(z.object({ deviceId: z.number() }))
|
|
36
|
+
.subscription(({ input, ctx }) => {
|
|
37
|
+
return iterableSubscription<unknown>((push) => {
|
|
38
|
+
const deviceRegistry = ar.getDeviceRegistry()
|
|
39
|
+
const device = deviceRegistry.getById(input.deviceId)
|
|
40
|
+
if (!device) throw new Error(`Device id=${input.deviceId} not found`)
|
|
41
|
+
// Permission check — new IDevice has no providerId; admin bypass only
|
|
42
|
+
if (!ctx.user.isAdmin) {
|
|
43
|
+
const ad = ctx.user.permissions?.allowedDevices
|
|
44
|
+
if (!ad) throw new Error('Access denied')
|
|
45
|
+
// Find which addon owns this device to determine the provider key
|
|
46
|
+
const ownerAddonId = deviceRegistry.getAddonId(input.deviceId)
|
|
47
|
+
if (!ownerAddonId) throw new Error('Access denied')
|
|
48
|
+
const pd = ad[ownerAddonId]
|
|
49
|
+
// Permission lists are still keyed by stableId — that's the
|
|
50
|
+
// external admin-facing identifier. Resolve the device's
|
|
51
|
+
// stableId for the membership check.
|
|
52
|
+
if (!pd || (pd !== '*' && !pd.includes(device.stableId))) {
|
|
53
|
+
throw new Error('Access denied')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return eb.subscribe({ source: { type: 'device', id: input.deviceId } }, push)
|
|
57
|
+
})
|
|
58
|
+
}),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logs router — fixed core API (not a capability).
|
|
3
|
+
*
|
|
4
|
+
* Queries and streams log entries via `LoggingService` (thin NestJS DI
|
|
5
|
+
* wrapper over `LogManager` from `@camstack/core`). Admin can read
|
|
6
|
+
* everything; viewers/protected users only see entries matching their
|
|
7
|
+
* allowed providers scope (filtered in `query`).
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import type { LoggingService } from '../../core/logging/logging.service.js'
|
|
11
|
+
import { trpcRouter, protectedProcedure, adminProcedure, iterableSubscription } from '../trpc/trpc.middleware.js'
|
|
12
|
+
import type { LogEntry } from '@camstack/types'
|
|
13
|
+
|
|
14
|
+
const LogLevelSchema = z.enum(['debug', 'info', 'warn', 'error'])
|
|
15
|
+
|
|
16
|
+
const LogTagsSchema = z.object({
|
|
17
|
+
agentId: z.string().optional(),
|
|
18
|
+
nodeId: z.string().optional(),
|
|
19
|
+
/** Numeric progressive id (or its string form for legacy callers). */
|
|
20
|
+
deviceId: z.union([z.string(), z.number()]).optional(),
|
|
21
|
+
deviceName: z.string().optional(),
|
|
22
|
+
integrationId: z.string().optional(),
|
|
23
|
+
addonId: z.string().optional(),
|
|
24
|
+
streamId: z.string().optional(),
|
|
25
|
+
streamName: z.string().optional(),
|
|
26
|
+
sessionId: z.string().optional(),
|
|
27
|
+
/**
|
|
28
|
+
* Correlation id used to scope a single short-lived operation
|
|
29
|
+
* (e.g. a `testCreationField` probe). The Add-Device modal generates
|
|
30
|
+
* one per dialog open and providers tag their loggers with it so the
|
|
31
|
+
* modal can stream test logs without a deviceId (the device doesn't
|
|
32
|
+
* exist yet).
|
|
33
|
+
*/
|
|
34
|
+
requestId: z.string().optional(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** Wire shape of a log entry — timestamp is serialised to ISO string by tRPC. */
|
|
38
|
+
const LogEntrySchema = z.object({
|
|
39
|
+
timestamp: z.string(),
|
|
40
|
+
level: z.string(),
|
|
41
|
+
scope: z.string().optional(),
|
|
42
|
+
message: z.string(),
|
|
43
|
+
tags: z.record(z.string(), z.string().optional()).optional(),
|
|
44
|
+
/**
|
|
45
|
+
* Ad-hoc structured payload attached to the log call (e.g.
|
|
46
|
+
* `{error, brokerId, sourceType, url}` for stream-broker errors).
|
|
47
|
+
* Tags are filterable structural fields; meta is the per-call
|
|
48
|
+
* payload operators read in the expanded LogStream row.
|
|
49
|
+
*/
|
|
50
|
+
meta: z.record(z.string(), z.unknown()).optional(),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const LogEntryArraySchema = z.array(LogEntrySchema)
|
|
54
|
+
|
|
55
|
+
export function createLogsRouter(logging: LoggingService) {
|
|
56
|
+
return trpcRouter({
|
|
57
|
+
query: adminProcedure
|
|
58
|
+
.input(z.object({
|
|
59
|
+
level: LogLevelSchema.optional(),
|
|
60
|
+
since: z.number().optional(),
|
|
61
|
+
until: z.number().optional(),
|
|
62
|
+
limit: z.number().optional(),
|
|
63
|
+
tags: LogTagsSchema.optional(),
|
|
64
|
+
}))
|
|
65
|
+
.output(LogEntryArraySchema)
|
|
66
|
+
.query(({ input, ctx }) => {
|
|
67
|
+
const filter: Record<string, unknown> = { limit: input.limit ?? 100 }
|
|
68
|
+
if (input.level) filter.level = input.level
|
|
69
|
+
if (input.since !== undefined) filter.since = new Date(input.since)
|
|
70
|
+
if (input.until !== undefined) filter.until = new Date(input.until)
|
|
71
|
+
if (input.tags) filter.tags = input.tags
|
|
72
|
+
const entries: LogEntry[] = logging.query(filter)
|
|
73
|
+
const filtered = (() => {
|
|
74
|
+
if (!ctx.user.isAdmin) {
|
|
75
|
+
const ap = ctx.user.permissions?.allowedProviders
|
|
76
|
+
if (ap && ap !== '*') {
|
|
77
|
+
const allowed = ap as string[]
|
|
78
|
+
return entries.filter(e => {
|
|
79
|
+
const addonId = e.tags?.addonId
|
|
80
|
+
if (typeof addonId !== 'string') return false
|
|
81
|
+
return allowed.some(p => {
|
|
82
|
+
const bare = p.startsWith('addon:') ? p.slice('addon:'.length) : p
|
|
83
|
+
return addonId === bare || addonId.startsWith(bare)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return entries
|
|
89
|
+
})()
|
|
90
|
+
return filtered.map(e => ({
|
|
91
|
+
timestamp: e.timestamp instanceof Date ? e.timestamp.toISOString() : String(e.timestamp),
|
|
92
|
+
level: e.level,
|
|
93
|
+
...(e.scope !== undefined ? { scope: e.scope } : {}),
|
|
94
|
+
message: e.message,
|
|
95
|
+
// Tags carry mixed primitive types (numeric deviceId is common —
|
|
96
|
+
// see kernel/capability-registry.ts logging calls). The wire
|
|
97
|
+
// schema is Record<string,string>, so stringify here. Mirrors
|
|
98
|
+
// the same normalisation applied in `subscribe` below.
|
|
99
|
+
tags: e.tags
|
|
100
|
+
? Object.fromEntries(
|
|
101
|
+
Object.entries(e.tags).map(([k, v]) => [k, v === undefined ? undefined : String(v)]),
|
|
102
|
+
)
|
|
103
|
+
: undefined,
|
|
104
|
+
...(e.meta !== undefined ? { meta: e.meta } : {}),
|
|
105
|
+
}))
|
|
106
|
+
}),
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Drop matching entries from the in-memory ring buffer. Powers
|
|
110
|
+
* the UI's "Clear logs" button — without server-side purge, the
|
|
111
|
+
* cleared rows reappear on the next page open from the historical
|
|
112
|
+
* `query`. Admin-only so a viewer can't wipe context other ops
|
|
113
|
+
* are reading.
|
|
114
|
+
*/
|
|
115
|
+
clear: adminProcedure
|
|
116
|
+
.input(z.object({
|
|
117
|
+
level: LogLevelSchema.optional(),
|
|
118
|
+
since: z.number().optional(),
|
|
119
|
+
until: z.number().optional(),
|
|
120
|
+
tags: LogTagsSchema.optional(),
|
|
121
|
+
}))
|
|
122
|
+
.output(z.object({ removed: z.number().int().nonnegative() }))
|
|
123
|
+
.mutation(({ input }) => {
|
|
124
|
+
const filter: Record<string, unknown> = {}
|
|
125
|
+
if (input.level) filter.level = input.level
|
|
126
|
+
if (input.since !== undefined) filter.since = new Date(input.since)
|
|
127
|
+
if (input.until !== undefined) filter.until = new Date(input.until)
|
|
128
|
+
if (input.tags) filter.tags = input.tags
|
|
129
|
+
const removed = logging.clear(filter)
|
|
130
|
+
return { removed }
|
|
131
|
+
}),
|
|
132
|
+
|
|
133
|
+
subscribe: protectedProcedure
|
|
134
|
+
.input(z.object({
|
|
135
|
+
level: LogLevelSchema.optional(),
|
|
136
|
+
tags: LogTagsSchema.optional(),
|
|
137
|
+
}))
|
|
138
|
+
.subscription(({ input }) => {
|
|
139
|
+
return iterableSubscription<unknown>((push) => {
|
|
140
|
+
return logging.subscribe(
|
|
141
|
+
{
|
|
142
|
+
level: input.level,
|
|
143
|
+
tags: input.tags as Record<string, string> | undefined,
|
|
144
|
+
},
|
|
145
|
+
(entry: { timestamp: Date | string | number; level: string; message: string; scope?: string; tags?: Record<string, string | number | undefined>; meta?: Record<string, unknown> }) => {
|
|
146
|
+
const stringifiedTags = entry.tags
|
|
147
|
+
? Object.fromEntries(Object.entries(entry.tags).map(([k, v]) => [k, v === undefined ? undefined : String(v)]))
|
|
148
|
+
: undefined
|
|
149
|
+
push({
|
|
150
|
+
timestamp: entry.timestamp instanceof Date ? entry.timestamp.toISOString() : String(entry.timestamp),
|
|
151
|
+
level: entry.level,
|
|
152
|
+
message: entry.message,
|
|
153
|
+
scope: entry.scope,
|
|
154
|
+
tags: stringifiedTags,
|
|
155
|
+
meta: entry.meta,
|
|
156
|
+
})
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
}),
|
|
161
|
+
})
|
|
162
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifications router — fixed core API (not a capability).
|
|
3
|
+
*
|
|
4
|
+
* Routes events by category to registered outputs. The NotificationService
|
|
5
|
+
* is a singleton from @camstack/core; individual outputs are still provided
|
|
6
|
+
* by addons via the `notification-output` capability collection.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import type { NotificationService } from '@camstack/core'
|
|
10
|
+
import { trpcRouter, protectedProcedure, adminProcedure } from '../trpc/trpc.middleware.js'
|
|
11
|
+
|
|
12
|
+
const NotificationOutputSchema = z.object({
|
|
13
|
+
id: z.string(),
|
|
14
|
+
name: z.string(),
|
|
15
|
+
icon: z.string(),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const SendTestResultSchema = z.object({
|
|
19
|
+
success: z.boolean(),
|
|
20
|
+
error: z.string().optional(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export function createNotificationsRouter(ns: NotificationService | null) {
|
|
24
|
+
return trpcRouter({
|
|
25
|
+
listOutputs: protectedProcedure
|
|
26
|
+
.input(z.void())
|
|
27
|
+
.output(z.array(NotificationOutputSchema).readonly())
|
|
28
|
+
.query(() => {
|
|
29
|
+
if (!ns) return []
|
|
30
|
+
return ns.getOutputs()
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
getRouting: protectedProcedure
|
|
34
|
+
.input(z.void())
|
|
35
|
+
.output(z.record(z.string(), z.array(z.string())))
|
|
36
|
+
.query(() => {
|
|
37
|
+
if (!ns) return {}
|
|
38
|
+
const routing = ns.getRouting()
|
|
39
|
+
const result: Record<string, string[]> = {}
|
|
40
|
+
for (const [category, outputIds] of routing) {
|
|
41
|
+
result[category] = [...outputIds]
|
|
42
|
+
}
|
|
43
|
+
return result
|
|
44
|
+
}),
|
|
45
|
+
|
|
46
|
+
setRouting: adminProcedure
|
|
47
|
+
.input(z.object({ category: z.string(), outputIds: z.array(z.string()) }))
|
|
48
|
+
.output(z.void())
|
|
49
|
+
.mutation(({ input }) => {
|
|
50
|
+
if (!ns) throw new Error('Notification service unavailable')
|
|
51
|
+
ns.setRouting(input.category, input.outputIds)
|
|
52
|
+
}),
|
|
53
|
+
|
|
54
|
+
sendTest: adminProcedure
|
|
55
|
+
.input(z.object({ outputId: z.string() }))
|
|
56
|
+
.output(SendTestResultSchema)
|
|
57
|
+
.mutation(async ({ input }) => {
|
|
58
|
+
if (!ns) return { success: false, error: 'Notification service unavailable' }
|
|
59
|
+
const output = ns.getOutput(input.outputId)
|
|
60
|
+
if (!output) return { success: false, error: `Output "${input.outputId}" not found` }
|
|
61
|
+
if (!output.sendTest) return { success: false, error: 'Output does not support test notifications' }
|
|
62
|
+
return output.sendTest()
|
|
63
|
+
}),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REPL router — fixed core API (not a capability).
|
|
3
|
+
*
|
|
4
|
+
* The REPL engine is a single NestJS-backed service in the backend
|
|
5
|
+
* (`ReplEngineService` extends `ReplEngine` from `@camstack/core`).
|
|
6
|
+
* Only super_admin can execute code.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import type { ReplEngineService } from '../../core/repl/repl-engine.service.js'
|
|
10
|
+
import { trpcRouter, adminProcedure } from '../trpc/trpc.middleware.js'
|
|
11
|
+
|
|
12
|
+
const ReplScopeSchema = z.discriminatedUnion('type', [
|
|
13
|
+
z.object({ type: z.literal('system') }),
|
|
14
|
+
z.object({ type: z.literal('device'), deviceId: z.number() }),
|
|
15
|
+
z.object({ type: z.literal('provider'), providerId: z.string() }),
|
|
16
|
+
z.object({ type: z.literal('addon'), addonId: z.string() }),
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
const ReplResultSchema = z.object({
|
|
20
|
+
output: z.string(),
|
|
21
|
+
error: z.string().optional(),
|
|
22
|
+
duration: z.number().optional(),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export function createReplRouter(repl: ReplEngineService) {
|
|
26
|
+
return trpcRouter({
|
|
27
|
+
execute: adminProcedure
|
|
28
|
+
.input(z.object({ code: z.string().max(10000), scope: ReplScopeSchema }))
|
|
29
|
+
.output(ReplResultSchema)
|
|
30
|
+
.mutation(({ input }) =>
|
|
31
|
+
repl.execute(input.code, { scope: input.scope, variables: {} }),
|
|
32
|
+
),
|
|
33
|
+
|
|
34
|
+
completions: adminProcedure
|
|
35
|
+
.input(z.object({ partial: z.string(), scope: ReplScopeSchema }))
|
|
36
|
+
.output(z.array(z.string()).readonly())
|
|
37
|
+
.query(({ input }) =>
|
|
38
|
+
repl.getCompletions(input.partial, { scope: input.scope, variables: {} }),
|
|
39
|
+
),
|
|
40
|
+
})
|
|
41
|
+
}
|