@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,90 @@
1
+ import { LoggingService } from '../logging/logging.service'
2
+ import { errMsg } from '@camstack/types'
3
+
4
+ interface NpmSearchResult {
5
+ name: string
6
+ version: string
7
+ description: string
8
+ keywords: string[]
9
+ date: string
10
+ publisher: { username: string }
11
+ }
12
+
13
+ interface AddonSearchResult {
14
+ name: string
15
+ version: string
16
+ description: string
17
+ keywords: string[]
18
+ publishedAt: string
19
+ author: string
20
+ installed: boolean
21
+ installedVersion?: string
22
+ }
23
+
24
+ export class AddonSearchService {
25
+ private cache: { results: NpmSearchResult[]; timestamp: number } | null = null
26
+ private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
27
+
28
+ constructor(private readonly loggingService: LoggingService) {}
29
+
30
+ async searchAddons(query?: string): Promise<AddonSearchResult[]> {
31
+ const npmResults = await this.fetchFromNpm()
32
+
33
+ // Filter by additional query if provided
34
+ let filtered = npmResults
35
+ if (query) {
36
+ const q = query.toLowerCase()
37
+ filtered = npmResults.filter(
38
+ (r) =>
39
+ r.name.toLowerCase().includes(q) ||
40
+ r.description?.toLowerCase().includes(q) ||
41
+ r.keywords?.some((k) => k.toLowerCase().includes(q)),
42
+ )
43
+ }
44
+
45
+ // Return without install status — the caller can merge with installed list
46
+ return filtered.map((r) => ({
47
+ name: r.name,
48
+ version: r.version,
49
+ description: r.description ?? '',
50
+ keywords: r.keywords ?? [],
51
+ publishedAt: r.date ?? '',
52
+ author: r.publisher?.username ?? '',
53
+ installed: false, // caller will enrich this
54
+ installedVersion: undefined,
55
+ }))
56
+ }
57
+
58
+ private async fetchFromNpm(): Promise<NpmSearchResult[]> {
59
+ // Return from cache if still fresh
60
+ if (this.cache && Date.now() - this.cache.timestamp < this.CACHE_TTL_MS) {
61
+ return this.cache.results
62
+ }
63
+
64
+ // npm registry v1 search: packages with BOTH "camstack" and "addon" keywords
65
+ const url = 'https://registry.npmjs.org/-/v1/search?text=keywords:camstack+keywords:addon&size=250'
66
+
67
+ try {
68
+ const response = await fetch(url, {
69
+ headers: { Accept: 'application/json' },
70
+ signal: AbortSignal.timeout(10000),
71
+ })
72
+
73
+ if (!response.ok) {
74
+ throw new Error(`npm search failed: ${response.status}`)
75
+ }
76
+
77
+ const data = (await response.json()) as { objects: Array<{ package: NpmSearchResult }> }
78
+ const results = data.objects.map((o) => o.package)
79
+
80
+ this.cache = { results, timestamp: Date.now() }
81
+
82
+ return results
83
+ } catch (err) {
84
+ const logger = this.loggingService.createLogger('addon-search')
85
+ logger.warn('npm search failed', { meta: { error: errMsg(err) } })
86
+ // Stale cache is better than nothing on transient network failure
87
+ return this.cache?.results ?? []
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Hub-side singleton provider for the `addon-settings` capability.
3
+ *
4
+ * Acts as a gateway: resolves `addonId` to the target addon and
5
+ * delegates the settings call. For hub-local addons, calls the
6
+ * addon instance directly. For remote agents, proxies via the
7
+ * per-addon Moleculer service (`<addonId>.settings.<method>`).
8
+ *
9
+ * Replaces the `$addonHost` Moleculer service.
10
+ */
11
+ import type { ICamstackAddon, ConfigUISchemaWithValues } from '@camstack/types'
12
+ import type { IAddonSettingsProvider } from '@camstack/types'
13
+
14
+ /**
15
+ * Minimal structural view of the Moleculer broker this provider needs
16
+ * (`call` + `registry`). Typed locally — mirroring the
17
+ * `ReconcileBrokerLike` pattern in `agent-registry.service.ts` —
18
+ * because the lint type-checker cannot resolve Moleculer's
19
+ * `ServiceBroker` type, which left every `broker.*` access `any`-typed.
20
+ */
21
+ interface SettingsBroker {
22
+ readonly registry: unknown
23
+ call(
24
+ action: string,
25
+ params: Record<string, unknown>,
26
+ opts?: { readonly nodeID?: string; readonly timeout?: number },
27
+ ): Promise<unknown>
28
+ }
29
+
30
+ // ── Dependency interfaces ────────────────────────────────────────────
31
+
32
+ /** Resolve an addon by id. Returns null if not loaded on this node. */
33
+ type AddonLookup = (addonId: string) => ICamstackAddon | null
34
+
35
+ /** Resolve which node hosts a given addon. Returns 'hub' for local. */
36
+ type NodeResolver = (addonId: string) => string
37
+
38
+ // ── Input types (match the cap definition's z.infer) ─────────────────
39
+
40
+ interface AddonIdInput {
41
+ readonly addonId: string
42
+ readonly nodeId?: string
43
+ readonly overlay?: Record<string, unknown>
44
+ }
45
+
46
+ interface AddonPatchInput {
47
+ readonly addonId: string
48
+ readonly nodeId?: string
49
+ readonly patch: Record<string, unknown>
50
+ }
51
+
52
+ interface DeviceGetInput {
53
+ readonly addonId: string
54
+ readonly deviceId: number
55
+ readonly nodeId?: string
56
+ }
57
+
58
+ interface DeviceUpdateInput {
59
+ readonly addonId: string
60
+ readonly deviceId: number
61
+ readonly nodeId?: string
62
+ readonly patch: Record<string, unknown>
63
+ }
64
+
65
+ interface SettingsUpdateResult {
66
+ readonly success: true
67
+ }
68
+
69
+ // ── Reshape helper ───────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Deep-copy a `ConfigUISchemaWithValues` into mutable-array shape for
73
+ * Zod output. The domain type uses `readonly` arrays; Zod's inferred
74
+ * output uses mutable arrays. Structurally identical at runtime.
75
+ */
76
+ interface ReshapedSchema {
77
+ tabs?: Array<{ id: string; label: string; icon: string; order?: number }>
78
+ sections: Array<{
79
+ id: string
80
+ title: string
81
+ description?: string
82
+ style?: 'card' | 'accordion'
83
+ defaultCollapsed?: boolean
84
+ columns?: 1 | 2 | 3 | 4
85
+ tab?: string
86
+ order?: number
87
+ fields: unknown[]
88
+ }>
89
+ }
90
+
91
+ function reshapeForOutput(schema: ConfigUISchemaWithValues): ReshapedSchema {
92
+ return {
93
+ tabs: schema.tabs
94
+ ? schema.tabs.map((t) => ({ id: t.id, label: t.label, icon: t.icon, order: t.order }))
95
+ : undefined,
96
+ sections: schema.sections.map((s) => ({
97
+ id: s.id,
98
+ title: s.title,
99
+ description: s.description,
100
+ style: s.style,
101
+ defaultCollapsed: s.defaultCollapsed,
102
+ columns: s.columns,
103
+ tab: s.tab,
104
+ order: s.order,
105
+ fields: [...s.fields],
106
+ })),
107
+ }
108
+ }
109
+
110
+ /**
111
+ * True for Moleculer's `ServiceNotFoundError` — raised when a
112
+ * `<addonId>.settings.*` action is called before the owning forked
113
+ * addon has registered its service (still booting, or wedged in
114
+ * `onInitialize`). Matched by `name` so we don't depend on Moleculer's
115
+ * error class being importable here.
116
+ */
117
+ function isServiceNotFoundError(err: unknown): boolean {
118
+ return err instanceof Error && err.name === 'ServiceNotFoundError'
119
+ }
120
+
121
+ // ── Provider factory ─────────────────────────────────────────────────
122
+
123
+ interface AddonSettingsProviderDeps {
124
+ /** Look up a loaded addon by id on this hub node. */
125
+ readonly getAddon: AddonLookup
126
+ /** Resolve which node hosts a given addon. Defaults to 'hub'. */
127
+ readonly resolveNode: NodeResolver
128
+ /** Moleculer broker for remote calls. */
129
+ readonly broker: SettingsBroker
130
+ /** This hub's node ID (for local short-circuit). */
131
+ readonly hubNodeId: string
132
+ }
133
+
134
+ function createAddonSettingsProvider(deps: AddonSettingsProviderDeps): IAddonSettingsProvider {
135
+ const { getAddon, resolveNode, broker, hubNodeId } = deps
136
+
137
+ function isLocal(addonId: string, explicitNodeId?: string): boolean {
138
+ // `resolveNode` is consulted FIRST: it knows whether the addon
139
+ // runs in-process on the hub or as a forkable worker. An explicit
140
+ // `nodeId: 'hub'` from the caller is treated as "target the hub
141
+ // cluster" — NOT "force local dispatch" — so for forkable addons
142
+ // we still route via Moleculer to `hub/<addonId>`. Without this
143
+ // check the hub-side stub (which never had `initialize()` run and
144
+ // has no `_ctx.settings`) would respond with defaults only and
145
+ // silently drop every `updateGlobalSettings` patch.
146
+ const resolved = resolveNode(addonId)
147
+ if (resolved !== 'hub' && resolved !== hubNodeId) return false
148
+ const nodeId = explicitNodeId ?? resolved
149
+ return nodeId === 'hub' || nodeId === hubNodeId
150
+ }
151
+
152
+ // ── Remote call helpers ──────────────────────────────────────────
153
+
154
+ // Resolve the Moleculer nodeID that actually hosts an addon's
155
+ // `<addonId>.settings.*` service. Forkable addons register under
156
+ // `${baseNodeId}/${addonId}`; in-process addons register under the
157
+ // base nodeId itself. Walk the Moleculer registry and pick the
158
+ // first node whose id matches either convention.
159
+ function resolveWorkerNodeId(addonId: string, baseNodeId: string): string | null {
160
+ const registry = broker.registry
161
+ const services = (registry as unknown as {
162
+ getServiceList: (opts: { onlyAvailable: boolean }) => readonly { name: string; nodeID: string }[]
163
+ }).getServiceList({ onlyAvailable: true })
164
+ // Find the node that actually hosts the `<addonId>` service (the
165
+ // moleculer service factory registers addons under their id). The
166
+ // baseNodeId is a hint — we trust the registry's ground truth
167
+ // instead. Forkable addons live at `<parent>/<addonId>`;
168
+ // in-process addons live at `<parent>`.
169
+ const exactNode = `${baseNodeId}/${addonId}`
170
+ const preferred = services.find((s) => s.name === addonId && s.nodeID === exactNode)
171
+ if (preferred) return preferred.nodeID
172
+ const anyForBase = services.find((s) => s.name === addonId && s.nodeID === baseNodeId)
173
+ if (anyForBase) return anyForBase.nodeID
174
+ const anyWithName = services.find((s) => s.name === addonId)
175
+ return anyWithName?.nodeID ?? null
176
+ }
177
+
178
+ async function remoteGet(
179
+ addonId: string,
180
+ nodeId: string,
181
+ action: string,
182
+ extraParams?: Record<string, unknown>,
183
+ ): Promise<ReshapedSchema | null> {
184
+ const params = { ...extraParams }
185
+ const workerNodeId = resolveWorkerNodeId(addonId, nodeId)
186
+ try {
187
+ const result: unknown = await broker.call(
188
+ `${addonId}.settings.${action}`,
189
+ params,
190
+ workerNodeId ? { nodeID: workerNodeId, timeout: 10_000 } : { timeout: 10_000 },
191
+ )
192
+ return result ? reshapeForOutput(result as ConfigUISchemaWithValues) : null
193
+ } catch (err) {
194
+ // A forked addon that is still booting (or wedged in
195
+ // `onInitialize`) has not yet registered its
196
+ // `<addonId>.settings.*` Moleculer service. A settings READ
197
+ // against a missing service should degrade to "no schema" so the
198
+ // panel shows its empty state — NOT bubble a 500 to the operator.
199
+ if (isServiceNotFoundError(err)) return null
200
+ throw err
201
+ }
202
+ }
203
+
204
+ async function remoteUpdate(
205
+ addonId: string,
206
+ nodeId: string,
207
+ action: string,
208
+ extraParams: Record<string, unknown>,
209
+ ): Promise<SettingsUpdateResult> {
210
+ const workerNodeId = resolveWorkerNodeId(addonId, nodeId)
211
+ await broker.call(
212
+ `${addonId}.settings.${action}`,
213
+ extraParams,
214
+ workerNodeId ? { nodeID: workerNodeId, timeout: 10_000 } : { timeout: 10_000 },
215
+ )
216
+ return { success: true as const }
217
+ }
218
+
219
+ // ── Provider implementation ──────────────────────────────────────
220
+
221
+ return {
222
+ async getGlobalSettings(input: AddonIdInput) {
223
+ if (isLocal(input.addonId, input.nodeId)) {
224
+ const addon = getAddon(input.addonId)
225
+ if (!addon || typeof addon.getGlobalSettings !== 'function') return null
226
+ const result = await addon.getGlobalSettings(input.overlay)
227
+ return result ? reshapeForOutput(result) : null
228
+ }
229
+ const nodeId = input.nodeId ?? resolveNode(input.addonId)
230
+ return remoteGet(input.addonId, nodeId, 'getGlobalSettings', { ...(input.overlay ? { overlay: input.overlay } : {}) })
231
+ },
232
+
233
+ async updateGlobalSettings(input: AddonPatchInput) {
234
+ if (isLocal(input.addonId, input.nodeId)) {
235
+ const addon = getAddon(input.addonId)
236
+ if (!addon || typeof addon.updateGlobalSettings !== 'function') {
237
+ throw new Error(`Addon "${input.addonId}" does not implement updateGlobalSettings`)
238
+ }
239
+ await addon.updateGlobalSettings(input.patch)
240
+ return { success: true as const }
241
+ }
242
+ const nodeId = input.nodeId ?? resolveNode(input.addonId)
243
+ return remoteUpdate(input.addonId, nodeId, 'updateGlobalSettings', { patch: input.patch })
244
+ },
245
+
246
+ async getDeviceSettings(input: DeviceGetInput) {
247
+ if (isLocal(input.addonId, input.nodeId)) {
248
+ const addon = getAddon(input.addonId)
249
+ if (!addon || typeof addon.getDeviceSettings !== 'function') return null
250
+ const result = await addon.getDeviceSettings(input.deviceId)
251
+ return result ? reshapeForOutput(result) : null
252
+ }
253
+ const nodeId = input.nodeId ?? resolveNode(input.addonId)
254
+ return remoteGet(input.addonId, nodeId, 'getDeviceSettings', { deviceId: input.deviceId })
255
+ },
256
+
257
+ async updateDeviceSettings(input: DeviceUpdateInput) {
258
+ if (isLocal(input.addonId, input.nodeId)) {
259
+ const addon = getAddon(input.addonId)
260
+ if (!addon || typeof addon.updateDeviceSettings !== 'function') {
261
+ throw new Error(`Addon "${input.addonId}" does not implement updateDeviceSettings`)
262
+ }
263
+ await addon.updateDeviceSettings(input.deviceId, input.patch)
264
+ return { success: true as const }
265
+ }
266
+ const nodeId = input.nodeId ?? resolveNode(input.addonId)
267
+ return remoteUpdate(input.addonId, nodeId, 'updateDeviceSettings', {
268
+ deviceId: input.deviceId,
269
+ patch: input.patch,
270
+ })
271
+ },
272
+ }
273
+ }
274
+
275
+ export { createAddonSettingsProvider }
276
+ export type { AddonSettingsProviderDeps }
@@ -0,0 +1,2 @@
1
+ export const ADDON_REGISTRY = Symbol('ADDON_REGISTRY')
2
+ export const BUILTIN_ADDONS = Symbol('BUILTIN_ADDONS')
@@ -0,0 +1,125 @@
1
+ import * as path from 'node:path'
2
+ import { LoggingService } from '../logging/logging.service'
3
+ import type { IScopedLogger } from '@camstack/types'
4
+ import { errMsg } from '@camstack/types'
5
+
6
+ // Dynamically imported to tolerate missing / unbuilt packages at startup
7
+ type AddonLoader = import('@camstack/kernel').AddonLoader
8
+ type AddonInstaller = import('@camstack/kernel').AddonInstaller
9
+ type RegisteredAddon = import('@camstack/kernel').RegisteredAddon
10
+
11
+ /**
12
+ * AddonBridgeService — slim package-management surface.
13
+ *
14
+ * Historically this service also cached per-camera pipeline configs + per-
15
+ * addon config records + hosted an in-process `PipelineRunner` that consumers
16
+ * called via `processFrame` / `processMotionFrame`. Every piece of that
17
+ * plumbing was dead post commit 2c (the runtime scheduler moved to
18
+ * `addon-pipeline-runner`) and the pipeline page redesign finishes the job
19
+ * by deleting the cache, the AddonEngineManager wiring, the bridge-pipeline
20
+ * tRPC router, and the PipelineWiringModule that bolted the legacy addon
21
+ * resolver onto this service.
22
+ *
23
+ * What's left is ONLY the addon package management surface: the installer
24
+ * + the loader + `reloadPackages` + `listAvailableAddons`. These are still
25
+ * used by:
26
+ * - `server/backend/src/api/addon-upload.ts` (multipart upload → install
27
+ * → loader reload)
28
+ * - `server/backend/src/api/bridge-addons.router.ts` (install/uninstall
29
+ * package lifecycle, addon list via the loader)
30
+ */
31
+ export class AddonBridgeService {
32
+ private readonly logger: IScopedLogger
33
+
34
+ private loader!: AddonLoader
35
+ private installer: AddonInstaller | null = null
36
+
37
+ /** Whether the bridge initialised successfully */
38
+ private available = false
39
+
40
+ constructor(
41
+ private readonly loggingService: LoggingService,
42
+ ) {
43
+ this.logger = this.loggingService.createLogger('AddonBridge')
44
+
45
+ // Initialize installer eagerly in constructor (no async needed).
46
+ // This ensures install/uninstall works even before onModuleInit completes.
47
+ try {
48
+ const kernel = require('@camstack/kernel') as typeof import('@camstack/kernel')
49
+ const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data'
50
+ const addonsDir = path.resolve(dataDir, 'addons')
51
+ const workspacePackagesDir = kernel.detectWorkspacePackagesDir(__dirname)
52
+ this.installer = new kernel.AddonInstaller({ addonsDir, workspacePackagesDir: workspacePackagesDir ?? undefined })
53
+ kernel.ensureDir(addonsDir)
54
+ } catch (error: unknown) {
55
+ const msg = errMsg(error)
56
+ this.logger.warn('Installer init failed', { meta: { error: msg } })
57
+ }
58
+ }
59
+
60
+ async onModuleInit(): Promise<void> {
61
+ this.logger.info('Initializing addon bridge (package management only)...')
62
+
63
+ try {
64
+ const kernel = await import('@camstack/kernel')
65
+
66
+ this.loader = new kernel.AddonLoader(this.logger.child('AddonLoader'))
67
+
68
+ const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data'
69
+ const addonsDir = path.resolve(dataDir, 'addons')
70
+ await this.loader.loadFromDirectory(addonsDir)
71
+
72
+ this.available = true
73
+ this.logger.info('Addon bridge initialized', { meta: { count: this.loader.listAddons().length } })
74
+ } catch (error: unknown) {
75
+ const msg = errMsg(error)
76
+ this.logger.warn('Addon bridge loader failed — install/uninstall still available', { meta: { error: msg } })
77
+ }
78
+ }
79
+
80
+ async onModuleDestroy(): Promise<void> {
81
+ this.logger.info('Addon bridge shut down')
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Public API
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /** Whether the bridge is ready for use */
89
+ isAvailable(): boolean {
90
+ return this.available
91
+ }
92
+
93
+ /** Return the underlying AddonLoader (for querying / introspection) */
94
+ getLoader(): AddonLoader {
95
+ return this.loader
96
+ }
97
+
98
+ /** List IDs of all successfully loaded addons */
99
+ listAvailableAddons(): string[] {
100
+ if (!this.available) return []
101
+ return this.loader.listAddons().map((a: RegisteredAddon) => a.declaration.id)
102
+ }
103
+
104
+ /** Return the AddonInstaller instance (may be null if bridge failed to init) */
105
+ getInstaller(): AddonInstaller | null {
106
+ return this.installer
107
+ }
108
+
109
+ /** Re-discover addons from the addons directory (call after install/uninstall) */
110
+ async reloadPackages(): Promise<void> {
111
+ if (!this.available) {
112
+ this.logger.warn('reloadPackages called but bridge is unavailable — skipping')
113
+ return
114
+ }
115
+
116
+ const kernel = await import('@camstack/kernel')
117
+ this.loader = new kernel.AddonLoader(this.logger.child('AddonLoader'))
118
+
119
+ const dataDir = process.env.CAMSTACK_DATA ?? 'camstack-data'
120
+ const addonsDir = path.resolve(dataDir, 'addons')
121
+ await this.loader.loadFromDirectory(addonsDir)
122
+
123
+ this.logger.info('Reloaded', { meta: { count: this.loader.listAddons().length } })
124
+ }
125
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+
3
+ vi.mock('node:fs', () => ({
4
+ existsSync: vi.fn(),
5
+ }))
6
+
7
+ import * as fs from 'node:fs'
8
+ import { AddonPagesService } from './addon-pages.service'
9
+ import type { CapabilityService } from '../capability/capability.service'
10
+ import type { LoggingService } from '../logging/logging.service'
11
+ import type { ConfigService } from '../config/config.service'
12
+ import type { IAddonPageProvider, AddonPageDeclaration, IScopedLogger } from '@camstack/types'
13
+
14
+ function createMockLogger(): IScopedLogger {
15
+ return {
16
+ debug: vi.fn(),
17
+ info: vi.fn(),
18
+ warn: vi.fn(),
19
+ error: vi.fn(),
20
+ child: vi.fn().mockReturnThis(),
21
+ }
22
+ }
23
+
24
+ function createMockLoggingService(): LoggingService {
25
+ return {
26
+ createLogger: vi.fn().mockReturnValue(createMockLogger()),
27
+ } as unknown as LoggingService
28
+ }
29
+
30
+ function createMockConfigService(): ConfigService {
31
+ return {
32
+ get: vi.fn().mockReturnValue('./data'),
33
+ } as unknown as ConfigService
34
+ }
35
+
36
+ function createMockPageProvider(
37
+ id: string,
38
+ pages: readonly AddonPageDeclaration[],
39
+ ): IAddonPageProvider {
40
+ return {
41
+ id,
42
+ listPages: () => pages,
43
+ }
44
+ }
45
+
46
+ /**
47
+ * AddonPagesService now resolves bundles via the `addon-pages-source`
48
+ * collection cap (split from the public `addon-pages` singleton cap
49
+ * earlier this session).
50
+ */
51
+ function createMockCapabilityService(providers: IAddonPageProvider[] = []): CapabilityService {
52
+ return {
53
+ getSingleton: vi.fn(() => null),
54
+ getCollection: vi.fn((capability: string) => {
55
+ if (capability === 'addon-pages-source') return providers
56
+ return []
57
+ }),
58
+ resolveForDevice: vi.fn(() => null),
59
+ resolveCollectionForDevice: vi.fn(() => []),
60
+ setRegistry: vi.fn(),
61
+ getRegistry: vi.fn(() => null),
62
+ } as unknown as CapabilityService
63
+ }
64
+
65
+ function buildService(providers: IAddonPageProvider[] = []): AddonPagesService {
66
+ return new AddonPagesService(
67
+ createMockLoggingService(),
68
+ createMockConfigService(),
69
+ createMockCapabilityService(providers),
70
+ )
71
+ }
72
+
73
+ describe('AddonPagesService.resolveBundle', () => {
74
+ it('should return null for unregistered addon', () => {
75
+ const service = buildService([])
76
+ const result = service.resolveBundle('nonexistent', 'dist/pages/main.js')
77
+ expect(result).toBeNull()
78
+ })
79
+
80
+ it('should return null when file does not exist', () => {
81
+ const provider = createMockPageProvider('benchmark', [])
82
+ const service = buildService([provider])
83
+
84
+ const existsMock = fs.existsSync as unknown as ReturnType<typeof vi.fn>
85
+ existsMock.mockReturnValue(false)
86
+
87
+ const result = service.resolveBundle('benchmark', 'dist/pages/benchmark.js')
88
+ expect(result).toBeNull()
89
+ })
90
+
91
+ it('should return resolved path when file exists', () => {
92
+ const provider = createMockPageProvider('benchmark', [])
93
+ const service = buildService([provider])
94
+
95
+ const existsMock = fs.existsSync as unknown as ReturnType<typeof vi.fn>
96
+ existsMock.mockReturnValue(true)
97
+
98
+ const result = service.resolveBundle('benchmark', 'dist/pages/benchmark.js')
99
+ expect(result).not.toBeNull()
100
+ expect(result).toContain('addon-benchmark')
101
+ expect(result).toContain('dist/pages/benchmark.js')
102
+ })
103
+
104
+ it('should deny path traversal attempts', () => {
105
+ const provider = createMockPageProvider('benchmark', [])
106
+ const service = buildService([provider])
107
+ const result = service.resolveBundle('benchmark', '../../etc/passwd')
108
+ expect(result).toBeNull()
109
+ })
110
+
111
+ it('should deny absolute path traversal', () => {
112
+ const provider = createMockPageProvider('benchmark', [])
113
+ const service = buildService([provider])
114
+ const result = service.resolveBundle('benchmark', '../../../etc/passwd')
115
+ expect(result).toBeNull()
116
+ })
117
+ })
@@ -0,0 +1,80 @@
1
+ import * as path from 'node:path'
2
+ import * as fs from 'node:fs'
3
+ import { LoggingService } from '../logging/logging.service'
4
+ import { ConfigService } from '../config/config.service'
5
+ import { CapabilityService } from '../capability/capability.service'
6
+ import type { IScopedLogger } from '@camstack/types'
7
+ import type { IAddonPageProvider } from '@camstack/types'
8
+
9
+ /**
10
+ * AddonPagesService — server-side helper that backs the static file
11
+ * route `/api/addon-pages/:addonId/*` (registered in `main.ts`).
12
+ *
13
+ * The public listing surface (`addonPages.listPages` on the AppRouter)
14
+ * has been split out of this class — it now lives in the
15
+ * `addon-pages-aggregator` builtin (`@camstack/core/builtins/...`)
16
+ * which walks every `addon-pages-source` collection provider and
17
+ * stamps versioned `bundleUrl`s. The split lets both ends flow through
18
+ * codegen instead of relying on a hand-written wrapper.
19
+ *
20
+ * What stays here: filesystem path resolution + traversal protection
21
+ * for the static file route. Both rely on `CapabilityService` to
22
+ * enumerate registered page providers (so unknown / unregistered
23
+ * addons can't be probed via path traversal tricks) and on
24
+ * `ConfigService` to locate the addons directory.
25
+ */
26
+ export class AddonPagesService {
27
+ private readonly logger: IScopedLogger
28
+
29
+ constructor(
30
+ private readonly loggingService: LoggingService,
31
+ private readonly configService: ConfigService,
32
+ private readonly caps: CapabilityService,
33
+ ) {
34
+ this.logger = this.loggingService.createLogger('AddonPagesService')
35
+ }
36
+
37
+ /**
38
+ * Resolve the filesystem path to an addon's page bundle file.
39
+ * Returns null if the addon is not registered, the file doesn't exist,
40
+ * or the path would escape the addon directory (path traversal protection).
41
+ */
42
+ resolveBundle(addonId: string, filePath: string): string | null {
43
+ // Check if the addon is a registered page provider via the
44
+ // collection cap. `addon-pages-source` is the new home for the raw
45
+ // per-provider declarations (the public `addon-pages` cap is the
46
+ // aggregated singleton — different shape, different surface).
47
+ const providers = this.caps.getCollection<IAddonPageProvider>('addon-pages-source')
48
+ const isRegistered = providers.some((p) => p.id === addonId)
49
+ if (!isRegistered) {
50
+ this.logger.warn('Bundle resolve failed: addon not registered as page provider', { tags: { addonId } })
51
+ return null
52
+ }
53
+
54
+ const addonsDir = this.resolveAddonsDir()
55
+ // Addon packages are at addons/@camstack/addon-<id>/dist/
56
+ const addonDistPath = path.join(addonsDir, '@camstack', `addon-${addonId}`, 'dist')
57
+
58
+ const resolvedBase = path.resolve(addonDistPath)
59
+ const resolvedFile = path.resolve(addonDistPath, filePath)
60
+
61
+ // Path traversal protection
62
+ if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
63
+ this.logger.warn('Path traversal denied for addon', { tags: { addonId }, meta: { filePath } })
64
+ return null
65
+ }
66
+
67
+ if (!fs.existsSync(resolvedFile)) {
68
+ this.logger.debug('Bundle file not found', { meta: { resolvedFile } })
69
+ return null
70
+ }
71
+
72
+ return resolvedFile
73
+ }
74
+
75
+ /** Resolve the addons directory from config */
76
+ private resolveAddonsDir(): string {
77
+ const dataPath = this.configService.get<string>('server.dataPath') ?? 'camstack-data'
78
+ return path.resolve(dataPath, 'addons')
79
+ }
80
+ }