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