@serfab/cadre-core 1.2.0 → 1.4.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 (46) hide show
  1. package/README.md +5 -3
  2. package/dist/cadre-node.d.ts +32 -19
  3. package/dist/cadre-node.js +121 -31
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-formation-recorder.d.ts +4 -4
  6. package/dist/control-formation-recorder.js +6 -6
  7. package/dist/control-formation-recorder.js.map +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +5 -3
  10. package/dist/index.js.map +1 -1
  11. package/dist/membership-connection-gater.d.ts +18 -5
  12. package/dist/membership-connection-gater.js +50 -10
  13. package/dist/membership-connection-gater.js.map +1 -1
  14. package/dist/storage-scope.d.ts +50 -12
  15. package/dist/storage-scope.js +85 -12
  16. package/dist/storage-scope.js.map +1 -1
  17. package/dist/strand-formation-manager.js +2 -1
  18. package/dist/strand-formation-manager.js.map +1 -1
  19. package/dist/strand-id.d.ts +35 -0
  20. package/dist/strand-id.js +46 -0
  21. package/dist/strand-id.js.map +1 -0
  22. package/dist/strand-instance-manager.js +20 -2
  23. package/dist/strand-instance-manager.js.map +1 -1
  24. package/dist/strand-network-config.d.ts +3 -3
  25. package/dist/strand-network-config.js +3 -3
  26. package/dist/strand-solicitation.js +2 -1
  27. package/dist/strand-solicitation.js.map +1 -1
  28. package/dist/strand-watcher.d.ts +45 -0
  29. package/dist/strand-watcher.js +74 -13
  30. package/dist/strand-watcher.js.map +1 -1
  31. package/dist/types.d.ts +118 -15
  32. package/dist/types.js +59 -0
  33. package/dist/types.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/cadre-node.ts +127 -31
  36. package/src/control-formation-recorder.ts +6 -6
  37. package/src/index.ts +563 -554
  38. package/src/membership-connection-gater.ts +56 -11
  39. package/src/storage-scope.ts +163 -78
  40. package/src/strand-formation-manager.ts +647 -646
  41. package/src/strand-id.ts +48 -0
  42. package/src/strand-instance-manager.ts +21 -2
  43. package/src/strand-network-config.ts +250 -250
  44. package/src/strand-solicitation.ts +500 -499
  45. package/src/strand-watcher.ts +388 -325
  46. package/src/types.ts +121 -15
@@ -0,0 +1,48 @@
1
+ /**
2
+ * strand-id.ts — the only places cadre-core mints a strand id.
3
+ *
4
+ * A strand id is not just a key in the control database: it is the strand's storage
5
+ * scope key (a directory, a LevelDB filename, an IndexedDB database name — whatever
6
+ * the embedder's provider makes of it) and the strand node's libp2p network name,
7
+ * `strand-<id>`, which becomes the protocol prefix `/optimystic/strand-<id>`. It also
8
+ * replicates to every other node in the party, which will turn it into those same
9
+ * names on their own disks.
10
+ *
11
+ * So a generator that emits a shape `isValidStrandScopeKey` rejects does not fail
12
+ * here — it fails later, on a peer, at launch. Minting in one module and asserting the
13
+ * predicate on the way out is what keeps that impossible: a future edit to either shape
14
+ * fails at its own call site rather than on someone else's machine.
15
+ */
16
+
17
+ import { randomBytes } from '@optimystic/quereus-plugin-crypto';
18
+ import { assertStrandScopeKey } from './storage-scope.js';
19
+
20
+ /**
21
+ * Mint a fresh, unguessable strand id: `strand-` plus 32 hex characters (128 bits).
22
+ *
23
+ * Uses {@link randomBytes} — the same cross-platform CSPRNG `control-database`'s
24
+ * `generateStampId` uses — NOT `crypto.randomUUID` / `Date.now` / `Math.random`, none
25
+ * of which are uniformly available across node/browser/React Native. Unguessability is
26
+ * the point for a responder-provisioned strand: the id is what an invite redemption
27
+ * hands back, so a predictable one would let a third party name a strand before its
28
+ * founder published it.
29
+ */
30
+ export function mintStrandId(): string {
31
+ const strandId = `strand-${randomBytes(128, 'hex') as string}`;
32
+ assertStrandScopeKey(strandId);
33
+ return strandId;
34
+ }
35
+
36
+ /**
37
+ * Mint a structural placeholder strand id: `strand-<ms since epoch>-<6 base36 chars>`.
38
+ *
39
+ * For the no-node / no-recorder fallbacks, where the caller needs a well-shaped id an
40
+ * initiator can validate but no strand is actually provisioned. NOT unguessable —
41
+ * `Math.random` is not a CSPRNG — so never use it where {@link mintStrandId}'s
42
+ * unpredictability is load-bearing.
43
+ */
44
+ export function mintPlaceholderStrandId(): string {
45
+ const strandId = `strand-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
46
+ assertStrandScopeKey(strandId);
47
+ return strandId;
48
+ }
@@ -18,6 +18,7 @@ import {
18
18
  import { removeMemberPeer } from './strand-membership-writer.js';
19
19
  import { strandMemberKeyPair } from './strand-member-key.js';
20
20
  import { assertSchemaSignature } from './schema-verification.js';
21
+ import { assertStrandScopeKey } from './storage-scope.js';
21
22
  import {
22
23
  StrandFirstSyncGate,
23
24
  StrandAwaitingFirstSyncError,
@@ -37,7 +38,7 @@ import type {
37
38
  RawStorageProvider,
38
39
  Libp2pNodeWithRepo
39
40
  } from './types.js';
40
- import { resolveStrandClusterSize, strandClusterPolicy } from './types.js';
41
+ import { DEFAULT_CONNECTION_MONITOR, resolveStrandClusterSize, strandClusterPolicy } from './types.js';
41
42
  import { strandNodeAddrs } from './strand-network-config.js';
42
43
  import { superviseRelayReservation, type RelayReservationSupervisor } from './relay-reservation.js';
43
44
 
@@ -311,7 +312,10 @@ export function getStrandStoragePath(basePath: string, strandId: string): string
311
312
  * If the provider is a factory function, call it with the strandId.
312
313
  *
313
314
  * Called only from {@link StrandInstanceManager.startStrand}, which owns the result
314
- * for the instance's lifetime — see `strandStorages`.
315
+ * for the instance's lifetime — see `strandStorages`, and which has already asserted
316
+ * the id is usable as a scope key. Do not move that assertion here: this function
317
+ * returns early when no provider is configured, and the id becomes a libp2p protocol
318
+ * prefix on that path too.
315
319
  *
316
320
  * @param provider - Storage provider (instance or factory)
317
321
  * @param strandId - The strand ID to create storage for
@@ -485,6 +489,15 @@ export class StrandInstanceManager {
485
489
  log('Starting strand instance: %s (sApp: %s v%s)', strandId, sAppConfig.id, sAppConfig.version);
486
490
  const tTotal = performance.now();
487
491
 
492
+ // The id becomes two names below: the storage scope key the embedder's provider
493
+ // turns into a directory or database name, and `networkName` in buildStrandRuntime,
494
+ // from which the libp2p protocol prefix `/optimystic/strand-<id>` is built. A
495
+ // replicated strand row carries whatever id the founding node wrote, so neither is
496
+ // safe unchecked. Asserted HERE rather than in `resolveStrandStorage` because that
497
+ // function returns early when no provider is configured — a node with no storage
498
+ // still reaches buildStrandRuntime and still mints the protocol prefix.
499
+ assertStrandScopeKey(strandId);
500
+
488
501
  // Verify schema signature before proceeding (fail-closed by default)
489
502
  const requireSignature = config.requireSignedSchemas ?? true;
490
503
  assertSchemaSignature(sAppConfig, { requireSignature });
@@ -667,6 +680,12 @@ export class StrandInstanceManager {
667
680
  },
668
681
  ...(config.privateKey && { privateKey: config.privateKey }),
669
682
  ...(config.network?.transports && { transports: config.network.transports }),
683
+ ...(config.network?.noiseCrypto && { noiseCrypto: config.network.noiseCrypto }),
684
+ // Unconditional, and the same default the control node takes: every node of
685
+ // every party runs the monitor over its connection to a slow phone, so the
686
+ // widened ping deadline has to reach the strand nodes too (see
687
+ // DEFAULT_CONNECTION_MONITOR).
688
+ connectionMonitor: config.network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
670
689
  // Listen entries plus the WebSocket transport switch they imply — a strand node
671
690
  // announces nothing the operator configured (`strand-network-config.ts`), and
672
691
  // spreads AFTER `transports` above because the switch is a no-op whenever the
@@ -1,250 +1,250 @@
1
- /**
2
- * Derive the per-STRAND-node view of the machine's one `NetworkConfig`.
3
- *
4
- * A cadre machine runs one **control** libp2p node plus one more libp2p node per
5
- * strand, and every one of them is built from the SAME operator-written
6
- * `NetworkConfig` block. Two of its fields describe a single endpoint on the host
7
- * and therefore cannot be inherited literally by a second node on that host:
8
- *
9
- * - **`listenAddrs` with a fixed port.** `cadre-cli`'s example config ships
10
- * `/ip4/0.0.0.0/tcp/4001`. The control node binds it first; every strand node
11
- * then tries to bind the same port and fails with `EADDRINUSE`, so a machine
12
- * configured with a fixed port could not start any strand at all.
13
- * - **`announceAddrs` / `appendAnnounceAddrs`.** Any concrete announce entry names
14
- * a port, and that port is the CONTROL node's. A strand node advertising it
15
- * sends peers to the control node — and `announceAddrs` REPLACES the advertised
16
- * set (see `announce-addrs.ts`), so that wrong address would be the ONLY thing
17
- * the strand node publishes.
18
- *
19
- * The reachability model this rests on: **a strand node is not separately dialable
20
- * at a published fixed address.** It is reached through (a) its ephemeral direct
21
- * listener plus the addresses peers observe for it, and (b) circuit relay —
22
- * `network.relayAddrs` resolves to ONE bare `/p2p-circuit` SEARCH listen entry PER
23
- * relay, and the strand runtime (`strand-instance-manager.ts`) runs one
24
- * reservation supervisor per relay over the dial addrs this module returns beside
25
- * them ({@link StrandNodeAddrs.relayAddrs}). The same route the control node takes,
26
- * for the same reason the configured `<relay>/p2p-circuit` shape was retired
27
- * everywhere (`relay-addrs.ts`): a configured listener loses its address on a relay
28
- * restart, on either side hanging up, and on libp2p's own reservation refresh, and
29
- * recovers from none of them. One search entry per relay rather than one for all,
30
- * because a search listener registers exactly ONE pending reservation and libp2p
31
- * fills a pending reservation with exactly one relay — so N relays need N listeners,
32
- * each with a supervisor that owns it.
33
- *
34
- * A hand-written `<relay>/p2p-circuit` entry in `listenAddrs` counts as a relay
35
- * (its dial prefix is supervised like a `relayAddrs` entry) — the control node
36
- * rejects that entry outright, so in production it never reaches here, but a
37
- * strand built straight from such a config must not get an unsupervised configured
38
- * listener either. A hand-written BARE `/p2p-circuit` (the browser shape,
39
- * `reference-app-web`) passes through unchanged when no relay is named, and is
40
- * absorbed by the per-relay entries when one is.
41
- *
42
- * Everything else in `NetworkConfig` — `transports`, `connectionGater`,
43
- * `enableRelay` — is inherited by the caller; this module only owns the two
44
- * host-endpoint fields above and the relay listen shape. One caveat on
45
- * `connectionGater`: an OPEN strand's node gets it unchanged, while a CLOSED strand's
46
- * node composes revoked-peer denial onto it in `strand-instance-manager.ts` (every
47
- * supplied hook still honored — see `strand-revocation-enforcer.ts`).
48
- *
49
- * It does carry one non-address option out with them: the WebSocket transport switch
50
- * a `/ws` listen entry implies (`relay-addrs.ts` → `resolveTransportOptions`). That is
51
- * DERIVED from the listen entries rather than inherited, so it has to be computed
52
- * wherever they are, and a strand node whose listen entries reached libp2p without it
53
- * would bind no WebSocket listener and report nothing.
54
- */
55
-
56
- import { multiaddr, type Component } from '@multiformats/multiaddr';
57
- import {
58
- DEFAULT_DIRECT_LISTEN_ADDR,
59
- RELAY_SEARCH_LISTEN_ADDR,
60
- isConfiguredCircuitListenAddr,
61
- relayCircuitAddrs,
62
- resolveTransportOptions
63
- } from './relay-addrs.js';
64
- import { extractCircuitRelayTargets } from './delegate-admission.js';
65
- import type { NetworkConfig } from './types.js';
66
-
67
- /**
68
- * The address-shaped `createLibp2pNode` options a strand node gets, ready to spread
69
- * into the rest of its options.
70
- *
71
- * There is deliberately no announce field here: a strand node announces NOTHING the
72
- * operator configured, only what libp2p derives for it (its own listeners, observed
73
- * addresses, and any `/p2p-circuit` address a relay reservation earns).
74
- *
75
- * NOTE: accepted tradeoff — a hosted deployment that wants a strand node reachable
76
- * at its own published public port has no way to say so; the announce config is
77
- * dropped for strand nodes wholesale rather than per-strand. Nothing needs that
78
- * today, and the alternative is a per-strand network-config surface. Revisit if a
79
- * hosted or reverse-proxy deployment ever needs direct-dialable strand nodes.
80
- */
81
- export interface StrandNodeAddrs {
82
- /**
83
- * Listen entries for this strand node, or `undefined` when the operator
84
- * configured neither `listenAddrs` nor `relayAddrs` — in which case the caller
85
- * omits the option and inherits `@optimystic/db-p2p`'s own default. Carries one
86
- * bare `/p2p-circuit` entry per entry of {@link relayAddrs}, deliberately NOT
87
- * deduplicated: each one is its own listener with its own pending reservation.
88
- */
89
- listenAddrs?: string[];
90
- /**
91
- * The WebSocket transport switch those listen entries imply, present only when one
92
- * of them names WebSocket (`relay-addrs.ts` → `resolveTransportOptions`). Not an
93
- * address — it is deliberately `0` and nothing binds it — but it lives here because
94
- * it is derived from, and must travel with, the listen entries above. Without it a
95
- * `/ws` strand listen entry binds nothing and libp2p reports nothing.
96
- */
97
- wsPort?: number;
98
- /**
99
- * The relays this strand node reserves through, as DIRECT dial addrs
100
- * (`<host>/p2p/<relayPeerId>`, no `/p2p-circuit`), one per relay, in config order,
101
- * deduplicated by relay peer id — the union of `network.relayAddrs` and any
102
- * hand-written `<relay>/p2p-circuit` entry in `network.listenAddrs`. Present only
103
- * when at least one relay is named.
104
- *
105
- * NOT a `createLibp2pNode` option: the caller (`strand-instance-manager.ts`)
106
- * destructures it off before spreading the rest, and starts one
107
- * `superviseRelayReservation` per entry over the node it built.
108
- */
109
- relayAddrs?: string[];
110
- }
111
-
112
- /**
113
- * The strand-node view of `network`: the operator's direct listen entries with every
114
- * fixed port rewritten to an ephemeral one, one bare `/p2p-circuit` search entry per
115
- * relay, the relay dial addrs those entries are supervised against, and no announce
116
- * fields.
117
- *
118
- * Validation is fail-fast exactly as it is for the control node — `relayCircuitAddrs`
119
- * throws here on a malformed `relayAddrs` entry.
120
- *
121
- * NOTE: announce entries are no longer validated on this path at all; the CONTROL
122
- * node's own build (`cadre-node.ts`) is the only thing that parses them, and it runs
123
- * before any strand starts, so a typo still refuses node start today. That is an
124
- * ORDERING guarantee, not a structural one. Revisit if a strand node is ever built
125
- * before the control node's libp2p options are resolved — a malformed announce entry
126
- * would then go unreported until the control node's own build.
127
- */
128
- export function strandNodeAddrs(network: NetworkConfig | undefined): StrandNodeAddrs {
129
- const relayAddrs = strandRelayAddrs(network);
130
- const configured = network?.listenAddrs;
131
- if (!configured && relayAddrs.length === 0) {
132
- return {};
133
- }
134
- // An explicitly empty `listenAddrs` (the React Native "cannot listen" case) stays
135
- // empty — rewriting must never resurrect a direct listener that was opted out of.
136
- // Naming a relay with no `listenAddrs` keeps the direct listener the control node
137
- // keeps in that case (`relay-addrs.ts`), so a relay ADDS reachability rather than
138
- // replacing it.
139
- const direct = configured ?? [DEFAULT_DIRECT_LISTEN_ADDR];
140
- // Every circuit entry — the configured shape (now a relay in `relayAddrs`) and a
141
- // hand-written bare search entry alike — is replaced by the per-relay search entries
142
- // once a relay is named; with none, the operator's entries pass through as written.
143
- const kept = relayAddrs.length > 0 ? direct.filter((addr) => !isCircuitListenAddr(addr)) : direct;
144
- // The search entries are appended AFTER the dedupe on purpose: they are identical
145
- // strings, and each one must survive as its own listener (see the module doc).
146
- //
147
- // NOTE: every bare entry also makes libp2p start its own relay discovery whenever
148
- // that listener's slot is empty (launch, and every loss). Measured on a node with
149
- // no peer router: one peer-store scan plus a random walk that fails at once
150
- // (`NoPeerRoutersError`) per loss event — no dial churn. If strand nodes ever get
151
- // a peer router (a DHT), that walk turns into real dials on every relay loss;
152
- // cap or disable `circuitRelayTransport`'s discovery then.
153
- const listenAddrs = [
154
- ...dedupe(kept.map(ephemeralPortListenAddr)),
155
- ...relayAddrs.map(() => RELAY_SEARCH_LISTEN_ADDR)
156
- ];
157
- // Classified AFTER the ephemeral rewrite, so the check reads what this strand node
158
- // will actually bind. Zeroing a port cannot change an entry's transport — only the
159
- // `tcp`/`udp` component's value moves — so `/ip4/0.0.0.0/tcp/4002/ws` still resolves
160
- // to WebSocket as `/ip4/0.0.0.0/tcp/0/ws`; a bare `/p2p-circuit` is `'circuit'`.
161
- return {
162
- listenAddrs,
163
- ...resolveTransportOptions(network, listenAddrs),
164
- ...(relayAddrs.length > 0 && { relayAddrs })
165
- };
166
- }
167
-
168
- /**
169
- * The relays a strand node built from `network` reserves through, as direct dial
170
- * addrs: every `relayAddrs` entry (validated — a malformed one throws, naming the
171
- * field) plus the relay named by any hand-written `<relay>/p2p-circuit` listen entry,
172
- * deduplicated by relay PEER ID with the first spelling winning — two spellings of one
173
- * relay would otherwise get two supervisors fighting over one reservation slot.
174
- */
175
- function strandRelayAddrs(network: NetworkConfig | undefined): string[] {
176
- const configured = relayCircuitAddrs(network?.relayAddrs ?? []);
177
- const handWritten = (network?.listenAddrs ?? []).filter(isConfiguredCircuitListenAddr);
178
- return extractCircuitRelayTargets([...configured, ...handWritten]).map((relay) => relay.relayAddr);
179
- }
180
-
181
- /** Any `/p2p-circuit` listen entry — bare or configured; an unparsable entry is not one. */
182
- function isCircuitListenAddr(listenAddr: string): boolean {
183
- return listenAddr === RELAY_SEARCH_LISTEN_ADDR || isConfiguredCircuitListenAddr(listenAddr);
184
- }
185
-
186
- /**
187
- * One listen entry with its fixed direct port rewritten to `0`, so the OS assigns a
188
- * free one per node. Interface and transport choices are preserved — `/ws`, a
189
- * specific-interface bind, a `/udp/…/quic-v1` stack — since only the port is
190
- * contended.
191
- *
192
- * Passed through UNCHANGED when there is nothing to rewrite, so the operator's exact
193
- * string survives rather than being round-tripped through the multiaddr normalizer:
194
- *
195
- * - a circuit entry (`…/p2p-circuit`), whose embedded port belongs to the RELAY and
196
- * is not a local bind at all;
197
- * - a port that is already `0`, or an entry that names no port;
198
- * - an unparsable entry — libp2p reports a bad listen addr itself, and this helper
199
- * only ever narrows a port.
200
- */
201
- function ephemeralPortListenAddr(listenAddr: string): string {
202
- let components: Component[];
203
- try {
204
- components = multiaddr(listenAddr).getComponents();
205
- } catch {
206
- return listenAddr;
207
- }
208
- if (components.some((c) => c.name === 'p2p-circuit')) {
209
- return listenAddr;
210
- }
211
- if (!components.some(isFixedPort)) {
212
- return listenAddr;
213
- }
214
- return multiaddr(components.map(withEphemeralPort)).toString();
215
- }
216
-
217
- /** A `tcp`/`udp` component naming a port the OS did not choose. */
218
- function isFixedPort(component: Component): boolean {
219
- return (component.name === 'tcp' || component.name === 'udp')
220
- && component.value !== undefined
221
- && component.value !== '0';
222
- }
223
-
224
- /**
225
- * `component` with its port zeroed, or unchanged when it is not a fixed port.
226
- *
227
- * Rebuilt field-by-field rather than spread: a parsed component may carry a `bytes`
228
- * cache, and `componentsToBytes` PREFERS that cache over `value` — a spread would
229
- * re-encode the old port.
230
- */
231
- function withEphemeralPort(component: Component): Component {
232
- return isFixedPort(component)
233
- ? { code: component.code, name: component.name, value: '0' }
234
- : component;
235
- }
236
-
237
- /**
238
- * Exact-string dedupe, first occurrence wins — matching `relay-addrs.ts`. Entries
239
- * that differed only by port collapse into one after the rewrite, and libp2p would
240
- * otherwise try to bind the same ephemeral-port entry twice.
241
- *
242
- * NOTE: an operator who writes two fixed ports on the same interface and transport
243
- * (`/ip4/0.0.0.0/tcp/4001` and `…/tcp/4002`) gets ONE strand listener, not two, since
244
- * the two entries are identical once zeroed. Intended — the ports were the only thing
245
- * distinguishing them and neither survives. Revisit if a deployment ever needs a
246
- * strand node to hold a fixed count of direct listeners.
247
- */
248
- function dedupe(addrs: readonly string[]): string[] {
249
- return [...new Set(addrs)];
250
- }
1
+ /**
2
+ * Derive the per-STRAND-node view of the machine's one `NetworkConfig`.
3
+ *
4
+ * A cadre machine runs one **control** libp2p node plus one more libp2p node per
5
+ * strand, and every one of them is built from the SAME operator-written
6
+ * `NetworkConfig` block. Two of its fields describe a single endpoint on the host
7
+ * and therefore cannot be inherited literally by a second node on that host:
8
+ *
9
+ * - **`listenAddrs` with a fixed port.** `cadre-cli`'s example config ships
10
+ * `/ip4/0.0.0.0/tcp/4001`. The control node binds it first; every strand node
11
+ * then tries to bind the same port and fails with `EADDRINUSE`, so a machine
12
+ * configured with a fixed port could not start any strand at all.
13
+ * - **`announceAddrs` / `appendAnnounceAddrs`.** Any concrete announce entry names
14
+ * a port, and that port is the CONTROL node's. A strand node advertising it
15
+ * sends peers to the control node — and `announceAddrs` REPLACES the advertised
16
+ * set (see `announce-addrs.ts`), so that wrong address would be the ONLY thing
17
+ * the strand node publishes.
18
+ *
19
+ * The reachability model this rests on: **a strand node is not separately dialable
20
+ * at a published fixed address.** It is reached through (a) its ephemeral direct
21
+ * listener plus the addresses peers observe for it, and (b) circuit relay —
22
+ * `network.relayAddrs` resolves to ONE bare `/p2p-circuit` SEARCH listen entry PER
23
+ * relay, and the strand runtime (`strand-instance-manager.ts`) runs one
24
+ * reservation supervisor per relay over the dial addrs this module returns beside
25
+ * them ({@link StrandNodeAddrs.relayAddrs}). The same route the control node takes,
26
+ * for the same reason the configured `<relay>/p2p-circuit` shape was retired
27
+ * everywhere (`relay-addrs.ts`): a configured listener loses its address on a relay
28
+ * restart, on either side hanging up, and on libp2p's own reservation refresh, and
29
+ * recovers from none of them. One search entry per relay rather than one for all,
30
+ * because a search listener registers exactly ONE pending reservation and libp2p
31
+ * fills a pending reservation with exactly one relay — so N relays need N listeners,
32
+ * each with a supervisor that owns it.
33
+ *
34
+ * A hand-written `<relay>/p2p-circuit` entry in `listenAddrs` counts as a relay
35
+ * (its dial prefix is supervised like a `relayAddrs` entry) — the control node
36
+ * rejects that entry outright, so in production it never reaches here, but a
37
+ * strand built straight from such a config must not get an unsupervised configured
38
+ * listener either. A hand-written BARE `/p2p-circuit` (the browser shape,
39
+ * `reference-app-web`) passes through unchanged when no relay is named, and is
40
+ * absorbed by the per-relay entries when one is.
41
+ *
42
+ * Everything else in `NetworkConfig` — `transports`, `noiseCrypto`, `connectionMonitor`,
43
+ * `connectionGater`, `enableRelay` — is inherited by the caller; this module only owns
44
+ * the two host-endpoint fields above and the relay listen shape. One caveat on
45
+ * `connectionGater`: an OPEN strand's node gets it unchanged, while a CLOSED strand's
46
+ * node composes revoked-peer denial onto it in `strand-instance-manager.ts` (every
47
+ * supplied hook still honored — see `strand-revocation-enforcer.ts`).
48
+ *
49
+ * It does carry one non-address option out with them: the WebSocket transport switch
50
+ * a `/ws` listen entry implies (`relay-addrs.ts` → `resolveTransportOptions`). That is
51
+ * DERIVED from the listen entries rather than inherited, so it has to be computed
52
+ * wherever they are, and a strand node whose listen entries reached libp2p without it
53
+ * would bind no WebSocket listener and report nothing.
54
+ */
55
+
56
+ import { multiaddr, type Component } from '@multiformats/multiaddr';
57
+ import {
58
+ DEFAULT_DIRECT_LISTEN_ADDR,
59
+ RELAY_SEARCH_LISTEN_ADDR,
60
+ isConfiguredCircuitListenAddr,
61
+ relayCircuitAddrs,
62
+ resolveTransportOptions
63
+ } from './relay-addrs.js';
64
+ import { extractCircuitRelayTargets } from './delegate-admission.js';
65
+ import type { NetworkConfig } from './types.js';
66
+
67
+ /**
68
+ * The address-shaped `createLibp2pNode` options a strand node gets, ready to spread
69
+ * into the rest of its options.
70
+ *
71
+ * There is deliberately no announce field here: a strand node announces NOTHING the
72
+ * operator configured, only what libp2p derives for it (its own listeners, observed
73
+ * addresses, and any `/p2p-circuit` address a relay reservation earns).
74
+ *
75
+ * NOTE: accepted tradeoff — a hosted deployment that wants a strand node reachable
76
+ * at its own published public port has no way to say so; the announce config is
77
+ * dropped for strand nodes wholesale rather than per-strand. Nothing needs that
78
+ * today, and the alternative is a per-strand network-config surface. Revisit if a
79
+ * hosted or reverse-proxy deployment ever needs direct-dialable strand nodes.
80
+ */
81
+ export interface StrandNodeAddrs {
82
+ /**
83
+ * Listen entries for this strand node, or `undefined` when the operator
84
+ * configured neither `listenAddrs` nor `relayAddrs` — in which case the caller
85
+ * omits the option and inherits `@optimystic/db-p2p`'s own default. Carries one
86
+ * bare `/p2p-circuit` entry per entry of {@link relayAddrs}, deliberately NOT
87
+ * deduplicated: each one is its own listener with its own pending reservation.
88
+ */
89
+ listenAddrs?: string[];
90
+ /**
91
+ * The WebSocket transport switch those listen entries imply, present only when one
92
+ * of them names WebSocket (`relay-addrs.ts` → `resolveTransportOptions`). Not an
93
+ * address — it is deliberately `0` and nothing binds it — but it lives here because
94
+ * it is derived from, and must travel with, the listen entries above. Without it a
95
+ * `/ws` strand listen entry binds nothing and libp2p reports nothing.
96
+ */
97
+ wsPort?: number;
98
+ /**
99
+ * The relays this strand node reserves through, as DIRECT dial addrs
100
+ * (`<host>/p2p/<relayPeerId>`, no `/p2p-circuit`), one per relay, in config order,
101
+ * deduplicated by relay peer id — the union of `network.relayAddrs` and any
102
+ * hand-written `<relay>/p2p-circuit` entry in `network.listenAddrs`. Present only
103
+ * when at least one relay is named.
104
+ *
105
+ * NOT a `createLibp2pNode` option: the caller (`strand-instance-manager.ts`)
106
+ * destructures it off before spreading the rest, and starts one
107
+ * `superviseRelayReservation` per entry over the node it built.
108
+ */
109
+ relayAddrs?: string[];
110
+ }
111
+
112
+ /**
113
+ * The strand-node view of `network`: the operator's direct listen entries with every
114
+ * fixed port rewritten to an ephemeral one, one bare `/p2p-circuit` search entry per
115
+ * relay, the relay dial addrs those entries are supervised against, and no announce
116
+ * fields.
117
+ *
118
+ * Validation is fail-fast exactly as it is for the control node — `relayCircuitAddrs`
119
+ * throws here on a malformed `relayAddrs` entry.
120
+ *
121
+ * NOTE: announce entries are no longer validated on this path at all; the CONTROL
122
+ * node's own build (`cadre-node.ts`) is the only thing that parses them, and it runs
123
+ * before any strand starts, so a typo still refuses node start today. That is an
124
+ * ORDERING guarantee, not a structural one. Revisit if a strand node is ever built
125
+ * before the control node's libp2p options are resolved — a malformed announce entry
126
+ * would then go unreported until the control node's own build.
127
+ */
128
+ export function strandNodeAddrs(network: NetworkConfig | undefined): StrandNodeAddrs {
129
+ const relayAddrs = strandRelayAddrs(network);
130
+ const configured = network?.listenAddrs;
131
+ if (!configured && relayAddrs.length === 0) {
132
+ return {};
133
+ }
134
+ // An explicitly empty `listenAddrs` (the React Native "cannot listen" case) stays
135
+ // empty — rewriting must never resurrect a direct listener that was opted out of.
136
+ // Naming a relay with no `listenAddrs` keeps the direct listener the control node
137
+ // keeps in that case (`relay-addrs.ts`), so a relay ADDS reachability rather than
138
+ // replacing it.
139
+ const direct = configured ?? [DEFAULT_DIRECT_LISTEN_ADDR];
140
+ // Every circuit entry — the configured shape (now a relay in `relayAddrs`) and a
141
+ // hand-written bare search entry alike — is replaced by the per-relay search entries
142
+ // once a relay is named; with none, the operator's entries pass through as written.
143
+ const kept = relayAddrs.length > 0 ? direct.filter((addr) => !isCircuitListenAddr(addr)) : direct;
144
+ // The search entries are appended AFTER the dedupe on purpose: they are identical
145
+ // strings, and each one must survive as its own listener (see the module doc).
146
+ //
147
+ // NOTE: every bare entry also makes libp2p start its own relay discovery whenever
148
+ // that listener's slot is empty (launch, and every loss). Measured on a node with
149
+ // no peer router: one peer-store scan plus a random walk that fails at once
150
+ // (`NoPeerRoutersError`) per loss event — no dial churn. If strand nodes ever get
151
+ // a peer router (a DHT), that walk turns into real dials on every relay loss;
152
+ // cap or disable `circuitRelayTransport`'s discovery then.
153
+ const listenAddrs = [
154
+ ...dedupe(kept.map(ephemeralPortListenAddr)),
155
+ ...relayAddrs.map(() => RELAY_SEARCH_LISTEN_ADDR)
156
+ ];
157
+ // Classified AFTER the ephemeral rewrite, so the check reads what this strand node
158
+ // will actually bind. Zeroing a port cannot change an entry's transport — only the
159
+ // `tcp`/`udp` component's value moves — so `/ip4/0.0.0.0/tcp/4002/ws` still resolves
160
+ // to WebSocket as `/ip4/0.0.0.0/tcp/0/ws`; a bare `/p2p-circuit` is `'circuit'`.
161
+ return {
162
+ listenAddrs,
163
+ ...resolveTransportOptions(network, listenAddrs),
164
+ ...(relayAddrs.length > 0 && { relayAddrs })
165
+ };
166
+ }
167
+
168
+ /**
169
+ * The relays a strand node built from `network` reserves through, as direct dial
170
+ * addrs: every `relayAddrs` entry (validated — a malformed one throws, naming the
171
+ * field) plus the relay named by any hand-written `<relay>/p2p-circuit` listen entry,
172
+ * deduplicated by relay PEER ID with the first spelling winning — two spellings of one
173
+ * relay would otherwise get two supervisors fighting over one reservation slot.
174
+ */
175
+ function strandRelayAddrs(network: NetworkConfig | undefined): string[] {
176
+ const configured = relayCircuitAddrs(network?.relayAddrs ?? []);
177
+ const handWritten = (network?.listenAddrs ?? []).filter(isConfiguredCircuitListenAddr);
178
+ return extractCircuitRelayTargets([...configured, ...handWritten]).map((relay) => relay.relayAddr);
179
+ }
180
+
181
+ /** Any `/p2p-circuit` listen entry — bare or configured; an unparsable entry is not one. */
182
+ function isCircuitListenAddr(listenAddr: string): boolean {
183
+ return listenAddr === RELAY_SEARCH_LISTEN_ADDR || isConfiguredCircuitListenAddr(listenAddr);
184
+ }
185
+
186
+ /**
187
+ * One listen entry with its fixed direct port rewritten to `0`, so the OS assigns a
188
+ * free one per node. Interface and transport choices are preserved — `/ws`, a
189
+ * specific-interface bind, a `/udp/…/quic-v1` stack — since only the port is
190
+ * contended.
191
+ *
192
+ * Passed through UNCHANGED when there is nothing to rewrite, so the operator's exact
193
+ * string survives rather than being round-tripped through the multiaddr normalizer:
194
+ *
195
+ * - a circuit entry (`…/p2p-circuit`), whose embedded port belongs to the RELAY and
196
+ * is not a local bind at all;
197
+ * - a port that is already `0`, or an entry that names no port;
198
+ * - an unparsable entry — libp2p reports a bad listen addr itself, and this helper
199
+ * only ever narrows a port.
200
+ */
201
+ function ephemeralPortListenAddr(listenAddr: string): string {
202
+ let components: Component[];
203
+ try {
204
+ components = multiaddr(listenAddr).getComponents();
205
+ } catch {
206
+ return listenAddr;
207
+ }
208
+ if (components.some((c) => c.name === 'p2p-circuit')) {
209
+ return listenAddr;
210
+ }
211
+ if (!components.some(isFixedPort)) {
212
+ return listenAddr;
213
+ }
214
+ return multiaddr(components.map(withEphemeralPort)).toString();
215
+ }
216
+
217
+ /** A `tcp`/`udp` component naming a port the OS did not choose. */
218
+ function isFixedPort(component: Component): boolean {
219
+ return (component.name === 'tcp' || component.name === 'udp')
220
+ && component.value !== undefined
221
+ && component.value !== '0';
222
+ }
223
+
224
+ /**
225
+ * `component` with its port zeroed, or unchanged when it is not a fixed port.
226
+ *
227
+ * Rebuilt field-by-field rather than spread: a parsed component may carry a `bytes`
228
+ * cache, and `componentsToBytes` PREFERS that cache over `value` — a spread would
229
+ * re-encode the old port.
230
+ */
231
+ function withEphemeralPort(component: Component): Component {
232
+ return isFixedPort(component)
233
+ ? { code: component.code, name: component.name, value: '0' }
234
+ : component;
235
+ }
236
+
237
+ /**
238
+ * Exact-string dedupe, first occurrence wins — matching `relay-addrs.ts`. Entries
239
+ * that differed only by port collapse into one after the rewrite, and libp2p would
240
+ * otherwise try to bind the same ephemeral-port entry twice.
241
+ *
242
+ * NOTE: an operator who writes two fixed ports on the same interface and transport
243
+ * (`/ip4/0.0.0.0/tcp/4001` and `…/tcp/4002`) gets ONE strand listener, not two, since
244
+ * the two entries are identical once zeroed. Intended — the ports were the only thing
245
+ * distinguishing them and neither survives. Revisit if a deployment ever needs a
246
+ * strand node to hold a fixed count of direct listeners.
247
+ */
248
+ function dedupe(addrs: readonly string[]): string[] {
249
+ return [...new Set(addrs)];
250
+ }