@serfab/cadre-core 0.9.0 → 0.10.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 (162) hide show
  1. package/README.md +4 -2
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +918 -53
  9. package/dist/cadre-node.js +1843 -190
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-database.d.ts +990 -50
  15. package/dist/control-database.js +1459 -108
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -10
  18. package/dist/control-formation-recorder.js +129 -14
  19. package/dist/control-formation-recorder.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +774 -210
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +38 -4
  24. package/dist/control-stream.js +70 -4
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +151 -0
  27. package/dist/control-write-retry.js +411 -0
  28. package/dist/control-write-retry.js.map +1 -0
  29. package/dist/delegate-admission.d.ts +134 -0
  30. package/dist/delegate-admission.js +244 -0
  31. package/dist/delegate-admission.js.map +1 -0
  32. package/dist/device-token.d.ts +11 -11
  33. package/dist/device-token.js +14 -13
  34. package/dist/device-token.js.map +1 -1
  35. package/dist/ed25519-key.d.ts +26 -0
  36. package/dist/ed25519-key.js +56 -1
  37. package/dist/ed25519-key.js.map +1 -1
  38. package/dist/file-durable-slot.d.ts +20 -0
  39. package/dist/file-durable-slot.js +53 -0
  40. package/dist/file-durable-slot.js.map +1 -0
  41. package/dist/formation-approval.d.ts +157 -0
  42. package/dist/formation-approval.js +375 -0
  43. package/dist/formation-approval.js.map +1 -0
  44. package/dist/fs-atomic.d.ts +38 -0
  45. package/dist/fs-atomic.js +97 -0
  46. package/dist/fs-atomic.js.map +1 -0
  47. package/dist/identity-key.d.ts +49 -0
  48. package/dist/identity-key.js +81 -0
  49. package/dist/identity-key.js.map +1 -0
  50. package/dist/index.d.ts +16 -6
  51. package/dist/index.js +39 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/key-store-file.d.ts +2 -24
  54. package/dist/key-store-file.js +10 -77
  55. package/dist/key-store-file.js.map +1 -1
  56. package/dist/membership-connection-gater.d.ts +146 -0
  57. package/dist/membership-connection-gater.js +180 -0
  58. package/dist/membership-connection-gater.js.map +1 -0
  59. package/dist/node-local-snapshot.d.ts +109 -0
  60. package/dist/node-local-snapshot.js +182 -0
  61. package/dist/node-local-snapshot.js.map +1 -0
  62. package/dist/peer-authorization.d.ts +135 -18
  63. package/dist/peer-authorization.js +152 -21
  64. package/dist/peer-authorization.js.map +1 -1
  65. package/dist/peer-record.d.ts +21 -8
  66. package/dist/peer-record.js +26 -10
  67. package/dist/peer-record.js.map +1 -1
  68. package/dist/relay-addrs.d.ts +45 -0
  69. package/dist/relay-addrs.js +103 -0
  70. package/dist/relay-addrs.js.map +1 -0
  71. package/dist/relay-reservation.d.ts +266 -0
  72. package/dist/relay-reservation.js +613 -0
  73. package/dist/relay-reservation.js.map +1 -0
  74. package/dist/seed-bootstrap.d.ts +221 -56
  75. package/dist/seed-bootstrap.js +328 -188
  76. package/dist/seed-bootstrap.js.map +1 -1
  77. package/dist/seed-trust-policy.d.ts +47 -21
  78. package/dist/seed-trust-policy.js +24 -15
  79. package/dist/seed-trust-policy.js.map +1 -1
  80. package/dist/strand-addr-protocol.d.ts +42 -13
  81. package/dist/strand-addr-protocol.js +58 -52
  82. package/dist/strand-addr-protocol.js.map +1 -1
  83. package/dist/strand-backfill.d.ts +109 -0
  84. package/dist/strand-backfill.js +279 -0
  85. package/dist/strand-backfill.js.map +1 -0
  86. package/dist/strand-formation-manager.d.ts +38 -9
  87. package/dist/strand-formation-manager.js +120 -17
  88. package/dist/strand-formation-manager.js.map +1 -1
  89. package/dist/strand-formation-protocol.d.ts +103 -4
  90. package/dist/strand-formation-protocol.js +232 -4
  91. package/dist/strand-formation-protocol.js.map +1 -1
  92. package/dist/strand-instance-manager.d.ts +39 -1
  93. package/dist/strand-instance-manager.js +92 -14
  94. package/dist/strand-instance-manager.js.map +1 -1
  95. package/dist/strand-member-registry.d.ts +5 -5
  96. package/dist/strand-member-registry.js +18 -10
  97. package/dist/strand-member-registry.js.map +1 -1
  98. package/dist/strand-membership-writer.d.ts +482 -88
  99. package/dist/strand-membership-writer.js +757 -136
  100. package/dist/strand-membership-writer.js.map +1 -1
  101. package/dist/strand-solicitation.d.ts +99 -33
  102. package/dist/strand-solicitation.js +100 -52
  103. package/dist/strand-solicitation.js.map +1 -1
  104. package/dist/strand-transport-key.d.ts +40 -0
  105. package/dist/strand-transport-key.js +56 -0
  106. package/dist/strand-transport-key.js.map +1 -0
  107. package/dist/strand-wake-protocol.d.ts +9 -7
  108. package/dist/strand-wake-protocol.js +22 -46
  109. package/dist/strand-wake-protocol.js.map +1 -1
  110. package/dist/strand-watcher.d.ts +17 -2
  111. package/dist/strand-watcher.js +47 -3
  112. package/dist/strand-watcher.js.map +1 -1
  113. package/dist/trusted-owner-store-file.d.ts +12 -0
  114. package/dist/trusted-owner-store-file.js +39 -0
  115. package/dist/trusted-owner-store-file.js.map +1 -0
  116. package/dist/trusted-owner-store.d.ts +64 -0
  117. package/dist/trusted-owner-store.js +126 -0
  118. package/dist/trusted-owner-store.js.map +1 -0
  119. package/dist/types.d.ts +275 -20
  120. package/dist/types.js +13 -0
  121. package/dist/types.js.map +1 -1
  122. package/package.json +19 -8
  123. package/src/bootstrap-peer-store-file.ts +44 -0
  124. package/src/bootstrap-peer-store.ts +172 -0
  125. package/src/cadre-node.ts +1992 -202
  126. package/src/control-authorization.ts +114 -0
  127. package/src/control-database.ts +1704 -146
  128. package/src/control-formation-recorder.ts +172 -19
  129. package/src/control-schema.ts +786 -222
  130. package/src/control-stream.ts +81 -4
  131. package/src/control-write-retry.ts +473 -0
  132. package/src/delegate-admission.ts +302 -0
  133. package/src/device-token.ts +18 -13
  134. package/src/ed25519-key.ts +134 -69
  135. package/src/file-durable-slot.ts +61 -0
  136. package/src/formation-approval.ts +604 -0
  137. package/src/fs-atomic.ts +107 -0
  138. package/src/identity-key.ts +109 -0
  139. package/src/index.ts +136 -5
  140. package/src/key-store-file.ts +10 -81
  141. package/src/membership-connection-gater.ts +204 -0
  142. package/src/node-local-snapshot.ts +260 -0
  143. package/src/peer-authorization.ts +199 -21
  144. package/src/peer-record.ts +28 -10
  145. package/src/relay-addrs.ts +111 -0
  146. package/src/relay-reservation.ts +823 -0
  147. package/src/seed-bootstrap.ts +397 -197
  148. package/src/seed-trust-policy.ts +58 -25
  149. package/src/strand-addr-protocol.ts +84 -53
  150. package/src/strand-backfill.ts +366 -0
  151. package/src/strand-formation-manager.ts +145 -19
  152. package/src/strand-formation-protocol.ts +309 -8
  153. package/src/strand-instance-manager.ts +111 -14
  154. package/src/strand-member-registry.ts +18 -13
  155. package/src/strand-membership-writer.ts +1430 -640
  156. package/src/strand-solicitation.ts +155 -90
  157. package/src/strand-transport-key.ts +58 -0
  158. package/src/strand-wake-protocol.ts +29 -46
  159. package/src/strand-watcher.ts +66 -3
  160. package/src/trusted-owner-store-file.ts +41 -0
  161. package/src/trusted-owner-store.ts +171 -0
  162. package/src/types.ts +292 -21
@@ -8,25 +8,38 @@
8
8
  * The trust decision must therefore rest on an anchor that does not come from
9
9
  * the seed body:
10
10
  *
11
- * 1. DB-anchored — keys already in the receiver's `CadreControl.OwnerKey`
12
- * table (steady state: the node is enrolled / has synced control state).
13
- * 2. Pinned out-of-band — keys handed to the node from outside the seed
14
- * (carried by a `CadreInvite.ownerKeys`, or pinned by an operator).
11
+ * 1. Anchored — keys in the receiver's NODE-LOCAL `TrustedOwnerStore`: the
12
+ * non-replicated, per-party record of owner keys established out of band
13
+ * (founding the party, the `CadreInvite` that enrolled this node, an
14
+ * operator pin). Deliberately NOT the replicated `CadreControl.OwnerKey`
15
+ * table — any connecting node can genesis-insert its own key there and let
16
+ * it replicate, so that table can be made to say "yes" by a stranger.
17
+ * 2. Pinned out-of-band — keys handed to the node for this seed only (carried
18
+ * by a `CadreInvite.ownerKeys`, or pinned by an operator) without having
19
+ * been anchored yet.
15
20
  * 3. TOFU (opt-in) — an explicit confirmation callback invoked on first sight
16
21
  * of an unknown signer key. Interactive hosts only; off by default.
17
22
  *
18
- * Secure default (`dbAnchoredTrustPolicy`): a cold-start node with an empty
19
- * `OwnerKey` table and no pinned keys rejects the seed — a seed can no
20
- * longer vouch for its own signer.
23
+ * Secure default (`anchoredTrustPolicy`): a node with an empty anchor and no
24
+ * pinned keys rejects the seed — a seed can no longer vouch for its own signer,
25
+ * and neither can a polluted replicated table.
26
+ *
27
+ * A key accepted via anchor 2 or 3 is reported back through
28
+ * {@link SeedTrustDecision.anchorAs} so `applySeed` can persist it into the
29
+ * node-local anchor, and later seeds from the same owner are accepted without
30
+ * re-supplying the invite/confirmation.
21
31
  */
32
+ import type { TrustSource } from './trusted-owner-store.js';
33
+
22
34
  export interface SeedTrustContext {
23
35
  /** Party the seed claims to belong to. */
24
36
  partyId: string;
25
37
  /** ed25519 base64url signer key — already signature-verified by `applySeed`. */
26
38
  signerKey: string;
27
39
  /**
28
- * The receiver's known owner keys, sourced from its `OwnerKey`
29
- * table — NOT from the seed. Empty for a cold-start node.
40
+ * The receiver's anchored owner keys, sourced from its node-local
41
+ * `TrustedOwnerStore` — NOT from the seed, and NOT from the replicated
42
+ * `OwnerKey` table. Empty for a node whose anchor was never seeded.
30
43
  */
31
44
  knownOwnerKeys: ReadonlySet<string>;
32
45
  }
@@ -36,21 +49,30 @@ export interface SeedTrustDecision {
36
49
  trusted: boolean;
37
50
  /** Human-readable reason, surfaced as the seed-apply error when not trusted. */
38
51
  reason?: string;
52
+ /**
53
+ * Set when the key was trusted via an anchor OUTSIDE the node-local store (a
54
+ * pinned key, a TOFU confirmation): the provenance to record it under, so
55
+ * `applySeed` persists it and the next seed from that owner is anchored
56
+ * without re-supplying the pin. Omitted when the key was already anchored
57
+ * (nothing to persist) or not trusted at all.
58
+ */
59
+ anchorAs?: Exclude<TrustSource, 'genesis'>;
39
60
  }
40
61
 
41
62
  /**
42
63
  * Decides whether a signature-verified seed's signer key should be trusted.
43
- * May be synchronous (DB/pinned anchors) or asynchronous (TOFU confirmation).
64
+ * May be synchronous (anchored/pinned) or asynchronous (TOFU confirmation).
44
65
  */
45
66
  export interface SeedTrustPolicy {
46
67
  evaluate(ctx: SeedTrustContext): Promise<SeedTrustDecision> | SeedTrustDecision;
47
68
  }
48
69
 
49
70
  /**
50
- * Default policy: trust only keys already present in the receiver's
51
- * `OwnerKey` table. A cold-start node (empty table) rejects every seed.
71
+ * Default policy: trust only keys already in the receiver's node-local
72
+ * trusted-owner anchor. A node whose anchor was never seeded (no genesis, no
73
+ * invite pin, no operator pin) rejects every seed.
52
74
  */
53
- export function dbAnchoredTrustPolicy(): SeedTrustPolicy {
75
+ export function anchoredTrustPolicy(): SeedTrustPolicy {
54
76
  return {
55
77
  evaluate({ signerKey, knownOwnerKeys }) {
56
78
  if (knownOwnerKeys.has(signerKey)) {
@@ -58,37 +80,48 @@ export function dbAnchoredTrustPolicy(): SeedTrustPolicy {
58
80
  }
59
81
  return {
60
82
  trusted: false,
61
- reason: 'Signer key is not a known owner (DB-anchored trust policy)',
83
+ reason: 'Signer key is not an anchored owner (anchored trust policy)',
62
84
  };
63
85
  },
64
86
  };
65
87
  }
66
88
 
67
89
  /**
68
- * Cold-start policy: trust keys in the receiver's `OwnerKey` table plus a
69
- * set pinned out-of-band (typically `CadreInvite.ownerKeys` or operator
70
- * config). Lets an unenrolled invitee accept its first seed without the seed
71
- * vouching for itself.
90
+ * Cold-start policy: trust anchored keys plus a set pinned out-of-band
91
+ * (typically `CadreInvite.ownerKeys` or operator config). Lets an unenrolled
92
+ * invitee accept its first seed without the seed vouching for itself.
93
+ *
94
+ * @param anchorAs - provenance under which a pin-only acceptance is persisted
95
+ * into the node-local anchor ('invite' by default — the invite-redemption
96
+ * case; pass 'operator' for an operator-supplied pin).
72
97
  */
73
- export function pinnedKeyTrustPolicy(pinned: Iterable<string>): SeedTrustPolicy {
98
+ export function pinnedKeyTrustPolicy(
99
+ pinned: Iterable<string>,
100
+ anchorAs: Exclude<TrustSource, 'genesis'> = 'invite'
101
+ ): SeedTrustPolicy {
74
102
  const pinnedSet = new Set(pinned);
75
103
  return {
76
104
  evaluate({ signerKey, knownOwnerKeys }) {
77
- if (knownOwnerKeys.has(signerKey) || pinnedSet.has(signerKey)) {
105
+ if (knownOwnerKeys.has(signerKey)) {
78
106
  return { trusted: true };
79
107
  }
108
+ if (pinnedSet.has(signerKey)) {
109
+ return { trusted: true, anchorAs };
110
+ }
80
111
  return {
81
112
  trusted: false,
82
- reason: 'Signer key is neither a known nor a pinned owner (pinned-key trust policy)',
113
+ reason: 'Signer key is neither an anchored nor a pinned owner (pinned-key trust policy)',
83
114
  };
84
115
  },
85
116
  };
86
117
  }
87
118
 
88
119
  /**
89
- * Opt-in interactive policy: trust keys already in the `OwnerKey` table,
90
- * and on an unknown key invoke `confirm` (e.g. a trust-circle UI prompt). The
91
- * key is trusted iff `confirm` resolves true. Not enabled by default.
120
+ * Opt-in interactive policy: trust keys already in the node-local anchor, and
121
+ * on an unknown key invoke `confirm` (e.g. a trust-circle UI prompt). The key is
122
+ * trusted iff `confirm` resolves true, and a confirmed key is persisted into the
123
+ * anchor as an 'operator' pin (a human at the console is the same provenance as
124
+ * an explicit operator pin) so the prompt is not repeated. Not enabled by default.
92
125
  */
93
126
  export function tofuTrustPolicy(
94
127
  confirm: (ctx: SeedTrustContext) => Promise<boolean>
@@ -100,7 +133,7 @@ export function tofuTrustPolicy(
100
133
  }
101
134
  const accepted = await confirm(ctx);
102
135
  return accepted
103
- ? { trusted: true }
136
+ ? { trusted: true, anchorAs: 'operator' }
104
137
  : { trusted: false, reason: 'TOFU confirmation declined for unknown signer key' };
105
138
  },
106
139
  };
@@ -2,11 +2,13 @@
2
2
  * Control-network strand-address RPC.
3
3
  *
4
4
  * A strand runs as its own libp2p node (`strand-<id>`, random port), separate
5
- * from the control node (`control-<partyId>`) even though both share the same
6
- * peerId. To seed a strand's mesh, a node needs a sibling's **strand-network**
7
- * address — but `CadrePeer.Multiaddr` only stores the **control** node's
8
- * addresses. This protocol resolves the strand address on demand: a node asks
9
- * its connected co-cadre siblings "what are your live strand-`X` multiaddrs?" and
5
+ * from the control node (`control-<partyId>`), with its own transport peerId
6
+ * derived from the cadre identity key (`strand-transport-key.ts`). To seed a
7
+ * strand's mesh, a node needs a sibling's **strand-network** address — but
8
+ * `CadrePeer.Multiaddr` only stores the **control** node's addresses, and a
9
+ * control address now names a different peer entirely, not merely a different
10
+ * port. This protocol resolves the strand address on demand: a node asks its
11
+ * connected co-cadre siblings "what are your live strand-`X` multiaddrs?" and
10
12
  * uses the union of their answers as the seed.
11
13
  *
12
14
  * Modeled directly on `strand-wake-protocol.ts`: a dedicated libp2p protocol id,
@@ -16,13 +18,14 @@
16
18
  * one stream, so each side reads to EOF (under a read timeout) and decodes one
17
19
  * frame via the shared {@link decodeLengthPrefixedFrame} guard.
18
20
  *
19
- * **Authorization (v1):** the control network is single-party — only this party's
20
- * cadre peers connect (schema-gated `CadrePeer`). Like wake, the receiver gates
21
- * on cadre membership (via the injected `isMember`) and requires no further
22
- * signature; a non-member is refused with an empty address list. This is
23
- * defense-in-depth (the control mesh already restricts membership) kept for
24
- * parity with wake. Cross-party strand bootstrap is a different mechanism
25
- * (strand formation / `MemberPeer`) and is out of scope here.
21
+ * **Authorization (v1):** like wake, the receiver defers entirely to the injected
22
+ * `isMember` predicate and requires no further signature; a peer it rejects gets
23
+ * an empty address list. `CadreNode` injects its AUTHORIZED-membership predicate
24
+ * (`isAuthorizedMember`: voucher on the requester's `CadrePeer` row verified
25
+ * against the node-local trusted-owner anchor), so an outsider that published its
26
+ * own rows into the replicated control DB cannot harvest live strand addresses.
27
+ * Cross-party strand bootstrap is a different mechanism (strand formation /
28
+ * `MemberPeer`) and is out of scope here.
26
29
  */
27
30
 
28
31
  import debug from 'debug';
@@ -32,7 +35,7 @@ import { peerIdFromString } from '@libp2p/peer-id';
32
35
  import type { StrandAddrRequest, StrandAddrResponse } from './types.js';
33
36
  import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
34
37
  import { orderSignalingFirst } from './peer-record.js';
35
- import { type ControlStream, writeFrame, withTimeout, readStreamToEnd } from './control-stream.js';
38
+ import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
36
39
 
37
40
  const log = debug('sereus:cadre:strand-addr');
38
41
 
@@ -86,6 +89,16 @@ export interface StrandAddrServiceOptions {
86
89
  * decision logic is unit-testable without a full node.
87
90
  */
88
91
  getStrandMultiaddrs(strandId: string): string[];
92
+ /**
93
+ * Called when a member's request carries a `delegatePeerId` — the derived
94
+ * transport peerId its strand-`strandId` node runs as. Invoked only AFTER the
95
+ * `isMember` gate passed, with a validated (parseable, non-self) peerId.
96
+ * `CadreNode` injects its delegate-admission grant recorder so this node's
97
+ * connection gate — and thus its circuit-relay server, when it runs one —
98
+ * admits that peerId (see `delegate-admission.ts`). Optional: absent, an
99
+ * announce is ignored and the address lookup proceeds unchanged.
100
+ */
101
+ onDelegateAnnounce?(announcerPeerId: string, strandId: string, delegatePeerId: string): void;
89
102
  /**
90
103
  * Time to wait for the inbound request frame before aborting the read (ms).
91
104
  * Defaults to {@link DEFAULT_ADDR_READ_TIMEOUT_MS}. Bounds a buggy/compromised
@@ -209,22 +222,50 @@ export class StrandAddrService {
209
222
  * decision matrix can be unit-tested directly (mirrors wake's
210
223
  * `processWakeRequest`).
211
224
  *
212
- * - Non-member sender → empty `multiaddrs` (refused).
225
+ * - Non-member sender → empty `multiaddrs` (refused), no delegate grant.
226
+ * - Member request carrying a `delegatePeerId` → recorded via
227
+ * {@link StrandAddrServiceOptions.onDelegateAnnounce} before the lookup.
213
228
  * - Strand not running locally → empty `multiaddrs` (`getStrandMultiaddrs` → `[]`).
214
229
  * - Member + running strand → the strand's live, signaling-first multiaddrs.
215
230
  */
216
231
  async processAddrRequest(request: StrandAddrRequest, remotePeerId: string): Promise<StrandAddrResponse> {
217
232
  // Control-network membership is the v1 authorization: only this party's
218
- // cadre peers may ask us for a strand address.
233
+ // cadre peers may ask us for a strand address (or announce a delegate).
219
234
  if (!(await this.options.isMember(remotePeerId))) {
220
235
  log('Refusing strand-addr from non-member %s', remotePeerId);
221
236
  return emptyResponse(request.strandId);
222
237
  }
223
238
 
239
+ this.recordDelegateAnnounce(request, remotePeerId);
240
+
224
241
  const multiaddrs = this.options.getStrandMultiaddrs(request.strandId);
225
242
  log('Strand-addr for %s → %d addr(s)', request.strandId, multiaddrs.length);
226
243
  return { strandId: request.strandId, multiaddrs };
227
244
  }
245
+
246
+ /**
247
+ * Validate and forward a member's delegate announcement. A malformed field
248
+ * (unparsable peerId) and a self-announcement (`delegatePeerId` equal to the
249
+ * announcer's own peerId — a member is admitted directly, never by grant)
250
+ * are logged and dropped; the address lookup proceeds either way.
251
+ */
252
+ private recordDelegateAnnounce(request: StrandAddrRequest, remotePeerId: string): void {
253
+ const { strandId, delegatePeerId } = request;
254
+ if (delegatePeerId === undefined || !this.options.onDelegateAnnounce) {
255
+ return;
256
+ }
257
+ if (delegatePeerId === remotePeerId) {
258
+ log('Ignoring self-announcement from %s (strand %s)', remotePeerId, strandId);
259
+ return;
260
+ }
261
+ try {
262
+ peerIdFromString(delegatePeerId);
263
+ } catch (err) {
264
+ log('Ignoring malformed delegatePeerId %o from %s: %o', delegatePeerId, remotePeerId, err);
265
+ return;
266
+ }
267
+ this.options.onDelegateAnnounce(remotePeerId, strandId, delegatePeerId);
268
+ }
228
269
  }
229
270
 
230
271
  /** A sibling to ask for its strand address. */
@@ -245,6 +286,13 @@ export interface CollectStrandAddrsOptions {
245
286
  timeoutMs?: number;
246
287
  /** Override the protocol id (defaults to {@link STRAND_ADDR_PROTOCOL}). */
247
288
  protocolId?: string;
289
+ /**
290
+ * The derived transport peerId the caller's own strand-`strandId` node runs
291
+ * (or is about to run) as. Set on every request this collection sends, so
292
+ * each receiver records a delegate admission grant for it — see
293
+ * {@link StrandAddrRequest.delegatePeerId}. Omit for a pure address lookup.
294
+ */
295
+ delegatePeerId?: string;
248
296
  }
249
297
 
250
298
  /**
@@ -268,7 +316,9 @@ export async function collectStrandAddrs(
268
316
  options: CollectStrandAddrsOptions = {}
269
317
  ): Promise<string[]> {
270
318
  const selfId = node.peerId.toString();
271
- const request: StrandAddrRequest = { strandId };
319
+ const request: StrandAddrRequest = options.delegatePeerId !== undefined
320
+ ? { strandId, delegatePeerId: options.delegatePeerId }
321
+ : { strandId };
272
322
  const candidates = peers.filter(p => p.peerId !== selfId);
273
323
 
274
324
  // Concurrent dials; `dialOneSibling` swallows per-peer failure into `[]`, so
@@ -314,16 +364,13 @@ async function dialOneSibling(
314
364
  }
315
365
 
316
366
  for (const target of targets) {
317
- // One controller per attempt: on timeout, `withTimeout`'s `onTimeout` aborts
318
- // it, which aborts the in-flight dialProtocol (via the dial `signal`) and the
319
- // live stream — so neither the connect nor the unbounded response-read leaks.
320
- const controller = new AbortController();
321
367
  try {
322
- const response = await withTimeout(
368
+ // One deadline per attempt: its signal aborts the in-flight dialProtocol and
369
+ // resets the live stream, so neither the connect nor the response-read leaks.
370
+ const response = await withDeadline(
323
371
  timeoutMs,
324
372
  `Strand-addr dial ${peer.peerId}`,
325
- () => sendStrandAddr(node, target, protocolId, request, timeoutMs, controller.signal),
326
- () => controller.abort(),
373
+ (signal) => sendStrandAddr(node, target, protocolId, request, timeoutMs, signal),
327
374
  );
328
375
  return response.multiaddrs;
329
376
  } catch (err) {
@@ -361,12 +408,10 @@ function describeTarget(target: PeerId | Multiaddr): string {
361
408
  * Open one stream to a target, send the request, half-close, and read the
362
409
  * response.
363
410
  *
364
- * `signal` is the per-attempt abort signal from {@link dialOneSibling}: it is
365
- * passed to `dialProtocol` so a timeout during connect aborts the dial, and once
366
- * the stream is open an abort listener resets the live stream — releasing the
367
- * otherwise unbounded response-read. `readFrame` still applies `timeoutMs` as a
368
- * backstop for the case where an abort does not propagate (e.g. a stream double
369
- * whose `abort()` is a no-op).
411
+ * `signal` is the per-attempt deadline from {@link dialOneSibling}: it goes to
412
+ * `dialProtocol` so a timeout during connect aborts the dial, and into
413
+ * {@link exchangeFrame} so a timeout after the stream is open resets it —
414
+ * releasing the otherwise unbounded response-read.
370
415
  */
371
416
  async function sendStrandAddr(
372
417
  node: Libp2p,
@@ -382,29 +427,15 @@ async function sendStrandAddr(
382
427
  // relay's data/duration cap, so opening it on the limited connection is safe
383
428
  // and is the whole point of dialing over the relay.
384
429
  const rawStream = await node.dialProtocol(target, protocolId, { runOnLimitedConnection: true, signal });
385
- const stream = rawStream as unknown as ControlStream;
386
-
387
- const abortErr = new Error('Strand-addr dial aborted by timeout');
388
- const onAbort = (): void => stream.abort(abortErr);
389
- // If the timeout already fired during connect, release the freshly-opened stream now.
390
- if (signal.aborted) {
391
- onAbort();
392
- throw abortErr;
393
- }
394
- signal.addEventListener('abort', onAbort, { once: true });
395
430
 
396
- try {
397
- writeFrame(stream, request);
398
- // close() half-closes the write end (EOF) while the read end stays open for
399
- // the response — the same libp2p 3.x pattern as wake/seed delivery.
400
- await stream.close();
401
- const response = await readFrame<StrandAddrResponse>(stream, timeoutMs);
402
- log('Strand-addr response: %d addr(s) for %s', response.multiaddrs.length, response.strandId);
403
- return response;
404
- } catch (err) {
405
- stream.abort(err instanceof Error ? err : new Error(String(err)));
406
- throw err;
407
- } finally {
408
- signal.removeEventListener('abort', onAbort);
409
- }
431
+ const response = await exchangeFrame(
432
+ rawStream as unknown as ControlStream,
433
+ signal,
434
+ request,
435
+ (stream) => readFrame<StrandAddrResponse>(stream, timeoutMs),
436
+ 'Strand-addr dial aborted by timeout',
437
+ );
438
+
439
+ log('Strand-addr response: %d addr(s) for %s', response.multiaddrs.length, response.strandId);
440
+ return response;
410
441
  }