@camstack/server 1.1.53 → 1.1.55
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/dist/api/addon-upload.js +14 -124
- package/dist/api/core/auth.router.js +38 -8
- package/dist/api/health/health.routes.js +52 -7
- package/dist/api/server-upload.js +188 -0
- package/dist/api/static/spa-static.js +23 -0
- package/dist/api/tarball-manifest.js +86 -0
- package/dist/api/trpc/share-view-access.js +30 -0
- package/dist/api/trpc/trpc.middleware.js +10 -0
- package/dist/api/upload-auth.js +69 -0
- package/dist/auth/auth-rate-limit.js +76 -0
- package/dist/main.js +29 -23
- package/dist/server-root/index.js +264 -61
- package/package.json +4 -4
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.isTarballFilename = isTarballFilename;
|
|
37
|
+
exports.readTarballManifest = readTarballManifest;
|
|
38
|
+
/**
|
|
39
|
+
* npm-pack tarball manifest inspection — shared by the addon upload route
|
|
40
|
+
* (`/api/addons/upload`) and the dev server-deploy upload route
|
|
41
|
+
* (`/api/server-upload`).
|
|
42
|
+
*/
|
|
43
|
+
const node_child_process_1 = require("node:child_process");
|
|
44
|
+
const fs = __importStar(require("node:fs"));
|
|
45
|
+
const os = __importStar(require("node:os"));
|
|
46
|
+
const path = __importStar(require("node:path"));
|
|
47
|
+
const TARBALL_EXTENSIONS = ['.tgz', '.tar.gz'];
|
|
48
|
+
function isTarballFilename(filename) {
|
|
49
|
+
return TARBALL_EXTENSIONS.some((ext) => filename.endsWith(ext));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate an uploaded tarball by extracting its `package.json` and
|
|
53
|
+
* checking it declares `name` + `version`. Runs BEFORE any install branch
|
|
54
|
+
* so broken archives are rejected at the gateway instead of failing
|
|
55
|
+
* mid-extraction downstream (which has no clean rollback).
|
|
56
|
+
*
|
|
57
|
+
* The routine writes the buffer to a scratch path and invokes `tar` with
|
|
58
|
+
* `-xzO` to stream-extract just `package/package.json` to stdout. No full
|
|
59
|
+
* unpack is needed — npm-pack layout always puts the manifest at that
|
|
60
|
+
* fixed inner path. Returns null (not throw) on malformed archives so
|
|
61
|
+
* the caller can surface a precise 400 response.
|
|
62
|
+
*/
|
|
63
|
+
function readTarballManifest(buffer, filename) {
|
|
64
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-tarball-check-'));
|
|
65
|
+
const tgzPath = path.join(tmpDir, path.basename(filename));
|
|
66
|
+
try {
|
|
67
|
+
fs.writeFileSync(tgzPath, buffer);
|
|
68
|
+
const output = (0, node_child_process_1.execFileSync)('tar', ['-xzO', '-f', tgzPath, 'package/package.json'], {
|
|
69
|
+
timeout: 5_000,
|
|
70
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
71
|
+
});
|
|
72
|
+
const parsed = JSON.parse(output.toString('utf8'));
|
|
73
|
+
if (!parsed || typeof parsed !== 'object')
|
|
74
|
+
return null;
|
|
75
|
+
const pkg = parsed;
|
|
76
|
+
if (typeof pkg['name'] !== 'string' || typeof pkg['version'] !== 'string')
|
|
77
|
+
return null;
|
|
78
|
+
return { name: pkg['name'], version: pkg['version'] };
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.checkShareViewAccess = checkShareViewAccess;
|
|
4
|
+
exports.projectListAllForShareScope = projectListAllForShareScope;
|
|
4
5
|
exports.liveEventInShareScope = liveEventInShareScope;
|
|
5
6
|
/**
|
|
6
7
|
* Methods callable WITHOUT a per-device check — their input carries no
|
|
@@ -58,6 +59,35 @@ function checkShareViewAccess(scope, path, input) {
|
|
|
58
59
|
}
|
|
59
60
|
return { ok: false, reason: `'${path}' is not available to share-view tokens` };
|
|
60
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Output filter for `deviceManager.listAll` under a share-view principal:
|
|
64
|
+
* drop every device outside the token's scope, and project the survivors
|
|
65
|
+
* onto the whitelisted row (never the raw device — fail closed on fields).
|
|
66
|
+
* Non-array payloads pass through untouched (the route errored upstream).
|
|
67
|
+
*/
|
|
68
|
+
function projectListAllForShareScope(data, scope) {
|
|
69
|
+
if (!Array.isArray(data))
|
|
70
|
+
return data;
|
|
71
|
+
const allowed = new Set(scope.deviceIds);
|
|
72
|
+
const rows = [];
|
|
73
|
+
for (const entry of data) {
|
|
74
|
+
if (entry === null || typeof entry !== 'object')
|
|
75
|
+
continue;
|
|
76
|
+
const id = Reflect.get(entry, 'id');
|
|
77
|
+
if (typeof id !== 'number' || !allowed.has(id))
|
|
78
|
+
continue;
|
|
79
|
+
rows.push({
|
|
80
|
+
id,
|
|
81
|
+
name: Reflect.get(entry, 'name'),
|
|
82
|
+
online: Reflect.get(entry, 'online'),
|
|
83
|
+
isCamera: Reflect.get(entry, 'isCamera'),
|
|
84
|
+
type: Reflect.get(entry, 'type'),
|
|
85
|
+
location: Reflect.get(entry, 'location'),
|
|
86
|
+
disabled: Reflect.get(entry, 'disabled'),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return rows;
|
|
90
|
+
}
|
|
61
91
|
/**
|
|
62
92
|
* Whether a live event belongs to a device inside the share scope.
|
|
63
93
|
* Matches the device identity two ways (fail closed — no match, no push):
|
|
@@ -104,6 +104,16 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
|
|
|
104
104
|
if (!result.ok) {
|
|
105
105
|
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
|
106
106
|
}
|
|
107
|
+
// `deviceManager.listAll` is allowlisted for the embed's tile names,
|
|
108
|
+
// but its raw rows describe EVERY device (config incl. credentials).
|
|
109
|
+
// Project the output down to the in-scope whitelisted rows.
|
|
110
|
+
if (path === 'deviceManager.listAll') {
|
|
111
|
+
const out = await next({ ctx: { ...ctx, user: ctx.user } });
|
|
112
|
+
if (out.ok) {
|
|
113
|
+
return { ...out, data: (0, share_view_access_js_1.projectListAllForShareScope)(out.data, ctx.user.shareView.scope) };
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
107
117
|
return next({ ctx: { ...ctx, user: ctx.user } });
|
|
108
118
|
}
|
|
109
119
|
// Spread+reassign of `user` narrows downstream ctx from `User | null`
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UPLOAD_TRPC_PATH = void 0;
|
|
4
|
+
exports.authorizeUploadRequest = authorizeUploadRequest;
|
|
5
|
+
const scope_access_js_1 = require("./trpc/scope-access.js");
|
|
6
|
+
exports.UPLOAD_TRPC_PATH = 'addons.installPackage';
|
|
7
|
+
/**
|
|
8
|
+
* Validate a `cst_*` scoped token via the `user-management` cap singleton.
|
|
9
|
+
* Returns null when the singleton isn't mounted yet (boot race) or the
|
|
10
|
+
* token doesn't validate. Caller treats both as auth failure.
|
|
11
|
+
*/
|
|
12
|
+
async function validateScopedTokenViaCap(addonRegistry, token) {
|
|
13
|
+
const capRegistry = addonRegistry.getCapabilityRegistry();
|
|
14
|
+
// Documented type boundary: the registry returns the provider untyped; the
|
|
15
|
+
// `user-management` cap's `validateScopedToken` surface is asserted here
|
|
16
|
+
// (same shape the tRPC middleware relies on).
|
|
17
|
+
const userMgmt = capRegistry.getSingleton('user-management');
|
|
18
|
+
if (!userMgmt)
|
|
19
|
+
return null;
|
|
20
|
+
return userMgmt.validateScopedToken({ token });
|
|
21
|
+
}
|
|
22
|
+
function isUploadAllowed(scoped) {
|
|
23
|
+
return (0, scope_access_js_1.checkScopeAccess)(scoped.scopes, exports.UPLOAD_TRPC_PATH).ok;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Run the full upload auth chain. Returns `{ok: true}` on success, or the
|
|
27
|
+
* HTTP status + error message the route should reply with:
|
|
28
|
+
* - 401 `Unauthorized` when no Authorization header is present,
|
|
29
|
+
* - 403 `Forbidden: <reason>` for any recognised-but-insufficient token.
|
|
30
|
+
*/
|
|
31
|
+
async function authorizeUploadRequest(args) {
|
|
32
|
+
const { authHeader, authService, addonRegistry } = args;
|
|
33
|
+
if (!authHeader) {
|
|
34
|
+
return { ok: false, status: 401, error: 'Unauthorized' };
|
|
35
|
+
}
|
|
36
|
+
const token = authHeader.replace('Bearer ', '');
|
|
37
|
+
let authReason;
|
|
38
|
+
// Try JWT first — fastest path + carries the isAdmin flag directly.
|
|
39
|
+
try {
|
|
40
|
+
const payload = authService.verifyToken(token);
|
|
41
|
+
if (payload.isAdmin) {
|
|
42
|
+
return { ok: true };
|
|
43
|
+
}
|
|
44
|
+
authReason = 'JWT is not admin';
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not a JWT (or invalid signature) — fall through to scoped-token path.
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const record = await validateScopedTokenViaCap(addonRegistry, token);
|
|
51
|
+
if (!record) {
|
|
52
|
+
authReason = authReason ?? 'token not recognised';
|
|
53
|
+
}
|
|
54
|
+
else if (isUploadAllowed(record)) {
|
|
55
|
+
return { ok: true };
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
authReason = `scoped token lacks create access on '${exports.UPLOAD_TRPC_PATH}'`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
authReason = `scoped token validation failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
status: 403,
|
|
67
|
+
error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Public-auth rate limiting — fixed window per (bucket, client ip).
|
|
4
|
+
*
|
|
5
|
+
* The public auth surface (login, TOTP verify, passkey ceremonies,
|
|
6
|
+
* handoff redeem, session exchange/upgrade) previously had NO
|
|
7
|
+
* throttling: unlimited online credential guessing. This module is the
|
|
8
|
+
* first line of defence — deliberately simple (in-memory fixed window),
|
|
9
|
+
* because the deployment is a single hub process and the goal is to
|
|
10
|
+
* blunt brute force, not to be a distributed quota system.
|
|
11
|
+
*
|
|
12
|
+
* Pure + clock-injectable so specs drive it directly. The key map is
|
|
13
|
+
* BOUNDED (`maxKeys`, oldest-window evicted first) so an attacker
|
|
14
|
+
* rotating spoofed source addresses cannot grow it without limit.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.createRateLimiter = createRateLimiter;
|
|
18
|
+
exports.clientKeyFromRequest = clientKeyFromRequest;
|
|
19
|
+
function createRateLimiter(options) {
|
|
20
|
+
const maxKeys = options.maxKeys ?? 10_000;
|
|
21
|
+
const now = options.now ?? Date.now;
|
|
22
|
+
const entries = new Map();
|
|
23
|
+
const evictOldest = () => {
|
|
24
|
+
let oldestKey = null;
|
|
25
|
+
let oldestStart = Number.POSITIVE_INFINITY;
|
|
26
|
+
for (const [key, entry] of entries) {
|
|
27
|
+
if (entry.windowStart < oldestStart) {
|
|
28
|
+
oldestStart = entry.windowStart;
|
|
29
|
+
oldestKey = key;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (oldestKey !== null)
|
|
33
|
+
entries.delete(oldestKey);
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
check(key) {
|
|
37
|
+
const at = now();
|
|
38
|
+
const existing = entries.get(key);
|
|
39
|
+
if (!existing || at - existing.windowStart >= options.windowMs) {
|
|
40
|
+
if (!entries.has(key) && entries.size >= maxKeys)
|
|
41
|
+
evictOldest();
|
|
42
|
+
entries.set(key, { windowStart: at, count: 1 });
|
|
43
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
44
|
+
}
|
|
45
|
+
if (existing.count < options.max) {
|
|
46
|
+
existing.count += 1;
|
|
47
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
48
|
+
}
|
|
49
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((existing.windowStart + options.windowMs - at) / 1000));
|
|
50
|
+
return { allowed: false, retryAfterSeconds };
|
|
51
|
+
},
|
|
52
|
+
size() {
|
|
53
|
+
return entries.size;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Client key for a tRPC/Fastify request principal: `FastifyRequest.ip`
|
|
59
|
+
* (proxy-aware when Fastify's `trustProxy` is on) or the raw socket
|
|
60
|
+
* address for WS-upgrade `IncomingMessage`s. `null` = no originating
|
|
61
|
+
* request (mesh-internal `createCaller` invocation) — never limited.
|
|
62
|
+
*/
|
|
63
|
+
function clientKeyFromRequest(req) {
|
|
64
|
+
if (req === null || typeof req !== 'object' || req === undefined)
|
|
65
|
+
return null;
|
|
66
|
+
const ip = Reflect.get(req, 'ip');
|
|
67
|
+
if (typeof ip === 'string' && ip.length > 0)
|
|
68
|
+
return ip;
|
|
69
|
+
const socket = Reflect.get(req, 'socket');
|
|
70
|
+
if (socket !== null && typeof socket === 'object') {
|
|
71
|
+
const remote = Reflect.get(socket, 'remoteAddress');
|
|
72
|
+
if (typeof remote === 'string' && remote.length > 0)
|
|
73
|
+
return remote;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -80,6 +80,7 @@ const trpc_router_1 = require("./api/trpc/trpc.router");
|
|
|
80
80
|
const core_cap_bridge_1 = require("./api/trpc/core-cap-bridge");
|
|
81
81
|
const trpc_context_1 = require("./api/trpc/trpc.context");
|
|
82
82
|
const addon_upload_1 = require("./api/addon-upload");
|
|
83
|
+
const server_upload_1 = require("./api/server-upload");
|
|
83
84
|
const auth_whoami_1 = require("./api/auth-whoami");
|
|
84
85
|
const session_cookie_js_1 = require("./auth/session-cookie.js");
|
|
85
86
|
const health_routes_1 = require("./api/health/health.routes");
|
|
@@ -231,6 +232,15 @@ async function bootstrap() {
|
|
|
231
232
|
const uploadLogger = app.get(logging_service_1.LoggingService).createLogger('addon-upload');
|
|
232
233
|
await (0, addon_upload_1.registerAddonUploadRoute)(fastify, uploadAddonBridge, uploadAuthService, uploadMoleculer, uploadAddonRegistry, uploadAddonPackage, uploadLogger);
|
|
233
234
|
console.log('[bootstrap] Addon upload route registered at POST /api/addons/upload');
|
|
235
|
+
// Dev server-deploy channel gateway (`camstack deploy-server`). MUST be
|
|
236
|
+
// registered AFTER registerAddonUploadRoute — it reuses the multipart
|
|
237
|
+
// plugin that route installs on this instance.
|
|
238
|
+
(0, server_upload_1.registerServerUploadRoute)(fastify, {
|
|
239
|
+
authService: uploadAuthService,
|
|
240
|
+
addonRegistry: uploadAddonRegistry,
|
|
241
|
+
logger: app.get(logging_service_1.LoggingService).createLogger('server-upload'),
|
|
242
|
+
});
|
|
243
|
+
console.log('[bootstrap] Server upload route registered at POST /api/server-upload');
|
|
234
244
|
// Companion endpoint: /api/auth/whoami — validates JWT or cst_*
|
|
235
245
|
// scoped tokens, returns the resolved identity + scope summary.
|
|
236
246
|
// Mirrors the addon-upload auth chain so the CLI can ping for
|
|
@@ -320,6 +330,7 @@ async function bootstrap() {
|
|
|
320
330
|
// Read the live flag at request time — flips to true once the tRPC
|
|
321
331
|
// router finishes registering (see `trpcRegistered = true` below).
|
|
322
332
|
isReady: () => trpcRegistered,
|
|
333
|
+
verifyToken: (token) => app.get(auth_service_1.AuthService).verifyToken(token),
|
|
323
334
|
});
|
|
324
335
|
console.log(`[bootstrap] Health routes registered (hub v${hubVersion})`);
|
|
325
336
|
}
|
|
@@ -434,7 +445,7 @@ async function bootstrap() {
|
|
|
434
445
|
: 'application/octet-stream';
|
|
435
446
|
const stream = fs.createReadStream(resolved);
|
|
436
447
|
return reply
|
|
437
|
-
.header('cache-control', addonBundleCacheControl(resolved))
|
|
448
|
+
.header('cache-control', (0, spa_static_1.addonBundleCacheControl)(resolved))
|
|
438
449
|
.type(contentType)
|
|
439
450
|
.send(stream);
|
|
440
451
|
});
|
|
@@ -466,7 +477,7 @@ async function bootstrap() {
|
|
|
466
477
|
: 'application/octet-stream';
|
|
467
478
|
const stream = fs.createReadStream(resolved);
|
|
468
479
|
return reply
|
|
469
|
-
.header('cache-control', addonBundleCacheControl(resolved))
|
|
480
|
+
.header('cache-control', (0, spa_static_1.addonBundleCacheControl)(resolved))
|
|
470
481
|
.type(contentType)
|
|
471
482
|
.send(stream);
|
|
472
483
|
});
|
|
@@ -940,7 +951,16 @@ async function bootstrap() {
|
|
|
940
951
|
try {
|
|
941
952
|
const bootAddonRegistry = app.get(addon_registry_service_1.AddonRegistryService);
|
|
942
953
|
const capRegistry = bootAddonRegistry.getCapabilityRegistry();
|
|
943
|
-
|
|
954
|
+
// HUB-NODE-SCOPED resolution — never the cluster-global singleton.
|
|
955
|
+
// Agent nodes register the same `admin-ui` cap through their own
|
|
956
|
+
// agent-ui addon (placement: agent-only), and the cluster-elected
|
|
957
|
+
// active provider can land on the AGENT's registration depending on
|
|
958
|
+
// node boot order. Its `getStaticDir()` then answers with a path on
|
|
959
|
+
// the agent's filesystem — nonexistent here — and the SPA catch-all
|
|
960
|
+
// silently never registers (every `GET /` 404s until the next
|
|
961
|
+
// restart re-rolls the election). `getSingletonForNode(cap, 'hub')`
|
|
962
|
+
// only ever resolves providers hosted on THIS node.
|
|
963
|
+
let adminUI = capRegistry?.getSingletonForNode('admin-ui', 'hub');
|
|
944
964
|
// CAMSTACK_SKIP_ADMIN_UI_WAIT — bypass the 60s poll. Used by the
|
|
945
965
|
// e2e harness, which doesn't need the SPA served and spawns hubs
|
|
946
966
|
// with strict boot timeouts. Production keeps the poll so cold
|
|
@@ -958,7 +978,7 @@ async function bootstrap() {
|
|
|
958
978
|
const deadline = Date.now() + ADMIN_UI_WAIT_MS;
|
|
959
979
|
while (!adminUI && Date.now() < deadline) {
|
|
960
980
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
961
|
-
adminUI = capRegistry.
|
|
981
|
+
adminUI = capRegistry.getSingletonForNode('admin-ui', 'hub');
|
|
962
982
|
}
|
|
963
983
|
}
|
|
964
984
|
if (adminUI) {
|
|
@@ -997,7 +1017,6 @@ async function bootstrap() {
|
|
|
997
1017
|
url.startsWith('/api/') ||
|
|
998
1018
|
url.startsWith('/agent') ||
|
|
999
1019
|
url.startsWith('/health') ||
|
|
1000
|
-
url.startsWith('/discovery') ||
|
|
1001
1020
|
// The viewer SPA owns /viewer/** via its own catch-all (registered
|
|
1002
1021
|
// below). Fall through here so admin-ui never serves it — and so
|
|
1003
1022
|
// /viewer 404s cleanly when the viewer-ui addon isn't registered.
|
|
@@ -1055,7 +1074,10 @@ async function bootstrap() {
|
|
|
1055
1074
|
try {
|
|
1056
1075
|
const bootAddonRegistry = app.get(addon_registry_service_1.AddonRegistryService);
|
|
1057
1076
|
const capRegistry = bootAddonRegistry.getCapabilityRegistry();
|
|
1058
|
-
|
|
1077
|
+
// Hub-node-scoped for the same reason as admin-ui above: a remote
|
|
1078
|
+
// node's registration must never win the election for the provider
|
|
1079
|
+
// whose staticDir this process reads from local disk.
|
|
1080
|
+
let viewerUI = capRegistry?.getSingletonForNode('viewer-ui', 'hub');
|
|
1059
1081
|
const skipViewerUIWait = process.env['CAMSTACK_SKIP_VIEWER_UI_WAIT'] === '1';
|
|
1060
1082
|
if (!viewerUI && capRegistry && !skipViewerUIWait) {
|
|
1061
1083
|
// The viewer-ui addon runs in its own forked runner and registers a few
|
|
@@ -1066,7 +1088,7 @@ async function bootstrap() {
|
|
|
1066
1088
|
const deadline = Date.now() + VIEWER_UI_WAIT_MS;
|
|
1067
1089
|
while (!viewerUI && Date.now() < deadline) {
|
|
1068
1090
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1069
|
-
viewerUI = capRegistry.
|
|
1091
|
+
viewerUI = capRegistry.getSingletonForNode('viewer-ui', 'hub');
|
|
1070
1092
|
}
|
|
1071
1093
|
}
|
|
1072
1094
|
if (viewerUI) {
|
|
@@ -1211,22 +1233,6 @@ function readHubVersion() {
|
|
|
1211
1233
|
/**
|
|
1212
1234
|
* Build an AddonHttpReply wrapper around a Fastify reply.
|
|
1213
1235
|
*/
|
|
1214
|
-
/**
|
|
1215
|
-
* Cache-Control for an addon MF bundle file. The entry point
|
|
1216
|
-
* (`remoteEntry.js` / `widgets.mjs`) has a STABLE filename — only its
|
|
1217
|
-
* `?v=` query changes across builds — so it MUST revalidate, otherwise a
|
|
1218
|
-
* caching proxy (Cloudflare edge, whose default browser-cache TTL was
|
|
1219
|
-
* serving these 4h) keeps handing out a stale entry whose export map
|
|
1220
|
-
* lacks the current widgets ("bundle does not export this stableId",
|
|
1221
|
-
* tunnel-only). The sibling chunks are CONTENT-HASHED in their filenames
|
|
1222
|
-
* (`dist-<hash>.mjs`, `_virtual_mf-<hash>.mjs`), so they are safely
|
|
1223
|
-
* immutable. The revalidation cost is a cheap 304 on a ~4KB entry.
|
|
1224
|
-
*/
|
|
1225
|
-
function addonBundleCacheControl(filePath) {
|
|
1226
|
-
const base = path.basename(filePath);
|
|
1227
|
-
const isMfEntry = base === 'remoteEntry.js' || base === 'widgets.mjs';
|
|
1228
|
-
return isMfEntry ? 'no-cache, must-revalidate' : 'public, max-age=31536000, immutable';
|
|
1229
|
-
}
|
|
1230
1236
|
function buildAddonReply(reply) {
|
|
1231
1237
|
const wrapper = {
|
|
1232
1238
|
status(code) {
|