@serfab/cadre-core 1.7.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.
- package/README.md +8 -6
- package/dist/cadre-node.d.ts +527 -206
- package/dist/cadre-node.js +1084 -528
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +6 -0
- package/dist/control-cohort.js +6 -0
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +46 -8
- package/dist/control-database.js +97 -18
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +37 -14
- package/dist/control-formation-recorder.js +60 -10
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-read-retry.d.ts +13 -12
- package/dist/control-read-retry.js +14 -12
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +67 -5
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +9 -1
- package/dist/control-stream.js +24 -1
- package/dist/control-stream.js.map +1 -1
- package/dist/control-write-retry.d.ts +12 -2
- package/dist/control-write-retry.js +13 -2
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +11 -8
- package/dist/delegate-admission.js +19 -11
- package/dist/delegate-admission.js.map +1 -1
- package/dist/diagnostics/connection-path.d.ts +0 -7
- package/dist/diagnostics/connection-path.js +0 -7
- package/dist/diagnostics/connection-path.js.map +1 -1
- package/dist/formation-approval.d.ts +7 -0
- package/dist/formation-approval.js +9 -3
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +11 -2
- package/dist/fs-atomic.js +13 -3
- package/dist/fs-atomic.js.map +1 -1
- package/dist/hibernation-manager.d.ts +111 -8
- package/dist/hibernation-manager.js +234 -49
- package/dist/hibernation-manager.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.js +25 -20
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +90 -25
- package/dist/joined-strand-store.js +223 -55
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store-file.js +14 -5
- package/dist/key-store-file.js.map +1 -1
- package/dist/link-budget.d.ts +367 -69
- package/dist/link-budget.js +375 -72
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +30 -43
- package/dist/membership-connection-gater.js +31 -42
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +6 -4
- package/dist/node-local-snapshot.js +7 -3
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +24 -4
- package/dist/peer-dial.js +74 -5
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +2 -10
- package/dist/peer-join-backfill.js +8 -8
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/push-fanout.d.ts +6 -0
- package/dist/push-fanout.js +6 -0
- package/dist/push-fanout.js.map +1 -1
- package/dist/relay-reservation.d.ts +3 -1
- package/dist/relay-reservation.js +3 -1
- package/dist/relay-reservation.js.map +1 -1
- package/dist/relay-server.d.ts +79 -0
- package/dist/relay-server.js +74 -0
- package/dist/relay-server.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +57 -4
- package/dist/seed-bootstrap.js +107 -83
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +33 -17
- package/dist/storage-scope.js +44 -23
- package/dist/storage-scope.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +58 -21
- package/dist/strand-addr-protocol.js +133 -85
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-database.d.ts +20 -2
- package/dist/strand-database.js +42 -5
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +59 -21
- package/dist/strand-first-sync-gate.js +58 -19
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-deadlines.d.ts +151 -0
- package/dist/strand-formation-deadlines.js +171 -0
- package/dist/strand-formation-deadlines.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +63 -30
- package/dist/strand-formation-manager.js +67 -41
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +45 -46
- package/dist/strand-formation-protocol.js +62 -126
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-fret-addrs.d.ts +54 -0
- package/dist/strand-fret-addrs.js +108 -0
- package/dist/strand-fret-addrs.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +122 -44
- package/dist/strand-instance-manager.js +270 -114
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +6 -0
- package/dist/strand-membership-writer.js +11 -0
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-state-file.d.ts +12 -0
- package/dist/strand-network-state-file.js +38 -0
- package/dist/strand-network-state-file.js.map +1 -0
- package/dist/strand-network-state.d.ts +111 -0
- package/dist/strand-network-state.js +191 -0
- package/dist/strand-network-state.js.map +1 -0
- package/dist/strand-revocation-enforcer.js +2 -1
- package/dist/strand-revocation-enforcer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +36 -20
- package/dist/strand-solicitation.js +4 -4
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +80 -23
- package/dist/strand-wake-protocol.js +89 -64
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +148 -61
- package/dist/types.js +4 -3
- package/dist/types.js.map +1 -1
- package/package.json +11 -10
- package/src/cadre-node.ts +8400 -7776
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +6 -0
- package/src/control-database.ts +122 -18
- package/src/control-formation-recorder.ts +354 -305
- package/src/control-read-retry.ts +14 -12
- package/src/control-schema.ts +67 -5
- package/src/control-stream.ts +25 -1
- package/src/control-write-retry.ts +13 -2
- package/src/delegate-admission.ts +23 -13
- package/src/diagnostics/connection-path.ts +0 -7
- package/src/formation-approval.ts +610 -604
- package/src/fs-atomic.ts +14 -4
- package/src/hibernation-manager.ts +638 -437
- package/src/index.ts +43 -65
- package/src/joined-strand-store.ts +247 -57
- package/src/key-store-file.ts +14 -5
- package/src/link-budget.ts +393 -74
- package/src/membership-connection-gater.ts +31 -45
- package/src/node-local-snapshot.ts +12 -7
- package/src/peer-dial.ts +81 -7
- package/src/peer-join-backfill.ts +10 -9
- package/src/push-fanout.ts +6 -0
- package/src/relay-reservation.ts +3 -1
- package/src/relay-server.ts +98 -0
- package/src/seed-bootstrap.ts +133 -91
- package/src/storage-scope.ts +44 -23
- package/src/strand-addr-protocol.ts +547 -458
- package/src/strand-database.ts +287 -242
- package/src/strand-first-sync-gate.ts +58 -19
- package/src/strand-formation-deadlines.ts +227 -0
- package/src/strand-formation-manager.ts +700 -647
- package/src/strand-formation-protocol.ts +106 -154
- package/src/strand-fret-addrs.ts +145 -0
- package/src/strand-instance-manager.ts +307 -140
- package/src/strand-membership-writer.ts +12 -0
- package/src/strand-network-state-file.ts +40 -0
- package/src/strand-network-state.ts +243 -0
- package/src/strand-revocation-enforcer.ts +2 -1
- package/src/strand-solicitation.ts +519 -500
- package/src/strand-wake-protocol.ts +401 -355
- package/src/types.ts +150 -61
- package/dist/strand-peer-book-file.d.ts +0 -12
- package/dist/strand-peer-book-file.js +0 -37
- package/dist/strand-peer-book-file.js.map +0 -1
- package/dist/strand-peer-book-protocol.d.ts +0 -159
- package/dist/strand-peer-book-protocol.js +0 -360
- package/dist/strand-peer-book-protocol.js.map +0 -1
- package/dist/strand-peer-book-swap.d.ts +0 -104
- package/dist/strand-peer-book-swap.js +0 -284
- package/dist/strand-peer-book-swap.js.map +0 -1
- package/dist/strand-peer-book.d.ts +0 -159
- package/dist/strand-peer-book.js +0 -354
- package/dist/strand-peer-book.js.map +0 -1
- package/dist/strand-peer-observer.d.ts +0 -88
- package/dist/strand-peer-observer.js +0 -180
- package/dist/strand-peer-observer.js.map +0 -1
- package/src/strand-peer-book-file.ts +0 -39
- package/src/strand-peer-book-protocol.ts +0 -463
- package/src/strand-peer-book-swap.ts +0 -338
- package/src/strand-peer-book.ts +0 -468
- package/src/strand-peer-observer.ts +0 -235
|
@@ -19,16 +19,22 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import debug from 'debug';
|
|
22
|
-
import { multiaddr } from '@multiformats/multiaddr';
|
|
22
|
+
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
23
23
|
import type { Libp2p } from '@libp2p/interface';
|
|
24
24
|
import type { StrandFormationDisclosure, StrandMembershipInvite } from './types.js';
|
|
25
|
-
import { type ControlStream, writeFrame, withTimeout } from './control-stream.js';
|
|
25
|
+
import { type ControlStream, writeFrame, withDeadline, withTimeout } from './control-stream.js';
|
|
26
26
|
import { canonicalJson } from './canonical-json.js';
|
|
27
27
|
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
28
28
|
import { verifyFormationConsent } from './peer-authorization.js';
|
|
29
29
|
// seed-bootstrap imports neither this protocol nor the manager/solicitation layers,
|
|
30
30
|
// so this import introduces no cycle.
|
|
31
31
|
import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
|
|
32
|
+
import {
|
|
33
|
+
formationDeadlines,
|
|
34
|
+
resolveProvisionTimeoutMs,
|
|
35
|
+
responderClampReserveMs,
|
|
36
|
+
splitProvisionBudget
|
|
37
|
+
} from './strand-formation-deadlines.js';
|
|
32
38
|
|
|
33
39
|
const log = debug('sereus:cadre:formation-proto');
|
|
34
40
|
|
|
@@ -47,66 +53,16 @@ export const INVALID_TOKEN_REASON = 'Invalid token';
|
|
|
47
53
|
/** Maximum formation message size (1MB). */
|
|
48
54
|
const MAX_FORMATION_MSG_SIZE = 1024 * 1024;
|
|
49
55
|
|
|
50
|
-
/** Default whole-session timeout (ms). */
|
|
51
|
-
const DEFAULT_SESSION_TIMEOUT_MS = 30_000;
|
|
52
|
-
/** Default per-step (single read/write) timeout (ms). */
|
|
53
|
-
const DEFAULT_STEP_TIMEOUT_MS = 5_000;
|
|
54
|
-
/**
|
|
55
|
-
* Default provisioning budget (ms) — distinct from {@link DEFAULT_STEP_TIMEOUT_MS} because
|
|
56
|
-
* `provisionStrand` is real work (DB writes, and an outbound approval-hook HTTP call when
|
|
57
|
-
* the invite carries a `ValidationUrl`), not a bare wire read/write.
|
|
58
|
-
*
|
|
59
|
-
* Ordering is deliberate — each layer must be able to fail and report before the layer above
|
|
60
|
-
* it gives up:
|
|
61
|
-
*
|
|
62
|
-
* approval hook (10 s, `formation-approval.ts`)
|
|
63
|
-
* < responder provisioning (12 s)
|
|
64
|
-
* < initiator await-response (15 s)
|
|
65
|
-
* < session (30 s)
|
|
66
|
-
*
|
|
67
|
-
* The 3 s margin between responder provisioning and initiator await-response is the wire
|
|
68
|
-
* latency budget for the result frame to travel back.
|
|
69
|
-
*
|
|
70
|
-
* The last {@link PROVISION_SETTLE_GRACE_MS} of this budget is a settle grace, so the default
|
|
71
|
-
* WORK budget (12 s − 2 s = 10 s) EQUALS the hook's own 10 s: a dead hook races 'Formation
|
|
72
|
-
* approval unavailable, retry' against 'Formation provisioning timed out'. Both are retryable
|
|
73
|
-
* and both leave the invite unspent, so the race is benign.
|
|
74
|
-
*/
|
|
75
|
-
const DEFAULT_PROVISION_TIMEOUT_MS = 12_000;
|
|
76
|
-
/**
|
|
77
|
-
* Settle grace (ms), carved OUT of `provisionTimeoutMs` — never added on top, so the budget
|
|
78
|
-
* ladder above is untouched. The listener aborts the provisioning hook when the work budget
|
|
79
|
-
* ({@link splitProvisionBudget}) expires, then waits up to this grace for the work to settle
|
|
80
|
-
* anyway: once the `FormationUsage` insert has been issued nothing can un-spend it (the table
|
|
81
|
-
* is append-only), so a provisioning that lands late is ADOPTED and reported as a real
|
|
82
|
-
* approval rather than lying "timed out" over a spent invite. A hook that observes the
|
|
83
|
-
* abort before writing leaves the invite unspent.
|
|
84
|
-
*/
|
|
85
|
-
const PROVISION_SETTLE_GRACE_MS = 2_000;
|
|
86
|
-
/**
|
|
87
|
-
* Wire-latency margin (ms) added to the responder's provisioning budget to get the
|
|
88
|
-
* initiator's await-response budget — the travel time for the result frame to come back
|
|
89
|
-
* after the responder finishes (see {@link DEFAULT_PROVISION_TIMEOUT_MS}).
|
|
90
|
-
* `StrandFormationManager` reuses this constant so a CONFIGURED budget preserves the same
|
|
91
|
-
* margin the defaults do, instead of applying the same number to both sides and racing the
|
|
92
|
-
* initiator's own timeout against the responder's clean rejection reply. The responder's
|
|
93
|
-
* clamp ({@link provisionCeilingMs}) holds the same margin back, so the ordering survives a
|
|
94
|
-
* budget large enough that BOTH roles get clamped.
|
|
95
|
-
*
|
|
96
|
-
* NOTE: 3 s is a fixed assumption about how long the result frame takes to travel back. If
|
|
97
|
-
* formation ever runs over paths where that frame can take longer (a slow circuit relay, a
|
|
98
|
-
* congested mobile link), the margin is too thin and the initiator's timeout races the
|
|
99
|
-
* responder's reply again — raise it, or derive it from an observed round-trip.
|
|
100
|
-
*/
|
|
101
|
-
export const PROVISION_RESPONSE_TRAVEL_MARGIN_MS = 3_000;
|
|
102
|
-
/** Default initiator await-response budget (ms); see {@link DEFAULT_PROVISION_TIMEOUT_MS}. */
|
|
103
|
-
const DEFAULT_INITIATOR_PROVISION_TIMEOUT_MS = DEFAULT_PROVISION_TIMEOUT_MS + PROVISION_RESPONSE_TRAVEL_MARGIN_MS;
|
|
104
56
|
/**
|
|
105
57
|
* Default cap on concurrent inbound formation sessions.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
58
|
+
*
|
|
59
|
+
* NOTE: a session holds its slot for its whole provisioning budget, 171 s at the default
|
|
60
|
+
* declared link ({@link formationDeadlines}), so a slow hook or commit holds slots for
|
|
61
|
+
* minutes. Only a caller holding a valid token AND a valid consent signature reaches
|
|
62
|
+
* provisioning — a stranger is cut at the contact wait (7 s at the default) or at the first
|
|
63
|
+
* validation read — so the exposure is one invitee opening many sessions with one token. If
|
|
64
|
+
* invitation tokens are ever published where untrusted parties can read them, cap in-flight
|
|
65
|
+
* sessions per token rather than lowering this.
|
|
110
66
|
*/
|
|
111
67
|
const DEFAULT_MAX_CONCURRENT_SESSIONS = 100;
|
|
112
68
|
|
|
@@ -116,11 +72,10 @@ const DEFAULT_MAX_CONCURRENT_SESSIONS = 100;
|
|
|
116
72
|
* handful of entries (one per listen transport, plus a circuit-relay reservation), so
|
|
117
73
|
* 16 is generous for the honest case while keeping the result frame bounded against a
|
|
118
74
|
* peer that would pad it — either direction, since both sides run the list through
|
|
119
|
-
* {@link sanitizeStrandAddrs}.
|
|
120
|
-
*
|
|
121
|
-
* strand, so it caps the accumulation rather than only each arriving list.
|
|
75
|
+
* {@link sanitizeStrandAddrs}. The initiator's kept copy is one arriving list, replaced by
|
|
76
|
+
* the next formation against the same strand, so the same bound holds there.
|
|
122
77
|
*/
|
|
123
|
-
|
|
78
|
+
const MAX_STRAND_ADDRS = 16;
|
|
124
79
|
|
|
125
80
|
// ── Roles ────────────────────────────────────────────────────────────────────
|
|
126
81
|
|
|
@@ -380,62 +335,6 @@ export function isValidResponderCreatesResult(response: FormationResultMessage):
|
|
|
380
335
|
return true;
|
|
381
336
|
}
|
|
382
337
|
|
|
383
|
-
/**
|
|
384
|
-
* Ceiling for a resolved provisioning budget.
|
|
385
|
-
*
|
|
386
|
-
* The session budget also has to cover the wire step that PRECEDES provisioning (the
|
|
387
|
-
* responder's contact read, the initiator's dial-connect), so a whole step is held back
|
|
388
|
-
* rather than triggering only on a literal overrun.
|
|
389
|
-
*
|
|
390
|
-
* `reserveMs` is the EXTRA room only the responder holds back, so its own budget still
|
|
391
|
-
* lands strictly before the initiator's larger await-response budget even when both are
|
|
392
|
-
* clamped — without it, any configured budget at or above `sessionTimeoutMs -
|
|
393
|
-
* stepTimeoutMs` clamps both roles onto the same number and the responder's clean
|
|
394
|
-
* rejection races the initiator's timeout again. Capped at half the remaining room (like
|
|
395
|
-
* {@link splitProvisionBudget}) so a small session config still spends most of it working.
|
|
396
|
-
*/
|
|
397
|
-
function provisionCeilingMs(sessionTimeoutMs: number, stepTimeoutMs: number, reserveMs: number): number {
|
|
398
|
-
const roomMs = sessionTimeoutMs - stepTimeoutMs;
|
|
399
|
-
return Math.max(1, roomMs - Math.min(reserveMs, Math.floor(roomMs / 2)));
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Resolve a caller-supplied provisioning budget: `0`/negative means "unset" (use
|
|
404
|
-
* `defaultMs`). If the result would let provisioning outlive the session — no result
|
|
405
|
-
* frame is ever sent, exactly the failure this budget exists to prevent — clamp it to
|
|
406
|
-
* {@link provisionCeilingMs} and log a warning.
|
|
407
|
-
*/
|
|
408
|
-
function resolveProvisionTimeoutMs(
|
|
409
|
-
configured: number | undefined,
|
|
410
|
-
defaultMs: number,
|
|
411
|
-
sessionTimeoutMs: number,
|
|
412
|
-
stepTimeoutMs: number,
|
|
413
|
-
role: string,
|
|
414
|
-
reserveMs = 0
|
|
415
|
-
): number {
|
|
416
|
-
const requested = configured && configured > 0 ? configured : defaultMs;
|
|
417
|
-
const ceilingMs = provisionCeilingMs(sessionTimeoutMs, stepTimeoutMs, reserveMs);
|
|
418
|
-
if (requested >= ceilingMs) {
|
|
419
|
-
log(
|
|
420
|
-
'%s provisionTimeoutMs %dms leaves no room under sessionTimeoutMs %dms (step %dms, reserve %dms); clamping to %dms',
|
|
421
|
-
role, requested, sessionTimeoutMs, stepTimeoutMs, reserveMs, ceilingMs
|
|
422
|
-
);
|
|
423
|
-
return ceilingMs;
|
|
424
|
-
}
|
|
425
|
-
return requested;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Split a resolved provisioning budget into the WORK budget and the trailing settle grace.
|
|
430
|
-
*
|
|
431
|
-
* The grace is carved OUT of the budget (see {@link PROVISION_SETTLE_GRACE_MS}), and capped at
|
|
432
|
-
* half of it so a small configured budget still spends at least half its time doing work.
|
|
433
|
-
*/
|
|
434
|
-
function splitProvisionBudget(provisionTimeoutMs: number): { workMs: number; graceMs: number } {
|
|
435
|
-
const graceMs = Math.min(PROVISION_SETTLE_GRACE_MS, Math.floor(provisionTimeoutMs / 2));
|
|
436
|
-
return { workMs: provisionTimeoutMs - graceMs, graceMs };
|
|
437
|
-
}
|
|
438
|
-
|
|
439
338
|
// ── Responder (listener) ─────────────────────────────────────────────────────
|
|
440
339
|
|
|
441
340
|
export interface FormationListenerOptions {
|
|
@@ -471,14 +370,22 @@ export interface FormationListenerOptions {
|
|
|
471
370
|
* per-strand address lookup.
|
|
472
371
|
*/
|
|
473
372
|
resolveStrandAddrs?(strandId: string): string[];
|
|
373
|
+
/**
|
|
374
|
+
* This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
|
|
375
|
+
* every deadline below takes its default ({@link formationDeadlines}). The default
|
|
376
|
+
* declaration when omitted.
|
|
377
|
+
*/
|
|
378
|
+
linkRoundTripMs?: number;
|
|
379
|
+
/** Whole-session budget; default `sessionMs` of the derived ladder. */
|
|
474
380
|
sessionTimeoutMs?: number;
|
|
381
|
+
/** Budget for the contact-frame read; default `awaitContactMs` of the derived ladder. */
|
|
475
382
|
stepTimeoutMs?: number;
|
|
476
383
|
/**
|
|
477
|
-
* Budget for the `provisionStrand` hook call — distinct from
|
|
478
|
-
* provisioning is real work, not a bare wire read
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
384
|
+
* Budget for the `provisionStrand` hook call, work plus settle grace — distinct from
|
|
385
|
+
* `stepTimeoutMs` because provisioning is real work, not a bare wire read. Default
|
|
386
|
+
* `provisionWorkMs + provisionGraceMs` of the derived ladder (171 s at the default
|
|
387
|
+
* declaration); `0`/unset uses the default; a value that would outlive the session is
|
|
388
|
+
* clamped ({@link resolveProvisionTimeoutMs}). See {@link formationDeadlines} for the ordering.
|
|
482
389
|
*/
|
|
483
390
|
provisionTimeoutMs?: number;
|
|
484
391
|
maxConcurrentSessions?: number;
|
|
@@ -493,7 +400,7 @@ export interface FormationListenerOptions {
|
|
|
493
400
|
export class FormationListener {
|
|
494
401
|
private readonly options: FormationListenerOptions;
|
|
495
402
|
private readonly sessionTimeoutMs: number;
|
|
496
|
-
private readonly
|
|
403
|
+
private readonly awaitContactMs: number;
|
|
497
404
|
private readonly provisionWorkMs: number;
|
|
498
405
|
private readonly provisionGraceMs: number;
|
|
499
406
|
private readonly maxConcurrentSessions: number;
|
|
@@ -503,13 +410,14 @@ export class FormationListener {
|
|
|
503
410
|
|
|
504
411
|
constructor(options: FormationListenerOptions) {
|
|
505
412
|
this.options = options;
|
|
506
|
-
|
|
507
|
-
this.
|
|
413
|
+
const deadlines = formationDeadlines(options.linkRoundTripMs);
|
|
414
|
+
this.sessionTimeoutMs = options.sessionTimeoutMs ?? deadlines.sessionMs;
|
|
415
|
+
this.awaitContactMs = options.stepTimeoutMs ?? deadlines.awaitContactMs;
|
|
508
416
|
const split = splitProvisionBudget(resolveProvisionTimeoutMs(
|
|
509
|
-
options.provisionTimeoutMs,
|
|
510
|
-
this.sessionTimeoutMs, this.
|
|
511
|
-
|
|
512
|
-
));
|
|
417
|
+
options.provisionTimeoutMs, deadlines.provisionWorkMs + deadlines.provisionGraceMs,
|
|
418
|
+
this.sessionTimeoutMs, this.awaitContactMs + deadlines.validationMs, 'FormationListener',
|
|
419
|
+
responderClampReserveMs(deadlines, this.awaitContactMs)
|
|
420
|
+
), deadlines.provisionGraceMs);
|
|
513
421
|
this.provisionWorkMs = split.workMs;
|
|
514
422
|
this.provisionGraceMs = split.graceMs;
|
|
515
423
|
this.maxConcurrentSessions = options.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS;
|
|
@@ -520,21 +428,22 @@ export class FormationListener {
|
|
|
520
428
|
return this.activeSessions;
|
|
521
429
|
}
|
|
522
430
|
|
|
523
|
-
|
|
431
|
+
/** Register the formation handler on `node`. Rejects when libp2p refuses the registration. */
|
|
432
|
+
async register(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): Promise<void> {
|
|
524
433
|
if (this.registered.has(node)) {
|
|
525
434
|
log('node already registered');
|
|
526
435
|
return;
|
|
527
436
|
}
|
|
528
|
-
|
|
437
|
+
await node.handle(protocolId, async (rawStream: unknown, _connection: unknown) => {
|
|
529
438
|
await this.handleStream(rawStream as ControlStream);
|
|
530
439
|
});
|
|
531
440
|
this.registered.add(node);
|
|
532
441
|
log('formation listener registered (%s)', protocolId);
|
|
533
442
|
}
|
|
534
443
|
|
|
535
|
-
unregister(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): void {
|
|
444
|
+
async unregister(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): Promise<void> {
|
|
536
445
|
if (!this.registered.has(node)) return;
|
|
537
|
-
|
|
446
|
+
await node.unhandle(protocolId);
|
|
538
447
|
this.registered.delete(node);
|
|
539
448
|
log('formation listener unregistered (%s)', protocolId);
|
|
540
449
|
}
|
|
@@ -559,8 +468,8 @@ export class FormationListener {
|
|
|
559
468
|
}
|
|
560
469
|
|
|
561
470
|
/**
|
|
562
|
-
* Run the provisioning hook under its WORK budget — the
|
|
563
|
-
*
|
|
471
|
+
* Run the provisioning hook under its WORK budget — the provisioning budget minus the
|
|
472
|
+
* trailing settle grace ({@link splitProvisionBudget}).
|
|
564
473
|
*
|
|
565
474
|
* When the work budget expires the hook's signal is aborted, then
|
|
566
475
|
* {@link settleWithinGrace} waits out the grace for the aborted work to settle anyway.
|
|
@@ -600,11 +509,16 @@ export class FormationListener {
|
|
|
600
509
|
* inside the grace its outcome is ADOPTED — the joiner is told the truth (approved, or the
|
|
601
510
|
* hook's own rejection reason) rather than "timed out" over a spent invite.
|
|
602
511
|
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
512
|
+
* The grace is derived to contain one seat read plus one commit at the declared link
|
|
513
|
+
* ({@link formationDeadlines}), which is exactly what runs after the insert attempt passes
|
|
514
|
+
* its abort check, so at that link the late outcome lands inside it.
|
|
515
|
+
*
|
|
516
|
+
* NOTE: a commit that outlasts even the derived grace (a link slower than declared, or a
|
|
517
|
+
* configured `provisionTimeoutMs` whose half-cap shrank the grace) still tells the joiner
|
|
518
|
+
* 'timed out' while its one-time invite is in fact spent, and since every retry mints a
|
|
519
|
+
* fresh keypair no recovery path can match it. Nothing can un-spend an append-only row, so
|
|
520
|
+
* the late-settle logging below is the observability for it; if it is seen in the wild,
|
|
521
|
+
* raise the declared link rather than this grace.
|
|
608
522
|
*/
|
|
609
523
|
private async settleWithinGrace(
|
|
610
524
|
id: number,
|
|
@@ -706,7 +620,7 @@ export class FormationListener {
|
|
|
706
620
|
|
|
707
621
|
try {
|
|
708
622
|
const reader = new FrameReader(stream);
|
|
709
|
-
const contact = await withTimeout(this.
|
|
623
|
+
const contact = await withTimeout(this.awaitContactMs, `Formation await-contact#${id}`, () => reader.read<FormationContactMessage>());
|
|
710
624
|
log('formation session #%d contact: token=%s party=%s', id, contact.token, contact.partyId);
|
|
711
625
|
|
|
712
626
|
if (!this.isJoinerConsentValid(id, contact)) {
|
|
@@ -777,12 +691,24 @@ export interface FormationDialOptions {
|
|
|
777
691
|
responderAddrs: string[];
|
|
778
692
|
/** Validate the responder's result; a false return aborts the formation. */
|
|
779
693
|
validateResponse(response: FormationResultMessage): Promise<boolean>;
|
|
694
|
+
/**
|
|
695
|
+
* This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
|
|
696
|
+
* every deadline below takes its default ({@link formationDeadlines}). The default
|
|
697
|
+
* declaration when omitted.
|
|
698
|
+
*/
|
|
699
|
+
linkRoundTripMs?: number;
|
|
700
|
+
/** Whole-session budget; default `sessionMs` of the derived ladder. */
|
|
780
701
|
sessionTimeoutMs?: number;
|
|
781
|
-
stepTimeoutMs?: number;
|
|
782
702
|
/**
|
|
783
|
-
* Budget for
|
|
784
|
-
*
|
|
785
|
-
|
|
703
|
+
* Budget for `dial-connect`: opening the connection, through a relay if need be, and
|
|
704
|
+
* negotiating the formation protocol. Default `dialMs` of the derived ladder.
|
|
705
|
+
*/
|
|
706
|
+
dialTimeoutMs?: number;
|
|
707
|
+
/**
|
|
708
|
+
* Budget for the `await-response` read only (the responder's provisioning plus the reply's
|
|
709
|
+
* travel time). Default `initiatorAwaitResponseMs` of the derived ladder; `0`/unset uses
|
|
710
|
+
* the default; a value that would outlive the session is clamped. See
|
|
711
|
+
* {@link formationDeadlines} for the ordering.
|
|
786
712
|
*/
|
|
787
713
|
provisionTimeoutMs?: number;
|
|
788
714
|
protocolId?: string;
|
|
@@ -806,6 +732,32 @@ export interface FormationDialResult {
|
|
|
806
732
|
strandAddrs: string[];
|
|
807
733
|
}
|
|
808
734
|
|
|
735
|
+
/**
|
|
736
|
+
* Open the formation stream under the dial deadline, cancelling the dial when it expires.
|
|
737
|
+
*
|
|
738
|
+
* The deadline's signal goes into `dialProtocol`, so an expired dial is abandoned by libp2p
|
|
739
|
+
* rather than left running. A dial that settles in the same tick the deadline fires loses
|
|
740
|
+
* the race to the timeout rejection and would leave its stream open with nobody to close
|
|
741
|
+
* it, so a stream that arrives after the signal aborted is reset here — the same treatment
|
|
742
|
+
* `exchangeFrame` (`control-stream.ts`) gives a stream dialed past its deadline. A late
|
|
743
|
+
* rejection is the abort itself, or reaches the caller through the awaited copy, so the
|
|
744
|
+
* handler below only stops it being reported as unhandled.
|
|
745
|
+
*/
|
|
746
|
+
function openFormationStream(node: Libp2p, addr: Multiaddr, protocolId: string, dialTimeoutMs: number): Promise<ControlStream> {
|
|
747
|
+
return withDeadline(dialTimeoutMs, 'Formation dial-connect', (signal) => {
|
|
748
|
+
const pending = node.dialProtocol(addr, protocolId, { signal });
|
|
749
|
+
void pending.then(
|
|
750
|
+
(stream) => {
|
|
751
|
+
if (signal.aborted) {
|
|
752
|
+
(stream as unknown as ControlStream).abort(new Error('Formation dial-connect resolved after its deadline'));
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
() => { /* surfaced through the awaited copy, or the abort itself */ }
|
|
756
|
+
);
|
|
757
|
+
return pending as unknown as Promise<ControlStream>;
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
809
761
|
/**
|
|
810
762
|
* Initiator side of the native formation protocol. Dials the responder, sends the
|
|
811
763
|
* contact (carrying the real disclosure/token/cadre), validates the responder's
|
|
@@ -817,17 +769,17 @@ export async function dialFormation(node: Libp2p, options: FormationDialOptions)
|
|
|
817
769
|
throw new Error('No responder addresses available for formation');
|
|
818
770
|
}
|
|
819
771
|
const protocolId = options.protocolId ?? FORMATION_PROTOCOL;
|
|
820
|
-
const
|
|
821
|
-
const
|
|
772
|
+
const deadlines = formationDeadlines(options.linkRoundTripMs);
|
|
773
|
+
const sessionTimeoutMs = options.sessionTimeoutMs ?? deadlines.sessionMs;
|
|
774
|
+
const dialTimeoutMs = options.dialTimeoutMs ?? deadlines.dialMs;
|
|
822
775
|
const provisionTimeoutMs = resolveProvisionTimeoutMs(
|
|
823
|
-
options.provisionTimeoutMs,
|
|
824
|
-
sessionTimeoutMs,
|
|
776
|
+
options.provisionTimeoutMs, deadlines.initiatorAwaitResponseMs,
|
|
777
|
+
sessionTimeoutMs, dialTimeoutMs, 'dialFormation'
|
|
825
778
|
);
|
|
826
779
|
const addr = multiaddr(options.responderAddrs[0]);
|
|
827
780
|
|
|
828
781
|
return withTimeout(sessionTimeoutMs, 'Formation dial', async () => {
|
|
829
|
-
const
|
|
830
|
-
const stream = rawStream as unknown as ControlStream;
|
|
782
|
+
const stream = await openFormationStream(node, addr, protocolId, dialTimeoutMs);
|
|
831
783
|
try {
|
|
832
784
|
const reader = new FrameReader(stream);
|
|
833
785
|
writeFrame(stream, options.contact);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the signed address records a running strand node's FRET routing table holds,
|
|
3
|
+
* as per-peer addresses ready for that node's own address book (`peer-addr-book.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Why a strand node needs this. libp2p's peerStore hides every address one hour after
|
|
6
|
+
* it was first observed (the `NOTE:` on `mergePeerAddrs`), and FRET does not re-apply
|
|
7
|
+
* the records it holds: it hands a record to the peerStore when a neighbour's snapshot
|
|
8
|
+
* delivers it or a saved table is imported, and the peerStore refuses a record whose
|
|
9
|
+
* sequence number it already has. So a strand node that has been up for more than an
|
|
10
|
+
* hour would have no address for a peer whose connection then drops, although FRET
|
|
11
|
+
* still holds that peer's record. `CadreNode.refreshStrandPeerAddrs` re-merges the
|
|
12
|
+
* records on every pass, which is what keeps another party's strand nodes dialable;
|
|
13
|
+
* own-party siblings are re-resolved separately, over the strand-addr RPC.
|
|
14
|
+
*
|
|
15
|
+
* How long a record is available here is FRET's rule, not this module's: while FRET
|
|
16
|
+
* holds the entry, and at most 14 days after the record was last confirmed (a peer
|
|
17
|
+
* connected at export time counts as confirmed then).
|
|
18
|
+
*
|
|
19
|
+
* Every record is opened and verified here rather than taken on FRET's word. A record
|
|
20
|
+
* is self-certifying, the table is reached through a structural type rather than an
|
|
21
|
+
* interface this package depends on, and the address book write is attributed from what
|
|
22
|
+
* the record proves, not from the id the table filed it under.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import debug from 'debug';
|
|
26
|
+
import { PeerRecord, RecordEnvelope } from '@libp2p/peer-record';
|
|
27
|
+
import { peerIdFromPublicKey } from '@libp2p/peer-id';
|
|
28
|
+
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
29
|
+
import { fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
30
|
+
import { withTrailingPeerId } from './peer-record.js';
|
|
31
|
+
|
|
32
|
+
const log = debug('sereus:cadre:strand-fret-addrs');
|
|
33
|
+
|
|
34
|
+
/** The slice of FRET's exported routing table read here (`SerializedTable` in `p2p-fret`). */
|
|
35
|
+
interface FretTableSlice {
|
|
36
|
+
entries: ReadonlyArray<{
|
|
37
|
+
id: string;
|
|
38
|
+
/** `envelope` is the base64url of a marshaled libp2p record envelope. */
|
|
39
|
+
addressRecord?: { envelope: string };
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The slice of FRET's libp2p service called here. */
|
|
44
|
+
interface FretTableSource {
|
|
45
|
+
exportTable(): FretTableSlice;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The slice of a strand's libp2p node this module touches. Structurally satisfied by
|
|
50
|
+
* `Libp2p`; db-p2p registers FRET as `services.fret`.
|
|
51
|
+
*/
|
|
52
|
+
export interface FretAddrHost {
|
|
53
|
+
peerId: { toString(): string };
|
|
54
|
+
services?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What one read of a strand node's FRET table yields. */
|
|
58
|
+
export interface FretPeerAddrs {
|
|
59
|
+
/** Peer id → the addresses its record lists, each bound to that peer. Table order. */
|
|
60
|
+
peers: Map<string, Multiaddr[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Records left out because they did not decode, did not verify, or were not signed
|
|
63
|
+
* by and about the peer the table filed them under.
|
|
64
|
+
*/
|
|
65
|
+
rejected: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The addresses of every peer `host`'s FRET table holds a usable signed record for,
|
|
70
|
+
* `host` itself excluded. A node with no FRET service (a db-p2p build without it, a
|
|
71
|
+
* test double) reads as an empty table. One bad record never costs the others: it is
|
|
72
|
+
* counted in `rejected` and skipped.
|
|
73
|
+
*
|
|
74
|
+
* Throws only what `exportTable()` throws, which a caller treats like any other
|
|
75
|
+
* failure of a pass over a node that may be mid-teardown.
|
|
76
|
+
*/
|
|
77
|
+
export async function strandFretPeerAddrs(host: FretAddrHost): Promise<FretPeerAddrs> {
|
|
78
|
+
const result: FretPeerAddrs = { peers: new Map(), rejected: 0 };
|
|
79
|
+
const fret = host.services?.fret;
|
|
80
|
+
if (!isFretTableSource(fret)) {
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
const selfId = host.peerId.toString();
|
|
84
|
+
for (const entry of fret.exportTable().entries) {
|
|
85
|
+
if (entry.id === selfId || entry.addressRecord === undefined) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const addrs = await verifiedRecordAddrs(entry.id, entry.addressRecord.envelope);
|
|
89
|
+
if (addrs === null) {
|
|
90
|
+
result.rejected++;
|
|
91
|
+
} else if (addrs.length > 0) {
|
|
92
|
+
result.peers.set(entry.id, addrs);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isFretTableSource(service: unknown): service is FretTableSource {
|
|
99
|
+
return typeof service === 'object' && service !== null
|
|
100
|
+
&& typeof (service as { exportTable?: unknown }).exportTable === 'function';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The addresses one record lists, bound to `peerId`; null when the record must not be
|
|
105
|
+
* used. `openAndCertify` proves only that the envelope's own key signed it, so the
|
|
106
|
+
* signer and the peer the payload describes are each held against `peerId` here:
|
|
107
|
+
* without that, any peer could sign a record rewriting another peer's addresses.
|
|
108
|
+
*/
|
|
109
|
+
async function verifiedRecordAddrs(peerId: string, envelope: string): Promise<Multiaddr[] | null> {
|
|
110
|
+
try {
|
|
111
|
+
const opened = await RecordEnvelope.openAndCertify(uint8ArrayFromString(envelope, 'base64url'), PeerRecord.DOMAIN);
|
|
112
|
+
const record = PeerRecord.createFromProtobuf(opened.payload);
|
|
113
|
+
const signer = peerIdFromPublicKey(opened.publicKey).toString();
|
|
114
|
+
const subject = record.peerId.toString();
|
|
115
|
+
if (signer !== peerId || subject !== peerId) {
|
|
116
|
+
log('skipping the address record filed under %s: signed by %s about %s', peerId, signer, subject);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return bindToPeer(record.multiaddrs.map((addr) => addr.toString()), peerId);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
log('skipping the address record filed under %s: %o', peerId, error);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A record names its peer once, in `peerId`, so its addresses carry no trailing
|
|
128
|
+
* `/p2p/<peerId>` and a relay-only peer's circuit address ends at the relay hop
|
|
129
|
+
* (`…/p2p/<relay>/p2p-circuit`). Each is given the suffix that makes it a complete
|
|
130
|
+
* dial target; one already terminating in a different peer id does not reach this peer
|
|
131
|
+
* and is dropped.
|
|
132
|
+
*
|
|
133
|
+
* Takes strings because `@libp2p/peer-record` carries its own `@multiformats/multiaddr`
|
|
134
|
+
* copy, a structurally different type from the one `withTrailingPeerId` takes.
|
|
135
|
+
*/
|
|
136
|
+
function bindToPeer(addrs: string[], peerId: string): Multiaddr[] {
|
|
137
|
+
const bound: Multiaddr[] = [];
|
|
138
|
+
for (const addr of addrs) {
|
|
139
|
+
const withPeer = withTrailingPeerId(multiaddr(addr), peerId);
|
|
140
|
+
if (withPeer !== null) {
|
|
141
|
+
bound.push(withPeer);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return bound;
|
|
145
|
+
}
|