@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.
- package/package.json +11 -9
- package/src/__tests__/addon-install-e2e.test.ts +0 -1
- package/src/__tests__/addon-pages-e2e.test.ts +40 -18
- package/src/__tests__/addon-settings-router.spec.ts +6 -1
- package/src/__tests__/addon-upload.spec.ts +91 -29
- package/src/__tests__/agent-registry.spec.ts +26 -9
- package/src/__tests__/agent-status-page.spec.ts +1 -3
- package/src/__tests__/auth-session-cookie.test.ts +28 -1
- package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
- package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
- package/src/__tests__/cap-route-adapter.spec.ts +28 -15
- package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
- package/src/__tests__/cap-routers/harness.ts +11 -7
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
- package/src/__tests__/capability-e2e.test.ts +9 -11
- package/src/__tests__/cli-e2e.test.ts +80 -59
- package/src/__tests__/core-cap-bridge.spec.ts +3 -1
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
- package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
- package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
- package/src/__tests__/framework-allowlist.spec.ts +5 -4
- package/src/__tests__/https-e2e.test.ts +12 -6
- package/src/__tests__/lifecycle-e2e.test.ts +60 -11
- package/src/__tests__/live-events-subscription.spec.ts +17 -18
- package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
- package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
- package/src/__tests__/native-cap-route.spec.ts +42 -19
- package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
- package/src/__tests__/singleton-contention.test.ts +23 -11
- package/src/__tests__/streaming-diagnostic.test.ts +156 -53
- package/src/__tests__/streaming-scale.test.ts +69 -35
- package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
- package/src/agent-status-page.ts +4 -3
- package/src/api/__tests__/addons-custom.spec.ts +22 -8
- package/src/api/__tests__/capabilities.router.test.ts +18 -9
- package/src/api/addon-upload.ts +46 -15
- package/src/api/addons-custom.router.ts +7 -6
- package/src/api/auth-whoami.ts +3 -1
- package/src/api/bridge-addons.router.ts +3 -1
- package/src/api/capabilities.router.ts +117 -78
- package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/addon-settings.router.ts +4 -1
- package/src/api/core/agents.router.ts +52 -53
- package/src/api/core/auth.router.ts +55 -36
- package/src/api/core/bulk-update-coordinator.ts +25 -22
- package/src/api/core/cap-providers.ts +459 -166
- package/src/api/core/capabilities.router.ts +30 -23
- package/src/api/core/hwaccel.router.ts +37 -10
- package/src/api/core/live-events.router.ts +16 -9
- package/src/api/core/logs.router.ts +58 -25
- package/src/api/core/notifications.router.ts +2 -1
- package/src/api/core/repl.router.ts +1 -3
- package/src/api/core/settings-backend.router.ts +68 -70
- package/src/api/core/system-events.router.ts +41 -32
- package/src/api/health/health.routes.ts +7 -13
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
- package/src/api/oauth2/consent-page.ts +4 -3
- package/src/api/oauth2/oauth2-routes.ts +41 -12
- package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
- package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
- package/src/api/trpc/cap-mount-helpers.ts +64 -44
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/client-ip.ts +17 -0
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +801 -286
- package/src/api/trpc/generated-cap-routers.ts +5723 -719
- package/src/api/trpc/scope-access.ts +7 -7
- package/src/api/trpc/trpc.context.ts +7 -4
- package/src/api/trpc/trpc.middleware.ts +4 -2
- package/src/api/trpc/trpc.router.ts +117 -48
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
- package/src/boot/boot-config.ts +103 -122
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/boot/post-boot.service.ts +5 -3
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1212 -1267
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-search.service.ts +2 -1
- package/src/core/addon/addon-settings-provider.ts +27 -7
- package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
- package/src/core/addon-pages/addon-pages.service.ts +3 -1
- package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
- package/src/core/agent/agent-registry.service.ts +60 -38
- package/src/core/auth/auth.service.spec.ts +6 -8
- package/src/core/config/config.service.spec.ts +1 -1
- package/src/core/events/event-bus.service.spec.ts +44 -21
- package/src/core/events/event-bus.service.ts +5 -1
- package/src/core/feature/feature.service.spec.ts +4 -1
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
- package/src/core/logging/logging.service.spec.ts +61 -21
- package/src/core/logging/logging.service.ts +19 -5
- package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
- package/src/core/moleculer/cap-call-fn.ts +5 -1
- package/src/core/moleculer/cap-route-authority.ts +18 -6
- package/src/core/moleculer/moleculer.service.ts +145 -29
- package/src/core/network/network-quality.service.spec.ts +7 -1
- package/src/core/notification/notification-wrapper.service.ts +1 -3
- package/src/core/notification/toast-wrapper.service.ts +1 -5
- package/src/core/repl/repl-engine.service.spec.ts +66 -39
- package/src/core/repl/repl-engine.service.ts +11 -12
- package/src/core/storage/storage-location-manager.spec.ts +12 -3
- package/src/core/streaming/stream-probe.service.ts +22 -13
- package/src/core/topology/topology-emitter.service.ts +5 -1
- package/src/launcher.ts +14 -9
- package/src/main.ts +658 -495
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- package/src/core/storage/sql-schema.ts +0 -3
package/src/boot/boot-config.ts
CHANGED
|
@@ -4,26 +4,26 @@
|
|
|
4
4
|
* Extracted from main.ts — pure extraction, no behavior change.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import * as fs from
|
|
8
|
-
import * as path from
|
|
9
|
-
import * as yaml from
|
|
10
|
-
import { randomBytes } from
|
|
11
|
-
import { asJsonObject } from
|
|
12
|
-
import { bootstrapSchema } from
|
|
13
|
-
import type { BootstrapConfig } from
|
|
14
|
-
import { StorageLocationManager } from
|
|
7
|
+
import * as fs from 'node:fs'
|
|
8
|
+
import * as path from 'node:path'
|
|
9
|
+
import * as yaml from 'js-yaml'
|
|
10
|
+
import { randomBytes } from 'node:crypto'
|
|
11
|
+
import { asJsonObject } from '@camstack/types'
|
|
12
|
+
import { bootstrapSchema } from '../core/config/config.schema'
|
|
13
|
+
import type { BootstrapConfig } from '../core/config/config.schema'
|
|
14
|
+
import { StorageLocationManager } from '../core/storage/storage-location-manager'
|
|
15
15
|
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
// Types
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
|
|
20
|
-
export type { BootstrapConfig }
|
|
20
|
+
export type { BootstrapConfig }
|
|
21
21
|
|
|
22
22
|
export interface InfraContext {
|
|
23
|
-
readonly bootstrapConfig: BootstrapConfig
|
|
24
|
-
readonly dataPath: string
|
|
25
|
-
readonly locationManager: StorageLocationManager
|
|
26
|
-
readonly tlsOptions: { key: Buffer; cert: Buffer } | undefined
|
|
23
|
+
readonly bootstrapConfig: BootstrapConfig
|
|
24
|
+
readonly dataPath: string
|
|
25
|
+
readonly locationManager: StorageLocationManager
|
|
26
|
+
readonly tlsOptions: { key: Buffer; cert: Buffer } | undefined
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
// ---------------------------------------------------------------------------
|
|
@@ -31,28 +31,28 @@ export interface InfraContext {
|
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
32
32
|
|
|
33
33
|
const CONFIG_DEFAULTS: Record<string, unknown> = {
|
|
34
|
-
server: { port: 4443, host:
|
|
34
|
+
server: { port: 4443, host: '0.0.0.0', dataPath: 'camstack-data' },
|
|
35
35
|
auth: {
|
|
36
36
|
jwtSecret: null,
|
|
37
|
-
adminUsername:
|
|
38
|
-
adminPassword:
|
|
37
|
+
adminUsername: 'admin',
|
|
38
|
+
adminPassword: 'changeme',
|
|
39
39
|
},
|
|
40
|
-
}
|
|
40
|
+
}
|
|
41
41
|
|
|
42
42
|
const ENV_VAR_MAP: Record<string, string> = {
|
|
43
|
-
CAMSTACK_PORT:
|
|
44
|
-
CAMSTACK_HOST:
|
|
45
|
-
CAMSTACK_DATA:
|
|
46
|
-
CAMSTACK_JWT_SECRET:
|
|
47
|
-
CAMSTACK_ADMIN_USER:
|
|
48
|
-
CAMSTACK_ADMIN_PASS:
|
|
49
|
-
CAMSTACK_HUB_URL:
|
|
50
|
-
CAMSTACK_HUB_TOKEN:
|
|
51
|
-
CAMSTACK_AGENT_NAME:
|
|
52
|
-
CAMSTACK_TLS_ENABLED:
|
|
53
|
-
CAMSTACK_TLS_CERT:
|
|
54
|
-
CAMSTACK_TLS_KEY:
|
|
55
|
-
}
|
|
43
|
+
CAMSTACK_PORT: 'server.port',
|
|
44
|
+
CAMSTACK_HOST: 'server.host',
|
|
45
|
+
CAMSTACK_DATA: 'server.dataPath',
|
|
46
|
+
CAMSTACK_JWT_SECRET: 'auth.jwtSecret',
|
|
47
|
+
CAMSTACK_ADMIN_USER: 'auth.adminUsername',
|
|
48
|
+
CAMSTACK_ADMIN_PASS: 'auth.adminPassword',
|
|
49
|
+
CAMSTACK_HUB_URL: 'hub.url',
|
|
50
|
+
CAMSTACK_HUB_TOKEN: 'hub.token',
|
|
51
|
+
CAMSTACK_AGENT_NAME: 'agent.name',
|
|
52
|
+
CAMSTACK_TLS_ENABLED: 'tls.enabled',
|
|
53
|
+
CAMSTACK_TLS_CERT: 'tls.certPath',
|
|
54
|
+
CAMSTACK_TLS_KEY: 'tls.keyPath',
|
|
55
|
+
}
|
|
56
56
|
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
58
|
// Helpers
|
|
@@ -63,11 +63,11 @@ function setNested(
|
|
|
63
63
|
p: string,
|
|
64
64
|
value: unknown,
|
|
65
65
|
): Record<string, unknown> {
|
|
66
|
-
const [head, ...rest] = p.split(
|
|
67
|
-
if (!head) return obj
|
|
68
|
-
if (rest.length === 0) return { ...obj, [head]: value }
|
|
69
|
-
const child = asJsonObject(obj[head]) ?? {}
|
|
70
|
-
return { ...obj, [head]: setNested(child, rest.join(
|
|
66
|
+
const [head, ...rest] = p.split('.')
|
|
67
|
+
if (!head) return obj
|
|
68
|
+
if (rest.length === 0) return { ...obj, [head]: value }
|
|
69
|
+
const child = asJsonObject(obj[head]) ?? {}
|
|
70
|
+
return { ...obj, [head]: setNested(child, rest.join('.'), value) }
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
@@ -82,72 +82,68 @@ export function loadBootstrapConfig(configPath: string): BootstrapConfig {
|
|
|
82
82
|
// Only bootstrap sections live in config.yaml.
|
|
83
83
|
// All runtime settings are stored in the SQL system_settings table.
|
|
84
84
|
|
|
85
|
-
let raw: Record<string, unknown
|
|
85
|
+
let raw: Record<string, unknown>
|
|
86
86
|
|
|
87
87
|
if (fs.existsSync(configPath)) {
|
|
88
|
-
const content = fs.readFileSync(configPath,
|
|
89
|
-
raw = asJsonObject(yaml.load(content)) ?? {}
|
|
88
|
+
const content = fs.readFileSync(configPath, 'utf-8')
|
|
89
|
+
raw = asJsonObject(yaml.load(content)) ?? {}
|
|
90
90
|
// Merge in any missing bootstrap sections (server, auth only)
|
|
91
|
-
let updated = false
|
|
91
|
+
let updated = false
|
|
92
92
|
for (const [key, defaults] of Object.entries(CONFIG_DEFAULTS)) {
|
|
93
93
|
if (!(key in raw)) {
|
|
94
|
-
raw[key] = defaults
|
|
95
|
-
updated = true
|
|
94
|
+
raw[key] = defaults
|
|
95
|
+
updated = true
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
if (updated) {
|
|
99
99
|
try {
|
|
100
|
-
const tmpPath = `${configPath}.tmp
|
|
100
|
+
const tmpPath = `${configPath}.tmp`
|
|
101
101
|
fs.writeFileSync(
|
|
102
102
|
tmpPath,
|
|
103
103
|
yaml.dump(raw, { lineWidth: 120, indent: 2, quotingType: '"' }),
|
|
104
|
-
|
|
105
|
-
)
|
|
106
|
-
fs.renameSync(tmpPath, configPath)
|
|
107
|
-
console.log(
|
|
108
|
-
`[Phase1] Updated config.yaml with missing bootstrap defaults`,
|
|
109
|
-
);
|
|
104
|
+
'utf-8',
|
|
105
|
+
)
|
|
106
|
+
fs.renameSync(tmpPath, configPath)
|
|
107
|
+
console.log(`[Phase1] Updated config.yaml with missing bootstrap defaults`)
|
|
110
108
|
} catch (err) {
|
|
111
|
-
console.warn(`[Phase1] Could not update config.yaml:`, err)
|
|
109
|
+
console.warn(`[Phase1] Could not update config.yaml:`, err)
|
|
112
110
|
}
|
|
113
111
|
}
|
|
114
|
-
console.log(`[Phase1] Loaded bootstrap config from: ${configPath}`)
|
|
112
|
+
console.log(`[Phase1] Loaded bootstrap config from: ${configPath}`)
|
|
115
113
|
} else {
|
|
116
|
-
console.log(
|
|
117
|
-
|
|
118
|
-
);
|
|
119
|
-
const defaults = { ...CONFIG_DEFAULTS };
|
|
114
|
+
console.log(`[Phase1] Config file not found at: ${configPath} — writing defaults`)
|
|
115
|
+
const defaults = { ...CONFIG_DEFAULTS }
|
|
120
116
|
try {
|
|
121
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
|
122
|
-
const tmpPath = `${configPath}.tmp
|
|
117
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
|
118
|
+
const tmpPath = `${configPath}.tmp`
|
|
123
119
|
fs.writeFileSync(
|
|
124
120
|
tmpPath,
|
|
125
121
|
yaml.dump(defaults, { lineWidth: 120, indent: 2, quotingType: '"' }),
|
|
126
|
-
|
|
127
|
-
)
|
|
128
|
-
fs.renameSync(tmpPath, configPath)
|
|
129
|
-
console.log(`[Phase1] Default config.yaml written to: ${configPath}`)
|
|
122
|
+
'utf-8',
|
|
123
|
+
)
|
|
124
|
+
fs.renameSync(tmpPath, configPath)
|
|
125
|
+
console.log(`[Phase1] Default config.yaml written to: ${configPath}`)
|
|
130
126
|
} catch (err) {
|
|
131
|
-
console.warn(`[Phase1] Could not write default config.yaml:`, err)
|
|
127
|
+
console.warn(`[Phase1] Could not write default config.yaml:`, err)
|
|
132
128
|
}
|
|
133
|
-
raw = defaults
|
|
129
|
+
raw = defaults
|
|
134
130
|
}
|
|
135
131
|
|
|
136
132
|
// Apply env var overrides for bootstrap keys
|
|
137
133
|
for (const [envKey, configPath_] of Object.entries(ENV_VAR_MAP)) {
|
|
138
|
-
const envValue = process.env[envKey]
|
|
139
|
-
if (envValue === undefined || envValue ===
|
|
134
|
+
const envValue = process.env[envKey]
|
|
135
|
+
if (envValue === undefined || envValue === '') continue
|
|
140
136
|
const coerced: unknown =
|
|
141
|
-
configPath_ ===
|
|
137
|
+
configPath_ === 'server.port'
|
|
142
138
|
? Number(envValue)
|
|
143
|
-
: configPath_ ===
|
|
144
|
-
? envValue ===
|
|
145
|
-
: envValue
|
|
146
|
-
raw = setNested(raw, configPath_, coerced)
|
|
147
|
-
console.log(`[Phase1] Env override: ${envKey} → ${configPath_}`)
|
|
139
|
+
: configPath_ === 'tls.enabled'
|
|
140
|
+
? envValue === 'true'
|
|
141
|
+
: envValue
|
|
142
|
+
raw = setNested(raw, configPath_, coerced)
|
|
143
|
+
console.log(`[Phase1] Env override: ${envKey} → ${configPath_}`)
|
|
148
144
|
}
|
|
149
145
|
|
|
150
|
-
return bootstrapSchema.parse(raw)
|
|
146
|
+
return bootstrapSchema.parse(raw)
|
|
151
147
|
}
|
|
152
148
|
|
|
153
149
|
// ---------------------------------------------------------------------------
|
|
@@ -163,42 +159,37 @@ export function autoGenerateJwtSecret(
|
|
|
163
159
|
bootstrapConfig: BootstrapConfig,
|
|
164
160
|
): BootstrapConfig {
|
|
165
161
|
if (bootstrapConfig.auth.jwtSecret !== null) {
|
|
166
|
-
return bootstrapConfig
|
|
162
|
+
return bootstrapConfig
|
|
167
163
|
}
|
|
168
164
|
|
|
169
|
-
const secret = randomBytes(32).toString(
|
|
170
|
-
console.log(
|
|
171
|
-
"[Phase1] jwtSecret is null — auto-generating and writing to config.yaml",
|
|
172
|
-
);
|
|
165
|
+
const secret = randomBytes(32).toString('hex')
|
|
166
|
+
console.log('[Phase1] jwtSecret is null — auto-generating and writing to config.yaml')
|
|
173
167
|
|
|
174
|
-
let raw: Record<string, unknown> = {}
|
|
168
|
+
let raw: Record<string, unknown> = {}
|
|
175
169
|
if (fs.existsSync(configPath)) {
|
|
176
|
-
raw = asJsonObject(yaml.load(fs.readFileSync(configPath,
|
|
170
|
+
raw = asJsonObject(yaml.load(fs.readFileSync(configPath, 'utf-8'))) ?? {}
|
|
177
171
|
}
|
|
178
172
|
|
|
179
|
-
const authSection = asJsonObject(raw.auth) ?? {}
|
|
180
|
-
raw.auth = { ...authSection, jwtSecret: secret }
|
|
173
|
+
const authSection = asJsonObject(raw.auth) ?? {}
|
|
174
|
+
raw.auth = { ...authSection, jwtSecret: secret }
|
|
181
175
|
|
|
182
|
-
const tmpPath = `${configPath}.tmp
|
|
176
|
+
const tmpPath = `${configPath}.tmp`
|
|
183
177
|
try {
|
|
184
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
|
178
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
|
185
179
|
fs.writeFileSync(
|
|
186
180
|
tmpPath,
|
|
187
181
|
yaml.dump(raw, { lineWidth: 120, indent: 2, quotingType: '"' }),
|
|
188
|
-
|
|
189
|
-
)
|
|
190
|
-
fs.renameSync(tmpPath, configPath)
|
|
182
|
+
'utf-8',
|
|
183
|
+
)
|
|
184
|
+
fs.renameSync(tmpPath, configPath)
|
|
191
185
|
} catch (err) {
|
|
192
|
-
console.warn(
|
|
193
|
-
"[Phase1] Could not write auto-generated jwtSecret to config.yaml:",
|
|
194
|
-
err,
|
|
195
|
-
);
|
|
186
|
+
console.warn('[Phase1] Could not write auto-generated jwtSecret to config.yaml:', err)
|
|
196
187
|
}
|
|
197
188
|
|
|
198
189
|
return {
|
|
199
190
|
...bootstrapConfig,
|
|
200
191
|
auth: { ...bootstrapConfig.auth, jwtSecret: secret },
|
|
201
|
-
}
|
|
192
|
+
}
|
|
202
193
|
}
|
|
203
194
|
|
|
204
195
|
// ---------------------------------------------------------------------------
|
|
@@ -214,58 +205,48 @@ export async function setupInfra(
|
|
|
214
205
|
bootstrapConfig: BootstrapConfig,
|
|
215
206
|
): Promise<InfraContext> {
|
|
216
207
|
// Auto-generate jwtSecret if not set
|
|
217
|
-
const config = autoGenerateJwtSecret(configPath, bootstrapConfig)
|
|
208
|
+
const config = autoGenerateJwtSecret(configPath, bootstrapConfig)
|
|
218
209
|
|
|
219
|
-
const dataPath = path.resolve(config.server.dataPath)
|
|
220
|
-
const port = config.server.port
|
|
221
|
-
const host = config.server.host
|
|
210
|
+
const dataPath = path.resolve(config.server.dataPath)
|
|
211
|
+
const port = config.server.port
|
|
212
|
+
const host = config.server.host
|
|
222
213
|
|
|
223
|
-
console.log(
|
|
224
|
-
`[Phase1] Bootstrap: port=${port}, host=${host}, dataPath=${dataPath}`,
|
|
225
|
-
);
|
|
214
|
+
console.log(`[Phase1] Bootstrap: port=${port}, host=${host}, dataPath=${dataPath}`)
|
|
226
215
|
|
|
227
216
|
// --- Phase 2: Init StorageLocationManager → ensure all dirs exist ---
|
|
228
|
-
console.log(
|
|
229
|
-
const locationManager = new StorageLocationManager(dataPath)
|
|
230
|
-
await locationManager.initializeDefaults()
|
|
217
|
+
console.log('[Phase2] Initializing storage locations…')
|
|
218
|
+
const locationManager = new StorageLocationManager(dataPath)
|
|
219
|
+
await locationManager.initializeDefaults()
|
|
231
220
|
|
|
232
|
-
const locationStatus = locationManager.getStatus()
|
|
221
|
+
const locationStatus = locationManager.getStatus()
|
|
233
222
|
for (const { name, available, path: locPath } of locationStatus) {
|
|
234
|
-
console.log(
|
|
235
|
-
`[Phase2] Location "${name}": ${available ? "OK" : "UNAVAILABLE"} → ${locPath}`,
|
|
236
|
-
);
|
|
223
|
+
console.log(`[Phase2] Location "${name}": ${available ? 'OK' : 'UNAVAILABLE'} → ${locPath}`)
|
|
237
224
|
}
|
|
238
225
|
|
|
239
226
|
// --- Phase 2c: TLS certificate setup ---
|
|
240
|
-
let tlsOptions: { key: Buffer; cert: Buffer } | undefined
|
|
227
|
+
let tlsOptions: { key: Buffer; cert: Buffer } | undefined
|
|
241
228
|
|
|
242
229
|
if (config.tls.enabled) {
|
|
243
230
|
// Use require() instead of import() — the ESM build of @camstack/core has
|
|
244
231
|
// broken chunks with require("fs") when leaked .js files exist in core/src/.
|
|
245
232
|
// CJS build works correctly and tsx supports require().
|
|
246
|
-
const core = require(
|
|
247
|
-
const { ensureTlsCert, loadTlsCert } = core
|
|
233
|
+
const core = require('@camstack/core') as typeof import('@camstack/core')
|
|
234
|
+
const { ensureTlsCert, loadTlsCert } = core
|
|
248
235
|
if (config.tls.certPath && config.tls.keyPath) {
|
|
249
236
|
// User-provided cert
|
|
250
|
-
console.log(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const pair = loadTlsCert(config.tls.certPath, config.tls.keyPath);
|
|
254
|
-
tlsOptions = { key: pair.key, cert: pair.cert };
|
|
237
|
+
console.log(`[Phase2c] Loading custom TLS cert from ${config.tls.certPath}`)
|
|
238
|
+
const pair = loadTlsCert(config.tls.certPath, config.tls.keyPath)
|
|
239
|
+
tlsOptions = { key: pair.key, cert: pair.cert }
|
|
255
240
|
} else {
|
|
256
241
|
// Auto-generate self-signed
|
|
257
|
-
const tlsResult = await ensureTlsCert(dataPath)
|
|
242
|
+
const tlsResult = await ensureTlsCert(dataPath)
|
|
258
243
|
if (tlsResult.generated) {
|
|
259
|
-
console.log(
|
|
260
|
-
`[Phase2c] Generated self-signed TLS cert at ${tlsResult.certPath}`,
|
|
261
|
-
);
|
|
244
|
+
console.log(`[Phase2c] Generated self-signed TLS cert at ${tlsResult.certPath}`)
|
|
262
245
|
} else {
|
|
263
|
-
console.log(
|
|
264
|
-
`[Phase2c] Using existing TLS cert at ${tlsResult.certPath}`,
|
|
265
|
-
);
|
|
246
|
+
console.log(`[Phase2c] Using existing TLS cert at ${tlsResult.certPath}`)
|
|
266
247
|
}
|
|
267
|
-
const pair = loadTlsCert(tlsResult.certPath, tlsResult.keyPath)
|
|
268
|
-
tlsOptions = { key: pair.key, cert: pair.cert }
|
|
248
|
+
const pair = loadTlsCert(tlsResult.certPath, tlsResult.keyPath)
|
|
249
|
+
tlsOptions = { key: pair.key, cert: pair.cert }
|
|
269
250
|
}
|
|
270
251
|
}
|
|
271
252
|
|
|
@@ -274,5 +255,5 @@ export async function setupInfra(
|
|
|
274
255
|
dataPath,
|
|
275
256
|
locationManager,
|
|
276
257
|
tlsOptions,
|
|
277
|
-
}
|
|
258
|
+
}
|
|
278
259
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time integration-id backfill for devices created before the
|
|
3
|
+
* device-manager forwarder started stamping `integrationId` (camera
|
|
4
|
+
* providers). Maps each addon that hosts EXACTLY ONE integration to that
|
|
5
|
+
* integration id, then stamps top-level untagged devices of those addons.
|
|
6
|
+
* Multi-instance addons (e.g. Home Assistant with several brokers) are
|
|
7
|
+
* ambiguous on `addonId` alone and are skipped — they stamp going forward.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface BackfillIntegration {
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly addonId: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BackfillDevice {
|
|
16
|
+
readonly id: number
|
|
17
|
+
readonly addonId: string
|
|
18
|
+
readonly parentDeviceId: number | null
|
|
19
|
+
readonly integrationId?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BackfillStamp {
|
|
23
|
+
readonly deviceId: number
|
|
24
|
+
readonly integrationId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function planIntegrationIdBackfill(
|
|
28
|
+
integrations: readonly BackfillIntegration[],
|
|
29
|
+
devices: readonly BackfillDevice[],
|
|
30
|
+
): readonly BackfillStamp[] {
|
|
31
|
+
const singleByAddon = new Map<string, string>()
|
|
32
|
+
const ambiguous = new Set<string>()
|
|
33
|
+
for (const integration of integrations) {
|
|
34
|
+
if (ambiguous.has(integration.addonId)) continue
|
|
35
|
+
if (singleByAddon.has(integration.addonId)) {
|
|
36
|
+
singleByAddon.delete(integration.addonId)
|
|
37
|
+
ambiguous.add(integration.addonId)
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
singleByAddon.set(integration.addonId, integration.id)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stamps: BackfillStamp[] = []
|
|
44
|
+
for (const device of devices) {
|
|
45
|
+
if (device.parentDeviceId !== null) continue
|
|
46
|
+
if (device.integrationId !== undefined && device.integrationId !== '') continue
|
|
47
|
+
const integrationId = singleByAddon.get(device.addonId)
|
|
48
|
+
if (integrationId === undefined) continue
|
|
49
|
+
stamps.push({ deviceId: device.id, integrationId })
|
|
50
|
+
}
|
|
51
|
+
return stamps
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Stamps to apply at integration-DELETE time so a cascade removes legacy
|
|
56
|
+
* un-tagged devices too. The boot backfill only runs on startup; a device
|
|
57
|
+
* created before stamping (or whose provider never stamps, e.g. `provider-rtsp`)
|
|
58
|
+
* keeps no `integrationId`, so once its integration is deleted it would orphan
|
|
59
|
+
* forever — `removeByIntegration` matches on `integrationId` and finds nothing.
|
|
60
|
+
*
|
|
61
|
+
* Run this in the delete handler BEFORE deleting the integration record (while
|
|
62
|
+
* it is still present in `integrations`), then stamp the returned devices and
|
|
63
|
+
* let `removeByIntegration` cascade them. Reuses the boot backfill's safety
|
|
64
|
+
* rule (only addons hosting exactly ONE integration are unambiguous) and
|
|
65
|
+
* filters to the integration being deleted so siblings are never touched.
|
|
66
|
+
*/
|
|
67
|
+
export function planDeleteTimeStamps(
|
|
68
|
+
integrationId: string,
|
|
69
|
+
integrations: readonly BackfillIntegration[],
|
|
70
|
+
devices: readonly BackfillDevice[],
|
|
71
|
+
): readonly BackfillStamp[] {
|
|
72
|
+
return planIntegrationIdBackfill(integrations, devices).filter(
|
|
73
|
+
(stamp) => stamp.integrationId === integrationId,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BackfillLogger {
|
|
78
|
+
readonly info: (message: string, meta?: Record<string, unknown>) => void
|
|
79
|
+
readonly warn: (message: string, meta?: Record<string, unknown>) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface IntegrationIdBackfillDeps {
|
|
83
|
+
readonly listIntegrations: () => Promise<readonly BackfillIntegration[]>
|
|
84
|
+
readonly listDevices: () => Promise<readonly BackfillDevice[]>
|
|
85
|
+
readonly setIntegrationId: (deviceId: number, integrationId: string) => Promise<void>
|
|
86
|
+
readonly logger: BackfillLogger
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runIntegrationIdBackfill(
|
|
90
|
+
deps: IntegrationIdBackfillDeps,
|
|
91
|
+
): Promise<{ stamped: number }> {
|
|
92
|
+
const [integrations, devices] = await Promise.all([deps.listIntegrations(), deps.listDevices()])
|
|
93
|
+
const stamps = planIntegrationIdBackfill(integrations, devices)
|
|
94
|
+
let stamped = 0
|
|
95
|
+
for (const stamp of stamps) {
|
|
96
|
+
try {
|
|
97
|
+
await deps.setIntegrationId(stamp.deviceId, stamp.integrationId)
|
|
98
|
+
stamped++
|
|
99
|
+
} catch (err) {
|
|
100
|
+
deps.logger.warn('integrationId backfill: stamp failed', {
|
|
101
|
+
deviceId: stamp.deviceId,
|
|
102
|
+
integrationId: stamp.integrationId,
|
|
103
|
+
error: err instanceof Error ? err.message : String(err),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (stamped > 0) deps.logger.info('integrationId backfill complete', { stamped })
|
|
108
|
+
return { stamped }
|
|
109
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { readPendingRestart } from '@camstack/kernel'
|
|
3
|
+
import { EventCategory } from '@camstack/types'
|
|
3
4
|
import type { IScopedLogger, PendingRestartMarkerPayload } from '@camstack/types'
|
|
4
5
|
import { AddonRegistryService } from '../core/addon/addon-registry.service'
|
|
5
6
|
import { EventBusService } from '../core/events/event-bus.service'
|
|
@@ -21,7 +22,8 @@ export class PostBootService {
|
|
|
21
22
|
* after they reconnect — the live event itself is emitted before the
|
|
22
23
|
* WS resubscribe has a chance to land.
|
|
23
24
|
*/
|
|
24
|
-
private static lastRestart: { payload: PendingRestartMarkerPayload; expiresAt: number } | null =
|
|
25
|
+
private static lastRestart: { payload: PendingRestartMarkerPayload; expiresAt: number } | null =
|
|
26
|
+
null
|
|
25
27
|
|
|
26
28
|
/** How long the last-restart marker stays queryable after boot (5 min). */
|
|
27
29
|
static readonly LAST_RESTART_RETENTION_MS = 5 * 60_000
|
|
@@ -62,7 +64,7 @@ export class PostBootService {
|
|
|
62
64
|
id: randomUUID(),
|
|
63
65
|
timestamp: new Date(),
|
|
64
66
|
source: { type: 'core', id: 'system' },
|
|
65
|
-
category:
|
|
67
|
+
category: EventCategory.SystemBoot,
|
|
66
68
|
data: { port, host, trpcRegistered, dataPath },
|
|
67
69
|
})
|
|
68
70
|
|
|
@@ -96,7 +98,7 @@ export class PostBootService {
|
|
|
96
98
|
id: randomUUID(),
|
|
97
99
|
timestamp: new Date(),
|
|
98
100
|
source: { type: 'core', id: 'system' },
|
|
99
|
-
category:
|
|
101
|
+
category: EventCategory.SystemRestartCompleted,
|
|
100
102
|
data: payload,
|
|
101
103
|
})
|
|
102
104
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
// server/backend/src/core/addon/__tests__/addon-registry-capability.test.ts
|
|
3
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
4
3
|
import { CapabilityRegistry } from '@camstack/kernel'
|
|
@@ -32,7 +31,12 @@ describe('AddonRegistryService -- CapabilityRegistry integration', () => {
|
|
|
32
31
|
})
|
|
33
32
|
|
|
34
33
|
it('streaming-engine singleton wired after provider registers', () => {
|
|
35
|
-
registry.declareCapability({
|
|
34
|
+
registry.declareCapability({
|
|
35
|
+
name: 'streaming-engine',
|
|
36
|
+
scope: 'system',
|
|
37
|
+
mode: 'singleton',
|
|
38
|
+
methods: {},
|
|
39
|
+
})
|
|
36
40
|
|
|
37
41
|
const mockEngine = { initialize: vi.fn() }
|
|
38
42
|
registry.registerProvider('streaming-engine', 'go2rtc', mockEngine)
|
|
@@ -41,7 +45,12 @@ describe('AddonRegistryService -- CapabilityRegistry integration', () => {
|
|
|
41
45
|
})
|
|
42
46
|
|
|
43
47
|
it('log-destination collection receives all providers', () => {
|
|
44
|
-
registry.declareCapability({
|
|
48
|
+
registry.declareCapability({
|
|
49
|
+
name: 'log-destination',
|
|
50
|
+
scope: 'system',
|
|
51
|
+
mode: 'collection',
|
|
52
|
+
methods: {},
|
|
53
|
+
})
|
|
45
54
|
|
|
46
55
|
const dest1 = { id: 'winston' }
|
|
47
56
|
const dest2 = { id: 'loki' }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { overlayDeclaration } from '../addon-row-manifest.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: after the HA broker rework, redeploying provider-homeassistant
|
|
7
|
+
* (broker + device-adoption) left the integration picker without Home
|
|
8
|
+
* Assistant. `loadNewAddons` refreshed `entry.declaration` to the new caps but
|
|
9
|
+
* `listAddons` built its row manifest from the STALE `entry.addon.manifest`
|
|
10
|
+
* (still [broker, ha-discovery]), so `getAvailableTypes` filtered HA out.
|
|
11
|
+
*/
|
|
12
|
+
interface TestManifest {
|
|
13
|
+
id: string
|
|
14
|
+
name: string
|
|
15
|
+
icon?: string
|
|
16
|
+
brokerKind?: string
|
|
17
|
+
capabilities?: ReadonlyArray<{ name: string }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('overlayDeclaration — listAddons row manifest freshness', () => {
|
|
21
|
+
const base = { id: 'provider-homeassistant', name: 'Home Assistant' }
|
|
22
|
+
|
|
23
|
+
it('prefers the fresh declaration capabilities over the stale instance manifest', () => {
|
|
24
|
+
const instanceManifest: TestManifest = {
|
|
25
|
+
...base,
|
|
26
|
+
capabilities: [{ name: 'broker' }, { name: 'ha-discovery' }],
|
|
27
|
+
}
|
|
28
|
+
const declaration: Partial<TestManifest> = {
|
|
29
|
+
capabilities: [{ name: 'broker' }, { name: 'device-adoption' }],
|
|
30
|
+
brokerKind: 'home-assistant',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const merged = overlayDeclaration(instanceManifest, declaration)
|
|
34
|
+
|
|
35
|
+
expect(merged.capabilities).toEqual([{ name: 'broker' }, { name: 'device-adoption' }])
|
|
36
|
+
expect(merged.brokerKind).toBe('home-assistant')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps the instance manifest when no fresh declaration exists', () => {
|
|
40
|
+
const instanceManifest: TestManifest = { ...base, capabilities: [{ name: 'broker' }] }
|
|
41
|
+
|
|
42
|
+
const merged = overlayDeclaration(instanceManifest, undefined)
|
|
43
|
+
|
|
44
|
+
expect(merged.capabilities).toEqual([{ name: 'broker' }])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('fills gaps from the instance manifest for keys the declaration omits', () => {
|
|
48
|
+
const instanceManifest: TestManifest = {
|
|
49
|
+
...base,
|
|
50
|
+
icon: 'assets/icon.svg',
|
|
51
|
+
capabilities: [{ name: 'broker' }],
|
|
52
|
+
}
|
|
53
|
+
const declaration: Partial<TestManifest> = {
|
|
54
|
+
capabilities: [{ name: 'broker' }, { name: 'device-adoption' }],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const merged = overlayDeclaration(instanceManifest, declaration)
|
|
58
|
+
|
|
59
|
+
expect(merged.icon).toBe('assets/icon.svg')
|
|
60
|
+
expect(merged.capabilities).toContainEqual({ name: 'device-adoption' })
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -92,7 +92,11 @@ export class AddonCallGateway {
|
|
|
92
92
|
* must invoke the in-process instance directly (the invocation is
|
|
93
93
|
* surface-specific; only the ROUTING is centralised here).
|
|
94
94
|
*/
|
|
95
|
-
async callForked(
|
|
95
|
+
async callForked(
|
|
96
|
+
addonId: string,
|
|
97
|
+
input: AddonCallSurface,
|
|
98
|
+
explicitNodeId?: string,
|
|
99
|
+
): Promise<unknown> {
|
|
96
100
|
const dest = this.classify(addonId, explicitNodeId)
|
|
97
101
|
const fullInput: AddonCallInput = { ...input, addonId }
|
|
98
102
|
switch (dest.kind) {
|
|
@@ -115,7 +119,11 @@ export class AddonCallGateway {
|
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
/** Map an addon-level call to the remote agent's Moleculer action. */
|
|
118
|
-
private async callRemoteAgent(
|
|
122
|
+
private async callRemoteAgent(
|
|
123
|
+
addonId: string,
|
|
124
|
+
baseNodeId: string,
|
|
125
|
+
input: AddonCallInput,
|
|
126
|
+
): Promise<unknown> {
|
|
119
127
|
const workerNodeId = this.resolveWorkerNodeId(addonId, baseNodeId)
|
|
120
128
|
const opts = workerNodeId
|
|
121
129
|
? { nodeID: workerNodeId, timeout: REMOTE_TIMEOUT_MS }
|
|
@@ -132,7 +140,9 @@ export class AddonCallGateway {
|
|
|
132
140
|
}
|
|
133
141
|
// routes/custom are hub-local-child surfaces (mounted / invoked on the
|
|
134
142
|
// owning node); they are not proxied to a remote agent through this gateway.
|
|
135
|
-
throw new Error(
|
|
143
|
+
throw new Error(
|
|
144
|
+
`AddonCallGateway: target "${input.target}" not supported for remote agent "${baseNodeId}"`,
|
|
145
|
+
)
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
/**
|
|
@@ -143,9 +153,13 @@ export class AddonCallGateway {
|
|
|
143
153
|
*/
|
|
144
154
|
private resolveWorkerNodeId(addonId: string, baseNodeId: string): string | null {
|
|
145
155
|
const registry = this.deps.broker.registry
|
|
146
|
-
const services = (
|
|
147
|
-
|
|
148
|
-
|
|
156
|
+
const services = (
|
|
157
|
+
registry as unknown as {
|
|
158
|
+
getServiceList: (opts: {
|
|
159
|
+
onlyAvailable: boolean
|
|
160
|
+
}) => readonly { name: string; nodeID: string }[]
|
|
161
|
+
}
|
|
162
|
+
).getServiceList({ onlyAvailable: true })
|
|
149
163
|
const exactNode = `${baseNodeId}/${addonId}`
|
|
150
164
|
const preferred = services.find((s) => s.name === addonId && s.nodeID === exactNode)
|
|
151
165
|
if (preferred) return preferred.nodeID
|