@serfab/cadre-core 1.14.0 → 1.16.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 +293 -274
- package/dist/cadre-invite-protocol.d.ts +64 -29
- package/dist/cadre-invite-protocol.js +207 -38
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +42 -0
- package/dist/cadre-join-request.js +144 -0
- package/dist/cadre-join-request.js.map +1 -0
- package/dist/cadre-node.d.ts +375 -79
- package/dist/cadre-node.js +758 -186
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +92 -12
- package/dist/control-database.js +323 -142
- package/dist/control-database.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +19 -3
- package/dist/control-schema.js.map +1 -1
- package/dist/ed25519-key.d.ts +7 -9
- package/dist/ed25519-key.js +20 -10
- package/dist/ed25519-key.js.map +1 -1
- package/dist/fetch-impl.d.ts +12 -0
- package/dist/fetch-impl.js +23 -0
- package/dist/fetch-impl.js.map +1 -0
- package/dist/file-durable-slot.js +2 -1
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/file-safe-name.d.ts +25 -0
- package/dist/file-safe-name.js +29 -0
- package/dist/file-safe-name.js.map +1 -0
- package/dist/formation-approval.js +5 -11
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +0 -19
- package/dist/fs-atomic.js +3 -24
- package/dist/fs-atomic.js.map +1 -1
- package/dist/index.d.ts +12 -5
- package/dist/index.js +31 -6
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +50 -12
- package/dist/invitation-bootstrap.js +76 -13
- package/dist/invitation-bootstrap.js.map +1 -1
- package/dist/joined-strand-store.d.ts +7 -0
- package/dist/joined-strand-store.js +9 -0
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store-file.js +2 -1
- package/dist/key-store-file.js.map +1 -1
- package/dist/link-budget.d.ts +9 -4
- package/dist/link-budget.js +9 -4
- package/dist/link-budget.js.map +1 -1
- package/dist/machine-removal.d.ts +78 -0
- package/dist/machine-removal.js +166 -0
- package/dist/machine-removal.js.map +1 -0
- package/dist/membership-connection-gater.d.ts +6 -5
- package/dist/membership-connection-gater.js +6 -5
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +55 -0
- package/dist/node-claim-payload.js +108 -4
- package/dist/node-claim-payload.js.map +1 -1
- package/dist/node-default-transports.d.ts +27 -0
- package/dist/node-default-transports.js +35 -0
- package/dist/node-default-transports.js.map +1 -0
- package/dist/node-transports.d.ts +30 -0
- package/dist/node-transports.js +40 -0
- package/dist/node-transports.js.map +1 -0
- package/dist/peer-dial.d.ts +124 -27
- package/dist/peer-dial.js +254 -30
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.js +6 -0
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +6 -45
- package/dist/peer-record.js +8 -50
- package/dist/peer-record.js.map +1 -1
- package/dist/primary-lan-address.d.ts +8 -0
- package/dist/primary-lan-address.js +46 -0
- package/dist/primary-lan-address.js.map +1 -0
- package/dist/provider/client.d.ts +131 -0
- package/dist/provider/client.js +226 -0
- package/dist/provider/client.js.map +1 -0
- package/dist/provider/descriptor.d.ts +41 -0
- package/dist/provider/descriptor.js +103 -0
- package/dist/provider/descriptor.js.map +1 -0
- package/dist/provider/index.d.ts +9 -0
- package/dist/provider/index.js +10 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/provider/oauth.d.ts +125 -0
- package/dist/provider/oauth.js +199 -0
- package/dist/provider/oauth.js.map +1 -0
- package/dist/provider/rent-node.d.ts +101 -0
- package/dist/provider/rent-node.js +220 -0
- package/dist/provider/rent-node.js.map +1 -0
- package/dist/relay-addrs.d.ts +9 -4
- package/dist/relay-addrs.js +9 -4
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +51 -16
- package/dist/relay-reservation.js +107 -24
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +22 -5
- package/dist/seed-bootstrap.js +48 -25
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/strand-commit-durability.d.ts +54 -0
- package/dist/strand-commit-durability.js +74 -0
- package/dist/strand-commit-durability.js.map +1 -0
- package/dist/strand-database.d.ts +23 -8
- package/dist/strand-database.js +41 -13
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +7 -1
- package/dist/strand-first-sync-gate.js +7 -1
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-protocol.js +4 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-fret-addrs.d.ts +19 -1
- package/dist/strand-fret-addrs.js +27 -9
- package/dist/strand-fret-addrs.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +81 -19
- package/dist/strand-instance-manager.js +216 -105
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-move.d.ts +124 -0
- package/dist/strand-move.js +438 -0
- package/dist/strand-move.js.map +1 -0
- package/dist/strand-network-config.js +7 -6
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-peer-redial.d.ts +113 -0
- package/dist/strand-peer-redial.js +236 -0
- package/dist/strand-peer-redial.js.map +1 -0
- package/dist/strand-wake-protocol.js +10 -7
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +212 -4
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -1
- package/package.json +18 -10
- package/src/cadre-invite-protocol.ts +1052 -864
- package/src/cadre-join-request.ts +167 -0
- package/src/cadre-node.ts +10003 -9381
- package/src/control-database.ts +4663 -4451
- package/src/control-schema.ts +19 -3
- package/src/ed25519-key.ts +20 -10
- package/src/fetch-impl.ts +23 -0
- package/src/file-durable-slot.ts +64 -63
- package/src/file-safe-name.ts +32 -0
- package/src/formation-approval.ts +604 -610
- package/src/fs-atomic.ts +92 -117
- package/src/index.ts +71 -4
- package/src/invitation-bootstrap.ts +91 -13
- package/src/joined-strand-store.ts +508 -498
- package/src/key-store-file.ts +142 -141
- package/src/link-budget.ts +9 -4
- package/src/machine-removal.ts +216 -0
- package/src/membership-connection-gater.ts +638 -637
- package/src/node-claim-payload.ts +138 -4
- package/src/node-default-transports.ts +38 -0
- package/src/node-transports.ts +49 -0
- package/src/peer-dial.ts +604 -336
- package/src/peer-join-backfill.ts +6 -0
- package/src/peer-record.ts +159 -202
- package/src/primary-lan-address.ts +47 -0
- package/src/provider/client.ts +326 -0
- package/src/provider/descriptor.ts +129 -0
- package/src/provider/index.ts +59 -0
- package/src/provider/oauth.ts +285 -0
- package/src/provider/rent-node.ts +275 -0
- package/src/relay-addrs.ts +9 -4
- package/src/relay-reservation.ts +1211 -1121
- package/src/seed-bootstrap.ts +1502 -1471
- package/src/strand-commit-durability.ts +116 -0
- package/src/strand-database.ts +46 -14
- package/src/strand-first-sync-gate.ts +7 -1
- package/src/strand-formation-protocol.ts +1039 -1036
- package/src/strand-fret-addrs.ts +179 -145
- package/src/strand-instance-manager.ts +2026 -1883
- package/src/strand-move.ts +585 -0
- package/src/strand-network-config.ts +7 -6
- package/src/strand-peer-redial.ts +307 -0
- package/src/strand-wake-protocol.ts +10 -7
- package/src/types.ts +220 -4
package/dist/cadre-node.js
CHANGED
|
@@ -22,22 +22,23 @@ import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
|
|
|
22
22
|
import { JoinedStrandSession, KeyStoreJoinedStrandStore, MemoryJoinedStrandStore } from './joined-strand-store.js';
|
|
23
23
|
import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
|
|
24
24
|
import { strandFretPeerAddrs } from './strand-fret-addrs.js';
|
|
25
|
-
import { verifyCadrePeerVoucher, verifyInvitationAdmission } from './peer-authorization.js';
|
|
25
|
+
import { verifyCadrePeerVoucher, verifyInvitationAdmission, verifyRevocationSigner } from './peer-authorization.js';
|
|
26
26
|
import { deriveOwnerAnchor } from './owner-anchor-sync.js';
|
|
27
|
+
import { describeMachines, planMachineRemoval, revouchWithinPlan, MachineRemovalRefusedError } from './machine-removal.js';
|
|
27
28
|
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
28
|
-
import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr,
|
|
29
|
+
import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
|
|
29
30
|
import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
|
|
30
31
|
import { StrandWatcher } from './strand-watcher.js';
|
|
31
32
|
import { StrandInstanceManager, liveStrandStatus } from './strand-instance-manager.js';
|
|
32
33
|
import { PeerJoinBackfill } from './peer-join-backfill.js';
|
|
33
34
|
import { deriveCohortMembers } from './strand-cohort.js';
|
|
34
35
|
import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
|
|
35
|
-
import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
|
|
36
|
+
import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS, trailingPeerId, withTrailingPeerId } from './peer-dial.js';
|
|
36
37
|
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, relayedRequestBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
37
38
|
import { EnrollmentService } from './enrollment.js';
|
|
38
39
|
import { HibernationManager } from './hibernation-manager.js';
|
|
39
40
|
import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, pendingJoinId } from './control-database.js';
|
|
40
|
-
import { CadreInviteHandler, encodeCadreInvitation,
|
|
41
|
+
import { CadreInviteHandler, encodeCadreInvitation, redeemInPasses, signRedeemRequest } from './cadre-invite-protocol.js';
|
|
41
42
|
import { FormationPostApprovalError } from './strand-formation-rejection.js';
|
|
42
43
|
import { PendingJoinRunner, membershipInvitesToStage, parseStoredDisclosure, requestedPendingJoin } from './pending-join-runner.js';
|
|
43
44
|
import { SeedBootstrapService, mergeSeedPeers, seedDigest } from './seed-bootstrap.js';
|
|
@@ -45,13 +46,14 @@ import { claimSecretTrustPolicy } from './seed-trust-policy.js';
|
|
|
45
46
|
import { ClaimRefusedError, claimProof, parseClaimSecret } from './claim-proof.js';
|
|
46
47
|
import { StrandSolicitationService } from './strand-solicitation.js';
|
|
47
48
|
import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
|
|
48
|
-
import { selectInvitationSiblingAddrs } from './invitation-bootstrap.js';
|
|
49
|
+
import { cadreInvitationReach, selectCadreInvitationMembers, selectInvitationSiblingAddrs } from './invitation-bootstrap.js';
|
|
49
50
|
import { createMembershipConnectionGater, UnauthorizedReservationBudget } from './membership-connection-gater.js';
|
|
50
51
|
import { controlNetworkName, installControlProtocolGuard } from './control-protocol-guard.js';
|
|
51
52
|
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
52
53
|
import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
|
|
53
54
|
import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, prunePeerStrandKeys, peerStrandKey } from './delegate-admission.js';
|
|
54
55
|
import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
|
|
56
|
+
import { nodeTransportsOption } from './node-transports.js';
|
|
55
57
|
import { resolveRelayServer } from './relay-server.js';
|
|
56
58
|
import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
|
|
57
59
|
import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
|
|
@@ -259,8 +261,9 @@ export class CadreNode {
|
|
|
259
261
|
this.controlBackfill = null;
|
|
260
262
|
/**
|
|
261
263
|
* Devices whose invitation redemption this member has verified and is admitting right now,
|
|
262
|
-
* each with its count of overlapping redemptions. The control backfill's gate
|
|
263
|
-
*
|
|
264
|
+
* each with its count of overlapping redemptions. The control backfill's gate and the
|
|
265
|
+
* control cohort choice (Optimystic's `isNetworkMember`) count them as members, for that
|
|
266
|
+
* window only ({@link admitRedeemingDevice}); the per-stream gate never does.
|
|
264
267
|
*/
|
|
265
268
|
this.admittingDevices = new Map();
|
|
266
269
|
this.strandWatcher = null;
|
|
@@ -307,6 +310,8 @@ export class CadreNode {
|
|
|
307
310
|
this.pushFanoutService = null;
|
|
308
311
|
/** Backing field for the {@link running} / {@link isRunning} getters. */
|
|
309
312
|
this._running = false;
|
|
313
|
+
/** The {@link stop} in progress, or null; a concurrent `stop()` awaits it. */
|
|
314
|
+
this.stopInFlight = null;
|
|
310
315
|
/**
|
|
311
316
|
* The control database's own raw storage (cache-wrapped), resolved once per
|
|
312
317
|
* `start()` and released in {@link cleanup}. Owning it is what keeps the
|
|
@@ -404,13 +409,15 @@ export class CadreNode {
|
|
|
404
409
|
this.bootstrapPeerIds = null;
|
|
405
410
|
/**
|
|
406
411
|
* Materialized in-memory snapshot of the AUTHORIZED member peer ids (the
|
|
407
|
-
* {@link listAuthorizedMembers} result), consulted
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
412
|
+
* {@link listAuthorizedMembers} result), consulted through
|
|
413
|
+
* {@link isControlNetworkMember} by the per-stream control-DB gate
|
|
414
|
+
* ({@link authorizeInboundControlStream}) and by Optimystic's cohort choice
|
|
415
|
+
* (`isNetworkMember`). The stream gate runs on EVERY inbound Optimystic
|
|
416
|
+
* control-DB stream and must never await a control-DB read: those reads pull
|
|
417
|
+
* blocks over the very protocols the predicate gates, which closes a circular
|
|
418
|
+
* wait that ends in mutual denial (the upstream gate is fail-closed on
|
|
419
|
+
* timeout, unlike the fail-open connection gater). Optimystic's predicate is
|
|
420
|
+
* synchronous by contract. So the set is refreshed OUT OF BAND instead — after
|
|
414
421
|
* start, on every {@link reconcileControlCohort} pass (15s cadence), and
|
|
415
422
|
* immediately after every LOCAL membership mutation so a just-vouched peer
|
|
416
423
|
* is admitted without waiting for the timer. That last refresh is AUTOMATIC:
|
|
@@ -419,7 +426,11 @@ export class CadreNode {
|
|
|
419
426
|
* writer has to remember. A change that arrives by REPLICATION is picked up
|
|
420
427
|
* on the next timed refresh — bounded staleness, acceptable because this gate
|
|
421
428
|
* is defense in depth: rows an unadmitted peer manages to write are still
|
|
422
|
-
* disbelieved at read time by the voucher-anchored predicate.
|
|
429
|
+
* disbelieved at read time by the voucher-anchored predicate. For cohort
|
|
430
|
+
* choice the same window means a member whose row arrived by replication is
|
|
431
|
+
* left out of this node's control-block cohorts until that refresh.
|
|
432
|
+
* Optimystic reads the predicate live on every lookup, so replacing this set
|
|
433
|
+
* is the whole re-evaluation; there is no per-peer reclassify call to make.
|
|
423
434
|
*/
|
|
424
435
|
this.authorizedControlPeers = new Set();
|
|
425
436
|
/**
|
|
@@ -431,6 +442,17 @@ export class CadreNode {
|
|
|
431
442
|
this.membershipGateDirty = false;
|
|
432
443
|
this.membershipGateDrain = null;
|
|
433
444
|
this.membershipGateDeferDepth = 0;
|
|
445
|
+
/**
|
|
446
|
+
* This node's own `CadrePeer` row as a membership refresh last read it live, or null until
|
|
447
|
+
* one has. Reporting a removal needs it ({@link noteOwnRemoval}). Kept across
|
|
448
|
+
* stop()→start(), like {@link removal}.
|
|
449
|
+
*/
|
|
450
|
+
this.ownLiveRow = null;
|
|
451
|
+
/**
|
|
452
|
+
* The removal this node learned of ({@link removedFromCadre}), or null. Set once and never
|
|
453
|
+
* cleared, so `cadre:removed` fires once per node object.
|
|
454
|
+
*/
|
|
455
|
+
this.removal = null;
|
|
434
456
|
/**
|
|
435
457
|
* In-memory delegate admission grants (see `delegate-admission.ts`): the
|
|
436
458
|
* strand-node transport peerIds this party's members have announced over the
|
|
@@ -673,9 +695,11 @@ export class CadreNode {
|
|
|
673
695
|
* When this node last PUBLISHED its own `CadrePeer` address record (inserted or
|
|
674
696
|
* refreshed), or null if it never has in this session. Stamped by
|
|
675
697
|
* {@link noteSelfRecordPublished} on every successful publish, wherever it was driven
|
|
676
|
-
* from — the boot pass, the heartbeat, an address change, a drain or an
|
|
698
|
+
* from — the boot pass, the heartbeat, an address change, a drain, a reconcile pass or an
|
|
699
|
+
* explicit call.
|
|
677
700
|
*
|
|
678
|
-
* Read by {@link
|
|
701
|
+
* Read by {@link publishFirstSelfRecord}, which stops trying once it is set, and by
|
|
702
|
+
* {@link escalateIfSelfRecordStale} for the one consequence worth an operator's
|
|
679
703
|
* attention: once this gap exceeds {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}, every other
|
|
680
704
|
* machine in the party is already discarding this node's address as stale.
|
|
681
705
|
*/
|
|
@@ -1039,7 +1063,7 @@ export class CadreNode {
|
|
|
1039
1063
|
this.cadreInviteHandler = new CadreInviteHandler({
|
|
1040
1064
|
partyId: this.config.controlNetwork.partyId,
|
|
1041
1065
|
store: this.controlDatabase,
|
|
1042
|
-
|
|
1066
|
+
admitDevice: (peerId) => this.admitRedeemingDevice(peerId),
|
|
1043
1067
|
});
|
|
1044
1068
|
await this.cadreInviteHandler.register(this.controlNode);
|
|
1045
1069
|
// A node waiting to be claimed listens for its claim seed from the start: nobody
|
|
@@ -1128,10 +1152,26 @@ export class CadreNode {
|
|
|
1128
1152
|
* Stop the cadre node
|
|
1129
1153
|
*/
|
|
1130
1154
|
async stop() {
|
|
1155
|
+
// A second call while one is in flight joins it rather than running cleanup() twice
|
|
1156
|
+
// concurrently: `_running` stays true until cleanup() ends, and an event raised by a
|
|
1157
|
+
// refresh still in flight (`cadre:removed`, whose embedder stops the node) can reach a
|
|
1158
|
+
// handler that calls stop() mid-teardown.
|
|
1159
|
+
if (this.stopInFlight) {
|
|
1160
|
+
return this.stopInFlight;
|
|
1161
|
+
}
|
|
1131
1162
|
if (!this._running) {
|
|
1132
1163
|
log('CadreNode not running');
|
|
1133
1164
|
return;
|
|
1134
1165
|
}
|
|
1166
|
+
this.stopInFlight = this.stopRunning();
|
|
1167
|
+
try {
|
|
1168
|
+
await this.stopInFlight;
|
|
1169
|
+
}
|
|
1170
|
+
finally {
|
|
1171
|
+
this.stopInFlight = null;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
async stopRunning() {
|
|
1135
1175
|
log('Stopping CadreNode');
|
|
1136
1176
|
await this.cleanup();
|
|
1137
1177
|
this.emit('control:disconnected', undefined);
|
|
@@ -1301,7 +1341,7 @@ export class CadreNode {
|
|
|
1301
1341
|
* {@link start} has built the anchor.
|
|
1302
1342
|
*
|
|
1303
1343
|
* While true the node admits strangers' CONNECTIONS (the claim seed has to ride one)
|
|
1304
|
-
* and refuses
|
|
1344
|
+
* and refuses them control-database streams and cohort places ({@link isControlNetworkMember})
|
|
1305
1345
|
* and relay reservations ({@link admitControlRelayReservation}): an unclaimed node has
|
|
1306
1346
|
* no siblings to replicate from and must not become a public relay while it waits.
|
|
1307
1347
|
* cadre-cli reports this on `/status`; `claim:accepted` is the event form.
|
|
@@ -1309,6 +1349,15 @@ export class CadreNode {
|
|
|
1309
1349
|
isAwaitingClaim() {
|
|
1310
1350
|
return this.config.claim !== undefined && this.trustedOwnerStore?.all().size === 0;
|
|
1311
1351
|
}
|
|
1352
|
+
/**
|
|
1353
|
+
* The removal from the cadre this node has learned of, or null: the query form of
|
|
1354
|
+
* `cadre:removed`, for an embedder that subscribed after it fired. Kept once set, across
|
|
1355
|
+
* stop()→start() and a later re-admission, because the embedder acts on it once (stops this
|
|
1356
|
+
* node, deletes the cadre's data); a re-added machine joins again from a fresh node.
|
|
1357
|
+
*/
|
|
1358
|
+
removedFromCadre() {
|
|
1359
|
+
return this.removal;
|
|
1360
|
+
}
|
|
1312
1361
|
/**
|
|
1313
1362
|
* Construct (or adopt) the node-local cold-start bootstrap-peer store (see
|
|
1314
1363
|
* {@link bootstrapPeerStore}). Synchronous: an injected store has already
|
|
@@ -1433,6 +1482,38 @@ export class CadreNode {
|
|
|
1433
1482
|
getStrandNetworkStateStore() {
|
|
1434
1483
|
return this.strandNetworkStateStore;
|
|
1435
1484
|
}
|
|
1485
|
+
/**
|
|
1486
|
+
* The strands this machine joined from another party and has not published party-wide
|
|
1487
|
+
* (see {@link CadreNodeConfig.joinedStrands}). Publication needs a control connection,
|
|
1488
|
+
* so a one-node party's joins live here until it holds one; `exportStrandBundle` reads
|
|
1489
|
+
* it beside the `JoinedStrand` table.
|
|
1490
|
+
*
|
|
1491
|
+
* @throws when the node is not started.
|
|
1492
|
+
*/
|
|
1493
|
+
listUnpublishedJoinedStrands() {
|
|
1494
|
+
if (!this.joinedStrands) {
|
|
1495
|
+
throw new Error('CadreNode must be started before listing its unpublished joined strands');
|
|
1496
|
+
}
|
|
1497
|
+
return this.joinedStrands.unpublishedRecords();
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* The strand-network addresses a formation carried for `strandId` (see
|
|
1501
|
+
* {@link formationStrandAddrs}), as kept: attributed per peer. Empty when none were
|
|
1502
|
+
* carried or the strand's peers were forgotten. A copy; the node keeps its own list.
|
|
1503
|
+
*/
|
|
1504
|
+
getCarriedStrandAddrs(strandId) {
|
|
1505
|
+
return [...(this.formationStrandAddrs.get(strandId) ?? [])];
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Seat `addrs` as the addresses a formation carried for `strandId`, exactly as a
|
|
1509
|
+
* formation of this node's own would ({@link recordFormationStrandAddrs}): a strand
|
|
1510
|
+
* move seats the exporting node's list here so the first launch has a cross-party
|
|
1511
|
+
* seed. The usual rules apply: a list naming a peer replaces the strand's earlier
|
|
1512
|
+
* one, and a list naming none records nothing.
|
|
1513
|
+
*/
|
|
1514
|
+
recordCarriedStrandAddrs(strandId, addrs) {
|
|
1515
|
+
return this.recordFormationStrandAddrs(strandId, addrs, 'recordCarriedStrandAddrs');
|
|
1516
|
+
}
|
|
1436
1517
|
/**
|
|
1437
1518
|
* The node-local trusted-owner anchor (null before {@link start}). This is
|
|
1438
1519
|
* the set the authorized-membership predicate and seed-trust anchor consult —
|
|
@@ -1516,7 +1597,7 @@ export class CadreNode {
|
|
|
1516
1597
|
* connect-then-authorize — on every committed membership change via
|
|
1517
1598
|
* {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
|
|
1518
1599
|
* The one non-member the gate admits is a device whose invitation
|
|
1519
|
-
* redemption this member is admitting ({@link
|
|
1600
|
+
* redemption this member is admitting ({@link admitRedeemingDevice}).
|
|
1520
1601
|
*
|
|
1521
1602
|
* No-ops (logged) when the embedder configured no control storage or the
|
|
1522
1603
|
* node exposes no key network — the backfill would have nothing to read or
|
|
@@ -1573,29 +1654,50 @@ export class CadreNode {
|
|
|
1573
1654
|
this.controlBackfill.start();
|
|
1574
1655
|
}
|
|
1575
1656
|
/**
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1657
|
+
* Admit a device whose invitation redemption has verified to this member's control
|
|
1658
|
+
* replication until the returned release is called (the handler's `admitDevice`;
|
|
1659
|
+
* `CadreInviteHandler.admitDeviceIfLive` says why the redemption's writes need it): push it
|
|
1660
|
+
* this member's control store and wait for the push, with the device counted as a member by
|
|
1661
|
+
* the backfill's gate and by the control cohort choice ({@link admittingDevices}), so the
|
|
1662
|
+
* seat and the admission that follow are written to the device too. A failed push releases
|
|
1663
|
+
* at once and rethrows: in the cohort without the store, the device would refuse the
|
|
1664
|
+
* commits and tear the writes. With no backfill on this node nothing is admitted.
|
|
1581
1665
|
*
|
|
1582
1666
|
* Through {@link PeerJoinBackfill.forceCatchUpPeer}, not `catchUpPeer`: the device's own
|
|
1583
1667
|
* `peer:identify` scheduled a run a moment earlier, which was denied if it passed the gate
|
|
1584
1668
|
* before the device was added here, and `catchUpPeer` would answer that run's in-flight
|
|
1585
1669
|
* status with an empty result.
|
|
1586
1670
|
*/
|
|
1587
|
-
async
|
|
1671
|
+
async admitRedeemingDevice(peerId) {
|
|
1588
1672
|
const backfill = this.controlBackfill;
|
|
1589
1673
|
if (!backfill) {
|
|
1590
|
-
log('No control backfill on this node; device %s is
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1674
|
+
log('No control backfill on this node; device %s is neither caught up nor admitted before its admission', peerId);
|
|
1675
|
+
return () => undefined;
|
|
1676
|
+
}
|
|
1677
|
+
// NOTE: counted in before the push, because the backfill's gate must admit the device to
|
|
1678
|
+
// push to it; so for the push's length any other control write this member makes includes
|
|
1679
|
+
// a device without the base revisions, which refuses it and the write retries. If that
|
|
1680
|
+
// shows up as retried control writes during redemptions, count the device into the cohort
|
|
1681
|
+
// choice only once the push resolves (a second set beside this one).
|
|
1682
|
+
const release = this.countAdmittingDevice(peerId);
|
|
1594
1683
|
try {
|
|
1595
1684
|
const result = await backfill.forceCatchUpPeer(peerIdFromString(peerId));
|
|
1596
1685
|
log('Device %s caught up before its admission: offered=%d accepted=%d rejected=%d', peerId, result.offered, result.accepted, result.rejected.length);
|
|
1686
|
+
return release;
|
|
1597
1687
|
}
|
|
1598
|
-
|
|
1688
|
+
catch (error) {
|
|
1689
|
+
release();
|
|
1690
|
+
throw error;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
/** Count one more redemption admitting `peerId`; the returned release undoes it, once. */
|
|
1694
|
+
countAdmittingDevice(peerId) {
|
|
1695
|
+
this.admittingDevices.set(peerId, (this.admittingDevices.get(peerId) ?? 0) + 1);
|
|
1696
|
+
let released = false;
|
|
1697
|
+
return () => {
|
|
1698
|
+
if (released)
|
|
1699
|
+
return;
|
|
1700
|
+
released = true;
|
|
1599
1701
|
const remaining = (this.admittingDevices.get(peerId) ?? 1) - 1;
|
|
1600
1702
|
if (remaining > 0) {
|
|
1601
1703
|
this.admittingDevices.set(peerId, remaining);
|
|
@@ -1603,7 +1705,7 @@ export class CadreNode {
|
|
|
1603
1705
|
else {
|
|
1604
1706
|
this.admittingDevices.delete(peerId);
|
|
1605
1707
|
}
|
|
1606
|
-
}
|
|
1708
|
+
};
|
|
1607
1709
|
}
|
|
1608
1710
|
/**
|
|
1609
1711
|
* The control database's cache-wrapped raw storage, resolved from
|
|
@@ -1747,7 +1849,13 @@ export class CadreNode {
|
|
|
1747
1849
|
}),
|
|
1748
1850
|
arachnode: { enableRingZulu: profile === 'storage' },
|
|
1749
1851
|
...(identityKey && { privateKey: identityKey }),
|
|
1750
|
-
|
|
1852
|
+
// With `network.relayAddrs` set, built with libp2p's relay discovery off (given a
|
|
1853
|
+
// transport list to wrap — `node-transports.ts`), so the supervisor
|
|
1854
|
+
// `driveControlRelayReservation` starts is the only thing that fills the search slot:
|
|
1855
|
+
// otherwise a member's control node running the relay server, which this node
|
|
1856
|
+
// identifies (same party namespace), can take it first (`relay-reservation.ts`,
|
|
1857
|
+
// "WHY LIBP2P'S RELAY DISCOVERY IS SWITCHED OFF").
|
|
1858
|
+
...nodeTransportsOption(network, transportOptions, (network?.relayAddrs ?? []).length > 0),
|
|
1751
1859
|
...(network?.noiseCrypto && { noiseCrypto: network.noiseCrypto }),
|
|
1752
1860
|
// Always present, unlike the conditional spreads around it: an absent
|
|
1753
1861
|
// `connectionMonitor` would leave libp2p's 5s ping deadline, which drops a peer
|
|
@@ -1766,7 +1874,9 @@ export class CadreNode {
|
|
|
1766
1874
|
...optimysticDialLimits(network?.linkRoundTripMs),
|
|
1767
1875
|
// `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
|
|
1768
1876
|
// `{}` when `network.transports` is set, since the embedder owns transport policy
|
|
1769
|
-
// then. Spread NEXT to `transports` because the two answer the same question
|
|
1877
|
+
// then. Spread NEXT to `transports` because the two answer the same question, and a
|
|
1878
|
+
// no-op whenever a list is passed there — with `relayAddrs` set that list comes from
|
|
1879
|
+
// `network.defaultTransports` when the embedder gave none, on the same switch.
|
|
1770
1880
|
...transportOptions,
|
|
1771
1881
|
// Configured `listenAddrs`, plus the bare `/p2p-circuit` search listener when
|
|
1772
1882
|
// `network.relayAddrs` is set — that listener registers the pending reservation
|
|
@@ -1796,7 +1906,14 @@ export class CadreNode {
|
|
|
1796
1906
|
// (installControlProtocolGuard) checks these four with this same predicate
|
|
1797
1907
|
// first; Optimystic's own check is kept because it is a working, supported
|
|
1798
1908
|
// upstream defence, not a second policy.
|
|
1799
|
-
authorizeInboundStream: (remotePeerId, protocol) => this.authorizeInboundControlStream(remotePeerId, protocol)
|
|
1909
|
+
authorizeInboundStream: (remotePeerId, protocol) => this.authorizeInboundControlStream(remotePeerId, protocol),
|
|
1910
|
+
// Who this node SENDS control writes to (cohort and coordinator choice), where
|
|
1911
|
+
// `authorizeInboundStream` is who may send to it. Without it Optimystic picks cohort
|
|
1912
|
+
// members from the protocols a peer's peer-store record lists, which any connected
|
|
1913
|
+
// peer can fill. The stream gate's rule, unlogged (Optimystic asks several times per
|
|
1914
|
+
// peer per cohort assembly and counts refusals on its own debug line), plus a device
|
|
1915
|
+
// whose verified redemption is being written, which must hold the rows that admit it.
|
|
1916
|
+
isNetworkMember: (peerId) => this.isControlNetworkMember(peerId) || this.admittingDevices.has(peerId)
|
|
1800
1917
|
};
|
|
1801
1918
|
return nodeOptions;
|
|
1802
1919
|
}
|
|
@@ -2027,13 +2144,17 @@ export class CadreNode {
|
|
|
2027
2144
|
return this.getBootstrapPeerIds().has(remotePeerId);
|
|
2028
2145
|
}
|
|
2029
2146
|
/**
|
|
2030
|
-
*
|
|
2031
|
-
*
|
|
2032
|
-
*
|
|
2033
|
-
*
|
|
2034
|
-
*
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2147
|
+
* The membership rule this control node applies to a remote peer, without logging. Two
|
|
2148
|
+
* consumers: the per-stream gate ({@link authorizeInboundControlStream}, who may SEND to
|
|
2149
|
+
* this node on a members-only protocol) and Optimystic's `isNetworkMember` (who this node
|
|
2150
|
+
* sends control writes TO — which peers can be a control block's cohort member or
|
|
2151
|
+
* coordinator), which adds only a device whose verified invitation redemption is being
|
|
2152
|
+
* written ({@link admittingDevices}). One rule for both keeps the outbound choice as
|
|
2153
|
+
* permissive as the inbound acceptance: a joining node's cohort must include the inviter
|
|
2154
|
+
* its empty anchor or empty snapshot lets replicate to it, or it would see a self-only
|
|
2155
|
+
* cohort, rule an existing collection absent and found a second copy. Sereus builds the
|
|
2156
|
+
* control node with `bootstrapNodes: []`, so Optimystic's refused-bootstrap-peer guard
|
|
2157
|
+
* does not cover that.
|
|
2037
2158
|
*
|
|
2038
2159
|
* The STRICT SUBSET of {@link admitInboundControlConnection}: the same
|
|
2039
2160
|
* "no basis to judge" admissions (shared via
|
|
@@ -2042,57 +2163,74 @@ export class CadreNode {
|
|
|
2042
2163
|
* grant). The first two exist so a stranger can reach `/sereus/formation/1.0.0`
|
|
2043
2164
|
* and `/sereus/cadre-invite/1.0.0` — neither is gated here, and admitting a
|
|
2044
2165
|
* stranger to `repo` while an invitation is live is exactly the hole this
|
|
2045
|
-
*
|
|
2166
|
+
* rule closes. A DELEGATE-admitted connection (a member's strand node
|
|
2046
2167
|
* holding a circuit-relay reservation, see `delegate-admission.ts`) is
|
|
2047
|
-
* likewise
|
|
2048
|
-
* the connection and never the control DB.
|
|
2168
|
+
* likewise refused: the delegate gets the connection and never the control DB.
|
|
2049
2169
|
*
|
|
2050
2170
|
* Refuses everyone, ahead of every admission below, while this node is waiting to be
|
|
2051
2171
|
* claimed ({@link isAwaitingClaim}). The empty-anchor and empty-snapshot admissions
|
|
2052
2172
|
* exist so a brand-new node can take replication from the siblings that will
|
|
2053
2173
|
* authorize it; an unclaimed node has no siblings yet, and the rows that will
|
|
2054
2174
|
* authorize anyone arrive only after the claim, over the owner's streams that the
|
|
2055
|
-
* same two admissions then let in.
|
|
2056
|
-
*
|
|
2175
|
+
* same two admissions then let in. Its cohort is itself alone meanwhile, which costs
|
|
2176
|
+
* nothing: it built its control database with zero connections. Configured bootstrap
|
|
2177
|
+
* infra is refused too: a relay opens no control-database stream.
|
|
2057
2178
|
*
|
|
2058
2179
|
* Otherwise admits when ANY of:
|
|
2059
2180
|
* 1. a shared-baseline check admits (not running / no DB, empty anchor,
|
|
2060
|
-
* configured bootstrap infra
|
|
2181
|
+
* configured bootstrap infra — which Optimystic still classifies `foreign`,
|
|
2182
|
+
* since a relay serves none of the party's database protocols);
|
|
2061
2183
|
* 2. the materialized authorized set is empty — cold start, before the
|
|
2062
2184
|
* rows that would authorize anyone have replicated in;
|
|
2063
2185
|
* 3. the peer IS in the materialized authorized set.
|
|
2064
2186
|
*
|
|
2065
2187
|
* Deliberately SYNCHRONOUS and pure in-memory — see
|
|
2066
2188
|
* {@link authorizedControlPeers} for why a control-DB read here would
|
|
2067
|
-
* deadlock into mutual denial.
|
|
2068
|
-
* (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its
|
|
2069
|
-
* default.
|
|
2189
|
+
* deadlock into mutual denial.
|
|
2070
2190
|
*
|
|
2071
2191
|
* NOTE: the snapshot keys on PEER ID, so a node with no persistent identity
|
|
2072
2192
|
* key never lands a `CadrePeer` row ({@link registerSelf} skips) and its
|
|
2073
|
-
* siblings deny its control-DB streams
|
|
2074
|
-
* owner status is a KEY, not a peer id, so
|
|
2075
|
-
* Harmless today (a real deployment persists
|
|
2076
|
-
* case appears only in tests, which configure
|
|
2077
|
-
* `push-wake-e2e.integration.ts` design note 3).
|
|
2078
|
-
* ever becomes a supported deployment, this
|
|
2079
|
-
|
|
2080
|
-
|
|
2193
|
+
* siblings deny its control-DB streams, and leave it out of their cohorts, once
|
|
2194
|
+
* their own snapshot is non-empty — owner status is a KEY, not a peer id, so
|
|
2195
|
+
* being the owner does not exempt it. Harmless today (a real deployment persists
|
|
2196
|
+
* its identity; the ephemeral-owner case appears only in tests, which configure
|
|
2197
|
+
* the owner as bootstrap infra — `push-wake-e2e.integration.ts` design note 3).
|
|
2198
|
+
* If an ephemeral-identity node ever becomes a supported deployment, this rule
|
|
2199
|
+
* needs a key-based admission.
|
|
2200
|
+
*/
|
|
2201
|
+
isControlNetworkMember(remotePeerId) {
|
|
2081
2202
|
if (this.isAwaitingClaim()) {
|
|
2082
|
-
log('authorizeInboundControlStream: DENYING %s on %s — this node is unclaimed and replicates with nobody until it is', remotePeerId, protocol);
|
|
2083
2203
|
return false;
|
|
2084
2204
|
}
|
|
2085
2205
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
2086
2206
|
return true;
|
|
2087
2207
|
}
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2208
|
+
return this.authorizedControlPeers.size === 0 || this.authorizedControlPeers.has(remotePeerId);
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Per-stream authorization for the protocol guard's `members-snapshot` class
|
|
2212
|
+
* (`control-protocol-guard.ts`): the four Optimystic control-DB protocols
|
|
2213
|
+
* (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), FRET's
|
|
2214
|
+
* five, and any protocol nobody classed. Also wired as Optimystic's
|
|
2215
|
+
* `authorizeInboundStream` in {@link createControlNode} for the four DB
|
|
2216
|
+
* protocols. The fail-closed layer behind the fail-open connection gater.
|
|
2217
|
+
* Control node ONLY: strand cohort nodes legitimately serve cross-party peers.
|
|
2218
|
+
*
|
|
2219
|
+
* Admits exactly the peers {@link isControlNetworkMember} does, and logs why it
|
|
2220
|
+
* refused one. With a sync predicate the upstream deadline
|
|
2221
|
+
* (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its default.
|
|
2222
|
+
*/
|
|
2223
|
+
authorizeInboundControlStream(remotePeerId, protocol) {
|
|
2224
|
+
if (this.isControlNetworkMember(remotePeerId)) {
|
|
2092
2225
|
return true;
|
|
2093
2226
|
}
|
|
2094
2227
|
// Upstream logs its own denial line; this one carries the WHY.
|
|
2095
|
-
|
|
2228
|
+
if (this.isAwaitingClaim()) {
|
|
2229
|
+
log('authorizeInboundControlStream: DENYING %s on %s — this node is unclaimed and replicates with nobody until it is', remotePeerId, protocol);
|
|
2230
|
+
}
|
|
2231
|
+
else {
|
|
2232
|
+
log('authorizeInboundControlStream: DENYING %s on %s — not in the materialized authorized set (%d member(s))', remotePeerId, protocol, this.authorizedControlPeers.size);
|
|
2233
|
+
}
|
|
2096
2234
|
return false;
|
|
2097
2235
|
}
|
|
2098
2236
|
/**
|
|
@@ -2112,7 +2250,9 @@ export class CadreNode {
|
|
|
2112
2250
|
*
|
|
2113
2251
|
* The trusted-owner anchor sync ({@link syncOwnerAnchor}) runs FIRST, so the snapshot
|
|
2114
2252
|
* built here judges `CadrePeer` rows against the anchor as the `OwnerKey` table now
|
|
2115
|
-
* has it. The invitation chain both reads may need
|
|
2253
|
+
* has it. The invitation chain both reads may need, and the tombstone list the sync and
|
|
2254
|
+
* the two end-of-refresh checks ({@link hangUpRevokedControlPeers}, then
|
|
2255
|
+
* {@link noteOwnRemoval}) read, are each loaded at most once per refresh.
|
|
2116
2256
|
*/
|
|
2117
2257
|
async refreshAuthorizedControlPeers(reason) {
|
|
2118
2258
|
if (!this._running || !this.controlDatabase) {
|
|
@@ -2120,8 +2260,10 @@ export class CadreNode {
|
|
|
2120
2260
|
}
|
|
2121
2261
|
try {
|
|
2122
2262
|
const loadChain = this.invitationChainSource(this.controlDatabase, false);
|
|
2123
|
-
|
|
2124
|
-
|
|
2263
|
+
const loadTombstones = this.revocationSource(this.controlDatabase, false);
|
|
2264
|
+
await this.syncOwnerAnchor(reason, false, loadChain, loadTombstones);
|
|
2265
|
+
const rows = await this.controlDatabase.queryCadrePeers(false);
|
|
2266
|
+
const members = this.authorizedAmong(rows, await this.chainFor(rows, loadChain));
|
|
2125
2267
|
this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
|
|
2126
2268
|
log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
|
|
2127
2269
|
// Remember the party's size for the NEXT launch's control-node repair yardstick
|
|
@@ -2188,11 +2330,136 @@ export class CadreNode {
|
|
|
2188
2330
|
// re-arming peers whose last pass was denied under the SAME membership
|
|
2189
2331
|
// snapshot instead of re-arming unconditionally.
|
|
2190
2332
|
void this.controlBackfill?.scheduleConnectedPeers();
|
|
2333
|
+
await this.hangUpRevokedControlPeers(rows, loadTombstones);
|
|
2334
|
+
await this.noteOwnRemoval(rows, loadTombstones);
|
|
2191
2335
|
}
|
|
2192
2336
|
catch (error) {
|
|
2193
2337
|
log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
|
|
2194
2338
|
}
|
|
2195
2339
|
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Hang up this node's control connections to every peer that a `CadrePeer` tombstone names
|
|
2342
|
+
* and that has no live `CadrePeer` row: a machine removed from the cadre, whose streams the
|
|
2343
|
+
* protocol guard already refuses but whose open connection would otherwise stay up until it
|
|
2344
|
+
* dropped on its own. Runs at the end of every membership refresh: on the remover from the
|
|
2345
|
+
* removal's own membership notification, so the connection is closed before
|
|
2346
|
+
* {@link removeMachine} returns; on every other machine from the refresh that follows the
|
|
2347
|
+
* replicated tombstone.
|
|
2348
|
+
*
|
|
2349
|
+
* Denies only on evidence held, the strand enforcer's rule (`strand-revocation-enforcer.ts`):
|
|
2350
|
+
* a peer that merely dropped out of the authorized snapshot (an anchor read that failed, a
|
|
2351
|
+
* voucher whose owner is gone) keeps its connection, self is never hung up, and a peer with a
|
|
2352
|
+
* tombstone AND a live row under a fresh stamp was re-admitted and keeps its connection too.
|
|
2353
|
+
* The tombstones are the refresh's own read (`loadTombstones`, shared with
|
|
2354
|
+
* {@link syncOwnerAnchor}), taken only when some connected peer has no live row, so a cadre
|
|
2355
|
+
* with every connection accounted for costs nothing here. A failed `hangUp` is logged and
|
|
2356
|
+
* retried by the next refresh. Never rejects.
|
|
2357
|
+
*/
|
|
2358
|
+
async hangUpRevokedControlPeers(rows, loadTombstones) {
|
|
2359
|
+
const node = this.controlNode;
|
|
2360
|
+
if (!node) {
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
const selfPeerId = node.peerId.toString();
|
|
2364
|
+
const live = new Set(rows.map((row) => row.peerId));
|
|
2365
|
+
const rowless = new Map();
|
|
2366
|
+
for (const connection of node.getConnections()) {
|
|
2367
|
+
const remote = connection.remotePeer.toString();
|
|
2368
|
+
if (remote !== selfPeerId && !live.has(remote)) {
|
|
2369
|
+
rowless.set(remote, connection.remotePeer);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
if (rowless.size === 0) {
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
// NOTE: on a node whose anchor is empty the anchor sync skips its tombstone read, so a
|
|
2376
|
+
// connection to a peer with no row (a configured relay or bootstrap peer) makes this the
|
|
2377
|
+
// refresh's one `Revocation` read; bounded at one per refresh. If an un-enrolled node's
|
|
2378
|
+
// refresh ever shows up in the consult budget, skip this read while the anchor is empty.
|
|
2379
|
+
let revoked;
|
|
2380
|
+
try {
|
|
2381
|
+
revoked = new Set((await loadTombstones()).filter((tombstone) => tombstone.tableName === 'CadrePeer').map((tombstone) => tombstone.rowKey));
|
|
2382
|
+
}
|
|
2383
|
+
catch (error) {
|
|
2384
|
+
log('membership refresh: tombstone read for the hang-up of removed peers failed; next refresh retries: %o', error);
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
// NOTE: awaited inside the membership listener, so under the control database's write
|
|
2388
|
+
// lock; libp2p bounds each connection's close at its 1 s `connectionCloseTimeout`, so a
|
|
2389
|
+
// silent peer holds local writes for at most that. If several removed peers ever pile up
|
|
2390
|
+
// here, hang them up concurrently or move the call after the listener returns.
|
|
2391
|
+
for (const [remote, peer] of rowless) {
|
|
2392
|
+
if (!revoked.has(remote)) {
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
try {
|
|
2396
|
+
await node.hangUp(peer);
|
|
2397
|
+
log('membership refresh: hung up %s (its CadrePeer row is tombstoned and no live row replaced it)', remote);
|
|
2398
|
+
}
|
|
2399
|
+
catch (error) {
|
|
2400
|
+
log('membership refresh: hangUp of removed peer %s failed; next refresh retries: %o', remote, error);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Learn that an owner removed this node from the cadre, at the end of every membership
|
|
2406
|
+
* refresh: a `CadrePeer` tombstone names this node's peer id, `rows` (the refresh's own
|
|
2407
|
+
* retired-stamp-filtered read) has no live row for it, and an earlier refresh of this process
|
|
2408
|
+
* saw one ({@link ownLiveRow}). Then it records {@link removal} and emits `cadre:removed`,
|
|
2409
|
+
* once. Nothing is stopped here: stopping and deleting are the embedder's, whose storage,
|
|
2410
|
+
* saved starts and process lifetime they are.
|
|
2411
|
+
*
|
|
2412
|
+
* The "seen live" condition keeps a machine joining again, whose fresh store can sync the old
|
|
2413
|
+
* tombstone before its new row, from reporting itself removed. A re-admission under a fresh
|
|
2414
|
+
* stamp is a live row, so a tombstone plus a live row is never a removal. The tombstones are
|
|
2415
|
+
* the refresh's shared read, taken only once the own row is missing. Never rejects.
|
|
2416
|
+
*/
|
|
2417
|
+
async noteOwnRemoval(rows, loadTombstones) {
|
|
2418
|
+
const selfPeerId = this.controlNode?.peerId.toString();
|
|
2419
|
+
if (selfPeerId === undefined || this.removal !== null) {
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
const own = rows.find((row) => row.peerId === selfPeerId);
|
|
2423
|
+
if (own) {
|
|
2424
|
+
this.ownLiveRow = own;
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
// NOTE: a machine that synced its tombstone and restarted before its embedder acted never
|
|
2428
|
+
// reports, since the new process never saw its row live. The embedders persist what they
|
|
2429
|
+
// did (a marker, a deleted party), so the window is a crash between this event and that
|
|
2430
|
+
// action; if it is ever hit, persist the removal here.
|
|
2431
|
+
const lastLive = this.ownLiveRow;
|
|
2432
|
+
if (lastLive === null) {
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
let tombstones;
|
|
2436
|
+
try {
|
|
2437
|
+
tombstones = (await loadTombstones()).filter((tombstone) => tombstone.tableName === 'CadrePeer' && tombstone.rowKey === selfPeerId);
|
|
2438
|
+
}
|
|
2439
|
+
catch (error) {
|
|
2440
|
+
log('membership refresh: tombstone read for this node\'s own removal failed; next refresh retries: %o', error);
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
if (tombstones.length === 0 || !this._running || !this.controlDatabase) {
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
const tombstone = tombstones.find((candidate) => candidate.stampId === lastLive.stampId) ?? tombstones[0];
|
|
2447
|
+
this.removal = { partyId: this.partyId, stampId: tombstone.stampId, signerKey: this.anchoredRevocationSigner(tombstone), at: Date.now() };
|
|
2448
|
+
log('membership refresh: this node was removed from cadre %s (tombstone %s, signer %s)', this.partyId, tombstone.stampId, tombstone.signerKey);
|
|
2449
|
+
this.emit('cadre:removed', this.removal);
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* The tombstone's signer when its signature verifies and the key is in this node's
|
|
2453
|
+
* trusted-owner anchor, else null. A tombstone that fails this is still a removal (every
|
|
2454
|
+
* other machine honours it, and this node's own row is gone), so it is logged, not refused.
|
|
2455
|
+
*/
|
|
2456
|
+
anchoredRevocationSigner(tombstone) {
|
|
2457
|
+
if (verifyRevocationSigner(this.partyId, tombstone) && this.trustedOwnerStore?.has(tombstone.signerKey)) {
|
|
2458
|
+
return tombstone.signerKey;
|
|
2459
|
+
}
|
|
2460
|
+
log('membership refresh: the tombstone removing this node (%s) is not signed by an owner anchored here (signer %s)', tombstone.stampId, tombstone.signerKey);
|
|
2461
|
+
return null;
|
|
2462
|
+
}
|
|
2196
2463
|
/**
|
|
2197
2464
|
* Add the configured `controlNetwork.bootstrapNodes` to the control node's address book
|
|
2198
2465
|
* and dial each peer once — what `@libp2p/bootstrap` would have done had the node been
|
|
@@ -2323,12 +2590,31 @@ export class CadreNode {
|
|
|
2323
2590
|
* reference apps), and a write it signed would only be refused.
|
|
2324
2591
|
*/
|
|
2325
2592
|
async enrolledOwnerSigningKey() {
|
|
2326
|
-
const signingKey = this.
|
|
2593
|
+
const signingKey = this.ownerSigningKeyCandidate();
|
|
2327
2594
|
if (!signingKey || !this.controlDatabase) {
|
|
2328
2595
|
return null;
|
|
2329
2596
|
}
|
|
2330
2597
|
return (await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64) ? signingKey : null;
|
|
2331
2598
|
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Whether this node can act as an owner of its cadre: its identity key is wired as its
|
|
2601
|
+
* owner signing key ({@link initializeSeedBootstrap}) and enrolled in the live `OwnerKey`
|
|
2602
|
+
* set ({@link enrolledOwnerSigningKey}). What the owner-only flows (`createCadreInvitation`,
|
|
2603
|
+
* `claimNode`, `rentNode`) need; false before `start()`, and on a machine that joined as a
|
|
2604
|
+
* member. The reference apps' "is this device an owner" check.
|
|
2605
|
+
*/
|
|
2606
|
+
async isOwner() {
|
|
2607
|
+
return (await this.enrolledOwnerSigningKey()) !== null;
|
|
2608
|
+
}
|
|
2609
|
+
/** {@link enrolledOwnerSigningKey} judged against an owner set the caller has already read. */
|
|
2610
|
+
ownerSigningKeyEnrolledIn(ownerKeys) {
|
|
2611
|
+
const signingKey = this.ownerSigningKeyCandidate();
|
|
2612
|
+
return signingKey !== null && ownerKeys.has(signingKey.publicKeyB64) ? signingKey : null;
|
|
2613
|
+
}
|
|
2614
|
+
/** This machine's identity key as an owner signing key, when its seed bootstrap is wired to sign; enrollment not yet checked. */
|
|
2615
|
+
ownerSigningKeyCandidate() {
|
|
2616
|
+
return this.seedBootstrapService?.canAuthorize() ? this.getSelfSigningKey() : null;
|
|
2617
|
+
}
|
|
2332
2618
|
/**
|
|
2333
2619
|
* Schedule the node's initial self-record publish + ongoing refresh shortly
|
|
2334
2620
|
* after start (non-blocking). {@link registerSelf} is idempotent and safely
|
|
@@ -2342,12 +2628,11 @@ export class CadreNode {
|
|
|
2342
2628
|
await this.registerSelf();
|
|
2343
2629
|
}
|
|
2344
2630
|
catch (error) {
|
|
2345
|
-
// Background task — a failed publish must not crash the node.
|
|
2346
|
-
//
|
|
2347
|
-
//
|
|
2348
|
-
//
|
|
2349
|
-
//
|
|
2350
|
-
// retry once here.
|
|
2631
|
+
// Background task — a failed publish must not crash the node. A restarted machine
|
|
2632
|
+
// whose own CadrePeer row replicates in while this update runs fails here with
|
|
2633
|
+
// ConcurrentModificationError (seen on the owner's restart in every traced run of
|
|
2634
|
+
// cadre-invite-any-member); the next connected reconcile pass retries
|
|
2635
|
+
// (publishFirstSelfRecord).
|
|
2351
2636
|
log('Self-registration failed: %o', error);
|
|
2352
2637
|
}
|
|
2353
2638
|
// Wire the ongoing refresh + control-cohort reconcile cadence, then run
|
|
@@ -2413,23 +2698,26 @@ export class CadreNode {
|
|
|
2413
2698
|
return 'skipped';
|
|
2414
2699
|
}
|
|
2415
2700
|
const peerId = this.controlNode.peerId.toString();
|
|
2416
|
-
const addrs = await this.collectSelfAddrs();
|
|
2417
2701
|
const existing = await this.controlDatabase.queryPeerRecord(peerId);
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2702
|
+
// A listener-only service (`enableSeedListener`, a claim node's) holds no owner key and
|
|
2703
|
+
// cannot sign the insert, so it is "no owner service" here too.
|
|
2704
|
+
const ownerService = this.seedBootstrapService?.canAuthorize() ? this.seedBootstrapService : null;
|
|
2705
|
+
if (!existing && !ownerService) {
|
|
2706
|
+
// "No row" also covers "revoked": queryPeerRecord reads a row whose StampId is
|
|
2707
|
+
// retired in Revocation as absent, so a removed node lands here every heartbeat
|
|
2708
|
+
// and stops refreshing its own record — correct, but say so, or the message reads
|
|
2709
|
+
// as "never added" for a node that was removed.
|
|
2710
|
+
log('registerSelf: no readable CadrePeer row for self (never added, or removed and its stamp retired) and no owner service to self-insert; skipping');
|
|
2711
|
+
return 'skipped';
|
|
2712
|
+
}
|
|
2713
|
+
// Collected only past that skip, which a node waiting for its row replicated in takes on
|
|
2714
|
+
// every connected reconcile pass ({@link publishFirstSelfRecord}).
|
|
2715
|
+
const addrs = await this.collectSelfAddrs();
|
|
2716
|
+
if (!existing && ownerService) {
|
|
2429
2717
|
// First-time row: requires an owner signature (the node is its own
|
|
2430
2718
|
// owner). insertSelfPeerRecord throws if no owner key is present.
|
|
2431
2719
|
const record = this.signSelfRecord(peerId, signingKey, addrs, null);
|
|
2432
|
-
if (await
|
|
2720
|
+
if (await ownerService.insertSelfPeerRecord(record)) {
|
|
2433
2721
|
if (this.committedAlone())
|
|
2434
2722
|
this.pendingSelfPeerWrite = true;
|
|
2435
2723
|
this.noteSelfRecordPublished();
|
|
@@ -2553,7 +2841,7 @@ export class CadreNode {
|
|
|
2553
2841
|
*/
|
|
2554
2842
|
signSelfRecord(peerId, signingKey, addrs, existing) {
|
|
2555
2843
|
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
2556
|
-
return signPeerRecord(this.partyId, { peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt }, signingKey.privateKeyB64);
|
|
2844
|
+
return signPeerRecord(this.partyId, { peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt, profile: this.config.profile }, signingKey.privateKeyB64);
|
|
2557
2845
|
}
|
|
2558
2846
|
/**
|
|
2559
2847
|
* The ed25519 keypair (base64url) the node signs its own record with — the key
|
|
@@ -2629,25 +2917,39 @@ export class CadreNode {
|
|
|
2629
2917
|
return ed25519KeyPairFromLibp2p(this.identityKey);
|
|
2630
2918
|
}
|
|
2631
2919
|
/**
|
|
2632
|
-
* Publish this node's own address record from the reconcile pass
|
|
2633
|
-
*
|
|
2634
|
-
*
|
|
2635
|
-
*
|
|
2636
|
-
*
|
|
2637
|
-
*
|
|
2638
|
-
*
|
|
2639
|
-
*
|
|
2640
|
-
*
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2920
|
+
* Publish this node's own address record from the reconcile pass until its first publish of
|
|
2921
|
+
* this session lands. A node with no owner key — seeded (`addDrone`, `cadre enroll add`),
|
|
2922
|
+
* claimed (`claimNode`) or otherwise authorized after it booted — cannot insert its own
|
|
2923
|
+
* `CadrePeer` row; the row an owner wrote for it reaches it by replication once the two
|
|
2924
|
+
* connect, usually after the boot-time publish and the first-growth drain have both found
|
|
2925
|
+
* nothing to publish. Without this the record would wait for the heartbeat
|
|
2926
|
+
* ({@link startRecordRefresh}), and the cadre could not resolve the node's addresses until
|
|
2927
|
+
* then. The reconcile pass is where replicated membership is picked up, so each pass tries.
|
|
2928
|
+
* It also retries an owner's boot-time publish that failed.
|
|
2929
|
+
*
|
|
2930
|
+
* A try before the row has arrived is a `skipped` publish: one `CadrePeer` read. Not tried
|
|
2931
|
+
* while the node waits to be claimed (it has no row and no party yet) or once it has learned
|
|
2932
|
+
* it was removed (nothing to publish). A removed node that never saw its own row live in this
|
|
2933
|
+
* process does not learn it was removed ({@link noteOwnRemoval}), and pays that read on every
|
|
2934
|
+
* pass.
|
|
2935
|
+
*
|
|
2936
|
+
* Connected-only: a row written elsewhere arrives over a connection, and an owner's publish
|
|
2937
|
+
* committed alone is local-only and forks its own history — a restarted owner whose embedder
|
|
2938
|
+
* runs a pass before it reconnects would write its row on the stale side of the fork, and
|
|
2939
|
+
* then never see rows written while it was down (`cadre-invite-any-member` runs that pass and
|
|
2940
|
+
* fails every time without this condition). The boot-time publish and the first-growth drain already try at the node's lone and
|
|
2941
|
+
* first-connection moments; this retries on the passes after.
|
|
2942
|
+
*/
|
|
2943
|
+
async publishFirstSelfRecord() {
|
|
2944
|
+
if (this.lastSelfRecordPublishAt !== null || this.isAwaitingClaim() || this.removal !== null
|
|
2945
|
+
|| this.getControlConnectionCount() === 0) {
|
|
2644
2946
|
return;
|
|
2645
2947
|
}
|
|
2646
2948
|
const outcome = await this.registerSelf().catch((error) => {
|
|
2647
|
-
log('reconcileControlCohort:
|
|
2949
|
+
log('reconcileControlCohort: first self-record publish failed (continuing): %o', error);
|
|
2648
2950
|
return 'skipped';
|
|
2649
2951
|
});
|
|
2650
|
-
log('reconcileControlCohort:
|
|
2952
|
+
log('reconcileControlCohort: first self-record publish: %s', outcome);
|
|
2651
2953
|
}
|
|
2652
2954
|
/**
|
|
2653
2955
|
* Collect this node's current dialable addresses for publication, signaling
|
|
@@ -2690,25 +2992,21 @@ export class CadreNode {
|
|
|
2690
2992
|
reconcile('self:peer:update');
|
|
2691
2993
|
};
|
|
2692
2994
|
this.controlNode.addEventListener('self:peer:update', this.selfPeerUpdateHandler);
|
|
2693
|
-
//
|
|
2694
|
-
//
|
|
2695
|
-
//
|
|
2696
|
-
//
|
|
2697
|
-
//
|
|
2698
|
-
//
|
|
2699
|
-
//
|
|
2700
|
-
//
|
|
2701
|
-
//
|
|
2702
|
-
//
|
|
2703
|
-
//
|
|
2704
|
-
//
|
|
2705
|
-
//
|
|
2706
|
-
//
|
|
2707
|
-
//
|
|
2708
|
-
// the membership-change seam (`ControlDatabase.setMembershipChangeListener`) fires
|
|
2709
|
-
// on LOCAL commits only, and the row a late-authorized node waits for is written
|
|
2710
|
-
// elsewhere. Measured while writing `relay-only-control-addr.integration.ts` case 4,
|
|
2711
|
-
// which drives `registerSelf()` explicitly because neither trigger is dependable there.
|
|
2995
|
+
// A node AUTHORIZED AFTER IT BOOTED (seeded, claimed, invited) gets its first
|
|
2996
|
+
// publish from the reconcile pass ({@link publishFirstSelfRecord}), not from the
|
|
2997
|
+
// two triggers wired here. `registerSelf` no-ops while a node has no readable
|
|
2998
|
+
// `CadrePeer` row, so its boot-time publish does nothing, and neither trigger is
|
|
2999
|
+
// dependable after that: `self:peer:update` needs an ADDRESS CHANGE, and a
|
|
3000
|
+
// relay-only node's addresses stop changing the moment its reservation lands —
|
|
3001
|
+
// inside `start()`, before this listener exists (on the `network.requireRelay:
|
|
3002
|
+
// false` posture the reservation may instead land LATER, from the retry
|
|
3003
|
+
// supervisor, and then this listener is what publishes the `/p2p-circuit` address
|
|
3004
|
+
// the boot-time publish could not carry); the heartbeat is 7.5 min away. Nor is
|
|
3005
|
+
// the membership-change seam (`ControlDatabase.setMembershipChangeListener`): it
|
|
3006
|
+
// fires on LOCAL commits only, and the row a late-authorized node waits for is
|
|
3007
|
+
// written elsewhere. Measured while writing `relay-only-control-addr.integration.ts`
|
|
3008
|
+
// case 4, which drives `registerSelf()` explicitly because neither trigger is
|
|
3009
|
+
// dependable there.
|
|
2712
3010
|
this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
|
|
2713
3011
|
this.recordRefreshTimer?.unref?.();
|
|
2714
3012
|
const reconcileMs = this.config.network?.controlCohort?.reconcileMs ?? DEFAULT_CONTROL_COHORT_RECONCILE_MS;
|
|
@@ -2863,8 +3161,9 @@ export class CadreNode {
|
|
|
2863
3161
|
* ({@link bootstrapDialAddrs}).
|
|
2864
3162
|
*
|
|
2865
3163
|
* An address naming a DIFFERENT trailing peer id is dropped: it does not reach
|
|
2866
|
-
* `peerId`, so it does not belong in this peer's candidate list (
|
|
2867
|
-
*
|
|
3164
|
+
* `peerId`, so it does not belong in this peer's candidate list (`dialPeerAddrs`
|
|
3165
|
+
* binds every address to the peer it is given and drops the same shape one
|
|
3166
|
+
* layer lower; here it is dropped before it can reach the address book).
|
|
2868
3167
|
*/
|
|
2869
3168
|
normalizeDialAddrs(addrs, peerId) {
|
|
2870
3169
|
// Keyed by the NORMALIZED string, so two entries differing only by the suffix
|
|
@@ -2995,7 +3294,7 @@ export class CadreNode {
|
|
|
2995
3294
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2996
3295
|
return { dialed: [] };
|
|
2997
3296
|
}
|
|
2998
|
-
await this.
|
|
3297
|
+
await this.publishFirstSelfRecord();
|
|
2999
3298
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
3000
3299
|
return { dialed: [] };
|
|
3001
3300
|
}
|
|
@@ -3347,7 +3646,7 @@ export class CadreNode {
|
|
|
3347
3646
|
}
|
|
3348
3647
|
try {
|
|
3349
3648
|
log('reconcileControlCohort: dialing sibling %s (%d addr(s))', sibling.peerId, addrs.length);
|
|
3350
|
-
await dialPeerAddrs(controlNode, addrs, this.controlDialBudget(), `reconcileControlCohort dial of sibling ${sibling.peerId}`);
|
|
3649
|
+
await dialPeerAddrs(controlNode, sibling.peerId, addrs, this.controlDialBudget(), `reconcileControlCohort dial of sibling ${sibling.peerId}`);
|
|
3351
3650
|
return true;
|
|
3352
3651
|
}
|
|
3353
3652
|
catch (error) {
|
|
@@ -3380,9 +3679,9 @@ export class CadreNode {
|
|
|
3380
3679
|
*
|
|
3381
3680
|
* The list may name transports this node cannot dial (a lent node reports TCP
|
|
3382
3681
|
* and `/ws` addresses to a phone that dials WebSockets only). That needs no
|
|
3383
|
-
* filtering here: libp2p's
|
|
3384
|
-
*
|
|
3385
|
-
*
|
|
3682
|
+
* filtering here: libp2p's transport manager rejects an address no transport
|
|
3683
|
+
* can dial before touching the network (`TransportUnavailableError`), so such
|
|
3684
|
+
* an address costs a log line.
|
|
3386
3685
|
*
|
|
3387
3686
|
* The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
|
|
3388
3687
|
* addresses one after another under a single deadline, and sorts loopback
|
|
@@ -3599,7 +3898,7 @@ export class CadreNode {
|
|
|
3599
3898
|
}
|
|
3600
3899
|
try {
|
|
3601
3900
|
log('reconcileControlCohort(cold-start): dialing bootstrap peer %s (%d addr(s))', peerId, parsed.length);
|
|
3602
|
-
await dialPeerAddrs(controlNode, parsed, this.controlDialBudget(), `reconcileControlCohort cold-start dial of bootstrap peer ${peerId}`);
|
|
3901
|
+
await dialPeerAddrs(controlNode, peerId, parsed, this.controlDialBudget(), `reconcileControlCohort cold-start dial of bootstrap peer ${peerId}`);
|
|
3603
3902
|
return true;
|
|
3604
3903
|
}
|
|
3605
3904
|
catch (error) {
|
|
@@ -3815,7 +4114,10 @@ export class CadreNode {
|
|
|
3815
4114
|
*/
|
|
3816
4115
|
async drainPendingRevocations() {
|
|
3817
4116
|
const sweep = !this.reissuedHeldRevocations;
|
|
3818
|
-
|
|
4117
|
+
// A removed machine's owner key went with its row (`removeMachine`), so the schema's
|
|
4118
|
+
// live-owner clause refuses every re-issue it could sign; reconstructAuthoredMembership
|
|
4119
|
+
// and drainPendingPeerWrites stop on the same test.
|
|
4120
|
+
if (this.removal !== null || (!sweep && this.pendingRevocations.size === 0)) {
|
|
3819
4121
|
return;
|
|
3820
4122
|
}
|
|
3821
4123
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
@@ -3842,12 +4144,10 @@ export class CadreNode {
|
|
|
3842
4144
|
}
|
|
3843
4145
|
}
|
|
3844
4146
|
catch (error) {
|
|
3845
|
-
// NOTE:
|
|
3846
|
-
//
|
|
3847
|
-
//
|
|
3848
|
-
//
|
|
3849
|
-
// learns it has been revoked shutting itself down belongs to the device-removal plan
|
|
3850
|
-
// ticket (remove-a-device-or-app-from-a-cadre).
|
|
4147
|
+
// NOTE: an owner whose key alone was removed (`removeOwner`, its machine still a member,
|
|
4148
|
+
// so not a removal this node reports) keeps running these drains, and every attempt
|
|
4149
|
+
// fails the schema's live-owner clause and lands here on each growth edge. Noise, not
|
|
4150
|
+
// harm; if it shows up, gate the drains on this node's key being a live owner.
|
|
3851
4151
|
log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
|
|
3852
4152
|
}
|
|
3853
4153
|
}
|
|
@@ -3869,7 +4169,7 @@ export class CadreNode {
|
|
|
3869
4169
|
* `CadrePeer` trace, but its tombstone is re-issuable).
|
|
3870
4170
|
*/
|
|
3871
4171
|
async reconstructAuthoredMembership() {
|
|
3872
|
-
if (!this.controlDatabase || !this.controlNode) {
|
|
4172
|
+
if (!this.controlDatabase || !this.controlNode || this.removal !== null) {
|
|
3873
4173
|
return;
|
|
3874
4174
|
}
|
|
3875
4175
|
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
@@ -3927,7 +4227,7 @@ export class CadreNode {
|
|
|
3927
4227
|
* here.
|
|
3928
4228
|
*/
|
|
3929
4229
|
async drainPendingPeerWrites() {
|
|
3930
|
-
if (this.pendingPeerWrites.size === 0) {
|
|
4230
|
+
if (this.pendingPeerWrites.size === 0 || this.removal !== null) {
|
|
3931
4231
|
return;
|
|
3932
4232
|
}
|
|
3933
4233
|
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
@@ -5589,6 +5889,8 @@ export class CadreNode {
|
|
|
5589
5889
|
// comes up `'syncing'` announces the moment its database is published.
|
|
5590
5890
|
firstSync: this.config.strandFirstSync,
|
|
5591
5891
|
onWritable: (writableStrandId) => this.handleStrandWritable(writableStrandId),
|
|
5892
|
+
onCommitDurability: (commit) => this.emit('strand:commit-durability', commit),
|
|
5893
|
+
onCommitHeld: (held) => this.emit('strand:commit-held', held),
|
|
5592
5894
|
// Re-announce the delegate to ONE relay before the strand node's reservation
|
|
5593
5895
|
// supervisor re-drives it (see announceDelegateToRelay). Retained with the
|
|
5594
5896
|
// launch config, so a hibernation wake's rebuilt supervisors get it too.
|
|
@@ -6687,6 +6989,15 @@ export class CadreNode {
|
|
|
6687
6989
|
*
|
|
6688
6990
|
* Passing an empty list stops the supervisor and resets the posture to `none`
|
|
6689
6991
|
* without dialing or waiting.
|
|
6992
|
+
*
|
|
6993
|
+
* NOTE: on a node with no `network.relayAddrs` (a browser tab with a hand-written
|
|
6994
|
+
* bare `/p2p-circuit`), libp2p's relay discovery stays on, because it was the only
|
|
6995
|
+
* thing filling that slot before this call. Such a tab connected to some other
|
|
6996
|
+
* relay-capable peer it identifies (a party member running the relay server) can
|
|
6997
|
+
* lose its one slot to that peer before this supervisor's request runs, the theft
|
|
6998
|
+
* `nodeTransportsOption` prevents on configured nodes (`relay-reservation.ts`). If a
|
|
6999
|
+
* tab is ever seen reserved through a relay it was not asked to use, build that
|
|
7000
|
+
* node through `withoutRelayDiscovery` too, or have the tab configure `relayAddrs`.
|
|
6690
7001
|
*/
|
|
6691
7002
|
async reserveRelays(addrs, opts) {
|
|
6692
7003
|
// Stop first, unconditionally: a second call with a different list must not
|
|
@@ -6790,18 +7101,23 @@ export class CadreNode {
|
|
|
6790
7101
|
// — anchor it in the node-local store. The in-memory set updates
|
|
6791
7102
|
// synchronously (see TrustedOwnerStore.trust); only the file persist is
|
|
6792
7103
|
// fire-and-forget, logged on failure.
|
|
6793
|
-
// NOTE:
|
|
6794
|
-
//
|
|
6795
|
-
// it
|
|
6796
|
-
//
|
|
6797
|
-
//
|
|
6798
|
-
//
|
|
6799
|
-
//
|
|
6800
|
-
// `
|
|
6801
|
-
//
|
|
6802
|
-
//
|
|
6803
|
-
//
|
|
6804
|
-
//
|
|
7104
|
+
// NOTE: wiring a key that is not a live owner here self-anchors a key that is
|
|
7105
|
+
// no party authority, and the anchor sync (`syncOwnerAnchor`) does not correct
|
|
7106
|
+
// it: a `genesis` entry is base, kept whether or not a live `OwnerKey` row
|
|
7107
|
+
// vouches it. A machine that joined by invitation reaches this only through
|
|
7108
|
+
// `wireOwnKeyIfOwner`, which checks the row first, so a joined non-owner never
|
|
7109
|
+
// self-anchors. Three callers still wire unconditionally on every start, so a
|
|
7110
|
+
// node of theirs started for a party founded elsewhere does: the phone kit's
|
|
7111
|
+
// `runOwnerGenesis` (cadre-rn; only for a party its saved start does not record
|
|
7112
|
+
// as joined, whose starts call `wireOwnKeyIfOwner` instead), the web reference
|
|
7113
|
+
// app's start (`cadre-web.ts`) and `cadre start --owner`. Contained while the store does
|
|
7114
|
+
// not replicate and such a node cannot mint: `createCadreInvitation` hands out
|
|
7115
|
+
// the anchor as the device's pins, but the `CadreInvite` insert needs a live
|
|
7116
|
+
// owner as issuer. If anything hands out the anchor without that check, gate
|
|
7117
|
+
// this self-anchor on a live `OwnerKey` row instead. A non-owner needs no
|
|
7118
|
+
// owner key to publish its own address record: once an owner or a redemption
|
|
7119
|
+
// has written its `CadrePeer` row, `registerSelf` refreshes it with the
|
|
7120
|
+
// identity key alone.
|
|
6805
7121
|
if (this.trustedOwnerStore) {
|
|
6806
7122
|
void this.trustedOwnerStore
|
|
6807
7123
|
.trust(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
|
|
@@ -6894,6 +7210,11 @@ export class CadreNode {
|
|
|
6894
7210
|
* {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
|
|
6895
7211
|
* never reach here: {@link ControlDatabase.queryCadrePeers} excludes them, so a
|
|
6896
7212
|
* revoked peer is not addressable either.
|
|
7213
|
+
*
|
|
7214
|
+
* NOTE: answers the whole `CadrePeerRow`, past the two fields declared; the integration
|
|
7215
|
+
* harness (`owner-cli-node.ts`, `OwnerCliMemberRow.sig`) reads `sig` off it through the admin
|
|
7216
|
+
* channel to tell a row another member seated from one the subject published itself. Narrow
|
|
7217
|
+
* this to the declared fields only together with that reader.
|
|
6897
7218
|
*/
|
|
6898
7219
|
async listMembers() {
|
|
6899
7220
|
if (!this.controlDatabase) {
|
|
@@ -6982,9 +7303,20 @@ export class CadreNode {
|
|
|
6982
7303
|
* source so the membership refresh can share one load with {@link syncOwnerAnchor}.
|
|
6983
7304
|
*/
|
|
6984
7305
|
async collectAuthorizedMembers(controlDatabase, retry, loadChain) {
|
|
6985
|
-
const selfPeerId = this.peerId?.toString();
|
|
6986
7306
|
const rows = await controlDatabase.queryCadrePeers(retry);
|
|
6987
|
-
|
|
7307
|
+
return this.authorizedAmong(rows, await this.chainFor(rows, loadChain));
|
|
7308
|
+
}
|
|
7309
|
+
/** The invitation chain, loaded only when some row in `rows` is invitation-admitted. */
|
|
7310
|
+
async chainFor(rows, loadChain) {
|
|
7311
|
+
return rows.some(isInvitationAdmitted) ? await loadChain() : null;
|
|
7312
|
+
}
|
|
7313
|
+
/**
|
|
7314
|
+
* The authorized members among `rows`, already read: checks 1 to 4 of
|
|
7315
|
+
* {@link listAuthorizedMembers}' predicate (check 5 is `queryCadrePeers`'s), so the membership
|
|
7316
|
+
* refresh can judge the rows it has to read anyway for the hang-up of removed peers.
|
|
7317
|
+
*/
|
|
7318
|
+
authorizedAmong(rows, chain) {
|
|
7319
|
+
const selfPeerId = this.peerId?.toString();
|
|
6988
7320
|
const authorized = rows
|
|
6989
7321
|
.filter(row => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain))
|
|
6990
7322
|
.map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
|
|
@@ -7045,7 +7377,8 @@ export class CadreNode {
|
|
|
7045
7377
|
*
|
|
7046
7378
|
* NOTE: cost per membership refresh is one `OwnerKey` read and one `Revocation` read (the
|
|
7047
7379
|
* tombstone list, whose `OwnerKey` stamps are handed to `queryOwnerKeyRows` so it does not
|
|
7048
|
-
* read the retired set again
|
|
7380
|
+
* read the retired set again, and which the hang-up of removed peers shares through
|
|
7381
|
+
* `loadTombstones`), plus the invitation chain when some owner row is
|
|
7049
7382
|
* invitation-admitted — shared with the membership read through `loadChain`; the memo
|
|
7050
7383
|
* suggestion on {@link loadInvitationChain} covers that part. Before the Revocation ledger
|
|
7051
7384
|
* marker is filed that one read is a cohort consult per refresh, which
|
|
@@ -7053,8 +7386,9 @@ export class CadreNode {
|
|
|
7053
7386
|
*
|
|
7054
7387
|
* @param retry - forwarded to every read; `false` on the refresh path, for the reason on
|
|
7055
7388
|
* {@link refreshAuthorizedControlPeers}.
|
|
7389
|
+
* @param loadTombstones - the refresh's shared tombstone read ({@link revocationSource}).
|
|
7056
7390
|
*/
|
|
7057
|
-
async syncOwnerAnchor(reason, retry, loadChain) {
|
|
7391
|
+
async syncOwnerAnchor(reason, retry, loadChain, loadTombstones) {
|
|
7058
7392
|
const store = this.trustedOwnerStore;
|
|
7059
7393
|
const controlDatabase = this.controlDatabase;
|
|
7060
7394
|
if (!store || !controlDatabase) {
|
|
@@ -7066,7 +7400,7 @@ export class CadreNode {
|
|
|
7066
7400
|
}
|
|
7067
7401
|
let target;
|
|
7068
7402
|
try {
|
|
7069
|
-
const tombstones = await
|
|
7403
|
+
const tombstones = await loadTombstones();
|
|
7070
7404
|
const retired = new Set(tombstones.filter(tombstone => tombstone.tableName === 'OwnerKey').map(tombstone => tombstone.stampId));
|
|
7071
7405
|
const rows = await controlDatabase.queryOwnerKeyRows(retry, retired);
|
|
7072
7406
|
const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
|
|
@@ -7118,6 +7452,11 @@ export class CadreNode {
|
|
|
7118
7452
|
let loading = null;
|
|
7119
7453
|
return () => (loading ?? (loading = this.loadInvitationChain(controlDatabase, retry)));
|
|
7120
7454
|
}
|
|
7455
|
+
/** A per-refresh {@link RevocationSource}: one `queryRevocations` read, shared by every caller in the pass. */
|
|
7456
|
+
revocationSource(controlDatabase, retry) {
|
|
7457
|
+
let loading = null;
|
|
7458
|
+
return () => (loading ?? (loading = controlDatabase.queryRevocations(retry)));
|
|
7459
|
+
}
|
|
7121
7460
|
/**
|
|
7122
7461
|
* The usage and invitation rows {@link hasAnchoredProof} and {@link syncOwnerAnchor}
|
|
7123
7462
|
* resolve an invitation-admitted row through, keyed for lookup. Both reads honour `retry`
|
|
@@ -7429,6 +7768,10 @@ export class CadreNode {
|
|
|
7429
7768
|
* Remove a previously-authorized peer from the cadre.
|
|
7430
7769
|
* Signs the peer ID with the owner key and deletes the CadrePeer row.
|
|
7431
7770
|
*
|
|
7771
|
+
* Removes the `CadrePeer` row only. The call that removes a machine completely (its owner
|
|
7772
|
+
* key, its push token and the invitations it could come back through, in one transaction)
|
|
7773
|
+
* is {@link removeMachine}.
|
|
7774
|
+
*
|
|
7432
7775
|
* @param peerId - The peer ID to remove
|
|
7433
7776
|
*/
|
|
7434
7777
|
async removePeer(peerId) {
|
|
@@ -7478,6 +7821,9 @@ export class CadreNode {
|
|
|
7478
7821
|
* tombstone rides the committed-delete seam ({@link noteGuardedDelete}), so one committed
|
|
7479
7822
|
* while alone is re-issued on cohort growth like every other removal.
|
|
7480
7823
|
*
|
|
7824
|
+
* Removes the `OwnerKey` row only. The call that removes a machine completely, its owner key
|
|
7825
|
+
* included, is {@link removeMachine}.
|
|
7826
|
+
*
|
|
7481
7827
|
* @param ownerPublicKeyB64 - the owner's ed25519 public key (base64url).
|
|
7482
7828
|
* @returns `true` when the row was removed, `false` when the key is not a live owner here.
|
|
7483
7829
|
*/
|
|
@@ -7488,6 +7834,155 @@ export class CadreNode {
|
|
|
7488
7834
|
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
7489
7835
|
return await this.seedBootstrapService.removeOwner(key);
|
|
7490
7836
|
}
|
|
7837
|
+
/**
|
|
7838
|
+
* The cadre's machines as an app lists them ({@link CadreMachine}): one entry per live
|
|
7839
|
+
* `CadrePeer` row, judged by this node's membership predicate, plus one per live `OwnerKey`
|
|
7840
|
+
* whose key names a machine with no row (`describeMachines` in `machine-removal.ts`).
|
|
7841
|
+
* Unlocked reads with the default retry.
|
|
7842
|
+
*/
|
|
7843
|
+
async listMachines() {
|
|
7844
|
+
if (!this.controlDatabase) {
|
|
7845
|
+
throw new Error('CadreNode must be started before listing machines');
|
|
7846
|
+
}
|
|
7847
|
+
return (await this.readMachines(this.controlDatabase)).machines;
|
|
7848
|
+
}
|
|
7849
|
+
/**
|
|
7850
|
+
* What {@link removeMachine} would do for `peerId`, read without writing, for an app's
|
|
7851
|
+
* confirmation step ({@link MachineRemovalPlan}): whether this node may remove it, whether its
|
|
7852
|
+
* owner key goes with it, which rows that key vouched (so the app can offer to keep them by
|
|
7853
|
+
* re-vouching), which invitations it could come back through, and whether this node's own
|
|
7854
|
+
* rows depend on it (`planMachineRemoval` in `machine-removal.ts`).
|
|
7855
|
+
*/
|
|
7856
|
+
async planMachineRemoval(peerId) {
|
|
7857
|
+
if (!this.controlDatabase) {
|
|
7858
|
+
throw new Error('CadreNode must be started before planning a machine removal');
|
|
7859
|
+
}
|
|
7860
|
+
return (await this.planMachineRemovalWith(this.controlDatabase, peerId)).plan;
|
|
7861
|
+
}
|
|
7862
|
+
/**
|
|
7863
|
+
* Remove a machine from the cadre completely, signed by this node's owner key: its
|
|
7864
|
+
* `CadrePeer` row, its `OwnerKey` row when its key is an owner, its `DeviceToken` row (a
|
|
7865
|
+
* removed phone must not be push-woken) and a withdrawal of every live invitation it could
|
|
7866
|
+
* redeem to come back, in ONE transaction ({@link ControlDatabase.removeMachine}), with a
|
|
7867
|
+
* `Revocation` tombstone per row so the removal holds on every machine
|
|
7868
|
+
* ("Deletes made while alone" in docs/architecture.md).
|
|
7869
|
+
*
|
|
7870
|
+
* Steps: re-plan ({@link planMachineRemoval}) and throw {@link MachineRemovalRefusedError} on a
|
|
7871
|
+
* refusal; check `options.revouch` against the plan's dependents and throw before any write
|
|
7872
|
+
* when it names anything else; **re-vouch first**, under this node's key, the dependents the
|
|
7873
|
+
* caller chose to keep (`ControlDatabase.revouchOwnerKey`, `reauthorizePeer`), so they are
|
|
7874
|
+
* never untrusted in between, and stop before the removal if one fails (re-vouches already
|
|
7875
|
+
* made stay, harmlessly); then the removal transaction. Afterwards this node forgets the
|
|
7876
|
+
* machine as a dial target, and the refresh the write notifies hangs up its control
|
|
7877
|
+
* connection ({@link hangUpRevokedControlPeers}) before this resolves.
|
|
7878
|
+
*
|
|
7879
|
+
* Not undone by this call: the machine keeps every row and strand block it already synced
|
|
7880
|
+
* and the party's strand member keys (`tickets/blocked/decide-removed-machine-strand-access`).
|
|
7881
|
+
* A fresh invitation re-admits it later under fresh stamps.
|
|
7882
|
+
*
|
|
7883
|
+
* @returns what was written ({@link MachineRemovalResult}); all false when no live row names
|
|
7884
|
+
* the machine here (already removed elsewhere).
|
|
7885
|
+
* @throws {MachineRemovalRefusedError} `'not-an-owner'`, `'self'` or `'last-owner'`.
|
|
7886
|
+
*/
|
|
7887
|
+
async removeMachine(peerId, options = {}) {
|
|
7888
|
+
const service = this.seedBootstrapService;
|
|
7889
|
+
if (!service || !this.controlDatabase) {
|
|
7890
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7891
|
+
}
|
|
7892
|
+
const { plan, rows } = await this.planMachineRemovalWith(this.controlDatabase, peerId);
|
|
7893
|
+
if (plan.refusal !== null) {
|
|
7894
|
+
throw new MachineRemovalRefusedError(peerId, plan.refusal);
|
|
7895
|
+
}
|
|
7896
|
+
const revouch = revouchWithinPlan(plan, options.revouch);
|
|
7897
|
+
if (plan.machine === null) {
|
|
7898
|
+
log('removeMachine(%s): no live row names it here; nothing to remove', peerId);
|
|
7899
|
+
return { removedPeer: false, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [], revouched: { owners: [], peers: [] } };
|
|
7900
|
+
}
|
|
7901
|
+
await this.revouchDependents(service, revouch, rows);
|
|
7902
|
+
const writes = await service.removeMachine({
|
|
7903
|
+
peerId,
|
|
7904
|
+
ownerKey: plan.removesOwnerKey ? plan.machine.publicKey : null,
|
|
7905
|
+
inviteKeys: plan.invitations,
|
|
7906
|
+
});
|
|
7907
|
+
if (writes.removedPeer) {
|
|
7908
|
+
// Track + loudly flag a delete that committed local-only (security-relevant).
|
|
7909
|
+
this.noteControlWrite(peerId, 'remove');
|
|
7910
|
+
}
|
|
7911
|
+
// As removePeer: a removed machine is no longer a dial target, however this node learned
|
|
7912
|
+
// its address. A persist failure only lets the entry reappear after a restart.
|
|
7913
|
+
void this.bootstrapPeerStore?.forget(peerId).catch((error) => {
|
|
7914
|
+
log('removeMachine: persisting the removal of dial target %s failed (forgotten in memory): %o', peerId, error);
|
|
7915
|
+
});
|
|
7916
|
+
log('removeMachine(%s): peer=%s ownerKey=%s deviceToken=%s invitations=%d revouched owners=%d peers=%d', peerId, writes.removedPeer, writes.removedOwnerKey, writes.removedDeviceToken, writes.withdrawnInvitations.length, revouch.owners.length, revouch.peers.length);
|
|
7917
|
+
return { ...writes, revouched: revouch };
|
|
7918
|
+
}
|
|
7919
|
+
/**
|
|
7920
|
+
* The reads behind {@link listMachines}: the live `CadrePeer` and `OwnerKey` rows and the
|
|
7921
|
+
* machines projected from them, with this node's membership predicate as the `trusted` test.
|
|
7922
|
+
*/
|
|
7923
|
+
async readMachines(controlDatabase) {
|
|
7924
|
+
const [rows, owners] = await Promise.all([controlDatabase.queryCadrePeers(), controlDatabase.queryOwnerKeyRows()]);
|
|
7925
|
+
const chain = await this.chainFor(rows, this.invitationChainSource(controlDatabase, true));
|
|
7926
|
+
const selfPeerId = this.peerId?.toString() ?? null;
|
|
7927
|
+
const machines = describeMachines({
|
|
7928
|
+
partyId: this.partyId,
|
|
7929
|
+
selfPeerId,
|
|
7930
|
+
rows,
|
|
7931
|
+
owners,
|
|
7932
|
+
trusted: (row) => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain),
|
|
7933
|
+
});
|
|
7934
|
+
return { machines, rows, owners };
|
|
7935
|
+
}
|
|
7936
|
+
/** {@link planMachineRemoval} with the rows it was judged from, which {@link removeMachine} re-vouches dependents by. */
|
|
7937
|
+
async planMachineRemovalWith(controlDatabase, peerId) {
|
|
7938
|
+
const { machines, rows, owners } = await this.readMachines(controlDatabase);
|
|
7939
|
+
const [invites, usages] = await Promise.all([controlDatabase.listCadreInviteStatuses(), controlDatabase.queryCadreInviteUsages()]);
|
|
7940
|
+
const plan = planMachineRemoval({
|
|
7941
|
+
peerId,
|
|
7942
|
+
machines,
|
|
7943
|
+
rows,
|
|
7944
|
+
owners,
|
|
7945
|
+
selfPeerId: this.peerId?.toString() ?? null,
|
|
7946
|
+
selfOwnerKey: this.ownerSigningKeyEnrolledIn(new Set(owners.map((owner) => owner.key)))?.publicKeyB64 ?? null,
|
|
7947
|
+
invites,
|
|
7948
|
+
usages,
|
|
7949
|
+
});
|
|
7950
|
+
return { plan, rows };
|
|
7951
|
+
}
|
|
7952
|
+
/**
|
|
7953
|
+
* The re-vouch step of {@link removeMachine}: every chosen dependent row is rebound to this
|
|
7954
|
+
* node's key BEFORE the removal, so no reader ever judges it against an owner that is gone.
|
|
7955
|
+
* Owners through `ControlDatabase.revouchOwnerKey` (a delete-and-re-add under a fresh stamp,
|
|
7956
|
+
* the only rebinding `OwnerKey.NoUpdate` allows); peers through the owner re-touch
|
|
7957
|
+
* (`reauthorizePeer`), which rewrites `VouchOwner`/`VouchSig` and clears `VouchUsage`.
|
|
7958
|
+
*
|
|
7959
|
+
* The peer re-touch keeps the row's `UpdatedAt` where it is. The owner branch of
|
|
7960
|
+
* `CadrePeer.AuthorizedUpdate` does not require a bump, and bumping a self-published row
|
|
7961
|
+
* would leave its `Sig` unverifiable, so unresolvable and unlisted as self-published, until
|
|
7962
|
+
* the machine's next heartbeat (the NOTE on that constraint); the write-while-alone drain
|
|
7963
|
+
* bumps because it re-issues an otherwise unchanged row, whereas here the voucher itself
|
|
7964
|
+
* changes. A row never self-published carries no stamp to keep and gets the clock.
|
|
7965
|
+
*
|
|
7966
|
+
* A failure rethrows and stops the call before the removal; re-vouches already made stay
|
|
7967
|
+
* (the row is only re-signed by a current owner) and the caller retries. An owner re-vouch
|
|
7968
|
+
* committed alone is not re-issued on growth (the add half has no re-issue path,
|
|
7969
|
+
* `tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue`), so it is logged as
|
|
7970
|
+
* {@link addOwner} logs it; the peer re-touches are re-issued by the existing drain.
|
|
7971
|
+
*/
|
|
7972
|
+
async revouchDependents(service, revouch, rows) {
|
|
7973
|
+
for (const key of revouch.owners) {
|
|
7974
|
+
await service.revouchOwner(key);
|
|
7975
|
+
if (this.committedAlone()) {
|
|
7976
|
+
log('removeMachine: re-vouch of owner %s committed while ALONE (0 control connections): the re-added ' +
|
|
7977
|
+
'owner row is local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
|
|
7978
|
+
}
|
|
7979
|
+
}
|
|
7980
|
+
for (const dependent of revouch.peers) {
|
|
7981
|
+
const row = rows.find((candidate) => candidate.peerId === dependent);
|
|
7982
|
+
const updatedAt = row !== undefined && row.updatedAt > 0 ? row.updatedAt : Date.now();
|
|
7983
|
+
await service.reauthorizePeer(dependent, updatedAt);
|
|
7984
|
+
}
|
|
7985
|
+
}
|
|
7491
7986
|
/**
|
|
7492
7987
|
* Create a seed from the current control network state.
|
|
7493
7988
|
* The seed contains peer information and is signed by an owner.
|
|
@@ -7715,6 +8210,29 @@ export class CadreNode {
|
|
|
7715
8210
|
// ============================================================================
|
|
7716
8211
|
// Cadre Invitation API (redeemable at any member, `/sereus/cadre-invite/1.0.0`)
|
|
7717
8212
|
// ============================================================================
|
|
8213
|
+
/**
|
|
8214
|
+
* Where a device could redeem a cadre invitation minted now from, once this machine is gone
|
|
8215
|
+
* ({@link CadreInvitationReach}): the same member selection {@link createCadreInvitation}
|
|
8216
|
+
* makes, judged without minting anything. `rentNode` reads it to choose between inviting a
|
|
8217
|
+
* provider's container and claiming it; `local-network` counts as not reachable there, since
|
|
8218
|
+
* the container is never on a member's LAN. Requires a started node.
|
|
8219
|
+
*/
|
|
8220
|
+
async invitationReach() {
|
|
8221
|
+
if (!this.controlNode) {
|
|
8222
|
+
throw new Error('CadreNode must be started before judging invitation reach');
|
|
8223
|
+
}
|
|
8224
|
+
return (await this.selectInvitationMembers()).reach;
|
|
8225
|
+
}
|
|
8226
|
+
/**
|
|
8227
|
+
* The `members` a cadre invitation minted now would name, and how far they reach: one
|
|
8228
|
+
* selection behind both {@link createCadreInvitation} and {@link invitationReach}, so the
|
|
8229
|
+
* two cannot disagree.
|
|
8230
|
+
*/
|
|
8231
|
+
async selectInvitationMembers() {
|
|
8232
|
+
const { siblings, connected } = await this.resolveInvitationSiblings();
|
|
8233
|
+
const members = selectCadreInvitationMembers(await this.resolveInviteAddresses(), siblings, connected);
|
|
8234
|
+
return { members, reach: cadreInvitationReach(members, this.controlNode.peerId.toString()) };
|
|
8235
|
+
}
|
|
7718
8236
|
/**
|
|
7719
8237
|
* Mint an owner-signed cadre invitation that a device can redeem at ANY member machine of
|
|
7720
8238
|
* this party, the owner offline included ({@link redeemCadreInvitation}). Requires the
|
|
@@ -7732,9 +8250,13 @@ export class CadreNode {
|
|
|
7732
8250
|
* only, never the raw replicated `OwnerKey` table, because the device anchors whatever
|
|
7733
8251
|
* arrives and a stranger's genesis-inserted key must not ride an invitation into a fresh
|
|
7734
8252
|
* node's anchor; an empty anchor is refused because a reply could not be checked), and
|
|
7735
|
-
*
|
|
7736
|
-
*
|
|
7737
|
-
*
|
|
8253
|
+
* at most six addresses: this machine's first ({@link resolveInviteAddresses}, which
|
|
8254
|
+
* honours pushed NAT addresses), then two each of up to three other members
|
|
8255
|
+
* (`selectCadreInvitationMembers`), so the encoded text fits a QR code.
|
|
8256
|
+
*
|
|
8257
|
+
* The result's `reach` says where a device could redeem it from once this machine is gone
|
|
8258
|
+
* (`cadreInvitationReach`, judged on the other members' addresses only), so an app can warn
|
|
8259
|
+
* before handing over an invitation only it could answer.
|
|
7738
8260
|
*
|
|
7739
8261
|
* The row commits locally and replicates like any other control write; minted while
|
|
7740
8262
|
* alone (a phone with no connection), it reaches the other members through the
|
|
@@ -7758,7 +8280,7 @@ export class CadreNode {
|
|
|
7758
8280
|
if (!Number.isInteger(uses) || uses < 1) {
|
|
7759
8281
|
throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
|
|
7760
8282
|
}
|
|
7761
|
-
const members
|
|
8283
|
+
const { members, reach } = await this.selectInvitationMembers();
|
|
7762
8284
|
if (members.length === 0) {
|
|
7763
8285
|
throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
|
|
7764
8286
|
}
|
|
@@ -7780,8 +8302,8 @@ export class CadreNode {
|
|
|
7780
8302
|
ownerKeys,
|
|
7781
8303
|
members,
|
|
7782
8304
|
};
|
|
7783
|
-
log('Cadre invitation minted: %s (peer=%s owner=%s uses=%d, %d member address(es))', row.key, row.peerId ?? 'any', row.grantsOwner, uses, members.length);
|
|
7784
|
-
return { invitation, encoded: encodeCadreInvitation(invitation) };
|
|
8305
|
+
log('Cadre invitation minted: %s (peer=%s owner=%s uses=%d, %d member address(es), reach=%s)', row.key, row.peerId ?? 'any', row.grantsOwner, uses, members.length, reach);
|
|
8306
|
+
return { invitation, encoded: encodeCadreInvitation(invitation), reach };
|
|
7785
8307
|
}
|
|
7786
8308
|
/**
|
|
7787
8309
|
* Every cadre invitation this node holds — minted here or replicated in — with its
|
|
@@ -7818,7 +8340,7 @@ export class CadreNode {
|
|
|
7818
8340
|
* Steps: refuse an invitation for another party; pin its `ownerKeys` into the node-local
|
|
7819
8341
|
* anchor (`'invite'` provenance — the pin sticks even if the redemption then fails, as it
|
|
7820
8342
|
* does for a seed invite, so a later attempt or seed from the same owner is anchored);
|
|
7821
|
-
* sign the request; dial the listed members in order (
|
|
8343
|
+
* sign the request; dial the listed members in order (`redeemAtMembers`); on
|
|
7822
8344
|
* acceptance verify the reply against the pinned keys, merge its dial hints into the peer
|
|
7823
8345
|
* store, retain the answering member as a cold-start dial target, refresh the membership
|
|
7824
8346
|
* gate and start a cohort reconcile pass. This node then syncs the control database over
|
|
@@ -7827,11 +8349,20 @@ export class CadreNode {
|
|
|
7827
8349
|
* A device that is already a member (a restart with the invitation still configured) is
|
|
7828
8350
|
* answered as accepted by the member's idempotent redemption, with no second usage row.
|
|
7829
8351
|
*
|
|
8352
|
+
* One pass over the listed members by default. With `options.waitForMember` the call keeps
|
|
8353
|
+
* making passes while every answer is retryable — for a device redeeming while no
|
|
8354
|
+
* always-on member is up — until a member accepts, one refuses finally, the invitation
|
|
8355
|
+
* expires (thrown as the `invite-spent` refusal) or `options.signal` aborts
|
|
8356
|
+
* (`redeemInPasses`). The request is signed once, before the first pass, and every pass
|
|
8357
|
+
* sends it: its `usageStampId` keys the usage row, so a member that admitted this node on a
|
|
8358
|
+
* pass whose reply was lost answers the next as already a member. `options.onProgress`
|
|
8359
|
+
* reports each pass and pause.
|
|
8360
|
+
*
|
|
7830
8361
|
* When the invitation grants ownership, the member seats this node's key as an `OwnerKey`
|
|
7831
|
-
* row by consent. The caller still has to wire that key for signing afterwards
|
|
7832
|
-
*
|
|
7833
|
-
*
|
|
7834
|
-
*
|
|
8362
|
+
* row by consent. The caller still has to wire that key for signing afterwards, with
|
|
8363
|
+
* {@link wireOwnKeyIfOwner}; never `ensureOwnerKey`, the row is already there. Other
|
|
8364
|
+
* machines derive this node's key into their anchors from the row's stored proof on their
|
|
8365
|
+
* next membership refresh ({@link syncOwnerAnchor}).
|
|
7835
8366
|
*
|
|
7836
8367
|
* Limit: the reply check ({@link verifyRedeemReply}) does not authenticate the member. A
|
|
7837
8368
|
* forged bundle, or a legitimate one whose addresses were swapped, dials a machine that
|
|
@@ -7842,10 +8373,10 @@ export class CadreNode {
|
|
|
7842
8373
|
*
|
|
7843
8374
|
* @throws `CadreInviteRejectedError` on a final refusal, `CadreInviteReplyInvalidError` on
|
|
7844
8375
|
* an acceptance that fails the reply check, `CadreInviteUnreachableError` when every
|
|
7845
|
-
* listed address was tried without an acceptance
|
|
7846
|
-
* preconditions above.
|
|
8376
|
+
* listed address was tried without an acceptance (one pass only), the signal's reason on
|
|
8377
|
+
* an abort, and a plain `Error` for the preconditions above.
|
|
7847
8378
|
*/
|
|
7848
|
-
async redeemCadreInvitation(invitation) {
|
|
8379
|
+
async redeemCadreInvitation(invitation, options = {}) {
|
|
7849
8380
|
const controlNode = this.controlNode;
|
|
7850
8381
|
if (!controlNode || !this.controlDatabase || !this._running) {
|
|
7851
8382
|
throw new Error('CadreNode must be started before redeeming a cadre invitation');
|
|
@@ -7860,11 +8391,14 @@ export class CadreNode {
|
|
|
7860
8391
|
await this.trustOwnerKeys(invitation.ownerKeys, 'invite');
|
|
7861
8392
|
const pinned = new Set(invitation.ownerKeys);
|
|
7862
8393
|
const request = signRedeemRequest(invitation, { peerKey: signer.publicKeyB64, peerPrivateKey: signer.privateKeyB64 }, generateStampId(controlNode.peerId.toString()), await this.resolveInviteAddresses());
|
|
7863
|
-
const { reply, memberPeerId, memberAddr } = await
|
|
8394
|
+
const { reply, memberPeerId, memberAddr } = await redeemInPasses(controlNode, {
|
|
7864
8395
|
invitation,
|
|
7865
8396
|
request,
|
|
7866
8397
|
isTrustedIssuer: (ownerKey) => pinned.has(ownerKey),
|
|
7867
8398
|
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
8399
|
+
waitForMember: options.waitForMember ?? false,
|
|
8400
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
8401
|
+
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
7868
8402
|
});
|
|
7869
8403
|
log('Cadre invitation %s redeemed at %s (owner=%s)', invitation.invite.key, memberAddr, reply.invite.grantsOwner);
|
|
7870
8404
|
const selfPeerId = controlNode.peerId.toString();
|
|
@@ -7882,6 +8416,38 @@ export class CadreNode {
|
|
|
7882
8416
|
void this.reconcileControlCohort().catch((error) => log('redeemCadreInvitation: the post-redemption reconcile pass failed: %o', error));
|
|
7883
8417
|
return { peerId: memberPeerId, grantsOwner: reply.invite.grantsOwner, redeemedAt: new Date().toISOString() };
|
|
7884
8418
|
}
|
|
8419
|
+
/**
|
|
8420
|
+
* If this node's identity key is a live `OwnerKey` here, wire it for signing
|
|
8421
|
+
* ({@link initializeSeedBootstrap}) and return true; otherwise change nothing and return
|
|
8422
|
+
* false. For a machine that joined someone else's cadre by invitation: after an
|
|
8423
|
+
* owner-granting redemption, and on every later start. A redemption seats the request's
|
|
8424
|
+
* `peerKey`, the key behind this node's peer id, so the identity key is the one to look for.
|
|
8425
|
+
*
|
|
8426
|
+
* Unlike `ensureOwnerKey` it never inserts an `OwnerKey` row, so a machine that joined a
|
|
8427
|
+
* cadre can never genesis-insert itself into it, and a non-owner never anchors its own key.
|
|
8428
|
+
* An owner whose key is already wired (an earlier call) is answered true with nothing redone.
|
|
8429
|
+
*
|
|
8430
|
+
* NOTE: a node running the listener-only seed service ({@link enableSeedListener}) cannot be
|
|
8431
|
+
* wired this way: libp2p takes one seed handler, and {@link initializeSeedBootstrap} would
|
|
8432
|
+
* register a second. Nothing that joins by invitation enables the listener today; if one
|
|
8433
|
+
* does, replace the listener here rather than beside it.
|
|
8434
|
+
*
|
|
8435
|
+
* @throws when the node is not started.
|
|
8436
|
+
*/
|
|
8437
|
+
async wireOwnKeyIfOwner() {
|
|
8438
|
+
if (!this.controlDatabase || !this._running) {
|
|
8439
|
+
throw new Error('CadreNode must be started before wiring its owner key');
|
|
8440
|
+
}
|
|
8441
|
+
const signingKey = this.getSelfSigningKey();
|
|
8442
|
+
if (!signingKey || !(await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64)) {
|
|
8443
|
+
log('wireOwnKeyIfOwner: this node\'s key is not an owner here; not wiring it');
|
|
8444
|
+
return false;
|
|
8445
|
+
}
|
|
8446
|
+
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
8447
|
+
await this.initializeSeedBootstrap(signingKey.privateKeyB64);
|
|
8448
|
+
}
|
|
8449
|
+
return true;
|
|
8450
|
+
}
|
|
7885
8451
|
// ============================================================================
|
|
7886
8452
|
// Strand Solicitation API (native cadre-core formation transport)
|
|
7887
8453
|
// ============================================================================
|
|
@@ -8010,20 +8576,27 @@ export class CadreNode {
|
|
|
8010
8576
|
return await this.strandSolicitationService.createOpenInvitation(sAppId, expirationMs, bootstrap);
|
|
8011
8577
|
}
|
|
8012
8578
|
/**
|
|
8013
|
-
* Addresses of the party's other machines for an invitation's bootstrap list
|
|
8014
|
-
*
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8579
|
+
* Addresses of the party's other machines for an open strand invitation's bootstrap list
|
|
8580
|
+
* ({@link createOpenInvitation}); {@link selectInvitationSiblingAddrs} picks which.
|
|
8581
|
+
*/
|
|
8582
|
+
async siblingInvitationAddrs() {
|
|
8583
|
+
const { siblings, connected } = await this.resolveInvitationSiblings();
|
|
8584
|
+
return selectInvitationSiblingAddrs(siblings, connected);
|
|
8585
|
+
}
|
|
8586
|
+
/**
|
|
8587
|
+
* The party's other machines an invitation may name — an open strand invitation
|
|
8588
|
+
* ({@link siblingInvitationAddrs}) or a cadre invitation ({@link createCadreInvitation}) —
|
|
8589
|
+
* and which of them this node is connected to. The source is each authorized member's
|
|
8590
|
+
* signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record is
|
|
8591
|
+
* missing, stale or untrusted is left out; live connections only order them.
|
|
8019
8592
|
*
|
|
8020
8593
|
* Best-effort: a failed read leaves the invitation naming this machine alone, which is what
|
|
8021
8594
|
* it named before siblings were added.
|
|
8022
8595
|
*/
|
|
8023
|
-
async
|
|
8596
|
+
async resolveInvitationSiblings() {
|
|
8024
8597
|
const controlNode = this.controlNode;
|
|
8025
8598
|
if (!controlNode || !this.controlDatabase) {
|
|
8026
|
-
return [];
|
|
8599
|
+
return { siblings: [], connected: new Set() };
|
|
8027
8600
|
}
|
|
8028
8601
|
try {
|
|
8029
8602
|
const siblings = [];
|
|
@@ -8033,12 +8606,11 @@ export class CadreNode {
|
|
|
8033
8606
|
siblings.push({ peerId, updatedAt: resolved.updatedAt, addrs: resolved.addrs.map(String) });
|
|
8034
8607
|
}
|
|
8035
8608
|
}
|
|
8036
|
-
|
|
8037
|
-
return selectInvitationSiblingAddrs(siblings, connected);
|
|
8609
|
+
return { siblings, connected: new Set(controlNode.getConnections().map((c) => c.remotePeer.toString())) };
|
|
8038
8610
|
}
|
|
8039
8611
|
catch (error) {
|
|
8040
8612
|
log('siblingInvitationAddrs: resolving the other machines failed; the invitation names only this one: %o', error);
|
|
8041
|
-
return [];
|
|
8613
|
+
return { siblings: [], connected: new Set() };
|
|
8042
8614
|
}
|
|
8043
8615
|
}
|
|
8044
8616
|
/**
|
|
@@ -8059,7 +8631,7 @@ export class CadreNode {
|
|
|
8059
8631
|
await this.initializeStrandSolicitation();
|
|
8060
8632
|
}
|
|
8061
8633
|
const result = await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
|
|
8062
|
-
await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs);
|
|
8634
|
+
await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs, 'formStrand');
|
|
8063
8635
|
// A closed host strand's approval carries the joiner's own membership invitation —
|
|
8064
8636
|
// persist this party's identity key and stage the invitation for bring-up to redeem.
|
|
8065
8637
|
// Runs AFTER the addr recording so a persistence failure (which throws — see the
|
|
@@ -8484,15 +9056,15 @@ export class CadreNode {
|
|
|
8484
9056
|
* Nothing else would deliver them: the seed is read only at launch and resume, and the
|
|
8485
9057
|
* refresh pass skips a peer FRET already holds a record for — the stale one, here.
|
|
8486
9058
|
*/
|
|
8487
|
-
async recordFormationStrandAddrs(strandId, strandAddrs) {
|
|
9059
|
+
async recordFormationStrandAddrs(strandId, strandAddrs, caller) {
|
|
8488
9060
|
const attributed = [...groupAddrsByPeerId([...strandAddrs]).values()]
|
|
8489
9061
|
.flatMap((peerAddrs) => peerAddrs.map((addr) => addr.toString()));
|
|
8490
9062
|
if (attributed.length === 0) {
|
|
8491
|
-
log('
|
|
9063
|
+
log('%s: no usable strand addrs for %s (%d carried) — cross-party seed unchanged', caller, strandId, strandAddrs.length);
|
|
8492
9064
|
return;
|
|
8493
9065
|
}
|
|
8494
9066
|
this.formationStrandAddrs.set(strandId, attributed);
|
|
8495
|
-
log('
|
|
9067
|
+
log('%s: kept %d of %d cross-party strand addr(s) for %s', caller, attributed.length, strandAddrs.length, strandId);
|
|
8496
9068
|
const runningNode = this.strandManager.getInstance(strandId)?.libp2pNode;
|
|
8497
9069
|
if (runningNode) {
|
|
8498
9070
|
await this.mergeStrandPeerAddrs(runningNode, attributed, strandId);
|