@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/main.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument -- pre-existing lint debt across this 800+ line bootstrap module. The flagged sites cross typed boundaries (Fastify request typing, AddonRouteRegistry, AuthService inherited methods) where the projectService context can't trace inheritance chains. Tracked separately; do not amend in unrelated edits. */
|
|
2
|
-
import { fastifyTRPCPlugin } from
|
|
3
|
-
import { applyWSSHandler } from
|
|
4
|
-
import type { CreateWSSContextFnOptions } from
|
|
5
|
-
import fastifyStatic from
|
|
6
|
-
import fastifyCookie from
|
|
7
|
-
import { WebSocketServer } from
|
|
8
|
-
import * as fs from
|
|
9
|
-
import * as path from
|
|
10
|
-
import { execSync } from
|
|
11
|
-
import { LoggingService } from
|
|
12
|
-
import { EventBusService } from
|
|
13
|
-
import { ConfigService } from
|
|
14
|
-
import { AuthService } from
|
|
2
|
+
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'
|
|
3
|
+
import { applyWSSHandler } from '@trpc/server/adapters/ws'
|
|
4
|
+
import type { CreateWSSContextFnOptions } from '@trpc/server/adapters/ws'
|
|
5
|
+
import fastifyStatic from '@fastify/static'
|
|
6
|
+
import fastifyCookie from '@fastify/cookie'
|
|
7
|
+
import { WebSocketServer } from 'ws'
|
|
8
|
+
import * as fs from 'node:fs'
|
|
9
|
+
import * as path from 'node:path'
|
|
10
|
+
import { execSync } from 'node:child_process'
|
|
11
|
+
import { LoggingService } from './core/logging/logging.service'
|
|
12
|
+
import { EventBusService } from './core/events/event-bus.service'
|
|
13
|
+
import { ConfigService } from './core/config/config.service'
|
|
14
|
+
import { AuthService } from './core/auth/auth.service'
|
|
15
15
|
|
|
16
16
|
// Boot-time capability declaration runs over the auto-generated
|
|
17
17
|
// `ALL_CAPABILITY_DEFINITIONS` array — every `*.cap.ts` file that ships
|
|
@@ -20,92 +20,89 @@ import { AuthService } from "./core/auth/auth.service";
|
|
|
20
20
|
// `npx tsx scripts/generate-capability-router-types.ts`. The previous
|
|
21
21
|
// hand-curated list silently dropped caps (zones, zone-rules,
|
|
22
22
|
// zone-analytics, audio-metrics — see git for the regression).
|
|
23
|
-
import { ALL_CAPABILITY_DEFINITIONS } from
|
|
24
|
-
import { StreamProbeService } from
|
|
25
|
-
import { FeatureService } from
|
|
26
|
-
import { AgentRegistryService } from
|
|
27
|
-
import { MoleculerService } from
|
|
28
|
-
import { AddonRegistryService } from
|
|
29
|
-
import { AddonPackageService } from
|
|
30
|
-
import { ReplEngineService } from
|
|
31
|
-
import { NetworkQualityService } from
|
|
32
|
-
import { StorageService } from
|
|
33
|
-
import { AddonBridgeService } from
|
|
34
|
-
import { AddonPagesService } from
|
|
35
|
-
import { AddonWidgetsService } from
|
|
36
|
-
import { buildAppRouter } from
|
|
37
|
-
import { buildCoreCapService } from
|
|
23
|
+
import { ALL_CAPABILITY_DEFINITIONS } from '@camstack/types'
|
|
24
|
+
import { StreamProbeService } from './core/streaming/stream-probe.service'
|
|
25
|
+
import { FeatureService } from './core/feature/feature.service'
|
|
26
|
+
import { AgentRegistryService } from './core/agent/agent-registry.service'
|
|
27
|
+
import { MoleculerService } from './core/moleculer/moleculer.service'
|
|
28
|
+
import { AddonRegistryService } from './core/addon/addon-registry.service'
|
|
29
|
+
import { AddonPackageService } from './core/addon/addon-package.service'
|
|
30
|
+
import { ReplEngineService } from './core/repl/repl-engine.service'
|
|
31
|
+
import { NetworkQualityService } from './core/network/network-quality.service'
|
|
32
|
+
import { StorageService } from './core/storage/storage.service'
|
|
33
|
+
import { AddonBridgeService } from './core/addon-bridge/addon-bridge.service'
|
|
34
|
+
import { AddonPagesService } from './core/addon-pages/addon-pages.service'
|
|
35
|
+
import { AddonWidgetsService } from './core/addon-widgets/addon-widgets.service'
|
|
36
|
+
import { buildAppRouter } from './api/trpc/trpc.router'
|
|
37
|
+
import { buildCoreCapService } from './api/trpc/core-cap-bridge'
|
|
38
|
+
import { createTrpcContext, createWsTrpcContext } from './api/trpc/trpc.context'
|
|
39
|
+
import { registerAddonUploadRoute } from './api/addon-upload'
|
|
40
|
+
import { registerAuthWhoamiRoute } from './api/auth-whoami'
|
|
38
41
|
import {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
|
|
49
|
-
} from
|
|
50
|
-
import
|
|
51
|
-
import {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
import {
|
|
42
|
+
buildSessionCookie,
|
|
43
|
+
clearSessionCookie,
|
|
44
|
+
SESSION_COOKIE,
|
|
45
|
+
shouldRedirectToLogin,
|
|
46
|
+
loginRedirectUrl,
|
|
47
|
+
isEmbedRedirectTarget,
|
|
48
|
+
} from './auth/session-cookie.js'
|
|
49
|
+
import { registerHealthRoutes } from './api/health/health.routes'
|
|
50
|
+
import { registerOauth2Routes } from './api/oauth2/oauth2-routes.js'
|
|
51
|
+
import { AddonRouteRegistry, DataPlaneRegistry, proxyToUpstream } from '@camstack/core'
|
|
52
|
+
import type { FastifyRequest, FastifyReply } from 'fastify'
|
|
53
|
+
import { loadBootstrapConfig, setupInfra } from './boot/boot-config'
|
|
54
|
+
import { bootManual } from './manual-boot'
|
|
55
|
+
|
|
56
|
+
import { PostBootService } from './boot/post-boot.service'
|
|
57
|
+
import { runIntegrationIdBackfill } from './boot/integration-id-backfill'
|
|
55
58
|
|
|
56
59
|
// ---- Process-level error handlers ----
|
|
57
60
|
|
|
58
|
-
process.on(
|
|
59
|
-
console.error(
|
|
60
|
-
|
|
61
|
-
err,
|
|
62
|
-
);
|
|
63
|
-
});
|
|
61
|
+
process.on('uncaughtException', (err) => {
|
|
62
|
+
console.error('[uncaughtException] Unhandled exception — server will continue:', err)
|
|
63
|
+
})
|
|
64
64
|
|
|
65
|
-
process.on(
|
|
66
|
-
console.error(
|
|
67
|
-
|
|
68
|
-
reason,
|
|
69
|
-
);
|
|
70
|
-
});
|
|
65
|
+
process.on('unhandledRejection', (reason) => {
|
|
66
|
+
console.error('[unhandledRejection] Unhandled promise rejection — server will continue:', reason)
|
|
67
|
+
})
|
|
71
68
|
|
|
72
69
|
// ---- Graceful shutdown ----
|
|
73
70
|
|
|
74
71
|
// Kill all child processes immediately on signal, then let Fastify clean up.
|
|
75
72
|
// This is critical because tsx watch (dev) force-kills the main process after a short
|
|
76
73
|
// timeout — if we wait for Fastify OnModuleDestroy, children become orphans.
|
|
77
|
-
let shutdownStarted = false
|
|
74
|
+
let shutdownStarted = false
|
|
78
75
|
function immediateChildCleanup(signal: string) {
|
|
79
|
-
if (shutdownStarted) return
|
|
80
|
-
shutdownStarted = true
|
|
76
|
+
if (shutdownStarted) return
|
|
77
|
+
shutdownStarted = true
|
|
81
78
|
|
|
82
|
-
console.log(`[shutdown] Received ${signal} — killing child processes immediately…`)
|
|
79
|
+
console.log(`[shutdown] Received ${signal} — killing child processes immediately…`)
|
|
83
80
|
|
|
84
81
|
// Synchronously kill all children of this process via the OS.
|
|
85
82
|
// This is fast and ensures no orphans even if Fastify shutdown is slow.
|
|
86
83
|
try {
|
|
87
|
-
const myPid = process.pid
|
|
88
|
-
const isMac = process.platform ===
|
|
84
|
+
const myPid = process.pid
|
|
85
|
+
const isMac = process.platform === 'darwin'
|
|
89
86
|
// pkill sends SIGTERM to all processes whose parent is our PID
|
|
90
87
|
const cmd = isMac
|
|
91
88
|
? `pkill -TERM -P ${myPid} 2>/dev/null; true`
|
|
92
|
-
: `kill -- -${myPid} 2>/dev/null; true
|
|
93
|
-
execSync(cmd, { timeout: 3000 })
|
|
94
|
-
console.log(
|
|
89
|
+
: `kill -- -${myPid} 2>/dev/null; true`
|
|
90
|
+
execSync(cmd, { timeout: 3000 })
|
|
91
|
+
console.log('[shutdown] Child processes signalled')
|
|
95
92
|
} catch {
|
|
96
93
|
// Best effort — children may have already exited
|
|
97
94
|
}
|
|
98
95
|
|
|
99
96
|
// Safety: force exit after 10s if Fastify shutdown stalls
|
|
100
97
|
const timer = setTimeout(() => {
|
|
101
|
-
console.error(
|
|
102
|
-
process.exit(1)
|
|
103
|
-
}, 10_000)
|
|
104
|
-
timer.unref()
|
|
98
|
+
console.error('[shutdown] Graceful shutdown timed out after 10s — forcing exit')
|
|
99
|
+
process.exit(1)
|
|
100
|
+
}, 10_000)
|
|
101
|
+
timer.unref()
|
|
105
102
|
}
|
|
106
103
|
|
|
107
|
-
process.on(
|
|
108
|
-
process.on(
|
|
104
|
+
process.on('SIGTERM', () => immediateChildCleanup('SIGTERM'))
|
|
105
|
+
process.on('SIGINT', () => immediateChildCleanup('SIGINT'))
|
|
109
106
|
|
|
110
107
|
// ---- Orphan cleanup ----
|
|
111
108
|
|
|
@@ -115,32 +112,32 @@ process.on("SIGINT", () => immediateChildCleanup("SIGINT"));
|
|
|
115
112
|
* Only kills processes whose parent is PID 1 (orphaned) to avoid killing children of a live server.
|
|
116
113
|
*/
|
|
117
114
|
function cleanupOrphanProcesses(): void {
|
|
118
|
-
const isMac = process.platform ===
|
|
119
|
-
const isLinux = process.platform ===
|
|
120
|
-
if (!isMac && !isLinux) return
|
|
115
|
+
const isMac = process.platform === 'darwin'
|
|
116
|
+
const isLinux = process.platform === 'linux'
|
|
117
|
+
if (!isMac && !isLinux) return
|
|
121
118
|
|
|
122
|
-
let killed = 0
|
|
119
|
+
let killed = 0
|
|
123
120
|
|
|
124
121
|
try {
|
|
125
122
|
// Find orphaned coreml_inference.py processes (ppid=1 means orphaned)
|
|
126
123
|
const cmd = isMac
|
|
127
124
|
? "ps -eo pid,ppid,command | grep -E 'coreml_inference\\.py' | grep -v grep"
|
|
128
|
-
: "ps -eo pid,ppid,cmd | grep -E 'coreml_inference\\.py' | grep -v grep"
|
|
125
|
+
: "ps -eo pid,ppid,cmd | grep -E 'coreml_inference\\.py' | grep -v grep"
|
|
129
126
|
|
|
130
|
-
const output = execSync(cmd, { encoding:
|
|
131
|
-
if (!output) return
|
|
127
|
+
const output = execSync(cmd, { encoding: 'utf8', timeout: 5000 }).trim()
|
|
128
|
+
if (!output) return
|
|
132
129
|
|
|
133
|
-
for (const line of output.split(
|
|
134
|
-
const parts = line.trim().split(/\s+/)
|
|
135
|
-
const pid = parseInt(parts[0]!, 10)
|
|
136
|
-
const ppid = parseInt(parts[1]!, 10)
|
|
130
|
+
for (const line of output.split('\n')) {
|
|
131
|
+
const parts = line.trim().split(/\s+/)
|
|
132
|
+
const pid = parseInt(parts[0]!, 10)
|
|
133
|
+
const ppid = parseInt(parts[1]!, 10)
|
|
137
134
|
|
|
138
135
|
// Only kill orphans (ppid=1) — never kill children of a running server
|
|
139
|
-
if (ppid !== 1 || isNaN(pid) || pid === process.pid) continue
|
|
136
|
+
if (ppid !== 1 || isNaN(pid) || pid === process.pid) continue
|
|
140
137
|
|
|
141
138
|
try {
|
|
142
|
-
process.kill(pid,
|
|
143
|
-
killed
|
|
139
|
+
process.kill(pid, 'SIGTERM')
|
|
140
|
+
killed++
|
|
144
141
|
} catch {
|
|
145
142
|
// Process may have already exited
|
|
146
143
|
}
|
|
@@ -150,7 +147,7 @@ function cleanupOrphanProcesses(): void {
|
|
|
150
147
|
}
|
|
151
148
|
|
|
152
149
|
if (killed > 0) {
|
|
153
|
-
console.log(`[cleanup] Killed ${killed} orphaned camstack process(es) from a previous run`)
|
|
150
|
+
console.log(`[cleanup] Killed ${killed} orphaned camstack process(es) from a previous run`)
|
|
154
151
|
}
|
|
155
152
|
}
|
|
156
153
|
|
|
@@ -158,51 +155,58 @@ function cleanupOrphanProcesses(): void {
|
|
|
158
155
|
|
|
159
156
|
async function bootstrap() {
|
|
160
157
|
// Clean up orphaned processes from previous crashes before starting
|
|
161
|
-
cleanupOrphanProcesses()
|
|
158
|
+
cleanupOrphanProcesses()
|
|
162
159
|
|
|
163
160
|
// SPA fallback — set later when admin UI is resolved, used by addon route catch-all
|
|
164
|
-
let spaIndexHtml: string | null = null
|
|
161
|
+
let spaIndexHtml: string | null = null
|
|
165
162
|
|
|
166
163
|
// --- Phase 1 + 2: Load config, setup storage locations, TLS ---
|
|
167
164
|
const configPath =
|
|
168
|
-
process.env.CONFIG_PATH ??
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
const infra = await setupInfra(configPath, bootstrapConfig);
|
|
165
|
+
process.env.CONFIG_PATH ?? path.join(process.cwd(), 'camstack-data', 'config.yaml')
|
|
166
|
+
const bootstrapConfig = loadBootstrapConfig(configPath)
|
|
167
|
+
const infra = await setupInfra(configPath, bootstrapConfig)
|
|
172
168
|
|
|
173
|
-
const { dataPath, tlsOptions } = infra
|
|
174
|
-
const port = infra.bootstrapConfig.server.port
|
|
175
|
-
const host = infra.bootstrapConfig.server.host
|
|
169
|
+
const { dataPath, tlsOptions } = infra
|
|
170
|
+
const port = infra.bootstrapConfig.server.port
|
|
171
|
+
const host = infra.bootstrapConfig.server.host
|
|
176
172
|
|
|
177
173
|
// --- Phase 3: Create app + tRPC register + listen ---
|
|
178
|
-
const fastifyOpts: Record<string, unknown> = tlsOptions
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
app.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
174
|
+
const fastifyOpts: Record<string, unknown> = tlsOptions ? { https: tlsOptions } : {}
|
|
175
|
+
const app = await bootManual({ infra, fastifyOpts })
|
|
176
|
+
app.enableShutdownHooks()
|
|
177
|
+
app.enableCors()
|
|
178
|
+
|
|
179
|
+
const fastify = app.getHttpAdapter().getInstance()
|
|
180
|
+
await fastify.register(fastifyCookie)
|
|
181
|
+
|
|
182
|
+
// Data-plane POST bodies: the addon reverse-proxy (`proxyToUpstream`) pipes
|
|
183
|
+
// `request.raw` upstream, but Fastify's default application/json parser would
|
|
184
|
+
// drain it first, so a POST body would reach the addon empty. Register a
|
|
185
|
+
// passthrough parser for application/octet-stream (used by data-plane POST
|
|
186
|
+
// clients, e.g. the app log shipper → stream-broker `clientlog`) that leaves
|
|
187
|
+
// the raw stream intact for piping. No effect on JSON API routes.
|
|
188
|
+
fastify.addContentTypeParser('application/octet-stream', (_req, _payload, done) =>
|
|
189
|
+
done(null, undefined),
|
|
190
|
+
)
|
|
187
191
|
|
|
188
192
|
// Make LocationManager available to StorageService before lifecycle hooks run
|
|
189
|
-
const storageService = app.get(StorageService)
|
|
190
|
-
storageService.setLocationManager(infra.locationManager)
|
|
193
|
+
const storageService = app.get(StorageService)
|
|
194
|
+
storageService.setLocationManager(infra.locationManager)
|
|
191
195
|
|
|
192
196
|
// ConfigService — SettingsStore is wired later by the settings-store capability consumer
|
|
193
|
-
const config = app.get(ConfigService)
|
|
197
|
+
const config = app.get(ConfigService)
|
|
194
198
|
|
|
195
199
|
// Register addon upload route (multipart — must be registered before tRPC).
|
|
196
200
|
// We pass AddonRegistryService so the handler can resolve the
|
|
197
201
|
// `user-management` cap singleton at request time (the only working
|
|
198
202
|
// path to validate `cst_*` scoped tokens — see addon-upload.ts).
|
|
199
203
|
try {
|
|
200
|
-
const uploadAuthService = app.get(AuthService)
|
|
201
|
-
const uploadAddonBridge = app.get(AddonBridgeService)
|
|
202
|
-
const uploadMoleculer = app.get(MoleculerService)
|
|
203
|
-
const uploadAddonRegistry = app.get(AddonRegistryService)
|
|
204
|
-
const uploadAddonPackage = app.get(AddonPackageService)
|
|
205
|
-
const uploadLogger = app.get(LoggingService).createLogger(
|
|
204
|
+
const uploadAuthService = app.get(AuthService)
|
|
205
|
+
const uploadAddonBridge = app.get(AddonBridgeService)
|
|
206
|
+
const uploadMoleculer = app.get(MoleculerService)
|
|
207
|
+
const uploadAddonRegistry = app.get(AddonRegistryService)
|
|
208
|
+
const uploadAddonPackage = app.get(AddonPackageService)
|
|
209
|
+
const uploadLogger = app.get(LoggingService).createLogger('addon-upload')
|
|
206
210
|
await registerAddonUploadRoute(
|
|
207
211
|
fastify,
|
|
208
212
|
uploadAddonBridge,
|
|
@@ -211,50 +215,44 @@ async function bootstrap() {
|
|
|
211
215
|
uploadAddonRegistry,
|
|
212
216
|
uploadAddonPackage,
|
|
213
217
|
uploadLogger,
|
|
214
|
-
)
|
|
215
|
-
console.log(
|
|
216
|
-
"[bootstrap] Addon upload route registered at POST /api/addons/upload",
|
|
217
|
-
);
|
|
218
|
+
)
|
|
219
|
+
console.log('[bootstrap] Addon upload route registered at POST /api/addons/upload')
|
|
218
220
|
// Companion endpoint: /api/auth/whoami — validates JWT or cst_*
|
|
219
221
|
// scoped tokens, returns the resolved identity + scope summary.
|
|
220
222
|
// Mirrors the addon-upload auth chain so the CLI can ping for
|
|
221
223
|
// token-still-valid without consuming a real cap.
|
|
222
|
-
await registerAuthWhoamiRoute(
|
|
223
|
-
|
|
224
|
-
uploadAuthService,
|
|
225
|
-
uploadAddonRegistry,
|
|
226
|
-
);
|
|
227
|
-
console.log(
|
|
228
|
-
"[bootstrap] Auth whoami route registered at GET /api/auth/whoami",
|
|
229
|
-
);
|
|
224
|
+
await registerAuthWhoamiRoute(fastify, uploadAuthService, uploadAddonRegistry)
|
|
225
|
+
console.log('[bootstrap] Auth whoami route registered at GET /api/auth/whoami')
|
|
230
226
|
} catch (err) {
|
|
231
|
-
console.warn(
|
|
227
|
+
console.warn('[bootstrap] Failed to register addon upload route:', err)
|
|
232
228
|
}
|
|
233
229
|
|
|
234
230
|
// Register tRPC plugin on Fastify BEFORE listen.
|
|
235
231
|
// If registration fails, start in degraded mode: serve a health warning endpoint.
|
|
236
232
|
// Instantiate new core services
|
|
237
|
-
const loggingService = app.get(LoggingService)
|
|
238
|
-
const addonRouteRegistry = new AddonRouteRegistry()
|
|
233
|
+
const loggingService = app.get(LoggingService)
|
|
234
|
+
const addonRouteRegistry = new AddonRouteRegistry()
|
|
235
|
+
const dataPlaneRegistry = new DataPlaneRegistry()
|
|
239
236
|
|
|
240
237
|
// Use Fastify-managed notification/toast wrappers (globally provided by NotificationModule)
|
|
241
|
-
const { NotificationServiceWrapper } =
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const notificationWrapper = app.get(NotificationServiceWrapper)
|
|
246
|
-
const toastWrapper = app.get(ToastServiceWrapper)
|
|
238
|
+
const { NotificationServiceWrapper } = await import(
|
|
239
|
+
'./core/notification/notification-wrapper.service'
|
|
240
|
+
)
|
|
241
|
+
const { ToastServiceWrapper } = await import('./core/notification/toast-wrapper.service')
|
|
242
|
+
const notificationWrapper = app.get(NotificationServiceWrapper)
|
|
243
|
+
const toastWrapper = app.get(ToastServiceWrapper)
|
|
247
244
|
// Expose the underlying core services for the tRPC router
|
|
248
|
-
const notificationService = notificationWrapper.service
|
|
249
|
-
const toastService = toastWrapper.service
|
|
245
|
+
const notificationService = notificationWrapper.service
|
|
246
|
+
const toastService = toastWrapper.service
|
|
250
247
|
|
|
251
248
|
// Wire AddonRouteRegistry and NotificationService
|
|
252
|
-
const addonRegistry = app.get(AddonRegistryService)
|
|
253
|
-
addonRegistry.setAddonRouteRegistry(addonRouteRegistry)
|
|
249
|
+
const addonRegistry = app.get(AddonRegistryService)
|
|
250
|
+
addonRegistry.setAddonRouteRegistry(addonRouteRegistry)
|
|
251
|
+
addonRegistry.setDataPlaneRegistry(dataPlaneRegistry)
|
|
254
252
|
|
|
255
253
|
// ── Configure the CapabilityRegistry (created in AddonRegistryService constructor) ──
|
|
256
|
-
const capabilityRegistry = addonRegistry.getCapabilityRegistry()
|
|
257
|
-
capabilityRegistry.setConfigManager(config)
|
|
254
|
+
const capabilityRegistry = addonRegistry.getCapabilityRegistry()
|
|
255
|
+
capabilityRegistry.setConfigManager(config)
|
|
258
256
|
|
|
259
257
|
// Declare every shipped capability BEFORE app.init() so addon
|
|
260
258
|
// registerProvider() calls (in-process or via the Moleculer bridge
|
|
@@ -262,7 +260,7 @@ async function bootstrap() {
|
|
|
262
260
|
// onModuleInit. The list comes from the codegen — see the import
|
|
263
261
|
// comment above for the rationale.
|
|
264
262
|
for (const capDef of ALL_CAPABILITY_DEFINITIONS) {
|
|
265
|
-
capabilityRegistry.declareCapability(capDef)
|
|
263
|
+
capabilityRegistry.declareCapability(capDef)
|
|
266
264
|
}
|
|
267
265
|
|
|
268
266
|
// Hub-internal `sso-bridge` provider — gives auth-provider addons
|
|
@@ -270,52 +268,65 @@ async function bootstrap() {
|
|
|
270
268
|
// bridge token before redirecting to `/api/auth/sso/finish`. Without
|
|
271
269
|
// this, the finish endpoint would have to trust unsigned query params
|
|
272
270
|
// (anyone could craft `?isAdmin=1`). Backed by AuthService.
|
|
273
|
-
const authServiceForBridge = app.get(AuthService)
|
|
271
|
+
const authServiceForBridge = app.get(AuthService)
|
|
274
272
|
capabilityRegistry.registerProvider('sso-bridge', '$hub', {
|
|
275
|
-
signBridgeToken: async ({
|
|
276
|
-
|
|
277
|
-
|
|
273
|
+
signBridgeToken: async ({
|
|
274
|
+
claims,
|
|
275
|
+
ttlSec,
|
|
276
|
+
}: {
|
|
277
|
+
claims: {
|
|
278
|
+
userId: string
|
|
279
|
+
username: string
|
|
280
|
+
isAdmin: boolean
|
|
281
|
+
provider: string
|
|
282
|
+
email?: string
|
|
283
|
+
displayName?: string
|
|
284
|
+
}
|
|
285
|
+
ttlSec?: number
|
|
286
|
+
}) => {
|
|
287
|
+
const token = authServiceForBridge.signSsoBridgeToken(claims, ttlSec ?? 300)
|
|
288
|
+
return { token }
|
|
278
289
|
},
|
|
279
290
|
verifyBridgeToken: async ({ token }: { token: string }) => {
|
|
280
|
-
return authServiceForBridge.verifySsoBridgeToken(token)
|
|
291
|
+
return authServiceForBridge.verifySsoBridgeToken(token)
|
|
281
292
|
},
|
|
282
|
-
})
|
|
293
|
+
})
|
|
283
294
|
|
|
284
295
|
// Wire registry into NotificationService for proxy-based output resolution
|
|
285
|
-
notificationService.setRegistry(capabilityRegistry)
|
|
296
|
+
notificationService.setRegistry(capabilityRegistry)
|
|
286
297
|
|
|
287
298
|
// Hub metrics and sub-process info now come from Moleculer service discovery
|
|
288
299
|
// and the distributed metrics-provider capability — no manual wiring needed.
|
|
289
300
|
|
|
290
|
-
let trpcRegistered = false
|
|
291
|
-
let appRouter: ReturnType<typeof buildAppRouter> | null = null
|
|
301
|
+
let trpcRegistered = false
|
|
302
|
+
let appRouter: ReturnType<typeof buildAppRouter> | null = null
|
|
292
303
|
|
|
293
304
|
// Run app.init() FIRST so onModuleInit hooks complete (PipelineWiring, AddonBridge, etc.)
|
|
294
305
|
// before we build the tRPC router which depends on their results.
|
|
295
|
-
await app.init()
|
|
296
|
-
console.log(
|
|
306
|
+
await app.init()
|
|
307
|
+
console.log('[bootstrap] app.init() complete — all onModuleInit hooks have run')
|
|
297
308
|
|
|
298
309
|
// Mark registry as ready — providers from app.init() are now registered
|
|
299
|
-
capabilityRegistry.ready()
|
|
310
|
+
capabilityRegistry.ready()
|
|
300
311
|
|
|
301
312
|
// Register log-receiver service for agent log forwarding (after app.init
|
|
302
313
|
// so Moleculer re-advertises the service list to the network)
|
|
303
|
-
const moleculer = app.get(MoleculerService)
|
|
304
|
-
moleculer.registerLogReceiver()
|
|
314
|
+
const moleculer = app.get(MoleculerService)
|
|
315
|
+
moleculer.registerLogReceiver()
|
|
305
316
|
|
|
306
317
|
// ── Health routes (hub self + agent forwarding) ──────────────────
|
|
307
318
|
// Registered after app.init() so the AgentRegistryService is wired and
|
|
308
319
|
// the Moleculer broker is ready to forward `$agent.health` calls.
|
|
309
320
|
try {
|
|
310
|
-
const hubVersion = readHubVersion()
|
|
321
|
+
const hubVersion = readHubVersion()
|
|
311
322
|
registerHealthRoutes(fastify, {
|
|
312
323
|
moleculer,
|
|
313
324
|
agentRegistry: app.get(AgentRegistryService),
|
|
314
325
|
hubVersion,
|
|
315
|
-
})
|
|
316
|
-
console.log(`[bootstrap] Health routes registered (hub v${hubVersion})`)
|
|
326
|
+
})
|
|
327
|
+
console.log(`[bootstrap] Health routes registered (hub v${hubVersion})`)
|
|
317
328
|
} catch (err) {
|
|
318
|
-
console.warn(
|
|
329
|
+
console.warn('[bootstrap] Failed to register health routes:', err)
|
|
319
330
|
}
|
|
320
331
|
|
|
321
332
|
// Seed the device-name cache the log formatter consults so logs
|
|
@@ -333,14 +344,19 @@ async function bootstrap() {
|
|
|
333
344
|
meta: { count: devices.length, ids: devices.map((d) => d.id) },
|
|
334
345
|
})
|
|
335
346
|
} else {
|
|
336
|
-
loggingService
|
|
347
|
+
loggingService
|
|
348
|
+
.createLogger('logging')
|
|
349
|
+
.warn('device-name cache seed skipped — device-manager not available')
|
|
337
350
|
}
|
|
338
351
|
} catch (err) {
|
|
339
|
-
console.warn(
|
|
352
|
+
console.warn(
|
|
353
|
+
'[bootstrap] device-name cache seed skipped:',
|
|
354
|
+
err instanceof Error ? err.message : err,
|
|
355
|
+
)
|
|
340
356
|
}
|
|
341
357
|
|
|
342
358
|
try {
|
|
343
|
-
const authService = app.get(AuthService)
|
|
359
|
+
const authService = app.get(AuthService)
|
|
344
360
|
|
|
345
361
|
appRouter = buildAppRouter({
|
|
346
362
|
authService,
|
|
@@ -360,141 +376,155 @@ async function bootstrap() {
|
|
|
360
376
|
toastService,
|
|
361
377
|
capabilityRegistry,
|
|
362
378
|
streamProbe: app.get(StreamProbeService),
|
|
363
|
-
})
|
|
379
|
+
})
|
|
364
380
|
|
|
365
381
|
await fastify.register(fastifyTRPCPlugin, {
|
|
366
|
-
prefix:
|
|
382
|
+
prefix: '/trpc',
|
|
367
383
|
trpcOptions: {
|
|
368
384
|
router: appRouter,
|
|
369
|
-
|
|
385
|
+
createContext: ({ req }: { req: FastifyRequest }) =>
|
|
370
386
|
createTrpcContext(req, authService, addonRegistry),
|
|
371
|
-
onError: ({
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
387
|
+
onError: ({
|
|
388
|
+
path,
|
|
389
|
+
error,
|
|
390
|
+
}: {
|
|
391
|
+
path: string | undefined
|
|
392
|
+
error: { code: string; message: string; cause?: unknown }
|
|
393
|
+
}) => {
|
|
394
|
+
const trpcLogger = app.get(LoggingService).createLogger('tRPC')
|
|
395
|
+
trpcLogger.warn('tRPC error', {
|
|
396
|
+
meta: { code: error.code, path: path ?? '?', message: error.message },
|
|
397
|
+
})
|
|
398
|
+
if (error.cause)
|
|
399
|
+
trpcLogger.warn('tRPC error cause', {
|
|
400
|
+
meta: {
|
|
401
|
+
cause:
|
|
402
|
+
error.cause instanceof Error ? error.cause.message : JSON.stringify(error.cause),
|
|
403
|
+
},
|
|
404
|
+
})
|
|
375
405
|
},
|
|
376
406
|
},
|
|
377
|
-
})
|
|
378
|
-
trpcRegistered = true
|
|
407
|
+
})
|
|
408
|
+
trpcRegistered = true
|
|
379
409
|
|
|
380
410
|
// Mount the `$core-caps` Moleculer service so forked addons /
|
|
381
411
|
// remote agents can reach the hub's core (non-addon) tRPC routers
|
|
382
412
|
// through `ctx.api.<coreCap>`. Without it those calls hang in
|
|
383
413
|
// `brokerTransportLink`'s unbounded discovery wait.
|
|
384
414
|
try {
|
|
385
|
-
moleculer.registerCoreCapService(buildCoreCapService(appRouter))
|
|
386
|
-
console.log(
|
|
415
|
+
moleculer.registerCoreCapService(buildCoreCapService(appRouter))
|
|
416
|
+
console.log('[bootstrap] core-cap mesh bridge registered ($core-caps)')
|
|
387
417
|
} catch (err) {
|
|
388
|
-
console.warn(
|
|
418
|
+
console.warn('[bootstrap] Failed to register core-cap mesh bridge:', err)
|
|
389
419
|
}
|
|
390
420
|
|
|
391
421
|
// Register addon-pages static file endpoint
|
|
392
|
-
const addonPagesService = app.get(AddonPagesService)
|
|
393
|
-
fastify.get<{ Params: { addonId: string;
|
|
394
|
-
|
|
422
|
+
const addonPagesService = app.get(AddonPagesService)
|
|
423
|
+
fastify.get<{ Params: { addonId: string; '*': string } }>(
|
|
424
|
+
'/api/addon-pages/:addonId/*',
|
|
395
425
|
async (request, reply) => {
|
|
396
|
-
const { addonId } = request.params
|
|
397
|
-
const filePath = request.params[
|
|
426
|
+
const { addonId } = request.params
|
|
427
|
+
const filePath = request.params['*']
|
|
398
428
|
if (!filePath) {
|
|
399
|
-
return reply.status(400).send(
|
|
429
|
+
return reply.status(400).send('Missing file path')
|
|
400
430
|
}
|
|
401
|
-
const resolved = addonPagesService.resolveBundle(addonId, filePath)
|
|
431
|
+
const resolved = addonPagesService.resolveBundle(addonId, filePath)
|
|
402
432
|
if (!resolved) {
|
|
403
|
-
return reply.status(404).send(
|
|
433
|
+
return reply.status(404).send('Not found')
|
|
404
434
|
}
|
|
405
|
-
const ext = path.extname(resolved).toLowerCase()
|
|
435
|
+
const ext = path.extname(resolved).toLowerCase()
|
|
406
436
|
const contentType =
|
|
407
|
-
ext ===
|
|
408
|
-
?
|
|
409
|
-
: ext ===
|
|
410
|
-
?
|
|
411
|
-
: ext ===
|
|
412
|
-
?
|
|
413
|
-
: ext ===
|
|
414
|
-
?
|
|
415
|
-
:
|
|
416
|
-
const stream = fs.createReadStream(resolved)
|
|
417
|
-
return reply.type(contentType).send(stream)
|
|
437
|
+
ext === '.js' || ext === '.mjs'
|
|
438
|
+
? 'application/javascript'
|
|
439
|
+
: ext === '.css'
|
|
440
|
+
? 'text/css'
|
|
441
|
+
: ext === '.json'
|
|
442
|
+
? 'application/json'
|
|
443
|
+
: ext === '.html'
|
|
444
|
+
? 'text/html'
|
|
445
|
+
: 'application/octet-stream'
|
|
446
|
+
const stream = fs.createReadStream(resolved)
|
|
447
|
+
return reply.type(contentType).send(stream)
|
|
418
448
|
},
|
|
419
|
-
)
|
|
449
|
+
)
|
|
420
450
|
|
|
421
451
|
// Register addon-widgets static file endpoint — same shape as
|
|
422
452
|
// addon-pages but reads bundles from each addon's
|
|
423
453
|
// `dist/widgets.mjs` (declared per-widget in `addon-widgets-source`
|
|
424
454
|
// metadata). Path-traversal protection + cap-registration check
|
|
425
455
|
// both live in `AddonWidgetsService.resolveBundle`.
|
|
426
|
-
const addonWidgetsService = app.get(AddonWidgetsService)
|
|
427
|
-
fastify.get<{ Params: { addonId: string;
|
|
428
|
-
|
|
456
|
+
const addonWidgetsService = app.get(AddonWidgetsService)
|
|
457
|
+
fastify.get<{ Params: { addonId: string; '*': string } }>(
|
|
458
|
+
'/api/addon-widgets/:addonId/*',
|
|
429
459
|
async (request, reply) => {
|
|
430
|
-
const { addonId } = request.params
|
|
431
|
-
const filePath = request.params[
|
|
460
|
+
const { addonId } = request.params
|
|
461
|
+
const filePath = request.params['*']
|
|
432
462
|
if (!filePath) {
|
|
433
|
-
return reply.status(400).send(
|
|
463
|
+
return reply.status(400).send('Missing file path')
|
|
434
464
|
}
|
|
435
|
-
const resolved = addonWidgetsService.resolveBundle(addonId, filePath)
|
|
465
|
+
const resolved = addonWidgetsService.resolveBundle(addonId, filePath)
|
|
436
466
|
if (!resolved) {
|
|
437
|
-
return reply.status(404).send(
|
|
467
|
+
return reply.status(404).send('Not found')
|
|
438
468
|
}
|
|
439
|
-
const ext = path.extname(resolved).toLowerCase()
|
|
469
|
+
const ext = path.extname(resolved).toLowerCase()
|
|
440
470
|
const contentType =
|
|
441
|
-
ext ===
|
|
442
|
-
?
|
|
443
|
-
: ext ===
|
|
444
|
-
?
|
|
445
|
-
: ext ===
|
|
446
|
-
?
|
|
447
|
-
: ext ===
|
|
448
|
-
?
|
|
449
|
-
:
|
|
450
|
-
const stream = fs.createReadStream(resolved)
|
|
451
|
-
return reply.type(contentType).send(stream)
|
|
471
|
+
ext === '.js' || ext === '.mjs'
|
|
472
|
+
? 'application/javascript'
|
|
473
|
+
: ext === '.css'
|
|
474
|
+
? 'text/css'
|
|
475
|
+
: ext === '.json'
|
|
476
|
+
? 'application/json'
|
|
477
|
+
: ext === '.html'
|
|
478
|
+
? 'text/html'
|
|
479
|
+
: 'application/octet-stream'
|
|
480
|
+
const stream = fs.createReadStream(resolved)
|
|
481
|
+
return reply.type(contentType).send(stream)
|
|
452
482
|
},
|
|
453
|
-
)
|
|
483
|
+
)
|
|
454
484
|
|
|
455
485
|
// Serve static assets (e.g. SVG icons) from an addon's package directory
|
|
456
|
-
fastify.get<{ Params: { addonId: string;
|
|
457
|
-
|
|
486
|
+
fastify.get<{ Params: { addonId: string; '*': string } }>(
|
|
487
|
+
'/api/addon-assets/:addonId/*',
|
|
458
488
|
async (request, reply) => {
|
|
459
|
-
const { addonId } = request.params
|
|
460
|
-
const assetPath = request.params[
|
|
489
|
+
const { addonId } = request.params
|
|
490
|
+
const assetPath = request.params['*'] ?? ''
|
|
461
491
|
|
|
462
|
-
const packageDir = addonRegistry.getAddonPackageDir(addonId)
|
|
492
|
+
const packageDir = addonRegistry.getAddonPackageDir(addonId)
|
|
463
493
|
if (!packageDir) {
|
|
464
|
-
return reply.code(404).send({ error:
|
|
494
|
+
return reply.code(404).send({ error: 'Addon not found' })
|
|
465
495
|
}
|
|
466
496
|
|
|
467
|
-
const resolvedPackageDir = path.resolve(packageDir)
|
|
468
|
-
const filePath = path.resolve(resolvedPackageDir, assetPath)
|
|
497
|
+
const resolvedPackageDir = path.resolve(packageDir)
|
|
498
|
+
const filePath = path.resolve(resolvedPackageDir, assetPath)
|
|
469
499
|
|
|
470
500
|
// Security: prevent path traversal attacks
|
|
471
501
|
if (
|
|
472
502
|
!filePath.startsWith(resolvedPackageDir + path.sep) &&
|
|
473
503
|
filePath !== resolvedPackageDir
|
|
474
504
|
) {
|
|
475
|
-
return reply.code(403).send({ error:
|
|
505
|
+
return reply.code(403).send({ error: 'Access denied' })
|
|
476
506
|
}
|
|
477
507
|
|
|
478
508
|
if (!fs.existsSync(filePath)) {
|
|
479
|
-
return reply.code(404).send({ error:
|
|
509
|
+
return reply.code(404).send({ error: 'Asset not found' })
|
|
480
510
|
}
|
|
481
511
|
|
|
482
|
-
const ext = path.extname(filePath).toLowerCase()
|
|
512
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
483
513
|
const contentTypes: Record<string, string> = {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
491
|
-
const contentType = contentTypes[ext] ??
|
|
492
|
-
|
|
493
|
-
reply.header(
|
|
494
|
-
reply.header(
|
|
495
|
-
return reply.send(fs.readFileSync(filePath))
|
|
514
|
+
'.svg': 'image/svg+xml',
|
|
515
|
+
'.png': 'image/png',
|
|
516
|
+
'.jpg': 'image/jpeg',
|
|
517
|
+
'.jpeg': 'image/jpeg',
|
|
518
|
+
'.json': 'application/json',
|
|
519
|
+
'.webp': 'image/webp',
|
|
520
|
+
}
|
|
521
|
+
const contentType = contentTypes[ext] ?? 'application/octet-stream'
|
|
522
|
+
|
|
523
|
+
reply.header('content-type', contentType)
|
|
524
|
+
reply.header('cache-control', 'public, max-age=86400')
|
|
525
|
+
return reply.send(fs.readFileSync(filePath))
|
|
496
526
|
},
|
|
497
|
-
)
|
|
527
|
+
)
|
|
498
528
|
|
|
499
529
|
// ── SSO finish endpoint ─────────────────────────────────────────
|
|
500
530
|
// OIDC / SAML / external auth providers redirect here after their
|
|
@@ -515,51 +545,48 @@ async function bootstrap() {
|
|
|
515
545
|
Querystring: {
|
|
516
546
|
bridge?: string
|
|
517
547
|
}
|
|
518
|
-
}>(
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
return reply.status(401).send({ error: "Invalid or expired bridge token" })
|
|
535
|
-
}
|
|
548
|
+
}>('/api/auth/sso/finish', async (request, reply) => {
|
|
549
|
+
// The auth-provider addon (OIDC, SAML, …) mints an HMAC-signed
|
|
550
|
+
// bridge token via the `sso-bridge` cap and 302s to here with
|
|
551
|
+
// `?bridge=<jwt>`. We verify the signature with the same JWT
|
|
552
|
+
// secret used elsewhere — only then do we trust the claims
|
|
553
|
+
// (including the `isAdmin` flag). This closes the prior gap
|
|
554
|
+
// where any client could call `?isAdmin=1` and become admin.
|
|
555
|
+
const bridge = request.query.bridge
|
|
556
|
+
if (!bridge) {
|
|
557
|
+
return reply.status(400).send({ error: 'Missing bridge token' })
|
|
558
|
+
}
|
|
559
|
+
const ssoAuth = app.get(AuthService)
|
|
560
|
+
const claims = ssoAuth.verifySsoBridgeToken(bridge)
|
|
561
|
+
if (!claims) {
|
|
562
|
+
return reply.status(401).send({ error: 'Invalid or expired bridge token' })
|
|
563
|
+
}
|
|
536
564
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
)
|
|
565
|
+
try {
|
|
566
|
+
const token = ssoAuth.signToken({
|
|
567
|
+
userId: claims.userId,
|
|
568
|
+
username: claims.username,
|
|
569
|
+
isAdmin: claims.isAdmin,
|
|
570
|
+
allowedProviders: '*',
|
|
571
|
+
allowedDevices: {},
|
|
572
|
+
})
|
|
573
|
+
// Redirect to the admin UI with the token in the URL fragment.
|
|
574
|
+
// Fragments don't hit the server log + don't get sent on
|
|
575
|
+
// subsequent requests — the SPA's auth-context picks the
|
|
576
|
+
// token up on mount and stores it in localStorage.
|
|
577
|
+
reply.code(302)
|
|
578
|
+
reply.header(
|
|
579
|
+
'Location',
|
|
580
|
+
`/admin/login#token=${encodeURIComponent(token)}&provider=${encodeURIComponent(claims.provider)}`,
|
|
581
|
+
)
|
|
582
|
+
return reply.send('')
|
|
583
|
+
} catch (err) {
|
|
584
|
+
return reply.status(500).send({
|
|
585
|
+
error: 'SSO finish failed',
|
|
586
|
+
message: err instanceof Error ? err.message : String(err),
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
})
|
|
563
590
|
|
|
564
591
|
// ── Backup archive download (Phase 4 / Task 24) ────────────────
|
|
565
592
|
// Streams an archive at `<locationId>/<archiveId>` through the
|
|
@@ -568,97 +595,103 @@ async function bootstrap() {
|
|
|
568
595
|
// fetch and trigger a save dialog (no cookie auth on this server,
|
|
569
596
|
// so we can't rely on `window.location.assign`).
|
|
570
597
|
fastify.get<{ Params: { locationId: string; archiveId: string } }>(
|
|
571
|
-
|
|
598
|
+
'/api/backup/download/:locationId/:archiveId',
|
|
572
599
|
async (request, reply) => {
|
|
573
|
-
const authHeader = request.headers.authorization
|
|
600
|
+
const authHeader = request.headers.authorization
|
|
574
601
|
if (!authHeader) {
|
|
575
|
-
return reply.status(401).send({ error:
|
|
602
|
+
return reply.status(401).send({ error: 'Unauthorized' })
|
|
576
603
|
}
|
|
577
604
|
try {
|
|
578
|
-
const token = authHeader.replace(
|
|
579
|
-
const downloadAuth = app.get(AuthService)
|
|
580
|
-
const payload = downloadAuth.verifyToken(token)
|
|
605
|
+
const token = authHeader.replace('Bearer ', '')
|
|
606
|
+
const downloadAuth = app.get(AuthService)
|
|
607
|
+
const payload = downloadAuth.verifyToken(token)
|
|
581
608
|
if (!payload.isAdmin) {
|
|
582
|
-
return reply.status(403).send({ error:
|
|
609
|
+
return reply.status(403).send({ error: 'Admin required' })
|
|
583
610
|
}
|
|
584
611
|
} catch {
|
|
585
|
-
return reply.status(401).send({ error:
|
|
612
|
+
return reply.status(401).send({ error: 'Invalid token' })
|
|
586
613
|
}
|
|
587
614
|
|
|
588
|
-
const { locationId, archiveId } = request.params
|
|
615
|
+
const { locationId, archiveId } = request.params
|
|
589
616
|
const backupSingleton = capabilityRegistry.getSingleton<{
|
|
590
|
-
listArchives: (input: {
|
|
591
|
-
|
|
617
|
+
listArchives: (input: {
|
|
618
|
+
destinationId: string
|
|
619
|
+
}) => Promise<
|
|
620
|
+
readonly { id: string; filename: string; sizeBytes: number; label?: string }[]
|
|
621
|
+
>
|
|
622
|
+
}>('backup')
|
|
592
623
|
if (!backupSingleton?.listArchives) {
|
|
593
|
-
return reply.status(503).send({ error:
|
|
624
|
+
return reply.status(503).send({ error: 'Backup orchestrator unavailable' })
|
|
594
625
|
}
|
|
595
|
-
const archives = await backupSingleton.listArchives({ destinationId: locationId })
|
|
596
|
-
const archive = archives.find((a) => a.id === archiveId)
|
|
626
|
+
const archives = await backupSingleton.listArchives({ destinationId: locationId })
|
|
627
|
+
const archive = archives.find((a) => a.id === archiveId)
|
|
597
628
|
if (!archive) {
|
|
598
|
-
return reply.status(404).send({ error:
|
|
629
|
+
return reply.status(404).send({ error: 'Archive not found' })
|
|
599
630
|
}
|
|
600
631
|
|
|
601
632
|
const storage = capabilityRegistry.getSingleton<{
|
|
602
|
-
beginDownload: (input: {
|
|
603
|
-
|
|
633
|
+
beginDownload: (input: {
|
|
634
|
+
location: string
|
|
635
|
+
relativePath: string
|
|
636
|
+
}) => Promise<{ downloadId: string; sizeBytes: number }>
|
|
637
|
+
readChunk: (input: {
|
|
638
|
+
downloadId: string
|
|
639
|
+
offset: number
|
|
640
|
+
length: number
|
|
641
|
+
}) => Promise<Uint8Array>
|
|
604
642
|
endDownload: (input: { downloadId: string }) => Promise<void>
|
|
605
|
-
}>(
|
|
643
|
+
}>('storage')
|
|
606
644
|
if (!storage?.beginDownload) {
|
|
607
|
-
return reply.status(503).send({ error:
|
|
645
|
+
return reply.status(503).send({ error: 'Storage cap unavailable' })
|
|
608
646
|
}
|
|
609
647
|
|
|
610
|
-
const downloadName = `${archive.label ?? archive.id}.tar.gz
|
|
648
|
+
const downloadName = `${archive.label ?? archive.id}.tar.gz`
|
|
611
649
|
// Sanitize: strip newlines and double-quotes which would break
|
|
612
650
|
// the Content-Disposition header. Whitespace is fine.
|
|
613
|
-
const safeName = downloadName.replace(/[\r\n"]/g,
|
|
614
|
-
reply.header(
|
|
615
|
-
reply.header(
|
|
616
|
-
reply.header(
|
|
617
|
-
"content-disposition",
|
|
618
|
-
`attachment; filename="${safeName}"`,
|
|
619
|
-
);
|
|
651
|
+
const safeName = downloadName.replace(/[\r\n"]/g, '_')
|
|
652
|
+
reply.header('content-type', 'application/gzip')
|
|
653
|
+
reply.header('content-length', String(archive.sizeBytes))
|
|
654
|
+
reply.header('content-disposition', `attachment; filename="${safeName}"`)
|
|
620
655
|
|
|
621
656
|
const { downloadId, sizeBytes } = await storage.beginDownload({
|
|
622
657
|
location: locationId,
|
|
623
658
|
relativePath: archive.filename,
|
|
624
|
-
})
|
|
625
|
-
const CHUNK = 8 * 1024 * 1024
|
|
659
|
+
})
|
|
660
|
+
const CHUNK = 8 * 1024 * 1024
|
|
626
661
|
try {
|
|
627
|
-
let offset = 0
|
|
662
|
+
let offset = 0
|
|
628
663
|
// Stream the chunked response. Fastify's `reply.raw` is the
|
|
629
664
|
// Node.js writable; we write each chunk and `end()` once
|
|
630
665
|
// we've drained the source. Per-write back-pressure is
|
|
631
666
|
// handled inside the runtime — buffered writes pile up but
|
|
632
667
|
// never beyond the OS socket buffer.
|
|
633
668
|
while (offset < sizeBytes) {
|
|
634
|
-
const len = Math.min(CHUNK, sizeBytes - offset)
|
|
635
|
-
const chunk = await storage.readChunk({ downloadId, offset, length: len })
|
|
669
|
+
const len = Math.min(CHUNK, sizeBytes - offset)
|
|
670
|
+
const chunk = await storage.readChunk({ downloadId, offset, length: len })
|
|
636
671
|
if (chunk.byteLength === 0) {
|
|
637
|
-
throw new Error(
|
|
638
|
-
`backup download: empty chunk at offset ${offset}/${sizeBytes}`,
|
|
639
|
-
);
|
|
672
|
+
throw new Error(`backup download: empty chunk at offset ${offset}/${sizeBytes}`)
|
|
640
673
|
}
|
|
641
|
-
reply.raw.write(chunk)
|
|
642
|
-
offset += chunk.byteLength
|
|
674
|
+
reply.raw.write(chunk)
|
|
675
|
+
offset += chunk.byteLength
|
|
643
676
|
}
|
|
644
|
-
reply.raw.end()
|
|
677
|
+
reply.raw.end()
|
|
645
678
|
} catch (err) {
|
|
646
|
-
console.error(
|
|
679
|
+
console.error('[backup-download] stream failed:', err)
|
|
647
680
|
// Headers may already be flushed — abort the socket if so.
|
|
648
681
|
if (!reply.raw.headersSent) {
|
|
649
|
-
return reply.status(500).send({ error:
|
|
682
|
+
return reply.status(500).send({ error: 'Download failed' })
|
|
650
683
|
}
|
|
651
|
-
reply.raw.destroy(err instanceof Error ? err : new Error(String(err)))
|
|
684
|
+
reply.raw.destroy(err instanceof Error ? err : new Error(String(err)))
|
|
652
685
|
} finally {
|
|
653
686
|
try {
|
|
654
|
-
await storage.endDownload({ downloadId })
|
|
687
|
+
await storage.endDownload({ downloadId })
|
|
655
688
|
} catch {
|
|
656
689
|
/* best-effort */
|
|
657
690
|
}
|
|
658
691
|
}
|
|
659
|
-
return reply
|
|
692
|
+
return reply
|
|
660
693
|
},
|
|
661
|
-
)
|
|
694
|
+
)
|
|
662
695
|
|
|
663
696
|
// POST /api/auth/session — upgrade a tRPC-issued JWT to a browser cookie.
|
|
664
697
|
fastify.post<{ Body: { token?: string } }>('/api/auth/session', async (request, reply) => {
|
|
@@ -666,7 +699,7 @@ async function bootstrap() {
|
|
|
666
699
|
if (!token) return reply.status(400).send({ error: 'token required' })
|
|
667
700
|
let ttlSec: number
|
|
668
701
|
try {
|
|
669
|
-
const payload = authService.verifyToken(token)
|
|
702
|
+
const payload = authService.verifyToken(token) // throws on invalid/expired
|
|
670
703
|
const expSec = typeof payload.exp === 'number' ? payload.exp : 0
|
|
671
704
|
ttlSec = Math.max(0, expSec - Math.floor(Date.now() / 1000))
|
|
672
705
|
} catch {
|
|
@@ -684,63 +717,145 @@ async function bootstrap() {
|
|
|
684
717
|
return reply.send({ ok: true })
|
|
685
718
|
})
|
|
686
719
|
|
|
720
|
+
// GET /api/embed-auth?next=<embed-path> — establish the browser session
|
|
721
|
+
// cookie from a Bearer token, then 302 to the embed. A native WebView can
|
|
722
|
+
// pass `Authorization` only on the INITIAL navigation, not on the page's
|
|
723
|
+
// asset sub-requests; the data-plane `authenticated` gate reads the cookie,
|
|
724
|
+
// so we mint it here (mirroring POST /api/auth/session) and bounce to the
|
|
725
|
+
// embed — page + assets then authenticate via the cookie. `next` is
|
|
726
|
+
// restricted to the stream-broker embed prefix (no open redirect).
|
|
727
|
+
fastify.get<{ Querystring: { next?: string } }>('/api/embed-auth', async (request, reply) => {
|
|
728
|
+
const authHeader = request.headers.authorization
|
|
729
|
+
const token = authHeader?.startsWith('Bearer ')
|
|
730
|
+
? authHeader.slice('Bearer '.length)
|
|
731
|
+
: undefined
|
|
732
|
+
if (!token) return reply.status(401).send({ error: 'Bearer token required' })
|
|
733
|
+
const next = request.query?.next ?? ''
|
|
734
|
+
if (!isEmbedRedirectTarget(next)) return reply.status(400).send({ error: 'invalid next' })
|
|
735
|
+
let ttlSec: number
|
|
736
|
+
try {
|
|
737
|
+
const payload = authService.verifyToken(token)
|
|
738
|
+
const expSec = typeof payload.exp === 'number' ? payload.exp : 0
|
|
739
|
+
ttlSec = Math.max(0, expSec - Math.floor(Date.now() / 1000))
|
|
740
|
+
} catch {
|
|
741
|
+
return reply.status(401).send({ error: 'invalid token' })
|
|
742
|
+
}
|
|
743
|
+
const c = buildSessionCookie(token, ttlSec)
|
|
744
|
+
reply.setCookie(c.name, c.value, c.options)
|
|
745
|
+
return reply.redirect(next)
|
|
746
|
+
})
|
|
747
|
+
|
|
687
748
|
// Addon HTTP API route catch-all: /addon/:addonId/*
|
|
688
749
|
// Only handles non-GET or routes that actually exist in the addon route registry.
|
|
689
750
|
// GET requests that don't match an addon route are SPA pages — handled by the /* fallback.
|
|
690
751
|
fastify.all<{
|
|
691
|
-
Params: { addonId: string;
|
|
752
|
+
Params: { addonId: string; '*': string }
|
|
692
753
|
Querystring: Record<string, string>
|
|
693
754
|
Headers: Record<string, string>
|
|
694
|
-
}>(
|
|
695
|
-
const { addonId } = request.params
|
|
696
|
-
const subPath = request.params[
|
|
697
|
-
const method = request.method
|
|
698
|
-
const fullPath = `/addon/${addonId}/${subPath}
|
|
699
|
-
const query: Record<string, string> = request.query ?? {}
|
|
700
|
-
const headers: Record<string, string> = {}
|
|
755
|
+
}>('/addon/:addonId/*', async (request, reply) => {
|
|
756
|
+
const { addonId } = request.params
|
|
757
|
+
const subPath = request.params['*'] ?? ''
|
|
758
|
+
const method = request.method
|
|
759
|
+
const fullPath = `/addon/${addonId}/${subPath}`
|
|
760
|
+
const query: Record<string, string> = request.query ?? {}
|
|
761
|
+
const headers: Record<string, string> = {}
|
|
701
762
|
for (const [k, v] of Object.entries(request.headers)) {
|
|
702
|
-
if (typeof v ===
|
|
703
|
-
else if (Array.isArray(v)) headers[k] = v.join(
|
|
763
|
+
if (typeof v === 'string') headers[k] = v
|
|
764
|
+
else if (Array.isArray(v)) headers[k] = v.join(',')
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ── HTTP data-plane: reverse-proxy to the addon's own listener ───────
|
|
768
|
+
// Checked BEFORE control routes. A hit means the addon serves this prefix
|
|
769
|
+
// via `ctx.dataPlane` (it streams the bytes with real req/res); the hub
|
|
770
|
+
// authenticates here, then pipes. Same origin/cert as the admin-ui.
|
|
771
|
+
const dpMatch = dataPlaneRegistry.match(addonId, subPath)
|
|
772
|
+
if (dpMatch) {
|
|
773
|
+
const access = dpMatch.endpoint.access
|
|
774
|
+
if (access !== 'public') {
|
|
775
|
+
const authHeader = request.headers.authorization
|
|
776
|
+
const cookieToken = request.cookies?.[SESSION_COOKIE]
|
|
777
|
+
if (!authHeader && !cookieToken) {
|
|
778
|
+
return reply.status(401).send({ error: 'Unauthorized' })
|
|
779
|
+
}
|
|
780
|
+
const token = authHeader ? authHeader.replace('Bearer ', '') : cookieToken!
|
|
781
|
+
if (token.startsWith('cst_')) {
|
|
782
|
+
const userMgmt = capabilityRegistry?.getSingleton('user-management')
|
|
783
|
+
if (!userMgmt) return reply.status(503).send({ error: 'User management not available' })
|
|
784
|
+
const scopedToken = await userMgmt.validateScopedToken({ token })
|
|
785
|
+
const scopeOk = scopedToken?.scopes.some(
|
|
786
|
+
(scope: { type: string; target?: string }) =>
|
|
787
|
+
scope.type === 'addon' && scope.target === addonId,
|
|
788
|
+
)
|
|
789
|
+
if (!scopedToken || !scopeOk) {
|
|
790
|
+
return reply.status(403).send({ error: 'Token scope mismatch' })
|
|
791
|
+
}
|
|
792
|
+
} else {
|
|
793
|
+
try {
|
|
794
|
+
const payload = authService.verifyToken(token)
|
|
795
|
+
if (access === 'admin' && !payload.isAdmin) {
|
|
796
|
+
return reply.status(403).send({ error: 'Admin required' })
|
|
797
|
+
}
|
|
798
|
+
} catch {
|
|
799
|
+
return reply.status(401).send({ error: 'Invalid token' })
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
const qIdx = request.url.indexOf('?')
|
|
804
|
+
const query = qIdx >= 0 ? request.url.slice(qIdx) : ''
|
|
805
|
+
// Forward the FULL sub-path INCLUDING the prefix — the addon's facility
|
|
806
|
+
// multiplexes by prefix, so it strips the prefix itself. (`dpMatch.rest`
|
|
807
|
+
// is only used to pick the endpoint, not to rewrite the path.)
|
|
808
|
+
const upstreamPath = `/${subPath}${query}`
|
|
809
|
+
// Take over the socket — `proxyToUpstream` drives the raw response.
|
|
810
|
+
reply.hijack()
|
|
811
|
+
proxyToUpstream({
|
|
812
|
+
baseUrl: dpMatch.endpoint.baseUrl,
|
|
813
|
+
secret: dpMatch.endpoint.secret,
|
|
814
|
+
upstreamPath,
|
|
815
|
+
clientReq: request.raw,
|
|
816
|
+
clientRes: reply.raw,
|
|
817
|
+
})
|
|
818
|
+
return
|
|
704
819
|
}
|
|
705
820
|
|
|
706
|
-
const match = addonRouteRegistry.matchRoute(method, fullPath)
|
|
821
|
+
const match = addonRouteRegistry.matchRoute(method, fullPath)
|
|
707
822
|
if (!match) {
|
|
708
|
-
if (method ===
|
|
709
|
-
return reply.type(
|
|
823
|
+
if (method === 'GET' && spaIndexHtml) {
|
|
824
|
+
return reply.type('text/html').send(fs.createReadStream(spaIndexHtml))
|
|
710
825
|
}
|
|
711
|
-
return reply.status(404).send({ error:
|
|
826
|
+
return reply.status(404).send({ error: 'Not found' })
|
|
712
827
|
}
|
|
713
828
|
|
|
714
829
|
// Auth check based on route.access
|
|
715
|
-
if (match.route.access !==
|
|
716
|
-
const authHeader = request.headers.authorization
|
|
830
|
+
if (match.route.access !== 'public') {
|
|
831
|
+
const authHeader = request.headers.authorization
|
|
717
832
|
// Browser navigation has no Authorization header — fall back to the
|
|
718
833
|
// session cookie, and if that is missing bounce to the login page.
|
|
719
|
-
const cookieToken = request.cookies?.[SESSION_COOKIE]
|
|
834
|
+
const cookieToken = request.cookies?.[SESSION_COOKIE]
|
|
720
835
|
if (!authHeader && !cookieToken) {
|
|
721
836
|
if (shouldRedirectToLogin(request.method, request.headers.accept)) {
|
|
722
|
-
const qs = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
|
|
723
|
-
return reply.redirect(loginRedirectUrl(fullPath + qs))
|
|
837
|
+
const qs = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
|
|
838
|
+
return reply.redirect(loginRedirectUrl(fullPath + qs))
|
|
724
839
|
}
|
|
725
|
-
return reply.status(401).send({ error:
|
|
840
|
+
return reply.status(401).send({ error: 'Unauthorized' })
|
|
726
841
|
}
|
|
727
|
-
const token = authHeader ? authHeader.replace(
|
|
728
|
-
if (token.startsWith(
|
|
729
|
-
const userMgmt = capabilityRegistry?.getSingleton('user-management')
|
|
730
|
-
if (!userMgmt) return reply.status(503).send({ error:
|
|
731
|
-
const scopedToken = await userMgmt.validateScopedToken({ token })
|
|
842
|
+
const token = authHeader ? authHeader.replace('Bearer ', '') : cookieToken!
|
|
843
|
+
if (token.startsWith('cst_')) {
|
|
844
|
+
const userMgmt = capabilityRegistry?.getSingleton('user-management')
|
|
845
|
+
if (!userMgmt) return reply.status(503).send({ error: 'User management not available' })
|
|
846
|
+
const scopedToken = await userMgmt.validateScopedToken({ token })
|
|
732
847
|
if (!scopedToken) {
|
|
733
|
-
return reply.status(401).send({ error:
|
|
848
|
+
return reply.status(401).send({ error: 'Invalid token' })
|
|
734
849
|
}
|
|
735
850
|
// v2 model: scoped tokens grant by cap-category/cap-name/addon.
|
|
736
851
|
// For addon-route REST endpoints we match the `addon` scope
|
|
737
852
|
// type only — generic route-prefix scopes were dropped with
|
|
738
853
|
// the caps-only refactor.
|
|
739
854
|
const scopeMatch = scopedToken.scopes.some((scope) => {
|
|
740
|
-
return scope.type === 'addon' && scope.target === addonId
|
|
741
|
-
})
|
|
855
|
+
return scope.type === 'addon' && scope.target === addonId
|
|
856
|
+
})
|
|
742
857
|
if (!scopeMatch) {
|
|
743
|
-
return reply.status(403).send({ error:
|
|
858
|
+
return reply.status(403).send({ error: 'Token scope mismatch' })
|
|
744
859
|
}
|
|
745
860
|
// Build addon request with scoped token context
|
|
746
861
|
const addonRequest = {
|
|
@@ -753,14 +868,14 @@ async function bootstrap() {
|
|
|
753
868
|
userId: scopedToken.userId,
|
|
754
869
|
scopes: scopedToken.scopes,
|
|
755
870
|
},
|
|
756
|
-
}
|
|
757
|
-
const addonReply = buildAddonReply(reply)
|
|
758
|
-
return match.route.handler(addonRequest, addonReply)
|
|
871
|
+
}
|
|
872
|
+
const addonReply = buildAddonReply(reply)
|
|
873
|
+
return match.route.handler(addonRequest, addonReply)
|
|
759
874
|
} else {
|
|
760
875
|
try {
|
|
761
|
-
const payload = authService.verifyToken(token)
|
|
762
|
-
if (match.route.access ===
|
|
763
|
-
return reply.status(403).send({ error:
|
|
876
|
+
const payload = authService.verifyToken(token)
|
|
877
|
+
if (match.route.access === 'admin' && !payload.isAdmin) {
|
|
878
|
+
return reply.status(403).send({ error: 'Admin required' })
|
|
764
879
|
}
|
|
765
880
|
const addonRequest = {
|
|
766
881
|
params: match.params,
|
|
@@ -768,15 +883,15 @@ async function bootstrap() {
|
|
|
768
883
|
body: request.body,
|
|
769
884
|
headers,
|
|
770
885
|
user: {
|
|
771
|
-
id: payload.userId ??
|
|
772
|
-
username: payload.username ??
|
|
886
|
+
id: payload.userId ?? 'unknown',
|
|
887
|
+
username: payload.username ?? 'unknown',
|
|
773
888
|
isAdmin: payload.isAdmin,
|
|
774
889
|
},
|
|
775
|
-
}
|
|
776
|
-
const addonReply = buildAddonReply(reply)
|
|
777
|
-
return match.route.handler(addonRequest, addonReply)
|
|
890
|
+
}
|
|
891
|
+
const addonReply = buildAddonReply(reply)
|
|
892
|
+
return match.route.handler(addonRequest, addonReply)
|
|
778
893
|
} catch {
|
|
779
|
-
return reply.status(401).send({ error:
|
|
894
|
+
return reply.status(401).send({ error: 'Invalid token' })
|
|
780
895
|
}
|
|
781
896
|
}
|
|
782
897
|
}
|
|
@@ -787,10 +902,10 @@ async function bootstrap() {
|
|
|
787
902
|
query,
|
|
788
903
|
body: request.body,
|
|
789
904
|
headers,
|
|
790
|
-
}
|
|
791
|
-
const addonReply = buildAddonReply(reply)
|
|
792
|
-
return match.route.handler(addonRequest, addonReply)
|
|
793
|
-
})
|
|
905
|
+
}
|
|
906
|
+
const addonReply = buildAddonReply(reply)
|
|
907
|
+
return match.route.handler(addonRequest, addonReply)
|
|
908
|
+
})
|
|
794
909
|
|
|
795
910
|
// ── OAuth2 authorization endpoints ─────────────────────────────────────
|
|
796
911
|
// Mounted under /api/oauth2/* so they sit inside the universal "/api/"
|
|
@@ -801,63 +916,75 @@ async function bootstrap() {
|
|
|
801
916
|
getRegistry: () => capabilityRegistry,
|
|
802
917
|
verifyToken: (t) => authService.verifyToken(t),
|
|
803
918
|
publicHubUrl: () => process.env.CAMSTACK_PUBLIC_ORIGIN ?? `https://localhost:${port}`,
|
|
804
|
-
})
|
|
805
|
-
console.log('[bootstrap] OAuth2 routes registered at /api/oauth2/*')
|
|
919
|
+
})
|
|
920
|
+
console.log('[bootstrap] OAuth2 routes registered at /api/oauth2/*')
|
|
806
921
|
|
|
807
922
|
// Attach tRPC WebSocket handler using noServer mode to avoid
|
|
808
923
|
// Fastify intercepting the upgrade request with a 400 response.
|
|
809
|
-
const wss = new WebSocketServer({ noServer: true })
|
|
924
|
+
const wss = new WebSocketServer({ noServer: true })
|
|
810
925
|
applyWSSHandler({
|
|
811
926
|
wss,
|
|
812
927
|
router: appRouter,
|
|
813
|
-
createContext: (opts: CreateWSSContextFnOptions) =>
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
928
|
+
createContext: (opts: CreateWSSContextFnOptions) =>
|
|
929
|
+
createWsTrpcContext(opts, authService, addonRegistry),
|
|
930
|
+
onError: ({
|
|
931
|
+
path,
|
|
932
|
+
error,
|
|
933
|
+
}: {
|
|
934
|
+
path: string | undefined
|
|
935
|
+
error: { code: string; message: string; cause?: unknown }
|
|
936
|
+
}) => {
|
|
937
|
+
const trpcLogger = app.get(LoggingService).createLogger('tRPC:ws')
|
|
938
|
+
trpcLogger.warn('tRPC error', {
|
|
939
|
+
meta: { code: error.code, path: path ?? '?', message: error.message },
|
|
940
|
+
})
|
|
941
|
+
if (error.cause)
|
|
942
|
+
trpcLogger.warn('tRPC error cause', {
|
|
943
|
+
meta: {
|
|
944
|
+
cause:
|
|
945
|
+
error.cause instanceof Error ? error.cause.message : JSON.stringify(error.cause),
|
|
946
|
+
},
|
|
947
|
+
})
|
|
818
948
|
},
|
|
819
|
-
})
|
|
949
|
+
})
|
|
820
950
|
|
|
821
951
|
// Manually handle HTTP upgrade for /trpc path.
|
|
822
952
|
// Must use 'upgrade' on the raw Node HTTP server BEFORE Fastify processes it.
|
|
823
|
-
const httpServer = fastify.server
|
|
953
|
+
const httpServer = fastify.server
|
|
824
954
|
// Remove any existing upgrade listeners that might conflict
|
|
825
|
-
const existingUpgradeListeners = httpServer.listeners(
|
|
826
|
-
httpServer.removeAllListeners(
|
|
955
|
+
const existingUpgradeListeners = httpServer.listeners('upgrade')
|
|
956
|
+
httpServer.removeAllListeners('upgrade')
|
|
827
957
|
|
|
828
|
-
httpServer.on(
|
|
829
|
-
const pathname = (request.url ??
|
|
830
|
-
if (pathname ===
|
|
958
|
+
httpServer.on('upgrade', (request, socket, head) => {
|
|
959
|
+
const pathname = (request.url ?? '').split('?')[0]
|
|
960
|
+
if (pathname === '/trpc') {
|
|
831
961
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
832
|
-
wss.emit(
|
|
833
|
-
})
|
|
962
|
+
wss.emit('connection', ws, request)
|
|
963
|
+
})
|
|
834
964
|
} else {
|
|
835
965
|
// Re-emit for other upgrade handlers (agent WS, etc.)
|
|
836
966
|
// `EventEmitter.listeners()` returns `Function[]` with no call
|
|
837
967
|
// signature — use Reflect.apply to invoke without a cast.
|
|
838
968
|
for (const listener of existingUpgradeListeners) {
|
|
839
|
-
Reflect.apply(listener, httpServer, [request, socket, head])
|
|
969
|
+
Reflect.apply(listener, httpServer, [request, socket, head])
|
|
840
970
|
}
|
|
841
971
|
}
|
|
842
|
-
})
|
|
972
|
+
})
|
|
843
973
|
} catch (err) {
|
|
844
|
-
console.error(
|
|
845
|
-
"[bootstrap] tRPC registration failed — starting in degraded mode:",
|
|
846
|
-
err,
|
|
847
|
-
);
|
|
974
|
+
console.error('[bootstrap] tRPC registration failed — starting in degraded mode:', err)
|
|
848
975
|
// Register a fallback health endpoint that signals degraded state
|
|
849
|
-
fastify.get(
|
|
976
|
+
fastify.get('/trpc/health', async (_req: FastifyRequest, reply: FastifyReply) => {
|
|
850
977
|
reply.status(503).send({
|
|
851
|
-
status:
|
|
852
|
-
message:
|
|
853
|
-
})
|
|
854
|
-
})
|
|
978
|
+
status: 'degraded',
|
|
979
|
+
message: 'tRPC router failed to initialize. Check server logs.',
|
|
980
|
+
})
|
|
981
|
+
})
|
|
855
982
|
}
|
|
856
983
|
|
|
857
984
|
// Wire the app router into AddonRegistry so addons get context.api
|
|
858
985
|
if (appRouter) {
|
|
859
|
-
await addonRegistry.setAppRouter(appRouter)
|
|
860
|
-
console.log(
|
|
986
|
+
await addonRegistry.setAppRouter(appRouter)
|
|
987
|
+
console.log('[bootstrap] AddonRegistry wired with tRPC direct caller')
|
|
861
988
|
}
|
|
862
989
|
|
|
863
990
|
// ScopedTokenManager and admin user creation handled by local-auth addon.
|
|
@@ -871,8 +998,8 @@ async function bootstrap() {
|
|
|
871
998
|
// no static file serving' warn that left the SPA unserved until next
|
|
872
999
|
// restart.
|
|
873
1000
|
try {
|
|
874
|
-
const addonRegistry = app.get(AddonRegistryService)
|
|
875
|
-
const capRegistry = addonRegistry.getCapabilityRegistry()
|
|
1001
|
+
const addonRegistry = app.get(AddonRegistryService)
|
|
1002
|
+
const capRegistry = addonRegistry.getCapabilityRegistry()
|
|
876
1003
|
// The admin-ui addon runs in its own dedicated runner subprocess
|
|
877
1004
|
// (one addon, one process — base-layer D2), so `getSingleton`
|
|
878
1005
|
// returns a RemoteProxy whose method calls cross the broker — every
|
|
@@ -880,15 +1007,15 @@ async function bootstrap() {
|
|
|
880
1007
|
// `{version}` objects so plain (in-process) and remote-proxied
|
|
881
1008
|
// providers share the same Promise<object> signature.
|
|
882
1009
|
type AdminUI = {
|
|
883
|
-
getStaticDir(): Promise<{ readonly staticDir: string }
|
|
884
|
-
getVersion(): Promise<{ readonly version: string }
|
|
885
|
-
}
|
|
886
|
-
let adminUI = capRegistry?.getSingleton<AdminUI>(
|
|
1010
|
+
getStaticDir(): Promise<{ readonly staticDir: string }>
|
|
1011
|
+
getVersion(): Promise<{ readonly version: string }>
|
|
1012
|
+
}
|
|
1013
|
+
let adminUI = capRegistry?.getSingleton<AdminUI>('admin-ui')
|
|
887
1014
|
// CAMSTACK_SKIP_ADMIN_UI_WAIT — bypass the 60s poll. Used by the
|
|
888
1015
|
// e2e harness, which doesn't need the SPA served and spawns hubs
|
|
889
1016
|
// with strict boot timeouts. Production keeps the poll so cold
|
|
890
1017
|
// boots wait for the forked admin-ui group to register.
|
|
891
|
-
const skipAdminUIWait = process.env['CAMSTACK_SKIP_ADMIN_UI_WAIT'] === '1'
|
|
1018
|
+
const skipAdminUIWait = process.env['CAMSTACK_SKIP_ADMIN_UI_WAIT'] === '1'
|
|
892
1019
|
if (!adminUI && capRegistry && !skipAdminUIWait) {
|
|
893
1020
|
// Forked-addon spawn + Moleculer registration + tRPC hydration
|
|
894
1021
|
// can take ~15-20s on cold boot, especially when several runners
|
|
@@ -896,19 +1023,19 @@ async function bootstrap() {
|
|
|
896
1023
|
// the admin-ui runner's boot — fastify-static didn't register and
|
|
897
1024
|
// every `GET /` returned 404. Bump to 60s; we only pay this
|
|
898
1025
|
// wait once at boot.
|
|
899
|
-
const ADMIN_UI_WAIT_MS = 60_000
|
|
900
|
-
const POLL_MS = 200
|
|
901
|
-
const deadline = Date.now() + ADMIN_UI_WAIT_MS
|
|
1026
|
+
const ADMIN_UI_WAIT_MS = 60_000
|
|
1027
|
+
const POLL_MS = 200
|
|
1028
|
+
const deadline = Date.now() + ADMIN_UI_WAIT_MS
|
|
902
1029
|
while (!adminUI && Date.now() < deadline) {
|
|
903
|
-
await new Promise<void>(r => setTimeout(r, POLL_MS))
|
|
904
|
-
adminUI = capRegistry.getSingleton<AdminUI>(
|
|
1030
|
+
await new Promise<void>((r) => setTimeout(r, POLL_MS))
|
|
1031
|
+
adminUI = capRegistry.getSingleton<AdminUI>('admin-ui')
|
|
905
1032
|
}
|
|
906
1033
|
}
|
|
907
1034
|
if (adminUI) {
|
|
908
|
-
const { staticDir } = await adminUI.getStaticDir()
|
|
909
|
-
const indexPath = path.join(staticDir,
|
|
1035
|
+
const { staticDir } = await adminUI.getStaticDir()
|
|
1036
|
+
const indexPath = path.join(staticDir, 'index.html')
|
|
910
1037
|
if (fs.existsSync(staticDir) && fs.existsSync(indexPath)) {
|
|
911
|
-
spaIndexHtml = indexPath
|
|
1038
|
+
spaIndexHtml = indexPath
|
|
912
1039
|
// `serve: false` registers no route — it only decorates
|
|
913
1040
|
// `reply.sendFile`, so the single SPA `/*` handler below owns all
|
|
914
1041
|
// routing and serves each asset LIVE from the current `staticDir`.
|
|
@@ -919,27 +1046,27 @@ async function bootstrap() {
|
|
|
919
1046
|
root: staticDir,
|
|
920
1047
|
serve: false,
|
|
921
1048
|
decorateReply: true,
|
|
922
|
-
})
|
|
1049
|
+
})
|
|
923
1050
|
// Dev diagnostic: serve webrtc-test.html from dataPath if it exists.
|
|
924
|
-
const webrtcTestPath = path.join(dataPath,
|
|
1051
|
+
const webrtcTestPath = path.join(dataPath, 'webrtc-test.html')
|
|
925
1052
|
if (fs.existsSync(webrtcTestPath)) {
|
|
926
|
-
fastify.get(
|
|
927
|
-
return reply.type(
|
|
928
|
-
})
|
|
1053
|
+
fastify.get('/webrtc-test.html', async (_request, reply) => {
|
|
1054
|
+
return reply.type('text/html').send(fs.createReadStream(webrtcTestPath))
|
|
1055
|
+
})
|
|
929
1056
|
}
|
|
930
1057
|
|
|
931
1058
|
// SPA fallback + live static serving: this single catch-all owns every
|
|
932
1059
|
// GET. Core API prefixes fall through to their own routers via
|
|
933
1060
|
// `callNotFound`. Uses a wildcard route instead of setNotFoundHandler.
|
|
934
|
-
fastify.get(
|
|
935
|
-
const url = request.url
|
|
1061
|
+
fastify.get('/*', async (request, reply) => {
|
|
1062
|
+
const url = request.url
|
|
936
1063
|
if (
|
|
937
|
-
url.startsWith(
|
|
938
|
-
url.startsWith(
|
|
939
|
-
url.startsWith(
|
|
940
|
-
url.startsWith(
|
|
1064
|
+
url.startsWith('/trpc') ||
|
|
1065
|
+
url.startsWith('/api/') ||
|
|
1066
|
+
url.startsWith('/agent') ||
|
|
1067
|
+
url.startsWith('/health')
|
|
941
1068
|
) {
|
|
942
|
-
return reply.callNotFound()
|
|
1069
|
+
return reply.callNotFound()
|
|
943
1070
|
}
|
|
944
1071
|
// A request whose last path segment has a file extension is a static
|
|
945
1072
|
// asset: serve it LIVE from the current dist so a redeployed
|
|
@@ -948,85 +1075,121 @@ async function bootstrap() {
|
|
|
948
1075
|
// `index.html`: serving HTML under a `.js`/`.css` URL makes upstream
|
|
949
1076
|
// caches (Cloudflare, the browser) pin `text/html`, which then fails
|
|
950
1077
|
// the module MIME check long after the file is actually available.
|
|
951
|
-
const pathOnly = url.split(
|
|
1078
|
+
const pathOnly = url.split('?')[0] ?? url
|
|
952
1079
|
if (/\.[a-zA-Z0-9]+$/.test(pathOnly)) {
|
|
953
|
-
const rel = pathOnly.replace(/^\/+/,
|
|
954
|
-
const abs = path.join(staticDir, rel)
|
|
955
|
-
if (
|
|
956
|
-
(abs === staticDir || abs.startsWith(staticDir + path.sep)) &&
|
|
957
|
-
fs.existsSync(abs)
|
|
958
|
-
) {
|
|
1080
|
+
const rel = pathOnly.replace(/^\/+/, '')
|
|
1081
|
+
const abs = path.join(staticDir, rel)
|
|
1082
|
+
if ((abs === staticDir || abs.startsWith(staticDir + path.sep)) && fs.existsSync(abs)) {
|
|
959
1083
|
// Cache policy that lets PWA updates actually propagate (the
|
|
960
1084
|
// stale-bundle bug): the service worker + registration + manifest
|
|
961
1085
|
// MUST be revalidated every load or a redeploy never reaches the
|
|
962
1086
|
// client (the SW keeps serving the old precache). Content-hashed
|
|
963
1087
|
// build assets (assets/index-<hash>.js) are immutable. Everything
|
|
964
1088
|
// else gets a short cache.
|
|
965
|
-
const base = rel.split(
|
|
1089
|
+
const base = rel.split('/').pop() ?? rel
|
|
966
1090
|
if (/^(sw\.js|registerSW\.js|workbox-.*\.js|manifest\.webmanifest)$/.test(base)) {
|
|
967
|
-
reply.header(
|
|
968
|
-
} else if (rel.startsWith(
|
|
969
|
-
reply.header(
|
|
1091
|
+
reply.header('cache-control', 'no-cache, must-revalidate')
|
|
1092
|
+
} else if (rel.startsWith('assets/') && /-[A-Za-z0-9_-]{8,}\./.test(base)) {
|
|
1093
|
+
reply.header('cache-control', 'public, max-age=31536000, immutable')
|
|
970
1094
|
} else {
|
|
971
|
-
reply.header(
|
|
1095
|
+
reply.header('cache-control', 'no-cache')
|
|
972
1096
|
}
|
|
973
|
-
return reply.sendFile(rel)
|
|
1097
|
+
return reply.sendFile(rel)
|
|
974
1098
|
}
|
|
975
|
-
return reply.callNotFound()
|
|
1099
|
+
return reply.callNotFound()
|
|
976
1100
|
}
|
|
977
1101
|
// index.html (the SPA shell) must never be cached — it references the
|
|
978
1102
|
// content-hashed bundles, so a stale copy pins the old app forever.
|
|
979
|
-
reply.header(
|
|
980
|
-
return reply.type(
|
|
981
|
-
})
|
|
982
|
-
const { version } = await adminUI.getVersion()
|
|
983
|
-
console.log(
|
|
984
|
-
`[bootstrap] Admin UI served from: ${staticDir} (v${version})`,
|
|
985
|
-
);
|
|
1103
|
+
reply.header('cache-control', 'no-cache, must-revalidate')
|
|
1104
|
+
return reply.type('text/html').send(fs.createReadStream(spaIndexHtml!))
|
|
1105
|
+
})
|
|
1106
|
+
const { version } = await adminUI.getVersion()
|
|
1107
|
+
console.log(`[bootstrap] Admin UI served from: ${staticDir} (v${version})`)
|
|
986
1108
|
} else {
|
|
987
1109
|
console.warn(
|
|
988
1110
|
`[bootstrap] Admin UI dist not found at: ${staticDir} — run 'npm run build' in addon-admin-ui`,
|
|
989
|
-
)
|
|
1111
|
+
)
|
|
990
1112
|
}
|
|
991
1113
|
} else {
|
|
992
|
-
console.warn(
|
|
993
|
-
"[bootstrap] admin-ui capability not registered — no static file serving",
|
|
994
|
-
);
|
|
1114
|
+
console.warn('[bootstrap] admin-ui capability not registered — no static file serving')
|
|
995
1115
|
}
|
|
996
1116
|
} catch (err) {
|
|
997
|
-
console.error(
|
|
998
|
-
"[bootstrap] Failed to set up admin UI static serving:",
|
|
999
|
-
err,
|
|
1000
|
-
);
|
|
1117
|
+
console.error('[bootstrap] Failed to set up admin UI static serving:', err)
|
|
1001
1118
|
}
|
|
1002
1119
|
|
|
1003
1120
|
try {
|
|
1004
|
-
await app.listen(port, host)
|
|
1121
|
+
await app.listen(port, host)
|
|
1005
1122
|
} catch (listenErr: unknown) {
|
|
1006
1123
|
if (
|
|
1007
|
-
listenErr !== null
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1124
|
+
listenErr !== null &&
|
|
1125
|
+
typeof listenErr === 'object' &&
|
|
1126
|
+
'code' in listenErr &&
|
|
1127
|
+
listenErr.code === 'EADDRINUSE'
|
|
1011
1128
|
) {
|
|
1012
1129
|
console.error(
|
|
1013
1130
|
`[bootstrap] FATAL: Port ${port} is already in use. Stop the other process or change server.port in config.yaml.`,
|
|
1014
|
-
)
|
|
1015
|
-
process.exit(1)
|
|
1131
|
+
)
|
|
1132
|
+
process.exit(1)
|
|
1016
1133
|
}
|
|
1017
|
-
throw listenErr
|
|
1134
|
+
throw listenErr
|
|
1018
1135
|
}
|
|
1019
1136
|
|
|
1020
|
-
const logger = app.get(LoggingService).createLogger(
|
|
1021
|
-
const protocol = tlsOptions ?
|
|
1022
|
-
logger.info(
|
|
1023
|
-
'CamStack server listening',
|
|
1024
|
-
{ meta: { protocol, host, port, trpcRegistered } },
|
|
1025
|
-
);
|
|
1137
|
+
const logger = app.get(LoggingService).createLogger('System')
|
|
1138
|
+
const protocol = tlsOptions ? 'https' : 'http'
|
|
1139
|
+
logger.info('CamStack server listening', { meta: { protocol, host, port, trpcRegistered } })
|
|
1026
1140
|
|
|
1027
1141
|
// Post-boot: fork workers, register device streams, emit system.boot
|
|
1028
|
-
const postBoot = app.get(PostBootService)
|
|
1029
|
-
await postBoot.run({ port, host, dataPath, trpcRegistered })
|
|
1142
|
+
const postBoot = app.get(PostBootService)
|
|
1143
|
+
await postBoot.run({ port, host, dataPath, trpcRegistered })
|
|
1144
|
+
|
|
1145
|
+
// One-time backfill: stamp integrationId on devices created before the
|
|
1146
|
+
// device-manager forwarder started stamping it (legacy camera providers),
|
|
1147
|
+
// so deleting their integration cascades them. Idempotent — only touches
|
|
1148
|
+
// untagged top-level devices of single-instance addons.
|
|
1149
|
+
try {
|
|
1150
|
+
const dmForBackfill = capabilityRegistry.getSingleton('device-manager') as {
|
|
1151
|
+
listAll?: (input: { addonId?: string }) => Promise<
|
|
1152
|
+
readonly {
|
|
1153
|
+
id: number
|
|
1154
|
+
addonId: string
|
|
1155
|
+
parentDeviceId: number | null
|
|
1156
|
+
integrationId?: string
|
|
1157
|
+
}[]
|
|
1158
|
+
>
|
|
1159
|
+
setIntegrationId?: (input: { deviceId: number; integrationId: string }) => Promise<void>
|
|
1160
|
+
} | null
|
|
1161
|
+
const integrationRegistry = addonRegistry.getIntegrationRegistry()
|
|
1162
|
+
if (dmForBackfill?.listAll && dmForBackfill?.setIntegrationId && integrationRegistry) {
|
|
1163
|
+
const listAll = dmForBackfill.listAll
|
|
1164
|
+
const setIntegrationId = dmForBackfill.setIntegrationId
|
|
1165
|
+
const backfillLogger = loggingService.createLogger('integration-backfill')
|
|
1166
|
+
await runIntegrationIdBackfill({
|
|
1167
|
+
listIntegrations: async () =>
|
|
1168
|
+
(await integrationRegistry.listIntegrations()).map((i) => ({
|
|
1169
|
+
id: i.id,
|
|
1170
|
+
addonId: i.addonId,
|
|
1171
|
+
})),
|
|
1172
|
+
listDevices: async () =>
|
|
1173
|
+
(await listAll({})).map((d) => ({
|
|
1174
|
+
id: d.id,
|
|
1175
|
+
addonId: d.addonId,
|
|
1176
|
+
parentDeviceId: d.parentDeviceId,
|
|
1177
|
+
integrationId: d.integrationId,
|
|
1178
|
+
})),
|
|
1179
|
+
setIntegrationId: (deviceId, integrationId) =>
|
|
1180
|
+
setIntegrationId({ deviceId, integrationId }),
|
|
1181
|
+
logger: {
|
|
1182
|
+
info: (message, meta) => backfillLogger.info(message, { meta }),
|
|
1183
|
+
warn: (message, meta) => backfillLogger.warn(message, { meta }),
|
|
1184
|
+
},
|
|
1185
|
+
})
|
|
1186
|
+
}
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
console.warn(
|
|
1189
|
+
'[bootstrap] integrationId backfill skipped:',
|
|
1190
|
+
err instanceof Error ? err.message : err,
|
|
1191
|
+
)
|
|
1192
|
+
}
|
|
1030
1193
|
}
|
|
1031
1194
|
|
|
1032
1195
|
/**
|
|
@@ -1034,15 +1197,15 @@ async function bootstrap() {
|
|
|
1034
1197
|
*/
|
|
1035
1198
|
function readHubVersion(): string {
|
|
1036
1199
|
try {
|
|
1037
|
-
const pkgPath = path.resolve(__dirname,
|
|
1200
|
+
const pkgPath = path.resolve(__dirname, '..', 'package.json')
|
|
1038
1201
|
if (fs.existsSync(pkgPath)) {
|
|
1039
|
-
const raw = JSON.parse(fs.readFileSync(pkgPath,
|
|
1040
|
-
if (typeof raw.version ===
|
|
1202
|
+
const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as { version?: string }
|
|
1203
|
+
if (typeof raw.version === 'string') return raw.version
|
|
1041
1204
|
}
|
|
1042
1205
|
} catch {
|
|
1043
1206
|
// best-effort
|
|
1044
1207
|
}
|
|
1045
|
-
return
|
|
1208
|
+
return 'unknown'
|
|
1046
1209
|
}
|
|
1047
1210
|
|
|
1048
1211
|
/**
|
|
@@ -1051,32 +1214,32 @@ function readHubVersion(): string {
|
|
|
1051
1214
|
function buildAddonReply(reply: FastifyReply) {
|
|
1052
1215
|
const wrapper = {
|
|
1053
1216
|
status(code: number) {
|
|
1054
|
-
reply.status(code)
|
|
1055
|
-
return wrapper
|
|
1217
|
+
reply.status(code)
|
|
1218
|
+
return wrapper
|
|
1056
1219
|
},
|
|
1057
1220
|
code(code: number) {
|
|
1058
|
-
reply.code(code)
|
|
1059
|
-
return wrapper
|
|
1221
|
+
reply.code(code)
|
|
1222
|
+
return wrapper
|
|
1060
1223
|
},
|
|
1061
1224
|
send(data: unknown) {
|
|
1062
|
-
reply.send(data)
|
|
1225
|
+
reply.send(data)
|
|
1063
1226
|
},
|
|
1064
1227
|
redirect(url: string) {
|
|
1065
|
-
reply.redirect(url)
|
|
1228
|
+
reply.redirect(url)
|
|
1066
1229
|
},
|
|
1067
1230
|
header(name: string, value: string) {
|
|
1068
|
-
reply.header(name, value)
|
|
1069
|
-
return wrapper
|
|
1231
|
+
reply.header(name, value)
|
|
1232
|
+
return wrapper
|
|
1070
1233
|
},
|
|
1071
1234
|
type(mime: string) {
|
|
1072
|
-
reply.type(mime)
|
|
1073
|
-
return wrapper
|
|
1235
|
+
reply.type(mime)
|
|
1236
|
+
return wrapper
|
|
1074
1237
|
},
|
|
1075
|
-
}
|
|
1076
|
-
return wrapper
|
|
1238
|
+
}
|
|
1239
|
+
return wrapper
|
|
1077
1240
|
}
|
|
1078
1241
|
|
|
1079
1242
|
bootstrap().catch((err) => {
|
|
1080
|
-
console.error(
|
|
1081
|
-
process.exit(1)
|
|
1082
|
-
})
|
|
1243
|
+
console.error('Bootstrap failed:', err)
|
|
1244
|
+
process.exit(1)
|
|
1245
|
+
})
|