@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
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Node-local, NON-replicated **strand network state**: for each strand this node
3
+ * runs, the last network state Optimystic's db-p2p saved for that strand's libp2p
4
+ * node — its FRET routing table (each peer entry carrying the peer's signed libp2p
5
+ * address record), the network-size high-water mark, and which of those peers were
6
+ * seen serving the strand. db-p2p reads it back once when the strand node is built
7
+ * and re-imports the table, which hands every record to the libp2p peerStore, so a
8
+ * restarted strand node already has addresses for the peers it was talking to
9
+ * (gotchoices/sereus#18).
10
+ *
11
+ * The payload is opaque here. db-p2p owns its version fence (a snapshot whose
12
+ * `version` it does not write is discarded whole) and FRET owns table validation
13
+ * (a structurally corrupt table is refused, a stale record dropped). This module
14
+ * checks only that an entry is a plain object with a numeric `version`, and never
15
+ * rewrites one.
16
+ *
17
+ * Three implementations mirroring `bootstrap-peer-store.ts` (same "must outlive the
18
+ * process, storage differs per platform" problem; both persistent forms share
19
+ * `node-local-snapshot.ts`):
20
+ * - {@link MemoryStrandNetworkStateStore} (this module, cross-platform) — ephemeral;
21
+ * the default when no store is injected via `CadreNodeConfig.strandNetworkState`.
22
+ * A node using it restarts with an empty routing table.
23
+ * - {@link PersistentStrandNetworkStateStore} (this module, cross-platform) —
24
+ * durable over any `DurableSlot` the embedding app supplies.
25
+ * - `FileStrandNetworkStateStore` — the above over a Node file in the node's state
26
+ * directory; Node-only, behind the subpath
27
+ * `@serfab/cadre-core/strand-network-state-file` so `node:fs` never lands in the
28
+ * RN/browser graph.
29
+ *
30
+ * One store per NODE holding every strand, keyed by strand id, so the embedder
31
+ * wires one slot, like the other node-local stores.
32
+ *
33
+ * **Nothing here is trust-bearing.** A routing-table entry grants no authority:
34
+ * every address record is signed by the peer it names and verified by FRET at
35
+ * import, and the dialed peer authenticates by peer id at the handshake.
36
+ */
37
+ import debug from 'debug';
38
+ import type { NetworkStatePersistence, PersistedNetworkState } from '@optimystic/db-p2p';
39
+ import { NodeLocalSnapshot, type DurableSlot, type NodeLocalSnapshotSpec } from './node-local-snapshot.js';
40
+
41
+ const log = debug('sereus:cadre:strand-network-state');
42
+
43
+ export interface StrandNetworkStateStore {
44
+ /** Party this store is scoped to. */
45
+ readonly partyId: string;
46
+
47
+ /**
48
+ * The last state saved for `strandId`, or undefined. Synchronous: served from the
49
+ * in-memory half of the store. The returned object is the stored one — callers
50
+ * must not mutate it.
51
+ */
52
+ load(strandId: string): PersistedNetworkState | undefined;
53
+
54
+ /**
55
+ * Replace the strand's state. Implementations MUST reflect it in {@link load}
56
+ * SYNCHRONOUSLY; the returned promise tracks durability only. The store keeps
57
+ * `state` itself, so the caller must not mutate it afterwards (db-p2p builds a
58
+ * fresh object per save).
59
+ */
60
+ save(strandId: string, state: PersistedNetworkState): Promise<void>;
61
+
62
+ /**
63
+ * Drop the strand's state — for a strand this node unpublished or left, whose
64
+ * peers must not be re-imported by a later launch. A no-op for the stored state
65
+ * when nothing is held, but {@link forgetGeneration} advances either way. Same
66
+ * contract as {@link save}: reflected synchronously, the promise tracks durability.
67
+ */
68
+ forget(strandId: string): Promise<void>;
69
+
70
+ /**
71
+ * How many times `strandId` has been forgotten through this store instance.
72
+ * {@link strandNetworkStatePersistence} compares it against the value it captured
73
+ * to drop a save that arrives after the forget.
74
+ */
75
+ forgetGeneration(strandId: string): number;
76
+ }
77
+
78
+ /** Per-strand forget counts; the in-process half of {@link StrandNetworkStateStore.forgetGeneration}. */
79
+ class ForgetGenerations {
80
+ private readonly counts = new Map<string, number>();
81
+
82
+ of(strandId: string): number {
83
+ return this.counts.get(strandId) ?? 0;
84
+ }
85
+
86
+ advance(strandId: string): void {
87
+ this.counts.set(strandId, this.of(strandId) + 1);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Ephemeral in-memory store for nodes without durable storage (tests, and the
93
+ * default when nothing is injected). Same contract, no disk: a strand relaunched in
94
+ * the same process re-imports its table (a hibernation resume, a stop()→start()),
95
+ * and a restarted process starts with none.
96
+ */
97
+ export class MemoryStrandNetworkStateStore implements StrandNetworkStateStore {
98
+ private readonly strands = new Map<string, PersistedNetworkState>();
99
+ private readonly generations = new ForgetGenerations();
100
+
101
+ constructor(readonly partyId: string) {}
102
+
103
+ load(strandId: string): PersistedNetworkState | undefined {
104
+ return this.strands.get(strandId);
105
+ }
106
+
107
+ async save(strandId: string, state: PersistedNetworkState): Promise<void> {
108
+ this.strands.set(strandId, state);
109
+ }
110
+
111
+ async forget(strandId: string): Promise<void> {
112
+ this.generations.advance(strandId);
113
+ if (this.strands.delete(strandId)) {
114
+ log('strand %s: network state forgotten (party=%s)', strandId, this.partyId);
115
+ }
116
+ }
117
+
118
+ forgetGeneration(strandId: string): number {
119
+ return this.generations.of(strandId);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * What the store persists: `strands` maps strandId -> the state db-p2p saved. An
125
+ * entry that is not a plain object with a numeric `version` is dropped and its
126
+ * siblings kept (`drop-entry`): nothing here is trust-bearing, and one damaged
127
+ * strand must not cost every other strand its routing table. Everything past that
128
+ * structural check is db-p2p's and FRET's to judge at import.
129
+ */
130
+ const STRAND_NETWORK_STATE_SNAPSHOT_SPEC: NodeLocalSnapshotSpec<PersistedNetworkState> = {
131
+ label: 'strand network state',
132
+ payloadKey: 'strands',
133
+ unusableEntry: 'drop-entry',
134
+ acceptEntry: (_strandId, raw) => {
135
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined;
136
+ if (typeof (raw as { version?: unknown }).version !== 'number') return undefined;
137
+ return raw as PersistedNetworkState;
138
+ }
139
+ };
140
+
141
+ /**
142
+ * Durable {@link StrandNetworkStateStore} over an app-supplied {@link DurableSlot} —
143
+ * the cross-platform half of every persistent backend (the Node
144
+ * `FileStrandNetworkStateStore` is this class over a file slot).
145
+ *
146
+ * Construct via {@link open}. Load and persist policy — what an absent, corrupt,
147
+ * foreign-party or unreadable slot does (the last one THROWS), and what a failed
148
+ * persist does — is documented once on `NodeLocalSnapshot`. Writes are serialised
149
+ * in-process by the snapshot's write chain, so several strand nodes saving at once
150
+ * cannot interleave partial snapshots.
151
+ *
152
+ * NOTE: an entry leaves only through {@link forget}, and nothing ages one out. A
153
+ * strand this machine detached because the strand watcher saw its control row gone
154
+ * (another machine of the party unpublished or left it), or one stopped and never
155
+ * relaunched, keeps its entry and it is rewritten with every save. The watcher path
156
+ * (`CadreNode.handleStrandRemoved`) does not forget because its removal is not proof
157
+ * the strand is gone: its query answers an empty list while the control database is
158
+ * absent. If snapshots grow on machines that host many short-lived strands, or a
159
+ * re-published strand id is seen importing its predecessor's table, drop entries at
160
+ * {@link open} that are older than FRET's own 14-day record age.
161
+ */
162
+ export class PersistentStrandNetworkStateStore implements StrandNetworkStateStore {
163
+ private readonly generations = new ForgetGenerations();
164
+
165
+ private constructor(private readonly snapshot: NodeLocalSnapshot<PersistedNetworkState>) {}
166
+
167
+ /** Load (or cold-start) the party's strand network state from `slot`. */
168
+ static async open(slot: DurableSlot, partyId: string): Promise<PersistentStrandNetworkStateStore> {
169
+ return new PersistentStrandNetworkStateStore(
170
+ await NodeLocalSnapshot.open(slot, partyId, STRAND_NETWORK_STATE_SNAPSHOT_SPEC)
171
+ );
172
+ }
173
+
174
+ get partyId(): string {
175
+ return this.snapshot.partyId;
176
+ }
177
+
178
+ load(strandId: string): PersistedNetworkState | undefined {
179
+ return this.snapshot.get(strandId);
180
+ }
181
+
182
+ /**
183
+ * Replace, visible via {@link load} synchronously, then the full snapshot is persisted.
184
+ *
185
+ * NOTE: every save rewrites the party's whole snapshot — every strand's table —
186
+ * and db-p2p saves on every `connection:open`. Fine for a handful of strands; if
187
+ * slot writes show up in profiles, move to one slot per strand or coalesce saves.
188
+ */
189
+ save(strandId: string, state: PersistedNetworkState): Promise<void> {
190
+ return this.snapshot.put(strandId, state);
191
+ }
192
+
193
+ /** Forget, gone from {@link load} synchronously, then persisted — unless nothing was there. */
194
+ forget(strandId: string): Promise<void> {
195
+ this.generations.advance(strandId);
196
+ if (this.snapshot.has(strandId)) {
197
+ log('strand %s: network state forgotten (party=%s); persisting', strandId, this.partyId);
198
+ }
199
+ return this.snapshot.remove(strandId);
200
+ }
201
+
202
+ forgetGeneration(strandId: string): number {
203
+ return this.generations.of(strandId);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Adapts one strand's slice of the store to db-p2p's `NodeOptions.persistence`.
209
+ * Build one per strand runtime (`StrandInstanceManager.buildStrandRuntime` does).
210
+ *
211
+ * A save is dropped once the strand has been forgotten after this adapter was
212
+ * built: db-p2p saves fire-and-forget on connection events, so a node being torn
213
+ * down can still save after `forget(strandId)`, which would put the entry back.
214
+ * Saving resumes with the next runtime built for the strand (a relaunch or a
215
+ * hibernation resume), which builds a new adapter.
216
+ *
217
+ * NOTE: a strand that keeps running after its state was forgotten (self-revocation
218
+ * forgets but stops nothing) saves nothing more until its runtime is rebuilt. If a
219
+ * removed party is routinely re-admitted without a relaunch, re-arm saving at
220
+ * re-admission.
221
+ *
222
+ * NOTE: db-p2p saves on `connection:open` and on a changed serving verdict, not when
223
+ * a peer's addresses change. A peer whose address rotates during a long connection
224
+ * (a relay reservation moving) is saved with its old record until the next save,
225
+ * which matters only to a restart in between. If restarts are seen dialing rotated
226
+ * addresses, the fix is upstream: db-p2p saving when FRET's table changes.
227
+ */
228
+ export function strandNetworkStatePersistence(
229
+ store: StrandNetworkStateStore,
230
+ strandId: string
231
+ ): NetworkStatePersistence {
232
+ const generation = store.forgetGeneration(strandId);
233
+ return {
234
+ load: async () => store.load(strandId),
235
+ save: async (state) => {
236
+ if (store.forgetGeneration(strandId) !== generation) {
237
+ log('strand %s: dropping a network-state save that arrived after the strand was forgotten', strandId);
238
+ return;
239
+ }
240
+ await store.save(strandId, state);
241
+ }
242
+ };
243
+ }
package/src/types.ts CHANGED
@@ -11,7 +11,7 @@ import type { SeedTrustPolicy } from './seed-trust-policy.js';
11
11
  import type { KeyStore, KeyId } from './key-store.js';
12
12
  import type { TrustedOwnerStore, TrustSource } from './trusted-owner-store.js';
13
13
  import type { BootstrapPeerStore } from './bootstrap-peer-store.js';
14
- import type { StrandPeerBookStore } from './strand-peer-book.js';
14
+ import type { StrandNetworkStateStore } from './strand-network-state.js';
15
15
  import type { EnrolledMachineStore } from './enrolled-machine-store.js';
16
16
  import type { JoinedStrandStore } from './joined-strand-store.js';
17
17
  import type { PushNotifier } from './push-notifier.js';
@@ -1030,32 +1030,29 @@ export interface CadreNodeConfig {
1030
1030
  };
1031
1031
 
1032
1032
  /**
1033
- * Node-local strand peer book (see `strand-peer-book.ts`): per strand, the strand
1034
- * peers this node has met — the other parties' strand transport peers a formation
1035
- * carried back, and every strand peer observed on a live connection — with their
1036
- * last-known addresses. Read on every launch, hibernation resume and periodic
1037
- * address refresh, so a restarted machine dials the peers it was talking to before
1038
- * anything else, instead of coming back up alone (gotchoices/sereus#18). Sibling
1039
- * of {@link bootstrapPeers} — same NON-replicated, per-party, injected-backend
1040
- * shape — and, like it, nothing here is trust-bearing: dial hints only, each
1041
- * bound to its peer id. Entries age out after 14 days (`STRAND_PEER_MAX_AGE_MS`,
1042
- * overridable per store via `StrandPeerBookOptions.maxAgeMs`).
1043
- *
1044
- * Absent ⇒ an in-memory store is created at start() (ephemeral: the book dies
1045
- * with the process, and a cross-party strand whose only contact was the other
1046
- * party does not re-mesh after a restart until a fresh formation). Every
1047
- * reference embedder injects a durable backend over the same slot kind as its
1048
- * bootstrap-peer store. A new platform needs no new store class: supply a
1049
- * `DurableSlot` and inject `PersistentStrandPeerBookStore.open(slot, partyId)`.
1050
- */
1051
- strandPeers?: {
1033
+ * Node-local strand network state (see `strand-network-state.ts`): per strand, the
1034
+ * state Optimystic's db-p2p saves for the strand's libp2p node — its FRET routing
1035
+ * table, each entry carrying the peer's signed address record. A strand node reads
1036
+ * it back when it is built and re-imports the table, so a restarted machine already
1037
+ * holds addresses for the strand peers it was talking to (gotchoices/sereus#18).
1038
+ * Sibling of {@link bootstrapPeers} — same NON-replicated, per-party,
1039
+ * injected-backend shape — and, like it, nothing here is trust-bearing: FRET
1040
+ * verifies every record at import.
1041
+ *
1042
+ * Absent ⇒ an in-memory store is created at start() (ephemeral: a restarted process
1043
+ * starts every strand with an empty routing table). Every reference embedder
1044
+ * injects a durable backend over the same slot kind as its bootstrap-peer store. A
1045
+ * new platform needs no new store class: supply a `DurableSlot` and inject
1046
+ * `PersistentStrandNetworkStateStore.open(slot, partyId)`.
1047
+ */
1048
+ strandNetworkState?: {
1052
1049
  /**
1053
- * Injected store instance — e.g. a `FileStrandPeerBookStore` from the
1054
- * Node-only subpath `@serfab/cadre-core/strand-peer-book-file`, persisted in
1050
+ * Injected store instance — e.g. a `FileStrandNetworkStateStore` from the
1051
+ * Node-only subpath `@serfab/cadre-core/strand-network-state-file`, persisted in
1055
1052
  * the node's state directory. Its `partyId` must match
1056
1053
  * `controlNetwork.partyId`; start() fails closed on a mismatch.
1057
1054
  */
1058
- store?: StrandPeerBookStore;
1055
+ store?: StrandNetworkStateStore;
1059
1056
  };
1060
1057
 
1061
1058
  /**
@@ -1,12 +0,0 @@
1
- import type { StrandPeerBookOptions, StrandPeerBookStore } from './strand-peer-book.js';
2
- /**
3
- * File-backed {@link StrandPeerBookStore}. Open via {@link open}, which loads the
4
- * existing file and returns the cross-platform store over it; an absent, corrupt,
5
- * or wrong-party file is a cold start (empty book) and structurally junk or
6
- * aged-out entries are dropped, while a present-but-unreadable file throws. Those
7
- * rules and their reasoning live on `NodeLocalSnapshot.open`.
8
- */
9
- export declare const FileStrandPeerBookStore: {
10
- /** Load (or cold-start) the party's strand peer book from a file in `dir`. */
11
- open(dir: string, partyId: string, options?: StrandPeerBookOptions): Promise<StrandPeerBookStore>;
12
- };
@@ -1,37 +0,0 @@
1
- /**
2
- * Node-only file-backed {@link StrandPeerBookStore}: one JSON file per party under
3
- * a configured directory (the node's state directory — `cadre-cli` passes
4
- * `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated strand
5
- * peer book (the strand peers this node has met, with their last-known addresses).
6
- *
7
- * Nothing but the file itself lives here: the envelope, load policy, per-entry
8
- * validation, the merge rule and the serialised snapshot writes are cross-platform
9
- * in `PersistentStrandPeerBookStore` / `node-local-snapshot.ts`, and the file is a
10
- * `FileDurableSlot`. This module exists only so the `node:fs` edge stays out of the
11
- * package's cross-platform default entry (`./index.js`) — import it from the
12
- * dedicated subpath instead:
13
- *
14
- * ```ts
15
- * import { FileStrandPeerBookStore } from '@serfab/cadre-core/strand-peer-book-file';
16
- * ```
17
- *
18
- * Same isolation pattern as `bootstrap-peer-store-file.ts` / `key-store-file.ts`.
19
- */
20
- import { FileDurableSlot } from './file-durable-slot.js';
21
- import { PersistentStrandPeerBookStore } from './strand-peer-book.js';
22
- /** Base name of the store file: `<dir>/strand-peers.<encoded partyId>.json`. */
23
- const SLOT_NAME = 'strand-peers';
24
- /**
25
- * File-backed {@link StrandPeerBookStore}. Open via {@link open}, which loads the
26
- * existing file and returns the cross-platform store over it; an absent, corrupt,
27
- * or wrong-party file is a cold start (empty book) and structurally junk or
28
- * aged-out entries are dropped, while a present-but-unreadable file throws. Those
29
- * rules and their reasoning live on `NodeLocalSnapshot.open`.
30
- */
31
- export const FileStrandPeerBookStore = {
32
- /** Load (or cold-start) the party's strand peer book from a file in `dir`. */
33
- async open(dir, partyId, options) {
34
- return PersistentStrandPeerBookStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId, options);
35
- }
36
- };
37
- //# sourceMappingURL=strand-peer-book-file.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"strand-peer-book-file.js","sourceRoot":"","sources":["../src/strand-peer-book-file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,6BAA6B,EAAE,MAAM,uBAAuB,CAAC;AAGtE,gFAAgF;AAChF,MAAM,SAAS,GAAG,cAAc,CAAC;AAEjC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG;IACtC,8EAA8E;IAC9E,KAAK,CAAC,IAAI,CAAC,GAAW,EAAE,OAAe,EAAE,OAA+B;QACvE,OAAO,6BAA6B,CAAC,IAAI,CAAC,IAAI,eAAe,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC3G,CAAC;CACD,CAAC"}
@@ -1,168 +0,0 @@
1
- import type { Connection, Libp2p, PrivateKey } from '@libp2p/interface';
2
- /** Protocol id of the strand peer book swap, registered on every running strand node. */
3
- export declare const STRAND_PEER_BOOK_PROTOCOL = "/sereus/strand-peers/1.0.0";
4
- /** Version tag inside every signed payload, so a later payload shape cannot verify as this one. */
5
- export declare const SIGNED_STRAND_PEER_ENTRY_VERSION = 1;
6
- /**
7
- * How far ahead of the receiver's clock a signed entry's `issuedAt` may be. Skew
8
- * between honest machines is seconds; a stamp further ahead than this is a broken
9
- * clock or an attempt to pin an entry, and is rejected rather than held.
10
- */
11
- export declare const STRAND_PEER_ISSUED_AT_SKEW_MS: number;
12
- /**
13
- * Cap on one frame's bytes. A typical full frame — {@link MAX_STRAND_PEERS} entries of
14
- * {@link MAX_STRAND_ADDRS} addresses at roughly 150 bytes each, plus a peer id and
15
- * signature per entry — is about 40 KiB, but nothing bounds a multiaddr's length (a
16
- * long DNS name can take one past 300 bytes), so a sender trims its forwarded
17
- * entries to fit ({@link trimBookFrameToFit}) rather than trusting the arithmetic;
18
- * {@link assertBookFrameFits} is then the invariant check.
19
- */
20
- export declare const MAX_BOOK_FRAME_SIZE: number;
21
- /**
22
- * One peer's own signed statement of where it is: a `StrandPeerEntry` without the
23
- * receiver-local `lastSeenAt`. `addrs` may be empty — a peer that lost its relay
24
- * reservation truthfully says "not reachable now", and that displaces a stale
25
- * reachable list at every receiver.
26
- */
27
- export interface SignedStrandPeerEntry {
28
- /** The signer's strand transport peer id (Ed25519, so it embeds the verifying key). */
29
- peerId: string;
30
- /** Bound to `peerId`, signaling-first, at most `MAX_STRAND_ADDRS`. */
31
- addrs: string[];
32
- /** Signer's clock, ms. Greater wins between two statements by the same signer. */
33
- issuedAt: number;
34
- /** base64url Ed25519 signature over {@link signedStrandPeerEntryPayload}. */
35
- sig: string;
36
- }
37
- /** Request and response of the swap: the sender's signed book for one strand. */
38
- export interface StrandPeerBookFrame {
39
- /** Must name the strand whose network the frame arrived on; anything else is refused. */
40
- strandId: string;
41
- /** At most {@link MAX_STRAND_PEERS}. */
42
- entries: SignedStrandPeerEntry[];
43
- }
44
- /** The bytes a signed entry's signature covers. */
45
- export declare function signedStrandPeerEntryPayload(strandId: string, entry: Pick<SignedStrandPeerEntry, 'peerId' | 'addrs' | 'issuedAt'>): Uint8Array;
46
- /**
47
- * Sign this node's own entry with its strand transport key. `addrs` must already be
48
- * bound to the key's peer id (`dialableAddrs` in `strand-peer-observer.ts` does that);
49
- * a receiver rejects an entry whose addresses do not all attribute to the signer.
50
- */
51
- export declare function signStrandPeerEntry(privateKey: PrivateKey, strandId: string, addrs: string[], issuedAt: number): Promise<SignedStrandPeerEntry>;
52
- /** Why a received entry was dropped — for logs and for the verification test. */
53
- export type StrandPeerEntryVerdict = {
54
- ok: true;
55
- entry: SignedStrandPeerEntry;
56
- } | {
57
- ok: false;
58
- reason: string;
59
- };
60
- /**
61
- * Verify one received entry: well-formed, an Ed25519 peer id, every address bound to
62
- * that peer id, `issuedAt` not further ahead of `now` than the skew ceiling, and a
63
- * valid self-signature. Never throws: junk from a peer is a verdict, not an error.
64
- */
65
- export declare function verifySignedStrandPeerEntry(strandId: string, raw: unknown, now: number): Promise<StrandPeerEntryVerdict>;
66
- /**
67
- * Verify a whole received frame. A frame-level violation — the wrong strand, entries
68
- * not a list, more entries than {@link MAX_STRAND_PEERS} — throws, and the caller
69
- * refuses the frame. An entry that fails {@link verifySignedStrandPeerEntry} is
70
- * dropped INDIVIDUALLY with a debug line; its siblings are kept. An entry naming
71
- * `selfPeerId` is dropped too: this node's own statement is authoritative locally
72
- * and a remote copy of it is at best redundant. One entry per peer id (the first).
73
- */
74
- export declare function verifyStrandPeerBookFrame(strandId: string, raw: unknown, selfPeerId: string, now: number): Promise<SignedStrandPeerEntry[]>;
75
- /**
76
- * The frame with its LAST entries dropped until it encodes within
77
- * {@link MAX_BOOK_FRAME_SIZE} and {@link MAX_STRAND_PEERS}. Callers list the entries
78
- * in the order they would rather keep them (the own entry first, then freshest
79
- * first), so what goes is the stalest forwarded statement, and a frame that fits is
80
- * returned as is. Dropping an entry is logged: a forwarded peer the recipient never
81
- * learns of is worth seeing in a trace.
82
- */
83
- export declare function trimBookFrameToFit(frame: StrandPeerBookFrame): StrandPeerBookFrame;
84
- /**
85
- * Refuse to send a frame the receiver would reject: over the entry cap, or over
86
- * {@link MAX_BOOK_FRAME_SIZE} once encoded. A caller that built its frame through
87
- * {@link trimBookFrameToFit} cannot trip this; it is the invariant check, not a
88
- * runtime condition to handle.
89
- */
90
- export declare function assertBookFrameFits(frame: StrandPeerBookFrame): void;
91
- /** What the {@link StrandPeerBookService} receiver needs from its host, injected so it is testable without a node. */
92
- export interface StrandPeerBookServiceDeps {
93
- strandId: string;
94
- /** This strand node's own peer id: a received entry naming it is never merged. */
95
- selfPeerId: string;
96
- /**
97
- * The entries to answer `forPeerId` with — this node's own signed entry plus every
98
- * signed entry it holds, minus `forPeerId`'s own (the asker knows where it is).
99
- */
100
- localEntries(forPeerId: string): SignedStrandPeerEntry[];
101
- /** The asker's VERIFIED entries, for the host to merge. Must not throw. */
102
- onEntries(entries: SignedStrandPeerEntry[], fromPeerId: string): void;
103
- /** Clock, for tests. Default `Date.now`. */
104
- now?: () => number;
105
- /**
106
- * How long the receiver waits for the inbound request frame (ms). Required for the same
107
- * reason as {@link ExchangeStrandPeerBookOptions.timeoutMs}: the swap gives both sides one
108
- * link-derived deadline.
109
- */
110
- readTimeoutMs: number;
111
- /** Default {@link DEFAULT_MAX_CONCURRENT}. Over it, an empty frame is returned unread. */
112
- maxConcurrent?: number;
113
- }
114
- /**
115
- * Receiver side: registers the protocol on a strand node and, per inbound frame,
116
- * verifies and hands over the asker's entries, then answers with the local book.
117
- * Registered through `node.handle`, so on a closed strand every inbound stream
118
- * passes the revocation gate first — a revoked machine cannot push addresses into
119
- * anyone's book.
120
- */
121
- export declare class StrandPeerBookService {
122
- private readonly deps;
123
- private readonly readTimeoutMs;
124
- private readonly maxConcurrent;
125
- private readonly now;
126
- private node;
127
- private activeStreams;
128
- constructor(deps: StrandPeerBookServiceDeps);
129
- /** In-flight inbound streams. */
130
- get activeCount(): number;
131
- /** Register the handler. Resolves once libp2p has recorded the protocol on the self record. */
132
- initialize(node: Libp2p): Promise<void>;
133
- /** Unregister the handler and release the node reference. */
134
- shutdown(): Promise<void>;
135
- /**
136
- * Read the request, merge what verifies, answer with the local book. Every failure
137
- * (cap, malformed or oversized frame, wrong strand, read timeout) answers an empty
138
- * frame rather than hanging or dropping the stream, so the asker's exchange settles.
139
- *
140
- * NOTE: the concurrency cap bounds streams in flight, not frames per peer per unit
141
- * time — the ten-minute throttle is the CLIENT's, so a connected peer can push a
142
- * verified frame, and the persist it costs, as fast as it likes; see
143
- * `backlog/debt-strand-peer-book-remote-write-bounds`.
144
- */
145
- private handleStream;
146
- private reply;
147
- }
148
- /** Options for {@link exchangeStrandPeerBook}. */
149
- export interface ExchangeStrandPeerBookOptions {
150
- /** This strand node's own peer id: a returned entry naming it is dropped. */
151
- selfPeerId: string;
152
- /**
153
- * Whole-exchange deadline (ms). Required, with no fallback here: the exchange crosses the
154
- * link, so its deadline derives from the declared link round trip (`link-budget.ts`), and
155
- * the caller is the one that knows the declaration. A default in this module would be a
156
- * second number free to drift from that derivation.
157
- */
158
- timeoutMs: number;
159
- /** Clock, for tests. Default `Date.now`. */
160
- now?: () => number;
161
- }
162
- /**
163
- * Client side: open one stream on `connection` — the live connection to the peer,
164
- * relayed or not — send `request`, and return the peer's VERIFIED entries. Throws on
165
- * any failure (the peer does not speak the protocol, a deadline, a refused frame);
166
- * the caller logs and moves on, never retrying before its throttle expires.
167
- */
168
- export declare function exchangeStrandPeerBook(connection: Connection, request: StrandPeerBookFrame, options: ExchangeStrandPeerBookOptions): Promise<SignedStrandPeerEntry[]>;