@serfab/cadre-core 0.11.0 → 0.12.0

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 (86) hide show
  1. package/dist/announce-addrs.d.ts +49 -0
  2. package/dist/announce-addrs.js +78 -0
  3. package/dist/announce-addrs.js.map +1 -0
  4. package/dist/cadre-node.d.ts +281 -30
  5. package/dist/cadre-node.js +513 -103
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/control-database.d.ts +155 -23
  8. package/dist/control-database.js +272 -104
  9. package/dist/control-database.js.map +1 -1
  10. package/dist/control-read-retry.d.ts +103 -0
  11. package/dist/control-read-retry.js +145 -0
  12. package/dist/control-read-retry.js.map +1 -0
  13. package/dist/control-retry.d.ts +101 -0
  14. package/dist/control-retry.js +108 -0
  15. package/dist/control-retry.js.map +1 -0
  16. package/dist/control-schema.d.ts +1 -1
  17. package/dist/control-schema.js +20 -4
  18. package/dist/control-schema.js.map +1 -1
  19. package/dist/control-write-retry.d.ts +59 -35
  20. package/dist/control-write-retry.js +21 -92
  21. package/dist/control-write-retry.js.map +1 -1
  22. package/dist/delegate-admission.d.ts +17 -15
  23. package/dist/delegate-admission.js +17 -15
  24. package/dist/delegate-admission.js.map +1 -1
  25. package/dist/index.d.ts +6 -4
  26. package/dist/index.js +17 -8
  27. package/dist/index.js.map +1 -1
  28. package/dist/membership-connection-gater.d.ts +211 -25
  29. package/dist/membership-connection-gater.js +309 -29
  30. package/dist/membership-connection-gater.js.map +1 -1
  31. package/dist/peer-record.d.ts +26 -0
  32. package/dist/peer-record.js +32 -0
  33. package/dist/peer-record.js.map +1 -1
  34. package/dist/relay-addrs.d.ts +76 -26
  35. package/dist/relay-addrs.js +115 -27
  36. package/dist/relay-addrs.js.map +1 -1
  37. package/dist/relay-reservation.d.ts +20 -15
  38. package/dist/relay-reservation.js +18 -14
  39. package/dist/relay-reservation.js.map +1 -1
  40. package/dist/strand-addr-protocol.js +16 -0
  41. package/dist/strand-addr-protocol.js.map +1 -1
  42. package/dist/strand-backfill.js +13 -4
  43. package/dist/strand-backfill.js.map +1 -1
  44. package/dist/strand-formation-manager.d.ts +10 -9
  45. package/dist/strand-formation-manager.js +10 -9
  46. package/dist/strand-formation-manager.js.map +1 -1
  47. package/dist/strand-formation-protocol.d.ts +7 -7
  48. package/dist/strand-formation-protocol.js +8 -8
  49. package/dist/strand-formation-protocol.js.map +1 -1
  50. package/dist/strand-instance-manager.d.ts +4 -3
  51. package/dist/strand-instance-manager.js +31 -6
  52. package/dist/strand-instance-manager.js.map +1 -1
  53. package/dist/strand-membership-writer.d.ts +106 -14
  54. package/dist/strand-membership-writer.js +180 -14
  55. package/dist/strand-membership-writer.js.map +1 -1
  56. package/dist/strand-solicitation.d.ts +3 -3
  57. package/dist/strand-solicitation.js +4 -5
  58. package/dist/strand-solicitation.js.map +1 -1
  59. package/dist/strand-wake-protocol.d.ts +47 -2
  60. package/dist/strand-wake-protocol.js +76 -6
  61. package/dist/strand-wake-protocol.js.map +1 -1
  62. package/dist/types.d.ts +74 -29
  63. package/dist/types.js.map +1 -1
  64. package/package.json +8 -8
  65. package/src/announce-addrs.ts +93 -0
  66. package/src/cadre-node.ts +5603 -5158
  67. package/src/control-database.ts +2584 -2406
  68. package/src/control-read-retry.ts +167 -0
  69. package/src/control-retry.ts +190 -0
  70. package/src/control-schema.ts +20 -4
  71. package/src/control-write-retry.ts +30 -130
  72. package/src/delegate-admission.ts +17 -15
  73. package/src/index.ts +435 -407
  74. package/src/membership-connection-gater.ts +533 -204
  75. package/src/peer-record.ts +33 -0
  76. package/src/relay-addrs.ts +218 -111
  77. package/src/relay-reservation.ts +828 -823
  78. package/src/strand-addr-protocol.ts +17 -0
  79. package/src/strand-backfill.ts +389 -366
  80. package/src/strand-formation-manager.ts +492 -491
  81. package/src/strand-formation-protocol.ts +8 -8
  82. package/src/strand-instance-manager.ts +636 -611
  83. package/src/strand-membership-writer.ts +209 -14
  84. package/src/strand-solicitation.ts +6 -7
  85. package/src/strand-wake-protocol.ts +94 -8
  86. package/src/types.ts +74 -29
@@ -15,7 +15,7 @@ import { MemoryBootstrapPeerStore } from './bootstrap-peer-store.js';
15
15
  import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
16
16
  import { verifyCadrePeerVoucher } from './peer-authorization.js';
17
17
  import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
18
- import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, trailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
18
+ import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, withTrailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
19
19
  import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
20
20
  import { StrandWatcher } from './strand-watcher.js';
21
21
  import { StrandInstanceManager } from './strand-instance-manager.js';
@@ -27,11 +27,12 @@ import { HibernationManager } from './hibernation-manager.js';
27
27
  import { ControlDatabase } from './control-database.js';
28
28
  import { SeedBootstrapService } from './seed-bootstrap.js';
29
29
  import { StrandSolicitationService } from './strand-solicitation.js';
30
- import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS } from './membership-connection-gater.js';
30
+ import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS, UnauthorizedReservationBudget } from './membership-connection-gater.js';
31
31
  import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
32
32
  import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
33
33
  import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, pruneStoppedStrandAnnounces, peerStrandKey } from './delegate-admission.js';
34
- import { resolveListenAddrs } from './relay-addrs.js';
34
+ import { relayCircuitAddrs, resolveListenAddrs, RelayReservationFailedError } from './relay-addrs.js';
35
+ import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
35
36
  import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
36
37
  import { PushFanoutService } from './push-fanout.js';
37
38
  import { summarizeConnectionPaths, classifyTransport } from './diagnostics/connection-path.js';
@@ -148,10 +149,11 @@ export class CadreNode {
148
149
  this.windowWaiters = new Set();
149
150
  this.eventHandlers = new Map();
150
151
  /**
151
- * Relay multiaddrs the caller asked {@link reserveRelays} to reserve through.
152
- * Empty (the default) means nobody asked, so {@link getRelayReservationState}
153
- * reports `none` — this whole surface is opt-in and dormant for the CLI/host
154
- * nodes, which use the configured `network.relayAddrs` route instead.
152
+ * Relay multiaddrs the caller asked {@link reserveRelays} to reserve through —
153
+ * or, on a node that names `network.relayAddrs`, the ones
154
+ * {@link driveControlRelayReservation} asked for at the end of {@link start}.
155
+ * Empty (the default) means neither happened, so {@link getRelayReservationState}
156
+ * reports `none`.
155
157
  */
156
158
  this.relayReserveAddrs = [];
157
159
  /**
@@ -166,6 +168,23 @@ export class CadreNode {
166
168
  * supervisor to hold one — i.e. the pre-start `control node unavailable` case.
167
169
  */
168
170
  this.relayReserveError = null;
171
+ /**
172
+ * True from just before the control libp2p node is created until
173
+ * {@link ControlDatabase.initialize} has settled — the connection gate's
174
+ * BRING-UP QUIET PERIOD (`membership-connection-gater.ts`), during which this
175
+ * node refuses every control connection in both directions.
176
+ *
177
+ * The invariant: the control database is built while this node holds zero
178
+ * control connections. Every same-party sibling connected in that window joins
179
+ * the Optimystic cohort the bring-up's block probes consult, and a sibling that
180
+ * has not yet replicated this node's `CadrePeer` row refuses them all — which
181
+ * fails `start()` outright and cannot be retried into convergence (writing the
182
+ * row that would clear the refusal needs the database being built).
183
+ *
184
+ * Cleared on FAILURE as well as success ({@link cleanup} clears it), so
185
+ * teardown is never gated.
186
+ */
187
+ this.controlBringUpInFlight = false;
169
188
  /** Map of strandId -> sAppConfig for sAppId filtering and management */
170
189
  this.sAppConfigs = new Map();
171
190
  /**
@@ -226,10 +245,12 @@ export class CadreNode {
226
245
  /**
227
246
  * In-memory delegate admission grants (see `delegate-admission.ts`): the
228
247
  * strand-node transport peerIds this party's members have announced over the
229
- * strand-addr RPC, admitted at the CONNECTION level so a member's NAT'd
230
- * strand node can hold a circuit-relay reservation on this node. Consulted
231
- * ONLY by {@link admitInboundControlConnection}; the fail-closed per-stream
232
- * gate ({@link authorizeInboundControlStream}) never honors it.
248
+ * strand-addr RPC, admitted at the CONNECTION and RESERVATION levels so a
249
+ * member's NAT'd strand node can hold a circuit-relay reservation on this
250
+ * node (without spending the unauthorized-reservation budget). Consulted
251
+ * ONLY by {@link admitInboundControlConnection} and
252
+ * {@link admitControlRelayReservation}; the fail-closed per-stream gate
253
+ * ({@link authorizeInboundControlStream}) never honors it.
233
254
  */
234
255
  this.delegateAdmission = new DelegateAdmissionStore();
235
256
  /**
@@ -390,6 +411,7 @@ export class CadreNode {
390
411
  this.config = config;
391
412
  this.strandManager = new StrandInstanceManager();
392
413
  this.enrollmentService = new EnrollmentService();
414
+ this.unauthorizedRelayReservations = new UnauthorizedReservationBudget(config.network?.unauthorizedRelayReservationCap);
393
415
  // Create hibernation manager with callbacks
394
416
  const hibernationCallbacks = {
395
417
  onIdle: async (strandId) => this.handleStrandIdle(strandId),
@@ -501,16 +523,12 @@ export class CadreNode {
501
523
  return;
502
524
  }
503
525
  log('Starting CadreNode for party: %s', this.config.controlNetwork.partyId);
504
- // NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
505
- // diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
506
- // second such warning ever appears here, route both through a `CadreNodeEvents` entry the
507
- // embedder surfaces instead of growing a console surface inside a library.
508
- if (this.config.network?.announceAddrs && this.config.network.announceAddrs.length > 0) {
509
- console.warn('network.announceAddrs is set but not yet supported (no upstream db-p2p option to apply it) ' +
510
- '— this node will keep advertising its listen/relay addresses instead.');
511
- }
512
526
  try {
513
527
  const tTotal = performance.now();
528
+ // Config pre-flight, inside the guarded region: resolving the listen addrs it reads
529
+ // throws on a malformed `relayAddrs` entry, and that failure belongs on the same
530
+ // logged-and-cleaned-up path as every other one in start().
531
+ this.warnIfAnnounceAddrsDiscardRelay();
514
532
  // Install the WebRTC TURN-relay tracker BEFORE any libp2p bring-up, so it
515
533
  // wraps globalThis.RTCPeerConnection before the control node can create one.
516
534
  // Inert on Node.js (no RTCPeerConnection); disposed in cleanup().
@@ -529,6 +547,24 @@ export class CadreNode {
529
547
  // closed before any network bring-up, and the retained dial targets are
530
548
  // loaded before the first reconcile pass could consult them.
531
549
  this.initializeBootstrapPeerStore();
550
+ // Arm the connection gate's BRING-UP QUIET PERIOD before the libp2p node
551
+ // exists, so no connection can form ahead of it: `@libp2p/bootstrap` emits
552
+ // its discovery events ~1 s after `libp2p.start()` and the connection
553
+ // manager auto-dials from there, which is INSIDE createControlNode below.
554
+ // The invariant being protected is "zero control connections during
555
+ // control-database bring-up" — see the field's doc and
556
+ // `membership-connection-gater.ts` → "The bring-up quiet period".
557
+ //
558
+ // NOTE: without this gate the bootstrap arm is a RACE, not a guarantee — its
559
+ // safety margin is bring-up duration versus @libp2p/bootstrap's 1 s discovery
560
+ // fuse, and bring-up duration is (raw-storage operations) × per-operation
561
+ // latency. The cold-start operation count and the 50-90 ms/op figure a loaded
562
+ // phone actually sees live in `control-database.ts`'s `loadSchema` note (and
563
+ // are pinned by `control-start-storage-op-budget.spec.ts`): at the top of that
564
+ // range bring-up is 9-15 s, far past the fuse. So if this gate is ever removed
565
+ // or bypassed, that arm reopens for exactly the nodes most likely to hit it —
566
+ // phones joining a party, whose membership row has not replicated yet.
567
+ this.controlBringUpInFlight = true;
532
568
  // Create the control network libp2p node
533
569
  let t0 = performance.now();
534
570
  this.controlNode = await this.createControlNode();
@@ -549,6 +585,21 @@ export class CadreNode {
549
585
  });
550
586
  await this.controlDatabase.initialize();
551
587
  timing('[start] controlDatabase.initialize: %dms', Math.round(performance.now() - t0));
588
+ // The database exists locally now, so the quiet period has done its job and
589
+ // this node can be in the conversation. A failure above never reaches here —
590
+ // `cleanup()` on the catch path opens the gate instead, so teardown (and a
591
+ // retry by the embedder) is not gated.
592
+ //
593
+ // NOTE: the window ends HERE, not at the end of start(), so the awaited control-DB
594
+ // work that follows — `strandWatcher.start()`'s first poll — can run against a
595
+ // cohort a connection just joined. Fine now: every block that poll reads was
596
+ // created locally by the initialize() above, so a refusing sibling has nothing to
597
+ // refuse. If a boot ever fails with `BlockUnavailableError` from BELOW this line,
598
+ // that assumption broke. Do not simply move this clear later without measuring:
599
+ // `handleStrandAdded` runs inside `strandWatcher.start()` and seeds each strand
600
+ // node from CONNECTED siblings (`resolveCohortSeed`), so a longer window trades a
601
+ // bring-up hazard for boot-time strand seeds that are always empty.
602
+ this.controlBringUpInFlight = false;
552
603
  log('Control database initialized');
553
604
  // Attach the per-stream gate to the control DB's membership hub, so every
554
605
  // committed `CadrePeer` write re-materializes the authorized snapshot on its
@@ -614,12 +665,27 @@ export class CadreNode {
614
665
  // lands the snapshot is empty, which the stream gate treats as cold
615
666
  // start and admits.
616
667
  void this.refreshMembershipGate('start');
668
+ // Reserve the configured relays LAST, so every step above ran against a
669
+ // cohort of one (see driveControlRelayReservation). Ahead of
670
+ // scheduleSelfRegistration, so this node's first published `CadrePeer` row
671
+ // already carries the `/p2p-circuit` address the reservation earns it.
672
+ // Throws on a relay that will not have us — start() is fail-fast for a node
673
+ // whose operator named a relay.
674
+ await this.driveControlRelayReservation();
617
675
  // Schedule self-registration in background
618
676
  this.scheduleSelfRegistration();
619
677
  }
620
678
  catch (error) {
621
679
  log('Failed to start CadreNode: %o', error);
680
+ // `driveControlRelayReservation` throws AFTER `control:connected` was emitted,
681
+ // so a subscriber can have seen this node come up. Balance the edge rather than
682
+ // leaving it dangling — `cleanup()` has torn the node down either way, and the
683
+ // events are what an embedder drives its "am I connected" UI from.
684
+ const wasConnected = this._running;
622
685
  await this.cleanup();
686
+ if (wasConnected) {
687
+ this.emit('control:disconnected', undefined);
688
+ }
623
689
  throw error;
624
690
  }
625
691
  }
@@ -657,19 +723,7 @@ export class CadreNode {
657
723
  return;
658
724
  }
659
725
  log('Stopping CadreNode');
660
- // Stop the retry loop BEFORE tearing the node down, not after: `cleanup()`
661
- // stops the control node partway through, and a tick that fires during it
662
- // would dial and then poll a half-torn-down node for the whole reserve
663
- // timeout — logging spurious dial failures and holding the process open.
664
- this.relayReserveSupervisor?.stop();
665
- this.relayReserveSupervisor = null;
666
726
  await this.cleanup();
667
- // Drop the rest of the relay-reservation posture: a stopped node holds no
668
- // reservation, so a restarted instance must not report the previous run's
669
- // `reserved`.
670
- this.relayReserveAddrs = [];
671
- this.relayReserveError = null;
672
- this._running = false;
673
727
  this.emit('control:disconnected', undefined);
674
728
  log('CadreNode stopped');
675
729
  }
@@ -868,6 +922,42 @@ export class CadreNode {
868
922
  this.controlStorage = wrapStorageWithCache(resolved, 'control');
869
923
  return this.controlStorage;
870
924
  }
925
+ /**
926
+ * Warn the operator when `network.announceAddrs` will silently cost this node the
927
+ * relay reachability it also configured.
928
+ *
929
+ * A non-empty announce set REPLACES everything libp2p advertises, so the
930
+ * `/p2p-circuit` address earned by a relay reservation is dropped from the node's
931
+ * advertised addresses even though the reservation itself is still held — peers
932
+ * behind NAT stop being able to reach it through that relay. Not an error: an
933
+ * operator whose relay slot is decorative may genuinely want only the announced
934
+ * address, so this reports and proceeds.
935
+ *
936
+ * Keyed off the RESOLVED listen addrs rather than `relayAddrs` alone, so a
937
+ * hand-written `/p2p-circuit` entry in `listenAddrs` — the same reservation by the
938
+ * longer route — is caught too.
939
+ *
940
+ * NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
941
+ * diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
942
+ * second such warning ever appears here, route both through a `CadreNodeEvents` entry the
943
+ * embedder surfaces instead of growing a console surface inside a library.
944
+ */
945
+ warnIfAnnounceAddrsDiscardRelay() {
946
+ const network = this.config.network;
947
+ if (!replacesAdvertisedAddrs(network)) {
948
+ return;
949
+ }
950
+ const listensOnCircuit = (resolveListenAddrs(network) ?? [])
951
+ .some((addr) => addr.includes('/p2p-circuit'));
952
+ if (!listensOnCircuit) {
953
+ return;
954
+ }
955
+ console.warn('network.announceAddrs is set alongside a circuit-relay listener (from network.relayAddrs, ' +
956
+ 'or a /p2p-circuit entry in network.listenAddrs). A non-empty announce set REPLACES every ' +
957
+ 'address this node advertises, so the /p2p-circuit address earned from the relay reservation ' +
958
+ 'will not be advertised and peers will stop reaching this node through that relay. ' +
959
+ 'Use network.appendAnnounceAddrs instead to advertise an extra address without discarding the rest.');
960
+ }
871
961
  /**
872
962
  * Map this node's config onto the control network's libp2p node options.
873
963
  *
@@ -879,10 +969,12 @@ export class CadreNode {
879
969
  buildControlNodeOptions() {
880
970
  const { controlNetwork, network, profile } = this.config;
881
971
  const identityKey = this.identityKey;
882
- const listenAddrs = resolveListenAddrs(network);
883
- // Determine relay mode: if explicitly set in config, use that;
884
- // otherwise default to true for storage profile nodes (better connectivity/uptime)
885
- const enableRelay = network?.enableRelay ?? (profile === 'storage');
972
+ // `'search'`: `network.relayAddrs` contributes the bare `/p2p-circuit` SEARCH
973
+ // entry, which opens no connection — the reservation is driven explicitly at
974
+ // the END of `start()`, so the control database is built while this node holds
975
+ // zero control connections. See `relay-addrs.ts` and `start()`.
976
+ const listenAddrs = resolveListenAddrs(network, 'search');
977
+ const enableRelay = this.relayServerEnabled();
886
978
  // The control node's own storage, resolved once per runtime (see
887
979
  // {@link resolveControlStorage}) rather than per call.
888
980
  const controlStorageProvider = this.resolveControlStorage();
@@ -904,15 +996,25 @@ export class CadreNode {
904
996
  arachnode: { enableRingZulu: profile === 'storage' },
905
997
  ...(identityKey && { privateKey: identityKey }),
906
998
  ...(network?.transports && { transports: network.transports }),
907
- // Configured `listenAddrs`, plus a `/p2p-circuit` listener for every
908
- // `network.relayAddrs` entry — that listener is what reserves the relay slot.
999
+ // Configured `listenAddrs`, plus the bare `/p2p-circuit` search listener when
1000
+ // `network.relayAddrs` is set — that listener registers the pending reservation
1001
+ // `driveControlRelayReservation` fills after bring-up.
909
1002
  ...(listenAddrs && { listenAddrs }),
1003
+ // What this node ADVERTISES, which is not the same question as what it binds.
1004
+ // Either field is present only when configured non-empty; a non-empty
1005
+ // `announceAddrs` replaces the advertised set (see warnIfAnnounceAddrsDiscardRelay).
1006
+ ...resolveAnnounceAddrs(network),
910
1007
  // The CONTROL node composes the membership admission gate onto any
911
- // caller-supplied gater (deny from either wins on inbound; all other
912
- // hooks pass through). Strand cohort nodes keep the raw configured gater
913
- // (see strand-instance-manager.ts) — their peers are legitimately
1008
+ // caller-supplied gater (deny from either wins on the inbound-encrypted
1009
+ // and relay-reservation hooks; all other hooks pass through). Strand
1010
+ // cohort nodes keep the raw configured gater (see
1011
+ // strand-instance-manager.ts) — their peers are legitimately
914
1012
  // cross-party, so cadre membership must not gate them.
915
- connectionGater: createMembershipConnectionGater({ admitInbound: (remotePeerId) => this.admitInboundControlConnection(remotePeerId) }, network?.connectionGater),
1013
+ connectionGater: createMembershipConnectionGater({
1014
+ admitInbound: (remotePeerId) => this.admitInboundControlConnection(remotePeerId),
1015
+ admitRelayReservation: (remotePeerId) => this.admitControlRelayReservation(remotePeerId),
1016
+ bringUpInFlight: () => this.controlBringUpInFlight
1017
+ }, network?.connectionGater),
916
1018
  // Fail-closed per-stream authorization for the four Optimystic control-DB
917
1019
  // protocols — the members-only layer the connection gater's stranger
918
1020
  // carve-outs cannot express (see authorizeInboundControlStream). Control
@@ -921,6 +1023,17 @@ export class CadreNode {
921
1023
  };
922
1024
  return nodeOptions;
923
1025
  }
1026
+ /**
1027
+ * Does this node's CONTROL libp2p run the circuit-relay server? An explicit
1028
+ * `network.enableRelay` wins; otherwise storage-profile nodes default to on
1029
+ * (better connectivity/uptime). Shared by {@link buildControlNodeOptions}
1030
+ * (which configures the server from it) and
1031
+ * {@link admitInboundControlConnection} (whose deny/admit-for-relay branch
1032
+ * must agree with whether a reservation is even servable here).
1033
+ */
1034
+ relayServerEnabled() {
1035
+ return this.config.network?.enableRelay ?? (this.config.profile === 'storage');
1036
+ }
924
1037
  /**
925
1038
  * Decide whether an inbound CONTROL-network connection from `remotePeerId`
926
1039
  * should be admitted — the policy behind the connection gater wired in
@@ -930,7 +1043,7 @@ export class CadreNode {
930
1043
  * `membership-connection-gater.ts` for the layer's rationale and the
931
1044
  * stranger-open protocol allowlist).
932
1045
  *
933
- * Admits when ANY of:
1046
+ * Returns `'admit'` when ANY of:
934
1047
  * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
935
1048
  * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
936
1049
  * configured bootstrap/relay infrastructure);
@@ -967,44 +1080,117 @@ export class CadreNode {
967
1080
  * this node yet is denied even though the formation handler would have
968
1081
  * accepted it, exactly like the unreplicated-membership-row case below.
969
1082
  *
1083
+ * When every check falls through, the verdict depends on whether this node
1084
+ * runs the circuit-relay server ({@link relayServerEnabled}): without one,
1085
+ * `'deny'`; with one, `'admit-for-relay'` — a circuit-relay reservation is
1086
+ * established by the reserving peer DIALING the relay, so a connection deny
1087
+ * here kills the reservation, and that deny is NOT self-healing: an outbound
1088
+ * reconcile re-dial re-establishes a data link, but no outbound dial can
1089
+ * grant the remote peer a reservation, and a relay-only peer has no address
1090
+ * of its own to dial back — the reservation IS its address. The gater admits
1091
+ * such a connection, decides the reservation via
1092
+ * {@link admitControlRelayReservation}, and drops the connection if no
1093
+ * reservation is admitted in time (see `membership-connection-gater.ts` →
1094
+ * "The relay-reservation seam").
1095
+ *
970
1096
  * NOTE: check 4/5 runs a control-DB read per inbound connection
971
1097
  * (`listAuthorizedMembers`); connections are rare and cadres small, and this
972
1098
  * layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
973
1099
  * read is safe here — unlike the per-stream gate, which must consult the
974
1100
  * materialized {@link authorizedControlPeers} snapshot instead.
975
- * NOTE: a sibling whose membership row has not yet replicated to
976
- * this node is denied until the row converges (typically via the owner);
977
- * either side's next outbound reconcile dial (outbound is never gated)
978
- * re-establishes the link — self-healing, but visible as a transient deny.
1101
+ * NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
1102
+ * replicated here is denied until the row converges (typically via the
1103
+ * owner); either side's next outbound reconcile dial (outbound is never
1104
+ * gated) re-establishes the DATA link — self-healing for data, visible as a
1105
+ * transient deny. That self-healing story never covered a reservation, which
1106
+ * is exactly why the relay-enabled path above exists.
979
1107
  */
980
1108
  async admitInboundControlConnection(remotePeerId) {
981
1109
  if (this.admitControlPeerUnconditionally(remotePeerId)) {
982
- return true;
1110
+ return 'admit';
983
1111
  }
984
1112
  if (Date.now() < this.enrollmentWindowUntil) {
985
- return true;
1113
+ return 'admit';
986
1114
  }
987
1115
  if (this.delegateAdmission.has(remotePeerId)) {
988
- return true;
1116
+ return 'admit';
989
1117
  }
990
1118
  const authorized = await this.listAuthorizedMembers();
991
1119
  if (authorized.length === 0) {
992
- return true;
1120
+ return 'admit';
993
1121
  }
994
1122
  if (authorized.some((m) => m.peerId === remotePeerId)) {
995
- return true;
1123
+ return 'admit';
996
1124
  }
997
1125
  try {
998
1126
  if (await this.strandSolicitationService?.hasOutstandingInvitation()) {
999
- return true;
1127
+ return 'admit';
1000
1128
  }
1001
1129
  }
1002
1130
  catch (error) {
1003
1131
  log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
1004
- return true;
1132
+ return 'admit';
1133
+ }
1134
+ if (this.relayServerEnabled()) {
1135
+ log('admitInboundControlConnection: admitting %s FOR RELAY ONLY — not an authorized member and no enrollment path open; the gater drops the connection unless a reservation is admitted', remotePeerId);
1136
+ return 'admit-for-relay';
1005
1137
  }
1006
1138
  log('admitInboundControlConnection: DENYING inbound from %s — not an authorized member and no enrollment path open', remotePeerId);
1007
- return false;
1139
+ return 'deny';
1140
+ }
1141
+ /**
1142
+ * Should `remotePeerId` be granted a circuit-relay reservation slot on this
1143
+ * node's relay server? The policy behind the gater's
1144
+ * `denyInboundRelayReservation` hook (see `membership-connection-gater.ts` →
1145
+ * "The relay-reservation seam"); the circuit-relay server consults it per
1146
+ * RESERVE request, so it is never called on a node whose relay server is off.
1147
+ *
1148
+ * Admits when ANY of:
1149
+ * 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
1150
+ * — not running / DB torn down, absent-or-empty trusted-owner anchor, or
1151
+ * configured bootstrap/relay infrastructure);
1152
+ * 2. the peer holds a live delegate admission grant — a member's strand
1153
+ * node reserving here (see `delegate-admission.ts`); never counted
1154
+ * against the unauthorized budget;
1155
+ * 3. the authorized-member set is empty (cold start) or the peer IS an
1156
+ * authorized member — members are never counted against the budget; or
1157
+ * 4. the peer takes (or already holds) a slot in the bounded
1158
+ * unauthorized-reservation budget — the boot-ordering window where a
1159
+ * genuine member reserves before its `CadrePeer` row replicates here.
1160
+ * Cap via `network.unauthorizedRelayReservationCap` (0 = refuse every
1161
+ * unauthorized reservation).
1162
+ *
1163
+ * Every uncounted admission also RELEASES the budget slot the peer may hold
1164
+ * from an earlier reservation taken while it was still unplaceable — a member
1165
+ * that boots, reserves, and only then has its row replicate here would
1166
+ * otherwise keep that slot spent for the rest of the entry's TTL.
1167
+ *
1168
+ * The connection gate's stranger carve-outs (an open enrollment window, an
1169
+ * outstanding formation invitation) deliberately do NOT extend here: they
1170
+ * exist so a stranger's SEED or FORMATION stream can ride a connection, and
1171
+ * neither needs relay capacity. A genuine invitee that does need a relay slot
1172
+ * takes one from the budget like any other unplaced peer, so an open window
1173
+ * never becomes an unbounded grant of this node's forwarding capacity.
1174
+ */
1175
+ async admitControlRelayReservation(remotePeerId) {
1176
+ if (this.admitControlPeerUnconditionally(remotePeerId)) {
1177
+ return this.admitReservationUncounted(remotePeerId);
1178
+ }
1179
+ if (this.delegateAdmission.has(remotePeerId)) {
1180
+ return this.admitReservationUncounted(remotePeerId);
1181
+ }
1182
+ const authorized = await this.listAuthorizedMembers();
1183
+ if (authorized.length === 0 || authorized.some((m) => m.peerId === remotePeerId)) {
1184
+ return this.admitReservationUncounted(remotePeerId);
1185
+ }
1186
+ const admitted = this.unauthorizedRelayReservations.tryAdmit(remotePeerId);
1187
+ log('admitControlRelayReservation: %s %s under the unauthorized-reservation budget', admitted ? 'admitting' : 'REFUSING', remotePeerId);
1188
+ return admitted;
1189
+ }
1190
+ /** Admit a reservation on the peer's own merits, giving back any budget slot it still holds. */
1191
+ admitReservationUncounted(remotePeerId) {
1192
+ this.unauthorizedRelayReservations.release(remotePeerId);
1193
+ return true;
1008
1194
  }
1009
1195
  /**
1010
1196
  * Hold the control-network inbound gate open for not-yet-authorized peers
@@ -1020,7 +1206,8 @@ export class CadreNode {
1020
1206
  /**
1021
1207
  * Record (or refresh) a delegate admission grant: `delegatePeerId` is the
1022
1208
  * transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
1023
- * at the CONNECTION level (all a circuit-relay reservation needs) for
1209
+ * at the CONNECTION and RESERVATION levels (all a circuit-relay reservation
1210
+ * needs, without spending the unauthorized-reservation budget) for
1024
1211
  * `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder after its
1025
1212
  * authorized-membership gate has passed; public so tests can drive the
1026
1213
  * admission policy without a full strand launch. A re-announce for the same
@@ -1123,13 +1310,19 @@ export class CadreNode {
1123
1310
  *
1124
1311
  * The SOLE caller is {@link drainMembershipGate}, which serializes refreshes —
1125
1312
  * so two reads can no longer settle out of order.
1313
+ *
1314
+ * Reads WITHOUT the transient-failure retry, deliberately: `ControlDatabase` drives
1315
+ * this listener with its write lock HELD (`notifyMembershipChanged`), so a retrying
1316
+ * read here would sleep its backoff holding the lock and stall every other local
1317
+ * writer. Nothing is lost — this refresh already keeps the previous snapshot on
1318
+ * failure and is re-driven by the next membership write and by the timed reconcile.
1126
1319
  */
1127
1320
  async refreshAuthorizedControlPeers(reason) {
1128
1321
  if (!this._running || !this.controlDatabase) {
1129
1322
  return;
1130
1323
  }
1131
1324
  try {
1132
- const members = await this.listAuthorizedMembers();
1325
+ const members = await this.listAuthorizedMembers(false);
1133
1326
  this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
1134
1327
  log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
1135
1328
  }
@@ -1411,6 +1604,20 @@ export class CadreNode {
1411
1604
  reconcile('self:peer:update');
1412
1605
  };
1413
1606
  this.controlNode.addEventListener('self:peer:update', this.selfPeerUpdateHandler);
1607
+ // NOTE: a node AUTHORIZED AFTER IT BOOTED can wait a whole heartbeat (7.5 min)
1608
+ // to publish its address. `registerSelf` no-ops while a node has no readable
1609
+ // `CadrePeer` row, so its boot-time publish does nothing, and the two triggers
1610
+ // wired here are the only ones after that: `self:peer:update` needs an ADDRESS
1611
+ // CHANGE, and a relay-only node's addresses stop changing the moment its
1612
+ // reservation lands — inside `start()`, before this listener exists. Harmless
1613
+ // where a late-authorized node's addresses keep churning (NAT/relay rotation
1614
+ // fire the event anyway), and invisible while parties authorize members before
1615
+ // they boot. If late enrollment becomes the normal path — an invited phone is
1616
+ // exactly that — republish on the membership-change seam that already exists
1617
+ // (`ControlDatabase.setMembershipChangeListener`) rather than lengthening this
1618
+ // list of triggers. Measured while writing
1619
+ // `relay-only-control-addr.integration.ts` case 4, which drives `registerSelf()`
1620
+ // explicitly because neither trigger is dependable there.
1414
1621
  this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
1415
1622
  this.recordRefreshTimer?.unref?.();
1416
1623
  const reconcileMs = this.config.network?.controlCohort?.reconcileMs ?? DEFAULT_CONTROL_COHORT_RECONCILE_MS;
@@ -1485,6 +1692,10 @@ export class CadreNode {
1485
1692
  * Survivors are returned signaling (`/p2p-circuit`) first, filtered to
1486
1693
  * signaling-only when requested, as parsed `Multiaddr`s (unparsable addrs
1487
1694
  * dropped). Any gate failure yields an empty array rather than throwing.
1695
+ *
1696
+ * Every returned address is normalized to terminate in `/p2p/<peerId>` — see
1697
+ * {@link normalizeDialAddrs} for why that invariant, not each dial site's own
1698
+ * handling, is what keeps a mixed list from taking a whole peer offline.
1488
1699
  */
1489
1700
  async resolvePeerAddrs(peerId, opts = {}) {
1490
1701
  if (!this.controlDatabase) {
@@ -1529,12 +1740,71 @@ export class CadreNode {
1529
1740
  }
1530
1741
  // Order signaling-first (the on-record order was what we verified above),
1531
1742
  // optionally restrict to signaling addrs, then parse — dropping any addr
1532
- // that does not parse as a multiaddr.
1743
+ // that does not parse as a multiaddr — and finally normalize every survivor
1744
+ // onto `/p2p/<peerId>` so no caller ever sees a mixed list.
1533
1745
  let addrs = orderSignalingFirst(record.addrs);
1534
1746
  if (opts.signalingOnly) {
1535
1747
  addrs = addrs.filter(isSignalingAddr);
1536
1748
  }
1537
- return this.parseMultiaddrs(addrs);
1749
+ return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
1750
+ }
1751
+ /**
1752
+ * Guarantee every address handed to a control-network dial terminates in
1753
+ * `/p2p/<peerId>`, with no duplicates — the invariant every dial path
1754
+ * downstream relies on.
1755
+ *
1756
+ * `libp2p.dial(addrs)` requires that the addresses in ONE dial either all name
1757
+ * a peer id or none do; a list mixing a circuit address (which ends in
1758
+ * `/p2p/<target>`) with a bare direct one (which ends in `/tcp/…`) makes that
1759
+ * call throw, and the whole peer is then silently skipped rather than one bad
1760
+ * address.
1761
+ *
1762
+ * Applied by ALL THREE sources of control-dial candidates, so no call site
1763
+ * invents its own rule and none can produce a mixed list: the signed record
1764
+ * ({@link resolvePeerAddrs}), the libp2p address book
1765
+ * ({@link peerStoreAddrs}), and a seed's retained owner addresses
1766
+ * ({@link bootstrapDialAddrs}).
1767
+ *
1768
+ * An address naming a DIFFERENT trailing peer id is dropped: it does not reach
1769
+ * `peerId`, so it does not belong in this peer's candidate list (libp2p's own
1770
+ * dial queue filters the same shape out one layer lower).
1771
+ */
1772
+ normalizeDialAddrs(addrs, peerId) {
1773
+ // Keyed by the NORMALIZED string, so two entries differing only by the suffix
1774
+ // (`/ip4/…/tcp/4001` and `/ip4/…/tcp/4001/p2p/<peerId>`) collapse into one.
1775
+ // Normalization is what makes them equal; a surviving duplicate would cost a
1776
+ // real dial attempt — a whole slice of `dialWake`'s budget — on an address
1777
+ // already being tried.
1778
+ const out = new Map();
1779
+ for (const addr of addrs) {
1780
+ const bound = this.bindAddrToPeer(addr, peerId);
1781
+ if (bound) {
1782
+ out.set(bound.toString(), bound);
1783
+ }
1784
+ }
1785
+ return [...out.values()];
1786
+ }
1787
+ /**
1788
+ * One address bound to `peerId`, or `null` when it cannot be — logged either
1789
+ * way, never thrown, so every caller's list-shaping stays total.
1790
+ *
1791
+ * `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
1792
+ * that does not parse. Unreachable for a `CadrePeer` row (the binding gate
1793
+ * above parsed it already), reachable for a seed-supplied one
1794
+ * ({@link bootstrapDialAddrs}).
1795
+ */
1796
+ bindAddrToPeer(addr, peerId) {
1797
+ try {
1798
+ const bound = withTrailingPeerId(addr, peerId);
1799
+ if (!bound) {
1800
+ log('normalizeDialAddrs: dropping addr %s — it names a peer other than %s', addr.toString(), peerId);
1801
+ }
1802
+ return bound;
1803
+ }
1804
+ catch (error) {
1805
+ log('normalizeDialAddrs: cannot bind %s to %s: %o', addr.toString(), peerId, error);
1806
+ return null;
1807
+ }
1538
1808
  }
1539
1809
  /** Parse multiaddr strings, dropping (and logging) any that fail to parse. */
1540
1810
  parseMultiaddrs(addrs) {
@@ -1851,6 +2121,16 @@ export class CadreNode {
1851
2121
  * Cold-start fallback: the libp2p peerStore multiaddrs for `peerId` (seeded by
1852
2122
  * {@link SeedBootstrapService.applySeed}). Returns `[]` on a missing entry or any
1853
2123
  * parse/lookup failure — never throws.
2124
+ *
2125
+ * Normalized through {@link normalizeDialAddrs} for the same reason
2126
+ * {@link resolvePeerAddrs} is, and with more cause: the address book hands back
2127
+ * a list that is inherently MIXED. `@libp2p/peer-store`'s
2128
+ * `dedupeFilterAndSortAddresses` strips a trailing `/p2p/<peerId>` only when
2129
+ * that id is the address's FIRST `/p2p/` component, so a direct address
2130
+ * round-trips bare while a relayed one — whose first `/p2p/` names the relay —
2131
+ * keeps its suffix (the same asymmetry `peer-addr-book.ts`'s `addrKey` exists
2132
+ * to absorb). Feeding both to one `dial()` is exactly the
2133
+ * `InvalidParametersError` that skipped the whole peer.
1854
2134
  */
1855
2135
  async peerStoreAddrs(peerId) {
1856
2136
  if (!this.controlNode) {
@@ -1860,7 +2140,7 @@ export class CadreNode {
1860
2140
  const peer = await this.controlNode.peerStore.get(peerIdFromString(peerId));
1861
2141
  // Re-parse through the top-level multiaddr parser so the returned type matches
1862
2142
  // resolvePeerAddrs (the peerStore bundles its own @multiformats/multiaddr copy).
1863
- return this.parseMultiaddrs(peer.addresses.map((a) => a.multiaddr.toString()));
2143
+ return this.normalizeDialAddrs(this.parseMultiaddrs(peer.addresses.map((a) => a.multiaddr.toString())), peerId);
1864
2144
  }
1865
2145
  catch (error) {
1866
2146
  log('reconcileControlCohort: peerStore lookup for %s failed: %o', peerId, error);
@@ -1949,6 +2229,13 @@ export class CadreNode {
1949
2229
  if (!this._running || !this.controlNode) {
1950
2230
  return;
1951
2231
  }
2232
+ // NOTE: a connection this node still holds in `status: 'open'` after the remote
2233
+ // has already aborted it counts as connected here, so the cold-start retry stays
2234
+ // suppressed for that peer until the connection monitor's next ping notices the
2235
+ // abort — measured at ~9 s in `control-cohort-cold-start-retry.integration.ts`.
2236
+ // Bounded and self-healing today; it would start to matter if the connection
2237
+ // monitor's ping interval were lengthened, or if recovery ever had to meet a
2238
+ // deadline tighter than one ping interval.
1952
2239
  if (connected.has(peerId)) {
1953
2240
  continue;
1954
2241
  }
@@ -1962,32 +2249,15 @@ export class CadreNode {
1962
2249
  * Bind a bootstrap peer's seed addresses to its peer id, so the dial
1963
2250
  * authenticates the peer it is aiming at rather than trusting whoever answers.
1964
2251
  *
1965
- * An address that already carries `/p2p/<id>` must carry THIS peer's id or it
1966
- * is dropped (a seed that disagrees with itself is not a dial target); one
1967
- * that carries none — an `addressOverrides`-published record, say — gets the
1968
- * id encapsulated. Unencapsulatable addresses are dropped rather than dialed
1969
- * bare.
2252
+ * The same {@link normalizeDialAddrs} rule the resolved and address-book paths
2253
+ * use, on the third and last source of control-dial candidates: an address that
2254
+ * already terminates in `/p2p/<id>` must carry THIS peer's id or it is dropped
2255
+ * (a seed that disagrees with itself is not a dial target); one that does not —
2256
+ * a bare listen addr, or a relay hop with the destination missing — gets the id
2257
+ * encapsulated; an unencapsulatable one is dropped rather than dialed bare.
1970
2258
  */
1971
2259
  bootstrapDialAddrs(peerId, addrs) {
1972
- const out = [];
1973
- for (const addr of this.parseMultiaddrs(addrs)) {
1974
- const embedded = trailingPeerId(addr);
1975
- if (embedded === peerId) {
1976
- out.push(addr);
1977
- continue;
1978
- }
1979
- if (embedded !== null) {
1980
- log('reconcileControlCohort(cold-start): addr %s names peer %s, not %s; dropping', addr.toString(), embedded, peerId);
1981
- continue;
1982
- }
1983
- try {
1984
- out.push(addr.encapsulate(`/p2p/${peerId}`));
1985
- }
1986
- catch (error) {
1987
- log('reconcileControlCohort(cold-start): cannot bind %s to %s: %o', addr.toString(), peerId, error);
1988
- }
1989
- }
1990
- return out;
2260
+ return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
1991
2261
  }
1992
2262
  /** Dial one cold-start bootstrap peer, best-effort. Returns whether it connected. */
1993
2263
  async dialBootstrapPeer(peerId, addrs) {
@@ -2705,7 +2975,30 @@ export class CadreNode {
2705
2975
  });
2706
2976
  }
2707
2977
  }
2978
+ /**
2979
+ * Return this node to its pre-{@link start} state. The ONE teardown — `stop()` and
2980
+ * a failed `start()` both come here, so anything a stopped node must not still be
2981
+ * doing belongs in this method and not in `stop()`. `stop()` adds only the
2982
+ * `control:disconnected` emit, which a never-connected start has no edge for.
2983
+ */
2708
2984
  async cleanup() {
2985
+ // Open the connection gate's bring-up quiet period unconditionally: teardown
2986
+ // must never be gated, and this is the path a FAILED start() takes.
2987
+ this.controlBringUpInFlight = false;
2988
+ // Stop the relay retry loop BEFORE tearing the node down, not after: the control
2989
+ // node is stopped partway through below, and a tick that fires during it would
2990
+ // dial and then poll a half-torn-down node for the whole reserve timeout —
2991
+ // logging spurious dial failures on a `.unref()`'d timer that nothing ever
2992
+ // clears. `driveControlRelayReservation` starts this supervisor from inside
2993
+ // `start()`, so a start that fails ON the reservation leaves one running unless
2994
+ // it is stopped here.
2995
+ this.relayReserveSupervisor?.stop();
2996
+ this.relayReserveSupervisor = null;
2997
+ // Drop the rest of the relay-reservation posture: a torn-down node holds no
2998
+ // reservation, so neither a restarted instance nor a caller inspecting a failed
2999
+ // start reports the previous attempt's `reserved`.
3000
+ this.relayReserveAddrs = [];
3001
+ this.relayReserveError = null;
2709
3002
  // Resolve + clear any in-flight wake windows first, so an in-flight check-in
2710
3003
  // or serviceWake unblocks and tears down cleanly rather than firing a stale
2711
3004
  // window timer (or hanging) after the strand manager is stopped below.
@@ -2773,16 +3066,23 @@ export class CadreNode {
2773
3066
  await this.controlNode.stop();
2774
3067
  this.controlNode = null;
2775
3068
  }
2776
- // Release the control store's cache registration LAST — after the database and
2777
- // node that write through it are down. Dropping the field is what lets a
2778
- // stop()/start() cycle re-resolve the provider against a live cache instead of
2779
- // handing the restarted node a retired wrapper. Logged, never thrown: a
2780
- // cache-bookkeeping failure must not abort a teardown.
3069
+ // Release the control store's claim on its cache LAST — after the database and
3070
+ // node that write through it are down. The wrapper counts holders, so this empties
3071
+ // and unregisters the cache only if no other scope still holds it. Dropping the
3072
+ // field is what lets a stop()/start() cycle re-resolve the provider against a live
3073
+ // cache instead of handing the restarted node a retired wrapper. Logged, never
3074
+ // thrown: a cache-bookkeeping failure must not abort a teardown.
2781
3075
  if (this.controlStorage) {
2782
3076
  const controlStorage = this.controlStorage;
2783
3077
  this.controlStorage = null;
2784
3078
  await disposeStorageCache(controlStorage).catch((err) => log('Failed to dispose control storage cache: %o', err));
2785
3079
  }
3080
+ // LAST, so every step above still ran against a node that considered itself up
3081
+ // (several of them early-return while `_running` is false). A failed `start()`
3082
+ // that had already flipped it — the reservation drive throws after that point —
3083
+ // must not leave `isRunning` true over a torn-down node, which would report
3084
+ // healthy and make the embedder's retry a no-op ("already running").
3085
+ this._running = false;
2786
3086
  }
2787
3087
  // Hibernation callbacks
2788
3088
  async handleStrandIdle(strandId) {
@@ -3406,11 +3706,25 @@ export class CadreNode {
3406
3706
  }
3407
3707
  /**
3408
3708
  * The circuit relays this node's strand nodes would reserve through: the
3409
- * union of the RESOLVED listen addrs' circuits — a hand-written
3410
- * `network.listenAddrs` circuit entry or a `network.relayAddrs` entry, which
3411
- * the strand node inherits verbatim either way — and the control node's own
3412
- * live `/p2p-circuit` multiaddrs (a reservation this node discovered rather
3413
- * than configured).
3709
+ * union of CONFIGURED relays — `network.relayAddrs`, plus any hand-written
3710
+ * `network.listenAddrs` circuit entry, both of which a strand node inherits
3711
+ * verbatim — and the control node's own live `/p2p-circuit` multiaddrs (a
3712
+ * reservation this node discovered rather than configured).
3713
+ *
3714
+ * The CONFIGURED half reads `network.relayAddrs` and `network.listenAddrs`
3715
+ * directly rather than going through {@link resolveListenAddrs}: the control
3716
+ * node's resolution takes the `'search'` route, whose bare `/p2p-circuit` entry
3717
+ * names no relay, so a configured relay would be unannounceable here until this
3718
+ * node's own reservation landed — and the announce must be in place BEFORE a
3719
+ * strand node dials its reservation, since the relay's membership gate does not
3720
+ * know the strand's derived peerId (see delegate-admission.ts). Configuration is
3721
+ * authoritative; the live multiaddrs only ADD relays nobody configured.
3722
+ *
3723
+ * The `listenAddrs` half is now unreachable on a control node — `relay-addrs.ts`
3724
+ * rejects a hand-written `<relay>/p2p-circuit` listen entry outright, because the
3725
+ * bring-up quiet period denies the dial that listener makes from inside
3726
+ * `libp2p.start()`. Kept because it costs one spread and this is the one place
3727
+ * that would silently stop announcing if that rejection is ever relaxed.
3414
3728
  *
3415
3729
  * NOTE: a relay the STRAND node discovers on its own (autorelay — in neither
3416
3730
  * source) gets no announcement, and a membership-gated one will deny it; fine
@@ -3418,7 +3732,8 @@ export class CadreNode {
3418
3732
  */
3419
3733
  circuitRelayTargets() {
3420
3734
  return extractCircuitRelayTargets([
3421
- ...(resolveListenAddrs(this.config.network) ?? []),
3735
+ ...relayCircuitAddrs(this.config.network?.relayAddrs ?? []),
3736
+ ...(this.config.network?.listenAddrs ?? []),
3422
3737
  ...(this.controlNode?.getMultiaddrs().map(String) ?? [])
3423
3738
  ]);
3424
3739
  }
@@ -3869,16 +4184,60 @@ export class CadreNode {
3869
4184
  getControlNode() {
3870
4185
  return this.controlNode;
3871
4186
  }
4187
+ /**
4188
+ * The boot-path half of {@link reserveRelays}: reserve through every
4189
+ * `network.relayAddrs` entry, or fail `start()`.
4190
+ *
4191
+ * WHY IT RUNS HERE, AT THE END. `network.relayAddrs` used to resolve to a
4192
+ * CONFIGURED circuit listener, which libp2p dials from inside `libp2p.start()`
4193
+ * — so the relay was already a connected peer, and therefore already in this
4194
+ * node's Optimystic cohort, before `ControlDatabase.initialize()` ran. Building
4195
+ * that database is a long chain of cohort-consulting block probes (catalog
4196
+ * hydration, then one per table, then the indexes), and a sibling that has not
4197
+ * yet replicated this node's `CadrePeer` row correctly refuses every one of
4198
+ * them, so bring-up died on `BlockUnavailableError` — deterministically, on
4199
+ * every boot of a relay-only node. Retrying could not converge either: the
4200
+ * condition that clears the refusal is this node's own row reaching the
4201
+ * sibling, and writing that row needs the database the retry is building.
4202
+ *
4203
+ * So the invariant is ordering, not retrying: the control database is built
4204
+ * while this node holds ZERO control connections (a cohort of one, entirely
4205
+ * local), and only then does it reach out. Everything above in {@link start}
4206
+ * has completed by the time this runs.
4207
+ *
4208
+ * BUDGET: the supervisor's first attempt — `relay-reservation.ts`'s
4209
+ * `DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, 10 s — is what `start()` waits on — deliberately the module default rather
4210
+ * than a boot-specific one. A healthy dial-plus-reserve is sub-second even over
4211
+ * a WAN, so 10 s is slack for a slow link; going much longer would make a dead
4212
+ * relay indistinguishable from a hung start, and much shorter would fail nodes
4213
+ * on links that were merely slow. The retries carry on in the background after
4214
+ * this resolves, exactly as they do for a {@link reserveRelays} caller.
4215
+ */
4216
+ async driveControlRelayReservation() {
4217
+ const relayAddrs = this.config.network?.relayAddrs ?? [];
4218
+ if (relayAddrs.length === 0) {
4219
+ return;
4220
+ }
4221
+ const t0 = performance.now();
4222
+ const state = await this.reserveRelays([...relayAddrs]);
4223
+ timing('[start] reserveRelays: %dms', Math.round(performance.now() - t0));
4224
+ if (state.status !== 'reserved') {
4225
+ throw new RelayReservationFailedError(relayAddrs, state);
4226
+ }
4227
+ log('Reserved a relay slot: %o', state.circuitAddrs);
4228
+ }
3872
4229
  /**
3873
4230
  * Start keeping a relay reservation: dial the given relay(s) from the control
3874
4231
  * node, ask the first one that answers for a reservation slot, wait until the
3875
4232
  * resulting `/p2p-circuit` address makes this node dialable — and keep a
3876
4233
  * supervisor running that re-drives whenever the reservation is later lost.
3877
4234
  *
3878
- * For nodes that listen on the bare `/p2p-circuit` SEARCH addr (a browser tab)
3879
- * rather than a configured `network.relayAddrs` circuit listener — see
3880
- * `relay-reservation.ts` for why the two are alternatives and why configuring
3881
- * both is a regression. Opt-in: nothing calls this for the CLI/host nodes.
4235
+ * The FAIL-SOFT entry point, for a caller that discovers its relay at runtime
4236
+ * (a browser tab, a host UI). A node that names `network.relayAddrs` gets the
4237
+ * same drive from {@link driveControlRelayReservation} at the end of
4238
+ * {@link start}, which is fail-fast instead. Both fill the pending reservation
4239
+ * the bare `/p2p-circuit` search listener registered; calling this afterwards
4240
+ * simply replaces the supervisor with one over the new list.
3882
4241
  *
3883
4242
  * Resolves once the FIRST attempt has settled, exactly as it did when it drove
3884
4243
  * once; the retries continue in the background until {@link stop} or the next
@@ -4011,6 +4370,10 @@ export class CadreNode {
4011
4370
  * Push the multiaddrs that future invites should advertise. Pass `null` to
4012
4371
  * revert to the libp2p-reported addresses (the default). The host calls this
4013
4372
  * at spawn and on every NAT change.
4373
+ *
4374
+ * Entries need NOT carry a `/p2p/<peerId>` suffix — {@link resolveInviteAddresses}
4375
+ * appends this node's own before anything publishes or dials them (see
4376
+ * {@link normalizeSelfAddrs}). Passing them suffixed is equally fine.
4014
4377
  */
4015
4378
  setInviteAddresses(addresses) {
4016
4379
  this.latestInviteAddresses = addresses;
@@ -4018,17 +4381,59 @@ export class CadreNode {
4018
4381
  }
4019
4382
  /**
4020
4383
  * Resolve the addresses to embed in invites. Prefers pushed addresses, then
4021
- * any config-supplied resolver, then the libp2p-observed multiaddrs.
4384
+ * any config-supplied resolver, then the libp2p-observed multiaddrs. The two
4385
+ * app-supplied sources are normalized onto `/p2p/<self>` (see
4386
+ * {@link normalizeSelfAddrs}) so neither hook has to remember the suffix;
4387
+ * libp2p's own addresses already carry it.
4022
4388
  */
4023
4389
  async resolveInviteAddresses() {
4024
4390
  if (this.latestInviteAddresses !== null) {
4025
- return this.latestInviteAddresses;
4391
+ return this.normalizeSelfAddrs(this.latestInviteAddresses);
4026
4392
  }
4027
4393
  if (this.config.network?.inviteAddressResolver) {
4028
- return this.config.network.inviteAddressResolver();
4394
+ return this.normalizeSelfAddrs(await this.config.network.inviteAddressResolver());
4029
4395
  }
4396
+ // Not normalized: this branch is libp2p's own output, which already
4397
+ // encapsulates the peer id into every address it reports. Only the two app
4398
+ // hooks above take arbitrary strings and therefore need the guarantee.
4030
4399
  return this.getMultiaddrs();
4031
4400
  }
4401
+ /**
4402
+ * Normalize addresses an app handed us for THIS node onto `/p2p/<self>`, the
4403
+ * same invariant {@link normalizeDialAddrs} enforces on the way out.
4404
+ *
4405
+ * `setInviteAddresses` (the admin API `PUT /admin/invite-addresses`) and
4406
+ * `network.inviteAddressResolver` both take arbitrary strings, and whatever
4407
+ * they return ends up in this node's published `CadrePeer` record — so ONE
4408
+ * unsuffixed entry from either hook is enough to give every sibling in the
4409
+ * party a mixed candidate list for this peer. Establishing the suffix here,
4410
+ * where those strings enter the system, means neither hook has to know the
4411
+ * rule; `cadre-host`'s `buildInviteAddresses` already appends it, and
4412
+ * re-normalizing an already-suffixed address is a no-op.
4413
+ *
4414
+ * Unparsable and other-peer-addressed entries are passed through untouched
4415
+ * rather than dropped: publication is not the place to police an address's
4416
+ * validity, and {@link resolvePeerAddrs} already drops both on the read side.
4417
+ * Before `start()` resolves an identity there is no peer id to append, so the
4418
+ * list is returned as given.
4419
+ */
4420
+ normalizeSelfAddrs(addrs) {
4421
+ const selfPeerId = this.peerId?.toString();
4422
+ if (!selfPeerId) {
4423
+ return addrs;
4424
+ }
4425
+ return addrs.map((addr) => {
4426
+ let parsed;
4427
+ try {
4428
+ parsed = multiaddr(addr);
4429
+ }
4430
+ catch (error) {
4431
+ log('normalizeSelfAddrs: leaving unparsable multiaddr %s as-is: %o', addr, error);
4432
+ return addr;
4433
+ }
4434
+ return withTrailingPeerId(parsed, selfPeerId)?.toString() ?? addr;
4435
+ });
4436
+ }
4032
4437
  /**
4033
4438
  * Enumerate the cadre's `CadrePeer` membership — the ADDRESSABLE surface (see
4034
4439
  * {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
@@ -4090,13 +4495,18 @@ export class CadreNode {
4090
4495
  * legit member goes un-authorized on readers that only pin the new key. Full
4091
4496
  * rotation handling (re-vouch on rotate) is the
4092
4497
  * `flip-strand-membership-rotation-known-gap` work, not this predicate's.
4498
+ *
4499
+ * @param retry - Whether the underlying membership read may retry a transient cluster
4500
+ * failure. Only {@link refreshAuthorizedControlPeers} passes `false`, because it runs
4501
+ * as the control database's membership listener with that database's write lock held;
4502
+ * see its comment.
4093
4503
  */
4094
- async listAuthorizedMembers() {
4504
+ async listAuthorizedMembers(retry = true) {
4095
4505
  if (!this.controlDatabase) {
4096
4506
  throw new Error('CadreNode must be started before listing members');
4097
4507
  }
4098
4508
  const selfPeerId = this.peerId?.toString();
4099
- const rows = await this.controlDatabase.queryCadrePeers();
4509
+ const rows = await this.controlDatabase.queryCadrePeers(retry);
4100
4510
  const authorized = rows
4101
4511
  .filter(row => row.peerId !== selfPeerId && this.hasAnchoredVoucher(row))
4102
4512
  .map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));