@camstack/server 1.2.687 → 1.2.688

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.688] - 2026-10-29
11
+
12
+ ### Changed
13
+
14
+ - Forgetting a node now clears its per-node capability overrides and tells every node-removal-hook owner
15
+ - Forgetting an offline node now revokes its mesh certificates, so it can no longer rejoin by itself; a revoked agent learns it was removed and resets to unpaired.
16
+
10
17
  ## [1.2.687] - 2026-10-29
11
18
 
12
19
  ### Changed
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AgentMembershipWatch = exports.DEFAULT_MEMBERSHIP_CONFIRM_DELAY_MS = exports.DEFAULT_MEMBERSHIP_ASK_INTERVAL_MS = exports.DEFAULT_MEMBERSHIP_GRACE_MS = void 0;
4
+ exports.DEFAULT_MEMBERSHIP_GRACE_MS = 90_000;
5
+ exports.DEFAULT_MEMBERSHIP_ASK_INTERVAL_MS = 60_000;
6
+ exports.DEFAULT_MEMBERSHIP_CONFIRM_DELAY_MS = 15_000;
7
+ /** `revoked` answers in a row that destroy the pairing. */
8
+ const REVOKED_READS_REQUIRED = 2;
9
+ class AgentMembershipWatch {
10
+ deps;
11
+ disconnectedSince = null;
12
+ lastAskAt = null;
13
+ revokedReads = 0;
14
+ asking = false;
15
+ constructor(deps) {
16
+ this.deps = deps;
17
+ }
18
+ /** One step. Resolves once any question this step asked has been handled. */
19
+ async tick(now) {
20
+ if (this.asking)
21
+ return;
22
+ if (!this.deps.isPaired() || this.deps.isConnected()) {
23
+ this.reset();
24
+ return;
25
+ }
26
+ this.disconnectedSince ??= now;
27
+ if (now - this.disconnectedSince < (this.deps.graceMs ?? exports.DEFAULT_MEMBERSHIP_GRACE_MS))
28
+ return;
29
+ const interval = this.revokedReads > 0
30
+ ? (this.deps.confirmDelayMs ?? exports.DEFAULT_MEMBERSHIP_CONFIRM_DELAY_MS)
31
+ : (this.deps.askIntervalMs ?? exports.DEFAULT_MEMBERSHIP_ASK_INTERVAL_MS);
32
+ if (this.lastAskAt !== null && now - this.lastAskAt < interval)
33
+ return;
34
+ this.lastAskAt = now;
35
+ this.asking = true;
36
+ try {
37
+ await this.handle(await this.deps.ask());
38
+ }
39
+ finally {
40
+ this.asking = false;
41
+ }
42
+ }
43
+ reset() {
44
+ this.disconnectedSince = null;
45
+ this.lastAskAt = null;
46
+ this.revokedReads = 0;
47
+ }
48
+ async handle(result) {
49
+ const tags = { nodeId: this.deps.nodeId };
50
+ if (result.kind === 'unreachable') {
51
+ this.revokedReads = 0;
52
+ this.deps.log.info('could not ask the hub whether this agent is still a member - keeping the pairing', {
53
+ tags,
54
+ meta: { error: result.error },
55
+ });
56
+ return;
57
+ }
58
+ const { status, reason } = result.answer;
59
+ if (status !== 'revoked') {
60
+ this.revokedReads = 0;
61
+ this.deps.log.info(`the hub answered "${status}" to the membership question - keeping the pairing`, {
62
+ tags,
63
+ });
64
+ return;
65
+ }
66
+ this.revokedReads += 1;
67
+ const why = reason ?? 'revoked';
68
+ if (this.revokedReads < REVOKED_READS_REQUIRED) {
69
+ this.deps.log.warn(`the hub says this agent's certificate is revoked (${why}) - asking once more before resetting`, {
70
+ tags,
71
+ meta: { reason: why },
72
+ });
73
+ return;
74
+ }
75
+ this.deps.log.warn(`the hub confirmed twice that this agent's certificate is revoked (${why}) - discarding the pairing and becoming pairable again`, { tags, meta: { reason: why } });
76
+ this.reset();
77
+ await this.deps.onRevoked(why);
78
+ }
79
+ }
80
+ exports.AgentMembershipWatch = AgentMembershipWatch;
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.isAgentPaired = isAgentPaired;
37
37
  exports.pinnedMeshCaCert = pinnedMeshCaCert;
38
38
  exports.persistPairing = persistPairing;
39
+ exports.readPairedHubIdentity = readPairedHubIdentity;
39
40
  exports.forgetPairing = forgetPairing;
40
41
  exports.pairedLeafFingerprint = pairedLeafFingerprint;
41
42
  /**
@@ -48,7 +49,13 @@ const fs = __importStar(require("node:fs"));
48
49
  const system_1 = require("@camstack/system");
49
50
  const agent_json_file_js_1 = require("./agent-json-file.js");
50
51
  /** Keys that exist only while this agent is paired. */
51
- const PAIRED_KEYS = ['meshTls', 'meshTlsPrevious', 'meshSwap', 'hubAddress'];
52
+ const PAIRED_KEYS = [
53
+ 'meshTls',
54
+ 'meshTlsPrevious',
55
+ 'meshSwap',
56
+ 'hubAddress',
57
+ 'hubUrl',
58
+ ];
52
59
  /** `agent.json` holds mesh material: the agent is paired. */
53
60
  function isAgentPaired(configPath) {
54
61
  const meshTls = (0, agent_json_file_js_1.readJsonFile)(configPath)['meshTls'];
@@ -72,9 +79,32 @@ function persistPairing(configPath, pairing) {
72
79
  (0, agent_json_file_js_1.writeJsonFile0600)(configPath, {
73
80
  ...existing,
74
81
  hubAddress: pairing.hubAddress,
82
+ // The hub's API address the hub itself gave: where a refused agent asks "am I still a member?".
83
+ hubUrl: pairing.hubUrl,
75
84
  meshTls: pairing.meshTls,
76
85
  });
77
86
  }
87
+ function readPairedHubIdentity(configPath) {
88
+ const config = (0, agent_json_file_js_1.readJsonFile)(configPath);
89
+ const meshTls = config['meshTls'];
90
+ if (meshTls === null || typeof meshTls !== 'object')
91
+ return null;
92
+ const certPem = Reflect.get(meshTls, 'certPem');
93
+ const keyPem = Reflect.get(meshTls, 'keyPem');
94
+ const caCertPem = Reflect.get(meshTls, 'caCertPem');
95
+ if (typeof certPem !== 'string' || typeof keyPem !== 'string' || typeof caCertPem !== 'string') {
96
+ return null;
97
+ }
98
+ const hubUrl = config['hubUrl'];
99
+ const hubAddress = config['hubAddress'];
100
+ return {
101
+ hubUrl: typeof hubUrl === 'string' && hubUrl.length > 0 ? hubUrl : null,
102
+ hubAddress: typeof hubAddress === 'string' && hubAddress.length > 0 ? hubAddress : null,
103
+ certPem,
104
+ keyPem,
105
+ caCertPem,
106
+ };
107
+ }
78
108
  /**
79
109
  * Leave the hub: erase the mesh material, the hub address and the HTTPS bundle
80
110
  * the hub issued. The agent is unpaired afterwards and accepts offers again.
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.membershipFetch = void 0;
4
+ exports.askHubMembership = askHubMembership;
5
+ /**
6
+ * The agent asks its hub "am I still a member?" (D857).
7
+ *
8
+ * Two plain HTTPS calls (`MEMBERSHIP_PATHS`): fetch a nonce, then prove possession of the mesh leaf's
9
+ * private key by signing it. The hub's HTTPS certificate is NOT what this trusts - the hub may serve a
10
+ * private one - the answer is a JWS signed by the mesh CA key, verified here against the CA this agent
11
+ * pinned at pairing, bound to the nonce and the leaf. The key never leaves the process: it signs, and
12
+ * only the signature travels (no argv, no env).
13
+ *
14
+ * Every doubt is `unreachable`, never an answer: a hub that cannot be reached, a status other than
15
+ * 200, a token that does not verify. The caller changes nothing on `unreachable` (D49).
16
+ */
17
+ const node_crypto_1 = require("node:crypto");
18
+ const system_1 = require("@camstack/system");
19
+ const undici_1 = require("undici");
20
+ const REQUEST_TIMEOUT_MS = 8_000;
21
+ /** Production transport: JSON POST with a timeout, TLS not verified (the answer is CA-signed). */
22
+ const membershipFetch = (url, init) => (0, undici_1.fetch)(url, {
23
+ method: 'POST',
24
+ headers: { 'content-type': 'application/json' },
25
+ ...(init.body !== undefined ? { body: init.body } : {}),
26
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
27
+ dispatcher: new undici_1.Agent({ connect: { rejectUnauthorized: false } }),
28
+ });
29
+ exports.membershipFetch = membershipFetch;
30
+ function errorMessage(err) {
31
+ return err instanceof Error ? err.message : String(err);
32
+ }
33
+ async function askHubMembership(query, doFetch = exports.membershipFetch) {
34
+ try {
35
+ const base = query.hubUrl.replace(/\/+$/, '');
36
+ const challenge = await doFetch(`${base}${system_1.MEMBERSHIP_PATHS.challenge}`, {
37
+ body: '{}',
38
+ });
39
+ if (challenge.status !== 200) {
40
+ return {
41
+ kind: 'unreachable',
42
+ error: `challenge answered HTTP ${challenge.status}`,
43
+ };
44
+ }
45
+ const nonce = Reflect.get(Object(await challenge.json()), 'nonce');
46
+ if (typeof nonce !== 'string' || nonce.length === 0) {
47
+ return { kind: 'unreachable', error: 'challenge carried no nonce' };
48
+ }
49
+ const response = await doFetch(`${base}${system_1.MEMBERSHIP_PATHS.query}`, {
50
+ body: JSON.stringify({
51
+ nodeId: query.nodeId,
52
+ certPem: query.certPem,
53
+ nonce,
54
+ signature: (0, system_1.signMembershipProof)(query.keyPem, query.nodeId, nonce),
55
+ }),
56
+ });
57
+ if (response.status !== 200) {
58
+ return {
59
+ kind: 'unreachable',
60
+ error: `membership answered HTTP ${response.status}`,
61
+ };
62
+ }
63
+ const token = Reflect.get(Object(await response.json()), 'token');
64
+ if (typeof token !== 'string')
65
+ return { kind: 'unreachable', error: 'answer carried no token' };
66
+ const answer = (0, system_1.verifyMembershipAnswer)(query.caCertPem, token, {
67
+ nodeId: query.nodeId,
68
+ nonce,
69
+ leafFingerprint: new node_crypto_1.X509Certificate(query.certPem).fingerprint256,
70
+ });
71
+ if (answer === null) {
72
+ return {
73
+ kind: 'unreachable',
74
+ error: 'the answer is not signed by the pinned mesh CA',
75
+ };
76
+ }
77
+ return { kind: 'answer', answer };
78
+ }
79
+ catch (err) {
80
+ return { kind: 'unreachable', error: errorMessage(err) };
81
+ }
82
+ }
@@ -49,7 +49,9 @@ const agent_addon_updates_js_1 = require("./agent-addon-updates.js");
49
49
  const agent_hub_registration_js_1 = require("./agent-hub-registration.js");
50
50
  const agent_mesh_gate_js_1 = require("./agent-mesh-gate.js");
51
51
  const agent_pairing_local_js_1 = require("./agent-pairing-local.js");
52
+ const agent_membership_watch_js_1 = require("./agent-membership-watch.js");
52
53
  const agent_pairing_store_js_1 = require("./agent-pairing-store.js");
54
+ const hub_membership_client_js_1 = require("./hub-membership-client.js");
53
55
  const agent_config_js_1 = require("./agent-config.js");
54
56
  const agent_group_runner_js_1 = require("./agent-group-runner.js");
55
57
  const agent_http_js_1 = require("./agent-http.js");
@@ -76,6 +78,8 @@ const register_agent_cap_dispatch_js_1 = require("./register-agent-cap-dispatch.
76
78
  const system_2 = require("@camstack/system");
77
79
  /** A reconnect (stop + rebuild + start) that takes longer than this is abandoned. */
78
80
  const RECONNECT_DEADLINE_MS = 90_000;
81
+ /** How often the agent re-evaluates whether to ask its hub "am I still a member?" (D857). */
82
+ const MEMBERSHIP_TICK_MS = 15_000;
79
83
  const agentLogManager = new system_2.LogManager(5000);
80
84
  /**
81
85
  * Caps whose `nodeId` arg is provider DATA (`nodeIdMode: 'data'` —
@@ -992,6 +996,41 @@ async function startAgent(configPath) {
992
996
  advertise();
993
997
  };
994
998
  advertise();
999
+ // A hub that forgot or unpaired this agent refuses its leaf without a word. While paired but not
1000
+ // connected, ask the hub whether the leaf is still a member; two CA-signed `revoked` answers in a
1001
+ // row discard the pairing, anything else changes nothing (D857, D49).
1002
+ const membershipWatch = new agent_membership_watch_js_1.AgentMembershipWatch({
1003
+ nodeId: config.nodeId,
1004
+ isPaired: () => (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
1005
+ isConnected: () => getHubConnectionState() === 'connected',
1006
+ ask: async () => {
1007
+ const identity = (0, agent_pairing_store_js_1.readPairedHubIdentity)(config.configPath);
1008
+ const hubUrl = identity?.hubUrl ??
1009
+ (identity?.hubAddress != null ? (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(identity.hubAddress) : undefined);
1010
+ if (identity === null || hubUrl === undefined) {
1011
+ return { kind: 'unreachable', error: 'no hub address on record' };
1012
+ }
1013
+ return (0, hub_membership_client_js_1.askHubMembership)({
1014
+ hubUrl,
1015
+ nodeId: config.nodeId,
1016
+ certPem: identity.certPem,
1017
+ keyPem: identity.keyPem,
1018
+ caCertPem: identity.caCertPem,
1019
+ });
1020
+ },
1021
+ onRevoked: (reason) => forgetHubNow(`the hub revoked this agent's certificate (${reason})`),
1022
+ log: {
1023
+ // The console line has no tag column: the node id rides in the text as well.
1024
+ warn: (message, extras) => consoleLogger.warn(`${message} [nodeId=${config.nodeId}]`, extras),
1025
+ info: (message, extras) => consoleLogger.info(`${message} [nodeId=${config.nodeId}]`, extras),
1026
+ },
1027
+ });
1028
+ const membershipTimer = setInterval(() => {
1029
+ void membershipWatch
1030
+ .tick(Date.now())
1031
+ .catch((err) => consoleLogger.warn(`membership check failed: ${(0, types_2.errMsg)(err)}`));
1032
+ }, MEMBERSHIP_TICK_MS);
1033
+ membershipTimer.unref();
995
1034
  void (0, agent_http_js_1.startAgentHttpServer)(getBrokerFn, {
996
1035
  log: {
997
1036
  info: (message, extras) => consoleLogger.info(message, extras),
@@ -35,6 +35,13 @@ const ForgetNodeResultSchema = zod_1.z.object({
35
35
  registryRemoved: zod_1.z.boolean(),
36
36
  /** Read back AFTER the attempt: the node is still in the cluster registry. */
37
37
  stillListed: zod_1.z.boolean(),
38
+ /** What happened to the node's mesh certificates: `registry-unavailable` / `failed` = NOT revoked. */
39
+ certificates: zod_1.z.discriminatedUnion('outcome', [
40
+ zod_1.z.object({ outcome: zod_1.z.literal('not-attempted') }),
41
+ zod_1.z.object({ outcome: zod_1.z.literal('revoked'), serials: zod_1.z.array(zod_1.z.string()) }),
42
+ zod_1.z.object({ outcome: zod_1.z.literal('registry-unavailable') }),
43
+ zod_1.z.object({ outcome: zod_1.z.literal('failed'), error: zod_1.z.string() }),
44
+ ]),
38
45
  /** One operator-grade sentence naming what happened. */
39
46
  message: zod_1.z.string(),
40
47
  });
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createProcessMembershipResponder = createProcessMembershipResponder;
4
+ exports.registerMeshMembershipRoutes = registerMeshMembershipRoutes;
5
+ const system_1 = require("@camstack/system");
6
+ const auth_rate_limit_1 = require("../auth/auth-rate-limit");
7
+ const WINDOW_MS = 60_000;
8
+ const DEFAULT_PER_MINUTE = 60;
9
+ function createProcessMembershipResponder(logger) {
10
+ return new system_1.MembershipResponder({
11
+ registry: system_1.getProcessMeshCertificateRegistry,
12
+ crl: system_1.processMeshCrl,
13
+ caKeyPem: () => {
14
+ const state = (0, system_1.getProcessMeshCaState)();
15
+ return state.state === 'ready' ? state.ca.keyPem : null;
16
+ },
17
+ log: (message, meta) => logger.warn(message, { tags: { nodeId: meta.nodeId } }),
18
+ });
19
+ }
20
+ function registerMeshMembershipRoutes(fastify, options) {
21
+ const responder = options.responder ?? createProcessMembershipResponder(options.logger);
22
+ const limiter = (0, auth_rate_limit_1.createRateLimiter)({
23
+ windowMs: WINDOW_MS,
24
+ max: options.perMinute ?? DEFAULT_PER_MINUTE,
25
+ ...(options.now !== undefined ? { now: options.now } : {}),
26
+ });
27
+ fastify.post(system_1.MEMBERSHIP_PATHS.challenge, async (request, reply) => {
28
+ const verdict = limiter.check(request.socket.remoteAddress ?? 'unknown');
29
+ if (!verdict.allowed) {
30
+ return reply
31
+ .status(429)
32
+ .header('retry-after', String(verdict.retryAfterSeconds))
33
+ .send({ ok: false });
34
+ }
35
+ return { nonce: responder.issueChallenge() };
36
+ });
37
+ fastify.post(system_1.MEMBERSHIP_PATHS.query, async (request, reply) => {
38
+ const verdict = limiter.check(request.socket.remoteAddress ?? 'unknown');
39
+ if (!verdict.allowed) {
40
+ return reply
41
+ .status(429)
42
+ .header('retry-after', String(verdict.retryAfterSeconds))
43
+ .send({ ok: false });
44
+ }
45
+ const outcome = responder.answer(request.body);
46
+ if (!outcome.ok)
47
+ return reply.status(outcome.status).send({ ok: false });
48
+ return { token: outcome.token };
49
+ });
50
+ }
@@ -2,7 +2,7 @@
2
2
  // AUTO-GENERATED by scripts/generate-cap-mounts.ts — DO NOT EDIT
3
3
  // Re-run: npx tsx scripts/generate-cap-mounts.ts
4
4
  //
5
- // Mounted: 187 Skipped (legacy): 3
5
+ // Mounted: 188 Skipped (legacy): 3
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.LEGACY_SHAPE_SKIP = void 0;
8
8
  exports.mountAllCaps = mountAllCaps;
@@ -456,6 +456,15 @@ function mountAllCaps(services) {
456
456
  null, remoteCapProxy),
457
457
  nodeCertificates: (0, generated_cap_routers_js_1.createCapRouter_nodeCertificates)((_ctx) => reg?.getSingleton('node-certificates') ??
458
458
  null, remoteCapProxy),
459
+ nodeRemovalHook: (0, generated_cap_routers_js_1.createCapRouter_nodeRemovalHook)((_ctx, addonId) => {
460
+ if (!reg)
461
+ return null;
462
+ if (addonId !== undefined) {
463
+ return reg.getProviderByAddonId('node-removal-hook', addonId);
464
+ }
465
+ const entries = reg.getCollectionEntries('node-removal-hook');
466
+ return entries[0]?.[1] ?? null;
467
+ }, remoteCapProxy),
459
468
  nodes: (0, generated_cap_routers_js_1.createCapRouter_nodes)((_ctx) => reg?.getSingleton('nodes') ?? null, remoteCapProxy),
460
469
  notificationOutput: (0, generated_cap_routers_js_1.createCapRouter_notificationOutput)((_ctx, addonId) => {
461
470
  if (!reg)