@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.
@@ -57,24 +57,27 @@ const node_crypto_1 = require("node:crypto");
57
57
  const fs = __importStar(require("node:fs"));
58
58
  const path = __importStar(require("node:path"));
59
59
  const fastify_1 = __importDefault(require("fastify"));
60
+ const zod_1 = require("zod");
60
61
  const system_1 = require("@camstack/system");
61
62
  const agent_sniffing_listener_js_1 = require("./agent-sniffing-listener.js");
62
63
  const agent_http_auth_js_1 = require("./agent-http-auth.js");
64
+ const agent_pairing_routes_js_1 = require("./agent-pairing-routes.js");
65
+ const agent_pairing_store_js_1 = require("./agent-pairing-store.js");
63
66
  const node_1 = require("@camstack/types/node");
64
- const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
65
67
  const agent_update_routes_js_1 = require("./agent-update-routes.js");
66
68
  const derive_hub_url_js_1 = require("./derive-hub-url.js");
67
69
  Object.defineProperty(exports, "pickIpv4", { enumerable: true, get: function () { return derive_hub_url_js_1.pickIpv4; } });
68
70
  const agent_addon_runtime_js_1 = require("./agent-addon-runtime.js");
69
71
  const hubs_found_js_1 = require("./hubs-found.js");
70
- const agent_pairing_routes_js_1 = require("./agent-pairing-routes.js");
71
- const agent_pairing_store_js_1 = require("./agent-pairing-store.js");
72
72
  /** The pinned CA's fingerprint, or `null` when none is pinned or the PEM cannot be read. */
73
73
  function describePairedHub(pinnedCa, hubHttpsOrigin) {
74
74
  if (pinnedCa === null)
75
75
  return null;
76
76
  try {
77
- return { url: hubHttpsOrigin, caFingerprint: (0, system_1.caFingerprintCompact)(pinnedCa) };
77
+ return {
78
+ url: hubHttpsOrigin,
79
+ caFingerprint: (0, system_1.caFingerprintCompact)(pinnedCa),
80
+ };
78
81
  }
79
82
  catch {
80
83
  return null;
@@ -174,7 +177,7 @@ function readConfigFile(configPath) {
174
177
  return {};
175
178
  }
176
179
  }
177
- /** agent.json holds the cluster secret (D797): owner-only on every OS. */
180
+ /** agent.json holds the agent's private mesh key (D857): owner-only on every OS. */
178
181
  function writeConfigFile(configPath, data) {
179
182
  (0, node_1.writeOwnerOnlyFileSync)(configPath, JSON.stringify(data, null, 2));
180
183
  }
@@ -235,13 +238,15 @@ function mapDiscoveredNodes(nodes, selfId) {
235
238
  }
236
239
  /**
237
240
  * Derive the hub connection state from broker registry + config when no
238
- * external getter is wired. Does NOT detect `secret-mismatch` — that
241
+ * external getter is wired. Does NOT detect `certificate-rejected` — that
239
242
  * requires the bootstrap to inject `getHubConnectionState`.
240
243
  */
241
- function deriveHubConnectionState(nodes, hubAddressConfigured) {
244
+ function deriveHubConnectionState(nodes, hubAddressConfigured, paired) {
242
245
  const hubInRegistry = nodes.some((n) => n.id === 'hub');
243
246
  if (hubInRegistry)
244
247
  return 'connected';
248
+ if (!paired)
249
+ return 'unpaired';
245
250
  if (!hubAddressConfigured)
246
251
  return 'unconfigured';
247
252
  return 'disconnected';
@@ -255,54 +260,51 @@ function getEffectiveConfig(configPath, nodeId) {
255
260
  return {
256
261
  name: typeof raw.name === 'string' ? raw.name : nodeId,
257
262
  hubAddress: typeof raw.hubAddress === 'string' ? raw.hubAddress : null,
258
- hasSecret: typeof raw.secret === 'string' && raw.secret.length > 0,
263
+ paired: (0, agent_pairing_store_js_1.isAgentPaired)(configPath),
259
264
  hasMeshTls: raw.meshTls !== null && typeof raw.meshTls === 'object',
260
265
  };
261
266
  }
262
- /** Persisted fields that are credentials: never echoed by `GET /api/agent/config`. */
263
- const NEVER_ECHOED_CONFIG_KEYS = new Set([
264
- 'secret',
265
- 'meshTls',
266
- 'meshTlsPrevious',
267
- 'joinToken',
268
- ]);
267
+ /**
268
+ * The only keys `POST /api/agent/config` writes and `GET /api/agent/config` echoes. Everything
269
+ * else in `agent.json` (the mesh key and chain, a swap in progress, the node id, an old `secret`)
270
+ * is not reachable through this API, in either direction (D857).
271
+ */
272
+ const EDITABLE_CONFIG_KEYS = ['name', 'hubAddress', 'logLevel'];
273
+ /** Value types of the editable keys; unknown keys pass through to the allow-list refusal above. */
274
+ const ConfigPatchSchema = zod_1.z.looseObject({
275
+ name: zod_1.z.string().max(100).nullable().optional(),
276
+ hubAddress: zod_1.z.string().max(300).nullable().optional(),
277
+ logLevel: zod_1.z.enum(['debug', 'info', 'warn', 'error']).optional(),
278
+ });
269
279
  /** Clock skew allowed between the hub that issued a ticket and this agent's boot instant. */
270
280
  const TICKET_BOOT_SKEW_MS = 2 * 60_000;
271
- const defaultRedeemJoinToken = (token, nodeId) => (0, agent_mesh_enroll_js_1.enrollAgentMesh)({ token, nodeId });
272
281
  // ---------------------------------------------------------------------------
273
282
  // Factory
274
283
  // ---------------------------------------------------------------------------
275
284
  async function createAgentHttpServer(getBroker, config) {
276
285
  const app = (0, fastify_1.default)({ logger: false });
277
- const cors = await Promise.resolve().then(() => __importStar(require('@fastify/cors')));
278
- await app.register(cors.default);
286
+ // No CORS: the agent's page is served by the agent itself, so every legitimate caller is
287
+ // same-origin. `Access-Control-Allow-Origin: *` would let any web page read this API (D857).
279
288
  // -- Auth gate (port-4444 hardening, mirrors the hub's /health work) --
280
- // Loopback (Electron renderer, in-container probes) always passes;
281
- // remote callers need Bearer cluster secret OR a hub unlock session
282
- // (D821). While UNPAIRED only the pairing surface is reachable remotely.
283
- // `POST /api/agent/session` redeems an unlock ticket without a prior Bearer.
284
- const currentSecret = () => {
285
- const raw = readConfigFile(config.configPath);
286
- return typeof raw.secret === 'string' && raw.secret.length > 0 ? raw.secret : null;
287
- };
289
+ // Loopback (the agent's own page, the Electron renderer, in-container probes) always passes - a
290
+ // loopback peer behind a proxy does not (forwarding header). A remote caller needs a session minted
291
+ // from a hub unlock ticket (D821, D857). The pairing protocol routes are reachable by anyone, each
292
+ // authorized by what it carries. `POST /api/agent/session` redeems an unlock ticket without a prior
293
+ // session.
288
294
  /**
289
- * Tab sessions from hub unlock tickets. A session is bound to what vouched for it - the mesh CA
290
- * (by fingerprint) or the cluster secret - and dies the moment that changes: forgetting the hub
291
- * or pairing with another one ends every session, not just the old Bearer (D857).
295
+ * Tab sessions from hub unlock tickets. A session is bound to the mesh CA that vouched for it (by
296
+ * fingerprint) and dies the moment that changes: forgetting the hub or pairing with another one
297
+ * ends every session (D857).
292
298
  */
293
299
  const unlockSessions = new Map();
294
300
  let unlockJtis = new system_1.UnlockTicketJtiStore();
295
301
  let jtiBinding = null;
296
302
  const bootMs = Date.now();
297
- /** What would vouch for a session now: the pinned CA, else the secret, else nothing. */
303
+ /** What vouches for a session now: the pinned CA, else nothing. */
298
304
  const caBinding = () => {
299
305
  const ca = (0, agent_pairing_store_js_1.pinnedMeshCaCert)(config.configPath);
300
306
  return ca === null ? null : `ca:${(0, system_1.caFingerprintCompact)(ca)}`;
301
307
  };
302
- const secretBinding = () => {
303
- const secret = currentSecret();
304
- return secret === null ? null : `secret:${(0, system_1.hashClusterSecret)(secret)}`;
305
- };
306
308
  const sessionOk = (token) => {
307
309
  const entry = unlockSessions.get(token);
308
310
  if (entry === undefined)
@@ -311,37 +313,33 @@ async function createAgentHttpServer(getBroker, config) {
311
313
  unlockSessions.delete(token);
312
314
  return false;
313
315
  }
314
- const current = entry.binding.startsWith('ca:') ? caBinding() : secretBinding();
315
- if (current !== entry.binding) {
316
+ if (caBinding() !== entry.binding) {
316
317
  unlockSessions.delete(token);
317
318
  return false;
318
319
  }
319
320
  return true;
320
321
  };
322
+ // The gate decides on the route the ROUTER matched (`routeOptions.url`, the pattern), never on the
323
+ // raw URL: Fastify percent-decodes the path before matching, so `/%61pi/agent/restart` reaches the
324
+ // `/api/agent/restart` handler. Deny by default: only an explicit public route, a static UI asset
325
+ // (marked below) or a 404 (served by the not-found handler, which exposes nothing) skips the check.
321
326
  app.addHook('onRequest', async (req, reply) => {
322
- const pathOnly = req.url.split('?')[0] ?? req.url;
323
- const isProtected = pathOnly.startsWith('/api/') || pathOnly.startsWith('/health/');
324
- if (!isProtected)
327
+ if (req.is404)
325
328
  return;
326
- if ((0, agent_http_auth_js_1.isPairingProtocolRequest)(req.method, pathOnly))
329
+ if (req.routeOptions.config?.publicAsset === true)
327
330
  return;
328
- const secret = currentSecret();
329
- // Paired = holds the cluster secret OR a mesh certificate (D857): either closes the open surface.
330
- const locked = secret !== null || (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath);
331
- if (locked && (0, agent_http_auth_js_1.isUnlockSessionRequest)(req.method, pathOnly))
331
+ const method = req.method === 'HEAD' ? 'GET' : req.method;
332
+ const matched = req.routeOptions.url ?? '';
333
+ if (method === 'GET' && matched === '/health')
332
334
  return;
333
- if ((0, agent_http_auth_js_1.isUnlockInfoRequest)(req.method, pathOnly))
335
+ if ((0, agent_http_auth_js_1.isPairingProtocolRequest)(method, matched))
334
336
  return;
335
- const authInput = {
336
- remoteAddress: req.socket.remoteAddress ?? undefined,
337
- forwarded: (0, agent_http_auth_js_1.hasForwardingHeader)(req.headers),
338
- ...(typeof req.headers.authorization === 'string'
339
- ? { authorization: req.headers.authorization }
340
- : {}),
341
- };
342
- if ((0, agent_http_auth_js_1.isAuthorizedAgentRequest)(authInput, secret, sessionOk))
337
+ if ((0, agent_http_auth_js_1.isUnlockSessionRequest)(method, matched))
338
+ return;
339
+ if ((0, agent_http_auth_js_1.isUnlockInfoRequest)(method, matched))
343
340
  return;
344
- if (!locked && (0, agent_http_auth_js_1.isPairingRequest)(req.method, pathOnly, config.pairing !== undefined))
341
+ const authInput = (0, agent_http_auth_js_1.authInputOf)(req);
342
+ if ((0, agent_http_auth_js_1.isAuthorizedAgentRequest)(authInput, sessionOk))
345
343
  return;
346
344
  return reply.status(401).send({ ok: false, error: 'unauthorized' });
347
345
  });
@@ -390,7 +388,7 @@ async function createAgentHttpServer(getBroker, config) {
390
388
  const hubConnected = nodes.some((n) => n.id === 'hub');
391
389
  const hubConnectionState = config.getHubConnectionState
392
390
  ? config.getHubConnectionState()
393
- : deriveHubConnectionState(nodes, Boolean(eff.hubAddress));
391
+ : deriveHubConnectionState(nodes, Boolean(eff.hubAddress), eff.paired);
394
392
  const meshTransport = config.getMeshTransport
395
393
  ? config.getMeshTransport()
396
394
  : eff.hasMeshTls
@@ -421,8 +419,7 @@ async function createAgentHttpServer(getBroker, config) {
421
419
  appVersion,
422
420
  hubConnected,
423
421
  hubConnectionState,
424
- hasSecret: eff.hasSecret,
425
- paired: (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
422
+ paired: eff.paired,
426
423
  pairedHub,
427
424
  meshTransport,
428
425
  discoveredNodes,
@@ -438,8 +435,7 @@ async function createAgentHttpServer(getBroker, config) {
438
435
  appVersion,
439
436
  hubConnected,
440
437
  hubConnectionState,
441
- hasSecret: eff.hasSecret,
442
- paired: (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
438
+ paired: eff.paired,
443
439
  pairedHub,
444
440
  meshTransport,
445
441
  discoveredNodes,
@@ -450,7 +446,6 @@ async function createAgentHttpServer(getBroker, config) {
450
446
  });
451
447
  // -- Unlock info (public; Continue-with-hub needs hub origin while locked) --
452
448
  app.get('/api/agent/unlock-info', async () => {
453
- const secret = currentSecret();
454
449
  const eff = getEffectiveConfig(config.configPath, config.nodeId);
455
450
  const nodes = getRegistryNodes(getBroker());
456
451
  const resolvedHubAddress = resolveHubEndpoint(nodes);
@@ -458,35 +453,28 @@ async function createAgentHttpServer(getBroker, config) {
458
453
  (resolvedHubAddress !== null ? (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(resolvedHubAddress) : undefined) ??
459
454
  null;
460
455
  return {
461
- paired: secret !== null || (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
456
+ paired: eff.paired,
462
457
  nodeId: config.nodeId,
463
458
  hubHttpsOrigin,
464
459
  };
465
460
  });
466
461
  // -- Unlock session (hub admin ticket → tab Bearer; D821) -----------
467
462
  app.post('/api/agent/session', async (req, reply) => {
468
- const secret = currentSecret();
463
+ // The ticket is signed by the mesh CA this agent pinned at pairing (D857).
469
464
  const pinnedCa = (0, agent_pairing_store_js_1.pinnedMeshCaCert)(config.configPath);
470
- if (secret === null && pinnedCa === null) {
465
+ if (pinnedCa === null) {
471
466
  return reply.status(400).send({ ok: false, error: 'agent is not paired' });
472
467
  }
473
468
  const ticket = typeof req.body?.ticket === 'string' ? req.body.ticket.trim() : '';
474
469
  if (ticket.length === 0) {
475
470
  return reply.status(400).send({ ok: false, error: 'ticket required' });
476
471
  }
477
- // Two forms are accepted in the transition release (D857): signed by the mesh CA this agent
478
- // pinned (ES256), or the older HMAC under the cluster secret.
479
472
  // Nothing issued before this process started is accepted: the one-use ledger is in memory.
480
- const options = {
473
+ const payload = (0, system_1.verifyAgentUiUnlockTicket)(pinnedCa, ticket, {
481
474
  expectedNodeId: config.nodeId,
482
475
  notBeforeMs: bootMs - TICKET_BOOT_SKEW_MS,
483
- };
484
- const es256 = pinnedCa !== null ? (0, system_1.verifyAgentUiUnlockTicketEs256)(pinnedCa, ticket, options) : null;
485
- const hmac = es256 === null && secret !== null
486
- ? (0, system_1.verifyAgentUiUnlockTicket)((0, system_1.hashClusterSecret)(secret), ticket, options)
487
- : null;
488
- const payload = es256 ?? hmac;
489
- const binding = es256 !== null ? caBinding() : secretBinding();
476
+ });
477
+ const binding = caBinding();
490
478
  if (payload === null || binding === null) {
491
479
  return reply.status(401).send({ ok: false, error: 'invalid or expired unlock ticket' });
492
480
  }
@@ -544,12 +532,9 @@ async function createAgentHttpServer(getBroker, config) {
544
532
  nodeId: config.nodeId,
545
533
  name: eff.name,
546
534
  hubAddress: eff.hubAddress,
547
- hasSecret: eff.hasSecret,
535
+ paired: eff.paired,
548
536
  hasMeshTls: eff.hasMeshTls,
549
- configPath: config.configPath,
550
- dataDir: config.dataDir,
551
- // Include all persisted fields except credentials (secret, mesh key, pending token)
552
- ...Object.fromEntries(Object.entries(persisted).filter(([k]) => !NEVER_ECHOED_CONFIG_KEYS.has(k))),
537
+ ...Object.fromEntries(Object.entries(persisted).filter(([k]) => EDITABLE_CONFIG_KEYS.includes(k))),
553
538
  };
554
539
  });
555
540
  // -- Config write (merge-patch + persist) ---------------------------
@@ -557,56 +542,23 @@ async function createAgentHttpServer(getBroker, config) {
557
542
  // the returned value / catches rejections, so an async handler is correct here.
558
543
  // eslint-disable-next-line oxc/no-async-endpoint-handlers
559
544
  app.post('/api/agent/config', async (req, reply) => {
560
- // `joinToken` is redeemed, never stored; `meshTls` only ever comes from an
561
- // enrollment (or is cleared via `clearMeshTls`), never pasted as PEM.
562
- const body = req.body ?? {};
563
- const joinToken = body['joinToken'];
564
- const clearMeshTls = body['clearMeshTls'] === true;
565
- const patch = Object.fromEntries(Object.entries(body).filter(([k]) => k !== 'joinToken' && k !== 'meshTls' && k !== 'clearMeshTls'));
566
- const existing = readConfigFile(config.configPath);
567
- const alreadyPaired = (typeof existing.secret === 'string' && existing.secret.length > 0) ||
568
- (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath);
569
- const patchSecret = typeof patch['secret'] === 'string' && patch['secret'].trim().length > 0;
570
- const hasJoinToken = typeof joinToken === 'string' && joinToken.trim().length > 0;
571
- // First pairing (D803 / D821): hub always has a secret — require secret or
572
- // join token. Later edits may omit secret to keep the stored value.
573
- // A rename alone is not a pairing: it is allowed before the agent is paired (D857).
574
- const renameOnly = Object.keys(patch).every((k) => k === 'name');
575
- if (!alreadyPaired && !patchSecret && !hasJoinToken && !renameOnly) {
545
+ // Allow-list (D857): the mesh material only ever comes from a pairing, and the node id never
546
+ // changes. Any other key is refused whole - nothing is written.
547
+ const parsed = ConfigPatchSchema.safeParse(req.body ?? {});
548
+ if (!parsed.success) {
549
+ return reply.status(400).send({ success: false, error: 'Invalid config values' });
550
+ }
551
+ const body = parsed.data;
552
+ const refused = Object.keys(body).filter((k) => !EDITABLE_CONFIG_KEYS.includes(k));
553
+ if (refused.length > 0) {
576
554
  return reply.status(400).send({
577
555
  success: false,
578
- error: 'Cluster secret is required — copy it from the hub System → Cluster page, or paste a join token',
556
+ error: `Not editable here: ${refused.join(', ')}. Editable: ${EDITABLE_CONFIG_KEYS.join(', ')}`,
579
557
  });
580
558
  }
581
- // Join-token enrollment (mesh mTLS Release A): the hub address comes from
582
- // the token and wins over a `hubAddress` posted in the same form. Nothing is
583
- // written unless the whole enrollment verified.
584
- let enrolled = null;
585
- if (hasJoinToken) {
586
- try {
587
- enrolled = await (config.redeemJoinToken ?? defaultRedeemJoinToken)(joinToken, config.nodeId);
588
- }
589
- catch (err) {
590
- const message = err instanceof Error ? err.message : String(err);
591
- console.error(`[Agent] join-token enrollment failed: ${message}`);
592
- return reply
593
- .status(err instanceof agent_mesh_enroll_js_1.MeshEnrollError ? 400 : 502)
594
- .send({ success: false, error: message });
595
- }
596
- }
597
- const merged = { ...existing, ...patch };
598
- if (enrolled !== null) {
599
- merged['hubAddress'] = enrolled.hubAddress;
600
- merged['meshTls'] = enrolled.meshTls;
601
- }
602
- else if (clearMeshTls) {
603
- delete merged['meshTls'];
604
- }
605
- writeConfigFile(config.configPath, merged);
606
- if (enrolled !== null) {
607
- console.log(`[Agent] Enrolled in the mesh CA via join token — hub ${enrolled.hubAddress}, ` +
608
- 'mutual TLS on next reconnect');
609
- }
559
+ const patch = Object.fromEntries(Object.entries(body));
560
+ const existing = readConfigFile(config.configPath);
561
+ writeConfigFile(config.configPath, { ...existing, ...patch });
610
562
  // Apply name change immediately (no reconnect needed)
611
563
  if (typeof patch.name === 'string' && patch.name.trim()) {
612
564
  try {
@@ -617,22 +569,13 @@ async function createAgentHttpServer(getBroker, config) {
617
569
  /* best-effort */
618
570
  }
619
571
  }
620
- // Only flag reconnect when a connection-affecting field actually
621
- // CHANGED. The form posts every visible field on every Save, so
622
- // `patch.hubAddress !== undefined` was always true and produced
623
- // false-positive "Restart required" prompts on no-op saves.
624
- const reconnectRelevant = ['hubAddress', 'secret'];
625
- const needsReconnect = enrolled !== null ||
626
- clearMeshTls ||
627
- reconnectRelevant.some((k) => Object.prototype.hasOwnProperty.call(patch, k) && patch[k] !== existing[k]);
628
- return {
629
- success: true,
630
- restartRequired: needsReconnect,
631
- ...(enrolled !== null ? { enrolled: true } : {}),
632
- ...(clearMeshTls ? { clearedMeshTls: true } : {}),
633
- };
572
+ // Only flag reconnect when a connection-affecting field actually CHANGED. The form posts
573
+ // every visible field on every Save, so a present `hubAddress` alone is not a change.
574
+ const needsReconnect = Object.prototype.hasOwnProperty.call(patch, 'hubAddress') &&
575
+ patch['hubAddress'] !== existing['hubAddress'];
576
+ return { success: true, restartRequired: needsReconnect };
634
577
  });
635
- // -- Agent reconnect (applies new hub/secret config) ----------------
578
+ // -- Agent reconnect (applies a new hub address / mesh material) -----
636
579
  app.post('/api/agent/restart', async () => {
637
580
  if (!config.onReconnect) {
638
581
  return { success: false, message: 'Reconnect not available' };
@@ -668,6 +611,12 @@ async function createAgentHttpServer(getBroker, config) {
668
611
  const uiDir = resolveUiDistDir(config.dataDir, process.env['CAMSTACK_BUNDLED_ADDONS_DIR']);
669
612
  if (uiDir) {
670
613
  const fastifyStatic = await Promise.resolve().then(() => __importStar(require('@fastify/static')));
614
+ // Every route the static plugin mounts is the public UI bundle: tag them for the auth gate.
615
+ let mountingAssets = true;
616
+ app.addHook('onRoute', (route) => {
617
+ if (mountingAssets)
618
+ route.config = { ...route.config, publicAsset: true };
619
+ });
671
620
  await app.register(fastifyStatic.default, {
672
621
  root: uiDir,
673
622
  prefix: '/',
@@ -677,6 +626,7 @@ async function createAgentHttpServer(getBroker, config) {
677
626
  // fallback request 500'd with "reply.sendFile is not a function".
678
627
  decorateReply: true,
679
628
  });
629
+ mountingAssets = false;
680
630
  app.setNotFoundHandler(async (req, reply) => {
681
631
  if (req.url.startsWith('/api/') || req.url.startsWith('/health')) {
682
632
  return reply.status(404).send({ error: 'Not found' });
@@ -2,59 +2,37 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createAgentHubRegistration = createAgentHubRegistration;
4
4
  /**
5
- * The agent's upward capability registration (D3), bound to the mesh join.
5
+ * The agent's upward capability registration (D3).
6
6
  *
7
- * The hub forgets an agent — its join AND its registration — on every
8
- * `$node.disconnected` the HUB sees. That event is not symmetric: Moleculer
9
- * marks a node offline on gossip from a third node, on a failed write, on a
10
- * heartbeat timeout, and the agent, whose own view of the hub never broke,
11
- * sees none of it. On 2026-10-14 (the D848 rollout) the hub flapped
12
- * little-unraid and the Mac offline/online every 2–6 s for ~35 min while older
13
- * agents still gossiped about them; each flap wiped the agent's capabilities
14
- * on the hub, the agent re-joined (its next call came back "not
15
- * authenticated") but never re-registered, and both stayed online with ZERO
16
- * capabilities until a manual restart.
7
+ * The hub forgets an agent's registration on every `$node.disconnected` the HUB
8
+ * sees. That event is not symmetric: Moleculer marks a node offline on gossip
9
+ * from a third node, on a failed write, on a heartbeat timeout, and the agent,
10
+ * whose own view of the hub never broke, sees none of it. On 2026-10-14 the hub
11
+ * flapped two agents offline/online every 2–6 s for ~35 min; each flap wiped the
12
+ * agent's capabilities on the hub and nothing told the agent to send them again.
17
13
  *
18
- * So the registration follows the JOIN, not the agent's own `$node.connected`:
19
- * - every join that settles in a state that lets calls through re-sends the
20
- * complete manifest — a join runs exactly when the hub's view of this agent
21
- * was reset (a new connection, or the hub said it lost us);
22
- * - a registration the hub refuses as "cluster secret mismatch" while THIS
23
- * connection's join proved the secret is not a mismatch: the hub forgot the
24
- * join. The agent joins again, and that join re-registers;
14
+ * So the registration follows the HUB'S CONNECTION and the hub's own ask, not
15
+ * the agent's view of itself:
16
+ * - every time the hub node connects, the complete manifest is sent again;
25
17
  * - the hub may ask for it (`$agent.requestRegistration`, sent on the hub's
26
- * own `$node.connected`), which closes the window in which the agent makes
27
- * no call that could tell it the hub forgot.
18
+ * own `$node.connected`);
19
+ * - a child runner registering, an addon reload and boot send it too.
20
+ *
21
+ * Who the agent is comes from its certificate (D857): there is no join to wait
22
+ * for and no secret to prove. If the hub refuses the registration because it
23
+ * does not accept this agent's certificate (revoked, or issued by another CA),
24
+ * `onCertificateRefused` says so and the retry loop keeps trying — the operator
25
+ * re-pairs, and the next attempt succeeds.
28
26
  *
29
27
  * Each send is ONE atomic `$hub.registerNode` carrying the full set; sends are
30
28
  * coalesced (one in flight, at most one queued, the manifest built when it
31
29
  * goes). Nothing waits on a timer: the triggers are events, the retry is the ack.
32
30
  */
33
31
  const system_1 = require("@camstack/system");
34
- /** States in which a call to the hub is refused locally — nothing to send. */
35
- const BLOCKING = new Set(['secret-mismatch', 'hub-unverified']);
36
32
  function createAgentHubRegistration(opts) {
37
- let waiting = false;
38
33
  let sending = false;
39
34
  let queuedReason = null;
40
- const onRefused = (err) => {
41
- if (!(0, system_1.isClusterSecretMismatchError)(err))
42
- return; // aborted: shutting down
43
- if (opts.join.client.state === 'authenticated') {
44
- // This connection's join PROVED the secret, so the hub did not refuse
45
- // the secret — it forgot the join (it saw this node disconnect). Join
46
- // again; the settled join re-registers.
47
- opts.log('[register-node] the hub refused the registration although this connection proved the cluster secret — it has forgotten the join; joining again');
48
- opts.join.rejoin();
49
- return;
50
- }
51
- opts.onSecretRefused?.(err);
52
- };
53
35
  const send = (reason) => {
54
- if (sending) {
55
- queuedReason = reason;
56
- return;
57
- }
58
36
  sending = true;
59
37
  const manifest = opts.buildManifest();
60
38
  const bindings = manifest.addons.reduce((n, a) => n + a.capabilities.length, 0);
@@ -67,12 +45,18 @@ function createAgentHubRegistration(opts) {
67
45
  target: 'hub',
68
46
  ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
69
47
  log: opts.log,
48
+ onAttemptFailed: (err) => {
49
+ if ((0, system_1.meshErrorType)(err) === system_1.MESH_NODE_UNAUTHENTICATED_TYPE)
50
+ opts.onCertificateRefused?.(err);
51
+ },
70
52
  })
71
53
  .then(() => {
72
54
  opts.log(`[register-node] the hub acked this node's capabilities: ${manifest.addons.length} addon(s), ${bindings} binding(s) (${reason})`);
73
55
  opts.onAcked?.();
74
56
  })
75
- .catch(onRefused)
57
+ .catch(() => {
58
+ // Aborted: shutting down.
59
+ })
76
60
  .finally(() => {
77
61
  sending = false;
78
62
  const next = queuedReason;
@@ -82,27 +66,16 @@ function createAgentHubRegistration(opts) {
82
66
  });
83
67
  };
84
68
  const trigger = (reason) => {
85
- // A send already waiting for the join builds its manifest when it goes.
86
- if (waiting)
69
+ if (sending) {
70
+ queuedReason = reason;
87
71
  return;
88
- waiting = true;
89
- opts.join.client.whenCallable().then(() => {
90
- waiting = false;
91
- send(reason);
92
- }, () => {
93
- // Blocked (mismatch / unverified hub) or the join is still pending:
94
- // the next settled join sends it (`onJoinSettled`).
95
- waiting = false;
96
- });
72
+ }
73
+ send(reason);
97
74
  };
98
75
  return {
99
76
  trigger,
100
- onJoinSettled(state) {
101
- if (BLOCKING.has(state))
102
- return;
103
- // Every settled join means the hub's view of this node was reset (a new
104
- // connection, or the hub said it lost us): the full set goes again.
105
- trigger(`join settled: ${state}`);
77
+ onHubConnected() {
78
+ trigger('hub connected');
106
79
  },
107
80
  };
108
81
  }
@@ -0,0 +1,59 @@
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.readJsonFile = readJsonFile;
37
+ exports.writeJsonFile0600 = writeJsonFile0600;
38
+ /**
39
+ * `agent.json` read/write helpers. The file is owner-only on every OS (an owner+SYSTEM ACL on
40
+ * Windows, where 0600 is a no-op) because it holds the mesh private key.
41
+ */
42
+ const fs = __importStar(require("node:fs"));
43
+ const node_1 = require("@camstack/types/node");
44
+ function readJsonFile(file) {
45
+ if (!fs.existsSync(file))
46
+ return {};
47
+ try {
48
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
49
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
50
+ ? Object.fromEntries(Object.entries(parsed))
51
+ : {};
52
+ }
53
+ catch {
54
+ return {};
55
+ }
56
+ }
57
+ function writeJsonFile0600(file, data) {
58
+ (0, node_1.writeOwnerOnlyFileSync)(file, JSON.stringify(data, null, 2));
59
+ }