@camstack/server 1.2.577 → 1.2.579

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,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAgentMeshGate = createAgentMeshGate;
4
+ /**
5
+ * The agent's mesh gate (D802, D857): only the hub may call this agent, and the
6
+ * hub is recognised by its CERTIFICATE — the agent's own TLS listener accepted
7
+ * it only because its leaf chains to the mesh CA this agent was paired with
8
+ * (`TlsTcpTransporter.verifiedPeer`).
9
+ *
10
+ * There is no join to run and nothing to hold: a verdict is a function of what
11
+ * the transport already knows, so the hub's very first packet on a new
12
+ * connection is judged correctly. An agent that is not paired holds no
13
+ * certificate, so it admits no one.
14
+ *
15
+ * Also here: the agent's outbound event scope (D848) — its events go to the hub only.
16
+ */
17
+ const system_1 = require("@camstack/system");
18
+ const HUB_NODE_ID = 'hub';
19
+ /** At most one "refused" line per sender per this long. */
20
+ const REFUSED_LOG_INTERVAL_MS = 60_000;
21
+ function createAgentMeshGate(opts) {
22
+ const lastLoggedAt = new Map();
23
+ const gate = new system_1.AgentMeshIdentityGate({
24
+ ownNodeId: opts.nodeId,
25
+ hubNodeId: HUB_NODE_ID,
26
+ isVerifiedPeer: opts.isVerifiedPeer,
27
+ onRefused: (nodeId, what) => {
28
+ const at = Date.now();
29
+ const last = lastLoggedAt.get(nodeId);
30
+ if (last !== undefined && at - last < REFUSED_LOG_INTERVAL_MS)
31
+ return;
32
+ if (lastLoggedAt.size >= 64)
33
+ lastLoggedAt.clear();
34
+ lastLoggedAt.set(nodeId, at);
35
+ opts.log?.(`mesh inbound: "${nodeId}" is not the certified hub — refused (first call: ${what})`);
36
+ },
37
+ });
38
+ const eventScope = (0, system_1.createAgentEventScope)({
39
+ hubNodeId: HUB_NODE_ID,
40
+ ...(opts.log !== undefined ? { log: opts.log } : {}),
41
+ });
42
+ const inbound = (0, system_1.createMeshAccessMiddleware)({
43
+ ownNodeId: opts.nodeId,
44
+ gate,
45
+ logger: {
46
+ warn: (message, meta) => {
47
+ const detail = meta === undefined
48
+ ? ''
49
+ : ` ${Object.entries(meta)
50
+ .map(([k, v]) => `${k}=${String(v)}`)
51
+ .join(' ')}`;
52
+ opts.log?.(`${message}${detail}`);
53
+ },
54
+ },
55
+ });
56
+ return { gate, eventScope, middlewares: [inbound, eventScope.middleware] };
57
+ }
@@ -26,7 +26,7 @@ exports.createAgentNodeCertificates = createAgentNodeCertificates;
26
26
  */
27
27
  const node_crypto_1 = require("node:crypto");
28
28
  const system_1 = require("@camstack/system");
29
- const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
29
+ const agent_json_file_js_1 = require("./agent-json-file.js");
30
30
  /** Let the capability answer before the broker is torn down and rebuilt. */
31
31
  const RECONNECT_DELAY_MS = 400;
32
32
  /** A swap found pending at boot gets at least this long for the hub to confirm. */
@@ -57,7 +57,7 @@ function createAgentNodeCertificates(deps) {
57
57
  let pendingKeyPem = null;
58
58
  let rollbackTimer = null;
59
59
  const readMesh = () => {
60
- const raw = (0, agent_mesh_enroll_js_1.readJsonFile)(deps.configPath);
60
+ const raw = (0, agent_json_file_js_1.readJsonFile)(deps.configPath);
61
61
  return {
62
62
  current: isMeshMaterial(raw['meshTls']) ? raw['meshTls'] : null,
63
63
  previous: isMeshMaterial(raw['meshTlsPrevious']) ? raw['meshTlsPrevious'] : null,
@@ -74,9 +74,9 @@ function createAgentNodeCertificates(deps) {
74
74
  };
75
75
  /** Write `agent.json` with the swap leftovers removed and `meshTls` optionally replaced. */
76
76
  const writeSettled = (replaceCurrent) => {
77
- const raw = (0, agent_mesh_enroll_js_1.readJsonFile)(deps.configPath);
77
+ const raw = (0, agent_json_file_js_1.readJsonFile)(deps.configPath);
78
78
  const { meshTlsPrevious: _p, meshSwap: _s, ...rest } = raw;
79
- (0, agent_mesh_enroll_js_1.writeJsonFile0600)(deps.configPath, {
79
+ (0, agent_json_file_js_1.writeJsonFile0600)(deps.configPath, {
80
80
  ...rest,
81
81
  ...(replaceCurrent !== undefined ? { meshTls: replaceCurrent } : {}),
82
82
  });
@@ -151,7 +151,7 @@ function createAgentNodeCertificates(deps) {
151
151
  return;
152
152
  }
153
153
  writeSettled();
154
- log.error('No valid mesh leaf on this node: renew it from the hub or re-join with a join token', {
154
+ log.error('No valid mesh leaf on this node: renew it from the hub or pair it again', {
155
155
  tags,
156
156
  });
157
157
  };
@@ -222,12 +222,12 @@ function createAgentNodeCertificates(deps) {
222
222
  if (!leaf.checkPrivateKey((0, node_crypto_1.createPrivateKey)(pendingKeyPem))) {
223
223
  throw new Error('the new leaf does not match the key this node generated');
224
224
  }
225
- const raw = (0, agent_mesh_enroll_js_1.readJsonFile)(deps.configPath);
225
+ const raw = (0, agent_json_file_js_1.readJsonFile)(deps.configPath);
226
226
  const swap = {
227
227
  fingerprint: leaf.fingerprint256,
228
228
  deadline: now() + input.confirmWithinMs,
229
229
  };
230
- (0, agent_mesh_enroll_js_1.writeJsonFile0600)(deps.configPath, {
230
+ (0, agent_json_file_js_1.writeJsonFile0600)(deps.configPath, {
231
231
  ...raw,
232
232
  meshTls: { certPem: input.certPem, keyPem: pendingKeyPem, caCertPem: current.caCertPem },
233
233
  meshTlsPrevious: current,
@@ -9,10 +9,7 @@ const DEFAULT_OFFERS_PER_MINUTE = 10;
9
9
  const MAX_TRACKED_SOURCES = 512;
10
10
  /** True for a request from this machine itself: loopback peer, no forwarding header. */
11
11
  function isLocalRequest(req) {
12
- return (0, agent_http_auth_js_1.isGenuineLoopback)({
13
- remoteAddress: req.socket.remoteAddress ?? undefined,
14
- forwarded: (0, agent_http_auth_js_1.hasForwardingHeader)(req.headers),
15
- });
12
+ return (0, agent_http_auth_js_1.isGenuineLoopback)((0, agent_http_auth_js_1.authInputOf)(req));
16
13
  }
17
14
  function send(reply, result) {
18
15
  if (result.ok)
@@ -46,12 +46,12 @@ exports.pairedLeafFingerprint = pairedLeafFingerprint;
46
46
  const node_crypto_1 = require("node:crypto");
47
47
  const fs = __importStar(require("node:fs"));
48
48
  const system_1 = require("@camstack/system");
49
- const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
49
+ const agent_json_file_js_1 = require("./agent-json-file.js");
50
50
  /** Keys that exist only while this agent is paired. */
51
51
  const PAIRED_KEYS = ['meshTls', 'meshTlsPrevious', 'meshSwap', 'hubAddress'];
52
52
  /** `agent.json` holds mesh material: the agent is paired. */
53
53
  function isAgentPaired(configPath) {
54
- const meshTls = (0, agent_mesh_enroll_js_1.readJsonFile)(configPath)['meshTls'];
54
+ const meshTls = (0, agent_json_file_js_1.readJsonFile)(configPath)['meshTls'];
55
55
  return (meshTls !== null &&
56
56
  typeof meshTls === 'object' &&
57
57
  typeof Reflect.get(meshTls, 'certPem') === 'string' &&
@@ -60,7 +60,7 @@ function isAgentPaired(configPath) {
60
60
  }
61
61
  /** The CA certificate this agent pinned at pairing, or `null` while unpaired. */
62
62
  function pinnedMeshCaCert(configPath) {
63
- const meshTls = (0, agent_mesh_enroll_js_1.readJsonFile)(configPath)['meshTls'];
63
+ const meshTls = (0, agent_json_file_js_1.readJsonFile)(configPath)['meshTls'];
64
64
  if (meshTls === null || typeof meshTls !== 'object')
65
65
  return null;
66
66
  const ca = Reflect.get(meshTls, 'caCertPem');
@@ -68,8 +68,8 @@ function pinnedMeshCaCert(configPath) {
68
68
  }
69
69
  /** Persist a completed pairing: the leaf, its key, the pinned CA and the hub's host. */
70
70
  function persistPairing(configPath, pairing) {
71
- const { joinToken: _joinToken, meshTlsPrevious: _previous, meshSwap: _swap, ...existing } = (0, agent_mesh_enroll_js_1.readJsonFile)(configPath);
72
- (0, agent_mesh_enroll_js_1.writeJsonFile0600)(configPath, {
71
+ const { meshTlsPrevious: _previous, meshSwap: _swap, ...existing } = (0, agent_json_file_js_1.readJsonFile)(configPath);
72
+ (0, agent_json_file_js_1.writeJsonFile0600)(configPath, {
73
73
  ...existing,
74
74
  hubAddress: pairing.hubAddress,
75
75
  meshTls: pairing.meshTls,
@@ -80,8 +80,8 @@ function persistPairing(configPath, pairing) {
80
80
  * the hub issued. The agent is unpaired afterwards and accepts offers again.
81
81
  */
82
82
  function forgetPairing(configPath, dataDir) {
83
- const existing = (0, agent_mesh_enroll_js_1.readJsonFile)(configPath);
84
- (0, agent_mesh_enroll_js_1.writeJsonFile0600)(configPath, Object.fromEntries(Object.entries(existing).filter(([key]) => !PAIRED_KEYS.includes(key))));
83
+ const existing = (0, agent_json_file_js_1.readJsonFile)(configPath);
84
+ (0, agent_json_file_js_1.writeJsonFile0600)(configPath, Object.fromEntries(Object.entries(existing).filter(([key]) => !PAIRED_KEYS.includes(key))));
85
85
  const https = (0, system_1.agentHttpsPaths)(dataDir);
86
86
  for (const file of [https.bundlePath, https.certPath, https.pendingKeyPath]) {
87
87
  fs.rmSync(file, { force: true });
@@ -89,7 +89,7 @@ function forgetPairing(configPath, dataDir) {
89
89
  }
90
90
  /** First 16 hex chars of the SHA-256 of the agent's mesh leaf, `null` while unpaired. */
91
91
  function pairedLeafFingerprint(configPath) {
92
- const meshTls = (0, agent_mesh_enroll_js_1.readJsonFile)(configPath)['meshTls'];
92
+ const meshTls = (0, agent_json_file_js_1.readJsonFile)(configPath)['meshTls'];
93
93
  const certPem = meshTls !== null && typeof meshTls === 'object' ? Reflect.get(meshTls, 'certPem') : null;
94
94
  if (typeof certPem !== 'string')
95
95
  return null;
@@ -607,15 +607,6 @@ function createAgentService(deps) {
607
607
  registerNode: {
608
608
  handler(ctx) {
609
609
  const { params } = ctx;
610
- // Cluster-secret gate: when the agent has a secret configured, a child
611
- // runner must present a matching hash or the registration is rejected.
612
- if (!(0, system_1.clusterSecretMatches)(deps.expectedClusterSecretHash, params.clusterSecretHash)) {
613
- throw new moleculer_1.Errors.MoleculerError(`cluster secret mismatch — node "${params.nodeId}" rejected`, 403, system_1.CLUSTER_SECRET_MISMATCH_TYPE);
614
- }
615
- const sender = typeof ctx.nodeID === 'string' ? ctx.nodeID : '';
616
- if (sender.length > 0) {
617
- deps.meshAuth?.noteChildRegistered(sender, deps.expectedClusterSecretHash !== undefined ? 'authenticated' : 'legacy');
618
- }
619
610
  deps.onChildRegistered?.(params);
620
611
  return { ok: true };
621
612
  },
@@ -45,11 +45,10 @@ const single_copy_cleanup_runner_js_1 = require("../single-copy-cleanup-runner.j
45
45
  const addon_scan_js_1 = require("./addon-scan.js");
46
46
  const agent_addon_updates_js_1 = require("./agent-addon-updates.js");
47
47
  const agent_hub_registration_js_1 = require("./agent-hub-registration.js");
48
- const agent_mesh_join_js_1 = require("./agent-mesh-join.js");
48
+ const agent_mesh_gate_js_1 = require("./agent-mesh-gate.js");
49
49
  const agent_pairing_local_js_1 = require("./agent-pairing-local.js");
50
50
  const agent_pairing_store_js_1 = require("./agent-pairing-store.js");
51
51
  const agent_config_js_1 = require("./agent-config.js");
52
- const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
53
52
  const agent_group_runner_js_1 = require("./agent-group-runner.js");
54
53
  const agent_http_js_1 = require("./agent-http.js");
55
54
  const agent_node_certificates_js_1 = require("./agent-node-certificates.js");
@@ -88,32 +87,8 @@ const DATA_NODEID_CAPS = new Set(types_1.ALL_CAPABILITY_DEFINITIONS.filter((c) =
88
87
  // ---------------------------------------------------------------------------
89
88
  // Main
90
89
  // ---------------------------------------------------------------------------
91
- /**
92
- * Load `agent.json`, first redeeming a join token the setup window left there
93
- * (mesh mTLS Release A — the desktop setup runs before the agent does). A token
94
- * is single-use, so it is dropped whether or not the hub accepted it; a refusal
95
- * is logged loudly and the agent still boots (clear, or with the cluster secret).
96
- */
97
- async function loadAgentConfigRedeemingJoinToken(configPath) {
98
- const loaded = (0, agent_config_js_1.loadAgentConfig)(configPath);
99
- if (loaded.pendingJoinToken === undefined)
100
- return loaded;
101
- try {
102
- const enrolled = await (0, agent_mesh_enroll_js_1.enrollAndPersist)({
103
- token: loaded.pendingJoinToken,
104
- nodeId: loaded.nodeId,
105
- configPath: loaded.configPath,
106
- });
107
- console.log(`[Agent] Enrolled in the mesh CA (hub ${enrolled.hubAddress}) — dialing over mutual TLS`);
108
- }
109
- catch (err) {
110
- console.error(`[Agent] Join-token enrollment failed — staying on the previous transport: ${(0, types_1.errMsg)(err)}`);
111
- (0, agent_mesh_enroll_js_1.discardPendingJoinToken)(loaded.configPath);
112
- }
113
- return (0, agent_config_js_1.loadAgentConfig)(configPath, loaded.dataDir);
114
- }
115
90
  async function startAgent(configPath) {
116
- const config = await loadAgentConfigRedeemingJoinToken(configPath);
91
+ const config = (0, agent_config_js_1.loadAgentConfig)(configPath);
117
92
  // Derive CAMSTACK_HUB_URL from the configured hub address so remote agents
118
93
  // no longer need a second, redundant env var set by hand (the cross-node
119
94
  // source trap). An explicit operator value ALWAYS wins — capture whether it
@@ -179,58 +154,31 @@ async function startAgent(configPath) {
179
154
  }
180
155
  }
181
156
  console.log(`[Agent] Addon packages ready in ${config.addonsDir}`);
182
- // D802: the mesh join. Every call to the hub waits for the challenge–response
183
- // (and the hub's own proof) on each connection. The secret comes from
184
- // `agent.json`; a secret saved on the status page / desktop Setup takes
185
- // effect through `reconnect()` below, which re-reads the file.
186
- let currentSecret = config.secret ?? null;
187
- let joinState = 'pending';
188
- const meshJoin = (0, agent_mesh_join_js_1.createAgentMeshJoin)({
157
+ // D802, D857: the inbound mesh gate. Only the hub may call this agent, and the hub is
158
+ // recognised by the certificate the agent's own TLS listener verified. The transport is
159
+ // read at call time because a reconnect replaces it.
160
+ const meshGate = (0, agent_mesh_gate_js_1.createAgentMeshGate)({
189
161
  nodeId: config.nodeId,
190
- secret: () => currentSecret,
191
- // D857 (transition release): the hub is also accepted by its certificate, verified by this
192
- // agent's own TLS listener, so a hub that stops answering the join still reaches this agent.
193
- isPaired: () => (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
194
162
  isVerifiedPeer: (nodeId) => {
195
163
  const tx = (0, system_1.currentBrokerTransporter)(broker).tx;
196
164
  return tx instanceof system_1.TlsTcpTransporter && tx.verifiedPeer(nodeId) !== undefined;
197
165
  },
198
- call: (action, params, opts) => broker.call(action, params, opts),
199
166
  log: (msg) => console.log(`[Agent] ${msg}`),
200
- onState: (state) => {
201
- joinState = state;
202
- if (state === 'secret-mismatch' || state === 'hub-unverified') {
203
- hubConnectionStateOverride = 'secret-mismatch';
204
- consoleLogger.error(state === 'secret-mismatch'
205
- ? "mesh join refused: cluster secret mismatch — copy the cluster secret from the hub's System → Cluster page into this agent's setup"
206
- : 'mesh join refused: the node answering as the hub could not prove the cluster secret — check the hub address and the secret');
207
- }
208
- else if (hubConnectionStateOverride === 'secret-mismatch') {
209
- hubConnectionStateOverride = null;
210
- }
211
- // Every settled join re-sends the full manifest (D3, 2026-10-14): a join
212
- // runs exactly when the hub's view of this node was reset.
213
- registration.onJoinSettled(state);
214
- if (state === 'no-secret') {
215
- consoleLogger.warn("no cluster secret configured — the hub tolerates this agent only while its System → Cluster is in Transition. Copy the hub's cluster secret into this agent's setup");
216
- }
217
- },
218
167
  });
219
- // Mesh mTLS (Release A): with a certificate the broker dials the hub's TLS
220
- // port; without one it keeps dialing clear 6000.
168
+ // Mesh mTLS: with a certificate (pairing, D857) the broker dials the hub's TLS
169
+ // port; an unpaired agent has none and admits no mesh peer at all.
221
170
  let currentMeshTls = config.meshTls ?? null;
222
171
  // The HTTP/HTTPS listener on the agent port (D856); set once it is up.
223
172
  let agentHttpListener = null;
224
- console.log(`[Agent] Mesh transport: ${currentMeshTls !== null ? 'tls' : 'clear'}`);
173
+ console.log(`[Agent] Mesh transport: ${currentMeshTls !== null ? 'tls (paired)' : 'none (unpaired)'}`);
225
174
  const broker = (0, system_1.createBroker)({
226
175
  nodeID: config.nodeId,
227
176
  mode: 'agent',
228
177
  hubAddress: config.hubAddress,
229
178
  logLevel: config.logLevel,
230
- middlewares: meshJoin.middlewares,
179
+ middlewares: meshGate.middlewares,
231
180
  ...(currentMeshTls !== null ? { meshTls: currentMeshTls } : {}),
232
181
  });
233
- meshJoin.attach(broker);
234
182
  /**
235
183
  * Reconnect: reload the config from file and replace the broker's TRANSPORT (the new hub
236
184
  * address, secret or mesh leaf) IN PLACE. The broker itself is never rebuilt: its services
@@ -268,11 +216,11 @@ async function startAgent(configPath) {
268
216
  },
269
217
  prepare: () => {
270
218
  console.log('[Agent] Reconnecting with updated config...');
271
- // Reload config from file (UI may have written new hubAddress/secret)
219
+ // Reload config from file (pairing, the UI or a local call may have written it)
272
220
  const fresh = (0, agent_config_js_1.loadAgentConfig)(undefined, config.dataDir);
273
221
  pendingConfig = fresh;
274
- console.log(`[Agent] New config: hub=${fresh.hubAddress ?? 'none'}, secret=${fresh.secret ? 'yes' : 'none'}, ` +
275
- `mesh=${fresh.meshTls !== undefined ? 'tls' : 'clear'}`);
222
+ console.log(`[Agent] New config: hub=${fresh.hubAddress ?? 'none'}, ` +
223
+ `mesh=${fresh.meshTls !== undefined ? 'tls (paired)' : 'none (unpaired)'}`);
276
224
  // Re-derive CAMSTACK_HUB_URL when the dashboard has just written a hubAddress. Explicit operator values
277
225
  // still win via the boot-captured `hubUrlWasExplicit` flag.
278
226
  // KNOWN LIMITATION (Stage-0): runners already spawned before this reconnect
@@ -284,7 +232,6 @@ async function startAgent(configPath) {
284
232
  process.env['CAMSTACK_HUB_URL'] = freshHubUrl;
285
233
  console.log(`[Agent] Derived CAMSTACK_HUB_URL=${freshHubUrl} from hub address "${fresh.hubAddress}"`);
286
234
  }
287
- currentSecret = fresh.secret ?? null;
288
235
  currentMeshTls = fresh.meshTls ?? null;
289
236
  },
290
237
  build: () => {
@@ -299,8 +246,8 @@ async function startAgent(configPath) {
299
246
  ...(currentMeshTls !== null ? { meshTls: currentMeshTls } : {}),
300
247
  }));
301
248
  },
302
- // The new wire's hub connection runs a join, and its settle re-sends the manifest
303
- // (`registration.onJoinSettled`): the subtree union, every child included (D3).
249
+ // The new wire's hub connection re-sends the manifest (`registration.onHubConnected`):
250
+ // the subtree union, every child included (D3).
304
251
  start: async () => {
305
252
  await (0, system_1.connectBrokerTransport)(broker);
306
253
  console.log('[Agent] Reconnected successfully');
@@ -318,12 +265,12 @@ async function startAgent(configPath) {
318
265
  // Hub connection state tracking (for richer UI status)
319
266
  // ---------------------------------------------------------------------------
320
267
  // Tracks the last known discriminated state so the HTTP status endpoint
321
- // can surface `secret-mismatch` which is not observable from the broker
268
+ // can surface `certificate-rejected` which is not observable from the broker
322
269
  // registry alone. All mutations are synchronous/single-threaded (Node.js
323
270
  // event loop) so no locking is needed.
324
271
  let hubConnectionStateOverride = null;
325
272
  function getHubConnectionState() {
326
- // If a definitive override is set (e.g. secret-mismatch), return it
273
+ // If a definitive override is set (e.g. certificate-rejected), return it
327
274
  // regardless of registry state.
328
275
  if (hubConnectionStateOverride !== null)
329
276
  return hubConnectionStateOverride;
@@ -338,6 +285,8 @@ async function startAgent(configPath) {
338
285
  catch {
339
286
  /* fall through */
340
287
  }
288
+ if (!(0, agent_pairing_store_js_1.isAgentPaired)(config.configPath))
289
+ return 'unpaired';
341
290
  const configNow = readConfigFile(config.configPath);
342
291
  const hubAddressConfigured = typeof configNow.hubAddress === 'string' && configNow.hubAddress.length > 0;
343
292
  return hubAddressConfigured ? 'disconnected' : 'unconfigured';
@@ -423,12 +372,7 @@ async function startAgent(configPath) {
423
372
  // carries the agent's root-package identity so the hub's HubNodeRegistry
424
373
  // (and the Server management UI) sees every node's exact version.
425
374
  const rootPackage = agentUpdateService.getRunningRootPackage();
426
- return (0, system_1.buildNodeManifest)(config.nodeId, allAddons, allNativeCaps.length > 0 ? allNativeCaps : undefined,
427
- // D802: the replayable D797 hash goes out ONLY to a hub that predates the
428
- // challenge–response join. After a successful join nothing is sent.
429
- joinState === 'unsupported' && currentSecret !== null && currentSecret.length > 0
430
- ? (0, system_1.hashClusterSecret)(currentSecret)
431
- : undefined, rootPackage.version === null
375
+ return (0, system_1.buildNodeManifest)(config.nodeId, allAddons, allNativeCaps.length > 0 ? allNativeCaps : undefined, rootPackage.version === null
432
376
  ? undefined
433
377
  : { name: rootPackage.name, version: rootPackage.version });
434
378
  }
@@ -437,26 +381,29 @@ async function startAgent(configPath) {
437
381
  * subtree union (`agent-hub-registration.ts`). Sent:
438
382
  * - once boot has loaded every addon (`registration.ready()`);
439
383
  * - on every `$agent.registerNode` from a child, and on `$agent.reload`;
440
- * - on every mesh join that settles — a new hub connection, or the hub
441
- * saying it lost this node — and when the hub asks for it
384
+ * - every time the hub node connects, and when the hub asks for it
442
385
  * (`$agent.requestRegistration`, on the hub's own `$node.connected`).
443
- * D802: never before the join lets calls to the hub through, and the
444
- * manifest is built AFTER the join, so it carries the legacy hash only for a
445
- * hub that needs it.
386
+ * D857: no join to wait for and no secret in the manifest — the hub knows
387
+ * who this agent is from its certificate.
446
388
  */
447
389
  const registration = (0, agent_hub_registration_js_1.createAgentHubRegistration)({
448
- join: meshJoin,
449
390
  buildManifest: () => aggregateManifest(),
450
391
  broker: () => broker,
451
392
  signal: registerAbortController.signal,
452
393
  log: (msg) => console.log(`[Agent] ${msg}`),
453
394
  // The hub acked the manifest — the agent reached its registered state.
454
395
  // Run the single-copy GC sweep once (first ack only; no-op after).
455
- onAcked: () => confirmAgentBootHealthy(),
456
- onSecretRefused: () => {
457
- consoleLogger.error("hub registration rejected: cluster secret mismatch — enter the hub's cluster secret (System → Cluster) in this agent's setup (agent.json) and restart the agent");
458
- // Surface the mismatch in the UI status.
459
- hubConnectionStateOverride = 'secret-mismatch';
396
+ onAcked: () => {
397
+ if (hubConnectionStateOverride === 'certificate-rejected')
398
+ hubConnectionStateOverride = null;
399
+ confirmAgentBootHealthy();
400
+ },
401
+ onCertificateRefused: () => {
402
+ if (hubConnectionStateOverride === 'certificate-rejected')
403
+ return;
404
+ consoleLogger.error("the hub does not accept this agent's certificate (revoked, or issued by another CA) — pair it again: unpair it here, then pair it from the hub (System → Cluster → Agents)");
405
+ // Surface it in the UI status.
406
+ hubConnectionStateOverride = 'certificate-rejected';
460
407
  },
461
408
  });
462
409
  const consoleLogger = {
@@ -615,8 +562,6 @@ async function startAgent(configPath) {
615
562
  subtree.registerNode(params);
616
563
  registration.trigger(`child ${params.nodeId} registered`);
617
564
  },
618
- expectedClusterSecretHash: config.secret ? (0, system_1.hashClusterSecret)(config.secret) : undefined,
619
- meshAuth: meshJoin.authenticator,
620
565
  installFromNpm: async (pkg, version) => {
621
566
  await installer.install(pkg, version);
622
567
  },
@@ -1003,8 +948,8 @@ async function startAgent(configPath) {
1003
948
  // ── D3: Fire the initial upward hub registration carrying the agent's
1004
949
  // complete in-process manifest (+ any children that already registered).
1005
950
  // Group-runner children call `$agent.registerNode` and trigger a re-fire
1006
- // via `onChildRegistered`; every settled mesh join re-fires it
1007
- // (`registration.onJoinSettled`), and so does the hub's ask.
951
+ // via `onChildRegistered`; every hub connection re-fires it
952
+ // (`registration.onHubConnected`), and so does the hub's ask.
1008
953
  registration.trigger('boot');
1009
954
  // Fire `onHubReachable()` on every in-process addon as soon as the hub
1010
955
  // node connects to our broker — this is the safe point for ctx.api.* calls
@@ -1015,13 +960,9 @@ async function startAgent(configPath) {
1015
960
  const node = data.node;
1016
961
  if (node?.id !== 'hub')
1017
962
  return;
1018
- // Hub connected — clear any stale secret-mismatch override so the
1019
- // status endpoint reflects the live state again.
1020
- if (hubConnectionStateOverride === 'secret-mismatch') {
1021
- hubConnectionStateOverride = null;
1022
- }
1023
- // No re-registration here: this connection runs a mesh join, and the
1024
- // join's settle re-sends the manifest (`registration.onJoinSettled`).
963
+ // Hub connected — its view of this agent was reset, so the full manifest goes again.
964
+ // (A stale `certificate-rejected` clears on the registration's ack.)
965
+ registration.onHubConnected();
1025
966
  if (hubReachableFired)
1026
967
  return;
1027
968
  hubReachableFired = true;
@@ -1053,7 +994,8 @@ async function startAgent(configPath) {
1053
994
  }
1054
995
  }
1055
996
  }
1056
- // Stop accepting new HTTP/HTTPS connections before the broker goes away.
997
+ // Withdraw the mDNS advertisement, then stop accepting new HTTP/HTTPS connections before
998
+ // the broker goes away.
1057
999
  await agentMdns.stop().catch(() => undefined);
1058
1000
  await agentHttpListener?.close().catch(() => undefined);
1059
1001
  await broker.stop();
@@ -19,7 +19,7 @@ exports.createCertificatesRouter = createCertificatesRouter;
19
19
  * recorded from the leaf it presents on its live connection (`observed`).
20
20
  * Renewal is a handover that never leaves a node without a working leaf
21
21
  * (`mesh-renewal.ts`); revocation puts the serial on the CRL and disconnects
22
- * the node, which must re-join with a new join token. Neither forgets a node:
22
+ * the node, which must be paired again. Neither forgets a node:
23
23
  * that stays `clusterNodes.forgetNode`.
24
24
  */
25
25
  const node_crypto_1 = require("node:crypto");
@@ -473,7 +473,7 @@ function createCertificatesRouter(deps) {
473
473
  }),
474
474
  /**
475
475
  * Put the node's mesh leaf on the CRL and disconnect it. The node can no longer
476
- * connect: it must re-join with a new join token. It is NOT forgotten - that stays
476
+ * connect: it must be paired again. It is NOT forgotten - that stays
477
477
  * `clusterNodes.forgetNode`.
478
478
  */
479
479
  revokeMeshOnNode: trpc_middleware_js_1.adminProcedure
@@ -1,37 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderConsentPage = renderConsentPage;
4
- function escapeHtml(s) {
5
- return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
6
- }
7
- /** Allow/Deny consent screen. Submitting POSTs to the same path with the
8
- * hidden query fields + `consent=allow|deny`. */
4
+ const app_page_shell_js_1 = require("../../auth/app-page-shell.js");
5
+ /** Allow/Deny consent screen on the hub's shared page shell (the look of the
6
+ * app sign-in pages). Submitting POSTs to the same path with the hidden query
7
+ * fields + `consent=allow|deny`. */
9
8
  function renderConsentPage(input) {
10
9
  const hidden = Object.entries(input.hidden)
11
- .map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`)
12
- .join('\n ');
13
- return `<!doctype html>
14
- <html lang="en"><head><meta charset="utf-8">
15
- <meta name="viewport" content="width=device-width, initial-scale=1">
16
- <title>CamStack · Authorize ${escapeHtml(input.displayName)}</title>
17
- <style>
18
- body{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:0 1rem;color:#1f2937}
19
- .card{border:1px solid #e5e7eb;border-radius:.5rem;padding:1.5rem}
20
- .meta{font-size:.85rem;color:#6b7280;margin:.5rem 0}
21
- button{font-size:1rem;padding:.5rem 1.25rem;border-radius:.375rem;cursor:pointer;border:1px solid transparent}
22
- .allow{background:#2563eb;color:#fff}.deny{background:#f3f4f6;color:#1f2937;border-color:#d1d5db;margin-left:.5rem}
23
- </style></head><body>
24
- <div class="card">
25
- <h1>Authorize ${escapeHtml(input.displayName)}</h1>
26
- <p class="meta">Signed in as <strong>${escapeHtml(input.username)}</strong></p>
27
- <p class="meta">This will grant: ${escapeHtml(input.scopeSummary)}</p>${input.redirectUri
28
- ? `\n <p class="meta">Sending the authorization to <code>${escapeHtml(input.redirectUri)}</code></p>`
29
- : ''}
30
- <form method="POST">
31
- ${hidden}
32
- <button class="allow" name="consent" value="allow" type="submit">Allow</button>
33
- <button class="deny" name="consent" value="deny" type="submit">Deny</button>
34
- </form>
35
- </div>
36
- </body></html>`;
10
+ .map(([name, value]) => (0, app_page_shell_js_1.appHiddenField)(name, value))
11
+ .join('');
12
+ const destination = input.redirectUri
13
+ ? `<p class="hint">Sending the authorization to <code>${(0, app_page_shell_js_1.escapeHtml)(input.redirectUri)}</code></p>`
14
+ : '';
15
+ return (0, app_page_shell_js_1.renderAppPage)({
16
+ title: `CamStack · Authorize ${input.displayName}`,
17
+ heading: `Authorize ${input.displayName}`,
18
+ body: [
19
+ `<p>Signed in as <strong>${(0, app_page_shell_js_1.escapeHtml)(input.username)}</strong></p>`,
20
+ (0, app_page_shell_js_1.appHint)(`This will grant: ${input.scopeSummary}`),
21
+ destination,
22
+ `<form method="POST">${hidden}<div class="actions">${(0, app_page_shell_js_1.appSubmitButton)('Allow', 'consent', 'allow')}${(0, app_page_shell_js_1.appSubmitButton)('Deny', 'consent', 'deny', true)}</div></form>`,
23
+ ].join(''),
24
+ });
37
25
  }
@@ -320,9 +320,8 @@ function makeDeviceScopeLookup(addonRegistry) {
320
320
  * `principal` names WHO is calling (D802): `mesh` for the hub's own runners
321
321
  * (they reach the bridge over UDS, inside the hub's trust boundary), and
322
322
  * `node:<nodeId>` for another node — whose id the hub's mesh gate has bound
323
- * to a challenge–response proof before the call got here
324
- * (`createMeshAccessMiddleware`; in `transition` mode an unauthenticated node
325
- * is tolerated and its id is only what it claims).
323
+ * to its CERTIFICATE before the call got here (`createMeshAccessMiddleware`:
324
+ * the TLS handshake chained to the mesh CA, the leaf CN is that id, D857).
326
325
  *
327
326
  * Both principals are still admins: that keeps every `adminProcedure` an
328
327
  * agent's addons call working. What is NOT reachable from another node is