@serfab/cadre-core 1.6.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 (51) hide show
  1. package/dist/cadre-node.d.ts +204 -65
  2. package/dist/cadre-node.js +392 -95
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/index.d.ts +5 -0
  5. package/dist/index.js +16 -0
  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/node-local-snapshot.d.ts +5 -4
  13. package/dist/node-local-snapshot.js +6 -3
  14. package/dist/node-local-snapshot.js.map +1 -1
  15. package/dist/peer-join-backfill.d.ts +33 -14
  16. package/dist/peer-join-backfill.js +109 -50
  17. package/dist/peer-join-backfill.js.map +1 -1
  18. package/dist/strand-instance-manager.d.ts +39 -0
  19. package/dist/strand-instance-manager.js +74 -1
  20. package/dist/strand-instance-manager.js.map +1 -1
  21. package/dist/strand-peer-book-file.d.ts +12 -0
  22. package/dist/strand-peer-book-file.js +37 -0
  23. package/dist/strand-peer-book-file.js.map +1 -0
  24. package/dist/strand-peer-book-protocol.d.ts +159 -0
  25. package/dist/strand-peer-book-protocol.js +360 -0
  26. package/dist/strand-peer-book-protocol.js.map +1 -0
  27. package/dist/strand-peer-book-swap.d.ts +104 -0
  28. package/dist/strand-peer-book-swap.js +284 -0
  29. package/dist/strand-peer-book-swap.js.map +1 -0
  30. package/dist/strand-peer-book.d.ts +159 -0
  31. package/dist/strand-peer-book.js +354 -0
  32. package/dist/strand-peer-book.js.map +1 -0
  33. package/dist/strand-peer-observer.d.ts +88 -0
  34. package/dist/strand-peer-observer.js +180 -0
  35. package/dist/strand-peer-observer.js.map +1 -0
  36. package/dist/types.d.ts +58 -0
  37. package/dist/types.js.map +1 -1
  38. package/package.json +6 -2
  39. package/src/cadre-node.ts +7776 -7449
  40. package/src/index.ts +67 -0
  41. package/src/joined-strand-store.ts +308 -0
  42. package/src/key-store.ts +6 -0
  43. package/src/node-local-snapshot.ts +293 -289
  44. package/src/peer-join-backfill.ts +112 -51
  45. package/src/strand-instance-manager.ts +100 -1
  46. package/src/strand-peer-book-file.ts +39 -0
  47. package/src/strand-peer-book-protocol.ts +463 -0
  48. package/src/strand-peer-book-swap.ts +338 -0
  49. package/src/strand-peer-book.ts +468 -0
  50. package/src/strand-peer-observer.ts +235 -0
  51. package/src/types.ts +60 -0
@@ -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
+ }