@serfab/cadre-core 1.8.0 → 1.9.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 (59) hide show
  1. package/dist/cadre-node.d.ts +153 -104
  2. package/dist/cadre-node.js +227 -199
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/index.d.ts +2 -4
  5. package/dist/index.js +10 -11
  6. package/dist/index.js.map +1 -1
  7. package/dist/node-local-snapshot.d.ts +6 -4
  8. package/dist/node-local-snapshot.js +7 -3
  9. package/dist/node-local-snapshot.js.map +1 -1
  10. package/dist/peer-join-backfill.d.ts +0 -9
  11. package/dist/peer-join-backfill.js +2 -3
  12. package/dist/peer-join-backfill.js.map +1 -1
  13. package/dist/strand-formation-protocol.d.ts +0 -11
  14. package/dist/strand-formation-protocol.js +3 -4
  15. package/dist/strand-formation-protocol.js.map +1 -1
  16. package/dist/strand-fret-addrs.d.ts +54 -0
  17. package/dist/strand-fret-addrs.js +108 -0
  18. package/dist/strand-fret-addrs.js.map +1 -0
  19. package/dist/strand-instance-manager.d.ts +9 -38
  20. package/dist/strand-instance-manager.js +13 -73
  21. package/dist/strand-instance-manager.js.map +1 -1
  22. package/dist/strand-network-state-file.d.ts +12 -0
  23. package/dist/strand-network-state-file.js +38 -0
  24. package/dist/strand-network-state-file.js.map +1 -0
  25. package/dist/strand-network-state.d.ts +111 -0
  26. package/dist/strand-network-state.js +191 -0
  27. package/dist/strand-network-state.js.map +1 -0
  28. package/dist/types.d.ts +20 -23
  29. package/package.json +11 -10
  30. package/src/cadre-node.ts +238 -205
  31. package/src/index.ts +15 -55
  32. package/src/node-local-snapshot.ts +12 -7
  33. package/src/peer-join-backfill.ts +2 -3
  34. package/src/strand-formation-protocol.ts +3 -4
  35. package/src/strand-fret-addrs.ts +145 -0
  36. package/src/strand-instance-manager.ts +20 -96
  37. package/src/strand-network-state-file.ts +40 -0
  38. package/src/strand-network-state.ts +243 -0
  39. package/src/types.ts +20 -23
  40. package/dist/strand-peer-book-file.d.ts +0 -12
  41. package/dist/strand-peer-book-file.js +0 -37
  42. package/dist/strand-peer-book-file.js.map +0 -1
  43. package/dist/strand-peer-book-protocol.d.ts +0 -168
  44. package/dist/strand-peer-book-protocol.js +0 -356
  45. package/dist/strand-peer-book-protocol.js.map +0 -1
  46. package/dist/strand-peer-book-swap.d.ts +0 -104
  47. package/dist/strand-peer-book-swap.js +0 -284
  48. package/dist/strand-peer-book-swap.js.map +0 -1
  49. package/dist/strand-peer-book.d.ts +0 -159
  50. package/dist/strand-peer-book.js +0 -361
  51. package/dist/strand-peer-book.js.map +0 -1
  52. package/dist/strand-peer-observer.d.ts +0 -88
  53. package/dist/strand-peer-observer.js +0 -180
  54. package/dist/strand-peer-observer.js.map +0 -1
  55. package/src/strand-peer-book-file.ts +0 -39
  56. package/src/strand-peer-book-protocol.ts +0 -466
  57. package/src/strand-peer-book-swap.ts +0 -338
  58. package/src/strand-peer-book.ts +0 -475
  59. package/src/strand-peer-observer.ts +0 -235
package/src/index.ts CHANGED
@@ -128,61 +128,21 @@ export {
128
128
  type JoinedStrandRecord
129
129
  } from './joined-strand-store.js';
130
130
 
131
- // Node-local strand peer book: per strand, the strand peers this node has met and
132
- // their last-known addresses, dialed first on every launch so a restarted machine
133
- // re-finds the other parties. Same cross-platform split as the bootstrap-peer store
134
- // above — interface + in-memory + slot-backed stores here, Node-only file backend
135
- // behind the subpath '@serfab/cadre-core/strand-peer-book-file'.
136
- export {
137
- MemoryStrandPeerBookStore,
138
- PersistentStrandPeerBookStore,
139
- mergeStrandPeerEntry,
140
- sanitizeStrandPeerEntry,
141
- strandPeerFreshness,
142
- MAX_STRAND_PEERS,
143
- STRAND_PEER_MAX_AGE_MS,
144
- type StrandPeerBookStore,
145
- type StrandPeerBookOptions,
146
- type StrandPeerEntry
147
- } from './strand-peer-book.js';
148
- export {
149
- StrandPeerObserver,
150
- dialableAddrs,
151
- connectedIdentifiedPeers,
152
- STRAND_PEER_OBSERVE_THROTTLE_MS,
153
- type StrandPeerObservation,
154
- type StrandPeerObserverDeps,
155
- type StrandPeerObserverOptions,
156
- type ConnectedIdentifiedPeer
157
- } from './strand-peer-observer.js';
158
- // The signed book SWAP between strand peers (`/sereus/strand-peers/1.0.0`): the wire
159
- // protocol, signing and verification, and the per-strand driver that arms it.
160
- export {
161
- STRAND_PEER_BOOK_PROTOCOL,
162
- SIGNED_STRAND_PEER_ENTRY_VERSION,
163
- STRAND_PEER_ISSUED_AT_SKEW_MS,
164
- MAX_BOOK_FRAME_SIZE,
165
- StrandPeerBookService,
166
- exchangeStrandPeerBook,
167
- signStrandPeerEntry,
168
- signedStrandPeerEntryPayload,
169
- verifySignedStrandPeerEntry,
170
- verifyStrandPeerBookFrame,
171
- trimBookFrameToFit,
172
- assertBookFrameFits,
173
- type SignedStrandPeerEntry,
174
- type StrandPeerBookFrame,
175
- type StrandPeerEntryVerdict,
176
- type StrandPeerBookServiceDeps,
177
- type ExchangeStrandPeerBookOptions
178
- } from './strand-peer-book-protocol.js';
179
- export {
180
- StrandPeerBookSwap,
181
- STRAND_PEER_BOOK_SWAP_THROTTLE_MS,
182
- OWN_ENTRY_RESIGN_DEBOUNCE_MS,
183
- type StrandPeerBookSwapDeps,
184
- type StrandPeerBookSwapOptions
185
- } from './strand-peer-book-swap.js';
131
+ // Node-local strand network state: per strand, the state Optimystic's db-p2p saves for
132
+ // the strand node (its FRET routing table with each peer's signed address record), so a
133
+ // restarted strand node re-imports the peers it was talking to. Same cross-platform
134
+ // split as the bootstrap-peer store above — interface + in-memory + slot-backed stores
135
+ // here, Node-only file backend behind '@serfab/cadre-core/strand-network-state-file'.
136
+ export {
137
+ MemoryStrandNetworkStateStore,
138
+ PersistentStrandNetworkStateStore,
139
+ strandNetworkStatePersistence,
140
+ type StrandNetworkStateStore
141
+ } from './strand-network-state.js';
142
+ // The signed address records a running strand node's FRET table holds, as per-peer dial
143
+ // addresses: what the periodic refresh re-merges into the node's address book, and a
144
+ // diagnostic for "whom could this strand node redial right now?".
145
+ export { strandFretPeerAddrs, type FretAddrHost, type FretPeerAddrs } from './strand-fret-addrs.js';
186
146
 
187
147
  // Storage scope keys — what `CadreNodeConfig.storage.provider` is called with. A
188
148
  // strand's key is its strand id; the control database's key carries the party id,
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * - the trusted-owner anchor (`trusted-owner-store.ts`),
7
7
  * - the cold-start bootstrap-peer store (`bootstrap-peer-store.ts`),
8
- * - the strand peer book (`strand-peer-book.ts`), whose entries are one map per
9
- * strand rather than one value per key — the validation hook below is what
10
- * lets it drop a single junk peer without dropping the strand.
8
+ * - the strand network state (`strand-network-state.ts`): per strand, the state
9
+ * Optimystic's db-p2p saved for the strand node, opaque here past a structural
10
+ * check.
11
11
  *
12
12
  * ANOTHER node-local record — the enrolled-machine count
13
13
  * (`enrolled-machine-store.ts`) — reuses {@link DurableSlot} but deliberately
@@ -72,10 +72,10 @@ const ENVELOPE_VERSION = 1;
72
72
  * in full is not an anchor. Trusting a *subset* of the keys a file claims is
73
73
  * a silent, security-relevant downgrade, so the whole record is discarded
74
74
  * and the node trusts no one until re-seeded out of band.
75
- * - `'drop-entry'` (the bootstrap-peer store and the strand peer book): the
76
- * record is a best-effort dial list and nothing in it is trust-bearing, so one
77
- * junk entry must not discard a stranded node's only remaining way back to
78
- * its party or its strand's peers.
75
+ * - `'drop-entry'` (the bootstrap-peer store and the strand network state):
76
+ * the record is a best-effort dial list and nothing in
77
+ * it is trust-bearing, so one junk entry must not discard a stranded node's
78
+ * only remaining way back to its party or its strand's peers.
79
79
  */
80
80
  export type UnusableEntryPolicy = 'discard-all' | 'drop-entry';
81
81
 
@@ -149,6 +149,11 @@ export class NodeLocalSnapshot<E> {
149
149
  return this.entries.has(key);
150
150
  }
151
151
 
152
+ /** The entry held for `key`, if any. Needs no copy: {@link put} replaces an entry, never mutates it. */
153
+ get(key: string): E | undefined {
154
+ return this.entries.get(key);
155
+ }
156
+
152
157
  /** Fresh copy of the keys — a snapshot decoupled from later {@link put} / {@link remove} calls. */
153
158
  keySnapshot(): Set<string> {
154
159
  return new Set(this.entries.keys());
@@ -148,10 +148,9 @@ export const MAX_BLOCK_MESSAGE_BYTES = 8 * 1024 * 1024;
148
148
  * protocol — the one gate that decides whether {@link PeerJoinBackfill} schedules a peer at
149
149
  * all. A peer that lacks it (a bare circuit relay, a bootstrap node, a stranger the control
150
150
  * network's inbound gate admitted without membership) cannot receive a push no matter how
151
- * long it is dialed, since the protocol id is namespaced per network. Exported because a
152
- * later strand peer-address ticket's identify-driven observation needs the identical test.
151
+ * long it is dialed, since the protocol id is namespaced per network.
153
152
  */
154
- export function speaksBlockTransfer(protocols: readonly string[], protocolPrefix: string): boolean {
153
+ function speaksBlockTransfer(protocols: readonly string[], protocolPrefix: string): boolean {
155
154
  return protocols.includes(buildBlockTransferProtocol(protocolPrefix));
156
155
  }
157
156
 
@@ -72,11 +72,10 @@ const DEFAULT_MAX_CONCURRENT_SESSIONS = 100;
72
72
  * handful of entries (one per listen transport, plus a circuit-relay reservation), so
73
73
  * 16 is generous for the honest case while keeping the result frame bounded against a
74
74
  * peer that would pad it — either direction, since both sides run the list through
75
- * {@link sanitizeStrandAddrs}. Exported because the same bound has to hold on the
76
- * initiator's stored copy: `CadreNode` accumulates across repeat formations against one
77
- * strand, so it caps the accumulation rather than only each arriving list.
75
+ * {@link sanitizeStrandAddrs}. The initiator's kept copy is one arriving list, replaced by
76
+ * the next formation against the same strand, so the same bound holds there.
78
77
  */
79
- export const MAX_STRAND_ADDRS = 16;
78
+ const MAX_STRAND_ADDRS = 16;
80
79
 
81
80
  // ── Roles ────────────────────────────────────────────────────────────────────
82
81
 
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Reading the signed address records a running strand node's FRET routing table holds,
3
+ * as per-peer addresses ready for that node's own address book (`peer-addr-book.ts`).
4
+ *
5
+ * Why a strand node needs this. libp2p's peerStore hides every address one hour after
6
+ * it was first observed (the `NOTE:` on `mergePeerAddrs`), and FRET does not re-apply
7
+ * the records it holds: it hands a record to the peerStore when a neighbour's snapshot
8
+ * delivers it or a saved table is imported, and the peerStore refuses a record whose
9
+ * sequence number it already has. So a strand node that has been up for more than an
10
+ * hour would have no address for a peer whose connection then drops, although FRET
11
+ * still holds that peer's record. `CadreNode.refreshStrandPeerAddrs` re-merges the
12
+ * records on every pass, which is what keeps another party's strand nodes dialable;
13
+ * own-party siblings are re-resolved separately, over the strand-addr RPC.
14
+ *
15
+ * How long a record is available here is FRET's rule, not this module's: while FRET
16
+ * holds the entry, and at most 14 days after the record was last confirmed (a peer
17
+ * connected at export time counts as confirmed then).
18
+ *
19
+ * Every record is opened and verified here rather than taken on FRET's word. A record
20
+ * is self-certifying, the table is reached through a structural type rather than an
21
+ * interface this package depends on, and the address book write is attributed from what
22
+ * the record proves, not from the id the table filed it under.
23
+ */
24
+
25
+ import debug from 'debug';
26
+ import { PeerRecord, RecordEnvelope } from '@libp2p/peer-record';
27
+ import { peerIdFromPublicKey } from '@libp2p/peer-id';
28
+ import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
29
+ import { fromString as uint8ArrayFromString } from 'uint8arrays';
30
+ import { withTrailingPeerId } from './peer-record.js';
31
+
32
+ const log = debug('sereus:cadre:strand-fret-addrs');
33
+
34
+ /** The slice of FRET's exported routing table read here (`SerializedTable` in `p2p-fret`). */
35
+ interface FretTableSlice {
36
+ entries: ReadonlyArray<{
37
+ id: string;
38
+ /** `envelope` is the base64url of a marshaled libp2p record envelope. */
39
+ addressRecord?: { envelope: string };
40
+ }>;
41
+ }
42
+
43
+ /** The slice of FRET's libp2p service called here. */
44
+ interface FretTableSource {
45
+ exportTable(): FretTableSlice;
46
+ }
47
+
48
+ /**
49
+ * The slice of a strand's libp2p node this module touches. Structurally satisfied by
50
+ * `Libp2p`; db-p2p registers FRET as `services.fret`.
51
+ */
52
+ export interface FretAddrHost {
53
+ peerId: { toString(): string };
54
+ services?: Record<string, unknown>;
55
+ }
56
+
57
+ /** What one read of a strand node's FRET table yields. */
58
+ export interface FretPeerAddrs {
59
+ /** Peer id → the addresses its record lists, each bound to that peer. Table order. */
60
+ peers: Map<string, Multiaddr[]>;
61
+ /**
62
+ * Records left out because they did not decode, did not verify, or were not signed
63
+ * by and about the peer the table filed them under.
64
+ */
65
+ rejected: number;
66
+ }
67
+
68
+ /**
69
+ * The addresses of every peer `host`'s FRET table holds a usable signed record for,
70
+ * `host` itself excluded. A node with no FRET service (a db-p2p build without it, a
71
+ * test double) reads as an empty table. One bad record never costs the others: it is
72
+ * counted in `rejected` and skipped.
73
+ *
74
+ * Throws only what `exportTable()` throws, which a caller treats like any other
75
+ * failure of a pass over a node that may be mid-teardown.
76
+ */
77
+ export async function strandFretPeerAddrs(host: FretAddrHost): Promise<FretPeerAddrs> {
78
+ const result: FretPeerAddrs = { peers: new Map(), rejected: 0 };
79
+ const fret = host.services?.fret;
80
+ if (!isFretTableSource(fret)) {
81
+ return result;
82
+ }
83
+ const selfId = host.peerId.toString();
84
+ for (const entry of fret.exportTable().entries) {
85
+ if (entry.id === selfId || entry.addressRecord === undefined) {
86
+ continue;
87
+ }
88
+ const addrs = await verifiedRecordAddrs(entry.id, entry.addressRecord.envelope);
89
+ if (addrs === null) {
90
+ result.rejected++;
91
+ } else if (addrs.length > 0) {
92
+ result.peers.set(entry.id, addrs);
93
+ }
94
+ }
95
+ return result;
96
+ }
97
+
98
+ function isFretTableSource(service: unknown): service is FretTableSource {
99
+ return typeof service === 'object' && service !== null
100
+ && typeof (service as { exportTable?: unknown }).exportTable === 'function';
101
+ }
102
+
103
+ /**
104
+ * The addresses one record lists, bound to `peerId`; null when the record must not be
105
+ * used. `openAndCertify` proves only that the envelope's own key signed it, so the
106
+ * signer and the peer the payload describes are each held against `peerId` here:
107
+ * without that, any peer could sign a record rewriting another peer's addresses.
108
+ */
109
+ async function verifiedRecordAddrs(peerId: string, envelope: string): Promise<Multiaddr[] | null> {
110
+ try {
111
+ const opened = await RecordEnvelope.openAndCertify(uint8ArrayFromString(envelope, 'base64url'), PeerRecord.DOMAIN);
112
+ const record = PeerRecord.createFromProtobuf(opened.payload);
113
+ const signer = peerIdFromPublicKey(opened.publicKey).toString();
114
+ const subject = record.peerId.toString();
115
+ if (signer !== peerId || subject !== peerId) {
116
+ log('skipping the address record filed under %s: signed by %s about %s', peerId, signer, subject);
117
+ return null;
118
+ }
119
+ return bindToPeer(record.multiaddrs.map((addr) => addr.toString()), peerId);
120
+ } catch (error) {
121
+ log('skipping the address record filed under %s: %o', peerId, error);
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * A record names its peer once, in `peerId`, so its addresses carry no trailing
128
+ * `/p2p/<peerId>` and a relay-only peer's circuit address ends at the relay hop
129
+ * (`…/p2p/<relay>/p2p-circuit`). Each is given the suffix that makes it a complete
130
+ * dial target; one already terminating in a different peer id does not reach this peer
131
+ * and is dropped.
132
+ *
133
+ * Takes strings because `@libp2p/peer-record` carries its own `@multiformats/multiaddr`
134
+ * copy, a structurally different type from the one `withTrailingPeerId` takes.
135
+ */
136
+ function bindToPeer(addrs: string[], peerId: string): Multiaddr[] {
137
+ const bound: Multiaddr[] = [];
138
+ for (const addr of addrs) {
139
+ const withPeer = withTrailingPeerId(multiaddr(addr), peerId);
140
+ if (withPeer !== null) {
141
+ bound.push(withPeer);
142
+ }
143
+ }
144
+ return bound;
145
+ }
@@ -4,9 +4,7 @@ import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
4
4
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
5
5
  import { StrandDatabase } from './strand-database.js';
6
6
  import { PeerJoinBackfill, type PeerJoinBackfillConfig } from './peer-join-backfill.js';
7
- import { StrandPeerObserver, type StrandPeerObservation } from './strand-peer-observer.js';
8
- import { StrandPeerBookSwap } from './strand-peer-book-swap.js';
9
- import type { StrandPeerBookStore } from './strand-peer-book.js';
7
+ import { strandNetworkStatePersistence, type StrandNetworkStateStore } from './strand-network-state.js';
10
8
  import {
11
9
  StrandRevocationEnforcer,
12
10
  createRevocationConnectionGater,
@@ -274,26 +272,14 @@ export interface StartStrandConfig {
274
272
  announceDelegateToRelay?: (strandId: string, relayAddr: string, delegatePeerId: string) => Promise<void>;
275
273
 
276
274
  /**
277
- * Called when the strand's libp2p node identifies a STRAND peer — one whose
278
- * identify result names this strand's block-transfer protocol, so never the
279
- * circuit relay or a bootstrap node — with the addresses it is dialable at
280
- * (`strand-peer-observer.ts`). Throttled per peer; must not throw. `CadreNode`
281
- * wires it to the node-local strand peer book, which is what lets a restarted
282
- * machine dial the peers it was talking to. Absent ⇒ nothing is observed.
283
- * Retained with the launch config, so a hibernation wake re-arms it on the
284
- * rebuilt node.
275
+ * The node-local strand network state (`strand-network-state.ts`): this strand's
276
+ * slice of it is handed to db-p2p as `persistence`, so the strand node saves its
277
+ * FRET routing table (with each peer's signed address record) on every connection
278
+ * and re-imports it when it is next built. Absent ⇒ nothing is saved and the node
279
+ * starts with an empty table. `CadreNode` passes its store. Retained with the launch
280
+ * config, so a hibernation wake rebuilds the node over the same state.
285
281
  */
286
- onStrandPeerIdentified?: (strandId: string, observation: StrandPeerObservation) => void;
287
-
288
- /**
289
- * The node-local strand peer book the book SWAP reads and writes
290
- * (`strand-peer-book-swap.ts`): the strand node signs its own addresses with
291
- * {@link privateKey}, exchanges signed entries with every strand peer it identifies,
292
- * and files what verifies. Absent ⇒ no swap runs — a peer that dials this node's
293
- * swap handler gets no answer. `CadreNode` passes its book store. Retained with the
294
- * launch config, so a hibernation wake re-arms the swap on the rebuilt node.
295
- */
296
- strandPeerBook?: StrandPeerBookStore;
282
+ networkState?: StrandNetworkStateStore;
297
283
  }
298
284
 
299
285
  /**
@@ -428,24 +414,6 @@ export class StrandInstanceManager {
428
414
  * missed writes).
429
415
  */
430
416
  private backfills: Map<string, PeerJoinBackfill> = new Map();
431
- /**
432
- * The per-strand peer-book observation writer (`strand-peer-observer.ts`), keyed
433
- * by strand id. Same lifecycle rationale as {@link backfills}: started in
434
- * `buildStrandRuntime` right after the libp2p node exists, stopped and dropped in
435
- * `releaseRuntime`, so quiesce → resume re-arms it on the rebuilt node. Only
436
- * present when the launch config supplied `onStrandPeerIdentified`.
437
- */
438
- private peerObservers: Map<string, StrandPeerObserver> = new Map();
439
-
440
- /**
441
- * Per-strand strand peer book SWAP driver ({@link StrandPeerBookSwap}) — the signed
442
- * exchange of book entries between strand peers. Same lifetime as the peer-book
443
- * observer above: armed in `buildStrandRuntime` right after the libp2p node exists
444
- * (its protocol handler must be registered before an early peer dials it), stopped
445
- * and dropped in `releaseRuntime`. Only present when the launch config supplied
446
- * `strandPeerBook`.
447
- */
448
- private peerBookSwaps: Map<string, StrandPeerBookSwap> = new Map();
449
417
  /**
450
418
  * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
451
419
  * id. Same lifecycle rationale as {@link backfills}: created in
@@ -821,6 +789,18 @@ export class StrandInstanceManager {
821
789
  enableRingZulu: config.profile === 'storage'
822
790
  },
823
791
  ...(config.privateKey && { privateKey: config.privateKey }),
792
+ // Built per runtime, not per launch: the adapter stops saving once the strand's
793
+ // state is forgotten, and a rebuilt runtime is what starts it saving again.
794
+ //
795
+ // NOTE: the saved state also restores which peers db-p2p saw serving the strand,
796
+ // and it counts them in the cohort until FRET finds them unreachable. A node built
797
+ // while every other member is offline therefore fails its first writes ("Failed to
798
+ // get super-majority") — for 4-6 s on loopback, the window a running node already
799
+ // sees when a member drops. If apps report failed sends after relaunching alone on
800
+ // a slow relayed link, retry the write in the app or raise it upstream (db-p2p's
801
+ // remembered-serving fallback); dropping `persistence` brings back the split
802
+ // restart.
803
+ ...(config.networkState && { persistence: strandNetworkStatePersistence(config.networkState, strandId) }),
824
804
  ...(config.network?.transports && { transports: config.network.transports }),
825
805
  ...(config.network?.noiseCrypto && { noiseCrypto: config.network.noiseCrypto }),
826
806
  // Unconditional, and the same default the control node takes: every node of
@@ -851,48 +831,6 @@ export class StrandInstanceManager {
851
831
 
852
832
  instance.libp2pNode = node;
853
833
 
854
- // The peer-book observation writer, armed BEFORE the database bring-up below
855
- // (unlike the backfill) because the bootstrap dials that follow `libp2p.start()`
856
- // land during that bring-up, and they are exactly the peers worth remembering:
857
- // identify for an early peer may already have fired by the time anything after
858
- // the database initializes runs. Registered in the map immediately so the
859
- // failure rollback (releaseRuntime) stops it like every other runtime component.
860
- const onStrandPeerIdentified = config.onStrandPeerIdentified;
861
- if (onStrandPeerIdentified) {
862
- const observer = new StrandPeerObserver({
863
- label: strandId,
864
- libp2p: node,
865
- protocolPrefix,
866
- onObserved: (observation) => onStrandPeerIdentified(strandId, observation)
867
- });
868
- this.peerObservers.set(strandId, observer);
869
- observer.start();
870
- }
871
-
872
- // The strand peer book SWAP, armed at the same moment and for the same reason as
873
- // the observer: a peer that dials this node's swap handler before it is registered
874
- // fails its exchange and sits out a ten-minute throttle. The own entry is signed
875
- // now and re-signed on every address change, which covers the relay reservation
876
- // landing below (`self:peer:update` fires when the circuit addr is added), so no
877
- // separate post-relay step is needed. Registered in the map immediately so the
878
- // failure rollback (releaseRuntime) stops it like every other runtime component.
879
- //
880
- // NOTE: this is a third `peer:identify` listener per running strand, beside the
881
- // observer's and the backfill's — see the backfill's NOTE below for when to fold
882
- // them into one dispatcher.
883
- if (config.strandPeerBook) {
884
- const swap = new StrandPeerBookSwap({
885
- strandId,
886
- libp2p: node,
887
- protocolPrefix,
888
- store: config.strandPeerBook,
889
- privateKey: config.privateKey,
890
- linkRoundTripMs: config.network?.linkRoundTripMs
891
- });
892
- this.peerBookSwaps.set(strandId, swap);
893
- swap.start();
894
- }
895
-
896
834
  // One reservation supervisor PER RELAY, started now so the first attempts
897
835
  // overlap the database bring-up below (the relay is never in a strand's
898
836
  // Optimystic cohort — its protocol ids are namespaced `/optimystic/strand-<id>/…`
@@ -1106,20 +1044,6 @@ export class StrandInstanceManager {
1106
1044
  backfill.stop();
1107
1045
  this.backfills.delete(instance.strandId);
1108
1046
  }
1109
- // The peer-book observer goes with the node it listened on; a resume re-arms
1110
- // one on the rebuilt node.
1111
- const peerObserver = this.peerObservers.get(instance.strandId);
1112
- if (peerObserver) {
1113
- peerObserver.stop();
1114
- this.peerObservers.delete(instance.strandId);
1115
- }
1116
- // The book swap likewise: unsubscribed and its handler unregistered while the node is
1117
- // still up; a resume re-arms one, which re-signs the own entry over the new addresses.
1118
- const peerBookSwap = this.peerBookSwaps.get(instance.strandId);
1119
- if (peerBookSwap) {
1120
- this.peerBookSwaps.delete(instance.strandId);
1121
- await peerBookSwap.stop();
1122
- }
1123
1047
  // The revoked-peer enforcer goes with the runtime it gated: a resume
1124
1048
  // rebuilds it with a fresh (initially empty, fail-open) snapshot.
1125
1049
  const revocationEnforcer = this.revocationEnforcers.get(instance.strandId);
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Node-only file-backed {@link StrandNetworkStateStore}: one JSON file per party
3
+ * under a configured directory (the node's state directory — `cadre-cli` passes
4
+ * `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated network
5
+ * state of every strand this node runs (each strand node's saved FRET routing
6
+ * table, with its peers' signed address records).
7
+ *
8
+ * Nothing but the file itself lives here: the envelope, load policy, per-entry
9
+ * check and the serialised snapshot writes are cross-platform in
10
+ * `PersistentStrandNetworkStateStore` / `node-local-snapshot.ts`, and the file is a
11
+ * `FileDurableSlot`. This module exists only so the `node:fs` edge stays out of the
12
+ * package's cross-platform default entry (`./index.js`) — import it from the
13
+ * dedicated subpath instead:
14
+ *
15
+ * ```ts
16
+ * import { FileStrandNetworkStateStore } from '@serfab/cadre-core/strand-network-state-file';
17
+ * ```
18
+ *
19
+ * Same isolation pattern as `bootstrap-peer-store-file.ts` / `key-store-file.ts`.
20
+ */
21
+ import { FileDurableSlot } from './file-durable-slot.js';
22
+ import { PersistentStrandNetworkStateStore } from './strand-network-state.js';
23
+ import type { StrandNetworkStateStore } from './strand-network-state.js';
24
+
25
+ /** Base name of the store file: `<dir>/strand-network.<encoded partyId>.json`. */
26
+ const SLOT_NAME = 'strand-network';
27
+
28
+ /**
29
+ * File-backed {@link StrandNetworkStateStore}. Open via {@link open}, which loads
30
+ * the existing file and returns the cross-platform store over it; an absent,
31
+ * corrupt, or wrong-party file is a cold start (no saved state) while a
32
+ * present-but-unreadable file throws. Those rules and their reasoning live on
33
+ * `NodeLocalSnapshot.open`.
34
+ */
35
+ export const FileStrandNetworkStateStore = {
36
+ /** Load (or cold-start) the party's strand network state from a file in `dir`. */
37
+ async open(dir: string, partyId: string): Promise<StrandNetworkStateStore> {
38
+ return PersistentStrandNetworkStateStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId);
39
+ }
40
+ };