@serfab/cadre-core 1.16.0 → 1.18.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 (268) hide show
  1. package/README.md +4 -3
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +81 -142
  6. package/dist/cadre-invite-protocol.js +168 -458
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +1089 -475
  12. package/dist/cadre-node.js +2170 -1037
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +88 -32
  18. package/dist/control-cohort.js +91 -33
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +856 -318
  21. package/dist/control-database.js +1422 -701
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-formation-recorder.d.ts +46 -43
  24. package/dist/control-formation-recorder.js +77 -78
  25. package/dist/control-formation-recorder.js.map +1 -1
  26. package/dist/control-protocol-guard.d.ts +11 -4
  27. package/dist/control-protocol-guard.js +20 -5
  28. package/dist/control-protocol-guard.js.map +1 -1
  29. package/dist/control-schema-version.d.ts +97 -0
  30. package/dist/control-schema-version.js +225 -0
  31. package/dist/control-schema-version.js.map +1 -0
  32. package/dist/control-schema.d.ts +45 -5
  33. package/dist/control-schema.js +262 -78
  34. package/dist/control-schema.js.map +1 -1
  35. package/dist/control-write-retry.d.ts +45 -10
  36. package/dist/control-write-retry.js +92 -22
  37. package/dist/control-write-retry.js.map +1 -1
  38. package/dist/delegate-admission.d.ts +13 -0
  39. package/dist/delegate-admission.js +27 -0
  40. package/dist/delegate-admission.js.map +1 -1
  41. package/dist/ed25519-key.d.ts +10 -7
  42. package/dist/ed25519-key.js +23 -20
  43. package/dist/ed25519-key.js.map +1 -1
  44. package/dist/edge-node/config.d.ts +122 -0
  45. package/dist/edge-node/config.js +199 -0
  46. package/dist/edge-node/config.js.map +1 -0
  47. package/dist/edge-node/dissolve.d.ts +95 -0
  48. package/dist/edge-node/dissolve.js +184 -0
  49. package/dist/edge-node/dissolve.js.map +1 -0
  50. package/dist/edge-node/edge-join.d.ts +44 -0
  51. package/dist/edge-node/edge-join.js +227 -0
  52. package/dist/edge-node/edge-join.js.map +1 -0
  53. package/dist/edge-node/edge-node.d.ts +295 -0
  54. package/dist/edge-node/edge-node.js +704 -0
  55. package/dist/edge-node/edge-node.js.map +1 -0
  56. package/dist/edge-node/index.d.ts +18 -0
  57. package/dist/edge-node/index.js +18 -0
  58. package/dist/edge-node/index.js.map +1 -0
  59. package/dist/edge-node/join-credential.d.ts +11 -0
  60. package/dist/edge-node/join-credential.js +11 -0
  61. package/dist/edge-node/join-credential.js.map +1 -0
  62. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  63. package/dist/edge-node/lazy-raw-storage.js +134 -0
  64. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  65. package/dist/edge-node/node-local.d.ts +60 -0
  66. package/dist/edge-node/node-local.js +58 -0
  67. package/dist/edge-node/node-local.js.map +1 -0
  68. package/dist/edge-node/options.d.ts +83 -0
  69. package/dist/edge-node/options.js +110 -0
  70. package/dist/edge-node/options.js.map +1 -0
  71. package/dist/edge-node/owner-grant.d.ts +41 -0
  72. package/dist/edge-node/owner-grant.js +38 -0
  73. package/dist/edge-node/owner-grant.js.map +1 -0
  74. package/dist/edge-node/platform.d.ts +140 -0
  75. package/dist/edge-node/platform.js +12 -0
  76. package/dist/edge-node/platform.js.map +1 -0
  77. package/dist/edge-node/strand-move.d.ts +115 -0
  78. package/dist/edge-node/strand-move.js +223 -0
  79. package/dist/edge-node/strand-move.js.map +1 -0
  80. package/dist/edge-node/strands.d.ts +28 -0
  81. package/dist/edge-node/strands.js +60 -0
  82. package/dist/edge-node/strands.js.map +1 -0
  83. package/dist/enrolled-machine-store.d.ts +3 -3
  84. package/dist/enrolled-machine-store.js +3 -3
  85. package/dist/formation-token.d.ts +48 -0
  86. package/dist/formation-token.js +26 -0
  87. package/dist/formation-token.js.map +1 -0
  88. package/dist/index.d.ts +27 -17
  89. package/dist/index.js +39 -17
  90. package/dist/index.js.map +1 -1
  91. package/dist/joined-strand-store.d.ts +29 -12
  92. package/dist/joined-strand-store.js +66 -27
  93. package/dist/joined-strand-store.js.map +1 -1
  94. package/dist/key-store.d.ts +9 -4
  95. package/dist/key-store.js +9 -4
  96. package/dist/key-store.js.map +1 -1
  97. package/dist/link-budget.d.ts +9 -7
  98. package/dist/link-budget.js +9 -7
  99. package/dist/link-budget.js.map +1 -1
  100. package/dist/machine-removal.d.ts +19 -39
  101. package/dist/machine-removal.js +24 -98
  102. package/dist/machine-removal.js.map +1 -1
  103. package/dist/member-redemption.d.ts +256 -0
  104. package/dist/member-redemption.js +490 -0
  105. package/dist/member-redemption.js.map +1 -0
  106. package/dist/membership-view.d.ts +91 -0
  107. package/dist/membership-view.js +110 -0
  108. package/dist/membership-view.js.map +1 -0
  109. package/dist/owner-anchor-sync.d.ts +4 -4
  110. package/dist/owner-anchor-sync.js +16 -15
  111. package/dist/owner-anchor-sync.js.map +1 -1
  112. package/dist/owner-grant-protocol.d.ts +251 -0
  113. package/dist/owner-grant-protocol.js +344 -0
  114. package/dist/owner-grant-protocol.js.map +1 -0
  115. package/dist/owner-key-export.d.ts +70 -0
  116. package/dist/owner-key-export.js +279 -0
  117. package/dist/owner-key-export.js.map +1 -0
  118. package/dist/owner-key-flows.d.ts +55 -0
  119. package/dist/owner-key-flows.js +60 -0
  120. package/dist/owner-key-flows.js.map +1 -0
  121. package/dist/owner-key-store.d.ts +31 -0
  122. package/dist/owner-key-store.js +81 -0
  123. package/dist/owner-key-store.js.map +1 -0
  124. package/dist/owner-keys.d.ts +78 -0
  125. package/dist/owner-keys.js +133 -0
  126. package/dist/owner-keys.js.map +1 -0
  127. package/dist/owner-signer.d.ts +54 -0
  128. package/dist/owner-signer.js +51 -0
  129. package/dist/owner-signer.js.map +1 -0
  130. package/dist/peer-addr-book.d.ts +10 -1
  131. package/dist/peer-addr-book.js +4 -3
  132. package/dist/peer-addr-book.js.map +1 -1
  133. package/dist/peer-authorization.d.ts +83 -18
  134. package/dist/peer-authorization.js +123 -53
  135. package/dist/peer-authorization.js.map +1 -1
  136. package/dist/peer-join-backfill.d.ts +89 -20
  137. package/dist/peer-join-backfill.js +265 -108
  138. package/dist/peer-join-backfill.js.map +1 -1
  139. package/dist/pending-join-runner.d.ts +48 -18
  140. package/dist/pending-join-runner.js +74 -56
  141. package/dist/pending-join-runner.js.map +1 -1
  142. package/dist/provider/rent-node.js +1 -1
  143. package/dist/provider/rent-node.js.map +1 -1
  144. package/dist/seed-bootstrap.d.ts +162 -133
  145. package/dist/seed-bootstrap.js +300 -283
  146. package/dist/seed-bootstrap.js.map +1 -1
  147. package/dist/seed-trust-policy.js +4 -4
  148. package/dist/strand-addr-protocol.d.ts +4 -1
  149. package/dist/strand-addr-protocol.js +8 -2
  150. package/dist/strand-addr-protocol.js.map +1 -1
  151. package/dist/strand-cohort.d.ts +3 -0
  152. package/dist/strand-cohort.js.map +1 -1
  153. package/dist/strand-database.d.ts +24 -2
  154. package/dist/strand-database.js +29 -6
  155. package/dist/strand-database.js.map +1 -1
  156. package/dist/strand-first-sync-gate.d.ts +176 -88
  157. package/dist/strand-first-sync-gate.js +288 -121
  158. package/dist/strand-first-sync-gate.js.map +1 -1
  159. package/dist/strand-formation-deadlines.d.ts +9 -9
  160. package/dist/strand-formation-deadlines.js +9 -9
  161. package/dist/strand-formation-manager.d.ts +23 -17
  162. package/dist/strand-formation-manager.js +46 -44
  163. package/dist/strand-formation-manager.js.map +1 -1
  164. package/dist/strand-formation-protocol.d.ts +5 -8
  165. package/dist/strand-formation-protocol.js +101 -16
  166. package/dist/strand-formation-protocol.js.map +1 -1
  167. package/dist/strand-instance-manager.d.ts +27 -18
  168. package/dist/strand-instance-manager.js +119 -50
  169. package/dist/strand-instance-manager.js.map +1 -1
  170. package/dist/strand-membership-reconciler.d.ts +16 -13
  171. package/dist/strand-membership-reconciler.js +30 -22
  172. package/dist/strand-membership-reconciler.js.map +1 -1
  173. package/dist/strand-membership-writer.d.ts +45 -22
  174. package/dist/strand-membership-writer.js +226 -131
  175. package/dist/strand-membership-writer.js.map +1 -1
  176. package/dist/strand-move.d.ts +6 -5
  177. package/dist/strand-move.js +36 -32
  178. package/dist/strand-move.js.map +1 -1
  179. package/dist/strand-peer-redial.js +2 -3
  180. package/dist/strand-peer-redial.js.map +1 -1
  181. package/dist/strand-revocation-enforcer.d.ts +134 -60
  182. package/dist/strand-revocation-enforcer.js +144 -71
  183. package/dist/strand-revocation-enforcer.js.map +1 -1
  184. package/dist/strand-solicitation.d.ts +50 -39
  185. package/dist/strand-solicitation.js +10 -3
  186. package/dist/strand-solicitation.js.map +1 -1
  187. package/dist/strand-watcher.d.ts +80 -13
  188. package/dist/strand-watcher.js +152 -48
  189. package/dist/strand-watcher.js.map +1 -1
  190. package/dist/table-change-watch.d.ts +63 -0
  191. package/dist/table-change-watch.js +209 -0
  192. package/dist/table-change-watch.js.map +1 -0
  193. package/dist/timeout-scheduler.d.ts +4 -3
  194. package/dist/timeout-scheduler.js +4 -3
  195. package/dist/timeout-scheduler.js.map +1 -1
  196. package/dist/types.d.ts +374 -150
  197. package/dist/types.js +4 -4
  198. package/dist/types.js.map +1 -1
  199. package/package.json +17 -8
  200. package/src/bootstrap-peer-store-file.ts +44 -44
  201. package/src/bootstrap-peer-store.ts +214 -214
  202. package/src/cadre-invite-protocol.ts +740 -1052
  203. package/src/cadre-join-request.ts +24 -3
  204. package/src/cadre-node.ts +2402 -1079
  205. package/src/control-authorization.ts +47 -14
  206. package/src/control-cohort.ts +204 -128
  207. package/src/control-database.ts +1749 -820
  208. package/src/control-formation-recorder.ts +359 -353
  209. package/src/control-protocol-guard.ts +23 -6
  210. package/src/control-schema-version.ts +301 -0
  211. package/src/control-schema.ts +273 -78
  212. package/src/control-write-retry.ts +641 -567
  213. package/src/delegate-admission.ts +351 -314
  214. package/src/ed25519-key.ts +174 -171
  215. package/src/edge-node/config.ts +277 -0
  216. package/src/edge-node/dissolve.ts +251 -0
  217. package/src/edge-node/edge-join.ts +319 -0
  218. package/src/edge-node/edge-node.ts +1045 -0
  219. package/src/edge-node/index.ts +59 -0
  220. package/src/edge-node/join-credential.ts +16 -0
  221. package/src/edge-node/lazy-raw-storage.ts +178 -0
  222. package/src/edge-node/node-local.ts +84 -0
  223. package/src/edge-node/options.ts +179 -0
  224. package/src/edge-node/owner-grant.ts +63 -0
  225. package/src/edge-node/platform.ts +151 -0
  226. package/src/edge-node/strand-move.ts +270 -0
  227. package/src/edge-node/strands.ts +72 -0
  228. package/src/enrolled-machine-store.ts +3 -3
  229. package/src/formation-token.ts +63 -0
  230. package/src/index.ts +899 -789
  231. package/src/joined-strand-store.ts +73 -28
  232. package/src/key-store.ts +10 -4
  233. package/src/link-budget.ts +9 -7
  234. package/src/machine-removal.ts +26 -111
  235. package/src/member-redemption.ts +649 -0
  236. package/src/membership-view.ts +144 -0
  237. package/src/owner-anchor-sync.ts +19 -18
  238. package/src/owner-grant-protocol.ts +547 -0
  239. package/src/owner-key-export.ts +348 -0
  240. package/src/owner-key-flows.ts +109 -0
  241. package/src/owner-key-store.ts +85 -0
  242. package/src/owner-keys.ts +178 -0
  243. package/src/owner-signer.ts +94 -0
  244. package/src/peer-addr-book.ts +4 -3
  245. package/src/peer-authorization.ts +558 -488
  246. package/src/peer-join-backfill.ts +971 -761
  247. package/src/pending-join-runner.ts +76 -53
  248. package/src/provider/rent-node.ts +1 -1
  249. package/src/seed-bootstrap.ts +343 -349
  250. package/src/seed-trust-policy.ts +4 -4
  251. package/src/strand-addr-protocol.ts +547 -538
  252. package/src/strand-cohort.ts +4 -0
  253. package/src/strand-database.ts +352 -319
  254. package/src/strand-first-sync-gate.ts +334 -133
  255. package/src/strand-formation-deadlines.ts +227 -227
  256. package/src/strand-formation-manager.ts +731 -725
  257. package/src/strand-formation-protocol.ts +122 -24
  258. package/src/strand-instance-manager.ts +2092 -2026
  259. package/src/strand-membership-reconciler.ts +33 -25
  260. package/src/strand-membership-writer.ts +2093 -1974
  261. package/src/strand-move.ts +50 -38
  262. package/src/strand-peer-redial.ts +2 -3
  263. package/src/strand-revocation-enforcer.ts +740 -654
  264. package/src/strand-solicitation.ts +61 -34
  265. package/src/strand-watcher.ts +175 -56
  266. package/src/table-change-watch.ts +285 -0
  267. package/src/timeout-scheduler.ts +29 -28
  268. package/src/types.ts +391 -152
@@ -1,538 +1,547 @@
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 request carries no signature. Membership
22
- * is checked before this handler runs, by the control node's protocol guard
23
- * (`control-protocol-guard.ts`, the `members` class: `CadreNode.isAuthorizedMember`,
24
- * a voucher on the requester's `CadrePeer` row verified against the node-local
25
- * trusted-owner anchor). A peer it refuses gets its stream reset, never a reply, so
26
- * an outsider that published its own rows into the replicated control DB can neither
27
- * harvest live strand addresses nor announce a delegate. Cross-party strand bootstrap
28
- * is a different mechanism (strand formation / `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, StrandAddrStatus } from './types.js';
36
- import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
37
- import { orderSignalingFirst } from './peer-record.js';
38
- import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
39
- import { relayedRequestBudgetMs } from './link-budget.js';
40
-
41
- const log = debug('sereus:cadre:strand-addr');
42
-
43
- /** Protocol id for the control-network strand-address RPC (parallel to `/sereus/strand-wake/1.0.0`). */
44
- export const STRAND_ADDR_PROTOCOL = '/sereus/strand-addr/1.0.0';
45
-
46
- /**
47
- * Maximum strand-addr frame size. Responses are tiny (a strand id + a few
48
- * multiaddrs), so this is a defensive cap — far below the 1MB seed ceiling —
49
- * that bounds the bytes a peer can make the receiver buffer per stream.
50
- */
51
- const MAX_ADDR_SIZE = 64 * 1024;
52
-
53
- /** Default time the receiver waits for an inbound request frame before aborting (ms). */
54
- // eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one small request frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
55
- const DEFAULT_ADDR_READ_TIMEOUT_MS = 10_000;
56
-
57
- /** Default cap on concurrent inbound strand-addr streams a single peer can pin open. */
58
- const DEFAULT_MAX_CONCURRENT_ADDRS = 100;
59
-
60
- /**
61
- * Read a libp2p stream to EOF and decode the single length-prefixed JSON frame
62
- * it carries. Bounded by `timeoutMs` (a never-half-closing peer is aborted, not
63
- * awaited forever) and capped at {@link MAX_ADDR_SIZE}, reusing the shared
64
- * {@link decodeLengthPrefixedFrame} guard for the prefix/length checks.
65
- */
66
- async function readFrame<T>(stream: ControlStream, timeoutMs: number): Promise<T> {
67
- const data = await readStreamToEnd(stream, { maxBytes: MAX_ADDR_SIZE, timeoutMs, label: 'Strand-addr' });
68
- const body = decodeLengthPrefixedFrame(data, MAX_ADDR_SIZE);
69
- return JSON.parse(new TextDecoder().decode(body)) as T;
70
- }
71
-
72
- /** An address-free reply, for every path but a lookup: over the cap, an unreadable request, a lookup that threw. */
73
- function addrlessResponse(status: StrandAddrStatus, strandId: string): StrandAddrResponse {
74
- return { status, strandId, multiaddrs: [] };
75
- }
76
-
77
- /**
78
- * Dependencies the {@link StrandAddrService} receiver needs from its host
79
- * (`CadreNode`), injected so the service is testable without a full node.
80
- */
81
- export interface StrandAddrServiceOptions {
82
- /**
83
- * The local strand instance's dialable strand-network multiaddrs, ordered
84
- * signaling-first. Returns `[]` when the strand is not running / has no live
85
- * node (hibernating, quiescing, or never participated). Kept injectable so the
86
- * decision logic is unit-testable without a full node.
87
- */
88
- getStrandMultiaddrs(strandId: string): string[];
89
- /**
90
- * Called when a member's request carries a `delegatePeerId` — the derived
91
- * transport peerId its strand-`strandId` node runs as, with a validated
92
- * (parseable, non-self) peerId. Every caller is an authorized member: the
93
- * protocol guard admitted it before this handler ran.
94
- * `CadreNode` injects its delegate-admission grant recorder so this node's
95
- * connection gate — and thus its circuit-relay server, when it runs one —
96
- * admits that peerId (see `delegate-admission.ts`). Optional: absent, an
97
- * announce is ignored and the address lookup proceeds unchanged.
98
- */
99
- onDelegateAnnounce?(announcerPeerId: string, strandId: string, delegatePeerId: string): void;
100
- /**
101
- * Time to wait for the inbound request frame before aborting the read (ms).
102
- * Defaults to {@link DEFAULT_ADDR_READ_TIMEOUT_MS}. Bounds a buggy/compromised
103
- * own-cadre node that opens a stream and never half-closes its write end.
104
- */
105
- readTimeoutMs?: number;
106
- /**
107
- * Cap on concurrent inbound strand-addr streams (defaults to
108
- * {@link DEFAULT_MAX_CONCURRENT_ADDRS}). Over the cap, an `unavailable` reply is
109
- * returned without looking up any strand address.
110
- */
111
- maxConcurrent?: number;
112
- }
113
-
114
- /**
115
- * Receiver side of the strand-address RPC. Registers a `STRAND_ADDR_PROTOCOL`
116
- * handler on the control node and, for each inbound {@link StrandAddrRequest}
117
- * (from a peer the protocol guard has already admitted as an authorized member),
118
- * replies with the local strand instance's live multiaddrs — `ok` with an empty
119
- * list when the strand is not running, `unavailable` when it could not answer at all.
120
- */
121
- export class StrandAddrService {
122
- private readonly options: StrandAddrServiceOptions;
123
- private readonly readTimeoutMs: number;
124
- private readonly maxConcurrent: number;
125
- private node: Libp2p | null = null;
126
- /** In-flight inbound strand-addr streams, used to enforce {@link maxConcurrent}. */
127
- private activeStreams = 0;
128
-
129
- constructor(options: StrandAddrServiceOptions) {
130
- this.options = options;
131
- this.readTimeoutMs = options.readTimeoutMs ?? DEFAULT_ADDR_READ_TIMEOUT_MS;
132
- this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_ADDRS;
133
- }
134
-
135
- /** Number of in-flight inbound strand-addr streams. */
136
- get activeCount(): number {
137
- return this.activeStreams;
138
- }
139
-
140
- /**
141
- * Register the strand-addr protocol handler on the control node. Rejects when
142
- * libp2p refuses the registration (a duplicate handler, a peer-store write
143
- * failure); the node reference is kept only once the handler is in place.
144
- */
145
- async initialize(node: Libp2p): Promise<void> {
146
- // `runOnLimitedConnection: true` is REQUIRED for the relay path: a NAT'd
147
- // sibling is reached over a circuit-relay connection, which libp2p marks
148
- // "limited" (the relay caps its data/duration). Without this the receiver
149
- // would refuse the inbound stream on exactly the connection the protocol is
150
- // designed to use (same reasoning as wake; see the relay note on
151
- // `collectStrandAddrs`).
152
- await node.handle(STRAND_ADDR_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
153
- const remotePeerId = (rawConnection as Connection).remotePeer.toString();
154
- await this.handleStream(rawStream as ControlStream, remotePeerId);
155
- }, { runOnLimitedConnection: true });
156
- this.node = node;
157
- log('StrandAddrService registered handler: %s', STRAND_ADDR_PROTOCOL);
158
- }
159
-
160
- /** Unregister the handler and release the node reference. */
161
- async shutdown(): Promise<void> {
162
- if (this.node) {
163
- await this.node.unhandle(STRAND_ADDR_PROTOCOL);
164
- this.node = null;
165
- log('StrandAddrService shutdown');
166
- }
167
- }
168
-
169
- /**
170
- * Read the inbound request, decide the response, and write it back.
171
- *
172
- * Three hardening layers, all reported as an `unavailable`
173
- * {@link StrandAddrResponse} rather than a dropped/hung stream: a concurrency cap
174
- * (over {@link maxConcurrent}, reply without looking up any address), a read
175
- * timeout (a peer that never half-closes is aborted inside {@link readFrame}/
176
- * `readStreamToEnd`), and the existing malformed/oversized-frame guard. A
177
- * lookup that throws is `unavailable` too, never an empty `ok` the asker would
178
- * wait ten minutes on.
179
- */
180
- private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
181
- log('Incoming strand-addr request from: %s', remotePeerId);
182
-
183
- if (this.activeStreams >= this.maxConcurrent) {
184
- log('Rejecting strand-addr from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrent);
185
- // The request frame is unread here, so the strand id is unknown — reply
186
- // under an empty strand id; the asker retries an `unavailable` sibling soon.
187
- await replyAndClose(stream, addrlessResponse('unavailable', ''), 'Strand-addr');
188
- return;
189
- }
190
-
191
- this.activeStreams++;
192
- try {
193
- await replyAndClose(stream, await this.answerStream(stream, remotePeerId), 'Strand-addr');
194
- } finally {
195
- this.activeStreams--;
196
- }
197
- }
198
-
199
- /** Read and decide one inbound request; any failure becomes an `unavailable` response. */
200
- private async answerStream(stream: ControlStream, remotePeerId: string): Promise<StrandAddrResponse> {
201
- try {
202
- const request = await readFrame<StrandAddrRequest>(stream, this.readTimeoutMs);
203
- return await this.processAddrRequest(request, remotePeerId);
204
- } catch (err) {
205
- log('Error handling strand-addr request from %s: %o', remotePeerId, err);
206
- // Malformed/oversized/timed-out request, or a lookup that threw: reply
207
- // under an empty strand id rather than hanging.
208
- return addrlessResponse('unavailable', '');
209
- }
210
- }
211
-
212
- /**
213
- * Decide the response for a decoded request. Exposed (not private) so the
214
- * decision matrix can be unit-tested directly (mirrors wake's
215
- * `processWakeRequest`).
216
- *
217
- * - Request carrying a `delegatePeerId` → recorded via
218
- * {@link StrandAddrServiceOptions.onDelegateAnnounce} before the lookup.
219
- * - Strand not running locally → `ok` with empty `multiaddrs` (`getStrandMultiaddrs` → `[]`).
220
- * - Running strand → `ok` with the strand's live, signaling-first multiaddrs.
221
- *
222
- * A throwing lookup propagates; {@link handleStream} answers it `unavailable`.
223
- */
224
- async processAddrRequest(request: StrandAddrRequest, remotePeerId: string): Promise<StrandAddrResponse> {
225
- this.recordDelegateAnnounce(request, remotePeerId);
226
-
227
- const multiaddrs = this.options.getStrandMultiaddrs(request.strandId);
228
- log('Strand-addr for %s → %d addr(s)', request.strandId, multiaddrs.length);
229
- return { status: 'ok', strandId: request.strandId, multiaddrs };
230
- }
231
-
232
- /**
233
- * Validate and forward a member's delegate announcement. A malformed field
234
- * (unparsable peerId) and a self-announcement (`delegatePeerId` equal to the
235
- * announcer's own peerId — a member is admitted directly, never by grant)
236
- * are logged and dropped; the address lookup proceeds either way.
237
- */
238
- private recordDelegateAnnounce(request: StrandAddrRequest, remotePeerId: string): void {
239
- const { strandId, delegatePeerId } = request;
240
- if (delegatePeerId === undefined || !this.options.onDelegateAnnounce) {
241
- return;
242
- }
243
- if (delegatePeerId === remotePeerId) {
244
- log('Ignoring self-announcement from %s (strand %s)', remotePeerId, strandId);
245
- return;
246
- }
247
- try {
248
- peerIdFromString(delegatePeerId);
249
- } catch (err) {
250
- log('Ignoring malformed delegatePeerId %o from %s: %o', delegatePeerId, remotePeerId, err);
251
- return;
252
- }
253
- this.options.onDelegateAnnounce(remotePeerId, strandId, delegatePeerId);
254
- }
255
- }
256
-
257
- /** A sibling to ask for its strand address. */
258
- export interface StrandAddrPeer {
259
- /** The sibling's control-network peer id (also its strand-node peer id). */
260
- peerId: string;
261
- /**
262
- * Pre-resolved control-network multiaddrs, used as a fallback when no control
263
- * connection is already open. Dialing by {@link peerId} is preferred (it
264
- * reuses an existing connection); these are tried only if that fails.
265
- */
266
- addrs?: Multiaddr[];
267
- }
268
-
269
- /** Options for {@link collectStrandAddrs}. */
270
- export interface CollectStrandAddrsOptions {
271
- /**
272
- * The asker's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which the
273
- * per-dial timeout is derived when {@link timeoutMs} is not given. Unset means the declared
274
- * default.
275
- */
276
- linkRoundTripMs?: number;
277
- /** Per-dial timeout in ms (default: {@link attemptTimeoutMs}). */
278
- timeoutMs?: number;
279
- /** Override the protocol id (defaults to {@link STRAND_ADDR_PROTOCOL}). */
280
- protocolId?: string;
281
- /**
282
- * The derived transport peerId the caller's own strand-`strandId` node runs
283
- * (or is about to run) as. Set on every request this collection sends, so
284
- * each receiver records a delegate admission grant for it — see
285
- * {@link StrandAddrRequest.delegatePeerId}. Omit for a pure address lookup.
286
- */
287
- delegatePeerId?: string;
288
- }
289
-
290
- /**
291
- * What asking one sibling produced, as {@link collectStrandAddrs} reports it.
292
- *
293
- * - `answered` — status `ok` with at least one address.
294
- * - `empty` — status `ok` with no address: the sibling does not run the strand
295
- * right now, a normal steady state.
296
- * - `unavailable` — the sibling replied that it could not answer.
297
- * - `unreachable` — no reply at all: every dial target failed or timed out, the
298
- * reply was malformed, or the sibling's protocol guard reset the stream because
299
- * it does not (yet) count the asker as an authorized member.
300
- */
301
- export type StrandAddrOutcome = 'answered' | 'empty' | 'unavailable' | 'unreachable';
302
-
303
- /** Result of {@link collectStrandAddrs}. */
304
- export interface StrandAddrCollection {
305
- /** Deduplicated union of every answer, signaling-first. */
306
- addrs: string[];
307
- /** One entry per candidate (self excluded), keyed by the sibling's control peerId. */
308
- outcomes: Map<string, StrandAddrOutcome>;
309
- }
310
-
311
- /** One sibling's share of a {@link StrandAddrCollection}. */
312
- interface SiblingAnswer {
313
- outcome: StrandAddrOutcome;
314
- multiaddrs: string[];
315
- }
316
-
317
- /**
318
- * Client side: ask each candidate sibling for its live strand-`strandId`
319
- * multiaddrs and return the **deduplicated union** of every answer, ordered
320
- * signaling-first, alongside each sibling's {@link StrandAddrOutcome}.
321
- *
322
- * Best-effort per peer: a failed/timed-out/empty sibling contributes no address
323
- * and never fails the collection — an empty union (no sibling online or running
324
- * the strand) is an acceptable seed that self-heals on the next resume/reconcile
325
- * pass. `outcomes` is what lets a caller retry the siblings that could not answer
326
- * sooner than the ones that answered with nothing. The local node (`node.peerId`)
327
- * is excluded so we never RPC ourselves or seed with our own strand address.
328
- *
329
- * Dials run concurrently but the union preserves candidate order, so the result
330
- * is deterministic regardless of which sibling answers first.
331
- */
332
- export async function collectStrandAddrs(
333
- node: Libp2p,
334
- peers: StrandAddrPeer[],
335
- strandId: string,
336
- options: CollectStrandAddrsOptions = {}
337
- ): Promise<StrandAddrCollection> {
338
- const selfId = node.peerId.toString();
339
- const request: StrandAddrRequest = options.delegatePeerId !== undefined
340
- ? { strandId, delegatePeerId: options.delegatePeerId }
341
- : { strandId };
342
- const candidates = peers.filter(p => p.peerId !== selfId);
343
-
344
- // Concurrent dials; `dialOneSibling` folds per-peer failure into an outcome, so
345
- // `Promise.all` never rejects and the result array stays in candidate order.
346
- const answers = await Promise.all(
347
- candidates.map(async (peer) => ({ peerId: peer.peerId, ...await dialOneSibling(node, peer, request, options) }))
348
- );
349
-
350
- const outcomes = new Map<string, StrandAddrOutcome>();
351
- // Deduplicated union in candidate order, then signaling-first for a usable
352
- // dial sequence (relay/`p2p-circuit` ahead of direct addrs).
353
- const seen = new Set<string>();
354
- const union: string[] = [];
355
- for (const { peerId, outcome, multiaddrs } of answers) {
356
- outcomes.set(peerId, outcome);
357
- for (const addr of multiaddrs) {
358
- if (!seen.has(addr)) {
359
- seen.add(addr);
360
- union.push(addr);
361
- }
362
- }
363
- }
364
- return { addrs: orderSignalingFirst(union), outcomes };
365
- }
366
-
367
- /**
368
- * Ask one sibling for its strand address. Tries each dial target in order (peerId
369
- * first to reuse an open control connection, then explicit addrs) until one
370
- * produces a well-formed reply, and reports that reply's outcome; a total failure
371
- * is `unreachable`, so a single dead sibling never aborts the collection. Never
372
- * throws.
373
- *
374
- * A reply of any status ends the loop: every target reaches the same responder,
375
- * so asking it again by another address would get the same answer.
376
- *
377
- * A total failure logs ONE line naming every target and its cause, because
378
- * per-target lines scattered through a concurrent fan-out do not reassemble into
379
- * "this sibling was unreachable".
380
- *
381
- * NOTE: cost is (targets × `timeoutMs`, 28.5 s each at the default declared link) with
382
- * no whole-sibling budget, the same shape `dialWake` bounds with
383
- * `DEFAULT_WAKE_DIAL_BUDGET_MS`. Siblings are asked concurrently, so a collection costs
384
- * its slowest sibling, not the sum. Fine today — `dialTargets` yields the peerId plus
385
- * whatever `resolvePeerAddrs` returned, which for a cadre device is one or two
386
- * addresses. If a sibling's record ever carries a long address list, give this the same
387
- * whole-sibling budget.
388
- */
389
- async function dialOneSibling(
390
- node: Libp2p,
391
- peer: StrandAddrPeer,
392
- request: StrandAddrRequest,
393
- options: CollectStrandAddrsOptions
394
- ): Promise<SiblingAnswer> {
395
- const protocolId = options.protocolId ?? STRAND_ADDR_PROTOCOL;
396
- const timeoutMs = attemptTimeoutMs(options);
397
- const targets = dialTargets(peer);
398
-
399
- if (targets.length === 0) {
400
- log('No dial target for sibling %s', peer.peerId);
401
- return { outcome: 'unreachable', multiaddrs: [] };
402
- }
403
-
404
- const failures: string[] = [];
405
- for (const target of targets) {
406
- try {
407
- // One deadline per attempt: its signal aborts the in-flight dialProtocol and
408
- // resets the live stream, so neither the connect nor the response-read leaks.
409
- const response = await withDeadline(
410
- timeoutMs,
411
- `Strand-addr dial ${peer.peerId}`,
412
- (signal) => sendStrandAddr(node, target, protocolId, request, timeoutMs, signal),
413
- );
414
- return siblingAnswer(response);
415
- } catch (err) {
416
- const error = err instanceof Error ? err : new Error(String(err));
417
- failures.push(`${describeTarget(target)} — ${error.message}`);
418
- log('Strand-addr dial to %s via %s failed: %o', peer.peerId, describeTarget(target), err);
419
- }
420
- }
421
- log('Strand-addr dial to %s failed on all %d target(s), contributing no addrs: %s',
422
- peer.peerId, failures.length, failures.join('; '));
423
- return { outcome: 'unreachable', multiaddrs: [] };
424
- }
425
-
426
- /**
427
- * Deadline for ONE attempt to ask a sibling — dial, request, response — unless the caller set
428
- * its own: `relayedRequestBudgetMs` at the declared link (28.5 s at the default).
429
- *
430
- * An attempt is either a dial by peer id, which normally reuses an open control connection
431
- * (`CIRCUIT_REQUEST_ROUND_TRIPS`, 2), or the fallback to the sibling's control addresses, a
432
- * fresh and possibly relayed dial. One deadline covers both, so it is sized for the fallback.
433
- *
434
- * NOTE: the receiver's membership check (two live control reads) runs inside this deadline and
435
- * is not counted. In steady state those reads touch only held blocks and do not consult the
436
- * cohort. If a wake or address request is seen timing out while the receiver's membership read
437
- * is consulting, count one membership decision in this deadline or answer the check from the
438
- * materialized authorized-peer snapshot. See docs/cadre-consistency.md → "Deadlines Over
439
- * Optimystic's Reads and Commits".
440
- */
441
- function attemptTimeoutMs(options: CollectStrandAddrsOptions): number {
442
- return options.timeoutMs ?? relayedRequestBudgetMs(options.linkRoundTripMs);
443
- }
444
-
445
- /** Map a validated reply to its outcome; only an `ok` reply contributes addresses. */
446
- function siblingAnswer(response: StrandAddrResponse): SiblingAnswer {
447
- if (response.status !== 'ok') {
448
- return { outcome: response.status, multiaddrs: [] };
449
- }
450
- return {
451
- outcome: response.multiaddrs.length > 0 ? 'answered' : 'empty',
452
- multiaddrs: response.multiaddrs
453
- };
454
- }
455
-
456
- /**
457
- * Dial targets for a sibling, peerId first. Dialing by peerId lets libp2p reuse
458
- * an already-open control connection (the common case — the wiring ticket passes
459
- * connected siblings); the explicit control addrs are a fallback for when no
460
- * connection is open. An unparsable peerId is skipped (rely on addrs).
461
- */
462
- function dialTargets(peer: StrandAddrPeer): Array<PeerId | Multiaddr> {
463
- const targets: Array<PeerId | Multiaddr> = [];
464
- try {
465
- targets.push(peerIdFromString(peer.peerId));
466
- } catch (err) {
467
- log('Unparsable peer id %s, falling back to addrs: %o', peer.peerId, err);
468
- }
469
- if (peer.addrs) {
470
- targets.push(...peer.addrs);
471
- }
472
- return targets;
473
- }
474
-
475
- /** Short label for a dial target (peerId or multiaddr) for logging. */
476
- function describeTarget(target: PeerId | Multiaddr): string {
477
- return target.toString();
478
- }
479
-
480
- /**
481
- * Open one stream to a target, send the request, half-close, and read the
482
- * response.
483
- *
484
- * `signal` is the per-attempt deadline from {@link dialOneSibling}: it goes to
485
- * `dialProtocol` so a timeout during connect aborts the dial, and into
486
- * {@link exchangeFrame} so a timeout after the stream is open resets it —
487
- * releasing the otherwise unbounded response-read.
488
- */
489
- async function sendStrandAddr(
490
- node: Libp2p,
491
- target: PeerId | Multiaddr,
492
- protocolId: string,
493
- request: StrandAddrRequest,
494
- timeoutMs: number,
495
- signal: AbortSignal,
496
- ): Promise<StrandAddrResponse> {
497
- // `runOnLimitedConnection: true`: the sibling may be reachable only over a
498
- // circuit-relay connection (the signaling-first addr), which libp2p marks
499
- // "limited". The exchange is a single tiny request→response well within the
500
- // relay's data/duration cap, so opening it on the limited connection is safe
501
- // and is the whole point of dialing over the relay.
502
- const rawStream = await node.dialProtocol(target, protocolId, { runOnLimitedConnection: true, signal });
503
-
504
- const response = await exchangeFrame(
505
- rawStream as unknown as ControlStream,
506
- signal,
507
- request,
508
- (stream) => readFrame<unknown>(stream, timeoutMs),
509
- 'Strand-addr dial aborted by timeout',
510
- );
511
- if (!isStrandAddrResponse(response)) {
512
- throw new Error('Malformed strand-addr response');
513
- }
514
-
515
- log('Strand-addr response: %s, %d addr(s) for %s', response.status, response.multiaddrs.length, response.strandId);
516
- return response;
517
- }
518
-
519
- const STRAND_ADDR_STATUSES: ReadonlySet<string> = new Set<StrandAddrStatus>(['ok', 'unavailable']);
520
-
521
- /**
522
- * Shape check on a decoded reply: the responder is another machine, so a reply
523
- * missing its status (an older responder, or a buggy one) or carrying anything but
524
- * strings in `multiaddrs` is a failed exchange, not an answer.
525
- *
526
- * Deliberately not `sanitizeStrandAddrs` (strand-formation-protocol.ts): that drops
527
- * bad entries and caps the list at 16, which suits a cross-party formation result
528
- * but would silently truncate a sibling's full address list here.
529
- */
530
- function isStrandAddrResponse(value: unknown): value is StrandAddrResponse {
531
- if (typeof value !== 'object' || value === null) {
532
- return false;
533
- }
534
- const { status, strandId, multiaddrs } = value as Record<string, unknown>;
535
- return typeof status === 'string' && STRAND_ADDR_STATUSES.has(status)
536
- && typeof strandId === 'string'
537
- && Array.isArray(multiaddrs) && multiaddrs.every((addr) => typeof addr === 'string');
538
- }
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 request carries no signature. Membership
22
+ * is checked before this handler runs, by the control node's protocol guard
23
+ * (`control-protocol-guard.ts`, the `members` class: `CadreNode.isAuthorizedMember`,
24
+ * a voucher on the requester's `CadrePeer` row verified against the node-local
25
+ * trusted-owner anchor). A peer it refuses gets its stream reset, never a reply, so
26
+ * an outsider that published its own rows into the replicated control DB can neither
27
+ * harvest live strand addresses nor announce a delegate. Cross-party strand bootstrap
28
+ * is a different mechanism (strand formation / `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, StrandAddrStatus } from './types.js';
36
+ import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
37
+ import { orderSignalingFirst } from './peer-record.js';
38
+ import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
39
+ import { relayedRequestBudgetMs } from './link-budget.js';
40
+
41
+ const log = debug('sereus:cadre:strand-addr');
42
+
43
+ /** Protocol id for the control-network strand-address RPC (parallel to `/sereus/strand-wake/1.0.0`). */
44
+ export const STRAND_ADDR_PROTOCOL = '/sereus/strand-addr/1.0.0';
45
+
46
+ /**
47
+ * Maximum strand-addr frame size. Responses are tiny (a strand id + a few
48
+ * multiaddrs), so this is a defensive cap — far below the 1MB seed ceiling —
49
+ * that bounds the bytes a peer can make the receiver buffer per stream.
50
+ */
51
+ const MAX_ADDR_SIZE = 64 * 1024;
52
+
53
+ /** Default time the receiver waits for an inbound request frame before aborting (ms). */
54
+ // eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one small request frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
55
+ const DEFAULT_ADDR_READ_TIMEOUT_MS = 10_000;
56
+
57
+ /** Default cap on concurrent inbound strand-addr streams a single peer can pin open. */
58
+ const DEFAULT_MAX_CONCURRENT_ADDRS = 100;
59
+
60
+ /**
61
+ * Read a libp2p stream to EOF and decode the single length-prefixed JSON frame
62
+ * it carries. Bounded by `timeoutMs` (a never-half-closing peer is aborted, not
63
+ * awaited forever) and capped at {@link MAX_ADDR_SIZE}, reusing the shared
64
+ * {@link decodeLengthPrefixedFrame} guard for the prefix/length checks.
65
+ */
66
+ async function readFrame<T>(stream: ControlStream, timeoutMs: number): Promise<T> {
67
+ const data = await readStreamToEnd(stream, { maxBytes: MAX_ADDR_SIZE, timeoutMs, label: 'Strand-addr' });
68
+ const body = decodeLengthPrefixedFrame(data, MAX_ADDR_SIZE);
69
+ return JSON.parse(new TextDecoder().decode(body)) as T;
70
+ }
71
+
72
+ /** An address-free reply, for every path but a lookup: over the cap, an unreadable request, a lookup that threw. */
73
+ function addrlessResponse(status: StrandAddrStatus, strandId: string): StrandAddrResponse {
74
+ return { status, strandId, multiaddrs: [] };
75
+ }
76
+
77
+ /**
78
+ * Dependencies the {@link StrandAddrService} receiver needs from its host
79
+ * (`CadreNode`), injected so the service is testable without a full node.
80
+ */
81
+ export interface StrandAddrServiceOptions {
82
+ /**
83
+ * The local strand instance's dialable strand-network multiaddrs, ordered
84
+ * signaling-first. Returns `[]` when the strand is not running / has no live
85
+ * node (hibernating, quiescing, or never participated). Kept injectable so the
86
+ * decision logic is unit-testable without a full node.
87
+ */
88
+ getStrandMultiaddrs(strandId: string): string[];
89
+ /**
90
+ * Called when a member's request carries a `delegatePeerId` — the derived
91
+ * transport peerId its strand-`strandId` node runs as, with a validated
92
+ * (parseable, non-self) peerId. Every caller is an authorized member: the
93
+ * protocol guard admitted it before this handler ran.
94
+ * `CadreNode` injects its delegate-admission grant recorder so this node's
95
+ * connection gate — and thus its circuit-relay server, when it runs one —
96
+ * admits that peerId (see `delegate-admission.ts`). Optional: absent, an
97
+ * announce is ignored and the address lookup proceeds unchanged.
98
+ */
99
+ onDelegateAnnounce?(announcerPeerId: string, strandId: string, delegatePeerId: string): void;
100
+ /**
101
+ * Time to wait for the inbound request frame before aborting the read (ms).
102
+ * Defaults to {@link DEFAULT_ADDR_READ_TIMEOUT_MS}. Bounds a buggy/compromised
103
+ * own-cadre node that opens a stream and never half-closes its write end.
104
+ */
105
+ readTimeoutMs?: number;
106
+ /**
107
+ * Cap on concurrent inbound strand-addr streams (defaults to
108
+ * {@link DEFAULT_MAX_CONCURRENT_ADDRS}). Over the cap, an `unavailable` reply is
109
+ * returned without looking up any strand address.
110
+ */
111
+ maxConcurrent?: number;
112
+ }
113
+
114
+ /**
115
+ * Receiver side of the strand-address RPC. Registers a `STRAND_ADDR_PROTOCOL`
116
+ * handler on the control node and, for each inbound {@link StrandAddrRequest}
117
+ * (from a peer the protocol guard has already admitted as an authorized member),
118
+ * replies with the local strand instance's live multiaddrs — `ok` with an empty
119
+ * list when the strand is not running, `unavailable` when it could not answer at all.
120
+ */
121
+ export class StrandAddrService {
122
+ private readonly options: StrandAddrServiceOptions;
123
+ private readonly readTimeoutMs: number;
124
+ private readonly maxConcurrent: number;
125
+ private node: Libp2p | null = null;
126
+ /** In-flight inbound strand-addr streams, used to enforce {@link maxConcurrent}. */
127
+ private activeStreams = 0;
128
+
129
+ constructor(options: StrandAddrServiceOptions) {
130
+ this.options = options;
131
+ this.readTimeoutMs = options.readTimeoutMs ?? DEFAULT_ADDR_READ_TIMEOUT_MS;
132
+ this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_ADDRS;
133
+ }
134
+
135
+ /** Number of in-flight inbound strand-addr streams. */
136
+ get activeCount(): number {
137
+ return this.activeStreams;
138
+ }
139
+
140
+ /**
141
+ * Register the strand-addr protocol handler on the control node. Rejects when
142
+ * libp2p refuses the registration (a duplicate handler, a peer-store write
143
+ * failure); the node reference is kept only once the handler is in place.
144
+ */
145
+ async initialize(node: Libp2p): Promise<void> {
146
+ // `runOnLimitedConnection: true` is REQUIRED for the relay path: a NAT'd
147
+ // sibling is reached over a circuit-relay connection, which libp2p marks
148
+ // "limited" (the relay caps its data/duration). Without this the receiver
149
+ // would refuse the inbound stream on exactly the connection the protocol is
150
+ // designed to use (same reasoning as wake; see the relay note on
151
+ // `collectStrandAddrs`).
152
+ await node.handle(STRAND_ADDR_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
153
+ const remotePeerId = (rawConnection as Connection).remotePeer.toString();
154
+ await this.handleStream(rawStream as ControlStream, remotePeerId);
155
+ }, { runOnLimitedConnection: true });
156
+ this.node = node;
157
+ log('StrandAddrService registered handler: %s', STRAND_ADDR_PROTOCOL);
158
+ }
159
+
160
+ /** Unregister the handler and release the node reference. */
161
+ async shutdown(): Promise<void> {
162
+ if (this.node) {
163
+ await this.node.unhandle(STRAND_ADDR_PROTOCOL);
164
+ this.node = null;
165
+ log('StrandAddrService shutdown');
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Read the inbound request, decide the response, and write it back.
171
+ *
172
+ * Three hardening layers, all reported as an `unavailable`
173
+ * {@link StrandAddrResponse} rather than a dropped/hung stream: a concurrency cap
174
+ * (over {@link maxConcurrent}, reply without looking up any address), a read
175
+ * timeout (a peer that never half-closes is aborted inside {@link readFrame}/
176
+ * `readStreamToEnd`), and the existing malformed/oversized-frame guard. A
177
+ * lookup that throws is `unavailable` too, never an empty `ok` the asker would
178
+ * wait ten minutes on.
179
+ */
180
+ private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
181
+ log('Incoming strand-addr request from: %s', remotePeerId);
182
+
183
+ if (this.activeStreams >= this.maxConcurrent) {
184
+ log('Rejecting strand-addr from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrent);
185
+ // The request frame is unread here, so the strand id is unknown — reply
186
+ // under an empty strand id; the asker retries an `unavailable` sibling soon.
187
+ await replyAndClose(stream, addrlessResponse('unavailable', ''), 'Strand-addr');
188
+ return;
189
+ }
190
+
191
+ this.activeStreams++;
192
+ try {
193
+ await replyAndClose(stream, await this.answerStream(stream, remotePeerId), 'Strand-addr');
194
+ } finally {
195
+ this.activeStreams--;
196
+ }
197
+ }
198
+
199
+ /** Read and decide one inbound request; any failure becomes an `unavailable` response. */
200
+ private async answerStream(stream: ControlStream, remotePeerId: string): Promise<StrandAddrResponse> {
201
+ try {
202
+ const request = await readFrame<StrandAddrRequest>(stream, this.readTimeoutMs);
203
+ return await this.processAddrRequest(request, remotePeerId);
204
+ } catch (err) {
205
+ log('Error handling strand-addr request from %s: %o', remotePeerId, err);
206
+ // Malformed/oversized/timed-out request, or a lookup that threw: reply
207
+ // under an empty strand id rather than hanging.
208
+ return addrlessResponse('unavailable', '');
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Decide the response for a decoded request. Exposed (not private) so the
214
+ * decision matrix can be unit-tested directly (mirrors wake's
215
+ * `processWakeRequest`).
216
+ *
217
+ * - Request carrying a `delegatePeerId` → recorded via
218
+ * {@link StrandAddrServiceOptions.onDelegateAnnounce} before the lookup.
219
+ * - Strand not running locally → `ok` with empty `multiaddrs` (`getStrandMultiaddrs` → `[]`).
220
+ * - Running strand → `ok` with the strand's live, signaling-first multiaddrs.
221
+ *
222
+ * A throwing lookup propagates; {@link handleStream} answers it `unavailable`.
223
+ */
224
+ async processAddrRequest(request: StrandAddrRequest, remotePeerId: string): Promise<StrandAddrResponse> {
225
+ this.recordDelegateAnnounce(request, remotePeerId);
226
+
227
+ const multiaddrs = this.options.getStrandMultiaddrs(request.strandId);
228
+ log('Strand-addr for %s → %d addr(s)', request.strandId, multiaddrs.length);
229
+ return { status: 'ok', strandId: request.strandId, multiaddrs };
230
+ }
231
+
232
+ /**
233
+ * Validate and forward a member's delegate announcement. A malformed field
234
+ * (unparsable peerId) and a self-announcement (`delegatePeerId` equal to the
235
+ * announcer's own peerId — a member is admitted directly, never by grant)
236
+ * are logged and dropped; the address lookup proceeds either way.
237
+ */
238
+ private recordDelegateAnnounce(request: StrandAddrRequest, remotePeerId: string): void {
239
+ const { strandId, delegatePeerId } = request;
240
+ if (delegatePeerId === undefined || !this.options.onDelegateAnnounce) {
241
+ return;
242
+ }
243
+ if (delegatePeerId === remotePeerId) {
244
+ log('Ignoring self-announcement from %s (strand %s)', remotePeerId, strandId);
245
+ return;
246
+ }
247
+ try {
248
+ peerIdFromString(delegatePeerId);
249
+ } catch (err) {
250
+ log('Ignoring malformed delegatePeerId %o from %s: %o', delegatePeerId, remotePeerId, err);
251
+ return;
252
+ }
253
+ this.options.onDelegateAnnounce(remotePeerId, strandId, delegatePeerId);
254
+ }
255
+ }
256
+
257
+ /** A sibling to ask for its strand address. */
258
+ export interface StrandAddrPeer {
259
+ /** The sibling's control-network peer id (also its strand-node peer id). */
260
+ peerId: string;
261
+ /**
262
+ * Pre-resolved control-network multiaddrs, used as a fallback when no control
263
+ * connection is already open. Dialing by {@link peerId} is preferred (it
264
+ * reuses an existing connection); these are tried only if that fails.
265
+ */
266
+ addrs?: Multiaddr[];
267
+ }
268
+
269
+ /** Options for {@link collectStrandAddrs}. */
270
+ export interface CollectStrandAddrsOptions {
271
+ /**
272
+ * The asker's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which the
273
+ * per-dial timeout is derived when {@link timeoutMs} is not given. Unset means the declared
274
+ * default.
275
+ */
276
+ linkRoundTripMs?: number;
277
+ /** Per-dial timeout in ms (default: {@link attemptTimeoutMs}). */
278
+ timeoutMs?: number;
279
+ /** Override the protocol id (defaults to {@link STRAND_ADDR_PROTOCOL}). */
280
+ protocolId?: string;
281
+ /**
282
+ * The derived transport peerId the caller's own strand-`strandId` node runs
283
+ * (or is about to run) as. Set on every request this collection sends, so
284
+ * each receiver records a delegate admission grant for it — see
285
+ * {@link StrandAddrRequest.delegatePeerId}. Omit for a pure address lookup.
286
+ */
287
+ delegatePeerId?: string;
288
+ }
289
+
290
+ /**
291
+ * What asking one sibling produced, as {@link collectStrandAddrs} reports it.
292
+ *
293
+ * - `answered` — status `ok` with at least one address.
294
+ * - `empty` — status `ok` with no address: the sibling does not run the strand
295
+ * right now, a normal steady state.
296
+ * - `unavailable` — the sibling replied that it could not answer.
297
+ * - `unsupported` — the peer was reached and its protocol negotiation refused
298
+ * strand-addr: it registers no handler for it (a dedicated relay, an older
299
+ * build) or has not registered one yet (a node still starting).
300
+ * - `unreachable` — no reply at all: every dial target failed or timed out, the
301
+ * reply was malformed, or the sibling's protocol guard reset the stream because
302
+ * it does not (yet) count the asker as an authorized member.
303
+ */
304
+ export type StrandAddrOutcome = 'answered' | 'empty' | 'unavailable' | 'unsupported' | 'unreachable';
305
+
306
+ /** Result of {@link collectStrandAddrs}. */
307
+ export interface StrandAddrCollection {
308
+ /** Deduplicated union of every answer, signaling-first. */
309
+ addrs: string[];
310
+ /** One entry per candidate (self excluded), keyed by the sibling's control peerId. */
311
+ outcomes: Map<string, StrandAddrOutcome>;
312
+ }
313
+
314
+ /** One sibling's share of a {@link StrandAddrCollection}. */
315
+ interface SiblingAnswer {
316
+ outcome: StrandAddrOutcome;
317
+ multiaddrs: string[];
318
+ }
319
+
320
+ /**
321
+ * Client side: ask each candidate sibling for its live strand-`strandId`
322
+ * multiaddrs and return the **deduplicated union** of every answer, ordered
323
+ * signaling-first, alongside each sibling's {@link StrandAddrOutcome}.
324
+ *
325
+ * Best-effort per peer: a failed/timed-out/empty sibling contributes no address
326
+ * and never fails the collection — an empty union (no sibling online or running
327
+ * the strand) is an acceptable seed that self-heals on the next resume/reconcile
328
+ * pass. `outcomes` is what lets a caller retry the siblings that could not answer
329
+ * sooner than the ones that answered with nothing. The local node (`node.peerId`)
330
+ * is excluded so we never RPC ourselves or seed with our own strand address.
331
+ *
332
+ * Dials run concurrently but the union preserves candidate order, so the result
333
+ * is deterministic regardless of which sibling answers first.
334
+ */
335
+ export async function collectStrandAddrs(
336
+ node: Libp2p,
337
+ peers: StrandAddrPeer[],
338
+ strandId: string,
339
+ options: CollectStrandAddrsOptions = {}
340
+ ): Promise<StrandAddrCollection> {
341
+ const selfId = node.peerId.toString();
342
+ const request: StrandAddrRequest = options.delegatePeerId !== undefined
343
+ ? { strandId, delegatePeerId: options.delegatePeerId }
344
+ : { strandId };
345
+ const candidates = peers.filter(p => p.peerId !== selfId);
346
+
347
+ // Concurrent dials; `dialOneSibling` folds per-peer failure into an outcome, so
348
+ // `Promise.all` never rejects and the result array stays in candidate order.
349
+ const answers = await Promise.all(
350
+ candidates.map(async (peer) => ({ peerId: peer.peerId, ...await dialOneSibling(node, peer, request, options) }))
351
+ );
352
+
353
+ const outcomes = new Map<string, StrandAddrOutcome>();
354
+ // Deduplicated union in candidate order, then signaling-first for a usable
355
+ // dial sequence (relay/`p2p-circuit` ahead of direct addrs).
356
+ const seen = new Set<string>();
357
+ const union: string[] = [];
358
+ for (const { peerId, outcome, multiaddrs } of answers) {
359
+ outcomes.set(peerId, outcome);
360
+ for (const addr of multiaddrs) {
361
+ if (!seen.has(addr)) {
362
+ seen.add(addr);
363
+ union.push(addr);
364
+ }
365
+ }
366
+ }
367
+ return { addrs: orderSignalingFirst(union), outcomes };
368
+ }
369
+
370
+ /**
371
+ * Ask one sibling for its strand address. Tries each dial target in order (peerId
372
+ * first to reuse an open control connection, then explicit addrs) until one
373
+ * produces a well-formed reply, and reports that reply's outcome; a total failure
374
+ * is `unreachable`, so a single dead sibling never aborts the collection. Never
375
+ * throws.
376
+ *
377
+ * A reply of any status, or a refused protocol negotiation (`unsupported`), ends
378
+ * the loop: every target reaches the same responder, so asking it again by another
379
+ * address would get the same answer.
380
+ *
381
+ * A total failure logs ONE line naming every target and its cause, because
382
+ * per-target lines scattered through a concurrent fan-out do not reassemble into
383
+ * "this sibling was unreachable".
384
+ *
385
+ * NOTE: cost is (targets × `timeoutMs`, 28.5 s each at the default declared link) with
386
+ * no whole-sibling budget, the same shape `dialWake` bounds with
387
+ * `DEFAULT_WAKE_DIAL_BUDGET_MS`. Siblings are asked concurrently, so a collection costs
388
+ * its slowest sibling, not the sum. Fine today — `dialTargets` yields the peerId plus
389
+ * whatever `resolvePeerAddrs` returned, which for a cadre device is one or two
390
+ * addresses. If a sibling's record ever carries a long address list, give this the same
391
+ * whole-sibling budget.
392
+ */
393
+ async function dialOneSibling(
394
+ node: Libp2p,
395
+ peer: StrandAddrPeer,
396
+ request: StrandAddrRequest,
397
+ options: CollectStrandAddrsOptions
398
+ ): Promise<SiblingAnswer> {
399
+ const protocolId = options.protocolId ?? STRAND_ADDR_PROTOCOL;
400
+ const timeoutMs = attemptTimeoutMs(options);
401
+ const targets = dialTargets(peer);
402
+
403
+ if (targets.length === 0) {
404
+ log('No dial target for sibling %s', peer.peerId);
405
+ return { outcome: 'unreachable', multiaddrs: [] };
406
+ }
407
+
408
+ const failures: string[] = [];
409
+ for (const target of targets) {
410
+ try {
411
+ // One deadline per attempt: its signal aborts the in-flight dialProtocol and
412
+ // resets the live stream, so neither the connect nor the response-read leaks.
413
+ const response = await withDeadline(
414
+ timeoutMs,
415
+ `Strand-addr dial ${peer.peerId}`,
416
+ (signal) => sendStrandAddr(node, target, protocolId, request, timeoutMs, signal),
417
+ );
418
+ return siblingAnswer(response);
419
+ } catch (err) {
420
+ const error = err instanceof Error ? err : new Error(String(err));
421
+ // Matched by name, as libp2p's own error classes are compared across package copies.
422
+ if (error.name === 'UnsupportedProtocolError') {
423
+ log('Strand-addr refused by %s via %s: it does not serve %s', peer.peerId, describeTarget(target), protocolId);
424
+ return { outcome: 'unsupported', multiaddrs: [] };
425
+ }
426
+ failures.push(`${describeTarget(target)} — ${error.message}`);
427
+ log('Strand-addr dial to %s via %s failed: %o', peer.peerId, describeTarget(target), err);
428
+ }
429
+ }
430
+ log('Strand-addr dial to %s failed on all %d target(s), contributing no addrs: %s',
431
+ peer.peerId, failures.length, failures.join('; '));
432
+ return { outcome: 'unreachable', multiaddrs: [] };
433
+ }
434
+
435
+ /**
436
+ * Deadline for ONE attempt to ask a sibling — dial, request, response — unless the caller set
437
+ * its own: `relayedRequestBudgetMs` at the declared link (28.5 s at the default).
438
+ *
439
+ * An attempt is either a dial by peer id, which normally reuses an open control connection
440
+ * (`CIRCUIT_REQUEST_ROUND_TRIPS`, 2), or the fallback to the sibling's control addresses, a
441
+ * fresh and possibly relayed dial. One deadline covers both, so it is sized for the fallback.
442
+ *
443
+ * NOTE: the receiver's membership check (two live control reads) runs inside this deadline and
444
+ * is not counted. In steady state those reads touch only held blocks and do not consult the
445
+ * cohort. If a wake or address request is seen timing out while the receiver's membership read
446
+ * is consulting, count one membership decision in this deadline or answer the check from the
447
+ * materialized authorized-peer snapshot. See docs/cadre-consistency.md → "Deadlines Over
448
+ * Optimystic's Reads and Commits".
449
+ */
450
+ function attemptTimeoutMs(options: CollectStrandAddrsOptions): number {
451
+ return options.timeoutMs ?? relayedRequestBudgetMs(options.linkRoundTripMs);
452
+ }
453
+
454
+ /** Map a validated reply to its outcome; only an `ok` reply contributes addresses. */
455
+ function siblingAnswer(response: StrandAddrResponse): SiblingAnswer {
456
+ if (response.status !== 'ok') {
457
+ return { outcome: response.status, multiaddrs: [] };
458
+ }
459
+ return {
460
+ outcome: response.multiaddrs.length > 0 ? 'answered' : 'empty',
461
+ multiaddrs: response.multiaddrs
462
+ };
463
+ }
464
+
465
+ /**
466
+ * Dial targets for a sibling, peerId first. Dialing by peerId lets libp2p reuse
467
+ * an already-open control connection (the common case — the wiring ticket passes
468
+ * connected siblings); the explicit control addrs are a fallback for when no
469
+ * connection is open. An unparsable peerId is skipped (rely on addrs).
470
+ */
471
+ function dialTargets(peer: StrandAddrPeer): Array<PeerId | Multiaddr> {
472
+ const targets: Array<PeerId | Multiaddr> = [];
473
+ try {
474
+ targets.push(peerIdFromString(peer.peerId));
475
+ } catch (err) {
476
+ log('Unparsable peer id %s, falling back to addrs: %o', peer.peerId, err);
477
+ }
478
+ if (peer.addrs) {
479
+ targets.push(...peer.addrs);
480
+ }
481
+ return targets;
482
+ }
483
+
484
+ /** Short label for a dial target (peerId or multiaddr) for logging. */
485
+ function describeTarget(target: PeerId | Multiaddr): string {
486
+ return target.toString();
487
+ }
488
+
489
+ /**
490
+ * Open one stream to a target, send the request, half-close, and read the
491
+ * response.
492
+ *
493
+ * `signal` is the per-attempt deadline from {@link dialOneSibling}: it goes to
494
+ * `dialProtocol` so a timeout during connect aborts the dial, and into
495
+ * {@link exchangeFrame} so a timeout after the stream is open resets it —
496
+ * releasing the otherwise unbounded response-read.
497
+ */
498
+ async function sendStrandAddr(
499
+ node: Libp2p,
500
+ target: PeerId | Multiaddr,
501
+ protocolId: string,
502
+ request: StrandAddrRequest,
503
+ timeoutMs: number,
504
+ signal: AbortSignal,
505
+ ): Promise<StrandAddrResponse> {
506
+ // `runOnLimitedConnection: true`: the sibling may be reachable only over a
507
+ // circuit-relay connection (the signaling-first addr), which libp2p marks
508
+ // "limited". The exchange is a single tiny request→response well within the
509
+ // relay's data/duration cap, so opening it on the limited connection is safe
510
+ // and is the whole point of dialing over the relay.
511
+ const rawStream = await node.dialProtocol(target, protocolId, { runOnLimitedConnection: true, signal });
512
+
513
+ const response = await exchangeFrame(
514
+ rawStream as unknown as ControlStream,
515
+ signal,
516
+ request,
517
+ (stream) => readFrame<unknown>(stream, timeoutMs),
518
+ 'Strand-addr dial aborted by timeout',
519
+ );
520
+ if (!isStrandAddrResponse(response)) {
521
+ throw new Error('Malformed strand-addr response');
522
+ }
523
+
524
+ log('Strand-addr response: %s, %d addr(s) for %s', response.status, response.multiaddrs.length, response.strandId);
525
+ return response;
526
+ }
527
+
528
+ const STRAND_ADDR_STATUSES: ReadonlySet<string> = new Set<StrandAddrStatus>(['ok', 'unavailable']);
529
+
530
+ /**
531
+ * Shape check on a decoded reply: the responder is another machine, so a reply
532
+ * missing its status (an older responder, or a buggy one) or carrying anything but
533
+ * strings in `multiaddrs` is a failed exchange, not an answer.
534
+ *
535
+ * Deliberately not `sanitizeStrandAddrs` (strand-formation-protocol.ts): that drops
536
+ * bad entries and caps the list at 16, which suits a cross-party formation result
537
+ * but would silently truncate a sibling's full address list here.
538
+ */
539
+ function isStrandAddrResponse(value: unknown): value is StrandAddrResponse {
540
+ if (typeof value !== 'object' || value === null) {
541
+ return false;
542
+ }
543
+ const { status, strandId, multiaddrs } = value as Record<string, unknown>;
544
+ return typeof status === 'string' && STRAND_ADDR_STATUSES.has(status)
545
+ && typeof strandId === 'string'
546
+ && Array.isArray(multiaddrs) && multiaddrs.every((addr) => typeof addr === 'string');
547
+ }