@camstack/server 1.2.540 → 1.2.542

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.
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ /**
3
+ * ONE list for the agent UI: every addon on this node with the runner that
4
+ * hosts it (D841). It used to be two lists — `$agent.status` addons and
5
+ * `$process.list` runners — that the operator had to join by eye.
6
+ *
7
+ * The addon → runner link is the runner's own `addonIds`, which the process
8
+ * service fills from the runner it actually spawned (`resolveRunnerId` at spawn
9
+ * time). Nothing here (and nothing in the UI) re-derives a grouping: an addon
10
+ * shares a runner exactly when the process service listed it in that runner's
11
+ * `addonIds`. A runner hosting no known addon is still a row, so a stray
12
+ * process stays visible.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.joinAddonRuntime = joinAddonRuntime;
16
+ exports.addonsFromStatus = addonsFromStatus;
17
+ exports.processesFromList = processesFromList;
18
+ function toRunner(p) {
19
+ return {
20
+ name: p.name,
21
+ pid: p.pid,
22
+ state: p.state,
23
+ groupId: p.groupId,
24
+ cpuPercent: p.cpuPercent,
25
+ memoryRss: p.memoryRss,
26
+ uptimeSeconds: p.uptimeSeconds,
27
+ addonIds: p.addonIds,
28
+ };
29
+ }
30
+ function joinAddonRuntime(addons, processes) {
31
+ const runnerByAddon = new Map();
32
+ for (const p of processes) {
33
+ for (const id of p.addonIds) {
34
+ if (!runnerByAddon.has(id))
35
+ runnerByAddon.set(id, p);
36
+ }
37
+ }
38
+ const known = new Set(addons.map((a) => a.id));
39
+ const addonRows = addons.map((a) => {
40
+ const runner = runnerByAddon.get(a.id);
41
+ return {
42
+ key: a.id,
43
+ kind: 'addon',
44
+ addonId: a.id,
45
+ packageName: a.packageName ?? null,
46
+ version: a.version ?? null,
47
+ status: a.status,
48
+ runner: runner === undefined ? null : toRunner(runner),
49
+ };
50
+ });
51
+ const strayRunners = processes
52
+ .filter((p) => !p.addonIds.some((id) => known.has(id)))
53
+ .map((p) => ({
54
+ key: `runner:${p.name}`,
55
+ kind: 'runner',
56
+ addonId: null,
57
+ packageName: null,
58
+ version: null,
59
+ status: p.state,
60
+ runner: toRunner(p),
61
+ }));
62
+ return [...addonRows, ...strayRunners];
63
+ }
64
+ function str(value) {
65
+ return typeof value === 'string' ? value : undefined;
66
+ }
67
+ function num(value) {
68
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
69
+ }
70
+ /** The addon rows of an `$agent.status` answer; unreadable rows are skipped. */
71
+ function addonsFromStatus(status) {
72
+ const raw = status !== null && typeof status === 'object' ? Reflect.get(status, 'addons') : null;
73
+ if (!Array.isArray(raw))
74
+ return [];
75
+ const out = [];
76
+ for (const item of raw) {
77
+ if (item === null || typeof item !== 'object')
78
+ continue;
79
+ const id = str(Reflect.get(item, 'id'));
80
+ if (id === undefined)
81
+ continue;
82
+ const version = str(Reflect.get(item, 'version'));
83
+ const packageName = str(Reflect.get(item, 'packageName'));
84
+ out.push({
85
+ id,
86
+ status: str(Reflect.get(item, 'status')) ?? 'unknown',
87
+ ...(version !== undefined ? { version } : {}),
88
+ ...(packageName !== undefined ? { packageName } : {}),
89
+ });
90
+ }
91
+ return out;
92
+ }
93
+ /** The runners of a `$process.list` answer; unreadable rows are skipped. */
94
+ function processesFromList(list) {
95
+ if (!Array.isArray(list))
96
+ return [];
97
+ const out = [];
98
+ for (const item of list) {
99
+ if (item === null || typeof item !== 'object')
100
+ continue;
101
+ const name = str(Reflect.get(item, 'name'));
102
+ if (name === undefined)
103
+ continue;
104
+ const ids = Reflect.get(item, 'addonIds');
105
+ out.push({
106
+ name,
107
+ pid: num(Reflect.get(item, 'pid')),
108
+ state: str(Reflect.get(item, 'state')) ?? 'unknown',
109
+ groupId: str(Reflect.get(item, 'groupId')) ?? name,
110
+ addonIds: Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [],
111
+ cpuPercent: num(Reflect.get(item, 'cpuPercent')),
112
+ memoryRss: num(Reflect.get(item, 'memoryRss')),
113
+ uptimeSeconds: num(Reflect.get(item, 'uptimeSeconds')),
114
+ });
115
+ }
116
+ return out;
117
+ }
@@ -42,27 +42,17 @@ exports.buildAgentAddonUpdateReport = buildAgentAddonUpdateReport;
42
42
  * Agent-local addon ALIGNMENT report — "is this node behind?", answered
43
43
  * WITHOUT the hub.
44
44
  *
45
- * ── Why this is read-only, and the update button is not here ──────────────
46
- * Updating an addon on an agent is hub-driven and CANNOT be initiated here:
47
- * the hub packs the version IT resolved and pushes the tarball over
48
- * `$agent.deploy` (`api/core/cap-providers.ts` `updatePackage`, agent branch).
49
- * There is no agent→hub trigger for that — the `addons` capability is
50
- * `mount: 'server-provided'`, i.e. it exists only inside the hub's tRPC
51
- * router and is never published as a Moleculer cap service, so an agent's
52
- * broker cannot reach it; and CLAUDE.md forbids minting a Moleculer action
53
- * for business logic to bridge the gap.
45
+ * ── Why this report installs nothing ─────────────────────────────────────
46
+ * An addon's version on an agent is decided by the HUB: every reconnect's
47
+ * back-fill restores the hub's installed version (`agent-addon-backfill.ts`).
48
+ * The Update action is therefore a request to the hub to bring a package to
49
+ * THAT version (`agent-hub-addon-update.ts`, D840) — never a local install, and
50
+ * never npm-latest: the hub routinely ships builds via `camstack deploy` that
51
+ * were never published, so a node that self-pulled npm-latest would run bytes
52
+ * the hub never sent and be undone at the next reconcile.
54
53
  *
55
- * Pulling the published tarball locally instead would be a SECOND install
56
- * channel resolving a DIFFERENT artifact: the hub routinely ships builds via
57
- * `camstack deploy` that were never published, so a node that self-pulled
58
- * npm-latest would run bytes the hub never sent and still report itself
59
- * updated. That is precisely the divergence the hub-push path exists to
60
- * prevent.
61
- *
62
- * So this module answers the question the isolated operator actually has —
63
- * *which of my addons are behind the published release?* — and the UI names
64
- * where the action lives. A read that cannot mislead beats a button that
65
- * cannot work.
54
+ * This module answers the read half: *which of my addons are behind the
55
+ * published release?* The hub-version half is joined in by the routes.
66
56
  */
67
57
  const fs = __importStar(require("node:fs"));
68
58
  const path = __importStar(require("node:path"));
@@ -38,6 +38,7 @@ const fs = __importStar(require("node:fs"));
38
38
  const path = __importStar(require("node:path"));
39
39
  const crypto = __importStar(require("node:crypto"));
40
40
  const os = __importStar(require("node:os"));
41
+ const node_1 = require("@camstack/types/node");
41
42
  /**
42
43
  * Generate a stable nodeId and persist it to the config file.
43
44
  *
@@ -72,9 +73,8 @@ function ensurePersistedNodeId(configPath, dataDir) {
72
73
  const id = envSeed && envSeed.length > 0 ? envSeed : `agent-${crypto.randomBytes(4).toString('hex')}`;
73
74
  raw.nodeId = id;
74
75
  try {
75
- fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
76
- // 0600: the same file holds the cluster secret.
77
- fs.writeFileSync(resolvedPath, JSON.stringify(raw, null, 2), { encoding: 'utf-8', mode: 0o600 });
76
+ // Owner-only: the same file holds the cluster secret.
77
+ (0, node_1.writeOwnerOnlyFileSync)(resolvedPath, JSON.stringify(raw, null, 2));
78
78
  }
79
79
  catch {
80
80
  /* best-effort */
@@ -82,10 +82,13 @@ function isPairingRequest(method, urlPath) {
82
82
  if (method === 'GET') {
83
83
  return (pathOnly === '/api/agent/status' ||
84
84
  pathOnly === '/api/agent/config' ||
85
- pathOnly === '/api/agent/discovered-nodes');
85
+ pathOnly === '/api/agent/discovered-nodes' ||
86
+ pathOnly === '/api/agent/hubs-found');
86
87
  }
87
88
  if (method === 'POST') {
88
- return pathOnly === '/api/agent/config' || pathOnly === '/api/agent/restart';
89
+ return (pathOnly === '/api/agent/config' ||
90
+ pathOnly === '/api/agent/restart' ||
91
+ pathOnly === '/api/agent/hubs-found/refresh');
89
92
  }
90
93
  return false;
91
94
  }
@@ -47,6 +47,7 @@ exports.pickUiDist = pickUiDist;
47
47
  exports.resolveUiDistDir = resolveUiDistDir;
48
48
  exports.getRegistryNodes = getRegistryNodes;
49
49
  exports.resolveHubEndpoint = resolveHubEndpoint;
50
+ exports.isOpaqueHostname = isOpaqueHostname;
50
51
  exports.nodeDisplayName = nodeDisplayName;
51
52
  exports.mapDiscoveredNodes = mapDiscoveredNodes;
52
53
  exports.createAgentHttpServer = createAgentHttpServer;
@@ -57,10 +58,13 @@ const path = __importStar(require("node:path"));
57
58
  const fastify_1 = __importDefault(require("fastify"));
58
59
  const system_1 = require("@camstack/system");
59
60
  const agent_http_auth_js_1 = require("./agent-http-auth.js");
61
+ const node_1 = require("@camstack/types/node");
60
62
  const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
61
63
  const agent_update_routes_js_1 = require("./agent-update-routes.js");
62
64
  const derive_hub_url_js_1 = require("./derive-hub-url.js");
63
65
  Object.defineProperty(exports, "pickIpv4", { enumerable: true, get: function () { return derive_hub_url_js_1.pickIpv4; } });
66
+ const agent_addon_runtime_js_1 = require("./agent-addon-runtime.js");
67
+ const hubs_found_js_1 = require("./hubs-found.js");
64
68
  /** Dotted-numeric version compare; `null` sorts lowest. */
65
69
  function compareVersions(a, b) {
66
70
  if (a === null && b === null)
@@ -155,11 +159,9 @@ function readConfigFile(configPath) {
155
159
  return {};
156
160
  }
157
161
  }
158
- /** agent.json holds the cluster secret (D797): always 0600. */
162
+ /** agent.json holds the cluster secret (D797): owner-only on every OS. */
159
163
  function writeConfigFile(configPath, data) {
160
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
161
- fs.writeFileSync(configPath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
162
- fs.chmodSync(configPath, 0o600);
164
+ (0, node_1.writeOwnerOnlyFileSync)(configPath, JSON.stringify(data, null, 2));
163
165
  }
164
166
  /** Live Moleculer registry snapshot — reused by agent-bootstrap's Option A. */
165
167
  function getRegistryNodes(broker) {
@@ -182,10 +184,17 @@ function resolveHubEndpoint(nodes) {
182
184
  return null;
183
185
  return (0, derive_hub_url_js_1.pickIpv4)(hub.ipList) ?? hub.hostname ?? null;
184
186
  }
185
- /** Prefer a name an operator can recognise over the bare Moleculer node id. */
187
+ /** A container id (12 hex) or full id (64 hex): a name nobody chose. */
188
+ function isOpaqueHostname(hostname) {
189
+ return /^[0-9a-f]{12}$|^[0-9a-f]{64}$/.test(hostname);
190
+ }
191
+ /**
192
+ * Prefer a name an operator can recognise over the bare Moleculer node id —
193
+ * and over a container's id, which is what the hostname of a hub in Docker is.
194
+ */
186
195
  function nodeDisplayName(node) {
187
196
  const hostname = typeof node.hostname === 'string' ? node.hostname.trim() : '';
188
- if (hostname.length > 0 && hostname !== node.id)
197
+ if (hostname.length > 0 && hostname !== node.id && !isOpaqueHostname(hostname))
189
198
  return hostname;
190
199
  const ip = (0, derive_hub_url_js_1.pickIpv4)(node.ipList);
191
200
  if (ip !== null)
@@ -214,12 +223,12 @@ function mapDiscoveredNodes(nodes, selfId) {
214
223
  * external getter is wired. Does NOT detect `secret-mismatch` — that
215
224
  * requires the bootstrap to inject `getHubConnectionState`.
216
225
  */
217
- function deriveHubConnectionState(nodes, discoveryMode) {
226
+ function deriveHubConnectionState(nodes, hubAddressConfigured) {
218
227
  const hubInRegistry = nodes.some((n) => n.id === 'hub');
219
228
  if (hubInRegistry)
220
229
  return 'connected';
221
- if (discoveryMode)
222
- return 'searching';
230
+ if (!hubAddressConfigured)
231
+ return 'unconfigured';
223
232
  return 'disconnected';
224
233
  }
225
234
  /**
@@ -325,10 +334,9 @@ async function createAgentHttpServer(getBroker, config) {
325
334
  const eff = getEffectiveConfig(config.configPath, config.nodeId);
326
335
  const nodes = getRegistryNodes(broker);
327
336
  const hubConnected = nodes.some((n) => n.id === 'hub');
328
- const discoveryMode = !eff.hubAddress;
329
337
  const hubConnectionState = config.getHubConnectionState
330
338
  ? config.getHubConnectionState()
331
- : deriveHubConnectionState(nodes, discoveryMode);
339
+ : deriveHubConnectionState(nodes, Boolean(eff.hubAddress));
332
340
  const meshTransport = config.getMeshTransport
333
341
  ? config.getMeshTransport()
334
342
  : eff.hasMeshTls
@@ -357,7 +365,6 @@ async function createAgentHttpServer(getBroker, config) {
357
365
  appVersion,
358
366
  hubConnected,
359
367
  hubConnectionState,
360
- discoveryMode,
361
368
  hasSecret: eff.hasSecret,
362
369
  meshTransport,
363
370
  discoveredNodes,
@@ -373,7 +380,6 @@ async function createAgentHttpServer(getBroker, config) {
373
380
  appVersion,
374
381
  hubConnected,
375
382
  hubConnectionState,
376
- discoveryMode,
377
383
  hasSecret: eff.hasSecret,
378
384
  meshTransport,
379
385
  discoveredNodes,
@@ -431,6 +437,15 @@ async function createAgentHttpServer(getBroker, config) {
431
437
  return [];
432
438
  }
433
439
  });
440
+ // -- Addons joined with their runners (D841) ---------------------------
441
+ app.get('/api/agent/addon-runtime', async () => {
442
+ const broker = getBroker();
443
+ const [status, list] = await Promise.all([
444
+ broker.call('$agent.status').catch(() => null),
445
+ broker.call('$process.list').catch(() => []),
446
+ ]);
447
+ return (0, agent_addon_runtime_js_1.joinAddonRuntime)((0, agent_addon_runtime_js_1.addonsFromStatus)(status), (0, agent_addon_runtime_js_1.processesFromList)(list));
448
+ });
434
449
  // -- Addon restart --------------------------------------------------
435
450
  app.post('/api/agent/addon/restart', async (req, reply) => {
436
451
  const addonId = req.body?.addonId;
@@ -553,6 +568,17 @@ async function createAgentHttpServer(getBroker, config) {
553
568
  // Registered AFTER the onRequest hook above, so every path it mounts is
554
569
  // gated by the same loopback-or-bearer rule as the rest of `/api/`.
555
570
  (0, agent_update_routes_js_1.registerAgentUpdateRoutes)(app, config.updateControls ?? {});
571
+ // -- Hubs found on this network (D839) ------------------------------
572
+ // The cached list is free; the refresh browses mDNS for a few seconds. The
573
+ // UI asks on demand (opening the edit form, "Search again"), never on its
574
+ // status poll.
575
+ const hubsFinder = config.hubsFinder ??
576
+ (0, hubs_found_js_1.createHubsFinder)({
577
+ getConfiguredHubAddress: () => getEffectiveConfig(config.configPath, config.nodeId).hubAddress,
578
+ log: (message) => console.warn(`[Agent] ${message}`),
579
+ });
580
+ app.get('/api/agent/hubs-found', async () => hubsFinder.snapshot());
581
+ app.post('/api/agent/hubs-found/refresh', async () => hubsFinder.refresh());
556
582
  // -- Discovered nodes -----------------------------------------------
557
583
  app.get('/api/agent/discovered-nodes', async () => {
558
584
  const b = getBroker();
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HUB_UPDATE_TIMEOUT_MS = exports.HUB_ADDON_UPDATE_ACTION = exports.HUB_ADDON_LIST_ACTION = void 0;
4
+ exports.hubVersionsFromList = hubVersionsFromList;
5
+ exports.isBehindHub = isBehindHub;
6
+ exports.createAgentHubAddonControls = createAgentHubAddonControls;
7
+ /**
8
+ * Updating an addon from the agent itself (D840) — by asking the HUB.
9
+ *
10
+ * ── Who decides an agent's addon version ───────────────────────────────────
11
+ * The hub. Every reconnect runs `reconcileAgentAddons` → `agent-addon-backfill`,
12
+ * whose INTENDED VERSION is "the hub's own installed version, and only that".
13
+ * A package installed on the agent at any other version is put back to the
14
+ * hub's — which is why an update run on an agent before the hub is undone, and
15
+ * why this module never installs anything locally.
16
+ *
17
+ * ── What the Update action does ───────────────────────────────────────────
18
+ * It asks the hub, over the mesh channel the agent already holds (the mTLS
19
+ * leaf; the bridge names the caller `node:<id>`), to deploy THE HUB'S version
20
+ * of one package to THIS node: `addons.updatePackage({ name, version:
21
+ * <hub version>, nodeId: <self> })`. The hub packs and pushes the tarball
22
+ * (`$agent.deploy` + `$agent.reload`) and records nothing new — the version
23
+ * it recorded is the version this node now runs, so the next reconcile finds
24
+ * nothing to repair. A newer PUBLISHED release is a hub-level decision (it
25
+ * moves every node), not something one agent can start.
26
+ *
27
+ * The hub accepts the call from an agent only under the scoped grant of D842
28
+ * (`remote-addon-update-grant.ts`): own node, the hub's recorded version, audited.
29
+ * Any refusal reaches the UI verbatim as the update's failure, never as silence.
30
+ */
31
+ const index_js_1 = require("../server-root/index.js");
32
+ /** Hub procedures this module calls, as `$core-caps` actions. */
33
+ exports.HUB_ADDON_LIST_ACTION = '$core-caps.addons.list';
34
+ exports.HUB_ADDON_UPDATE_ACTION = '$core-caps.addons.updatePackage';
35
+ /** An agent's deploy + reload of a large package can take minutes. */
36
+ exports.HUB_UPDATE_TIMEOUT_MS = 330_000;
37
+ const HUB_LIST_TIMEOUT_MS = 15_000;
38
+ function errorMessage(err) {
39
+ return err instanceof Error ? err.message : String(err);
40
+ }
41
+ /** `packageName → packageVersion` from an `addons.list` answer; unreadable rows are skipped. */
42
+ function hubVersionsFromList(list) {
43
+ const out = new Map();
44
+ if (!Array.isArray(list))
45
+ return out;
46
+ for (const item of list) {
47
+ if (item === null || typeof item !== 'object')
48
+ continue;
49
+ const manifest = Reflect.get(item, 'manifest');
50
+ if (manifest === null || typeof manifest !== 'object')
51
+ continue;
52
+ const name = Reflect.get(manifest, 'packageName');
53
+ const version = Reflect.get(manifest, 'packageVersion');
54
+ if (typeof name === 'string' && typeof version === 'string' && !out.has(name)) {
55
+ out.set(name, version);
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+ /** True when the hub's version is strictly newer than this node's. */
61
+ function isBehindHub(installed, hub) {
62
+ return installed !== null && hub !== null && (0, index_js_1.compareSemver)(hub, installed) > 0;
63
+ }
64
+ /** The hub answered `{ success: false, error }`, or something unreadable. */
65
+ function failureOf(result) {
66
+ if (result === null || typeof result !== 'object')
67
+ return null;
68
+ if (Reflect.get(result, 'success') !== false)
69
+ return null;
70
+ const error = Reflect.get(result, 'error');
71
+ return typeof error === 'string' && error.length > 0 ? error : 'the hub reported a failure';
72
+ }
73
+ function createAgentHubAddonControls(deps) {
74
+ const states = new Map();
75
+ const getHubVersions = async () => {
76
+ try {
77
+ const list = await deps.callHub(exports.HUB_ADDON_LIST_ACTION, undefined, HUB_LIST_TIMEOUT_MS);
78
+ return { ok: true, versions: hubVersionsFromList(list) };
79
+ }
80
+ catch (err) {
81
+ return { ok: false, error: errorMessage(err) };
82
+ }
83
+ };
84
+ const run = async (packageName, version) => {
85
+ try {
86
+ const result = await deps.callHub(exports.HUB_ADDON_UPDATE_ACTION, { name: packageName, version, nodeId: deps.nodeId }, exports.HUB_UPDATE_TIMEOUT_MS);
87
+ const failure = failureOf(result);
88
+ states.set(packageName, failure === null
89
+ ? { status: 'done', targetVersion: version, message: null }
90
+ : { status: 'failed', targetVersion: version, message: failure });
91
+ if (failure !== null) {
92
+ deps.onProblem?.(`hub refused to update ${packageName} to ${version}: ${failure}`);
93
+ }
94
+ }
95
+ catch (err) {
96
+ const message = errorMessage(err);
97
+ states.set(packageName, { status: 'failed', targetVersion: version, message });
98
+ deps.onProblem?.(`update of ${packageName} to ${version} through the hub failed: ${message}`);
99
+ }
100
+ };
101
+ return {
102
+ getHubVersions,
103
+ progress: () => states,
104
+ start: async (packageName, installedPackages) => {
105
+ if (!installedPackages.includes(packageName)) {
106
+ return { started: false, message: `${packageName} is not installed on this node.` };
107
+ }
108
+ if (states.get(packageName)?.status === 'running') {
109
+ return { started: false, message: `${packageName} is already being updated.` };
110
+ }
111
+ const hub = await getHubVersions();
112
+ if (!hub.ok) {
113
+ return {
114
+ started: false,
115
+ message: `The hub could not be asked which version this node should run: ${hub.error}`,
116
+ };
117
+ }
118
+ const hubVersion = hub.versions.get(packageName);
119
+ if (hubVersion === undefined) {
120
+ return {
121
+ started: false,
122
+ message: `The hub does not install ${packageName}, so it has no version to bring this node to.`,
123
+ };
124
+ }
125
+ states.set(packageName, { status: 'running', targetVersion: hubVersion, message: null });
126
+ // Not awaited: a deploy + reload outlives any request. The UI polls.
127
+ void run(packageName, hubVersion);
128
+ return { started: true, message: null };
129
+ },
130
+ };
131
+ }
@@ -64,8 +64,8 @@ const node_crypto_1 = require("node:crypto");
64
64
  const fs = __importStar(require("node:fs"));
65
65
  const http = __importStar(require("node:http"));
66
66
  const https = __importStar(require("node:https"));
67
- const path = __importStar(require("node:path"));
68
67
  const system_1 = require("@camstack/system");
68
+ const node_1 = require("@camstack/types/node");
69
69
  class MeshEnrollError extends Error {
70
70
  constructor(message) {
71
71
  super(message);
@@ -283,12 +283,9 @@ function readJsonFile(file) {
283
283
  function withoutJoinToken(record) {
284
284
  return Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'joinToken'));
285
285
  }
286
+ /** Owner-only on every OS (an owner+SYSTEM ACL on Windows, where 0600 is a no-op). */
286
287
  function writeJsonFile0600(file, data) {
287
- fs.mkdirSync(path.dirname(file), { recursive: true });
288
- const tmp = `${file}.tmp`;
289
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
290
- fs.renameSync(tmp, file);
291
- fs.chmodSync(file, 0o600);
288
+ (0, node_1.writeOwnerOnlyFileSync)(file, JSON.stringify(data, null, 2));
292
289
  }
293
290
  /**
294
291
  * Write the enrollment into `agent.json` (0600): `meshTls` + the host-only
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ADDON_UPDATE_UNAVAILABLE_REASON = exports.SERVER_UPDATE_UNAVAILABLE_REASON = exports.APPLY_ALREADY_RUNNING_MESSAGE = void 0;
3
+ exports.ADDON_HUB_UPDATE_UNAVAILABLE_REASON = exports.ADDON_UPDATE_UNAVAILABLE_REASON = exports.SERVER_UPDATE_UNAVAILABLE_REASON = exports.APPLY_ALREADY_RUNNING_MESSAGE = void 0;
4
4
  exports.createAgentServerUpdateControls = createAgentServerUpdateControls;
5
5
  exports.registerAgentUpdateRoutes = registerAgentUpdateRoutes;
6
+ const agent_hub_addon_update_js_1 = require("./agent-hub-addon-update.js");
6
7
  /** The message a second concurrent apply gets. Exported so the test names it once. */
7
8
  exports.APPLY_ALREADY_RUNNING_MESSAGE = 'Refused: an update is already in flight on this node.';
8
9
  /**
@@ -66,6 +67,25 @@ function createAgentServerUpdateControls(engine, onError) {
66
67
  exports.SERVER_UPDATE_UNAVAILABLE_REASON = 'Framework updates are not available on this agent build.';
67
68
  /** Why the addon report renders empty when nothing was wired. */
68
69
  exports.ADDON_UPDATE_UNAVAILABLE_REASON = 'The addon roster is not available on this agent build.';
70
+ /** Why the Update action is absent when the hub channel was not wired. */
71
+ exports.ADDON_HUB_UPDATE_UNAVAILABLE_REASON = 'Updating an addon through the hub is not available on this agent build.';
72
+ async function withHubVersions(report, hub) {
73
+ const versions = hub === undefined ? null : await hub.getHubVersions();
74
+ const progress = hub?.progress();
75
+ return {
76
+ ...report,
77
+ hubVersionsError: versions === null || versions.ok ? null : versions.error,
78
+ entries: report.entries.map((entry) => {
79
+ const hubVersion = versions?.ok === true ? (versions.versions.get(entry.packageName) ?? null) : null;
80
+ return {
81
+ ...entry,
82
+ hubVersion,
83
+ behindHub: (0, agent_hub_addon_update_js_1.isBehindHub)(entry.installedVersion, hubVersion),
84
+ update: progress?.get(entry.packageName) ?? null,
85
+ };
86
+ }),
87
+ };
88
+ }
69
89
  /**
70
90
  * Mount the update routes on an already-built agent HTTP server. Called from
71
91
  * `createAgentHttpServer` AFTER the auth hook, so every path here inherits it.
@@ -96,6 +116,18 @@ function registerAgentUpdateRoutes(app, controls) {
96
116
  if (addons === undefined) {
97
117
  return { available: false, reason: exports.ADDON_UPDATE_UNAVAILABLE_REASON };
98
118
  }
99
- return { available: true, ...(await addons.getReport()) };
119
+ const report = await addons.getReport();
120
+ return { available: true, ...(await withHubVersions(report, addons.hub)) };
121
+ });
122
+ app.post('/api/agent/addon-updates/update', async (req, reply) => {
123
+ if (addons?.hub === undefined) {
124
+ return reply.status(503).send({ error: exports.ADDON_HUB_UPDATE_UNAVAILABLE_REASON });
125
+ }
126
+ const packageName = req.body?.packageName;
127
+ if (typeof packageName !== 'string' || packageName.length === 0) {
128
+ return reply.status(400).send({ error: 'packageName required' });
129
+ }
130
+ const report = await addons.getReport();
131
+ return addons.hub.start(packageName, report.entries.map((e) => e.packageName));
100
132
  });
101
133
  }
@@ -28,8 +28,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.deriveHubUrlFromHubAddress = deriveHubUrlFromHubAddress;
29
29
  exports.deriveHubUrlForExport = deriveHubUrlForExport;
30
30
  exports.pickIpv4 = pickIpv4;
31
- exports.normalizeObservedHost = normalizeObservedHost;
32
- exports.deriveHubUrlFromRegistry = deriveHubUrlFromRegistry;
33
31
  const HUB_API_PORT = 4443;
34
32
  function deriveHubUrlFromHubAddress(hubAddress) {
35
33
  const trimmed = hubAddress.trim();
@@ -72,15 +70,10 @@ function extractHostFromAuthority(authority) {
72
70
  const host = value.split(':')[0] ?? '';
73
71
  return host.length > 0 ? host : undefined;
74
72
  }
75
- /** Node id the hub broker always registers under. */
76
- const HUB_NODE_ID = 'hub';
77
- const IPV4_MAPPED_PREFIX = '::ffff:';
78
- const IPV4_DOTTED_QUAD = /^\d{1,3}(?:\.\d{1,3}){3}$/;
79
73
  /**
80
74
  * First non-internal IPv4 in a Moleculer node's advertised IP list.
81
75
  * Canonical home of this helper — `resolveHubEndpoint` in `agent-http.ts`
82
- * (UI-facing `resolvedHubAddress`) re-exports and reuses it so the UI display
83
- * and the exported `CAMSTACK_HUB_URL` always agree.
76
+ * (UI-facing `resolvedHubAddress`) re-exports and reuses it.
84
77
  */
85
78
  function pickIpv4(ipList) {
86
79
  if (!ipList)
@@ -94,46 +87,3 @@ function pickIpv4(ipList) {
94
87
  }
95
88
  return null;
96
89
  }
97
- /**
98
- * Normalize a socket-observed address so it survives a WHATWG-`URL` hostname
99
- * write (`substituteRtspHost`): strip the `::ffff:` IPv4-mapped prefix,
100
- * bracket bare IPv6 (an unbracketed IPv6 assigned to `url.hostname` is
101
- * silently IGNORED — the URL would keep 127.0.0.1), pass plain IPv4 /
102
- * hostnames / already-bracketed IPv6 through untouched.
103
- */
104
- function normalizeObservedHost(raw) {
105
- if (raw === undefined || raw === null)
106
- return undefined;
107
- const trimmed = raw.trim();
108
- if (trimmed.length === 0)
109
- return undefined;
110
- if (trimmed.startsWith('['))
111
- return trimmed; // already-bracketed IPv6
112
- if (trimmed.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)) {
113
- const mapped = trimmed.slice(IPV4_MAPPED_PREFIX.length);
114
- if (IPV4_DOTTED_QUAD.test(mapped))
115
- return mapped;
116
- }
117
- // Any remaining colon means bare IPv6 — bracket it.
118
- if (trimmed.includes(':'))
119
- return `[${trimmed}]`;
120
- return trimmed;
121
- }
122
- /**
123
- * Best hub host from the agent-side Moleculer registry view:
124
- * `udpAddress` (wire truth) → first non-loopback IPv4 in `ipList`
125
- * (hub self-advertised; container-internal in bridge-mode Docker) →
126
- * `hostname` (hub's `os.hostname()`; often unresolvable — last resort).
127
- * Returns `undefined` when the hub node is not (yet) known. Emits the SAME
128
- * `https://host:4443` shape as `deriveHubUrlFromHubAddress` — see the port
129
- * note at the top of this file.
130
- */
131
- function deriveHubUrlFromRegistry(nodes) {
132
- const hub = nodes.find((node) => node.id === HUB_NODE_ID);
133
- if (hub === undefined)
134
- return undefined;
135
- const host = normalizeObservedHost(hub.udpAddress) ?? pickIpv4(hub.ipList) ?? hub.hostname;
136
- if (host === undefined || host.length === 0)
137
- return undefined;
138
- return `https://${host}:${HUB_API_PORT}`;
139
- }
@@ -0,0 +1,100 @@
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.CONFIGURED_HUB_SOURCE = void 0;
37
+ exports.createHubsFinder = createHubsFinder;
38
+ const derive_hub_url_js_1 = require("./derive-hub-url.js");
39
+ /** Label of the configured hub in {@link FoundHub.sources}. */
40
+ exports.CONFIGURED_HUB_SOURCE = 'configured hub address';
41
+ /** A scan newer than this answers a refresh without scanning again. */
42
+ const MIN_RESCAN_INTERVAL_MS = 5_000;
43
+ const BROWSE_TIMEOUT_MS = 3_000;
44
+ function toFoundHub(candidate) {
45
+ return {
46
+ url: candidate.url,
47
+ host: candidate.host,
48
+ nodeId: candidate.nodeId,
49
+ version: candidate.version,
50
+ sources: candidate.sources,
51
+ };
52
+ }
53
+ async function defaultDiscover(deps) {
54
+ // Lazy: the socket library is only loaded when an operator asks.
55
+ const { discoverHubs } = await Promise.resolve().then(() => __importStar(require('@camstack/develop/hub-discovery')));
56
+ return discoverHubs(deps);
57
+ }
58
+ function createHubsFinder(deps) {
59
+ const discover = deps.discover ?? defaultDiscover;
60
+ const now = deps.now ?? Date.now;
61
+ let hubs = [];
62
+ let scannedAt = null;
63
+ let inFlight = null;
64
+ const snapshot = () => ({ hubs, scannedAt, scanning: inFlight !== null });
65
+ const scan = async () => {
66
+ const configured = deps.getConfiguredHubAddress();
67
+ const configuredUrl = configured === null ? undefined : (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(configured);
68
+ try {
69
+ const found = await discover({
70
+ mdnsTimeoutMs: BROWSE_TIMEOUT_MS,
71
+ known: configuredUrl === undefined
72
+ ? []
73
+ : [{ url: configuredUrl, source: exports.CONFIGURED_HUB_SOURCE }],
74
+ onProblem: (message) => deps.log?.(`hub browse problem: ${message}`),
75
+ });
76
+ hubs = found.map(toFoundHub);
77
+ }
78
+ catch (err) {
79
+ // A failed scan keeps the previous list: unknown is not "no hub".
80
+ deps.log?.(`hub scan failed: ${err instanceof Error ? err.message : String(err)}`);
81
+ }
82
+ scannedAt = now();
83
+ return { hubs, scannedAt, scanning: false };
84
+ };
85
+ return {
86
+ snapshot,
87
+ refresh: () => {
88
+ if (inFlight !== null)
89
+ return inFlight;
90
+ if (scannedAt !== null && now() - scannedAt < MIN_RESCAN_INTERVAL_MS) {
91
+ return Promise.resolve(snapshot());
92
+ }
93
+ const running = scan().finally(() => {
94
+ inFlight = null;
95
+ });
96
+ inFlight = running;
97
+ return running;
98
+ },
99
+ };
100
+ }
@@ -51,6 +51,7 @@ const agent_group_runner_js_1 = require("./agent-group-runner.js");
51
51
  const agent_http_js_1 = require("./agent-http.js");
52
52
  const agent_infra_capabilities_js_1 = require("./agent-infra-capabilities.js");
53
53
  const agent_service_js_1 = require("./agent-service.js");
54
+ const agent_hub_addon_update_js_1 = require("./agent-hub-addon-update.js");
54
55
  const agent_update_routes_js_1 = require("./agent-update-routes.js");
55
56
  const agent_update_service_js_1 = require("./agent-update-service.js");
56
57
  const derive_hub_url_js_1 = require("./derive-hub-url.js");
@@ -122,7 +123,7 @@ async function startAgent(configPath) {
122
123
  console.log(`[Agent] Starting node "${config.nodeId}" (name="${config.name}") connecting to ${config.hubAddress}`);
123
124
  }
124
125
  else {
125
- console.log(`[Agent] Starting node "${config.nodeId}" (name="${config.name}") in discovery mode`);
126
+ console.log(`[Agent] Starting node "${config.nodeId}" (name="${config.name}") with no hub address — set one from the agent UI`);
126
127
  }
127
128
  console.log(`[Agent] Config: ${config.configPath}`);
128
129
  console.log(`[Agent] Data dir: ${config.dataDir}`);
@@ -230,10 +231,9 @@ async function startAgent(configPath) {
230
231
  }
231
232
  // Reload config from file (UI may have written new hubAddress/secret)
232
233
  const fresh = (0, agent_config_js_1.loadAgentConfig)(undefined, config.dataDir);
233
- console.log(`[Agent] New config: hub=${fresh.hubAddress ?? 'discovery'}, secret=${fresh.secret ? 'yes' : 'none'}, ` +
234
+ console.log(`[Agent] New config: hub=${fresh.hubAddress ?? 'none'}, secret=${fresh.secret ? 'yes' : 'none'}, ` +
234
235
  `mesh=${fresh.meshTls !== undefined ? 'tls' : 'clear'}`);
235
- // Re-derive CAMSTACK_HUB_URL for the discovery-mode → dashboard-configured
236
- // flow (config file freshly written a hubAddress). Explicit operator values
236
+ // Re-derive CAMSTACK_HUB_URL when the dashboard has just written a hubAddress. Explicit operator values
237
237
  // still win via the boot-captured `hubUrlWasExplicit` flag.
238
238
  // KNOWN LIMITATION (Stage-0): runners already spawned before this reconnect
239
239
  // keep the env they inherited; picking up a changed hub address in live
@@ -279,7 +279,7 @@ async function startAgent(configPath) {
279
279
  // regardless of registry state.
280
280
  if (hubConnectionStateOverride !== null)
281
281
  return hubConnectionStateOverride;
282
- // Fall through to live registry-derived state (connected/searching/disconnected).
282
+ // Fall through to live registry-derived state (connected/unconfigured/disconnected).
283
283
  // getRegistryNodes-equivalent inline: broker.registry.getNodeList
284
284
  try {
285
285
  const registry = broker.registry;
@@ -291,8 +291,8 @@ async function startAgent(configPath) {
291
291
  /* fall through */
292
292
  }
293
293
  const configNow = readConfigFile(config.configPath);
294
- const discoveryMode = typeof configNow.hubAddress !== 'string' || configNow.hubAddress.length === 0;
295
- return discoveryMode ? 'searching' : 'disconnected';
294
+ const hubAddressConfigured = typeof configNow.hubAddress === 'string' && configNow.hubAddress.length > 0;
295
+ return hubAddressConfigured ? 'disconnected' : 'unconfigured';
296
296
  }
297
297
  function readConfigFile(p) {
298
298
  if (!fs.existsSync(p))
@@ -773,45 +773,6 @@ async function startAgent(configPath) {
773
773
  // discovery resolve (or time out cleanly via the read-blob fallback)
774
774
  // so agent boot completes instead of hanging on the first infra addon.
775
775
  await broker.start();
776
- // ── Registry-derived CAMSTACK_HUB_URL (Option A — discovery-mode fallback) ──
777
- // In UDP-discovery mode `config.hubAddress` is null, so the boot-time
778
- // derivation above (Option B) produced nothing. The agent parent is the only
779
- // agent-side process with a Moleculer registry (addon-runner children are
780
- // broker-less), so derive the hub host from the LIVE hub connection —
781
- // `udpAddress` is wire truth — and export it through the same env contract
782
- // before the runner spawns in loadClusterCapableAddons below.
783
- // Precedence: explicit operator env > config-derived (B) > registry (A).
784
- // A fills only the empty case; on later hub (re)connects it refreshes only
785
- // its OWN previous value (hub IP change), never an explicit or B-derived one.
786
- // BOOT-ORDER LIMITATION (Stage-0): a runner spawned before the first hub
787
- // connect in discovery mode inherits an empty env until it is respawned —
788
- // same class as Option B's reconnect limitation documented above. The first
789
- // hub connect normally precedes any orchestrator attach dispatch, but the
790
- // child env snapshot is fixed at spawn time.
791
- let hubUrlFromRegistry;
792
- const reconcileHubUrlFromRegistry = () => {
793
- if (hubUrlWasExplicit)
794
- return;
795
- const current = process.env['CAMSTACK_HUB_URL'];
796
- if (current !== undefined && current !== hubUrlFromRegistry)
797
- return;
798
- const fromRegistry = (0, derive_hub_url_js_1.deriveHubUrlFromRegistry)((0, agent_http_js_1.getRegistryNodes)(broker));
799
- if (fromRegistry === undefined || fromRegistry === current)
800
- return;
801
- process.env['CAMSTACK_HUB_URL'] = fromRegistry;
802
- hubUrlFromRegistry = fromRegistry;
803
- console.log(`[Agent] Derived CAMSTACK_HUB_URL=${fromRegistry} from live hub connection`);
804
- };
805
- // Immediate attempt (the hub may already be in the registry), then reconcile
806
- // on every hub (re)connect via the `$node.connected` localBus idiom — NO
807
- // timers, NO registry polling (CLAUDE.md invariant).
808
- reconcileHubUrlFromRegistry();
809
- broker.localBus.on('$node.connected', (data) => {
810
- const node = data.node;
811
- if (node?.id !== 'hub')
812
- return;
813
- reconcileHubUrlFromRegistry();
814
- });
815
776
  // C2: wire the UDS ↔ Moleculer event bridge so events emitted by UDS
816
777
  // children fan to siblings and reach the cluster, and cluster / parent-
817
778
  // local events propagate to every UDS child. Inert when agentUdsRegistry
@@ -870,6 +831,13 @@ async function startAgent(configPath) {
870
831
  });
871
832
  }),
872
833
  addons: {
834
+ hub: (0, agent_hub_addon_update_js_1.createAgentHubAddonControls)({
835
+ nodeId: config.nodeId,
836
+ // The mesh channel the agent already holds; the hub names this caller
837
+ // `node:<id>` and answers per its remote privilege grant.
838
+ callHub: (action, params, timeoutMs) => broker.call(action, params, { nodeID: 'hub', timeout: timeoutMs }),
839
+ onProblem: (message) => consoleLogger.warn(message, { tags: { nodeId: config.nodeId } }),
840
+ }),
873
841
  getReport: () => (0, agent_addon_updates_js_1.buildAgentAddonUpdateReport)({
874
842
  addonsDir: config.addonsDir,
875
843
  ...(process.env['CAMSTACK_NPM_REGISTRY'] !== undefined
@@ -18,7 +18,7 @@
18
18
  * routes the call exactly like any addon-provided cap.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.REMOTE_PRIVILEGE_LOG_ONLY = exports.REMOTE_PRIVILEGE_ALLOWLIST = exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE = exports.REMOTE_DENIED_NAMESPACES = void 0;
21
+ exports.REMOTE_PRIVILEGE_LOG_ONLY = exports.REMOTE_UPDATE_PACKAGE_PATH = exports.REMOTE_PRIVILEGE_ALLOWLIST = exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE = exports.REMOTE_DENIED_NAMESPACES = void 0;
22
22
  exports.setRemotePrivilegeLogSink = setRemotePrivilegeLogSink;
23
23
  exports.__resetRemotePrivilegeObservationForTests = __resetRemotePrivilegeObservationForTests;
24
24
  exports.observeRemotePrivilege = observeRemotePrivilege;
@@ -28,6 +28,7 @@ exports.describeMeshOrigin = describeMeshOrigin;
28
28
  exports.buildCoreCapService = buildCoreCapService;
29
29
  const system_1 = require("@camstack/system");
30
30
  const moleculer_1 = require("moleculer");
31
+ const remote_addon_update_grant_js_1 = require("./remote-addon-update-grant.js");
31
32
  const http_request_census_singleton_js_1 = require("./http-request-census.singleton.js");
32
33
  const server_provided_caps_js_1 = require("./server-provided-caps.js");
33
34
  const trpc_context_js_1 = require("./trpc.context.js");
@@ -90,6 +91,8 @@ exports.REMOTE_PRIVILEGE_ALLOWLIST = new Set([
90
91
  'addons.setCapabilityProviderEnabled',
91
92
  'system.getSiteLocation',
92
93
  ]);
94
+ /** The one path outside the allowlist a node may call, under {@link authorizeRemoteUpdatePackage}. */
95
+ exports.REMOTE_UPDATE_PACKAGE_PATH = 'addons.updatePackage';
93
96
  /**
94
97
  * When true, remote bridged calls outside {@link REMOTE_PRIVILEGE_ALLOWLIST} are
95
98
  * logged once per `(nodeId, path)` per process — still admin, not refused.
@@ -276,7 +279,7 @@ function buildCoreCapService(appRouter, census = (0, http_request_census_singlet
276
279
  const method = path.slice(dot + 1);
277
280
  actions.push({
278
281
  actionName: `${toKebab(namespace)}.${method}`,
279
- invoke: (input, origin) => {
282
+ invoke: async (input, origin) => {
280
283
  countMeshCall(census, path, origin);
281
284
  const remoteNode = remoteOrigin(origin, options.ownNodeId);
282
285
  if (remoteNode !== null && exports.REMOTE_DENIED_NAMESPACES.has(namespace)) {
@@ -286,6 +289,12 @@ function buildCoreCapService(appRouter, census = (0, http_request_census_singlet
286
289
  if (exports.REMOTE_PRIVILEGE_LOG_ONLY) {
287
290
  observeRemotePrivilege(remoteNode, path);
288
291
  }
292
+ else if (path === exports.REMOTE_UPDATE_PACKAGE_PATH && options.remoteUpdateGrant) {
293
+ const decision = await (0, remote_addon_update_grant_js_1.authorizeRemoteUpdatePackage)(remoteNode, input, options.remoteUpdateGrant);
294
+ if (!decision.allowed) {
295
+ throw new moleculer_1.Errors.MoleculerClientError(`"${path}" refused for caller "${remoteNode}": ${decision.reason ?? 'not permitted'}`, 403, exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE);
296
+ }
297
+ }
289
298
  else if (!exports.REMOTE_PRIVILEGE_ALLOWLIST.has(path)) {
290
299
  observeRemotePrivilegeRefused(remoteNode, path);
291
300
  throw new moleculer_1.Errors.MoleculerClientError(`"${path}" is not on the remote privilege grant (caller "${remoteNode}")`, 403, exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE);
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ /**
3
+ * The scoped grant that lets an agent update an addon on ITS OWN node (D842).
4
+ *
5
+ * `addons.updatePackage` is deliberately not on `REMOTE_PRIVILEGE_ALLOWLIST`:
6
+ * that list is by path, and a path grant would let any enrolled node deploy any
7
+ * package version to any other node. This rule grants the path only under three
8
+ * conditions, all decided from what the hub knows, never from what the caller
9
+ * says about itself:
10
+ *
11
+ * 1. the target `nodeId` IS the caller's authenticated node id (the mesh
12
+ * identity Moleculer reports for the calling node; the request body names a
13
+ * target, it never names the caller);
14
+ * 2. the version asked for IS the version the hub itself has installed for that
15
+ * package — the one its back-fill restores on every reconnect. An agent can
16
+ * therefore only converge to the hub's record, never inject an older, newer
17
+ * or unpublished build;
18
+ * 3. the package exists on the hub.
19
+ *
20
+ * Every decision, allowed or refused, is written to the auth audit trail.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.authorizeRemoteUpdatePackage = authorizeRemoteUpdatePackage;
24
+ function readRequest(input) {
25
+ if (input === null || typeof input !== 'object')
26
+ return null;
27
+ const name = Reflect.get(input, 'name');
28
+ const version = Reflect.get(input, 'version');
29
+ const nodeId = Reflect.get(input, 'nodeId');
30
+ if (typeof name !== 'string' || name === '')
31
+ return null;
32
+ if (typeof version !== 'string' || version === '')
33
+ return null;
34
+ if (typeof nodeId !== 'string' || nodeId === '')
35
+ return null;
36
+ return { name, version, nodeId };
37
+ }
38
+ function decide(callerNodeId, input, deps) {
39
+ const request = readRequest(input);
40
+ if (request === null) {
41
+ return {
42
+ allowed: false,
43
+ reason: 'addons.updatePackage from a node needs { name, version, nodeId } — all three',
44
+ detail: { reason: 'malformed' },
45
+ };
46
+ }
47
+ const detail = { name: request.name, version: request.version, target: request.nodeId };
48
+ if (request.nodeId !== callerNodeId) {
49
+ return {
50
+ allowed: false,
51
+ reason: `a node may update packages on its own node only: "${callerNodeId}" asked for "${request.nodeId}"`,
52
+ detail: { ...detail, reason: 'other-node' },
53
+ };
54
+ }
55
+ const hubVersion = deps.hubInstalledVersion(request.name);
56
+ if (hubVersion === null) {
57
+ return {
58
+ allowed: false,
59
+ reason: `the hub does not install ${request.name}, so it has no version to bring "${callerNodeId}" to`,
60
+ detail: { ...detail, reason: 'not-on-hub' },
61
+ };
62
+ }
63
+ if (request.version !== hubVersion) {
64
+ return {
65
+ allowed: false,
66
+ reason: `a node can only be brought to the hub's version: the hub records ${request.name}@${hubVersion}, not ${request.version}`,
67
+ detail: { ...detail, reason: 'version-differs', hubVersion },
68
+ };
69
+ }
70
+ return { allowed: true, reason: null, detail };
71
+ }
72
+ /** Decide, audit, and return the decision. */
73
+ async function authorizeRemoteUpdatePackage(callerNodeId, input, deps) {
74
+ const { allowed, reason, detail } = decide(callerNodeId, input, deps);
75
+ await deps.audit({
76
+ action: allowed ? 'agent-addon-update' : 'agent-addon-update-refused',
77
+ userId: `node:${callerNodeId}`,
78
+ detail,
79
+ });
80
+ return { allowed, reason };
81
+ }
@@ -181,21 +181,18 @@ class MoleculerService {
181
181
  this.streamProbe = streamProbe;
182
182
  this.logger = this.logging.createLogger('moleculer');
183
183
  // Optional port overrides. Live primarily for the e2e harness: when
184
- // a developer's dev:full is up on the default 6000/4445 ports, an
185
- // isolated test hub can't reuse them. Production keeps the defaults
186
- // so cluster discovery + agent-to-hub connections keep working
187
- // without per-deploy config.
184
+ // a developer's dev:full is up on the default 6000 port, an
185
+ // isolated test hub can't reuse it. Production keeps the default so
186
+ // agent-to-hub connections keep working without per-deploy config.
188
187
  const tcpPortEnv = process.env['CAMSTACK_HUB_TCP_PORT'];
189
- const udpPortEnv = process.env['CAMSTACK_HUB_UDP_PORT'];
190
188
  const tcpPort = tcpPortEnv ? Number(tcpPortEnv) : undefined;
191
- const udpPort = udpPortEnv ? Number(udpPortEnv) : undefined;
192
189
  // Two-step cast: createBroker's dist `.d.ts` chains through
193
190
  // moleculer→eventemitter2 whose types are unresolvable at this
194
191
  // boundary, so the inference falls to `error` and trips
195
192
  // `no-unsafe-assignment`. Going via `unknown` documents the boundary.
196
193
  // No cluster secret here (D797): the broker is created before the keychain
197
194
  // can answer, and the Moleculer namespace was never authentication (TCP
198
- // ignores it; UDP discovery broadcasts it in clear). The secret gates
195
+ // ignores it). The secret gates
199
196
  // `$hub.registerNode`, read from the keychain at call time.
200
197
  this.meshGuard = new hub_mesh_guard_js_1.HubMeshGuard('hub', this.logging.createLogger('mesh-auth'));
201
198
  const broker = (0, system_1.createBroker)({
@@ -205,7 +202,6 @@ class MoleculerService {
205
202
  middlewares: [this.meshGuard.middleware()],
206
203
  logLevel: this.config.get('moleculer.logLevel') ?? 'warn',
207
204
  ...(tcpPort && !Number.isNaN(tcpPort) ? { tcpPort } : {}),
208
- ...(udpPort && !Number.isNaN(udpPort) ? { udpPort } : {}),
209
205
  });
210
206
  // `ServiceBroker` itself surfaces as `error`-typed at this boundary
211
207
  // (eventemitter2 chain unresolvable). Documented + single-site cast.
package/dist/main.js CHANGED
@@ -526,6 +526,34 @@ async function bootstrap() {
526
526
  addonRegistry.setDataPlaneRegistry(dataPlaneRegistry);
527
527
  // ── Configure the CapabilityRegistry (created in AddonRegistryService constructor) ──
528
528
  const capabilityRegistry = addonRegistry.getCapabilityRegistry();
529
+ // D842: the scoped grant behind a node's own-node `addons.updatePackage`. The
530
+ // hub's record is its installed package version; every decision is audited.
531
+ const remoteUpdateGrantDeps = () => ({
532
+ hubInstalledVersion: (name) => {
533
+ try {
534
+ return (app
535
+ .get(addon_package_service_1.AddonPackageService)
536
+ .listInstalled()
537
+ .find((p) => p.name === name)?.version ?? null);
538
+ }
539
+ catch {
540
+ return null;
541
+ }
542
+ },
543
+ audit: async (event) => {
544
+ try {
545
+ const internal = (0, system_1.getUserManagementInternal)(capabilityRegistry.getSingleton('user-management'));
546
+ if (internal === null) {
547
+ console.warn('[bootstrap] agent addon update not audited: no user-management internal');
548
+ return;
549
+ }
550
+ await internal.audit.record(event);
551
+ }
552
+ catch (err) {
553
+ console.warn('[bootstrap] agent addon update audit row not written:', err);
554
+ }
555
+ },
556
+ });
529
557
  const sessionBacking = (0, hub_sessions_js_1.sessionBackingFrom)(capabilityRegistry, sessionLifetimes);
530
558
  // From here on every verify of a session-grade token — tRPC, addon routes,
531
559
  // the session cookie, OAuth authorize, uploads, health — requires its
@@ -647,7 +675,10 @@ async function bootstrap() {
647
675
  {
648
676
  const meshBroker = app.get(moleculer_service_1.MoleculerService);
649
677
  // D802: another node's call runs as `node:<id>`, never the hub's own principal.
650
- meshBroker.registerCoreCapService((0, core_cap_bridge_1.buildCoreCapService)(appRouter, undefined, { ownNodeId: meshBroker.nodeId }));
678
+ meshBroker.registerCoreCapService((0, core_cap_bridge_1.buildCoreCapService)(appRouter, undefined, {
679
+ ownNodeId: meshBroker.nodeId,
680
+ remoteUpdateGrant: remoteUpdateGrantDeps(),
681
+ }));
651
682
  }
652
683
  console.log('[bootstrap] core-cap mesh bridge registered ($core-caps) — before addon boot');
653
684
  }
@@ -742,7 +773,10 @@ async function bootstrap() {
742
773
  {
743
774
  const meshBroker = app.get(moleculer_service_1.MoleculerService);
744
775
  // D802: another node's call runs as `node:<id>`, never the hub's own principal.
745
- meshBroker.registerCoreCapService((0, core_cap_bridge_1.buildCoreCapService)(appRouter, undefined, { ownNodeId: meshBroker.nodeId }));
776
+ meshBroker.registerCoreCapService((0, core_cap_bridge_1.buildCoreCapService)(appRouter, undefined, {
777
+ ownNodeId: meshBroker.nodeId,
778
+ remoteUpdateGrant: remoteUpdateGrantDeps(),
779
+ }));
746
780
  }
747
781
  console.log('[bootstrap] core-cap mesh bridge registered ($core-caps) — late fallback');
748
782
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.540",
3
+ "version": "1.2.542",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -24,7 +24,8 @@
24
24
  "start": "node --env-file=.env dist/launcher.js",
25
25
  "typecheck": "tsc --noEmit -p tsconfig.spec.json",
26
26
  "test": "vitest run --reporter verbose",
27
- "test:watch": "vitest"
27
+ "test:watch": "vitest",
28
+ "test:cluster": "vitest run -c vitest.cluster.config.ts --reporter verbose"
28
29
  },
29
30
  "camstack": {
30
31
  "agentBootstrapAddons": [
@@ -37,17 +38,18 @@
37
38
  ]
38
39
  },
39
40
  "dependencies": {
40
- "@camstack/addon-admin-ui": "1.2.406",
41
- "@camstack/addon-agent-ui": "1.2.223",
41
+ "@camstack/addon-admin-ui": "1.2.408",
42
+ "@camstack/addon-agent-ui": "1.2.224",
42
43
  "@camstack/addon-auth": "1.2.141",
43
44
  "@camstack/addon-decoder-nodeav": "1.2.123",
44
45
  "@camstack/addon-notifiers": "1.2.134",
45
- "@camstack/addon-pipeline": "1.2.359",
46
+ "@camstack/addon-pipeline": "1.2.361",
46
47
  "@camstack/addon-pipeline-orchestrator": "1.2.310",
47
- "@camstack/addon-post-analysis": "1.2.358",
48
+ "@camstack/addon-post-analysis": "1.2.360",
49
+ "@camstack/develop": "0.1.5",
48
50
  "@camstack/sdk": "1.2.131",
49
- "@camstack/system": "1.2.416",
50
- "@camstack/types": "1.2.354",
51
+ "@camstack/system": "1.2.418",
52
+ "@camstack/types": "1.2.356",
51
53
  "@camstack/ui-library": "1.2.281",
52
54
  "@fastify/compress": "^9.0.0",
53
55
  "@fastify/cookie": "^11.0.2",