@serfab/cadre-core 1.5.0 → 1.7.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 (68) hide show
  1. package/dist/cadre-node.d.ts +206 -67
  2. package/dist/cadre-node.js +399 -98
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/index.d.ts +6 -1
  5. package/dist/index.js +17 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/joined-strand-store.d.ts +112 -0
  8. package/dist/joined-strand-store.js +263 -0
  9. package/dist/joined-strand-store.js.map +1 -0
  10. package/dist/key-store.d.ts +6 -0
  11. package/dist/key-store.js.map +1 -1
  12. package/dist/link-budget.d.ts +95 -53
  13. package/dist/link-budget.js +97 -53
  14. package/dist/link-budget.js.map +1 -1
  15. package/dist/node-local-snapshot.d.ts +5 -4
  16. package/dist/node-local-snapshot.js +6 -3
  17. package/dist/node-local-snapshot.js.map +1 -1
  18. package/dist/peer-dial.d.ts +3 -3
  19. package/dist/peer-dial.js +3 -3
  20. package/dist/peer-join-backfill.d.ts +35 -16
  21. package/dist/peer-join-backfill.js +117 -57
  22. package/dist/peer-join-backfill.js.map +1 -1
  23. package/dist/relay-reservation.d.ts +2 -2
  24. package/dist/relay-reservation.js +2 -2
  25. package/dist/seed-bootstrap.js +1 -1
  26. package/dist/strand-first-sync-gate.d.ts +88 -43
  27. package/dist/strand-first-sync-gate.js +92 -46
  28. package/dist/strand-first-sync-gate.js.map +1 -1
  29. package/dist/strand-instance-manager.d.ts +39 -0
  30. package/dist/strand-instance-manager.js +80 -3
  31. package/dist/strand-instance-manager.js.map +1 -1
  32. package/dist/strand-peer-book-file.d.ts +12 -0
  33. package/dist/strand-peer-book-file.js +37 -0
  34. package/dist/strand-peer-book-file.js.map +1 -0
  35. package/dist/strand-peer-book-protocol.d.ts +159 -0
  36. package/dist/strand-peer-book-protocol.js +360 -0
  37. package/dist/strand-peer-book-protocol.js.map +1 -0
  38. package/dist/strand-peer-book-swap.d.ts +104 -0
  39. package/dist/strand-peer-book-swap.js +284 -0
  40. package/dist/strand-peer-book-swap.js.map +1 -0
  41. package/dist/strand-peer-book.d.ts +159 -0
  42. package/dist/strand-peer-book.js +354 -0
  43. package/dist/strand-peer-book.js.map +1 -0
  44. package/dist/strand-peer-observer.d.ts +88 -0
  45. package/dist/strand-peer-observer.js +180 -0
  46. package/dist/strand-peer-observer.js.map +1 -0
  47. package/dist/types.d.ts +84 -17
  48. package/dist/types.js +6 -0
  49. package/dist/types.js.map +1 -1
  50. package/package.json +11 -7
  51. package/src/cadre-node.ts +7776 -7445
  52. package/src/index.ts +68 -0
  53. package/src/joined-strand-store.ts +308 -0
  54. package/src/key-store.ts +6 -0
  55. package/src/link-budget.ts +100 -53
  56. package/src/node-local-snapshot.ts +293 -289
  57. package/src/peer-dial.ts +3 -3
  58. package/src/peer-join-backfill.ts +709 -647
  59. package/src/relay-reservation.ts +2 -2
  60. package/src/seed-bootstrap.ts +1 -1
  61. package/src/strand-first-sync-gate.ts +352 -306
  62. package/src/strand-instance-manager.ts +106 -3
  63. package/src/strand-peer-book-file.ts +39 -0
  64. package/src/strand-peer-book-protocol.ts +463 -0
  65. package/src/strand-peer-book-swap.ts +338 -0
  66. package/src/strand-peer-book.ts +468 -0
  67. package/src/strand-peer-observer.ts +235 -0
  68. package/src/types.ts +86 -17
@@ -4,6 +4,9 @@ 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
10
  import {
8
11
  StrandRevocationEnforcer,
9
12
  createRevocationConnectionGater,
@@ -41,7 +44,7 @@ import type {
41
44
  import { DEFAULT_CONNECTION_MONITOR, resolveStrandClusterSize, strandClusterPolicy } from './types.js';
42
45
  import { strandNodeAddrs } from './strand-network-config.js';
43
46
  import { superviseRelayReservation, type RelayReservationSupervisor } from './relay-reservation.js';
44
- import { peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
47
+ import { connectionManagerTimeouts, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
45
48
 
46
49
  const log = debug('sereus:cadre:strand-manager');
47
50
  const timing = debug('sereus:cadre:timing');
@@ -261,6 +264,28 @@ export interface StartStrandConfig {
261
264
  * hibernation wake's rebuilt supervisors carry it too.
262
265
  */
263
266
  announceDelegateToRelay?: (strandId: string, relayAddr: string, delegatePeerId: string) => Promise<void>;
267
+
268
+ /**
269
+ * Called when the strand's libp2p node identifies a STRAND peer — one whose
270
+ * identify result names this strand's block-transfer protocol, so never the
271
+ * circuit relay or a bootstrap node — with the addresses it is dialable at
272
+ * (`strand-peer-observer.ts`). Throttled per peer; must not throw. `CadreNode`
273
+ * wires it to the node-local strand peer book, which is what lets a restarted
274
+ * machine dial the peers it was talking to. Absent ⇒ nothing is observed.
275
+ * Retained with the launch config, so a hibernation wake re-arms it on the
276
+ * rebuilt node.
277
+ */
278
+ onStrandPeerIdentified?: (strandId: string, observation: StrandPeerObservation) => void;
279
+
280
+ /**
281
+ * The node-local strand peer book the book SWAP reads and writes
282
+ * (`strand-peer-book-swap.ts`): the strand node signs its own addresses with
283
+ * {@link privateKey}, exchanges signed entries with every strand peer it identifies,
284
+ * and files what verifies. Absent ⇒ no swap runs — a peer that dials this node's
285
+ * swap handler gets no answer. `CadreNode` passes its book store. Retained with the
286
+ * launch config, so a hibernation wake re-arms the swap on the rebuilt node.
287
+ */
288
+ strandPeerBook?: StrandPeerBookStore;
264
289
  }
265
290
 
266
291
  /**
@@ -370,6 +395,24 @@ export class StrandInstanceManager {
370
395
  * missed writes).
371
396
  */
372
397
  private backfills: Map<string, PeerJoinBackfill> = new Map();
398
+ /**
399
+ * The per-strand peer-book observation writer (`strand-peer-observer.ts`), keyed
400
+ * by strand id. Same lifecycle rationale as {@link backfills}: started in
401
+ * `buildStrandRuntime` right after the libp2p node exists, stopped and dropped in
402
+ * `releaseRuntime`, so quiesce → resume re-arms it on the rebuilt node. Only
403
+ * present when the launch config supplied `onStrandPeerIdentified`.
404
+ */
405
+ private peerObservers: Map<string, StrandPeerObserver> = new Map();
406
+
407
+ /**
408
+ * Per-strand strand peer book SWAP driver ({@link StrandPeerBookSwap}) — the signed
409
+ * exchange of book entries between strand peers. Same lifetime as the peer-book
410
+ * observer above: armed in `buildStrandRuntime` right after the libp2p node exists
411
+ * (its protocol handler must be registered before an early peer dials it), stopped
412
+ * and dropped in `releaseRuntime`. Only present when the launch config supplied
413
+ * `strandPeerBook`.
414
+ */
415
+ private peerBookSwaps: Map<string, StrandPeerBookSwap> = new Map();
373
416
  /**
374
417
  * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
375
418
  * id. Same lifecycle rationale as {@link backfills}: created in
@@ -711,6 +754,10 @@ export class StrandInstanceManager {
711
754
  // widened ping deadline has to reach the strand nodes too (see
712
755
  // DEFAULT_CONNECTION_MONITOR).
713
756
  connectionMonitor: config.network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
757
+ // The same declared-link limits the control node takes, and for the same reason: a
758
+ // strand node is the listener for every other member's strand node (`link-budget.ts`,
759
+ // `connectionManagerTimeouts`).
760
+ connectionManager: connectionManagerTimeouts(config.network?.linkRoundTripMs),
714
761
  // Listen entries plus the WebSocket transport switch they imply — a strand node
715
762
  // announces nothing the operator configured (`strand-network-config.ts`), and
716
763
  // spreads AFTER `transports` above because the switch is a no-op whenever the
@@ -730,6 +777,48 @@ export class StrandInstanceManager {
730
777
 
731
778
  instance.libp2pNode = node;
732
779
 
780
+ // The peer-book observation writer, armed BEFORE the database bring-up below
781
+ // (unlike the backfill) because the bootstrap dials that follow `libp2p.start()`
782
+ // land during that bring-up, and they are exactly the peers worth remembering:
783
+ // identify for an early peer may already have fired by the time anything after
784
+ // the database initializes runs. Registered in the map immediately so the
785
+ // failure rollback (releaseRuntime) stops it like every other runtime component.
786
+ const onStrandPeerIdentified = config.onStrandPeerIdentified;
787
+ if (onStrandPeerIdentified) {
788
+ const observer = new StrandPeerObserver({
789
+ label: strandId,
790
+ libp2p: node,
791
+ protocolPrefix,
792
+ onObserved: (observation) => onStrandPeerIdentified(strandId, observation)
793
+ });
794
+ this.peerObservers.set(strandId, observer);
795
+ observer.start();
796
+ }
797
+
798
+ // The strand peer book SWAP, armed at the same moment and for the same reason as
799
+ // the observer: a peer that dials this node's swap handler before it is registered
800
+ // fails its exchange and sits out a ten-minute throttle. The own entry is signed
801
+ // now and re-signed on every address change, which covers the relay reservation
802
+ // landing below (`self:peer:update` fires when the circuit addr is added), so no
803
+ // separate post-relay step is needed. Registered in the map immediately so the
804
+ // failure rollback (releaseRuntime) stops it like every other runtime component.
805
+ //
806
+ // NOTE: this is a third `peer:identify` listener per running strand, beside the
807
+ // observer's and the backfill's — see the backfill's NOTE below for when to fold
808
+ // them into one dispatcher.
809
+ if (config.strandPeerBook) {
810
+ const swap = new StrandPeerBookSwap({
811
+ strandId,
812
+ libp2p: node,
813
+ protocolPrefix,
814
+ store: config.strandPeerBook,
815
+ privateKey: config.privateKey,
816
+ linkRoundTripMs: config.network?.linkRoundTripMs
817
+ });
818
+ this.peerBookSwaps.set(strandId, swap);
819
+ swap.start();
820
+ }
821
+
733
822
  // One reservation supervisor PER RELAY, started now so the first attempts
734
823
  // overlap the database bring-up below (the relay is never in a strand's
735
824
  // Optimystic cohort — its protocol ids are namespaced `/optimystic/strand-<id>/…`
@@ -846,7 +935,7 @@ export class StrandInstanceManager {
846
935
  // peer-join-backfill.ts for the strand-side argument (and why the control
847
936
  // network, which DOES gate, is different).
848
937
  //
849
- // NOTE: cost is one PeerJoinBackfill object + one `connection:open` listener
938
+ // NOTE: cost is one PeerJoinBackfill object + one `peer:identify` listener
850
939
  // per running strand — linear in strand count, negligible at the handful a
851
940
  // device or host runs today. If a node ever hosts strands by the hundred,
852
941
  // move to one shared listener that dispatches by strand id.
@@ -884,7 +973,7 @@ export class StrandInstanceManager {
884
973
  // failing here would only trade that for `StrandWatcher`'s full-rebuild retry.
885
974
  //
886
975
  // NOTE: a relay that is down costs this launch one full drive — the reservation budget
887
- // counted from the declared link round trip, 8 s at its default (`link-budget.ts`) — and
976
+ // counted from the declared link round trip, 14 s at its default (`link-budget.ts`) — and
888
977
  // `StrandWatcher` launches strands one at a time, so N strands cost N of those in
889
978
  // bring-up during a relay outage, and MORE on a host that declared a slower link. If that
890
979
  // ever matters, stop awaiting here (the circuit addr then lands after `active`) rather
@@ -942,6 +1031,20 @@ export class StrandInstanceManager {
942
1031
  backfill.stop();
943
1032
  this.backfills.delete(instance.strandId);
944
1033
  }
1034
+ // The peer-book observer goes with the node it listened on; a resume re-arms
1035
+ // one on the rebuilt node.
1036
+ const peerObserver = this.peerObservers.get(instance.strandId);
1037
+ if (peerObserver) {
1038
+ peerObserver.stop();
1039
+ this.peerObservers.delete(instance.strandId);
1040
+ }
1041
+ // The book swap likewise: unsubscribed and its handler unregistered while the node is
1042
+ // still up; a resume re-arms one, which re-signs the own entry over the new addresses.
1043
+ const peerBookSwap = this.peerBookSwaps.get(instance.strandId);
1044
+ if (peerBookSwap) {
1045
+ this.peerBookSwaps.delete(instance.strandId);
1046
+ await peerBookSwap.stop();
1047
+ }
945
1048
  // The revoked-peer enforcer goes with the runtime it gated: a resume
946
1049
  // rebuilds it with a fresh (initially empty, fail-open) snapshot.
947
1050
  const revocationEnforcer = this.revocationEnforcers.get(instance.strandId);
@@ -0,0 +1,39 @@
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
+ import type { StrandPeerBookOptions, StrandPeerBookStore } from './strand-peer-book.js';
23
+
24
+ /** Base name of the store file: `<dir>/strand-peers.<encoded partyId>.json`. */
25
+ const SLOT_NAME = 'strand-peers';
26
+
27
+ /**
28
+ * File-backed {@link StrandPeerBookStore}. Open via {@link open}, which loads the
29
+ * existing file and returns the cross-platform store over it; an absent, corrupt,
30
+ * or wrong-party file is a cold start (empty book) and structurally junk or
31
+ * aged-out entries are dropped, while a present-but-unreadable file throws. Those
32
+ * rules and their reasoning live on `NodeLocalSnapshot.open`.
33
+ */
34
+ export const FileStrandPeerBookStore = {
35
+ /** Load (or cold-start) the party's strand peer book from a file in `dir`. */
36
+ async open(dir: string, partyId: string, options?: StrandPeerBookOptions): Promise<StrandPeerBookStore> {
37
+ return PersistentStrandPeerBookStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId, options);
38
+ }
39
+ };
@@ -0,0 +1,463 @@
1
+ /**
2
+ * The strand peer book SWAP protocol, `/sereus/strand-peers/1.0.0`, on the STRAND
3
+ * libp2p node: when two strand peers connect, each sends the other the signed
4
+ * entries of its strand peer book (`strand-peer-book.ts`) — its own current
5
+ * addresses, self-signed, plus the freshest signed entry it holds for every other
6
+ * peer of the strand — and merges what comes back. That closes the two gaps the
7
+ * node-local book alone leaves: a peer whose relay reservation or address ROTATES is
8
+ * known to the others by its old address only until they happen to reconnect, and in
9
+ * a strand of three or more parties a late joiner holds an address only for the party
10
+ * that invited it. After one swap it holds everyone's.
11
+ *
12
+ * Wire shape, modeled on `strand-addr-protocol.ts`: 4-byte big-endian length-prefixed
13
+ * JSON frames over one stream, one request → one response, the shared
14
+ * `control-stream.ts` primitives, a read timeout and a concurrency cap on the
15
+ * receiver, and a {@link MAX_BOOK_FRAME_SIZE} cap on what either side buffers.
16
+ * Request and response are the same {@link StrandPeerBookFrame}. Only SIGNED entries
17
+ * ever travel: an entry this node observed or a formation carried is its own
18
+ * observation, not the peer's statement, and is never forwarded.
19
+ *
20
+ * ── Whose signature ──
21
+ *
22
+ * Each entry is signed by the named peer's own STRAND TRANSPORT key
23
+ * (`strand-transport-key.ts`, the key its strand libp2p node runs under), over the
24
+ * canonical JSON of `{ v: 1, strandId, peerId, addrs, issuedAt }`. Verification needs
25
+ * no key exchange: an Ed25519 libp2p peer id embeds its public key, so any receiver
26
+ * checks that the entry for peer X was signed by X. NOT the strand member key, for
27
+ * four reasons:
28
+ *
29
+ * 1. Open strands have no shared member key at all, and the book has to work there.
30
+ * 2. A member-key signature proves only "some member said this", so any member could
31
+ * forge another member's address and cost every peer a 14 s relayed dial per
32
+ * launch; a self-signature is a claim only the named peer can make.
33
+ * 3. On a closed strand the member key is shared by every machine of a party, so it
34
+ * cannot identify a machine anyway.
35
+ * 4. Membership is a separate question, judged at the connection by the revocation
36
+ * gate today (`strand-revocation-enforcer.ts`) and by the allowlist later
37
+ * (`feat-strand-member-allowlist-admission`); this protocol rides that gate
38
+ * (`node.handle` → `authorizeInboundStream` on a closed strand).
39
+ *
40
+ * **The book therefore proves "this peer's own claim about where it is", never "this
41
+ * peer is a member of the strand".** An address grants no authority (the dialed peer
42
+ * authenticates by peer id at the handshake), so the worst a forged or stale entry
43
+ * costs is a failed dial, and a self-signature is exactly what bounds who can impose
44
+ * that cost on whom.
45
+ *
46
+ * ── Clocks ──
47
+ *
48
+ * `issuedAt` is the signer's clock. It is compared only between entries from the
49
+ * SAME signer (the store's merge rule), so skew between machines is harmless — except
50
+ * that a far-future stamp would pin an entry forever, hence the
51
+ * {@link STRAND_PEER_ISSUED_AT_SKEW_MS} ceiling (reject and log). The store ages an
52
+ * entry from `max(issuedAt, lastSeenAt)`, so a signer far in the past ages out fast,
53
+ * which is the right failure for a machine with a broken clock.
54
+ */
55
+ import debug from 'debug';
56
+ import type { Connection, Libp2p, PrivateKey } from '@libp2p/interface';
57
+ import { peerIdFromString } from '@libp2p/peer-id';
58
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
59
+ import { canonicalJson } from './canonical-json.js';
60
+ import { type ControlStream, exchangeFrame, readStreamToEnd, withDeadline, writeFrame } from './control-stream.js';
61
+ import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
62
+ import { groupAddrsByPeerId } from './peer-addr-book.js';
63
+ import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
64
+ import { MAX_STRAND_PEERS } from './strand-peer-book.js';
65
+
66
+ const log = debug('sereus:cadre:strand-peer-book-protocol');
67
+
68
+ /** Protocol id of the strand peer book swap, registered on every running strand node. */
69
+ export const STRAND_PEER_BOOK_PROTOCOL = '/sereus/strand-peers/1.0.0';
70
+
71
+ /** Version tag inside every signed payload, so a later payload shape cannot verify as this one. */
72
+ export const SIGNED_STRAND_PEER_ENTRY_VERSION = 1;
73
+
74
+ /**
75
+ * How far ahead of the receiver's clock a signed entry's `issuedAt` may be. Skew
76
+ * between honest machines is seconds; a stamp further ahead than this is a broken
77
+ * clock or an attempt to pin an entry, and is rejected rather than held.
78
+ */
79
+ export const STRAND_PEER_ISSUED_AT_SKEW_MS = 5 * 60 * 1000;
80
+
81
+ /**
82
+ * Cap on one frame's bytes. A typical full frame — {@link MAX_STRAND_PEERS} entries of
83
+ * {@link MAX_STRAND_ADDRS} addresses at roughly 150 bytes each, plus a peer id and
84
+ * signature per entry — is about 40 KiB, but nothing bounds a multiaddr's length (a
85
+ * long DNS name can take one past 300 bytes), so a sender trims its forwarded
86
+ * entries to fit ({@link trimBookFrameToFit}) rather than trusting the arithmetic;
87
+ * {@link assertBookFrameFits} is then the invariant check.
88
+ */
89
+ export const MAX_BOOK_FRAME_SIZE = 64 * 1024;
90
+
91
+ /** Default time the receiver waits for the inbound request frame before aborting (ms). */
92
+ const DEFAULT_READ_TIMEOUT_MS = 10_000;
93
+
94
+ /** Default time the client waits for the whole exchange (ms). */
95
+ const DEFAULT_EXCHANGE_TIMEOUT_MS = 10_000;
96
+
97
+ /** Default cap on concurrent inbound swap streams a single strand node serves. */
98
+ const DEFAULT_MAX_CONCURRENT = 100;
99
+
100
+ /**
101
+ * One peer's own signed statement of where it is: a `StrandPeerEntry` without the
102
+ * receiver-local `lastSeenAt`. `addrs` may be empty — a peer that lost its relay
103
+ * reservation truthfully says "not reachable now", and that displaces a stale
104
+ * reachable list at every receiver.
105
+ */
106
+ export interface SignedStrandPeerEntry {
107
+ /** The signer's strand transport peer id (Ed25519, so it embeds the verifying key). */
108
+ peerId: string;
109
+ /** Bound to `peerId`, signaling-first, at most `MAX_STRAND_ADDRS`. */
110
+ addrs: string[];
111
+ /** Signer's clock, ms. Greater wins between two statements by the same signer. */
112
+ issuedAt: number;
113
+ /** base64url Ed25519 signature over {@link signedStrandPeerEntryPayload}. */
114
+ sig: string;
115
+ }
116
+
117
+ /** Request and response of the swap: the sender's signed book for one strand. */
118
+ export interface StrandPeerBookFrame {
119
+ /** Must name the strand whose network the frame arrived on; anything else is refused. */
120
+ strandId: string;
121
+ /** At most {@link MAX_STRAND_PEERS}. */
122
+ entries: SignedStrandPeerEntry[];
123
+ }
124
+
125
+ /** The bytes a signed entry's signature covers. */
126
+ export function signedStrandPeerEntryPayload(
127
+ strandId: string,
128
+ entry: Pick<SignedStrandPeerEntry, 'peerId' | 'addrs' | 'issuedAt'>
129
+ ): Uint8Array {
130
+ return new TextEncoder().encode(canonicalJson({
131
+ v: SIGNED_STRAND_PEER_ENTRY_VERSION,
132
+ strandId,
133
+ peerId: entry.peerId,
134
+ addrs: entry.addrs,
135
+ issuedAt: entry.issuedAt
136
+ }));
137
+ }
138
+
139
+ /**
140
+ * Sign this node's own entry with its strand transport key. `addrs` must already be
141
+ * bound to the key's peer id (`dialableAddrs` in `strand-peer-observer.ts` does that);
142
+ * a receiver rejects an entry whose addresses do not all attribute to the signer.
143
+ */
144
+ export async function signStrandPeerEntry(
145
+ privateKey: PrivateKey,
146
+ strandId: string,
147
+ addrs: string[],
148
+ issuedAt: number
149
+ ): Promise<SignedStrandPeerEntry> {
150
+ if (privateKey.type !== 'Ed25519') {
151
+ throw new Error(`signStrandPeerEntry requires an Ed25519 key, got ${privateKey.type}`);
152
+ }
153
+ const peerId = privateKey.publicKey.toString();
154
+ const entry = { peerId, addrs: [...addrs], issuedAt };
155
+ const sig = await privateKey.sign(signedStrandPeerEntryPayload(strandId, entry));
156
+ return { ...entry, sig: uint8ArrayToString(sig, 'base64url') };
157
+ }
158
+
159
+ /** Why a received entry was dropped — for logs and for the verification test. */
160
+ export type StrandPeerEntryVerdict =
161
+ | { ok: true; entry: SignedStrandPeerEntry }
162
+ | { ok: false; reason: string };
163
+
164
+ /**
165
+ * Verify one received entry: well-formed, an Ed25519 peer id, every address bound to
166
+ * that peer id, `issuedAt` not further ahead of `now` than the skew ceiling, and a
167
+ * valid self-signature. Never throws: junk from a peer is a verdict, not an error.
168
+ */
169
+ export async function verifySignedStrandPeerEntry(
170
+ strandId: string,
171
+ raw: unknown,
172
+ now: number
173
+ ): Promise<StrandPeerEntryVerdict> {
174
+ const shaped = shapeEntry(raw);
175
+ if (typeof shaped === 'string') {
176
+ return { ok: false, reason: shaped };
177
+ }
178
+ let publicKey;
179
+ try {
180
+ const peerId = peerIdFromString(shaped.peerId);
181
+ if (peerId.type !== 'Ed25519') {
182
+ return { ok: false, reason: `peer id ${shaped.peerId} is ${peerId.type}, not Ed25519` };
183
+ }
184
+ publicKey = peerId.publicKey;
185
+ } catch (error) {
186
+ return { ok: false, reason: `peer id ${shaped.peerId} does not parse: ${(error as Error).message}` };
187
+ }
188
+ const attributed = groupAddrsByPeerId([...shaped.addrs]).get(shaped.peerId)?.length ?? 0;
189
+ if (attributed !== shaped.addrs.length) {
190
+ return { ok: false, reason: `${shaped.addrs.length - attributed} addr(s) do not attribute to ${shaped.peerId}` };
191
+ }
192
+ if (shaped.issuedAt > now + STRAND_PEER_ISSUED_AT_SKEW_MS) {
193
+ return { ok: false, reason: `issuedAt ${shaped.issuedAt} is more than ${STRAND_PEER_ISSUED_AT_SKEW_MS} ms ahead of now` };
194
+ }
195
+ try {
196
+ const sig = uint8ArrayFromString(shaped.sig, 'base64url');
197
+ if (!(await publicKey.verify(signedStrandPeerEntryPayload(strandId, shaped), sig))) {
198
+ return { ok: false, reason: `signature does not verify for ${shaped.peerId}` };
199
+ }
200
+ } catch (error) {
201
+ return { ok: false, reason: `signature unusable for ${shaped.peerId}: ${(error as Error).message}` };
202
+ }
203
+ return { ok: true, entry: shaped };
204
+ }
205
+
206
+ /** Structural check of untrusted JSON: the fields, their types, the address cap. */
207
+ function shapeEntry(raw: unknown): SignedStrandPeerEntry | string {
208
+ if (typeof raw !== 'object' || raw === null) return 'entry is not an object';
209
+ const { peerId, addrs, issuedAt, sig } = raw as Record<string, unknown>;
210
+ if (typeof peerId !== 'string' || peerId.length === 0) return 'peerId is not a string';
211
+ if (!Array.isArray(addrs) || addrs.some((addr) => typeof addr !== 'string' || addr.length === 0)) {
212
+ return `addrs of ${peerId} is not a list of strings`;
213
+ }
214
+ if (addrs.length > MAX_STRAND_ADDRS) return `${peerId} lists ${addrs.length} addrs, over the cap of ${MAX_STRAND_ADDRS}`;
215
+ if (!Number.isSafeInteger(issuedAt) || (issuedAt as number) < 0) return `issuedAt of ${peerId} is not a timestamp`;
216
+ if (typeof sig !== 'string' || sig.length === 0) return `sig of ${peerId} is not a string`;
217
+ // A duplicate address is not a protocol error, but the store de-duplicates and the
218
+ // attribution count above compares lengths, so collapse it here.
219
+ return { peerId, addrs: [...new Set(addrs as string[])], issuedAt: issuedAt as number, sig };
220
+ }
221
+
222
+ /**
223
+ * Verify a whole received frame. A frame-level violation — the wrong strand, entries
224
+ * not a list, more entries than {@link MAX_STRAND_PEERS} — throws, and the caller
225
+ * refuses the frame. An entry that fails {@link verifySignedStrandPeerEntry} is
226
+ * dropped INDIVIDUALLY with a debug line; its siblings are kept. An entry naming
227
+ * `selfPeerId` is dropped too: this node's own statement is authoritative locally
228
+ * and a remote copy of it is at best redundant. One entry per peer id (the first).
229
+ */
230
+ export async function verifyStrandPeerBookFrame(
231
+ strandId: string,
232
+ raw: unknown,
233
+ selfPeerId: string,
234
+ now: number
235
+ ): Promise<SignedStrandPeerEntry[]> {
236
+ if (typeof raw !== 'object' || raw === null) {
237
+ throw new Error('strand peer book frame is not an object');
238
+ }
239
+ const { strandId: named, entries } = raw as Record<string, unknown>;
240
+ if (named !== strandId) {
241
+ throw new Error(`strand peer book frame names strand ${String(named)}, expected ${strandId}`);
242
+ }
243
+ if (!Array.isArray(entries)) {
244
+ throw new Error('strand peer book frame entries is not a list');
245
+ }
246
+ if (entries.length > MAX_STRAND_PEERS) {
247
+ throw new Error(`strand peer book frame carries ${entries.length} entries, over the cap of ${MAX_STRAND_PEERS}`);
248
+ }
249
+ const verified = new Map<string, SignedStrandPeerEntry>();
250
+ for (const candidate of entries) {
251
+ const verdict = await verifySignedStrandPeerEntry(strandId, candidate, now);
252
+ if (!verdict.ok) {
253
+ log('strand %s: dropping received entry: %s', strandId, verdict.reason);
254
+ continue;
255
+ }
256
+ if (verdict.entry.peerId === selfPeerId || verified.has(verdict.entry.peerId)) {
257
+ continue;
258
+ }
259
+ verified.set(verdict.entry.peerId, verdict.entry);
260
+ }
261
+ return [...verified.values()];
262
+ }
263
+
264
+ /** The encoded size of a frame, as {@link writeFrame} would send it (the 4-byte prefix aside). */
265
+ function encodedFrameBytes(frame: StrandPeerBookFrame): number {
266
+ return new TextEncoder().encode(JSON.stringify(frame)).length;
267
+ }
268
+
269
+ /**
270
+ * The frame with its LAST entries dropped until it encodes within
271
+ * {@link MAX_BOOK_FRAME_SIZE} and {@link MAX_STRAND_PEERS}. Callers list the entries
272
+ * in the order they would rather keep them (the own entry first, then freshest
273
+ * first), so what goes is the stalest forwarded statement, and a frame that fits is
274
+ * returned as is. Dropping an entry is logged: a forwarded peer the recipient never
275
+ * learns of is worth seeing in a trace.
276
+ */
277
+ export function trimBookFrameToFit(frame: StrandPeerBookFrame): StrandPeerBookFrame {
278
+ let entries = frame.entries.slice(0, MAX_STRAND_PEERS);
279
+ while (entries.length > 0 && encodedFrameBytes({ strandId: frame.strandId, entries }) > MAX_BOOK_FRAME_SIZE) {
280
+ entries = entries.slice(0, -1);
281
+ }
282
+ if (entries.length === frame.entries.length) return frame;
283
+ log('strand %s: frame trimmed from %d to %d entr(ies) to fit the caps', frame.strandId, frame.entries.length, entries.length);
284
+ return { strandId: frame.strandId, entries };
285
+ }
286
+
287
+ /**
288
+ * Refuse to send a frame the receiver would reject: over the entry cap, or over
289
+ * {@link MAX_BOOK_FRAME_SIZE} once encoded. A caller that built its frame through
290
+ * {@link trimBookFrameToFit} cannot trip this; it is the invariant check, not a
291
+ * runtime condition to handle.
292
+ */
293
+ export function assertBookFrameFits(frame: StrandPeerBookFrame): void {
294
+ if (frame.entries.length > MAX_STRAND_PEERS) {
295
+ throw new Error(`strand peer book frame carries ${frame.entries.length} entries, over the cap of ${MAX_STRAND_PEERS}`);
296
+ }
297
+ const bytes = encodedFrameBytes(frame);
298
+ if (bytes > MAX_BOOK_FRAME_SIZE) {
299
+ throw new Error(`strand peer book frame is ${bytes} bytes, over the cap of ${MAX_BOOK_FRAME_SIZE}`);
300
+ }
301
+ }
302
+
303
+ /** Read a stream to EOF and decode its single frame, bounded by `timeoutMs` and the size cap. */
304
+ async function readBookFrame(stream: ControlStream, timeoutMs: number): Promise<unknown> {
305
+ const data = await readStreamToEnd(stream, { maxBytes: MAX_BOOK_FRAME_SIZE, timeoutMs, label: 'Strand peer book' });
306
+ return JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_BOOK_FRAME_SIZE)));
307
+ }
308
+
309
+ /** What the {@link StrandPeerBookService} receiver needs from its host, injected so it is testable without a node. */
310
+ export interface StrandPeerBookServiceDeps {
311
+ strandId: string;
312
+ /** This strand node's own peer id: a received entry naming it is never merged. */
313
+ selfPeerId: string;
314
+ /**
315
+ * The entries to answer `forPeerId` with — this node's own signed entry plus every
316
+ * signed entry it holds, minus `forPeerId`'s own (the asker knows where it is).
317
+ */
318
+ localEntries(forPeerId: string): SignedStrandPeerEntry[];
319
+ /** The asker's VERIFIED entries, for the host to merge. Must not throw. */
320
+ onEntries(entries: SignedStrandPeerEntry[], fromPeerId: string): void;
321
+ /** Clock, for tests. Default `Date.now`. */
322
+ now?: () => number;
323
+ /** Default {@link DEFAULT_READ_TIMEOUT_MS}. */
324
+ readTimeoutMs?: number;
325
+ /** Default {@link DEFAULT_MAX_CONCURRENT}. Over it, an empty frame is returned unread. */
326
+ maxConcurrent?: number;
327
+ }
328
+
329
+ /**
330
+ * Receiver side: registers the protocol on a strand node and, per inbound frame,
331
+ * verifies and hands over the asker's entries, then answers with the local book.
332
+ * Registered through `node.handle`, so on a closed strand every inbound stream
333
+ * passes the revocation gate first — a revoked machine cannot push addresses into
334
+ * anyone's book.
335
+ */
336
+ export class StrandPeerBookService {
337
+ private readonly readTimeoutMs: number;
338
+ private readonly maxConcurrent: number;
339
+ private readonly now: () => number;
340
+ private node: Libp2p | null = null;
341
+ private activeStreams = 0;
342
+
343
+ constructor(private readonly deps: StrandPeerBookServiceDeps) {
344
+ this.readTimeoutMs = deps.readTimeoutMs ?? DEFAULT_READ_TIMEOUT_MS;
345
+ this.maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
346
+ this.now = deps.now ?? Date.now;
347
+ }
348
+
349
+ /** In-flight inbound streams. */
350
+ get activeCount(): number {
351
+ return this.activeStreams;
352
+ }
353
+
354
+ /** Register the handler. Resolves once libp2p has recorded the protocol on the self record. */
355
+ async initialize(node: Libp2p): Promise<void> {
356
+ this.node = node;
357
+ // `runOnLimitedConnection: true`: strand peers reach each other over relayed
358
+ // (limited) connections as a matter of course, and this is one small frame each way.
359
+ await node.handle(STRAND_PEER_BOOK_PROTOCOL, (rawStream: unknown, rawConnection: unknown) => {
360
+ const remotePeerId = (rawConnection as Connection).remotePeer.toString();
361
+ void this.handleStream(rawStream as ControlStream, remotePeerId);
362
+ }, { runOnLimitedConnection: true });
363
+ log('[%s] registered %s', this.deps.strandId, STRAND_PEER_BOOK_PROTOCOL);
364
+ }
365
+
366
+ /** Unregister the handler and release the node reference. */
367
+ async shutdown(): Promise<void> {
368
+ if (this.node) {
369
+ await this.node.unhandle(STRAND_PEER_BOOK_PROTOCOL);
370
+ this.node = null;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Read the request, merge what verifies, answer with the local book. Every failure
376
+ * (cap, malformed or oversized frame, wrong strand, read timeout) answers an empty
377
+ * frame rather than hanging or dropping the stream, so the asker's exchange settles.
378
+ *
379
+ * NOTE: the concurrency cap bounds streams in flight, not frames per peer per unit
380
+ * time — the ten-minute throttle is the CLIENT's, so a connected peer can push a
381
+ * verified frame, and the persist it costs, as fast as it likes; see
382
+ * `backlog/debt-strand-peer-book-remote-write-bounds`.
383
+ */
384
+ private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
385
+ if (this.activeStreams >= this.maxConcurrent) {
386
+ log('[%s] refusing swap from %s: %d streams at the cap', this.deps.strandId, remotePeerId, this.activeStreams);
387
+ this.reply(stream, []);
388
+ await closeQuietly(stream);
389
+ return;
390
+ }
391
+ this.activeStreams++;
392
+ try {
393
+ const raw = await readBookFrame(stream, this.readTimeoutMs);
394
+ const entries = await verifyStrandPeerBookFrame(this.deps.strandId, raw, this.deps.selfPeerId, this.now());
395
+ log('[%s] swap from %s: %d verified entr(ies) received', this.deps.strandId, remotePeerId, entries.length);
396
+ this.deps.onEntries(entries, remotePeerId);
397
+ this.reply(stream, this.deps.localEntries(remotePeerId));
398
+ } catch (error) {
399
+ log('[%s] swap from %s failed: %o', this.deps.strandId, remotePeerId, error);
400
+ this.reply(stream, []);
401
+ } finally {
402
+ this.activeStreams--;
403
+ await closeQuietly(stream);
404
+ }
405
+ }
406
+
407
+ private reply(stream: ControlStream, entries: SignedStrandPeerEntry[]): void {
408
+ try {
409
+ const frame: StrandPeerBookFrame = { strandId: this.deps.strandId, entries };
410
+ assertBookFrameFits(frame);
411
+ writeFrame(stream, frame);
412
+ } catch (error) {
413
+ log('[%s] swap reply failed: %o', this.deps.strandId, error);
414
+ }
415
+ }
416
+ }
417
+
418
+ async function closeQuietly(stream: ControlStream): Promise<void> {
419
+ try {
420
+ await stream.close();
421
+ } catch (error) {
422
+ log('stream close failed (ignored): %o', error);
423
+ }
424
+ }
425
+
426
+ /** Options for {@link exchangeStrandPeerBook}. */
427
+ export interface ExchangeStrandPeerBookOptions {
428
+ /** This strand node's own peer id: a returned entry naming it is dropped. */
429
+ selfPeerId: string;
430
+ /** Whole-exchange deadline (ms). Default {@link DEFAULT_EXCHANGE_TIMEOUT_MS}. */
431
+ timeoutMs?: number;
432
+ /** Clock, for tests. Default `Date.now`. */
433
+ now?: () => number;
434
+ }
435
+
436
+ /**
437
+ * Client side: open one stream on `connection` — the live connection to the peer,
438
+ * relayed or not — send `request`, and return the peer's VERIFIED entries. Throws on
439
+ * any failure (the peer does not speak the protocol, a deadline, a refused frame);
440
+ * the caller logs and moves on, never retrying before its throttle expires.
441
+ */
442
+ export async function exchangeStrandPeerBook(
443
+ connection: Connection,
444
+ request: StrandPeerBookFrame,
445
+ options: ExchangeStrandPeerBookOptions
446
+ ): Promise<SignedStrandPeerEntry[]> {
447
+ const timeoutMs = options.timeoutMs ?? DEFAULT_EXCHANGE_TIMEOUT_MS;
448
+ const remotePeerId = connection.remotePeer.toString();
449
+ assertBookFrameFits(request);
450
+ // One deadline for the stream open and the exchange together: its signal aborts the
451
+ // negotiation and resets a live stream, so neither leaks on a timeout.
452
+ const raw = await withDeadline(timeoutMs, `Strand peer book swap with ${remotePeerId}`, async (signal) => {
453
+ const stream = await connection.newStream(STRAND_PEER_BOOK_PROTOCOL, { runOnLimitedConnection: true, signal });
454
+ return exchangeFrame(
455
+ stream as unknown as ControlStream,
456
+ signal,
457
+ request,
458
+ (s) => readBookFrame(s, timeoutMs),
459
+ 'Strand peer book swap aborted by timeout'
460
+ );
461
+ });
462
+ return verifyStrandPeerBookFrame(request.strandId, raw, options.selfPeerId, (options.now ?? Date.now)());
463
+ }