@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/src/cadre-node.ts
CHANGED
|
@@ -18,11 +18,10 @@ import type {
|
|
|
18
18
|
CadreNodeEvents,
|
|
19
19
|
ControlNetworkSeed,
|
|
20
20
|
ApplySeedResult,
|
|
21
|
+
SeedAckMessage,
|
|
22
|
+
SeedDeliveryTarget,
|
|
21
23
|
AddDroneOptions,
|
|
22
|
-
AddPhoneOptions,
|
|
23
24
|
DroneInitResult,
|
|
24
|
-
InviteResult,
|
|
25
|
-
CadreInvite,
|
|
26
25
|
OpenInvitation,
|
|
27
26
|
FormStrandResult,
|
|
28
27
|
StrandFormationDisclosure,
|
|
@@ -35,13 +34,27 @@ import type {
|
|
|
35
34
|
DeviceTokenRecord,
|
|
36
35
|
CadrePeerVoucherFields,
|
|
37
36
|
CadrePeerRow,
|
|
37
|
+
OwnerKeyRow,
|
|
38
|
+
RevocationRow,
|
|
39
|
+
InvitationChain,
|
|
40
|
+
CadreInviteStatus,
|
|
41
|
+
CadreMachine,
|
|
42
|
+
MachineRemovalPlan,
|
|
43
|
+
MachineRemovalResult,
|
|
44
|
+
RemoveMachineOptions,
|
|
45
|
+
CadreRemoval,
|
|
46
|
+
CadreInvitationReach,
|
|
47
|
+
CreateCadreInvitationOptions,
|
|
48
|
+
CreateCadreInvitationResult,
|
|
49
|
+
RedeemCadreInvitationOptions,
|
|
50
|
+
RedeemCadreInvitationResult,
|
|
38
51
|
PeerAddressRecord,
|
|
39
52
|
ResolveDeviceTokenOpts,
|
|
40
|
-
|
|
53
|
+
PendingJoin,
|
|
41
54
|
PendingJoinStatus
|
|
42
55
|
} from './types.js';
|
|
43
56
|
import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
|
|
44
|
-
import { sign } from '@optimystic/quereus-plugin-crypto';
|
|
57
|
+
import { generatePrivateKey, sign } from '@optimystic/quereus-plugin-crypto';
|
|
45
58
|
import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64, type Ed25519KeyPair } from './ed25519-key.js';
|
|
46
59
|
import { strandTransportKey } from './strand-transport-key.js';
|
|
47
60
|
import {
|
|
@@ -64,21 +77,22 @@ import {
|
|
|
64
77
|
JoinedStrandSession,
|
|
65
78
|
KeyStoreJoinedStrandStore,
|
|
66
79
|
MemoryJoinedStrandStore,
|
|
80
|
+
type JoinedStrandRecord,
|
|
67
81
|
type JoinedStrandStore,
|
|
68
82
|
type PartyJoinedStrandLedger
|
|
69
83
|
} from './joined-strand-store.js';
|
|
70
84
|
import { mergePeerAddrs, groupAddrsByPeerId, type MergeAddrsResult } from './peer-addr-book.js';
|
|
71
85
|
import { strandFretPeerAddrs } from './strand-fret-addrs.js';
|
|
72
|
-
import { verifyCadrePeerVoucher } from './peer-authorization.js';
|
|
73
|
-
import {
|
|
86
|
+
import { verifyCadrePeerVoucher, verifyInvitationAdmission, verifyRevocationSigner } from './peer-authorization.js';
|
|
87
|
+
import { deriveOwnerAnchor } from './owner-anchor-sync.js';
|
|
88
|
+
import { describeMachines, planMachineRemoval, revouchWithinPlan, MachineRemovalRefusedError } from './machine-removal.js';
|
|
89
|
+
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
74
90
|
import {
|
|
75
91
|
signPeerRecord,
|
|
76
92
|
verifyPeerRecordSignature,
|
|
77
93
|
isPeerRecordFresh,
|
|
78
94
|
orderSignalingFirst,
|
|
79
95
|
isSignalingAddr,
|
|
80
|
-
trailingPeerId,
|
|
81
|
-
withTrailingPeerId,
|
|
82
96
|
currentMemberTrustPolicy,
|
|
83
97
|
DEFAULT_PEER_RECORD_MAX_AGE_MS,
|
|
84
98
|
DEFAULT_PEER_RECORD_HEARTBEAT_MS
|
|
@@ -103,35 +117,49 @@ import {
|
|
|
103
117
|
dialPeerAddrs,
|
|
104
118
|
SelfRelayOnlyError,
|
|
105
119
|
CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS,
|
|
120
|
+
trailingPeerId,
|
|
121
|
+
withTrailingPeerId,
|
|
106
122
|
type PeerDialBudget
|
|
107
123
|
} from './peer-dial.js';
|
|
108
|
-
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget,
|
|
124
|
+
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, relayedRequestBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
109
125
|
import { EnrollmentService } from './enrollment.js';
|
|
110
126
|
import { HibernationManager, type HibernationCallbacks } from './hibernation-manager.js';
|
|
111
|
-
import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type RevokedRowRef } from './control-database.js';
|
|
127
|
+
import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type JoinRequestFields, type RevokedRowRef } from './control-database.js';
|
|
128
|
+
import {
|
|
129
|
+
CadreInviteHandler,
|
|
130
|
+
encodeCadreInvitation,
|
|
131
|
+
redeemInPasses,
|
|
132
|
+
signRedeemRequest,
|
|
133
|
+
type CadreInvitation
|
|
134
|
+
} from './cadre-invite-protocol.js';
|
|
112
135
|
import { FormationPostApprovalError } from './strand-formation-rejection.js';
|
|
113
136
|
import {
|
|
114
137
|
PendingJoinRunner,
|
|
115
138
|
membershipInvitesToStage,
|
|
116
139
|
parseStoredDisclosure,
|
|
117
|
-
requestedPendingJoin
|
|
118
|
-
type PendingJoinFields
|
|
140
|
+
requestedPendingJoin
|
|
119
141
|
} from './pending-join-runner.js';
|
|
120
142
|
import type { ControlRetryAbandonment } from './control-retry.js';
|
|
121
|
-
import { SeedBootstrapService, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
|
|
122
|
-
import type
|
|
143
|
+
import { SeedBootstrapService, mergeSeedPeers, seedDigest, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
|
|
144
|
+
import { claimSecretTrustPolicy, type SeedTrustPolicy } from './seed-trust-policy.js';
|
|
145
|
+
import { ClaimRefusedError, claimProof, parseClaimSecret } from './claim-proof.js';
|
|
123
146
|
import {
|
|
124
147
|
StrandSolicitationService,
|
|
125
148
|
type StrandSolicitationServiceOptions
|
|
126
149
|
} from './strand-solicitation.js';
|
|
127
150
|
import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
|
|
128
|
-
import {
|
|
151
|
+
import {
|
|
152
|
+
cadreInvitationReach,
|
|
153
|
+
selectCadreInvitationMembers,
|
|
154
|
+
selectInvitationSiblingAddrs,
|
|
155
|
+
type InvitationSibling
|
|
156
|
+
} from './invitation-bootstrap.js';
|
|
129
157
|
import {
|
|
130
158
|
createMembershipConnectionGater,
|
|
131
|
-
DEFAULT_ENROLLMENT_WINDOW_MS,
|
|
132
159
|
UnauthorizedReservationBudget,
|
|
133
160
|
type InboundConnectionVerdict
|
|
134
161
|
} from './membership-connection-gater.js';
|
|
162
|
+
import { controlNetworkName, installControlProtocolGuard } from './control-protocol-guard.js';
|
|
135
163
|
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
136
164
|
import { StrandAddrService, collectStrandAddrs, type StrandAddrPeer, type StrandAddrOutcome, type StrandAddrCollection } from './strand-addr-protocol.js';
|
|
137
165
|
import {
|
|
@@ -143,6 +171,7 @@ import {
|
|
|
143
171
|
type CircuitRelayTarget
|
|
144
172
|
} from './delegate-admission.js';
|
|
145
173
|
import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
|
|
174
|
+
import { nodeTransportsOption } from './node-transports.js';
|
|
146
175
|
import { resolveRelayServer, type ResolvedRelayServer } from './relay-server.js';
|
|
147
176
|
import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
|
|
148
177
|
import {
|
|
@@ -187,6 +216,17 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
|
|
|
187
216
|
*/
|
|
188
217
|
export const STRAND_PEER_ADDR_RETRY_MS = 60 * 1000;
|
|
189
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Default lifetime of an UNTARGETED, OWNER-GRANTING cadre invitation
|
|
221
|
+
* ({@link CadreNode.createCadreInvitation}): whoever holds the bundle becomes an owner, so
|
|
222
|
+
* it is a bearer credential for admin rights and lives only long enough to be pasted or
|
|
223
|
+
* scanned by the device it was minted for.
|
|
224
|
+
*/
|
|
225
|
+
export const OWNER_INVITATION_DEFAULT_TTL_MS = 15 * 60 * 1000;
|
|
226
|
+
|
|
227
|
+
/** Default lifetime of every other cadre invitation: targeted, or granting membership only. */
|
|
228
|
+
export const CADRE_INVITATION_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
229
|
+
|
|
190
230
|
type EventHandler<T> = (data: T) => void;
|
|
191
231
|
|
|
192
232
|
/**
|
|
@@ -235,6 +275,25 @@ function unionAddrs(primary: readonly string[], extra: readonly string[]): strin
|
|
|
235
275
|
* canonical row-bound message BYTES (see `buildAuthorizationMessage`), and it ed25519-signs
|
|
236
276
|
* them directly (no pre-hash) with the owner private key, returning a base64url signature.
|
|
237
277
|
*/
|
|
278
|
+
/**
|
|
279
|
+
* One membership refresh's invitation chain ({@link InvitationChain}), loaded at most once
|
|
280
|
+
* and shared by the anchor sync (`OwnerKey` rows) and the membership read (`CadrePeer`
|
|
281
|
+
* rows), each of which calls it only when some row it judges is invitation-admitted.
|
|
282
|
+
*/
|
|
283
|
+
type InvitationChainSource = () => Promise<InvitationChain>;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* One membership refresh's `Revocation` tombstones, loaded at most once and shared by the
|
|
287
|
+
* anchor sync (`OwnerKey` stamps and signers) and the hang-up of removed peers (`CadrePeer`
|
|
288
|
+
* row keys), each of which calls it only when it has something to judge.
|
|
289
|
+
*/
|
|
290
|
+
type RevocationSource = () => Promise<RevocationRow[]>;
|
|
291
|
+
|
|
292
|
+
/** Whether a `CadrePeer` or `OwnerKey` row's proof is an invitation admission rather than an owner voucher (`types.ts` → `CadrePeerRow`, `OwnerKeyRow`). */
|
|
293
|
+
function isInvitationAdmitted(row: { vouchSig: string | null; vouchUsage: string | null }): boolean {
|
|
294
|
+
return row.vouchSig === null && row.vouchUsage !== null;
|
|
295
|
+
}
|
|
296
|
+
|
|
238
297
|
function signMessageWith(privateKeyB64: string): (message: Uint8Array) => string {
|
|
239
298
|
return (message: Uint8Array): string =>
|
|
240
299
|
sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
|
|
@@ -374,11 +433,26 @@ export class CadreNode implements SAppIdLookup {
|
|
|
374
433
|
* start() cycle rebuilds it with a fresh caught-up-peer memo.
|
|
375
434
|
*/
|
|
376
435
|
private controlBackfill: PeerJoinBackfill | null = null;
|
|
436
|
+
/**
|
|
437
|
+
* Devices whose invitation redemption this member has verified and is admitting right now,
|
|
438
|
+
* each with its count of overlapping redemptions. The control backfill's gate and the
|
|
439
|
+
* control cohort choice (Optimystic's `isNetworkMember`) count them as members, for that
|
|
440
|
+
* window only ({@link admitRedeemingDevice}); the per-stream gate never does.
|
|
441
|
+
*/
|
|
442
|
+
private readonly admittingDevices = new Map<string, number>();
|
|
377
443
|
private strandWatcher: StrandWatcher | null = null;
|
|
378
444
|
private strandManager: StrandInstanceManager;
|
|
379
445
|
private hibernationManager: HibernationManager;
|
|
380
446
|
private enrollmentService: EnrollmentService;
|
|
381
447
|
private seedBootstrapService: SeedBootstrapService | null = null;
|
|
448
|
+
/**
|
|
449
|
+
* The claim-secret trust policy of a node started with `config.claim`, built once by
|
|
450
|
+
* {@link initializeClaimPolicy} over {@link trustedOwnerStore} and handed to every
|
|
451
|
+
* seed service this node constructs ({@link inboundSeedTrustPolicy}). Kept across
|
|
452
|
+
* stop()→start() like the anchor it writes: its in-process latch and failure window
|
|
453
|
+
* are per node, not per seed service. Null when the node has no claim secret.
|
|
454
|
+
*/
|
|
455
|
+
private claimTrustPolicy: SeedTrustPolicy | null = null;
|
|
382
456
|
private strandSolicitationService: StrandSolicitationService | null = null;
|
|
383
457
|
/**
|
|
384
458
|
* The service whose formation handler is registered on the control node. Trails
|
|
@@ -397,6 +471,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
397
471
|
* stops conflating control addresses with strand seeding.
|
|
398
472
|
*/
|
|
399
473
|
private strandAddrService: StrandAddrService | null = null;
|
|
474
|
+
/**
|
|
475
|
+
* Cadre invitation redemption handler (`/sereus/cadre-invite/1.0.0`): a device holding an
|
|
476
|
+
* invitation dials this machine and proves possession; the handler seats the row and
|
|
477
|
+
* admits the device against this node's control database. Registered by {@link start}
|
|
478
|
+
* once that database is up, on every node.
|
|
479
|
+
*/
|
|
480
|
+
private cadreInviteHandler: CadreInviteHandler | null = null;
|
|
400
481
|
/**
|
|
401
482
|
* Server-side push-wake fan-out. Constructed by {@link start} only when
|
|
402
483
|
* `config.push` (an injected `PushNotifier` + policy) is present — without it
|
|
@@ -406,6 +487,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
406
487
|
private pushFanoutService: PushFanoutService | null = null;
|
|
407
488
|
/** Backing field for the {@link running} / {@link isRunning} getters. */
|
|
408
489
|
private _running = false;
|
|
490
|
+
/** The {@link stop} in progress, or null; a concurrent `stop()` awaits it. */
|
|
491
|
+
private stopInFlight: Promise<void> | null = null;
|
|
409
492
|
/**
|
|
410
493
|
* The control database's own raw storage (cache-wrapped), resolved once per
|
|
411
494
|
* `start()` and released in {@link cleanup}. Owning it is what keeps the
|
|
@@ -508,26 +591,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
508
591
|
/**
|
|
509
592
|
* Most-recently pushed invite addresses (see {@link setInviteAddresses}).
|
|
510
593
|
* When non-null these take priority over `libp2pNode.getMultiaddrs()` when
|
|
511
|
-
* minting
|
|
512
|
-
* control-network node never needs to dial back to the manager.
|
|
594
|
+
* minting cadre invitations — the host pushes NAT-resolved addresses here so
|
|
595
|
+
* the control-network node never needs to dial back to the manager.
|
|
513
596
|
*/
|
|
514
597
|
private latestInviteAddresses: string[] | null = null;
|
|
515
598
|
|
|
516
|
-
/**
|
|
517
|
-
* Epoch ms until which the control-network inbound gate admits
|
|
518
|
-
* not-yet-authorized peers (see {@link admitInboundControlConnection} /
|
|
519
|
-
* {@link openEnrollmentWindow}). 0 = no window open. Opened automatically by
|
|
520
|
-
* {@link createInvite}; deliberately NOT reset on stop() — a stop/start cycle
|
|
521
|
-
* inside an outstanding invite's validity must not strand the invitee.
|
|
522
|
-
*
|
|
523
|
-
* NOTE: in-memory only, so a PROCESS restart mid-invite closes the door until
|
|
524
|
-
* the owner re-mints or the invitee is authorized. Fine while invites are
|
|
525
|
-
* short-lived and redeemed promptly; if long-lived invites or restart-prone
|
|
526
|
-
* hosts become normal, persist the window (or derive it from the issued-invite
|
|
527
|
-
* records `SeedBootstrapService` already keeps).
|
|
528
|
-
*/
|
|
529
|
-
private enrollmentWindowUntil = 0;
|
|
530
|
-
|
|
531
599
|
/**
|
|
532
600
|
* Lazily-parsed PeerIds of `controlNetwork.bootstrapNodes` (see
|
|
533
601
|
* {@link getBootstrapPeerIds}) — infrastructure the inbound gate always admits.
|
|
@@ -536,13 +604,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
536
604
|
|
|
537
605
|
/**
|
|
538
606
|
* Materialized in-memory snapshot of the AUTHORIZED member peer ids (the
|
|
539
|
-
* {@link listAuthorizedMembers} result), consulted
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
607
|
+
* {@link listAuthorizedMembers} result), consulted through
|
|
608
|
+
* {@link isControlNetworkMember} by the per-stream control-DB gate
|
|
609
|
+
* ({@link authorizeInboundControlStream}) and by Optimystic's cohort choice
|
|
610
|
+
* (`isNetworkMember`). The stream gate runs on EVERY inbound Optimystic
|
|
611
|
+
* control-DB stream and must never await a control-DB read: those reads pull
|
|
612
|
+
* blocks over the very protocols the predicate gates, which closes a circular
|
|
613
|
+
* wait that ends in mutual denial (the upstream gate is fail-closed on
|
|
614
|
+
* timeout, unlike the fail-open connection gater). Optimystic's predicate is
|
|
615
|
+
* synchronous by contract. So the set is refreshed OUT OF BAND instead — after
|
|
546
616
|
* start, on every {@link reconcileControlCohort} pass (15s cadence), and
|
|
547
617
|
* immediately after every LOCAL membership mutation so a just-vouched peer
|
|
548
618
|
* is admitted without waiting for the timer. That last refresh is AUTOMATIC:
|
|
@@ -551,7 +621,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
551
621
|
* writer has to remember. A change that arrives by REPLICATION is picked up
|
|
552
622
|
* on the next timed refresh — bounded staleness, acceptable because this gate
|
|
553
623
|
* is defense in depth: rows an unadmitted peer manages to write are still
|
|
554
|
-
* disbelieved at read time by the voucher-anchored predicate.
|
|
624
|
+
* disbelieved at read time by the voucher-anchored predicate. For cohort
|
|
625
|
+
* choice the same window means a member whose row arrived by replication is
|
|
626
|
+
* left out of this node's control-block cohorts until that refresh.
|
|
627
|
+
* Optimystic reads the predicate live on every lookup, so replacing this set
|
|
628
|
+
* is the whole re-evaluation; there is no per-peer reclassify call to make.
|
|
555
629
|
*/
|
|
556
630
|
private authorizedControlPeers: Set<string> = new Set();
|
|
557
631
|
|
|
@@ -565,6 +639,18 @@ export class CadreNode implements SAppIdLookup {
|
|
|
565
639
|
private membershipGateDrain: Promise<void> | null = null;
|
|
566
640
|
private membershipGateDeferDepth = 0;
|
|
567
641
|
|
|
642
|
+
/**
|
|
643
|
+
* This node's own `CadrePeer` row as a membership refresh last read it live, or null until
|
|
644
|
+
* one has. Reporting a removal needs it ({@link noteOwnRemoval}). Kept across
|
|
645
|
+
* stop()→start(), like {@link removal}.
|
|
646
|
+
*/
|
|
647
|
+
private ownLiveRow: CadrePeerRow | null = null;
|
|
648
|
+
/**
|
|
649
|
+
* The removal this node learned of ({@link removedFromCadre}), or null. Set once and never
|
|
650
|
+
* cleared, so `cadre:removed` fires once per node object.
|
|
651
|
+
*/
|
|
652
|
+
private removal: CadreRemoval | null = null;
|
|
653
|
+
|
|
568
654
|
/**
|
|
569
655
|
* In-memory delegate admission grants (see `delegate-admission.ts`): the
|
|
570
656
|
* strand-node transport peerIds this party's members have announced over the
|
|
@@ -594,10 +680,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
594
680
|
* Does this node's CONTROL libp2p run the circuit-relay server, and with which
|
|
595
681
|
* init? Resolved once from `network` and `profile` by the same function every
|
|
596
682
|
* strand node's build uses (`relay-server.ts`). Read by
|
|
597
|
-
* {@link buildControlNodeOptions} (which configures the server from it)
|
|
598
|
-
*
|
|
599
|
-
* agree with whether a reservation is even servable here), and the
|
|
600
|
-
* unauthorized budget above (whose TTL is the server's).
|
|
683
|
+
* {@link buildControlNodeOptions} (which configures the server from it) and
|
|
684
|
+
* the unauthorized budget above (whose TTL is the server's).
|
|
601
685
|
*/
|
|
602
686
|
private readonly relayServer: ResolvedRelayServer;
|
|
603
687
|
|
|
@@ -709,7 +793,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
709
793
|
|
|
710
794
|
/**
|
|
711
795
|
* Invite keys of the membership invitations this process has staged, by
|
|
712
|
-
* {@link adoptFormationMembershipInvite} or from a `
|
|
796
|
+
* {@link adoptFormationMembershipInvite} or from a `JoinSuccess` row
|
|
713
797
|
* ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
|
|
714
798
|
* staged again by the next pending-join pass. Same lifetime as
|
|
715
799
|
* {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
|
|
@@ -863,9 +947,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
863
947
|
* When this node last PUBLISHED its own `CadrePeer` address record (inserted or
|
|
864
948
|
* refreshed), or null if it never has in this session. Stamped by
|
|
865
949
|
* {@link noteSelfRecordPublished} on every successful publish, wherever it was driven
|
|
866
|
-
* from — the boot pass, the heartbeat, an address change, a drain or an
|
|
950
|
+
* from — the boot pass, the heartbeat, an address change, a drain, a reconcile pass or an
|
|
951
|
+
* explicit call.
|
|
867
952
|
*
|
|
868
|
-
* Read by {@link
|
|
953
|
+
* Read by {@link publishFirstSelfRecord}, which stops trying once it is set, and by
|
|
954
|
+
* {@link escalateIfSelfRecordStale} for the one consequence worth an operator's
|
|
869
955
|
* attention: once this gap exceeds {@link DEFAULT_PEER_RECORD_MAX_AGE_MS}, every other
|
|
870
956
|
* machine in the party is already discarding this node's address as stale.
|
|
871
957
|
*/
|
|
@@ -1108,12 +1194,22 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1108
1194
|
// store fails closed before a node is created.
|
|
1109
1195
|
await this.resolveIdentityKey();
|
|
1110
1196
|
|
|
1197
|
+
// A conflicting `claim` is refused BEFORE the anchor is built: the store below
|
|
1198
|
+
// writes the config pins, and on a persistent anchor a pin written ahead of
|
|
1199
|
+
// this refusal would survive the failed start and mark the node claimed by it.
|
|
1200
|
+
this.validateClaimConfig();
|
|
1201
|
+
|
|
1111
1202
|
// Bring up the node-local trusted-owner anchor (and seed config pins)
|
|
1112
1203
|
// before any network bring-up: a mis-scoped injected store fails closed
|
|
1113
1204
|
// here, and out-of-band pins are anchored before the first seed/peer
|
|
1114
1205
|
// interaction could consult them.
|
|
1115
1206
|
await this.initializeTrustedOwnerStore();
|
|
1116
1207
|
|
|
1208
|
+
// A node started with a claim secret gets its trust policy here, over the anchor
|
|
1209
|
+
// just built; a malformed secret fails closed before any network bring-up, like
|
|
1210
|
+
// a mis-scoped anchor above.
|
|
1211
|
+
this.initializeClaimPolicy();
|
|
1212
|
+
|
|
1117
1213
|
// Bring up the node-local cold-start bootstrap-peer store alongside the
|
|
1118
1214
|
// anchor, and for the same two reasons: a mis-scoped injected store fails
|
|
1119
1215
|
// closed before any network bring-up, and the retained dial targets are
|
|
@@ -1159,6 +1255,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1159
1255
|
this.controlNode = await this.createControlNode();
|
|
1160
1256
|
timing('[start] createControlNode: %dms', Math.round(performance.now() - t0));
|
|
1161
1257
|
log('Control node started with ID: %s', this.controlNode.peerId.toString());
|
|
1258
|
+
// Before anything else can happen on the node: the quiet period above holds every
|
|
1259
|
+
// connection off, so no stream can arrive ahead of the guard.
|
|
1260
|
+
this.installControlProtocolGuard(this.controlNode);
|
|
1162
1261
|
|
|
1163
1262
|
// Extract coordinatedRepo from the node (attached by createLibp2pNode)
|
|
1164
1263
|
const coordinatedRepo = (this.controlNode as Libp2pNodeWithRepo).coordinatedRepo;
|
|
@@ -1234,11 +1333,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1234
1333
|
this.hibernationManager.start();
|
|
1235
1334
|
|
|
1236
1335
|
// Register the control-network push-wake receiver: a same-cadre peer can
|
|
1237
|
-
// signal us to bring a hibernating strand online.
|
|
1238
|
-
//
|
|
1239
|
-
// path as a local wake.
|
|
1336
|
+
// signal us to bring a hibernating strand online. The protocol guard admits
|
|
1337
|
+
// only AUTHORIZED members (not the addressable surface); the wake routes
|
|
1338
|
+
// through the same path as a local wake.
|
|
1240
1339
|
this.strandWakeService = new StrandWakeService({
|
|
1241
|
-
isMember: (peerId) => this.isAuthorizedMember(peerId),
|
|
1242
1340
|
getStrand: (strandId) => this.strandManager.getInstance(strandId),
|
|
1243
1341
|
wake: (strandId) => this.wakeStrand(strandId),
|
|
1244
1342
|
});
|
|
@@ -1246,14 +1344,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1246
1344
|
|
|
1247
1345
|
// Register the control-network strand-address responder: a same-cadre peer
|
|
1248
1346
|
// resolving a strand's bootstrap seed asks us for our live strand-network
|
|
1249
|
-
// multiaddrs (its CadrePeer row only knows our *control* address).
|
|
1250
|
-
// AUTHORIZED
|
|
1251
|
-
// The same RPC doubles as the delegate-announce channel:
|
|
1252
|
-
// may carry the derived peerId its strand node runs as, and
|
|
1253
|
-
// admission grant so our connection gate (and thus our relay
|
|
1254
|
-
// enabled) admits it — see delegate-admission.ts.
|
|
1347
|
+
// multiaddrs (its CadrePeer row only knows our *control* address). The
|
|
1348
|
+
// protocol guard admits only AUTHORIZED members; answers only for strands we
|
|
1349
|
+
// are actively meshing. The same RPC doubles as the delegate-announce channel:
|
|
1350
|
+
// a member's request may carry the derived peerId its strand node runs as, and
|
|
1351
|
+
// we record an admission grant so our connection gate (and thus our relay
|
|
1352
|
+
// server, when enabled) admits it — see delegate-admission.ts.
|
|
1255
1353
|
this.strandAddrService = new StrandAddrService({
|
|
1256
|
-
isMember: (peerId) => this.isAuthorizedMember(peerId),
|
|
1257
1354
|
getStrandMultiaddrs: (strandId) => this.getStrandMultiaddrs(strandId),
|
|
1258
1355
|
onDelegateAnnounce: (announcer, strandId, delegate) =>
|
|
1259
1356
|
this.grantDelegateAdmission(announcer, strandId, delegate),
|
|
@@ -1266,6 +1363,24 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1266
1363
|
// so the responder never reads a database that cannot answer yet.
|
|
1267
1364
|
await this.installDefaultFormationResponder();
|
|
1268
1365
|
|
|
1366
|
+
// Answer cadre invitation redemptions from now on: a device holding an invitation
|
|
1367
|
+
// dials any member machine, this one included, and proves possession in-protocol.
|
|
1368
|
+
// After the control database is up, since the handler seats and redeems against it.
|
|
1369
|
+
this.cadreInviteHandler = new CadreInviteHandler({
|
|
1370
|
+
partyId: this.config.controlNetwork.partyId,
|
|
1371
|
+
store: this.controlDatabase,
|
|
1372
|
+
admitDevice: (peerId) => this.admitRedeemingDevice(peerId),
|
|
1373
|
+
});
|
|
1374
|
+
await this.cadreInviteHandler.register(this.controlNode);
|
|
1375
|
+
|
|
1376
|
+
// A node waiting to be claimed listens for its claim seed from the start: nobody
|
|
1377
|
+
// else will turn the listener on, and a node no seed can reach cannot be claimed.
|
|
1378
|
+
// Before `control:connected`, so an embedder that reports "awaiting claim" on that
|
|
1379
|
+
// event is already reachable on `/sereus/seed/1.0.0`.
|
|
1380
|
+
if (this.config.claim) {
|
|
1381
|
+
await this.enableSeedListener();
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1269
1384
|
// Server-side push-wake fan-out: only when push is configured.
|
|
1270
1385
|
if (this.config.push) {
|
|
1271
1386
|
this.pushFanoutService = this.buildPushFanout(this.config.push);
|
|
@@ -1354,11 +1469,26 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1354
1469
|
* Stop the cadre node
|
|
1355
1470
|
*/
|
|
1356
1471
|
async stop(): Promise<void> {
|
|
1472
|
+
// A second call while one is in flight joins it rather than running cleanup() twice
|
|
1473
|
+
// concurrently: `_running` stays true until cleanup() ends, and an event raised by a
|
|
1474
|
+
// refresh still in flight (`cadre:removed`, whose embedder stops the node) can reach a
|
|
1475
|
+
// handler that calls stop() mid-teardown.
|
|
1476
|
+
if (this.stopInFlight) {
|
|
1477
|
+
return this.stopInFlight;
|
|
1478
|
+
}
|
|
1357
1479
|
if (!this._running) {
|
|
1358
1480
|
log('CadreNode not running');
|
|
1359
1481
|
return;
|
|
1360
1482
|
}
|
|
1483
|
+
this.stopInFlight = this.stopRunning();
|
|
1484
|
+
try {
|
|
1485
|
+
await this.stopInFlight;
|
|
1486
|
+
} finally {
|
|
1487
|
+
this.stopInFlight = null;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1361
1490
|
|
|
1491
|
+
private async stopRunning(): Promise<void> {
|
|
1362
1492
|
log('Stopping CadreNode');
|
|
1363
1493
|
await this.cleanup();
|
|
1364
1494
|
this.emit('control:disconnected', undefined);
|
|
@@ -1446,15 +1576,22 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1446
1576
|
|
|
1447
1577
|
/**
|
|
1448
1578
|
* Construct (or adopt) the node-local trusted-owner anchor and seed the
|
|
1449
|
-
* out-of-band pinned keys from `config.trustedOwners`. The
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1579
|
+
* out-of-band pinned keys from `config.trustedOwners`. The anchor's BASE comes
|
|
1580
|
+
* only from out of band — genesis self-trust ({@link initializeSeedBootstrap}),
|
|
1581
|
+
* config pins (here), runtime enrollment pins ({@link trustOwnerKeys}) and a
|
|
1582
|
+
* claim; everything else in it is derived from that base along verifiable
|
|
1583
|
+
* chains through the replicated `OwnerKey` table ({@link syncOwnerAnchor}),
|
|
1584
|
+
* never read from the table directly.
|
|
1453
1585
|
*
|
|
1454
1586
|
* Idempotent across stop()→start(): the store instance is kept, and
|
|
1455
1587
|
* re-seeding config pins is a no-op ({@link TrustedOwnerStore.trust} is
|
|
1456
1588
|
* idempotent). An injected store scoped to a different party is a
|
|
1457
1589
|
* configuration error (fail closed before any network bring-up).
|
|
1590
|
+
*
|
|
1591
|
+
* NOTE: a config pin of a key the owners have since REMOVED is re-anchored here at
|
|
1592
|
+
* every start and removed again by the first sync (`refreshMembershipGate('start')`,
|
|
1593
|
+
* fired without await from {@link start}), so such a pin is honoured for at most one
|
|
1594
|
+
* membership refresh per start; dropping the pin from the config ends that.
|
|
1458
1595
|
*/
|
|
1459
1596
|
private async initializeTrustedOwnerStore(): Promise<void> {
|
|
1460
1597
|
const { trustedOwners } = this.config;
|
|
@@ -1478,6 +1615,93 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1478
1615
|
}
|
|
1479
1616
|
}
|
|
1480
1617
|
|
|
1618
|
+
/**
|
|
1619
|
+
* Refuse a `config.claim` that sits beside another cold-start trust source. A node
|
|
1620
|
+
* has exactly ONE, so `claim` with a non-empty `trustedOwners.pinnedKeys` or a
|
|
1621
|
+
* `seedTrustPolicy` is refused here rather than silently letting one of them win.
|
|
1622
|
+
* Asserted at this seam, not by a type: the embedders build `CadreNodeConfig` as one
|
|
1623
|
+
* object literal with every optional field present, which a discriminated union over
|
|
1624
|
+
* the whole config would not express.
|
|
1625
|
+
*
|
|
1626
|
+
* Runs BEFORE {@link initializeTrustedOwnerStore}, which writes the config pins into
|
|
1627
|
+
* the anchor: on a persistent anchor a pin written ahead of this refusal would outlive
|
|
1628
|
+
* the failed start, and the node, restarted with the pin dropped from its config and
|
|
1629
|
+
* `claim` kept, would read as claimed by that pin and never accept its claimant.
|
|
1630
|
+
*/
|
|
1631
|
+
private validateClaimConfig(): void {
|
|
1632
|
+
if (!this.config.claim) {
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
if ((this.config.trustedOwners?.pinnedKeys?.length ?? 0) > 0) {
|
|
1636
|
+
throw new Error(
|
|
1637
|
+
'CadreNodeConfig: `claim` and `trustedOwners.pinnedKeys` are both set — a node has one ' +
|
|
1638
|
+
'cold-start trust source; a node waiting to be claimed anchors its claimant, not a pin'
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
if (this.config.seedTrustPolicy) {
|
|
1642
|
+
throw new Error(
|
|
1643
|
+
'CadreNodeConfig: `claim` and `seedTrustPolicy` are both set — a node has one cold-start ' +
|
|
1644
|
+
'trust source; the claim secret supplies the seed trust policy itself'
|
|
1645
|
+
);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Build {@link claimTrustPolicy} from `config.claim`, once per node (a restart keeps
|
|
1651
|
+
* the policy, as {@link initializeTrustedOwnerStore} keeps the anchor it writes).
|
|
1652
|
+
* Runs after the anchor exists and before any network bring-up, so a malformed secret
|
|
1653
|
+
* fails `start()` closed with nothing to tear down; `parseClaimSecret` names the
|
|
1654
|
+
* problem and never echoes the value. The conflicts with the other trust sources are
|
|
1655
|
+
* {@link validateClaimConfig}'s, checked before the anchor is written.
|
|
1656
|
+
*/
|
|
1657
|
+
private initializeClaimPolicy(): void {
|
|
1658
|
+
const { claim } = this.config;
|
|
1659
|
+
if (!claim || this.claimTrustPolicy) {
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
// Unreachable in practice: start() builds the store just above. Guarded so the
|
|
1663
|
+
// policy can never be built over a missing anchor and silently claim into nothing.
|
|
1664
|
+
if (!this.trustedOwnerStore) {
|
|
1665
|
+
throw new Error('CadreNode: the trusted-owner anchor must exist before the claim policy is built');
|
|
1666
|
+
}
|
|
1667
|
+
this.claimTrustPolicy = claimSecretTrustPolicy({
|
|
1668
|
+
secret: parseClaimSecret(claim.secret),
|
|
1669
|
+
trustedOwners: this.trustedOwnerStore,
|
|
1670
|
+
recordClaim: claim.record,
|
|
1671
|
+
onClaimed: (accepted) => this.emit('claim:accepted', accepted),
|
|
1672
|
+
});
|
|
1673
|
+
log('Node starts awaiting a claim; start() enables the seed listener once the control node is up');
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* Is this node waiting to be claimed: started with `config.claim` and owned by nobody
|
|
1678
|
+
* yet (empty anchor)? Read live from the anchor every time, as
|
|
1679
|
+
* {@link admitControlPeerUnconditionally} reads it per stream: the anchor is the one
|
|
1680
|
+
* source of truth for "claimed", and a cached flag would need its own invalidation
|
|
1681
|
+
* from the policy's persist, the rollback and a restart. False on a node with no claim
|
|
1682
|
+
* secret, on a claimed one (restarted with the secret still set or not), and before
|
|
1683
|
+
* {@link start} has built the anchor.
|
|
1684
|
+
*
|
|
1685
|
+
* While true the node admits strangers' CONNECTIONS (the claim seed has to ride one)
|
|
1686
|
+
* and refuses them control-database streams and cohort places ({@link isControlNetworkMember})
|
|
1687
|
+
* and relay reservations ({@link admitControlRelayReservation}): an unclaimed node has
|
|
1688
|
+
* no siblings to replicate from and must not become a public relay while it waits.
|
|
1689
|
+
* cadre-cli reports this on `/status`; `claim:accepted` is the event form.
|
|
1690
|
+
*/
|
|
1691
|
+
isAwaitingClaim(): boolean {
|
|
1692
|
+
return this.config.claim !== undefined && this.trustedOwnerStore?.all().size === 0;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* The removal from the cadre this node has learned of, or null: the query form of
|
|
1697
|
+
* `cadre:removed`, for an embedder that subscribed after it fired. Kept once set, across
|
|
1698
|
+
* stop()→start() and a later re-admission, because the embedder acts on it once (stops this
|
|
1699
|
+
* node, deletes the cadre's data); a re-added machine joins again from a fresh node.
|
|
1700
|
+
*/
|
|
1701
|
+
removedFromCadre(): CadreRemoval | null {
|
|
1702
|
+
return this.removal;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1481
1705
|
/**
|
|
1482
1706
|
* Construct (or adopt) the node-local cold-start bootstrap-peer store (see
|
|
1483
1707
|
* {@link bootstrapPeerStore}). Synchronous: an injected store has already
|
|
@@ -1617,6 +1841,41 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1617
1841
|
return this.strandNetworkStateStore;
|
|
1618
1842
|
}
|
|
1619
1843
|
|
|
1844
|
+
/**
|
|
1845
|
+
* The strands this machine joined from another party and has not published party-wide
|
|
1846
|
+
* (see {@link CadreNodeConfig.joinedStrands}). Publication needs a control connection,
|
|
1847
|
+
* so a one-node party's joins live here until it holds one; `exportStrandBundle` reads
|
|
1848
|
+
* it beside the `JoinedStrand` table.
|
|
1849
|
+
*
|
|
1850
|
+
* @throws when the node is not started.
|
|
1851
|
+
*/
|
|
1852
|
+
listUnpublishedJoinedStrands(): Promise<JoinedStrandRecord[]> {
|
|
1853
|
+
if (!this.joinedStrands) {
|
|
1854
|
+
throw new Error('CadreNode must be started before listing its unpublished joined strands');
|
|
1855
|
+
}
|
|
1856
|
+
return this.joinedStrands.unpublishedRecords();
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* The strand-network addresses a formation carried for `strandId` (see
|
|
1861
|
+
* {@link formationStrandAddrs}), as kept: attributed per peer. Empty when none were
|
|
1862
|
+
* carried or the strand's peers were forgotten. A copy; the node keeps its own list.
|
|
1863
|
+
*/
|
|
1864
|
+
getCarriedStrandAddrs(strandId: string): string[] {
|
|
1865
|
+
return [...(this.formationStrandAddrs.get(strandId) ?? [])];
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Seat `addrs` as the addresses a formation carried for `strandId`, exactly as a
|
|
1870
|
+
* formation of this node's own would ({@link recordFormationStrandAddrs}): a strand
|
|
1871
|
+
* move seats the exporting node's list here so the first launch has a cross-party
|
|
1872
|
+
* seed. The usual rules apply: a list naming a peer replaces the strand's earlier
|
|
1873
|
+
* one, and a list naming none records nothing.
|
|
1874
|
+
*/
|
|
1875
|
+
recordCarriedStrandAddrs(strandId: string, addrs: readonly string[]): Promise<void> {
|
|
1876
|
+
return this.recordFormationStrandAddrs(strandId, addrs, 'recordCarriedStrandAddrs');
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1620
1879
|
/**
|
|
1621
1880
|
* The node-local trusted-owner anchor (null before {@link start}). This is
|
|
1622
1881
|
* the set the authorized-membership predicate and seed-trust anchor consult —
|
|
@@ -1628,22 +1887,23 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1628
1887
|
|
|
1629
1888
|
/**
|
|
1630
1889
|
* Persist out-of-band-established owner keys into the node-local anchor —
|
|
1631
|
-
* the runtime enrollment seam:
|
|
1632
|
-
*
|
|
1633
|
-
* holds the pins when
|
|
1634
|
-
* pin. Idempotent. ('genesis'
|
|
1635
|
-
* founding key, seeded internally by
|
|
1890
|
+
* the runtime enrollment seam: {@link redeemCadreInvitation} calls it with the
|
|
1891
|
+
* cadre invitation's `ownerKeys` BEFORE dialing a member (so the anchor already
|
|
1892
|
+
* holds the pins when the reply and the rows that follow are judged), and an
|
|
1893
|
+
* embedder calls it with an operator-supplied pin. Idempotent. ('genesis'
|
|
1894
|
+
* provenance is reserved for the node's own founding key, seeded internally by
|
|
1895
|
+
* {@link initializeSeedBootstrap}; 'chain' is written only by {@link syncOwnerAnchor}.)
|
|
1636
1896
|
*
|
|
1637
1897
|
* Validates every key's shape before trusting any of them (all-or-nothing):
|
|
1638
1898
|
* a malformed entry anywhere in `keys` rejects the whole call before a
|
|
1639
|
-
* single key is anchored. For the
|
|
1899
|
+
* single key is anchored. For the invitation route this means a bundle
|
|
1640
1900
|
* carrying one malformed `ownerKeys` entry fails the redemption outright —
|
|
1641
1901
|
* consistent with the anchor's existing whole-record-or-nothing policy for
|
|
1642
1902
|
* a corrupt persisted entry (see `trusted-owner-store.ts`'s
|
|
1643
1903
|
* `unusableEntry: 'discard-all'`) — rather than silently anchoring a subset
|
|
1644
1904
|
* and leaving the caller to notice a key went missing.
|
|
1645
1905
|
*/
|
|
1646
|
-
async trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis'>): Promise<void> {
|
|
1906
|
+
async trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis' | 'chain'>): Promise<void> {
|
|
1647
1907
|
if (!this.trustedOwnerStore) {
|
|
1648
1908
|
throw new Error('CadreNode must be started before trusting owner keys');
|
|
1649
1909
|
}
|
|
@@ -1658,19 +1918,24 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1658
1918
|
}
|
|
1659
1919
|
|
|
1660
1920
|
/**
|
|
1661
|
-
*
|
|
1662
|
-
*
|
|
1663
|
-
*
|
|
1664
|
-
*
|
|
1665
|
-
*
|
|
1666
|
-
* NOTE: the party id goes in UNENCODED here, unlike in `controlStorageScope`. Safe
|
|
1667
|
-
* today because a party id is locally configured rather than replicated in, and both
|
|
1668
|
-
* ends of a connection derive this string identically — an odd party id yields an odd
|
|
1669
|
-
* but consistent protocol id, not a mismatch or an escaped name. If a party id ever
|
|
1670
|
-
* arrives from the network, encode it here as the storage scope key already does.
|
|
1921
|
+
* Refuse non-members on every members-only protocol of the control node, at one seam
|
|
1922
|
+
* (`control-protocol-guard.ts`). Wake and strand-addr are judged by the live
|
|
1923
|
+
* {@link isAuthorizedMember}, bounded by the asker's own attempt deadline at this
|
|
1924
|
+
* node's declared link; everything a control-database read depends on is judged by
|
|
1925
|
+
* the snapshot ({@link authorizeInboundControlStream}).
|
|
1671
1926
|
*/
|
|
1927
|
+
private installControlProtocolGuard(controlNode: Libp2p): void {
|
|
1928
|
+
installControlProtocolGuard(controlNode, {
|
|
1929
|
+
partyId: this.config.controlNetwork.partyId,
|
|
1930
|
+
isMemberLive: (peerId) => this.isAuthorizedMember(peerId),
|
|
1931
|
+
isMemberSnapshot: (peerId, protocol) => this.authorizeInboundControlStream(peerId, protocol),
|
|
1932
|
+
liveDecisionTimeoutMs: relayedRequestBudgetMs(this.config.network?.linkRoundTripMs),
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/** This node's control network name ({@link controlNetworkName}, the one binding for it). */
|
|
1672
1937
|
private controlNetworkName(): string {
|
|
1673
|
-
return
|
|
1938
|
+
return controlNetworkName(this.config.controlNetwork.partyId);
|
|
1674
1939
|
}
|
|
1675
1940
|
|
|
1676
1941
|
/**
|
|
@@ -1698,6 +1963,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1698
1963
|
* reconnect re-runs identify), and — because the production join order is
|
|
1699
1964
|
* connect-then-authorize — on every committed membership change via
|
|
1700
1965
|
* {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
|
|
1966
|
+
* The one non-member the gate admits is a device whose invitation
|
|
1967
|
+
* redemption this member is admitting ({@link admitRedeemingDevice}).
|
|
1701
1968
|
*
|
|
1702
1969
|
* No-ops (logged) when the embedder configured no control storage or the
|
|
1703
1970
|
* node exposes no key network — the backfill would have nothing to read or
|
|
@@ -1728,7 +1995,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1728
1995
|
// under — derived from the same networkName binding the node options
|
|
1729
1996
|
// used, never re-spelled here.
|
|
1730
1997
|
protocolPrefix: `/optimystic/${networkName}`,
|
|
1731
|
-
authorizePeer: (peerId) => this.isAuthorizedMember(peerId)
|
|
1998
|
+
authorizePeer: async (peerId) => this.admittingDevices.has(peerId) || await this.isAuthorizedMember(peerId)
|
|
1732
1999
|
}, {
|
|
1733
2000
|
// A shorter settle window than the strand default (1000 ms): the control
|
|
1734
2001
|
// store is small (a party's membership — dozens of blocks), a re-push is
|
|
@@ -1755,6 +2022,60 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1755
2022
|
this.controlBackfill.start();
|
|
1756
2023
|
}
|
|
1757
2024
|
|
|
2025
|
+
/**
|
|
2026
|
+
* Admit a device whose invitation redemption has verified to this member's control
|
|
2027
|
+
* replication until the returned release is called (the handler's `admitDevice`;
|
|
2028
|
+
* `CadreInviteHandler.admitDeviceIfLive` says why the redemption's writes need it): push it
|
|
2029
|
+
* this member's control store and wait for the push, with the device counted as a member by
|
|
2030
|
+
* the backfill's gate and by the control cohort choice ({@link admittingDevices}), so the
|
|
2031
|
+
* seat and the admission that follow are written to the device too. A failed push releases
|
|
2032
|
+
* at once and rethrows: in the cohort without the store, the device would refuse the
|
|
2033
|
+
* commits and tear the writes. With no backfill on this node nothing is admitted.
|
|
2034
|
+
*
|
|
2035
|
+
* Through {@link PeerJoinBackfill.forceCatchUpPeer}, not `catchUpPeer`: the device's own
|
|
2036
|
+
* `peer:identify` scheduled a run a moment earlier, which was denied if it passed the gate
|
|
2037
|
+
* before the device was added here, and `catchUpPeer` would answer that run's in-flight
|
|
2038
|
+
* status with an empty result.
|
|
2039
|
+
*/
|
|
2040
|
+
private async admitRedeemingDevice(peerId: string): Promise<() => void> {
|
|
2041
|
+
const backfill = this.controlBackfill;
|
|
2042
|
+
if (!backfill) {
|
|
2043
|
+
log('No control backfill on this node; device %s is neither caught up nor admitted before its admission', peerId);
|
|
2044
|
+
return () => undefined;
|
|
2045
|
+
}
|
|
2046
|
+
// NOTE: counted in before the push, because the backfill's gate must admit the device to
|
|
2047
|
+
// push to it; so for the push's length any other control write this member makes includes
|
|
2048
|
+
// a device without the base revisions, which refuses it and the write retries. If that
|
|
2049
|
+
// shows up as retried control writes during redemptions, count the device into the cohort
|
|
2050
|
+
// choice only once the push resolves (a second set beside this one).
|
|
2051
|
+
const release = this.countAdmittingDevice(peerId);
|
|
2052
|
+
try {
|
|
2053
|
+
const result = await backfill.forceCatchUpPeer(peerIdFromString(peerId));
|
|
2054
|
+
log('Device %s caught up before its admission: offered=%d accepted=%d rejected=%d',
|
|
2055
|
+
peerId, result.offered, result.accepted, result.rejected.length);
|
|
2056
|
+
return release;
|
|
2057
|
+
} catch (error) {
|
|
2058
|
+
release();
|
|
2059
|
+
throw error;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/** Count one more redemption admitting `peerId`; the returned release undoes it, once. */
|
|
2064
|
+
private countAdmittingDevice(peerId: string): () => void {
|
|
2065
|
+
this.admittingDevices.set(peerId, (this.admittingDevices.get(peerId) ?? 0) + 1);
|
|
2066
|
+
let released = false;
|
|
2067
|
+
return () => {
|
|
2068
|
+
if (released) return;
|
|
2069
|
+
released = true;
|
|
2070
|
+
const remaining = (this.admittingDevices.get(peerId) ?? 1) - 1;
|
|
2071
|
+
if (remaining > 0) {
|
|
2072
|
+
this.admittingDevices.set(peerId, remaining);
|
|
2073
|
+
} else {
|
|
2074
|
+
this.admittingDevices.delete(peerId);
|
|
2075
|
+
}
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
|
|
1758
2079
|
/**
|
|
1759
2080
|
* The control database's cache-wrapped raw storage, resolved from
|
|
1760
2081
|
* `config.storage.provider` on first use and then held in {@link controlStorage}
|
|
@@ -1903,7 +2224,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1903
2224
|
}),
|
|
1904
2225
|
arachnode: { enableRingZulu: profile === 'storage' },
|
|
1905
2226
|
...(identityKey && { privateKey: identityKey }),
|
|
1906
|
-
|
|
2227
|
+
// With `network.relayAddrs` set, built with libp2p's relay discovery off (given a
|
|
2228
|
+
// transport list to wrap — `node-transports.ts`), so the supervisor
|
|
2229
|
+
// `driveControlRelayReservation` starts is the only thing that fills the search slot:
|
|
2230
|
+
// otherwise a member's control node running the relay server, which this node
|
|
2231
|
+
// identifies (same party namespace), can take it first (`relay-reservation.ts`,
|
|
2232
|
+
// "WHY LIBP2P'S RELAY DISCOVERY IS SWITCHED OFF").
|
|
2233
|
+
...nodeTransportsOption(network, transportOptions, (network?.relayAddrs ?? []).length > 0),
|
|
1907
2234
|
...(network?.noiseCrypto && { noiseCrypto: network.noiseCrypto }),
|
|
1908
2235
|
// Always present, unlike the conditional spreads around it: an absent
|
|
1909
2236
|
// `connectionMonitor` would leave libp2p's 5s ping deadline, which drops a peer
|
|
@@ -1922,7 +2249,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1922
2249
|
...optimysticDialLimits(network?.linkRoundTripMs),
|
|
1923
2250
|
// `{ wsPort }` when a listen entry names WebSocket, otherwise `{}` — and always
|
|
1924
2251
|
// `{}` when `network.transports` is set, since the embedder owns transport policy
|
|
1925
|
-
// then. Spread NEXT to `transports` because the two answer the same question
|
|
2252
|
+
// then. Spread NEXT to `transports` because the two answer the same question, and a
|
|
2253
|
+
// no-op whenever a list is passed there — with `relayAddrs` set that list comes from
|
|
2254
|
+
// `network.defaultTransports` when the embedder gave none, on the same switch.
|
|
1926
2255
|
...transportOptions,
|
|
1927
2256
|
// Configured `listenAddrs`, plus the bare `/p2p-circuit` search listener when
|
|
1928
2257
|
// `network.relayAddrs` is set — that listener registers the pending reservation
|
|
@@ -1933,9 +2262,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1933
2262
|
// `announceAddrs` replaces the advertised set (see warnIfAnnounceAddrsDiscardRelay).
|
|
1934
2263
|
...resolveAnnounceAddrs(network),
|
|
1935
2264
|
// The CONTROL node composes the membership admission gate onto any
|
|
1936
|
-
// caller-supplied gater (deny from
|
|
1937
|
-
//
|
|
1938
|
-
//
|
|
2265
|
+
// caller-supplied gater (a deny from the caller's wins on every composed
|
|
2266
|
+
// hook; the membership policy refuses reservations, never connections,
|
|
2267
|
+
// and puts an unplaced peer's connection on the provisional deadline,
|
|
2268
|
+
// derived from THIS node's declared link because this node decides).
|
|
2269
|
+
// Strand cohort nodes keep the raw configured gater (see
|
|
1939
2270
|
// strand-instance-manager.ts) — their peers are legitimately
|
|
1940
2271
|
// cross-party, so cadre membership must not gate them.
|
|
1941
2272
|
connectionGater: createMembershipConnectionGater(
|
|
@@ -1946,14 +2277,24 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1946
2277
|
},
|
|
1947
2278
|
network?.connectionGater,
|
|
1948
2279
|
ADMISSION_DECISION_TIMEOUT_MS,
|
|
1949
|
-
|
|
2280
|
+
relayedRequestBudgetMs(network?.linkRoundTripMs)
|
|
1950
2281
|
),
|
|
1951
2282
|
// Fail-closed per-stream authorization for the four Optimystic control-DB
|
|
1952
2283
|
// protocols — the members-only layer the connection gater's stranger
|
|
1953
2284
|
// carve-outs cannot express (see authorizeInboundControlStream). Control
|
|
1954
|
-
// node only: strand cohort nodes serve cross-party peers.
|
|
2285
|
+
// node only: strand cohort nodes serve cross-party peers. The protocol guard
|
|
2286
|
+
// (installControlProtocolGuard) checks these four with this same predicate
|
|
2287
|
+
// first; Optimystic's own check is kept because it is a working, supported
|
|
2288
|
+
// upstream defence, not a second policy.
|
|
1955
2289
|
authorizeInboundStream: (remotePeerId, protocol) =>
|
|
1956
|
-
this.authorizeInboundControlStream(remotePeerId, protocol)
|
|
2290
|
+
this.authorizeInboundControlStream(remotePeerId, protocol),
|
|
2291
|
+
// Who this node SENDS control writes to (cohort and coordinator choice), where
|
|
2292
|
+
// `authorizeInboundStream` is who may send to it. Without it Optimystic picks cohort
|
|
2293
|
+
// members from the protocols a peer's peer-store record lists, which any connected
|
|
2294
|
+
// peer can fill. The stream gate's rule, unlogged (Optimystic asks several times per
|
|
2295
|
+
// peer per cohort assembly and counts refusals on its own debug line), plus a device
|
|
2296
|
+
// whose verified redemption is being written, which must hold the rows that admit it.
|
|
2297
|
+
isNetworkMember: (peerId) => this.isControlNetworkMember(peerId) || this.admittingDevices.has(peerId)
|
|
1957
2298
|
};
|
|
1958
2299
|
|
|
1959
2300
|
return nodeOptions;
|
|
@@ -1975,86 +2316,85 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1975
2316
|
}
|
|
1976
2317
|
|
|
1977
2318
|
/**
|
|
1978
|
-
* Decide
|
|
1979
|
-
*
|
|
1980
|
-
* {@link createControlNode}.
|
|
1981
|
-
*
|
|
1982
|
-
* and defers to the fail-closed per-stream gates (see
|
|
2319
|
+
* Decide how an inbound CONTROL-network connection from `remotePeerId` is
|
|
2320
|
+
* admitted — the policy behind the connection gater wired in
|
|
2321
|
+
* {@link createControlNode}. Every connection is admitted; the answer decides
|
|
2322
|
+
* only whether it carries the provisional deadline (see
|
|
1983
2323
|
* `membership-connection-gater.ts` for the layer's rationale and the
|
|
1984
|
-
* stranger-open protocol allowlist).
|
|
2324
|
+
* stranger-open protocol allowlist). Asked once when the connection arrives
|
|
2325
|
+
* and again at a provisional connection's deadline.
|
|
1985
2326
|
*
|
|
1986
|
-
* Returns `'admit'` when ANY of:
|
|
2327
|
+
* Returns `'admit'` (no deadline) when ANY of:
|
|
1987
2328
|
* 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
|
|
1988
2329
|
* — not running / DB torn down, absent-or-empty trusted-owner anchor, or
|
|
1989
2330
|
* configured bootstrap/relay infrastructure);
|
|
1990
|
-
* 2.
|
|
1991
|
-
* {@link createInvite}) — the invitee dials in before it is authorized;
|
|
1992
|
-
* 3. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
|
|
2331
|
+
* 2. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
|
|
1993
2332
|
* announced it (over the strand-addr RPC) as the transport peerId of its
|
|
1994
2333
|
* own strand node, so a NAT'd member's strand node can hold a
|
|
1995
2334
|
* circuit-relay reservation here (see `delegate-admission.ts`).
|
|
1996
2335
|
* Connection only: the per-stream gate below never honors a grant;
|
|
1997
|
-
*
|
|
2336
|
+
* 3. the authorized-member set is empty — cold start: the rows that would
|
|
1998
2337
|
* authorize anyone arrive by replication over these very connections;
|
|
1999
|
-
*
|
|
2000
|
-
*
|
|
2338
|
+
* 4. the peer IS an authorized member; or
|
|
2339
|
+
* 5. an open invitation is OUTSTANDING — at least one unexpired,
|
|
2001
2340
|
* not-fully-consumed invitation this node minted or persisted (see
|
|
2002
2341
|
* `StrandSolicitationService.hasOutstandingInvitation`). A formation
|
|
2003
2342
|
* initiator is another party's peer by design and its token is only
|
|
2004
2343
|
* checkable inside the protocol, so the gate asks the coarser question
|
|
2005
2344
|
* "does this node expect a stranger at all?". REGISTERING the responder
|
|
2006
|
-
* does not
|
|
2007
|
-
*
|
|
2345
|
+
* does not count: every node registers one at {@link start}, and
|
|
2346
|
+
* registering mints no invitation; or
|
|
2347
|
+
* 6. a LIVE cadre invitation exists — a `CadreInvite` row this node holds
|
|
2348
|
+
* that is not withdrawn, unexpired, has uses left and whose issuer is
|
|
2349
|
+
* still an owner (`ControlDatabase.hasLiveCadreInvite`). The device that
|
|
2350
|
+
* redeems it is a stranger until the redemption writes its row, and its
|
|
2351
|
+
* proof of possession is only checkable inside `/sereus/cadre-invite/1.0.0`;
|
|
2352
|
+
* same reasoning as check 5, and the same expectation-of-a-stranger key.
|
|
2008
2353
|
*
|
|
2009
2354
|
* Ordering is semantically free (the checks are OR'd) but decides who pays:
|
|
2010
|
-
* checks 1-
|
|
2011
|
-
*
|
|
2355
|
+
* checks 1-2 are in-memory, 3/4 share one control-DB read, and only a peer
|
|
2356
|
+
* that is otherwise headed for a provisional admission reaches check 5's and
|
|
2357
|
+
* 6's invitation lookups.
|
|
2012
2358
|
*
|
|
2013
|
-
* Caveats of check
|
|
2359
|
+
* Caveats of check 5:
|
|
2014
2360
|
* - the in-memory mint registry dies with the process, so after a restart
|
|
2015
2361
|
* only invitations persisted as `FormationInvite` rows still hold the
|
|
2016
|
-
*
|
|
2362
|
+
* window open (re-mint otherwise);
|
|
2017
2363
|
* - a peer holding a token whose `FormationInvite` row has not replicated to
|
|
2018
|
-
* this node yet is
|
|
2019
|
-
*
|
|
2020
|
-
*
|
|
2021
|
-
* When every check falls through
|
|
2022
|
-
*
|
|
2023
|
-
*
|
|
2024
|
-
*
|
|
2025
|
-
*
|
|
2026
|
-
*
|
|
2027
|
-
*
|
|
2028
|
-
*
|
|
2029
|
-
*
|
|
2030
|
-
* {@link admitControlRelayReservation}
|
|
2031
|
-
*
|
|
2032
|
-
*
|
|
2033
|
-
*
|
|
2034
|
-
* NOTE: check 4/5 runs a control-DB read per inbound connection
|
|
2364
|
+
* this node yet is admitted only provisionally, so its connection is closed
|
|
2365
|
+
* at the deadline unless the row has landed by then.
|
|
2366
|
+
*
|
|
2367
|
+
* When every check falls through: `'admit-provisionally'`, on every node,
|
|
2368
|
+
* relay server or not. The gate sees only a peer id, so it cannot tell an
|
|
2369
|
+
* outsider from a device whose invitation row this node has not received yet
|
|
2370
|
+
* (it redeems here by seating the row from its bundle, after which check 6
|
|
2371
|
+
* admits) or from a sibling whose membership row is still replicating (check 4
|
|
2372
|
+
* admits once it lands; if it has not landed by the deadline, the connection
|
|
2373
|
+
* is closed and either side's next reconcile dial re-forms it, since outbound
|
|
2374
|
+
* dials are never gated). The protocol guard refuses all of them every
|
|
2375
|
+
* members-only protocol meanwhile. A circuit-relay reservation admitted by
|
|
2376
|
+
* {@link admitControlRelayReservation} disarms the deadline (see
|
|
2377
|
+
* `membership-connection-gater.ts` → "The relay-reservation seam").
|
|
2378
|
+
*
|
|
2379
|
+
* NOTE: check 3/4 runs a control-DB read per inbound connection
|
|
2035
2380
|
* (`listAuthorizedMembers`); connections are rare and cadres small, and this
|
|
2036
2381
|
* layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
|
|
2037
2382
|
* read is safe here — unlike the per-stream gate, which must consult the
|
|
2038
2383
|
* materialized {@link authorizedControlPeers} snapshot instead.
|
|
2039
|
-
* NOTE:
|
|
2040
|
-
* stranger with no locally minted invitation in play, on
|
|
2041
|
-
* node
|
|
2042
|
-
*
|
|
2043
|
-
* the
|
|
2044
|
-
* NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
|
|
2045
|
-
* replicated here is denied until the row converges (typically via the
|
|
2046
|
-
* owner); either side's next outbound reconcile dial (outbound is never
|
|
2047
|
-
* gated) re-establishes the DATA link — self-healing for data, visible as a
|
|
2048
|
-
* transient deny. That self-healing story never covered a reservation, which
|
|
2049
|
-
* is exactly why the relay-enabled path above exists.
|
|
2384
|
+
* NOTE: checks 5 and 6 add two more control reads (`hasOutstandingFormationInvite`,
|
|
2385
|
+
* `hasLiveCadreInvite`) for a stranger with no locally minted invitation in play, on
|
|
2386
|
+
* every node, and a provisional connection's deadline runs the whole decision once
|
|
2387
|
+
* more. If stranger connections ever arrive fast enough for those reads — on arrival
|
|
2388
|
+
* or at the deadline — to show, cache the answers for a few seconds.
|
|
2050
2389
|
*/
|
|
2051
2390
|
private async admitInboundControlConnection(remotePeerId: string): Promise<InboundConnectionVerdict> {
|
|
2391
|
+
// NOTE: a node waiting to be claimed (isAwaitingClaim) lands on this empty-anchor
|
|
2392
|
+
// admission too, so a stranger can hold an idle, mute connection to it for as long
|
|
2393
|
+
// as it waits on a public address; if that ever shows up as a resource problem, put
|
|
2394
|
+
// unclaimed-node admissions on the provisional deadline too.
|
|
2052
2395
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
2053
2396
|
return 'admit';
|
|
2054
2397
|
}
|
|
2055
|
-
if (Date.now() < this.enrollmentWindowUntil) {
|
|
2056
|
-
return 'admit';
|
|
2057
|
-
}
|
|
2058
2398
|
if (this.delegateAdmission.has(remotePeerId)) {
|
|
2059
2399
|
return 'admit';
|
|
2060
2400
|
}
|
|
@@ -2069,16 +2409,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2069
2409
|
if (await this.strandSolicitationService?.hasOutstandingInvitation()) {
|
|
2070
2410
|
return 'admit';
|
|
2071
2411
|
}
|
|
2412
|
+
if (await this.controlDatabase?.hasLiveCadreInvite()) {
|
|
2413
|
+
return 'admit';
|
|
2414
|
+
}
|
|
2072
2415
|
} catch (error) {
|
|
2073
2416
|
log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
|
|
2074
2417
|
return 'admit';
|
|
2075
2418
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
return 'admit-for-relay';
|
|
2079
|
-
}
|
|
2080
|
-
log('admitInboundControlConnection: DENYING inbound from %s — not an authorized member and no enrollment path open', remotePeerId);
|
|
2081
|
-
return 'deny';
|
|
2419
|
+
log('admitInboundControlConnection: %s is not an authorized member and no stranger window is open — admitting provisionally', remotePeerId);
|
|
2420
|
+
return 'admit-provisionally';
|
|
2082
2421
|
}
|
|
2083
2422
|
|
|
2084
2423
|
/**
|
|
@@ -2088,7 +2427,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2088
2427
|
* "The relay-reservation seam"); the circuit-relay server consults it per
|
|
2089
2428
|
* RESERVE request, so it is never called on a node whose relay server is off.
|
|
2090
2429
|
*
|
|
2091
|
-
*
|
|
2430
|
+
* Refuses everyone, ahead of every admission below, while this node is waiting to be
|
|
2431
|
+
* claimed ({@link isAwaitingClaim}): a storage-profile node runs the relay server by
|
|
2432
|
+
* default, and an unclaimed node on a public address must not become anyone's relay.
|
|
2433
|
+
* The empty-anchor admission in check 1 exists for a node that will be enrolled by
|
|
2434
|
+
* its siblings, which an unclaimed node has none of.
|
|
2435
|
+
*
|
|
2436
|
+
* Otherwise admits when ANY of:
|
|
2092
2437
|
* 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
|
|
2093
2438
|
* — not running / DB torn down, absent-or-empty trusted-owner anchor, or
|
|
2094
2439
|
* configured bootstrap/relay infrastructure);
|
|
@@ -2108,14 +2453,18 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2108
2453
|
* that boots, reserves, and only then has its row replicate here would
|
|
2109
2454
|
* otherwise keep that slot spent for the rest of the entry's TTL.
|
|
2110
2455
|
*
|
|
2111
|
-
* The connection gate's stranger carve-outs (an
|
|
2112
|
-
*
|
|
2113
|
-
* exist so a stranger's
|
|
2114
|
-
* neither needs relay capacity. A genuine invitee that does need a relay
|
|
2115
|
-
* takes one from the budget like any other unplaced peer, so
|
|
2116
|
-
* never becomes an unbounded grant of this node's forwarding capacity.
|
|
2456
|
+
* The connection gate's stranger carve-outs (an outstanding formation
|
|
2457
|
+
* invitation, a live cadre invitation) deliberately do NOT extend here: they
|
|
2458
|
+
* exist so a stranger's FORMATION or CADRE-INVITE stream can ride a connection,
|
|
2459
|
+
* and neither needs relay capacity. A genuine invitee that does need a relay
|
|
2460
|
+
* slot takes one from the budget like any other unplaced peer, so a live
|
|
2461
|
+
* invitation never becomes an unbounded grant of this node's forwarding capacity.
|
|
2117
2462
|
*/
|
|
2118
2463
|
private async admitControlRelayReservation(remotePeerId: string): Promise<boolean> {
|
|
2464
|
+
if (this.isAwaitingClaim()) {
|
|
2465
|
+
log('admitControlRelayReservation: REFUSING %s — this node is unclaimed and relays for nobody until it is', remotePeerId);
|
|
2466
|
+
return false;
|
|
2467
|
+
}
|
|
2119
2468
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
2120
2469
|
return this.admitReservationUncounted(remotePeerId);
|
|
2121
2470
|
}
|
|
@@ -2138,25 +2487,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2138
2487
|
return true;
|
|
2139
2488
|
}
|
|
2140
2489
|
|
|
2141
|
-
/**
|
|
2142
|
-
* Hold the control-network inbound gate open for not-yet-authorized peers
|
|
2143
|
-
* until `untilEpochMs` (extends, never shrinks, an already-open window).
|
|
2144
|
-
* {@link createInvite} calls this automatically; a host running an
|
|
2145
|
-
* out-of-band enrollment flow (e.g. accepting a phone whose invite this node
|
|
2146
|
-
* never minted) can open it explicitly before the stranger dials in.
|
|
2147
|
-
*/
|
|
2148
|
-
openEnrollmentWindow(untilEpochMs: number): void {
|
|
2149
|
-
this.enrollmentWindowUntil = Math.max(this.enrollmentWindowUntil, untilEpochMs);
|
|
2150
|
-
log('Enrollment window open until %d', this.enrollmentWindowUntil);
|
|
2151
|
-
}
|
|
2152
|
-
|
|
2153
2490
|
/**
|
|
2154
2491
|
* Record (or refresh) a delegate admission grant: `delegatePeerId` is the
|
|
2155
2492
|
* transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
|
|
2156
2493
|
* at the CONNECTION and RESERVATION levels (all a circuit-relay reservation
|
|
2157
2494
|
* needs, without spending the unauthorized-reservation budget) for
|
|
2158
|
-
* `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder
|
|
2159
|
-
* authorized
|
|
2495
|
+
* `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder, which the
|
|
2496
|
+
* protocol guard admits only authorized members to; public so tests can drive the
|
|
2160
2497
|
* admission policy without a full strand launch. A re-announce for the same
|
|
2161
2498
|
* (announcer, strand) REPLACES the previous delegate rather than
|
|
2162
2499
|
* accumulating. Never honored by {@link authorizeInboundControlStream}.
|
|
@@ -2198,59 +2535,95 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2198
2535
|
}
|
|
2199
2536
|
|
|
2200
2537
|
/**
|
|
2201
|
-
*
|
|
2202
|
-
* (
|
|
2203
|
-
*
|
|
2204
|
-
*
|
|
2205
|
-
*
|
|
2538
|
+
* The membership rule this control node applies to a remote peer, without logging. Two
|
|
2539
|
+
* consumers: the per-stream gate ({@link authorizeInboundControlStream}, who may SEND to
|
|
2540
|
+
* this node on a members-only protocol) and Optimystic's `isNetworkMember` (who this node
|
|
2541
|
+
* sends control writes TO — which peers can be a control block's cohort member or
|
|
2542
|
+
* coordinator), which adds only a device whose verified invitation redemption is being
|
|
2543
|
+
* written ({@link admittingDevices}). One rule for both keeps the outbound choice as
|
|
2544
|
+
* permissive as the inbound acceptance: a joining node's cohort must include the inviter
|
|
2545
|
+
* its empty anchor or empty snapshot lets replicate to it, or it would see a self-only
|
|
2546
|
+
* cohort, rule an existing collection absent and found a second copy. Sereus builds the
|
|
2547
|
+
* control node with `bootstrapNodes: []`, so Optimystic's refused-bootstrap-peer guard
|
|
2548
|
+
* does not cover that.
|
|
2206
2549
|
*
|
|
2207
2550
|
* The STRICT SUBSET of {@link admitInboundControlConnection}: the same
|
|
2208
2551
|
* "no basis to judge" admissions (shared via
|
|
2209
2552
|
* {@link admitControlPeerUnconditionally}), minus the stranger carve-outs
|
|
2210
|
-
* (
|
|
2211
|
-
* grant). The first two exist so a stranger can reach `/sereus/
|
|
2212
|
-
* and `/sereus/
|
|
2213
|
-
* stranger to `repo`
|
|
2214
|
-
*
|
|
2553
|
+
* (outstanding open invitation, live cadre invitation, delegate admission
|
|
2554
|
+
* grant). The first two exist so a stranger can reach `/sereus/formation/1.0.0`
|
|
2555
|
+
* and `/sereus/cadre-invite/1.0.0` — neither is gated here, and admitting a
|
|
2556
|
+
* stranger to `repo` while an invitation is live is exactly the hole this
|
|
2557
|
+
* rule closes. A DELEGATE-admitted connection (a member's strand node
|
|
2215
2558
|
* holding a circuit-relay reservation, see `delegate-admission.ts`) is
|
|
2216
|
-
* likewise
|
|
2217
|
-
*
|
|
2218
|
-
*
|
|
2219
|
-
*
|
|
2559
|
+
* likewise refused: the delegate gets the connection and never the control DB.
|
|
2560
|
+
*
|
|
2561
|
+
* Refuses everyone, ahead of every admission below, while this node is waiting to be
|
|
2562
|
+
* claimed ({@link isAwaitingClaim}). The empty-anchor and empty-snapshot admissions
|
|
2563
|
+
* exist so a brand-new node can take replication from the siblings that will
|
|
2564
|
+
* authorize it; an unclaimed node has no siblings yet, and the rows that will
|
|
2565
|
+
* authorize anyone arrive only after the claim, over the owner's streams that the
|
|
2566
|
+
* same two admissions then let in. Its cohort is itself alone meanwhile, which costs
|
|
2567
|
+
* nothing: it built its control database with zero connections. Configured bootstrap
|
|
2568
|
+
* infra is refused too: a relay opens no control-database stream.
|
|
2569
|
+
*
|
|
2570
|
+
* Otherwise admits when ANY of:
|
|
2220
2571
|
* 1. a shared-baseline check admits (not running / no DB, empty anchor,
|
|
2221
|
-
* configured bootstrap infra
|
|
2572
|
+
* configured bootstrap infra — which Optimystic still classifies `foreign`,
|
|
2573
|
+
* since a relay serves none of the party's database protocols);
|
|
2222
2574
|
* 2. the materialized authorized set is empty — cold start, before the
|
|
2223
2575
|
* rows that would authorize anyone have replicated in;
|
|
2224
2576
|
* 3. the peer IS in the materialized authorized set.
|
|
2225
2577
|
*
|
|
2226
2578
|
* Deliberately SYNCHRONOUS and pure in-memory — see
|
|
2227
2579
|
* {@link authorizedControlPeers} for why a control-DB read here would
|
|
2228
|
-
* deadlock into mutual denial.
|
|
2229
|
-
* (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its
|
|
2230
|
-
* default.
|
|
2580
|
+
* deadlock into mutual denial.
|
|
2231
2581
|
*
|
|
2232
2582
|
* NOTE: the snapshot keys on PEER ID, so a node with no persistent identity
|
|
2233
2583
|
* key never lands a `CadrePeer` row ({@link registerSelf} skips) and its
|
|
2234
|
-
* siblings deny its control-DB streams
|
|
2235
|
-
* owner status is a KEY, not a peer id, so
|
|
2236
|
-
* Harmless today (a real deployment persists
|
|
2237
|
-
* case appears only in tests, which configure
|
|
2238
|
-
* `push-wake-e2e.integration.ts` design note 3).
|
|
2239
|
-
* ever becomes a supported deployment, this
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2584
|
+
* siblings deny its control-DB streams, and leave it out of their cohorts, once
|
|
2585
|
+
* their own snapshot is non-empty — owner status is a KEY, not a peer id, so
|
|
2586
|
+
* being the owner does not exempt it. Harmless today (a real deployment persists
|
|
2587
|
+
* its identity; the ephemeral-owner case appears only in tests, which configure
|
|
2588
|
+
* the owner as bootstrap infra — `push-wake-e2e.integration.ts` design note 3).
|
|
2589
|
+
* If an ephemeral-identity node ever becomes a supported deployment, this rule
|
|
2590
|
+
* needs a key-based admission.
|
|
2591
|
+
*/
|
|
2592
|
+
private isControlNetworkMember(remotePeerId: string): boolean {
|
|
2593
|
+
if (this.isAwaitingClaim()) {
|
|
2594
|
+
return false;
|
|
2244
2595
|
}
|
|
2245
|
-
if (this.
|
|
2596
|
+
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
2246
2597
|
return true;
|
|
2247
2598
|
}
|
|
2248
|
-
|
|
2599
|
+
return this.authorizedControlPeers.size === 0 || this.authorizedControlPeers.has(remotePeerId);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
/**
|
|
2603
|
+
* Per-stream authorization for the protocol guard's `members-snapshot` class
|
|
2604
|
+
* (`control-protocol-guard.ts`): the four Optimystic control-DB protocols
|
|
2605
|
+
* (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), FRET's
|
|
2606
|
+
* five, and any protocol nobody classed. Also wired as Optimystic's
|
|
2607
|
+
* `authorizeInboundStream` in {@link createControlNode} for the four DB
|
|
2608
|
+
* protocols. The fail-closed layer behind the fail-open connection gater.
|
|
2609
|
+
* Control node ONLY: strand cohort nodes legitimately serve cross-party peers.
|
|
2610
|
+
*
|
|
2611
|
+
* Admits exactly the peers {@link isControlNetworkMember} does, and logs why it
|
|
2612
|
+
* refused one. With a sync predicate the upstream deadline
|
|
2613
|
+
* (`authorizeInboundStreamTimeoutMs`) never trips, so it stays at its default.
|
|
2614
|
+
*/
|
|
2615
|
+
private authorizeInboundControlStream(remotePeerId: string, protocol: string): boolean {
|
|
2616
|
+
if (this.isControlNetworkMember(remotePeerId)) {
|
|
2249
2617
|
return true;
|
|
2250
2618
|
}
|
|
2251
2619
|
// Upstream logs its own denial line; this one carries the WHY.
|
|
2252
|
-
|
|
2253
|
-
|
|
2620
|
+
if (this.isAwaitingClaim()) {
|
|
2621
|
+
log('authorizeInboundControlStream: DENYING %s on %s — this node is unclaimed and replicates with nobody until it is',
|
|
2622
|
+
remotePeerId, protocol);
|
|
2623
|
+
} else {
|
|
2624
|
+
log('authorizeInboundControlStream: DENYING %s on %s — not in the materialized authorized set (%d member(s))',
|
|
2625
|
+
remotePeerId, protocol, this.authorizedControlPeers.size);
|
|
2626
|
+
}
|
|
2254
2627
|
return false;
|
|
2255
2628
|
}
|
|
2256
2629
|
|
|
@@ -2268,13 +2641,23 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2268
2641
|
* read here would sleep its backoff holding the lock and stall every other local
|
|
2269
2642
|
* writer. Nothing is lost — this refresh already keeps the previous snapshot on
|
|
2270
2643
|
* failure and is re-driven by the next membership write and by the timed reconcile.
|
|
2644
|
+
*
|
|
2645
|
+
* The trusted-owner anchor sync ({@link syncOwnerAnchor}) runs FIRST, so the snapshot
|
|
2646
|
+
* built here judges `CadrePeer` rows against the anchor as the `OwnerKey` table now
|
|
2647
|
+
* has it. The invitation chain both reads may need, and the tombstone list the sync and
|
|
2648
|
+
* the two end-of-refresh checks ({@link hangUpRevokedControlPeers}, then
|
|
2649
|
+
* {@link noteOwnRemoval}) read, are each loaded at most once per refresh.
|
|
2271
2650
|
*/
|
|
2272
2651
|
private async refreshAuthorizedControlPeers(reason: string): Promise<void> {
|
|
2273
2652
|
if (!this._running || !this.controlDatabase) {
|
|
2274
2653
|
return;
|
|
2275
2654
|
}
|
|
2276
2655
|
try {
|
|
2277
|
-
const
|
|
2656
|
+
const loadChain = this.invitationChainSource(this.controlDatabase, false);
|
|
2657
|
+
const loadTombstones = this.revocationSource(this.controlDatabase, false);
|
|
2658
|
+
await this.syncOwnerAnchor(reason, false, loadChain, loadTombstones);
|
|
2659
|
+
const rows = await this.controlDatabase.queryCadrePeers(false);
|
|
2660
|
+
const members = this.authorizedAmong(rows, await this.chainFor(rows, loadChain));
|
|
2278
2661
|
this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
|
|
2279
2662
|
log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
|
|
2280
2663
|
// Remember the party's size for the NEXT launch's control-node repair yardstick
|
|
@@ -2341,11 +2724,136 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2341
2724
|
// re-arming peers whose last pass was denied under the SAME membership
|
|
2342
2725
|
// snapshot instead of re-arming unconditionally.
|
|
2343
2726
|
void this.controlBackfill?.scheduleConnectedPeers();
|
|
2727
|
+
await this.hangUpRevokedControlPeers(rows, loadTombstones);
|
|
2728
|
+
await this.noteOwnRemoval(rows, loadTombstones);
|
|
2344
2729
|
} catch (error) {
|
|
2345
2730
|
log('refreshAuthorizedControlPeers(%s) failed — keeping previous snapshot: %o', reason, error);
|
|
2346
2731
|
}
|
|
2347
2732
|
}
|
|
2348
2733
|
|
|
2734
|
+
/**
|
|
2735
|
+
* Hang up this node's control connections to every peer that a `CadrePeer` tombstone names
|
|
2736
|
+
* and that has no live `CadrePeer` row: a machine removed from the cadre, whose streams the
|
|
2737
|
+
* protocol guard already refuses but whose open connection would otherwise stay up until it
|
|
2738
|
+
* dropped on its own. Runs at the end of every membership refresh: on the remover from the
|
|
2739
|
+
* removal's own membership notification, so the connection is closed before
|
|
2740
|
+
* {@link removeMachine} returns; on every other machine from the refresh that follows the
|
|
2741
|
+
* replicated tombstone.
|
|
2742
|
+
*
|
|
2743
|
+
* Denies only on evidence held, the strand enforcer's rule (`strand-revocation-enforcer.ts`):
|
|
2744
|
+
* a peer that merely dropped out of the authorized snapshot (an anchor read that failed, a
|
|
2745
|
+
* voucher whose owner is gone) keeps its connection, self is never hung up, and a peer with a
|
|
2746
|
+
* tombstone AND a live row under a fresh stamp was re-admitted and keeps its connection too.
|
|
2747
|
+
* The tombstones are the refresh's own read (`loadTombstones`, shared with
|
|
2748
|
+
* {@link syncOwnerAnchor}), taken only when some connected peer has no live row, so a cadre
|
|
2749
|
+
* with every connection accounted for costs nothing here. A failed `hangUp` is logged and
|
|
2750
|
+
* retried by the next refresh. Never rejects.
|
|
2751
|
+
*/
|
|
2752
|
+
private async hangUpRevokedControlPeers(rows: readonly CadrePeerRow[], loadTombstones: RevocationSource): Promise<void> {
|
|
2753
|
+
const node = this.controlNode;
|
|
2754
|
+
if (!node) {
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
const selfPeerId = node.peerId.toString();
|
|
2758
|
+
const live = new Set(rows.map((row) => row.peerId));
|
|
2759
|
+
const rowless = new Map<string, PeerId>();
|
|
2760
|
+
for (const connection of node.getConnections()) {
|
|
2761
|
+
const remote = connection.remotePeer.toString();
|
|
2762
|
+
if (remote !== selfPeerId && !live.has(remote)) {
|
|
2763
|
+
rowless.set(remote, connection.remotePeer);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
if (rowless.size === 0) {
|
|
2767
|
+
return;
|
|
2768
|
+
}
|
|
2769
|
+
// NOTE: on a node whose anchor is empty the anchor sync skips its tombstone read, so a
|
|
2770
|
+
// connection to a peer with no row (a configured relay or bootstrap peer) makes this the
|
|
2771
|
+
// refresh's one `Revocation` read; bounded at one per refresh. If an un-enrolled node's
|
|
2772
|
+
// refresh ever shows up in the consult budget, skip this read while the anchor is empty.
|
|
2773
|
+
let revoked: Set<string>;
|
|
2774
|
+
try {
|
|
2775
|
+
revoked = new Set((await loadTombstones()).filter((tombstone) => tombstone.tableName === 'CadrePeer').map((tombstone) => tombstone.rowKey));
|
|
2776
|
+
} catch (error) {
|
|
2777
|
+
log('membership refresh: tombstone read for the hang-up of removed peers failed; next refresh retries: %o', error);
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
// NOTE: awaited inside the membership listener, so under the control database's write
|
|
2781
|
+
// lock; libp2p bounds each connection's close at its 1 s `connectionCloseTimeout`, so a
|
|
2782
|
+
// silent peer holds local writes for at most that. If several removed peers ever pile up
|
|
2783
|
+
// here, hang them up concurrently or move the call after the listener returns.
|
|
2784
|
+
for (const [remote, peer] of rowless) {
|
|
2785
|
+
if (!revoked.has(remote)) {
|
|
2786
|
+
continue;
|
|
2787
|
+
}
|
|
2788
|
+
try {
|
|
2789
|
+
await node.hangUp(peer);
|
|
2790
|
+
log('membership refresh: hung up %s (its CadrePeer row is tombstoned and no live row replaced it)', remote);
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
log('membership refresh: hangUp of removed peer %s failed; next refresh retries: %o', remote, error);
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
/**
|
|
2798
|
+
* Learn that an owner removed this node from the cadre, at the end of every membership
|
|
2799
|
+
* refresh: a `CadrePeer` tombstone names this node's peer id, `rows` (the refresh's own
|
|
2800
|
+
* retired-stamp-filtered read) has no live row for it, and an earlier refresh of this process
|
|
2801
|
+
* saw one ({@link ownLiveRow}). Then it records {@link removal} and emits `cadre:removed`,
|
|
2802
|
+
* once. Nothing is stopped here: stopping and deleting are the embedder's, whose storage,
|
|
2803
|
+
* saved starts and process lifetime they are.
|
|
2804
|
+
*
|
|
2805
|
+
* The "seen live" condition keeps a machine joining again, whose fresh store can sync the old
|
|
2806
|
+
* tombstone before its new row, from reporting itself removed. A re-admission under a fresh
|
|
2807
|
+
* stamp is a live row, so a tombstone plus a live row is never a removal. The tombstones are
|
|
2808
|
+
* the refresh's shared read, taken only once the own row is missing. Never rejects.
|
|
2809
|
+
*/
|
|
2810
|
+
private async noteOwnRemoval(rows: readonly CadrePeerRow[], loadTombstones: RevocationSource): Promise<void> {
|
|
2811
|
+
const selfPeerId = this.controlNode?.peerId.toString();
|
|
2812
|
+
if (selfPeerId === undefined || this.removal !== null) {
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
const own = rows.find((row) => row.peerId === selfPeerId);
|
|
2816
|
+
if (own) {
|
|
2817
|
+
this.ownLiveRow = own;
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
// NOTE: a machine that synced its tombstone and restarted before its embedder acted never
|
|
2821
|
+
// reports, since the new process never saw its row live. The embedders persist what they
|
|
2822
|
+
// did (a marker, a deleted party), so the window is a crash between this event and that
|
|
2823
|
+
// action; if it is ever hit, persist the removal here.
|
|
2824
|
+
const lastLive = this.ownLiveRow;
|
|
2825
|
+
if (lastLive === null) {
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
let tombstones: RevocationRow[];
|
|
2829
|
+
try {
|
|
2830
|
+
tombstones = (await loadTombstones()).filter((tombstone) => tombstone.tableName === 'CadrePeer' && tombstone.rowKey === selfPeerId);
|
|
2831
|
+
} catch (error) {
|
|
2832
|
+
log('membership refresh: tombstone read for this node\'s own removal failed; next refresh retries: %o', error);
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
if (tombstones.length === 0 || !this._running || !this.controlDatabase) {
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
const tombstone = tombstones.find((candidate) => candidate.stampId === lastLive.stampId) ?? tombstones[0];
|
|
2839
|
+
this.removal = { partyId: this.partyId, stampId: tombstone.stampId, signerKey: this.anchoredRevocationSigner(tombstone), at: Date.now() };
|
|
2840
|
+
log('membership refresh: this node was removed from cadre %s (tombstone %s, signer %s)', this.partyId, tombstone.stampId, tombstone.signerKey);
|
|
2841
|
+
this.emit('cadre:removed', this.removal);
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
/**
|
|
2845
|
+
* The tombstone's signer when its signature verifies and the key is in this node's
|
|
2846
|
+
* trusted-owner anchor, else null. A tombstone that fails this is still a removal (every
|
|
2847
|
+
* other machine honours it, and this node's own row is gone), so it is logged, not refused.
|
|
2848
|
+
*/
|
|
2849
|
+
private anchoredRevocationSigner(tombstone: RevocationRow): string | null {
|
|
2850
|
+
if (verifyRevocationSigner(this.partyId, tombstone) && this.trustedOwnerStore?.has(tombstone.signerKey)) {
|
|
2851
|
+
return tombstone.signerKey;
|
|
2852
|
+
}
|
|
2853
|
+
log('membership refresh: the tombstone removing this node (%s) is not signed by an owner anchored here (signer %s)', tombstone.stampId, tombstone.signerKey);
|
|
2854
|
+
return null;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2349
2857
|
/**
|
|
2350
2858
|
* Add the configured `controlNetwork.bootstrapNodes` to the control node's address book
|
|
2351
2859
|
* and dial each peer once — what `@libp2p/bootstrap` would have done had the node been
|
|
@@ -2478,14 +2986,36 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2478
2986
|
* founded the party passes the first test and not the second (`runOwnerGenesis` in the
|
|
2479
2987
|
* reference apps), and a write it signed would only be refused.
|
|
2480
2988
|
*/
|
|
2481
|
-
private async enrolledOwnerSigningKey(): Promise<
|
|
2482
|
-
const signingKey = this.
|
|
2989
|
+
private async enrolledOwnerSigningKey(): Promise<Ed25519KeyPair | null> {
|
|
2990
|
+
const signingKey = this.ownerSigningKeyCandidate();
|
|
2483
2991
|
if (!signingKey || !this.controlDatabase) {
|
|
2484
2992
|
return null;
|
|
2485
2993
|
}
|
|
2486
2994
|
return (await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64) ? signingKey : null;
|
|
2487
2995
|
}
|
|
2488
2996
|
|
|
2997
|
+
/**
|
|
2998
|
+
* Whether this node can act as an owner of its cadre: its identity key is wired as its
|
|
2999
|
+
* owner signing key ({@link initializeSeedBootstrap}) and enrolled in the live `OwnerKey`
|
|
3000
|
+
* set ({@link enrolledOwnerSigningKey}). What the owner-only flows (`createCadreInvitation`,
|
|
3001
|
+
* `claimNode`, `rentNode`) need; false before `start()`, and on a machine that joined as a
|
|
3002
|
+
* member. The reference apps' "is this device an owner" check.
|
|
3003
|
+
*/
|
|
3004
|
+
async isOwner(): Promise<boolean> {
|
|
3005
|
+
return (await this.enrolledOwnerSigningKey()) !== null;
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
/** {@link enrolledOwnerSigningKey} judged against an owner set the caller has already read. */
|
|
3009
|
+
private ownerSigningKeyEnrolledIn(ownerKeys: ReadonlySet<string>): Ed25519KeyPair | null {
|
|
3010
|
+
const signingKey = this.ownerSigningKeyCandidate();
|
|
3011
|
+
return signingKey !== null && ownerKeys.has(signingKey.publicKeyB64) ? signingKey : null;
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
/** This machine's identity key as an owner signing key, when its seed bootstrap is wired to sign; enrollment not yet checked. */
|
|
3015
|
+
private ownerSigningKeyCandidate(): Ed25519KeyPair | null {
|
|
3016
|
+
return this.seedBootstrapService?.canAuthorize() ? this.getSelfSigningKey() : null;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
2489
3019
|
/**
|
|
2490
3020
|
* Schedule the node's initial self-record publish + ongoing refresh shortly
|
|
2491
3021
|
* after start (non-blocking). {@link registerSelf} is idempotent and safely
|
|
@@ -2498,7 +3028,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2498
3028
|
try {
|
|
2499
3029
|
await this.registerSelf();
|
|
2500
3030
|
} catch (error) {
|
|
2501
|
-
// Background task — a failed publish must not crash the node.
|
|
3031
|
+
// Background task — a failed publish must not crash the node. A restarted machine
|
|
3032
|
+
// whose own CadrePeer row replicates in while this update runs fails here with
|
|
3033
|
+
// ConcurrentModificationError (seen on the owner's restart in every traced run of
|
|
3034
|
+
// cadre-invite-any-member); the next connected reconcile pass retries
|
|
3035
|
+
// (publishFirstSelfRecord).
|
|
2502
3036
|
log('Self-registration failed: %o', error);
|
|
2503
3037
|
}
|
|
2504
3038
|
// Wire the ongoing refresh + control-cohort reconcile cadence, then run
|
|
@@ -2568,22 +3102,27 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2568
3102
|
}
|
|
2569
3103
|
|
|
2570
3104
|
const peerId = this.controlNode.peerId.toString();
|
|
2571
|
-
const addrs = await this.collectSelfAddrs();
|
|
2572
3105
|
const existing = await this.controlDatabase.queryPeerRecord(peerId);
|
|
3106
|
+
// A listener-only service (`enableSeedListener`, a claim node's) holds no owner key and
|
|
3107
|
+
// cannot sign the insert, so it is "no owner service" here too.
|
|
3108
|
+
const ownerService = this.seedBootstrapService?.canAuthorize() ? this.seedBootstrapService : null;
|
|
3109
|
+
if (!existing && !ownerService) {
|
|
3110
|
+
// "No row" also covers "revoked": queryPeerRecord reads a row whose StampId is
|
|
3111
|
+
// retired in Revocation as absent, so a removed node lands here every heartbeat
|
|
3112
|
+
// and stops refreshing its own record — correct, but say so, or the message reads
|
|
3113
|
+
// as "never added" for a node that was removed.
|
|
3114
|
+
log('registerSelf: no readable CadrePeer row for self (never added, or removed and its stamp retired) and no owner service to self-insert; skipping');
|
|
3115
|
+
return 'skipped';
|
|
3116
|
+
}
|
|
3117
|
+
// Collected only past that skip, which a node waiting for its row replicated in takes on
|
|
3118
|
+
// every connected reconcile pass ({@link publishFirstSelfRecord}).
|
|
3119
|
+
const addrs = await this.collectSelfAddrs();
|
|
2573
3120
|
|
|
2574
|
-
if (!existing) {
|
|
2575
|
-
if (!this.seedBootstrapService) {
|
|
2576
|
-
// "No row" also covers "revoked": queryPeerRecord reads a row whose StampId is
|
|
2577
|
-
// retired in Revocation as absent, so a removed node lands here every heartbeat
|
|
2578
|
-
// and stops refreshing its own record — correct, but say so, or the message reads
|
|
2579
|
-
// as "never added" for a node that was removed.
|
|
2580
|
-
log('registerSelf: no readable CadrePeer row for self (never added, or removed and its stamp retired) and no owner service to self-insert; skipping');
|
|
2581
|
-
return 'skipped';
|
|
2582
|
-
}
|
|
3121
|
+
if (!existing && ownerService) {
|
|
2583
3122
|
// First-time row: requires an owner signature (the node is its own
|
|
2584
3123
|
// owner). insertSelfPeerRecord throws if no owner key is present.
|
|
2585
3124
|
const record = this.signSelfRecord(peerId, signingKey, addrs, null);
|
|
2586
|
-
if (await
|
|
3125
|
+
if (await ownerService.insertSelfPeerRecord(record)) {
|
|
2587
3126
|
if (this.committedAlone()) this.pendingSelfPeerWrite = true;
|
|
2588
3127
|
this.noteSelfRecordPublished();
|
|
2589
3128
|
log('registerSelf: inserted own CadrePeer record (owner-signed, updatedAt=%d, %d addrs, sig=%s…)', record.updatedAt, addrs.length, record.sig.slice(0, 16));
|
|
@@ -2720,7 +3259,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2720
3259
|
): PeerAddressRecord {
|
|
2721
3260
|
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
2722
3261
|
return signPeerRecord(
|
|
2723
|
-
|
|
3262
|
+
this.partyId,
|
|
3263
|
+
{ peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt, profile: this.config.profile },
|
|
2724
3264
|
signingKey.privateKeyB64
|
|
2725
3265
|
);
|
|
2726
3266
|
}
|
|
@@ -2802,6 +3342,42 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2802
3342
|
return ed25519KeyPairFromLibp2p(this.identityKey);
|
|
2803
3343
|
}
|
|
2804
3344
|
|
|
3345
|
+
/**
|
|
3346
|
+
* Publish this node's own address record from the reconcile pass until its first publish of
|
|
3347
|
+
* this session lands. A node with no owner key — seeded (`addDrone`, `cadre enroll add`),
|
|
3348
|
+
* claimed (`claimNode`) or otherwise authorized after it booted — cannot insert its own
|
|
3349
|
+
* `CadrePeer` row; the row an owner wrote for it reaches it by replication once the two
|
|
3350
|
+
* connect, usually after the boot-time publish and the first-growth drain have both found
|
|
3351
|
+
* nothing to publish. Without this the record would wait for the heartbeat
|
|
3352
|
+
* ({@link startRecordRefresh}), and the cadre could not resolve the node's addresses until
|
|
3353
|
+
* then. The reconcile pass is where replicated membership is picked up, so each pass tries.
|
|
3354
|
+
* It also retries an owner's boot-time publish that failed.
|
|
3355
|
+
*
|
|
3356
|
+
* A try before the row has arrived is a `skipped` publish: one `CadrePeer` read. Not tried
|
|
3357
|
+
* while the node waits to be claimed (it has no row and no party yet) or once it has learned
|
|
3358
|
+
* it was removed (nothing to publish). A removed node that never saw its own row live in this
|
|
3359
|
+
* process does not learn it was removed ({@link noteOwnRemoval}), and pays that read on every
|
|
3360
|
+
* pass.
|
|
3361
|
+
*
|
|
3362
|
+
* Connected-only: a row written elsewhere arrives over a connection, and an owner's publish
|
|
3363
|
+
* committed alone is local-only and forks its own history — a restarted owner whose embedder
|
|
3364
|
+
* runs a pass before it reconnects would write its row on the stale side of the fork, and
|
|
3365
|
+
* then never see rows written while it was down (`cadre-invite-any-member` runs that pass and
|
|
3366
|
+
* fails every time without this condition). The boot-time publish and the first-growth drain already try at the node's lone and
|
|
3367
|
+
* first-connection moments; this retries on the passes after.
|
|
3368
|
+
*/
|
|
3369
|
+
private async publishFirstSelfRecord(): Promise<void> {
|
|
3370
|
+
if (this.lastSelfRecordPublishAt !== null || this.isAwaitingClaim() || this.removal !== null
|
|
3371
|
+
|| this.getControlConnectionCount() === 0) {
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
const outcome = await this.registerSelf().catch((error: unknown) => {
|
|
3375
|
+
log('reconcileControlCohort: first self-record publish failed (continuing): %o', error);
|
|
3376
|
+
return 'skipped' as const;
|
|
3377
|
+
});
|
|
3378
|
+
log('reconcileControlCohort: first self-record publish: %s', outcome);
|
|
3379
|
+
}
|
|
3380
|
+
|
|
2805
3381
|
/**
|
|
2806
3382
|
* Collect this node's current dialable addresses for publication, signaling
|
|
2807
3383
|
* (`/p2p-circuit`) first. Prefers the best invite/NAT-resolved set and folds
|
|
@@ -2848,23 +3424,21 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2848
3424
|
};
|
|
2849
3425
|
this.controlNode.addEventListener('self:peer:update', this.selfPeerUpdateHandler);
|
|
2850
3426
|
|
|
2851
|
-
//
|
|
2852
|
-
//
|
|
2853
|
-
//
|
|
2854
|
-
//
|
|
2855
|
-
//
|
|
2856
|
-
//
|
|
2857
|
-
//
|
|
2858
|
-
//
|
|
2859
|
-
//
|
|
2860
|
-
//
|
|
2861
|
-
//
|
|
2862
|
-
//
|
|
2863
|
-
//
|
|
2864
|
-
// (`
|
|
2865
|
-
//
|
|
2866
|
-
// `relay-only-control-addr.integration.ts` case 4, which drives `registerSelf()`
|
|
2867
|
-
// explicitly because neither trigger is dependable there.
|
|
3427
|
+
// A node AUTHORIZED AFTER IT BOOTED (seeded, claimed, invited) gets its first
|
|
3428
|
+
// publish from the reconcile pass ({@link publishFirstSelfRecord}), not from the
|
|
3429
|
+
// two triggers wired here. `registerSelf` no-ops while a node has no readable
|
|
3430
|
+
// `CadrePeer` row, so its boot-time publish does nothing, and neither trigger is
|
|
3431
|
+
// dependable after that: `self:peer:update` needs an ADDRESS CHANGE, and a
|
|
3432
|
+
// relay-only node's addresses stop changing the moment its reservation lands —
|
|
3433
|
+
// inside `start()`, before this listener exists (on the `network.requireRelay:
|
|
3434
|
+
// false` posture the reservation may instead land LATER, from the retry
|
|
3435
|
+
// supervisor, and then this listener is what publishes the `/p2p-circuit` address
|
|
3436
|
+
// the boot-time publish could not carry); the heartbeat is 7.5 min away. Nor is
|
|
3437
|
+
// the membership-change seam (`ControlDatabase.setMembershipChangeListener`): it
|
|
3438
|
+
// fires on LOCAL commits only, and the row a late-authorized node waits for is
|
|
3439
|
+
// written elsewhere. Measured while writing `relay-only-control-addr.integration.ts`
|
|
3440
|
+
// case 4, which drives `registerSelf()` explicitly because neither trigger is
|
|
3441
|
+
// dependable there.
|
|
2868
3442
|
this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
|
|
2869
3443
|
(this.recordRefreshTimer as { unref?: () => void } | null)?.unref?.();
|
|
2870
3444
|
|
|
@@ -2976,7 +3550,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2976
3550
|
}
|
|
2977
3551
|
|
|
2978
3552
|
// Self-signature over (peerId, addrs, updatedAt).
|
|
2979
|
-
if (!verifyPeerRecordSignature(record)) {
|
|
3553
|
+
if (!verifyPeerRecordSignature(this.partyId, record)) {
|
|
2980
3554
|
// Field detail distinguishes "holding the owner-vouch revision (Sig null,
|
|
2981
3555
|
// no addrs — a not-yet-self-published row)" from a genuinely corrupt or
|
|
2982
3556
|
// mixed-revision row. Sig/addrs are public replicated data.
|
|
@@ -3035,8 +3609,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3035
3609
|
* ({@link bootstrapDialAddrs}).
|
|
3036
3610
|
*
|
|
3037
3611
|
* An address naming a DIFFERENT trailing peer id is dropped: it does not reach
|
|
3038
|
-
* `peerId`, so it does not belong in this peer's candidate list (
|
|
3039
|
-
*
|
|
3612
|
+
* `peerId`, so it does not belong in this peer's candidate list (`dialPeerAddrs`
|
|
3613
|
+
* binds every address to the peer it is given and drops the same shape one
|
|
3614
|
+
* layer lower; here it is dropped before it can reach the address book).
|
|
3040
3615
|
*/
|
|
3041
3616
|
private normalizeDialAddrs(addrs: Multiaddr[], peerId: string): Multiaddr[] {
|
|
3042
3617
|
// Keyed by the NORMALIZED string, so two entries differing only by the suffix
|
|
@@ -3169,6 +3744,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3169
3744
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
3170
3745
|
return { dialed: [] };
|
|
3171
3746
|
}
|
|
3747
|
+
await this.publishFirstSelfRecord();
|
|
3748
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
3749
|
+
return { dialed: [] };
|
|
3750
|
+
}
|
|
3172
3751
|
// Refresh relay delegate grants BEFORE the sibling enumeration below: a
|
|
3173
3752
|
// solo cadre with a party relay has no siblings to dial but must still keep
|
|
3174
3753
|
// its running strands' grants alive (a dropped relay connection re-dials
|
|
@@ -3464,8 +4043,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3464
4043
|
* addresses ({@link dialPeerAddrs}): the reconcile pass's steady-state sibling
|
|
3465
4044
|
* ({@link dialControlSibling}) and cold-start bootstrap peer
|
|
3466
4045
|
* ({@link dialBootstrapPeer}) dials, and — handed to every
|
|
3467
|
-
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials
|
|
3468
|
-
* `dialInvite`.
|
|
4046
|
+
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials.
|
|
3469
4047
|
*
|
|
3470
4048
|
* See `peer-dial.ts`'s `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` for why a peer's dial is
|
|
3471
4049
|
* bounded as a whole, and `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS` for why each
|
|
@@ -3540,6 +4118,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3540
4118
|
log('reconcileControlCohort: dialing sibling %s (%d addr(s))', sibling.peerId, addrs.length);
|
|
3541
4119
|
await dialPeerAddrs(
|
|
3542
4120
|
controlNode,
|
|
4121
|
+
sibling.peerId,
|
|
3543
4122
|
addrs,
|
|
3544
4123
|
this.controlDialBudget(),
|
|
3545
4124
|
`reconcileControlCohort dial of sibling ${sibling.peerId}`
|
|
@@ -3575,9 +4154,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3575
4154
|
*
|
|
3576
4155
|
* The list may name transports this node cannot dial (a lent node reports TCP
|
|
3577
4156
|
* and `/ws` addresses to a phone that dials WebSockets only). That needs no
|
|
3578
|
-
* filtering here: libp2p's
|
|
3579
|
-
*
|
|
3580
|
-
*
|
|
4157
|
+
* filtering here: libp2p's transport manager rejects an address no transport
|
|
4158
|
+
* can dial before touching the network (`TransportUnavailableError`), so such
|
|
4159
|
+
* an address costs a log line.
|
|
3581
4160
|
*
|
|
3582
4161
|
* The list is NOT handed to one `dial()`. libp2p tries a multi-address dial's
|
|
3583
4162
|
* addresses one after another under a single deadline, and sorts loopback
|
|
@@ -3806,6 +4385,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3806
4385
|
log('reconcileControlCohort(cold-start): dialing bootstrap peer %s (%d addr(s))', peerId, parsed.length);
|
|
3807
4386
|
await dialPeerAddrs(
|
|
3808
4387
|
controlNode,
|
|
4388
|
+
peerId,
|
|
3809
4389
|
parsed,
|
|
3810
4390
|
this.controlDialBudget(),
|
|
3811
4391
|
`reconcileControlCohort cold-start dial of bootstrap peer ${peerId}`
|
|
@@ -3901,7 +4481,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3901
4481
|
return;
|
|
3902
4482
|
}
|
|
3903
4483
|
this.pendingRevocations.set(revocation.stampId, revocation);
|
|
3904
|
-
log('%s
|
|
4484
|
+
log('%s tombstone for %s committed while alone (0 control connections); Revocation tombstone ' +
|
|
3905
4485
|
'(stamp %s) queued for re-issue on cohort growth', revocation.tableName, revocation.rowKey, revocation.stampId);
|
|
3906
4486
|
}
|
|
3907
4487
|
|
|
@@ -3976,7 +4556,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3976
4556
|
// 4. In-session pending owner authorize writes.
|
|
3977
4557
|
await this.drainPendingPeerWrites();
|
|
3978
4558
|
|
|
3979
|
-
// 5.
|
|
4559
|
+
// 5. Pending joins this process wrote while alone. Their tombstones went out in step 1.
|
|
3980
4560
|
await this.pendingJoinRunner?.reissueWritesMadeAlone();
|
|
3981
4561
|
}
|
|
3982
4562
|
|
|
@@ -4036,7 +4616,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4036
4616
|
*/
|
|
4037
4617
|
private async drainPendingRevocations(): Promise<void> {
|
|
4038
4618
|
const sweep = !this.reissuedHeldRevocations;
|
|
4039
|
-
|
|
4619
|
+
// A removed machine's owner key went with its row (`removeMachine`), so the schema's
|
|
4620
|
+
// live-owner clause refuses every re-issue it could sign; reconstructAuthoredMembership
|
|
4621
|
+
// and drainPendingPeerWrites stop on the same test.
|
|
4622
|
+
if (this.removal !== null || (!sweep && this.pendingRevocations.size === 0)) {
|
|
4040
4623
|
return;
|
|
4041
4624
|
}
|
|
4042
4625
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
@@ -4062,6 +4645,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4062
4645
|
this.pendingRevocations.delete(row.stampId);
|
|
4063
4646
|
}
|
|
4064
4647
|
} catch (error) {
|
|
4648
|
+
// NOTE: an owner whose key alone was removed (`removeOwner`, its machine still a member,
|
|
4649
|
+
// so not a removal this node reports) keeps running these drains, and every attempt
|
|
4650
|
+
// fails the schema's live-owner clause and lands here on each growth edge. Noise, not
|
|
4651
|
+
// harm; if it shows up, gate the drains on this node's key being a live owner.
|
|
4065
4652
|
log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
|
|
4066
4653
|
}
|
|
4067
4654
|
}
|
|
@@ -4084,7 +4671,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4084
4671
|
* `CadrePeer` trace, but its tombstone is re-issuable).
|
|
4085
4672
|
*/
|
|
4086
4673
|
private async reconstructAuthoredMembership(): Promise<void> {
|
|
4087
|
-
if (!this.controlDatabase || !this.controlNode) {
|
|
4674
|
+
if (!this.controlDatabase || !this.controlNode || this.removal !== null) {
|
|
4088
4675
|
return;
|
|
4089
4676
|
}
|
|
4090
4677
|
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
@@ -4146,7 +4733,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4146
4733
|
* here.
|
|
4147
4734
|
*/
|
|
4148
4735
|
private async drainPendingPeerWrites(): Promise<void> {
|
|
4149
|
-
if (this.pendingPeerWrites.size === 0) {
|
|
4736
|
+
if (this.pendingPeerWrites.size === 0 || this.removal !== null) {
|
|
4150
4737
|
return;
|
|
4151
4738
|
}
|
|
4152
4739
|
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
@@ -4299,6 +4886,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4299
4886
|
// Strictly increase UpdatedAt even on a same-millisecond re-publish (rotation).
|
|
4300
4887
|
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
4301
4888
|
const record = signDeviceTokenRecord(
|
|
4889
|
+
this.partyId,
|
|
4302
4890
|
{ peerId, platform, token, updatedAt },
|
|
4303
4891
|
signingKey.privateKeyB64
|
|
4304
4892
|
);
|
|
@@ -4395,7 +4983,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4395
4983
|
|
|
4396
4984
|
// Self-signature over (peerId, platform, token, updatedAt), verified against the
|
|
4397
4985
|
// CadrePeer.PublicKey bound to this peerId.
|
|
4398
|
-
if (!verifyDeviceTokenSignature(record, peerRecord.publicKey)) {
|
|
4986
|
+
if (!verifyDeviceTokenSignature(this.partyId, record, peerRecord.publicKey)) {
|
|
4399
4987
|
log('resolveDeviceToken: signature verification failed for %s', peerId);
|
|
4400
4988
|
return null;
|
|
4401
4989
|
}
|
|
@@ -4656,6 +5244,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4656
5244
|
this.strandAddrService = null;
|
|
4657
5245
|
}
|
|
4658
5246
|
|
|
5247
|
+
// Unregister the cadre invitation redemption handler, for the same reason.
|
|
5248
|
+
if (this.cadreInviteHandler && this.controlNode) {
|
|
5249
|
+
await this.cadreInviteHandler.unregister(this.controlNode);
|
|
5250
|
+
}
|
|
5251
|
+
this.cadreInviteHandler = null;
|
|
5252
|
+
|
|
4659
5253
|
// Tear down the push-wake fan-out (releases the notifier's APNs HTTP/2 session).
|
|
4660
5254
|
if (this.pushFanoutService) {
|
|
4661
5255
|
await this.pushFanoutService.close().catch((err) => log('Push fan-out close failed: %o', err));
|
|
@@ -5947,6 +6541,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5947
6541
|
// comes up `'syncing'` announces the moment its database is published.
|
|
5948
6542
|
firstSync: this.config.strandFirstSync,
|
|
5949
6543
|
onWritable: (writableStrandId) => this.handleStrandWritable(writableStrandId),
|
|
6544
|
+
onCommitDurability: (commit) => this.emit('strand:commit-durability', commit),
|
|
6545
|
+
onCommitHeld: (held) => this.emit('strand:commit-held', held),
|
|
5950
6546
|
// Re-announce the delegate to ONE relay before the strand node's reservation
|
|
5951
6547
|
// supervisor re-drives it (see announceDelegateToRelay). Retained with the
|
|
5952
6548
|
// launch config, so a hibernation wake's rebuilt supervisors get it too.
|
|
@@ -7120,6 +7716,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7120
7716
|
*
|
|
7121
7717
|
* Passing an empty list stops the supervisor and resets the posture to `none`
|
|
7122
7718
|
* without dialing or waiting.
|
|
7719
|
+
*
|
|
7720
|
+
* NOTE: on a node with no `network.relayAddrs` (a browser tab with a hand-written
|
|
7721
|
+
* bare `/p2p-circuit`), libp2p's relay discovery stays on, because it was the only
|
|
7722
|
+
* thing filling that slot before this call. Such a tab connected to some other
|
|
7723
|
+
* relay-capable peer it identifies (a party member running the relay server) can
|
|
7724
|
+
* lose its one slot to that peer before this supervisor's request runs, the theft
|
|
7725
|
+
* `nodeTransportsOption` prevents on configured nodes (`relay-reservation.ts`). If a
|
|
7726
|
+
* tab is ever seen reserved through a relay it was not asked to use, build that
|
|
7727
|
+
* node through `withoutRelayDiscovery` too, or have the tab configure `relayAddrs`.
|
|
7123
7728
|
*/
|
|
7124
7729
|
async reserveRelays(
|
|
7125
7730
|
addrs: string[],
|
|
@@ -7228,22 +7833,35 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7228
7833
|
if (!this.controlNode || !this.controlDatabase) {
|
|
7229
7834
|
throw new Error('CadreNode must be started before initializing seed bootstrap');
|
|
7230
7835
|
}
|
|
7836
|
+
// A hosted node waiting to be claimed never holds an owner key: the claim is its
|
|
7837
|
+
// one trust source, and genesis-anchoring a key here would mark it claimed by
|
|
7838
|
+
// itself. Refused here rather than in start() because this call comes after it.
|
|
7839
|
+
if (this.config.claim) {
|
|
7840
|
+
throw new Error('CadreNode: a node started with `claim` is owned by its claimant and cannot initialize seed bootstrap with an owner key');
|
|
7841
|
+
}
|
|
7231
7842
|
|
|
7232
7843
|
// A node wiring seed-bootstrap with an owner PRIVATE key is declaring that
|
|
7233
7844
|
// key an authority of its own party (founder genesis / self-signing owner)
|
|
7234
7845
|
// — anchor it in the node-local store. The in-memory set updates
|
|
7235
7846
|
// synchronously (see TrustedOwnerStore.trust); only the file persist is
|
|
7236
7847
|
// fire-and-forget, logged on failure.
|
|
7237
|
-
// NOTE:
|
|
7238
|
-
//
|
|
7239
|
-
// it
|
|
7240
|
-
//
|
|
7241
|
-
//
|
|
7242
|
-
//
|
|
7243
|
-
//
|
|
7244
|
-
//
|
|
7245
|
-
//
|
|
7246
|
-
//
|
|
7848
|
+
// NOTE: wiring a key that is not a live owner here self-anchors a key that is
|
|
7849
|
+
// no party authority, and the anchor sync (`syncOwnerAnchor`) does not correct
|
|
7850
|
+
// it: a `genesis` entry is base, kept whether or not a live `OwnerKey` row
|
|
7851
|
+
// vouches it. A machine that joined by invitation reaches this only through
|
|
7852
|
+
// `wireOwnKeyIfOwner`, which checks the row first, so a joined non-owner never
|
|
7853
|
+
// self-anchors. Three callers still wire unconditionally on every start, so a
|
|
7854
|
+
// node of theirs started for a party founded elsewhere does: the phone kit's
|
|
7855
|
+
// `runOwnerGenesis` (cadre-rn; only for a party its saved start does not record
|
|
7856
|
+
// as joined, whose starts call `wireOwnKeyIfOwner` instead), the web reference
|
|
7857
|
+
// app's start (`cadre-web.ts`) and `cadre start --owner`. Contained while the store does
|
|
7858
|
+
// not replicate and such a node cannot mint: `createCadreInvitation` hands out
|
|
7859
|
+
// the anchor as the device's pins, but the `CadreInvite` insert needs a live
|
|
7860
|
+
// owner as issuer. If anything hands out the anchor without that check, gate
|
|
7861
|
+
// this self-anchor on a live `OwnerKey` row instead. A non-owner needs no
|
|
7862
|
+
// owner key to publish its own address record: once an owner or a redemption
|
|
7863
|
+
// has written its `CadrePeer` row, `registerSelf` refreshes it with the
|
|
7864
|
+
// identity key alone.
|
|
7247
7865
|
if (this.trustedOwnerStore) {
|
|
7248
7866
|
void this.trustedOwnerStore
|
|
7249
7867
|
.trust(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
|
|
@@ -7253,9 +7871,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7253
7871
|
await this.installSeedBootstrapService(new SeedBootstrapService({
|
|
7254
7872
|
partyId: this.config.controlNetwork.partyId,
|
|
7255
7873
|
ownerPrivateKey,
|
|
7256
|
-
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
7257
7874
|
...this.seedServiceBudgets(),
|
|
7258
|
-
trustPolicy: this.
|
|
7875
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
7259
7876
|
// Seed trust anchors on the node-local store (seeded just above with this
|
|
7260
7877
|
// node's own genesis key), never on the replicated OwnerKey table.
|
|
7261
7878
|
...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
|
|
@@ -7341,6 +7958,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7341
7958
|
* {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
|
|
7342
7959
|
* never reach here: {@link ControlDatabase.queryCadrePeers} excludes them, so a
|
|
7343
7960
|
* revoked peer is not addressable either.
|
|
7961
|
+
*
|
|
7962
|
+
* NOTE: answers the whole `CadrePeerRow`, past the two fields declared; the integration
|
|
7963
|
+
* harness (`owner-cli-node.ts`, `OwnerCliMemberRow.sig`) reads `sig` off it through the admin
|
|
7964
|
+
* channel to tell a row another member seated from one the subject published itself. Narrow
|
|
7965
|
+
* this to the declared fields only together with that reader.
|
|
7344
7966
|
*/
|
|
7345
7967
|
async listMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
|
|
7346
7968
|
if (!this.controlDatabase) {
|
|
@@ -7373,14 +7995,21 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7373
7995
|
* 1. it is not this node itself — a node publishes its own `CadrePeer` address
|
|
7374
7996
|
* row so its dialable address rides in seeds, but "self" is not a peer this
|
|
7375
7997
|
* node authorized;
|
|
7376
|
-
* 2. its `CadrePeer` row carries a complete
|
|
7377
|
-
* `VouchSig` all non-null)
|
|
7998
|
+
* 2. its `CadrePeer` row carries a complete proof: EITHER an owner voucher
|
|
7999
|
+
* (`StampId`, `VouchOwner`, `VouchSig` all non-null) OR an invitation admission
|
|
8000
|
+
* (`VouchSig` null, `VouchUsage` naming a `CadreInviteUsage` row);
|
|
7378
8001
|
* 3. `VouchOwner` is in the NODE-LOCAL trusted-owner anchor
|
|
7379
|
-
* ({@link getTrustedOwnerStore})
|
|
7380
|
-
*
|
|
7381
|
-
*
|
|
7382
|
-
*
|
|
7383
|
-
*
|
|
8002
|
+
* ({@link getTrustedOwnerStore}), which follows the replicated `OwnerKey` table
|
|
8003
|
+
* only along chains that verify from this node's out-of-band keys
|
|
8004
|
+
* ({@link syncOwnerAnchor}) — never the table itself, which any stranger can
|
|
8005
|
+
* genesis-pollute; and
|
|
8006
|
+
* 4. the proof verifies: for a voucher, `VouchSig` is that owner's signature over
|
|
8007
|
+
* the row's voucher digest ({@link verifyCadrePeerVoucher}), so the anchored
|
|
8008
|
+
* owner really vouched THIS peer id under THIS row's nonce; for an admission,
|
|
8009
|
+
* the chain row → usage → invitation → anchored issuer verifies link by link
|
|
8010
|
+
* ({@link verifyInvitationAdmission} — the issuer is `VouchOwner`, the invitation's
|
|
8011
|
+
* own owner signature, the holder's and the device's signatures, and the stored
|
|
8012
|
+
* peer key really is the key behind the peer id); and
|
|
7384
8013
|
* 5. the row's `StampId` is NOT retired in `CadreControl.Revocation` — enforced
|
|
7385
8014
|
* upstream in {@link ControlDatabase.queryCadrePeers}, which drops retired rows
|
|
7386
8015
|
* before ANY reader (this predicate included) sees them, so a row resurrected
|
|
@@ -7389,18 +8018,26 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7389
8018
|
* local rows) is still inert to every reader that has the tombstone.
|
|
7390
8019
|
*
|
|
7391
8020
|
* Fail-closed at every step: a missing anchor (pre-start), an empty anchor (a
|
|
7392
|
-
* not-yet-enrolled node authorizes no one), a null/partial
|
|
7393
|
-
* unanchored `VouchOwner`,
|
|
7394
|
-
* having an address row is NOT
|
|
7395
|
-
* strand-address
|
|
8021
|
+
* not-yet-enrolled node authorizes no one), a null/partial proof, an
|
|
8022
|
+
* unanchored `VouchOwner`, a bad signature, or a usage or invitation row this node
|
|
8023
|
+
* does not hold all yield "not authorized" — having an address row is NOT
|
|
8024
|
+
* membership. The protocol guard admits wake and strand-address requests by this set,
|
|
8025
|
+
* NOT the addressable one.
|
|
8026
|
+
*
|
|
8027
|
+
* The usage and invitation tables are read only when at least one row needs them,
|
|
8028
|
+
* so a cadre with no invitation-admitted member stays at one read per call.
|
|
7396
8029
|
*
|
|
7397
8030
|
* NOTE (rotation): if a party owner rotates keys and only the NEW key is pinned
|
|
7398
8031
|
* in the anchor, rows the OLD key vouched fail check 3 until re-vouched — a
|
|
7399
8032
|
* legit member goes un-authorized on readers that only pin the new key. Full
|
|
7400
8033
|
* rotation handling (re-vouch on rotate) is the
|
|
7401
|
-
* `flip-strand-membership-rotation-known-gap` work, not this predicate's.
|
|
8034
|
+
* `flip-strand-membership-rotation-known-gap` work, not this predicate's. The
|
|
8035
|
+
* `OwnerKey` table differs: an owner the rotated-out (or removed) key vouched keeps
|
|
8036
|
+
* its place in every anchor that had derived it ({@link syncOwnerAnchor}: a derived
|
|
8037
|
+
* entry stays while its own row is live), and is only out of reach for a machine that
|
|
8038
|
+
* never held it until a remaining owner re-adds it or an invitation pins it.
|
|
7402
8039
|
*
|
|
7403
|
-
* @param retry - Whether the underlying membership
|
|
8040
|
+
* @param retry - Whether the underlying membership reads may retry a transient cluster
|
|
7404
8041
|
* failure. Only {@link refreshAuthorizedControlPeers} passes `false`, because it runs
|
|
7405
8042
|
* as the control database's membership listener with that database's write lock held;
|
|
7406
8043
|
* see its comment.
|
|
@@ -7409,10 +8046,36 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7409
8046
|
if (!this.controlDatabase) {
|
|
7410
8047
|
throw new Error('CadreNode must be started before listing members');
|
|
7411
8048
|
}
|
|
8049
|
+
return await this.collectAuthorizedMembers(this.controlDatabase, retry, this.invitationChainSource(this.controlDatabase, retry));
|
|
8050
|
+
}
|
|
8051
|
+
|
|
8052
|
+
/**
|
|
8053
|
+
* The body of {@link listAuthorizedMembers}, with the invitation chain supplied as a
|
|
8054
|
+
* source so the membership refresh can share one load with {@link syncOwnerAnchor}.
|
|
8055
|
+
*/
|
|
8056
|
+
private async collectAuthorizedMembers(
|
|
8057
|
+
controlDatabase: ControlDatabase,
|
|
8058
|
+
retry: boolean,
|
|
8059
|
+
loadChain: InvitationChainSource,
|
|
8060
|
+
): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
|
|
8061
|
+
const rows = await controlDatabase.queryCadrePeers(retry);
|
|
8062
|
+
return this.authorizedAmong(rows, await this.chainFor(rows, loadChain));
|
|
8063
|
+
}
|
|
8064
|
+
|
|
8065
|
+
/** The invitation chain, loaded only when some row in `rows` is invitation-admitted. */
|
|
8066
|
+
private async chainFor(rows: readonly CadrePeerRow[], loadChain: InvitationChainSource): Promise<InvitationChain | null> {
|
|
8067
|
+
return rows.some(isInvitationAdmitted) ? await loadChain() : null;
|
|
8068
|
+
}
|
|
8069
|
+
|
|
8070
|
+
/**
|
|
8071
|
+
* The authorized members among `rows`, already read: checks 1 to 4 of
|
|
8072
|
+
* {@link listAuthorizedMembers}' predicate (check 5 is `queryCadrePeers`'s), so the membership
|
|
8073
|
+
* refresh can judge the rows it has to read anyway for the hang-up of removed peers.
|
|
8074
|
+
*/
|
|
8075
|
+
private authorizedAmong(rows: readonly CadrePeerRow[], chain: InvitationChain | null): Array<{ peerId: string; multiaddr: string | null }> {
|
|
7412
8076
|
const selfPeerId = this.peerId?.toString();
|
|
7413
|
-
const rows = await this.controlDatabase.queryCadrePeers(retry);
|
|
7414
8077
|
const authorized = rows
|
|
7415
|
-
.filter(row => row.peerId !== selfPeerId && this.
|
|
8078
|
+
.filter(row => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain))
|
|
7416
8079
|
.map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
|
|
7417
8080
|
// A node whose anchor was never seeded (no invite pin, no operator pin, not a
|
|
7418
8081
|
// founder) refuses every wake and strand-addr request, which from the outside
|
|
@@ -7426,9 +8089,168 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7426
8089
|
|
|
7427
8090
|
/**
|
|
7428
8091
|
* Checks 2–4 of the authorized-membership predicate (see
|
|
7429
|
-
* {@link listAuthorizedMembers}) for one `CadrePeer` row
|
|
7430
|
-
*
|
|
7431
|
-
* (
|
|
8092
|
+
* {@link listAuthorizedMembers}) for one `CadrePeer` row, by the kind of proof it
|
|
8093
|
+
* carries: an owner voucher ({@link hasAnchoredVoucher}) or an invitation admission
|
|
8094
|
+
* ({@link verifyInvitationAdmission} over the usage and invitation rows in `chain`,
|
|
8095
|
+
* loaded only when some row needs them). A row with a `VouchSig` is judged as a
|
|
8096
|
+
* voucher even if it also names a usage — the signature is the stronger proof and the
|
|
8097
|
+
* one an owner re-vouch leaves behind.
|
|
8098
|
+
*/
|
|
8099
|
+
private hasAnchoredProof(row: CadrePeerVoucherFields, chain: InvitationChain | null): boolean {
|
|
8100
|
+
if (!isInvitationAdmitted(row)) {
|
|
8101
|
+
return this.hasAnchoredVoucher(row);
|
|
8102
|
+
}
|
|
8103
|
+
const usage = chain?.usages.get(row.vouchUsage!);
|
|
8104
|
+
const invite = usage === undefined ? undefined : chain?.invites.get(usage.inviteKey);
|
|
8105
|
+
if (usage === undefined || invite === undefined) {
|
|
8106
|
+
return false;
|
|
8107
|
+
}
|
|
8108
|
+
return verifyInvitationAdmission(this.partyId, row, usage, invite, key => this.trustedOwnerStore?.has(key) ?? false);
|
|
8109
|
+
}
|
|
8110
|
+
|
|
8111
|
+
/**
|
|
8112
|
+
* Recompute the node-local trusted-owner anchor from the replicated `OwnerKey` table along
|
|
8113
|
+
* verifiable chains (`owner-anchor-sync.ts` → {@link deriveOwnerAnchor}), as the first step
|
|
8114
|
+
* of every membership refresh ({@link refreshAuthorizedControlPeers}) so the snapshot built
|
|
8115
|
+
* in the same pass judges `CadrePeer` rows against the updated anchor. A key whose live
|
|
8116
|
+
* row's stored proof verifies against a key already anchored enters under `chain` and
|
|
8117
|
+
* stays while its row is live; a key an anchored owner's verifiable tombstone names
|
|
8118
|
+
* leaves, base pins included — which is how a config pin of a removed key, re-anchored at
|
|
8119
|
+
* every start ({@link initializeTrustedOwnerStore}), is removed again. The derivation
|
|
8120
|
+
* refuses removals that would empty the anchor; they are logged here.
|
|
8121
|
+
*
|
|
8122
|
+
* With an EMPTY anchor the sync does nothing: there is no key to derive from, and a node
|
|
8123
|
+
* waiting to be claimed ({@link isAwaitingClaim}: claim secret set, anchor empty) must
|
|
8124
|
+
* stay empty until the claim anchors its claimant — the replicated table must never seat
|
|
8125
|
+
* the first key. The early return below is the seam that rests on.
|
|
8126
|
+
*
|
|
8127
|
+
* Any read failure skips the whole pass and leaves the anchor as it was; the refresh then
|
|
8128
|
+
* builds its snapshot against that anchor, which is also what its previous snapshot was
|
|
8129
|
+
* judged against. Caught here rather than failing the refresh, because the membership read
|
|
8130
|
+
* tolerates an unreachable `Revocation` block on an isolated node
|
|
8131
|
+
* (`ControlDatabase.readRevokedStampRows`) and `queryRevocations` does not: failing the
|
|
8132
|
+
* whole refresh on that would freeze such a node's gate snapshot.
|
|
8133
|
+
* NOTE: on such a node the sync is skipped at every refresh until the block is held; if an
|
|
8134
|
+
* isolated node ever has to derive owner additions while cut off, give `queryRevocations`
|
|
8135
|
+
* the same cohort-unreachable fallback.
|
|
8136
|
+
*
|
|
8137
|
+
* NOTE: cost per membership refresh is one `OwnerKey` read and one `Revocation` read (the
|
|
8138
|
+
* tombstone list, whose `OwnerKey` stamps are handed to `queryOwnerKeyRows` so it does not
|
|
8139
|
+
* read the retired set again, and which the hang-up of removed peers shares through
|
|
8140
|
+
* `loadTombstones`), plus the invitation chain when some owner row is
|
|
8141
|
+
* invitation-admitted — shared with the membership read through `loadChain`; the memo
|
|
8142
|
+
* suggestion on {@link loadInvitationChain} covers that part. Before the Revocation ledger
|
|
8143
|
+
* marker is filed that one read is a cohort consult per refresh, which
|
|
8144
|
+
* `control-founding-consult-budget.spec.ts` pins.
|
|
8145
|
+
*
|
|
8146
|
+
* @param retry - forwarded to every read; `false` on the refresh path, for the reason on
|
|
8147
|
+
* {@link refreshAuthorizedControlPeers}.
|
|
8148
|
+
* @param loadTombstones - the refresh's shared tombstone read ({@link revocationSource}).
|
|
8149
|
+
*/
|
|
8150
|
+
private async syncOwnerAnchor(reason: string, retry: boolean, loadChain: InvitationChainSource, loadTombstones: RevocationSource): Promise<void> {
|
|
8151
|
+
const store = this.trustedOwnerStore;
|
|
8152
|
+
const controlDatabase = this.controlDatabase;
|
|
8153
|
+
if (!store || !controlDatabase) {
|
|
8154
|
+
return;
|
|
8155
|
+
}
|
|
8156
|
+
const before = store.sources();
|
|
8157
|
+
if (before.size === 0) {
|
|
8158
|
+
return;
|
|
8159
|
+
}
|
|
8160
|
+
let target: ReadonlySet<string>;
|
|
8161
|
+
try {
|
|
8162
|
+
const tombstones = await loadTombstones();
|
|
8163
|
+
const retired = new Set(tombstones.filter(tombstone => tombstone.tableName === 'OwnerKey').map(tombstone => tombstone.stampId));
|
|
8164
|
+
const rows = await controlDatabase.queryOwnerKeyRows(retry, retired);
|
|
8165
|
+
const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
|
|
8166
|
+
const derivation = deriveOwnerAnchor({ partyId: this.partyId, anchor: before, rows, tombstones, chain });
|
|
8167
|
+
if (derivation.refusedRemovals.length > 0) {
|
|
8168
|
+
log('syncOwnerAnchor(%s): refused %d owner removal(s) that would empty the anchor: %o', reason, derivation.refusedRemovals.length, derivation.refusedRemovals);
|
|
8169
|
+
}
|
|
8170
|
+
target = derivation.target;
|
|
8171
|
+
} catch (error) {
|
|
8172
|
+
log('syncOwnerAnchor(%s): read failed — anchor left as it was: %o', reason, error);
|
|
8173
|
+
return;
|
|
8174
|
+
}
|
|
8175
|
+
await this.applyOwnerAnchor(store, before, target, reason);
|
|
8176
|
+
}
|
|
8177
|
+
|
|
8178
|
+
/**
|
|
8179
|
+
* Apply one pass's result: `chain` entries for the keys it newly derived, removal of the keys
|
|
8180
|
+
* it started with that are outside `target` — base keys a verifiable tombstone named, and
|
|
8181
|
+
* `chain` entries whose row is gone.
|
|
8182
|
+
*
|
|
8183
|
+
* Diffed against `before`, the snapshot the derivation was computed from, NOT the live store:
|
|
8184
|
+
* the anchor is written out of band while the pass's reads are in flight (an invitation pin,
|
|
8185
|
+
* the genesis anchor, a claim, a seed), and such a key is outside `target` only because the
|
|
8186
|
+
* pass never saw it — judging the live store would remove it. Likewise a key removed
|
|
8187
|
+
* meanwhile (a claim rollback) must not come back as `chain`. Both are judged by the next
|
|
8188
|
+
* pass. Each change lands in memory synchronously (`TrustedOwnerStore.trust` / `remove`); a
|
|
8189
|
+
* failed PERSIST is logged and does not stop the pass or fail the refresh, as
|
|
8190
|
+
* `SeedBootstrapService.anchorAcceptedSigner` treats the same failure.
|
|
8191
|
+
*/
|
|
8192
|
+
private async applyOwnerAnchor(
|
|
8193
|
+
store: TrustedOwnerStore,
|
|
8194
|
+
before: ReadonlyMap<string, TrustSource>,
|
|
8195
|
+
target: ReadonlySet<string>,
|
|
8196
|
+
reason: string,
|
|
8197
|
+
): Promise<void> {
|
|
8198
|
+
for (const key of target) {
|
|
8199
|
+
if (!before.has(key) && !store.has(key)) {
|
|
8200
|
+
log('syncOwnerAnchor(%s): owner %s derived from the OwnerKey table', reason, key);
|
|
8201
|
+
await store.trust(key, 'chain').catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
|
|
8202
|
+
}
|
|
8203
|
+
}
|
|
8204
|
+
for (const key of before.keys()) {
|
|
8205
|
+
if (!target.has(key) && store.has(key)) {
|
|
8206
|
+
log('syncOwnerAnchor(%s): owner %s removed from the anchor', reason, key);
|
|
8207
|
+
await store.remove(key).catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
|
|
8208
|
+
}
|
|
8209
|
+
}
|
|
8210
|
+
}
|
|
8211
|
+
|
|
8212
|
+
/**
|
|
8213
|
+
* A per-refresh {@link InvitationChainSource}: the first call loads the chain through
|
|
8214
|
+
* {@link loadInvitationChain}, every later call shares that load.
|
|
8215
|
+
*/
|
|
8216
|
+
private invitationChainSource(controlDatabase: ControlDatabase, retry: boolean): InvitationChainSource {
|
|
8217
|
+
let loading: Promise<InvitationChain> | null = null;
|
|
8218
|
+
return () => (loading ??= this.loadInvitationChain(controlDatabase, retry));
|
|
8219
|
+
}
|
|
8220
|
+
|
|
8221
|
+
/** A per-refresh {@link RevocationSource}: one `queryRevocations` read, shared by every caller in the pass. */
|
|
8222
|
+
private revocationSource(controlDatabase: ControlDatabase, retry: boolean): RevocationSource {
|
|
8223
|
+
let loading: Promise<RevocationRow[]> | null = null;
|
|
8224
|
+
return () => (loading ??= controlDatabase.queryRevocations(retry));
|
|
8225
|
+
}
|
|
8226
|
+
|
|
8227
|
+
/**
|
|
8228
|
+
* The usage and invitation rows {@link hasAnchoredProof} and {@link syncOwnerAnchor}
|
|
8229
|
+
* resolve an invitation-admitted row through, keyed for lookup. Both reads honour `retry`
|
|
8230
|
+
* for {@link listAuthorizedMembers}' reason.
|
|
8231
|
+
*
|
|
8232
|
+
* NOTE: once invitations are the only admission route (ticket
|
|
8233
|
+
* `cadre-invitations-redeemable-by-any-member`), every cadre with a member not yet
|
|
8234
|
+
* re-vouched pays these two whole-table reads and three ed25519 verifies per row on every
|
|
8235
|
+
* membership refresh. Both tables are append-only and a verified chain never changes (expiry,
|
|
8236
|
+
* use count and withdrawal are not re-checked), so if the gate's admission deadline ever
|
|
8237
|
+
* shows this, memoise verified (row stamp → usage stamp) pairs per node and skip the reads
|
|
8238
|
+
* when every admitted row is already memoised.
|
|
8239
|
+
*/
|
|
8240
|
+
private async loadInvitationChain(controlDatabase: ControlDatabase, retry: boolean): Promise<InvitationChain> {
|
|
8241
|
+
const [usages, invites] = await Promise.all([
|
|
8242
|
+
controlDatabase.queryCadreInviteUsages(retry),
|
|
8243
|
+
controlDatabase.queryCadreInvites(retry),
|
|
8244
|
+
]);
|
|
8245
|
+
return {
|
|
8246
|
+
usages: new Map(usages.map(usage => [usage.usageStampId, usage])),
|
|
8247
|
+
invites: new Map(invites.map(invite => [invite.key, invite])),
|
|
8248
|
+
};
|
|
8249
|
+
}
|
|
8250
|
+
|
|
8251
|
+
/**
|
|
8252
|
+
* The owner-voucher half of {@link hasAnchoredProof}: complete voucher, `VouchOwner` in
|
|
8253
|
+
* the node-local anchor, signature valid over the row's (PeerId, StampId) voucher digest.
|
|
7432
8254
|
*
|
|
7433
8255
|
* NOTE: verifies the ed25519 signature on every call (no memo of already-
|
|
7434
8256
|
* verified (peerId, stampId, vouchSig) triples). Cadres are a handful of
|
|
@@ -7443,13 +8265,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7443
8265
|
if (!this.trustedOwnerStore?.has(row.vouchOwner)) {
|
|
7444
8266
|
return false;
|
|
7445
8267
|
}
|
|
7446
|
-
return verifyCadrePeerVoucher(row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
|
|
8268
|
+
return verifyCadrePeerVoucher(this.partyId, row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
|
|
7447
8269
|
}
|
|
7448
8270
|
|
|
7449
8271
|
/**
|
|
7450
8272
|
* Probe whether a given peer is an AUTHORIZED party member (see
|
|
7451
|
-
* {@link listAuthorizedMembers}) — the
|
|
7452
|
-
* strand-address
|
|
8273
|
+
* {@link listAuthorizedMembers}) — the check the protocol guard runs ahead of the
|
|
8274
|
+
* control-network wake and strand-address handlers, NOT {@link isMember} (the addressable
|
|
7453
8275
|
* surface). Deliberately scans the full membership (one `CadrePeer` query)
|
|
7454
8276
|
* rather than adding a single-row read path: cadres are small, and one code
|
|
7455
8277
|
* path keeps the predicate impossible to drift from the list.
|
|
@@ -7466,14 +8288,16 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7466
8288
|
* record (via {@link resolvePeerAddrs}, signaling/relay first — so a NAT'd peer
|
|
7467
8289
|
* is reachable through its circuit-relay address), dials `WAKE_PROTOCOL`, sends
|
|
7468
8290
|
* the {@link WakeRequest}, and returns the peer's {@link WakeAck}. The receiver
|
|
7469
|
-
*
|
|
7470
|
-
*
|
|
7471
|
-
*
|
|
8291
|
+
* only resumes a strand it already participates in; it acks once it has decided,
|
|
8292
|
+
* before the strand is up. The dial deadlines derive from this node's
|
|
8293
|
+
* `network.linkRoundTripMs`.
|
|
7472
8294
|
*
|
|
7473
8295
|
* @param targetPeerId - The hibernating cadre peer to wake.
|
|
7474
8296
|
* @param strandId - The strand the caller knows has pending activity.
|
|
7475
8297
|
* @param reason - Optional cause hint, e.g. `"activity"` or `"manual"`.
|
|
7476
|
-
* @throws if the node is not started
|
|
8298
|
+
* @throws if the node is not started, the target has no dialable address, or no
|
|
8299
|
+
* attempt got an ack — including when the receiver does not count this node as an
|
|
8300
|
+
* authorized member, since its protocol guard resets the stream without a reply.
|
|
7477
8301
|
*/
|
|
7478
8302
|
async pushWake(targetPeerId: string, strandId: string, reason?: string): Promise<WakeAck> {
|
|
7479
8303
|
if (!this.controlNode) {
|
|
@@ -7490,7 +8314,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7490
8314
|
/**
|
|
7491
8315
|
* Enable the seed listener for receiving seeds via the /sereus/seed/1.0.0 protocol.
|
|
7492
8316
|
* This is for drone nodes that need to receive seeds without being an owner.
|
|
7493
|
-
* Does not require an owner key.
|
|
8317
|
+
* Does not require an owner key. Idempotent; a node started with `config.claim`
|
|
8318
|
+
* calls it itself inside {@link start}, so an embedder's own call is a no-op.
|
|
7494
8319
|
*/
|
|
7495
8320
|
async enableSeedListener(): Promise<void> {
|
|
7496
8321
|
if (!this.controlNode || !this.controlDatabase) {
|
|
@@ -7506,9 +8331,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7506
8331
|
await this.installSeedBootstrapService(new SeedBootstrapService({
|
|
7507
8332
|
partyId: this.config.controlNetwork.partyId,
|
|
7508
8333
|
// No owner key - this node only receives seeds
|
|
7509
|
-
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
7510
8334
|
...this.seedServiceBudgets(),
|
|
7511
|
-
trustPolicy: this.
|
|
8335
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
7512
8336
|
// A listener-only node accepts a wire-delivered seed solely against this
|
|
7513
8337
|
// anchor (there is no per-call override on the inbound handler): with no
|
|
7514
8338
|
// genesis/invite/operator pin it authorizes nobody, which is the point.
|
|
@@ -7577,6 +8401,18 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7577
8401
|
};
|
|
7578
8402
|
}
|
|
7579
8403
|
|
|
8404
|
+
/**
|
|
8405
|
+
* The node-wide default trust policy for INBOUND seeds, the one value every
|
|
8406
|
+
* {@link SeedBootstrapService} this node constructs is given — the owner service,
|
|
8407
|
+
* the listener, and `applySeed`'s temp service — so the three cannot drift. The
|
|
8408
|
+
* claim policy when the node was started with `config.claim` (the two are refused
|
|
8409
|
+
* together at start, so there is nothing to merge), otherwise the configured
|
|
8410
|
+
* `seedTrustPolicy`, and `undefined` for the service's anchored default.
|
|
8411
|
+
*/
|
|
8412
|
+
private inboundSeedTrustPolicy(): SeedTrustPolicy | undefined {
|
|
8413
|
+
return this.claimTrustPolicy ?? this.config.seedTrustPolicy;
|
|
8414
|
+
}
|
|
8415
|
+
|
|
7580
8416
|
/**
|
|
7581
8417
|
* Re-materialize the authorized-peer snapshot that the fail-closed per-stream
|
|
7582
8418
|
* control-DB gate ({@link authorizeInboundControlStream}) judges against.
|
|
@@ -7714,6 +8550,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7714
8550
|
* Remove a previously-authorized peer from the cadre.
|
|
7715
8551
|
* Signs the peer ID with the owner key and deletes the CadrePeer row.
|
|
7716
8552
|
*
|
|
8553
|
+
* Removes the `CadrePeer` row only. The call that removes a machine completely (its owner
|
|
8554
|
+
* key, its push token and the invitations it could come back through, in one transaction)
|
|
8555
|
+
* is {@link removeMachine}.
|
|
8556
|
+
*
|
|
7717
8557
|
* @param peerId - The peer ID to remove
|
|
7718
8558
|
*/
|
|
7719
8559
|
async removePeer(peerId: string): Promise<void> {
|
|
@@ -7734,6 +8574,212 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7734
8574
|
});
|
|
7735
8575
|
}
|
|
7736
8576
|
|
|
8577
|
+
/**
|
|
8578
|
+
* Seat another owner key, signed by this node's owner key (the signed-add branch of
|
|
8579
|
+
* `OwnerKey.Authorized`; the row stores the vouching pair). The write notifies the
|
|
8580
|
+
* membership hub, so the refresh it triggers has run by the time this resolves.
|
|
8581
|
+
*
|
|
8582
|
+
* @param ownerPublicKeyB64 - the new owner's ed25519 public key (base64url).
|
|
8583
|
+
* @returns `true` when the row was seated, `false` when the key is already a live owner.
|
|
8584
|
+
*/
|
|
8585
|
+
async addOwner(ownerPublicKeyB64: string): Promise<boolean> {
|
|
8586
|
+
if (!this.seedBootstrapService) {
|
|
8587
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8588
|
+
}
|
|
8589
|
+
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
8590
|
+
const added = await this.seedBootstrapService.addOwner(key);
|
|
8591
|
+
// NOTE: an OwnerKey add committed alone is local-only and nothing re-issues it (no
|
|
8592
|
+
// update branch to re-touch, unlike CadrePeer), so it is only logged;
|
|
8593
|
+
// tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue.md owns the fix.
|
|
8594
|
+
if (added && this.committedAlone()) {
|
|
8595
|
+
log('addOwner(%s) committed while ALONE (0 control connections): the new owner row is ' +
|
|
8596
|
+
'local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
|
|
8597
|
+
}
|
|
8598
|
+
return added;
|
|
8599
|
+
}
|
|
8600
|
+
|
|
8601
|
+
/**
|
|
8602
|
+
* Remove an owner key, signed by this node's owner key (which must be a different owner):
|
|
8603
|
+
* the delete and its `Revocation` tombstone commit together. Refused by name when the key
|
|
8604
|
+
* is this node's own or the only live owner. The write notifies the membership hub; the
|
|
8605
|
+
* tombstone rides the committed-delete seam ({@link noteGuardedDelete}), so one committed
|
|
8606
|
+
* while alone is re-issued on cohort growth like every other removal.
|
|
8607
|
+
*
|
|
8608
|
+
* Removes the `OwnerKey` row only. The call that removes a machine completely, its owner key
|
|
8609
|
+
* included, is {@link removeMachine}.
|
|
8610
|
+
*
|
|
8611
|
+
* @param ownerPublicKeyB64 - the owner's ed25519 public key (base64url).
|
|
8612
|
+
* @returns `true` when the row was removed, `false` when the key is not a live owner here.
|
|
8613
|
+
*/
|
|
8614
|
+
async removeOwner(ownerPublicKeyB64: string): Promise<boolean> {
|
|
8615
|
+
if (!this.seedBootstrapService) {
|
|
8616
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8617
|
+
}
|
|
8618
|
+
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
8619
|
+
return await this.seedBootstrapService.removeOwner(key);
|
|
8620
|
+
}
|
|
8621
|
+
|
|
8622
|
+
/**
|
|
8623
|
+
* The cadre's machines as an app lists them ({@link CadreMachine}): one entry per live
|
|
8624
|
+
* `CadrePeer` row, judged by this node's membership predicate, plus one per live `OwnerKey`
|
|
8625
|
+
* whose key names a machine with no row (`describeMachines` in `machine-removal.ts`).
|
|
8626
|
+
* Unlocked reads with the default retry.
|
|
8627
|
+
*/
|
|
8628
|
+
async listMachines(): Promise<CadreMachine[]> {
|
|
8629
|
+
if (!this.controlDatabase) {
|
|
8630
|
+
throw new Error('CadreNode must be started before listing machines');
|
|
8631
|
+
}
|
|
8632
|
+
return (await this.readMachines(this.controlDatabase)).machines;
|
|
8633
|
+
}
|
|
8634
|
+
|
|
8635
|
+
/**
|
|
8636
|
+
* What {@link removeMachine} would do for `peerId`, read without writing, for an app's
|
|
8637
|
+
* confirmation step ({@link MachineRemovalPlan}): whether this node may remove it, whether its
|
|
8638
|
+
* owner key goes with it, which rows that key vouched (so the app can offer to keep them by
|
|
8639
|
+
* re-vouching), which invitations it could come back through, and whether this node's own
|
|
8640
|
+
* rows depend on it (`planMachineRemoval` in `machine-removal.ts`).
|
|
8641
|
+
*/
|
|
8642
|
+
async planMachineRemoval(peerId: string): Promise<MachineRemovalPlan> {
|
|
8643
|
+
if (!this.controlDatabase) {
|
|
8644
|
+
throw new Error('CadreNode must be started before planning a machine removal');
|
|
8645
|
+
}
|
|
8646
|
+
return (await this.planMachineRemovalWith(this.controlDatabase, peerId)).plan;
|
|
8647
|
+
}
|
|
8648
|
+
|
|
8649
|
+
/**
|
|
8650
|
+
* Remove a machine from the cadre completely, signed by this node's owner key: its
|
|
8651
|
+
* `CadrePeer` row, its `OwnerKey` row when its key is an owner, its `DeviceToken` row (a
|
|
8652
|
+
* removed phone must not be push-woken) and a withdrawal of every live invitation it could
|
|
8653
|
+
* redeem to come back, in ONE transaction ({@link ControlDatabase.removeMachine}), with a
|
|
8654
|
+
* `Revocation` tombstone per row so the removal holds on every machine
|
|
8655
|
+
* ("Deletes made while alone" in docs/architecture.md).
|
|
8656
|
+
*
|
|
8657
|
+
* Steps: re-plan ({@link planMachineRemoval}) and throw {@link MachineRemovalRefusedError} on a
|
|
8658
|
+
* refusal; check `options.revouch` against the plan's dependents and throw before any write
|
|
8659
|
+
* when it names anything else; **re-vouch first**, under this node's key, the dependents the
|
|
8660
|
+
* caller chose to keep (`ControlDatabase.revouchOwnerKey`, `reauthorizePeer`), so they are
|
|
8661
|
+
* never untrusted in between, and stop before the removal if one fails (re-vouches already
|
|
8662
|
+
* made stay, harmlessly); then the removal transaction. Afterwards this node forgets the
|
|
8663
|
+
* machine as a dial target, and the refresh the write notifies hangs up its control
|
|
8664
|
+
* connection ({@link hangUpRevokedControlPeers}) before this resolves.
|
|
8665
|
+
*
|
|
8666
|
+
* Not undone by this call: the machine keeps every row and strand block it already synced
|
|
8667
|
+
* and the party's strand member keys (`tickets/blocked/decide-removed-machine-strand-access`).
|
|
8668
|
+
* A fresh invitation re-admits it later under fresh stamps.
|
|
8669
|
+
*
|
|
8670
|
+
* @returns what was written ({@link MachineRemovalResult}); all false when no live row names
|
|
8671
|
+
* the machine here (already removed elsewhere).
|
|
8672
|
+
* @throws {MachineRemovalRefusedError} `'not-an-owner'`, `'self'` or `'last-owner'`.
|
|
8673
|
+
*/
|
|
8674
|
+
async removeMachine(peerId: string, options: RemoveMachineOptions = {}): Promise<MachineRemovalResult> {
|
|
8675
|
+
const service = this.seedBootstrapService;
|
|
8676
|
+
if (!service || !this.controlDatabase) {
|
|
8677
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8678
|
+
}
|
|
8679
|
+
const { plan, rows } = await this.planMachineRemovalWith(this.controlDatabase, peerId);
|
|
8680
|
+
if (plan.refusal !== null) {
|
|
8681
|
+
throw new MachineRemovalRefusedError(peerId, plan.refusal);
|
|
8682
|
+
}
|
|
8683
|
+
const revouch = revouchWithinPlan(plan, options.revouch);
|
|
8684
|
+
if (plan.machine === null) {
|
|
8685
|
+
log('removeMachine(%s): no live row names it here; nothing to remove', peerId);
|
|
8686
|
+
return { removedPeer: false, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [], revouched: { owners: [], peers: [] } };
|
|
8687
|
+
}
|
|
8688
|
+
await this.revouchDependents(service, revouch, rows);
|
|
8689
|
+
const writes = await service.removeMachine({
|
|
8690
|
+
peerId,
|
|
8691
|
+
ownerKey: plan.removesOwnerKey ? plan.machine.publicKey : null,
|
|
8692
|
+
inviteKeys: plan.invitations,
|
|
8693
|
+
});
|
|
8694
|
+
if (writes.removedPeer) {
|
|
8695
|
+
// Track + loudly flag a delete that committed local-only (security-relevant).
|
|
8696
|
+
this.noteControlWrite(peerId, 'remove');
|
|
8697
|
+
}
|
|
8698
|
+
// As removePeer: a removed machine is no longer a dial target, however this node learned
|
|
8699
|
+
// its address. A persist failure only lets the entry reappear after a restart.
|
|
8700
|
+
void this.bootstrapPeerStore?.forget(peerId).catch((error: unknown) => {
|
|
8701
|
+
log('removeMachine: persisting the removal of dial target %s failed (forgotten in memory): %o', peerId, error);
|
|
8702
|
+
});
|
|
8703
|
+
log('removeMachine(%s): peer=%s ownerKey=%s deviceToken=%s invitations=%d revouched owners=%d peers=%d', peerId,
|
|
8704
|
+
writes.removedPeer, writes.removedOwnerKey, writes.removedDeviceToken, writes.withdrawnInvitations.length,
|
|
8705
|
+
revouch.owners.length, revouch.peers.length);
|
|
8706
|
+
return { ...writes, revouched: revouch };
|
|
8707
|
+
}
|
|
8708
|
+
|
|
8709
|
+
/**
|
|
8710
|
+
* The reads behind {@link listMachines}: the live `CadrePeer` and `OwnerKey` rows and the
|
|
8711
|
+
* machines projected from them, with this node's membership predicate as the `trusted` test.
|
|
8712
|
+
*/
|
|
8713
|
+
private async readMachines(controlDatabase: ControlDatabase): Promise<{ machines: CadreMachine[]; rows: CadrePeerRow[]; owners: OwnerKeyRow[] }> {
|
|
8714
|
+
const [rows, owners] = await Promise.all([controlDatabase.queryCadrePeers(), controlDatabase.queryOwnerKeyRows()]);
|
|
8715
|
+
const chain = await this.chainFor(rows, this.invitationChainSource(controlDatabase, true));
|
|
8716
|
+
const selfPeerId = this.peerId?.toString() ?? null;
|
|
8717
|
+
const machines = describeMachines({
|
|
8718
|
+
partyId: this.partyId,
|
|
8719
|
+
selfPeerId,
|
|
8720
|
+
rows,
|
|
8721
|
+
owners,
|
|
8722
|
+
trusted: (row) => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain),
|
|
8723
|
+
});
|
|
8724
|
+
return { machines, rows, owners };
|
|
8725
|
+
}
|
|
8726
|
+
|
|
8727
|
+
/** {@link planMachineRemoval} with the rows it was judged from, which {@link removeMachine} re-vouches dependents by. */
|
|
8728
|
+
private async planMachineRemovalWith(controlDatabase: ControlDatabase, peerId: string): Promise<{ plan: MachineRemovalPlan; rows: CadrePeerRow[] }> {
|
|
8729
|
+
const { machines, rows, owners } = await this.readMachines(controlDatabase);
|
|
8730
|
+
const [invites, usages] = await Promise.all([controlDatabase.listCadreInviteStatuses(), controlDatabase.queryCadreInviteUsages()]);
|
|
8731
|
+
const plan = planMachineRemoval({
|
|
8732
|
+
peerId,
|
|
8733
|
+
machines,
|
|
8734
|
+
rows,
|
|
8735
|
+
owners,
|
|
8736
|
+
selfPeerId: this.peerId?.toString() ?? null,
|
|
8737
|
+
selfOwnerKey: this.ownerSigningKeyEnrolledIn(new Set(owners.map((owner) => owner.key)))?.publicKeyB64 ?? null,
|
|
8738
|
+
invites,
|
|
8739
|
+
usages,
|
|
8740
|
+
});
|
|
8741
|
+
return { plan, rows };
|
|
8742
|
+
}
|
|
8743
|
+
|
|
8744
|
+
/**
|
|
8745
|
+
* The re-vouch step of {@link removeMachine}: every chosen dependent row is rebound to this
|
|
8746
|
+
* node's key BEFORE the removal, so no reader ever judges it against an owner that is gone.
|
|
8747
|
+
* Owners through `ControlDatabase.revouchOwnerKey` (a delete-and-re-add under a fresh stamp,
|
|
8748
|
+
* the only rebinding `OwnerKey.NoUpdate` allows); peers through the owner re-touch
|
|
8749
|
+
* (`reauthorizePeer`), which rewrites `VouchOwner`/`VouchSig` and clears `VouchUsage`.
|
|
8750
|
+
*
|
|
8751
|
+
* The peer re-touch keeps the row's `UpdatedAt` where it is. The owner branch of
|
|
8752
|
+
* `CadrePeer.AuthorizedUpdate` does not require a bump, and bumping a self-published row
|
|
8753
|
+
* would leave its `Sig` unverifiable, so unresolvable and unlisted as self-published, until
|
|
8754
|
+
* the machine's next heartbeat (the NOTE on that constraint); the write-while-alone drain
|
|
8755
|
+
* bumps because it re-issues an otherwise unchanged row, whereas here the voucher itself
|
|
8756
|
+
* changes. A row never self-published carries no stamp to keep and gets the clock.
|
|
8757
|
+
*
|
|
8758
|
+
* A failure rethrows and stops the call before the removal; re-vouches already made stay
|
|
8759
|
+
* (the row is only re-signed by a current owner) and the caller retries. An owner re-vouch
|
|
8760
|
+
* committed alone is not re-issued on growth (the add half has no re-issue path,
|
|
8761
|
+
* `tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue`), so it is logged as
|
|
8762
|
+
* {@link addOwner} logs it; the peer re-touches are re-issued by the existing drain.
|
|
8763
|
+
*/
|
|
8764
|
+
private async revouchDependents(
|
|
8765
|
+
service: SeedBootstrapService,
|
|
8766
|
+
revouch: MachineRemovalPlan['dependents'],
|
|
8767
|
+
rows: readonly CadrePeerRow[],
|
|
8768
|
+
): Promise<void> {
|
|
8769
|
+
for (const key of revouch.owners) {
|
|
8770
|
+
await service.revouchOwner(key);
|
|
8771
|
+
if (this.committedAlone()) {
|
|
8772
|
+
log('removeMachine: re-vouch of owner %s committed while ALONE (0 control connections): the re-added ' +
|
|
8773
|
+
'owner row is local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
|
|
8774
|
+
}
|
|
8775
|
+
}
|
|
8776
|
+
for (const dependent of revouch.peers) {
|
|
8777
|
+
const row = rows.find((candidate) => candidate.peerId === dependent);
|
|
8778
|
+
const updatedAt = row !== undefined && row.updatedAt > 0 ? row.updatedAt : Date.now();
|
|
8779
|
+
await service.reauthorizePeer(dependent, updatedAt);
|
|
8780
|
+
}
|
|
8781
|
+
}
|
|
8782
|
+
|
|
7737
8783
|
/**
|
|
7738
8784
|
* Create a seed from the current control network state.
|
|
7739
8785
|
* The seed contains peer information and is signed by an owner.
|
|
@@ -7749,10 +8795,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7749
8795
|
* Apply a seed to populate the peer cache and enable connections.
|
|
7750
8796
|
*
|
|
7751
8797
|
* Validates the seed signature, then evaluates a trust anchor for the signer
|
|
7752
|
-
* key (see `SeedTrustPolicy`). An
|
|
7753
|
-
* `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a
|
|
7754
|
-
*
|
|
7755
|
-
*
|
|
8798
|
+
* key (see `SeedTrustPolicy`). An operator-driven caller can pass a per-seed
|
|
8799
|
+
* `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a pinned
|
|
8800
|
+
* owner key — so a cold-start node can accept its first seed without
|
|
8801
|
+
* reconfiguring the service. The override wins on a node started with
|
|
8802
|
+
* `config.claim` as well: the claim secret is the node's default inbound policy,
|
|
8803
|
+
* not a lock on its local operator, who already controls the process.
|
|
7756
8804
|
*/
|
|
7757
8805
|
async applySeed(
|
|
7758
8806
|
seed: ControlNetworkSeed,
|
|
@@ -7766,13 +8814,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7766
8814
|
//
|
|
7767
8815
|
// This temp service is discarded after the call, so it must NOT own the shared
|
|
7768
8816
|
// node's inbound seed handler: pass { registerHandler: false }. That keeps
|
|
7769
|
-
// repeated service-less applySeed
|
|
8817
|
+
// repeated service-less applySeed idempotent (no handler leak, no
|
|
7770
8818
|
// DuplicateProtocolHandlerError). The temp service still applies this seed; a
|
|
7771
8819
|
// node that wants to RECEIVE inbound seeds needs a persistent service
|
|
7772
8820
|
// (enableSeedListener / initializeSeedBootstrap) to own the handler.
|
|
7773
8821
|
const tempService = new SeedBootstrapService({
|
|
7774
8822
|
partyId: seed.partyId,
|
|
7775
|
-
trustPolicy: this.
|
|
8823
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
7776
8824
|
...this.seedServiceBudgets(),
|
|
7777
8825
|
// The anchor is node-scoped, not service-scoped: a throwaway service
|
|
7778
8826
|
// must consult (and persist an accepted signer into) the SAME store the
|
|
@@ -7815,12 +8863,23 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7815
8863
|
|
|
7816
8864
|
/**
|
|
7817
8865
|
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
8866
|
+
*
|
|
8867
|
+
* `target` is a multiaddr string, or a peer id with its addresses (the form a phone
|
|
8868
|
+
* uses for a node it scanned), which are dialed one at a time before the request
|
|
8869
|
+
* (`SeedBootstrapService.deliverSeed`). `options.claimProof` rides beside the seed when
|
|
8870
|
+
* the target is a brand-new node being claimed (`claim-proof.ts`).
|
|
8871
|
+
*
|
|
8872
|
+
* @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it formed.
|
|
7818
8873
|
*/
|
|
7819
|
-
async deliverSeed(
|
|
8874
|
+
async deliverSeed(
|
|
8875
|
+
target: SeedDeliveryTarget,
|
|
8876
|
+
seed: ControlNetworkSeed,
|
|
8877
|
+
options?: { claimProof?: string },
|
|
8878
|
+
): Promise<SeedAckMessage> {
|
|
7820
8879
|
if (!this.seedBootstrapService) {
|
|
7821
8880
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7822
8881
|
}
|
|
7823
|
-
return await this.seedBootstrapService.deliverSeed(
|
|
8882
|
+
return await this.seedBootstrapService.deliverSeed(target, seed, options);
|
|
7824
8883
|
}
|
|
7825
8884
|
|
|
7826
8885
|
/**
|
|
@@ -7893,32 +8952,63 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7893
8952
|
}
|
|
7894
8953
|
|
|
7895
8954
|
/**
|
|
7896
|
-
*
|
|
7897
|
-
*
|
|
8955
|
+
* Claim a node that was started with a claim secret (`CadreNodeConfig.claim`), from
|
|
8956
|
+
* the peer id, addresses and secret its host showed (the QR code): mint a seed, prove
|
|
8957
|
+
* possession of the secret beside it (`claim-proof.ts`), deliver it, and on acceptance
|
|
8958
|
+
* vouch the node into this cadre and keep its addresses as a dial target. The owner
|
|
8959
|
+
* side of the claim-secret entry in the Seed Delivery Protocol (`docs/architecture.md`).
|
|
8960
|
+
*
|
|
8961
|
+
* Requires the seed service with an owner key ({@link initializeSeedBootstrap}), as
|
|
8962
|
+
* {@link addDrone} does. The order is the REVERSE of `addDrone`'s: seed first, row
|
|
8963
|
+
* second. A refused claim or a failed delivery therefore leaves no `CadrePeer` row and
|
|
8964
|
+
* no retained dial target behind on this node — a phone that typed the wrong secret,
|
|
8965
|
+
* or scanned a node someone else already owns, has nothing to clean up.
|
|
8966
|
+
*
|
|
8967
|
+
* A failure is one of three kinds a caller can tell apart:
|
|
8968
|
+
*
|
|
8969
|
+
* - `ClaimRefusedError` — the node was reached and refused, with its refusal code when
|
|
8970
|
+
* its policy gave one.
|
|
8971
|
+
* - `PeerUnreachableError` — no connection to the node formed from any of its addresses,
|
|
8972
|
+
* so nothing was sent. Each address is dialed in turn on its own limit, so this can
|
|
8973
|
+
* take up to the per-peer dial limit plus the seed request's (114.5 s at the default
|
|
8974
|
+
* declared link) when none answers.
|
|
8975
|
+
* - anything else — the node was reached and the exchange failed, or a local step failed
|
|
8976
|
+
* (a malformed secret or address among them). Repeating the claim with the same secret
|
|
8977
|
+
* is safe (see below).
|
|
8978
|
+
*
|
|
8979
|
+
* Idempotent for the same owner: a second call after a dropped response is accepted
|
|
8980
|
+
* by the node (its anchored branch), the row insert is a no-op on an existing row
|
|
8981
|
+
* (`ControlDatabase.insertCadrePeer`), and the dial target is replaced, not duplicated.
|
|
8982
|
+
*
|
|
8983
|
+
* The first dial is kicked (not awaited) so it happens now rather than on the next
|
|
8984
|
+
* timed reconcile pass; a caller that wants to wait for the connection awaits
|
|
8985
|
+
* {@link reconcileControlCohort} itself, with `addDrone`'s caveat that a pass already
|
|
8986
|
+
* in flight is joined rather than restarted. The claimed node dials this one back too,
|
|
8987
|
+
* from the seed's owner list, when this node has a listen or relay address; a phone
|
|
8988
|
+
* with neither is reached by its own reconcile pass alone.
|
|
8989
|
+
*
|
|
8990
|
+
* @throws {ClaimRefusedError} when the node was reached and refused the claim.
|
|
8991
|
+
* @throws {PeerUnreachableError} when the node was never reached.
|
|
7898
8992
|
*/
|
|
7899
|
-
async
|
|
8993
|
+
async claimNode(target: { peerId: string; multiaddrs: string[]; secret: string }): Promise<void> {
|
|
7900
8994
|
if (!this.seedBootstrapService) {
|
|
7901
8995
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7902
8996
|
}
|
|
7903
|
-
const
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
/**
|
|
7912
|
-
* Accept a phone connection using an invite.
|
|
7913
|
-
* Call this when a phone dials in with an invite token.
|
|
7914
|
-
*/
|
|
7915
|
-
async acceptPhone(options: AddPhoneOptions, issuedInvite?: CadreInvite): Promise<void> {
|
|
7916
|
-
if (!this.seedBootstrapService) {
|
|
7917
|
-
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8997
|
+
const secret = parseClaimSecret(target.secret);
|
|
8998
|
+
const seed = await this.seedBootstrapService.createSeed();
|
|
8999
|
+
const proof = claimProof(secret, target.peerId, seed.signerKey, seedDigest(seed));
|
|
9000
|
+
const ack = await this.seedBootstrapService.deliverSeed(
|
|
9001
|
+
{ peerId: target.peerId, multiaddrs: target.multiaddrs }, seed, { claimProof: proof });
|
|
9002
|
+
if (!ack.accepted) {
|
|
9003
|
+
throw new ClaimRefusedError(target.peerId, ack.reason ?? 'no reason given', ack.code);
|
|
7918
9004
|
}
|
|
7919
|
-
|
|
7920
|
-
//
|
|
7921
|
-
|
|
9005
|
+
log('claimNode: %s accepted the claim; vouching it into the cadre', target.peerId);
|
|
9006
|
+
// The wrapper queues the write for re-replication: a phone claiming its first
|
|
9007
|
+
// always-on node has no control connection yet, so the insert commits local-only.
|
|
9008
|
+
await this.authorizePeer(target.peerId, target.multiaddrs);
|
|
9009
|
+
this.retainDialTarget(target.peerId, target.multiaddrs, 'claimNode');
|
|
9010
|
+
void this.reconcileControlCohort().catch((error) =>
|
|
9011
|
+
log('claimNode: reconcile kick after claiming %s failed: %o', target.peerId, error));
|
|
7922
9012
|
}
|
|
7923
9013
|
|
|
7924
9014
|
/**
|
|
@@ -7936,47 +9026,261 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7936
9026
|
return await this.seedBootstrapService.addPhoneWithRelay(phonePeerId);
|
|
7937
9027
|
}
|
|
7938
9028
|
|
|
9029
|
+
// ============================================================================
|
|
9030
|
+
// Cadre Invitation API (redeemable at any member, `/sereus/cadre-invite/1.0.0`)
|
|
9031
|
+
// ============================================================================
|
|
9032
|
+
|
|
7939
9033
|
/**
|
|
7940
|
-
*
|
|
9034
|
+
* Where a device could redeem a cadre invitation minted now from, once this machine is gone
|
|
9035
|
+
* ({@link CadreInvitationReach}): the same member selection {@link createCadreInvitation}
|
|
9036
|
+
* makes, judged without minting anything. `rentNode` reads it to choose between inviting a
|
|
9037
|
+
* provider's container and claiming it; `local-network` counts as not reachable there, since
|
|
9038
|
+
* the container is never on a member's LAN. Requires a started node.
|
|
7941
9039
|
*/
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
9040
|
+
async invitationReach(): Promise<CadreInvitationReach> {
|
|
9041
|
+
if (!this.controlNode) {
|
|
9042
|
+
throw new Error('CadreNode must be started before judging invitation reach');
|
|
9043
|
+
}
|
|
9044
|
+
return (await this.selectInvitationMembers()).reach;
|
|
9045
|
+
}
|
|
9046
|
+
|
|
9047
|
+
/**
|
|
9048
|
+
* The `members` a cadre invitation minted now would name, and how far they reach: one
|
|
9049
|
+
* selection behind both {@link createCadreInvitation} and {@link invitationReach}, so the
|
|
9050
|
+
* two cannot disagree.
|
|
9051
|
+
*/
|
|
9052
|
+
private async selectInvitationMembers(): Promise<{ members: string[]; reach: CadreInvitationReach }> {
|
|
9053
|
+
const { siblings, connected } = await this.resolveInvitationSiblings();
|
|
9054
|
+
const members = selectCadreInvitationMembers(await this.resolveInviteAddresses(), siblings, connected);
|
|
9055
|
+
return { members, reach: cadreInvitationReach(members, this.controlNode!.peerId.toString()) };
|
|
9056
|
+
}
|
|
9057
|
+
|
|
9058
|
+
/**
|
|
9059
|
+
* Mint an owner-signed cadre invitation that a device can redeem at ANY member machine of
|
|
9060
|
+
* this party, the owner offline included ({@link redeemCadreInvitation}). Requires the
|
|
9061
|
+
* owner key ({@link initializeSeedBootstrap}).
|
|
9062
|
+
*
|
|
9063
|
+
* The invitation is an ed25519 keypair: the public half is the `CadreInvite` row's key,
|
|
9064
|
+
* the private half rides in the returned bundle as the proof of possession. Whoever holds
|
|
9065
|
+
* the bundle can redeem it, so an UNTARGETED, OWNER-GRANTING invitation — a bearer
|
|
9066
|
+
* credential for admin rights — defaults to a 15-minute lifetime; every other kind to 24
|
|
9067
|
+
* hours. One use unless `uses` says otherwise. The bundle carries the signed row (so a
|
|
9068
|
+
* member that has not received it by replication seats it from the bundle), this node's
|
|
9069
|
+
* anchored owner keys — every current owner this node has verified, the out-of-band ones
|
|
9070
|
+
* and those derived along verifiable chains ({@link syncOwnerAnchor}) — (the device pins
|
|
9071
|
+
* them and checks the member's reply against them; sourced from the node-local anchor
|
|
9072
|
+
* only, never the raw replicated `OwnerKey` table, because the device anchors whatever
|
|
9073
|
+
* arrives and a stranger's genesis-inserted key must not ride an invitation into a fresh
|
|
9074
|
+
* node's anchor; an empty anchor is refused because a reply could not be checked), and
|
|
9075
|
+
* at most six addresses: this machine's first ({@link resolveInviteAddresses}, which
|
|
9076
|
+
* honours pushed NAT addresses), then two each of up to three other members
|
|
9077
|
+
* (`selectCadreInvitationMembers`), so the encoded text fits a QR code.
|
|
9078
|
+
*
|
|
9079
|
+
* The result's `reach` says where a device could redeem it from once this machine is gone
|
|
9080
|
+
* (`cadreInvitationReach`, judged on the other members' addresses only), so an app can warn
|
|
9081
|
+
* before handing over an invitation only it could answer.
|
|
9082
|
+
*
|
|
9083
|
+
* The row commits locally and replicates like any other control write; minted while
|
|
9084
|
+
* alone (a phone with no connection), it reaches the other members through the
|
|
9085
|
+
* peer-join block catch-up on the next connection. The device can redeem at any member it
|
|
9086
|
+
* reaches meanwhile: a member that does not hold the row yet admits the device's connection
|
|
9087
|
+
* provisionally ({@link admitInboundControlConnection}) and seats the row from the bundle.
|
|
9088
|
+
*
|
|
9089
|
+
* @throws when no owner key is wired, the anchor is empty, `uses` is not a positive
|
|
9090
|
+
* integer, or neither this machine nor any other member has an address.
|
|
9091
|
+
*/
|
|
9092
|
+
async createCadreInvitation(options: CreateCadreInvitationOptions): Promise<CreateCadreInvitationResult> {
|
|
9093
|
+
const service = this.seedBootstrapService;
|
|
9094
|
+
if (!service?.canAuthorize()) {
|
|
9095
|
+
throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
|
|
9096
|
+
}
|
|
9097
|
+
const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
|
|
9098
|
+
if (ownerKeys.length === 0) {
|
|
9099
|
+
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');
|
|
9100
|
+
}
|
|
9101
|
+
const uses = options.uses ?? 1;
|
|
9102
|
+
if (!Number.isInteger(uses) || uses < 1) {
|
|
9103
|
+
throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
|
|
9104
|
+
}
|
|
9105
|
+
const { members, reach } = await this.selectInvitationMembers();
|
|
9106
|
+
if (members.length === 0) {
|
|
9107
|
+
throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
|
|
9108
|
+
}
|
|
9109
|
+
const expiresInMs = options.expiresInMs
|
|
9110
|
+
?? (options.grantsOwner && !options.peerId ? OWNER_INVITATION_DEFAULT_TTL_MS : CADRE_INVITATION_DEFAULT_TTL_MS);
|
|
9111
|
+
const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
|
|
9112
|
+
const row = await service.insertCadreInvite({
|
|
9113
|
+
key: ed25519PublicKeyFromPrivate(invitePrivateKey),
|
|
9114
|
+
peerId: options.peerId ?? null,
|
|
9115
|
+
grantsOwner: options.grantsOwner,
|
|
9116
|
+
expiresAtMs: Date.now() + expiresInMs,
|
|
9117
|
+
totalUses: uses,
|
|
9118
|
+
});
|
|
9119
|
+
const invitation: CadreInvitation = {
|
|
9120
|
+
v: 1,
|
|
9121
|
+
partyId: this.config.controlNetwork.partyId,
|
|
9122
|
+
invitePrivateKey,
|
|
9123
|
+
invite: row,
|
|
9124
|
+
ownerKeys,
|
|
9125
|
+
members,
|
|
9126
|
+
};
|
|
9127
|
+
log('Cadre invitation minted: %s (peer=%s owner=%s uses=%d, %d member address(es), reach=%s)',
|
|
9128
|
+
row.key, row.peerId ?? 'any', row.grantsOwner, uses, members.length, reach);
|
|
9129
|
+
return { invitation, encoded: encodeCadreInvitation(invitation), reach };
|
|
7945
9130
|
}
|
|
7946
9131
|
|
|
7947
9132
|
/**
|
|
7948
|
-
*
|
|
9133
|
+
* Every cadre invitation this node holds — minted here or replicated in — with its
|
|
9134
|
+
* standing ({@link CadreInviteStatus}: live, withdrawn, redemptions recorded).
|
|
7949
9135
|
*/
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
9136
|
+
async listCadreInvitations(): Promise<CadreInviteStatus[]> {
|
|
9137
|
+
if (!this.controlDatabase) {
|
|
9138
|
+
throw new Error('CadreNode must be started before listing cadre invitations');
|
|
9139
|
+
}
|
|
9140
|
+
return await this.controlDatabase.listCadreInviteStatuses();
|
|
7954
9141
|
}
|
|
7955
9142
|
|
|
7956
9143
|
/**
|
|
7957
|
-
*
|
|
9144
|
+
* Withdraw a cadre invitation: an owner-signed `Revocation` tombstone over its row, which
|
|
9145
|
+
* stays as the proof of membership for every device it admitted. No further redemption
|
|
9146
|
+
* succeeds anywhere the tombstone reaches, and a member holding it refuses the bundle's
|
|
9147
|
+
* copy of the row. A withdrawal committed while alone is re-issued on cohort growth like
|
|
9148
|
+
* any other tombstone.
|
|
9149
|
+
*
|
|
9150
|
+
* @returns `true` when this call filed the tombstone, `false` when the row is not held
|
|
9151
|
+
* here or was already withdrawn.
|
|
7958
9152
|
*/
|
|
7959
|
-
async
|
|
9153
|
+
async withdrawCadreInvitation(key: string): Promise<boolean> {
|
|
7960
9154
|
if (!this.seedBootstrapService) {
|
|
7961
|
-
|
|
7962
|
-
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
-
|
|
7975
|
-
|
|
7976
|
-
|
|
7977
|
-
|
|
9155
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
9156
|
+
}
|
|
9157
|
+
return await this.seedBootstrapService.withdrawCadreInvite(key);
|
|
9158
|
+
}
|
|
9159
|
+
|
|
9160
|
+
/**
|
|
9161
|
+
* Join the cadre an invitation names by redeeming it at one of the member machines it
|
|
9162
|
+
* lists, the owner that minted it offline or not. Needs a started node with a stable
|
|
9163
|
+
* identity (`keyStore` or `privateKey`): the redemption is signed with the key behind
|
|
9164
|
+
* this node's peer id, and the member checks that the two match.
|
|
9165
|
+
*
|
|
9166
|
+
* Steps: refuse an invitation for another party; pin its `ownerKeys` into the node-local
|
|
9167
|
+
* anchor (`'invite'` provenance — the pin sticks even if the redemption then fails, as it
|
|
9168
|
+
* does for a seed invite, so a later attempt or seed from the same owner is anchored);
|
|
9169
|
+
* sign the request; dial the listed members in order (`redeemAtMembers`); on
|
|
9170
|
+
* acceptance verify the reply against the pinned keys, merge its dial hints into the peer
|
|
9171
|
+
* store, retain the answering member as a cold-start dial target, refresh the membership
|
|
9172
|
+
* gate and start a cohort reconcile pass. This node then syncs the control database over
|
|
9173
|
+
* the connection it already holds, the same unified behaviour as after a seed.
|
|
9174
|
+
*
|
|
9175
|
+
* A device that is already a member (a restart with the invitation still configured) is
|
|
9176
|
+
* answered as accepted by the member's idempotent redemption, with no second usage row.
|
|
9177
|
+
*
|
|
9178
|
+
* One pass over the listed members by default. With `options.waitForMember` the call keeps
|
|
9179
|
+
* making passes while every answer is retryable — for a device redeeming while no
|
|
9180
|
+
* always-on member is up — until a member accepts, one refuses finally, the invitation
|
|
9181
|
+
* expires (thrown as the `invite-spent` refusal) or `options.signal` aborts
|
|
9182
|
+
* (`redeemInPasses`). The request is signed once, before the first pass, and every pass
|
|
9183
|
+
* sends it: its `usageStampId` keys the usage row, so a member that admitted this node on a
|
|
9184
|
+
* pass whose reply was lost answers the next as already a member. `options.onProgress`
|
|
9185
|
+
* reports each pass and pause.
|
|
9186
|
+
*
|
|
9187
|
+
* When the invitation grants ownership, the member seats this node's key as an `OwnerKey`
|
|
9188
|
+
* row by consent. The caller still has to wire that key for signing afterwards, with
|
|
9189
|
+
* {@link wireOwnKeyIfOwner}; never `ensureOwnerKey`, the row is already there. Other
|
|
9190
|
+
* machines derive this node's key into their anchors from the row's stored proof on their
|
|
9191
|
+
* next membership refresh ({@link syncOwnerAnchor}).
|
|
9192
|
+
*
|
|
9193
|
+
* Limit: the reply check ({@link verifyRedeemReply}) does not authenticate the member. A
|
|
9194
|
+
* forged bundle, or a legitimate one whose addresses were swapped, dials a machine that
|
|
9195
|
+
* can echo the owner-signed row it was just sent; that is what pasting an attacker's
|
|
9196
|
+
* invitation means. What protects this node afterwards is its anchor: every row it then
|
|
9197
|
+
* syncs is judged against the pinned owner keys, so a machine that is not a member of the
|
|
9198
|
+
* cadre the invitation names can admit nobody this node will trust.
|
|
9199
|
+
*
|
|
9200
|
+
* @throws `CadreInviteRejectedError` on a final refusal, `CadreInviteReplyInvalidError` on
|
|
9201
|
+
* an acceptance that fails the reply check, `CadreInviteUnreachableError` when every
|
|
9202
|
+
* listed address was tried without an acceptance (one pass only), the signal's reason on
|
|
9203
|
+
* an abort, and a plain `Error` for the preconditions above.
|
|
9204
|
+
*/
|
|
9205
|
+
async redeemCadreInvitation(invitation: CadreInvitation, options: RedeemCadreInvitationOptions = {}): Promise<RedeemCadreInvitationResult> {
|
|
9206
|
+
const controlNode = this.controlNode;
|
|
9207
|
+
if (!controlNode || !this.controlDatabase || !this._running) {
|
|
9208
|
+
throw new Error('CadreNode must be started before redeeming a cadre invitation');
|
|
9209
|
+
}
|
|
9210
|
+
if (invitation.partyId !== this.config.controlNetwork.partyId) {
|
|
9211
|
+
throw new Error(`Cadre invitation is for party ${invitation.partyId}; this node serves ${this.config.controlNetwork.partyId}`);
|
|
9212
|
+
}
|
|
9213
|
+
const signer = this.getSelfSigningKey();
|
|
9214
|
+
if (!signer) {
|
|
9215
|
+
throw new Error('redeemCadreInvitation: this node runs on an ephemeral identity; configure `keyStore` or `privateKey` so its key can sign the redemption');
|
|
9216
|
+
}
|
|
9217
|
+
await this.trustOwnerKeys(invitation.ownerKeys, 'invite');
|
|
9218
|
+
const pinned = new Set(invitation.ownerKeys);
|
|
9219
|
+
const request = signRedeemRequest(
|
|
9220
|
+
invitation,
|
|
9221
|
+
{ peerKey: signer.publicKeyB64, peerPrivateKey: signer.privateKeyB64 },
|
|
9222
|
+
generateStampId(controlNode.peerId.toString()),
|
|
9223
|
+
await this.resolveInviteAddresses()
|
|
9224
|
+
);
|
|
9225
|
+
const { reply, memberPeerId, memberAddr } = await redeemInPasses(controlNode, {
|
|
9226
|
+
invitation,
|
|
9227
|
+
request,
|
|
9228
|
+
isTrustedIssuer: (ownerKey) => pinned.has(ownerKey),
|
|
9229
|
+
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
9230
|
+
waitForMember: options.waitForMember ?? false,
|
|
9231
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
9232
|
+
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
9233
|
+
});
|
|
9234
|
+
log('Cadre invitation %s redeemed at %s (owner=%s)', invitation.invite.key, memberAddr, reply.invite.grantsOwner);
|
|
9235
|
+
|
|
9236
|
+
const selfPeerId = controlNode.peerId.toString();
|
|
9237
|
+
await mergeSeedPeers(controlNode, reply.peers.filter((peer) => peer.peerId !== selfPeerId));
|
|
9238
|
+
if (memberPeerId !== null) {
|
|
9239
|
+
// Every address the bundle gave the answering member, so a restart has the same choices.
|
|
9240
|
+
const memberAddrs = this.parseMultiaddrs(invitation.members)
|
|
9241
|
+
.filter((addr) => trailingPeerId(addr) === memberPeerId)
|
|
9242
|
+
.map((addr) => addr.toString());
|
|
9243
|
+
this.retainDialTarget(memberPeerId, memberAddrs, 'redeemCadreInvitation');
|
|
7978
9244
|
}
|
|
7979
|
-
|
|
9245
|
+
// The pins above flip nothing yet (this node holds no rows), but the rows about to
|
|
9246
|
+
// replicate in are judged against them; refresh so the snapshot is not stale when they land.
|
|
9247
|
+
await this.refreshMembershipGate('invitation-redeemed');
|
|
9248
|
+
void this.reconcileControlCohort().catch((error) =>
|
|
9249
|
+
log('redeemCadreInvitation: the post-redemption reconcile pass failed: %o', error));
|
|
9250
|
+
return { peerId: memberPeerId, grantsOwner: reply.invite.grantsOwner, redeemedAt: new Date().toISOString() };
|
|
9251
|
+
}
|
|
9252
|
+
|
|
9253
|
+
/**
|
|
9254
|
+
* If this node's identity key is a live `OwnerKey` here, wire it for signing
|
|
9255
|
+
* ({@link initializeSeedBootstrap}) and return true; otherwise change nothing and return
|
|
9256
|
+
* false. For a machine that joined someone else's cadre by invitation: after an
|
|
9257
|
+
* owner-granting redemption, and on every later start. A redemption seats the request's
|
|
9258
|
+
* `peerKey`, the key behind this node's peer id, so the identity key is the one to look for.
|
|
9259
|
+
*
|
|
9260
|
+
* Unlike `ensureOwnerKey` it never inserts an `OwnerKey` row, so a machine that joined a
|
|
9261
|
+
* cadre can never genesis-insert itself into it, and a non-owner never anchors its own key.
|
|
9262
|
+
* An owner whose key is already wired (an earlier call) is answered true with nothing redone.
|
|
9263
|
+
*
|
|
9264
|
+
* NOTE: a node running the listener-only seed service ({@link enableSeedListener}) cannot be
|
|
9265
|
+
* wired this way: libp2p takes one seed handler, and {@link initializeSeedBootstrap} would
|
|
9266
|
+
* register a second. Nothing that joins by invitation enables the listener today; if one
|
|
9267
|
+
* does, replace the listener here rather than beside it.
|
|
9268
|
+
*
|
|
9269
|
+
* @throws when the node is not started.
|
|
9270
|
+
*/
|
|
9271
|
+
async wireOwnKeyIfOwner(): Promise<boolean> {
|
|
9272
|
+
if (!this.controlDatabase || !this._running) {
|
|
9273
|
+
throw new Error('CadreNode must be started before wiring its owner key');
|
|
9274
|
+
}
|
|
9275
|
+
const signingKey = this.getSelfSigningKey();
|
|
9276
|
+
if (!signingKey || !(await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64)) {
|
|
9277
|
+
log('wireOwnKeyIfOwner: this node\'s key is not an owner here; not wiring it');
|
|
9278
|
+
return false;
|
|
9279
|
+
}
|
|
9280
|
+
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
9281
|
+
await this.initializeSeedBootstrap(signingKey.privateKeyB64);
|
|
9282
|
+
}
|
|
9283
|
+
return true;
|
|
7980
9284
|
}
|
|
7981
9285
|
|
|
7982
9286
|
// ============================================================================
|
|
@@ -8122,18 +9426,28 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8122
9426
|
}
|
|
8123
9427
|
|
|
8124
9428
|
/**
|
|
8125
|
-
* Addresses of the party's other machines for an invitation's bootstrap list
|
|
8126
|
-
* ({@link selectInvitationSiblingAddrs} picks which
|
|
8127
|
-
|
|
8128
|
-
|
|
9429
|
+
* Addresses of the party's other machines for an open strand invitation's bootstrap list
|
|
9430
|
+
* ({@link createOpenInvitation}); {@link selectInvitationSiblingAddrs} picks which.
|
|
9431
|
+
*/
|
|
9432
|
+
private async siblingInvitationAddrs(): Promise<string[]> {
|
|
9433
|
+
const { siblings, connected } = await this.resolveInvitationSiblings();
|
|
9434
|
+
return selectInvitationSiblingAddrs(siblings, connected);
|
|
9435
|
+
}
|
|
9436
|
+
|
|
9437
|
+
/**
|
|
9438
|
+
* The party's other machines an invitation may name — an open strand invitation
|
|
9439
|
+
* ({@link siblingInvitationAddrs}) or a cadre invitation ({@link createCadreInvitation}) —
|
|
9440
|
+
* and which of them this node is connected to. The source is each authorized member's
|
|
9441
|
+
* signed `CadrePeer` record ({@link resolvePeerRecord}), so a machine whose record is
|
|
9442
|
+
* missing, stale or untrusted is left out; live connections only order them.
|
|
8129
9443
|
*
|
|
8130
9444
|
* Best-effort: a failed read leaves the invitation naming this machine alone, which is what
|
|
8131
9445
|
* it named before siblings were added.
|
|
8132
9446
|
*/
|
|
8133
|
-
private async
|
|
9447
|
+
private async resolveInvitationSiblings(): Promise<{ siblings: InvitationSibling[]; connected: ReadonlySet<string> }> {
|
|
8134
9448
|
const controlNode = this.controlNode;
|
|
8135
9449
|
if (!controlNode || !this.controlDatabase) {
|
|
8136
|
-
return [];
|
|
9450
|
+
return { siblings: [], connected: new Set() };
|
|
8137
9451
|
}
|
|
8138
9452
|
try {
|
|
8139
9453
|
const siblings: InvitationSibling[] = [];
|
|
@@ -8143,11 +9457,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8143
9457
|
siblings.push({ peerId, updatedAt: resolved.updatedAt, addrs: resolved.addrs.map(String) });
|
|
8144
9458
|
}
|
|
8145
9459
|
}
|
|
8146
|
-
|
|
8147
|
-
return selectInvitationSiblingAddrs(siblings, connected);
|
|
9460
|
+
return { siblings, connected: new Set(controlNode.getConnections().map((c) => c.remotePeer.toString())) };
|
|
8148
9461
|
} catch (error) {
|
|
8149
|
-
log('
|
|
8150
|
-
return [];
|
|
9462
|
+
log('siblingInvitationAddrs: resolving the other machines failed; the invitation names only this one: %o', error);
|
|
9463
|
+
return { siblings: [], connected: new Set() };
|
|
8151
9464
|
}
|
|
8152
9465
|
}
|
|
8153
9466
|
|
|
@@ -8178,7 +9491,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8178
9491
|
disclosure,
|
|
8179
9492
|
this.controlNode
|
|
8180
9493
|
);
|
|
8181
|
-
await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs);
|
|
9494
|
+
await this.recordFormationStrandAddrs(result.strandId, result.strandAddrs, 'formStrand');
|
|
8182
9495
|
// A closed host strand's approval carries the joiner's own membership invitation —
|
|
8183
9496
|
// persist this party's identity key and stage the invitation for bring-up to redeem.
|
|
8184
9497
|
// Runs AFTER the addr recording so a persistence failure (which throws — see the
|
|
@@ -8194,7 +9507,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8194
9507
|
* Ask to join through `invitation` and have the party keep trying until the join works, the
|
|
8195
9508
|
* invitation is used up or expires, or the request is dismissed: across restarts, and from
|
|
8196
9509
|
* any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
|
|
8197
|
-
* offline"). Records a party-wide `
|
|
9510
|
+
* offline"). Records a party-wide `JoinRequest` row, runs one attempt on this machine at
|
|
8198
9511
|
* once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
|
|
8199
9512
|
* when it was unreachable or not ready, `'failed'` when it refused for good.
|
|
8200
9513
|
*
|
|
@@ -8269,17 +9582,17 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8269
9582
|
}
|
|
8270
9583
|
|
|
8271
9584
|
/**
|
|
8272
|
-
* Write a join request
|
|
8273
|
-
* pending one as it is, a finished one
|
|
9585
|
+
* Write a join request, or adopt the party's existing request for the same invitation: a
|
|
9586
|
+
* pending one as it is, a finished one rewritten as `requested` (the user asked again).
|
|
8274
9587
|
*/
|
|
8275
9588
|
private async recordJoinRequest(
|
|
8276
9589
|
database: ControlDatabase,
|
|
8277
|
-
requested:
|
|
9590
|
+
requested: JoinRequestFields,
|
|
8278
9591
|
{ ownerKey, signMessage }: { ownerKey: string; signMessage: (message: Uint8Array) => string }
|
|
8279
|
-
): Promise<
|
|
8280
|
-
let written:
|
|
9592
|
+
): Promise<PendingJoin> {
|
|
9593
|
+
let written: PendingJoin;
|
|
8281
9594
|
try {
|
|
8282
|
-
written = await database.
|
|
9595
|
+
written = await database.insertJoinRequest(requested, ownerKey, signMessage);
|
|
8283
9596
|
} catch (error) {
|
|
8284
9597
|
if (!isPendingJoinConflict(error)) {
|
|
8285
9598
|
throw error;
|
|
@@ -8292,12 +9605,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8292
9605
|
{ cause: error }
|
|
8293
9606
|
);
|
|
8294
9607
|
}
|
|
8295
|
-
if (existing.
|
|
9608
|
+
if (existing.outcome === null) {
|
|
8296
9609
|
return existing;
|
|
8297
9610
|
}
|
|
8298
|
-
written = await database.
|
|
9611
|
+
written = await database.rewritePendingJoin(existing.StampId, { ...requested, outcome: null }, ownerKey, signMessage);
|
|
8299
9612
|
}
|
|
8300
|
-
this.pendingJoinRunner?.noteWritten(written);
|
|
9613
|
+
this.pendingJoinRunner?.noteWritten(written, 'request');
|
|
8301
9614
|
return written;
|
|
8302
9615
|
}
|
|
8303
9616
|
|
|
@@ -8315,15 +9628,19 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8315
9628
|
selfId: this.controlNode!.peerId.toString(),
|
|
8316
9629
|
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
8317
9630
|
isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
|
|
8318
|
-
// NOTE: one owner-key read and one
|
|
8319
|
-
// asked for a join the second reads
|
|
8320
|
-
// shows up in a device profile, poll more slowly while the
|
|
9631
|
+
// NOTE: one owner-key read and one pending-join read (three tables) per pass (30 s); for a
|
|
9632
|
+
// party that never asked for a join the second reads never-written blocks, which consults
|
|
9633
|
+
// the cohort. If it shows up in a device profile, poll more slowly while the tables read empty.
|
|
8321
9634
|
readRows: () => database().queryPendingJoins(),
|
|
8322
9635
|
readRow: (id) => database().queryPendingJoin(id),
|
|
8323
|
-
attempt: (
|
|
8324
|
-
|
|
9636
|
+
attempt: (join) => this.formStrand(this.pendingJoinInvitation(join), parseStoredDisclosure(join)),
|
|
9637
|
+
record: async (expected, outcome) => {
|
|
9638
|
+
const { ownerKey, signMessage } = await outcomeSigner();
|
|
9639
|
+
return database().recordJoinOutcome(expected, outcome, ownerKey, signMessage);
|
|
9640
|
+
},
|
|
9641
|
+
rewrite: async (expectedStampId, next) => {
|
|
8325
9642
|
const { ownerKey, signMessage } = await outcomeSigner();
|
|
8326
|
-
return database().
|
|
9643
|
+
return database().rewritePendingJoin(expectedStampId, next, ownerKey, signMessage);
|
|
8327
9644
|
},
|
|
8328
9645
|
remove: async (id) => {
|
|
8329
9646
|
const { ownerKey, signMessage } = await outcomeSigner();
|
|
@@ -8337,33 +9654,33 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8337
9654
|
this.pendingJoinRunner.start();
|
|
8338
9655
|
}
|
|
8339
9656
|
|
|
8340
|
-
/** The invitation a `
|
|
8341
|
-
private pendingJoinInvitation(
|
|
9657
|
+
/** The invitation a `JoinRequest` row stores. A failure names the row, never the invitation, which is a bearer credential. */
|
|
9658
|
+
private pendingJoinInvitation(join: PendingJoin): OpenInvitation {
|
|
8342
9659
|
try {
|
|
8343
|
-
return this.decodeInvitation(
|
|
9660
|
+
return this.decodeInvitation(join.Invitation);
|
|
8344
9661
|
} catch {
|
|
8345
|
-
throw new Error(`
|
|
9662
|
+
throw new Error(`JoinRequest ${join.Id}: the stored invitation does not decode`);
|
|
8346
9663
|
}
|
|
8347
9664
|
}
|
|
8348
9665
|
|
|
8349
|
-
private pendingJoinSAppId(
|
|
9666
|
+
private pendingJoinSAppId(join: PendingJoin): string {
|
|
8350
9667
|
try {
|
|
8351
|
-
return this.decodeInvitation(
|
|
9668
|
+
return this.decodeInvitation(join.Invitation).sAppId;
|
|
8352
9669
|
} catch {
|
|
8353
|
-
log('
|
|
9670
|
+
log('JoinRequest %s: the stored invitation does not decode; reporting no sApp', join.Id);
|
|
8354
9671
|
return '';
|
|
8355
9672
|
}
|
|
8356
9673
|
}
|
|
8357
9674
|
|
|
8358
9675
|
/**
|
|
8359
|
-
* Stage the membership invitation of each
|
|
9676
|
+
* Stage the membership invitation of each joined request this process has not staged yet, so a
|
|
8360
9677
|
* closed strand joined on another owner machine is seated by whichever machine launches it
|
|
8361
9678
|
* first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
|
|
8362
9679
|
* seated. A strand that already has an invitation staged keeps it, for the reconciler to
|
|
8363
9680
|
* settle first.
|
|
8364
9681
|
*/
|
|
8365
|
-
private stageMembershipInvitesFromPendingJoins(
|
|
8366
|
-
for (const { rowId, strandId, invite } of membershipInvitesToStage(
|
|
9682
|
+
private stageMembershipInvitesFromPendingJoins(joins: readonly PendingJoin[]): void {
|
|
9683
|
+
for (const { rowId, strandId, invite } of membershipInvitesToStage(joins, this.stagedMembershipInviteKeys, Date.now())) {
|
|
8367
9684
|
this.stagedMembershipInviteKeys.add(invite.inviteKey);
|
|
8368
9685
|
if (this.pendingMembershipInvites.has(strandId)) {
|
|
8369
9686
|
continue;
|
|
@@ -8643,16 +9960,16 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8643
9960
|
* Nothing else would deliver them: the seed is read only at launch and resume, and the
|
|
8644
9961
|
* refresh pass skips a peer FRET already holds a record for — the stale one, here.
|
|
8645
9962
|
*/
|
|
8646
|
-
private async recordFormationStrandAddrs(strandId: string, strandAddrs: readonly string[]): Promise<void> {
|
|
9963
|
+
private async recordFormationStrandAddrs(strandId: string, strandAddrs: readonly string[], caller: string): Promise<void> {
|
|
8647
9964
|
const attributed = [...groupAddrsByPeerId([...strandAddrs]).values()]
|
|
8648
9965
|
.flatMap((peerAddrs) => peerAddrs.map((addr) => addr.toString()));
|
|
8649
9966
|
if (attributed.length === 0) {
|
|
8650
|
-
log('
|
|
8651
|
-
strandId, strandAddrs.length);
|
|
9967
|
+
log('%s: no usable strand addrs for %s (%d carried) — cross-party seed unchanged',
|
|
9968
|
+
caller, strandId, strandAddrs.length);
|
|
8652
9969
|
return;
|
|
8653
9970
|
}
|
|
8654
9971
|
this.formationStrandAddrs.set(strandId, attributed);
|
|
8655
|
-
log('
|
|
9972
|
+
log('%s: kept %d of %d cross-party strand addr(s) for %s', caller, attributed.length, strandAddrs.length, strandId);
|
|
8656
9973
|
const runningNode = this.strandManager.getInstance(strandId)?.libp2pNode;
|
|
8657
9974
|
if (runningNode) {
|
|
8658
9975
|
await this.mergeStrandPeerAddrs(runningNode, attributed, strandId);
|