@serfab/cadre-core 0.11.0 → 0.13.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 (121) hide show
  1. package/README.md +3 -2
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +607 -53
  6. package/dist/cadre-node.js +1268 -157
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-database.d.ts +174 -23
  9. package/dist/control-database.js +317 -107
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-read-retry.d.ts +103 -0
  12. package/dist/control-read-retry.js +145 -0
  13. package/dist/control-read-retry.js.map +1 -0
  14. package/dist/control-retry.d.ts +101 -0
  15. package/dist/control-retry.js +108 -0
  16. package/dist/control-retry.js.map +1 -0
  17. package/dist/control-schema.d.ts +1 -1
  18. package/dist/control-schema.js +50 -6
  19. package/dist/control-schema.js.map +1 -1
  20. package/dist/control-write-retry.d.ts +59 -35
  21. package/dist/control-write-retry.js +21 -92
  22. package/dist/control-write-retry.js.map +1 -1
  23. package/dist/delegate-admission.d.ts +17 -15
  24. package/dist/delegate-admission.js +17 -15
  25. package/dist/delegate-admission.js.map +1 -1
  26. package/dist/enrolled-machine-store-file.d.ts +13 -0
  27. package/dist/enrolled-machine-store-file.js +41 -0
  28. package/dist/enrolled-machine-store-file.js.map +1 -0
  29. package/dist/enrolled-machine-store.d.ts +81 -0
  30. package/dist/enrolled-machine-store.js +225 -0
  31. package/dist/enrolled-machine-store.js.map +1 -0
  32. package/dist/file-durable-slot.js +5 -3
  33. package/dist/file-durable-slot.js.map +1 -1
  34. package/dist/index.d.ts +10 -6
  35. package/dist/index.js +39 -12
  36. package/dist/index.js.map +1 -1
  37. package/dist/membership-connection-gater.d.ts +226 -26
  38. package/dist/membership-connection-gater.js +316 -30
  39. package/dist/membership-connection-gater.js.map +1 -1
  40. package/dist/node-local-snapshot.d.ts +6 -0
  41. package/dist/node-local-snapshot.js +7 -1
  42. package/dist/node-local-snapshot.js.map +1 -1
  43. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  44. package/dist/{strand-backfill.js → peer-join-backfill.js} +146 -56
  45. package/dist/peer-join-backfill.js.map +1 -0
  46. package/dist/peer-record.d.ts +26 -0
  47. package/dist/peer-record.js +32 -0
  48. package/dist/peer-record.js.map +1 -1
  49. package/dist/relay-addrs.d.ts +145 -22
  50. package/dist/relay-addrs.js +314 -31
  51. package/dist/relay-addrs.js.map +1 -1
  52. package/dist/relay-reservation.d.ts +20 -15
  53. package/dist/relay-reservation.js +18 -14
  54. package/dist/relay-reservation.js.map +1 -1
  55. package/dist/strand-addr-protocol.js +16 -0
  56. package/dist/strand-addr-protocol.js.map +1 -1
  57. package/dist/strand-database.d.ts +12 -0
  58. package/dist/strand-database.js +16 -0
  59. package/dist/strand-database.js.map +1 -1
  60. package/dist/strand-formation-manager.d.ts +22 -9
  61. package/dist/strand-formation-manager.js +20 -12
  62. package/dist/strand-formation-manager.js.map +1 -1
  63. package/dist/strand-formation-protocol.d.ts +83 -9
  64. package/dist/strand-formation-protocol.js +86 -10
  65. package/dist/strand-formation-protocol.js.map +1 -1
  66. package/dist/strand-instance-manager.d.ts +151 -17
  67. package/dist/strand-instance-manager.js +237 -32
  68. package/dist/strand-instance-manager.js.map +1 -1
  69. package/dist/strand-membership-writer.d.ts +118 -14
  70. package/dist/strand-membership-writer.js +203 -15
  71. package/dist/strand-membership-writer.js.map +1 -1
  72. package/dist/strand-network-config.d.ts +84 -0
  73. package/dist/strand-network-config.js +136 -0
  74. package/dist/strand-network-config.js.map +1 -0
  75. package/dist/strand-revocation-enforcer.d.ts +392 -0
  76. package/dist/strand-revocation-enforcer.js +533 -0
  77. package/dist/strand-revocation-enforcer.js.map +1 -0
  78. package/dist/strand-solicitation.d.ts +10 -3
  79. package/dist/strand-solicitation.js +12 -7
  80. package/dist/strand-solicitation.js.map +1 -1
  81. package/dist/strand-wake-protocol.d.ts +47 -2
  82. package/dist/strand-wake-protocol.js +76 -6
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/strand-watcher.js +6 -1
  85. package/dist/strand-watcher.js.map +1 -1
  86. package/dist/types.d.ts +282 -43
  87. package/dist/types.js +7 -1
  88. package/dist/types.js.map +1 -1
  89. package/package.json +16 -8
  90. package/src/announce-addrs.ts +93 -0
  91. package/src/cadre-node.ts +1366 -171
  92. package/src/control-database.ts +2628 -2406
  93. package/src/control-read-retry.ts +167 -0
  94. package/src/control-retry.ts +190 -0
  95. package/src/control-schema.ts +50 -6
  96. package/src/control-write-retry.ts +30 -130
  97. package/src/delegate-admission.ts +17 -15
  98. package/src/enrolled-machine-store-file.ts +43 -0
  99. package/src/enrolled-machine-store.ts +280 -0
  100. package/src/file-durable-slot.ts +5 -3
  101. package/src/index.ts +477 -407
  102. package/src/membership-connection-gater.ts +539 -204
  103. package/src/node-local-snapshot.ts +13 -1
  104. package/src/peer-addr-book.ts +251 -250
  105. package/src/{strand-backfill.ts → peer-join-backfill.ts} +187 -75
  106. package/src/peer-record.ts +33 -0
  107. package/src/relay-addrs.ts +436 -111
  108. package/src/relay-reservation.ts +828 -823
  109. package/src/strand-addr-protocol.ts +17 -0
  110. package/src/strand-database.ts +17 -0
  111. package/src/strand-formation-manager.ts +511 -491
  112. package/src/strand-formation-protocol.ts +808 -692
  113. package/src/strand-instance-manager.ts +899 -611
  114. package/src/strand-membership-writer.ts +232 -15
  115. package/src/strand-network-config.ts +172 -0
  116. package/src/strand-revocation-enforcer.ts +653 -0
  117. package/src/strand-solicitation.ts +484 -472
  118. package/src/strand-wake-protocol.ts +94 -8
  119. package/src/strand-watcher.ts +319 -314
  120. package/src/types.ts +1554 -1309
  121. package/dist/strand-backfill.js.map +0 -1
package/dist/types.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import type { ConnectionGater, Libp2p, PeerId, PrivateKey } from '@libp2p/interface';
2
2
  import type { IRawStorage, Libp2pTransports } from '@optimystic/db-p2p';
3
3
  import type { IPeerNetwork, IRepo } from '@optimystic/db-core';
4
- import type { StrandBackfillConfig } from './strand-backfill.js';
4
+ import type { PeerJoinBackfillConfig } from './peer-join-backfill.js';
5
+ import type { StrandRevocationEnforcementConfig } from './strand-revocation-enforcer.js';
5
6
  import type { StrandDatabase } from './strand-database.js';
6
7
  import type { SeedTrustPolicy } from './seed-trust-policy.js';
7
8
  import type { KeyStore, KeyId } from './key-store.js';
8
9
  import type { TrustedOwnerStore, TrustSource } from './trusted-owner-store.js';
9
10
  import type { BootstrapPeerStore } from './bootstrap-peer-store.js';
11
+ import type { EnrolledMachineStore } from './enrolled-machine-store.js';
10
12
  import type { PushNotifier } from './push-notifier.js';
11
13
  import type { RevocableTable } from './control-authorization.js';
12
14
  /**
@@ -20,7 +22,7 @@ export interface Libp2pNodeWithRepo extends Libp2p {
20
22
  * `coordinatedRepo` (`libp2p-node-base.ts`). Optional deliberately: the base
21
23
  * factory always assigns it, but it is not part of the upstream node type, so
22
24
  * declaring it required would be a claim this repo cannot enforce. The strand
23
- * backfill (`strand-backfill.ts`) logs once and stays inert when it is absent.
25
+ * backfill (`peer-join-backfill.ts`) logs once and stays inert when it is absent.
24
26
  */
25
27
  keyNetwork?: IPeerNetwork;
26
28
  }
@@ -98,12 +100,12 @@ export declare const HIBERNATION_TIMEOUTS: Record<LatencyHint, HibernationTimeou
98
100
  * cadre-core disposes only its own cache wrapper — it never closes the store you
99
101
  * returned. Closing the underlying handle stays the embedder's job.
100
102
  *
101
- * **Prefer the factory form for any persistent backend.** One instance handed to
102
- * every scope also shares ONE cache wrapper (`wrapStorageWithCache` memoizes per
103
- * inner instance), and no scope solely owns it — so the first scope to stop retires
104
- * the wrapper the still-running scopes are reading through. See
105
- * `tickets/backlog/debt-storage-cache-wrap-unrefcounted.md`; an in-memory store is
106
- * unaffected, since it is never wrapped.
103
+ * Either form is safe with a persistent backend. One instance handed to every scope
104
+ * also shares ONE cache wrapper (`wrapStorageWithCache` memoizes per inner instance),
105
+ * which counts its holders: a scope stopping releases only its own claim, and the
106
+ * wrapper is retired when the last scope releases it. The factory form is still the
107
+ * better default for a different reason — it partitions each strand's data, which
108
+ * simplifies cleanup.
107
109
  */
108
110
  export type RawStorageProvider = IRawStorage | ((strandId: string) => IRawStorage);
109
111
  /**
@@ -140,28 +142,75 @@ export interface StorageConfig {
140
142
  * Network configuration for libp2p
141
143
  */
142
144
  export interface NetworkConfig {
145
+ /**
146
+ * Addresses this machine's libp2p nodes bind. Omitted, `@optimystic/db-p2p`'s own
147
+ * default (`/ip4/0.0.0.0/tcp/0`) applies; `[]` means "cannot listen" and is honored
148
+ * as written (React Native hosts rely on that).
149
+ *
150
+ * The machine runs one control node plus one node per strand, so a FIXED port here
151
+ * can only belong to one of them. The control node binds it as configured; each
152
+ * strand node binds the same entry with its port rewritten to `0`, keeping the
153
+ * interface and transport (`/ws`, a specific-interface bind). A
154
+ * `<relay>/p2p-circuit` entry is untouched — the port inside it is the relay's, not
155
+ * a local bind. See `strand-network-config.ts`.
156
+ *
157
+ * **Only TCP, WebSocket (`/ws`, `/wss`) and circuit-relay addresses are bindable**
158
+ * unless {@link transports} supplies the factories for something else. A `/ws` entry
159
+ * switches the WebSocket transport on by itself; anything else — `/quic-v1`,
160
+ * `/webrtc`, `/webtransport` — refuses node start naming the address and the libp2p
161
+ * package it would need, rather than being dropped in silence by libp2p's transport
162
+ * manager. See `relay-addrs.ts` → `resolveTransportOptions`.
163
+ */
143
164
  listenAddrs?: string[];
144
165
  /**
145
- * Addresses to advertise to peers instead of `listenAddrs` — for a node behind
166
+ * Addresses to advertise to peers **instead of** `listenAddrs` — for a node behind
146
167
  * NAT or a reverse proxy that is reachable at a different address than it binds
147
- * (e.g. `mynode.example.com:4001` in front of a `0.0.0.0:4001` listener).
168
+ * (e.g. `mynode.example.com:4001` in front of a `0.0.0.0:4001` listener). Reaches
169
+ * libp2p's `addresses.announce` via `@optimystic/db-p2p`'s `NodeOptions.announceAddrs`.
148
170
  *
149
- * Accepted (settable in `cadre.yaml`, via `CADRE_ANNOUNCE_ADDRS`, and through the
150
- * Docker entrypoint) but **not yet applied**: libp2p only takes announce addresses
151
- * at construction (`addresses.announce` / `addresses.appendAnnounce`), and
152
- * `@optimystic/db-p2p`'s `NodeOptions` has no field for either — there is nothing
153
- * in this repo to wire this into. `CadreNode.start()` warns when this is set,
154
- * rather than pretending it works. Unblocked by the `announce-addrs-option`
155
- * request filed against `@optimystic/db-p2p`; wiring lands here once this repo's
156
- * dependency range moves to a version carrying it.
171
+ * **Applies to this machine's CONTROL node only.** Strand nodes drop it: any concrete
172
+ * entry names a port, and that port is the control node's, so a strand node
173
+ * advertising it would send peers to the wrong node (`strand-network-config.ts`).
174
+ * Entries are still validated at node start whichever node ends up using them.
157
175
  *
158
- * Use {@link relayAddrs} for reachability behind NAT today (a reserved relay slot
159
- * gives the node a real dialable circuit address), or `inviteAddressResolver` to
160
- * substitute a reachable address into invite payloads specifically — neither
161
- * changes what this node advertises on the wire, but both cover the operator need
162
- * this field cannot yet.
176
+ * **A non-empty value REPLACES the advertised set entirely.** Observed addresses,
177
+ * and the `/p2p-circuit` address earned from a {@link relayAddrs} reservation, are
178
+ * all dropped from what peers are told — so a node configured with both this and
179
+ * `relayAddrs` stops being reachable through its relay. `CadreNode.start()` warns
180
+ * when it sees that combination; it does not refuse it, since a node whose relay
181
+ * slot is decorative may legitimately want only the announced address.
182
+ *
183
+ * Reach for {@link appendAnnounceAddrs} instead in the common case — one publicly
184
+ * reachable address ADDED to everything else the node advertises. Or for
185
+ * {@link relayAddrs} alone, when the node has no stable public address to name and
186
+ * a relay reservation is what makes it dialable. (`inviteAddressResolver` is a
187
+ * narrower tool again: it substitutes a reachable address into invite payloads
188
+ * only, and changes nothing about what this node advertises on the wire.)
189
+ *
190
+ * An empty array means "unset" — it is dropped rather than forwarded, so it cannot
191
+ * land as an explicit empty announce set. An empty *entry* is a different thing and
192
+ * is rejected: a list of one blank string is non-empty, so it would replace every
193
+ * advertised address with nothing. Malformed entries are rejected on the same terms,
194
+ * both at node start — libp2p itself would not notice either until its first address
195
+ * lookup (see `announce-addrs.ts`), so this repo checks each entry up front.
163
196
  */
164
197
  announceAddrs?: string[];
198
+ /**
199
+ * Addresses to advertise **in addition to** `listenAddrs` — the field most
200
+ * deployments actually want, since it makes a node reachable at a public address
201
+ * without discarding the observed and `/p2p-circuit` addresses it would otherwise
202
+ * advertise. Reaches libp2p's `addresses.appendAnnounce` via
203
+ * `@optimystic/db-p2p`'s `NodeOptions.appendAnnounceAddrs`.
204
+ *
205
+ * **Control node only**, on the same terms as {@link announceAddrs}.
206
+ *
207
+ * **Ignored while {@link announceAddrs} is non-empty** — that is libp2p's own
208
+ * precedence, applied upstream; this repo does not merge the two locally. Set one
209
+ * or the other, not both.
210
+ *
211
+ * Empty-array, blank-entry and malformed-entry handling all match {@link announceAddrs}.
212
+ */
213
+ appendAnnounceAddrs?: string[];
165
214
  /**
166
215
  * Circuit-relay servers this node reserves a slot on, so peers can reach it
167
216
  * from behind NAT. Each entry is the relay's DIRECT dial multiaddr ending in
@@ -169,18 +218,26 @@ export interface NetworkConfig {
169
218
  * `/p2p-circuit` suffix (one is appended for you; an entry that already has
170
219
  * one is passed through unchanged).
171
220
  *
172
- * This is sugar for a circuit entry in {@link listenAddrs}: `relay-addrs.ts`
173
- * resolves the two into one listen list, and listening on a relay's circuit
174
- * addr is what makes libp2p dial it and hold the reservation. A relay named
175
- * here therefore also becomes a delegate-announce target, so this node's
176
- * strand nodes may reserve on it too (see `delegate-admission.ts`).
221
+ * On the CONTROL node this adds libp2p's bare `/p2p-circuit` SEARCH listener to
222
+ * {@link listenAddrs} (`relay-addrs.ts`) and `CadreNode.start()` drives the
223
+ * reservation itself once the control database is up — the ordering matters,
224
+ * because a listener that dials its relay from inside `libp2p.start()` put a
225
+ * sibling in this node's cohort before its own database existed, and a sibling
226
+ * that had not yet replicated this node's membership row refused the bring-up.
227
+ * STRAND nodes inherit the older per-relay `<relay>/p2p-circuit` listener, which
228
+ * has no such ordering hazard. A relay named here also becomes a
229
+ * delegate-announce target, so this node's strand nodes may reserve on it too
230
+ * (see `delegate-admission.ts`).
177
231
  *
178
232
  * Setting this gives the node a listener even when `listenAddrs` is `[]` —
179
233
  * that is the point of naming a relay, but note that React Native hosts
180
234
  * (`reference-app-rn`'s `cadre-phone.ts`) deliberately do NOT listen, and
181
235
  * must not acquire a listener by accident.
182
236
  *
183
- * A malformed entry throws at node start rather than being silently dropped.
237
+ * FAIL-FAST both ways: a malformed entry throws at node start rather than being
238
+ * silently dropped, and a first reservation attempt that lands no
239
+ * `/p2p-circuit` address throws `RelayReservationFailedError` out of `start()`.
240
+ * The fail-soft posture over the same machinery is `CadreNode.reserveRelays()`.
184
241
  */
185
242
  relayAddrs?: string[];
186
243
  /**
@@ -189,6 +246,16 @@ export interface NetworkConfig {
189
246
  * better connectivity and uptime), false for transaction profile nodes.
190
247
  */
191
248
  enableRelay?: boolean;
249
+ /**
250
+ * Cap on concurrent circuit-relay reservations this node's relay server grants to
251
+ * peers it cannot (yet) recognize as authorized members — the boot-ordering window
252
+ * where a genuine member reserves its relay slot before its membership row has
253
+ * replicated here (see `membership-connection-gater.ts` → "The relay-reservation
254
+ * seam"). Authorized members and announced delegate peers are never counted against
255
+ * it. Default `MAX_UNAUTHORIZED_RELAY_RESERVATIONS` (8); 0 refuses every
256
+ * unauthorized reservation. Only meaningful while {@link enableRelay} is on.
257
+ */
258
+ unauthorizedRelayReservationCap?: number;
192
259
  /**
193
260
  * Custom libp2p transports. When omitted, the default transports from
194
261
  * `@optimystic/db-p2p` are used (TCP + circuit relay for Node.js).
@@ -211,6 +278,11 @@ export interface NetworkConfig {
211
278
  * (and other owner-address contexts). When unset, `libp2pNode.getMultiaddrs()`
212
279
  * is used. Hosts behind NAT supply this to substitute their DDNS hostname
213
280
  * and externally-mapped port — see `@serfab/cadre-host`'s NatService.
281
+ *
282
+ * Returned addresses need NOT carry a `/p2p/<peerId>` suffix: `CadreNode`
283
+ * appends its own before publishing or dialing any of them, so a resolver
284
+ * cannot leave a sibling with the mixed suffixed/unsuffixed list that
285
+ * `libp2p.dial` rejects. Returning them already suffixed is equally fine.
214
286
  */
215
287
  inviteAddressResolver?: () => Promise<string[]>;
216
288
  /**
@@ -225,8 +297,14 @@ export interface NetworkConfig {
225
297
  * On the CONTROL node this gater is composed with the built-in membership
226
298
  * admission gate (`membership-connection-gater.ts`): every hook supplied here
227
299
  * is honored unchanged, and on inbound encrypted connections a deny from
228
- * either this gater or the membership policy denies. Strand cohort nodes
229
- * receive this gater as-is (their peers are legitimately cross-party).
300
+ * either this gater or the membership policy denies.
301
+ *
302
+ * Strand cohort nodes: an OPEN strand's node receives this gater as-is (its
303
+ * peers are legitimately cross-party). A CLOSED strand's node composes
304
+ * revoked-peer denial onto it (`strand-revocation-enforcer.ts`) — every hook
305
+ * supplied here is still honored unchanged, and on the composed hooks
306
+ * (inbound encrypted connection, outbound peer dial, relay reservation) a
307
+ * deny from either this gater or the revocation check denies.
230
308
  */
231
309
  connectionGater?: ConnectionGater;
232
310
  /**
@@ -307,8 +385,14 @@ export interface ControlNetworkConfig {
307
385
  * by omission); the strand breadth defaults to
308
386
  * {@link CadreNodeConfig.strandClusterSize} while its policy is the fixed
309
387
  * {@link STRAND_CLUSTER_POLICY}.
388
+ *
389
+ * The two `*ClusterPolicy` BUILDERS are the same objects with the block-repair
390
+ * corroboration yardstick declared from the machines enrolled in this party
391
+ * ({@link resolveRepairYardstick}); handed no count, each returns its frozen base
392
+ * constant unchanged. A network picks the derived number up when its libp2p node is
393
+ * built, which for a strand is every wake from hibernation.
310
394
  */
311
- export { MIN_CLUSTER_SIZE, CONTROL_REPLICATION_BREADTH, CONTROL_CLUSTER_POLICY, DEFAULT_STRAND_CLUSTER_SIZE, STRAND_CLUSTER_POLICY, resolveStrandClusterSize } from '@serfab/quereus-plugin-sereus';
395
+ export { MIN_CLUSTER_SIZE, CONTROL_REPLICATION_BREADTH, CONTROL_CLUSTER_POLICY, DEFAULT_STRAND_CLUSTER_SIZE, STRAND_CLUSTER_POLICY, resolveStrandClusterSize, resolveRepairYardstick, controlClusterPolicy, strandClusterPolicy } from '@serfab/quereus-plugin-sereus';
312
396
  /**
313
397
  * Main configuration for a CadreNode
314
398
  */
@@ -323,8 +407,8 @@ export interface CadreNodeConfig {
323
407
  * Pluggable secure store for node key material. When set, the node loads its
324
408
  * identity from `keyStore` under {@link identityKeyId}, generating + persisting
325
409
  * a fresh Ed25519 key on first run (protobuf bytes are the canonical stored
326
- * form). Mutually exclusive with {@link privateKey}. Absent ⇒ legacy behavior
327
- * (use `privateKey`, else libp2p generates an ephemeral key).
410
+ * form). Mutually exclusive with {@link privateKey}. Absent ⇒ use `privateKey`
411
+ * when set, else libp2p generates an ephemeral key.
328
412
  *
329
413
  * In the single-key reference model the owner signing key is *derived from*
330
414
  * the node identity (see {@link CadreNode.getIdentityOwnerKey}), so
@@ -369,17 +453,42 @@ export interface CadreNodeConfig {
369
453
  */
370
454
  strandClusterSize?: number;
371
455
  /**
372
- * Tuning for the strand peer-join block catch-up (see `strand-backfill.ts`):
456
+ * Tuning for the strand peer-join block catch-up (see `peer-join-backfill.ts`):
373
457
  * when a strand's libp2p node connects to a peer this runtime has not yet
374
458
  * caught up, every block in the strand's own raw store is pushed to that peer,
375
459
  * so a machine that joined the strand after blocks were committed still ends
376
- * up physically holding them. Applies to every strand this node starts;
377
- * strand-only — the control network has its own row-level re-issue queue
378
- * (`drainPendingControlReplication`). Omit for the defaults
379
- * (`DEFAULT_STRAND_BACKFILL`); `{ enabled: false }` restores the
380
- * no-backfill behaviour.
460
+ * up physically holding them. Applies to every strand this node starts.
461
+ * Omit for the defaults (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }`
462
+ * restores the no-backfill behaviour. The control network runs its own,
463
+ * separately tuned catch-up — see {@link controlBackfill}.
464
+ */
465
+ strandBackfill?: PeerJoinBackfillConfig;
466
+ /**
467
+ * Tuning for the CONTROL network's peer-join block catch-up (same module,
468
+ * `peer-join-backfill.ts`, wired in `CadreNode.start`): when the control
469
+ * libp2p node connects to an AUTHORIZED party member this runtime has not yet
470
+ * caught up, every block in the control database's own raw store is pushed to
471
+ * it. This is what makes a control block committed while the writer was alone
472
+ * (collection headers written at genesis above all) physically reach members
473
+ * that joined later — without it, such a member that restarts offline reads
474
+ * the affected tables as empty. Pushes are gated on
475
+ * `CadreNode.isAuthorizedMember`, checked at push time, because the control
476
+ * network's inbound gate deliberately admits non-members in several states
477
+ * (seed delivery, enrollment, bootstrap/relay peers). Omit for the defaults
478
+ * (`DEFAULT_PEER_JOIN_BACKFILL`); `{ enabled: false }` disables it.
381
479
  */
382
- strandBackfill?: StrandBackfillConfig;
480
+ controlBackfill?: PeerJoinBackfillConfig;
481
+ /**
482
+ * Tuning for the CLOSED-strand revoked-peer gate
483
+ * (`strand-revocation-enforcer.ts`), applied to every closed strand this node
484
+ * starts: each strand node materializes the peer ids bound to REMOVED members
485
+ * (orphaned `Strand.MemberPeer` rows) and refuses them at the stream and
486
+ * connection layers, so removing a party cuts the network to its machines
487
+ * rather than only deleting its row. Open strands never arm it. Omit for the
488
+ * defaults (`DEFAULT_REVOCATION_POLL_INTERVAL_MS` refresh cadence);
489
+ * `{ enabled: false }` restores the pre-existing behaviour.
490
+ */
491
+ strandRevocationEnforcement?: StrandRevocationEnforcementConfig;
383
492
  /** Hibernation configuration */
384
493
  hibernation?: HibernationConfig;
385
494
  /** Polling interval for strand watcher in ms (default: 5000) */
@@ -463,6 +572,39 @@ export interface CadreNodeConfig {
463
572
  */
464
573
  store?: BootstrapPeerStore;
465
574
  };
575
+ /**
576
+ * Node-local record of how many machines this party had enrolled the last time
577
+ * this node looked (see `enrolled-machine-store.ts`): the block-repair
578
+ * corroboration yardstick the CONTROL node declares when its libp2p node is
579
+ * built. Sibling of {@link trustedOwners} / {@link bootstrapPeers} — same
580
+ * NON-replicated, per-party, injected-backend shape — and, like
581
+ * {@link bootstrapPeers}, nothing here is trust-bearing: it is a repair hint,
582
+ * recomputed from `CadrePeer` rows the moment the control database is up.
583
+ *
584
+ * It exists only because the control node is created BEFORE that database, so
585
+ * the live count is unreadable at the one moment it is needed; remembering it
586
+ * across the restart is what breaks that deadlock. Absent ⇒ an in-memory store
587
+ * is created at start(), which cold-starts on every launch and therefore
588
+ * declares nothing — byte-for-byte the behaviour before this record existed.
589
+ * No embedder is forced to change.
590
+ *
591
+ * A party that grows applies the larger number on each node's NEXT launch,
592
+ * deliberately: Optimystic freezes the policy at node construction and offers
593
+ * no runtime setter, and a node still holding the old value runs at exactly
594
+ * today's behaviour, so there is no forced rebuild.
595
+ *
596
+ * A new platform needs no new store class: supply a `DurableSlot` for the
597
+ * platform's storage and inject `PersistentEnrolledMachineStore.open(slot, partyId)`.
598
+ */
599
+ enrolledMachines?: {
600
+ /**
601
+ * Injected store instance — e.g. a `FileEnrolledMachineStore` from the
602
+ * Node-only subpath `@serfab/cadre-core/enrolled-machine-store-file`,
603
+ * persisted in the node's state directory. Its `partyId` must match
604
+ * `controlNetwork.partyId`; start() fails closed on a mismatch.
605
+ */
606
+ store?: EnrolledMachineStore;
607
+ };
466
608
  /**
467
609
  * Platform push-delivery for suspended mobile peers. When present, the node's
468
610
  * server fan-out can deliver strand-wake data messages over the platform push
@@ -555,6 +697,17 @@ export interface StrandRow {
555
697
  Id: string;
556
698
  MemberPrivateKey: string | null;
557
699
  Type: 'o' | 'c';
700
+ /**
701
+ * ed25519 (base64url) owner key of the MACHINE that published this row — the
702
+ * signer of the owner-signed `Strand` insert (`== context.OwnerKey`, pinned by
703
+ * the schema's `AuthorizedInsert`). Identifies the founding machine, so a
704
+ * launch with no explicit `founder` flag derives "am I the founder?" by
705
+ * comparing it to the node's own owner key (see `CadreNode.launchStrand`).
706
+ * `null` on a consent-seated strand (the unsigned `FormationUsage` branch
707
+ * records no trustworthy signer). Provenance, not content —
708
+ * `strandRowMismatches` excludes it from the identical-content comparison.
709
+ */
710
+ FounderOwnerKey: string | null;
558
711
  }
559
712
  /**
560
713
  * sApp configuration provided by the hosting application when creating a strand.
@@ -592,11 +745,62 @@ export interface StrandConfig {
592
745
  * paths; the same party that calls {@link CadreNode.publishStrand}). The founder
593
746
  * runs the one-time membership bootstrap at bring-up (writes `Strand.Header`, and
594
747
  * for a closed strand the founding `Member`+`Manager`). A joiner leaves this
595
- * `false`/unset and writes nothing — it receives those rows via Optimystic sync.
596
- * Defaults to `false`.
748
+ * unset and writes nothing — it receives those rows via Optimystic sync.
749
+ *
750
+ * When unset, founder-ness is DERIVED from the row: this node founds iff
751
+ * {@link StrandRow.FounderOwnerKey} equals its own owner key. An explicit
752
+ * `true`/`false` wins over the derivation — the formation/responder flows pass
753
+ * it deliberately, since a consent-seated row carries a null column.
597
754
  */
598
755
  founder?: boolean;
599
756
  }
757
+ /**
758
+ * What {@link CadreNode.foundStrand} needs to publish a strand's control row AND start it
759
+ * locally as its founder — the resumable one-call form of `publishStrand` + `addStrand`.
760
+ *
761
+ * No `strandRow`: the row is the OUTPUT of founding, either freshly published or read back
762
+ * from an interrupted earlier attempt. `StrandConfig` (the join/attach shape) takes one
763
+ * because there the row already exists and came from the control network.
764
+ */
765
+ export interface FoundStrandConfig {
766
+ /** Unique strand identifier. Trimmed; blank is rejected. */
767
+ strandId: string;
768
+ /** `'o'` for open (default) or `'c'` for closed. */
769
+ type?: 'o' | 'c';
770
+ /**
771
+ * Membership key gating a closed strand. Required in practice for `type: 'c'` (the
772
+ * founder bootstrap rejects a null key), and IGNORED when the strand is already
773
+ * published — the stored key wins, so read the resolved key back off the returned
774
+ * instance rather than trusting a freshly minted one.
775
+ */
776
+ memberPrivateKey?: string;
777
+ /** sApp configuration the hosting application provides, as for {@link StrandConfig}. */
778
+ sAppConfig: SAppConfig;
779
+ }
780
+ /**
781
+ * What {@link CadreNode.foundStrand} hands back: the running instance PLUS the `Strand` row
782
+ * the strand actually runs under.
783
+ *
784
+ * The row is returned separately rather than left for the caller to read off the instance
785
+ * because on a resumed founding it is NOT the caller's input — a closed strand adopts the
786
+ * stored `MemberPrivateKey`, discarding a freshly minted one. A caller that must carry the
787
+ * membership key onward (to mint an invitation, say) reads it from HERE.
788
+ */
789
+ export interface FoundStrandResult {
790
+ /** The active local instance, as {@link CadreNode.addStrand} would have returned. */
791
+ instance: StrandInstance;
792
+ /** The live control-plane row: freshly published, or the one already there. */
793
+ strandRow: StrandRow;
794
+ /**
795
+ * Whether THIS machine actually founded (ran / will have run the one-time
796
+ * founder bootstrap). `false` when the resolved row was published by a
797
+ * DIFFERENT machine's owner key — e.g. a sibling won a concurrent founding
798
+ * race — in which case the call attached as a joiner instead, which is the
799
+ * correct outcome (two machines bootstrapping one strand on separate replicas
800
+ * is the double-`Header` hazard).
801
+ */
802
+ founded: boolean;
803
+ }
600
804
  /**
601
805
  * Result of creating a new cadre peer
602
806
  */
@@ -654,6 +858,21 @@ export interface FormStrandResult {
654
858
  * from {@link invitePrivateKey} (the initiator's own generated signing key).
655
859
  */
656
860
  memberPrivateKey?: string;
861
+ /**
862
+ * The responder's live STRAND-network multiaddrs for the formed strand — the only
863
+ * cross-party discovery seed there is, since the strand-addr RPC that resolves a
864
+ * sibling's strand addresses answers own-party callers only.
865
+ *
866
+ * Always an array, frequently EMPTY: the responder discloses none when it holds no
867
+ * live strand node for the strand it just provisioned (the responder-provisions path
868
+ * mints a strand that has not launched yet). `CadreNode.formStrand` records these
869
+ * itself, so an embedding app only needs them to seed a strand it launches OUTSIDE
870
+ * that node.
871
+ *
872
+ * One-shot and in-memory: they are the responder's addresses at the moment of
873
+ * formation and are never re-resolved — see `docs/strands.md`.
874
+ */
875
+ strandAddrs: string[];
657
876
  }
658
877
  /**
659
878
  * Disclosure object provided during strand formation.
@@ -706,6 +925,26 @@ export interface CadreNodeEvents {
706
925
  'strand:waking': {
707
926
  strandId: string;
708
927
  };
928
+ /**
929
+ * Emitted when this node discovers it is no longer a member of a CLOSED
930
+ * strand — its party was removed by a manager, or it left. Detected by the
931
+ * strand's revoked-peer gate (`strand-revocation-enforcer.ts`) seeing this
932
+ * node's own peer id in the revoked set, so it is BEST-EFFORT: it fires only
933
+ * if the removal replicated here before the rest of the strand cut us off,
934
+ * and a node that was already offline may never see it at all.
935
+ *
936
+ * Nothing is stopped or torn down for you. Treat it as "this strand will no
937
+ * longer sync": remaining members refuse this node's streams, dials, and
938
+ * connections, so reads keep working against whatever is stored locally while
939
+ * writes stop propagating. Stopping the strand (`stopStrand`) or deleting its
940
+ * data is the app's decision. Emitted once per removal — re-armed if a manager
941
+ * re-admits this party, so a second removal is reported again — and a
942
+ * hibernation wake rebuilds the gate, which may re-emit for a strand still
943
+ * revoked.
944
+ */
945
+ 'strand:revoked': {
946
+ strandId: string;
947
+ };
709
948
  /**
710
949
  * Emitted when the control network advertises a strand this node has no
711
950
  * registered `sAppConfig` for — i.e. a strand created by another member. The
package/dist/types.js CHANGED
@@ -51,6 +51,12 @@ export const DEFAULT_CHECKIN_WINDOW_MS = 15 * 1000;
51
51
  * by omission); the strand breadth defaults to
52
52
  * {@link CadreNodeConfig.strandClusterSize} while its policy is the fixed
53
53
  * {@link STRAND_CLUSTER_POLICY}.
54
+ *
55
+ * The two `*ClusterPolicy` BUILDERS are the same objects with the block-repair
56
+ * corroboration yardstick declared from the machines enrolled in this party
57
+ * ({@link resolveRepairYardstick}); handed no count, each returns its frozen base
58
+ * constant unchanged. A network picks the derived number up when its libp2p node is
59
+ * built, which for a strand is every wake from hibernation.
54
60
  */
55
- export { MIN_CLUSTER_SIZE, CONTROL_REPLICATION_BREADTH, CONTROL_CLUSTER_POLICY, DEFAULT_STRAND_CLUSTER_SIZE, STRAND_CLUSTER_POLICY, resolveStrandClusterSize } from '@serfab/quereus-plugin-sereus';
61
+ export { MIN_CLUSTER_SIZE, CONTROL_REPLICATION_BREADTH, CONTROL_CLUSTER_POLICY, DEFAULT_STRAND_CLUSTER_SIZE, STRAND_CLUSTER_POLICY, resolveStrandClusterSize, resolveRepairYardstick, controlClusterPolicy, strandClusterPolicy } from '@serfab/quereus-plugin-sereus';
56
62
  //# sourceMappingURL=types.js.map
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AA2EA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAA6C;IAC5E,QAAQ,EAAE;QACR,WAAW,EAAE,QAAQ,EAAS,aAAa;QAC3C,gBAAgB,EAAE,QAAQ,EAAI,kBAAkB;QAChD,eAAe,EAAE,QAAQ,EAAK,MAAM;QACpC,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,QAAQ,CAAE,MAAM;KACrC;IACD,WAAW,EAAE;QACX,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,YAAY;QAC1C,gBAAgB,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,wBAAwB;QAC1D,eAAe,EAAE,EAAE,GAAG,IAAI,EAAI,mBAAmB;QACjD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,eAAe;KACnD;IACD,UAAU,EAAE;QACV,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,WAAW;QACzC,gBAAgB,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAG,uBAAuB;QACzD,eAAe,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,kBAAkB;QACpD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,gBAAgB;KACxD;IACD,OAAO,EAAE;QACP,WAAW,EAAE,EAAE,GAAG,IAAI,EAAQ,aAAa;QAC3C,gBAAgB,EAAE,EAAE,GAAG,IAAI,EAAG,wBAAwB;QACtD,eAAe,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,EAAG,eAAe;QACjD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,eAAe;KAC5D;CACF,CAAC;AA+MF;;;GAGG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,EAAE,GAAG,IAAI,CAAC;AAYnD;;;;;;;;;;;GAWG;AACH,OAAO,EACL,gBAAgB,EAChB,2BAA2B,EAC3B,sBAAsB,EACtB,2BAA2B,EAC3B,qBAAqB,EACrB,wBAAwB,EACzB,MAAM,+BAA+B,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AA6EA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAA6C;IAC5E,QAAQ,EAAE;QACR,WAAW,EAAE,QAAQ,EAAS,aAAa;QAC3C,gBAAgB,EAAE,QAAQ,EAAI,kBAAkB;QAChD,eAAe,EAAE,QAAQ,EAAK,MAAM;QACpC,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,QAAQ,CAAE,MAAM;KACrC;IACD,WAAW,EAAE;QACX,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,YAAY;QAC1C,gBAAgB,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,wBAAwB;QAC1D,eAAe,EAAE,EAAE,GAAG,IAAI,EAAI,mBAAmB;QACjD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,eAAe;KACnD;IACD,UAAU,EAAE;QACV,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,WAAW;QACzC,gBAAgB,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAG,uBAAuB;QACzD,eAAe,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,EAAI,kBAAkB;QACpD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,gBAAgB;KACxD;IACD,OAAO,EAAE;QACP,WAAW,EAAE,EAAE,GAAG,IAAI,EAAQ,aAAa;QAC3C,gBAAgB,EAAE,EAAE,GAAG,IAAI,EAAG,wBAAwB;QACtD,eAAe,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,EAAG,eAAe;QACjD,oBAAoB,EAAE,CAAC;QACvB,kBAAkB,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,eAAe;KAC5D;CACF,CAAC;AA2RF;;;GAGG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,EAAE,GAAG,IAAI,CAAC;AAYnD;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EACL,gBAAgB,EAChB,2BAA2B,EAC3B,sBAAsB,EACtB,2BAA2B,EAC3B,qBAAqB,EACrB,wBAAwB,EACxB,sBAAsB,EACtB,oBAAoB,EACpB,mBAAmB,EACpB,MAAM,+BAA+B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serfab/cadre-core",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Core library for Sereus cadre nodes - manages control networks and strand participation",
6
6
  "main": "dist/index.js",
@@ -32,6 +32,10 @@
32
32
  "types": "./dist/bootstrap-peer-store-file.d.ts",
33
33
  "import": "./dist/bootstrap-peer-store-file.js"
34
34
  },
35
+ "./enrolled-machine-store-file": {
36
+ "types": "./dist/enrolled-machine-store-file.d.ts",
37
+ "import": "./dist/enrolled-machine-store-file.js"
38
+ },
35
39
  "./push-node": {
36
40
  "types": "./dist/push-node.d.ts",
37
41
  "import": "./dist/push-node.js"
@@ -64,13 +68,17 @@
64
68
  "@chainsafe/libp2p-yamux": "^8.0.1",
65
69
  "@libp2p/circuit-relay-v2": "^4.1.3",
66
70
  "@libp2p/identify": "4.0.10",
71
+ "@libp2p/logger": "^6.2.2",
72
+ "@libp2p/peer-store": "^12.0.10",
67
73
  "@libp2p/tcp": "^11.0.10",
68
74
  "@libp2p/webrtc": "6.0.14",
69
75
  "@libp2p/websockets": "^10.1.3",
70
- "@optimystic/db-p2p-storage-fs": "^0.24.0",
76
+ "@optimystic/db-p2p-storage-fs": "^1.0.0-beta.2",
71
77
  "@types/debug": "^4.1.12",
72
78
  "@types/node": "^25.0.10",
73
79
  "@vitest/coverage-v8": "^4.0.17",
80
+ "datastore-core": "^11.0.1",
81
+ "main-event": "^1.0.1",
74
82
  "rimraf": "^6.1.2",
75
83
  "typescript": "^5.9.3",
76
84
  "vitest": "^4.0.17"
@@ -80,12 +88,12 @@
80
88
  "@libp2p/interface": "^3.1.0",
81
89
  "@libp2p/peer-id": "^6.0.4",
82
90
  "@multiformats/multiaddr": "^12.5.1",
83
- "@optimystic/db-core": "^0.24.0",
84
- "@optimystic/db-p2p": "^0.24.0",
85
- "@optimystic/quereus-plugin-crypto": "^0.24.0",
86
- "@optimystic/quereus-plugin-optimystic": "^0.24.0",
87
- "@quereus/quereus": "^4.14.0",
88
- "@serfab/quereus-plugin-sereus": "^0.11.0",
91
+ "@optimystic/db-core": "^1.0.0-beta.2",
92
+ "@optimystic/db-p2p": "^1.0.0-beta.2",
93
+ "@optimystic/quereus-plugin-crypto": "^1.0.0-beta.2",
94
+ "@optimystic/quereus-plugin-optimystic": "^1.0.0-beta.2",
95
+ "@quereus/quereus": "^4.19.0",
96
+ "@serfab/quereus-plugin-sereus": "^0.13.0",
89
97
  "debug": "^4.4.3",
90
98
  "libp2p": "^3.1.3",
91
99
  "uint8arrays": "^5.1.0"
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Translate `network.announceAddrs` / `network.appendAnnounceAddrs` — the operator-facing
3
+ * "advertise these addresses" settings — into the two `@optimystic/db-p2p` `NodeOptions`
4
+ * fields of the same names, which reach libp2p's `addresses.announce` /
5
+ * `addresses.appendAnnounce`.
6
+ *
7
+ * Two jobs, both of which the control node and every strand node need identically, hence
8
+ * one module rather than the same conditional spread written twice:
9
+ *
10
+ * - **Empty means unset.** libp2p treats `announce: []` as "no override", and forwarding
11
+ * `[]` would still be forwarding a field. An empty config array is dropped so it cannot
12
+ * land as an explicit empty announce set.
13
+ * - **Validate the multiaddrs here, at start.** libp2p does NOT validate announce addrs
14
+ * at construction: `AddressManager` stores them as raw strings and only parses them on
15
+ * the first `getAnnounceAddrs()`. A typo therefore produces a node that starts
16
+ * cleanly, then throws `InvalidMultiaddrError` out of every `getMultiaddrs()` call —
17
+ * including an UNHANDLED one from the debounced peer-store update libp2p runs on
18
+ * `transport:listening`. Verified against libp2p's `address-manager` (its constructor
19
+ * does `announce.map(ma => ma.toString())`; `getAnnounceAddrs()` does `multiaddr(a)`).
20
+ * Parsing each entry up front converts that into a loud failure at node start, which is
21
+ * what {@link relayCircuitAddrs} already does for `network.relayAddrs`.
22
+ *
23
+ * Parsing alone is not enough, though: `''` and `'/'` both parse into a component-less
24
+ * multiaddr, and a set holding only those is non-empty by the rule above — so it would
25
+ * replace every advertised address with nothing at all. An entry naming no address is
26
+ * therefore rejected alongside one that fails to parse.
27
+ */
28
+
29
+ import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
30
+ import type { NetworkConfig } from './types.js';
31
+
32
+ /**
33
+ * The announce fields to spread into `createLibp2pNode` options. A field is present
34
+ * only when the operator configured a non-empty list for it, so a node that configures
35
+ * neither passes neither.
36
+ */
37
+ export interface ResolvedAnnounceAddrs {
38
+ announceAddrs?: string[];
39
+ appendAnnounceAddrs?: string[];
40
+ }
41
+
42
+ /**
43
+ * Resolve both announce fields off a `NetworkConfig`. Throws — naming the offending
44
+ * config field and entry — when any entry is not a parsable multiaddr.
45
+ */
46
+ export function resolveAnnounceAddrs(network: NetworkConfig | undefined): ResolvedAnnounceAddrs {
47
+ const announceAddrs = configuredAddrs('network.announceAddrs', network?.announceAddrs);
48
+ const appendAnnounceAddrs = configuredAddrs('network.appendAnnounceAddrs', network?.appendAnnounceAddrs);
49
+ return {
50
+ ...(announceAddrs && { announceAddrs }),
51
+ ...(appendAnnounceAddrs && { appendAnnounceAddrs })
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Whether this node's advertised set will be REPLACED rather than extended — i.e.
57
+ * whether {@link ResolvedAnnounceAddrs.announceAddrs} is in play at all. The single
58
+ * predicate behind `CadreNode.start()`'s operator warning, kept here so the warning
59
+ * and the forwarding agree on what "set" means (non-empty, not merely present).
60
+ */
61
+ export function replacesAdvertisedAddrs(network: NetworkConfig | undefined): boolean {
62
+ return (network?.announceAddrs?.length ?? 0) > 0;
63
+ }
64
+
65
+ /** The entries to forward for one announce field, or `undefined` when it is unset or empty. */
66
+ function configuredAddrs(field: string, addrs: readonly string[] | undefined): string[] | undefined {
67
+ if (!addrs || addrs.length === 0) {
68
+ return undefined;
69
+ }
70
+ return addrs.map((addr) => validated(field, addr));
71
+ }
72
+
73
+ /** `addr` unchanged, having proved it names a real address — config field named on failure. */
74
+ function validated(field: string, addr: string): string {
75
+ if (parsed(field, addr).getComponents().length === 0) {
76
+ // `''` and `'/'` PARSE: both yield a component-less multiaddr that stringifies to
77
+ // `/`. Forwarded, they make an announce set that is non-empty but names nothing —
78
+ // which by the replacement rule above costs the node every address it would
79
+ // otherwise advertise, silently. A templated `cadre.yaml` whose address variable went
80
+ // unsubstituted is the realistic way in, so it is rejected rather than forwarded.
81
+ throw new Error(`${field} entry names no address: ${JSON.stringify(addr)}`);
82
+ }
83
+ return addr;
84
+ }
85
+
86
+ /** `addr` as a multiaddr, with the config field named on a parse failure. */
87
+ function parsed(field: string, addr: string): Multiaddr {
88
+ try {
89
+ return multiaddr(addr);
90
+ } catch (err) {
91
+ throw new Error(`${field} entry is not a valid multiaddr: ${addr}`, { cause: err });
92
+ }
93
+ }