@camstack/server 0.1.7 → 0.2.0

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 (135) hide show
  1. package/package.json +11 -9
  2. package/src/__tests__/addon-install-e2e.test.ts +0 -1
  3. package/src/__tests__/addon-pages-e2e.test.ts +40 -18
  4. package/src/__tests__/addon-settings-router.spec.ts +6 -1
  5. package/src/__tests__/addon-upload.spec.ts +91 -29
  6. package/src/__tests__/agent-registry.spec.ts +26 -9
  7. package/src/__tests__/agent-status-page.spec.ts +1 -3
  8. package/src/__tests__/auth-session-cookie.test.ts +28 -1
  9. package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
  10. package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
  11. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
  12. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
  13. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
  14. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
  15. package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
  16. package/src/__tests__/cap-route-adapter.spec.ts +28 -15
  17. package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
  18. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
  19. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
  20. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
  21. package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
  22. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
  23. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
  24. package/src/__tests__/cap-routers/harness.ts +11 -7
  25. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
  26. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
  27. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
  28. package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
  29. package/src/__tests__/capability-e2e.test.ts +9 -11
  30. package/src/__tests__/cli-e2e.test.ts +80 -59
  31. package/src/__tests__/core-cap-bridge.spec.ts +3 -1
  32. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
  33. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
  34. package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
  35. package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
  36. package/src/__tests__/framework-allowlist.spec.ts +5 -4
  37. package/src/__tests__/https-e2e.test.ts +12 -6
  38. package/src/__tests__/lifecycle-e2e.test.ts +60 -11
  39. package/src/__tests__/live-events-subscription.spec.ts +17 -18
  40. package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
  41. package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
  42. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
  43. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
  44. package/src/__tests__/native-cap-route.spec.ts +42 -19
  45. package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
  46. package/src/__tests__/singleton-contention.test.ts +23 -11
  47. package/src/__tests__/streaming-diagnostic.test.ts +156 -53
  48. package/src/__tests__/streaming-scale.test.ts +69 -35
  49. package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
  50. package/src/agent-status-page.ts +4 -3
  51. package/src/api/__tests__/addons-custom.spec.ts +22 -8
  52. package/src/api/__tests__/capabilities.router.test.ts +18 -9
  53. package/src/api/addon-upload.ts +46 -15
  54. package/src/api/addons-custom.router.ts +7 -6
  55. package/src/api/auth-whoami.ts +3 -1
  56. package/src/api/bridge-addons.router.ts +3 -1
  57. package/src/api/capabilities.router.ts +117 -78
  58. package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
  59. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  60. package/src/api/core/addon-settings.router.ts +4 -1
  61. package/src/api/core/agents.router.ts +52 -53
  62. package/src/api/core/auth.router.ts +55 -36
  63. package/src/api/core/bulk-update-coordinator.ts +25 -22
  64. package/src/api/core/cap-providers.ts +459 -166
  65. package/src/api/core/capabilities.router.ts +30 -23
  66. package/src/api/core/hwaccel.router.ts +37 -10
  67. package/src/api/core/live-events.router.ts +16 -9
  68. package/src/api/core/logs.router.ts +58 -25
  69. package/src/api/core/notifications.router.ts +2 -1
  70. package/src/api/core/repl.router.ts +1 -3
  71. package/src/api/core/settings-backend.router.ts +68 -70
  72. package/src/api/core/system-events.router.ts +41 -32
  73. package/src/api/health/health.routes.ts +7 -13
  74. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  75. package/src/api/oauth2/consent-page.ts +4 -3
  76. package/src/api/oauth2/oauth2-routes.ts +41 -12
  77. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  78. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  79. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  80. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
  81. package/src/api/trpc/cap-mount-helpers.ts +64 -44
  82. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  83. package/src/api/trpc/client-ip.ts +17 -0
  84. package/src/api/trpc/core-cap-bridge.ts +3 -1
  85. package/src/api/trpc/generated-cap-mounts.ts +801 -286
  86. package/src/api/trpc/generated-cap-routers.ts +5723 -719
  87. package/src/api/trpc/scope-access.ts +7 -7
  88. package/src/api/trpc/trpc.context.ts +7 -4
  89. package/src/api/trpc/trpc.middleware.ts +4 -2
  90. package/src/api/trpc/trpc.router.ts +117 -48
  91. package/src/auth/session-cookie.ts +10 -0
  92. package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
  93. package/src/boot/boot-config.ts +103 -122
  94. package/src/boot/integration-id-backfill.ts +109 -0
  95. package/src/boot/post-boot.service.ts +5 -3
  96. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  97. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  98. package/src/core/addon/addon-call-gateway.ts +20 -6
  99. package/src/core/addon/addon-package.service.ts +183 -89
  100. package/src/core/addon/addon-registry.service.ts +1212 -1267
  101. package/src/core/addon/addon-row-manifest.ts +29 -0
  102. package/src/core/addon/addon-search.service.ts +2 -1
  103. package/src/core/addon/addon-settings-provider.ts +27 -7
  104. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  105. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  106. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  107. package/src/core/agent/agent-registry.service.ts +60 -38
  108. package/src/core/auth/auth.service.spec.ts +6 -8
  109. package/src/core/config/config.service.spec.ts +1 -1
  110. package/src/core/events/event-bus.service.spec.ts +44 -21
  111. package/src/core/events/event-bus.service.ts +5 -1
  112. package/src/core/feature/feature.service.spec.ts +4 -1
  113. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  114. package/src/core/logging/logging.service.spec.ts +61 -21
  115. package/src/core/logging/logging.service.ts +19 -5
  116. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  117. package/src/core/moleculer/cap-call-fn.ts +5 -1
  118. package/src/core/moleculer/cap-route-authority.ts +18 -6
  119. package/src/core/moleculer/moleculer.service.ts +145 -29
  120. package/src/core/network/network-quality.service.spec.ts +7 -1
  121. package/src/core/notification/notification-wrapper.service.ts +1 -3
  122. package/src/core/notification/toast-wrapper.service.ts +1 -5
  123. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  124. package/src/core/repl/repl-engine.service.ts +11 -12
  125. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  126. package/src/core/streaming/stream-probe.service.ts +22 -13
  127. package/src/core/topology/topology-emitter.service.ts +5 -1
  128. package/src/launcher.ts +14 -9
  129. package/src/main.ts +658 -495
  130. package/src/manual-boot.ts +133 -154
  131. package/tsconfig.json +20 -8
  132. package/src/core/storage/settings-store.spec.ts +0 -213
  133. package/src/core/storage/settings-store.ts +0 -2
  134. package/src/core/storage/sql-schema.spec.ts +0 -140
  135. package/src/core/storage/sql-schema.ts +0 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -9,8 +9,8 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc -p tsconfig.build.json",
12
- "dev": "npx concurrently -n server,ui -c blue,magenta \"tsx watch --env-file=.env --ignore=./camstack-data src/launcher.ts\" \"cd ../../packages/addon-admin-ui && npx vite --port 3001\"",
13
- "serve": "tsx watch --env-file=.env --ignore=./camstack-data src/launcher.ts",
12
+ "dev": "npx concurrently -n server,ui -c blue,magenta \"CAMSTACK_RESTART_TOUCH_FILE=src/launcher.ts tsx watch --env-file=.env --ignore=./camstack-data --ignore=../../packages/core/dist --ignore=../../packages/sdk/dist --ignore=../../packages/ui-library/dist --ignore=../../packages/shm-ring/dist src/launcher.ts\" \"cd ../../packages/addon-admin-ui && npx vite --port 3001\"",
13
+ "serve": "CAMSTACK_RESTART_TOUCH_FILE=src/launcher.ts tsx watch --env-file=.env --ignore=./camstack-data --ignore=../../packages/core/dist --ignore=../../packages/sdk/dist --ignore=../../packages/ui-library/dist --ignore=../../packages/shm-ring/dist src/launcher.ts",
14
14
  "serve:once": "tsx --env-file=.env src/launcher.ts",
15
15
  "start": "node --env-file=.env dist/launcher.js",
16
16
  "typecheck": "tsc --noEmit",
@@ -20,7 +20,6 @@
20
20
  "dependencies": {
21
21
  "@camstack/addon-admin-ui": "*",
22
22
  "@camstack/addon-advanced-notifier": "*",
23
- "@camstack/addon-benchmark": "*",
24
23
  "@camstack/addon-pipeline": "*",
25
24
  "@camstack/addon-pipeline-orchestrator": "*",
26
25
  "@camstack/addon-post-analysis": "*",
@@ -31,25 +30,28 @@
31
30
  "@camstack/types": "*",
32
31
  "@camstack/ui-library": "*",
33
32
  "@fastify/cookie": "^11.0.2",
34
- "@fastify/multipart": "^9.0.0",
35
- "@fastify/static": "^8.0.0",
33
+ "@fastify/multipart": "^10.0.0",
34
+ "@fastify/static": "^9.1.3",
35
+ "@trpc/client": "^11.16.0",
36
36
  "@trpc/server": "^11.16.0",
37
37
  "fastify": "^5",
38
38
  "js-yaml": "^4",
39
39
  "moleculer": "^0.15.0",
40
- "tar": "^6.2.1",
40
+ "superjson": "^2.2.6",
41
+ "tar": "6.2.1",
41
42
  "ws": "^8.20.0",
42
43
  "zod": "^4.3.6"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@swc/core": "^1.15.18",
46
47
  "@types/js-yaml": "^4",
47
- "@types/node": "^22",
48
+ "@types/node": "^24",
48
49
  "@types/tar": "^6.1.13",
49
50
  "@types/ws": "^8.18.1",
50
51
  "tsx": "^4",
51
- "typescript": "^5.7",
52
+ "typescript": "~6.0.3",
52
53
  "unplugin-swc": "^1.5.9",
54
+ "vite": "^8.0.11",
53
55
  "vitest": "*"
54
56
  }
55
57
  }
@@ -10,7 +10,6 @@ import * as path from 'node:path'
10
10
  const TEST_ADDONS_DIR = path.resolve('test-output/fresh-addons')
11
11
 
12
12
  describe('Addon Directory Loading', () => {
13
-
14
13
  beforeAll(() => {
15
14
  // Clean slate — delete the test addons directory
16
15
  fs.rmSync(TEST_ADDONS_DIR, { recursive: true, force: true })
@@ -18,16 +18,23 @@ import type { AddonContext, IScopedLogger, ProviderRegistration } from '@camstac
18
18
 
19
19
  interface AdminUIAddonInstance {
20
20
  readonly id: string
21
- initialize(ctx: AddonContext): Promise<ProviderRegistration[] | void | undefined | {
22
- readonly providers?: readonly ProviderRegistration[]
23
- }>
21
+ initialize(ctx: AddonContext): Promise<
22
+ | ProviderRegistration[]
23
+ | void
24
+ | undefined
25
+ | {
26
+ readonly providers?: readonly ProviderRegistration[]
27
+ }
28
+ >
24
29
  }
25
30
 
26
31
  type AdminUIAddonCtor = new () => AdminUIAddonInstance
27
32
 
28
33
  async function loadAdminUIAddon(): Promise<AdminUIAddonCtor | null> {
29
34
  try {
30
- const mod = await import('@camstack/addon-admin-ui/server/addon') as { AdminUIAddon?: AdminUIAddonCtor }
35
+ const mod = (await import('@camstack/addon-admin-ui/server/addon')) as {
36
+ AdminUIAddon?: AdminUIAddonCtor
37
+ }
31
38
  return mod.AdminUIAddon ?? null
32
39
  } catch {
33
40
  return null
@@ -67,9 +74,10 @@ describe('Addon Pages Integration', () => {
67
74
  if (result) {
68
75
  const regs = Array.isArray(result) ? result : ((result as any).providers ?? [])
69
76
  for (const reg of regs as ProviderRegistration[]) {
70
- const capName = typeof reg.capability === 'string'
71
- ? reg.capability
72
- : (reg.capability as any)?.name ?? String(reg.capability)
77
+ const capName =
78
+ typeof reg.capability === 'string'
79
+ ? reg.capability
80
+ : ((reg.capability as any)?.name ?? String(reg.capability))
73
81
  providers.set(capName, reg.provider)
74
82
  }
75
83
  }
@@ -77,7 +85,6 @@ describe('Addon Pages Integration', () => {
77
85
  const provider = providers.get('addon-pages') as { getPages(): unknown[] } | undefined
78
86
  expect(provider).toBeDefined()
79
87
 
80
-
81
88
  const pages = provider!.getPages()
82
89
  expect(pages).toHaveLength(1)
83
90
 
@@ -116,17 +123,20 @@ describe('Addon Pages Integration', () => {
116
123
  if (result) {
117
124
  const regs = Array.isArray(result) ? result : ((result as any).providers ?? [])
118
125
  for (const reg of regs as ProviderRegistration[]) {
119
- const capName = typeof reg.capability === 'string'
120
- ? reg.capability
121
- : (reg.capability as any)?.name ?? String(reg.capability)
126
+ const capName =
127
+ typeof reg.capability === 'string'
128
+ ? reg.capability
129
+ : ((reg.capability as any)?.name ?? String(reg.capability))
122
130
  providers.set(capName, reg.provider)
123
131
  }
124
132
  }
125
133
 
126
- const ui = providers.get('admin-ui') as {
127
- getStaticDir(): Promise<{ readonly staticDir: string }>
128
- getVersion(): Promise<{ readonly version: string }>
129
- } | undefined
134
+ const ui = providers.get('admin-ui') as
135
+ | {
136
+ getStaticDir(): Promise<{ readonly staticDir: string }>
137
+ getVersion(): Promise<{ readonly version: string }>
138
+ }
139
+ | undefined
130
140
  expect(ui).toBeDefined()
131
141
  expect(typeof ui!.getStaticDir).toBe('function')
132
142
  expect(typeof ui!.getVersion).toBe('function')
@@ -138,7 +148,12 @@ describe('Addon Pages Integration', () => {
138
148
  const registry = new CapabilityRegistry(createMockLogger())
139
149
  registry.ready()
140
150
 
141
- registry.declareCapability({ name: 'addon-pages', scope: 'system', mode: 'collection', methods: {} })
151
+ registry.declareCapability({
152
+ name: 'addon-pages',
153
+ scope: 'system',
154
+ mode: 'collection',
155
+ methods: {},
156
+ })
142
157
 
143
158
  registry.registerProvider('addon-pages', 'benchmark', {
144
159
  id: 'benchmark',
@@ -163,14 +178,21 @@ describe('Addon Pages Integration', () => {
163
178
  const registry = new CapabilityRegistry(createMockLogger())
164
179
  registry.ready()
165
180
 
166
- registry.declareCapability({ name: 'admin-ui', scope: 'system', mode: 'singleton', methods: {} })
181
+ registry.declareCapability({
182
+ name: 'admin-ui',
183
+ scope: 'system',
184
+ mode: 'singleton',
185
+ methods: {},
186
+ })
167
187
 
168
188
  registry.registerProvider('admin-ui', 'admin-ui', {
169
189
  getStaticDir: async () => ({ staticDir: '/some/path/dist' }),
170
190
  getVersion: async () => ({ version: '0.1.0' }),
171
191
  })
172
192
 
173
- const ui = registry.getSingleton<{ getVersion(): Promise<{ readonly version: string }> }>('admin-ui')
193
+ const ui = registry.getSingleton<{ getVersion(): Promise<{ readonly version: string }> }>(
194
+ 'admin-ui',
195
+ )
174
196
  expect(ui).toBeDefined()
175
197
  const version = await ui!.getVersion()
176
198
  expect(version.version).toBe('0.1.0')
@@ -48,7 +48,12 @@ describe('addon-settings router', () => {
48
48
  })
49
49
 
50
50
  it('updateDevice stores a field and getDeviceOverrides reflects it', async () => {
51
- await caller.updateDevice({ addonId: 'my-addon', deviceId: 'cam-1', field: 'resolution', value: '1080p' })
51
+ await caller.updateDevice({
52
+ addonId: 'my-addon',
53
+ deviceId: 'cam-1',
54
+ field: 'resolution',
55
+ value: '1080p',
56
+ })
52
57
  const result = await caller.getDeviceOverrides({ addonId: 'my-addon', deviceId: 'cam-1' })
53
58
  expect(result).toEqual({ resolution: '1080p' })
54
59
  })
@@ -36,10 +36,7 @@ function buildValidTarball(manifest: { name: string; version: string }): Buffer
36
36
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vitest-tarball-'))
37
37
  try {
38
38
  fs.mkdirSync(path.join(tmpDir, 'package'), { recursive: true })
39
- fs.writeFileSync(
40
- path.join(tmpDir, 'package', 'package.json'),
41
- JSON.stringify(manifest),
42
- )
39
+ fs.writeFileSync(path.join(tmpDir, 'package', 'package.json'), JSON.stringify(manifest))
43
40
  const tgz = path.join(tmpDir, 'out.tgz')
44
41
  execFileSync('tar', ['-czf', tgz, '-C', tmpDir, 'package'])
45
42
  return fs.readFileSync(tgz)
@@ -94,9 +91,13 @@ function makeAuth(kind: 'admin' | 'non-admin' | 'invalid' = 'admin'): AuthServic
94
91
 
95
92
  function makeAddonBridge(installed?: { name: string; version: string }): AddonBridgeService {
96
93
  return {
97
- getInstaller: vi.fn(() => installed === undefined ? null : ({
98
- installFromTgz: vi.fn(async () => installed),
99
- })),
94
+ getInstaller: vi.fn(() =>
95
+ installed === undefined
96
+ ? null
97
+ : {
98
+ installFromTgz: vi.fn(async () => installed),
99
+ },
100
+ ),
100
101
  reloadPackages: vi.fn(async () => {}),
101
102
  } as unknown as AddonBridgeService
102
103
  }
@@ -144,8 +145,12 @@ function makeLogger(): IScopedLogger {
144
145
  info: vi.fn(),
145
146
  warn: vi.fn(),
146
147
  error: vi.fn(),
147
- child: vi.fn(function (this: IScopedLogger) { return this }),
148
- withTags: vi.fn(function (this: IScopedLogger) { return this }),
148
+ child: vi.fn(function (this: IScopedLogger) {
149
+ return this
150
+ }),
151
+ withTags: vi.fn(function (this: IScopedLogger) {
152
+ return this
153
+ }),
149
154
  } as unknown as IScopedLogger
150
155
  }
151
156
 
@@ -199,7 +204,9 @@ describe('POST /api/addons/upload', () => {
199
204
  method: 'POST',
200
205
  url: '/api/addons/upload',
201
206
  headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}` },
202
- payload: buildMultipart(TGZ_BOUNDARY, { file: { filename: 'addon-x.tgz', content: VALID_TARBALL } }),
207
+ payload: buildMultipart(TGZ_BOUNDARY, {
208
+ file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
209
+ }),
203
210
  })
204
211
  expect(res.statusCode).toBe(401)
205
212
  })
@@ -214,8 +221,13 @@ describe('POST /api/addons/upload', () => {
214
221
  const res = await fastify.inject({
215
222
  method: 'POST',
216
223
  url: '/api/addons/upload',
217
- headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer token' },
218
- payload: buildMultipart(TGZ_BOUNDARY, { file: { filename: 'addon-x.tgz', content: VALID_TARBALL } }),
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
+ }),
219
231
  })
220
232
  expect(res.statusCode).toBe(403)
221
233
  })
@@ -230,8 +242,13 @@ describe('POST /api/addons/upload', () => {
230
242
  const res = await fastify.inject({
231
243
  method: 'POST',
232
244
  url: '/api/addons/upload',
233
- headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
234
- payload: buildMultipart(TGZ_BOUNDARY, { file: { filename: 'evil.exe', content: VALID_TARBALL } }),
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
+ }),
235
252
  })
236
253
  expect(res.statusCode).toBe(400)
237
254
  })
@@ -240,7 +257,9 @@ describe('POST /api/addons/upload', () => {
240
257
  // Hub install writes a `.install-source` marker into
241
258
  // `${CAMSTACK_DATA}/addons/<addonName>/`. Point CAMSTACK_DATA at an
242
259
  // isolated temp dir for the test so the side effect doesn't escape.
243
- const tmpRoot = await import('node:fs/promises').then(fs => fs.mkdtemp('/tmp/camstack-upload-test-'))
260
+ const tmpRoot = await import('node:fs/promises').then((fs) =>
261
+ fs.mkdtemp('/tmp/camstack-upload-test-'),
262
+ )
244
263
  const previous = process.env['CAMSTACK_DATA']
245
264
  process.env['CAMSTACK_DATA'] = tmpRoot
246
265
  const fsSync = await import('node:fs')
@@ -256,12 +275,27 @@ describe('POST /api/addons/upload', () => {
256
275
  const res = await fastify.inject({
257
276
  method: 'POST',
258
277
  url: '/api/addons/upload',
259
- headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
260
- payload: buildMultipart(TGZ_BOUNDARY, { file: { filename: 'addon-x.tgz', content: VALID_TARBALL } }),
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
+ }),
261
285
  })
262
286
  expect(res.statusCode).toBe(200)
263
- const body = res.json() as { success: boolean; target: string; packageName: string; version: string }
264
- expect(body).toMatchObject({ success: true, target: 'hub', packageName: 'addon-x', version: '2.0.0' })
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
+ })
265
299
  } finally {
266
300
  if (previous === undefined) delete process.env['CAMSTACK_DATA']
267
301
  else process.env['CAMSTACK_DATA'] = previous
@@ -272,7 +306,9 @@ describe('POST /api/addons/upload', () => {
272
306
  it('restarts the server (not an in-process reload) when a framework package is deployed', async () => {
273
307
  // @camstack/core ships hub builtins that can't hot-reload in place — the
274
308
  // upload path must call restartServer() and SKIP the per-addon restartAddon.
275
- const tmpRoot = await import('node:fs/promises').then(fs => fs.mkdtemp('/tmp/camstack-upload-fw-'))
309
+ const tmpRoot = await import('node:fs/promises').then((fs) =>
310
+ fs.mkdtemp('/tmp/camstack-upload-fw-'),
311
+ )
276
312
  const previous = process.env['CAMSTACK_DATA']
277
313
  process.env['CAMSTACK_DATA'] = tmpRoot
278
314
  const fsSync = await import('node:fs')
@@ -282,7 +318,9 @@ describe('POST /api/addons/upload', () => {
282
318
  const restartServer = vi.fn((_requestedBy?: string) => {})
283
319
  const restartAddon = vi.fn(async (_id: string) => ({ success: true }))
284
320
  const registry = {
285
- listAddons: vi.fn(() => [{ manifest: { id: 'sqlite-settings', packageName: '@camstack/core' } }]),
321
+ listAddons: vi.fn(() => [
322
+ { manifest: { id: 'sqlite-settings', packageName: '@camstack/core' } },
323
+ ]),
286
324
  loadNewAddons: vi.fn(async () => ({ loaded: [] as string[], failed: [] as string[] })),
287
325
  restartAddon,
288
326
  getCapabilityRegistry: vi.fn(() => ({ getSingleton: vi.fn((_n: string) => null) })),
@@ -299,9 +337,15 @@ describe('POST /api/addons/upload', () => {
299
337
  const res = await fastify.inject({
300
338
  method: 'POST',
301
339
  url: '/api/addons/upload',
302
- headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
340
+ headers: {
341
+ 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
342
+ authorization: 'Bearer t',
343
+ },
303
344
  payload: buildMultipart(TGZ_BOUNDARY, {
304
- file: { filename: 'camstack-core-9.9.9.tgz', content: buildValidTarball({ name: '@camstack/core', version: '9.9.9' }) },
345
+ file: {
346
+ filename: 'camstack-core-9.9.9.tgz',
347
+ content: buildValidTarball({ name: '@camstack/core', version: '9.9.9' }),
348
+ },
305
349
  }),
306
350
  })
307
351
  expect(res.statusCode).toBe(200)
@@ -317,7 +361,11 @@ describe('POST /api/addons/upload', () => {
317
361
  })
318
362
 
319
363
  it('routes to $agent.deploy when nodeId is provided', async () => {
320
- const call = vi.fn(async () => ({ success: true, addonId: 'addon-x', path: '/agent/addons/addon-x' }))
364
+ const call = vi.fn(async () => ({
365
+ success: true,
366
+ addonId: 'addon-x',
367
+ path: '/agent/addons/addon-x',
368
+ }))
321
369
  fastify = await makeServer({
322
370
  auth: makeAuth('admin'),
323
371
  bridge: makeAddonBridge(),
@@ -326,7 +374,10 @@ describe('POST /api/addons/upload', () => {
326
374
  const res = await fastify.inject({
327
375
  method: 'POST',
328
376
  url: '/api/addons/upload',
329
- headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
377
+ headers: {
378
+ 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
379
+ authorization: 'Bearer t',
380
+ },
330
381
  payload: buildMultipart(TGZ_BOUNDARY, {
331
382
  file: { filename: 'addon-x-1.2.3.tgz', content: VALID_TARBALL },
332
383
  nodeId: 'agent-frigate',
@@ -346,7 +397,9 @@ describe('POST /api/addons/upload', () => {
346
397
  })
347
398
 
348
399
  it('returns 502 when $agent.deploy throws', async () => {
349
- const call = vi.fn(async () => { throw new Error('agent unreachable') })
400
+ const call = vi.fn(async () => {
401
+ throw new Error('agent unreachable')
402
+ })
350
403
  fastify = await makeServer({
351
404
  auth: makeAuth('admin'),
352
405
  bridge: makeAddonBridge(),
@@ -355,7 +408,10 @@ describe('POST /api/addons/upload', () => {
355
408
  const res = await fastify.inject({
356
409
  method: 'POST',
357
410
  url: '/api/addons/upload',
358
- headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
411
+ headers: {
412
+ 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
413
+ authorization: 'Bearer t',
414
+ },
359
415
  payload: buildMultipart(TGZ_BOUNDARY, {
360
416
  file: { filename: 'addon-x.tgz', content: VALID_TARBALL },
361
417
  nodeId: 'agent-frigate',
@@ -377,7 +433,10 @@ describe('POST /api/addons/upload', () => {
377
433
  await fastify.inject({
378
434
  method: 'POST',
379
435
  url: '/api/addons/upload',
380
- headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
436
+ headers: {
437
+ 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
438
+ authorization: 'Bearer t',
439
+ },
381
440
  payload: buildMultipart(TGZ_BOUNDARY, {
382
441
  // Filename is intentionally different from manifest.name to prove
383
442
  // that the server reads the manifest, not the filename.
@@ -401,7 +460,10 @@ describe('POST /api/addons/upload', () => {
401
460
  const res = await fastify.inject({
402
461
  method: 'POST',
403
462
  url: '/api/addons/upload',
404
- headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
463
+ headers: {
464
+ 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`,
465
+ authorization: 'Bearer t',
466
+ },
405
467
  payload: buildMultipart(TGZ_BOUNDARY, {
406
468
  file: { filename: 'bogus.tgz', content: INVALID_TARBALL },
407
469
  }),
@@ -27,7 +27,9 @@ interface CapturedEmits {
27
27
  function createFakes(opts: { preExistingNodeIds?: readonly string[] } = {}) {
28
28
  const emits: CapturedEmits = { events: [] }
29
29
  const fakeEventBus = {
30
- emit: (event: SystemEvent) => { emits.events.push(event) },
30
+ emit: (event: SystemEvent) => {
31
+ emits.events.push(event)
32
+ },
31
33
  } as unknown as EventBusService
32
34
 
33
35
  const localBus = new EventEmitter()
@@ -46,7 +48,9 @@ function createFakes(opts: { preExistingNodeIds?: readonly string[] } = {}) {
46
48
  },
47
49
  },
48
50
  },
49
- setOnAgentRegistered: (_cb: (nodeId: string) => void) => { /* no-op in unit tests */ },
51
+ setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
52
+ /* no-op in unit tests */
53
+ },
50
54
  } as unknown as MoleculerService
51
55
 
52
56
  const fakeCapability = {} as unknown as CapabilityService
@@ -87,8 +91,13 @@ describe('AgentRegistryService — agent.online event lifecycle', () => {
87
91
  localBus.emit('$node.connected', { node: { id: `agent-${i}` } })
88
92
  }
89
93
  expect(captured.events).toHaveLength(5)
90
- expect(captured.events.map((e) => (e.data as Record<string, unknown>).agentId))
91
- .toEqual(['agent-0', 'agent-1', 'agent-2', 'agent-3', 'agent-4'])
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
+ ])
92
101
  })
93
102
  })
94
103
 
@@ -109,7 +118,7 @@ describe('AgentRegistryService — agent.online event lifecycle', () => {
109
118
  const workerIds = fakes.emits.events
110
119
  .filter((e) => e.category === 'worker.online')
111
120
  .map((e) => (e.data as Record<string, unknown>).workerId)
112
- expect(agentIds.sort()).toEqual(['dev-agent-0', 'dev-agent-1'])
121
+ expect(agentIds.toSorted()).toEqual(['dev-agent-0', 'dev-agent-1'])
113
122
  expect(workerIds).toEqual(['hub/benchmark'])
114
123
  })
115
124
 
@@ -138,10 +147,14 @@ describe('AgentRegistryService — agent.online event lifecycle', () => {
138
147
  describe('resilience: malformed registry shape', () => {
139
148
  it('does not throw if broker.registry.getNodeList is missing', () => {
140
149
  const emits: CapturedEmits = { events: [] }
141
- const fakeEventBus = { emit: (e: SystemEvent) => emits.events.push(e) } as unknown as EventBusService
150
+ const fakeEventBus = {
151
+ emit: (e: SystemEvent) => emits.events.push(e),
152
+ } as unknown as EventBusService
142
153
  const fakeMoleculer = {
143
154
  broker: { nodeID: 'hub', localBus: new EventEmitter(), registry: {} },
144
- setOnAgentRegistered: (_cb: (nodeId: string) => void) => { /* no-op */ },
155
+ setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
156
+ /* no-op */
157
+ },
145
158
  } as unknown as MoleculerService
146
159
  const svc = new AgentRegistryService(fakeEventBus, fakeMoleculer, {} as CapabilityService)
147
160
  expect(() => svc.onModuleInit()).not.toThrow()
@@ -150,10 +163,14 @@ describe('AgentRegistryService — agent.online event lifecycle', () => {
150
163
 
151
164
  it('does not throw if broker.registry itself is missing', () => {
152
165
  const emits: CapturedEmits = { events: [] }
153
- const fakeEventBus = { emit: (e: SystemEvent) => emits.events.push(e) } as unknown as EventBusService
166
+ const fakeEventBus = {
167
+ emit: (e: SystemEvent) => emits.events.push(e),
168
+ } as unknown as EventBusService
154
169
  const fakeMoleculer = {
155
170
  broker: { nodeID: 'hub', localBus: new EventEmitter() },
156
- setOnAgentRegistered: (_cb: (nodeId: string) => void) => { /* no-op */ },
171
+ setOnAgentRegistered: (_cb: (nodeId: string) => void) => {
172
+ /* no-op */
173
+ },
157
174
  } as unknown as MoleculerService
158
175
  const svc = new AgentRegistryService(fakeEventBus, fakeMoleculer, {} as CapabilityService)
159
176
  expect(() => svc.onModuleInit()).not.toThrow()
@@ -61,9 +61,7 @@ describe('renderAgentStatusPage', () => {
61
61
  })
62
62
 
63
63
  it('calculates memory usage', () => {
64
- const html = renderAgentStatusPage(
65
- makeStatusData({ memoryTotalMB: 16384, memoryFreeMB: 4096 }),
66
- )
64
+ const html = renderAgentStatusPage(makeStatusData({ memoryTotalMB: 16384, memoryFreeMB: 4096 }))
67
65
  // 12288 / 16384 = 75%
68
66
  expect(html).toContain('12288')
69
67
  expect(html).toContain('75%')
@@ -1,5 +1,10 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { buildSessionCookie, clearSessionCookie, SESSION_COOKIE } from '../auth/session-cookie.js'
2
+ import {
3
+ buildSessionCookie,
4
+ clearSessionCookie,
5
+ SESSION_COOKIE,
6
+ isEmbedRedirectTarget,
7
+ } from '../auth/session-cookie.js'
3
8
 
4
9
  describe('session cookie', () => {
5
10
  it('buildSessionCookie produces an httpOnly lax cookie carrying the token', () => {
@@ -19,3 +24,25 @@ describe('session cookie', () => {
19
24
  expect(c.options.maxAge).toBe(0)
20
25
  })
21
26
  })
27
+
28
+ describe('isEmbedRedirectTarget', () => {
29
+ it('accepts a stream-broker embed path (with query)', () => {
30
+ expect(isEmbedRedirectTarget('/addon/stream-broker/embed/?mode=grid')).toBe(true)
31
+ expect(isEmbedRedirectTarget('/addon/stream-broker/embed/?mode=player&devices=7,8')).toBe(true)
32
+ expect(isEmbedRedirectTarget('/addon/stream-broker/embed/')).toBe(true)
33
+ })
34
+
35
+ it('rejects open redirects (absolute / protocol-relative / backslash)', () => {
36
+ expect(isEmbedRedirectTarget('https://evil.com/addon/stream-broker/embed/')).toBe(false)
37
+ expect(isEmbedRedirectTarget('//evil.com')).toBe(false)
38
+ expect(isEmbedRedirectTarget('/\\evil.com')).toBe(false)
39
+ expect(isEmbedRedirectTarget('http://x/addon/stream-broker/embed/')).toBe(false)
40
+ })
41
+
42
+ it('rejects non-embed paths and path traversal', () => {
43
+ expect(isEmbedRedirectTarget('/addon/other/embed/')).toBe(false)
44
+ expect(isEmbedRedirectTarget('/etc/passwd')).toBe(false)
45
+ expect(isEmbedRedirectTarget('/addon/stream-broker/embed/../../secret')).toBe(false)
46
+ expect(isEmbedRedirectTarget('')).toBe(false)
47
+ })
48
+ })