@serfab/cadre-core 1.9.0 → 1.11.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/dist/cadre-node.d.ts +148 -44
- package/dist/cadre-node.js +363 -139
- 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 +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-database.d.ts +105 -14
- package/dist/control-database.js +242 -73
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +6 -6
- package/dist/control-formation-recorder.js +7 -8
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +1066 -979
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +21 -15
- package/dist/control-write-retry.js +21 -15
- package/dist/control-write-retry.js.map +1 -1
- package/dist/diagnostics/connection-path.d.ts +1 -18
- package/dist/diagnostics/connection-path.js +1 -20
- package/dist/diagnostics/connection-path.js.map +1 -1
- package/dist/identity-key.d.ts +0 -30
- package/dist/identity-key.js +7 -40
- package/dist/identity-key.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +32 -0
- package/dist/invitation-bootstrap.js +30 -0
- package/dist/invitation-bootstrap.js.map +1 -0
- package/dist/link-budget.d.ts +204 -119
- package/dist/link-budget.js +208 -120
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +3 -3
- package/dist/membership-connection-gater.js +3 -3
- package/dist/peer-dial.d.ts +18 -14
- package/dist/peer-dial.js +18 -14
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +3 -3
- package/dist/pending-join-runner.d.ts +186 -0
- package/dist/pending-join-runner.js +649 -0
- package/dist/pending-join-runner.js.map +1 -0
- package/dist/push-fanout.d.ts +1 -1
- package/dist/push-fanout.js +1 -1
- package/dist/relay-stun.d.ts +24 -0
- package/dist/relay-stun.js +51 -0
- package/dist/relay-stun.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +2 -2
- package/dist/seed-bootstrap.js +5 -5
- package/dist/strand-addr-protocol.js +2 -2
- package/dist/strand-formation-deadlines.d.ts +5 -5
- package/dist/strand-formation-deadlines.js +5 -5
- package/dist/strand-formation-manager.d.ts +18 -4
- package/dist/strand-formation-manager.js +48 -33
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +71 -14
- package/dist/strand-formation-protocol.js +222 -47
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-formation-rejection.d.ts +94 -0
- package/dist/strand-formation-rejection.js +77 -0
- package/dist/strand-formation-rejection.js.map +1 -0
- package/dist/strand-instance-manager.js +4 -1
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.js +32 -32
- package/dist/strand-solicitation.d.ts +11 -3
- package/dist/strand-solicitation.js +9 -0
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/types.d.ts +103 -17
- package/dist/types.js +5 -9
- package/dist/types.js.map +1 -1
- package/package.json +22 -22
- package/src/cadre-node.ts +8654 -8400
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +128 -128
- package/src/control-database.ts +303 -82
- package/src/control-formation-recorder.ts +353 -354
- package/src/control-read-retry.ts +216 -216
- package/src/control-schema.ts +1078 -991
- package/src/control-write-retry.ts +567 -561
- package/src/diagnostics/connection-path.ts +1 -29
- package/src/formation-approval.ts +610 -610
- package/src/hibernation-manager.ts +638 -638
- package/src/identity-key.ts +7 -62
- package/src/index.ts +644 -627
- package/src/invitation-bootstrap.ts +40 -0
- package/src/joined-strand-store.ts +498 -498
- package/src/link-budget.ts +684 -583
- package/src/membership-connection-gater.ts +570 -570
- package/src/node-local-snapshot.ts +298 -298
- package/src/peer-dial.ts +315 -311
- package/src/peer-join-backfill.ts +710 -710
- package/src/pending-join-runner.ts +735 -0
- package/src/push-fanout.ts +1 -1
- package/src/relay-reservation.ts +1121 -1121
- package/src/relay-server.ts +98 -98
- package/src/relay-stun.ts +58 -0
- package/src/seed-bootstrap.ts +1448 -1448
- package/src/strand-addr-protocol.ts +547 -547
- package/src/strand-database.ts +287 -287
- package/src/strand-first-sync-gate.ts +391 -391
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +725 -700
- package/src/strand-formation-protocol.ts +1036 -802
- package/src/strand-formation-rejection.ts +129 -0
- package/src/strand-fret-addrs.ts +145 -145
- package/src/strand-instance-manager.ts +1789 -1786
- package/src/strand-membership-writer.ts +1974 -1974
- package/src/strand-network-state-file.ts +40 -40
- package/src/strand-network-state.ts +243 -243
- package/src/strand-revocation-enforcer.ts +654 -654
- package/src/strand-solicitation.ts +532 -519
- package/src/strand-wake-protocol.ts +401 -401
- package/src/types.ts +2222 -2140
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
- package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
- package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
- package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
package/dist/cadre-node.js
CHANGED
|
@@ -32,12 +32,16 @@ import { PeerJoinBackfill } from './peer-join-backfill.js';
|
|
|
32
32
|
import { deriveCohortMembers } from './strand-cohort.js';
|
|
33
33
|
import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
|
|
34
34
|
import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
|
|
35
|
-
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
35
|
+
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayAdmissionReserveDeadlineMs, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
36
36
|
import { EnrollmentService } from './enrollment.js';
|
|
37
37
|
import { HibernationManager } from './hibernation-manager.js';
|
|
38
|
-
import { ControlDatabase, isStrandIdConflict } from './control-database.js';
|
|
38
|
+
import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId } from './control-database.js';
|
|
39
|
+
import { FormationPostApprovalError } from './strand-formation-rejection.js';
|
|
40
|
+
import { PendingJoinRunner, membershipInvitesToStage, parseStoredDisclosure, requestedPendingJoin } from './pending-join-runner.js';
|
|
39
41
|
import { SeedBootstrapService } from './seed-bootstrap.js';
|
|
40
42
|
import { StrandSolicitationService } from './strand-solicitation.js';
|
|
43
|
+
import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
|
|
44
|
+
import { selectInvitationSiblingAddrs } from './invitation-bootstrap.js';
|
|
41
45
|
import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS, UnauthorizedReservationBudget } from './membership-connection-gater.js';
|
|
42
46
|
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
43
47
|
import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
|
|
@@ -47,17 +51,10 @@ import { resolveRelayServer } from './relay-server.js';
|
|
|
47
51
|
import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
|
|
48
52
|
import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
|
|
49
53
|
import { PushFanoutService } from './push-fanout.js';
|
|
50
|
-
import { summarizeConnectionPaths
|
|
51
|
-
import { TurnRelayTracker } from './diagnostics/webrtc-turn-tracker.js';
|
|
54
|
+
import { summarizeConnectionPaths } from './diagnostics/connection-path.js';
|
|
52
55
|
import { timedStep } from './timed-step.js';
|
|
53
56
|
const log = debug('sereus:cadre:node');
|
|
54
57
|
const timing = debug('sereus:cadre:timing');
|
|
55
|
-
/**
|
|
56
|
-
* Window (ms) within which a queued TURN-relay settlement is correlated to a
|
|
57
|
-
* `connection:open`. Generous relative to the tight async gap between an
|
|
58
|
-
* `RTCPeerConnection` reaching `connected` and libp2p surfacing the connection.
|
|
59
|
-
*/
|
|
60
|
-
const TURN_CONSUME_WINDOW_MS = 1000;
|
|
61
58
|
/**
|
|
62
59
|
* How often a running strand re-asks each connected sibling that last ANSWERED
|
|
63
60
|
* (even with nothing) for its strand-network addresses, and re-merges them into
|
|
@@ -250,6 +247,15 @@ export class CadreNode {
|
|
|
250
247
|
this.strandWatcher = null;
|
|
251
248
|
this.seedBootstrapService = null;
|
|
252
249
|
this.strandSolicitationService = null;
|
|
250
|
+
/**
|
|
251
|
+
* The service whose formation handler is registered on the control node. Trails
|
|
252
|
+
* {@link strandSolicitationService} while {@link initializeStrandSolicitation} swaps a new
|
|
253
|
+
* one in: the field is set at once so concurrent callers reuse it, the handler only when
|
|
254
|
+
* its queued swap runs.
|
|
255
|
+
*/
|
|
256
|
+
this.registeredSolicitation = null;
|
|
257
|
+
/** Tail of the queued formation-handler swaps ({@link swapFormationResponder}). */
|
|
258
|
+
this.solicitationSwaps = Promise.resolve();
|
|
253
259
|
this.strandWakeService = null;
|
|
254
260
|
/**
|
|
255
261
|
* Control-network strand-address responder. Answers a co-cadre sibling's
|
|
@@ -517,6 +523,20 @@ export class CadreNode {
|
|
|
517
523
|
* `unpublishStrand` / `forgetJoinedStrand` as {@link formationStrandAddrs} is.
|
|
518
524
|
*/
|
|
519
525
|
this.pendingMembershipInvites = new Map();
|
|
526
|
+
/**
|
|
527
|
+
* Invite keys of the membership invitations this process has staged, by
|
|
528
|
+
* {@link adoptFormationMembershipInvite} or from a `joined` `PendingJoin` row
|
|
529
|
+
* ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
|
|
530
|
+
* staged again by the next pending-join pass. Same lifetime as
|
|
531
|
+
* {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
|
|
532
|
+
* reconciler settles it again (spent, or the party already seated).
|
|
533
|
+
*/
|
|
534
|
+
this.stagedMembershipInviteKeys = new Set();
|
|
535
|
+
/**
|
|
536
|
+
* The pending-join retry loop (`pending-join-runner.ts`): built by {@link start}, stopped by
|
|
537
|
+
* {@link cleanup}. It acts only while this machine is an enrolled owner.
|
|
538
|
+
*/
|
|
539
|
+
this.pendingJoinRunner = null;
|
|
520
540
|
/**
|
|
521
541
|
* Founder launches refused because the strand was founded before the per-party
|
|
522
542
|
* identity split (`PreSplitStrandIdentityError`), keyed by strandId. Read by
|
|
@@ -707,24 +727,6 @@ export class CadreNode {
|
|
|
707
727
|
this.controlConnectionOpenHandler = null;
|
|
708
728
|
/** `connection:close` listener re-arming the growth edge (teardown in {@link stopRecordRefresh}). */
|
|
709
729
|
this.controlConnectionCloseHandler = null;
|
|
710
|
-
// ── WebRTC TURN-relay detection (turn-relayed-path-metrics) ─────────────────
|
|
711
|
-
/**
|
|
712
|
-
* Hooks `globalThis.RTCPeerConnection` to observe whether ICE selected a TURN
|
|
713
|
-
* relay candidate for each WebRTC session. Installed in {@link start}, disposed
|
|
714
|
-
* in {@link cleanup}; inert on Node.js (no `RTCPeerConnection`).
|
|
715
|
-
*/
|
|
716
|
-
this.turnTracker = new TurnRelayTracker();
|
|
717
|
-
/**
|
|
718
|
-
* Peer IDs whose current WebRTC connection was observed to be TURN-relayed.
|
|
719
|
-
* Populated by {@link handleTurnConnectionOpen} (drains the tracker queue on a
|
|
720
|
-
* `/webrtc` `connection:open`), cleared per-peer on `connection:close`. Read by
|
|
721
|
-
* {@link getConnectionPaths} to promote `webrtc` → `webrtc-turn` (relayed).
|
|
722
|
-
*/
|
|
723
|
-
this.turnRelayedPeers = new Set();
|
|
724
|
-
/** `connection:open` listener feeding TURN detection (teardown in {@link stopRecordRefresh}). */
|
|
725
|
-
this.turnConnectionOpenHandler = null;
|
|
726
|
-
/** `connection:close` listener clearing a peer's TURN flag (teardown in {@link stopRecordRefresh}). */
|
|
727
|
-
this.turnConnectionCloseHandler = null;
|
|
728
730
|
this.config = config;
|
|
729
731
|
this.hostUnclaimedStrands = config.hostUnclaimedStrands ?? config.profile === 'storage';
|
|
730
732
|
this.strandManager = new StrandInstanceManager();
|
|
@@ -804,18 +806,7 @@ export class CadreNode {
|
|
|
804
806
|
* direct sibling is considered stuck (default 10_000ms)
|
|
805
807
|
*/
|
|
806
808
|
getConnectionPaths(settleWindowMs) {
|
|
807
|
-
|
|
808
|
-
// Annotate each connection with the TURN-relay hint so a WebRTC session whose
|
|
809
|
-
// ICE selected a TURN candidate classifies as relayed/webrtc-turn rather than
|
|
810
|
-
// direct/webrtc (the multiaddr alone cannot reveal it).
|
|
811
|
-
const annotated = conns.map((c) => ({
|
|
812
|
-
remotePeer: c.remotePeer,
|
|
813
|
-
remoteAddr: c.remoteAddr,
|
|
814
|
-
direction: c.direction,
|
|
815
|
-
timeline: c.timeline,
|
|
816
|
-
turnRelayed: this.turnRelayedPeers.has(c.remotePeer.toString()),
|
|
817
|
-
}));
|
|
818
|
-
return summarizeConnectionPaths(annotated, settleWindowMs);
|
|
809
|
+
return summarizeConnectionPaths(this.controlNode?.getConnections(), settleWindowMs);
|
|
819
810
|
}
|
|
820
811
|
/**
|
|
821
812
|
* Get all strand instances
|
|
@@ -875,10 +866,6 @@ export class CadreNode {
|
|
|
875
866
|
// yet), so a node with a zero or NaN declaration would otherwise boot and fail later
|
|
876
867
|
// inside a best-effort path that logs and carries on. `link-budget.ts`.
|
|
877
868
|
resolveLinkRoundTripMs(this.config.network?.linkRoundTripMs);
|
|
878
|
-
// Install the WebRTC TURN-relay tracker BEFORE any libp2p bring-up, so it
|
|
879
|
-
// wraps globalThis.RTCPeerConnection before the control node can create one.
|
|
880
|
-
// Inert on Node.js (no RTCPeerConnection); disposed in cleanup().
|
|
881
|
-
this.turnTracker.install();
|
|
882
869
|
// Resolve the node identity (keyStore | privateKey | ephemeral) BEFORE any
|
|
883
870
|
// libp2p/network bring-up, so a misconfiguration or an access-denied secure
|
|
884
871
|
// store fails closed before a node is created.
|
|
@@ -1010,6 +997,11 @@ export class CadreNode {
|
|
|
1010
997
|
onDelegateAnnounce: (announcer, strandId, delegate) => this.grantDelegateAdmission(announcer, strandId, delegate),
|
|
1011
998
|
});
|
|
1012
999
|
await this.strandAddrService.initialize(this.controlNode);
|
|
1000
|
+
// Answer strand formation for this party from now on. Every machine of a party
|
|
1001
|
+
// holds the replicated FormationInvite/FormationUsage rows, so an always-on one can
|
|
1002
|
+
// check a token for an inviter that is offline. After the control database is up,
|
|
1003
|
+
// so the responder never reads a database that cannot answer yet.
|
|
1004
|
+
await this.installDefaultFormationResponder();
|
|
1013
1005
|
// Server-side push-wake fan-out: only when push is configured.
|
|
1014
1006
|
if (this.config.push) {
|
|
1015
1007
|
this.pushFanoutService = this.buildPushFanout(this.config.push);
|
|
@@ -1043,6 +1035,8 @@ export class CadreNode {
|
|
|
1043
1035
|
await this.driveControlRelayReservation();
|
|
1044
1036
|
// Schedule self-registration in background
|
|
1045
1037
|
this.scheduleSelfRegistration();
|
|
1038
|
+
// Last: its first pass may dial another party's machines.
|
|
1039
|
+
this.startPendingJoinRunner();
|
|
1046
1040
|
}
|
|
1047
1041
|
catch (error) {
|
|
1048
1042
|
log('Failed to start CadreNode: %o', error);
|
|
@@ -1593,11 +1587,15 @@ export class CadreNode {
|
|
|
1593
1587
|
// replaces the default whole (see DEFAULT_CONNECTION_MONITOR).
|
|
1594
1588
|
connectionMonitor: network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
|
|
1595
1589
|
// The declared link every cadre budget is derived from, stated to Optimystic too so it
|
|
1596
|
-
// derives its own deadlines from the same number: its request
|
|
1597
|
-
//
|
|
1598
|
-
//
|
|
1599
|
-
//
|
|
1590
|
+
// derives its own deadlines from the same number: its request responses, its block
|
|
1591
|
+
// pushes, and libp2p's `inboundUpgradeTimeout`. Always stated, default included:
|
|
1592
|
+
// undeclared, Optimystic keeps LAN deadlines that cannot open a relayed connection at the
|
|
1593
|
+
// link sereus supports (`link-budget.ts`).
|
|
1600
1594
|
linkRoundTripMs: resolveLinkRoundTripMs(network?.linkRoundTripMs),
|
|
1595
|
+
// The three limits on opening a connection (libp2p's per-address and whole-dial limits,
|
|
1596
|
+
// and Optimystic's request dial): Optimystic's derivation from the same link plus the
|
|
1597
|
+
// relay's and the called machine's admission decisions, which run on this dial's clock.
|
|
1598
|
+
...optimysticDialLimits(network?.linkRoundTripMs),
|
|
1601
1599
|
// `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
|
|
1602
1600
|
// `{}` when `network.transports` is set, since the embedder owns transport policy
|
|
1603
1601
|
// then. Spread NEXT to `transports` because the two answer the same question.
|
|
@@ -1670,11 +1668,9 @@ export class CadreNode {
|
|
|
1670
1668
|
* `StrandSolicitationService.hasOutstandingInvitation`). A formation
|
|
1671
1669
|
* initiator is another party's peer by design and its token is only
|
|
1672
1670
|
* checkable inside the protocol, so the gate asks the coarser question
|
|
1673
|
-
* "does this node expect a stranger at all?".
|
|
1674
|
-
*
|
|
1675
|
-
*
|
|
1676
|
-
* bring-up) and `formStrand`'s lazy initialization both leave the gate
|
|
1677
|
-
* armed, because neither mints an invitation.
|
|
1671
|
+
* "does this node expect a stranger at all?". REGISTERING the responder
|
|
1672
|
+
* does not suspend stranger denial: every node registers one at
|
|
1673
|
+
* {@link start}, and registering mints no invitation.
|
|
1678
1674
|
*
|
|
1679
1675
|
* Ordering is semantically free (the checks are OR'd) but decides who pays:
|
|
1680
1676
|
* checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
|
|
@@ -1706,6 +1702,11 @@ export class CadreNode {
|
|
|
1706
1702
|
* layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
|
|
1707
1703
|
* read is safe here — unlike the per-stream gate, which must consult the
|
|
1708
1704
|
* materialized {@link authorizedControlPeers} snapshot instead.
|
|
1705
|
+
* NOTE: check 6 adds a second control read (`hasOutstandingFormationInvite`) for a
|
|
1706
|
+
* stranger with no locally minted invitation in play, on every node now that every
|
|
1707
|
+
* node runs the responder — a relay-enabled storage node included. If stranger
|
|
1708
|
+
* connections to such a node ever arrive fast enough for that read to show, cache
|
|
1709
|
+
* the answer for a few seconds.
|
|
1709
1710
|
* NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
|
|
1710
1711
|
* replicated here is denied until the row converges (typically via the
|
|
1711
1712
|
* owner); either side's next outbound reconcile dial (outbound is never
|
|
@@ -2494,18 +2495,10 @@ export class CadreNode {
|
|
|
2494
2495
|
if (this.controlConnectionCloseHandler) {
|
|
2495
2496
|
this.controlNode.removeEventListener('connection:close', this.controlConnectionCloseHandler);
|
|
2496
2497
|
}
|
|
2497
|
-
if (this.turnConnectionOpenHandler) {
|
|
2498
|
-
this.controlNode.removeEventListener('connection:open', this.turnConnectionOpenHandler);
|
|
2499
|
-
}
|
|
2500
|
-
if (this.turnConnectionCloseHandler) {
|
|
2501
|
-
this.controlNode.removeEventListener('connection:close', this.turnConnectionCloseHandler);
|
|
2502
|
-
}
|
|
2503
2498
|
}
|
|
2504
2499
|
this.selfPeerUpdateHandler = null;
|
|
2505
2500
|
this.controlConnectionOpenHandler = null;
|
|
2506
2501
|
this.controlConnectionCloseHandler = null;
|
|
2507
|
-
this.turnConnectionOpenHandler = null;
|
|
2508
|
-
this.turnConnectionCloseHandler = null;
|
|
2509
2502
|
// Reset the write-while-alone re-replication state so a stop()→start() cycle
|
|
2510
2503
|
// re-arms the growth edge and re-runs BOTH one-shot passes. Any queued
|
|
2511
2504
|
// local-only writes are moot once the node is torn down: owner inserts are
|
|
@@ -2549,19 +2542,27 @@ export class CadreNode {
|
|
|
2549
2542
|
* handling, is what keeps a mixed list from taking a whole peer offline.
|
|
2550
2543
|
*/
|
|
2551
2544
|
async resolvePeerAddrs(peerId, opts = {}) {
|
|
2545
|
+
return (await this.resolvePeerRecord(peerId, opts))?.addrs ?? [];
|
|
2546
|
+
}
|
|
2547
|
+
/**
|
|
2548
|
+
* {@link resolvePeerAddrs} with the record's `UpdatedAt` kept beside the addresses, for a
|
|
2549
|
+
* caller that ranks peers by how recently they published. `null` wherever that returns `[]`
|
|
2550
|
+
* for a failed gate.
|
|
2551
|
+
*/
|
|
2552
|
+
async resolvePeerRecord(peerId, opts) {
|
|
2552
2553
|
if (!this.controlDatabase) {
|
|
2553
2554
|
throw new Error('CadreNode must be started before resolving peer addrs');
|
|
2554
2555
|
}
|
|
2555
2556
|
const record = await this.controlDatabase.queryPeerRecord(peerId);
|
|
2556
2557
|
if (!record) {
|
|
2557
2558
|
log('resolvePeerAddrs: no record for %s', peerId);
|
|
2558
|
-
return
|
|
2559
|
+
return null;
|
|
2559
2560
|
}
|
|
2560
2561
|
// publicKey <-> peerId binding: the stored key must be the one embedded in
|
|
2561
2562
|
// the requested Ed25519 peer id (also rejects a non-Ed25519 / missing key).
|
|
2562
2563
|
if (!record.publicKey || ed25519PublicKeyB64FromPeerId(peerId) !== record.publicKey) {
|
|
2563
2564
|
log('resolvePeerAddrs: publicKey does not match peerId for %s', peerId);
|
|
2564
|
-
return
|
|
2565
|
+
return null;
|
|
2565
2566
|
}
|
|
2566
2567
|
// Self-signature over (peerId, addrs, updatedAt).
|
|
2567
2568
|
if (!verifyPeerRecordSignature(record)) {
|
|
@@ -2569,13 +2570,13 @@ export class CadreNode {
|
|
|
2569
2570
|
// no addrs — a not-yet-self-published row)" from a genuinely corrupt or
|
|
2570
2571
|
// mixed-revision row. Sig/addrs are public replicated data.
|
|
2571
2572
|
log('resolvePeerAddrs: signature verification failed for %s (updatedAt=%d, addrs=%o, sig=%s)', peerId, record.updatedAt, record.addrs, record.sig ? `${record.sig.slice(0, 16)}…` : '(empty)');
|
|
2572
|
-
return
|
|
2573
|
+
return null;
|
|
2573
2574
|
}
|
|
2574
2575
|
// Freshness: never hand back a dead relay reservation.
|
|
2575
2576
|
const maxAgeMs = opts.maxAgeMs ?? DEFAULT_PEER_RECORD_MAX_AGE_MS;
|
|
2576
2577
|
if (!isPeerRecordFresh(record.updatedAt, maxAgeMs, Date.now())) {
|
|
2577
2578
|
log('resolvePeerAddrs: record for %s is stale (updatedAt=%d, maxAgeMs=%d)', peerId, record.updatedAt, maxAgeMs);
|
|
2578
|
-
return
|
|
2579
|
+
return null;
|
|
2579
2580
|
}
|
|
2580
2581
|
// Pluggable trust gate (defaults to current-member).
|
|
2581
2582
|
const trustPolicy = opts.trustPolicy ?? currentMemberTrustPolicy();
|
|
@@ -2587,7 +2588,7 @@ export class CadreNode {
|
|
|
2587
2588
|
});
|
|
2588
2589
|
if (!trusted) {
|
|
2589
2590
|
log('resolvePeerAddrs: trust policy rejected %s', peerId);
|
|
2590
|
-
return
|
|
2591
|
+
return null;
|
|
2591
2592
|
}
|
|
2592
2593
|
// Order signaling-first (the on-record order was what we verified above),
|
|
2593
2594
|
// optionally restrict to signaling addrs, then parse — dropping any addr
|
|
@@ -2597,7 +2598,7 @@ export class CadreNode {
|
|
|
2597
2598
|
if (opts.signalingOnly) {
|
|
2598
2599
|
addrs = addrs.filter(isSignalingAddr);
|
|
2599
2600
|
}
|
|
2600
|
-
return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
|
|
2601
|
+
return { addrs: this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId), updatedAt: record.updatedAt };
|
|
2601
2602
|
}
|
|
2602
2603
|
/**
|
|
2603
2604
|
* Guarantee every address handed to a control-network dial terminates in
|
|
@@ -3131,15 +3132,18 @@ export class CadreNode {
|
|
|
3131
3132
|
*
|
|
3132
3133
|
* The list may name transports this node cannot dial (a lent node reports TCP
|
|
3133
3134
|
* and `/ws` addresses to a phone that dials WebSockets only). That needs no
|
|
3134
|
-
* filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.
|
|
3135
|
+
* filtering here: libp2p's dial queue (`calculateMultiaddrs`, libp2p 3.3.11)
|
|
3135
3136
|
* rejects an address no transport can dial before touching the network, so
|
|
3136
3137
|
* such an address costs a log line.
|
|
3137
3138
|
*
|
|
3138
3139
|
* The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
|
|
3139
|
-
* addresses one after another under a single deadline,
|
|
3140
|
-
*
|
|
3141
|
-
*
|
|
3142
|
-
*
|
|
3140
|
+
* addresses one after another under a single deadline, and sorts loopback
|
|
3141
|
+
* addresses last — so one or two addresses that never answer use up most of
|
|
3142
|
+
* the deadline before the address that works is tried. libp2p 3.3's
|
|
3143
|
+
* per-address `addressDialTimeout` does not prevent that: cadre sizes it for a
|
|
3144
|
+
* cold relayed open through two admission decisions (`optimysticDialLimits`,
|
|
3145
|
+
* 39 s at the default declared link).
|
|
3146
|
+
* {@link dialPeerAddrs} dials each address on its own time limit instead.
|
|
3143
3147
|
*/
|
|
3144
3148
|
async resolveControlDialAddrs(peerId, resolved) {
|
|
3145
3149
|
if (resolved.length > 0) {
|
|
@@ -3504,6 +3508,8 @@ export class CadreNode {
|
|
|
3504
3508
|
}
|
|
3505
3509
|
// 4. In-session pending owner authorize writes.
|
|
3506
3510
|
await this.drainPendingPeerWrites();
|
|
3511
|
+
// 5. `PendingJoin` rows this process wrote while alone. Their tombstones went out in step 1.
|
|
3512
|
+
await this.pendingJoinRunner?.reissueWritesMadeAlone();
|
|
3507
3513
|
}
|
|
3508
3514
|
/**
|
|
3509
3515
|
* Re-touch this node's own `DeviceToken` row (re-sign + bump `UpdatedAt` via
|
|
@@ -3761,47 +3767,6 @@ export class CadreNode {
|
|
|
3761
3767
|
this.controlConnectionCloseHandler = () => this.handleControlConnectionClose();
|
|
3762
3768
|
this.controlNode.addEventListener('connection:open', this.controlConnectionOpenHandler);
|
|
3763
3769
|
this.controlNode.addEventListener('connection:close', this.controlConnectionCloseHandler);
|
|
3764
|
-
// TURN-relay detection: tag/untag peers whose WebRTC ICE used a TURN candidate.
|
|
3765
|
-
this.turnConnectionOpenHandler = (evt) => this.handleTurnConnectionOpen(evt.detail);
|
|
3766
|
-
this.turnConnectionCloseHandler = (evt) => this.handleTurnConnectionClose(evt.detail);
|
|
3767
|
-
this.controlNode.addEventListener('connection:open', this.turnConnectionOpenHandler);
|
|
3768
|
-
this.controlNode.addEventListener('connection:close', this.turnConnectionCloseHandler);
|
|
3769
|
-
}
|
|
3770
|
-
/**
|
|
3771
|
-
* On a control connection opening: if it is a WebRTC connection, drain the TURN
|
|
3772
|
-
* tracker for the just-settled ICE verdict and, when it relayed, mark the peer
|
|
3773
|
-
* so {@link getConnectionPaths} classifies it `webrtc-turn` (relayed). The
|
|
3774
|
-
* settlement↔open correlation is timing-based and best-effort; an unknown
|
|
3775
|
-
* verdict degrades to not-relayed. Never throws to the event loop.
|
|
3776
|
-
*/
|
|
3777
|
-
handleTurnConnectionOpen(conn) {
|
|
3778
|
-
try {
|
|
3779
|
-
const addr = conn.remoteAddr?.toString() ?? '';
|
|
3780
|
-
// Only a genuine `/webrtc` (ICE) session can be TURN-relayed and promoted to
|
|
3781
|
-
// webrtc-turn. Use the classifier (which checks `/webrtc-direct` first) rather
|
|
3782
|
-
// than a bare substring — `'/webrtc-direct'.includes('/webrtc')` is true, and a
|
|
3783
|
-
// webrtc-direct open must not drain a settlement meant for a real webrtc dial.
|
|
3784
|
-
if (classifyTransport(addr).transport !== 'webrtc') {
|
|
3785
|
-
return;
|
|
3786
|
-
}
|
|
3787
|
-
if (this.turnTracker.consume(TURN_CONSUME_WINDOW_MS) === true) {
|
|
3788
|
-
const peerId = conn.remotePeer.toString();
|
|
3789
|
-
this.turnRelayedPeers.add(peerId);
|
|
3790
|
-
log('TURN-relayed WebRTC connection detected for peer %s', peerId);
|
|
3791
|
-
}
|
|
3792
|
-
}
|
|
3793
|
-
catch (error) {
|
|
3794
|
-
log('handleTurnConnectionOpen failed: %o', error);
|
|
3795
|
-
}
|
|
3796
|
-
}
|
|
3797
|
-
/** On a control connection closing, drop any TURN-relayed flag for its peer. */
|
|
3798
|
-
handleTurnConnectionClose(conn) {
|
|
3799
|
-
try {
|
|
3800
|
-
this.turnRelayedPeers.delete(conn.remotePeer.toString());
|
|
3801
|
-
}
|
|
3802
|
-
catch (error) {
|
|
3803
|
-
log('handleTurnConnectionClose failed: %o', error);
|
|
3804
|
-
}
|
|
3805
3770
|
}
|
|
3806
3771
|
// ============================================================================
|
|
3807
3772
|
// Device-token registry (control-network push-token publish + resolve)
|
|
@@ -4147,6 +4112,10 @@ export class CadreNode {
|
|
|
4147
4112
|
// it is stopped here.
|
|
4148
4113
|
this.relayReserveSupervisor?.stop();
|
|
4149
4114
|
this.relayReserveSupervisor = null;
|
|
4115
|
+
// Before anything it reads is torn down. An attempt in flight is not awaited (NOTE at
|
|
4116
|
+
// `PendingJoinRunner.stop`).
|
|
4117
|
+
this.pendingJoinRunner?.stop();
|
|
4118
|
+
this.pendingJoinRunner = null;
|
|
4150
4119
|
// Drop the rest of the relay-reservation posture: a torn-down node holds no
|
|
4151
4120
|
// reservation, so neither a restarted instance nor a caller inspecting a failed
|
|
4152
4121
|
// start reports the previous attempt's `reserved`.
|
|
@@ -4159,9 +4128,6 @@ export class CadreNode {
|
|
|
4159
4128
|
// Stop self-record refresh timers + address-change listener (before the
|
|
4160
4129
|
// control node is torn down, so removeEventListener has a live target).
|
|
4161
4130
|
this.stopRecordRefresh();
|
|
4162
|
-
// Restore the wrapped globalThis.RTCPeerConnection and drop TURN-relay state.
|
|
4163
|
-
this.turnTracker.dispose();
|
|
4164
|
-
this.turnRelayedPeers.clear();
|
|
4165
4131
|
// Stop hibernation manager
|
|
4166
4132
|
this.hibernationManager.stop();
|
|
4167
4133
|
// Stop seed bootstrap service
|
|
@@ -4185,11 +4151,14 @@ export class CadreNode {
|
|
|
4185
4151
|
await this.pushFanoutService.close().catch((err) => log('Push fan-out close failed: %o', err));
|
|
4186
4152
|
this.pushFanoutService = null;
|
|
4187
4153
|
}
|
|
4188
|
-
// Unregister
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4154
|
+
// Unregister the formation responder once any queued swap has run, so no swap
|
|
4155
|
+
// registers a handler after this. A restarted node installs a fresh one.
|
|
4156
|
+
await this.solicitationSwaps;
|
|
4157
|
+
if (this.registeredSolicitation && this.controlNode) {
|
|
4158
|
+
await this.registeredSolicitation.unregisterResponder(this.controlNode);
|
|
4192
4159
|
}
|
|
4160
|
+
this.registeredSolicitation = null;
|
|
4161
|
+
this.strandSolicitationService = null;
|
|
4193
4162
|
// Stop strand watcher
|
|
4194
4163
|
if (this.strandWatcher) {
|
|
4195
4164
|
await this.strandWatcher.stop();
|
|
@@ -5658,9 +5627,9 @@ export class CadreNode {
|
|
|
5658
5627
|
* caller runs the drive regardless).
|
|
5659
5628
|
*
|
|
5660
5629
|
* NOTE: against a relay that is DOWN this hook costs up to two strand-addr
|
|
5661
|
-
* timeouts (dial by peer id, then by addr;
|
|
5630
|
+
* timeouts (dial by peer id, then by addr; 28.5 s each at the default declared link round trip)
|
|
5662
5631
|
* before the reservation drive even starts, so one failed re-drive holds the supervisor
|
|
5663
|
-
* `driving` for those
|
|
5632
|
+
* `driving` for those 57 s plus the drive's own deadline — 75 s at the default, and longer on
|
|
5664
5633
|
* a host that declared a slower one (`link-budget.ts`).
|
|
5665
5634
|
* Bounded and harmless while the relay is unreachable anyway; if recovery
|
|
5666
5635
|
* latency after a relay comes back ever matters, skip the announce when the
|
|
@@ -6581,6 +6550,9 @@ export class CadreNode {
|
|
|
6581
6550
|
...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
|
|
6582
6551
|
}), this.controlNode, this.controlDatabase);
|
|
6583
6552
|
log('Seed bootstrap service initialized');
|
|
6553
|
+
// Embedders wire the owner key after start(), so the runner's first pass usually found
|
|
6554
|
+
// this machine not yet an owner; without this it would wait a full poll interval.
|
|
6555
|
+
this.pendingJoinRunner?.kick();
|
|
6584
6556
|
}
|
|
6585
6557
|
/**
|
|
6586
6558
|
* Push the multiaddrs that future invites should advertise. Pass `null` to
|
|
@@ -7260,19 +7232,32 @@ export class CadreNode {
|
|
|
7260
7232
|
// Strand Solicitation API (native cadre-core formation transport)
|
|
7261
7233
|
// ============================================================================
|
|
7262
7234
|
/**
|
|
7263
|
-
*
|
|
7264
|
-
*
|
|
7235
|
+
* Replace this node's strand solicitation service — the formation responder every node
|
|
7236
|
+
* installs at {@link start}, and the initiator side of {@link formStrand}. Optional: call it
|
|
7237
|
+
* only to customize the responder (an approver, a provisioner, formation deadlines). The
|
|
7238
|
+
* new service's handler replaces the previous one's on the control node, and invitations
|
|
7239
|
+
* the previous service minted stay outstanding.
|
|
7240
|
+
*
|
|
7241
|
+
* Without `options.formationUsageRecorder`, tokens are checked against this party's
|
|
7242
|
+
* replicated `FormationInvite`/`FormationUsage` rows ({@link ControlFormationUsageRecorder}).
|
|
7243
|
+
* A responder that accepts every token is reachable only by constructing
|
|
7244
|
+
* {@link StrandSolicitationService} directly.
|
|
7265
7245
|
*
|
|
7266
7246
|
* @param options - Configuration for the solicitation service
|
|
7267
7247
|
*/
|
|
7268
7248
|
async initializeStrandSolicitation(options) {
|
|
7269
|
-
|
|
7249
|
+
const controlNode = this.controlNode;
|
|
7250
|
+
const controlDatabase = this.controlDatabase;
|
|
7251
|
+
if (!controlNode || !controlDatabase) {
|
|
7270
7252
|
throw new Error('CadreNode must be started before initializing strand solicitation');
|
|
7271
7253
|
}
|
|
7272
7254
|
const service = new StrandSolicitationService({
|
|
7273
7255
|
...options,
|
|
7256
|
+
formationUsageRecorder: options?.formationUsageRecorder ?? new ControlFormationUsageRecorder(controlDatabase),
|
|
7274
7257
|
partyId: this.config.controlNetwork.partyId,
|
|
7275
|
-
|
|
7258
|
+
// Read per formation: the responder is installed during start, before a relay
|
|
7259
|
+
// reservation gives a relay-only node any address, and addresses change after.
|
|
7260
|
+
cadrePeerAddrs: () => this.getMultiaddrs(),
|
|
7276
7261
|
// Every formation deadline derives from this node's declared link, like every other
|
|
7277
7262
|
// dial budget here; a caller's own declaration on the formation config still wins.
|
|
7278
7263
|
formationConfig: {
|
|
@@ -7288,25 +7273,60 @@ export class CadreNode {
|
|
|
7288
7273
|
// issuer here would admit joiners under someone else's authority.
|
|
7289
7274
|
issueMembershipInvite: (strandId, signal) => this.issueStrandMembershipInvite(strandId, signal)
|
|
7290
7275
|
});
|
|
7291
|
-
// Set before the registration is awaited, so a concurrent createOpenInvitation /
|
|
7292
|
-
// formStrand uses this service instead of building a second one whose handler
|
|
7293
|
-
// would collide; a failed registration puts the previous service back.
|
|
7294
|
-
// NOTE: same stray-handler caveat as installSeedBootstrapService — a failed
|
|
7295
|
-
// peer-store merge leaves the formation handler registered, so a retry is a duplicate.
|
|
7296
7276
|
const previous = this.strandSolicitationService;
|
|
7277
|
+
if (previous) {
|
|
7278
|
+
service.adoptMintedInvitations(previous);
|
|
7279
|
+
}
|
|
7280
|
+
// Set before the swap is awaited, so a concurrent createOpenInvitation / formStrand
|
|
7281
|
+
// uses this service instead of building another.
|
|
7297
7282
|
this.strandSolicitationService = service;
|
|
7283
|
+
const swap = this.solicitationSwaps.then(() => this.swapFormationResponder(service, controlNode));
|
|
7284
|
+
// The queue only orders swaps; a failed one rejects this call's own await below.
|
|
7285
|
+
this.solicitationSwaps = swap.catch(() => undefined);
|
|
7286
|
+
await swap;
|
|
7287
|
+
log('Strand solicitation service initialized');
|
|
7288
|
+
}
|
|
7289
|
+
/**
|
|
7290
|
+
* Answer formation from start. Log-and-continue: a node that cannot is still useful, and
|
|
7291
|
+
* {@link createOpenInvitation} / {@link formStrand} install the responder when it is missing.
|
|
7292
|
+
*/
|
|
7293
|
+
async installDefaultFormationResponder() {
|
|
7294
|
+
try {
|
|
7295
|
+
await this.initializeStrandSolicitation();
|
|
7296
|
+
}
|
|
7297
|
+
catch (error) {
|
|
7298
|
+
log('Formation responder not installed at start; createOpenInvitation/formStrand will retry: %o', error);
|
|
7299
|
+
}
|
|
7300
|
+
}
|
|
7301
|
+
/**
|
|
7302
|
+
* Move the control node's formation handler from the registered service to `service`.
|
|
7303
|
+
* Queued by {@link initializeStrandSolicitation} so swaps run one at a time: libp2p allows
|
|
7304
|
+
* one handler per protocol id, and two overlapping swaps would each find the other's
|
|
7305
|
+
* handler in the way. A failed swap registers the previous service again, points
|
|
7306
|
+
* {@link strandSolicitationService} back at it unless a later call has replaced it, and
|
|
7307
|
+
* rethrows.
|
|
7308
|
+
*
|
|
7309
|
+
* NOTE: same stray-handler caveat as {@link installSeedBootstrapService} — a failed
|
|
7310
|
+
* peer-store merge leaves the formation handler registered, so the restore below (and a
|
|
7311
|
+
* retry) rejects as a duplicate.
|
|
7312
|
+
*/
|
|
7313
|
+
async swapFormationResponder(service, controlNode) {
|
|
7314
|
+
const previous = this.registeredSolicitation;
|
|
7298
7315
|
try {
|
|
7299
|
-
await
|
|
7316
|
+
await previous?.unregisterResponder(controlNode);
|
|
7317
|
+
await service.registerResponder(controlNode);
|
|
7318
|
+
this.registeredSolicitation = service;
|
|
7300
7319
|
}
|
|
7301
7320
|
catch (error) {
|
|
7302
7321
|
if (this.strandSolicitationService === service)
|
|
7303
7322
|
this.strandSolicitationService = previous;
|
|
7323
|
+
await previous?.registerResponder(controlNode).catch((restoreError) => log('Re-registering the previous formation responder failed; none answers until the next initializeStrandSolicitation: %o', restoreError));
|
|
7304
7324
|
throw error;
|
|
7305
7325
|
}
|
|
7306
|
-
log('Strand solicitation service initialized');
|
|
7307
7326
|
}
|
|
7308
7327
|
/**
|
|
7309
|
-
* Get the strand solicitation service (for advanced use)
|
|
7328
|
+
* Get the strand solicitation service (for advanced use). Non-null on a started node unless
|
|
7329
|
+
* the install at {@link start} failed.
|
|
7310
7330
|
*/
|
|
7311
7331
|
getStrandSolicitationService() {
|
|
7312
7332
|
return this.strandSolicitationService;
|
|
@@ -7314,28 +7334,66 @@ export class CadreNode {
|
|
|
7314
7334
|
/**
|
|
7315
7335
|
* Create an open invitation for others to form strands with this party.
|
|
7316
7336
|
*
|
|
7337
|
+
* The invitation's bootstrap list names this machine first (it is the one most likely to
|
|
7338
|
+
* run the host strand), then a few of the party's other machines
|
|
7339
|
+
* ({@link siblingInvitationAddrs}), so a joiner can still form while this one is offline.
|
|
7340
|
+
*
|
|
7317
7341
|
* @param sAppId - The sApp to use for formed strands
|
|
7318
7342
|
* @param expirationMs - How long the invitation is valid (ms from now)
|
|
7319
7343
|
* @returns The open invitation to share out-of-band
|
|
7344
|
+
* @throws when neither this machine nor any other machine of the party has an address
|
|
7320
7345
|
*/
|
|
7321
7346
|
async createOpenInvitation(sAppId, expirationMs = 24 * 60 * 60 * 1000 // 24 hours default
|
|
7322
7347
|
) {
|
|
7323
7348
|
if (!this.strandSolicitationService) {
|
|
7324
|
-
//
|
|
7349
|
+
// Only when the install at start failed.
|
|
7325
7350
|
await this.initializeStrandSolicitation();
|
|
7326
7351
|
}
|
|
7327
|
-
const bootstrap = this.getMultiaddrs();
|
|
7352
|
+
const bootstrap = [...this.getMultiaddrs(), ...await this.siblingInvitationAddrs()];
|
|
7328
7353
|
if (bootstrap.length === 0) {
|
|
7329
7354
|
throw new Error('No multiaddrs available for invitation');
|
|
7330
7355
|
}
|
|
7331
7356
|
return await this.strandSolicitationService.createOpenInvitation(sAppId, expirationMs, bootstrap);
|
|
7332
7357
|
}
|
|
7333
7358
|
/**
|
|
7334
|
-
*
|
|
7359
|
+
* Addresses of the party's other machines for an invitation's bootstrap list
|
|
7360
|
+
* ({@link selectInvitationSiblingAddrs} picks which). The source is each authorized
|
|
7361
|
+
* member's signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record
|
|
7362
|
+
* is missing, stale or untrusted is left out; live connections only order them.
|
|
7363
|
+
*
|
|
7364
|
+
* Best-effort: a failed read leaves the invitation naming this machine alone, which is what
|
|
7365
|
+
* it named before siblings were added.
|
|
7366
|
+
*/
|
|
7367
|
+
async siblingInvitationAddrs() {
|
|
7368
|
+
const controlNode = this.controlNode;
|
|
7369
|
+
if (!controlNode || !this.controlDatabase) {
|
|
7370
|
+
return [];
|
|
7371
|
+
}
|
|
7372
|
+
try {
|
|
7373
|
+
const siblings = [];
|
|
7374
|
+
for (const { peerId } of await this.listAuthorizedMembers()) {
|
|
7375
|
+
const resolved = await this.resolvePeerRecord(peerId, {});
|
|
7376
|
+
if (resolved && resolved.addrs.length > 0) {
|
|
7377
|
+
siblings.push({ peerId, updatedAt: resolved.updatedAt, addrs: resolved.addrs.map(String) });
|
|
7378
|
+
}
|
|
7379
|
+
}
|
|
7380
|
+
const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
|
|
7381
|
+
return selectInvitationSiblingAddrs(siblings, connected);
|
|
7382
|
+
}
|
|
7383
|
+
catch (error) {
|
|
7384
|
+
log('createOpenInvitation: resolving the other machines failed; the invitation names only this one: %o', error);
|
|
7385
|
+
return [];
|
|
7386
|
+
}
|
|
7387
|
+
}
|
|
7388
|
+
/**
|
|
7389
|
+
* Form a strand with a responder via an open invitation: one attempt, from this machine, with
|
|
7390
|
+
* nothing recorded party-wide. {@link requestJoin} is the path that keeps trying.
|
|
7335
7391
|
*
|
|
7336
7392
|
* @param invitation - The open invitation received out-of-band
|
|
7337
7393
|
* @param disclosure - Identity/context information to share with the responder
|
|
7338
7394
|
* @returns The member key and strand info if successful
|
|
7395
|
+
* @throws `FormationRejectedError` or `FormationUnreachableError` when the formation fails, and
|
|
7396
|
+
* `FormationPostApprovalError` when it was approved and a step on this machine then failed
|
|
7339
7397
|
*/
|
|
7340
7398
|
async formStrand(invitation, disclosure = {}) {
|
|
7341
7399
|
if (!this.controlNode) {
|
|
@@ -7356,6 +7414,171 @@ export class CadreNode {
|
|
|
7356
7414
|
await this.rememberFormedStrand(result);
|
|
7357
7415
|
return result;
|
|
7358
7416
|
}
|
|
7417
|
+
/**
|
|
7418
|
+
* Ask to join through `invitation` and have the party keep trying until the join works, the
|
|
7419
|
+
* invitation is used up or expires, or the request is dismissed: across restarts, and from
|
|
7420
|
+
* any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
|
|
7421
|
+
* offline"). Records a party-wide `PendingJoin` row, runs one attempt on this machine at
|
|
7422
|
+
* once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
|
|
7423
|
+
* when it was unreachable or not ready, `'failed'` when it refused for good.
|
|
7424
|
+
*
|
|
7425
|
+
* Asking again for an invitation whose request is still pending adopts that request as it
|
|
7426
|
+
* is; asking again after it finished starts a fresh one. Later changes arrive as
|
|
7427
|
+
* `pendingJoin:changed`. A joined strand is offered through `strand:discovered` like any
|
|
7428
|
+
* {@link formStrand} join. Do not also call {@link formStrand} with the same invitation:
|
|
7429
|
+
* nothing coordinates the two, and one of them gets `token-spent`.
|
|
7430
|
+
*
|
|
7431
|
+
* @throws on a machine that is not an enrolled owner (the row is owner-signed), and on an
|
|
7432
|
+
* invitation already past its expiration
|
|
7433
|
+
*/
|
|
7434
|
+
async requestJoin(invitation, disclosure = {}) {
|
|
7435
|
+
const { database, runner } = this.requirePendingJoins();
|
|
7436
|
+
const signer = await this.requireOwnerSigner('requestJoin needs an enrolled owner machine of this party, because the party-wide join request is ' +
|
|
7437
|
+
'owner-signed. formStrand(invitation, disclosure) joins once from this machine, with no retries.');
|
|
7438
|
+
const now = Date.now();
|
|
7439
|
+
const expiresAt = invitation.expiration.getTime();
|
|
7440
|
+
if (!Number.isFinite(expiresAt)) {
|
|
7441
|
+
throw new Error('The invitation carries no valid expiration');
|
|
7442
|
+
}
|
|
7443
|
+
if (now >= expiresAt) {
|
|
7444
|
+
throw new Error(`The invitation expired at ${invitation.expiration.toISOString()}; ask the inviter for a fresh one`);
|
|
7445
|
+
}
|
|
7446
|
+
const requested = requestedPendingJoin(pendingJoinId(invitation.token), this.encodeInvitation(invitation), disclosure, expiresAt, now);
|
|
7447
|
+
const row = await this.recordJoinRequest(database, requested, signer);
|
|
7448
|
+
log('requestJoin: pending join %s recorded; attempting now', row.Id);
|
|
7449
|
+
return runner.attemptNow(row);
|
|
7450
|
+
}
|
|
7451
|
+
/**
|
|
7452
|
+
* Every join asked for with {@link requestJoin} and not dismissed, pending or finished, as this
|
|
7453
|
+
* machine sees it (see `PendingJoinStatus` for which states are machine-local).
|
|
7454
|
+
*/
|
|
7455
|
+
async listPendingJoins() {
|
|
7456
|
+
const { database, runner } = this.requirePendingJoins();
|
|
7457
|
+
return (await database.queryPendingJoins()).map((row) => runner.statusOf(row));
|
|
7458
|
+
}
|
|
7459
|
+
/**
|
|
7460
|
+
* Remove a join request party-wide. On a pending request this cancels it: every owner machine
|
|
7461
|
+
* stops at its next read, and an attempt already running does not record its outcome (a join
|
|
7462
|
+
* it made stays on that machine, as any {@link formStrand} join does). Owner machines only.
|
|
7463
|
+
* Resolves `false` when there was no such request.
|
|
7464
|
+
*/
|
|
7465
|
+
async dismissPendingJoin(id) {
|
|
7466
|
+
const { database, runner } = this.requirePendingJoins();
|
|
7467
|
+
const { ownerKey, signMessage } = await this.requireOwnerSigner('dismissPendingJoin needs an enrolled owner machine of this party: the removal is owner-signed');
|
|
7468
|
+
const removed = await database.deletePendingJoin(id, ownerKey, signMessage);
|
|
7469
|
+
runner.forgetRow(id);
|
|
7470
|
+
return removed;
|
|
7471
|
+
}
|
|
7472
|
+
requirePendingJoins() {
|
|
7473
|
+
if (!this._running || !this.controlDatabase || !this.pendingJoinRunner) {
|
|
7474
|
+
throw new Error('CadreNode must be started before using pending joins');
|
|
7475
|
+
}
|
|
7476
|
+
return { database: this.controlDatabase, runner: this.pendingJoinRunner };
|
|
7477
|
+
}
|
|
7478
|
+
/** This machine's owner key and signer for an owner-signed control write, or a throw carrying `refusal`. */
|
|
7479
|
+
async requireOwnerSigner(refusal) {
|
|
7480
|
+
const signingKey = await this.enrolledOwnerSigningKey();
|
|
7481
|
+
if (!signingKey) {
|
|
7482
|
+
throw new Error(refusal);
|
|
7483
|
+
}
|
|
7484
|
+
return { ownerKey: signingKey.publicKeyB64, signMessage: signMessageWith(signingKey.privateKeyB64) };
|
|
7485
|
+
}
|
|
7486
|
+
/**
|
|
7487
|
+
* Write a join request's row, or adopt the party's existing row for the same invitation: a
|
|
7488
|
+
* pending one as it is, a finished one replaced by `requested` (the user asked again).
|
|
7489
|
+
*/
|
|
7490
|
+
async recordJoinRequest(database, requested, { ownerKey, signMessage }) {
|
|
7491
|
+
let written;
|
|
7492
|
+
try {
|
|
7493
|
+
written = await database.insertPendingJoin(requested, ownerKey, signMessage);
|
|
7494
|
+
}
|
|
7495
|
+
catch (error) {
|
|
7496
|
+
if (!isPendingJoinConflict(error)) {
|
|
7497
|
+
throw error;
|
|
7498
|
+
}
|
|
7499
|
+
const existing = await database.queryPendingJoin(requested.Id);
|
|
7500
|
+
if (!existing) {
|
|
7501
|
+
throw new Error(`A removed join request for this invitation (${requested.Id}) is still held on this machine until its ` +
|
|
7502
|
+
'clean-up runs, which needs a connection to the party. Ask again once connected.', { cause: error });
|
|
7503
|
+
}
|
|
7504
|
+
if (existing.Outcome === null) {
|
|
7505
|
+
return existing;
|
|
7506
|
+
}
|
|
7507
|
+
written = await database.replacePendingJoin(existing.StampId, requested, ownerKey, signMessage);
|
|
7508
|
+
}
|
|
7509
|
+
this.pendingJoinRunner?.noteWritten(written);
|
|
7510
|
+
return written;
|
|
7511
|
+
}
|
|
7512
|
+
/** Build and start the pending-join retry loop over this node (see {@link pendingJoinRunner}). */
|
|
7513
|
+
startPendingJoinRunner() {
|
|
7514
|
+
const database = () => {
|
|
7515
|
+
if (!this.controlDatabase) {
|
|
7516
|
+
throw new Error('CadreNode is stopped: no control database for pending joins');
|
|
7517
|
+
}
|
|
7518
|
+
return this.controlDatabase;
|
|
7519
|
+
};
|
|
7520
|
+
const outcomeSigner = () => this.requireOwnerSigner('Cannot record a pending join outcome: this machine is not an enrolled owner of the party');
|
|
7521
|
+
this.pendingJoinRunner = new PendingJoinRunner({
|
|
7522
|
+
selfId: this.controlNode.peerId.toString(),
|
|
7523
|
+
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
7524
|
+
isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
|
|
7525
|
+
// NOTE: one owner-key read and one `PendingJoin` read per pass (30 s); for a party that never
|
|
7526
|
+
// asked for a join the second reads a never-written block, which consults the cohort. If it
|
|
7527
|
+
// shows up in a device profile, poll more slowly while the table reads empty.
|
|
7528
|
+
readRows: () => database().queryPendingJoins(),
|
|
7529
|
+
readRow: (id) => database().queryPendingJoin(id),
|
|
7530
|
+
attempt: (row) => this.formStrand(this.pendingJoinInvitation(row), parseStoredDisclosure(row)),
|
|
7531
|
+
replace: async (expectedStampId, next) => {
|
|
7532
|
+
const { ownerKey, signMessage } = await outcomeSigner();
|
|
7533
|
+
return database().replacePendingJoin(expectedStampId, next, ownerKey, signMessage);
|
|
7534
|
+
},
|
|
7535
|
+
remove: async (id) => {
|
|
7536
|
+
const { ownerKey, signMessage } = await outcomeSigner();
|
|
7537
|
+
return database().deletePendingJoin(id, ownerKey, signMessage);
|
|
7538
|
+
},
|
|
7539
|
+
isAlone: () => this.committedAlone(),
|
|
7540
|
+
sAppIdOf: (row) => this.pendingJoinSAppId(row),
|
|
7541
|
+
observeRows: (rows) => this.stageMembershipInvitesFromPendingJoins(rows),
|
|
7542
|
+
emit: (status) => this.emit('pendingJoin:changed', status),
|
|
7543
|
+
});
|
|
7544
|
+
this.pendingJoinRunner.start();
|
|
7545
|
+
}
|
|
7546
|
+
/** The invitation a `PendingJoin` row stores. A failure names the row, never the invitation, which is a bearer credential. */
|
|
7547
|
+
pendingJoinInvitation(row) {
|
|
7548
|
+
try {
|
|
7549
|
+
return this.decodeInvitation(row.Invitation);
|
|
7550
|
+
}
|
|
7551
|
+
catch {
|
|
7552
|
+
throw new Error(`PendingJoin ${row.Id}: the stored invitation does not decode`);
|
|
7553
|
+
}
|
|
7554
|
+
}
|
|
7555
|
+
pendingJoinSAppId(row) {
|
|
7556
|
+
try {
|
|
7557
|
+
return this.decodeInvitation(row.Invitation).sAppId;
|
|
7558
|
+
}
|
|
7559
|
+
catch {
|
|
7560
|
+
log('PendingJoin %s: the stored invitation does not decode; reporting no sApp', row.Id);
|
|
7561
|
+
return '';
|
|
7562
|
+
}
|
|
7563
|
+
}
|
|
7564
|
+
/**
|
|
7565
|
+
* Stage the membership invitation of each `joined` row this process has not staged yet, so a
|
|
7566
|
+
* closed strand joined on another owner machine is seated by whichever machine launches it
|
|
7567
|
+
* first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
|
|
7568
|
+
* seated. A strand that already has an invitation staged keeps it, for the reconciler to
|
|
7569
|
+
* settle first.
|
|
7570
|
+
*/
|
|
7571
|
+
stageMembershipInvitesFromPendingJoins(rows) {
|
|
7572
|
+
for (const { rowId, strandId, invite } of membershipInvitesToStage(rows, this.stagedMembershipInviteKeys, Date.now())) {
|
|
7573
|
+
this.stagedMembershipInviteKeys.add(invite.inviteKey);
|
|
7574
|
+
if (this.pendingMembershipInvites.has(strandId)) {
|
|
7575
|
+
continue;
|
|
7576
|
+
}
|
|
7577
|
+
this.pendingMembershipInvites.set(strandId, invite);
|
|
7578
|
+
log('pending join %s: staged its membership invitation for strand %s', rowId, strandId);
|
|
7579
|
+
this.strandManager.notifyMembershipInviteStaged(strandId);
|
|
7580
|
+
}
|
|
7581
|
+
}
|
|
7359
7582
|
/**
|
|
7360
7583
|
* Remember the strand a formation just joined (see {@link joinedStrandStore}), so it
|
|
7361
7584
|
* is re-offered after a restart even when the app is killed before its `addStrand`, and
|
|
@@ -7377,7 +7600,7 @@ export class CadreNode {
|
|
|
7377
7600
|
});
|
|
7378
7601
|
}
|
|
7379
7602
|
catch (error) {
|
|
7380
|
-
throw new
|
|
7603
|
+
throw new FormationPostApprovalError(result.strandId, `Formation for strand ${result.strandId} was approved (its one-time token is spent), but ` +
|
|
7381
7604
|
'remembering the join in this node\'s joined-strand store failed, so the strand would not ' +
|
|
7382
7605
|
'come back after a restart. Fix the store (usually the configured keyStore), then redeem a ' +
|
|
7383
7606
|
'fresh invitation.', { cause: error });
|
|
@@ -7410,12 +7633,13 @@ export class CadreNode {
|
|
|
7410
7633
|
await this.ensureStrandPartyKey(strandId);
|
|
7411
7634
|
}
|
|
7412
7635
|
catch (error) {
|
|
7413
|
-
throw new
|
|
7636
|
+
throw new FormationPostApprovalError(strandId, `Formation for strand ${strandId} was approved (its one-time token is spent), but ` +
|
|
7414
7637
|
'persisting this party\'s membership identity (StrandPartyKey) failed — the joiner ' +
|
|
7415
7638
|
'cannot become a member without it. Fix the underlying cause, then redeem a fresh ' +
|
|
7416
7639
|
'invitation (the delivered one dies with this error).', { cause: error });
|
|
7417
7640
|
}
|
|
7418
7641
|
this.pendingMembershipInvites.set(strandId, invite);
|
|
7642
|
+
this.stagedMembershipInviteKeys.add(invite.inviteKey);
|
|
7419
7643
|
log('formStrand: staged membership invitation for strand %s (party key persisted)', strandId);
|
|
7420
7644
|
this.strandManager.notifyMembershipInviteStaged(strandId);
|
|
7421
7645
|
}
|