@peerbit/pubsub 5.3.12 → 5.4.1

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.
Files changed (50) hide show
  1. package/dist/src/index.d.ts +31 -13
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +624 -147
  4. package/dist/src/index.js.map +1 -1
  5. package/package.json +8 -7
  6. package/src/index.ts +924 -218
  7. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts +0 -3
  8. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts.map +0 -1
  9. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js +0 -90
  10. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js.map +0 -1
  11. package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts +0 -2
  12. package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts.map +0 -1
  13. package/dist/benchmark/fanout-tree-parent-upgrade-eval.js +0 -712
  14. package/dist/benchmark/fanout-tree-parent-upgrade-eval.js.map +0 -1
  15. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts +0 -2
  16. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts.map +0 -1
  17. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js +0 -1591
  18. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js.map +0 -1
  19. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts +0 -2
  20. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts.map +0 -1
  21. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js +0 -297
  22. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js.map +0 -1
  23. package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts +0 -73
  24. package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts.map +0 -1
  25. package/dist/benchmark/fanout-tree-parent-upgrade-preset.js +0 -183
  26. package/dist/benchmark/fanout-tree-parent-upgrade-preset.js.map +0 -1
  27. package/dist/benchmark/fanout-tree-sim-lib.d.ts +0 -303
  28. package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +0 -1
  29. package/dist/benchmark/fanout-tree-sim-lib.js +0 -1752
  30. package/dist/benchmark/fanout-tree-sim-lib.js.map +0 -1
  31. package/dist/benchmark/fanout-tree-sim.d.ts +0 -2
  32. package/dist/benchmark/fanout-tree-sim.d.ts.map +0 -1
  33. package/dist/benchmark/fanout-tree-sim.js +0 -870
  34. package/dist/benchmark/fanout-tree-sim.js.map +0 -1
  35. package/dist/benchmark/index.d.ts +0 -8
  36. package/dist/benchmark/index.d.ts.map +0 -1
  37. package/dist/benchmark/index.js +0 -62
  38. package/dist/benchmark/index.js.map +0 -1
  39. package/dist/benchmark/pubsub-topic-sim-lib.d.ts +0 -86
  40. package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +0 -1
  41. package/dist/benchmark/pubsub-topic-sim-lib.js +0 -680
  42. package/dist/benchmark/pubsub-topic-sim-lib.js.map +0 -1
  43. package/dist/benchmark/pubsub-topic-sim.d.ts +0 -9
  44. package/dist/benchmark/pubsub-topic-sim.d.ts.map +0 -1
  45. package/dist/benchmark/pubsub-topic-sim.js +0 -134
  46. package/dist/benchmark/pubsub-topic-sim.js.map +0 -1
  47. package/dist/benchmark/sim/bench-utils.d.ts +0 -25
  48. package/dist/benchmark/sim/bench-utils.d.ts.map +0 -1
  49. package/dist/benchmark/sim/bench-utils.js +0 -141
  50. package/dist/benchmark/sim/bench-utils.js.map +0 -1
package/dist/src/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import {} from "@libp2p/interface";
2
- import { PublicSignKey, getPublicKeyFromPeerId } from "@peerbit/crypto";
2
+ import { PublicSignKey, getPublicKeyFromPeerId, sha256Sync, } from "@peerbit/crypto";
3
3
  import { logger as loggerFn } from "@peerbit/logger";
4
- import { assertCanonicalTopicRootCandidates, assertTopicRootCandidatesFrame, DataEvent, GetSubscribers, isCanonicalTopicRootCandidate, PeerUnavailable, PubSubData, PubSubMessage, PublishEvent, Subscribe, SubscriptionData, SubscriptionEvent, TOPIC_ROOT_CANDIDATES_MAX, TopicRootCandidates, TopicRootQuery, TopicRootQueryResponse, UnsubcriptionEvent, Unsubscribe, } from "@peerbit/pubsub-interface";
4
+ import { DataEvent, GetSubscribers, PeerUnavailable, PubSubData, PubSubMessage, PublishEvent, Subscribe, SubscriptionData, SubscriptionEvent, TOPIC_ROOT_CANDIDATES_MAX, TOPIC_ROOT_CANDIDATE_CLAIMS_MAX, TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES, TopicRootCandidateClaims, TopicRootCandidates, TopicRootQuery, TopicRootQueryResponse, UnsubcriptionEvent, Unsubscribe, assertCanonicalTopicRootCandidates, assertTopicRootCandidatesFrame, isCanonicalTopicRootCandidate, } from "@peerbit/pubsub-interface";
5
5
  import { DirectStream, } from "@peerbit/stream";
6
6
  import { AcknowledgeAnyWhere, AcknowledgeDelivery, AnyWhere, DataMessage, DeliveryError, MessageHeader, NotStartedError, SilentDelivery, deliveryModeHasReceiver, getMsgId, isAcknowledgeAnyWhereDeliveryMode, isAcknowledgeDeliveryMode, isAnyWhereDeliveryMode, isSilentDeliveryMode, } from "@peerbit/stream-interface";
7
7
  import { AbortError, TimeoutError, delay } from "@peerbit/time";
8
8
  import { Uint8ArrayList } from "uint8arraylist";
9
+ import { equals as bytesEqual } from "uint8arrays";
9
10
  import { toUint8Array } from "./bytes.js";
10
11
  import { debouncedAccumulatorSetCounter, } from "./debounced-set.js";
11
12
  import { FanoutChannel } from "./fanout-channel.js";
@@ -112,6 +113,25 @@ const DEFAULT_PUBSUB_SHARD_COUNT = 256;
112
113
  const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
113
114
  const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
114
115
  const AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS = 2_000;
116
+ const TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 = "/peerbit/topic-control-plane/2.1.0";
117
+ const TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 = "/peerbit/topic-control-plane/2.0.0";
118
+ const TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN = utf8Encoder.encode("peerbit/topic-root-candidate-claim/v1\0");
119
+ const TOPIC_ROOT_CANDIDATE_SCOPE_DOMAIN = "peerbit/topic-root-candidate-scope/v1";
120
+ const TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS = 90_000;
121
+ const TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS = 30_000;
122
+ const TOPIC_ROOT_CANDIDATE_CLAIM_RECEIVER_MAX_LIFETIME_MS = TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS +
123
+ TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS;
124
+ const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS = 30_000;
125
+ const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_JITTER_MS = 5_000;
126
+ const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS = 1_000;
127
+ const TOPIC_ROOT_CANDIDATE_CLAIM_ADVERTISEMENT_TIMEOUT_MS = 10_000;
128
+ // A sparse leaf can receive a 64-origin snapshot plus two refresh waves through
129
+ // one relay in the first minute. Keep per-relay headroom for that honest burst;
130
+ // the separate global cap still bounds total signature work.
131
+ const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST = 256;
132
+ const TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST = 512;
133
+ const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS = 60_000;
134
+ const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_PEERS_MAX = 4_096;
115
135
  const sameCandidates = (left, right) => left.length === right.length &&
116
136
  left.every((candidate, index) => candidate === right[index]);
117
137
  // Topic-root queries may need to wait for the responder to finish opening an
@@ -169,6 +189,7 @@ export class TopicControlPlane extends DirectStream {
169
189
  debounceUnsubscribeAggregator;
170
190
  shardCount;
171
191
  shardTopicPrefix;
192
+ topicRootCandidateClaimData;
172
193
  hostShards;
173
194
  shardRootCache = new Map();
174
195
  shardTopicCache = new Map();
@@ -196,16 +217,27 @@ export class TopicControlPlane extends DirectStream {
196
217
  topicRootCandidateResolutionAbortController = new AbortController();
197
218
  hostOwnedShardRootsInFlight;
198
219
  hostOwnedShardRootsDirty = false;
199
- autoCandidatesBroadcastTimers = [];
200
- autoCandidatesGossipInterval;
201
- autoCandidatesGossipUntil = 0;
220
+ signedTopicRootCandidateClaims = new Map();
221
+ // Active claims expire, but their per-origin timestamp floors survive for the
222
+ // auto-mode lifecycle so a frozen wall clock cannot renew a replayed lease.
223
+ // Retaining only the deterministic lowest origins keeps this state hard-bounded.
224
+ topicRootCandidateClaimReplayFloors = new Map();
225
+ localSignedTopicRootCandidateClaim;
226
+ topicRootCandidateClaimMaintenanceTimer;
227
+ topicRootCandidateClaimRefreshNotBefore;
228
+ topicRootCandidateClaimMaintenanceRefreshInFlight;
229
+ topicRootCandidateClaimVerifyBudgets = new Map();
230
+ topicRootCandidateClaimGlobalVerifyBudget = {
231
+ remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
232
+ refilledAt: Date.now(),
233
+ };
202
234
  _onFanoutPeerUnreachable;
203
235
  fanoutChannels = new Map();
204
236
  ensureFanoutChannelInFlight = new Map();
205
237
  pendingTopicRootQueries = new Map();
206
238
  nextTopicRootQueryId = 1;
207
239
  constructor(components, props) {
208
- super(components, ["/peerbit/topic-control-plane/2.0.0"], props);
240
+ super(components, [TOPIC_CONTROL_PLANE_PROTOCOL_V2_1, TOPIC_CONTROL_PLANE_PROTOCOL_V2_0], props);
209
241
  this.subscriptions = new Map();
210
242
  this.pendingSubscriptions = new Set();
211
243
  this.topics = new Map();
@@ -223,8 +255,8 @@ export class TopicControlPlane extends DirectStream {
223
255
  }
224
256
  this.fanout = props.fanout;
225
257
  // Default to a local-only shard-root candidate set so standalone peers can
226
- // subscribe/publish without explicit bootstraps. We'll expand candidates
227
- // opportunistically as neighbours connect.
258
+ // subscribe/publish without explicit bootstraps. Signed peer claims expand
259
+ // the set opportunistically as the ad-hoc overlay connects.
228
260
  if (this.topicRootControlPlane.getTopicRootCandidates().length === 0) {
229
261
  this.autoTopicRootCandidates = true;
230
262
  this.autoTopicRootCandidateSet = new Set([this.publicKeyHash]);
@@ -234,6 +266,14 @@ export class TopicControlPlane extends DirectStream {
234
266
  this.shardCount = Math.min(PUBSUB_SHARD_COUNT_HARD_CAP, Math.max(1, Math.floor(requestedShardCount)));
235
267
  const prefix = props?.shardTopicPrefix ?? DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX;
236
268
  this.shardTopicPrefix = prefix.endsWith("/") ? prefix : prefix + "/";
269
+ const scopeDigest = sha256Sync(utf8Encoder.encode(JSON.stringify([
270
+ TOPIC_ROOT_CANDIDATE_SCOPE_DOMAIN,
271
+ this.shardTopicPrefix,
272
+ this.shardCount,
273
+ ])));
274
+ this.topicRootCandidateClaimData = new Uint8Array(TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN.byteLength + scopeDigest.byteLength);
275
+ this.topicRootCandidateClaimData.set(TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN, 0);
276
+ this.topicRootCandidateClaimData.set(scopeDigest, TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN.byteLength);
237
277
  this.hostShards = props?.hostShards ?? false;
238
278
  const baseFanoutChannelOptions = {
239
279
  ...DEFAULT_PUBSUB_FANOUT_CHANNEL_OPTIONS,
@@ -282,25 +322,20 @@ export class TopicControlPlane extends DirectStream {
282
322
  * candidate mode.
283
323
  *
284
324
  * Auto mode is a convenience for small ad-hoc networks where no bootstraps/
285
- * routers are configured. When an explicit candidate set is provided (e.g.
286
- * from bootstraps or a test harness), we must stop mutating/gossiping the
325
+ * routers are configured. Automatic claims authenticate key ownership and
326
+ * freshness, not liveness, admission, or Sybil resistance; hostile or isolated
327
+ * deployments should configure explicit candidates. When an explicit candidate
328
+ * set is provided (e.g. from bootstraps or a test harness), we stop mutating the
287
329
  * candidate set; otherwise shard root resolution can diverge and overlays can
288
330
  * partition (especially in sparse graphs).
289
331
  */
290
332
  setTopicRootCandidates(candidates) {
291
333
  this.topicRootControlPlane.setTopicRootCandidates(candidates);
292
- // Disable auto mode and stop its background gossip/timers.
334
+ // Disable auto mode and clear its signed-claim state/timers.
293
335
  this.autoTopicRootCandidates = false;
294
336
  this.autoTopicRootCandidateSet = undefined;
337
+ this.clearSignedTopicRootCandidateState();
295
338
  this.clearAutoTopicRootCandidateUpdateSchedule();
296
- for (const t of this.autoCandidatesBroadcastTimers)
297
- clearTimeout(t);
298
- this.autoCandidatesBroadcastTimers = [];
299
- if (this.autoCandidatesGossipInterval) {
300
- clearInterval(this.autoCandidatesGossipInterval);
301
- this.autoCandidatesGossipInterval = undefined;
302
- }
303
- this.autoCandidatesGossipUntil = 0;
304
339
  // Re-resolve roots under the new mapping.
305
340
  this.shardRootCache.clear();
306
341
  // Only candidates can become deterministic roots. Avoid doing a full shard
@@ -311,16 +346,39 @@ export class TopicControlPlane extends DirectStream {
311
346
  this.scheduleReconcileShardOverlays();
312
347
  }
313
348
  async start() {
349
+ // Match DirectStream's idempotent start guard before mutating automatic
350
+ // candidates. In particular, a repeated start must not restore unsigned
351
+ // self while a signed-protocol peer is connected.
352
+ if (this.started)
353
+ return;
314
354
  this.topicControlPlaneStopping = false;
355
+ if (this.autoTopicRootCandidates &&
356
+ !this.localSignedTopicRootCandidateClaim) {
357
+ this.autoTopicRootCandidateSet = new Set([this.publicKeyHash]);
358
+ this.topicRootControlPlane.setTopicRootCandidates([this.publicKeyHash]);
359
+ this.shardRootCache.clear();
360
+ }
315
361
  if (this.topicRootResolutionAbortController.signal.aborted) {
316
362
  this.topicRootResolutionAbortController = new AbortController();
317
363
  }
318
364
  await this.fanout.start();
319
365
  this._onFanoutPeerUnreachable =
320
- this._onFanoutPeerUnreachable ||
321
- this.onFanoutPeerUnreachable.bind(this);
366
+ this._onFanoutPeerUnreachable || this.onFanoutPeerUnreachable.bind(this);
322
367
  await this.fanout.addEventListener("fanout:peer-unreachable", this._onFanoutPeerUnreachable);
323
368
  await super.start();
369
+ if (this.autoTopicRootCandidates) {
370
+ let retryDelayMs = 0;
371
+ try {
372
+ if (!(await this.refreshLocalTopicRootCandidateClaim())) {
373
+ retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
374
+ }
375
+ }
376
+ catch (error) {
377
+ logErrorIfStarted(error);
378
+ retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
379
+ }
380
+ this.scheduleTopicRootCandidateClaimMaintenance(retryDelayMs);
381
+ }
324
382
  if (this.hostShards) {
325
383
  await this.hostShardRootsNow();
326
384
  }
@@ -335,6 +393,7 @@ export class TopicControlPlane extends DirectStream {
335
393
  this.topicControlPlaneStopping = true;
336
394
  this.topicRootResolutionAbortController.abort(new AbortError("topic control plane stopped"));
337
395
  this.clearAutoTopicRootCandidateUpdateSchedule();
396
+ this.clearSignedTopicRootCandidateState();
338
397
  this.topicControlPlaneLifecycleRevision += 1;
339
398
  this.reconcileShardOverlaysDirty = false;
340
399
  for (const opening of this.ensureFanoutChannelInFlight.values()) {
@@ -373,14 +432,6 @@ export class TopicControlPlane extends DirectStream {
373
432
  }
374
433
  }
375
434
  this.fanoutChannels.clear();
376
- for (const t of this.autoCandidatesBroadcastTimers)
377
- clearTimeout(t);
378
- this.autoCandidatesBroadcastTimers = [];
379
- if (this.autoCandidatesGossipInterval) {
380
- clearInterval(this.autoCandidatesGossipInterval);
381
- this.autoCandidatesGossipInterval = undefined;
382
- }
383
- this.autoCandidatesGossipUntil = 0;
384
435
  this.hostOwnedShardRootsDirty = false;
385
436
  this.subscriptions.clear();
386
437
  this.pendingSubscriptions.clear();
@@ -405,33 +456,54 @@ export class TopicControlPlane extends DirectStream {
405
456
  await super.stop();
406
457
  this.reconcileShardOverlaysDirty = false;
407
458
  }
408
- async onPeerConnected(peerId, connection) {
409
- await super.onPeerConnected(peerId, connection);
410
- // If we're in auto-candidate mode, expand the deterministic shard-root
411
- // candidate set as neighbours connect, then reconcile shard overlays and
412
- // re-announce subscriptions so membership knowledge converges.
413
- if (!this.autoTopicRootCandidates)
414
- return;
415
- let peerHash;
416
- try {
417
- peerHash = getPublicKeyFromPeerId(peerId).hashcode();
418
- }
419
- catch {
420
- return;
421
- }
422
- void this.maybeUpdateAutoTopicRootCandidates(peerHash);
423
- }
459
+ // Candidate trust starts only after protocol negotiation in `addPeer()`;
460
+ // a transport connection alone cannot bypass signed 2.1 self-claims.
424
461
  // Ensure auto-candidate mode converges even when libp2p topology callbacks
425
462
  // are delayed or only fire for one side of a connection. `addPeer()` runs for
426
463
  // both inbound + outbound protocol streams once the remote public key is known.
427
464
  addPeer(peerId, publicKey, protocol, connId) {
465
+ const hadSignedPeer = [...this.peers.values()].some((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
466
+ const existingPeer = this.peers.get(publicKey.hashcode());
428
467
  const peer = super.addPeer(peerId, publicKey, protocol, connId);
429
468
  if (this.autoTopicRootCandidates) {
430
- void this.maybeUpdateAutoTopicRootCandidates(publicKey.hashcode());
431
- this.scheduleAutoTopicRootCandidatesBroadcast([peer]);
469
+ const hasSignedPeer = [...this.peers.values()].some((candidate) => candidate.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
470
+ this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
471
+ immediate: hadSignedPeer !== hasSignedPeer,
472
+ });
473
+ if (peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1) {
474
+ const sendWhenOutboundReady = () => {
475
+ if (!this.autoTopicRootCandidates ||
476
+ this.peers.get(publicKey.hashcode()) !== peer) {
477
+ return;
478
+ }
479
+ void this.sendAutoTopicRootCandidates([peer]).catch(() => { });
480
+ };
481
+ if (peer.isWritable) {
482
+ void this.sendAutoTopicRootCandidates([peer]).catch(() => { });
483
+ }
484
+ if (!existingPeer) {
485
+ peer.addEventListener("stream:outbound", sendWhenOutboundReady);
486
+ peer.addEventListener("close", () => peer.removeEventListener("stream:outbound", sendWhenOutboundReady), { once: true });
487
+ }
488
+ }
432
489
  }
433
490
  return peer;
434
491
  }
492
+ async _removePeer(publicKey) {
493
+ const hash = publicKey.hashcode();
494
+ const protocol = this.peers.get(hash)?.protocol;
495
+ const hadSignedPeer = [...this.peers.values()].some((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
496
+ const removed = await super._removePeer(publicKey);
497
+ if (this.autoTopicRootCandidates &&
498
+ (protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 ||
499
+ protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1)) {
500
+ const hasSignedPeer = [...this.peers.values()].some((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
501
+ this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
502
+ immediate: hadSignedPeer !== hasSignedPeer,
503
+ });
504
+ }
505
+ return removed;
506
+ }
435
507
  maybeDisableAutoTopicRootCandidatesIfExternallyConfigured() {
436
508
  if (!this.autoTopicRootCandidates)
437
509
  return false;
@@ -446,6 +518,7 @@ export class TopicControlPlane extends DirectStream {
446
518
  // intact and reconcile shard overlays under the new mapping.
447
519
  this.autoTopicRootCandidates = false;
448
520
  this.autoTopicRootCandidateSet = undefined;
521
+ this.clearSignedTopicRootCandidateState();
449
522
  this.clearAutoTopicRootCandidateUpdateSchedule();
450
523
  this.shardRootCache.clear();
451
524
  // Ensure we host any shard roots we're now responsible for. This is important
@@ -456,114 +529,493 @@ export class TopicControlPlane extends DirectStream {
456
529
  this.scheduleReconcileShardOverlays();
457
530
  return true;
458
531
  }
459
- maybeUpdateAutoTopicRootCandidates(peerHash) {
460
- if (!this.autoTopicRootCandidates)
461
- return;
462
- if (!isCanonicalTopicRootCandidate(peerHash) ||
463
- peerHash === this.publicKeyHash)
464
- return;
465
- this.queueAutoTopicRootCandidateUpdate([peerHash]);
532
+ clearTopicRootCandidateClaimTimer() {
533
+ if (this.topicRootCandidateClaimMaintenanceTimer) {
534
+ clearTimeout(this.topicRootCandidateClaimMaintenanceTimer);
535
+ this.topicRootCandidateClaimMaintenanceTimer = undefined;
536
+ }
537
+ }
538
+ clearSignedTopicRootCandidateState() {
539
+ this.clearTopicRootCandidateClaimTimer();
540
+ this.signedTopicRootCandidateClaims.clear();
541
+ this.topicRootCandidateClaimReplayFloors.clear();
542
+ this.localSignedTopicRootCandidateClaim = undefined;
543
+ this.topicRootCandidateClaimRefreshNotBefore = undefined;
544
+ this.topicRootCandidateClaimMaintenanceRefreshInFlight = undefined;
545
+ this.topicRootCandidateClaimVerifyBudgets.clear();
546
+ this.topicRootCandidateClaimGlobalVerifyBudget = {
547
+ remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
548
+ refilledAt: Date.now(),
549
+ };
466
550
  }
467
- normalizeAutoTopicRootCandidates(candidates) {
468
- const canonicalCandidates = candidates.filter(isCanonicalTopicRootCandidate);
469
- if (this.nativeTopicControl) {
470
- return this.nativeTopicControl.normalizeAutoCandidates(canonicalCandidates, this.publicKeyHash);
551
+ isTopicRootCandidateClaimTimeValid(timestamp, expires, now) {
552
+ return (expires > now &&
553
+ timestamp <= now + BigInt(TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS) &&
554
+ expires - timestamp === BigInt(TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS));
555
+ }
556
+ topicRootCandidateClaimAcceptanceDeadline(timestamp, expires, wallNow, monotonicNow, maxRemainingMs) {
557
+ if (!this.isTopicRootCandidateClaimTimeValid(timestamp, expires, wallNow)) {
558
+ return undefined;
559
+ }
560
+ return (monotonicNow +
561
+ Math.min(Number(expires - wallNow), Math.max(0, maxRemainingMs)));
562
+ }
563
+ async refreshLocalTopicRootCandidateClaim() {
564
+ if (!this.autoTopicRootCandidates ||
565
+ !this.started ||
566
+ this.stopping ||
567
+ this.topicControlPlaneStopping) {
568
+ return false;
569
+ }
570
+ if (!this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)) {
571
+ return false;
471
572
  }
472
- const unique = new Set();
473
- for (const c of canonicalCandidates) {
474
- unique.add(c);
573
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
574
+ const claim = await this.createMessage(this.topicRootCandidateClaimData, {
575
+ mode: new AnyWhere(),
576
+ priority: 1,
577
+ expiresInMs: TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS,
578
+ skipRecipientValidation: true,
579
+ });
580
+ const bytes = toUint8Array(claim.bytes());
581
+ if (bytes.byteLength > TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES) {
582
+ throw new Error(`Local topic-root candidate claim exceeds ${TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES} bytes`);
475
583
  }
476
- if (isCanonicalTopicRootCandidate(this.publicKeyHash)) {
477
- unique.add(this.publicKeyHash);
584
+ if (lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
585
+ !this.autoTopicRootCandidates ||
586
+ !this.started ||
587
+ this.stopping ||
588
+ this.topicControlPlaneStopping) {
589
+ return false;
478
590
  }
479
- const sorted = [...unique].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
480
- return sorted.slice(0, TOPIC_ROOT_CANDIDATES_MAX);
591
+ const timestamp = claim.header.timestamp;
592
+ const expires = claim.header.expires;
593
+ const acceptanceDeadline = this.topicRootCandidateClaimAcceptanceDeadline(timestamp, expires, BigInt(Date.now()), performance.now(), TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS);
594
+ if (acceptanceDeadline === undefined) {
595
+ return false;
596
+ }
597
+ const record = {
598
+ bytes,
599
+ timestamp,
600
+ expires,
601
+ acceptUntil: acceptanceDeadline,
602
+ };
603
+ const current = this.localSignedTopicRootCandidateClaim;
604
+ if (current && record.timestamp <= current.timestamp) {
605
+ return false;
606
+ }
607
+ if (!this.retainSignedTopicRootCandidateClaim(this.publicKeyHash, record)) {
608
+ return false;
609
+ }
610
+ this.localSignedTopicRootCandidateClaim = record;
611
+ this.topicRootCandidateClaimRefreshNotBefore = undefined;
612
+ this.rebuildAutoTopicRootCandidatesFromClaims();
613
+ return this.localSignedTopicRootCandidateClaim === record;
481
614
  }
482
- scheduleAutoTopicRootCandidatesBroadcast(targets) {
483
- if (!this.autoTopicRootCandidates)
484
- return;
485
- if (!this.started || this.stopping)
486
- return;
487
- if (targets && targets.length > 0) {
488
- void this.sendAutoTopicRootCandidates(targets).catch(() => { });
615
+ scheduleTopicRootCandidateClaimMaintenance(minDelayMs = 0) {
616
+ if (!this.autoTopicRootCandidates ||
617
+ !this.started ||
618
+ this.stopping ||
619
+ this.topicControlPlaneStopping) {
489
620
  return;
490
621
  }
491
- for (const t of this.autoCandidatesBroadcastTimers)
492
- clearTimeout(t);
493
- this.autoCandidatesBroadcastTimers = [];
494
- // Burst a few times to survive early "stream not writable yet" races.
495
- const delays = [25, 500, 2_000];
496
- for (const delayMs of delays) {
497
- const t = setTimeout(() => {
498
- void this.sendAutoTopicRootCandidates().catch(() => { });
499
- }, delayMs);
500
- t.unref?.();
501
- this.autoCandidatesBroadcastTimers.push(t);
502
- }
503
- // Keep gossiping for a while after changes so partially connected topologies
504
- // converge even under slow stream negotiation.
505
- this.autoCandidatesGossipUntil = Date.now() + 60_000;
506
- this.ensureAutoCandidatesGossipInterval();
507
- }
508
- ensureAutoCandidatesGossipInterval() {
509
- if (!this.autoTopicRootCandidates)
510
- return;
511
- if (!this.started || this.stopping)
512
- return;
513
- if (this.autoCandidatesGossipInterval)
514
- return;
515
- this.autoCandidatesGossipInterval = setInterval(() => {
516
- if (!this.started || this.stopping || !this.autoTopicRootCandidates)
622
+ this.clearTopicRootCandidateClaimTimer();
623
+ const now = Date.now();
624
+ const monotonicNow = performance.now();
625
+ const local = this.localSignedTopicRootCandidateClaim;
626
+ const localOriginEligible = this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash);
627
+ if (localOriginEligible && minDelayMs > 0) {
628
+ this.topicRootCandidateClaimRefreshNotBefore = Math.max(this.topicRootCandidateClaimRefreshNotBefore ?? 0, monotonicNow + minDelayMs);
629
+ }
630
+ else if (!localOriginEligible) {
631
+ this.topicRootCandidateClaimRefreshNotBefore = undefined;
632
+ }
633
+ const canScheduleLocalRefresh = localOriginEligible &&
634
+ !this.topicRootCandidateClaimMaintenanceRefreshInFlight;
635
+ const refreshDueAt = local
636
+ ? Number(local.timestamp) +
637
+ TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS +
638
+ Math.floor(Math.random() * TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_JITTER_MS)
639
+ : canScheduleLocalRefresh
640
+ ? now
641
+ : Number.POSITIVE_INFINITY;
642
+ let expiryDelayMs = Number.POSITIVE_INFINITY;
643
+ for (const claim of this.signedTopicRootCandidateClaims.values()) {
644
+ expiryDelayMs = Math.min(expiryDelayMs, Number(claim.expires) - now, claim.acceptUntil - monotonicNow);
645
+ }
646
+ if (local) {
647
+ expiryDelayMs = Math.min(expiryDelayMs, Number(local.expires) - now, local.acceptUntil - monotonicNow);
648
+ }
649
+ const refreshDelayMs = canScheduleLocalRefresh
650
+ ? Math.max(refreshDueAt - now, (this.topicRootCandidateClaimRefreshNotBefore ?? monotonicNow) -
651
+ monotonicNow, 0)
652
+ : Number.POSITIVE_INFINITY;
653
+ expiryDelayMs = Math.max(Math.ceil(expiryDelayMs), 0);
654
+ const delayMs = Math.min(2_147_483_647, refreshDelayMs, expiryDelayMs);
655
+ const timer = setTimeout(() => {
656
+ if (this.topicRootCandidateClaimMaintenanceTimer !== timer)
517
657
  return;
518
- if (this.autoCandidatesGossipUntil > 0 &&
519
- Date.now() > this.autoCandidatesGossipUntil) {
520
- if (this.autoCandidatesGossipInterval) {
521
- clearInterval(this.autoCandidatesGossipInterval);
522
- this.autoCandidatesGossipInterval = undefined;
658
+ this.topicRootCandidateClaimMaintenanceTimer = undefined;
659
+ let retryDelayMs = 0;
660
+ try {
661
+ this.rebuildAutoTopicRootCandidatesFromClaims();
662
+ const currentLocal = this.localSignedTopicRootCandidateClaim;
663
+ const refreshDue = !this.topicRootCandidateClaimMaintenanceRefreshInFlight &&
664
+ this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash) &&
665
+ performance.now() >=
666
+ (this.topicRootCandidateClaimRefreshNotBefore ?? 0) &&
667
+ (!currentLocal ||
668
+ Date.now() - Number(currentLocal.timestamp) >=
669
+ TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS);
670
+ if (refreshDue) {
671
+ this.startTopicRootCandidateClaimMaintenanceRefresh();
523
672
  }
524
- return;
525
673
  }
526
- void this.sendAutoTopicRootCandidates().catch(() => { });
527
- }, 2_000);
528
- this.autoCandidatesGossipInterval.unref?.();
674
+ catch (error) {
675
+ logErrorIfStarted(error);
676
+ retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
677
+ }
678
+ if (!this.topicRootCandidateClaimMaintenanceTimer) {
679
+ // Re-arm immediately. While refresh is in flight, its due time is
680
+ // treated as infinity but independent claim expiries remain scheduled.
681
+ this.scheduleTopicRootCandidateClaimMaintenance(retryDelayMs);
682
+ }
683
+ }, delayMs);
684
+ timer.unref?.();
685
+ this.topicRootCandidateClaimMaintenanceTimer = timer;
529
686
  }
530
- async sendAutoTopicRootCandidates(targets) {
531
- if (!this.started)
532
- throw new NotStartedError();
533
- const streams = targets ?? [...this.peers.values()];
534
- if (streams.length === 0)
687
+ startTopicRootCandidateClaimMaintenanceRefresh() {
688
+ if (this.topicRootCandidateClaimMaintenanceRefreshInFlight)
535
689
  return;
536
- const candidates = this.topicRootControlPlane.getTopicRootCandidates();
537
- if (candidates.length === 0)
538
- return;
539
- const msg = new TopicRootCandidates({ candidates });
540
- const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
541
- mode: new AnyWhere(),
542
- priority: 1,
543
- skipRecipientValidation: true,
544
- });
545
- await this.publishMessageMaybe(this.publicKey, embedded, streams);
690
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
691
+ let task;
692
+ task = (async () => {
693
+ try {
694
+ const refreshed = await this.refreshLocalTopicRootCandidateClaim();
695
+ if (this.topicRootCandidateClaimMaintenanceRefreshInFlight !== task ||
696
+ lifecycleRevision !== this.topicControlPlaneLifecycleRevision) {
697
+ return;
698
+ }
699
+ const local = this.localSignedTopicRootCandidateClaim;
700
+ if (!refreshed || !local) {
701
+ if (this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)) {
702
+ this.topicRootCandidateClaimRefreshNotBefore =
703
+ performance.now() + TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
704
+ }
705
+ else {
706
+ this.topicRootCandidateClaimRefreshNotBefore = undefined;
707
+ }
708
+ return;
709
+ }
710
+ this.topicRootCandidateClaimRefreshNotBefore = undefined;
711
+ // Arm the new local lease's expiry before outbound I/O can stall.
712
+ this.scheduleTopicRootCandidateClaimMaintenance();
713
+ try {
714
+ await this.sendSignedTopicRootCandidateClaims([local.bytes], undefined, AbortSignal.timeout(TOPIC_ROOT_CANDIDATE_CLAIM_ADVERTISEMENT_TIMEOUT_MS));
715
+ }
716
+ catch (error) {
717
+ // A later renewal or outbound-stream snapshot retries this
718
+ // best-effort advertisement; the signed lease itself is valid.
719
+ logErrorIfStarted(error);
720
+ }
721
+ }
722
+ catch (error) {
723
+ if (this.topicRootCandidateClaimMaintenanceRefreshInFlight === task &&
724
+ lifecycleRevision === this.topicControlPlaneLifecycleRevision) {
725
+ logErrorIfStarted(error);
726
+ this.topicRootCandidateClaimRefreshNotBefore =
727
+ this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)
728
+ ? performance.now() + TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS
729
+ : undefined;
730
+ }
731
+ }
732
+ finally {
733
+ if (this.topicRootCandidateClaimMaintenanceRefreshInFlight === task &&
734
+ lifecycleRevision === this.topicControlPlaneLifecycleRevision) {
735
+ this.topicRootCandidateClaimMaintenanceRefreshInFlight = undefined;
736
+ this.scheduleTopicRootCandidateClaimMaintenance();
737
+ }
738
+ }
739
+ })();
740
+ this.topicRootCandidateClaimMaintenanceRefreshInFlight = task;
741
+ }
742
+ pruneExpiredTopicRootCandidateClaims(now = BigInt(Date.now()), monotonicNow = performance.now()) {
743
+ const localClaim = this.localSignedTopicRootCandidateClaim;
744
+ if (localClaim &&
745
+ (localClaim.expires <= now || localClaim.acceptUntil <= monotonicNow)) {
746
+ this.localSignedTopicRootCandidateClaim = undefined;
747
+ }
748
+ let changed = false;
749
+ for (const [origin, claim] of this.signedTopicRootCandidateClaims) {
750
+ if (claim.expires <= now || claim.acceptUntil <= monotonicNow) {
751
+ this.signedTopicRootCandidateClaims.delete(origin);
752
+ changed = true;
753
+ }
754
+ }
755
+ return changed;
546
756
  }
547
- mergeAutoTopicRootCandidatesFromPeer(candidates) {
757
+ rebuildAutoTopicRootCandidatesFromClaims(now = BigInt(Date.now()), options) {
548
758
  if (!this.autoTopicRootCandidates)
549
759
  return false;
550
- return this.queueAutoTopicRootCandidateUpdate(candidates);
760
+ const removedExpired = this.pruneExpiredTopicRootCandidateClaims(now);
761
+ const legacyDirectCandidates = [...this.peers.values()]
762
+ .filter((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0)
763
+ .map((peer) => peer.publicKey.hashcode());
764
+ const hasSignedPeer = [...this.peers.values()].some((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
765
+ const next = [
766
+ ...new Set([
767
+ ...(this.localSignedTopicRootCandidateClaim || hasSignedPeer
768
+ ? []
769
+ : [this.publicKeyHash]),
770
+ ...legacyDirectCandidates,
771
+ ...this.signedTopicRootCandidateClaims.keys(),
772
+ ]),
773
+ ]
774
+ .filter(isCanonicalTopicRootCandidate)
775
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
776
+ .slice(0, TOPIC_ROOT_CANDIDATES_MAX);
777
+ return this.setAutoTopicRootCandidateSnapshot(next, {
778
+ // A cooldown may coalesce additions, but it must never extend an expired
779
+ // candidate's effective lease. The full snapshot is authoritative, so any
780
+ // queued pre-expiry snapshot can be discarded safely here.
781
+ immediate: removedExpired || options?.immediate,
782
+ });
551
783
  }
552
- queueAutoTopicRootCandidateUpdate(candidates) {
784
+ retainSignedTopicRootCandidateClaim(origin, claim) {
785
+ if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, claim.timestamp)) {
786
+ return false;
787
+ }
788
+ const existing = this.signedTopicRootCandidateClaims.get(origin);
789
+ if (existing) {
790
+ if (claim.timestamp <= existing.timestamp) {
791
+ return false;
792
+ }
793
+ }
794
+ if (!this.topicRootCandidateClaimReplayFloors.has(origin)) {
795
+ if (this.topicRootCandidateClaimReplayFloors.size >=
796
+ TOPIC_ROOT_CANDIDATES_MAX) {
797
+ const highest = [...this.topicRootCandidateClaimReplayFloors.keys()]
798
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
799
+ .at(-1);
800
+ // `canAdvance...` admitted a lower origin. Removing the displaced
801
+ // origin's live/local state preserves the same lowest-origin invariant;
802
+ // because the retained maximum only decreases, it cannot re-enter.
803
+ this.topicRootCandidateClaimReplayFloors.delete(highest);
804
+ this.signedTopicRootCandidateClaims.delete(highest);
805
+ if (highest === this.publicKeyHash) {
806
+ this.localSignedTopicRootCandidateClaim = undefined;
807
+ }
808
+ }
809
+ }
810
+ this.topicRootCandidateClaimReplayFloors.set(origin, claim.timestamp);
811
+ this.signedTopicRootCandidateClaims.set(origin, claim);
812
+ return true;
813
+ }
814
+ canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp) {
815
+ const floor = this.topicRootCandidateClaimReplayFloors.get(origin);
816
+ if (floor !== undefined) {
817
+ return timestamp > floor;
818
+ }
819
+ return this.canRetainTopicRootCandidateClaimOrigin(origin);
820
+ }
821
+ canRetainTopicRootCandidateClaimOrigin(origin) {
822
+ if (this.topicRootCandidateClaimReplayFloors.has(origin)) {
823
+ return true;
824
+ }
825
+ if (this.topicRootCandidateClaimReplayFloors.size < TOPIC_ROOT_CANDIDATES_MAX) {
826
+ return true;
827
+ }
828
+ const highest = [...this.topicRootCandidateClaimReplayFloors.keys()]
829
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
830
+ .at(-1);
831
+ return origin < highest;
832
+ }
833
+ consumeTopicRootCandidateClaimVerifyBudget(peerHash) {
834
+ const now = Date.now();
835
+ if (now - this.topicRootCandidateClaimGlobalVerifyBudget.refilledAt >=
836
+ TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS) {
837
+ this.topicRootCandidateClaimGlobalVerifyBudget = {
838
+ remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
839
+ refilledAt: now,
840
+ };
841
+ }
842
+ if (this.topicRootCandidateClaimGlobalVerifyBudget.remaining <= 0) {
843
+ return false;
844
+ }
845
+ let budget = this.topicRootCandidateClaimVerifyBudgets.get(peerHash);
846
+ if (!budget) {
847
+ if (this.topicRootCandidateClaimVerifyBudgets.size >=
848
+ TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_PEERS_MAX) {
849
+ const oldest = this.topicRootCandidateClaimVerifyBudgets.keys().next()
850
+ .value;
851
+ if (oldest)
852
+ this.topicRootCandidateClaimVerifyBudgets.delete(oldest);
853
+ }
854
+ budget = {
855
+ remaining: TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST,
856
+ refilledAt: now,
857
+ };
858
+ this.topicRootCandidateClaimVerifyBudgets.set(peerHash, budget);
859
+ }
860
+ else if (now - budget.refilledAt >=
861
+ TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS) {
862
+ budget.remaining = TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST;
863
+ budget.refilledAt = now;
864
+ // Refresh insertion order so bounded eviction remains O(1) and LRU-like.
865
+ this.topicRootCandidateClaimVerifyBudgets.delete(peerHash);
866
+ this.topicRootCandidateClaimVerifyBudgets.set(peerHash, budget);
867
+ }
868
+ if (budget.remaining <= 0)
869
+ return false;
870
+ budget.remaining -= 1;
871
+ this.topicRootCandidateClaimGlobalVerifyBudget.remaining -= 1;
872
+ return true;
873
+ }
874
+ async importSignedTopicRootCandidateClaim(rawClaim, outerPeerHash) {
875
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
876
+ if (rawClaim.byteLength === 0 ||
877
+ rawClaim.byteLength > TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES) {
878
+ return false;
879
+ }
880
+ let claim;
881
+ try {
882
+ claim = DataMessage.from(new Uint8ArrayList(rawClaim));
883
+ }
884
+ catch {
885
+ return false;
886
+ }
887
+ const canonicalBytes = toUint8Array(claim.bytes());
888
+ if (!bytesEqual(canonicalBytes, rawClaim))
889
+ return false;
890
+ if (!claim.data ||
891
+ !bytesEqual(claim.data, this.topicRootCandidateClaimData)) {
892
+ return false;
893
+ }
894
+ const signatures = claim.header.signatures?.signatures;
895
+ if (!signatures || signatures.length !== 1)
896
+ return false;
897
+ const signer = signatures[0]?.publicKey;
898
+ if (!signer)
899
+ return false;
900
+ const origin = signer.hashcode();
901
+ if (!isCanonicalTopicRootCandidate(origin))
902
+ return false;
903
+ if (origin === this.publicKeyHash)
904
+ return false;
905
+ const now = BigInt(Date.now());
906
+ const monotonicNow = performance.now();
907
+ const timestamp = claim.header.timestamp;
908
+ const expires = claim.header.expires;
909
+ const acceptanceDeadline = this.topicRootCandidateClaimAcceptanceDeadline(timestamp, expires, now, monotonicNow, TOPIC_ROOT_CANDIDATE_CLAIM_RECEIVER_MAX_LIFETIME_MS);
910
+ if (acceptanceDeadline === undefined) {
911
+ return false;
912
+ }
913
+ if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp)) {
914
+ return false;
915
+ }
916
+ this.rebuildAutoTopicRootCandidatesFromClaims(now);
917
+ if (!this.consumeTopicRootCandidateClaimVerifyBudget(outerPeerHash)) {
918
+ return false;
919
+ }
920
+ this.seedNativeWireVerification(claim, rawClaim);
921
+ let verified = false;
922
+ try {
923
+ verified = await claim.verify(true);
924
+ }
925
+ catch {
926
+ return false;
927
+ }
928
+ if (!verified)
929
+ return false;
930
+ if (lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
931
+ !this.isTopicRootCandidateClaimTimeValid(timestamp, expires, BigInt(Date.now())) ||
932
+ performance.now() >= acceptanceDeadline ||
933
+ !this.autoTopicRootCandidates ||
934
+ !this.started ||
935
+ this.stopping ||
936
+ this.topicControlPlaneStopping) {
937
+ return false;
938
+ }
939
+ if (!this.retainSignedTopicRootCandidateClaim(origin, {
940
+ bytes: rawClaim.slice(),
941
+ timestamp,
942
+ expires,
943
+ acceptUntil: acceptanceDeadline,
944
+ })) {
945
+ return false;
946
+ }
947
+ this.scheduleTopicRootCandidateClaimMaintenance();
948
+ return true;
949
+ }
950
+ async sendSignedTopicRootCandidateClaims(claims, targets, signal) {
951
+ if (claims.length === 0)
952
+ return;
953
+ const streams = (targets ?? [...this.peers.values()]).filter((stream) => stream.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
954
+ if (streams.length === 0)
955
+ return;
956
+ for (let offset = 0; offset < claims.length; offset += TOPIC_ROOT_CANDIDATE_CLAIMS_MAX) {
957
+ throwIfAborted(signal);
958
+ const message = new TopicRootCandidateClaims({
959
+ claims: claims.slice(offset, offset + TOPIC_ROOT_CANDIDATE_CLAIMS_MAX),
960
+ });
961
+ const embedded = await withAbort(this.createMessage(this.encodePubSubMessage(message), {
962
+ mode: new AnyWhere(),
963
+ priority: 1,
964
+ skipRecipientValidation: true,
965
+ }), signal);
966
+ throwIfAborted(signal);
967
+ await this.publishMessageMaybe(this.publicKey, embedded, streams, undefined, signal);
968
+ }
969
+ }
970
+ async sendAutoTopicRootCandidates(streams) {
971
+ if (!this.started)
972
+ throw new NotStartedError();
973
+ const signedStreams = streams.filter((stream) => stream.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1);
974
+ if (signedStreams.length === 0)
975
+ return;
976
+ this.rebuildAutoTopicRootCandidatesFromClaims();
977
+ if (!this.localSignedTopicRootCandidateClaim) {
978
+ try {
979
+ await this.refreshLocalTopicRootCandidateClaim();
980
+ }
981
+ catch (error) {
982
+ // Failure to refresh the nested local claim must not suppress
983
+ // authenticated remote claims; outer-envelope signing is still tried.
984
+ logErrorIfStarted(error);
985
+ }
986
+ }
987
+ const claims = [...this.signedTopicRootCandidateClaims.entries()]
988
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
989
+ .map(([, claim]) => claim.bytes);
990
+ await this.sendSignedTopicRootCandidateClaims(claims, signedStreams);
991
+ const localClaim = this.localSignedTopicRootCandidateClaim;
992
+ if (localClaim &&
993
+ !this.signedTopicRootCandidateClaims.has(this.publicKeyHash)) {
994
+ await this.sendSignedTopicRootCandidateClaims([localClaim.bytes], signedStreams);
995
+ }
996
+ }
997
+ setAutoTopicRootCandidateSnapshot(next, options) {
553
998
  if (!this.autoTopicRootCandidates ||
554
999
  this.stopping ||
555
- this.topicControlPlaneStopping)
1000
+ this.topicControlPlaneStopping) {
556
1001
  return false;
557
- if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
1002
+ }
1003
+ if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured()) {
558
1004
  return false;
1005
+ }
559
1006
  const managed = this.autoTopicRootCandidateSet;
560
1007
  if (!managed)
561
1008
  return false;
1009
+ if (options?.immediate) {
1010
+ this.clearAutoTopicRootCandidateUpdateSchedule();
1011
+ const current = [...managed];
1012
+ if (sameCandidates(current, next))
1013
+ return false;
1014
+ this.scheduleAutoTopicRootCandidateUpdateCooldown();
1015
+ this.applyAutoTopicRootCandidates(next);
1016
+ return true;
1017
+ }
562
1018
  const before = this.pendingAutoTopicRootCandidates ?? [...managed];
563
- const next = this.normalizeAutoTopicRootCandidates([
564
- ...before,
565
- ...candidates,
566
- ]);
567
1019
  if (sameCandidates(before, next))
568
1020
  return false;
569
1021
  if (this.autoTopicRootCandidateUpdateTimer) {
@@ -580,7 +1032,6 @@ export class TopicControlPlane extends DirectStream {
580
1032
  this.shardRootCache.clear();
581
1033
  this.scheduleReconcileShardOverlays();
582
1034
  this.scheduleHostOwnedShardRoots();
583
- this.scheduleAutoTopicRootCandidatesBroadcast();
584
1035
  }
585
1036
  scheduleAutoTopicRootCandidateUpdateCooldown() {
586
1037
  if (this.autoTopicRootCandidateUpdateTimer)
@@ -906,6 +1357,11 @@ export class TopicControlPlane extends DirectStream {
906
1357
  if (bytes[0] === 4) {
907
1358
  assertTopicRootCandidatesFrame(bytes);
908
1359
  }
1360
+ // Signed candidate claims are low-frequency opaque host-side frames. Keep
1361
+ // this explicit seam ahead of the native variants 0-7 codec.
1362
+ if (bytes[0] === 8) {
1363
+ return TopicRootCandidateClaims.from(bytes);
1364
+ }
909
1365
  const native = this.nativeTopicControl;
910
1366
  if (native) {
911
1367
  const decoded = native.decodePubSubMessage(bytes);
@@ -1106,7 +1562,7 @@ export class TopicControlPlane extends DirectStream {
1106
1562
  let requestId = this.nextTopicRootQueryId >>> 0;
1107
1563
  do {
1108
1564
  requestId = requestId === 0 ? 1 : requestId;
1109
- this.nextTopicRootQueryId = ((requestId + 1) >>> 0) || 1;
1565
+ this.nextTopicRootQueryId = (requestId + 1) >>> 0 || 1;
1110
1566
  if (!this.pendingTopicRootQueries.has(requestId)) {
1111
1567
  return requestId;
1112
1568
  }
@@ -1764,7 +2220,9 @@ export class TopicControlPlane extends DirectStream {
1764
2220
  this.shardRefCounts.set(shardTopic, next);
1765
2221
  }
1766
2222
  // Best-effort: do not block callers on network I/O (can hang under teardown).
1767
- void this.debounceUnsubscribeAggregator.add({ key: topic }).catch(logErrorIfStarted);
2223
+ void this.debounceUnsubscribeAggregator
2224
+ .add({ key: topic })
2225
+ .catch(logErrorIfStarted);
1768
2226
  return true;
1769
2227
  }
1770
2228
  async _announceUnsubscribe(topics) {
@@ -1893,9 +2351,7 @@ export class TopicControlPlane extends DirectStream {
1893
2351
  }
1894
2352
  }
1895
2353
  onFanoutPeerUnreachable(ev) {
1896
- void this
1897
- .announcePeerUnavailableOnShard(ev.detail.publicKeyHash, ev.detail.topic)
1898
- .catch(logErrorIfStarted);
2354
+ void this.announcePeerUnavailableOnShard(ev.detail.publicKeyHash, ev.detail.topic).catch(logErrorIfStarted);
1899
2355
  }
1900
2356
  getSubscribers(topic) {
1901
2357
  const t = topic.toString();
@@ -2147,9 +2603,7 @@ export class TopicControlPlane extends DirectStream {
2147
2603
  }))
2148
2604
  .filter((batch) => batch.topics.length > 0);
2149
2605
  if (batches.length > 0) {
2150
- void this
2151
- .announcePeerUnavailable(publicKeyHash, batches)
2152
- .catch(logErrorIfStarted);
2606
+ void this.announcePeerUnavailable(publicKeyHash, batches).catch(logErrorIfStarted);
2153
2607
  }
2154
2608
  }
2155
2609
  collectSubscriptionState(peerHash) {
@@ -2266,9 +2720,7 @@ export class TopicControlPlane extends DirectStream {
2266
2720
  if (!this.lastSubscriptionMessages.has(subscriberKey)) {
2267
2721
  this.lastSubscriptionMessages.set(subscriberKey, new Map());
2268
2722
  }
2269
- this.lastSubscriptionMessages
2270
- .get(subscriberKey)
2271
- .set(topic, {
2723
+ this.lastSubscriptionMessages.get(subscriberKey).set(topic, {
2272
2724
  session,
2273
2725
  timestamp,
2274
2726
  });
@@ -2378,6 +2830,7 @@ export class TopicControlPlane extends DirectStream {
2378
2830
  async processDirectPubSubMessage(input) {
2379
2831
  const { pubsubMessage, message, from, stream } = input;
2380
2832
  const isDirectRootControl = pubsubMessage instanceof TopicRootCandidates ||
2833
+ pubsubMessage instanceof TopicRootCandidateClaims ||
2381
2834
  pubsubMessage instanceof TopicRootQuery ||
2382
2835
  pubsubMessage instanceof TopicRootQueryResponse;
2383
2836
  const directRootSigner = isDirectRootControl
@@ -2388,8 +2841,31 @@ export class TopicControlPlane extends DirectStream {
2388
2841
  return;
2389
2842
  }
2390
2843
  if (pubsubMessage instanceof TopicRootCandidates) {
2391
- // Used only to converge deterministic shard-root candidates in auto mode.
2392
- this.mergeAutoTopicRootCandidatesFromPeer(pubsubMessage.candidates);
2844
+ if (stream.protocol !== TOPIC_CONTROL_PLANE_PROTOCOL_V2_0) {
2845
+ return;
2846
+ }
2847
+ // `addPeer()` already admitted the authenticated direct stream identity.
2848
+ // The advertised list is intentionally ignored.
2849
+ return;
2850
+ }
2851
+ if (pubsubMessage instanceof TopicRootCandidateClaims) {
2852
+ if (stream.protocol !== TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 ||
2853
+ !this.autoTopicRootCandidates) {
2854
+ return;
2855
+ }
2856
+ const imported = [];
2857
+ const outerPeerHash = stream.publicKey.hashcode();
2858
+ for (const claim of pubsubMessage.claims) {
2859
+ if (await this.importSignedTopicRootCandidateClaim(claim, outerPeerHash)) {
2860
+ imported.push(claim);
2861
+ }
2862
+ }
2863
+ if (imported.length > 0) {
2864
+ this.rebuildAutoTopicRootCandidatesFromClaims();
2865
+ // Relay the exact nested claims; no relay signature is substituted for
2866
+ // their origin signatures.
2867
+ void this.sendSignedTopicRootCandidateClaims(imported, [...this.peers.values()].filter((peer) => peer.publicKey.hashcode() !== outerPeerHash)).catch(() => { });
2868
+ }
2393
2869
  return;
2394
2870
  }
2395
2871
  if (pubsubMessage instanceof TopicRootQuery) {
@@ -2439,7 +2915,7 @@ export class TopicControlPlane extends DirectStream {
2439
2915
  }
2440
2916
  if (changed.length > 0) {
2441
2917
  this.dispatchEvent(new CustomEvent("subscribe", {
2442
- detail: new SubscriptionEvent(sender, changed),
2918
+ detail: new SubscriptionEvent(sender, changed, message.header.session),
2443
2919
  }));
2444
2920
  }
2445
2921
  }
@@ -2518,7 +2994,7 @@ export class TopicControlPlane extends DirectStream {
2518
2994
  }
2519
2995
  if (changed.length > 0) {
2520
2996
  this.dispatchEvent(new CustomEvent("subscribe", {
2521
- detail: new SubscriptionEvent(sender, changed),
2997
+ detail: new SubscriptionEvent(sender, changed, message.header.session),
2522
2998
  }));
2523
2999
  }
2524
3000
  }
@@ -2642,6 +3118,7 @@ export class TopicControlPlane extends DirectStream {
2642
3118
  // messages used for topic-root discovery and direct subscriber snapshots.
2643
3119
  if (!(pubsubMessage instanceof PubSubData) &&
2644
3120
  !(pubsubMessage instanceof TopicRootCandidates) &&
3121
+ !(pubsubMessage instanceof TopicRootCandidateClaims) &&
2645
3122
  !(pubsubMessage instanceof TopicRootQuery) &&
2646
3123
  !(pubsubMessage instanceof GetSubscribers) &&
2647
3124
  !(pubsubMessage instanceof Subscribe) &&