@camstack/server 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
package/src/main.ts
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
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 "@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
|
+
|
|
16
|
+
// Boot-time capability declaration runs over the auto-generated
|
|
17
|
+
// `ALL_CAPABILITY_DEFINITIONS` array — every `*.cap.ts` file that ships
|
|
18
|
+
// with `@camstack/types` is included automatically. Adding a new cap
|
|
19
|
+
// requires no edit to this file: drop the `*.cap.ts` and re-run
|
|
20
|
+
// `npx tsx scripts/generate-capability-router-types.ts`. The previous
|
|
21
|
+
// hand-curated list silently dropped caps (zones, zone-rules,
|
|
22
|
+
// zone-analytics, audio-metrics — see git for the regression).
|
|
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 {
|
|
39
|
+
createTrpcContext,
|
|
40
|
+
createWsTrpcContext,
|
|
41
|
+
} from "./api/trpc/trpc.context";
|
|
42
|
+
import { registerAddonUploadRoute } from "./api/addon-upload";
|
|
43
|
+
import { registerAuthWhoamiRoute } from "./api/auth-whoami";
|
|
44
|
+
import { buildSessionCookie, clearSessionCookie, SESSION_COOKIE, shouldRedirectToLogin, loginRedirectUrl } from "./auth/session-cookie.js";
|
|
45
|
+
import { registerHealthRoutes } from "./api/health/health.routes";
|
|
46
|
+
import { registerOauth2Routes } from "./api/oauth2/oauth2-routes.js";
|
|
47
|
+
import {
|
|
48
|
+
AddonRouteRegistry,
|
|
49
|
+
} from "@camstack/core";
|
|
50
|
+
import type { FastifyRequest, FastifyReply } from "fastify";
|
|
51
|
+
import { loadBootstrapConfig, setupInfra } from "./boot/boot-config";
|
|
52
|
+
import { bootManual } from "./manual-boot";
|
|
53
|
+
|
|
54
|
+
import { PostBootService } from "./boot/post-boot.service";
|
|
55
|
+
|
|
56
|
+
// ---- Process-level error handlers ----
|
|
57
|
+
|
|
58
|
+
process.on("uncaughtException", (err) => {
|
|
59
|
+
console.error(
|
|
60
|
+
"[uncaughtException] Unhandled exception — server will continue:",
|
|
61
|
+
err,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
process.on("unhandledRejection", (reason) => {
|
|
66
|
+
console.error(
|
|
67
|
+
"[unhandledRejection] Unhandled promise rejection — server will continue:",
|
|
68
|
+
reason,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---- Graceful shutdown ----
|
|
73
|
+
|
|
74
|
+
// Kill all child processes immediately on signal, then let Fastify clean up.
|
|
75
|
+
// This is critical because tsx watch (dev) force-kills the main process after a short
|
|
76
|
+
// timeout — if we wait for Fastify OnModuleDestroy, children become orphans.
|
|
77
|
+
let shutdownStarted = false;
|
|
78
|
+
function immediateChildCleanup(signal: string) {
|
|
79
|
+
if (shutdownStarted) return;
|
|
80
|
+
shutdownStarted = true;
|
|
81
|
+
|
|
82
|
+
console.log(`[shutdown] Received ${signal} — killing child processes immediately…`);
|
|
83
|
+
|
|
84
|
+
// Synchronously kill all children of this process via the OS.
|
|
85
|
+
// This is fast and ensures no orphans even if Fastify shutdown is slow.
|
|
86
|
+
try {
|
|
87
|
+
const myPid = process.pid;
|
|
88
|
+
const isMac = process.platform === "darwin";
|
|
89
|
+
// pkill sends SIGTERM to all processes whose parent is our PID
|
|
90
|
+
const cmd = isMac
|
|
91
|
+
? `pkill -TERM -P ${myPid} 2>/dev/null; true`
|
|
92
|
+
: `kill -- -${myPid} 2>/dev/null; true`;
|
|
93
|
+
execSync(cmd, { timeout: 3000 });
|
|
94
|
+
console.log("[shutdown] Child processes signalled");
|
|
95
|
+
} catch {
|
|
96
|
+
// Best effort — children may have already exited
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Safety: force exit after 10s if Fastify shutdown stalls
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
console.error("[shutdown] Graceful shutdown timed out after 10s — forcing exit");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}, 10_000);
|
|
104
|
+
timer.unref();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.on("SIGTERM", () => immediateChildCleanup("SIGTERM"));
|
|
108
|
+
process.on("SIGINT", () => immediateChildCleanup("SIGINT"));
|
|
109
|
+
|
|
110
|
+
// ---- Orphan cleanup ----
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Kill leftover camstack processes from a previous crash.
|
|
114
|
+
* Targets: coreml_inference.py (Python engines).
|
|
115
|
+
* Only kills processes whose parent is PID 1 (orphaned) to avoid killing children of a live server.
|
|
116
|
+
*/
|
|
117
|
+
function cleanupOrphanProcesses(): void {
|
|
118
|
+
const isMac = process.platform === "darwin";
|
|
119
|
+
const isLinux = process.platform === "linux";
|
|
120
|
+
if (!isMac && !isLinux) return;
|
|
121
|
+
|
|
122
|
+
let killed = 0;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Find orphaned coreml_inference.py processes (ppid=1 means orphaned)
|
|
126
|
+
const cmd = isMac
|
|
127
|
+
? "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";
|
|
129
|
+
|
|
130
|
+
const output = execSync(cmd, { encoding: "utf8", timeout: 5000 }).trim();
|
|
131
|
+
if (!output) return;
|
|
132
|
+
|
|
133
|
+
for (const line of output.split("\n")) {
|
|
134
|
+
const parts = line.trim().split(/\s+/);
|
|
135
|
+
const pid = parseInt(parts[0]!, 10);
|
|
136
|
+
const ppid = parseInt(parts[1]!, 10);
|
|
137
|
+
|
|
138
|
+
// Only kill orphans (ppid=1) — never kill children of a running server
|
|
139
|
+
if (ppid !== 1 || isNaN(pid) || pid === process.pid) continue;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
process.kill(pid, "SIGTERM");
|
|
143
|
+
killed++;
|
|
144
|
+
} catch {
|
|
145
|
+
// Process may have already exited
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// grep returns exit code 1 when no matches — that's fine
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (killed > 0) {
|
|
153
|
+
console.log(`[cleanup] Killed ${killed} orphaned camstack process(es) from a previous run`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- Bootstrap ----
|
|
158
|
+
|
|
159
|
+
async function bootstrap() {
|
|
160
|
+
// Clean up orphaned processes from previous crashes before starting
|
|
161
|
+
cleanupOrphanProcesses();
|
|
162
|
+
|
|
163
|
+
// SPA fallback — set later when admin UI is resolved, used by addon route catch-all
|
|
164
|
+
let spaIndexHtml: string | null = null;
|
|
165
|
+
|
|
166
|
+
// --- Phase 1 + 2: Load config, setup storage locations, TLS ---
|
|
167
|
+
const configPath =
|
|
168
|
+
process.env.CONFIG_PATH ??
|
|
169
|
+
path.join(process.cwd(), "camstack-data", "config.yaml");
|
|
170
|
+
const bootstrapConfig = loadBootstrapConfig(configPath);
|
|
171
|
+
const infra = await setupInfra(configPath, bootstrapConfig);
|
|
172
|
+
|
|
173
|
+
const { dataPath, tlsOptions } = infra;
|
|
174
|
+
const port = infra.bootstrapConfig.server.port;
|
|
175
|
+
const host = infra.bootstrapConfig.server.host;
|
|
176
|
+
|
|
177
|
+
// --- Phase 3: Create app + tRPC register + listen ---
|
|
178
|
+
const fastifyOpts: Record<string, unknown> = tlsOptions
|
|
179
|
+
? { https: tlsOptions }
|
|
180
|
+
: {};
|
|
181
|
+
const app = await bootManual({ infra, fastifyOpts });
|
|
182
|
+
app.enableShutdownHooks();
|
|
183
|
+
app.enableCors();
|
|
184
|
+
|
|
185
|
+
const fastify = app.getHttpAdapter().getInstance();
|
|
186
|
+
await fastify.register(fastifyCookie);
|
|
187
|
+
|
|
188
|
+
// Make LocationManager available to StorageService before lifecycle hooks run
|
|
189
|
+
const storageService = app.get(StorageService);
|
|
190
|
+
storageService.setLocationManager(infra.locationManager);
|
|
191
|
+
|
|
192
|
+
// ConfigService — SettingsStore is wired later by the settings-store capability consumer
|
|
193
|
+
const config = app.get(ConfigService);
|
|
194
|
+
|
|
195
|
+
// Register addon upload route (multipart — must be registered before tRPC).
|
|
196
|
+
// We pass AddonRegistryService so the handler can resolve the
|
|
197
|
+
// `user-management` cap singleton at request time (the only working
|
|
198
|
+
// path to validate `cst_*` scoped tokens — see addon-upload.ts).
|
|
199
|
+
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 uploadLogger = app.get(LoggingService).createLogger("addon-upload");
|
|
205
|
+
await registerAddonUploadRoute(
|
|
206
|
+
fastify,
|
|
207
|
+
uploadAddonBridge,
|
|
208
|
+
uploadAuthService,
|
|
209
|
+
uploadMoleculer,
|
|
210
|
+
uploadAddonRegistry,
|
|
211
|
+
uploadLogger,
|
|
212
|
+
);
|
|
213
|
+
console.log(
|
|
214
|
+
"[bootstrap] Addon upload route registered at POST /api/addons/upload",
|
|
215
|
+
);
|
|
216
|
+
// Companion endpoint: /api/auth/whoami — validates JWT or cst_*
|
|
217
|
+
// scoped tokens, returns the resolved identity + scope summary.
|
|
218
|
+
// Mirrors the addon-upload auth chain so the CLI can ping for
|
|
219
|
+
// token-still-valid without consuming a real cap.
|
|
220
|
+
await registerAuthWhoamiRoute(
|
|
221
|
+
fastify,
|
|
222
|
+
uploadAuthService,
|
|
223
|
+
uploadAddonRegistry,
|
|
224
|
+
);
|
|
225
|
+
console.log(
|
|
226
|
+
"[bootstrap] Auth whoami route registered at GET /api/auth/whoami",
|
|
227
|
+
);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.warn("[bootstrap] Failed to register addon upload route:", err);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Register tRPC plugin on Fastify BEFORE listen.
|
|
233
|
+
// If registration fails, start in degraded mode: serve a health warning endpoint.
|
|
234
|
+
// Instantiate new core services
|
|
235
|
+
const loggingService = app.get(LoggingService);
|
|
236
|
+
const addonRouteRegistry = new AddonRouteRegistry();
|
|
237
|
+
|
|
238
|
+
// Use Fastify-managed notification/toast wrappers (globally provided by NotificationModule)
|
|
239
|
+
const { NotificationServiceWrapper } =
|
|
240
|
+
await import("./core/notification/notification-wrapper.service");
|
|
241
|
+
const { ToastServiceWrapper } =
|
|
242
|
+
await import("./core/notification/toast-wrapper.service");
|
|
243
|
+
const notificationWrapper = app.get(NotificationServiceWrapper);
|
|
244
|
+
const toastWrapper = app.get(ToastServiceWrapper);
|
|
245
|
+
// Expose the underlying core services for the tRPC router
|
|
246
|
+
const notificationService = notificationWrapper.service;
|
|
247
|
+
const toastService = toastWrapper.service;
|
|
248
|
+
|
|
249
|
+
// Wire AddonRouteRegistry and NotificationService
|
|
250
|
+
const addonRegistry = app.get(AddonRegistryService);
|
|
251
|
+
addonRegistry.setAddonRouteRegistry(addonRouteRegistry);
|
|
252
|
+
|
|
253
|
+
// ── Configure the CapabilityRegistry (created in AddonRegistryService constructor) ──
|
|
254
|
+
const capabilityRegistry = addonRegistry.getCapabilityRegistry();
|
|
255
|
+
capabilityRegistry.setConfigManager(config);
|
|
256
|
+
|
|
257
|
+
// Declare every shipped capability BEFORE app.init() so addon
|
|
258
|
+
// registerProvider() calls (in-process or via the Moleculer bridge
|
|
259
|
+
// from forked workers / agents) find their definition during
|
|
260
|
+
// onModuleInit. The list comes from the codegen — see the import
|
|
261
|
+
// comment above for the rationale.
|
|
262
|
+
for (const capDef of ALL_CAPABILITY_DEFINITIONS) {
|
|
263
|
+
capabilityRegistry.declareCapability(capDef);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Hub-internal `sso-bridge` provider — gives auth-provider addons
|
|
267
|
+
// (OIDC, SAML, magic-link, …) a typed gateway to mint an HMAC-signed
|
|
268
|
+
// bridge token before redirecting to `/api/auth/sso/finish`. Without
|
|
269
|
+
// this, the finish endpoint would have to trust unsigned query params
|
|
270
|
+
// (anyone could craft `?isAdmin=1`). Backed by AuthService.
|
|
271
|
+
const authServiceForBridge = app.get(AuthService);
|
|
272
|
+
capabilityRegistry.registerProvider('sso-bridge', '$hub', {
|
|
273
|
+
signBridgeToken: async ({ claims, ttlSec }: { claims: { userId: string; username: string; isAdmin: boolean; provider: string; email?: string; displayName?: string }; ttlSec?: number }) => {
|
|
274
|
+
const token = authServiceForBridge.signSsoBridgeToken(claims, ttlSec ?? 300);
|
|
275
|
+
return { token };
|
|
276
|
+
},
|
|
277
|
+
verifyBridgeToken: async ({ token }: { token: string }) => {
|
|
278
|
+
return authServiceForBridge.verifySsoBridgeToken(token);
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Wire registry into NotificationService for proxy-based output resolution
|
|
283
|
+
notificationService.setRegistry(capabilityRegistry);
|
|
284
|
+
|
|
285
|
+
// Hub metrics and sub-process info now come from Moleculer service discovery
|
|
286
|
+
// and the distributed metrics-provider capability — no manual wiring needed.
|
|
287
|
+
|
|
288
|
+
let trpcRegistered = false;
|
|
289
|
+
let appRouter: ReturnType<typeof buildAppRouter> | null = null;
|
|
290
|
+
|
|
291
|
+
// Run app.init() FIRST so onModuleInit hooks complete (PipelineWiring, AddonBridge, etc.)
|
|
292
|
+
// before we build the tRPC router which depends on their results.
|
|
293
|
+
await app.init();
|
|
294
|
+
console.log("[bootstrap] app.init() complete — all onModuleInit hooks have run");
|
|
295
|
+
|
|
296
|
+
// Mark registry as ready — providers from app.init() are now registered
|
|
297
|
+
capabilityRegistry.ready();
|
|
298
|
+
|
|
299
|
+
// Register log-receiver service for agent log forwarding (after app.init
|
|
300
|
+
// so Moleculer re-advertises the service list to the network)
|
|
301
|
+
const moleculer = app.get(MoleculerService);
|
|
302
|
+
moleculer.registerLogReceiver();
|
|
303
|
+
|
|
304
|
+
// ── Health routes (hub self + agent forwarding) ──────────────────
|
|
305
|
+
// Registered after app.init() so the AgentRegistryService is wired and
|
|
306
|
+
// the Moleculer broker is ready to forward `$agent.health` calls.
|
|
307
|
+
try {
|
|
308
|
+
const hubVersion = readHubVersion();
|
|
309
|
+
registerHealthRoutes(fastify, {
|
|
310
|
+
moleculer,
|
|
311
|
+
agentRegistry: app.get(AgentRegistryService),
|
|
312
|
+
hubVersion,
|
|
313
|
+
});
|
|
314
|
+
console.log(`[bootstrap] Health routes registered (hub v${hubVersion})`);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
console.warn("[bootstrap] Failed to register health routes:", err);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Seed the device-name cache the log formatter consults so logs
|
|
320
|
+
// emitted before the first DeviceRegistered already resolve names.
|
|
321
|
+
// Subsequent registers/unregisters flow through the event-bus
|
|
322
|
+
// subscription installed in `LoggingService.attachDeviceNameStream`.
|
|
323
|
+
try {
|
|
324
|
+
const dm = capabilityRegistry.getSingleton('device-manager') as {
|
|
325
|
+
listAll?: (input: { addonId?: string }) => Promise<readonly { id: number; name: string }[]>
|
|
326
|
+
} | null
|
|
327
|
+
if (dm?.listAll) {
|
|
328
|
+
const devices = await dm.listAll({})
|
|
329
|
+
loggingService.setDeviceNames(devices.map((d) => ({ id: d.id, name: d.name })))
|
|
330
|
+
loggingService.createLogger('logging').info('device-name cache seeded from device-manager', {
|
|
331
|
+
meta: { count: devices.length, ids: devices.map((d) => d.id) },
|
|
332
|
+
})
|
|
333
|
+
} else {
|
|
334
|
+
loggingService.createLogger('logging').warn('device-name cache seed skipped — device-manager not available')
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.warn('[bootstrap] device-name cache seed skipped:', err instanceof Error ? err.message : err)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const authService = app.get(AuthService);
|
|
342
|
+
|
|
343
|
+
appRouter = buildAppRouter({
|
|
344
|
+
authService,
|
|
345
|
+
configService: config,
|
|
346
|
+
featureService: app.get(FeatureService),
|
|
347
|
+
|
|
348
|
+
loggingService,
|
|
349
|
+
eventBus: app.get(EventBusService),
|
|
350
|
+
agentRegistry: app.get(AgentRegistryService),
|
|
351
|
+
moleculer: app.get(MoleculerService),
|
|
352
|
+
addonRegistry,
|
|
353
|
+
replEngine: app.get(ReplEngineService),
|
|
354
|
+
networkQualityService: app.get(NetworkQualityService),
|
|
355
|
+
addonBridge: app.get(AddonBridgeService),
|
|
356
|
+
addonPackageService: app.get(AddonPackageService),
|
|
357
|
+
notificationService,
|
|
358
|
+
toastService,
|
|
359
|
+
capabilityRegistry,
|
|
360
|
+
streamProbe: app.get(StreamProbeService),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
await fastify.register(fastifyTRPCPlugin, {
|
|
364
|
+
prefix: "/trpc",
|
|
365
|
+
trpcOptions: {
|
|
366
|
+
router: appRouter,
|
|
367
|
+
createContext: ({ req }: { req: FastifyRequest }) =>
|
|
368
|
+
createTrpcContext(req, authService, addonRegistry),
|
|
369
|
+
onError: ({ path, error }: { path: string | undefined; error: { code: string; message: string; cause?: unknown } }) => {
|
|
370
|
+
const trpcLogger = app.get(LoggingService).createLogger("tRPC");
|
|
371
|
+
trpcLogger.warn('tRPC error', { meta: { code: error.code, path: path ?? '?', message: error.message } });
|
|
372
|
+
if (error.cause) trpcLogger.warn('tRPC error cause', { meta: { cause: error.cause instanceof Error ? error.cause.message : JSON.stringify(error.cause) } });
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
trpcRegistered = true;
|
|
377
|
+
|
|
378
|
+
// Mount the `$core-caps` Moleculer service so forked addons /
|
|
379
|
+
// remote agents can reach the hub's core (non-addon) tRPC routers
|
|
380
|
+
// through `ctx.api.<coreCap>`. Without it those calls hang in
|
|
381
|
+
// `brokerTransportLink`'s unbounded discovery wait.
|
|
382
|
+
try {
|
|
383
|
+
moleculer.registerCoreCapService(buildCoreCapService(appRouter));
|
|
384
|
+
console.log("[bootstrap] core-cap mesh bridge registered ($core-caps)");
|
|
385
|
+
} catch (err) {
|
|
386
|
+
console.warn("[bootstrap] Failed to register core-cap mesh bridge:", err);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Register addon-pages static file endpoint
|
|
390
|
+
const addonPagesService = app.get(AddonPagesService);
|
|
391
|
+
fastify.get<{ Params: { addonId: string; "*": string } }>(
|
|
392
|
+
"/api/addon-pages/:addonId/*",
|
|
393
|
+
async (request, reply) => {
|
|
394
|
+
const { addonId } = request.params;
|
|
395
|
+
const filePath = request.params["*"];
|
|
396
|
+
if (!filePath) {
|
|
397
|
+
return reply.status(400).send("Missing file path");
|
|
398
|
+
}
|
|
399
|
+
const resolved = addonPagesService.resolveBundle(addonId, filePath);
|
|
400
|
+
if (!resolved) {
|
|
401
|
+
return reply.status(404).send("Not found");
|
|
402
|
+
}
|
|
403
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
404
|
+
const contentType =
|
|
405
|
+
ext === ".js" || ext === ".mjs"
|
|
406
|
+
? "application/javascript"
|
|
407
|
+
: ext === ".css"
|
|
408
|
+
? "text/css"
|
|
409
|
+
: ext === ".json"
|
|
410
|
+
? "application/json"
|
|
411
|
+
: ext === ".html"
|
|
412
|
+
? "text/html"
|
|
413
|
+
: "application/octet-stream";
|
|
414
|
+
const stream = fs.createReadStream(resolved);
|
|
415
|
+
return reply.type(contentType).send(stream);
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
// Register addon-widgets static file endpoint — same shape as
|
|
420
|
+
// addon-pages but reads bundles from each addon's
|
|
421
|
+
// `dist/widgets.mjs` (declared per-widget in `addon-widgets-source`
|
|
422
|
+
// metadata). Path-traversal protection + cap-registration check
|
|
423
|
+
// both live in `AddonWidgetsService.resolveBundle`.
|
|
424
|
+
const addonWidgetsService = app.get(AddonWidgetsService);
|
|
425
|
+
fastify.get<{ Params: { addonId: string; "*": string } }>(
|
|
426
|
+
"/api/addon-widgets/:addonId/*",
|
|
427
|
+
async (request, reply) => {
|
|
428
|
+
const { addonId } = request.params;
|
|
429
|
+
const filePath = request.params["*"];
|
|
430
|
+
if (!filePath) {
|
|
431
|
+
return reply.status(400).send("Missing file path");
|
|
432
|
+
}
|
|
433
|
+
const resolved = addonWidgetsService.resolveBundle(addonId, filePath);
|
|
434
|
+
if (!resolved) {
|
|
435
|
+
return reply.status(404).send("Not found");
|
|
436
|
+
}
|
|
437
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
438
|
+
const contentType =
|
|
439
|
+
ext === ".js" || ext === ".mjs"
|
|
440
|
+
? "application/javascript"
|
|
441
|
+
: ext === ".css"
|
|
442
|
+
? "text/css"
|
|
443
|
+
: ext === ".json"
|
|
444
|
+
? "application/json"
|
|
445
|
+
: ext === ".html"
|
|
446
|
+
? "text/html"
|
|
447
|
+
: "application/octet-stream";
|
|
448
|
+
const stream = fs.createReadStream(resolved);
|
|
449
|
+
return reply.type(contentType).send(stream);
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// Serve static assets (e.g. SVG icons) from an addon's package directory
|
|
454
|
+
fastify.get<{ Params: { addonId: string; "*": string } }>(
|
|
455
|
+
"/api/addon-assets/:addonId/*",
|
|
456
|
+
async (request, reply) => {
|
|
457
|
+
const { addonId } = request.params;
|
|
458
|
+
const assetPath = request.params["*"] ?? "";
|
|
459
|
+
|
|
460
|
+
const packageDir = addonRegistry.getAddonPackageDir(addonId);
|
|
461
|
+
if (!packageDir) {
|
|
462
|
+
return reply.code(404).send({ error: "Addon not found" });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const resolvedPackageDir = path.resolve(packageDir);
|
|
466
|
+
const filePath = path.resolve(resolvedPackageDir, assetPath);
|
|
467
|
+
|
|
468
|
+
// Security: prevent path traversal attacks
|
|
469
|
+
if (
|
|
470
|
+
!filePath.startsWith(resolvedPackageDir + path.sep) &&
|
|
471
|
+
filePath !== resolvedPackageDir
|
|
472
|
+
) {
|
|
473
|
+
return reply.code(403).send({ error: "Access denied" });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!fs.existsSync(filePath)) {
|
|
477
|
+
return reply.code(404).send({ error: "Asset not found" });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
481
|
+
const contentTypes: Record<string, string> = {
|
|
482
|
+
".svg": "image/svg+xml",
|
|
483
|
+
".png": "image/png",
|
|
484
|
+
".jpg": "image/jpeg",
|
|
485
|
+
".jpeg": "image/jpeg",
|
|
486
|
+
".json": "application/json",
|
|
487
|
+
".webp": "image/webp",
|
|
488
|
+
};
|
|
489
|
+
const contentType = contentTypes[ext] ?? "application/octet-stream";
|
|
490
|
+
|
|
491
|
+
reply.header("content-type", contentType);
|
|
492
|
+
reply.header("cache-control", "public, max-age=86400");
|
|
493
|
+
return reply.send(fs.readFileSync(filePath));
|
|
494
|
+
},
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
// ── SSO finish endpoint ─────────────────────────────────────────
|
|
498
|
+
// OIDC / SAML / external auth providers redirect here after their
|
|
499
|
+
// own callback handler validated the IdP response. We trust the
|
|
500
|
+
// claims because:
|
|
501
|
+
// 1. The path is server-internal — providers run in-process and
|
|
502
|
+
// issue the redirect with their own validated state.
|
|
503
|
+
// 2. The query string is consumed once and immediately swapped
|
|
504
|
+
// for a JWT in the URL fragment (#token=...) which never hits
|
|
505
|
+
// the server logs and isn't replayable.
|
|
506
|
+
//
|
|
507
|
+
// The provider prefixes the redirect with `provider=auth-oidc` so
|
|
508
|
+
// we can attribute the session. The mint is currently best-effort
|
|
509
|
+
// — a future hardening should require the provider to also pass a
|
|
510
|
+
// nonce signed with the auth.jwtSecret so we can verify the redirect
|
|
511
|
+
// came from the addon's own callback handler.
|
|
512
|
+
fastify.get<{
|
|
513
|
+
Querystring: {
|
|
514
|
+
bridge?: string
|
|
515
|
+
}
|
|
516
|
+
}>(
|
|
517
|
+
"/api/auth/sso/finish",
|
|
518
|
+
async (request, reply) => {
|
|
519
|
+
// The auth-provider addon (OIDC, SAML, …) mints an HMAC-signed
|
|
520
|
+
// bridge token via the `sso-bridge` cap and 302s to here with
|
|
521
|
+
// `?bridge=<jwt>`. We verify the signature with the same JWT
|
|
522
|
+
// secret used elsewhere — only then do we trust the claims
|
|
523
|
+
// (including the `isAdmin` flag). This closes the prior gap
|
|
524
|
+
// where any client could call `?isAdmin=1` and become admin.
|
|
525
|
+
const bridge = request.query.bridge
|
|
526
|
+
if (!bridge) {
|
|
527
|
+
return reply.status(400).send({ error: "Missing bridge token" })
|
|
528
|
+
}
|
|
529
|
+
const ssoAuth = app.get(AuthService)
|
|
530
|
+
const claims = ssoAuth.verifySsoBridgeToken(bridge)
|
|
531
|
+
if (!claims) {
|
|
532
|
+
return reply.status(401).send({ error: "Invalid or expired bridge token" })
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
try {
|
|
536
|
+
const token = ssoAuth.signToken({
|
|
537
|
+
userId: claims.userId,
|
|
538
|
+
username: claims.username,
|
|
539
|
+
isAdmin: claims.isAdmin,
|
|
540
|
+
allowedProviders: '*',
|
|
541
|
+
allowedDevices: {},
|
|
542
|
+
})
|
|
543
|
+
// Redirect to the admin UI with the token in the URL fragment.
|
|
544
|
+
// Fragments don't hit the server log + don't get sent on
|
|
545
|
+
// subsequent requests — the SPA's auth-context picks the
|
|
546
|
+
// token up on mount and stores it in localStorage.
|
|
547
|
+
reply.code(302)
|
|
548
|
+
reply.header(
|
|
549
|
+
"Location",
|
|
550
|
+
`/admin/login#token=${encodeURIComponent(token)}&provider=${encodeURIComponent(claims.provider)}`,
|
|
551
|
+
)
|
|
552
|
+
return reply.send("")
|
|
553
|
+
} catch (err) {
|
|
554
|
+
return reply.status(500).send({
|
|
555
|
+
error: "SSO finish failed",
|
|
556
|
+
message: err instanceof Error ? err.message : String(err),
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
// ── Backup archive download (Phase 4 / Task 24) ────────────────
|
|
563
|
+
// Streams an archive at `<locationId>/<archiveId>` through the
|
|
564
|
+
// storage cap's chunked-download protocol straight to an HTTP
|
|
565
|
+
// response. The admin UI uses an authenticated `fetch` + Blob to
|
|
566
|
+
// fetch and trigger a save dialog (no cookie auth on this server,
|
|
567
|
+
// so we can't rely on `window.location.assign`).
|
|
568
|
+
fastify.get<{ Params: { locationId: string; archiveId: string } }>(
|
|
569
|
+
"/api/backup/download/:locationId/:archiveId",
|
|
570
|
+
async (request, reply) => {
|
|
571
|
+
const authHeader = request.headers.authorization;
|
|
572
|
+
if (!authHeader) {
|
|
573
|
+
return reply.status(401).send({ error: "Unauthorized" });
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
const token = authHeader.replace("Bearer ", "");
|
|
577
|
+
const downloadAuth = app.get(AuthService);
|
|
578
|
+
const payload = downloadAuth.verifyToken(token);
|
|
579
|
+
if (!payload.isAdmin) {
|
|
580
|
+
return reply.status(403).send({ error: "Admin required" });
|
|
581
|
+
}
|
|
582
|
+
} catch {
|
|
583
|
+
return reply.status(401).send({ error: "Invalid token" });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const { locationId, archiveId } = request.params;
|
|
587
|
+
const backupSingleton = capabilityRegistry.getSingleton<{
|
|
588
|
+
listArchives: (input: { destinationId: string }) => Promise<readonly { id: string; filename: string; sizeBytes: number; label?: string }[]>
|
|
589
|
+
}>("backup");
|
|
590
|
+
if (!backupSingleton?.listArchives) {
|
|
591
|
+
return reply.status(503).send({ error: "Backup orchestrator unavailable" });
|
|
592
|
+
}
|
|
593
|
+
const archives = await backupSingleton.listArchives({ destinationId: locationId });
|
|
594
|
+
const archive = archives.find((a) => a.id === archiveId);
|
|
595
|
+
if (!archive) {
|
|
596
|
+
return reply.status(404).send({ error: "Archive not found" });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const storage = capabilityRegistry.getSingleton<{
|
|
600
|
+
beginDownload: (input: { location: string; relativePath: string }) => Promise<{ downloadId: string; sizeBytes: number }>
|
|
601
|
+
readChunk: (input: { downloadId: string; offset: number; length: number }) => Promise<Uint8Array>
|
|
602
|
+
endDownload: (input: { downloadId: string }) => Promise<void>
|
|
603
|
+
}>("storage");
|
|
604
|
+
if (!storage?.beginDownload) {
|
|
605
|
+
return reply.status(503).send({ error: "Storage cap unavailable" });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const downloadName = `${archive.label ?? archive.id}.tar.gz`;
|
|
609
|
+
// Sanitize: strip newlines and double-quotes which would break
|
|
610
|
+
// the Content-Disposition header. Whitespace is fine.
|
|
611
|
+
const safeName = downloadName.replace(/[\r\n"]/g, "_");
|
|
612
|
+
reply.header("content-type", "application/gzip");
|
|
613
|
+
reply.header("content-length", String(archive.sizeBytes));
|
|
614
|
+
reply.header(
|
|
615
|
+
"content-disposition",
|
|
616
|
+
`attachment; filename="${safeName}"`,
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
const { downloadId, sizeBytes } = await storage.beginDownload({
|
|
620
|
+
location: locationId,
|
|
621
|
+
relativePath: archive.filename,
|
|
622
|
+
});
|
|
623
|
+
const CHUNK = 8 * 1024 * 1024;
|
|
624
|
+
try {
|
|
625
|
+
let offset = 0;
|
|
626
|
+
// Stream the chunked response. Fastify's `reply.raw` is the
|
|
627
|
+
// Node.js writable; we write each chunk and `end()` once
|
|
628
|
+
// we've drained the source. Per-write back-pressure is
|
|
629
|
+
// handled inside the runtime — buffered writes pile up but
|
|
630
|
+
// never beyond the OS socket buffer.
|
|
631
|
+
while (offset < sizeBytes) {
|
|
632
|
+
const len = Math.min(CHUNK, sizeBytes - offset);
|
|
633
|
+
const chunk = await storage.readChunk({ downloadId, offset, length: len });
|
|
634
|
+
if (chunk.byteLength === 0) {
|
|
635
|
+
throw new Error(
|
|
636
|
+
`backup download: empty chunk at offset ${offset}/${sizeBytes}`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
reply.raw.write(chunk);
|
|
640
|
+
offset += chunk.byteLength;
|
|
641
|
+
}
|
|
642
|
+
reply.raw.end();
|
|
643
|
+
} catch (err) {
|
|
644
|
+
console.error("[backup-download] stream failed:", err);
|
|
645
|
+
// Headers may already be flushed — abort the socket if so.
|
|
646
|
+
if (!reply.raw.headersSent) {
|
|
647
|
+
return reply.status(500).send({ error: "Download failed" });
|
|
648
|
+
}
|
|
649
|
+
reply.raw.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
650
|
+
} finally {
|
|
651
|
+
try {
|
|
652
|
+
await storage.endDownload({ downloadId });
|
|
653
|
+
} catch {
|
|
654
|
+
/* best-effort */
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return reply;
|
|
658
|
+
},
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
// POST /api/auth/session — upgrade a tRPC-issued JWT to a browser cookie.
|
|
662
|
+
fastify.post<{ Body: { token?: string } }>('/api/auth/session', async (request, reply) => {
|
|
663
|
+
const token = request.body?.token
|
|
664
|
+
if (!token) return reply.status(400).send({ error: 'token required' })
|
|
665
|
+
let ttlSec: number
|
|
666
|
+
try {
|
|
667
|
+
const payload = authService.verifyToken(token) // throws on invalid/expired
|
|
668
|
+
const expSec = typeof payload.exp === 'number' ? payload.exp : 0
|
|
669
|
+
ttlSec = Math.max(0, expSec - Math.floor(Date.now() / 1000))
|
|
670
|
+
} catch {
|
|
671
|
+
return reply.status(401).send({ error: 'invalid token' })
|
|
672
|
+
}
|
|
673
|
+
const c = buildSessionCookie(token, ttlSec)
|
|
674
|
+
reply.setCookie(c.name, c.value, c.options)
|
|
675
|
+
return reply.send({ ok: true })
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
// DELETE /api/auth/session — clear the cookie on logout.
|
|
679
|
+
fastify.delete('/api/auth/session', async (_request, reply) => {
|
|
680
|
+
const c = clearSessionCookie()
|
|
681
|
+
reply.setCookie(c.name, c.value, c.options)
|
|
682
|
+
return reply.send({ ok: true })
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
// Addon HTTP API route catch-all: /addon/:addonId/*
|
|
686
|
+
// Only handles non-GET or routes that actually exist in the addon route registry.
|
|
687
|
+
// GET requests that don't match an addon route are SPA pages — handled by the /* fallback.
|
|
688
|
+
fastify.all<{
|
|
689
|
+
Params: { addonId: string; "*": string }
|
|
690
|
+
Querystring: Record<string, string>
|
|
691
|
+
Headers: Record<string, string>
|
|
692
|
+
}>("/addon/:addonId/*", async (request, reply) => {
|
|
693
|
+
const { addonId } = request.params;
|
|
694
|
+
const subPath = request.params["*"] ?? "";
|
|
695
|
+
const method = request.method;
|
|
696
|
+
const fullPath = `/addon/${addonId}/${subPath}`;
|
|
697
|
+
const query: Record<string, string> = request.query ?? {};
|
|
698
|
+
const headers: Record<string, string> = {};
|
|
699
|
+
for (const [k, v] of Object.entries(request.headers)) {
|
|
700
|
+
if (typeof v === "string") headers[k] = v;
|
|
701
|
+
else if (Array.isArray(v)) headers[k] = v.join(",");
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const match = addonRouteRegistry.matchRoute(method, fullPath);
|
|
705
|
+
if (!match) {
|
|
706
|
+
if (method === "GET" && spaIndexHtml) {
|
|
707
|
+
return reply.type("text/html").send(fs.createReadStream(spaIndexHtml));
|
|
708
|
+
}
|
|
709
|
+
return reply.status(404).send({ error: "Not found" });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Auth check based on route.access
|
|
713
|
+
if (match.route.access !== "public") {
|
|
714
|
+
const authHeader = request.headers.authorization;
|
|
715
|
+
// Browser navigation has no Authorization header — fall back to the
|
|
716
|
+
// session cookie, and if that is missing bounce to the login page.
|
|
717
|
+
const cookieToken = request.cookies?.[SESSION_COOKIE];
|
|
718
|
+
if (!authHeader && !cookieToken) {
|
|
719
|
+
if (shouldRedirectToLogin(request.method, request.headers.accept)) {
|
|
720
|
+
const qs = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '';
|
|
721
|
+
return reply.redirect(loginRedirectUrl(fullPath + qs));
|
|
722
|
+
}
|
|
723
|
+
return reply.status(401).send({ error: "Unauthorized" });
|
|
724
|
+
}
|
|
725
|
+
const token = authHeader ? authHeader.replace("Bearer ", "") : cookieToken!;
|
|
726
|
+
if (token.startsWith("cst_")) {
|
|
727
|
+
const userMgmt = capabilityRegistry?.getSingleton('user-management');
|
|
728
|
+
if (!userMgmt) return reply.status(503).send({ error: "User management not available" });
|
|
729
|
+
const scopedToken = await userMgmt.validateScopedToken({ token });
|
|
730
|
+
if (!scopedToken) {
|
|
731
|
+
return reply.status(401).send({ error: "Invalid token" });
|
|
732
|
+
}
|
|
733
|
+
// v2 model: scoped tokens grant by cap-category/cap-name/addon.
|
|
734
|
+
// For addon-route REST endpoints we match the `addon` scope
|
|
735
|
+
// type only — generic route-prefix scopes were dropped with
|
|
736
|
+
// the caps-only refactor.
|
|
737
|
+
const scopeMatch = scopedToken.scopes.some((scope) => {
|
|
738
|
+
return scope.type === 'addon' && scope.target === addonId;
|
|
739
|
+
});
|
|
740
|
+
if (!scopeMatch) {
|
|
741
|
+
return reply.status(403).send({ error: "Token scope mismatch" });
|
|
742
|
+
}
|
|
743
|
+
// Build addon request with scoped token context
|
|
744
|
+
const addonRequest = {
|
|
745
|
+
params: match.params,
|
|
746
|
+
query,
|
|
747
|
+
body: request.body,
|
|
748
|
+
headers,
|
|
749
|
+
scopedToken: {
|
|
750
|
+
id: scopedToken.id,
|
|
751
|
+
userId: scopedToken.userId,
|
|
752
|
+
scopes: scopedToken.scopes,
|
|
753
|
+
},
|
|
754
|
+
};
|
|
755
|
+
const addonReply = buildAddonReply(reply);
|
|
756
|
+
return match.route.handler(addonRequest, addonReply);
|
|
757
|
+
} else {
|
|
758
|
+
try {
|
|
759
|
+
const payload = authService.verifyToken(token);
|
|
760
|
+
if (match.route.access === "admin" && !payload.isAdmin) {
|
|
761
|
+
return reply.status(403).send({ error: "Admin required" });
|
|
762
|
+
}
|
|
763
|
+
const addonRequest = {
|
|
764
|
+
params: match.params,
|
|
765
|
+
query,
|
|
766
|
+
body: request.body,
|
|
767
|
+
headers,
|
|
768
|
+
user: {
|
|
769
|
+
id: payload.userId ?? "unknown",
|
|
770
|
+
username: payload.username ?? "unknown",
|
|
771
|
+
isAdmin: payload.isAdmin,
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
const addonReply = buildAddonReply(reply);
|
|
775
|
+
return match.route.handler(addonRequest, addonReply);
|
|
776
|
+
} catch {
|
|
777
|
+
return reply.status(401).send({ error: "Invalid token" });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Public route — no auth required
|
|
783
|
+
const addonRequest = {
|
|
784
|
+
params: match.params,
|
|
785
|
+
query,
|
|
786
|
+
body: request.body,
|
|
787
|
+
headers,
|
|
788
|
+
};
|
|
789
|
+
const addonReply = buildAddonReply(reply);
|
|
790
|
+
return match.route.handler(addonRequest, addonReply);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// ── OAuth2 authorization endpoints ─────────────────────────────────────
|
|
794
|
+
// Mounted under /api/oauth2/* so they sit inside the universal "/api/"
|
|
795
|
+
// namespace already excluded from the SPA catch-all everywhere.
|
|
796
|
+
// getRegistry is a closure so it always resolves the live registry at
|
|
797
|
+
// request time (safe even if called before ready()).
|
|
798
|
+
registerOauth2Routes(fastify, {
|
|
799
|
+
getRegistry: () => capabilityRegistry,
|
|
800
|
+
verifyToken: (t) => authService.verifyToken(t),
|
|
801
|
+
publicHubUrl: () => process.env.CAMSTACK_PUBLIC_ORIGIN ?? `https://localhost:${port}`,
|
|
802
|
+
});
|
|
803
|
+
console.log('[bootstrap] OAuth2 routes registered at /api/oauth2/*');
|
|
804
|
+
|
|
805
|
+
// Attach tRPC WebSocket handler using noServer mode to avoid
|
|
806
|
+
// Fastify intercepting the upgrade request with a 400 response.
|
|
807
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
808
|
+
applyWSSHandler({
|
|
809
|
+
wss,
|
|
810
|
+
router: appRouter,
|
|
811
|
+
createContext: (opts: CreateWSSContextFnOptions) => createWsTrpcContext(opts, authService, addonRegistry),
|
|
812
|
+
onError: ({ path, error }: { path: string | undefined; error: { code: string; message: string; cause?: unknown } }) => {
|
|
813
|
+
const trpcLogger = app.get(LoggingService).createLogger("tRPC:ws");
|
|
814
|
+
trpcLogger.warn('tRPC error', { meta: { code: error.code, path: path ?? '?', message: error.message } });
|
|
815
|
+
if (error.cause) trpcLogger.warn('tRPC error cause', { meta: { cause: error.cause instanceof Error ? error.cause.message : JSON.stringify(error.cause) } });
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// Manually handle HTTP upgrade for /trpc path.
|
|
820
|
+
// Must use 'upgrade' on the raw Node HTTP server BEFORE Fastify processes it.
|
|
821
|
+
const httpServer = fastify.server;
|
|
822
|
+
// Remove any existing upgrade listeners that might conflict
|
|
823
|
+
const existingUpgradeListeners = httpServer.listeners("upgrade");
|
|
824
|
+
httpServer.removeAllListeners("upgrade");
|
|
825
|
+
|
|
826
|
+
httpServer.on("upgrade", (request, socket, head) => {
|
|
827
|
+
const pathname = (request.url ?? "").split("?")[0];
|
|
828
|
+
if (pathname === "/trpc") {
|
|
829
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
830
|
+
wss.emit("connection", ws, request);
|
|
831
|
+
});
|
|
832
|
+
} else {
|
|
833
|
+
// Re-emit for other upgrade handlers (agent WS, etc.)
|
|
834
|
+
// `EventEmitter.listeners()` returns `Function[]` with no call
|
|
835
|
+
// signature — use Reflect.apply to invoke without a cast.
|
|
836
|
+
for (const listener of existingUpgradeListeners) {
|
|
837
|
+
Reflect.apply(listener, httpServer, [request, socket, head]);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
} catch (err) {
|
|
842
|
+
console.error(
|
|
843
|
+
"[bootstrap] tRPC registration failed — starting in degraded mode:",
|
|
844
|
+
err,
|
|
845
|
+
);
|
|
846
|
+
// Register a fallback health endpoint that signals degraded state
|
|
847
|
+
fastify.get("/trpc/health", async (_req: FastifyRequest, reply: FastifyReply) => {
|
|
848
|
+
reply.status(503).send({
|
|
849
|
+
status: "degraded",
|
|
850
|
+
message: "tRPC router failed to initialize. Check server logs.",
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Wire the app router into AddonRegistry so addons get context.api
|
|
856
|
+
if (appRouter) {
|
|
857
|
+
await addonRegistry.setAppRouter(appRouter);
|
|
858
|
+
console.log("[bootstrap] AddonRegistry wired with tRPC direct caller");
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ScopedTokenManager and admin user creation handled by local-auth addon.
|
|
862
|
+
|
|
863
|
+
// Serve admin UI static files from the admin-ui singleton capability.
|
|
864
|
+
// Always enabled — in dev mode Vite runs on its own port and doesn't interfere.
|
|
865
|
+
//
|
|
866
|
+
// The admin-ui addon runs in its own dedicated runner subprocess and
|
|
867
|
+
// finishes registering 1-3s after bootstrap; poll briefly for it
|
|
868
|
+
// before giving up to avoid the 'admin-ui capability not registered —
|
|
869
|
+
// no static file serving' warn that left the SPA unserved until next
|
|
870
|
+
// restart.
|
|
871
|
+
try {
|
|
872
|
+
const addonRegistry = app.get(AddonRegistryService);
|
|
873
|
+
const capRegistry = addonRegistry.getCapabilityRegistry();
|
|
874
|
+
// The admin-ui addon runs in its own dedicated runner subprocess
|
|
875
|
+
// (one addon, one process — base-layer D2), so `getSingleton`
|
|
876
|
+
// returns a RemoteProxy whose method calls cross the broker — every
|
|
877
|
+
// call is async. The canonical cap shape returns `{staticDir}`/
|
|
878
|
+
// `{version}` objects so plain (in-process) and remote-proxied
|
|
879
|
+
// providers share the same Promise<object> signature.
|
|
880
|
+
type AdminUI = {
|
|
881
|
+
getStaticDir(): Promise<{ readonly staticDir: string }>;
|
|
882
|
+
getVersion(): Promise<{ readonly version: string }>;
|
|
883
|
+
};
|
|
884
|
+
let adminUI = capRegistry?.getSingleton<AdminUI>("admin-ui");
|
|
885
|
+
// CAMSTACK_SKIP_ADMIN_UI_WAIT — bypass the 60s poll. Used by the
|
|
886
|
+
// e2e harness, which doesn't need the SPA served and spawns hubs
|
|
887
|
+
// with strict boot timeouts. Production keeps the poll so cold
|
|
888
|
+
// boots wait for the forked admin-ui group to register.
|
|
889
|
+
const skipAdminUIWait = process.env['CAMSTACK_SKIP_ADMIN_UI_WAIT'] === '1';
|
|
890
|
+
if (!adminUI && capRegistry && !skipAdminUIWait) {
|
|
891
|
+
// Forked-addon spawn + Moleculer registration + tRPC hydration
|
|
892
|
+
// can take ~15-20s on cold boot, especially when several runners
|
|
893
|
+
// are spawning in parallel. The previous 10s window was racing
|
|
894
|
+
// the admin-ui runner's boot — fastify-static didn't register and
|
|
895
|
+
// every `GET /` returned 404. Bump to 60s; we only pay this
|
|
896
|
+
// wait once at boot.
|
|
897
|
+
const ADMIN_UI_WAIT_MS = 60_000;
|
|
898
|
+
const POLL_MS = 200;
|
|
899
|
+
const deadline = Date.now() + ADMIN_UI_WAIT_MS;
|
|
900
|
+
while (!adminUI && Date.now() < deadline) {
|
|
901
|
+
await new Promise<void>(r => setTimeout(r, POLL_MS));
|
|
902
|
+
adminUI = capRegistry.getSingleton<AdminUI>("admin-ui");
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (adminUI) {
|
|
906
|
+
const { staticDir } = await adminUI.getStaticDir();
|
|
907
|
+
const indexPath = path.join(staticDir, "index.html");
|
|
908
|
+
if (fs.existsSync(staticDir) && fs.existsSync(indexPath)) {
|
|
909
|
+
spaIndexHtml = indexPath;
|
|
910
|
+
await fastify.register(fastifyStatic, {
|
|
911
|
+
root: staticDir,
|
|
912
|
+
prefix: "/",
|
|
913
|
+
wildcard: false,
|
|
914
|
+
decorateReply: false,
|
|
915
|
+
});
|
|
916
|
+
// Dev diagnostic: serve webrtc-test.html from dataPath if it exists.
|
|
917
|
+
const webrtcTestPath = path.join(dataPath, "webrtc-test.html");
|
|
918
|
+
if (fs.existsSync(webrtcTestPath)) {
|
|
919
|
+
fastify.get("/webrtc-test.html", async (_request, reply) => {
|
|
920
|
+
return reply.type("text/html").send(fs.createReadStream(webrtcTestPath));
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// SPA fallback: catch-all route that serves index.html for non-API paths.
|
|
925
|
+
// Uses a wildcard route instead of setNotFoundHandler.
|
|
926
|
+
fastify.get("/*", async (request, reply) => {
|
|
927
|
+
const url = request.url;
|
|
928
|
+
if (
|
|
929
|
+
url.startsWith("/trpc") ||
|
|
930
|
+
url.startsWith("/api/") ||
|
|
931
|
+
url.startsWith("/agent") ||
|
|
932
|
+
url.startsWith("/health")
|
|
933
|
+
) {
|
|
934
|
+
return reply.callNotFound();
|
|
935
|
+
}
|
|
936
|
+
// A request whose last path segment has a file extension is a
|
|
937
|
+
// static asset, not a SPA navigation route. If it reached here,
|
|
938
|
+
// `fastify-static` has no route for it — the file is missing.
|
|
939
|
+
// Return 404 instead of the SPA `index.html`: serving HTML under
|
|
940
|
+
// an asset URL makes upstream caches (Cloudflare, the browser)
|
|
941
|
+
// pin `text/html` for a `.js`/`.css` URL, which then fails the
|
|
942
|
+
// module MIME check long after the file is actually available.
|
|
943
|
+
const pathOnly = url.split("?")[0] ?? url;
|
|
944
|
+
if (/\.[a-zA-Z0-9]+$/.test(pathOnly)) {
|
|
945
|
+
return reply.callNotFound();
|
|
946
|
+
}
|
|
947
|
+
return reply.type("text/html").send(fs.createReadStream(spaIndexHtml!));
|
|
948
|
+
});
|
|
949
|
+
const { version } = await adminUI.getVersion();
|
|
950
|
+
console.log(
|
|
951
|
+
`[bootstrap] Admin UI served from: ${staticDir} (v${version})`,
|
|
952
|
+
);
|
|
953
|
+
} else {
|
|
954
|
+
console.warn(
|
|
955
|
+
`[bootstrap] Admin UI dist not found at: ${staticDir} — run 'npm run build' in addon-admin-ui`,
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
} else {
|
|
959
|
+
console.warn(
|
|
960
|
+
"[bootstrap] admin-ui capability not registered — no static file serving",
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
} catch (err) {
|
|
964
|
+
console.error(
|
|
965
|
+
"[bootstrap] Failed to set up admin UI static serving:",
|
|
966
|
+
err,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
try {
|
|
971
|
+
await app.listen(port, host);
|
|
972
|
+
} catch (listenErr: unknown) {
|
|
973
|
+
if (
|
|
974
|
+
listenErr !== null
|
|
975
|
+
&& typeof listenErr === 'object'
|
|
976
|
+
&& 'code' in listenErr
|
|
977
|
+
&& listenErr.code === "EADDRINUSE"
|
|
978
|
+
) {
|
|
979
|
+
console.error(
|
|
980
|
+
`[bootstrap] FATAL: Port ${port} is already in use. Stop the other process or change server.port in config.yaml.`,
|
|
981
|
+
);
|
|
982
|
+
process.exit(1);
|
|
983
|
+
}
|
|
984
|
+
throw listenErr;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const logger = app.get(LoggingService).createLogger("System");
|
|
988
|
+
const protocol = tlsOptions ? "https" : "http";
|
|
989
|
+
logger.info(
|
|
990
|
+
'CamStack server listening',
|
|
991
|
+
{ meta: { protocol, host, port, trpcRegistered } },
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
// Post-boot: fork workers, register device streams, emit system.boot
|
|
995
|
+
const postBoot = app.get(PostBootService);
|
|
996
|
+
await postBoot.run({ port, host, dataPath, trpcRegistered });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Read the hub's own package version (best-effort) for /health responses.
|
|
1001
|
+
*/
|
|
1002
|
+
function readHubVersion(): string {
|
|
1003
|
+
try {
|
|
1004
|
+
const pkgPath = path.resolve(__dirname, "..", "package.json");
|
|
1005
|
+
if (fs.existsSync(pkgPath)) {
|
|
1006
|
+
const raw = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
|
|
1007
|
+
if (typeof raw.version === "string") return raw.version;
|
|
1008
|
+
}
|
|
1009
|
+
} catch {
|
|
1010
|
+
// best-effort
|
|
1011
|
+
}
|
|
1012
|
+
return "unknown";
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Build an AddonHttpReply wrapper around a Fastify reply.
|
|
1017
|
+
*/
|
|
1018
|
+
function buildAddonReply(reply: FastifyReply) {
|
|
1019
|
+
const wrapper = {
|
|
1020
|
+
status(code: number) {
|
|
1021
|
+
reply.status(code);
|
|
1022
|
+
return wrapper;
|
|
1023
|
+
},
|
|
1024
|
+
code(code: number) {
|
|
1025
|
+
reply.code(code);
|
|
1026
|
+
return wrapper;
|
|
1027
|
+
},
|
|
1028
|
+
send(data: unknown) {
|
|
1029
|
+
reply.send(data);
|
|
1030
|
+
},
|
|
1031
|
+
redirect(url: string) {
|
|
1032
|
+
reply.redirect(url);
|
|
1033
|
+
},
|
|
1034
|
+
header(name: string, value: string) {
|
|
1035
|
+
reply.header(name, value);
|
|
1036
|
+
return wrapper;
|
|
1037
|
+
},
|
|
1038
|
+
type(mime: string) {
|
|
1039
|
+
reply.type(mime);
|
|
1040
|
+
return wrapper;
|
|
1041
|
+
},
|
|
1042
|
+
};
|
|
1043
|
+
return wrapper;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
bootstrap().catch((err) => {
|
|
1047
|
+
console.error("Bootstrap failed:", err);
|
|
1048
|
+
process.exit(1);
|
|
1049
|
+
});
|