@serfab/cadre-core 0.10.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 (145) hide show
  1. package/README.md +255 -255
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +484 -58
  6. package/dist/cadre-node.js +953 -182
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +26 -0
  9. package/dist/control-cohort.js +26 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +222 -125
  12. package/dist/control-database.js +356 -266
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +8 -7
  15. package/dist/control-formation-recorder.js +8 -7
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +103 -0
  18. package/dist/control-read-retry.js +145 -0
  19. package/dist/control-read-retry.js.map +1 -0
  20. package/dist/control-retry.d.ts +101 -0
  21. package/dist/control-retry.js +108 -0
  22. package/dist/control-retry.js.map +1 -0
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +805 -774
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +65 -31
  27. package/dist/control-write-retry.js +33 -103
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +17 -15
  30. package/dist/delegate-admission.js +17 -15
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/index.d.ts +7 -5
  33. package/dist/index.js +18 -9
  34. package/dist/index.js.map +1 -1
  35. package/dist/membership-connection-gater.d.ts +211 -25
  36. package/dist/membership-connection-gater.js +309 -29
  37. package/dist/membership-connection-gater.js.map +1 -1
  38. package/dist/peer-addr-book.d.ts +92 -0
  39. package/dist/peer-addr-book.js +220 -0
  40. package/dist/peer-addr-book.js.map +1 -0
  41. package/dist/peer-record.d.ts +26 -0
  42. package/dist/peer-record.js +32 -0
  43. package/dist/peer-record.js.map +1 -1
  44. package/dist/relay-addrs.d.ts +76 -26
  45. package/dist/relay-addrs.js +115 -27
  46. package/dist/relay-addrs.js.map +1 -1
  47. package/dist/relay-reservation.d.ts +20 -15
  48. package/dist/relay-reservation.js +18 -14
  49. package/dist/relay-reservation.js.map +1 -1
  50. package/dist/seed-bootstrap.d.ts +7 -0
  51. package/dist/seed-bootstrap.js +15 -13
  52. package/dist/seed-bootstrap.js.map +1 -1
  53. package/dist/strand-addr-protocol.js +16 -0
  54. package/dist/strand-addr-protocol.js.map +1 -1
  55. package/dist/strand-backfill.js +13 -4
  56. package/dist/strand-backfill.js.map +1 -1
  57. package/dist/strand-cohort.d.ts +4 -37
  58. package/dist/strand-cohort.js +4 -15
  59. package/dist/strand-cohort.js.map +1 -1
  60. package/dist/strand-database.d.ts +11 -16
  61. package/dist/strand-database.js +19 -7
  62. package/dist/strand-database.js.map +1 -1
  63. package/dist/strand-formation-manager.d.ts +18 -17
  64. package/dist/strand-formation-manager.js +25 -27
  65. package/dist/strand-formation-manager.js.map +1 -1
  66. package/dist/strand-formation-protocol.d.ts +7 -7
  67. package/dist/strand-formation-protocol.js +8 -8
  68. package/dist/strand-formation-protocol.js.map +1 -1
  69. package/dist/strand-instance-manager.d.ts +53 -21
  70. package/dist/strand-instance-manager.js +128 -37
  71. package/dist/strand-instance-manager.js.map +1 -1
  72. package/dist/strand-membership-writer.d.ts +113 -19
  73. package/dist/strand-membership-writer.js +219 -51
  74. package/dist/strand-membership-writer.js.map +1 -1
  75. package/dist/strand-solicitation.d.ts +3 -3
  76. package/dist/strand-solicitation.js +4 -5
  77. package/dist/strand-solicitation.js.map +1 -1
  78. package/dist/strand-wake-protocol.d.ts +47 -2
  79. package/dist/strand-wake-protocol.js +76 -6
  80. package/dist/strand-wake-protocol.js.map +1 -1
  81. package/dist/types.d.ts +107 -54
  82. package/dist/types.js.map +1 -1
  83. package/package.json +8 -7
  84. package/src/announce-addrs.ts +93 -0
  85. package/src/arachnode-stub.ts +159 -159
  86. package/src/bootstrap-peer-store-file.ts +44 -44
  87. package/src/bootstrap-peer-store.ts +172 -172
  88. package/src/cadre-node.ts +1026 -194
  89. package/src/canonical-json.ts +30 -30
  90. package/src/control-authorization.ts +114 -114
  91. package/src/control-cohort.ts +130 -103
  92. package/src/control-database.ts +376 -298
  93. package/src/control-formation-recorder.ts +305 -304
  94. package/src/control-read-retry.ts +167 -0
  95. package/src/control-retry.ts +190 -0
  96. package/src/control-schema.ts +817 -786
  97. package/src/control-stream.ts +205 -205
  98. package/src/control-write-retry.ts +383 -473
  99. package/src/delegate-admission.ts +304 -302
  100. package/src/device-token.ts +94 -94
  101. package/src/diagnostics/connection-path.ts +277 -277
  102. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  103. package/src/ed25519-key.ts +134 -134
  104. package/src/enrollment.ts +199 -199
  105. package/src/file-durable-slot.ts +61 -61
  106. package/src/formation-approval.ts +604 -604
  107. package/src/fs-atomic.ts +107 -107
  108. package/src/hibernation-manager.ts +437 -437
  109. package/src/identity-key.ts +109 -109
  110. package/src/index.ts +435 -406
  111. package/src/key-store-file.ts +132 -132
  112. package/src/key-store.ts +99 -99
  113. package/src/membership-connection-gater.ts +368 -39
  114. package/src/node-local-snapshot.ts +260 -260
  115. package/src/peer-addr-book.ts +250 -0
  116. package/src/peer-authorization.ts +259 -259
  117. package/src/peer-record.ts +197 -164
  118. package/src/push-credentials.ts +84 -84
  119. package/src/push-node.ts +64 -64
  120. package/src/push-notifier-apns.ts +247 -247
  121. package/src/push-notifier-fcm.ts +220 -220
  122. package/src/push-notifier-shared.ts +28 -28
  123. package/src/push-notifier.ts +52 -52
  124. package/src/relay-addrs.ts +134 -27
  125. package/src/relay-reservation.ts +22 -17
  126. package/src/seed-bootstrap.ts +15 -15
  127. package/src/seed-trust-policy.ts +140 -140
  128. package/src/strand-addr-protocol.ts +458 -441
  129. package/src/strand-backfill.ts +28 -5
  130. package/src/strand-cohort.ts +38 -77
  131. package/src/strand-database.ts +202 -204
  132. package/src/strand-formation-manager.ts +26 -28
  133. package/src/strand-formation-protocol.ts +692 -692
  134. package/src/strand-instance-manager.ts +636 -553
  135. package/src/strand-member-key.ts +39 -39
  136. package/src/strand-member-registry.ts +182 -182
  137. package/src/strand-membership-writer.ts +1627 -1430
  138. package/src/strand-solicitation.ts +471 -472
  139. package/src/strand-transport-key.ts +58 -58
  140. package/src/strand-wake-payload.ts +32 -32
  141. package/src/strand-wake-protocol.ts +400 -314
  142. package/src/strand-watcher.ts +314 -314
  143. package/src/trusted-owner-store-file.ts +41 -41
  144. package/src/trusted-owner-store.ts +171 -171
  145. package/src/types.ts +1354 -1303
@@ -1,441 +1,458 @@
1
- /**
2
- * Control-network strand-address RPC.
3
- *
4
- * A strand runs as its own libp2p node (`strand-<id>`, random port), separate
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
12
- * uses the union of their answers as the seed.
13
- *
14
- * Modeled directly on `strand-wake-protocol.ts`: a dedicated libp2p protocol id,
15
- * 4-byte big-endian length-prefixed JSON frames, `node.handle` for the receiver,
16
- * `node.dialProtocol` for the client, and the shared `ControlStream` primitives
17
- * from `control-stream.ts`. The exchange is a single request → single response on
18
- * one stream, so each side reads to EOF (under a read timeout) and decodes one
19
- * frame via the shared {@link decodeLengthPrefixedFrame} guard.
20
- *
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.
29
- */
30
-
31
- import debug from 'debug';
32
- import type { Libp2p, Connection, PeerId } from '@libp2p/interface';
33
- import type { Multiaddr } from '@multiformats/multiaddr';
34
- import { peerIdFromString } from '@libp2p/peer-id';
35
- import type { StrandAddrRequest, StrandAddrResponse } from './types.js';
36
- import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
37
- import { orderSignalingFirst } from './peer-record.js';
38
- import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
39
-
40
- const log = debug('sereus:cadre:strand-addr');
41
-
42
- /** Protocol id for the control-network strand-address RPC (parallel to `/sereus/strand-wake/1.0.0`). */
43
- export const STRAND_ADDR_PROTOCOL = '/sereus/strand-addr/1.0.0';
44
-
45
- /**
46
- * Maximum strand-addr frame size. Responses are tiny (a strand id + a few
47
- * multiaddrs), so this is a defensive cap — far below the 1MB seed ceiling —
48
- * that bounds the bytes a peer can make the receiver buffer per stream.
49
- */
50
- const MAX_ADDR_SIZE = 64 * 1024;
51
-
52
- /** Default time to wait for the response before abandoning a strand-addr dial (ms). */
53
- const DEFAULT_ADDR_TIMEOUT_MS = 10_000;
54
-
55
- /** Default time the receiver waits for an inbound request frame before aborting (ms). */
56
- const DEFAULT_ADDR_READ_TIMEOUT_MS = 10_000;
57
-
58
- /** Default cap on concurrent inbound strand-addr streams a single peer can pin open. */
59
- const DEFAULT_MAX_CONCURRENT_ADDRS = 100;
60
-
61
- /**
62
- * Read a libp2p stream to EOF and decode the single length-prefixed JSON frame
63
- * it carries. Bounded by `timeoutMs` (a never-half-closing peer is aborted, not
64
- * awaited forever) and capped at {@link MAX_ADDR_SIZE}, reusing the shared
65
- * {@link decodeLengthPrefixedFrame} guard for the prefix/length checks.
66
- */
67
- async function readFrame<T>(stream: ControlStream, timeoutMs: number): Promise<T> {
68
- const data = await readStreamToEnd(stream, { maxBytes: MAX_ADDR_SIZE, timeoutMs, label: 'Strand-addr' });
69
- const body = decodeLengthPrefixedFrame(data, MAX_ADDR_SIZE);
70
- return JSON.parse(new TextDecoder().decode(body)) as T;
71
- }
72
-
73
- /** Empty response used on every reject/error path (cap, malformed frame, non-member). */
74
- function emptyResponse(strandId: string): StrandAddrResponse {
75
- return { strandId, multiaddrs: [] };
76
- }
77
-
78
- /**
79
- * Dependencies the {@link StrandAddrService} receiver needs from its host
80
- * (`CadreNode`), injected so the service is testable without a full node.
81
- */
82
- export interface StrandAddrServiceOptions {
83
- /** Membership gate: is the remote peer a `CadrePeer` member of this cadre? */
84
- isMember(remotePeerId: string): Promise<boolean>;
85
- /**
86
- * The local strand instance's dialable strand-network multiaddrs, ordered
87
- * signaling-first. Returns `[]` when the strand is not running / has no live
88
- * node (hibernating, quiescing, or never participated). Kept injectable so the
89
- * decision logic is unit-testable without a full node.
90
- */
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;
102
- /**
103
- * Time to wait for the inbound request frame before aborting the read (ms).
104
- * Defaults to {@link DEFAULT_ADDR_READ_TIMEOUT_MS}. Bounds a buggy/compromised
105
- * own-cadre node that opens a stream and never half-closes its write end.
106
- */
107
- readTimeoutMs?: number;
108
- /**
109
- * Cap on concurrent inbound strand-addr streams (defaults to
110
- * {@link DEFAULT_MAX_CONCURRENT_ADDRS}). Over the cap, an empty response is
111
- * returned without looking up any strand address.
112
- */
113
- maxConcurrent?: number;
114
- }
115
-
116
- /**
117
- * Receiver side of the strand-address RPC. Registers a `STRAND_ADDR_PROTOCOL`
118
- * handler on the control node and, for each inbound {@link StrandAddrRequest},
119
- * gates on cadre membership, then replies with the local strand instance's live
120
- * multiaddrs (or an empty list when not a member / not running).
121
- */
122
- export class StrandAddrService {
123
- private readonly options: StrandAddrServiceOptions;
124
- private readonly readTimeoutMs: number;
125
- private readonly maxConcurrent: number;
126
- private node: Libp2p | null = null;
127
- /** In-flight inbound strand-addr streams, used to enforce {@link maxConcurrent}. */
128
- private activeStreams = 0;
129
-
130
- constructor(options: StrandAddrServiceOptions) {
131
- this.options = options;
132
- this.readTimeoutMs = options.readTimeoutMs ?? DEFAULT_ADDR_READ_TIMEOUT_MS;
133
- this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_ADDRS;
134
- }
135
-
136
- /** Number of in-flight inbound strand-addr streams. */
137
- get activeCount(): number {
138
- return this.activeStreams;
139
- }
140
-
141
- /** Register the strand-addr protocol handler on the control node. */
142
- initialize(node: Libp2p): void {
143
- this.node = node;
144
- // `runOnLimitedConnection: true` is REQUIRED for the relay path: a NAT'd
145
- // sibling is reached over a circuit-relay connection, which libp2p marks
146
- // "limited" (the relay caps its data/duration). Without this the receiver
147
- // would refuse the inbound stream on exactly the connection the protocol is
148
- // designed to use (same reasoning as wake; see the relay note on
149
- // `collectStrandAddrs`).
150
- void node.handle(STRAND_ADDR_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
151
- const remotePeerId = (rawConnection as Connection).remotePeer.toString();
152
- await this.handleStream(rawStream as ControlStream, remotePeerId);
153
- }, { runOnLimitedConnection: true });
154
- log('StrandAddrService registered handler: %s', STRAND_ADDR_PROTOCOL);
155
- }
156
-
157
- /** Unregister the handler and release the node reference. */
158
- async shutdown(): Promise<void> {
159
- if (this.node) {
160
- await this.node.unhandle(STRAND_ADDR_PROTOCOL);
161
- this.node = null;
162
- log('StrandAddrService shutdown');
163
- }
164
- }
165
-
166
- /**
167
- * Read the inbound request, decide the response, and write it back.
168
- *
169
- * Three hardening layers, all reported as an empty {@link StrandAddrResponse}
170
- * rather than a dropped/hung stream: a concurrency cap (over
171
- * {@link maxConcurrent}, reply without looking up any address), a read timeout
172
- * (a peer that never half-closes is aborted inside {@link readFrame}/
173
- * `readStreamToEnd`), and the existing malformed/oversized-frame guard.
174
- */
175
- private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
176
- log('Incoming strand-addr request from: %s', remotePeerId);
177
-
178
- if (this.activeStreams >= this.maxConcurrent) {
179
- log('Rejecting strand-addr from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrent);
180
- // The request frame is unread here, so the strand id is unknown — reply
181
- // with an empty list under an empty strand id; the client treats any empty
182
- // response as "no addrs" and skips this sibling.
183
- try {
184
- writeFrame(stream, emptyResponse(''));
185
- } catch {
186
- // Ignore send errors on the reject path.
187
- }
188
- try {
189
- await stream.close();
190
- } catch {
191
- // Ignore close errors.
192
- }
193
- return;
194
- }
195
-
196
- this.activeStreams++;
197
- try {
198
- const request = await readFrame<StrandAddrRequest>(stream, this.readTimeoutMs);
199
- const response = await this.processAddrRequest(request, remotePeerId);
200
- writeFrame(stream, response);
201
- } catch (err) {
202
- log('Error handling strand-addr request from %s: %o', remotePeerId, err);
203
- // Malformed/oversized/timed-out request: strand id is unknown, so reply
204
- // with an empty list under an empty strand id rather than hanging.
205
- try {
206
- writeFrame(stream, emptyResponse(''));
207
- } catch {
208
- // Ignore send errors on the error path.
209
- }
210
- } finally {
211
- this.activeStreams--;
212
- try {
213
- await stream.close();
214
- } catch {
215
- // Ignore close errors.
216
- }
217
- }
218
- }
219
-
220
- /**
221
- * Decide the response for a decoded request. Exposed (not private) so the
222
- * decision matrix can be unit-tested directly (mirrors wake's
223
- * `processWakeRequest`).
224
- *
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.
228
- * - Strand not running locally → empty `multiaddrs` (`getStrandMultiaddrs` → `[]`).
229
- * - Member + running strand → the strand's live, signaling-first multiaddrs.
230
- */
231
- async processAddrRequest(request: StrandAddrRequest, remotePeerId: string): Promise<StrandAddrResponse> {
232
- // Control-network membership is the v1 authorization: only this party's
233
- // cadre peers may ask us for a strand address (or announce a delegate).
234
- if (!(await this.options.isMember(remotePeerId))) {
235
- log('Refusing strand-addr from non-member %s', remotePeerId);
236
- return emptyResponse(request.strandId);
237
- }
238
-
239
- this.recordDelegateAnnounce(request, remotePeerId);
240
-
241
- const multiaddrs = this.options.getStrandMultiaddrs(request.strandId);
242
- log('Strand-addr for %s → %d addr(s)', request.strandId, multiaddrs.length);
243
- return { strandId: request.strandId, multiaddrs };
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
- }
269
- }
270
-
271
- /** A sibling to ask for its strand address. */
272
- export interface StrandAddrPeer {
273
- /** The sibling's control-network peer id (also its strand-node peer id). */
274
- peerId: string;
275
- /**
276
- * Pre-resolved control-network multiaddrs, used as a fallback when no control
277
- * connection is already open. Dialing by {@link peerId} is preferred (it
278
- * reuses an existing connection); these are tried only if that fails.
279
- */
280
- addrs?: Multiaddr[];
281
- }
282
-
283
- /** Options for {@link collectStrandAddrs}. */
284
- export interface CollectStrandAddrsOptions {
285
- /** Per-dial timeout in ms (default {@link DEFAULT_ADDR_TIMEOUT_MS}). */
286
- timeoutMs?: number;
287
- /** Override the protocol id (defaults to {@link STRAND_ADDR_PROTOCOL}). */
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;
296
- }
297
-
298
- /**
299
- * Client side: ask each candidate sibling for its live strand-`strandId`
300
- * multiaddrs and return the **deduplicated union** of every answer, ordered
301
- * signaling-first.
302
- *
303
- * Best-effort per peer: a failed/timed-out/empty sibling is logged and skipped,
304
- * never fatal — an empty union (no sibling online or running the strand) returns
305
- * `[]`, which the caller treats as an acceptable seed that self-heals on the next
306
- * resume/reconcile pass. The local node (`node.peerId`) is excluded so we never
307
- * RPC ourselves or seed with our own strand address.
308
- *
309
- * Dials run concurrently but the union preserves candidate order, so the result
310
- * is deterministic regardless of which sibling answers first.
311
- */
312
- export async function collectStrandAddrs(
313
- node: Libp2p,
314
- peers: StrandAddrPeer[],
315
- strandId: string,
316
- options: CollectStrandAddrsOptions = {}
317
- ): Promise<string[]> {
318
- const selfId = node.peerId.toString();
319
- const request: StrandAddrRequest = options.delegatePeerId !== undefined
320
- ? { strandId, delegatePeerId: options.delegatePeerId }
321
- : { strandId };
322
- const candidates = peers.filter(p => p.peerId !== selfId);
323
-
324
- // Concurrent dials; `dialOneSibling` swallows per-peer failure into `[]`, so
325
- // `Promise.all` never rejects and the result array stays in candidate order.
326
- const perPeer = await Promise.all(
327
- candidates.map(peer => dialOneSibling(node, peer, request, options))
328
- );
329
-
330
- // Deduplicated union in candidate order, then signaling-first for a usable
331
- // dial sequence (relay/`p2p-circuit` ahead of direct addrs).
332
- const seen = new Set<string>();
333
- const union: string[] = [];
334
- for (const addrs of perPeer) {
335
- for (const addr of addrs) {
336
- if (!seen.has(addr)) {
337
- seen.add(addr);
338
- union.push(addr);
339
- }
340
- }
341
- }
342
- return orderSignalingFirst(union);
343
- }
344
-
345
- /**
346
- * Ask one sibling for its strand address, returning its multiaddrs or `[]` on any
347
- * failure. Tries each dial target in order (peerId first to reuse an open control
348
- * connection, then explicit addrs) until one answers; a total failure is logged
349
- * and folded to `[]` so a single dead sibling never aborts the collection.
350
- */
351
- async function dialOneSibling(
352
- node: Libp2p,
353
- peer: StrandAddrPeer,
354
- request: StrandAddrRequest,
355
- options: CollectStrandAddrsOptions
356
- ): Promise<string[]> {
357
- const protocolId = options.protocolId ?? STRAND_ADDR_PROTOCOL;
358
- const timeoutMs = options.timeoutMs ?? DEFAULT_ADDR_TIMEOUT_MS;
359
- const targets = dialTargets(peer);
360
-
361
- if (targets.length === 0) {
362
- log('No dial target for sibling %s', peer.peerId);
363
- return [];
364
- }
365
-
366
- for (const target of targets) {
367
- try {
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(
371
- timeoutMs,
372
- `Strand-addr dial ${peer.peerId}`,
373
- (signal) => sendStrandAddr(node, target, protocolId, request, timeoutMs, signal),
374
- );
375
- return response.multiaddrs;
376
- } catch (err) {
377
- log('Strand-addr dial to %s via %s failed: %o', peer.peerId, describeTarget(target), err);
378
- }
379
- }
380
- return [];
381
- }
382
-
383
- /**
384
- * Dial targets for a sibling, peerId first. Dialing by peerId lets libp2p reuse
385
- * an already-open control connection (the common case — the wiring ticket passes
386
- * connected siblings); the explicit control addrs are a fallback for when no
387
- * connection is open. An unparsable peerId is skipped (rely on addrs).
388
- */
389
- function dialTargets(peer: StrandAddrPeer): Array<PeerId | Multiaddr> {
390
- const targets: Array<PeerId | Multiaddr> = [];
391
- try {
392
- targets.push(peerIdFromString(peer.peerId));
393
- } catch (err) {
394
- log('Unparsable peer id %s, falling back to addrs: %o', peer.peerId, err);
395
- }
396
- if (peer.addrs) {
397
- targets.push(...peer.addrs);
398
- }
399
- return targets;
400
- }
401
-
402
- /** Short label for a dial target (peerId or multiaddr) for logging. */
403
- function describeTarget(target: PeerId | Multiaddr): string {
404
- return target.toString();
405
- }
406
-
407
- /**
408
- * Open one stream to a target, send the request, half-close, and read the
409
- * response.
410
- *
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.
415
- */
416
- async function sendStrandAddr(
417
- node: Libp2p,
418
- target: PeerId | Multiaddr,
419
- protocolId: string,
420
- request: StrandAddrRequest,
421
- timeoutMs: number,
422
- signal: AbortSignal,
423
- ): Promise<StrandAddrResponse> {
424
- // `runOnLimitedConnection: true`: the sibling may be reachable only over a
425
- // circuit-relay connection (the signaling-first addr), which libp2p marks
426
- // "limited". The exchange is a single tiny request→response well within the
427
- // relay's data/duration cap, so opening it on the limited connection is safe
428
- // and is the whole point of dialing over the relay.
429
- const rawStream = await node.dialProtocol(target, protocolId, { runOnLimitedConnection: true, signal });
430
-
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;
441
- }
1
+ /**
2
+ * Control-network strand-address RPC.
3
+ *
4
+ * A strand runs as its own libp2p node (`strand-<id>`, random port), separate
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
12
+ * uses the union of their answers as the seed.
13
+ *
14
+ * Modeled directly on `strand-wake-protocol.ts`: a dedicated libp2p protocol id,
15
+ * 4-byte big-endian length-prefixed JSON frames, `node.handle` for the receiver,
16
+ * `node.dialProtocol` for the client, and the shared `ControlStream` primitives
17
+ * from `control-stream.ts`. The exchange is a single request → single response on
18
+ * one stream, so each side reads to EOF (under a read timeout) and decodes one
19
+ * frame via the shared {@link decodeLengthPrefixedFrame} guard.
20
+ *
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.
29
+ */
30
+
31
+ import debug from 'debug';
32
+ import type { Libp2p, Connection, PeerId } from '@libp2p/interface';
33
+ import type { Multiaddr } from '@multiformats/multiaddr';
34
+ import { peerIdFromString } from '@libp2p/peer-id';
35
+ import type { StrandAddrRequest, StrandAddrResponse } from './types.js';
36
+ import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
37
+ import { orderSignalingFirst } from './peer-record.js';
38
+ import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
39
+
40
+ const log = debug('sereus:cadre:strand-addr');
41
+
42
+ /** Protocol id for the control-network strand-address RPC (parallel to `/sereus/strand-wake/1.0.0`). */
43
+ export const STRAND_ADDR_PROTOCOL = '/sereus/strand-addr/1.0.0';
44
+
45
+ /**
46
+ * Maximum strand-addr frame size. Responses are tiny (a strand id + a few
47
+ * multiaddrs), so this is a defensive cap — far below the 1MB seed ceiling —
48
+ * that bounds the bytes a peer can make the receiver buffer per stream.
49
+ */
50
+ const MAX_ADDR_SIZE = 64 * 1024;
51
+
52
+ /** Default time to wait for the response before abandoning a strand-addr dial (ms). */
53
+ const DEFAULT_ADDR_TIMEOUT_MS = 10_000;
54
+
55
+ /** Default time the receiver waits for an inbound request frame before aborting (ms). */
56
+ const DEFAULT_ADDR_READ_TIMEOUT_MS = 10_000;
57
+
58
+ /** Default cap on concurrent inbound strand-addr streams a single peer can pin open. */
59
+ const DEFAULT_MAX_CONCURRENT_ADDRS = 100;
60
+
61
+ /**
62
+ * Read a libp2p stream to EOF and decode the single length-prefixed JSON frame
63
+ * it carries. Bounded by `timeoutMs` (a never-half-closing peer is aborted, not
64
+ * awaited forever) and capped at {@link MAX_ADDR_SIZE}, reusing the shared
65
+ * {@link decodeLengthPrefixedFrame} guard for the prefix/length checks.
66
+ */
67
+ async function readFrame<T>(stream: ControlStream, timeoutMs: number): Promise<T> {
68
+ const data = await readStreamToEnd(stream, { maxBytes: MAX_ADDR_SIZE, timeoutMs, label: 'Strand-addr' });
69
+ const body = decodeLengthPrefixedFrame(data, MAX_ADDR_SIZE);
70
+ return JSON.parse(new TextDecoder().decode(body)) as T;
71
+ }
72
+
73
+ /** Empty response used on every reject/error path (cap, malformed frame, non-member). */
74
+ function emptyResponse(strandId: string): StrandAddrResponse {
75
+ return { strandId, multiaddrs: [] };
76
+ }
77
+
78
+ /**
79
+ * Dependencies the {@link StrandAddrService} receiver needs from its host
80
+ * (`CadreNode`), injected so the service is testable without a full node.
81
+ */
82
+ export interface StrandAddrServiceOptions {
83
+ /** Membership gate: is the remote peer a `CadrePeer` member of this cadre? */
84
+ isMember(remotePeerId: string): Promise<boolean>;
85
+ /**
86
+ * The local strand instance's dialable strand-network multiaddrs, ordered
87
+ * signaling-first. Returns `[]` when the strand is not running / has no live
88
+ * node (hibernating, quiescing, or never participated). Kept injectable so the
89
+ * decision logic is unit-testable without a full node.
90
+ */
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;
102
+ /**
103
+ * Time to wait for the inbound request frame before aborting the read (ms).
104
+ * Defaults to {@link DEFAULT_ADDR_READ_TIMEOUT_MS}. Bounds a buggy/compromised
105
+ * own-cadre node that opens a stream and never half-closes its write end.
106
+ */
107
+ readTimeoutMs?: number;
108
+ /**
109
+ * Cap on concurrent inbound strand-addr streams (defaults to
110
+ * {@link DEFAULT_MAX_CONCURRENT_ADDRS}). Over the cap, an empty response is
111
+ * returned without looking up any strand address.
112
+ */
113
+ maxConcurrent?: number;
114
+ }
115
+
116
+ /**
117
+ * Receiver side of the strand-address RPC. Registers a `STRAND_ADDR_PROTOCOL`
118
+ * handler on the control node and, for each inbound {@link StrandAddrRequest},
119
+ * gates on cadre membership, then replies with the local strand instance's live
120
+ * multiaddrs (or an empty list when not a member / not running).
121
+ */
122
+ export class StrandAddrService {
123
+ private readonly options: StrandAddrServiceOptions;
124
+ private readonly readTimeoutMs: number;
125
+ private readonly maxConcurrent: number;
126
+ private node: Libp2p | null = null;
127
+ /** In-flight inbound strand-addr streams, used to enforce {@link maxConcurrent}. */
128
+ private activeStreams = 0;
129
+
130
+ constructor(options: StrandAddrServiceOptions) {
131
+ this.options = options;
132
+ this.readTimeoutMs = options.readTimeoutMs ?? DEFAULT_ADDR_READ_TIMEOUT_MS;
133
+ this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_ADDRS;
134
+ }
135
+
136
+ /** Number of in-flight inbound strand-addr streams. */
137
+ get activeCount(): number {
138
+ return this.activeStreams;
139
+ }
140
+
141
+ /** Register the strand-addr protocol handler on the control node. */
142
+ initialize(node: Libp2p): void {
143
+ this.node = node;
144
+ // `runOnLimitedConnection: true` is REQUIRED for the relay path: a NAT'd
145
+ // sibling is reached over a circuit-relay connection, which libp2p marks
146
+ // "limited" (the relay caps its data/duration). Without this the receiver
147
+ // would refuse the inbound stream on exactly the connection the protocol is
148
+ // designed to use (same reasoning as wake; see the relay note on
149
+ // `collectStrandAddrs`).
150
+ void node.handle(STRAND_ADDR_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
151
+ const remotePeerId = (rawConnection as Connection).remotePeer.toString();
152
+ await this.handleStream(rawStream as ControlStream, remotePeerId);
153
+ }, { runOnLimitedConnection: true });
154
+ log('StrandAddrService registered handler: %s', STRAND_ADDR_PROTOCOL);
155
+ }
156
+
157
+ /** Unregister the handler and release the node reference. */
158
+ async shutdown(): Promise<void> {
159
+ if (this.node) {
160
+ await this.node.unhandle(STRAND_ADDR_PROTOCOL);
161
+ this.node = null;
162
+ log('StrandAddrService shutdown');
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Read the inbound request, decide the response, and write it back.
168
+ *
169
+ * Three hardening layers, all reported as an empty {@link StrandAddrResponse}
170
+ * rather than a dropped/hung stream: a concurrency cap (over
171
+ * {@link maxConcurrent}, reply without looking up any address), a read timeout
172
+ * (a peer that never half-closes is aborted inside {@link readFrame}/
173
+ * `readStreamToEnd`), and the existing malformed/oversized-frame guard.
174
+ */
175
+ private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
176
+ log('Incoming strand-addr request from: %s', remotePeerId);
177
+
178
+ if (this.activeStreams >= this.maxConcurrent) {
179
+ log('Rejecting strand-addr from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrent);
180
+ // The request frame is unread here, so the strand id is unknown — reply
181
+ // with an empty list under an empty strand id; the client treats any empty
182
+ // response as "no addrs" and skips this sibling.
183
+ try {
184
+ writeFrame(stream, emptyResponse(''));
185
+ } catch {
186
+ // Ignore send errors on the reject path.
187
+ }
188
+ try {
189
+ await stream.close();
190
+ } catch {
191
+ // Ignore close errors.
192
+ }
193
+ return;
194
+ }
195
+
196
+ this.activeStreams++;
197
+ try {
198
+ const request = await readFrame<StrandAddrRequest>(stream, this.readTimeoutMs);
199
+ const response = await this.processAddrRequest(request, remotePeerId);
200
+ writeFrame(stream, response);
201
+ } catch (err) {
202
+ log('Error handling strand-addr request from %s: %o', remotePeerId, err);
203
+ // Malformed/oversized/timed-out request: strand id is unknown, so reply
204
+ // with an empty list under an empty strand id rather than hanging.
205
+ try {
206
+ writeFrame(stream, emptyResponse(''));
207
+ } catch {
208
+ // Ignore send errors on the error path.
209
+ }
210
+ } finally {
211
+ this.activeStreams--;
212
+ try {
213
+ await stream.close();
214
+ } catch {
215
+ // Ignore close errors.
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Decide the response for a decoded request. Exposed (not private) so the
222
+ * decision matrix can be unit-tested directly (mirrors wake's
223
+ * `processWakeRequest`).
224
+ *
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.
228
+ * - Strand not running locally → empty `multiaddrs` (`getStrandMultiaddrs` → `[]`).
229
+ * - Member + running strand → the strand's live, signaling-first multiaddrs.
230
+ */
231
+ async processAddrRequest(request: StrandAddrRequest, remotePeerId: string): Promise<StrandAddrResponse> {
232
+ // Control-network membership is the v1 authorization: only this party's
233
+ // cadre peers may ask us for a strand address (or announce a delegate).
234
+ if (!(await this.options.isMember(remotePeerId))) {
235
+ log('Refusing strand-addr from non-member %s', remotePeerId);
236
+ return emptyResponse(request.strandId);
237
+ }
238
+
239
+ this.recordDelegateAnnounce(request, remotePeerId);
240
+
241
+ const multiaddrs = this.options.getStrandMultiaddrs(request.strandId);
242
+ log('Strand-addr for %s → %d addr(s)', request.strandId, multiaddrs.length);
243
+ return { strandId: request.strandId, multiaddrs };
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
+ }
269
+ }
270
+
271
+ /** A sibling to ask for its strand address. */
272
+ export interface StrandAddrPeer {
273
+ /** The sibling's control-network peer id (also its strand-node peer id). */
274
+ peerId: string;
275
+ /**
276
+ * Pre-resolved control-network multiaddrs, used as a fallback when no control
277
+ * connection is already open. Dialing by {@link peerId} is preferred (it
278
+ * reuses an existing connection); these are tried only if that fails.
279
+ */
280
+ addrs?: Multiaddr[];
281
+ }
282
+
283
+ /** Options for {@link collectStrandAddrs}. */
284
+ export interface CollectStrandAddrsOptions {
285
+ /** Per-dial timeout in ms (default {@link DEFAULT_ADDR_TIMEOUT_MS}). */
286
+ timeoutMs?: number;
287
+ /** Override the protocol id (defaults to {@link STRAND_ADDR_PROTOCOL}). */
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;
296
+ }
297
+
298
+ /**
299
+ * Client side: ask each candidate sibling for its live strand-`strandId`
300
+ * multiaddrs and return the **deduplicated union** of every answer, ordered
301
+ * signaling-first.
302
+ *
303
+ * Best-effort per peer: a failed/timed-out/empty sibling is logged and skipped,
304
+ * never fatal — an empty union (no sibling online or running the strand) returns
305
+ * `[]`, which the caller treats as an acceptable seed that self-heals on the next
306
+ * resume/reconcile pass. The local node (`node.peerId`) is excluded so we never
307
+ * RPC ourselves or seed with our own strand address.
308
+ *
309
+ * Dials run concurrently but the union preserves candidate order, so the result
310
+ * is deterministic regardless of which sibling answers first.
311
+ */
312
+ export async function collectStrandAddrs(
313
+ node: Libp2p,
314
+ peers: StrandAddrPeer[],
315
+ strandId: string,
316
+ options: CollectStrandAddrsOptions = {}
317
+ ): Promise<string[]> {
318
+ const selfId = node.peerId.toString();
319
+ const request: StrandAddrRequest = options.delegatePeerId !== undefined
320
+ ? { strandId, delegatePeerId: options.delegatePeerId }
321
+ : { strandId };
322
+ const candidates = peers.filter(p => p.peerId !== selfId);
323
+
324
+ // Concurrent dials; `dialOneSibling` swallows per-peer failure into `[]`, so
325
+ // `Promise.all` never rejects and the result array stays in candidate order.
326
+ const perPeer = await Promise.all(
327
+ candidates.map(peer => dialOneSibling(node, peer, request, options))
328
+ );
329
+
330
+ // Deduplicated union in candidate order, then signaling-first for a usable
331
+ // dial sequence (relay/`p2p-circuit` ahead of direct addrs).
332
+ const seen = new Set<string>();
333
+ const union: string[] = [];
334
+ for (const addrs of perPeer) {
335
+ for (const addr of addrs) {
336
+ if (!seen.has(addr)) {
337
+ seen.add(addr);
338
+ union.push(addr);
339
+ }
340
+ }
341
+ }
342
+ return orderSignalingFirst(union);
343
+ }
344
+
345
+ /**
346
+ * Ask one sibling for its strand address, returning its multiaddrs or `[]` on any
347
+ * failure. Tries each dial target in order (peerId first to reuse an open control
348
+ * connection, then explicit addrs) until one answers; a total failure is logged
349
+ * and folded to `[]` so a single dead sibling never aborts the collection.
350
+ *
351
+ * A total failure logs ONE line naming every target and its cause. `[]` alone
352
+ * cannot tell the caller "this sibling was unreachable" apart from "this sibling
353
+ * answered and has no strand address" — and per-target lines scattered through a
354
+ * concurrent fan-out do not reassemble into that answer either. The return
355
+ * contract is unchanged: best-effort `[]`, never a throw.
356
+ *
357
+ * NOTE: cost is (targets × `timeoutMs`) with no whole-sibling budget, the same
358
+ * shape `dialWake` bounds with `DEFAULT_WAKE_DIAL_BUDGET_MS`. Fine today —
359
+ * `dialTargets` yields the peerId plus whatever `resolvePeerAddrs` returned,
360
+ * which for a cadre device is one or two addresses. If a sibling's record ever
361
+ * carries a long address list, give this the same whole-sibling budget.
362
+ */
363
+ async function dialOneSibling(
364
+ node: Libp2p,
365
+ peer: StrandAddrPeer,
366
+ request: StrandAddrRequest,
367
+ options: CollectStrandAddrsOptions
368
+ ): Promise<string[]> {
369
+ const protocolId = options.protocolId ?? STRAND_ADDR_PROTOCOL;
370
+ const timeoutMs = options.timeoutMs ?? DEFAULT_ADDR_TIMEOUT_MS;
371
+ const targets = dialTargets(peer);
372
+
373
+ if (targets.length === 0) {
374
+ log('No dial target for sibling %s', peer.peerId);
375
+ return [];
376
+ }
377
+
378
+ const failures: string[] = [];
379
+ for (const target of targets) {
380
+ try {
381
+ // One deadline per attempt: its signal aborts the in-flight dialProtocol and
382
+ // resets the live stream, so neither the connect nor the response-read leaks.
383
+ const response = await withDeadline(
384
+ timeoutMs,
385
+ `Strand-addr dial ${peer.peerId}`,
386
+ (signal) => sendStrandAddr(node, target, protocolId, request, timeoutMs, signal),
387
+ );
388
+ return response.multiaddrs;
389
+ } catch (err) {
390
+ const error = err instanceof Error ? err : new Error(String(err));
391
+ failures.push(`${describeTarget(target)} — ${error.message}`);
392
+ log('Strand-addr dial to %s via %s failed: %o', peer.peerId, describeTarget(target), err);
393
+ }
394
+ }
395
+ log('Strand-addr dial to %s failed on all %d target(s), contributing no addrs: %s',
396
+ peer.peerId, failures.length, failures.join('; '));
397
+ return [];
398
+ }
399
+
400
+ /**
401
+ * Dial targets for a sibling, peerId first. Dialing by peerId lets libp2p reuse
402
+ * an already-open control connection (the common case — the wiring ticket passes
403
+ * connected siblings); the explicit control addrs are a fallback for when no
404
+ * connection is open. An unparsable peerId is skipped (rely on addrs).
405
+ */
406
+ function dialTargets(peer: StrandAddrPeer): Array<PeerId | Multiaddr> {
407
+ const targets: Array<PeerId | Multiaddr> = [];
408
+ try {
409
+ targets.push(peerIdFromString(peer.peerId));
410
+ } catch (err) {
411
+ log('Unparsable peer id %s, falling back to addrs: %o', peer.peerId, err);
412
+ }
413
+ if (peer.addrs) {
414
+ targets.push(...peer.addrs);
415
+ }
416
+ return targets;
417
+ }
418
+
419
+ /** Short label for a dial target (peerId or multiaddr) for logging. */
420
+ function describeTarget(target: PeerId | Multiaddr): string {
421
+ return target.toString();
422
+ }
423
+
424
+ /**
425
+ * Open one stream to a target, send the request, half-close, and read the
426
+ * response.
427
+ *
428
+ * `signal` is the per-attempt deadline from {@link dialOneSibling}: it goes to
429
+ * `dialProtocol` so a timeout during connect aborts the dial, and into
430
+ * {@link exchangeFrame} so a timeout after the stream is open resets it —
431
+ * releasing the otherwise unbounded response-read.
432
+ */
433
+ async function sendStrandAddr(
434
+ node: Libp2p,
435
+ target: PeerId | Multiaddr,
436
+ protocolId: string,
437
+ request: StrandAddrRequest,
438
+ timeoutMs: number,
439
+ signal: AbortSignal,
440
+ ): Promise<StrandAddrResponse> {
441
+ // `runOnLimitedConnection: true`: the sibling may be reachable only over a
442
+ // circuit-relay connection (the signaling-first addr), which libp2p marks
443
+ // "limited". The exchange is a single tiny request→response well within the
444
+ // relay's data/duration cap, so opening it on the limited connection is safe
445
+ // and is the whole point of dialing over the relay.
446
+ const rawStream = await node.dialProtocol(target, protocolId, { runOnLimitedConnection: true, signal });
447
+
448
+ const response = await exchangeFrame(
449
+ rawStream as unknown as ControlStream,
450
+ signal,
451
+ request,
452
+ (stream) => readFrame<StrandAddrResponse>(stream, timeoutMs),
453
+ 'Strand-addr dial aborted by timeout',
454
+ );
455
+
456
+ log('Strand-addr response: %d addr(s) for %s', response.multiaddrs.length, response.strandId);
457
+ return response;
458
+ }