@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.
@@ -402,6 +402,7 @@ class AddonRegistryService {
402
402
  activeSettingsBackend = null;
403
403
  integrationRegistry = null;
404
404
  clusterNodeHistoryStore = null;
405
+ nodeTombstoneStore = null;
405
406
  clusterAgentRecords = null;
406
407
  credentialTokenService = null;
407
408
  // Device architecture — in-memory registry of live IDevice instances.
@@ -1392,6 +1393,19 @@ class AddonRegistryService {
1392
1393
  const kind = records.kind(system_1.clusterNodeHistoryRecordKind);
1393
1394
  clusterStore.bindRecordsKind(() => kind);
1394
1395
  }
1396
+ /** Durable node tombstones (#265) — bound to the `cluster-agent` records like the history. */
1397
+ setNodeTombstoneStore(store) {
1398
+ this.nodeTombstoneStore = store;
1399
+ this.bindNodeTombstoneRecordsKind();
1400
+ }
1401
+ bindNodeTombstoneRecordsKind() {
1402
+ const store = this.nodeTombstoneStore;
1403
+ const records = this.clusterAgentRecords;
1404
+ if (store === null || records === null)
1405
+ return;
1406
+ const kind = records.kind(system_1.clusterNodeTombstoneRecordKind);
1407
+ store.bindRecordsKind(() => kind);
1408
+ }
1395
1409
  /** The unified token store (D892), bound to its `core-auth` records. */
1396
1410
  setCredentialTokenService(service) {
1397
1411
  this.credentialTokenService = service;
@@ -1409,6 +1423,7 @@ class AddonRegistryService {
1409
1423
  await clusterRecords.declare(system_1.clusterAgentRecordKinds);
1410
1424
  this.clusterAgentRecords = clusterRecords;
1411
1425
  this.bindClusterNodeHistoryRecordsKind();
1426
+ this.bindNodeTombstoneRecordsKind();
1412
1427
  const credentialTokens = this.credentialTokenService;
1413
1428
  if (credentialTokens !== null) {
1414
1429
  const coreRecords = this.recordsForOwner('core-auth', this.secretsForOwner('core-auth'));
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.AgentRegistryService = exports.AGENT_BACKFILL_RPC_TIMEOUT_MS = void 0;
36
+ exports.AgentRegistryService = exports.AGENT_BACKFILL_RPC_TIMEOUT_MS = exports.FORGOTTEN_NODE_REVOCATION_REASON = void 0;
37
37
  exports.toNodeLiveness = toNodeLiveness;
38
38
  exports.toDescriptor = toDescriptor;
39
39
  const node_crypto_1 = require("node:crypto");
@@ -45,6 +45,20 @@ const agent_delivery_ledger_1 = require("./agent-delivery-ledger");
45
45
  const agent_liveness_event_1 = require("./agent-liveness-event");
46
46
  const cluster_node_history_mirror_1 = require("./cluster-node-history-mirror");
47
47
  const node_directory_1 = require("./node-directory");
48
+ /** The reason recorded on a certificate revoked because its node was forgotten. */
49
+ exports.FORGOTTEN_NODE_REVOCATION_REASON = 'forgotten';
50
+ function describeRevocation(revocation) {
51
+ switch (revocation.outcome) {
52
+ case 'not-attempted':
53
+ return '';
54
+ case 'revoked':
55
+ return ` ${revocation.serials.length} certificate(s) revoked - the node must be paired again.`;
56
+ case 'registry-unavailable':
57
+ return ' Its certificates were NOT revoked (the certificate registry is not loaded) - it can still reconnect.';
58
+ case 'failed':
59
+ return ` Its certificates were NOT revoked (${revocation.error}) - it can still reconnect.`;
60
+ }
61
+ }
48
62
  /** Turn a forget outcome into the sentence the operator reads. */
49
63
  function describeForgetOutcome(nodeId, history, registryRemoved, stillListed) {
50
64
  const parts = [];
@@ -133,7 +147,11 @@ function toNodeLiveness(nodes) {
133
147
  // (seen live 2026-07-16 after agent churn): guard it or every consumer of
134
148
  // this projection 500s on `undefined.includes`.
135
149
  .filter((node) => typeof node.id === 'string' && !node.id.includes('/'))
136
- .map((node) => ({ id: node.id, isHub: node.id === 'hub', isOnline: node.available })));
150
+ .map((node) => ({
151
+ id: node.id,
152
+ isHub: node.id === 'hub',
153
+ isOnline: node.available,
154
+ })));
137
155
  }
138
156
  /**
139
157
  * Project a live `AgentListItem` into the durable history descriptor. Field
@@ -245,6 +263,52 @@ class AgentRegistryService {
245
263
  setAddonRegistry(addonRegistry) {
246
264
  this.addonRegistry = addonRegistry;
247
265
  }
266
+ /**
267
+ * Wire what runs AFTER a node is really forgotten: the `node-removal-hook`
268
+ * dispatch and the hub-owned node-scoped data (#265). It never runs for a
269
+ * refused or a no-op forget.
270
+ */
271
+ setNodeRemovalCleanup(cleanup) {
272
+ this.nodeRemovalCleanup = cleanup;
273
+ }
274
+ nodeRemovalCleanup = null;
275
+ /**
276
+ * Wire the revocation of a forgotten node's mesh certificates (D857). Injected so this service
277
+ * never imports the pairing router; unwired reads as `registry-unavailable`.
278
+ */
279
+ setNodeCertificateRevoker(revoker) {
280
+ this.nodeCertificateRevoker = revoker;
281
+ }
282
+ nodeCertificateRevoker = null;
283
+ async revokeForgottenNodeCertificates(nodeId) {
284
+ let revocation;
285
+ try {
286
+ const serials = (await this.nodeCertificateRevoker?.(nodeId, exports.FORGOTTEN_NODE_REVOCATION_REASON)) ?? null;
287
+ revocation =
288
+ serials === null
289
+ ? { outcome: 'registry-unavailable' }
290
+ : { outcome: 'revoked', serials: [...serials] };
291
+ }
292
+ catch (err) {
293
+ revocation = {
294
+ outcome: 'failed',
295
+ error: err instanceof Error ? err.message : String(err),
296
+ };
297
+ }
298
+ if (revocation.outcome === 'revoked') {
299
+ this.logger?.info('node forgotten: certificates revoked', {
300
+ tags: { nodeId },
301
+ meta: { serials: revocation.serials },
302
+ });
303
+ }
304
+ else {
305
+ this.logger?.warn('node forgotten: certificates were NOT revoked', {
306
+ tags: { nodeId },
307
+ meta: { outcome: revocation.outcome },
308
+ });
309
+ }
310
+ return revocation;
311
+ }
248
312
  /** Wire the durable offline-node history store (A1). */
249
313
  setClusterNodeHistoryStore(store) {
250
314
  this.historyStore = store;
@@ -300,7 +364,12 @@ class AgentRegistryService {
300
364
  timestamp: new Date(),
301
365
  source: { type: 'core', id: 'agent-registry' },
302
366
  category: types_1.EventCategory.AddonUninstalled,
303
- data: { addonId, agentId: nodeId, packageName, reason: 'operator uninstall' },
367
+ data: {
368
+ addonId,
369
+ agentId: nodeId,
370
+ packageName,
371
+ reason: 'operator uninstall',
372
+ },
304
373
  });
305
374
  }
306
375
  }
@@ -366,9 +435,12 @@ class AgentRegistryService {
366
435
  historyRemoved: false,
367
436
  registryRemoved: false,
368
437
  stillListed: true,
438
+ certificates: { outcome: 'not-attempted' },
369
439
  message,
370
440
  };
371
441
  }
442
+ // First: a forgotten node whose leaf stays valid walks back in on its next boot (D857).
443
+ const certificates = await this.revokeForgottenNodeCertificates(nodeId);
372
444
  const historyOutcome = this.historyStore
373
445
  ? await this.historyStore.forget(nodeId)
374
446
  : 'store-unavailable';
@@ -388,7 +460,8 @@ class AgentRegistryService {
388
460
  }));
389
461
  const historyRemoved = historyOutcome === 'removed';
390
462
  const stillListed = this.isNodeKnown(nodeId);
391
- const message = describeForgetOutcome(nodeId, historyOutcome, registryRemoved, stillListed);
463
+ const message = describeForgetOutcome(nodeId, historyOutcome, registryRemoved, stillListed) +
464
+ describeRevocation(certificates);
392
465
  this.logger?.info('forget cluster node', {
393
466
  tags: { nodeId },
394
467
  meta: { historyOutcome, historyRemoved, registryRemoved, stillListed },
@@ -398,6 +471,17 @@ class AgentRegistryService {
398
471
  // operator had to refresh by hand. `TopologyEmitterService` listens for
399
472
  // `AgentUnregistered` and re-emits immediately.
400
473
  if (historyRemoved || registryRemoved) {
474
+ // The recorded fact the owners of node-scoped data are driven from. A
475
+ // failure here must not turn a forget that happened into an error.
476
+ try {
477
+ await this.nodeRemovalCleanup?.(nodeId, Date.now());
478
+ }
479
+ catch (err) {
480
+ this.logger?.warn('node removal cleanup failed', {
481
+ tags: { nodeId },
482
+ meta: { error: err instanceof Error ? err.message : String(err) },
483
+ });
484
+ }
401
485
  this.eventBus.emit({
402
486
  id: (0, node_crypto_1.randomUUID)(),
403
487
  timestamp: new Date(),
@@ -411,6 +495,7 @@ class AgentRegistryService {
411
495
  historyRemoved,
412
496
  registryRemoved,
413
497
  stillListed,
498
+ certificates,
414
499
  message,
415
500
  };
416
501
  }
@@ -481,7 +566,10 @@ class AgentRegistryService {
481
566
  timestamp: new Date(),
482
567
  source: { type: 'core', id: 'agent-registry' },
483
568
  category: types_1.EventCategory.AgentOnline,
484
- data: { agentId: node.id, ...(nodeName !== undefined ? { nodeName } : {}) },
569
+ data: {
570
+ agentId: node.id,
571
+ ...(nodeName !== undefined ? { nodeName } : {}),
572
+ },
485
573
  });
486
574
  }
487
575
  else {
@@ -807,7 +895,9 @@ class AgentRegistryService {
807
895
  if (priorRoster.length === 0) {
808
896
  this.logger?.info('back-fill: fresh node (no prior declared roster), plan from the hub any-node set', {
809
897
  tags: { nodeId: agentId },
810
- meta: { planned: planned.targets.map((t) => `${t.name}@${t.version}`) },
898
+ meta: {
899
+ planned: planned.targets.map((t) => `${t.name}@${t.version}`),
900
+ },
811
901
  });
812
902
  for (const skip of planned.skipped) {
813
903
  this.logger?.info('back-fill: fresh node not given a hub package', {
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createForgottenNodeRevoker = createForgottenNodeRevoker;
4
+ /**
5
+ * The hub's revocation of a FORGOTTEN node's mesh certificates (D857, #265).
6
+ *
7
+ * `AgentRegistryService.forgetClusterNode` is handed this as a seam so it never imports the
8
+ * pairing router; the revocation is the same one the operator's "unpair" performs
9
+ * (`revokeMeshCertificate`: every leaf of the node onto the CRL, its sockets cut) with the
10
+ * reason `forgotten`, which is what the node is told when it asks why it is refused.
11
+ */
12
+ const system_1 = require("@camstack/system");
13
+ function createForgottenNodeRevoker(deps) {
14
+ const registry = deps.registry ?? system_1.getProcessMeshCertificateRegistry;
15
+ const crl = deps.crl ?? system_1.processMeshCrl;
16
+ const peers = deps.peers ?? {
17
+ peerLeaf: (nodeId) => (0, system_1.getProcessPeerLeaf)(nodeId),
18
+ dropPeer: (nodeId) => (0, system_1.dropProcessPeer)(nodeId),
19
+ };
20
+ return async (nodeId, reason) => {
21
+ const loaded = registry();
22
+ if (loaded === null)
23
+ return null;
24
+ const result = await (0, system_1.revokeMeshCertificate)({
25
+ nodeId,
26
+ registry: loaded,
27
+ crl: crl(),
28
+ peers,
29
+ reason,
30
+ log: deps.logger,
31
+ });
32
+ return result.revokedSerials;
33
+ };
34
+ }
@@ -0,0 +1,186 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NodeRemovalDispatcher = exports.NodeTombstoneStore = void 0;
4
+ exports.registryNodeRemovalHooks = registryNodeRemovalHooks;
5
+ exports.clearSingletonNodeOverrides = clearSingletonNodeOverrides;
6
+ const system_1 = require("@camstack/system");
7
+ const types_1 = require("@camstack/types");
8
+ const capability_preference_writes_1 = require("../../api/core/capability-preference-writes");
9
+ /** Durable tombstones over the `cluster-agent` `node-tombstones` records kind. */
10
+ class NodeTombstoneStore {
11
+ logger;
12
+ resolveKind;
13
+ constructor(logger, resolveKind = () => null) {
14
+ this.logger = logger;
15
+ this.resolveKind = resolveKind;
16
+ }
17
+ /** Hub wiring once the records provider is up. */
18
+ bindRecordsKind(resolver) {
19
+ this.resolveKind = resolver;
20
+ }
21
+ /** Record the forget. `false` = not persisted (kind unbound, or the write failed). */
22
+ async record(nodeId, removedAt, pending) {
23
+ const kind = this.resolveKind();
24
+ if (kind === null)
25
+ return false;
26
+ try {
27
+ await kind.put({ id: nodeId, at: removedAt, data: { removedAt, pending } });
28
+ return true;
29
+ }
30
+ catch (err) {
31
+ this.logger.warn('could not record node tombstone', {
32
+ tags: { nodeId },
33
+ meta: { error: (0, types_1.errMsg)(err) },
34
+ });
35
+ return false;
36
+ }
37
+ }
38
+ /** One implementer acknowledged: drop it from `pending`; delete the row when none is left. */
39
+ async acknowledge(nodeId, addonId) {
40
+ const kind = this.resolveKind();
41
+ if (kind === null)
42
+ return;
43
+ try {
44
+ const row = await kind.get(nodeId);
45
+ if (row === null)
46
+ return;
47
+ const pending = row.data.pending.filter((id) => id !== addonId);
48
+ if (pending.length === 0)
49
+ await kind.delete(nodeId);
50
+ else
51
+ await kind.put({ id: nodeId, at: row.data.removedAt, data: { ...row.data, pending } });
52
+ }
53
+ catch (err) {
54
+ this.logger.warn('could not acknowledge node tombstone', {
55
+ tags: { nodeId },
56
+ meta: { addonId, error: (0, types_1.errMsg)(err) },
57
+ });
58
+ }
59
+ }
60
+ /** Tombstones with an unacknowledged implementer. Unanswered read = none (a retry, never a delete). */
61
+ async listPending() {
62
+ const kind = this.resolveKind();
63
+ if (kind === null)
64
+ return [];
65
+ try {
66
+ const out = [];
67
+ for (const row of await (0, system_1.readAllRows)(kind)) {
68
+ const parsed = system_1.ClusterNodeTombstoneBodySchema.safeParse(row.data);
69
+ if (parsed.success && parsed.data.pending.length > 0) {
70
+ out.push({ nodeId: row.id, removedAt: parsed.data.removedAt, pending: parsed.data.pending });
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ catch (err) {
76
+ this.logger.warn('could not list node tombstones', { meta: { error: (0, types_1.errMsg)(err) } });
77
+ return [];
78
+ }
79
+ }
80
+ }
81
+ exports.NodeTombstoneStore = NodeTombstoneStore;
82
+ const DEFAULT_ATTEMPTS = 3;
83
+ class NodeRemovalDispatcher {
84
+ deps;
85
+ constructor(deps) {
86
+ this.deps = deps;
87
+ }
88
+ /** A node was forgotten: record the tombstone, then tell every implementer. */
89
+ async nodeForgotten(nodeId, removedAt) {
90
+ const implementers = this.deps.hooks.implementers();
91
+ if (implementers.length === 0)
92
+ return { acknowledged: [], pending: [] };
93
+ const persisted = await this.deps.store.record(nodeId, removedAt, implementers);
94
+ if (!persisted) {
95
+ this.deps.logger.warn('node tombstone not persisted — implementers are told now but cannot be re-driven', { tags: { nodeId } });
96
+ }
97
+ return this.drive({ nodeId, removedAt, pending: implementers });
98
+ }
99
+ /** Re-drive every tombstone that still has a registered, unacknowledged implementer. */
100
+ async redrive() {
101
+ for (const tombstone of await this.deps.store.listPending()) {
102
+ await this.drive(tombstone);
103
+ }
104
+ }
105
+ async drive(tombstone) {
106
+ const { nodeId, removedAt } = tombstone;
107
+ const registered = new Set(this.deps.hooks.implementers());
108
+ const attempts = this.deps.maxAttempts ?? DEFAULT_ATTEMPTS;
109
+ const acknowledged = [];
110
+ const pending = [];
111
+ for (const addonId of tombstone.pending) {
112
+ if (!registered.has(addonId)) {
113
+ pending.push(addonId);
114
+ continue;
115
+ }
116
+ const done = await this.callWithRetries(addonId, { nodeId, removedAt }, attempts);
117
+ if (done) {
118
+ acknowledged.push(addonId);
119
+ await this.deps.store.acknowledge(nodeId, addonId);
120
+ }
121
+ else {
122
+ pending.push(addonId);
123
+ }
124
+ }
125
+ return { acknowledged, pending };
126
+ }
127
+ async callWithRetries(addonId, input, attempts) {
128
+ for (let attempt = 1; attempt <= attempts; attempt++) {
129
+ try {
130
+ const result = await this.deps.hooks.call(addonId, input);
131
+ this.deps.logger.info('node removal hook done', {
132
+ tags: { nodeId: input.nodeId },
133
+ meta: { addonId, counts: result.counts },
134
+ });
135
+ return true;
136
+ }
137
+ catch (err) {
138
+ this.deps.logger.warn('node removal hook failed', {
139
+ tags: { nodeId: input.nodeId },
140
+ meta: { addonId, attempt, attempts, error: (0, types_1.errMsg)(err) },
141
+ });
142
+ }
143
+ }
144
+ return false;
145
+ }
146
+ }
147
+ exports.NodeRemovalDispatcher = NodeRemovalDispatcher;
148
+ const NODE_REMOVAL_HOOK_CAP = 'node-removal-hook';
149
+ function isNodeRemovalHook(value) {
150
+ return (typeof value === 'object' &&
151
+ value !== null &&
152
+ typeof Reflect.get(value, 'onNodeRemoved') === 'function');
153
+ }
154
+ /** {@link NodeRemovalHooks} over the capability registry (keys include `addon@node`). */
155
+ function registryNodeRemovalHooks(registry) {
156
+ return {
157
+ implementers: () => registry.getProviderAddonIds(NODE_REMOVAL_HOOK_CAP),
158
+ call: async (addonId, input) => {
159
+ const provider = registry.getProviderByAddonId(NODE_REMOVAL_HOOK_CAP, addonId);
160
+ if (!isNodeRemovalHook(provider)) {
161
+ throw new Error(`"${addonId}" no longer provides ${NODE_REMOVAL_HOOK_CAP}`);
162
+ }
163
+ return provider.onNodeRemoved(input);
164
+ },
165
+ };
166
+ }
167
+ /**
168
+ * A forgotten node leaves no per-node singleton override behind: for every
169
+ * singleton capability, the persisted preference is cleared FIRST, then the
170
+ * live override (the order `capabilities.clearSingletonNodeOverride` uses).
171
+ * Returns how many overrides were cleared. A failed write leaves that override
172
+ * in place and the error propagates — the caller reports it, nothing is hidden.
173
+ */
174
+ async function clearSingletonNodeOverrides(nodeId, registry, config) {
175
+ let cleared = 0;
176
+ for (const cap of registry.listCapabilities()) {
177
+ if (cap.mode !== 'singleton')
178
+ continue;
179
+ if (!registry.getSingletonNodeOverrides(cap.name).has(nodeId))
180
+ continue;
181
+ await config.set((0, capability_preference_writes_1.singletonPreferenceKey)(cap.name, nodeId), null);
182
+ registry.clearSingletonNodeOverride(cap.name, nodeId);
183
+ cleared += 1;
184
+ }
185
+ return cleared;
186
+ }
package/dist/main.js CHANGED
@@ -59,6 +59,7 @@ const fastify_1 = require("@trpc/server/adapters/fastify");
59
59
  const ws_1 = require("@trpc/server/adapters/ws");
60
60
  const ws_2 = require("ws");
61
61
  const addon_upload_1 = require("./api/addon-upload");
62
+ const mesh_membership_routes_1 = require("./api/mesh-membership-routes");
62
63
  const auth_whoami_1 = require("./api/auth-whoami");
63
64
  const backup_download_js_1 = require("./api/backup-download.js");
64
65
  const cap_providers_1 = require("./api/core/cap-providers");
@@ -482,6 +483,10 @@ async function bootstrap() {
482
483
  // token-still-valid without consuming a real cap.
483
484
  await (0, auth_whoami_1.registerAuthWhoamiRoute)(fastify, uploadAuthService, uploadAddonRegistry);
484
485
  console.log('[bootstrap] Auth whoami route registered at GET /api/auth/whoami');
486
+ // A refused (forgotten / unpaired) agent asks here whether it is still a member (D857).
487
+ (0, mesh_membership_routes_1.registerMeshMembershipRoutes)(fastify, {
488
+ logger: app.get(logging_service_1.LoggingService).createLogger('mesh-membership'),
489
+ });
485
490
  }
486
491
  catch (err) {
487
492
  console.warn('[bootstrap] Failed to register addon upload route:', err);
@@ -743,7 +748,10 @@ async function bootstrap() {
743
748
  console.log(`[bootstrap] Health routes registered (hub v${hubVersion})`);
744
749
  (0, adaptive_probe_routes_1.registerAdaptiveProbeRoute)(fastify, {
745
750
  verifyToken: (token) => app.get(auth_service_1.AuthService).verifyToken(token),
746
- limiter: (0, auth_rate_limit_1.createRateLimiter)({ windowMs: 60_000, max: adaptive_probe_routes_1.ADAPTIVE_PROBE_PER_IP_PER_MINUTE }),
751
+ limiter: (0, auth_rate_limit_1.createRateLimiter)({
752
+ windowMs: 60_000,
753
+ max: adaptive_probe_routes_1.ADAPTIVE_PROBE_PER_IP_PER_MINUTE,
754
+ }),
747
755
  network: authNetwork,
748
756
  });
749
757
  }
@@ -981,7 +989,9 @@ async function bootstrap() {
981
989
  const um = capabilityRegistry.getSingleton('user-management');
982
990
  return (0, second_factors_js_1.collectSecondFactors)({
983
991
  ...(um && typeof um.getTotpStatus === 'function'
984
- ? { getTotpStatus: (input) => um.getTotpStatus(input) }
992
+ ? {
993
+ getTotpStatus: (input) => um.getTotpStatus(input),
994
+ }
985
995
  : {}),
986
996
  passkeyProvider: () => (0, passkey_provider_js_1.resolvePasskeyProvider)(capabilityRegistry, app.get(moleculer_service_1.MoleculerService)),
987
997
  }, userId, 'passkey');
@@ -1106,7 +1116,12 @@ async function bootstrap() {
1106
1116
  const auth = await (0, addon_route_auth_js_1.authorizeAddonBearer)({
1107
1117
  bearer: token,
1108
1118
  verifier: addonVerifier,
1109
- target: { addonId, method, access, share: dpMatch.endpoint.share ?? [] },
1119
+ target: {
1120
+ addonId,
1121
+ method,
1122
+ access,
1123
+ share: dpMatch.endpoint.share ?? [],
1124
+ },
1110
1125
  facts: (0, trpc_context_1.requestFacts)(request),
1111
1126
  logger: addonGateLogger,
1112
1127
  });
@@ -1579,7 +1594,9 @@ async function bootstrap() {
1579
1594
  }
1580
1595
  const logger = app.get(logging_service_1.LoggingService).createLogger('System');
1581
1596
  const protocol = tlsOptions ? 'https' : 'http';
1582
- logger.info('CamStack server listening', { meta: { protocol, host, port, trpcRegistered } });
1597
+ logger.info('CamStack server listening', {
1598
+ meta: { protocol, host, port, trpcRegistered },
1599
+ });
1583
1600
  // Certificate actions (Network → Local access) swap the served certificate
1584
1601
  // in place through this registration — without it an upload sat on disk
1585
1602
  // while the listener kept serving the boot-time leaf (D726).
@@ -1608,7 +1625,9 @@ async function bootstrap() {
1608
1625
  logger.info('LAN HTTP listening', { meta: { port: lanHttp.port } });
1609
1626
  }
1610
1627
  else if (lanHttp.error) {
1611
- logger.warn('LAN HTTP not bound', { meta: { error: lanHttp.error, port: lanHttp.port } });
1628
+ logger.warn('LAN HTTP not bound', {
1629
+ meta: { error: lanHttp.error, port: lanHttp.port },
1630
+ });
1612
1631
  }
1613
1632
  }
1614
1633
  catch (err) {
@@ -62,6 +62,8 @@ const addon_pages_service_1 = require("./core/addon-pages/addon-pages.service");
62
62
  const addon_widgets_service_1 = require("./core/addon-widgets/addon-widgets.service");
63
63
  const agent_registry_service_1 = require("./core/agent/agent-registry.service");
64
64
  const cluster_node_history_store_1 = require("./core/agent/cluster-node-history-store");
65
+ const forgotten_node_revoker_1 = require("./core/agent/forgotten-node-revoker");
66
+ const node_removal_hooks_1 = require("./core/agent/node-removal-hooks");
65
67
  const auth_service_1 = require("./core/auth/auth.service");
66
68
  const capability_service_1 = require("./core/capability/capability.service");
67
69
  const config_service_1 = require("./core/config/config.service");
@@ -163,6 +165,11 @@ async function bootManual(opts) {
163
165
  // Same post-construction reason: the cluster-node lifecycle (forget, catalog
164
166
  // removal) must leave a per-node line, and `console.log` is not a log store.
165
167
  agentRegistryService.setLogger(loggingService.createLogger('ClusterNodes'));
168
+ // Forgetting a node revokes its mesh certificates, so an old box that is powered on again
169
+ // is refused instead of walking back in (D857).
170
+ agentRegistryService.setNodeCertificateRevoker((0, forgotten_node_revoker_1.createForgottenNodeRevoker)({
171
+ logger: loggingService.createLogger('ClusterNodes'),
172
+ }));
166
173
  // A1: durable, routing-blind offline-node history — the `cluster-agent`
167
174
  // records kind, bound by the addon registry once the records provider is up
168
175
  // (unavailable until then, D902). Wired post-construction so the service's
@@ -170,6 +177,33 @@ async function bootManual(opts) {
170
177
  const clusterNodeHistoryStore = new cluster_node_history_store_1.ClusterNodeHistoryStore(loggingService.createLogger('cluster-node-history'));
171
178
  agentRegistryService.setClusterNodeHistoryStore(clusterNodeHistoryStore);
172
179
  addonRegistryService.setClusterNodeHistoryStore(clusterNodeHistoryStore);
180
+ // A forgotten node leaves no node-scoped data behind (#265): a tombstone is
181
+ // recorded, then every `node-removal-hook` implementer is told; the hub-owned
182
+ // per-node singleton overrides are cleared in the same step.
183
+ const nodeRemovalLogger = loggingService.createLogger('node-removal');
184
+ const nodeTombstoneStore = new node_removal_hooks_1.NodeTombstoneStore(nodeRemovalLogger);
185
+ addonRegistryService.setNodeTombstoneStore(nodeTombstoneStore);
186
+ const nodeRemovalDispatcher = new node_removal_hooks_1.NodeRemovalDispatcher({
187
+ hooks: (0, node_removal_hooks_1.registryNodeRemovalHooks)(addonRegistryService.getCapabilityRegistry()),
188
+ store: nodeTombstoneStore,
189
+ logger: nodeRemovalLogger,
190
+ });
191
+ agentRegistryService.setNodeRemovalCleanup(async (nodeId, removedAt) => {
192
+ const overrides = await (0, node_removal_hooks_1.clearSingletonNodeOverrides)(nodeId, addonRegistryService.getCapabilityRegistry(), configService);
193
+ if (overrides > 0) {
194
+ nodeRemovalLogger.info('cleared per-node singleton overrides of a forgotten node', {
195
+ tags: { nodeId },
196
+ meta: { overrides },
197
+ });
198
+ }
199
+ await nodeRemovalDispatcher.nodeForgotten(nodeId, removedAt);
200
+ });
201
+ // An implementer that was down at forget time is told once the addons are up.
202
+ eventBusService.subscribe({ category: types_1.EventCategory.SystemAddonsReady }, () => {
203
+ void nodeRemovalDispatcher.redrive().catch((err) => nodeRemovalLogger.warn('node removal re-drive failed', {
204
+ meta: { error: err instanceof Error ? err.message : String(err) },
205
+ }));
206
+ });
173
207
  // ---- Layer 4: need AddonRegistry ---------------------------------------
174
208
  // AddonWidgetsService — needs AddonRegistryService for the bundled-addon
175
209
  // dist sub-folder lookup (see service docstring).
@@ -210,7 +244,10 @@ async function bootManual(opts) {
210
244
  return (installed ?? (await addonPackageService.packPackage(name, version))).buffer;
211
245
  },
212
246
  deploy: async (nodeId, packageName, bundle) => {
213
- await moleculerService.broker.call('$agent.deploy', { addonId: packageName, source: (0, addon_upload_1.buildHubHttpSource)(bundle, await resolveClusterBaseUrl()) }, { nodeID: nodeId, timeout: agent_registry_service_1.AGENT_BACKFILL_RPC_TIMEOUT_MS });
247
+ await moleculerService.broker.call('$agent.deploy', {
248
+ addonId: packageName,
249
+ source: (0, addon_upload_1.buildHubHttpSource)(bundle, await resolveClusterBaseUrl()),
250
+ }, { nodeID: nodeId, timeout: agent_registry_service_1.AGENT_BACKFILL_RPC_TIMEOUT_MS });
214
251
  },
215
252
  reload: async (nodeId) => {
216
253
  await moleculerService.broker.call('$agent.reload', {}, { nodeID: nodeId, timeout: agent_registry_service_1.AGENT_BACKFILL_RPC_TIMEOUT_MS });
@@ -281,7 +318,10 @@ async function bootManual(opts) {
281
318
  expected: check.expected,
282
319
  running: check.running,
283
320
  failed: [...check.failed],
284
- refused: check.refused.map((r) => ({ runnerId: r.runnerId, reason: r.reason })),
321
+ refused: check.refused.map((r) => ({
322
+ runnerId: r.runnerId,
323
+ reason: r.reason,
324
+ })),
285
325
  missing: [...check.missing],
286
326
  },
287
327
  });
@@ -325,8 +365,7 @@ async function bootManual(opts) {
325
365
  // downloads this process makes anyway.
326
366
  const registryReachability = new registry_reachability_js_1.RegistryReachabilityTracker({
327
367
  logger: updateCheckLogger,
328
- getAlerts: () => addonRegistryService.getCapabilityRegistry().getSingleton('alerts') ??
329
- undefined,
368
+ getAlerts: () => addonRegistryService.getCapabilityRegistry().getSingleton('alerts') ?? undefined,
330
369
  });
331
370
  addonPackageService.setNpmReachabilityObserver({
332
371
  reached: () => registryReachability.reached('npm'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.687",
3
+ "version": "1.2.688",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,20 +49,20 @@
49
49
  ]
50
50
  },
51
51
  "dependencies": {
52
- "@camstack/addon-admin-ui": "1.2.515",
53
- "@camstack/addon-agent-ui": "1.2.290",
52
+ "@camstack/addon-admin-ui": "1.2.516",
53
+ "@camstack/addon-agent-ui": "1.2.291",
54
54
  "@camstack/addon-auth": "1.2.170",
55
- "@camstack/addon-benchmark": "1.2.289",
55
+ "@camstack/addon-benchmark": "1.2.290",
56
56
  "@camstack/addon-decoder-nodeav": "1.2.144",
57
57
  "@camstack/addon-notifiers": "1.2.623",
58
58
  "@camstack/addon-pipeline": "1.2.634",
59
- "@camstack/addon-pipeline-orchestrator": "1.2.638",
60
- "@camstack/addon-post-analysis": "1.2.650",
59
+ "@camstack/addon-pipeline-orchestrator": "1.2.639",
60
+ "@camstack/addon-post-analysis": "1.2.651",
61
61
  "@camstack/develop": "0.1.39",
62
62
  "@camstack/sdk": "1.2.154",
63
- "@camstack/system": "1.2.516",
64
- "@camstack/types": "1.2.442",
65
- "@camstack/ui-library": "1.2.348",
63
+ "@camstack/system": "1.2.517",
64
+ "@camstack/types": "1.2.443",
65
+ "@camstack/ui-library": "1.2.349",
66
66
  "@fastify/compress": "^9.0.0",
67
67
  "@fastify/cookie": "^11.0.2",
68
68
  "@fastify/cors": "^11.2.0",