@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.
Files changed (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. 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
+ }