@camstack/server 0.2.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
  2. package/dist/api/addon-upload.js +441 -0
  3. package/dist/api/addons-custom.router.js +91 -0
  4. package/dist/api/auth-whoami.js +55 -0
  5. package/dist/api/bridge-addons.router.js +109 -0
  6. package/dist/api/capabilities.router.js +229 -0
  7. package/dist/api/core/addon-settings.router.js +117 -0
  8. package/dist/api/core/agents.router.js +73 -0
  9. package/dist/api/core/auth.router.js +286 -0
  10. package/dist/api/core/bulk-update-coordinator.js +229 -0
  11. package/dist/api/core/cap-providers.js +1124 -0
  12. package/dist/api/core/capabilities.router.js +138 -0
  13. package/dist/api/core/collection-preference.js +17 -0
  14. package/dist/api/core/event-bus-proxy.router.js +45 -0
  15. package/dist/api/core/hwaccel.router.js +91 -0
  16. package/dist/api/core/live-events.router.js +61 -0
  17. package/dist/api/core/logs.router.js +172 -0
  18. package/dist/api/core/notifications.router.js +67 -0
  19. package/dist/api/core/repl.router.js +35 -0
  20. package/dist/api/core/settings-backend.router.js +121 -0
  21. package/dist/api/core/stream-probe.router.js +58 -0
  22. package/dist/api/core/system-events.router.js +100 -0
  23. package/dist/api/health/health.routes.js +68 -0
  24. package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
  25. package/dist/api/oauth2/oauth2-routes.js +219 -0
  26. package/dist/api/trpc/cap-mount-helpers.js +194 -0
  27. package/dist/api/trpc/cap-route-error-formatter.js +133 -0
  28. package/dist/api/trpc/client-ip.js +147 -0
  29. package/dist/api/trpc/core-cap-bridge.js +115 -0
  30. package/dist/api/trpc/generated-cap-mounts.js +388 -0
  31. package/dist/api/trpc/generated-cap-routers.js +7635 -0
  32. package/dist/api/trpc/scope-access.js +93 -0
  33. package/dist/api/trpc/trpc.context.js +184 -0
  34. package/dist/api/trpc/trpc.middleware.js +139 -0
  35. package/dist/api/trpc/trpc.router.js +188 -0
  36. package/dist/auth/session-cookie.js +47 -0
  37. package/dist/boot/boot-config.js +241 -0
  38. package/dist/boot/integration-id-backfill.js +76 -0
  39. package/dist/boot/post-boot.service.js +85 -0
  40. package/dist/core/addon/addon-call-gateway.js +99 -0
  41. package/dist/core/addon/addon-package.service.js +1560 -0
  42. package/dist/core/addon/addon-registry.service.js +2739 -0
  43. package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
  44. package/dist/core/addon/addon-search.service.js +62 -0
  45. package/dist/core/addon/addon-settings-provider.js +102 -0
  46. package/dist/core/addon/addon.tokens.js +5 -0
  47. package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
  48. package/dist/core/addon-pages/addon-pages.service.js +107 -0
  49. package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
  50. package/dist/core/agent/agent-registry.service.js +477 -0
  51. package/dist/core/auth/auth.service.js +10 -0
  52. package/dist/core/capability/capability.service.js +58 -0
  53. package/dist/core/config/config.schema.js +7 -0
  54. package/dist/core/config/config.service.js +10 -0
  55. package/dist/core/events/event-bus.service.js +83 -0
  56. package/dist/core/feature/feature.service.js +10 -0
  57. package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
  58. package/dist/core/logging/log-ring-buffer.js +6 -0
  59. package/dist/core/logging/logging.service.js +130 -0
  60. package/dist/core/logging/scoped-logger.js +6 -0
  61. package/dist/core/moleculer/cap-call-fn.js +50 -0
  62. package/dist/core/moleculer/cap-route-authority.js +122 -0
  63. package/dist/core/moleculer/moleculer.service.js +898 -0
  64. package/dist/core/network/network-quality.service.js +7 -0
  65. package/dist/core/notification/notification-wrapper.service.js +33 -0
  66. package/dist/core/notification/toast-wrapper.service.js +25 -0
  67. package/dist/core/provider/provider.tokens.js +4 -0
  68. package/dist/core/repl/repl-engine.service.js +140 -0
  69. package/dist/core/storage/fs-storage-backend.js +6 -0
  70. package/dist/core/storage/storage-location-manager.js +6 -0
  71. package/dist/core/storage/storage.service.js +7 -0
  72. package/dist/core/streaming/stream-probe.service.js +209 -0
  73. package/dist/core/topology/topology-emitter.service.js +106 -0
  74. package/dist/launcher.js +325 -0
  75. package/dist/main.js +1098 -0
  76. package/dist/manual-boot.js +227 -0
  77. package/package.json +5 -1
  78. package/src/__tests__/addon-install-e2e.test.ts +0 -74
  79. package/src/__tests__/addon-pages-e2e.test.ts +0 -200
  80. package/src/__tests__/addon-route-session.test.ts +0 -17
  81. package/src/__tests__/addon-settings-router.spec.ts +0 -67
  82. package/src/__tests__/addon-upload.spec.ts +0 -475
  83. package/src/__tests__/agent-registry.spec.ts +0 -179
  84. package/src/__tests__/agent-status-page.spec.ts +0 -82
  85. package/src/__tests__/auth-session-cookie.test.ts +0 -48
  86. package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
  87. package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
  88. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
  89. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
  90. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
  91. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
  92. package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
  93. package/src/__tests__/cap-route-adapter.spec.ts +0 -302
  94. package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
  95. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
  96. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
  97. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
  98. package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
  99. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
  100. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
  101. package/src/__tests__/cap-routers/harness.ts +0 -163
  102. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
  103. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
  104. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
  105. package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
  106. package/src/__tests__/capability-e2e.test.ts +0 -384
  107. package/src/__tests__/cli-e2e.test.ts +0 -150
  108. package/src/__tests__/core-cap-bridge.spec.ts +0 -91
  109. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
  110. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
  111. package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
  112. package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
  113. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
  114. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
  115. package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
  116. package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
  117. package/src/__tests__/framework-allowlist.spec.ts +0 -96
  118. package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
  119. package/src/__tests__/https-e2e.test.ts +0 -124
  120. package/src/__tests__/lifecycle-e2e.test.ts +0 -189
  121. package/src/__tests__/live-events-subscription.spec.ts +0 -149
  122. package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
  123. package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
  124. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
  125. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
  126. package/src/__tests__/native-cap-route.spec.ts +0 -427
  127. package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
  128. package/src/__tests__/post-boot-restart.spec.ts +0 -161
  129. package/src/__tests__/singleton-contention.test.ts +0 -499
  130. package/src/__tests__/streaming-diagnostic.test.ts +0 -615
  131. package/src/__tests__/streaming-scale.test.ts +0 -314
  132. package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
  133. package/src/__tests__/uds-log-ingest.spec.ts +0 -183
  134. package/src/api/__tests__/addons-custom.spec.ts +0 -148
  135. package/src/api/__tests__/capabilities.router.test.ts +0 -56
  136. package/src/api/addon-upload.ts +0 -529
  137. package/src/api/addons-custom.router.ts +0 -101
  138. package/src/api/auth-whoami.ts +0 -101
  139. package/src/api/bridge-addons.router.ts +0 -122
  140. package/src/api/capabilities.router.ts +0 -265
  141. package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
  142. package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
  143. package/src/api/core/addon-settings.router.ts +0 -127
  144. package/src/api/core/agents.router.ts +0 -86
  145. package/src/api/core/auth.router.ts +0 -322
  146. package/src/api/core/bulk-update-coordinator.ts +0 -305
  147. package/src/api/core/cap-providers.ts +0 -1339
  148. package/src/api/core/capabilities.router.ts +0 -149
  149. package/src/api/core/collection-preference.ts +0 -40
  150. package/src/api/core/event-bus-proxy.router.ts +0 -45
  151. package/src/api/core/hwaccel.router.ts +0 -108
  152. package/src/api/core/live-events.router.ts +0 -67
  153. package/src/api/core/logs.router.ts +0 -195
  154. package/src/api/core/notifications.router.ts +0 -66
  155. package/src/api/core/repl.router.ts +0 -39
  156. package/src/api/core/settings-backend.router.ts +0 -140
  157. package/src/api/core/stream-probe.router.ts +0 -57
  158. package/src/api/core/system-events.router.ts +0 -125
  159. package/src/api/health/health.routes.ts +0 -117
  160. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
  161. package/src/api/oauth2/oauth2-routes.ts +0 -281
  162. package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
  163. package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
  164. package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
  165. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
  166. package/src/api/trpc/cap-mount-helpers.ts +0 -245
  167. package/src/api/trpc/cap-route-error-formatter.ts +0 -171
  168. package/src/api/trpc/client-ip.ts +0 -147
  169. package/src/api/trpc/core-cap-bridge.ts +0 -154
  170. package/src/api/trpc/generated-cap-mounts.ts +0 -1240
  171. package/src/api/trpc/generated-cap-routers.ts +0 -11523
  172. package/src/api/trpc/scope-access.ts +0 -110
  173. package/src/api/trpc/trpc.context.ts +0 -258
  174. package/src/api/trpc/trpc.middleware.ts +0 -146
  175. package/src/api/trpc/trpc.router.ts +0 -389
  176. package/src/auth/session-cookie.ts +0 -54
  177. package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
  178. package/src/boot/boot-config.ts +0 -259
  179. package/src/boot/integration-id-backfill.ts +0 -109
  180. package/src/boot/post-boot.service.ts +0 -105
  181. package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
  182. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
  183. package/src/core/addon/addon-call-gateway.ts +0 -171
  184. package/src/core/addon/addon-package.service.ts +0 -1787
  185. package/src/core/addon/addon-registry.service.ts +0 -3130
  186. package/src/core/addon/addon-search.service.ts +0 -91
  187. package/src/core/addon/addon-settings-provider.ts +0 -220
  188. package/src/core/addon/addon.tokens.ts +0 -2
  189. package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
  190. package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
  191. package/src/core/addon-pages/addon-pages.service.ts +0 -82
  192. package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
  193. package/src/core/agent/agent-registry.service.ts +0 -529
  194. package/src/core/auth/auth.service.spec.ts +0 -86
  195. package/src/core/auth/auth.service.ts +0 -8
  196. package/src/core/capability/capability.service.ts +0 -66
  197. package/src/core/config/config.schema.ts +0 -3
  198. package/src/core/config/config.service.spec.ts +0 -175
  199. package/src/core/config/config.service.ts +0 -7
  200. package/src/core/events/event-bus.service.spec.ts +0 -235
  201. package/src/core/events/event-bus.service.ts +0 -89
  202. package/src/core/feature/feature.service.spec.ts +0 -99
  203. package/src/core/feature/feature.service.ts +0 -8
  204. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
  205. package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
  206. package/src/core/logging/log-ring-buffer.ts +0 -3
  207. package/src/core/logging/logging.service.spec.ts +0 -287
  208. package/src/core/logging/logging.service.ts +0 -143
  209. package/src/core/logging/scoped-logger.ts +0 -3
  210. package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
  211. package/src/core/moleculer/cap-call-fn.ts +0 -107
  212. package/src/core/moleculer/cap-route-authority.ts +0 -194
  213. package/src/core/moleculer/moleculer.service.ts +0 -1072
  214. package/src/core/network/network-quality.service.spec.ts +0 -53
  215. package/src/core/network/network-quality.service.ts +0 -5
  216. package/src/core/notification/notification-wrapper.service.ts +0 -34
  217. package/src/core/notification/toast-wrapper.service.ts +0 -27
  218. package/src/core/provider/provider.tokens.ts +0 -1
  219. package/src/core/repl/repl-engine.service.spec.ts +0 -444
  220. package/src/core/repl/repl-engine.service.ts +0 -155
  221. package/src/core/storage/fs-storage-backend.spec.ts +0 -70
  222. package/src/core/storage/fs-storage-backend.ts +0 -3
  223. package/src/core/storage/storage-location-manager.spec.ts +0 -130
  224. package/src/core/storage/storage-location-manager.ts +0 -3
  225. package/src/core/storage/storage.service.spec.ts +0 -73
  226. package/src/core/storage/storage.service.ts +0 -3
  227. package/src/core/streaming/stream-probe.service.ts +0 -221
  228. package/src/core/topology/topology-emitter.service.ts +0 -105
  229. package/src/launcher.ts +0 -314
  230. package/src/main.ts +0 -1245
  231. package/src/manual-boot.ts +0 -301
  232. package/tsconfig.build.json +0 -8
  233. package/tsconfig.json +0 -33
  234. package/vitest.config.ts +0 -26
@@ -1,529 +0,0 @@
1
- import type { FastifyInstance, FastifyReply } from 'fastify'
2
- import * as fs from 'node:fs'
3
- import * as path from 'node:path'
4
- import * as os from 'node:os'
5
- import { execFileSync } from 'node:child_process'
6
- import type { IScopedLogger, TokenScope } from '@camstack/types'
7
- import { checkScopeAccess } from './trpc/scope-access.js'
8
- import type { AddonBridgeService } from '../core/addon-bridge/addon-bridge.service'
9
- import type { AddonRegistryService } from '../core/addon/addon-registry.service'
10
- import type { AddonPackageService } from '../core/addon/addon-package.service'
11
- import { isFrameworkPackage } from '../core/addon/addon-package.service.js'
12
- import type { AuthService } from '../core/auth/auth.service'
13
- import type { MoleculerService } from '../core/moleculer/moleculer.service'
14
-
15
- interface ScopedTokenLike {
16
- readonly scopes: readonly TokenScope[]
17
- }
18
- interface UserManagementLike {
19
- validateScopedToken(input: { token: string }): Promise<ScopedTokenLike | null>
20
- }
21
-
22
- /**
23
- * Validate a `cst_*` scoped token via the `user-management` cap singleton.
24
- *
25
- * The local `AuthService.validateScopedToken` indirection is unused (the
26
- * `setScopedTokenManager` wire-up was never invoked), so we go straight
27
- * to the cap registry — same path the generic addon-route handler in
28
- * `main.ts` uses (the one that actually works end-to-end).
29
- *
30
- * Returns null when the singleton isn't mounted yet (boot race) or the
31
- * token doesn't validate. Caller treats both as auth failure.
32
- */
33
- async function validateScopedTokenViaCap(
34
- addonRegistry: AddonRegistryService,
35
- token: string,
36
- ): Promise<ScopedTokenLike | null> {
37
- const capRegistry = addonRegistry.getCapabilityRegistry()
38
- const userMgmt = capRegistry.getSingleton('user-management') as UserManagementLike | undefined
39
- if (!userMgmt) return null
40
- return userMgmt.validateScopedToken({ token })
41
- }
42
-
43
- /**
44
- * REST endpoint `/api/addons/upload` shares the scope-gate of the
45
- * `addons.installPackage` cap method (semantically equivalent — both
46
- * install a tarball under the system-scope `addons` cap with `create`
47
- * access). Reuse the shared scope matcher so the gate stays in sync
48
- * with the tRPC middleware — no parallel REST-only ACL.
49
- *
50
- * Why not `addons.upload`? There is no `upload` method on the cap
51
- * definition (this endpoint is Fastify-only), so `METHOD_ACCESS_MAP`
52
- * has no row for it and `checkScopeAccess` falls through to deny.
53
- */
54
- const UPLOAD_TRPC_PATH = 'addons.installPackage'
55
- function isUploadAllowed(scoped: ScopedTokenLike): boolean {
56
- return checkScopeAccess(scoped.scopes, UPLOAD_TRPC_PATH).ok
57
- }
58
-
59
- const TARBALL_EXTENSIONS = ['.tgz', '.tar.gz']
60
- const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
61
- const AGENT_DEPLOY_TIMEOUT_MS = 60_000
62
-
63
- interface TarballManifest {
64
- readonly name: string
65
- readonly version: string
66
- }
67
-
68
- interface AgentDeployResponse {
69
- readonly success: boolean
70
- readonly addonId: string
71
- readonly path?: string
72
- }
73
-
74
- function isTarball(filename: string): boolean {
75
- return TARBALL_EXTENSIONS.some((ext) => filename.endsWith(ext))
76
- }
77
-
78
- /**
79
- * Validate an uploaded tarball by extracting its `package.json` and
80
- * checking it declares `name` + `version`. Runs BEFORE the hub/agent
81
- * branch so broken archives are rejected at the gateway instead of
82
- * failing mid-extraction on the agent (which has no clean rollback).
83
- *
84
- * The routine writes the buffer to a scratch path and invokes `tar` with
85
- * `-xzO` to stream-extract just `package/package.json` to stdout. No full
86
- * unpack is needed — npm-pack layout always puts the manifest at that
87
- * fixed inner path. Returns null (not throw) on malformed archives so
88
- * the caller can surface a precise 400 response.
89
- */
90
- function validateTarball(buffer: Buffer, filename: string): TarballManifest | null {
91
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-tarball-check-'))
92
- const tgzPath = path.join(tmpDir, filename)
93
- try {
94
- fs.writeFileSync(tgzPath, buffer)
95
- const output = execFileSync('tar', ['-xzO', '-f', tgzPath, 'package/package.json'], {
96
- timeout: 5_000,
97
- stdio: ['ignore', 'pipe', 'ignore'],
98
- })
99
- const parsed: unknown = JSON.parse(output.toString('utf8'))
100
- if (!parsed || typeof parsed !== 'object') return null
101
- const pkg = parsed as Record<string, unknown>
102
- if (typeof pkg['name'] !== 'string' || typeof pkg['version'] !== 'string') return null
103
- return { name: pkg['name'], version: pkg['version'] }
104
- } catch {
105
- return null
106
- } finally {
107
- fs.rmSync(tmpDir, { recursive: true, force: true })
108
- }
109
- }
110
-
111
- export async function registerAddonUploadRoute(
112
- fastify: FastifyInstance,
113
- addonBridge: AddonBridgeService,
114
- authService: AuthService,
115
- moleculer: MoleculerService,
116
- addonRegistry: AddonRegistryService,
117
- addonPackageService: AddonPackageService,
118
- logger: IScopedLogger,
119
- ): Promise<void> {
120
- await fastify.register(import('@fastify/multipart'), {
121
- limits: { fileSize: MAX_UPLOAD_BYTES },
122
- })
123
-
124
- fastify.post('/api/addons/upload', async (request, reply) => {
125
- const authHeader = request.headers.authorization
126
- if (!authHeader) {
127
- return reply.status(401).send({ error: 'Unauthorized' })
128
- }
129
-
130
- // Auth chain: JWT (isAdmin) OR scoped token whose scopes grant
131
- // `create` access on the `addons` capability. The scoped path is the
132
- // CLI's `camstack login` flow — fetches a long-lived upload-scoped
133
- // token so headless deploys don't need an admin password on disk.
134
- const token = authHeader.replace('Bearer ', '')
135
- let authOk = false
136
- let authReason: string | undefined
137
- // Try JWT first — fastest path + carries isAdmin flag directly.
138
- try {
139
- const payload = authService.verifyToken(token)
140
- if (payload.isAdmin) {
141
- authOk = true
142
- } else {
143
- authReason = 'JWT is not admin'
144
- }
145
- } catch {
146
- // Not a JWT (or invalid signature) — fall through to scoped-token path.
147
- }
148
- if (!authOk) {
149
- // `cst_*` scoped tokens — only the cap-registry singleton actually
150
- // validates; the local AuthService bridge was never wired. See main.ts:652.
151
- try {
152
- const record = await validateScopedTokenViaCap(addonRegistry, token)
153
- if (!record) {
154
- authReason = authReason ?? 'token not recognised'
155
- } else if (isUploadAllowed(record)) {
156
- authOk = true
157
- } else {
158
- authReason = `scoped token lacks create access on '${UPLOAD_TRPC_PATH}'`
159
- }
160
- } catch (err) {
161
- authReason = `scoped token validation failed: ${err instanceof Error ? err.message : String(err)}`
162
- }
163
- }
164
- if (!authOk) {
165
- return reply
166
- .status(403)
167
- .send({ error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}` })
168
- }
169
-
170
- const data = await request.file()
171
- if (!data) {
172
- return reply.status(400).send({ error: 'No file uploaded' })
173
- }
174
- if (!isTarball(data.filename)) {
175
- return reply.status(400).send({ error: 'File must be a .tgz or .tar.gz archive' })
176
- }
177
-
178
- // `nodeId` and `addonId` come through as multipart text fields.
179
- // `data.fields[X].value` is the parsed string; we narrow defensively
180
- // because the multipart plugin types it as `unknown`.
181
- const nodeIdField = data.fields['nodeId']
182
- const addonIdField = data.fields['addonId']
183
- const nodeId =
184
- typeof nodeIdField === 'object' &&
185
- nodeIdField !== null &&
186
- 'value' in nodeIdField &&
187
- typeof nodeIdField.value === 'string'
188
- ? nodeIdField.value
189
- : null
190
- const addonIdHint =
191
- typeof addonIdField === 'object' &&
192
- addonIdField !== null &&
193
- 'value' in addonIdField &&
194
- typeof addonIdField.value === 'string'
195
- ? addonIdField.value
196
- : null
197
-
198
- const buffer = await data.toBuffer()
199
-
200
- // Gate: reject archives that don't expose a parseable package.json with
201
- // name + version. The hub installer did this implicitly via npm; the
202
- // agent path would otherwise fail mid-extraction with no clean rollback.
203
- const manifest = validateTarball(buffer, data.filename)
204
- if (!manifest) {
205
- return reply.status(400).send({
206
- error: 'Tarball missing or malformed package/package.json (name + version required)',
207
- })
208
- }
209
-
210
- // Branch by deployment target. `nodeId === 'hub'` (or absent) installs on
211
- // the hub AND broadcasts to every connected agent that can run any of
212
- // the package's addons (i.e. anything not marked hub-only). Any other
213
- // explicit `nodeId` value routes only to that agent via `$agent.deploy`.
214
- if (!nodeId || nodeId === 'hub') {
215
- return installToHub(
216
- reply,
217
- addonBridge,
218
- addonRegistry,
219
- addonPackageService,
220
- moleculer,
221
- logger,
222
- data.filename,
223
- buffer,
224
- )
225
- }
226
- const agentAddonId = addonIdHint ?? manifest.name
227
- return deployToAgent(reply, moleculer, nodeId, agentAddonId, buffer)
228
- })
229
- }
230
-
231
- interface AddonExecutionLike {
232
- readonly placement?: 'hub-only' | 'agent-only' | 'any-node'
233
- }
234
- interface CamstackAddonDeclLike {
235
- readonly id?: unknown
236
- readonly execution?: AddonExecutionLike
237
- }
238
-
239
- /**
240
- * Read the on-disk package.json and decide whether the package contains
241
- * anything that needs to land on agents. We deliberately read from disk
242
- * (rather than `addonRegistry.listAddons()`) so the function is safe to
243
- * call before `loadNewAddons()` and so `agent-only` addons — which the
244
- * hub registry intentionally skips — still show up here.
245
- *
246
- * Returns `true` if at least one addon's `execution.placement` is anything
247
- * other than `'hub-only'`. Absence of `execution` defaults to hub-only so
248
- * legacy packages don't suddenly broadcast cluster-wide.
249
- */
250
- function packageHasAgentDeployable(addonsDir: string, packageName: string): boolean {
251
- try {
252
- const pkgPath = path.join(addonsDir, packageName, 'package.json')
253
- const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
254
- if (parsed === null || typeof parsed !== 'object') return false
255
- const camstack = (parsed as { camstack?: { addons?: readonly CamstackAddonDeclLike[] } })
256
- .camstack
257
- const addons = camstack?.addons ?? []
258
- return addons.some((a) => {
259
- const placement = a.execution?.placement ?? 'hub-only'
260
- return placement === 'agent-only' || placement === 'any-node'
261
- })
262
- } catch {
263
- return false
264
- }
265
- }
266
-
267
- interface AgentDeployResult {
268
- readonly nodeId: string
269
- readonly success: boolean
270
- readonly loaded?: readonly string[]
271
- readonly error?: string
272
- }
273
-
274
- interface MoleculerBrokerLike {
275
- call(
276
- action: string,
277
- params: Record<string, unknown>,
278
- options: { nodeID: string; timeout: number },
279
- ): Promise<unknown>
280
- registry?: {
281
- getNodeList?: (opts: { onlyAvailable: boolean }) => readonly { id: string }[]
282
- }
283
- }
284
-
285
- /**
286
- * Push `buffer` (a tarball) to every connected non-hub node and trigger a
287
- * `$agent.reload` so the freshly extracted dist is picked up immediately
288
- * without an agent restart. Agents that fail are reported per-node — one
289
- * unreachable agent must not block the others or the hub install.
290
- */
291
- async function propagateToAgents(
292
- moleculer: MoleculerService,
293
- logger: IScopedLogger,
294
- packageName: string,
295
- buffer: Buffer,
296
- ): Promise<readonly AgentDeployResult[]> {
297
- const broker = moleculer.broker as unknown as MoleculerBrokerLike
298
- const nodes = broker.registry?.getNodeList?.({ onlyAvailable: true }) ?? []
299
- // Moleculer reports both top-level nodes (`hub`, `dev-agent-0`, …) AND
300
- // their child per-addon runner processes (`hub/detection-pipeline`, `dev-agent-0/foo`).
301
- // Only the top-level agent nodes have the `$agent` service — calling
302
- // `$agent.deploy` on a child would hang until timeout. The hub itself
303
- // is excluded (it was already installed via `installToHub`).
304
- const allNodeIds = nodes.map((n) => n.id)
305
- const agentNodeIds = allNodeIds.filter((id) => id !== 'hub' && !id.includes('/'))
306
- logger.info('propagate: enumerated broker nodes', {
307
- meta: {
308
- packageName,
309
- allNodeIds,
310
- targetAgents: agentNodeIds,
311
- },
312
- })
313
- if (agentNodeIds.length === 0) return []
314
- const results: AgentDeployResult[] = []
315
- for (const nodeId of agentNodeIds) {
316
- try {
317
- const deployRaw = await broker.call(
318
- '$agent.deploy',
319
- { addonId: packageName, bundle: buffer },
320
- { nodeID: nodeId, timeout: AGENT_DEPLOY_TIMEOUT_MS },
321
- )
322
- if (!isAgentDeployResponse(deployRaw)) {
323
- results.push({ nodeId, success: false, error: 'malformed deploy response' })
324
- continue
325
- }
326
- const reloadRaw = await broker.call(
327
- '$agent.reload',
328
- {},
329
- { nodeID: nodeId, timeout: AGENT_DEPLOY_TIMEOUT_MS },
330
- )
331
- const reloaded =
332
- reloadRaw !== null &&
333
- typeof reloadRaw === 'object' &&
334
- 'loaded' in (reloadRaw as Record<string, unknown>)
335
- ? (reloadRaw as { loaded: readonly string[] }).loaded
336
- : []
337
- results.push({ nodeId, success: true, loaded: reloaded })
338
- } catch (err: unknown) {
339
- results.push({
340
- nodeId,
341
- success: false,
342
- error: err instanceof Error ? err.message : String(err),
343
- })
344
- }
345
- }
346
- return results
347
- }
348
-
349
- /**
350
- * Hub install path: write the tgz, refresh the manifest list, then drive the
351
- * registry through (a) `loadNewAddons` for brand-new addonIds and (b)
352
- * `restartAddon` for each addonId already tied to this package. The restart
353
- * branch is the hot-update story — group-hosted addons re-fork their child
354
- * process (fresh dist code is loaded) and in-process addons re-init in place.
355
- * Without this the CLI push was write-to-disk-only and required a server
356
- * restart to actually run the new code.
357
- */
358
- async function installToHub(
359
- reply: FastifyReply,
360
- addonBridge: AddonBridgeService,
361
- addonRegistry: AddonRegistryService,
362
- addonPackageService: AddonPackageService,
363
- moleculer: MoleculerService,
364
- logger: IScopedLogger,
365
- filename: string,
366
- buffer: Buffer,
367
- ): Promise<void> {
368
- const tmpDir = path.join(os.tmpdir(), `camstack-addon-upload-${Date.now()}`)
369
- fs.mkdirSync(tmpDir, { recursive: true })
370
- const tgzPath = path.join(tmpDir, filename)
371
- fs.writeFileSync(tgzPath, buffer)
372
-
373
- try {
374
- const installer = addonBridge.getInstaller()
375
- if (!installer) {
376
- return reply.status(500).send({ error: 'Addon installer not available' })
377
- }
378
-
379
- // Snapshot addonIds tied to this package BEFORE installation. Used after
380
- // reload to distinguish "new" vs "updated". installFromTgz only writes
381
- // to disk; `addonRegistry.listAddons()` reflects in-memory state, so the
382
- // order (snapshot first vs install first) doesn't matter here — kept
383
- // pre-install for readability.
384
- const result = await installer.installFromTgz(tgzPath)
385
- const preInstallAddonIds = addonRegistry
386
- .listAddons()
387
- .filter((row) => row.manifest.packageName === result.name)
388
- .map((row) => row.manifest.id)
389
-
390
- const addonsDir = path.resolve(process.env['CAMSTACK_DATA'] ?? 'camstack-data', 'addons')
391
- fs.writeFileSync(path.join(addonsDir, result.name, '.install-source'), 'upload')
392
-
393
- // Framework / system packages (`camstack.system: true`, e.g. @camstack/core)
394
- // ship hub builtins (sqlite-settings, filesystem-storage, …) that CANNOT be
395
- // hot-reloaded in place — an in-process `restartAddon` leaves them
396
- // uninitialized ("SqliteSettingsBackend not initialized"), breaking auth +
397
- // settings. The new code is now on disk; a clean server restart reloads it
398
- // and re-runs boot initialization. The 10s restart grace lets this response
399
- // flush before the process exits, so the CLI sees a clean confirmation.
400
- if (isFrameworkPackage(result.name)) {
401
- logger.info('framework package deployed — scheduling server restart', {
402
- meta: { packageName: result.name, packageVersion: result.version },
403
- })
404
- addonPackageService.restartServer(`addon-upload: ${result.name}@${result.version}`)
405
- return reply.send({
406
- success: true,
407
- name: result.name,
408
- version: result.version,
409
- requiresRestart: true,
410
- restarting: true,
411
- message: 'Framework package installed — server is restarting to load it',
412
- })
413
- }
414
-
415
- // `addonRegistry.loadNewAddons()` already runs its own fresh filesystem
416
- // scan, diff'ing against existing `addonEntries` — it updates metadata
417
- // for known addons without touching their live instances + initializes
418
- // only entries it hasn't seen before. The previous `addonBridge.
419
- // reloadPackages()` call here was redundant AND destructive: it built
420
- // a brand-new `AddonLoader` and replaced the global, which knocked
421
- // every isolated group-runner's IPC channel offline (visible as
422
- // `Group-runner hub/<X>-isolated disconnected` for 6+ addons after
423
- // a single `camstack deploy`). `loadNewAddons` alone is enough.
424
- const loadResult = await addonRegistry.loadNewAddons()
425
-
426
- // Hot-update + cluster propagation are FIRE-AND-FORGET. The upload
427
- // route must respond quickly so the CLI / UI sees confirmation; the
428
- // capability re-registration after a forked group-runner respawn can
429
- // legitimately take 30s–several minutes (Python pool warmup, native
430
- // module init, broken external deps) and we don't want operator
431
- // tooling to hang waiting for it. `restartAddon` and
432
- // `propagateToAgents` each log their own progress; operators monitor
433
- // status through the topology cap or the addons UI.
434
- const propagatable = packageHasAgentDeployable(addonsDir, result.name)
435
- logger.info('hub install OK', {
436
- meta: {
437
- packageName: result.name,
438
- packageVersion: result.version,
439
- loaded: loadResult.loaded,
440
- restartTargets: preInstallAddonIds,
441
- propagatable,
442
- },
443
- })
444
- for (const id of preInstallAddonIds) {
445
- void addonRegistry.restartAddon(id).then((r) => {
446
- if (!r.success) {
447
- logger.warn('background restart failed', {
448
- tags: { addonId: id },
449
- meta: { error: r.error ?? 'unknown' },
450
- })
451
- } else {
452
- logger.info('background restart OK', { tags: { addonId: id } })
453
- }
454
- })
455
- }
456
- if (propagatable) {
457
- void propagateToAgents(moleculer, logger, result.name, buffer).then((agentResults) => {
458
- logger.info('propagation done', {
459
- meta: { packageName: result.name, agents: agentResults },
460
- })
461
- })
462
- }
463
-
464
- return reply.send({
465
- success: true,
466
- target: 'hub',
467
- packageName: result.name,
468
- version: result.version,
469
- loaded: loadResult.loaded,
470
- restartingInBackground: preInstallAddonIds,
471
- propagatingToAgentsInBackground: propagatable,
472
- failures: loadResult.failed,
473
- })
474
- } finally {
475
- fs.rmSync(tmpDir, { recursive: true, force: true })
476
- }
477
- }
478
-
479
- function isAgentDeployResponse(value: unknown): value is AgentDeployResponse {
480
- if (value === null || typeof value !== 'object') return false
481
- const v = value as { success?: unknown; addonId?: unknown; path?: unknown }
482
- if (typeof v.success !== 'boolean') return false
483
- if (typeof v.addonId !== 'string') return false
484
- if (v.path !== undefined && typeof v.path !== 'string') return false
485
- return true
486
- }
487
-
488
- /**
489
- * Typed view over Moleculer's `ServiceBroker.call`. The library's published
490
- * types resolve to `any`, which the typed-eslint ruleset rejects across this
491
- * file. Casting the broker once at the boundary lets every downstream call
492
- * site read as a normal typed `Promise<unknown>`.
493
- */
494
- interface TypedBrokerCall {
495
- call(
496
- action: string,
497
- params: Record<string, unknown>,
498
- options: { nodeID: string; timeout: number },
499
- ): Promise<unknown>
500
- }
501
-
502
- async function deployToAgent(
503
- reply: FastifyReply,
504
- moleculer: MoleculerService,
505
- nodeId: string,
506
- addonId: string,
507
- buffer: Buffer,
508
- ): Promise<void> {
509
- try {
510
- const broker = moleculer.broker as unknown as TypedBrokerCall
511
- const raw = await broker.call(
512
- '$agent.deploy',
513
- { addonId, bundle: buffer },
514
- { nodeID: nodeId, timeout: AGENT_DEPLOY_TIMEOUT_MS },
515
- )
516
- if (!isAgentDeployResponse(raw)) {
517
- return reply.status(502).send({ error: 'Agent deploy returned malformed response' })
518
- }
519
- return reply.send({
520
- success: true,
521
- target: nodeId,
522
- addonId: raw.addonId,
523
- path: raw.path,
524
- })
525
- } catch (err: unknown) {
526
- const msg = err instanceof Error ? err.message : String(err)
527
- return reply.status(502).send({ error: `Agent deploy failed: ${msg}` })
528
- }
529
- }
@@ -1,101 +0,0 @@
1
- /**
2
- * `api.addons.custom` — generic dispatcher for addon-defined custom actions.
3
- *
4
- * Task 7.2 of the device-proxy redesign. Addons declare a catalog of
5
- * custom actions at boot via `AddonInitResult.customActions` + a single
6
- * `handleCustomAction(action, input)` handler. The catalog is registered
7
- * with a per-process `CustomActionRegistry` (Task 7.1). This endpoint is
8
- * the single tRPC entry point that resolves an `(addonId, action)` pair,
9
- * validates input + output against the action's Zod schemas, enforces the
10
- * action's declared auth level, and dispatches to the addon handler.
11
- *
12
- * The factory returns a record of procedures (not a router) so the caller
13
- * can spread it into the existing `addons` namespace:
14
- *
15
- * trpcRouter({
16
- * ...existingAddonsProcedures,
17
- * ...createAddonsCustomProcedures({ getCustomActionRegistry: ... }),
18
- * })
19
- *
20
- * This avoids `mergeRouters` (which requires sharing the `t` instance
21
- * across modules) while still mounting the procedure at `api.addons.custom`.
22
- */
23
- import { z } from 'zod'
24
- import { TRPCError } from '@trpc/server'
25
- import type { CustomActionRegistry } from '@camstack/kernel'
26
- import type { CapabilityMethodAuth } from '@camstack/types'
27
- import type { TrpcContext } from './trpc/trpc.context.js'
28
- import { protectedProcedure } from './trpc/trpc.middleware.js'
29
-
30
- export interface AddonsCustomDeps {
31
- readonly getCustomActionRegistry: () => CustomActionRegistry
32
- }
33
-
34
- /**
35
- * Build the procedure record for the `custom` endpoint.
36
- *
37
- * The OUTER procedure is `protectedProcedure` — every caller must be
38
- * authenticated. The INNER per-action auth declared in `spec.auth` is
39
- * enforced manually by `ensureAuth` because the auth level is not known
40
- * until after the registry lookup.
41
- */
42
- export function createAddonsCustomProcedures(deps: AddonsCustomDeps) {
43
- return {
44
- custom: protectedProcedure
45
- .input(
46
- z.object({
47
- addonId: z.string().min(1),
48
- action: z.string().min(1),
49
- input: z.unknown(),
50
- }),
51
- )
52
- .output(z.unknown())
53
- .mutation(async ({ input, ctx }) => {
54
- const registry = deps.getCustomActionRegistry()
55
- const entry = registry.resolve(input.addonId, input.action)
56
- if (!entry) {
57
- throw new TRPCError({
58
- code: 'NOT_FOUND',
59
- message: `addon '${input.addonId}' has no custom action '${input.action}'`,
60
- })
61
- }
62
-
63
- // Per-action authorization. The outer procedure already requires
64
- // authentication; here we additionally enforce the declared role
65
- // when it's stricter than 'protected'.
66
- ensureAuth(ctx, entry.spec.auth)
67
-
68
- // Validate input against the action's declared Zod schema.
69
- const parsedInput = entry.spec.input.parse(input.input)
70
-
71
- // Dispatch through the addon handler.
72
- const result = await entry.handler(parsedInput)
73
-
74
- // Validate the addon's output. Crash-early on misbehaving addons.
75
- return entry.spec.output.parse(result)
76
- }),
77
- } as const
78
- }
79
-
80
- /**
81
- * Enforce the action's declared auth level.
82
- *
83
- * Mirrors the role checks performed by `protectedProcedure` and
84
- * `adminProcedure` in trpc.middleware.ts:
85
- * - public: no auth
86
- * - protected: any authenticated user
87
- * - admin: isAdmin only (scoped tokens bounce)
88
- */
89
- function ensureAuth(ctx: TrpcContext, level: CapabilityMethodAuth): void {
90
- if (level === 'public') return
91
- if (!ctx.user) {
92
- throw new TRPCError({ code: 'UNAUTHORIZED' })
93
- }
94
- if (level === 'protected') return
95
- if (level === 'admin') {
96
- if (!ctx.user.isAdmin) {
97
- throw new TRPCError({ code: 'FORBIDDEN', message: 'custom action requires admin' })
98
- }
99
- return
100
- }
101
- }