@camstack/server 1.0.0 → 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,475 +0,0 @@
1
- /**
2
- * Integration test for the `/api/addons/upload` route.
3
- *
4
- * Uses a real Fastify instance to exercise the multipart parser end-to-end
5
- * with mock dependencies for the AddonBridgeService, AuthService, and
6
- * MoleculerService. Verifies:
7
- *
8
- * 1. Auth enforcement (401 without token, 403 for non-admin)
9
- * 2. File extension validation (.tgz / .tar.gz only)
10
- * 3. Hub install path (nodeId = 'hub' or absent → AddonInstaller.installFromTgz)
11
- * 4. Agent deploy path (nodeId = 'agent-x' → moleculer.broker.call with nodeID)
12
- * 5. Agent failure → 502 response
13
- */
14
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
15
- import Fastify from 'fastify'
16
- import type { FastifyInstance } from 'fastify'
17
- import * as fs from 'node:fs'
18
- import * as path from 'node:path'
19
- import * as os from 'node:os'
20
- import { execFileSync } from 'node:child_process'
21
- import { registerAddonUploadRoute } from '../api/addon-upload.js'
22
- import type { AddonBridgeService } from '../core/addon-bridge/addon-bridge.service.js'
23
- import type { AuthService } from '../core/auth/auth.service.js'
24
- import type { MoleculerService } from '../core/moleculer/moleculer.service.js'
25
- import type { AddonRegistryService } from '../core/addon/addon-registry.service.js'
26
- import type { AddonPackageService } from '../core/addon/addon-package.service.js'
27
- import type { IScopedLogger } from '@camstack/types'
28
-
29
- /**
30
- * Build a minimal valid addon tarball containing a `package/package.json`
31
- * with the given name + version. `validateTarball` on the server extracts
32
- * just that file via `tar -xzO`, so nothing else is needed for the happy
33
- * path tests. Returns a Buffer.
34
- */
35
- function buildValidTarball(manifest: { name: string; version: string }): Buffer {
36
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vitest-tarball-'))
37
- try {
38
- fs.mkdirSync(path.join(tmpDir, 'package'), { recursive: true })
39
- fs.writeFileSync(path.join(tmpDir, 'package', 'package.json'), JSON.stringify(manifest))
40
- const tgz = path.join(tmpDir, 'out.tgz')
41
- execFileSync('tar', ['-czf', tgz, '-C', tmpDir, 'package'])
42
- return fs.readFileSync(tgz)
43
- } finally {
44
- fs.rmSync(tmpDir, { recursive: true, force: true })
45
- }
46
- }
47
-
48
- interface MockAuthPayload {
49
- readonly isAdmin: boolean
50
- }
51
-
52
- interface FormDataLike {
53
- readonly file: { filename: string; content: Buffer }
54
- readonly nodeId?: string
55
- readonly addonId?: string
56
- }
57
-
58
- function buildMultipart(boundary: string, body: FormDataLike): Buffer {
59
- const parts: Buffer[] = []
60
- const push = (s: string) => parts.push(Buffer.from(s))
61
- push(`--${boundary}\r\n`)
62
- push(`Content-Disposition: form-data; name="file"; filename="${body.file.filename}"\r\n`)
63
- push('Content-Type: application/gzip\r\n\r\n')
64
- parts.push(body.file.content)
65
- push('\r\n')
66
- if (body.nodeId !== undefined) {
67
- push(`--${boundary}\r\n`)
68
- push('Content-Disposition: form-data; name="nodeId"\r\n\r\n')
69
- push(body.nodeId)
70
- push('\r\n')
71
- }
72
- if (body.addonId !== undefined) {
73
- push(`--${boundary}\r\n`)
74
- push('Content-Disposition: form-data; name="addonId"\r\n\r\n')
75
- push(body.addonId)
76
- push('\r\n')
77
- }
78
- push(`--${boundary}--\r\n`)
79
- return Buffer.concat(parts)
80
- }
81
-
82
- function makeAuth(kind: 'admin' | 'non-admin' | 'invalid' = 'admin'): AuthService {
83
- return {
84
- verifyToken: vi.fn(() => {
85
- if (kind === 'invalid') throw new Error('invalid token')
86
- const payload: MockAuthPayload = { isAdmin: kind === 'admin' }
87
- return payload
88
- }),
89
- } as unknown as AuthService
90
- }
91
-
92
- function makeAddonBridge(installed?: { name: string; version: string }): AddonBridgeService {
93
- return {
94
- getInstaller: vi.fn(() =>
95
- installed === undefined
96
- ? null
97
- : {
98
- installFromTgz: vi.fn(async () => installed),
99
- },
100
- ),
101
- reloadPackages: vi.fn(async () => {}),
102
- } as unknown as AddonBridgeService
103
- }
104
-
105
- function makeMoleculer(call: ReturnType<typeof vi.fn>): MoleculerService {
106
- return {
107
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- test stub: vi.fn isn't typed against the broker's internal call shape, but the runtime contract is what we exercise
108
- broker: { call } as unknown as MoleculerService['broker'],
109
- } as unknown as MoleculerService
110
- }
111
-
112
- function makeAddonRegistry(): AddonRegistryService {
113
- return {
114
- // Returns an empty list — no addons currently tied to any package.
115
- // `installToHub` uses this to build `preInstallAddonIds`; an empty
116
- // result means no background `restartAddon` calls are fired.
117
- listAddons: vi.fn(() => []),
118
- // Simulates a successful filesystem scan that found no new addons.
119
- loadNewAddons: vi.fn(async () => ({ loaded: [] as string[], failed: [] as string[] })),
120
- // Not reached in the default happy-path (preInstallAddonIds is empty),
121
- // but stubbed for completeness and per-test overrides.
122
- restartAddon: vi.fn(async (_id: string) => ({ success: true })),
123
- // Returns a minimal cap-registry stub. `validateScopedTokenViaCap`
124
- // calls `getSingleton('user-management')` — returning null means no
125
- // singleton is mounted, so scoped-token auth is skipped. All happy-
126
- // path tests authenticate via the JWT path (`Bearer t` → isAdmin),
127
- // so this is the correct safe default.
128
- getCapabilityRegistry: vi.fn(() => ({
129
- getSingleton: vi.fn((_name: string) => null),
130
- })),
131
- } as unknown as AddonRegistryService
132
- }
133
-
134
- function makeAddonPackageService(): AddonPackageService {
135
- // `restartServer` is exercised only by the framework-package branch; the
136
- // default happy-path tests deploy a regular addon and never call it.
137
- return {
138
- restartServer: vi.fn((_requestedBy?: string) => {}),
139
- } as unknown as AddonPackageService
140
- }
141
-
142
- function makeLogger(): IScopedLogger {
143
- return {
144
- debug: vi.fn(),
145
- info: vi.fn(),
146
- warn: vi.fn(),
147
- error: vi.fn(),
148
- child: vi.fn(function (this: IScopedLogger) {
149
- return this
150
- }),
151
- withTags: vi.fn(function (this: IScopedLogger) {
152
- return this
153
- }),
154
- } as unknown as IScopedLogger
155
- }
156
-
157
- async function makeServer(opts: {
158
- auth: AuthService
159
- bridge: AddonBridgeService
160
- moleculer: MoleculerService
161
- addonRegistry?: AddonRegistryService
162
- addonPackageService?: AddonPackageService
163
- logger?: IScopedLogger
164
- }): Promise<FastifyInstance> {
165
- const fastify = Fastify({ logger: false })
166
- await registerAddonUploadRoute(
167
- fastify,
168
- opts.bridge,
169
- opts.auth,
170
- opts.moleculer,
171
- opts.addonRegistry ?? makeAddonRegistry(),
172
- opts.addonPackageService ?? makeAddonPackageService(),
173
- opts.logger ?? makeLogger(),
174
- )
175
- await fastify.ready()
176
- return fastify
177
- }
178
-
179
- const TGZ_BOUNDARY = '----vitestboundary'
180
- const VALID_TARBALL = buildValidTarball({ name: 'addon-x', version: '1.0.0' })
181
- const INVALID_TARBALL = Buffer.from('fake-not-a-tarball')
182
-
183
- describe('POST /api/addons/upload', () => {
184
- let fastify: FastifyInstance | null = null
185
-
186
- afterEach(async () => {
187
- if (fastify) {
188
- await fastify.close()
189
- fastify = null
190
- }
191
- })
192
-
193
- describe('auth', () => {
194
- beforeEach(async () => {
195
- fastify = await makeServer({
196
- auth: makeAuth('admin'),
197
- bridge: makeAddonBridge({ name: 'addon-x', version: '1.0.0' }),
198
- moleculer: makeMoleculer(vi.fn()),
199
- })
200
- })
201
-
202
- it('returns 401 when Authorization header is missing', async () => {
203
- const res = await fastify!.inject({
204
- method: 'POST',
205
- url: '/api/addons/upload',
206
- headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}` },
207
- payload: buildMultipart(TGZ_BOUNDARY, {
208
- file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
209
- }),
210
- })
211
- expect(res.statusCode).toBe(401)
212
- })
213
-
214
- it('returns 403 when token is not admin', async () => {
215
- await fastify!.close()
216
- fastify = await makeServer({
217
- auth: makeAuth('non-admin'),
218
- bridge: makeAddonBridge({ name: 'addon-x', version: '1.0.0' }),
219
- moleculer: makeMoleculer(vi.fn()),
220
- })
221
- const res = await fastify.inject({
222
- method: 'POST',
223
- url: '/api/addons/upload',
224
- headers: {
225
- 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
226
- authorization: 'Bearer token',
227
- },
228
- payload: buildMultipart(TGZ_BOUNDARY, {
229
- file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
230
- }),
231
- })
232
- expect(res.statusCode).toBe(403)
233
- })
234
- })
235
-
236
- it('returns 400 when filename is not a tarball', async () => {
237
- fastify = await makeServer({
238
- auth: makeAuth('admin'),
239
- bridge: makeAddonBridge({ name: 'addon-x', version: '1.0.0' }),
240
- moleculer: makeMoleculer(vi.fn()),
241
- })
242
- const res = await fastify.inject({
243
- method: 'POST',
244
- url: '/api/addons/upload',
245
- headers: {
246
- 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
247
- authorization: 'Bearer t',
248
- },
249
- payload: buildMultipart(TGZ_BOUNDARY, {
250
- file: { filename: 'evil.exe', content: VALID_TARBALL },
251
- }),
252
- })
253
- expect(res.statusCode).toBe(400)
254
- })
255
-
256
- it('routes to hub installer when nodeId is absent', async () => {
257
- // Hub install writes a `.install-source` marker into
258
- // `${CAMSTACK_DATA}/addons/<addonName>/`. Point CAMSTACK_DATA at an
259
- // isolated temp dir for the test so the side effect doesn't escape.
260
- const tmpRoot = await import('node:fs/promises').then((fs) =>
261
- fs.mkdtemp('/tmp/camstack-upload-test-'),
262
- )
263
- const previous = process.env['CAMSTACK_DATA']
264
- process.env['CAMSTACK_DATA'] = tmpRoot
265
- const fsSync = await import('node:fs')
266
- fsSync.mkdirSync(`${tmpRoot}/addons/addon-x`, { recursive: true })
267
-
268
- try {
269
- const bridge = makeAddonBridge({ name: 'addon-x', version: '2.0.0' })
270
- fastify = await makeServer({
271
- auth: makeAuth('admin'),
272
- bridge,
273
- moleculer: makeMoleculer(vi.fn()),
274
- })
275
- const res = await fastify.inject({
276
- method: 'POST',
277
- url: '/api/addons/upload',
278
- headers: {
279
- 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
280
- authorization: 'Bearer t',
281
- },
282
- payload: buildMultipart(TGZ_BOUNDARY, {
283
- file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
284
- }),
285
- })
286
- expect(res.statusCode).toBe(200)
287
- const body = res.json() as {
288
- success: boolean
289
- target: string
290
- packageName: string
291
- version: string
292
- }
293
- expect(body).toMatchObject({
294
- success: true,
295
- target: 'hub',
296
- packageName: 'addon-x',
297
- version: '2.0.0',
298
- })
299
- } finally {
300
- if (previous === undefined) delete process.env['CAMSTACK_DATA']
301
- else process.env['CAMSTACK_DATA'] = previous
302
- fsSync.rmSync(tmpRoot, { recursive: true, force: true })
303
- }
304
- })
305
-
306
- it('restarts the server (not an in-process reload) when a framework package is deployed', async () => {
307
- // @camstack/core ships hub builtins that can't hot-reload in place — the
308
- // upload path must call restartServer() and SKIP the per-addon restartAddon.
309
- const tmpRoot = await import('node:fs/promises').then((fs) =>
310
- fs.mkdtemp('/tmp/camstack-upload-fw-'),
311
- )
312
- const previous = process.env['CAMSTACK_DATA']
313
- process.env['CAMSTACK_DATA'] = tmpRoot
314
- const fsSync = await import('node:fs')
315
- fsSync.mkdirSync(`${tmpRoot}/addons/@camstack/core`, { recursive: true })
316
-
317
- try {
318
- const restartServer = vi.fn((_requestedBy?: string) => {})
319
- const restartAddon = vi.fn(async (_id: string) => ({ success: true }))
320
- const registry = {
321
- listAddons: vi.fn(() => [
322
- { manifest: { id: 'sqlite-settings', packageName: '@camstack/core' } },
323
- ]),
324
- loadNewAddons: vi.fn(async () => ({ loaded: [] as string[], failed: [] as string[] })),
325
- restartAddon,
326
- getCapabilityRegistry: vi.fn(() => ({ getSingleton: vi.fn((_n: string) => null) })),
327
- } as unknown as AddonRegistryService
328
- const pkgSvc = { restartServer } as unknown as AddonPackageService
329
-
330
- fastify = await makeServer({
331
- auth: makeAuth('admin'),
332
- bridge: makeAddonBridge({ name: '@camstack/core', version: '9.9.9' }),
333
- moleculer: makeMoleculer(vi.fn()),
334
- addonRegistry: registry,
335
- addonPackageService: pkgSvc,
336
- })
337
- const res = await fastify.inject({
338
- method: 'POST',
339
- url: '/api/addons/upload',
340
- headers: {
341
- 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
342
- authorization: 'Bearer t',
343
- },
344
- payload: buildMultipart(TGZ_BOUNDARY, {
345
- file: {
346
- filename: 'camstack-core-9.9.9.tgz',
347
- content: buildValidTarball({ name: '@camstack/core', version: '9.9.9' }),
348
- },
349
- }),
350
- })
351
- expect(res.statusCode).toBe(200)
352
- expect(res.json()).toMatchObject({ success: true, requiresRestart: true, restarting: true })
353
- expect(restartServer).toHaveBeenCalledOnce()
354
- // Critical: the in-process restart that breaks builtins must NOT run.
355
- expect(restartAddon).not.toHaveBeenCalled()
356
- } finally {
357
- if (previous === undefined) delete process.env['CAMSTACK_DATA']
358
- else process.env['CAMSTACK_DATA'] = previous
359
- fsSync.rmSync(tmpRoot, { recursive: true, force: true })
360
- }
361
- })
362
-
363
- it('routes to $agent.deploy when nodeId is provided', async () => {
364
- const call = vi.fn(async () => ({
365
- success: true,
366
- addonId: 'addon-x',
367
- path: '/agent/addons/addon-x',
368
- }))
369
- fastify = await makeServer({
370
- auth: makeAuth('admin'),
371
- bridge: makeAddonBridge(),
372
- moleculer: makeMoleculer(call),
373
- })
374
- const res = await fastify.inject({
375
- method: 'POST',
376
- url: '/api/addons/upload',
377
- headers: {
378
- 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
379
- authorization: 'Bearer t',
380
- },
381
- payload: buildMultipart(TGZ_BOUNDARY, {
382
- file: { filename: 'addon-x-1.2.3.tgz', content: VALID_TARBALL },
383
- nodeId: 'agent-frigate',
384
- addonId: 'addon-x',
385
- }),
386
- })
387
- expect(res.statusCode).toBe(200)
388
- const body = res.json() as { success: boolean; target: string; addonId: string; path: string }
389
- expect(body.target).toBe('agent-frigate')
390
- expect(body.addonId).toBe('addon-x')
391
-
392
- expect(call).toHaveBeenCalledWith(
393
- '$agent.deploy',
394
- { addonId: 'addon-x', bundle: VALID_TARBALL },
395
- { nodeID: 'agent-frigate', timeout: 60_000 },
396
- )
397
- })
398
-
399
- it('returns 502 when $agent.deploy throws', async () => {
400
- const call = vi.fn(async () => {
401
- throw new Error('agent unreachable')
402
- })
403
- fastify = await makeServer({
404
- auth: makeAuth('admin'),
405
- bridge: makeAddonBridge(),
406
- moleculer: makeMoleculer(call),
407
- })
408
- const res = await fastify.inject({
409
- method: 'POST',
410
- url: '/api/addons/upload',
411
- headers: {
412
- 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
413
- authorization: 'Bearer t',
414
- },
415
- payload: buildMultipart(TGZ_BOUNDARY, {
416
- file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
417
- nodeId: 'agent-frigate',
418
- }),
419
- })
420
- expect(res.statusCode).toBe(502)
421
- const body = res.json() as { error: string }
422
- expect(body.error).toMatch(/agent unreachable/)
423
- })
424
-
425
- it('uses manifest.name as addonId fallback when client omits the hint', async () => {
426
- const call = vi.fn(async () => ({ success: true, addonId: 'addon-foo' }))
427
- const tarball = buildValidTarball({ name: 'addon-foo', version: '1.2.3' })
428
- fastify = await makeServer({
429
- auth: makeAuth('admin'),
430
- bridge: makeAddonBridge(),
431
- moleculer: makeMoleculer(call),
432
- })
433
- await fastify.inject({
434
- method: 'POST',
435
- url: '/api/addons/upload',
436
- headers: {
437
- 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
438
- authorization: 'Bearer t',
439
- },
440
- payload: buildMultipart(TGZ_BOUNDARY, {
441
- // Filename is intentionally different from manifest.name to prove
442
- // that the server reads the manifest, not the filename.
443
- file: { filename: 'unrelated-name-9.tgz', content: tarball },
444
- nodeId: 'agent-frigate',
445
- }),
446
- })
447
- expect(call).toHaveBeenCalledWith(
448
- '$agent.deploy',
449
- expect.objectContaining({ addonId: 'addon-foo' }),
450
- expect.anything(),
451
- )
452
- })
453
-
454
- it('returns 400 when the tarball has no valid package.json', async () => {
455
- fastify = await makeServer({
456
- auth: makeAuth('admin'),
457
- bridge: makeAddonBridge(),
458
- moleculer: makeMoleculer(vi.fn()),
459
- })
460
- const res = await fastify.inject({
461
- method: 'POST',
462
- url: '/api/addons/upload',
463
- headers: {
464
- 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
465
- authorization: 'Bearer t',
466
- },
467
- payload: buildMultipart(TGZ_BOUNDARY, {
468
- file: { filename: 'bogus.tgz', content: INVALID_TARBALL },
469
- }),
470
- })
471
- expect(res.statusCode).toBe(400)
472
- const body = res.json() as { error: string }
473
- expect(body.error).toMatch(/package\.json/)
474
- })
475
- })
@@ -1,179 +0,0 @@
1
- /**
2
- * AgentRegistryService — node.connected + cold-scan event emission.
3
- *
4
- * Regression guard for the race between hub boot and remote agents: when
5
- * a dev.sh cluster starts hub + agents simultaneously, agents can register
6
- * with Moleculer BEFORE the hub's `AgentRegistryService.onModuleInit`
7
- * wires its `$node.connected` listener. Without the cold-scan below,
8
- * those agents never produced an `agent.online` event, so alert-center
9
- * and admin-ui lost their lifecycle view of the cluster.
10
- *
11
- * These specs validate both the hot path (new connections fire the
12
- * listener) and the cold path (already-connected nodes retroactively
13
- * emit on init).
14
- */
15
- import { describe, it, expect, beforeEach } from 'vitest'
16
- import { EventEmitter } from 'node:events'
17
- import { AgentRegistryService } from '../core/agent/agent-registry.service.js'
18
- import type { EventBusService } from '../core/events/event-bus.service.js'
19
- import type { MoleculerService } from '../core/moleculer/moleculer.service.js'
20
- import type { CapabilityService } from '../core/capability/capability.service.js'
21
- import type { SystemEvent } from '@camstack/types'
22
-
23
- interface CapturedEmits {
24
- readonly events: SystemEvent[]
25
- }
26
-
27
- function createFakes(opts: { preExistingNodeIds?: readonly string[] } = {}) {
28
- const emits: CapturedEmits = { events: [] }
29
- const fakeEventBus = {
30
- emit: (event: SystemEvent) => {
31
- emits.events.push(event)
32
- },
33
- } as unknown as EventBusService
34
-
35
- const localBus = new EventEmitter()
36
- const nodeList = (opts.preExistingNodeIds ?? []).map((id) => ({ id }))
37
- // D3: setOnAgentRegistered is called by AgentRegistryService.onModuleInit
38
- // to wire the handshake-driven reconcile trigger. The fake captures the
39
- // callback but does not invoke it (reconcile tests are out of scope here).
40
- const fakeMoleculer = {
41
- broker: {
42
- nodeID: 'hub',
43
- localBus,
44
- registry: {
45
- getNodeList: ({ onlyAvailable }: { onlyAvailable: boolean }) => {
46
- void onlyAvailable
47
- return nodeList
48
- },
49
- },
50
- },
51
- setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
52
- /* no-op in unit tests */
53
- },
54
- } as unknown as MoleculerService
55
-
56
- const fakeCapability = {} as unknown as CapabilityService
57
- const service = new AgentRegistryService(fakeEventBus, fakeMoleculer, fakeCapability)
58
-
59
- return { service, emits, localBus }
60
- }
61
-
62
- describe('AgentRegistryService — agent.online event lifecycle', () => {
63
- let captured: CapturedEmits
64
- let service: AgentRegistryService
65
- let localBus: EventEmitter
66
-
67
- describe('hot path: $node.connected fired after onModuleInit', () => {
68
- beforeEach(() => {
69
- const fakes = createFakes()
70
- service = fakes.service
71
- captured = fakes.emits
72
- localBus = fakes.localBus
73
- service.onModuleInit()
74
- })
75
-
76
- it('emits agent.online with the agentId when a new node connects', () => {
77
- localBus.emit('$node.connected', { node: { id: 'dev-agent-0' } })
78
- expect(captured.events).toHaveLength(1)
79
- expect(captured.events[0]!.category).toBe('agent.online')
80
- expect((captured.events[0]!.data as Record<string, unknown>).agentId).toBe('dev-agent-0')
81
- })
82
-
83
- it('emits agent.offline on $node.disconnected', () => {
84
- localBus.emit('$node.disconnected', { node: { id: 'dev-agent-0' } })
85
- expect(captured.events).toHaveLength(1)
86
- expect(captured.events[0]!.category).toBe('agent.offline')
87
- })
88
-
89
- it('handles burst of connections without loss', () => {
90
- for (let i = 0; i < 5; i++) {
91
- localBus.emit('$node.connected', { node: { id: `agent-${i}` } })
92
- }
93
- expect(captured.events).toHaveLength(5)
94
- expect(captured.events.map((e) => (e.data as Record<string, unknown>).agentId)).toEqual([
95
- 'agent-0',
96
- 'agent-1',
97
- 'agent-2',
98
- 'agent-3',
99
- 'agent-4',
100
- ])
101
- })
102
- })
103
-
104
- describe('cold path: nodes already connected before onModuleInit', () => {
105
- it('retroactively emits agent.online for pre-existing remote agents and worker.online for sub-workers', () => {
106
- const fakes = createFakes({
107
- preExistingNodeIds: ['dev-agent-0', 'dev-agent-1', 'hub/benchmark'],
108
- })
109
- fakes.service.onModuleInit()
110
- // Skip the self-node ('hub'). Bare ids → agents (`agent.online`);
111
- // `hub/<x>` ids → in-process workers (`worker.online`). The split
112
- // is intentional — see "Event system: separate WorkerOnline /
113
- // WorkerOffline events split from agent lifecycle" in
114
- // `agent-registry.service.ts::onModuleInit`.
115
- const agentIds = fakes.emits.events
116
- .filter((e) => e.category === 'agent.online')
117
- .map((e) => (e.data as Record<string, unknown>).agentId)
118
- const workerIds = fakes.emits.events
119
- .filter((e) => e.category === 'worker.online')
120
- .map((e) => (e.data as Record<string, unknown>).workerId)
121
- expect(agentIds.toSorted()).toEqual(['dev-agent-0', 'dev-agent-1'])
122
- expect(workerIds).toEqual(['hub/benchmark'])
123
- })
124
-
125
- it('skips the hub self-node on cold-scan', () => {
126
- const fakes = createFakes({ preExistingNodeIds: ['hub', 'dev-agent-0'] })
127
- fakes.service.onModuleInit()
128
- const ids = fakes.emits.events
129
- .filter((e) => e.category === 'agent.online')
130
- .map((e) => (e.data as Record<string, unknown>).agentId)
131
- expect(ids).toEqual(['dev-agent-0'])
132
- expect(ids).not.toContain('hub')
133
- })
134
-
135
- it('combined cold-scan + hot-path: no duplicates, both sources produce events', () => {
136
- const fakes = createFakes({ preExistingNodeIds: ['dev-agent-0'] })
137
- fakes.service.onModuleInit()
138
- // One from cold-scan.
139
- expect(fakes.emits.events).toHaveLength(1)
140
- // New connection fires the hot path.
141
- fakes.localBus.emit('$node.connected', { node: { id: 'dev-agent-1' } })
142
- expect(fakes.emits.events).toHaveLength(2)
143
- expect((fakes.emits.events[1]!.data as Record<string, unknown>).agentId).toBe('dev-agent-1')
144
- })
145
- })
146
-
147
- describe('resilience: malformed registry shape', () => {
148
- it('does not throw if broker.registry.getNodeList is missing', () => {
149
- const emits: CapturedEmits = { events: [] }
150
- const fakeEventBus = {
151
- emit: (e: SystemEvent) => emits.events.push(e),
152
- } as unknown as EventBusService
153
- const fakeMoleculer = {
154
- broker: { nodeID: 'hub', localBus: new EventEmitter(), registry: {} },
155
- setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
156
- /* no-op */
157
- },
158
- } as unknown as MoleculerService
159
- const svc = new AgentRegistryService(fakeEventBus, fakeMoleculer, {} as CapabilityService)
160
- expect(() => svc.onModuleInit()).not.toThrow()
161
- expect(emits.events).toHaveLength(0)
162
- })
163
-
164
- it('does not throw if broker.registry itself is missing', () => {
165
- const emits: CapturedEmits = { events: [] }
166
- const fakeEventBus = {
167
- emit: (e: SystemEvent) => emits.events.push(e),
168
- } as unknown as EventBusService
169
- const fakeMoleculer = {
170
- broker: { nodeID: 'hub', localBus: new EventEmitter() },
171
- setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
172
- /* no-op */
173
- },
174
- } as unknown as MoleculerService
175
- const svc = new AgentRegistryService(fakeEventBus, fakeMoleculer, {} as CapabilityService)
176
- expect(() => svc.onModuleInit()).not.toThrow()
177
- })
178
- })
179
- })