@serfab/cadre-core 1.13.0 → 1.15.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 +383 -0
- package/dist/cadre-invite-protocol.js +774 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- 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 +849 -210
- package/dist/cadre-node.js +1574 -374
- package/dist/cadre-node.js.map +1 -1
- package/dist/claim-proof.d.ts +45 -0
- package/dist/claim-proof.js +132 -0
- package/dist/claim-proof.js.map +1 -0
- package/dist/control-authorization.d.ts +80 -19
- package/dist/control-authorization.js +55 -13
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +605 -108
- package/dist/control-database.js +1299 -280
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +142 -0
- package/dist/control-protocol-guard.js +293 -0
- package/dist/control-protocol-guard.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +817 -1067
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +4 -0
- package/dist/control-stream.js +22 -7
- package/dist/control-stream.js.map +1 -1
- package/dist/delegate-admission.d.ts +1 -1
- package/dist/delegate-admission.js +1 -1
- package/dist/device-token.d.ts +11 -9
- package/dist/device-token.js +14 -12
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +22 -9
- package/dist/ed25519-key.js +47 -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 +22 -10
- package/dist/index.js +54 -11
- 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 +25 -33
- package/dist/link-budget.js +25 -35
- 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 +191 -151
- package/dist/membership-connection-gater.js +236 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +74 -0
- package/dist/node-claim-payload.js +224 -0
- package/dist/node-claim-payload.js.map +1 -0
- 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/owner-anchor-sync.d.ts +22 -0
- package/dist/owner-anchor-sync.js +143 -0
- package/dist/owner-anchor-sync.js.map +1 -0
- package/dist/peer-authorization.d.ts +113 -18
- package/dist/peer-authorization.js +225 -29
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -27
- package/dist/peer-dial.js +276 -31
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +60 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +17 -52
- package/dist/peer-record.js +20 -58
- package/dist/peer-record.js.map +1 -1
- package/dist/pending-join-runner.d.ts +66 -58
- package/dist/pending-join-runner.js +136 -145
- package/dist/pending-join-runner.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/push-fanout.d.ts +8 -2
- package/dist/push-fanout.js +8 -2
- package/dist/push-fanout.js.map +1 -1
- 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 +165 -123
- package/dist/seed-bootstrap.js +313 -275
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +98 -37
- package/dist/seed-trust-policy.js +188 -2
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +24 -26
- package/dist/strand-addr-protocol.js +19 -26
- package/dist/strand-addr-protocol.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 +5 -2
- 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-revocation-enforcer.d.ts +11 -11
- package/dist/strand-revocation-enforcer.js +11 -11
- package/dist/strand-wake-protocol.d.ts +13 -16
- package/dist/strand-wake-protocol.js +23 -27
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/trusted-owner-store.d.ts +35 -5
- package/dist/trusted-owner-store.js +50 -8
- package/dist/trusted-owner-store.js.map +1 -1
- package/dist/types.d.ts +512 -89
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -1
- package/package.json +19 -10
- package/src/cadre-invite-protocol.ts +1052 -0
- package/src/cadre-join-request.ts +167 -0
- package/src/cadre-node.ts +1703 -386
- package/src/claim-proof.ts +147 -0
- package/src/control-authorization.ts +203 -117
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +4663 -3453
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +828 -1078
- package/src/control-stream.ts +26 -7
- package/src/delegate-admission.ts +1 -1
- package/src/device-token.ts +14 -10
- package/src/ed25519-key.ts +47 -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 +789 -648
- 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 +25 -36
- package/src/machine-removal.ts +216 -0
- package/src/membership-connection-gater.ts +259 -191
- package/src/node-claim-payload.ts +259 -0
- package/src/node-default-transports.ts +38 -0
- package/src/node-transports.ts +49 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +604 -315
- package/src/peer-join-backfill.ts +62 -11
- package/src/peer-record.ts +159 -197
- package/src/pending-join-runner.ts +729 -735
- 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/push-fanout.ts +8 -2
- package/src/relay-addrs.ts +9 -4
- package/src/relay-reservation.ts +1211 -1121
- package/src/seed-bootstrap.ts +1502 -1448
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- 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-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +23 -30
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2687 -2242
package/dist/cadre-node.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createLibp2pNode } from '@optimystic/db-p2p';
|
|
|
6
6
|
import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
|
|
7
7
|
import { multiaddr } from '@multiformats/multiaddr';
|
|
8
8
|
import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
|
|
9
|
-
import { sign } from '@optimystic/quereus-plugin-crypto';
|
|
9
|
+
import { generatePrivateKey, sign } from '@optimystic/quereus-plugin-crypto';
|
|
10
10
|
import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
11
11
|
import { strandTransportKey } from './strand-transport-key.js';
|
|
12
12
|
import { assertScopeKeyCharset, assertStrandScopeKey, controlStorageScope, InvalidStrandIdError, isValidStrandScopeKey } from './storage-scope.js';
|
|
@@ -22,31 +22,38 @@ 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 } from './peer-authorization.js';
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
25
|
+
import { verifyCadrePeerVoucher, verifyInvitationAdmission, verifyRevocationSigner } from './peer-authorization.js';
|
|
26
|
+
import { deriveOwnerAnchor } from './owner-anchor-sync.js';
|
|
27
|
+
import { describeMachines, planMachineRemoval, revouchWithinPlan, MachineRemovalRefusedError } from './machine-removal.js';
|
|
28
|
+
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
29
|
+
import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
|
|
28
30
|
import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
|
|
29
31
|
import { StrandWatcher } from './strand-watcher.js';
|
|
30
32
|
import { StrandInstanceManager, liveStrandStatus } from './strand-instance-manager.js';
|
|
31
33
|
import { PeerJoinBackfill } from './peer-join-backfill.js';
|
|
32
34
|
import { deriveCohortMembers } from './strand-cohort.js';
|
|
33
35
|
import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
|
|
34
|
-
import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
|
|
35
|
-
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget,
|
|
36
|
+
import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS, trailingPeerId, withTrailingPeerId } from './peer-dial.js';
|
|
37
|
+
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, relayedRequestBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
36
38
|
import { EnrollmentService } from './enrollment.js';
|
|
37
39
|
import { HibernationManager } from './hibernation-manager.js';
|
|
38
|
-
import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId } from './control-database.js';
|
|
40
|
+
import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, pendingJoinId } from './control-database.js';
|
|
41
|
+
import { CadreInviteHandler, encodeCadreInvitation, redeemInPasses, signRedeemRequest } from './cadre-invite-protocol.js';
|
|
39
42
|
import { FormationPostApprovalError } from './strand-formation-rejection.js';
|
|
40
43
|
import { PendingJoinRunner, membershipInvitesToStage, parseStoredDisclosure, requestedPendingJoin } from './pending-join-runner.js';
|
|
41
|
-
import { SeedBootstrapService } from './seed-bootstrap.js';
|
|
44
|
+
import { SeedBootstrapService, mergeSeedPeers, seedDigest } from './seed-bootstrap.js';
|
|
45
|
+
import { claimSecretTrustPolicy } from './seed-trust-policy.js';
|
|
46
|
+
import { ClaimRefusedError, claimProof, parseClaimSecret } from './claim-proof.js';
|
|
42
47
|
import { StrandSolicitationService } from './strand-solicitation.js';
|
|
43
48
|
import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
|
|
44
|
-
import { selectInvitationSiblingAddrs } from './invitation-bootstrap.js';
|
|
45
|
-
import { createMembershipConnectionGater,
|
|
49
|
+
import { cadreInvitationReach, selectCadreInvitationMembers, selectInvitationSiblingAddrs } from './invitation-bootstrap.js';
|
|
50
|
+
import { createMembershipConnectionGater, UnauthorizedReservationBudget } from './membership-connection-gater.js';
|
|
51
|
+
import { controlNetworkName, installControlProtocolGuard } from './control-protocol-guard.js';
|
|
46
52
|
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
47
53
|
import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
|
|
48
54
|
import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, prunePeerStrandKeys, peerStrandKey } from './delegate-admission.js';
|
|
49
55
|
import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
|
|
56
|
+
import { nodeTransportsOption } from './node-transports.js';
|
|
50
57
|
import { resolveRelayServer } from './relay-server.js';
|
|
51
58
|
import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
|
|
52
59
|
import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
|
|
@@ -77,6 +84,15 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
|
|
|
77
84
|
* minute rather than one a tick.
|
|
78
85
|
*/
|
|
79
86
|
export const STRAND_PEER_ADDR_RETRY_MS = 60 * 1000;
|
|
87
|
+
/**
|
|
88
|
+
* Default lifetime of an UNTARGETED, OWNER-GRANTING cadre invitation
|
|
89
|
+
* ({@link CadreNode.createCadreInvitation}): whoever holds the bundle becomes an owner, so
|
|
90
|
+
* it is a bearer credential for admin rights and lives only long enough to be pasted or
|
|
91
|
+
* scanned by the device it was minted for.
|
|
92
|
+
*/
|
|
93
|
+
export const OWNER_INVITATION_DEFAULT_TTL_MS = 15 * 60 * 1000;
|
|
94
|
+
/** Default lifetime of every other cadre invitation: targeted, or granting membership only. */
|
|
95
|
+
export const CADRE_INVITATION_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
80
96
|
/**
|
|
81
97
|
* A circuit relay as a strand-addr RPC target: dialed by peerId, with its
|
|
82
98
|
* direct addr riding along as the fallback for a relay we hold no connection to.
|
|
@@ -116,11 +132,10 @@ function unionAddrs(primary, extra) {
|
|
|
116
132
|
}
|
|
117
133
|
return out;
|
|
118
134
|
}
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
*/
|
|
135
|
+
/** Whether a `CadrePeer` or `OwnerKey` row's proof is an invitation admission rather than an owner voucher (`types.ts` → `CadrePeerRow`, `OwnerKeyRow`). */
|
|
136
|
+
function isInvitationAdmitted(row) {
|
|
137
|
+
return row.vouchSig === null && row.vouchUsage !== null;
|
|
138
|
+
}
|
|
124
139
|
function signMessageWith(privateKeyB64) {
|
|
125
140
|
return (message) => sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
|
|
126
141
|
}
|
|
@@ -244,8 +259,23 @@ export class CadreNode {
|
|
|
244
259
|
* start() cycle rebuilds it with a fresh caught-up-peer memo.
|
|
245
260
|
*/
|
|
246
261
|
this.controlBackfill = null;
|
|
262
|
+
/**
|
|
263
|
+
* Devices whose invitation redemption this member has verified and is admitting right now,
|
|
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.
|
|
267
|
+
*/
|
|
268
|
+
this.admittingDevices = new Map();
|
|
247
269
|
this.strandWatcher = null;
|
|
248
270
|
this.seedBootstrapService = null;
|
|
271
|
+
/**
|
|
272
|
+
* The claim-secret trust policy of a node started with `config.claim`, built once by
|
|
273
|
+
* {@link initializeClaimPolicy} over {@link trustedOwnerStore} and handed to every
|
|
274
|
+
* seed service this node constructs ({@link inboundSeedTrustPolicy}). Kept across
|
|
275
|
+
* stop()→start() like the anchor it writes: its in-process latch and failure window
|
|
276
|
+
* are per node, not per seed service. Null when the node has no claim secret.
|
|
277
|
+
*/
|
|
278
|
+
this.claimTrustPolicy = null;
|
|
249
279
|
this.strandSolicitationService = null;
|
|
250
280
|
/**
|
|
251
281
|
* The service whose formation handler is registered on the control node. Trails
|
|
@@ -264,6 +294,13 @@ export class CadreNode {
|
|
|
264
294
|
* stops conflating control addresses with strand seeding.
|
|
265
295
|
*/
|
|
266
296
|
this.strandAddrService = null;
|
|
297
|
+
/**
|
|
298
|
+
* Cadre invitation redemption handler (`/sereus/cadre-invite/1.0.0`): a device holding an
|
|
299
|
+
* invitation dials this machine and proves possession; the handler seats the row and
|
|
300
|
+
* admits the device against this node's control database. Registered by {@link start}
|
|
301
|
+
* once that database is up, on every node.
|
|
302
|
+
*/
|
|
303
|
+
this.cadreInviteHandler = null;
|
|
267
304
|
/**
|
|
268
305
|
* Server-side push-wake fan-out. Constructed by {@link start} only when
|
|
269
306
|
* `config.push` (an injected `PushNotifier` + policy) is present — without it
|
|
@@ -273,6 +310,8 @@ export class CadreNode {
|
|
|
273
310
|
this.pushFanoutService = null;
|
|
274
311
|
/** Backing field for the {@link running} / {@link isRunning} getters. */
|
|
275
312
|
this._running = false;
|
|
313
|
+
/** The {@link stop} in progress, or null; a concurrent `stop()` awaits it. */
|
|
314
|
+
this.stopInFlight = null;
|
|
276
315
|
/**
|
|
277
316
|
* The control database's own raw storage (cache-wrapped), resolved once per
|
|
278
317
|
* `start()` and released in {@link cleanup}. Owning it is what keeps the
|
|
@@ -359,24 +398,10 @@ export class CadreNode {
|
|
|
359
398
|
/**
|
|
360
399
|
* Most-recently pushed invite addresses (see {@link setInviteAddresses}).
|
|
361
400
|
* When non-null these take priority over `libp2pNode.getMultiaddrs()` when
|
|
362
|
-
* minting
|
|
363
|
-
* control-network node never needs to dial back to the manager.
|
|
401
|
+
* minting cadre invitations — the host pushes NAT-resolved addresses here so
|
|
402
|
+
* the control-network node never needs to dial back to the manager.
|
|
364
403
|
*/
|
|
365
404
|
this.latestInviteAddresses = null;
|
|
366
|
-
/**
|
|
367
|
-
* Epoch ms until which the control-network inbound gate admits
|
|
368
|
-
* not-yet-authorized peers (see {@link admitInboundControlConnection} /
|
|
369
|
-
* {@link openEnrollmentWindow}). 0 = no window open. Opened automatically by
|
|
370
|
-
* {@link createInvite}; deliberately NOT reset on stop() — a stop/start cycle
|
|
371
|
-
* inside an outstanding invite's validity must not strand the invitee.
|
|
372
|
-
*
|
|
373
|
-
* NOTE: in-memory only, so a PROCESS restart mid-invite closes the door until
|
|
374
|
-
* the owner re-mints or the invitee is authorized. Fine while invites are
|
|
375
|
-
* short-lived and redeemed promptly; if long-lived invites or restart-prone
|
|
376
|
-
* hosts become normal, persist the window (or derive it from the issued-invite
|
|
377
|
-
* records `SeedBootstrapService` already keeps).
|
|
378
|
-
*/
|
|
379
|
-
this.enrollmentWindowUntil = 0;
|
|
380
405
|
/**
|
|
381
406
|
* Lazily-parsed PeerIds of `controlNetwork.bootstrapNodes` (see
|
|
382
407
|
* {@link getBootstrapPeerIds}) — infrastructure the inbound gate always admits.
|
|
@@ -384,13 +409,15 @@ export class CadreNode {
|
|
|
384
409
|
this.bootstrapPeerIds = null;
|
|
385
410
|
/**
|
|
386
411
|
* Materialized in-memory snapshot of the AUTHORIZED member peer ids (the
|
|
387
|
-
* {@link listAuthorizedMembers} result), consulted
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
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
|
|
394
421
|
* start, on every {@link reconcileControlCohort} pass (15s cadence), and
|
|
395
422
|
* immediately after every LOCAL membership mutation so a just-vouched peer
|
|
396
423
|
* is admitted without waiting for the timer. That last refresh is AUTOMATIC:
|
|
@@ -399,7 +426,11 @@ export class CadreNode {
|
|
|
399
426
|
* writer has to remember. A change that arrives by REPLICATION is picked up
|
|
400
427
|
* on the next timed refresh — bounded staleness, acceptable because this gate
|
|
401
428
|
* is defense in depth: rows an unadmitted peer manages to write are still
|
|
402
|
-
* 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.
|
|
403
434
|
*/
|
|
404
435
|
this.authorizedControlPeers = new Set();
|
|
405
436
|
/**
|
|
@@ -411,6 +442,17 @@ export class CadreNode {
|
|
|
411
442
|
this.membershipGateDirty = false;
|
|
412
443
|
this.membershipGateDrain = null;
|
|
413
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;
|
|
414
456
|
/**
|
|
415
457
|
* In-memory delegate admission grants (see `delegate-admission.ts`): the
|
|
416
458
|
* strand-node transport peerIds this party's members have announced over the
|
|
@@ -525,7 +567,7 @@ export class CadreNode {
|
|
|
525
567
|
this.pendingMembershipInvites = new Map();
|
|
526
568
|
/**
|
|
527
569
|
* Invite keys of the membership invitations this process has staged, by
|
|
528
|
-
* {@link adoptFormationMembershipInvite} or from a `
|
|
570
|
+
* {@link adoptFormationMembershipInvite} or from a `JoinSuccess` row
|
|
529
571
|
* ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
|
|
530
572
|
* staged again by the next pending-join pass. Same lifetime as
|
|
531
573
|
* {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
|
|
@@ -653,9 +695,11 @@ export class CadreNode {
|
|
|
653
695
|
* When this node last PUBLISHED its own `CadrePeer` address record (inserted or
|
|
654
696
|
* refreshed), or null if it never has in this session. Stamped by
|
|
655
697
|
* {@link noteSelfRecordPublished} on every successful publish, wherever it was driven
|
|
656
|
-
* 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.
|
|
657
700
|
*
|
|
658
|
-
* 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
|
|
659
703
|
* attention: once this gap exceeds {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}, every other
|
|
660
704
|
* machine in the party is already discarding this node's address as stale.
|
|
661
705
|
*/
|
|
@@ -870,11 +914,19 @@ export class CadreNode {
|
|
|
870
914
|
// libp2p/network bring-up, so a misconfiguration or an access-denied secure
|
|
871
915
|
// store fails closed before a node is created.
|
|
872
916
|
await this.resolveIdentityKey();
|
|
917
|
+
// A conflicting `claim` is refused BEFORE the anchor is built: the store below
|
|
918
|
+
// writes the config pins, and on a persistent anchor a pin written ahead of
|
|
919
|
+
// this refusal would survive the failed start and mark the node claimed by it.
|
|
920
|
+
this.validateClaimConfig();
|
|
873
921
|
// Bring up the node-local trusted-owner anchor (and seed config pins)
|
|
874
922
|
// before any network bring-up: a mis-scoped injected store fails closed
|
|
875
923
|
// here, and out-of-band pins are anchored before the first seed/peer
|
|
876
924
|
// interaction could consult them.
|
|
877
925
|
await this.initializeTrustedOwnerStore();
|
|
926
|
+
// A node started with a claim secret gets its trust policy here, over the anchor
|
|
927
|
+
// just built; a malformed secret fails closed before any network bring-up, like
|
|
928
|
+
// a mis-scoped anchor above.
|
|
929
|
+
this.initializeClaimPolicy();
|
|
878
930
|
// Bring up the node-local cold-start bootstrap-peer store alongside the
|
|
879
931
|
// anchor, and for the same two reasons: a mis-scoped injected store fails
|
|
880
932
|
// closed before any network bring-up, and the retained dial targets are
|
|
@@ -915,6 +967,9 @@ export class CadreNode {
|
|
|
915
967
|
this.controlNode = await this.createControlNode();
|
|
916
968
|
timing('[start] createControlNode: %dms', Math.round(performance.now() - t0));
|
|
917
969
|
log('Control node started with ID: %s', this.controlNode.peerId.toString());
|
|
970
|
+
// Before anything else can happen on the node: the quiet period above holds every
|
|
971
|
+
// connection off, so no stream can arrive ahead of the guard.
|
|
972
|
+
this.installControlProtocolGuard(this.controlNode);
|
|
918
973
|
// Extract coordinatedRepo from the node (attached by createLibp2pNode)
|
|
919
974
|
const coordinatedRepo = this.controlNode.coordinatedRepo;
|
|
920
975
|
if (!coordinatedRepo) {
|
|
@@ -976,25 +1031,23 @@ export class CadreNode {
|
|
|
976
1031
|
// Start hibernation manager
|
|
977
1032
|
this.hibernationManager.start();
|
|
978
1033
|
// Register the control-network push-wake receiver: a same-cadre peer can
|
|
979
|
-
// signal us to bring a hibernating strand online.
|
|
980
|
-
//
|
|
981
|
-
// path as a local wake.
|
|
1034
|
+
// signal us to bring a hibernating strand online. The protocol guard admits
|
|
1035
|
+
// only AUTHORIZED members (not the addressable surface); the wake routes
|
|
1036
|
+
// through the same path as a local wake.
|
|
982
1037
|
this.strandWakeService = new StrandWakeService({
|
|
983
|
-
isMember: (peerId) => this.isAuthorizedMember(peerId),
|
|
984
1038
|
getStrand: (strandId) => this.strandManager.getInstance(strandId),
|
|
985
1039
|
wake: (strandId) => this.wakeStrand(strandId),
|
|
986
1040
|
});
|
|
987
1041
|
await this.strandWakeService.initialize(this.controlNode);
|
|
988
1042
|
// Register the control-network strand-address responder: a same-cadre peer
|
|
989
1043
|
// resolving a strand's bootstrap seed asks us for our live strand-network
|
|
990
|
-
// multiaddrs (its CadrePeer row only knows our *control* address).
|
|
991
|
-
// AUTHORIZED
|
|
992
|
-
// The same RPC doubles as the delegate-announce channel:
|
|
993
|
-
// may carry the derived peerId its strand node runs as, and
|
|
994
|
-
// admission grant so our connection gate (and thus our relay
|
|
995
|
-
// enabled) admits it — see delegate-admission.ts.
|
|
1044
|
+
// multiaddrs (its CadrePeer row only knows our *control* address). The
|
|
1045
|
+
// protocol guard admits only AUTHORIZED members; answers only for strands we
|
|
1046
|
+
// are actively meshing. The same RPC doubles as the delegate-announce channel:
|
|
1047
|
+
// a member's request may carry the derived peerId its strand node runs as, and
|
|
1048
|
+
// we record an admission grant so our connection gate (and thus our relay
|
|
1049
|
+
// server, when enabled) admits it — see delegate-admission.ts.
|
|
996
1050
|
this.strandAddrService = new StrandAddrService({
|
|
997
|
-
isMember: (peerId) => this.isAuthorizedMember(peerId),
|
|
998
1051
|
getStrandMultiaddrs: (strandId) => this.getStrandMultiaddrs(strandId),
|
|
999
1052
|
onDelegateAnnounce: (announcer, strandId, delegate) => this.grantDelegateAdmission(announcer, strandId, delegate),
|
|
1000
1053
|
});
|
|
@@ -1004,6 +1057,22 @@ export class CadreNode {
|
|
|
1004
1057
|
// check a token for an inviter that is offline. After the control database is up,
|
|
1005
1058
|
// so the responder never reads a database that cannot answer yet.
|
|
1006
1059
|
await this.installDefaultFormationResponder();
|
|
1060
|
+
// Answer cadre invitation redemptions from now on: a device holding an invitation
|
|
1061
|
+
// dials any member machine, this one included, and proves possession in-protocol.
|
|
1062
|
+
// After the control database is up, since the handler seats and redeems against it.
|
|
1063
|
+
this.cadreInviteHandler = new CadreInviteHandler({
|
|
1064
|
+
partyId: this.config.controlNetwork.partyId,
|
|
1065
|
+
store: this.controlDatabase,
|
|
1066
|
+
admitDevice: (peerId) => this.admitRedeemingDevice(peerId),
|
|
1067
|
+
});
|
|
1068
|
+
await this.cadreInviteHandler.register(this.controlNode);
|
|
1069
|
+
// A node waiting to be claimed listens for its claim seed from the start: nobody
|
|
1070
|
+
// else will turn the listener on, and a node no seed can reach cannot be claimed.
|
|
1071
|
+
// Before `control:connected`, so an embedder that reports "awaiting claim" on that
|
|
1072
|
+
// event is already reachable on `/sereus/seed/1.0.0`.
|
|
1073
|
+
if (this.config.claim) {
|
|
1074
|
+
await this.enableSeedListener();
|
|
1075
|
+
}
|
|
1007
1076
|
// Server-side push-wake fan-out: only when push is configured.
|
|
1008
1077
|
if (this.config.push) {
|
|
1009
1078
|
this.pushFanoutService = this.buildPushFanout(this.config.push);
|
|
@@ -1083,10 +1152,26 @@ export class CadreNode {
|
|
|
1083
1152
|
* Stop the cadre node
|
|
1084
1153
|
*/
|
|
1085
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
|
+
}
|
|
1086
1162
|
if (!this._running) {
|
|
1087
1163
|
log('CadreNode not running');
|
|
1088
1164
|
return;
|
|
1089
1165
|
}
|
|
1166
|
+
this.stopInFlight = this.stopRunning();
|
|
1167
|
+
try {
|
|
1168
|
+
await this.stopInFlight;
|
|
1169
|
+
}
|
|
1170
|
+
finally {
|
|
1171
|
+
this.stopInFlight = null;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
async stopRunning() {
|
|
1090
1175
|
log('Stopping CadreNode');
|
|
1091
1176
|
await this.cleanup();
|
|
1092
1177
|
this.emit('control:disconnected', undefined);
|
|
@@ -1158,15 +1243,22 @@ export class CadreNode {
|
|
|
1158
1243
|
}
|
|
1159
1244
|
/**
|
|
1160
1245
|
* Construct (or adopt) the node-local trusted-owner anchor and seed the
|
|
1161
|
-
* out-of-band pinned keys from `config.trustedOwners`. The
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1246
|
+
* out-of-band pinned keys from `config.trustedOwners`. The anchor's BASE comes
|
|
1247
|
+
* only from out of band — genesis self-trust ({@link initializeSeedBootstrap}),
|
|
1248
|
+
* config pins (here), runtime enrollment pins ({@link trustOwnerKeys}) and a
|
|
1249
|
+
* claim; everything else in it is derived from that base along verifiable
|
|
1250
|
+
* chains through the replicated `OwnerKey` table ({@link syncOwnerAnchor}),
|
|
1251
|
+
* never read from the table directly.
|
|
1165
1252
|
*
|
|
1166
1253
|
* Idempotent across stop()→start(): the store instance is kept, and
|
|
1167
1254
|
* re-seeding config pins is a no-op ({@link TrustedOwnerStore.trust} is
|
|
1168
1255
|
* idempotent). An injected store scoped to a different party is a
|
|
1169
1256
|
* configuration error (fail closed before any network bring-up).
|
|
1257
|
+
*
|
|
1258
|
+
* NOTE: a config pin of a key the owners have since REMOVED is re-anchored here at
|
|
1259
|
+
* every start and removed again by the first sync (`refreshMembershipGate('start')`,
|
|
1260
|
+
* fired without await from {@link start}), so such a pin is honoured for at most one
|
|
1261
|
+
* membership refresh per start; dropping the pin from the config ends that.
|
|
1170
1262
|
*/
|
|
1171
1263
|
async initializeTrustedOwnerStore() {
|
|
1172
1264
|
const { trustedOwners } = this.config;
|
|
@@ -1187,6 +1279,85 @@ export class CadreNode {
|
|
|
1187
1279
|
await this.trustedOwnerStore.trust(key, trustedOwners?.pinnedSource ?? 'operator');
|
|
1188
1280
|
}
|
|
1189
1281
|
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Refuse a `config.claim` that sits beside another cold-start trust source. A node
|
|
1284
|
+
* has exactly ONE, so `claim` with a non-empty `trustedOwners.pinnedKeys` or a
|
|
1285
|
+
* `seedTrustPolicy` is refused here rather than silently letting one of them win.
|
|
1286
|
+
* Asserted at this seam, not by a type: the embedders build `CadreNodeConfig` as one
|
|
1287
|
+
* object literal with every optional field present, which a discriminated union over
|
|
1288
|
+
* the whole config would not express.
|
|
1289
|
+
*
|
|
1290
|
+
* Runs BEFORE {@link initializeTrustedOwnerStore}, which writes the config pins into
|
|
1291
|
+
* the anchor: on a persistent anchor a pin written ahead of this refusal would outlive
|
|
1292
|
+
* the failed start, and the node, restarted with the pin dropped from its config and
|
|
1293
|
+
* `claim` kept, would read as claimed by that pin and never accept its claimant.
|
|
1294
|
+
*/
|
|
1295
|
+
validateClaimConfig() {
|
|
1296
|
+
if (!this.config.claim) {
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if ((this.config.trustedOwners?.pinnedKeys?.length ?? 0) > 0) {
|
|
1300
|
+
throw new Error('CadreNodeConfig: `claim` and `trustedOwners.pinnedKeys` are both set — a node has one ' +
|
|
1301
|
+
'cold-start trust source; a node waiting to be claimed anchors its claimant, not a pin');
|
|
1302
|
+
}
|
|
1303
|
+
if (this.config.seedTrustPolicy) {
|
|
1304
|
+
throw new Error('CadreNodeConfig: `claim` and `seedTrustPolicy` are both set — a node has one cold-start ' +
|
|
1305
|
+
'trust source; the claim secret supplies the seed trust policy itself');
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Build {@link claimTrustPolicy} from `config.claim`, once per node (a restart keeps
|
|
1310
|
+
* the policy, as {@link initializeTrustedOwnerStore} keeps the anchor it writes).
|
|
1311
|
+
* Runs after the anchor exists and before any network bring-up, so a malformed secret
|
|
1312
|
+
* fails `start()` closed with nothing to tear down; `parseClaimSecret` names the
|
|
1313
|
+
* problem and never echoes the value. The conflicts with the other trust sources are
|
|
1314
|
+
* {@link validateClaimConfig}'s, checked before the anchor is written.
|
|
1315
|
+
*/
|
|
1316
|
+
initializeClaimPolicy() {
|
|
1317
|
+
const { claim } = this.config;
|
|
1318
|
+
if (!claim || this.claimTrustPolicy) {
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
// Unreachable in practice: start() builds the store just above. Guarded so the
|
|
1322
|
+
// policy can never be built over a missing anchor and silently claim into nothing.
|
|
1323
|
+
if (!this.trustedOwnerStore) {
|
|
1324
|
+
throw new Error('CadreNode: the trusted-owner anchor must exist before the claim policy is built');
|
|
1325
|
+
}
|
|
1326
|
+
this.claimTrustPolicy = claimSecretTrustPolicy({
|
|
1327
|
+
secret: parseClaimSecret(claim.secret),
|
|
1328
|
+
trustedOwners: this.trustedOwnerStore,
|
|
1329
|
+
recordClaim: claim.record,
|
|
1330
|
+
onClaimed: (accepted) => this.emit('claim:accepted', accepted),
|
|
1331
|
+
});
|
|
1332
|
+
log('Node starts awaiting a claim; start() enables the seed listener once the control node is up');
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Is this node waiting to be claimed: started with `config.claim` and owned by nobody
|
|
1336
|
+
* yet (empty anchor)? Read live from the anchor every time, as
|
|
1337
|
+
* {@link admitControlPeerUnconditionally} reads it per stream: the anchor is the one
|
|
1338
|
+
* source of truth for "claimed", and a cached flag would need its own invalidation
|
|
1339
|
+
* from the policy's persist, the rollback and a restart. False on a node with no claim
|
|
1340
|
+
* secret, on a claimed one (restarted with the secret still set or not), and before
|
|
1341
|
+
* {@link start} has built the anchor.
|
|
1342
|
+
*
|
|
1343
|
+
* While true the node admits strangers' CONNECTIONS (the claim seed has to ride one)
|
|
1344
|
+
* and refuses them control-database streams and cohort places ({@link isControlNetworkMember})
|
|
1345
|
+
* and relay reservations ({@link admitControlRelayReservation}): an unclaimed node has
|
|
1346
|
+
* no siblings to replicate from and must not become a public relay while it waits.
|
|
1347
|
+
* cadre-cli reports this on `/status`; `claim:accepted` is the event form.
|
|
1348
|
+
*/
|
|
1349
|
+
isAwaitingClaim() {
|
|
1350
|
+
return this.config.claim !== undefined && this.trustedOwnerStore?.all().size === 0;
|
|
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
|
+
}
|
|
1190
1361
|
/**
|
|
1191
1362
|
* Construct (or adopt) the node-local cold-start bootstrap-peer store (see
|
|
1192
1363
|
* {@link bootstrapPeerStore}). Synchronous: an injected store has already
|
|
@@ -1311,6 +1482,38 @@ export class CadreNode {
|
|
|
1311
1482
|
getStrandNetworkStateStore() {
|
|
1312
1483
|
return this.strandNetworkStateStore;
|
|
1313
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
|
+
}
|
|
1314
1517
|
/**
|
|
1315
1518
|
* The node-local trusted-owner anchor (null before {@link start}). This is
|
|
1316
1519
|
* the set the authorized-membership predicate and seed-trust anchor consult —
|
|
@@ -1321,15 +1524,16 @@ export class CadreNode {
|
|
|
1321
1524
|
}
|
|
1322
1525
|
/**
|
|
1323
1526
|
* Persist out-of-band-established owner keys into the node-local anchor —
|
|
1324
|
-
* the runtime enrollment seam:
|
|
1325
|
-
*
|
|
1326
|
-
* holds the pins when
|
|
1327
|
-
* pin. Idempotent. ('genesis'
|
|
1328
|
-
* founding key, seeded internally by
|
|
1527
|
+
* the runtime enrollment seam: {@link redeemCadreInvitation} calls it with the
|
|
1528
|
+
* cadre invitation's `ownerKeys` BEFORE dialing a member (so the anchor already
|
|
1529
|
+
* holds the pins when the reply and the rows that follow are judged), and an
|
|
1530
|
+
* embedder calls it with an operator-supplied pin. Idempotent. ('genesis'
|
|
1531
|
+
* provenance is reserved for the node's own founding key, seeded internally by
|
|
1532
|
+
* {@link initializeSeedBootstrap}; 'chain' is written only by {@link syncOwnerAnchor}.)
|
|
1329
1533
|
*
|
|
1330
1534
|
* Validates every key's shape before trusting any of them (all-or-nothing):
|
|
1331
1535
|
* a malformed entry anywhere in `keys` rejects the whole call before a
|
|
1332
|
-
* single key is anchored. For the
|
|
1536
|
+
* single key is anchored. For the invitation route this means a bundle
|
|
1333
1537
|
* carrying one malformed `ownerKeys` entry fails the redemption outright —
|
|
1334
1538
|
* consistent with the anchor's existing whole-record-or-nothing policy for
|
|
1335
1539
|
* a corrupt persisted entry (see `trusted-owner-store.ts`'s
|
|
@@ -1349,19 +1553,23 @@ export class CadreNode {
|
|
|
1349
1553
|
return await createLibp2pNode(this.buildControlNodeOptions());
|
|
1350
1554
|
}
|
|
1351
1555
|
/**
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
* NOTE: the party id goes in UNENCODED here, unlike in `controlStorageScope`. Safe
|
|
1358
|
-
* today because a party id is locally configured rather than replicated in, and both
|
|
1359
|
-
* ends of a connection derive this string identically — an odd party id yields an odd
|
|
1360
|
-
* but consistent protocol id, not a mismatch or an escaped name. If a party id ever
|
|
1361
|
-
* arrives from the network, encode it here as the storage scope key already does.
|
|
1556
|
+
* Refuse non-members on every members-only protocol of the control node, at one seam
|
|
1557
|
+
* (`control-protocol-guard.ts`). Wake and strand-addr are judged by the live
|
|
1558
|
+
* {@link isAuthorizedMember}, bounded by the asker's own attempt deadline at this
|
|
1559
|
+
* node's declared link; everything a control-database read depends on is judged by
|
|
1560
|
+
* the snapshot ({@link authorizeInboundControlStream}).
|
|
1362
1561
|
*/
|
|
1562
|
+
installControlProtocolGuard(controlNode) {
|
|
1563
|
+
installControlProtocolGuard(controlNode, {
|
|
1564
|
+
partyId: this.config.controlNetwork.partyId,
|
|
1565
|
+
isMemberLive: (peerId) => this.isAuthorizedMember(peerId),
|
|
1566
|
+
isMemberSnapshot: (peerId, protocol) => this.authorizeInboundControlStream(peerId, protocol),
|
|
1567
|
+
liveDecisionTimeoutMs: relayedRequestBudgetMs(this.config.network?.linkRoundTripMs),
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
/** This node's control network name ({@link controlNetworkName}, the one binding for it). */
|
|
1363
1571
|
controlNetworkName() {
|
|
1364
|
-
return
|
|
1572
|
+
return controlNetworkName(this.config.controlNetwork.partyId);
|
|
1365
1573
|
}
|
|
1366
1574
|
/**
|
|
1367
1575
|
* Arm the CONTROL network's peer-join block catch-up: push every block in the
|
|
@@ -1388,6 +1596,8 @@ export class CadreNode {
|
|
|
1388
1596
|
* reconnect re-runs identify), and — because the production join order is
|
|
1389
1597
|
* connect-then-authorize — on every committed membership change via
|
|
1390
1598
|
* {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
|
|
1599
|
+
* The one non-member the gate admits is a device whose invitation
|
|
1600
|
+
* redemption this member is admitting ({@link admitRedeemingDevice}).
|
|
1391
1601
|
*
|
|
1392
1602
|
* No-ops (logged) when the embedder configured no control storage or the
|
|
1393
1603
|
* node exposes no key network — the backfill would have nothing to read or
|
|
@@ -1417,7 +1627,7 @@ export class CadreNode {
|
|
|
1417
1627
|
// under — derived from the same networkName binding the node options
|
|
1418
1628
|
// used, never re-spelled here.
|
|
1419
1629
|
protocolPrefix: `/optimystic/${networkName}`,
|
|
1420
|
-
authorizePeer: (peerId) => this.isAuthorizedMember(peerId)
|
|
1630
|
+
authorizePeer: async (peerId) => this.admittingDevices.has(peerId) || await this.isAuthorizedMember(peerId)
|
|
1421
1631
|
}, {
|
|
1422
1632
|
// A shorter settle window than the strand default (1000 ms): the control
|
|
1423
1633
|
// store is small (a party's membership — dozens of blocks), a re-push is
|
|
@@ -1443,6 +1653,60 @@ export class CadreNode {
|
|
|
1443
1653
|
});
|
|
1444
1654
|
this.controlBackfill.start();
|
|
1445
1655
|
}
|
|
1656
|
+
/**
|
|
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.
|
|
1665
|
+
*
|
|
1666
|
+
* Through {@link PeerJoinBackfill.forceCatchUpPeer}, not `catchUpPeer`: the device's own
|
|
1667
|
+
* `peer:identify` scheduled a run a moment earlier, which was denied if it passed the gate
|
|
1668
|
+
* before the device was added here, and `catchUpPeer` would answer that run's in-flight
|
|
1669
|
+
* status with an empty result.
|
|
1670
|
+
*/
|
|
1671
|
+
async admitRedeemingDevice(peerId) {
|
|
1672
|
+
const backfill = this.controlBackfill;
|
|
1673
|
+
if (!backfill) {
|
|
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);
|
|
1683
|
+
try {
|
|
1684
|
+
const result = await backfill.forceCatchUpPeer(peerIdFromString(peerId));
|
|
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;
|
|
1687
|
+
}
|
|
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;
|
|
1701
|
+
const remaining = (this.admittingDevices.get(peerId) ?? 1) - 1;
|
|
1702
|
+
if (remaining > 0) {
|
|
1703
|
+
this.admittingDevices.set(peerId, remaining);
|
|
1704
|
+
}
|
|
1705
|
+
else {
|
|
1706
|
+
this.admittingDevices.delete(peerId);
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1446
1710
|
/**
|
|
1447
1711
|
* The control database's cache-wrapped raw storage, resolved from
|
|
1448
1712
|
* `config.storage.provider` on first use and then held in {@link controlStorage}
|
|
@@ -1585,7 +1849,13 @@ export class CadreNode {
|
|
|
1585
1849
|
}),
|
|
1586
1850
|
arachnode: { enableRingZulu: profile === 'storage' },
|
|
1587
1851
|
...(identityKey && { privateKey: identityKey }),
|
|
1588
|
-
|
|
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),
|
|
1589
1859
|
...(network?.noiseCrypto && { noiseCrypto: network.noiseCrypto }),
|
|
1590
1860
|
// Always present, unlike the conditional spreads around it: an absent
|
|
1591
1861
|
// `connectionMonitor` would leave libp2p's 5s ping deadline, which drops a peer
|
|
@@ -1604,7 +1874,9 @@ export class CadreNode {
|
|
|
1604
1874
|
...optimysticDialLimits(network?.linkRoundTripMs),
|
|
1605
1875
|
// `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
|
|
1606
1876
|
// `{}` when `network.transports` is set, since the embedder owns transport policy
|
|
1607
|
-
// 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.
|
|
1608
1880
|
...transportOptions,
|
|
1609
1881
|
// Configured `listenAddrs`, plus the bare `/p2p-circuit` search listener when
|
|
1610
1882
|
// `network.relayAddrs` is set — that listener registers the pending reservation
|
|
@@ -1615,21 +1887,33 @@ export class CadreNode {
|
|
|
1615
1887
|
// `announceAddrs` replaces the advertised set (see warnIfAnnounceAddrsDiscardRelay).
|
|
1616
1888
|
...resolveAnnounceAddrs(network),
|
|
1617
1889
|
// The CONTROL node composes the membership admission gate onto any
|
|
1618
|
-
// caller-supplied gater (deny from
|
|
1619
|
-
//
|
|
1620
|
-
//
|
|
1890
|
+
// caller-supplied gater (a deny from the caller's wins on every composed
|
|
1891
|
+
// hook; the membership policy refuses reservations, never connections,
|
|
1892
|
+
// and puts an unplaced peer's connection on the provisional deadline,
|
|
1893
|
+
// derived from THIS node's declared link because this node decides).
|
|
1894
|
+
// Strand cohort nodes keep the raw configured gater (see
|
|
1621
1895
|
// strand-instance-manager.ts) — their peers are legitimately
|
|
1622
1896
|
// cross-party, so cadre membership must not gate them.
|
|
1623
1897
|
connectionGater: createMembershipConnectionGater({
|
|
1624
1898
|
admitInbound: (remotePeerId) => this.admitInboundControlConnection(remotePeerId),
|
|
1625
1899
|
admitRelayReservation: (remotePeerId) => this.admitControlRelayReservation(remotePeerId),
|
|
1626
1900
|
bringUpInFlight: () => this.controlBringUpInFlight
|
|
1627
|
-
}, network?.connectionGater, ADMISSION_DECISION_TIMEOUT_MS,
|
|
1901
|
+
}, network?.connectionGater, ADMISSION_DECISION_TIMEOUT_MS, relayedRequestBudgetMs(network?.linkRoundTripMs)),
|
|
1628
1902
|
// Fail-closed per-stream authorization for the four Optimystic control-DB
|
|
1629
1903
|
// protocols — the members-only layer the connection gater's stranger
|
|
1630
1904
|
// carve-outs cannot express (see authorizeInboundControlStream). Control
|
|
1631
|
-
// node only: strand cohort nodes serve cross-party peers.
|
|
1632
|
-
|
|
1905
|
+
// node only: strand cohort nodes serve cross-party peers. The protocol guard
|
|
1906
|
+
// (installControlProtocolGuard) checks these four with this same predicate
|
|
1907
|
+
// first; Optimystic's own check is kept because it is a working, supported
|
|
1908
|
+
// upstream defence, not a second policy.
|
|
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)
|
|
1633
1917
|
};
|
|
1634
1918
|
return nodeOptions;
|
|
1635
1919
|
}
|
|
@@ -1647,86 +1931,85 @@ export class CadreNode {
|
|
|
1647
1931
|
log('Relay server on: applyDefaultLimit=%s maxReservations=%d reservationTtl=%dms unauthorizedCap=%d', applyDefaultLimit, maxReservations, reservationTtl, this.unauthorizedRelayReservations.cap);
|
|
1648
1932
|
}
|
|
1649
1933
|
/**
|
|
1650
|
-
* Decide
|
|
1651
|
-
*
|
|
1652
|
-
* {@link createControlNode}.
|
|
1653
|
-
*
|
|
1654
|
-
* and defers to the fail-closed per-stream gates (see
|
|
1934
|
+
* Decide how an inbound CONTROL-network connection from `remotePeerId` is
|
|
1935
|
+
* admitted — the policy behind the connection gater wired in
|
|
1936
|
+
* {@link createControlNode}. Every connection is admitted; the answer decides
|
|
1937
|
+
* only whether it carries the provisional deadline (see
|
|
1655
1938
|
* `membership-connection-gater.ts` for the layer's rationale and the
|
|
1656
|
-
* stranger-open protocol allowlist).
|
|
1939
|
+
* stranger-open protocol allowlist). Asked once when the connection arrives
|
|
1940
|
+
* and again at a provisional connection's deadline.
|
|
1657
1941
|
*
|
|
1658
|
-
* Returns `'admit'` when ANY of:
|
|
1942
|
+
* Returns `'admit'` (no deadline) when ANY of:
|
|
1659
1943
|
* 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
|
|
1660
1944
|
* — not running / DB torn down, absent-or-empty trusted-owner anchor, or
|
|
1661
1945
|
* configured bootstrap/relay infrastructure);
|
|
1662
|
-
* 2.
|
|
1663
|
-
* {@link createInvite}) — the invitee dials in before it is authorized;
|
|
1664
|
-
* 3. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
|
|
1946
|
+
* 2. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
|
|
1665
1947
|
* announced it (over the strand-addr RPC) as the transport peerId of its
|
|
1666
1948
|
* own strand node, so a NAT'd member's strand node can hold a
|
|
1667
1949
|
* circuit-relay reservation here (see `delegate-admission.ts`).
|
|
1668
1950
|
* Connection only: the per-stream gate below never honors a grant;
|
|
1669
|
-
*
|
|
1951
|
+
* 3. the authorized-member set is empty — cold start: the rows that would
|
|
1670
1952
|
* authorize anyone arrive by replication over these very connections;
|
|
1671
|
-
*
|
|
1672
|
-
*
|
|
1953
|
+
* 4. the peer IS an authorized member; or
|
|
1954
|
+
* 5. an open invitation is OUTSTANDING — at least one unexpired,
|
|
1673
1955
|
* not-fully-consumed invitation this node minted or persisted (see
|
|
1674
1956
|
* `StrandSolicitationService.hasOutstandingInvitation`). A formation
|
|
1675
1957
|
* initiator is another party's peer by design and its token is only
|
|
1676
1958
|
* checkable inside the protocol, so the gate asks the coarser question
|
|
1677
1959
|
* "does this node expect a stranger at all?". REGISTERING the responder
|
|
1678
|
-
* does not
|
|
1679
|
-
*
|
|
1960
|
+
* does not count: every node registers one at {@link start}, and
|
|
1961
|
+
* registering mints no invitation; or
|
|
1962
|
+
* 6. a LIVE cadre invitation exists — a `CadreInvite` row this node holds
|
|
1963
|
+
* that is not withdrawn, unexpired, has uses left and whose issuer is
|
|
1964
|
+
* still an owner (`ControlDatabase.hasLiveCadreInvite`). The device that
|
|
1965
|
+
* redeems it is a stranger until the redemption writes its row, and its
|
|
1966
|
+
* proof of possession is only checkable inside `/sereus/cadre-invite/1.0.0`;
|
|
1967
|
+
* same reasoning as check 5, and the same expectation-of-a-stranger key.
|
|
1680
1968
|
*
|
|
1681
1969
|
* Ordering is semantically free (the checks are OR'd) but decides who pays:
|
|
1682
|
-
* checks 1-
|
|
1683
|
-
*
|
|
1970
|
+
* checks 1-2 are in-memory, 3/4 share one control-DB read, and only a peer
|
|
1971
|
+
* that is otherwise headed for a provisional admission reaches check 5's and
|
|
1972
|
+
* 6's invitation lookups.
|
|
1684
1973
|
*
|
|
1685
|
-
* Caveats of check
|
|
1974
|
+
* Caveats of check 5:
|
|
1686
1975
|
* - the in-memory mint registry dies with the process, so after a restart
|
|
1687
1976
|
* only invitations persisted as `FormationInvite` rows still hold the
|
|
1688
|
-
*
|
|
1977
|
+
* window open (re-mint otherwise);
|
|
1689
1978
|
* - a peer holding a token whose `FormationInvite` row has not replicated to
|
|
1690
|
-
* this node yet is
|
|
1691
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
* When every check falls through
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
1702
|
-
* {@link admitControlRelayReservation}
|
|
1703
|
-
*
|
|
1704
|
-
*
|
|
1705
|
-
*
|
|
1706
|
-
* NOTE: check 4/5 runs a control-DB read per inbound connection
|
|
1979
|
+
* this node yet is admitted only provisionally, so its connection is closed
|
|
1980
|
+
* at the deadline unless the row has landed by then.
|
|
1981
|
+
*
|
|
1982
|
+
* When every check falls through: `'admit-provisionally'`, on every node,
|
|
1983
|
+
* relay server or not. The gate sees only a peer id, so it cannot tell an
|
|
1984
|
+
* outsider from a device whose invitation row this node has not received yet
|
|
1985
|
+
* (it redeems here by seating the row from its bundle, after which check 6
|
|
1986
|
+
* admits) or from a sibling whose membership row is still replicating (check 4
|
|
1987
|
+
* admits once it lands; if it has not landed by the deadline, the connection
|
|
1988
|
+
* is closed and either side's next reconcile dial re-forms it, since outbound
|
|
1989
|
+
* dials are never gated). The protocol guard refuses all of them every
|
|
1990
|
+
* members-only protocol meanwhile. A circuit-relay reservation admitted by
|
|
1991
|
+
* {@link admitControlRelayReservation} disarms the deadline (see
|
|
1992
|
+
* `membership-connection-gater.ts` → "The relay-reservation seam").
|
|
1993
|
+
*
|
|
1994
|
+
* NOTE: check 3/4 runs a control-DB read per inbound connection
|
|
1707
1995
|
* (`listAuthorizedMembers`); connections are rare and cadres small, and this
|
|
1708
1996
|
* layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
|
|
1709
1997
|
* read is safe here — unlike the per-stream gate, which must consult the
|
|
1710
1998
|
* materialized {@link authorizedControlPeers} snapshot instead.
|
|
1711
|
-
* NOTE:
|
|
1712
|
-
* stranger with no locally minted invitation in play, on
|
|
1713
|
-
* node
|
|
1714
|
-
*
|
|
1715
|
-
* the
|
|
1716
|
-
* NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
|
|
1717
|
-
* replicated here is denied until the row converges (typically via the
|
|
1718
|
-
* owner); either side's next outbound reconcile dial (outbound is never
|
|
1719
|
-
* gated) re-establishes the DATA link — self-healing for data, visible as a
|
|
1720
|
-
* transient deny. That self-healing story never covered a reservation, which
|
|
1721
|
-
* is exactly why the relay-enabled path above exists.
|
|
1999
|
+
* NOTE: checks 5 and 6 add two more control reads (`hasOutstandingFormationInvite`,
|
|
2000
|
+
* `hasLiveCadreInvite`) for a stranger with no locally minted invitation in play, on
|
|
2001
|
+
* every node, and a provisional connection's deadline runs the whole decision once
|
|
2002
|
+
* more. If stranger connections ever arrive fast enough for those reads — on arrival
|
|
2003
|
+
* or at the deadline — to show, cache the answers for a few seconds.
|
|
1722
2004
|
*/
|
|
1723
2005
|
async admitInboundControlConnection(remotePeerId) {
|
|
2006
|
+
// NOTE: a node waiting to be claimed (isAwaitingClaim) lands on this empty-anchor
|
|
2007
|
+
// admission too, so a stranger can hold an idle, mute connection to it for as long
|
|
2008
|
+
// as it waits on a public address; if that ever shows up as a resource problem, put
|
|
2009
|
+
// unclaimed-node admissions on the provisional deadline too.
|
|
1724
2010
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
1725
2011
|
return 'admit';
|
|
1726
2012
|
}
|
|
1727
|
-
if (Date.now() < this.enrollmentWindowUntil) {
|
|
1728
|
-
return 'admit';
|
|
1729
|
-
}
|
|
1730
2013
|
if (this.delegateAdmission.has(remotePeerId)) {
|
|
1731
2014
|
return 'admit';
|
|
1732
2015
|
}
|
|
@@ -1741,17 +2024,16 @@ export class CadreNode {
|
|
|
1741
2024
|
if (await this.strandSolicitationService?.hasOutstandingInvitation()) {
|
|
1742
2025
|
return 'admit';
|
|
1743
2026
|
}
|
|
2027
|
+
if (await this.controlDatabase?.hasLiveCadreInvite()) {
|
|
2028
|
+
return 'admit';
|
|
2029
|
+
}
|
|
1744
2030
|
}
|
|
1745
2031
|
catch (error) {
|
|
1746
2032
|
log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
|
|
1747
2033
|
return 'admit';
|
|
1748
2034
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
return 'admit-for-relay';
|
|
1752
|
-
}
|
|
1753
|
-
log('admitInboundControlConnection: DENYING inbound from %s — not an authorized member and no enrollment path open', remotePeerId);
|
|
1754
|
-
return 'deny';
|
|
2035
|
+
log('admitInboundControlConnection: %s is not an authorized member and no stranger window is open — admitting provisionally', remotePeerId);
|
|
2036
|
+
return 'admit-provisionally';
|
|
1755
2037
|
}
|
|
1756
2038
|
/**
|
|
1757
2039
|
* Should `remotePeerId` be granted a circuit-relay reservation slot on this
|
|
@@ -1760,7 +2042,13 @@ export class CadreNode {
|
|
|
1760
2042
|
* "The relay-reservation seam"); the circuit-relay server consults it per
|
|
1761
2043
|
* RESERVE request, so it is never called on a node whose relay server is off.
|
|
1762
2044
|
*
|
|
1763
|
-
*
|
|
2045
|
+
* Refuses everyone, ahead of every admission below, while this node is waiting to be
|
|
2046
|
+
* claimed ({@link isAwaitingClaim}): a storage-profile node runs the relay server by
|
|
2047
|
+
* default, and an unclaimed node on a public address must not become anyone's relay.
|
|
2048
|
+
* The empty-anchor admission in check 1 exists for a node that will be enrolled by
|
|
2049
|
+
* its siblings, which an unclaimed node has none of.
|
|
2050
|
+
*
|
|
2051
|
+
* Otherwise admits when ANY of:
|
|
1764
2052
|
* 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
|
|
1765
2053
|
* — not running / DB torn down, absent-or-empty trusted-owner anchor, or
|
|
1766
2054
|
* configured bootstrap/relay infrastructure);
|
|
@@ -1780,14 +2068,18 @@ export class CadreNode {
|
|
|
1780
2068
|
* that boots, reserves, and only then has its row replicate here would
|
|
1781
2069
|
* otherwise keep that slot spent for the rest of the entry's TTL.
|
|
1782
2070
|
*
|
|
1783
|
-
* The connection gate's stranger carve-outs (an
|
|
1784
|
-
*
|
|
1785
|
-
* exist so a stranger's
|
|
1786
|
-
* neither needs relay capacity. A genuine invitee that does need a relay
|
|
1787
|
-
* takes one from the budget like any other unplaced peer, so
|
|
1788
|
-
* never becomes an unbounded grant of this node's forwarding capacity.
|
|
2071
|
+
* The connection gate's stranger carve-outs (an outstanding formation
|
|
2072
|
+
* invitation, a live cadre invitation) deliberately do NOT extend here: they
|
|
2073
|
+
* exist so a stranger's FORMATION or CADRE-INVITE stream can ride a connection,
|
|
2074
|
+
* and neither needs relay capacity. A genuine invitee that does need a relay
|
|
2075
|
+
* slot takes one from the budget like any other unplaced peer, so a live
|
|
2076
|
+
* invitation never becomes an unbounded grant of this node's forwarding capacity.
|
|
1789
2077
|
*/
|
|
1790
2078
|
async admitControlRelayReservation(remotePeerId) {
|
|
2079
|
+
if (this.isAwaitingClaim()) {
|
|
2080
|
+
log('admitControlRelayReservation: REFUSING %s — this node is unclaimed and relays for nobody until it is', remotePeerId);
|
|
2081
|
+
return false;
|
|
2082
|
+
}
|
|
1791
2083
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
1792
2084
|
return this.admitReservationUncounted(remotePeerId);
|
|
1793
2085
|
}
|
|
@@ -1807,24 +2099,13 @@ export class CadreNode {
|
|
|
1807
2099
|
this.unauthorizedRelayReservations.release(remotePeerId);
|
|
1808
2100
|
return true;
|
|
1809
2101
|
}
|
|
1810
|
-
/**
|
|
1811
|
-
* Hold the control-network inbound gate open for not-yet-authorized peers
|
|
1812
|
-
* until `untilEpochMs` (extends, never shrinks, an already-open window).
|
|
1813
|
-
* {@link createInvite} calls this automatically; a host running an
|
|
1814
|
-
* out-of-band enrollment flow (e.g. accepting a phone whose invite this node
|
|
1815
|
-
* never minted) can open it explicitly before the stranger dials in.
|
|
1816
|
-
*/
|
|
1817
|
-
openEnrollmentWindow(untilEpochMs) {
|
|
1818
|
-
this.enrollmentWindowUntil = Math.max(this.enrollmentWindowUntil, untilEpochMs);
|
|
1819
|
-
log('Enrollment window open until %d', this.enrollmentWindowUntil);
|
|
1820
|
-
}
|
|
1821
2102
|
/**
|
|
1822
2103
|
* Record (or refresh) a delegate admission grant: `delegatePeerId` is the
|
|
1823
2104
|
* transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
|
|
1824
2105
|
* at the CONNECTION and RESERVATION levels (all a circuit-relay reservation
|
|
1825
2106
|
* needs, without spending the unauthorized-reservation budget) for
|
|
1826
|
-
* `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder
|
|
1827
|
-
* authorized
|
|
2107
|
+
* `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder, which the
|
|
2108
|
+
* protocol guard admits only authorized members to; public so tests can drive the
|
|
1828
2109
|
* admission policy without a full strand launch. A re-announce for the same
|
|
1829
2110
|
* (announcer, strand) REPLACES the previous delegate rather than
|
|
1830
2111
|
* accumulating. Never honored by {@link authorizeInboundControlStream}.
|
|
@@ -1863,58 +2144,93 @@ export class CadreNode {
|
|
|
1863
2144
|
return this.getBootstrapPeerIds().has(remotePeerId);
|
|
1864
2145
|
}
|
|
1865
2146
|
/**
|
|
1866
|
-
*
|
|
1867
|
-
* (
|
|
1868
|
-
*
|
|
1869
|
-
*
|
|
1870
|
-
*
|
|
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.
|
|
1871
2158
|
*
|
|
1872
2159
|
* The STRICT SUBSET of {@link admitInboundControlConnection}: the same
|
|
1873
2160
|
* "no basis to judge" admissions (shared via
|
|
1874
2161
|
* {@link admitControlPeerUnconditionally}), minus the stranger carve-outs
|
|
1875
|
-
* (
|
|
1876
|
-
* grant). The first two exist so a stranger can reach `/sereus/
|
|
1877
|
-
* and `/sereus/
|
|
1878
|
-
* stranger to `repo`
|
|
1879
|
-
*
|
|
2162
|
+
* (outstanding open invitation, live cadre invitation, delegate admission
|
|
2163
|
+
* grant). The first two exist so a stranger can reach `/sereus/formation/1.0.0`
|
|
2164
|
+
* and `/sereus/cadre-invite/1.0.0` — neither is gated here, and admitting a
|
|
2165
|
+
* stranger to `repo` while an invitation is live is exactly the hole this
|
|
2166
|
+
* rule closes. A DELEGATE-admitted connection (a member's strand node
|
|
1880
2167
|
* holding a circuit-relay reservation, see `delegate-admission.ts`) is
|
|
1881
|
-
* likewise
|
|
1882
|
-
*
|
|
1883
|
-
*
|
|
1884
|
-
*
|
|
2168
|
+
* likewise refused: the delegate gets the connection and never the control DB.
|
|
2169
|
+
*
|
|
2170
|
+
* Refuses everyone, ahead of every admission below, while this node is waiting to be
|
|
2171
|
+
* claimed ({@link isAwaitingClaim}). The empty-anchor and empty-snapshot admissions
|
|
2172
|
+
* exist so a brand-new node can take replication from the siblings that will
|
|
2173
|
+
* authorize it; an unclaimed node has no siblings yet, and the rows that will
|
|
2174
|
+
* authorize anyone arrive only after the claim, over the owner's streams that the
|
|
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.
|
|
2178
|
+
*
|
|
2179
|
+
* Otherwise admits when ANY of:
|
|
1885
2180
|
* 1. a shared-baseline check admits (not running / no DB, empty anchor,
|
|
1886
|
-
* configured bootstrap infra
|
|
2181
|
+
* configured bootstrap infra — which Optimystic still classifies `foreign`,
|
|
2182
|
+
* since a relay serves none of the party's database protocols);
|
|
1887
2183
|
* 2. the materialized authorized set is empty — cold start, before the
|
|
1888
2184
|
* rows that would authorize anyone have replicated in;
|
|
1889
2185
|
* 3. the peer IS in the materialized authorized set.
|
|
1890
2186
|
*
|
|
1891
2187
|
* Deliberately SYNCHRONOUS and pure in-memory — see
|
|
1892
2188
|
* {@link authorizedControlPeers} for why a control-DB read here would
|
|
1893
|
-
* deadlock into mutual denial.
|
|
1894
|
-
* (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its
|
|
1895
|
-
* default.
|
|
2189
|
+
* deadlock into mutual denial.
|
|
1896
2190
|
*
|
|
1897
2191
|
* NOTE: the snapshot keys on PEER ID, so a node with no persistent identity
|
|
1898
2192
|
* key never lands a `CadrePeer` row ({@link registerSelf} skips) and its
|
|
1899
|
-
* siblings deny its control-DB streams
|
|
1900
|
-
* owner status is a KEY, not a peer id, so
|
|
1901
|
-
* Harmless today (a real deployment persists
|
|
1902
|
-
* case appears only in tests, which configure
|
|
1903
|
-
* `push-wake-e2e.integration.ts` design note 3).
|
|
1904
|
-
* ever becomes a supported deployment, this
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
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) {
|
|
2202
|
+
if (this.isAwaitingClaim()) {
|
|
2203
|
+
return false;
|
|
1909
2204
|
}
|
|
1910
|
-
if (this.
|
|
2205
|
+
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
1911
2206
|
return true;
|
|
1912
2207
|
}
|
|
1913
|
-
|
|
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)) {
|
|
1914
2225
|
return true;
|
|
1915
2226
|
}
|
|
1916
2227
|
// Upstream logs its own denial line; this one carries the WHY.
|
|
1917
|
-
|
|
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
|
+
}
|
|
1918
2234
|
return false;
|
|
1919
2235
|
}
|
|
1920
2236
|
/**
|
|
@@ -1931,13 +2247,23 @@ export class CadreNode {
|
|
|
1931
2247
|
* read here would sleep its backoff holding the lock and stall every other local
|
|
1932
2248
|
* writer. Nothing is lost — this refresh already keeps the previous snapshot on
|
|
1933
2249
|
* failure and is re-driven by the next membership write and by the timed reconcile.
|
|
2250
|
+
*
|
|
2251
|
+
* The trusted-owner anchor sync ({@link syncOwnerAnchor}) runs FIRST, so the snapshot
|
|
2252
|
+
* built here judges `CadrePeer` rows against the anchor as the `OwnerKey` table now
|
|
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.
|
|
1934
2256
|
*/
|
|
1935
2257
|
async refreshAuthorizedControlPeers(reason) {
|
|
1936
2258
|
if (!this._running || !this.controlDatabase) {
|
|
1937
2259
|
return;
|
|
1938
2260
|
}
|
|
1939
2261
|
try {
|
|
1940
|
-
const
|
|
2262
|
+
const loadChain = this.invitationChainSource(this.controlDatabase, false);
|
|
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));
|
|
1941
2267
|
this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
|
|
1942
2268
|
log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
|
|
1943
2269
|
// Remember the party's size for the NEXT launch's control-node repair yardstick
|
|
@@ -2004,11 +2330,136 @@ export class CadreNode {
|
|
|
2004
2330
|
// re-arming peers whose last pass was denied under the SAME membership
|
|
2005
2331
|
// snapshot instead of re-arming unconditionally.
|
|
2006
2332
|
void this.controlBackfill?.scheduleConnectedPeers();
|
|
2333
|
+
await this.hangUpRevokedControlPeers(rows, loadTombstones);
|
|
2334
|
+
await this.noteOwnRemoval(rows, loadTombstones);
|
|
2007
2335
|
}
|
|
2008
2336
|
catch (error) {
|
|
2009
2337
|
log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
|
|
2010
2338
|
}
|
|
2011
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
|
+
}
|
|
2012
2463
|
/**
|
|
2013
2464
|
* Add the configured `controlNetwork.bootstrapNodes` to the control node's address book
|
|
2014
2465
|
* and dial each peer once — what `@libp2p/bootstrap` would have done had the node been
|
|
@@ -2139,12 +2590,31 @@ export class CadreNode {
|
|
|
2139
2590
|
* reference apps), and a write it signed would only be refused.
|
|
2140
2591
|
*/
|
|
2141
2592
|
async enrolledOwnerSigningKey() {
|
|
2142
|
-
const signingKey = this.
|
|
2593
|
+
const signingKey = this.ownerSigningKeyCandidate();
|
|
2143
2594
|
if (!signingKey || !this.controlDatabase) {
|
|
2144
2595
|
return null;
|
|
2145
2596
|
}
|
|
2146
2597
|
return (await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64) ? signingKey : null;
|
|
2147
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
|
+
}
|
|
2148
2618
|
/**
|
|
2149
2619
|
* Schedule the node's initial self-record publish + ongoing refresh shortly
|
|
2150
2620
|
* after start (non-blocking). {@link registerSelf} is idempotent and safely
|
|
@@ -2158,7 +2628,11 @@ export class CadreNode {
|
|
|
2158
2628
|
await this.registerSelf();
|
|
2159
2629
|
}
|
|
2160
2630
|
catch (error) {
|
|
2161
|
-
// Background task — a failed publish must not crash the node.
|
|
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).
|
|
2162
2636
|
log('Self-registration failed: %o', error);
|
|
2163
2637
|
}
|
|
2164
2638
|
// Wire the ongoing refresh + control-cohort reconcile cadence, then run
|
|
@@ -2224,21 +2698,26 @@ export class CadreNode {
|
|
|
2224
2698
|
return 'skipped';
|
|
2225
2699
|
}
|
|
2226
2700
|
const peerId = this.controlNode.peerId.toString();
|
|
2227
|
-
const addrs = await this.collectSelfAddrs();
|
|
2228
2701
|
const existing = await this.controlDatabase.queryPeerRecord(peerId);
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
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) {
|
|
2238
2717
|
// First-time row: requires an owner signature (the node is its own
|
|
2239
2718
|
// owner). insertSelfPeerRecord throws if no owner key is present.
|
|
2240
2719
|
const record = this.signSelfRecord(peerId, signingKey, addrs, null);
|
|
2241
|
-
if (await
|
|
2720
|
+
if (await ownerService.insertSelfPeerRecord(record)) {
|
|
2242
2721
|
if (this.committedAlone())
|
|
2243
2722
|
this.pendingSelfPeerWrite = true;
|
|
2244
2723
|
this.noteSelfRecordPublished();
|
|
@@ -2362,7 +2841,7 @@ export class CadreNode {
|
|
|
2362
2841
|
*/
|
|
2363
2842
|
signSelfRecord(peerId, signingKey, addrs, existing) {
|
|
2364
2843
|
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
2365
|
-
return signPeerRecord({ 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);
|
|
2366
2845
|
}
|
|
2367
2846
|
/**
|
|
2368
2847
|
* The ed25519 keypair (base64url) the node signs its own record with — the key
|
|
@@ -2437,6 +2916,41 @@ export class CadreNode {
|
|
|
2437
2916
|
}
|
|
2438
2917
|
return ed25519KeyPairFromLibp2p(this.identityKey);
|
|
2439
2918
|
}
|
|
2919
|
+
/**
|
|
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) {
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
const outcome = await this.registerSelf().catch((error) => {
|
|
2949
|
+
log('reconcileControlCohort: first self-record publish failed (continuing): %o', error);
|
|
2950
|
+
return 'skipped';
|
|
2951
|
+
});
|
|
2952
|
+
log('reconcileControlCohort: first self-record publish: %s', outcome);
|
|
2953
|
+
}
|
|
2440
2954
|
/**
|
|
2441
2955
|
* Collect this node's current dialable addresses for publication, signaling
|
|
2442
2956
|
* (`/p2p-circuit`) first. Prefers the best invite/NAT-resolved set and folds
|
|
@@ -2478,23 +2992,21 @@ export class CadreNode {
|
|
|
2478
2992
|
reconcile('self:peer:update');
|
|
2479
2993
|
};
|
|
2480
2994
|
this.controlNode.addEventListener('self:peer:update', this.selfPeerUpdateHandler);
|
|
2481
|
-
//
|
|
2482
|
-
//
|
|
2483
|
-
//
|
|
2484
|
-
//
|
|
2485
|
-
//
|
|
2486
|
-
//
|
|
2487
|
-
//
|
|
2488
|
-
//
|
|
2489
|
-
//
|
|
2490
|
-
//
|
|
2491
|
-
//
|
|
2492
|
-
//
|
|
2493
|
-
//
|
|
2494
|
-
// (`
|
|
2495
|
-
//
|
|
2496
|
-
// `relay-only-control-addr.integration.ts` case 4, which drives `registerSelf()`
|
|
2497
|
-
// 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.
|
|
2498
3010
|
this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
|
|
2499
3011
|
this.recordRefreshTimer?.unref?.();
|
|
2500
3012
|
const reconcileMs = this.config.network?.controlCohort?.reconcileMs ?? DEFAULT_CONTROL_COHORT_RECONCILE_MS;
|
|
@@ -2596,7 +3108,7 @@ export class CadreNode {
|
|
|
2596
3108
|
return null;
|
|
2597
3109
|
}
|
|
2598
3110
|
// Self-signature over (peerId, addrs, updatedAt).
|
|
2599
|
-
if (!verifyPeerRecordSignature(record)) {
|
|
3111
|
+
if (!verifyPeerRecordSignature(this.partyId, record)) {
|
|
2600
3112
|
// Field detail distinguishes "holding the owner-vouch revision (Sig null,
|
|
2601
3113
|
// no addrs — a not-yet-self-published row)" from a genuinely corrupt or
|
|
2602
3114
|
// mixed-revision row. Sig/addrs are public replicated data.
|
|
@@ -2649,8 +3161,9 @@ export class CadreNode {
|
|
|
2649
3161
|
* ({@link bootstrapDialAddrs}).
|
|
2650
3162
|
*
|
|
2651
3163
|
* An address naming a DIFFERENT trailing peer id is dropped: it does not reach
|
|
2652
|
-
* `peerId`, so it does not belong in this peer's candidate list (
|
|
2653
|
-
*
|
|
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).
|
|
2654
3167
|
*/
|
|
2655
3168
|
normalizeDialAddrs(addrs, peerId) {
|
|
2656
3169
|
// Keyed by the NORMALIZED string, so two entries differing only by the suffix
|
|
@@ -2781,6 +3294,10 @@ export class CadreNode {
|
|
|
2781
3294
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2782
3295
|
return { dialed: [] };
|
|
2783
3296
|
}
|
|
3297
|
+
await this.publishFirstSelfRecord();
|
|
3298
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
3299
|
+
return { dialed: [] };
|
|
3300
|
+
}
|
|
2784
3301
|
// Refresh relay delegate grants BEFORE the sibling enumeration below: a
|
|
2785
3302
|
// solo cadre with a party relay has no siblings to dial but must still keep
|
|
2786
3303
|
// its running strands' grants alive (a dropped relay connection re-dials
|
|
@@ -3064,8 +3581,7 @@ export class CadreNode {
|
|
|
3064
3581
|
* addresses ({@link dialPeerAddrs}): the reconcile pass's steady-state sibling
|
|
3065
3582
|
* ({@link dialControlSibling}) and cold-start bootstrap peer
|
|
3066
3583
|
* ({@link dialBootstrapPeer}) dials, and — handed to every
|
|
3067
|
-
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials
|
|
3068
|
-
* `dialInvite`.
|
|
3584
|
+
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials.
|
|
3069
3585
|
*
|
|
3070
3586
|
* See `peer-dial.ts`'s `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` for why a peer's dial is
|
|
3071
3587
|
* bounded as a whole, and `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS` for why each
|
|
@@ -3130,7 +3646,7 @@ export class CadreNode {
|
|
|
3130
3646
|
}
|
|
3131
3647
|
try {
|
|
3132
3648
|
log('reconcileControlCohort: dialing sibling %s (%d addr(s))', sibling.peerId, addrs.length);
|
|
3133
|
-
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}`);
|
|
3134
3650
|
return true;
|
|
3135
3651
|
}
|
|
3136
3652
|
catch (error) {
|
|
@@ -3163,9 +3679,9 @@ export class CadreNode {
|
|
|
3163
3679
|
*
|
|
3164
3680
|
* The list may name transports this node cannot dial (a lent node reports TCP
|
|
3165
3681
|
* and `/ws` addresses to a phone that dials WebSockets only). That needs no
|
|
3166
|
-
* filtering here: libp2p's
|
|
3167
|
-
*
|
|
3168
|
-
*
|
|
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.
|
|
3169
3685
|
*
|
|
3170
3686
|
* The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
|
|
3171
3687
|
* addresses one after another under a single deadline, and sorts loopback
|
|
@@ -3382,7 +3898,7 @@ export class CadreNode {
|
|
|
3382
3898
|
}
|
|
3383
3899
|
try {
|
|
3384
3900
|
log('reconcileControlCohort(cold-start): dialing bootstrap peer %s (%d addr(s))', peerId, parsed.length);
|
|
3385
|
-
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}`);
|
|
3386
3902
|
return true;
|
|
3387
3903
|
}
|
|
3388
3904
|
catch (error) {
|
|
@@ -3472,7 +3988,7 @@ export class CadreNode {
|
|
|
3472
3988
|
return;
|
|
3473
3989
|
}
|
|
3474
3990
|
this.pendingRevocations.set(revocation.stampId, revocation);
|
|
3475
|
-
log('%s
|
|
3991
|
+
log('%s tombstone for %s committed while alone (0 control connections); Revocation tombstone ' +
|
|
3476
3992
|
'(stamp %s) queued for re-issue on cohort growth', revocation.tableName, revocation.rowKey, revocation.stampId);
|
|
3477
3993
|
}
|
|
3478
3994
|
/**
|
|
@@ -3539,7 +4055,7 @@ export class CadreNode {
|
|
|
3539
4055
|
}
|
|
3540
4056
|
// 4. In-session pending owner authorize writes.
|
|
3541
4057
|
await this.drainPendingPeerWrites();
|
|
3542
|
-
// 5.
|
|
4058
|
+
// 5. Pending joins this process wrote while alone. Their tombstones went out in step 1.
|
|
3543
4059
|
await this.pendingJoinRunner?.reissueWritesMadeAlone();
|
|
3544
4060
|
}
|
|
3545
4061
|
/**
|
|
@@ -3598,7 +4114,10 @@ export class CadreNode {
|
|
|
3598
4114
|
*/
|
|
3599
4115
|
async drainPendingRevocations() {
|
|
3600
4116
|
const sweep = !this.reissuedHeldRevocations;
|
|
3601
|
-
|
|
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)) {
|
|
3602
4121
|
return;
|
|
3603
4122
|
}
|
|
3604
4123
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
@@ -3625,6 +4144,10 @@ export class CadreNode {
|
|
|
3625
4144
|
}
|
|
3626
4145
|
}
|
|
3627
4146
|
catch (error) {
|
|
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.
|
|
3628
4151
|
log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
|
|
3629
4152
|
}
|
|
3630
4153
|
}
|
|
@@ -3646,7 +4169,7 @@ export class CadreNode {
|
|
|
3646
4169
|
* `CadrePeer` trace, but its tombstone is re-issuable).
|
|
3647
4170
|
*/
|
|
3648
4171
|
async reconstructAuthoredMembership() {
|
|
3649
|
-
if (!this.controlDatabase || !this.controlNode) {
|
|
4172
|
+
if (!this.controlDatabase || !this.controlNode || this.removal !== null) {
|
|
3650
4173
|
return;
|
|
3651
4174
|
}
|
|
3652
4175
|
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
@@ -3704,7 +4227,7 @@ export class CadreNode {
|
|
|
3704
4227
|
* here.
|
|
3705
4228
|
*/
|
|
3706
4229
|
async drainPendingPeerWrites() {
|
|
3707
|
-
if (this.pendingPeerWrites.size === 0) {
|
|
4230
|
+
if (this.pendingPeerWrites.size === 0 || this.removal !== null) {
|
|
3708
4231
|
return;
|
|
3709
4232
|
}
|
|
3710
4233
|
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
@@ -3847,7 +4370,7 @@ export class CadreNode {
|
|
|
3847
4370
|
const existing = await this.controlDatabase.queryDeviceToken(peerId);
|
|
3848
4371
|
// Strictly increase UpdatedAt even on a same-millisecond re-publish (rotation).
|
|
3849
4372
|
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
3850
|
-
const record = signDeviceTokenRecord({ peerId, platform, token, updatedAt }, signingKey.privateKeyB64);
|
|
4373
|
+
const record = signDeviceTokenRecord(this.partyId, { peerId, platform, token, updatedAt }, signingKey.privateKeyB64);
|
|
3851
4374
|
if (existing) {
|
|
3852
4375
|
await this.controlDatabase.updateSelfDeviceToken(record);
|
|
3853
4376
|
if (this.committedAlone())
|
|
@@ -3935,7 +4458,7 @@ export class CadreNode {
|
|
|
3935
4458
|
}
|
|
3936
4459
|
// Self-signature over (peerId, platform, token, updatedAt), verified against the
|
|
3937
4460
|
// CadrePeer.PublicKey bound to this peerId.
|
|
3938
|
-
if (!verifyDeviceTokenSignature(record, peerRecord.publicKey)) {
|
|
4461
|
+
if (!verifyDeviceTokenSignature(this.partyId, record, peerRecord.publicKey)) {
|
|
3939
4462
|
log('resolveDeviceToken: signature verification failed for %s', peerId);
|
|
3940
4463
|
return null;
|
|
3941
4464
|
}
|
|
@@ -4177,6 +4700,11 @@ export class CadreNode {
|
|
|
4177
4700
|
await this.strandAddrService.shutdown();
|
|
4178
4701
|
this.strandAddrService = null;
|
|
4179
4702
|
}
|
|
4703
|
+
// Unregister the cadre invitation redemption handler, for the same reason.
|
|
4704
|
+
if (this.cadreInviteHandler && this.controlNode) {
|
|
4705
|
+
await this.cadreInviteHandler.unregister(this.controlNode);
|
|
4706
|
+
}
|
|
4707
|
+
this.cadreInviteHandler = null;
|
|
4180
4708
|
// Tear down the push-wake fan-out (releases the notifier's APNs HTTP/2 session).
|
|
4181
4709
|
if (this.pushFanoutService) {
|
|
4182
4710
|
await this.pushFanoutService.close().catch((err) => log('Push fan-out close failed: %o', err));
|
|
@@ -5361,6 +5889,8 @@ export class CadreNode {
|
|
|
5361
5889
|
// comes up `'syncing'` announces the moment its database is published.
|
|
5362
5890
|
firstSync: this.config.strandFirstSync,
|
|
5363
5891
|
onWritable: (writableStrandId) => this.handleStrandWritable(writableStrandId),
|
|
5892
|
+
onCommitDurability: (commit) => this.emit('strand:commit-durability', commit),
|
|
5893
|
+
onCommitHeld: (held) => this.emit('strand:commit-held', held),
|
|
5364
5894
|
// Re-announce the delegate to ONE relay before the strand node's reservation
|
|
5365
5895
|
// supervisor re-drives it (see announceDelegateToRelay). Retained with the
|
|
5366
5896
|
// launch config, so a hibernation wake's rebuilt supervisors get it too.
|
|
@@ -6459,6 +6989,15 @@ export class CadreNode {
|
|
|
6459
6989
|
*
|
|
6460
6990
|
* Passing an empty list stops the supervisor and resets the posture to `none`
|
|
6461
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`.
|
|
6462
7001
|
*/
|
|
6463
7002
|
async reserveRelays(addrs, opts) {
|
|
6464
7003
|
// Stop first, unconditionally: a second call with a different list must not
|
|
@@ -6551,21 +7090,34 @@ export class CadreNode {
|
|
|
6551
7090
|
if (!this.controlNode || !this.controlDatabase) {
|
|
6552
7091
|
throw new Error('CadreNode must be started before initializing seed bootstrap');
|
|
6553
7092
|
}
|
|
7093
|
+
// A hosted node waiting to be claimed never holds an owner key: the claim is its
|
|
7094
|
+
// one trust source, and genesis-anchoring a key here would mark it claimed by
|
|
7095
|
+
// itself. Refused here rather than in start() because this call comes after it.
|
|
7096
|
+
if (this.config.claim) {
|
|
7097
|
+
throw new Error('CadreNode: a node started with `claim` is owned by its claimant and cannot initialize seed bootstrap with an owner key');
|
|
7098
|
+
}
|
|
6554
7099
|
// A node wiring seed-bootstrap with an owner PRIVATE key is declaring that
|
|
6555
7100
|
// key an authority of its own party (founder genesis / self-signing owner)
|
|
6556
7101
|
// — anchor it in the node-local store. The in-memory set updates
|
|
6557
7102
|
// synchronously (see TrustedOwnerStore.trust); only the file persist is
|
|
6558
7103
|
// fire-and-forget, logged on failure.
|
|
6559
|
-
// NOTE:
|
|
6560
|
-
//
|
|
6561
|
-
// it
|
|
6562
|
-
//
|
|
6563
|
-
//
|
|
6564
|
-
//
|
|
6565
|
-
//
|
|
6566
|
-
//
|
|
6567
|
-
//
|
|
6568
|
-
//
|
|
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.
|
|
6569
7121
|
if (this.trustedOwnerStore) {
|
|
6570
7122
|
void this.trustedOwnerStore
|
|
6571
7123
|
.trust(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
|
|
@@ -6574,9 +7126,8 @@ export class CadreNode {
|
|
|
6574
7126
|
await this.installSeedBootstrapService(new SeedBootstrapService({
|
|
6575
7127
|
partyId: this.config.controlNetwork.partyId,
|
|
6576
7128
|
ownerPrivateKey,
|
|
6577
|
-
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
6578
7129
|
...this.seedServiceBudgets(),
|
|
6579
|
-
trustPolicy: this.
|
|
7130
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
6580
7131
|
// Seed trust anchors on the node-local store (seeded just above with this
|
|
6581
7132
|
// node's own genesis key), never on the replicated OwnerKey table.
|
|
6582
7133
|
...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
|
|
@@ -6659,6 +7210,11 @@ export class CadreNode {
|
|
|
6659
7210
|
* {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
|
|
6660
7211
|
* never reach here: {@link ControlDatabase.queryCadrePeers} excludes them, so a
|
|
6661
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.
|
|
6662
7218
|
*/
|
|
6663
7219
|
async listMembers() {
|
|
6664
7220
|
if (!this.controlDatabase) {
|
|
@@ -6689,14 +7245,21 @@ export class CadreNode {
|
|
|
6689
7245
|
* 1. it is not this node itself — a node publishes its own `CadrePeer` address
|
|
6690
7246
|
* row so its dialable address rides in seeds, but "self" is not a peer this
|
|
6691
7247
|
* node authorized;
|
|
6692
|
-
* 2. its `CadrePeer` row carries a complete
|
|
6693
|
-
* `VouchSig` all non-null)
|
|
7248
|
+
* 2. its `CadrePeer` row carries a complete proof: EITHER an owner voucher
|
|
7249
|
+
* (`StampId`, `VouchOwner`, `VouchSig` all non-null) OR an invitation admission
|
|
7250
|
+
* (`VouchSig` null, `VouchUsage` naming a `CadreInviteUsage` row);
|
|
6694
7251
|
* 3. `VouchOwner` is in the NODE-LOCAL trusted-owner anchor
|
|
6695
|
-
* ({@link getTrustedOwnerStore})
|
|
6696
|
-
*
|
|
6697
|
-
*
|
|
6698
|
-
*
|
|
6699
|
-
*
|
|
7252
|
+
* ({@link getTrustedOwnerStore}), which follows the replicated `OwnerKey` table
|
|
7253
|
+
* only along chains that verify from this node's out-of-band keys
|
|
7254
|
+
* ({@link syncOwnerAnchor}) — never the table itself, which any stranger can
|
|
7255
|
+
* genesis-pollute; and
|
|
7256
|
+
* 4. the proof verifies: for a voucher, `VouchSig` is that owner's signature over
|
|
7257
|
+
* the row's voucher digest ({@link verifyCadrePeerVoucher}), so the anchored
|
|
7258
|
+
* owner really vouched THIS peer id under THIS row's nonce; for an admission,
|
|
7259
|
+
* the chain row → usage → invitation → anchored issuer verifies link by link
|
|
7260
|
+
* ({@link verifyInvitationAdmission} — the issuer is `VouchOwner`, the invitation's
|
|
7261
|
+
* own owner signature, the holder's and the device's signatures, and the stored
|
|
7262
|
+
* peer key really is the key behind the peer id); and
|
|
6700
7263
|
* 5. the row's `StampId` is NOT retired in `CadreControl.Revocation` — enforced
|
|
6701
7264
|
* upstream in {@link ControlDatabase.queryCadrePeers}, which drops retired rows
|
|
6702
7265
|
* before ANY reader (this predicate included) sees them, so a row resurrected
|
|
@@ -6705,18 +7268,26 @@ export class CadreNode {
|
|
|
6705
7268
|
* local rows) is still inert to every reader that has the tombstone.
|
|
6706
7269
|
*
|
|
6707
7270
|
* Fail-closed at every step: a missing anchor (pre-start), an empty anchor (a
|
|
6708
|
-
* not-yet-enrolled node authorizes no one), a null/partial
|
|
6709
|
-
* unanchored `VouchOwner`,
|
|
6710
|
-
* having an address row is NOT
|
|
6711
|
-
* strand-address
|
|
7271
|
+
* not-yet-enrolled node authorizes no one), a null/partial proof, an
|
|
7272
|
+
* unanchored `VouchOwner`, a bad signature, or a usage or invitation row this node
|
|
7273
|
+
* does not hold all yield "not authorized" — having an address row is NOT
|
|
7274
|
+
* membership. The protocol guard admits wake and strand-address requests by this set,
|
|
7275
|
+
* NOT the addressable one.
|
|
7276
|
+
*
|
|
7277
|
+
* The usage and invitation tables are read only when at least one row needs them,
|
|
7278
|
+
* so a cadre with no invitation-admitted member stays at one read per call.
|
|
6712
7279
|
*
|
|
6713
7280
|
* NOTE (rotation): if a party owner rotates keys and only the NEW key is pinned
|
|
6714
7281
|
* in the anchor, rows the OLD key vouched fail check 3 until re-vouched — a
|
|
6715
7282
|
* legit member goes un-authorized on readers that only pin the new key. Full
|
|
6716
7283
|
* rotation handling (re-vouch on rotate) is the
|
|
6717
|
-
* `flip-strand-membership-rotation-known-gap` work, not this predicate's.
|
|
7284
|
+
* `flip-strand-membership-rotation-known-gap` work, not this predicate's. The
|
|
7285
|
+
* `OwnerKey` table differs: an owner the rotated-out (or removed) key vouched keeps
|
|
7286
|
+
* its place in every anchor that had derived it ({@link syncOwnerAnchor}: a derived
|
|
7287
|
+
* entry stays while its own row is live), and is only out of reach for a machine that
|
|
7288
|
+
* never held it until a remaining owner re-adds it or an invitation pins it.
|
|
6718
7289
|
*
|
|
6719
|
-
* @param retry - Whether the underlying membership
|
|
7290
|
+
* @param retry - Whether the underlying membership reads may retry a transient cluster
|
|
6720
7291
|
* failure. Only {@link refreshAuthorizedControlPeers} passes `false`, because it runs
|
|
6721
7292
|
* as the control database's membership listener with that database's write lock held;
|
|
6722
7293
|
* see its comment.
|
|
@@ -6725,10 +7296,29 @@ export class CadreNode {
|
|
|
6725
7296
|
if (!this.controlDatabase) {
|
|
6726
7297
|
throw new Error('CadreNode must be started before listing members');
|
|
6727
7298
|
}
|
|
7299
|
+
return await this.collectAuthorizedMembers(this.controlDatabase, retry, this.invitationChainSource(this.controlDatabase, retry));
|
|
7300
|
+
}
|
|
7301
|
+
/**
|
|
7302
|
+
* The body of {@link listAuthorizedMembers}, with the invitation chain supplied as a
|
|
7303
|
+
* source so the membership refresh can share one load with {@link syncOwnerAnchor}.
|
|
7304
|
+
*/
|
|
7305
|
+
async collectAuthorizedMembers(controlDatabase, retry, loadChain) {
|
|
7306
|
+
const rows = await controlDatabase.queryCadrePeers(retry);
|
|
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) {
|
|
6728
7319
|
const selfPeerId = this.peerId?.toString();
|
|
6729
|
-
const rows = await this.controlDatabase.queryCadrePeers(retry);
|
|
6730
7320
|
const authorized = rows
|
|
6731
|
-
.filter(row => row.peerId !== selfPeerId && this.
|
|
7321
|
+
.filter(row => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain))
|
|
6732
7322
|
.map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
|
|
6733
7323
|
// A node whose anchor was never seeded (no invite pin, no operator pin, not a
|
|
6734
7324
|
// founder) refuses every wake and strand-addr request, which from the outside
|
|
@@ -6741,9 +7331,158 @@ export class CadreNode {
|
|
|
6741
7331
|
}
|
|
6742
7332
|
/**
|
|
6743
7333
|
* Checks 2–4 of the authorized-membership predicate (see
|
|
6744
|
-
* {@link listAuthorizedMembers}) for one `CadrePeer` row
|
|
6745
|
-
*
|
|
6746
|
-
* (
|
|
7334
|
+
* {@link listAuthorizedMembers}) for one `CadrePeer` row, by the kind of proof it
|
|
7335
|
+
* carries: an owner voucher ({@link hasAnchoredVoucher}) or an invitation admission
|
|
7336
|
+
* ({@link verifyInvitationAdmission} over the usage and invitation rows in `chain`,
|
|
7337
|
+
* loaded only when some row needs them). A row with a `VouchSig` is judged as a
|
|
7338
|
+
* voucher even if it also names a usage — the signature is the stronger proof and the
|
|
7339
|
+
* one an owner re-vouch leaves behind.
|
|
7340
|
+
*/
|
|
7341
|
+
hasAnchoredProof(row, chain) {
|
|
7342
|
+
if (!isInvitationAdmitted(row)) {
|
|
7343
|
+
return this.hasAnchoredVoucher(row);
|
|
7344
|
+
}
|
|
7345
|
+
const usage = chain?.usages.get(row.vouchUsage);
|
|
7346
|
+
const invite = usage === undefined ? undefined : chain?.invites.get(usage.inviteKey);
|
|
7347
|
+
if (usage === undefined || invite === undefined) {
|
|
7348
|
+
return false;
|
|
7349
|
+
}
|
|
7350
|
+
return verifyInvitationAdmission(this.partyId, row, usage, invite, key => this.trustedOwnerStore?.has(key) ?? false);
|
|
7351
|
+
}
|
|
7352
|
+
/**
|
|
7353
|
+
* Recompute the node-local trusted-owner anchor from the replicated `OwnerKey` table along
|
|
7354
|
+
* verifiable chains (`owner-anchor-sync.ts` → {@link deriveOwnerAnchor}), as the first step
|
|
7355
|
+
* of every membership refresh ({@link refreshAuthorizedControlPeers}) so the snapshot built
|
|
7356
|
+
* in the same pass judges `CadrePeer` rows against the updated anchor. A key whose live
|
|
7357
|
+
* row's stored proof verifies against a key already anchored enters under `chain` and
|
|
7358
|
+
* stays while its row is live; a key an anchored owner's verifiable tombstone names
|
|
7359
|
+
* leaves, base pins included — which is how a config pin of a removed key, re-anchored at
|
|
7360
|
+
* every start ({@link initializeTrustedOwnerStore}), is removed again. The derivation
|
|
7361
|
+
* refuses removals that would empty the anchor; they are logged here.
|
|
7362
|
+
*
|
|
7363
|
+
* With an EMPTY anchor the sync does nothing: there is no key to derive from, and a node
|
|
7364
|
+
* waiting to be claimed ({@link isAwaitingClaim}: claim secret set, anchor empty) must
|
|
7365
|
+
* stay empty until the claim anchors its claimant — the replicated table must never seat
|
|
7366
|
+
* the first key. The early return below is the seam that rests on.
|
|
7367
|
+
*
|
|
7368
|
+
* Any read failure skips the whole pass and leaves the anchor as it was; the refresh then
|
|
7369
|
+
* builds its snapshot against that anchor, which is also what its previous snapshot was
|
|
7370
|
+
* judged against. Caught here rather than failing the refresh, because the membership read
|
|
7371
|
+
* tolerates an unreachable `Revocation` block on an isolated node
|
|
7372
|
+
* (`ControlDatabase.readRevokedStampRows`) and `queryRevocations` does not: failing the
|
|
7373
|
+
* whole refresh on that would freeze such a node's gate snapshot.
|
|
7374
|
+
* NOTE: on such a node the sync is skipped at every refresh until the block is held; if an
|
|
7375
|
+
* isolated node ever has to derive owner additions while cut off, give `queryRevocations`
|
|
7376
|
+
* the same cohort-unreachable fallback.
|
|
7377
|
+
*
|
|
7378
|
+
* NOTE: cost per membership refresh is one `OwnerKey` read and one `Revocation` read (the
|
|
7379
|
+
* tombstone list, whose `OwnerKey` stamps are handed to `queryOwnerKeyRows` so it does not
|
|
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
|
|
7382
|
+
* invitation-admitted — shared with the membership read through `loadChain`; the memo
|
|
7383
|
+
* suggestion on {@link loadInvitationChain} covers that part. Before the Revocation ledger
|
|
7384
|
+
* marker is filed that one read is a cohort consult per refresh, which
|
|
7385
|
+
* `control-founding-consult-budget.spec.ts` pins.
|
|
7386
|
+
*
|
|
7387
|
+
* @param retry - forwarded to every read; `false` on the refresh path, for the reason on
|
|
7388
|
+
* {@link refreshAuthorizedControlPeers}.
|
|
7389
|
+
* @param loadTombstones - the refresh's shared tombstone read ({@link revocationSource}).
|
|
7390
|
+
*/
|
|
7391
|
+
async syncOwnerAnchor(reason, retry, loadChain, loadTombstones) {
|
|
7392
|
+
const store = this.trustedOwnerStore;
|
|
7393
|
+
const controlDatabase = this.controlDatabase;
|
|
7394
|
+
if (!store || !controlDatabase) {
|
|
7395
|
+
return;
|
|
7396
|
+
}
|
|
7397
|
+
const before = store.sources();
|
|
7398
|
+
if (before.size === 0) {
|
|
7399
|
+
return;
|
|
7400
|
+
}
|
|
7401
|
+
let target;
|
|
7402
|
+
try {
|
|
7403
|
+
const tombstones = await loadTombstones();
|
|
7404
|
+
const retired = new Set(tombstones.filter(tombstone => tombstone.tableName === 'OwnerKey').map(tombstone => tombstone.stampId));
|
|
7405
|
+
const rows = await controlDatabase.queryOwnerKeyRows(retry, retired);
|
|
7406
|
+
const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
|
|
7407
|
+
const derivation = deriveOwnerAnchor({ partyId: this.partyId, anchor: before, rows, tombstones, chain });
|
|
7408
|
+
if (derivation.refusedRemovals.length > 0) {
|
|
7409
|
+
log('syncOwnerAnchor(%s): refused %d owner removal(s) that would empty the anchor: %o', reason, derivation.refusedRemovals.length, derivation.refusedRemovals);
|
|
7410
|
+
}
|
|
7411
|
+
target = derivation.target;
|
|
7412
|
+
}
|
|
7413
|
+
catch (error) {
|
|
7414
|
+
log('syncOwnerAnchor(%s): read failed — anchor left as it was: %o', reason, error);
|
|
7415
|
+
return;
|
|
7416
|
+
}
|
|
7417
|
+
await this.applyOwnerAnchor(store, before, target, reason);
|
|
7418
|
+
}
|
|
7419
|
+
/**
|
|
7420
|
+
* Apply one pass's result: `chain` entries for the keys it newly derived, removal of the keys
|
|
7421
|
+
* it started with that are outside `target` — base keys a verifiable tombstone named, and
|
|
7422
|
+
* `chain` entries whose row is gone.
|
|
7423
|
+
*
|
|
7424
|
+
* Diffed against `before`, the snapshot the derivation was computed from, NOT the live store:
|
|
7425
|
+
* the anchor is written out of band while the pass's reads are in flight (an invitation pin,
|
|
7426
|
+
* the genesis anchor, a claim, a seed), and such a key is outside `target` only because the
|
|
7427
|
+
* pass never saw it — judging the live store would remove it. Likewise a key removed
|
|
7428
|
+
* meanwhile (a claim rollback) must not come back as `chain`. Both are judged by the next
|
|
7429
|
+
* pass. Each change lands in memory synchronously (`TrustedOwnerStore.trust` / `remove`); a
|
|
7430
|
+
* failed PERSIST is logged and does not stop the pass or fail the refresh, as
|
|
7431
|
+
* `SeedBootstrapService.anchorAcceptedSigner` treats the same failure.
|
|
7432
|
+
*/
|
|
7433
|
+
async applyOwnerAnchor(store, before, target, reason) {
|
|
7434
|
+
for (const key of target) {
|
|
7435
|
+
if (!before.has(key) && !store.has(key)) {
|
|
7436
|
+
log('syncOwnerAnchor(%s): owner %s derived from the OwnerKey table', reason, key);
|
|
7437
|
+
await store.trust(key, 'chain').catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
|
|
7438
|
+
}
|
|
7439
|
+
}
|
|
7440
|
+
for (const key of before.keys()) {
|
|
7441
|
+
if (!target.has(key) && store.has(key)) {
|
|
7442
|
+
log('syncOwnerAnchor(%s): owner %s removed from the anchor', reason, key);
|
|
7443
|
+
await store.remove(key).catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
|
|
7444
|
+
}
|
|
7445
|
+
}
|
|
7446
|
+
}
|
|
7447
|
+
/**
|
|
7448
|
+
* A per-refresh {@link InvitationChainSource}: the first call loads the chain through
|
|
7449
|
+
* {@link loadInvitationChain}, every later call shares that load.
|
|
7450
|
+
*/
|
|
7451
|
+
invitationChainSource(controlDatabase, retry) {
|
|
7452
|
+
let loading = null;
|
|
7453
|
+
return () => (loading ?? (loading = this.loadInvitationChain(controlDatabase, retry)));
|
|
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
|
+
}
|
|
7460
|
+
/**
|
|
7461
|
+
* The usage and invitation rows {@link hasAnchoredProof} and {@link syncOwnerAnchor}
|
|
7462
|
+
* resolve an invitation-admitted row through, keyed for lookup. Both reads honour `retry`
|
|
7463
|
+
* for {@link listAuthorizedMembers}' reason.
|
|
7464
|
+
*
|
|
7465
|
+
* NOTE: once invitations are the only admission route (ticket
|
|
7466
|
+
* `cadre-invitations-redeemable-by-any-member`), every cadre with a member not yet
|
|
7467
|
+
* re-vouched pays these two whole-table reads and three ed25519 verifies per row on every
|
|
7468
|
+
* membership refresh. Both tables are append-only and a verified chain never changes (expiry,
|
|
7469
|
+
* use count and withdrawal are not re-checked), so if the gate's admission deadline ever
|
|
7470
|
+
* shows this, memoise verified (row stamp → usage stamp) pairs per node and skip the reads
|
|
7471
|
+
* when every admitted row is already memoised.
|
|
7472
|
+
*/
|
|
7473
|
+
async loadInvitationChain(controlDatabase, retry) {
|
|
7474
|
+
const [usages, invites] = await Promise.all([
|
|
7475
|
+
controlDatabase.queryCadreInviteUsages(retry),
|
|
7476
|
+
controlDatabase.queryCadreInvites(retry),
|
|
7477
|
+
]);
|
|
7478
|
+
return {
|
|
7479
|
+
usages: new Map(usages.map(usage => [usage.usageStampId, usage])),
|
|
7480
|
+
invites: new Map(invites.map(invite => [invite.key, invite])),
|
|
7481
|
+
};
|
|
7482
|
+
}
|
|
7483
|
+
/**
|
|
7484
|
+
* The owner-voucher half of {@link hasAnchoredProof}: complete voucher, `VouchOwner` in
|
|
7485
|
+
* the node-local anchor, signature valid over the row's (PeerId, StampId) voucher digest.
|
|
6747
7486
|
*
|
|
6748
7487
|
* NOTE: verifies the ed25519 signature on every call (no memo of already-
|
|
6749
7488
|
* verified (peerId, stampId, vouchSig) triples). Cadres are a handful of
|
|
@@ -6758,12 +7497,12 @@ export class CadreNode {
|
|
|
6758
7497
|
if (!this.trustedOwnerStore?.has(row.vouchOwner)) {
|
|
6759
7498
|
return false;
|
|
6760
7499
|
}
|
|
6761
|
-
return verifyCadrePeerVoucher(row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
|
|
7500
|
+
return verifyCadrePeerVoucher(this.partyId, row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
|
|
6762
7501
|
}
|
|
6763
7502
|
/**
|
|
6764
7503
|
* Probe whether a given peer is an AUTHORIZED party member (see
|
|
6765
|
-
* {@link listAuthorizedMembers}) — the
|
|
6766
|
-
* strand-address
|
|
7504
|
+
* {@link listAuthorizedMembers}) — the check the protocol guard runs ahead of the
|
|
7505
|
+
* control-network wake and strand-address handlers, NOT {@link isMember} (the addressable
|
|
6767
7506
|
* surface). Deliberately scans the full membership (one `CadrePeer` query)
|
|
6768
7507
|
* rather than adding a single-row read path: cadres are small, and one code
|
|
6769
7508
|
* path keeps the predicate impossible to drift from the list.
|
|
@@ -6779,14 +7518,16 @@ export class CadreNode {
|
|
|
6779
7518
|
* record (via {@link resolvePeerAddrs}, signaling/relay first — so a NAT'd peer
|
|
6780
7519
|
* is reachable through its circuit-relay address), dials `WAKE_PROTOCOL`, sends
|
|
6781
7520
|
* the {@link WakeRequest}, and returns the peer's {@link WakeAck}. The receiver
|
|
6782
|
-
*
|
|
6783
|
-
*
|
|
6784
|
-
*
|
|
7521
|
+
* only resumes a strand it already participates in; it acks once it has decided,
|
|
7522
|
+
* before the strand is up. The dial deadlines derive from this node's
|
|
7523
|
+
* `network.linkRoundTripMs`.
|
|
6785
7524
|
*
|
|
6786
7525
|
* @param targetPeerId - The hibernating cadre peer to wake.
|
|
6787
7526
|
* @param strandId - The strand the caller knows has pending activity.
|
|
6788
7527
|
* @param reason - Optional cause hint, e.g. `"activity"` or `"manual"`.
|
|
6789
|
-
* @throws if the node is not started
|
|
7528
|
+
* @throws if the node is not started, the target has no dialable address, or no
|
|
7529
|
+
* attempt got an ack — including when the receiver does not count this node as an
|
|
7530
|
+
* authorized member, since its protocol guard resets the stream without a reply.
|
|
6790
7531
|
*/
|
|
6791
7532
|
async pushWake(targetPeerId, strandId, reason) {
|
|
6792
7533
|
if (!this.controlNode) {
|
|
@@ -6802,7 +7543,8 @@ export class CadreNode {
|
|
|
6802
7543
|
/**
|
|
6803
7544
|
* Enable the seed listener for receiving seeds via the /sereus/seed/1.0.0 protocol.
|
|
6804
7545
|
* This is for drone nodes that need to receive seeds without being an owner.
|
|
6805
|
-
* Does not require an owner key.
|
|
7546
|
+
* Does not require an owner key. Idempotent; a node started with `config.claim`
|
|
7547
|
+
* calls it itself inside {@link start}, so an embedder's own call is a no-op.
|
|
6806
7548
|
*/
|
|
6807
7549
|
async enableSeedListener() {
|
|
6808
7550
|
if (!this.controlNode || !this.controlDatabase) {
|
|
@@ -6816,9 +7558,8 @@ export class CadreNode {
|
|
|
6816
7558
|
await this.installSeedBootstrapService(new SeedBootstrapService({
|
|
6817
7559
|
partyId: this.config.controlNetwork.partyId,
|
|
6818
7560
|
// No owner key - this node only receives seeds
|
|
6819
|
-
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
6820
7561
|
...this.seedServiceBudgets(),
|
|
6821
|
-
trustPolicy: this.
|
|
7562
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
6822
7563
|
// A listener-only node accepts a wire-delivered seed solely against this
|
|
6823
7564
|
// anchor (there is no per-call override on the inbound handler): with no
|
|
6824
7565
|
// genesis/invite/operator pin it authorizes nobody, which is the point.
|
|
@@ -6881,6 +7622,17 @@ export class CadreNode {
|
|
|
6881
7622
|
onSeedError: (partyId, error) => this.emit('seed:error', { partyId, error }),
|
|
6882
7623
|
};
|
|
6883
7624
|
}
|
|
7625
|
+
/**
|
|
7626
|
+
* The node-wide default trust policy for INBOUND seeds, the one value every
|
|
7627
|
+
* {@link SeedBootstrapService} this node constructs is given — the owner service,
|
|
7628
|
+
* the listener, and `applySeed`'s temp service — so the three cannot drift. The
|
|
7629
|
+
* claim policy when the node was started with `config.claim` (the two are refused
|
|
7630
|
+
* together at start, so there is nothing to merge), otherwise the configured
|
|
7631
|
+
* `seedTrustPolicy`, and `undefined` for the service's anchored default.
|
|
7632
|
+
*/
|
|
7633
|
+
inboundSeedTrustPolicy() {
|
|
7634
|
+
return this.claimTrustPolicy ?? this.config.seedTrustPolicy;
|
|
7635
|
+
}
|
|
6884
7636
|
/**
|
|
6885
7637
|
* Re-materialize the authorized-peer snapshot that the fail-closed per-stream
|
|
6886
7638
|
* control-DB gate ({@link authorizeInboundControlStream}) judges against.
|
|
@@ -7016,6 +7768,10 @@ export class CadreNode {
|
|
|
7016
7768
|
* Remove a previously-authorized peer from the cadre.
|
|
7017
7769
|
* Signs the peer ID with the owner key and deletes the CadrePeer row.
|
|
7018
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
|
+
*
|
|
7019
7775
|
* @param peerId - The peer ID to remove
|
|
7020
7776
|
*/
|
|
7021
7777
|
async removePeer(peerId) {
|
|
@@ -7035,6 +7791,198 @@ export class CadreNode {
|
|
|
7035
7791
|
log('removePeer: persisting the removal of dial target %s failed (forgotten in memory): %o', peerId, error);
|
|
7036
7792
|
});
|
|
7037
7793
|
}
|
|
7794
|
+
/**
|
|
7795
|
+
* Seat another owner key, signed by this node's owner key (the signed-add branch of
|
|
7796
|
+
* `OwnerKey.Authorized`; the row stores the vouching pair). The write notifies the
|
|
7797
|
+
* membership hub, so the refresh it triggers has run by the time this resolves.
|
|
7798
|
+
*
|
|
7799
|
+
* @param ownerPublicKeyB64 - the new owner's ed25519 public key (base64url).
|
|
7800
|
+
* @returns `true` when the row was seated, `false` when the key is already a live owner.
|
|
7801
|
+
*/
|
|
7802
|
+
async addOwner(ownerPublicKeyB64) {
|
|
7803
|
+
if (!this.seedBootstrapService) {
|
|
7804
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7805
|
+
}
|
|
7806
|
+
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
7807
|
+
const added = await this.seedBootstrapService.addOwner(key);
|
|
7808
|
+
// NOTE: an OwnerKey add committed alone is local-only and nothing re-issues it (no
|
|
7809
|
+
// update branch to re-touch, unlike CadrePeer), so it is only logged;
|
|
7810
|
+
// tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue.md owns the fix.
|
|
7811
|
+
if (added && this.committedAlone()) {
|
|
7812
|
+
log('addOwner(%s) committed while ALONE (0 control connections): the new owner row is ' +
|
|
7813
|
+
'local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
|
|
7814
|
+
}
|
|
7815
|
+
return added;
|
|
7816
|
+
}
|
|
7817
|
+
/**
|
|
7818
|
+
* Remove an owner key, signed by this node's owner key (which must be a different owner):
|
|
7819
|
+
* the delete and its `Revocation` tombstone commit together. Refused by name when the key
|
|
7820
|
+
* is this node's own or the only live owner. The write notifies the membership hub; the
|
|
7821
|
+
* tombstone rides the committed-delete seam ({@link noteGuardedDelete}), so one committed
|
|
7822
|
+
* while alone is re-issued on cohort growth like every other removal.
|
|
7823
|
+
*
|
|
7824
|
+
* Removes the `OwnerKey` row only. The call that removes a machine completely, its owner key
|
|
7825
|
+
* included, is {@link removeMachine}.
|
|
7826
|
+
*
|
|
7827
|
+
* @param ownerPublicKeyB64 - the owner's ed25519 public key (base64url).
|
|
7828
|
+
* @returns `true` when the row was removed, `false` when the key is not a live owner here.
|
|
7829
|
+
*/
|
|
7830
|
+
async removeOwner(ownerPublicKeyB64) {
|
|
7831
|
+
if (!this.seedBootstrapService) {
|
|
7832
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7833
|
+
}
|
|
7834
|
+
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
7835
|
+
return await this.seedBootstrapService.removeOwner(key);
|
|
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
|
+
}
|
|
7038
7986
|
/**
|
|
7039
7987
|
* Create a seed from the current control network state.
|
|
7040
7988
|
* The seed contains peer information and is signed by an owner.
|
|
@@ -7049,10 +7997,12 @@ export class CadreNode {
|
|
|
7049
7997
|
* Apply a seed to populate the peer cache and enable connections.
|
|
7050
7998
|
*
|
|
7051
7999
|
* Validates the seed signature, then evaluates a trust anchor for the signer
|
|
7052
|
-
* key (see `SeedTrustPolicy`). An
|
|
7053
|
-
* `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a
|
|
7054
|
-
*
|
|
7055
|
-
*
|
|
8000
|
+
* key (see `SeedTrustPolicy`). An operator-driven caller can pass a per-seed
|
|
8001
|
+
* `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a pinned
|
|
8002
|
+
* owner key — so a cold-start node can accept its first seed without
|
|
8003
|
+
* reconfiguring the service. The override wins on a node started with
|
|
8004
|
+
* `config.claim` as well: the claim secret is the node's default inbound policy,
|
|
8005
|
+
* not a lock on its local operator, who already controls the process.
|
|
7056
8006
|
*/
|
|
7057
8007
|
async applySeed(seed, options) {
|
|
7058
8008
|
if (!this.seedBootstrapService) {
|
|
@@ -7063,13 +8013,13 @@ export class CadreNode {
|
|
|
7063
8013
|
//
|
|
7064
8014
|
// This temp service is discarded after the call, so it must NOT own the shared
|
|
7065
8015
|
// node's inbound seed handler: pass { registerHandler: false }. That keeps
|
|
7066
|
-
// repeated service-less applySeed
|
|
8016
|
+
// repeated service-less applySeed idempotent (no handler leak, no
|
|
7067
8017
|
// DuplicateProtocolHandlerError). The temp service still applies this seed; a
|
|
7068
8018
|
// node that wants to RECEIVE inbound seeds needs a persistent service
|
|
7069
8019
|
// (enableSeedListener / initializeSeedBootstrap) to own the handler.
|
|
7070
8020
|
const tempService = new SeedBootstrapService({
|
|
7071
8021
|
partyId: seed.partyId,
|
|
7072
|
-
trustPolicy: this.
|
|
8022
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
7073
8023
|
...this.seedServiceBudgets(),
|
|
7074
8024
|
// The anchor is node-scoped, not service-scoped: a throwaway service
|
|
7075
8025
|
// must consult (and persist an accepted signer into) the SAME store the
|
|
@@ -7109,12 +8059,19 @@ export class CadreNode {
|
|
|
7109
8059
|
}
|
|
7110
8060
|
/**
|
|
7111
8061
|
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
8062
|
+
*
|
|
8063
|
+
* `target` is a multiaddr string, or a peer id with its addresses (the form a phone
|
|
8064
|
+
* uses for a node it scanned), which are dialed one at a time before the request
|
|
8065
|
+
* (`SeedBootstrapService.deliverSeed`). `options.claimProof` rides beside the seed when
|
|
8066
|
+
* the target is a brand-new node being claimed (`claim-proof.ts`).
|
|
8067
|
+
*
|
|
8068
|
+
* @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it formed.
|
|
7112
8069
|
*/
|
|
7113
|
-
async deliverSeed(
|
|
8070
|
+
async deliverSeed(target, seed, options) {
|
|
7114
8071
|
if (!this.seedBootstrapService) {
|
|
7115
8072
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7116
8073
|
}
|
|
7117
|
-
return await this.seedBootstrapService.deliverSeed(
|
|
8074
|
+
return await this.seedBootstrapService.deliverSeed(target, seed, options);
|
|
7118
8075
|
}
|
|
7119
8076
|
/**
|
|
7120
8077
|
* Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
|
|
@@ -7180,31 +8137,61 @@ export class CadreNode {
|
|
|
7180
8137
|
return result;
|
|
7181
8138
|
}
|
|
7182
8139
|
/**
|
|
7183
|
-
*
|
|
7184
|
-
*
|
|
7185
|
-
|
|
7186
|
-
|
|
8140
|
+
* Claim a node that was started with a claim secret (`CadreNodeConfig.claim`), from
|
|
8141
|
+
* the peer id, addresses and secret its host showed (the QR code): mint a seed, prove
|
|
8142
|
+
* possession of the secret beside it (`claim-proof.ts`), deliver it, and on acceptance
|
|
8143
|
+
* vouch the node into this cadre and keep its addresses as a dial target. The owner
|
|
8144
|
+
* side of the claim-secret entry in the Seed Delivery Protocol (`docs/architecture.md`).
|
|
8145
|
+
*
|
|
8146
|
+
* Requires the seed service with an owner key ({@link initializeSeedBootstrap}), as
|
|
8147
|
+
* {@link addDrone} does. The order is the REVERSE of `addDrone`'s: seed first, row
|
|
8148
|
+
* second. A refused claim or a failed delivery therefore leaves no `CadrePeer` row and
|
|
8149
|
+
* no retained dial target behind on this node — a phone that typed the wrong secret,
|
|
8150
|
+
* or scanned a node someone else already owns, has nothing to clean up.
|
|
8151
|
+
*
|
|
8152
|
+
* A failure is one of three kinds a caller can tell apart:
|
|
8153
|
+
*
|
|
8154
|
+
* - `ClaimRefusedError` — the node was reached and refused, with its refusal code when
|
|
8155
|
+
* its policy gave one.
|
|
8156
|
+
* - `PeerUnreachableError` — no connection to the node formed from any of its addresses,
|
|
8157
|
+
* so nothing was sent. Each address is dialed in turn on its own limit, so this can
|
|
8158
|
+
* take up to the per-peer dial limit plus the seed request's (114.5 s at the default
|
|
8159
|
+
* declared link) when none answers.
|
|
8160
|
+
* - anything else — the node was reached and the exchange failed, or a local step failed
|
|
8161
|
+
* (a malformed secret or address among them). Repeating the claim with the same secret
|
|
8162
|
+
* is safe (see below).
|
|
8163
|
+
*
|
|
8164
|
+
* Idempotent for the same owner: a second call after a dropped response is accepted
|
|
8165
|
+
* by the node (its anchored branch), the row insert is a no-op on an existing row
|
|
8166
|
+
* (`ControlDatabase.insertCadrePeer`), and the dial target is replaced, not duplicated.
|
|
8167
|
+
*
|
|
8168
|
+
* The first dial is kicked (not awaited) so it happens now rather than on the next
|
|
8169
|
+
* timed reconcile pass; a caller that wants to wait for the connection awaits
|
|
8170
|
+
* {@link reconcileControlCohort} itself, with `addDrone`'s caveat that a pass already
|
|
8171
|
+
* in flight is joined rather than restarted. The claimed node dials this one back too,
|
|
8172
|
+
* from the seed's owner list, when this node has a listen or relay address; a phone
|
|
8173
|
+
* with neither is reached by its own reconcile pass alone.
|
|
8174
|
+
*
|
|
8175
|
+
* @throws {ClaimRefusedError} when the node was reached and refused the claim.
|
|
8176
|
+
* @throws {PeerUnreachableError} when the node was never reached.
|
|
8177
|
+
*/
|
|
8178
|
+
async claimNode(target) {
|
|
7187
8179
|
if (!this.seedBootstrapService) {
|
|
7188
8180
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7189
8181
|
}
|
|
7190
|
-
const
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
}
|
|
7197
|
-
/**
|
|
7198
|
-
* Accept a phone connection using an invite.
|
|
7199
|
-
* Call this when a phone dials in with an invite token.
|
|
7200
|
-
*/
|
|
7201
|
-
async acceptPhone(options, issuedInvite) {
|
|
7202
|
-
if (!this.seedBootstrapService) {
|
|
7203
|
-
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8182
|
+
const secret = parseClaimSecret(target.secret);
|
|
8183
|
+
const seed = await this.seedBootstrapService.createSeed();
|
|
8184
|
+
const proof = claimProof(secret, target.peerId, seed.signerKey, seedDigest(seed));
|
|
8185
|
+
const ack = await this.seedBootstrapService.deliverSeed({ peerId: target.peerId, multiaddrs: target.multiaddrs }, seed, { claimProof: proof });
|
|
8186
|
+
if (!ack.accepted) {
|
|
8187
|
+
throw new ClaimRefusedError(target.peerId, ack.reason ?? 'no reason given', ack.code);
|
|
7204
8188
|
}
|
|
7205
|
-
|
|
7206
|
-
//
|
|
7207
|
-
|
|
8189
|
+
log('claimNode: %s accepted the claim; vouching it into the cadre', target.peerId);
|
|
8190
|
+
// The wrapper queues the write for re-replication: a phone claiming its first
|
|
8191
|
+
// always-on node has no control connection yet, so the insert commits local-only.
|
|
8192
|
+
await this.authorizePeer(target.peerId, target.multiaddrs);
|
|
8193
|
+
this.retainDialTarget(target.peerId, target.multiaddrs, 'claimNode');
|
|
8194
|
+
void this.reconcileControlCohort().catch((error) => log('claimNode: reconcile kick after claiming %s failed: %o', target.peerId, error));
|
|
7208
8195
|
}
|
|
7209
8196
|
/**
|
|
7210
8197
|
* Add a phone to the cadre with relay support.
|
|
@@ -7220,45 +8207,246 @@ export class CadreNode {
|
|
|
7220
8207
|
// own control-DB schema load may have died denied.
|
|
7221
8208
|
return await this.seedBootstrapService.addPhoneWithRelay(phonePeerId);
|
|
7222
8209
|
}
|
|
8210
|
+
// ============================================================================
|
|
8211
|
+
// Cadre Invitation API (redeemable at any member, `/sereus/cadre-invite/1.0.0`)
|
|
8212
|
+
// ============================================================================
|
|
7223
8213
|
/**
|
|
7224
|
-
*
|
|
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.
|
|
7225
8219
|
*/
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
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
|
+
}
|
|
8236
|
+
/**
|
|
8237
|
+
* Mint an owner-signed cadre invitation that a device can redeem at ANY member machine of
|
|
8238
|
+
* this party, the owner offline included ({@link redeemCadreInvitation}). Requires the
|
|
8239
|
+
* owner key ({@link initializeSeedBootstrap}).
|
|
8240
|
+
*
|
|
8241
|
+
* The invitation is an ed25519 keypair: the public half is the `CadreInvite` row's key,
|
|
8242
|
+
* the private half rides in the returned bundle as the proof of possession. Whoever holds
|
|
8243
|
+
* the bundle can redeem it, so an UNTARGETED, OWNER-GRANTING invitation — a bearer
|
|
8244
|
+
* credential for admin rights — defaults to a 15-minute lifetime; every other kind to 24
|
|
8245
|
+
* hours. One use unless `uses` says otherwise. The bundle carries the signed row (so a
|
|
8246
|
+
* member that has not received it by replication seats it from the bundle), this node's
|
|
8247
|
+
* anchored owner keys — every current owner this node has verified, the out-of-band ones
|
|
8248
|
+
* and those derived along verifiable chains ({@link syncOwnerAnchor}) — (the device pins
|
|
8249
|
+
* them and checks the member's reply against them; sourced from the node-local anchor
|
|
8250
|
+
* only, never the raw replicated `OwnerKey` table, because the device anchors whatever
|
|
8251
|
+
* arrives and a stranger's genesis-inserted key must not ride an invitation into a fresh
|
|
8252
|
+
* node's anchor; an empty anchor is refused because a reply could not be checked), and
|
|
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.
|
|
8260
|
+
*
|
|
8261
|
+
* The row commits locally and replicates like any other control write; minted while
|
|
8262
|
+
* alone (a phone with no connection), it reaches the other members through the
|
|
8263
|
+
* peer-join block catch-up on the next connection. The device can redeem at any member it
|
|
8264
|
+
* reaches meanwhile: a member that does not hold the row yet admits the device's connection
|
|
8265
|
+
* provisionally ({@link admitInboundControlConnection}) and seats the row from the bundle.
|
|
8266
|
+
*
|
|
8267
|
+
* @throws when no owner key is wired, the anchor is empty, `uses` is not a positive
|
|
8268
|
+
* integer, or neither this machine nor any other member has an address.
|
|
8269
|
+
*/
|
|
8270
|
+
async createCadreInvitation(options) {
|
|
8271
|
+
const service = this.seedBootstrapService;
|
|
8272
|
+
if (!service?.canAuthorize()) {
|
|
8273
|
+
throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
|
|
8274
|
+
}
|
|
8275
|
+
const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
|
|
8276
|
+
if (ownerKeys.length === 0) {
|
|
8277
|
+
throw new Error('createCadreInvitation: this node anchors no owner key, and an invitation without owner keys cannot be verified by the device that redeems it');
|
|
8278
|
+
}
|
|
8279
|
+
const uses = options.uses ?? 1;
|
|
8280
|
+
if (!Number.isInteger(uses) || uses < 1) {
|
|
8281
|
+
throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
|
|
8282
|
+
}
|
|
8283
|
+
const { members, reach } = await this.selectInvitationMembers();
|
|
8284
|
+
if (members.length === 0) {
|
|
8285
|
+
throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
|
|
8286
|
+
}
|
|
8287
|
+
const expiresInMs = options.expiresInMs
|
|
8288
|
+
?? (options.grantsOwner && !options.peerId ? OWNER_INVITATION_DEFAULT_TTL_MS : CADRE_INVITATION_DEFAULT_TTL_MS);
|
|
8289
|
+
const invitePrivateKey = generatePrivateKey('ed25519', 'base64url');
|
|
8290
|
+
const row = await service.insertCadreInvite({
|
|
8291
|
+
key: ed25519PublicKeyFromPrivate(invitePrivateKey),
|
|
8292
|
+
peerId: options.peerId ?? null,
|
|
8293
|
+
grantsOwner: options.grantsOwner,
|
|
8294
|
+
expiresAtMs: Date.now() + expiresInMs,
|
|
8295
|
+
totalUses: uses,
|
|
8296
|
+
});
|
|
8297
|
+
const invitation = {
|
|
8298
|
+
v: 1,
|
|
8299
|
+
partyId: this.config.controlNetwork.partyId,
|
|
8300
|
+
invitePrivateKey,
|
|
8301
|
+
invite: row,
|
|
8302
|
+
ownerKeys,
|
|
8303
|
+
members,
|
|
8304
|
+
};
|
|
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 };
|
|
7229
8307
|
}
|
|
7230
8308
|
/**
|
|
7231
|
-
*
|
|
8309
|
+
* Every cadre invitation this node holds — minted here or replicated in — with its
|
|
8310
|
+
* standing ({@link CadreInviteStatus}: live, withdrawn, redemptions recorded).
|
|
7232
8311
|
*/
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
8312
|
+
async listCadreInvitations() {
|
|
8313
|
+
if (!this.controlDatabase) {
|
|
8314
|
+
throw new Error('CadreNode must be started before listing cadre invitations');
|
|
8315
|
+
}
|
|
8316
|
+
return await this.controlDatabase.listCadreInviteStatuses();
|
|
7237
8317
|
}
|
|
7238
8318
|
/**
|
|
7239
|
-
*
|
|
8319
|
+
* Withdraw a cadre invitation: an owner-signed `Revocation` tombstone over its row, which
|
|
8320
|
+
* stays as the proof of membership for every device it admitted. No further redemption
|
|
8321
|
+
* succeeds anywhere the tombstone reaches, and a member holding it refuses the bundle's
|
|
8322
|
+
* copy of the row. A withdrawal committed while alone is re-issued on cohort growth like
|
|
8323
|
+
* any other tombstone.
|
|
8324
|
+
*
|
|
8325
|
+
* @returns `true` when this call filed the tombstone, `false` when the row is not held
|
|
8326
|
+
* here or was already withdrawn.
|
|
7240
8327
|
*/
|
|
7241
|
-
async
|
|
8328
|
+
async withdrawCadreInvitation(key) {
|
|
7242
8329
|
if (!this.seedBootstrapService) {
|
|
7243
|
-
|
|
7244
|
-
// the call, so it does NOT own the shared node's inbound seed handler:
|
|
7245
|
-
// initialize with { registerHandler: false }. This temp dialInvite never
|
|
7246
|
-
// applies a seed itself — a node that wants to RECEIVE an inbound seed back
|
|
7247
|
-
// must have a persistent service (enableSeedListener / initializeSeedBootstrap)
|
|
7248
|
-
// own the /sereus/seed/1.0.0 handler. trustPolicy is therefore effectively dead
|
|
7249
|
-
// on this dial-only path; it is kept for symmetry with the applySeed temp site.
|
|
7250
|
-
const tempService = new SeedBootstrapService({
|
|
7251
|
-
partyId: invite.partyId,
|
|
7252
|
-
trustPolicy: this.config.seedTrustPolicy,
|
|
7253
|
-
...this.seedServiceBudgets(),
|
|
7254
|
-
});
|
|
7255
|
-
if (this.controlNode && this.controlDatabase) {
|
|
7256
|
-
await tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
|
|
7257
|
-
}
|
|
7258
|
-
await tempService.dialInvite(invite);
|
|
7259
|
-
return;
|
|
8330
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7260
8331
|
}
|
|
7261
|
-
await this.seedBootstrapService.
|
|
8332
|
+
return await this.seedBootstrapService.withdrawCadreInvite(key);
|
|
8333
|
+
}
|
|
8334
|
+
/**
|
|
8335
|
+
* Join the cadre an invitation names by redeeming it at one of the member machines it
|
|
8336
|
+
* lists, the owner that minted it offline or not. Needs a started node with a stable
|
|
8337
|
+
* identity (`keyStore` or `privateKey`): the redemption is signed with the key behind
|
|
8338
|
+
* this node's peer id, and the member checks that the two match.
|
|
8339
|
+
*
|
|
8340
|
+
* Steps: refuse an invitation for another party; pin its `ownerKeys` into the node-local
|
|
8341
|
+
* anchor (`'invite'` provenance — the pin sticks even if the redemption then fails, as it
|
|
8342
|
+
* does for a seed invite, so a later attempt or seed from the same owner is anchored);
|
|
8343
|
+
* sign the request; dial the listed members in order (`redeemAtMembers`); on
|
|
8344
|
+
* acceptance verify the reply against the pinned keys, merge its dial hints into the peer
|
|
8345
|
+
* store, retain the answering member as a cold-start dial target, refresh the membership
|
|
8346
|
+
* gate and start a cohort reconcile pass. This node then syncs the control database over
|
|
8347
|
+
* the connection it already holds, the same unified behaviour as after a seed.
|
|
8348
|
+
*
|
|
8349
|
+
* A device that is already a member (a restart with the invitation still configured) is
|
|
8350
|
+
* answered as accepted by the member's idempotent redemption, with no second usage row.
|
|
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
|
+
*
|
|
8361
|
+
* When the invitation grants ownership, the member seats this node's key as an `OwnerKey`
|
|
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}).
|
|
8366
|
+
*
|
|
8367
|
+
* Limit: the reply check ({@link verifyRedeemReply}) does not authenticate the member. A
|
|
8368
|
+
* forged bundle, or a legitimate one whose addresses were swapped, dials a machine that
|
|
8369
|
+
* can echo the owner-signed row it was just sent; that is what pasting an attacker's
|
|
8370
|
+
* invitation means. What protects this node afterwards is its anchor: every row it then
|
|
8371
|
+
* syncs is judged against the pinned owner keys, so a machine that is not a member of the
|
|
8372
|
+
* cadre the invitation names can admit nobody this node will trust.
|
|
8373
|
+
*
|
|
8374
|
+
* @throws `CadreInviteRejectedError` on a final refusal, `CadreInviteReplyInvalidError` on
|
|
8375
|
+
* an acceptance that fails the reply check, `CadreInviteUnreachableError` when every
|
|
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.
|
|
8378
|
+
*/
|
|
8379
|
+
async redeemCadreInvitation(invitation, options = {}) {
|
|
8380
|
+
const controlNode = this.controlNode;
|
|
8381
|
+
if (!controlNode || !this.controlDatabase || !this._running) {
|
|
8382
|
+
throw new Error('CadreNode must be started before redeeming a cadre invitation');
|
|
8383
|
+
}
|
|
8384
|
+
if (invitation.partyId !== this.config.controlNetwork.partyId) {
|
|
8385
|
+
throw new Error(`Cadre invitation is for party ${invitation.partyId}; this node serves ${this.config.controlNetwork.partyId}`);
|
|
8386
|
+
}
|
|
8387
|
+
const signer = this.getSelfSigningKey();
|
|
8388
|
+
if (!signer) {
|
|
8389
|
+
throw new Error('redeemCadreInvitation: this node runs on an ephemeral identity; configure `keyStore` or `privateKey` so its key can sign the redemption');
|
|
8390
|
+
}
|
|
8391
|
+
await this.trustOwnerKeys(invitation.ownerKeys, 'invite');
|
|
8392
|
+
const pinned = new Set(invitation.ownerKeys);
|
|
8393
|
+
const request = signRedeemRequest(invitation, { peerKey: signer.publicKeyB64, peerPrivateKey: signer.privateKeyB64 }, generateStampId(controlNode.peerId.toString()), await this.resolveInviteAddresses());
|
|
8394
|
+
const { reply, memberPeerId, memberAddr } = await redeemInPasses(controlNode, {
|
|
8395
|
+
invitation,
|
|
8396
|
+
request,
|
|
8397
|
+
isTrustedIssuer: (ownerKey) => pinned.has(ownerKey),
|
|
8398
|
+
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
8399
|
+
waitForMember: options.waitForMember ?? false,
|
|
8400
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
8401
|
+
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
8402
|
+
});
|
|
8403
|
+
log('Cadre invitation %s redeemed at %s (owner=%s)', invitation.invite.key, memberAddr, reply.invite.grantsOwner);
|
|
8404
|
+
const selfPeerId = controlNode.peerId.toString();
|
|
8405
|
+
await mergeSeedPeers(controlNode, reply.peers.filter((peer) => peer.peerId !== selfPeerId));
|
|
8406
|
+
if (memberPeerId !== null) {
|
|
8407
|
+
// Every address the bundle gave the answering member, so a restart has the same choices.
|
|
8408
|
+
const memberAddrs = this.parseMultiaddrs(invitation.members)
|
|
8409
|
+
.filter((addr) => trailingPeerId(addr) === memberPeerId)
|
|
8410
|
+
.map((addr) => addr.toString());
|
|
8411
|
+
this.retainDialTarget(memberPeerId, memberAddrs, 'redeemCadreInvitation');
|
|
8412
|
+
}
|
|
8413
|
+
// The pins above flip nothing yet (this node holds no rows), but the rows about to
|
|
8414
|
+
// replicate in are judged against them; refresh so the snapshot is not stale when they land.
|
|
8415
|
+
await this.refreshMembershipGate('invitation-redeemed');
|
|
8416
|
+
void this.reconcileControlCohort().catch((error) => log('redeemCadreInvitation: the post-redemption reconcile pass failed: %o', error));
|
|
8417
|
+
return { peerId: memberPeerId, grantsOwner: reply.invite.grantsOwner, redeemedAt: new Date().toISOString() };
|
|
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;
|
|
7262
8450
|
}
|
|
7263
8451
|
// ============================================================================
|
|
7264
8452
|
// Strand Solicitation API (native cadre-core formation transport)
|
|
@@ -7388,18 +8576,27 @@ export class CadreNode {
|
|
|
7388
8576
|
return await this.strandSolicitationService.createOpenInvitation(sAppId, expirationMs, bootstrap);
|
|
7389
8577
|
}
|
|
7390
8578
|
/**
|
|
7391
|
-
* Addresses of the party's other machines for an invitation's bootstrap list
|
|
7392
|
-
* ({@link selectInvitationSiblingAddrs} picks which
|
|
7393
|
-
|
|
7394
|
-
|
|
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.
|
|
7395
8592
|
*
|
|
7396
8593
|
* Best-effort: a failed read leaves the invitation naming this machine alone, which is what
|
|
7397
8594
|
* it named before siblings were added.
|
|
7398
8595
|
*/
|
|
7399
|
-
async
|
|
8596
|
+
async resolveInvitationSiblings() {
|
|
7400
8597
|
const controlNode = this.controlNode;
|
|
7401
8598
|
if (!controlNode || !this.controlDatabase) {
|
|
7402
|
-
return [];
|
|
8599
|
+
return { siblings: [], connected: new Set() };
|
|
7403
8600
|
}
|
|
7404
8601
|
try {
|
|
7405
8602
|
const siblings = [];
|
|
@@ -7409,12 +8606,11 @@ export class CadreNode {
|
|
|
7409
8606
|
siblings.push({ peerId, updatedAt: resolved.updatedAt, addrs: resolved.addrs.map(String) });
|
|
7410
8607
|
}
|
|
7411
8608
|
}
|
|
7412
|
-
|
|
7413
|
-
return selectInvitationSiblingAddrs(siblings, connected);
|
|
8609
|
+
return { siblings, connected: new Set(controlNode.getConnections().map((c) => c.remotePeer.toString())) };
|
|
7414
8610
|
}
|
|
7415
8611
|
catch (error) {
|
|
7416
|
-
log('
|
|
7417
|
-
return [];
|
|
8612
|
+
log('siblingInvitationAddrs: resolving the other machines failed; the invitation names only this one: %o', error);
|
|
8613
|
+
return { siblings: [], connected: new Set() };
|
|
7418
8614
|
}
|
|
7419
8615
|
}
|
|
7420
8616
|
/**
|
|
@@ -7435,7 +8631,7 @@ export class CadreNode {
|
|
|
7435
8631
|
await this.initializeStrandSolicitation();
|
|
7436
8632
|
}
|
|
7437
8633
|
const result = await this.strandSolicitationService.formStrand(invitation, disclosure, this.controlNode);
|
|
7438
|
-
await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs);
|
|
8634
|
+
await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs, 'formStrand');
|
|
7439
8635
|
// A closed host strand's approval carries the joiner's own membership invitation —
|
|
7440
8636
|
// persist this party's identity key and stage the invitation for bring-up to redeem.
|
|
7441
8637
|
// Runs AFTER the addr recording so a persistence failure (which throws — see the
|
|
@@ -7450,7 +8646,7 @@ export class CadreNode {
|
|
|
7450
8646
|
* Ask to join through `invitation` and have the party keep trying until the join works, the
|
|
7451
8647
|
* invitation is used up or expires, or the request is dismissed: across restarts, and from
|
|
7452
8648
|
* any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
|
|
7453
|
-
* offline"). Records a party-wide `
|
|
8649
|
+
* offline"). Records a party-wide `JoinRequest` row, runs one attempt on this machine at
|
|
7454
8650
|
* once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
|
|
7455
8651
|
* when it was unreachable or not ready, `'failed'` when it refused for good.
|
|
7456
8652
|
*
|
|
@@ -7516,13 +8712,13 @@ export class CadreNode {
|
|
|
7516
8712
|
return { ownerKey: signingKey.publicKeyB64, signMessage: signMessageWith(signingKey.privateKeyB64) };
|
|
7517
8713
|
}
|
|
7518
8714
|
/**
|
|
7519
|
-
* Write a join request
|
|
7520
|
-
* pending one as it is, a finished one
|
|
8715
|
+
* Write a join request, or adopt the party's existing request for the same invitation: a
|
|
8716
|
+
* pending one as it is, a finished one rewritten as `requested` (the user asked again).
|
|
7521
8717
|
*/
|
|
7522
8718
|
async recordJoinRequest(database, requested, { ownerKey, signMessage }) {
|
|
7523
8719
|
let written;
|
|
7524
8720
|
try {
|
|
7525
|
-
written = await database.
|
|
8721
|
+
written = await database.insertJoinRequest(requested, ownerKey, signMessage);
|
|
7526
8722
|
}
|
|
7527
8723
|
catch (error) {
|
|
7528
8724
|
if (!isPendingJoinConflict(error)) {
|
|
@@ -7533,12 +8729,12 @@ export class CadreNode {
|
|
|
7533
8729
|
throw new Error(`A removed join request for this invitation (${requested.Id}) is still held on this machine until its ` +
|
|
7534
8730
|
'clean-up runs, which needs a connection to the party. Ask again once connected.', { cause: error });
|
|
7535
8731
|
}
|
|
7536
|
-
if (existing.
|
|
8732
|
+
if (existing.outcome === null) {
|
|
7537
8733
|
return existing;
|
|
7538
8734
|
}
|
|
7539
|
-
written = await database.
|
|
8735
|
+
written = await database.rewritePendingJoin(existing.StampId, { ...requested, outcome: null }, ownerKey, signMessage);
|
|
7540
8736
|
}
|
|
7541
|
-
this.pendingJoinRunner?.noteWritten(written);
|
|
8737
|
+
this.pendingJoinRunner?.noteWritten(written, 'request');
|
|
7542
8738
|
return written;
|
|
7543
8739
|
}
|
|
7544
8740
|
/** Build and start the pending-join retry loop over this node (see {@link pendingJoinRunner}). */
|
|
@@ -7554,15 +8750,19 @@ export class CadreNode {
|
|
|
7554
8750
|
selfId: this.controlNode.peerId.toString(),
|
|
7555
8751
|
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
7556
8752
|
isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
|
|
7557
|
-
// NOTE: one owner-key read and one
|
|
7558
|
-
// asked for a join the second reads
|
|
7559
|
-
// shows up in a device profile, poll more slowly while the
|
|
8753
|
+
// NOTE: one owner-key read and one pending-join read (three tables) per pass (30 s); for a
|
|
8754
|
+
// party that never asked for a join the second reads never-written blocks, which consults
|
|
8755
|
+
// the cohort. If it shows up in a device profile, poll more slowly while the tables read empty.
|
|
7560
8756
|
readRows: () => database().queryPendingJoins(),
|
|
7561
8757
|
readRow: (id) => database().queryPendingJoin(id),
|
|
7562
|
-
attempt: (
|
|
7563
|
-
|
|
8758
|
+
attempt: (join) => this.formStrand(this.pendingJoinInvitation(join), parseStoredDisclosure(join)),
|
|
8759
|
+
record: async (expected, outcome) => {
|
|
8760
|
+
const { ownerKey, signMessage } = await outcomeSigner();
|
|
8761
|
+
return database().recordJoinOutcome(expected, outcome, ownerKey, signMessage);
|
|
8762
|
+
},
|
|
8763
|
+
rewrite: async (expectedStampId, next) => {
|
|
7564
8764
|
const { ownerKey, signMessage } = await outcomeSigner();
|
|
7565
|
-
return database().
|
|
8765
|
+
return database().rewritePendingJoin(expectedStampId, next, ownerKey, signMessage);
|
|
7566
8766
|
},
|
|
7567
8767
|
remove: async (id) => {
|
|
7568
8768
|
const { ownerKey, signMessage } = await outcomeSigner();
|
|
@@ -7575,33 +8775,33 @@ export class CadreNode {
|
|
|
7575
8775
|
});
|
|
7576
8776
|
this.pendingJoinRunner.start();
|
|
7577
8777
|
}
|
|
7578
|
-
/** The invitation a `
|
|
7579
|
-
pendingJoinInvitation(
|
|
8778
|
+
/** The invitation a `JoinRequest` row stores. A failure names the row, never the invitation, which is a bearer credential. */
|
|
8779
|
+
pendingJoinInvitation(join) {
|
|
7580
8780
|
try {
|
|
7581
|
-
return this.decodeInvitation(
|
|
8781
|
+
return this.decodeInvitation(join.Invitation);
|
|
7582
8782
|
}
|
|
7583
8783
|
catch {
|
|
7584
|
-
throw new Error(`
|
|
8784
|
+
throw new Error(`JoinRequest ${join.Id}: the stored invitation does not decode`);
|
|
7585
8785
|
}
|
|
7586
8786
|
}
|
|
7587
|
-
pendingJoinSAppId(
|
|
8787
|
+
pendingJoinSAppId(join) {
|
|
7588
8788
|
try {
|
|
7589
|
-
return this.decodeInvitation(
|
|
8789
|
+
return this.decodeInvitation(join.Invitation).sAppId;
|
|
7590
8790
|
}
|
|
7591
8791
|
catch {
|
|
7592
|
-
log('
|
|
8792
|
+
log('JoinRequest %s: the stored invitation does not decode; reporting no sApp', join.Id);
|
|
7593
8793
|
return '';
|
|
7594
8794
|
}
|
|
7595
8795
|
}
|
|
7596
8796
|
/**
|
|
7597
|
-
* Stage the membership invitation of each
|
|
8797
|
+
* Stage the membership invitation of each joined request this process has not staged yet, so a
|
|
7598
8798
|
* closed strand joined on another owner machine is seated by whichever machine launches it
|
|
7599
8799
|
* first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
|
|
7600
8800
|
* seated. A strand that already has an invitation staged keeps it, for the reconciler to
|
|
7601
8801
|
* settle first.
|
|
7602
8802
|
*/
|
|
7603
|
-
stageMembershipInvitesFromPendingJoins(
|
|
7604
|
-
for (const { rowId, strandId, invite } of membershipInvitesToStage(
|
|
8803
|
+
stageMembershipInvitesFromPendingJoins(joins) {
|
|
8804
|
+
for (const { rowId, strandId, invite } of membershipInvitesToStage(joins, this.stagedMembershipInviteKeys, Date.now())) {
|
|
7605
8805
|
this.stagedMembershipInviteKeys.add(invite.inviteKey);
|
|
7606
8806
|
if (this.pendingMembershipInvites.has(strandId)) {
|
|
7607
8807
|
continue;
|
|
@@ -7856,15 +9056,15 @@ export class CadreNode {
|
|
|
7856
9056
|
* Nothing else would deliver them: the seed is read only at launch and resume, and the
|
|
7857
9057
|
* refresh pass skips a peer FRET already holds a record for — the stale one, here.
|
|
7858
9058
|
*/
|
|
7859
|
-
async recordFormationStrandAddrs(strandId, strandAddrs) {
|
|
9059
|
+
async recordFormationStrandAddrs(strandId, strandAddrs, caller) {
|
|
7860
9060
|
const attributed = [...groupAddrsByPeerId([...strandAddrs]).values()]
|
|
7861
9061
|
.flatMap((peerAddrs) => peerAddrs.map((addr) => addr.toString()));
|
|
7862
9062
|
if (attributed.length === 0) {
|
|
7863
|
-
log('
|
|
9063
|
+
log('%s: no usable strand addrs for %s (%d carried) — cross-party seed unchanged', caller, strandId, strandAddrs.length);
|
|
7864
9064
|
return;
|
|
7865
9065
|
}
|
|
7866
9066
|
this.formationStrandAddrs.set(strandId, attributed);
|
|
7867
|
-
log('
|
|
9067
|
+
log('%s: kept %d of %d cross-party strand addr(s) for %s', caller, attributed.length, strandAddrs.length, strandId);
|
|
7868
9068
|
const runningNode = this.strandManager.getInstance(strandId)?.libp2pNode;
|
|
7869
9069
|
if (runningNode) {
|
|
7870
9070
|
await this.mergeStrandPeerAddrs(runningNode, attributed, strandId);
|