@camstack/server 1.1.43 → 1.1.45

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.
@@ -6,6 +6,7 @@ exports.buildAppRouter = buildAppRouter;
6
6
  const addon_settings_router_js_1 = require("../core/addon-settings.router.js");
7
7
  const auth_router_js_1 = require("../core/auth.router.js");
8
8
  const cap_providers_js_1 = require("../core/cap-providers.js");
9
+ const server_management_provider_js_1 = require("../core/server-management.provider.js");
9
10
  const capabilities_router_js_1 = require("../core/capabilities.router.js");
10
11
  const event_bus_proxy_router_js_1 = require("../core/event-bus-proxy.router.js");
11
12
  const hwaccel_router_js_1 = require("../core/hwaccel.router.js");
@@ -83,7 +84,9 @@ function buildServerProviders(services) {
83
84
  system: wrap(() => (0, cap_providers_js_1.buildSystemProvider)(services.featureService, services.capabilityRegistry)),
84
85
  toast: wrap((ctx) => (0, cap_providers_js_1.buildToastProvider)(services.toastService, ctx)),
85
86
  integrations: wrap(() => (0, cap_providers_js_1.buildIntegrationsProvider)(services.addonRegistry, services.eventBus, services.loggingService, services.capabilityRegistry)),
86
- nodes: wrap(() => (0, cap_providers_js_1.buildNodesProvider)(services.agentRegistry, services.moleculer, services.addonRegistry)),
87
+ nodes: wrap(() => (0, cap_providers_js_1.buildNodesProvider)(services.agentRegistry, services.moleculer, services.addonRegistry, (0, cap_providers_js_1.createNodeRootPackageLookup)(services.moleculer, services.serverUpdateService))),
88
+ // server-management — runtime-updatable root package (phase 1: hub only).
89
+ 'server-management': wrap(() => (0, server_management_provider_js_1.buildServerManagementProvider)(services.serverUpdateService)),
87
90
  addons: wrap((ctx) => (0, cap_providers_js_1.buildAddonsProvider)(services.addonRegistry, services.addonPackageService, services.loggingService, services.moleculer, services.configService, ctx)),
88
91
  // webrtc-session — the resolved broker singleton, wrapped per request to
89
92
  // inject the server-read User-Agent into signaling attribution (the broker
@@ -163,7 +166,7 @@ function buildCapabilityRouters(services) {
163
166
  // clusterNodes — fixed core API. Write-side purge for the durable
164
167
  // offline-node history (Track A "Forget node"); read side is push-only.
165
168
  clusterNodes: (0, cluster_nodes_router_js_1.createClusterNodesRouter)(services.agentRegistry),
166
- auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry),
169
+ auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer),
167
170
  // NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes
168
171
  // (positional args / sync returns) that don't match the codegen
169
172
  // routers' {input}-object + Promise<T> contract. The runtime builder
@@ -44,7 +44,7 @@ const os = __importStar(require("node:os"));
44
44
  const node_child_process_1 = require("node:child_process");
45
45
  const node_util_1 = require("node:util");
46
46
  const node_crypto_1 = require("node:crypto");
47
- const framework_live_sync_js_1 = require("./framework-live-sync.js");
47
+ const package_dir_utils_js_1 = require("./package-dir-utils.js");
48
48
  const types_1 = require("@camstack/types");
49
49
  const system_1 = require("@camstack/system");
50
50
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
@@ -54,24 +54,21 @@ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
54
54
  */
55
55
  exports.SYSTEM_PACKAGE = '@camstack/system';
56
56
  /**
57
- * Host-provided framework packages that a `camstack deploy` mirrors into the
58
- * running hub's live closure via `syncFrameworkDist` (hub-main node_modules +
59
- * addon-runner overlay) instead of the normal addon-install path, then bounces
60
- * the server — delivering the new code with NO image rebuild.
57
+ * Host-provided framework packages that take a special path through
58
+ * `camstack deploy` (the addon-upload route) instead of the normal
59
+ * addon-install path.
61
60
  *
62
- * `@camstack/types` is included so a CAPABILITY change ships live via
63
- * `camstack deploy packages/types`: the hub-main reads `ALL_CAPABILITY_DEFINITIONS`
64
- * + `METHOD_ACCESS_MAP` and the runtime cap-router builder reads the cap defs
65
- * from this single closure copy, so mirroring types' `dist/` refreshes the
66
- * ENTIRE cap surface (new caps, new methods, AND the per-method auth map)
67
- * without an image. `@camstack/sdk` rides along as a pure-JS lockstep companion.
68
- * `@camstack/shm-ring` is deliberately EXCLUDED: it ships a native `.node`
69
- * prebuild the dist-only mirror cannot rebuild — it must go through the
70
- * native-aware install path, not this JS-dist swap.
61
+ * Since the runtime-updatable root package shipped (2026-07-12 design), a
62
+ * framework upload on a DEPLOYED hub is rejected with guidance to update the
63
+ * `@camstack/server` root instead — the hub main + framework deps update as
64
+ * ONE npm closure via the `server-management` cap. In a WORKSPACE checkout
65
+ * the upload still triggers a plain restart: the hub resolves the framework
66
+ * from `packages/<pkg>/dist`, which the CLI rebuilt before uploading, so the
67
+ * dev loop (`camstack deploy packages/system`) is unchanged.
71
68
  *
72
69
  * Membership is by package NAME, independent of the `camstack.system` manifest
73
70
  * flag (`@camstack/types` declares `system: false` yet is a framework package
74
- * here) — that flag drives Admin-UI grouping; this gate drives live-sync routing.
71
+ * here) — that flag drives Admin-UI grouping; this gate drives upload routing.
75
72
  */
76
73
  exports.FRAMEWORK_PACKAGES = [
77
74
  exports.SYSTEM_PACKAGE,
@@ -79,8 +76,8 @@ exports.FRAMEWORK_PACKAGES = [
79
76
  '@camstack/sdk',
80
77
  ];
81
78
  /**
82
- * Returns true for a host-provided framework package whose `dist/` is mirrored
83
- * into the live closure by `syncFrameworkDist` (not installed as a normal addon).
79
+ * Returns true for a host-provided framework package (special-cased by the
80
+ * addon-upload route never installed as a normal addon).
84
81
  */
85
82
  function isFrameworkPackage(packageName) {
86
83
  return exports.FRAMEWORK_PACKAGES.includes(packageName);
@@ -1012,8 +1009,8 @@ class AddonPackageService {
1012
1009
  // Content build-id of the code the hub ACTUALLY loaded — resolved from
1013
1010
  // the same closure the running process uses. Surfaces "same semver,
1014
1011
  // new code" (framework packages ship changes without always bumping).
1015
- const hubPkgDir = (0, framework_live_sync_js_1.resolveHubClosurePackageDir)(packageName);
1016
- const buildId = hubPkgDir !== null ? (0, framework_live_sync_js_1.computeDistBuildId)(path.join(hubPkgDir, 'dist')) : null;
1012
+ const hubPkgDir = (0, package_dir_utils_js_1.resolveHubClosurePackageDir)(packageName);
1013
+ const buildId = hubPkgDir !== null ? (0, package_dir_utils_js_1.computeDistBuildId)(path.join(hubPkgDir, 'dist')) : null;
1017
1014
  let latestVersion = null;
1018
1015
  try {
1019
1016
  const args = [
@@ -0,0 +1,140 @@
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.resolveHubClosurePackageDir = resolveHubClosurePackageDir;
37
+ exports.computeDistBuildId = computeDistBuildId;
38
+ /**
39
+ * Package-dir introspection helpers for the hub's OWN framework closure.
40
+ *
41
+ * Extracted from the deleted `framework-live-sync.ts` (the dist-mirror
42
+ * machinery was superseded by the runtime-updatable root package — see
43
+ * docs/superpowers/specs/2026-07-12-runtime-updatable-node-packages-design.md);
44
+ * these two read-only helpers remain in use by `AddonPackageService`'s
45
+ * framework-package status surface.
46
+ */
47
+ const fs = __importStar(require("node:fs"));
48
+ const path = __importStar(require("node:path"));
49
+ const node_crypto_1 = require("node:crypto");
50
+ const node_module_1 = require("node:module");
51
+ // Resolve framework packages the same way the running hub does — from THIS
52
+ // module's location, whose walk-up lands on the hub's own `node_modules`
53
+ // closure (identical to the hub main's `@camstack/system` resolution).
54
+ const requireFromHere = (0, node_module_1.createRequire)(__filename);
55
+ function readJsonObjectSafe(filePath) {
56
+ try {
57
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
58
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
59
+ return { ...parsed };
60
+ }
61
+ }
62
+ catch {
63
+ /* missing / malformed — treat as absent */
64
+ }
65
+ return null;
66
+ }
67
+ function packageNameOf(pkgJsonPath) {
68
+ const obj = readJsonObjectSafe(pkgJsonPath);
69
+ const name = obj?.['name'];
70
+ return typeof name === 'string' ? name : null;
71
+ }
72
+ /**
73
+ * The package directory the HUB MAIN process loads `<packageName>` from — its
74
+ * own `node_modules` closure. `require.resolve('<pkg>/package.json')` is the
75
+ * happy path; packages whose `exports` map omits that subpath fall back to
76
+ * resolving the main entry and walking up to the matching `package.json`.
77
+ * Returns null when the package is not resolvable.
78
+ */
79
+ function resolveHubClosurePackageDir(packageName) {
80
+ try {
81
+ return path.dirname(requireFromHere.resolve(`${packageName}/package.json`));
82
+ }
83
+ catch {
84
+ // `exports` map blocks the package.json subpath — fall through.
85
+ }
86
+ try {
87
+ let dir = path.dirname(requireFromHere.resolve(packageName));
88
+ while (dir !== path.dirname(dir)) {
89
+ const candidate = path.join(dir, 'package.json');
90
+ if (fs.existsSync(candidate) && packageNameOf(candidate) === packageName) {
91
+ return dir;
92
+ }
93
+ dir = path.dirname(dir);
94
+ }
95
+ }
96
+ catch {
97
+ // package itself not resolvable — treat as not installed.
98
+ }
99
+ return null;
100
+ }
101
+ function collectJsFiles(root, dir, out) {
102
+ let entries;
103
+ try {
104
+ entries = fs.readdirSync(dir, { withFileTypes: true });
105
+ }
106
+ catch {
107
+ return;
108
+ }
109
+ for (const entry of entries) {
110
+ const full = path.join(dir, entry.name);
111
+ if (entry.isDirectory()) {
112
+ collectJsFiles(root, full, out);
113
+ }
114
+ else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.mjs'))) {
115
+ out.push(path.relative(root, full));
116
+ }
117
+ }
118
+ }
119
+ /**
120
+ * Content build-id of a `dist/` tree — md5 over every `.js`/`.mjs` file's
121
+ * relative path + bytes (sorted for determinism). Analogous to the Docker
122
+ * image's `seed-version` fingerprint. Returns null when the dir is missing or
123
+ * empty. Used to surface "same semver, new code" instead of trusting `version`.
124
+ */
125
+ function computeDistBuildId(distDir) {
126
+ if (!fs.existsSync(distDir))
127
+ return null;
128
+ const files = [];
129
+ collectJsFiles(distDir, distDir, files);
130
+ if (files.length === 0)
131
+ return null;
132
+ files.sort();
133
+ const hash = (0, node_crypto_1.createHash)('md5');
134
+ for (const rel of files) {
135
+ hash.update(rel);
136
+ hash.update('\0');
137
+ hash.update(fs.readFileSync(path.join(distDir, rel)));
138
+ }
139
+ return hash.digest('hex');
140
+ }
@@ -921,6 +921,14 @@ class MoleculerService {
921
921
  listClusterNativeCapsForDevice(deviceId) {
922
922
  return this.nodeRegistry.listNativeCapEntriesForDevice(deviceId);
923
923
  }
924
+ /**
925
+ * The root-package identity a node reported in its `registerNode`
926
+ * manifest (runtime-updatable node packages: version visibility), or null
927
+ * when the node never reported one (UDS children, pre-phase-2 agents).
928
+ */
929
+ getNodeRootPackage(nodeId) {
930
+ return this.nodeRegistry.getNodeRootPackage(nodeId);
931
+ }
924
932
  /**
925
933
  * E2: Send a `set-log-level` UDS message to a hub-local child identified by
926
934
  * `nodeId` (e.g. `hub/provider-reolink`). Extracts the `childId` from the
@@ -0,0 +1,81 @@
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.ServerUpdateService = void 0;
37
+ /**
38
+ * ServerUpdateService — hub-side adapter over the SHARED `RootUpdateService`
39
+ * (`@camstack/node-root`, bundled into `dist/server-root/index.js`).
40
+ *
41
+ * The stage/apply/rollback/probation engine — including the security-review
42
+ * fixes (rollback reentrancy guards, fresh-transient prune protection,
43
+ * `stateFileCorrupt` visibility) — lives in the shared core so the hub and
44
+ * the agent can never drift. This class only binds the hub's parameters:
45
+ * the `@camstack/server` root spec, the `CAMSTACK_SERVER_*` env markers,
46
+ * the `CAMSTACK_DATA` data-dir default and the running package.json path.
47
+ *
48
+ * The starter (server/backend/src/starter.ts → shared `runNodeStarter`)
49
+ * owns the OTHER half: choosing the closure at boot, bumping the probation
50
+ * counter, auto-rolling-back a version whose probation boot never confirmed.
51
+ *
52
+ * Spec: docs/superpowers/specs/2026-07-12-runtime-updatable-node-packages-design.md
53
+ */
54
+ const path = __importStar(require("node:path"));
55
+ const index_js_1 = require("../../server-root/index.js");
56
+ class ServerUpdateService extends index_js_1.RootUpdateService {
57
+ constructor(options) {
58
+ super({
59
+ spec: index_js_1.HUB_ROOT_SPEC,
60
+ envNames: {
61
+ bootMode: 'CAMSTACK_SERVER_BOOT_MODE',
62
+ activeVersion: 'CAMSTACK_SERVER_ACTIVE_VERSION',
63
+ seedDir: 'CAMSTACK_SEED_SERVER_DIR',
64
+ },
65
+ logger: options.logger,
66
+ restartServer: options.restartServer,
67
+ dataDir: path.resolve(options.dataDir ??
68
+ options.env?.['CAMSTACK_DATA'] ??
69
+ process.env['CAMSTACK_DATA'] ??
70
+ 'camstack-data'),
71
+ // dist/core/server-update/ → ../../../package.json = the running copy's
72
+ // package.json (workspace server/backend OR the active/baked closure).
73
+ runningPackageJsonPath: options.runningPackageJsonPath ?? path.resolve(__dirname, '..', '..', '..', 'package.json'),
74
+ workspaceProbeDir: __dirname,
75
+ execNpm: options.execNpm,
76
+ env: options.env,
77
+ now: options.now,
78
+ });
79
+ }
80
+ }
81
+ exports.ServerUpdateService = ServerUpdateService;
@@ -38,14 +38,18 @@ class TopologyEmitterService {
38
38
  eventBus;
39
39
  agentRegistry;
40
40
  addonRegistry;
41
+ getNodeRootPackage;
41
42
  unsubscribers = [];
42
43
  debounceTimer = null;
43
44
  heartbeatTimer = null;
44
45
  emitting = false;
45
- constructor(eventBus, agentRegistry, addonRegistry) {
46
+ constructor(eventBus, agentRegistry, addonRegistry,
47
+ /** Root-package identity per node (version visibility) — optional. */
48
+ getNodeRootPackage) {
46
49
  this.eventBus = eventBus;
47
50
  this.agentRegistry = agentRegistry;
48
51
  this.addonRegistry = addonRegistry;
52
+ this.getNodeRootPackage = getNodeRootPackage;
49
53
  }
50
54
  onModuleInit() {
51
55
  for (const category of LIFECYCLE_CATEGORIES) {
@@ -85,7 +89,7 @@ class TopologyEmitterService {
85
89
  return;
86
90
  this.emitting = true;
87
91
  try {
88
- const nodes = await (0, cap_providers_1.computeTopology)(this.agentRegistry, this.addonRegistry);
92
+ const nodes = await (0, cap_providers_1.computeTopology)(this.agentRegistry, this.addonRegistry, this.getNodeRootPackage);
89
93
  this.eventBus.emit({
90
94
  id: (0, node_crypto_1.randomUUID)(),
91
95
  timestamp: new Date(),
package/dist/main.js CHANGED
@@ -81,11 +81,13 @@ const addon_upload_1 = require("./api/addon-upload");
81
81
  const auth_whoami_1 = require("./api/auth-whoami");
82
82
  const session_cookie_js_1 = require("./auth/session-cookie.js");
83
83
  const health_routes_1 = require("./api/health/health.routes");
84
+ const spa_static_1 = require("./api/static/spa-static");
84
85
  const oauth2_routes_js_1 = require("./api/oauth2/oauth2-routes.js");
85
86
  const system_2 = require("@camstack/system");
86
87
  const boot_config_1 = require("./boot/boot-config");
87
88
  const manual_boot_1 = require("./manual-boot");
88
89
  const post_boot_service_1 = require("./boot/post-boot.service");
90
+ const server_update_service_1 = require("./core/server-update/server-update.service");
89
91
  const integration_id_backfill_1 = require("./boot/integration-id-backfill");
90
92
  // ---- Process-level error handlers ----
91
93
  process.on('uncaughtException', (err) => {
@@ -363,6 +365,7 @@ async function bootstrap() {
363
365
  toastService,
364
366
  capabilityRegistry,
365
367
  streamProbe: app.get(stream_probe_service_1.StreamProbeService),
368
+ serverUpdateService: app.get(server_update_service_1.ServerUpdateService),
366
369
  });
367
370
  await fastify.register(fastify_1.fastifyTRPCPlugin, {
368
371
  prefix: '/trpc',
@@ -418,7 +421,10 @@ async function bootstrap() {
418
421
  ? 'text/html'
419
422
  : 'application/octet-stream';
420
423
  const stream = fs.createReadStream(resolved);
421
- return reply.type(contentType).send(stream);
424
+ return reply
425
+ .header('cache-control', addonBundleCacheControl(resolved))
426
+ .type(contentType)
427
+ .send(stream);
422
428
  });
423
429
  // Register addon-widgets static file endpoint — same shape as
424
430
  // addon-pages but reads bundles from each addon's
@@ -447,7 +453,10 @@ async function bootstrap() {
447
453
  ? 'text/html'
448
454
  : 'application/octet-stream';
449
455
  const stream = fs.createReadStream(resolved);
450
- return reply.type(contentType).send(stream);
456
+ return reply
457
+ .header('cache-control', addonBundleCacheControl(resolved))
458
+ .type(contentType)
459
+ .send(stream);
451
460
  });
452
461
  // Serve static assets (e.g. SVG icons) from an addon's package directory
453
462
  fastify.get('/api/addon-assets/:addonId/*', async (request, reply) => {
@@ -968,7 +977,12 @@ async function bootstrap() {
968
977
  if (url.startsWith('/trpc') ||
969
978
  url.startsWith('/api/') ||
970
979
  url.startsWith('/agent') ||
971
- url.startsWith('/health')) {
980
+ url.startsWith('/health') ||
981
+ url.startsWith('/discovery') ||
982
+ // The viewer SPA owns /viewer/** via its own catch-all (registered
983
+ // below). Fall through here so admin-ui never serves it — and so
984
+ // /viewer 404s cleanly when the viewer-ui addon isn't registered.
985
+ url.startsWith('/viewer')) {
972
986
  return reply.callNotFound();
973
987
  }
974
988
  // A request whose last path segment has a file extension is a static
@@ -989,16 +1003,7 @@ async function bootstrap() {
989
1003
  // client (the SW keeps serving the old precache). Content-hashed
990
1004
  // build assets (assets/index-<hash>.js) are immutable. Everything
991
1005
  // else gets a short cache.
992
- const base = rel.split('/').pop() ?? rel;
993
- if (/^(sw\.js|registerSW\.js|workbox-.*\.js|manifest\.webmanifest)$/.test(base)) {
994
- reply.header('cache-control', 'no-cache, must-revalidate');
995
- }
996
- else if (rel.startsWith('assets/') && /-[A-Za-z0-9_-]{8,}\./.test(base)) {
997
- reply.header('cache-control', 'public, max-age=31536000, immutable');
998
- }
999
- else {
1000
- reply.header('cache-control', 'no-cache');
1001
- }
1006
+ reply.header('cache-control', (0, spa_static_1.spaAssetCacheControl)(rel));
1002
1007
  return reply.sendFile(rel);
1003
1008
  }
1004
1009
  return reply.callNotFound();
@@ -1022,6 +1027,82 @@ async function bootstrap() {
1022
1027
  catch (err) {
1023
1028
  console.error('[bootstrap] Failed to set up admin UI static serving:', err);
1024
1029
  }
1030
+ // Serve the CamStack viewer SPA from the viewer-ui singleton capability.
1031
+ // The viewer is built (Expo web export) with EXPO_PUBLIC_WEB_BASE_PATH=/viewer,
1032
+ // so its baseUrl — and therefore every asset URL — is /viewer/camstack/**. We
1033
+ // mount it at exactly that prefix and redirect /viewer → the SPA root. Mirrors
1034
+ // the admin-ui poll+serve, but streams assets directly from its own dist root
1035
+ // so it stays independent of the admin-ui @fastify/static decorator.
1036
+ try {
1037
+ const bootAddonRegistry = app.get(addon_registry_service_1.AddonRegistryService);
1038
+ const capRegistry = bootAddonRegistry.getCapabilityRegistry();
1039
+ let viewerUI = capRegistry?.getSingleton('viewer-ui');
1040
+ const skipViewerUIWait = process.env['CAMSTACK_SKIP_VIEWER_UI_WAIT'] === '1';
1041
+ if (!viewerUI && capRegistry && !skipViewerUIWait) {
1042
+ // The viewer-ui addon runs in its own forked runner and registers a few
1043
+ // seconds after boot. Poll briefly (shorter than admin-ui's 60s — the
1044
+ // viewer is non-critical and admin-ui already gated the slow cold boot).
1045
+ const VIEWER_UI_WAIT_MS = 30_000;
1046
+ const POLL_MS = 200;
1047
+ const deadline = Date.now() + VIEWER_UI_WAIT_MS;
1048
+ while (!viewerUI && Date.now() < deadline) {
1049
+ await new Promise((r) => setTimeout(r, POLL_MS));
1050
+ viewerUI = capRegistry.getSingleton('viewer-ui');
1051
+ }
1052
+ }
1053
+ if (viewerUI) {
1054
+ const { staticDir } = await viewerUI.getStaticDir();
1055
+ const indexPath = path.join(staticDir, 'index.html');
1056
+ if (fs.existsSync(staticDir) && fs.existsSync(indexPath)) {
1057
+ // The Expo baseUrl (`${webBasePath}/camstack`) is the on-hub mount.
1058
+ const VIEWER_MOUNT = '/viewer/camstack';
1059
+ // Clean entry point: /viewer and /viewer/ → the SPA root.
1060
+ fastify.get('/viewer', async (_request, reply) => reply.redirect(`${VIEWER_MOUNT}/`));
1061
+ fastify.get('/viewer/', async (_request, reply) => reply.redirect(`${VIEWER_MOUNT}/`));
1062
+ // Single catch-all owns every GET under /viewer/**: assets stream LIVE
1063
+ // from the current dist; everything else is the SPA shell.
1064
+ fastify.get('/viewer/*', async (request, reply) => {
1065
+ const pathOnly = request.url.split('?')[0] ?? request.url;
1066
+ // Map the request path to a dist-relative path by stripping the mount
1067
+ // prefix. Anything not under the mount is treated as the SPA shell.
1068
+ let rel = '';
1069
+ if (pathOnly === VIEWER_MOUNT || pathOnly === `${VIEWER_MOUNT}/`) {
1070
+ rel = '';
1071
+ }
1072
+ else if (pathOnly.startsWith(`${VIEWER_MOUNT}/`)) {
1073
+ rel = pathOnly.slice(VIEWER_MOUNT.length + 1);
1074
+ }
1075
+ // A path whose last segment has a file extension is a static asset:
1076
+ // serve it, or 404 — NEVER the SPA shell (serving HTML under a
1077
+ // .js/.css URL pins text/html in caches and breaks the module MIME
1078
+ // check long after the file is actually available).
1079
+ if (rel && /\.[a-zA-Z0-9]+$/.test(pathOnly)) {
1080
+ const abs = path.join(staticDir, rel);
1081
+ if ((abs === staticDir || abs.startsWith(staticDir + path.sep)) && fs.existsSync(abs)) {
1082
+ reply.header('cache-control', (0, spa_static_1.spaAssetCacheControl)(rel));
1083
+ reply.type((0, spa_static_1.contentTypeForPath)(rel));
1084
+ return reply.send(fs.createReadStream(abs));
1085
+ }
1086
+ return reply.callNotFound();
1087
+ }
1088
+ // SPA shell — never cached (it references content-hashed bundles).
1089
+ reply.header('cache-control', 'no-cache, must-revalidate');
1090
+ return reply.type('text/html').send(fs.createReadStream(indexPath));
1091
+ });
1092
+ const { version } = await viewerUI.getVersion();
1093
+ console.log(`[bootstrap] Viewer UI served from: ${staticDir} at ${VIEWER_MOUNT} (v${version})`);
1094
+ }
1095
+ else {
1096
+ console.warn(`[bootstrap] Viewer UI dist not found at: ${staticDir} — run 'npm run build' in addon-viewer-ui`);
1097
+ }
1098
+ }
1099
+ else {
1100
+ console.warn('[bootstrap] viewer-ui capability not registered — viewer not served');
1101
+ }
1102
+ }
1103
+ catch (err) {
1104
+ console.error('[bootstrap] Failed to set up viewer UI static serving:', err);
1105
+ }
1025
1106
  try {
1026
1107
  await app.listen(port, host);
1027
1108
  }
@@ -1041,6 +1122,22 @@ async function bootstrap() {
1041
1122
  // Post-boot: fork workers, register device streams, emit system.boot
1042
1123
  const postBoot = app.get(post_boot_service_1.PostBootService);
1043
1124
  await postBoot.run({ port, host, dataPath, trpcRegistered });
1125
+ // The hub reached ready — when this boot is the PROBATION boot of a freshly
1126
+ // staged @camstack/server root, promote it (currentVersion ← pending, N-1
1127
+ // retained for rollback) and disarm the starter's crash-loop auto-rollback.
1128
+ try {
1129
+ const confirm = app.get(server_update_service_1.ServerUpdateService).confirmBootHealthy();
1130
+ if (confirm.promoted !== null) {
1131
+ logger.info('Server root package update confirmed healthy', {
1132
+ meta: { version: confirm.promoted },
1133
+ });
1134
+ }
1135
+ }
1136
+ catch (err) {
1137
+ logger.warn('Server root boot confirmation failed', {
1138
+ meta: { error: err instanceof Error ? err.message : String(err) },
1139
+ });
1140
+ }
1044
1141
  // One-time backfill: stamp integrationId on devices created before the
1045
1142
  // device-manager forwarder started stamping it (legacy camera providers),
1046
1143
  // so deleting their integration cascades them. Idempotent — only touches
@@ -1095,6 +1192,22 @@ function readHubVersion() {
1095
1192
  /**
1096
1193
  * Build an AddonHttpReply wrapper around a Fastify reply.
1097
1194
  */
1195
+ /**
1196
+ * Cache-Control for an addon MF bundle file. The entry point
1197
+ * (`remoteEntry.js` / `widgets.mjs`) has a STABLE filename — only its
1198
+ * `?v=` query changes across builds — so it MUST revalidate, otherwise a
1199
+ * caching proxy (Cloudflare edge, whose default browser-cache TTL was
1200
+ * serving these 4h) keeps handing out a stale entry whose export map
1201
+ * lacks the current widgets ("bundle does not export this stableId",
1202
+ * tunnel-only). The sibling chunks are CONTENT-HASHED in their filenames
1203
+ * (`dist-<hash>.mjs`, `_virtual_mf-<hash>.mjs`), so they are safely
1204
+ * immutable. The revalidation cost is a cheap 304 on a ~4KB entry.
1205
+ */
1206
+ function addonBundleCacheControl(filePath) {
1207
+ const base = path.basename(filePath);
1208
+ const isMfEntry = base === 'remoteEntry.js' || base === 'widgets.mjs';
1209
+ return isMfEntry ? 'no-cache, must-revalidate' : 'public, max-age=31536000, immutable';
1210
+ }
1098
1211
  function buildAddonReply(reply) {
1099
1212
  const wrapper = {
1100
1213
  status(code) {
@@ -72,7 +72,9 @@ const addon_registry_service_1 = require("./core/addon/addon-registry.service");
72
72
  const addon_search_service_1 = require("./core/addon/addon-search.service");
73
73
  const addon_package_service_1 = require("./core/addon/addon-package.service");
74
74
  const repl_engine_service_1 = require("./core/repl/repl-engine.service");
75
+ const server_update_service_1 = require("./core/server-update/server-update.service");
75
76
  const topology_emitter_service_1 = require("./core/topology/topology-emitter.service");
77
+ const cap_providers_1 = require("./api/core/cap-providers");
76
78
  const post_boot_service_1 = require("./boot/post-boot.service");
77
79
  // ---------------------------------------------------------------------------
78
80
  // Service container — narrowing via `instanceof`, no casts.
@@ -200,7 +202,14 @@ async function bootManual(opts) {
200
202
  },
201
203
  });
202
204
  const replEngineService = new repl_engine_service_1.ReplEngineService(addonRegistryService, eventBusService, loggingService);
203
- const topologyEmitterService = new topology_emitter_service_1.TopologyEmitterService(eventBusService, agentRegistryService, addonRegistryService);
205
+ // Runtime-updatable root package (phase 1: hub only). The service owns the
206
+ // data-dir server-root staging/rollback lifecycle; restarts route through
207
+ // AddonPackageService.restartServer for the marker + reconnect toast.
208
+ const serverUpdateService = new server_update_service_1.ServerUpdateService({
209
+ logger: loggingService.createLogger('ServerUpdate'),
210
+ restartServer: (requestedBy) => addonPackageService.restartServer(requestedBy),
211
+ });
212
+ const topologyEmitterService = new topology_emitter_service_1.TopologyEmitterService(eventBusService, agentRegistryService, addonRegistryService, (0, cap_providers_1.createNodeRootPackageLookup)(moleculerService, serverUpdateService));
204
213
  const postBootService = new post_boot_service_1.PostBootService(addonRegistryService, eventBusService, loggingService);
205
214
  // ---- Container ---------------------------------------------------------
206
215
  const container = new ServiceContainer();
@@ -224,6 +233,7 @@ async function bootManual(opts) {
224
233
  container.register(addon_search_service_1.AddonSearchService, addonSearchService);
225
234
  container.register(addon_package_service_1.AddonPackageService, addonPackageService);
226
235
  container.register(repl_engine_service_1.ReplEngineService, replEngineService);
236
+ container.register(server_update_service_1.ServerUpdateService, serverUpdateService);
227
237
  container.register(topology_emitter_service_1.TopologyEmitterService, topologyEmitterService);
228
238
  container.register(post_boot_service_1.PostBootService, postBootService);
229
239
  // ---- Fastify instance --------------------------------------------------