@camstack/server 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,109 @@
1
+
2
+ /**
3
+ * Embedded dependencies E2E — verify ffmpeg/python resolution and download.
4
+ *
5
+ * These tests check the resolution logic (PATH detection, embedded binary check)
6
+ * without actually downloading large binaries (unless CAMSTACK_TEST_DOWNLOAD=true).
7
+ */
8
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
9
+ import * as fs from 'node:fs'
10
+ import * as path from 'node:path'
11
+ import * as os from 'node:os'
12
+ // Import directly from source submodules because vitest+swc doesn't resolve
13
+ // `export * from './deps/index.js'` barrel re-exports in the @camstack/core index.
14
+ import { findInPath, getPlatformInfo, buildBinaryPath } from '../../../../packages/core/src/deps/binary-downloader'
15
+ import { getFfmpegDownloadUrl, ensureFfmpeg } from '../../../../packages/core/src/deps/ffmpeg-downloader'
16
+ import { getPythonDownloadUrl, ensurePython } from '../../../../packages/core/src/deps/python-downloader'
17
+ import type { IScopedLogger } from '@camstack/types'
18
+
19
+ function createMockLogger(): IScopedLogger {
20
+ const logger: IScopedLogger = {
21
+ info: () => {},
22
+ warn: () => {},
23
+ error: () => {},
24
+ debug: () => {},
25
+ child: () => createMockLogger(),
26
+ }
27
+ return logger
28
+ }
29
+
30
+
31
+ const mockLogger = createMockLogger()
32
+
33
+ describe('Embedded Dependencies E2E', () => {
34
+ let tmpDir: string
35
+
36
+ beforeAll(() => {
37
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-deps-e2e-'))
38
+ })
39
+
40
+ afterAll(() => {
41
+ fs.rmSync(tmpDir, { recursive: true, force: true })
42
+ })
43
+
44
+ describe('Platform detection', () => {
45
+ it('returns valid platform and arch', () => {
46
+ const info = getPlatformInfo()
47
+ expect(['darwin', 'linux', 'win32']).toContain(info.platform)
48
+ expect(['x64', 'arm64', 'arm', 'ia32']).toContain(info.arch)
49
+ })
50
+ })
51
+
52
+ describe('Binary path resolution', () => {
53
+ it('builds correct path for deps directory', () => {
54
+ const p = buildBinaryPath('/data', 'ffmpeg')
55
+ expect(p).toBe('/data/deps/ffmpeg')
56
+ })
57
+ })
58
+
59
+ describe('FFmpeg', () => {
60
+ it('generates valid download URL for current platform', () => {
61
+ const url = getFfmpegDownloadUrl(process.platform, process.arch)
62
+ expect(url).toMatch(/^https:\/\//)
63
+ expect(url.length).toBeGreaterThan(20)
64
+ })
65
+
66
+ it('finds ffmpeg in PATH or reports not found', () => {
67
+ const result = findInPath('ffmpeg')
68
+ // Either found (returns the name) or null — both valid
69
+ expect(result === null || typeof result === 'string').toBe(true)
70
+ })
71
+
72
+ // Only run download test if explicitly enabled (downloads ~80MB)
73
+ const downloadTest = process.env.CAMSTACK_TEST_DOWNLOAD === 'true' ? it : it.skip
74
+ downloadTest('downloads ffmpeg binary', async () => {
75
+ const ffmpegPath = await ensureFfmpeg(tmpDir, mockLogger)
76
+ expect(ffmpegPath).toBeTruthy()
77
+ expect(fs.existsSync(ffmpegPath)).toBe(true)
78
+ }, 120000)
79
+ })
80
+
81
+ describe('Python', () => {
82
+ it('generates valid download URL for current platform', () => {
83
+ const url = getPythonDownloadUrl(process.platform, process.arch)
84
+ expect(url).toContain('python-headless')
85
+ if (process.platform === 'darwin') {
86
+ expect(url).toContain('universal2')
87
+ }
88
+ })
89
+
90
+ it('finds python in PATH or reports not found', () => {
91
+ const result = findInPath('python3') ?? findInPath('python')
92
+ // Either found or null — both valid
93
+ expect(result === null || typeof result === 'string').toBe(true)
94
+ })
95
+
96
+ // Only run download test if explicitly enabled (downloads ~25MB)
97
+ const downloadTest = process.env.CAMSTACK_TEST_DOWNLOAD === 'true' ? it : it.skip
98
+ downloadTest('downloads portable Python', async () => {
99
+ const pythonPath = await ensurePython(tmpDir, mockLogger)
100
+ expect(pythonPath).toBeTruthy()
101
+ expect(fs.existsSync(pythonPath!)).toBe(true)
102
+
103
+ // Verify it actually runs
104
+ const { execFileSync } = await import('node:child_process')
105
+ const version = execFileSync(pythonPath!, ['--version'], { encoding: 'utf8' }).trim()
106
+ expect(version).toMatch(/Python 3\.12/)
107
+ }, 120000)
108
+ })
109
+ })
@@ -0,0 +1,72 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment -- test mock typing */
2
+ import { describe, it, expect, vi } from 'vitest'
3
+ import { createEventBusProxyRouter } from '../api/core/event-bus-proxy.router.js'
4
+ import { makeCtx } from './cap-routers/harness.js'
5
+ import type { IEventBus } from '@camstack/types'
6
+
7
+ function createMockEventBus() {
8
+ return {
9
+ emit: vi.fn(),
10
+ } as unknown as IEventBus & { emit: ReturnType<typeof vi.fn> }
11
+ }
12
+
13
+ describe('event-bus-proxy router', () => {
14
+ it('emit calls eventBus.emit with the correct shape', async () => {
15
+ const bus = createMockEventBus()
16
+ const router = createEventBusProxyRouter(bus)
17
+ const caller = router.createCaller(makeCtx('admin'))
18
+
19
+ await caller.emit({
20
+ id: 'evt-1',
21
+ timestamp: '2026-01-15T10:00:00.000Z',
22
+ source: { type: 'addon', id: 'my-addon' },
23
+ category: 'motion',
24
+ data: { zone: 'front' },
25
+ })
26
+
27
+ expect(bus.emit).toHaveBeenCalledOnce()
28
+ expect(bus.emit).toHaveBeenCalledWith({
29
+ id: 'evt-1',
30
+ timestamp: expect.any(Date),
31
+ source: { type: 'addon', id: 'my-addon' },
32
+ category: 'motion',
33
+ data: { zone: 'front' },
34
+ })
35
+ })
36
+
37
+ it('emit returns { ok: true }', async () => {
38
+ const bus = createMockEventBus()
39
+ const router = createEventBusProxyRouter(bus)
40
+ const caller = router.createCaller(makeCtx('admin'))
41
+
42
+ const result = await caller.emit({
43
+ id: 'evt-2',
44
+ timestamp: '2026-01-15T10:00:00.000Z',
45
+ source: { type: 'device', id: 'cam-1' },
46
+ category: 'alert',
47
+ data: {},
48
+ })
49
+
50
+ expect(result).toEqual({ ok: true })
51
+ })
52
+
53
+ it('timestamp string is converted to a Date object', async () => {
54
+ const bus = createMockEventBus()
55
+ const router = createEventBusProxyRouter(bus)
56
+ const caller = router.createCaller(makeCtx('admin'))
57
+
58
+ const isoString = '2026-06-20T14:30:00.000Z'
59
+
60
+ await caller.emit({
61
+ id: 'evt-3',
62
+ timestamp: isoString,
63
+ source: { type: 'addon', id: 'test' },
64
+ category: 'info',
65
+ data: {},
66
+ })
67
+
68
+ const emittedEvent = bus.emit.mock.calls[0][0] as { timestamp: unknown }
69
+ expect(emittedEvent.timestamp).toBeInstanceOf(Date)
70
+ expect((emittedEvent.timestamp as Date).toISOString()).toBe(isoString)
71
+ })
72
+ })
@@ -0,0 +1,37 @@
1
+ // server/backend/src/__tests__/fixtures/mock-analysis-addon-a.ts
2
+ import type { ICamstackAddon, AddonDeclaration, AddonContext } from '@camstack/types'
3
+
4
+ export class MockAnalysisAddonA implements ICamstackAddon {
5
+ readonly manifest: AddonDeclaration = {
6
+ id: 'mock-analysis-a',
7
+ name: 'Mock Analysis A',
8
+ version: '1.0.0',
9
+ capabilities: ['object-detector'],
10
+ }
11
+
12
+ private initialized = false
13
+ readonly provider = { id: 'analysis-a', processFrame: async () => [] }
14
+
15
+ async initialize(_context: AddonContext) {
16
+ this.initialized = true
17
+ return [{ capability: 'object-detector', provider: this.provider }]
18
+ }
19
+
20
+ async shutdown(): Promise<void> {
21
+ this.initialized = false
22
+ }
23
+
24
+ isInitialized(): boolean {
25
+ return this.initialized
26
+ }
27
+
28
+ getConfigSchema() {
29
+ return { sections: [] }
30
+ }
31
+
32
+ getConfig() {
33
+ return {}
34
+ }
35
+
36
+ async onConfigChange() {}
37
+ }
@@ -0,0 +1,37 @@
1
+ // server/backend/src/__tests__/fixtures/mock-analysis-addon-b.ts
2
+ import type { ICamstackAddon, AddonDeclaration, AddonContext } from '@camstack/types'
3
+
4
+ export class MockAnalysisAddonB implements ICamstackAddon {
5
+ readonly manifest: AddonDeclaration = {
6
+ id: 'mock-analysis-b',
7
+ name: 'Mock Analysis B',
8
+ version: '1.0.0',
9
+ capabilities: ['object-detector'],
10
+ }
11
+
12
+ private initialized = false
13
+ readonly provider = { id: 'analysis-b', processFrame: async () => [] }
14
+
15
+ async initialize(_context: AddonContext) {
16
+ this.initialized = true
17
+ return [{ capability: 'object-detector', provider: this.provider }]
18
+ }
19
+
20
+ async shutdown(): Promise<void> {
21
+ this.initialized = false
22
+ }
23
+
24
+ isInitialized(): boolean {
25
+ return this.initialized
26
+ }
27
+
28
+ getConfigSchema() {
29
+ return { sections: [] }
30
+ }
31
+
32
+ getConfig() {
33
+ return {}
34
+ }
35
+
36
+ async onConfigChange() {}
37
+ }
@@ -0,0 +1,37 @@
1
+ // server/backend/src/__tests__/fixtures/mock-log-addon.ts
2
+ import type { ICamstackAddon, AddonDeclaration, AddonContext } from '@camstack/types'
3
+
4
+ export class MockLogAddon implements ICamstackAddon {
5
+ readonly manifest: AddonDeclaration = {
6
+ id: 'mock-log-addon',
7
+ name: 'Mock Log Destination',
8
+ version: '1.0.0',
9
+ capabilities: ['log-destination'],
10
+ }
11
+
12
+ private initialized = false
13
+ readonly provider = { id: 'mock-log', write: async () => {} }
14
+
15
+ async initialize(_context: AddonContext) {
16
+ this.initialized = true
17
+ return [{ capability: 'log-destination', provider: this.provider }]
18
+ }
19
+
20
+ async shutdown(): Promise<void> {
21
+ this.initialized = false
22
+ }
23
+
24
+ isInitialized(): boolean {
25
+ return this.initialized
26
+ }
27
+
28
+ getConfigSchema() {
29
+ return { sections: [] }
30
+ }
31
+
32
+ getConfig() {
33
+ return {}
34
+ }
35
+
36
+ async onConfigChange() {}
37
+ }
@@ -0,0 +1,40 @@
1
+ // server/backend/src/__tests__/fixtures/mock-storage-addon.ts
2
+ import type { ICamstackAddon, AddonDeclaration, AddonContext } from '@camstack/types'
3
+
4
+ export class MockStorageAddon implements ICamstackAddon {
5
+ readonly manifest: AddonDeclaration = {
6
+ id: 'mock-storage',
7
+ name: 'Mock Storage',
8
+ version: '1.0.0',
9
+ capabilities: ['storage', 'settings-store'],
10
+ }
11
+
12
+ private initialized = false
13
+ readonly provider = { id: 'mock-storage', type: 'mock' }
14
+
15
+ async initialize(_context: AddonContext) {
16
+ this.initialized = true
17
+ return [
18
+ { capability: 'storage', provider: this.provider },
19
+ { capability: 'settings-store', provider: this.provider },
20
+ ]
21
+ }
22
+
23
+ async shutdown(): Promise<void> {
24
+ this.initialized = false
25
+ }
26
+
27
+ isInitialized(): boolean {
28
+ return this.initialized
29
+ }
30
+
31
+ getConfigSchema() {
32
+ return { sections: [] }
33
+ }
34
+
35
+ getConfig() {
36
+ return {}
37
+ }
38
+
39
+ async onConfigChange() {}
40
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Framework package allow-list coherence.
3
+ *
4
+ * Spec: docs/superpowers/specs/2026-05-14-framework-live-update-design.md
5
+ *
6
+ * Invariants:
7
+ * - Every entry in `FRAMEWORK_PACKAGE_ALLOWLIST` resolves to a workspace
8
+ * package whose `package.json` declares `camstack.system: true`.
9
+ * - Every workspace package with `camstack.system: true` appears in the
10
+ * allow-list — drift in either direction breaks live-update.
11
+ * - `isFrameworkPackage` accepts framework names and rejects everything
12
+ * else (sanity check on the gate the cap method uses).
13
+ */
14
+ import { describe, it, expect } from 'vitest'
15
+ import * as fs from 'node:fs'
16
+ import * as path from 'node:path'
17
+ import {
18
+ FRAMEWORK_PACKAGE_ALLOWLIST,
19
+ isFrameworkPackage,
20
+ } from '../core/addon/addon-package.service.js'
21
+
22
+ function repoRoot(): string {
23
+ return path.resolve(__dirname, '..', '..', '..', '..')
24
+ }
25
+
26
+ interface PackageJsonView {
27
+ readonly name: string
28
+ readonly system: boolean
29
+ readonly path: string
30
+ }
31
+
32
+ function readPackageJson(pkgJsonPath: string): PackageJsonView | null {
33
+ try {
34
+ const raw: unknown = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'))
35
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
36
+ const obj = raw as Record<string, unknown>
37
+ const name = obj['name']
38
+ if (typeof name !== 'string') return null
39
+ const camstack = obj['camstack']
40
+ const isSystem = camstack !== null
41
+ && typeof camstack === 'object'
42
+ && !Array.isArray(camstack)
43
+ && (camstack as Record<string, unknown>)['system'] === true
44
+ return { name, system: isSystem, path: pkgJsonPath }
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ function discoverWorkspacePackages(): readonly PackageJsonView[] {
51
+ const packagesDir = path.join(repoRoot(), 'packages')
52
+ if (!fs.existsSync(packagesDir)) return []
53
+ const results: PackageJsonView[] = []
54
+ for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) {
55
+ if (!entry.isDirectory()) continue
56
+ const pkgJson = path.join(packagesDir, entry.name, 'package.json')
57
+ if (!fs.existsSync(pkgJson)) continue
58
+ const view = readPackageJson(pkgJson)
59
+ if (view !== null) results.push(view)
60
+ }
61
+ return results
62
+ }
63
+
64
+ describe('framework package allow-list — manifest parity', () => {
65
+ it('every allow-listed package exists in `packages/` with camstack.system: true', () => {
66
+ const pkgs = discoverWorkspacePackages()
67
+ for (const name of FRAMEWORK_PACKAGE_ALLOWLIST) {
68
+ const match = pkgs.find((p) => p.name === name)
69
+ expect(match, `expected ${name} in packages/`).toBeDefined()
70
+ expect(match?.system, `${name} must declare camstack.system: true`).toBe(true)
71
+ }
72
+ })
73
+
74
+ it('every workspace package with camstack.system: true is in the allow-list', () => {
75
+ const systemPkgs = discoverWorkspacePackages().filter((p) => p.system)
76
+ const allowSet = new Set(FRAMEWORK_PACKAGE_ALLOWLIST)
77
+ const orphans = systemPkgs.filter((p) => !allowSet.has(p.name))
78
+ expect(orphans.map((p) => p.name)).toEqual([])
79
+ })
80
+ })
81
+
82
+ describe('isFrameworkPackage', () => {
83
+ it('returns true for every allow-listed package', () => {
84
+ for (const name of FRAMEWORK_PACKAGE_ALLOWLIST) {
85
+ expect(isFrameworkPackage(name)).toBe(true)
86
+ }
87
+ })
88
+
89
+ it('returns false for addons and unknown packages', () => {
90
+ expect(isFrameworkPackage('@camstack/addon-stream-broker')).toBe(false)
91
+ expect(isFrameworkPackage('left-pad')).toBe(false)
92
+ expect(isFrameworkPackage('')).toBe(false)
93
+ expect(isFrameworkPackage('@camstack')).toBe(false)
94
+ })
95
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * HTTPS E2E tests — verify self-signed cert generation, HTTPS serving, and WSS agent connection.
3
+ */
4
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
5
+ import * as fs from 'node:fs'
6
+ import * as path from 'node:path'
7
+ import * as os from 'node:os'
8
+ import * as https from 'node:https'
9
+ import { X509Certificate } from 'node:crypto'
10
+ // Import directly from source submodule because vitest+swc doesn't resolve
11
+ // `export * from './tls/index.js'` barrel re-exports in the @camstack/core index.
12
+ import { ensureTlsCert, loadTlsCert } from '../../../../packages/core/src/tls/cert-manager'
13
+
14
+ describe('HTTPS E2E', () => {
15
+ let tmpDir: string
16
+
17
+ beforeAll(() => {
18
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-https-e2e-'))
19
+ })
20
+
21
+ afterAll(() => {
22
+ fs.rmSync(tmpDir, { recursive: true, force: true })
23
+ })
24
+
25
+ it('generates a valid self-signed cert on first call', async () => {
26
+ const result = await ensureTlsCert(tmpDir)
27
+ expect(result.generated).toBe(true)
28
+
29
+ // Verify cert file is valid PEM
30
+ const certPem = fs.readFileSync(result.certPath, 'utf-8')
31
+ expect(certPem).toContain('-----BEGIN CERTIFICATE-----')
32
+
33
+ // Parse and validate
34
+ const x509 = new X509Certificate(certPem)
35
+ expect(x509.subject).toContain('CN=camstack.local')
36
+
37
+ // Check SAN includes localhost
38
+ const san = x509.subjectAltName ?? ''
39
+ expect(san).toContain('DNS:localhost')
40
+ expect(san).toContain('IP Address:127.0.0.1')
41
+
42
+ // Check validity (at least 1 year)
43
+ const validTo = new Date(x509.validTo)
44
+ const oneYear = new Date()
45
+ oneYear.setFullYear(oneYear.getFullYear() + 1)
46
+ expect(validTo.getTime()).toBeGreaterThan(oneYear.getTime() - 86400000)
47
+ })
48
+
49
+ it('reuses existing cert on subsequent calls', async () => {
50
+ const first = await ensureTlsCert(tmpDir)
51
+ const second = await ensureTlsCert(tmpDir)
52
+ expect(second.generated).toBe(false)
53
+
54
+ const cert1 = fs.readFileSync(first.certPath, 'utf-8')
55
+ const cert2 = fs.readFileSync(second.certPath, 'utf-8')
56
+ expect(cert1).toBe(cert2)
57
+ })
58
+
59
+ it('serves HTTPS with the generated cert', async () => {
60
+ const { certPath, keyPath } = await ensureTlsCert(tmpDir)
61
+ const { cert, key } = loadTlsCert(certPath, keyPath)
62
+
63
+ const server = https.createServer({ cert, key }, (_req, res) => {
64
+ res.writeHead(200, { 'Content-Type': 'text/plain' })
65
+ res.end('camstack-ok')
66
+ })
67
+
68
+ const port = 10000 + Math.floor(Math.random() * 50000)
69
+ await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve))
70
+
71
+ try {
72
+ const body = await new Promise<string>((resolve, reject) => {
73
+ const req = https.request(
74
+ { hostname: '127.0.0.1', port, path: '/', method: 'GET', rejectUnauthorized: false },
75
+ (res) => {
76
+ let data = ''
77
+ res.on('data', (c) => { data += c })
78
+ res.on('end', () => resolve(data))
79
+ },
80
+ )
81
+ req.on('error', reject)
82
+ req.end()
83
+ })
84
+
85
+ expect(body).toBe('camstack-ok')
86
+ } finally {
87
+ server.close()
88
+ }
89
+ })
90
+
91
+ it('TLS disabled falls back to HTTP', async () => {
92
+ // When tls.enabled = false, server should work over plain HTTP
93
+ // This is a config-level test, verified by checking that FastifyAdapter
94
+ // receives no https options when tls is disabled
95
+ const http = await import('node:http')
96
+ const server = http.createServer((_req, res) => {
97
+ res.writeHead(200)
98
+ res.end('http-ok')
99
+ })
100
+
101
+ const port = 10000 + Math.floor(Math.random() * 50000)
102
+ await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve))
103
+
104
+ try {
105
+ const body = await new Promise<string>((resolve, reject) => {
106
+ http.get(`http://127.0.0.1:${port}/`, (res) => {
107
+ let data = ''
108
+ res.on('data', (c) => { data += c })
109
+ res.on('end', () => resolve(data))
110
+ }).on('error', reject)
111
+ })
112
+
113
+ expect(body).toBe('http-ok')
114
+ } finally {
115
+ server.close()
116
+ }
117
+ })
118
+ })