@serfab/cadre-core 1.15.0 → 1.17.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 +1 -1
- package/dist/bootstrap-peer-store-file.js +1 -1
- package/dist/bootstrap-peer-store.d.ts +1 -1
- package/dist/bootstrap-peer-store.js +4 -4
- package/dist/cadre-invite-protocol.d.ts +55 -138
- package/dist/cadre-invite-protocol.js +94 -449
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +15 -2
- package/dist/cadre-join-request.js +22 -3
- package/dist/cadre-join-request.js.map +1 -1
- package/dist/cadre-node.d.ts +530 -247
- package/dist/cadre-node.js +1046 -525
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +36 -14
- package/dist/control-authorization.js +20 -3
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +22 -23
- package/dist/control-cohort.js +24 -24
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +416 -136
- package/dist/control-database.js +757 -378
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +5 -4
- package/dist/control-protocol-guard.js +6 -3
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +80 -0
- package/dist/control-schema-version.js +203 -0
- package/dist/control-schema-version.js.map +1 -0
- package/dist/control-schema.d.ts +45 -5
- package/dist/control-schema.js +254 -70
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +5 -5
- package/dist/control-write-retry.js +10 -8
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +13 -0
- package/dist/delegate-admission.js +27 -0
- package/dist/delegate-admission.js.map +1 -1
- package/dist/ed25519-key.d.ts +10 -7
- package/dist/ed25519-key.js +23 -20
- package/dist/ed25519-key.js.map +1 -1
- package/dist/edge-node/config.d.ts +122 -0
- package/dist/edge-node/config.js +199 -0
- package/dist/edge-node/config.js.map +1 -0
- package/dist/edge-node/dissolve.d.ts +95 -0
- package/dist/edge-node/dissolve.js +184 -0
- package/dist/edge-node/dissolve.js.map +1 -0
- package/dist/edge-node/edge-join.d.ts +44 -0
- package/dist/edge-node/edge-join.js +227 -0
- package/dist/edge-node/edge-join.js.map +1 -0
- package/dist/edge-node/edge-node.d.ts +295 -0
- package/dist/edge-node/edge-node.js +704 -0
- package/dist/edge-node/edge-node.js.map +1 -0
- package/dist/edge-node/index.d.ts +18 -0
- package/dist/edge-node/index.js +18 -0
- package/dist/edge-node/index.js.map +1 -0
- package/dist/edge-node/join-credential.d.ts +11 -0
- package/dist/edge-node/join-credential.js +11 -0
- package/dist/edge-node/join-credential.js.map +1 -0
- package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
- package/dist/edge-node/lazy-raw-storage.js +134 -0
- package/dist/edge-node/lazy-raw-storage.js.map +1 -0
- package/dist/edge-node/node-local.d.ts +60 -0
- package/dist/edge-node/node-local.js +58 -0
- package/dist/edge-node/node-local.js.map +1 -0
- package/dist/edge-node/options.d.ts +83 -0
- package/dist/edge-node/options.js +110 -0
- package/dist/edge-node/options.js.map +1 -0
- package/dist/edge-node/owner-grant.d.ts +41 -0
- package/dist/edge-node/owner-grant.js +38 -0
- package/dist/edge-node/owner-grant.js.map +1 -0
- package/dist/edge-node/platform.d.ts +140 -0
- package/dist/edge-node/platform.js +12 -0
- package/dist/edge-node/platform.js.map +1 -0
- package/dist/edge-node/strand-move.d.ts +115 -0
- package/dist/edge-node/strand-move.js +223 -0
- package/dist/edge-node/strand-move.js.map +1 -0
- package/dist/edge-node/strands.d.ts +28 -0
- package/dist/edge-node/strands.js +60 -0
- package/dist/edge-node/strands.js.map +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.js +26 -7
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +3 -3
- package/dist/joined-strand-store.js +3 -3
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store.d.ts +9 -4
- package/dist/key-store.js +9 -4
- package/dist/key-store.js.map +1 -1
- package/dist/machine-removal.d.ts +19 -39
- package/dist/machine-removal.js +24 -98
- package/dist/machine-removal.js.map +1 -1
- package/dist/member-redemption.d.ts +250 -0
- package/dist/member-redemption.js +485 -0
- package/dist/member-redemption.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +4 -4
- package/dist/owner-anchor-sync.js +16 -15
- package/dist/owner-anchor-sync.js.map +1 -1
- package/dist/owner-grant-protocol.d.ts +247 -0
- package/dist/owner-grant-protocol.js +330 -0
- package/dist/owner-grant-protocol.js.map +1 -0
- package/dist/owner-key-export.d.ts +70 -0
- package/dist/owner-key-export.js +279 -0
- package/dist/owner-key-export.js.map +1 -0
- package/dist/owner-key-flows.d.ts +55 -0
- package/dist/owner-key-flows.js +60 -0
- package/dist/owner-key-flows.js.map +1 -0
- package/dist/owner-key-store.d.ts +31 -0
- package/dist/owner-key-store.js +81 -0
- package/dist/owner-key-store.js.map +1 -0
- package/dist/owner-keys.d.ts +78 -0
- package/dist/owner-keys.js +133 -0
- package/dist/owner-keys.js.map +1 -0
- package/dist/owner-signer.d.ts +54 -0
- package/dist/owner-signer.js +51 -0
- package/dist/owner-signer.js.map +1 -0
- package/dist/peer-authorization.d.ts +83 -18
- package/dist/peer-authorization.js +123 -53
- package/dist/peer-authorization.js.map +1 -1
- package/dist/provider/rent-node.js +1 -1
- package/dist/provider/rent-node.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +131 -109
- package/dist/seed-bootstrap.js +260 -248
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.js +4 -4
- package/dist/strand-addr-protocol.d.ts +4 -1
- package/dist/strand-addr-protocol.js +8 -2
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-cohort.d.ts +3 -0
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +1 -0
- package/dist/strand-database.js +3 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +4 -3
- package/dist/strand-first-sync-gate.js +4 -3
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +1 -1
- package/dist/strand-membership-writer.d.ts +13 -2
- package/dist/strand-membership-writer.js +52 -16
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.d.ts +6 -5
- package/dist/strand-move.js +31 -30
- package/dist/strand-move.js.map +1 -1
- package/dist/strand-peer-redial.js +2 -3
- package/dist/strand-peer-redial.js.map +1 -1
- package/dist/types.d.ts +286 -103
- package/dist/types.js.map +1 -1
- package/package.json +16 -7
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +214 -214
- package/src/cadre-invite-protocol.ts +131 -531
- package/src/cadre-join-request.ts +188 -167
- package/src/cadre-node.ts +1166 -541
- package/src/control-authorization.ts +47 -14
- package/src/control-cohort.ts +127 -128
- package/src/control-database.ts +940 -456
- package/src/control-protocol-guard.ts +7 -4
- package/src/control-schema-version.ts +251 -0
- package/src/control-schema.ts +265 -70
- package/src/control-write-retry.ts +569 -567
- package/src/delegate-admission.ts +37 -0
- package/src/ed25519-key.ts +174 -171
- package/src/edge-node/config.ts +277 -0
- package/src/edge-node/dissolve.ts +251 -0
- package/src/edge-node/edge-join.ts +319 -0
- package/src/edge-node/edge-node.ts +1045 -0
- package/src/edge-node/index.ts +59 -0
- package/src/edge-node/join-credential.ts +16 -0
- package/src/edge-node/lazy-raw-storage.ts +178 -0
- package/src/edge-node/node-local.ts +84 -0
- package/src/edge-node/options.ts +179 -0
- package/src/edge-node/owner-grant.ts +63 -0
- package/src/edge-node/platform.ts +151 -0
- package/src/edge-node/strand-move.ts +270 -0
- package/src/edge-node/strands.ts +72 -0
- package/src/index.ts +102 -5
- package/src/joined-strand-store.ts +4 -4
- package/src/key-store.ts +10 -4
- package/src/machine-removal.ts +26 -111
- package/src/member-redemption.ts +642 -0
- package/src/owner-anchor-sync.ts +19 -18
- package/src/owner-grant-protocol.ts +532 -0
- package/src/owner-key-export.ts +348 -0
- package/src/owner-key-flows.ts +109 -0
- package/src/owner-key-store.ts +85 -0
- package/src/owner-keys.ts +178 -0
- package/src/owner-signer.ts +94 -0
- package/src/peer-authorization.ts +558 -488
- package/src/provider/rent-node.ts +1 -1
- package/src/seed-bootstrap.ts +301 -312
- package/src/seed-trust-policy.ts +4 -4
- package/src/strand-addr-protocol.ts +547 -538
- package/src/strand-cohort.ts +4 -0
- package/src/strand-database.ts +3 -0
- package/src/strand-first-sync-gate.ts +4 -3
- package/src/strand-instance-manager.ts +1 -1
- package/src/strand-membership-writer.ts +61 -16
- package/src/strand-move.ts +45 -36
- package/src/strand-peer-redial.ts +2 -3
- package/src/types.ts +304 -103
package/dist/cadre-node.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createLibp2pNode } from '@optimystic/db-p2p';
|
|
|
6
6
|
import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
|
|
7
7
|
import { multiaddr } from '@multiformats/multiaddr';
|
|
8
8
|
import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
|
|
9
|
-
import { generatePrivateKey
|
|
9
|
+
import { generatePrivateKey } from '@optimystic/quereus-plugin-crypto';
|
|
10
10
|
import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
11
11
|
import { strandTransportKey } from './strand-transport-key.js';
|
|
12
12
|
import { assertScopeKeyCharset, assertStrandScopeKey, controlStorageScope, InvalidStrandIdError, isValidStrandScopeKey } from './storage-scope.js';
|
|
@@ -16,6 +16,7 @@ import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
|
|
|
16
16
|
import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
|
|
17
17
|
import { loadOrCreateIdentityKey } from './identity-key.js';
|
|
18
18
|
import { MemoryTrustedOwnerStore } from './trusted-owner-store.js';
|
|
19
|
+
import { softwareOwnerSigner, withinOwnerApproval } from './owner-signer.js';
|
|
19
20
|
import { MemoryBootstrapPeerStore } from './bootstrap-peer-store.js';
|
|
20
21
|
import { MemoryStrandNetworkStateStore } from './strand-network-state.js';
|
|
21
22
|
import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
|
|
@@ -24,7 +25,8 @@ import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
|
|
|
24
25
|
import { strandFretPeerAddrs } from './strand-fret-addrs.js';
|
|
25
26
|
import { verifyCadrePeerVoucher, verifyInvitationAdmission, verifyRevocationSigner } from './peer-authorization.js';
|
|
26
27
|
import { deriveOwnerAnchor } from './owner-anchor-sync.js';
|
|
27
|
-
import { describeMachines, planMachineRemoval,
|
|
28
|
+
import { describeMachines, planMachineRemoval, MachineRemovalRefusedError } from './machine-removal.js';
|
|
29
|
+
import { describeOwnerKeys, namedOwnerKey, planOwnerKeyRemoval, revouchWithinPlan, OwnerKeyAddRefusedError, OwnerKeyRemovalRefusedError } from './owner-keys.js';
|
|
28
30
|
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
29
31
|
import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
|
|
30
32
|
import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
|
|
@@ -37,8 +39,9 @@ import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS
|
|
|
37
39
|
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, relayedRequestBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
38
40
|
import { EnrollmentService } from './enrollment.js';
|
|
39
41
|
import { HibernationManager } from './hibernation-manager.js';
|
|
40
|
-
import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, pendingJoinId } from './control-database.js';
|
|
42
|
+
import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, IssuedRowNotHereYetError, pendingJoinId } from './control-database.js';
|
|
41
43
|
import { CadreInviteHandler, encodeCadreInvitation, redeemInPasses, signRedeemRequest } from './cadre-invite-protocol.js';
|
|
44
|
+
import { OwnerGrantHandler, OwnerGrantRejectedError, encodeOwnerGrant, redeemOwnerGrantInPasses, signOwnerGrantRequest } from './owner-grant-protocol.js';
|
|
42
45
|
import { FormationPostApprovalError } from './strand-formation-rejection.js';
|
|
43
46
|
import { PendingJoinRunner, membershipInvitesToStage, parseStoredDisclosure, requestedPendingJoin } from './pending-join-runner.js';
|
|
44
47
|
import { SeedBootstrapService, mergeSeedPeers, seedDigest } from './seed-bootstrap.js';
|
|
@@ -51,7 +54,7 @@ import { createMembershipConnectionGater, UnauthorizedReservationBudget } from '
|
|
|
51
54
|
import { controlNetworkName, installControlProtocolGuard } from './control-protocol-guard.js';
|
|
52
55
|
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
53
56
|
import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
|
|
54
|
-
import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, prunePeerStrandKeys, peerStrandKey } from './delegate-admission.js';
|
|
57
|
+
import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, prunePeerStrandKeys, peerStrandKey, recordRelayRefusals, relaysNotRefusing } from './delegate-admission.js';
|
|
55
58
|
import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
|
|
56
59
|
import { nodeTransportsOption } from './node-transports.js';
|
|
57
60
|
import { resolveRelayServer } from './relay-server.js';
|
|
@@ -85,13 +88,28 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
|
|
|
85
88
|
*/
|
|
86
89
|
export const STRAND_PEER_ADDR_RETRY_MS = 60 * 1000;
|
|
87
90
|
/**
|
|
88
|
-
* Default lifetime of an
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
91
|
+
* Default lifetime of an owner grant ({@link CadreNode.createOwnerGrant}): whoever holds the
|
|
92
|
+
* bundle can become an owner, so it is a bearer credential for admin rights and lives only
|
|
93
|
+
* long enough to be pasted or scanned by the device it was minted for. A backup grant is minted
|
|
94
|
+
* with no expiry, by explicit choice.
|
|
92
95
|
*/
|
|
93
|
-
export const
|
|
94
|
-
/**
|
|
96
|
+
export const OWNER_GRANT_DEFAULT_TTL_MS = 15 * 60 * 1000;
|
|
97
|
+
/**
|
|
98
|
+
* Lifetime of the invitation {@link CadreNode.joinWithOwnerGrant} issues to itself: redeemed in
|
|
99
|
+
* the same call at the member that just wrote the grant, so the grant's short default is plenty,
|
|
100
|
+
* and a later call signs a fresh one.
|
|
101
|
+
*/
|
|
102
|
+
const SELF_INVITATION_TTL_MS = OWNER_GRANT_DEFAULT_TTL_MS;
|
|
103
|
+
/** How often {@link CadreNode.joinWithOwnerGrant} asks again whether its own `OwnerKey` row has reached this node. */
|
|
104
|
+
const OWNER_ROW_SYNC_RECHECK_MS = 2000;
|
|
105
|
+
/**
|
|
106
|
+
* Bound on that wait, the edge node's owner-step bound (`DEFAULT_OWNER_GENESIS_TIMEOUT_MS` in
|
|
107
|
+
* `edge-node/config.ts`): the join has already succeeded at the member, so past it the call
|
|
108
|
+
* returns with the key unwired rather than wait out a stalled sync.
|
|
109
|
+
*/
|
|
110
|
+
// eslint-disable-next-line no-restricted-syntax -- cuts off by design: the call goes on with the key unwired rather than wait out a stalled control sync; `wireOwnKeyIfOwner` wires it later
|
|
111
|
+
const OWNER_ROW_SYNC_TIMEOUT_MS = 60000;
|
|
112
|
+
/** Default lifetime of a cadre invitation ({@link CadreNode.createCadreInvitation}): it admits a machine and grants nothing else, so it need not be short. */
|
|
95
113
|
export const CADRE_INVITATION_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
96
114
|
/**
|
|
97
115
|
* A circuit relay as a strand-addr RPC target: dialed by peerId, with its
|
|
@@ -100,6 +118,42 @@ export const CADRE_INVITATION_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
100
118
|
function relayStrandAddrPeer(relay) {
|
|
101
119
|
return { peerId: relay.relayPeerId, addrs: [multiaddr(relay.relayAddr)] };
|
|
102
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* What the holder of an owner grant approves on the signer whose key it seats: one request for
|
|
123
|
+
* the whole action, naming the party (as every owner signing request does) and the grant.
|
|
124
|
+
*/
|
|
125
|
+
function ownerGrantApproval(bundle, scope = 'redeem') {
|
|
126
|
+
const grant = namedOwnerKey(bundle.grant.key, bundle.grant.label);
|
|
127
|
+
return {
|
|
128
|
+
partyId: bundle.partyId,
|
|
129
|
+
action: scope === 'join'
|
|
130
|
+
? `Join the cadre with this device and make this key one of its owners, with the owner grant ${grant}`
|
|
131
|
+
: `Make this key an owner of the cadre, with the owner grant ${grant}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Run one of the local grant path's writes, refusing `conflict` (retryable) when the grant row
|
|
136
|
+
* the cadre holds is not readable here yet, as a member answers the same failure over the protocol.
|
|
137
|
+
*/
|
|
138
|
+
async function refusingUnsyncedGrant(write) {
|
|
139
|
+
try {
|
|
140
|
+
return await write();
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error instanceof IssuedRowNotHereYetError) {
|
|
144
|
+
log('owner grant write refused until this node syncs: %s', error.message);
|
|
145
|
+
throw new OwnerGrantRejectedError('conflict', 'The cadre holds the grant, and this node has not synced it yet; try again shortly');
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** `members` with the addresses naming `peerId` (by their trailing `/p2p/`) first, order otherwise kept; unchanged for a null `peerId`. */
|
|
151
|
+
function addressesOfFirst(members, peerId) {
|
|
152
|
+
if (peerId === null)
|
|
153
|
+
return [...members];
|
|
154
|
+
const names = (addr) => addr.endsWith(`/p2p/${peerId}`);
|
|
155
|
+
return [...members.filter(names), ...members.filter((addr) => !names(addr))];
|
|
156
|
+
}
|
|
103
157
|
/**
|
|
104
158
|
* Whether a sibling actually answered a strand-addr RPC — with addresses or
|
|
105
159
|
* without — as opposed to being unreachable, unavailable, or refusing us. Decides
|
|
@@ -132,13 +186,14 @@ function unionAddrs(primary, extra) {
|
|
|
132
186
|
}
|
|
133
187
|
return out;
|
|
134
188
|
}
|
|
135
|
-
/**
|
|
136
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Whether a `CadrePeer` or `OwnerKey` row's proof is a consent admission rather than an owner
|
|
191
|
+
* voucher (`types.ts` → `CadrePeerRow`, `OwnerKeyRow`): a machine invitation for a `CadrePeer`
|
|
192
|
+
* row, an owner grant for an `OwnerKey` row.
|
|
193
|
+
*/
|
|
194
|
+
function isConsentSeated(row) {
|
|
137
195
|
return row.vouchSig === null && row.vouchUsage !== null;
|
|
138
196
|
}
|
|
139
|
-
function signMessageWith(privateKeyB64) {
|
|
140
|
-
return (message) => sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
|
|
141
|
-
}
|
|
142
197
|
/**
|
|
143
198
|
* Reject a blank identifier before it reaches the control database, where it would fail an
|
|
144
199
|
* authorization CHECK as an opaque constraint error rather than an actionable message.
|
|
@@ -155,13 +210,13 @@ function requireNonBlank(value, label) {
|
|
|
155
210
|
* Is the live `Strand` row the one a publish of `desired` would have produced?
|
|
156
211
|
*
|
|
157
212
|
* "Identical content" means `(Type, MemberPrivateKey)` and deliberately NOT
|
|
158
|
-
* `FounderOwnerKey`:
|
|
159
|
-
* content. Two machines of one party racing to found
|
|
160
|
-
* "the winner's row stands" — the loser adopts the row
|
|
161
|
-
* it, correctly comes up as a joiner (see
|
|
162
|
-
*
|
|
163
|
-
* `StampId` is a single-use nonce, so the two
|
|
164
|
-
* is. A live row matching on both is, by construction, the state a repeat publish would
|
|
213
|
+
* `FounderOwnerKey` or `FounderPeerId`: those columns are provenance (WHICH owner signed the
|
|
214
|
+
* row and WHICH machine founds it), not content. Two machines of one party racing to found
|
|
215
|
+
* the same id must keep resolving as "the winner's row stands" — the loser adopts the row
|
|
216
|
+
* and, since the winner's peer id is on it, correctly comes up as a joiner (see
|
|
217
|
+
* `launchStrand`'s founder derivation). Comparing them here would turn that benign race
|
|
218
|
+
* into a hard throw. `Id` is the lookup key and `StampId` is a single-use nonce, so the two
|
|
219
|
+
* compared columns are all the content there is. A live row matching on both is, by construction, the state a repeat publish would
|
|
165
220
|
* have reached, whichever branch of `Strand.AuthorizedInsert` seated it (owner-signed, or
|
|
166
221
|
* the unsigned consent branch an invite redemption uses).
|
|
167
222
|
*
|
|
@@ -301,6 +356,13 @@ export class CadreNode {
|
|
|
301
356
|
* once that database is up, on every node.
|
|
302
357
|
*/
|
|
303
358
|
this.cadreInviteHandler = null;
|
|
359
|
+
/**
|
|
360
|
+
* Owner grant redemption handler (`/sereus/owner-grant/1.0.0`): a holder of a grant dials
|
|
361
|
+
* this machine and proves possession of the grant and of the key it seats; the handler seats
|
|
362
|
+
* the row and writes the `OwnerKey` row against this node's control database. Registered by
|
|
363
|
+
* {@link start} beside the invitation handler, on every node.
|
|
364
|
+
*/
|
|
365
|
+
this.ownerGrantHandler = null;
|
|
304
366
|
/**
|
|
305
367
|
* Server-side push-wake fan-out. Constructed by {@link start} only when
|
|
306
368
|
* `config.push` (an injected `PushNotifier` + policy) is present — without it
|
|
@@ -473,6 +535,25 @@ export class CadreNode {
|
|
|
473
535
|
* strand is no longer running are pruned on each reconcile pass.
|
|
474
536
|
*/
|
|
475
537
|
this.delegateAnnounceAt = new Map();
|
|
538
|
+
/**
|
|
539
|
+
* Relay peerIds whose protocol negotiation refused the delegate announcement
|
|
540
|
+
* (strand-addr outcome `unsupported`), recorded and cleared by
|
|
541
|
+
* {@link recordDelegateAnnounces} from every announce pass's outcomes. Only a
|
|
542
|
+
* party-run relay has a membership gate that needs a grant, and it serves
|
|
543
|
+
* strand-addr, so a refusal marks a dedicated relay: {@link circuitRelayTargets}
|
|
544
|
+
* leaves it out of the founding/resume and periodic announcements, which would
|
|
545
|
+
* otherwise spend a protocol negotiation on it every time (gotchoices/sereus#49). Not
|
|
546
|
+
* pruned: one entry per relay this node ever named.
|
|
547
|
+
*
|
|
548
|
+
* {@link announceDelegateToRelay} still asks every relay, remembered or not, and
|
|
549
|
+
* its reply is what clears an entry. A party-run relay registers its strand-addr
|
|
550
|
+
* handler late in its own `start()`, after its libp2p node and relay server are
|
|
551
|
+
* up, so an announcement landing in that window is refused by a relay that serves
|
|
552
|
+
* the protocol a moment later. If the re-drive hook skipped remembered relays too,
|
|
553
|
+
* that relay would stay skipped until this node restarts and its gate would deny
|
|
554
|
+
* every reservation through it.
|
|
555
|
+
*/
|
|
556
|
+
this.relaysRefusingStrandAddr = new Set();
|
|
476
557
|
/**
|
|
477
558
|
* When each connected sibling is next due a strand-addr RPC for each running
|
|
478
559
|
* strand, keyed `peerStrandKey(siblingControlPeerId, strandId)` → epoch ms; a
|
|
@@ -598,7 +679,7 @@ export class CadreNode {
|
|
|
598
679
|
/**
|
|
599
680
|
* Dial targets learned out of band, keyed by peer id, valued by the multiaddr
|
|
600
681
|
* strings this node was handed (parsed lazily, at dial time). Two writers:
|
|
601
|
-
* {@link recordSeedBootstrapPeers} retains the
|
|
682
|
+
* {@link recordSeedBootstrapPeers} retains the preferred peers of every seed
|
|
602
683
|
* this node applies, and {@link addDrone} retains the addresses of every node
|
|
603
684
|
* this node adds. Two readers: {@link dialColdStartBootstrap} dials every entry
|
|
604
685
|
* while the control database still has no siblings, and
|
|
@@ -1062,10 +1143,20 @@ export class CadreNode {
|
|
|
1062
1143
|
// After the control database is up, since the handler seats and redeems against it.
|
|
1063
1144
|
this.cadreInviteHandler = new CadreInviteHandler({
|
|
1064
1145
|
partyId: this.config.controlNetwork.partyId,
|
|
1146
|
+
peerId: this.controlNode.peerId.toString(),
|
|
1065
1147
|
store: this.controlDatabase,
|
|
1066
1148
|
admitDevice: (peerId) => this.admitRedeemingDevice(peerId),
|
|
1067
1149
|
});
|
|
1068
1150
|
await this.cadreInviteHandler.register(this.controlNode);
|
|
1151
|
+
// Answer owner grant redemptions likewise: a holder dials any member and proves possession
|
|
1152
|
+
// of the grant and of the key it seats; no machine is admitted, so the holder's identity
|
|
1153
|
+
// is not checked.
|
|
1154
|
+
this.ownerGrantHandler = new OwnerGrantHandler({
|
|
1155
|
+
partyId: this.config.controlNetwork.partyId,
|
|
1156
|
+
store: this.controlDatabase,
|
|
1157
|
+
admitDevice: (peerId) => this.admitRedeemingDevice(peerId),
|
|
1158
|
+
});
|
|
1159
|
+
await this.ownerGrantHandler.register(this.controlNode);
|
|
1069
1160
|
// A node waiting to be claimed listens for its claim seed from the start: nobody
|
|
1070
1161
|
// else will turn the listener on, and a node no seed can reach cannot be claimed.
|
|
1071
1162
|
// Before `control:connected`, so an embedder that reports "awaiting claim" on that
|
|
@@ -1244,7 +1335,7 @@ export class CadreNode {
|
|
|
1244
1335
|
/**
|
|
1245
1336
|
* Construct (or adopt) the node-local trusted-owner anchor and seed the
|
|
1246
1337
|
* out-of-band pinned keys from `config.trustedOwners`. The anchor's BASE comes
|
|
1247
|
-
* only from out of band — genesis self-trust ({@link
|
|
1338
|
+
* only from out of band — genesis self-trust ({@link useOwnerSigner}),
|
|
1248
1339
|
* config pins (here), runtime enrollment pins ({@link trustOwnerKeys}) and a
|
|
1249
1340
|
* claim; everything else in it is derived from that base along verifiable
|
|
1250
1341
|
* chains through the replicated `OwnerKey` table ({@link syncOwnerAnchor}),
|
|
@@ -1524,12 +1615,13 @@ export class CadreNode {
|
|
|
1524
1615
|
}
|
|
1525
1616
|
/**
|
|
1526
1617
|
* Persist out-of-band-established owner keys into the node-local anchor —
|
|
1527
|
-
* the runtime enrollment seam: {@link redeemCadreInvitation}
|
|
1528
|
-
*
|
|
1529
|
-
* holds the pins when the reply and the rows
|
|
1618
|
+
* the runtime enrollment seam: {@link redeemCadreInvitation} and
|
|
1619
|
+
* {@link redeemOwnerGrant} call it with their bundle's `ownerKeys` BEFORE dialing
|
|
1620
|
+
* a member (so the anchor already holds the pins when the reply and the rows
|
|
1621
|
+
* that follow are judged), and an
|
|
1530
1622
|
* embedder calls it with an operator-supplied pin. Idempotent. ('genesis'
|
|
1531
1623
|
* provenance is reserved for the node's own founding key, seeded internally by
|
|
1532
|
-
* {@link
|
|
1624
|
+
* {@link useOwnerSigner}; 'chain' is written only by {@link syncOwnerAnchor}.)
|
|
1533
1625
|
*
|
|
1534
1626
|
* Validates every key's shape before trusting any of them (all-or-nothing):
|
|
1535
1627
|
* a malformed entry anywhere in `keys` rejects the whole call before a
|
|
@@ -1948,10 +2040,18 @@ export class CadreNode {
|
|
|
1948
2040
|
* own strand node, so a NAT'd member's strand node can hold a
|
|
1949
2041
|
* circuit-relay reservation here (see `delegate-admission.ts`).
|
|
1950
2042
|
* Connection only: the per-stream gate below never honors a grant;
|
|
1951
|
-
* 3. the
|
|
2043
|
+
* 3. the peer is a device whose invitation or owner-grant redemption this
|
|
2044
|
+
* node has verified and is writing ({@link admittingDevices}): its
|
|
2045
|
+
* connection carries the exchange, which must not be cut at the
|
|
2046
|
+
* provisional deadline. A LIVE OWNER GRANT does not count as expecting a
|
|
2047
|
+
* stranger the way check 7's invitation does: a backup grant has no
|
|
2048
|
+
* expiry, and counting it would admit every stranger outright for the
|
|
2049
|
+
* party's life, so the holder rides the provisional admission until its
|
|
2050
|
+
* redemption verifies, then this;
|
|
2051
|
+
* 4. the authorized-member set is empty — cold start: the rows that would
|
|
1952
2052
|
* authorize anyone arrive by replication over these very connections;
|
|
1953
|
-
*
|
|
1954
|
-
*
|
|
2053
|
+
* 5. the peer IS an authorized member; or
|
|
2054
|
+
* 6. an open invitation is OUTSTANDING — at least one unexpired,
|
|
1955
2055
|
* not-fully-consumed invitation this node minted or persisted (see
|
|
1956
2056
|
* `StrandSolicitationService.hasOutstandingInvitation`). A formation
|
|
1957
2057
|
* initiator is another party's peer by design and its token is only
|
|
@@ -1959,19 +2059,19 @@ export class CadreNode {
|
|
|
1959
2059
|
* "does this node expect a stranger at all?". REGISTERING the responder
|
|
1960
2060
|
* does not count: every node registers one at {@link start}, and
|
|
1961
2061
|
* registering mints no invitation; or
|
|
1962
|
-
*
|
|
2062
|
+
* 7. a LIVE cadre invitation exists — a `CadreInvite` row this node holds
|
|
1963
2063
|
* that is not withdrawn, unexpired, has uses left and whose issuer is
|
|
1964
2064
|
* still an owner (`ControlDatabase.hasLiveCadreInvite`). The device that
|
|
1965
2065
|
* redeems it is a stranger until the redemption writes its row, and its
|
|
1966
2066
|
* proof of possession is only checkable inside `/sereus/cadre-invite/1.0.0`;
|
|
1967
|
-
* same reasoning as check
|
|
2067
|
+
* same reasoning as check 6, and the same expectation-of-a-stranger key.
|
|
1968
2068
|
*
|
|
1969
2069
|
* Ordering is semantically free (the checks are OR'd) but decides who pays:
|
|
1970
|
-
* checks 1-
|
|
1971
|
-
* that is otherwise headed for a provisional admission reaches check
|
|
1972
|
-
*
|
|
2070
|
+
* checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
|
|
2071
|
+
* that is otherwise headed for a provisional admission reaches check 6's and
|
|
2072
|
+
* 7's invitation lookups.
|
|
1973
2073
|
*
|
|
1974
|
-
* Caveats of check
|
|
2074
|
+
* Caveats of check 6:
|
|
1975
2075
|
* - the in-memory mint registry dies with the process, so after a restart
|
|
1976
2076
|
* only invitations persisted as `FormationInvite` rows still hold the
|
|
1977
2077
|
* window open (re-mint otherwise);
|
|
@@ -1982,8 +2082,8 @@ export class CadreNode {
|
|
|
1982
2082
|
* When every check falls through: `'admit-provisionally'`, on every node,
|
|
1983
2083
|
* relay server or not. The gate sees only a peer id, so it cannot tell an
|
|
1984
2084
|
* outsider from a device whose invitation row this node has not received yet
|
|
1985
|
-
* (it redeems here by seating the row from its bundle, after which check
|
|
1986
|
-
* admits) or from a sibling whose membership row is still replicating (check
|
|
2085
|
+
* (it redeems here by seating the row from its bundle, after which check 7
|
|
2086
|
+
* admits) or from a sibling whose membership row is still replicating (check 5
|
|
1987
2087
|
* admits once it lands; if it has not landed by the deadline, the connection
|
|
1988
2088
|
* is closed and either side's next reconcile dial re-forms it, since outbound
|
|
1989
2089
|
* dials are never gated). The protocol guard refuses all of them every
|
|
@@ -1991,12 +2091,12 @@ export class CadreNode {
|
|
|
1991
2091
|
* {@link admitControlRelayReservation} disarms the deadline (see
|
|
1992
2092
|
* `membership-connection-gater.ts` → "The relay-reservation seam").
|
|
1993
2093
|
*
|
|
1994
|
-
* NOTE: check
|
|
2094
|
+
* NOTE: check 4/5 runs a control-DB read per inbound connection
|
|
1995
2095
|
* (`listAuthorizedMembers`); connections are rare and cadres small, and this
|
|
1996
2096
|
* layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
|
|
1997
2097
|
* read is safe here — unlike the per-stream gate, which must consult the
|
|
1998
2098
|
* materialized {@link authorizedControlPeers} snapshot instead.
|
|
1999
|
-
* NOTE: checks
|
|
2099
|
+
* NOTE: checks 6 and 7 add two more control reads (`hasOutstandingFormationInvite`,
|
|
2000
2100
|
* `hasLiveCadreInvite`) for a stranger with no locally minted invitation in play, on
|
|
2001
2101
|
* every node, and a provisional connection's deadline runs the whole decision once
|
|
2002
2102
|
* more. If stranger connections ever arrive fast enough for those reads — on arrival
|
|
@@ -2013,6 +2113,9 @@ export class CadreNode {
|
|
|
2013
2113
|
if (this.delegateAdmission.has(remotePeerId)) {
|
|
2014
2114
|
return 'admit';
|
|
2015
2115
|
}
|
|
2116
|
+
if (this.admittingDevices.has(remotePeerId)) {
|
|
2117
|
+
return 'admit';
|
|
2118
|
+
}
|
|
2016
2119
|
const authorized = await this.listAuthorizedMembers();
|
|
2017
2120
|
if (authorized.length === 0) {
|
|
2018
2121
|
return 'admit';
|
|
@@ -2250,18 +2353,18 @@ export class CadreNode {
|
|
|
2250
2353
|
*
|
|
2251
2354
|
* The trusted-owner anchor sync ({@link syncOwnerAnchor}) runs FIRST, so the snapshot
|
|
2252
2355
|
* built here judges `CadrePeer` rows against the anchor as the `OwnerKey` table now
|
|
2253
|
-
* has it. The
|
|
2254
|
-
*
|
|
2255
|
-
*
|
|
2356
|
+
* has it. The tombstone list the sync and the two end-of-refresh checks
|
|
2357
|
+
* ({@link hangUpRevokedControlPeers}, then {@link noteOwnRemoval}) read is loaded at most
|
|
2358
|
+
* once per refresh, as is the invitation chain the membership read may need.
|
|
2256
2359
|
*/
|
|
2257
2360
|
async refreshAuthorizedControlPeers(reason) {
|
|
2258
2361
|
if (!this._running || !this.controlDatabase) {
|
|
2259
2362
|
return;
|
|
2260
2363
|
}
|
|
2261
2364
|
try {
|
|
2262
|
-
const loadChain = this.invitationChainSource(this.controlDatabase, false);
|
|
2263
2365
|
const loadTombstones = this.revocationSource(this.controlDatabase, false);
|
|
2264
|
-
await this.syncOwnerAnchor(reason, false,
|
|
2366
|
+
await this.syncOwnerAnchor(reason, false, loadTombstones);
|
|
2367
|
+
const loadChain = this.invitationChainSource(this.controlDatabase, false);
|
|
2265
2368
|
const rows = await this.controlDatabase.queryCadrePeers(false);
|
|
2266
2369
|
const members = this.authorizedAmong(rows, await this.chainFor(rows, loadChain));
|
|
2267
2370
|
this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
|
|
@@ -2552,14 +2655,14 @@ export class CadreNode {
|
|
|
2552
2655
|
list: () => database().queryJoinedStrands(),
|
|
2553
2656
|
names: async (strandId) => (await database().queryStrand(strandId)) !== null
|
|
2554
2657
|
|| (await database().queryJoinedStrand(strandId)) !== null,
|
|
2555
|
-
canSign: async () => (await this.
|
|
2658
|
+
canSign: async () => (await this.enrolledOwnerSigner()) !== null,
|
|
2556
2659
|
publish: async (record) => {
|
|
2557
|
-
const
|
|
2558
|
-
if (!
|
|
2660
|
+
const signer = await this.enrolledOwnerSigner();
|
|
2661
|
+
if (!signer) {
|
|
2559
2662
|
throw new Error(`Cannot publish joined strand ${record.Id}: this machine is not an enrolled owner`);
|
|
2560
2663
|
}
|
|
2561
2664
|
try {
|
|
2562
|
-
await database().insertJoinedStrand(record,
|
|
2665
|
+
await signer.approve({ partyId: this.partyId, action: `Record the joined strand ${record.Id} for the whole cadre` }, (sign) => database().insertJoinedStrand(record, signer.publicKey, sign));
|
|
2563
2666
|
}
|
|
2564
2667
|
catch (error) {
|
|
2565
2668
|
// Another machine of the party published this join first; its row wins.
|
|
@@ -2569,9 +2672,9 @@ export class CadreNode {
|
|
|
2569
2672
|
}
|
|
2570
2673
|
},
|
|
2571
2674
|
remove: async (strandId) => {
|
|
2572
|
-
const
|
|
2573
|
-
if (
|
|
2574
|
-
return database().deleteJoinedStrand(strandId,
|
|
2675
|
+
const signer = await this.enrolledOwnerSigner();
|
|
2676
|
+
if (signer) {
|
|
2677
|
+
return await signer.approve({ partyId: this.partyId, action: `Leave the joined strand ${strandId} for the whole cadre` }, (sign) => database().deleteJoinedStrand(strandId, signer.publicKey, sign));
|
|
2575
2678
|
}
|
|
2576
2679
|
if (await database().queryJoinedStrand(strandId)) {
|
|
2577
2680
|
throw new Error(`Cannot leave strand ${strandId} for the whole party: the join is recorded party-wide, and ` +
|
|
@@ -2583,37 +2686,43 @@ export class CadreNode {
|
|
|
2583
2686
|
};
|
|
2584
2687
|
}
|
|
2585
2688
|
/**
|
|
2586
|
-
* This machine's owner
|
|
2587
|
-
*
|
|
2588
|
-
*
|
|
2589
|
-
*
|
|
2590
|
-
*
|
|
2689
|
+
* This machine's owner signer when it can make owner-gated control writes: one is wired
|
|
2690
|
+
* ({@link useOwnerSigner}) and the party's `OwnerKey` table enrolls its key. A phone that
|
|
2691
|
+
* ran self-genesis after another machine founded the party passes the first test and not
|
|
2692
|
+
* the second (`runOwnerGenesis` in the reference apps), and a write it signed would only
|
|
2693
|
+
* be refused.
|
|
2591
2694
|
*/
|
|
2592
|
-
async
|
|
2593
|
-
const
|
|
2594
|
-
if (!
|
|
2695
|
+
async enrolledOwnerSigner() {
|
|
2696
|
+
const signer = this.getOwnerSigner();
|
|
2697
|
+
if (!signer || !this.controlDatabase) {
|
|
2595
2698
|
return null;
|
|
2596
2699
|
}
|
|
2597
|
-
return (await this.controlDatabase.getOwnerKeys()).has(
|
|
2700
|
+
return (await this.controlDatabase.getOwnerKeys()).has(signer.publicKey) ? signer : null;
|
|
2598
2701
|
}
|
|
2599
2702
|
/**
|
|
2600
|
-
* Whether this node can act as an owner of its cadre:
|
|
2601
|
-
*
|
|
2602
|
-
*
|
|
2603
|
-
*
|
|
2604
|
-
*
|
|
2703
|
+
* Whether this node can act as an owner of its cadre: an owner signer is wired
|
|
2704
|
+
* ({@link useOwnerSigner}) and its key is in the live `OwnerKey` set. What the owner-only
|
|
2705
|
+
* flows (`createCadreInvitation`, `claimNode`, `rentNode`) need; false before `start()`,
|
|
2706
|
+
* and on a machine that joined as a member. The reference apps' "is this device an owner"
|
|
2707
|
+
* check.
|
|
2605
2708
|
*/
|
|
2606
2709
|
async isOwner() {
|
|
2607
|
-
return (await this.
|
|
2710
|
+
return (await this.enrolledOwnerSigner()) !== null;
|
|
2608
2711
|
}
|
|
2609
|
-
/** {@link
|
|
2610
|
-
|
|
2611
|
-
const
|
|
2612
|
-
return
|
|
2712
|
+
/** {@link enrolledOwnerSigner} judged against an owner set the caller has already read. */
|
|
2713
|
+
ownerSignerEnrolledIn(ownerKeys) {
|
|
2714
|
+
const signer = this.getOwnerSigner();
|
|
2715
|
+
return signer !== null && ownerKeys.has(signer.publicKey) ? signer : null;
|
|
2613
2716
|
}
|
|
2614
|
-
/**
|
|
2615
|
-
|
|
2616
|
-
|
|
2717
|
+
/**
|
|
2718
|
+
* The owner signer {@link useOwnerSigner} wired, or null: every owner signature this node
|
|
2719
|
+
* makes goes through it. Enrollment is not checked ({@link isOwner} does). A caller that
|
|
2720
|
+
* makes several owner writes as one action opens one `approve` on it and hands the
|
|
2721
|
+
* resulting `sign` to the methods that take one (`publishStrand`, `ensureStrandPartyKey`,
|
|
2722
|
+
* `publishFormationInvite`), as `adoptStrandBundle` does.
|
|
2723
|
+
*/
|
|
2724
|
+
getOwnerSigner() {
|
|
2725
|
+
return this.seedBootstrapService?.ownerSigner ?? null;
|
|
2617
2726
|
}
|
|
2618
2727
|
/**
|
|
2619
2728
|
* Schedule the node's initial self-record publish + ongoing refresh shortly
|
|
@@ -2870,51 +2979,16 @@ export class CadreNode {
|
|
|
2870
2979
|
return null;
|
|
2871
2980
|
}
|
|
2872
2981
|
/**
|
|
2873
|
-
* Does the `Strand` row name THIS machine as its founder —
|
|
2874
|
-
*
|
|
2875
|
-
*
|
|
2876
|
-
*
|
|
2877
|
-
*
|
|
2878
|
-
* enough for {@link launchStrand}'s tracked-instance
|
|
2879
|
-
*
|
|
2880
|
-
* NOTE: reads "one owner key per machine" — the reference model, where the owner
|
|
2881
|
-
* key IS the key behind the PeerId. Two machines running the SAME identity key
|
|
2882
|
-
* would both derive `true` and each bootstrap on its own replica, which is the
|
|
2883
|
-
* double-`Header` hazard this derivation exists to avoid. Unreachable today: that
|
|
2884
|
-
* configuration also gives both machines one PeerId, which already breaks control
|
|
2885
|
-
* networking well before any strand launches. Revisit if machines ever share an
|
|
2886
|
-
* owner key while holding distinct transport identities — the derivation would
|
|
2887
|
-
* then need a per-machine discriminator (the `CadrePeer` PeerId) on the row.
|
|
2982
|
+
* Does the `Strand` row name THIS machine as its founder — is its `FounderPeerId` this
|
|
2983
|
+
* node's peer id? A null column (a consent-seated strand, or a hand-built row) derives
|
|
2984
|
+
* `false`: without a positive match this machine must attach, never bootstrap. A peer id
|
|
2985
|
+
* rather than an owner key, so two machines holding one owner key never both bootstrap
|
|
2986
|
+
* (the double-`Header` hazard), and a node derives it before any owner signer is wired.
|
|
2987
|
+
* Pure comparison, no I/O — cheap enough for {@link launchStrand}'s tracked-instance
|
|
2988
|
+
* early return.
|
|
2888
2989
|
*/
|
|
2889
2990
|
isSelfFoundedRow(strand) {
|
|
2890
|
-
|
|
2891
|
-
return false;
|
|
2892
|
-
}
|
|
2893
|
-
return strand.FounderOwnerKey === this.getSelfSigningKey()?.publicKeyB64;
|
|
2894
|
-
}
|
|
2895
|
-
/**
|
|
2896
|
-
* The owner keypair (base64url Ed25519) derived from this node's resolved
|
|
2897
|
-
* identity key. In the single-key reference model the owner signing key is
|
|
2898
|
-
* *derived from* the node identity (see {@link ed25519KeyPairFromLibp2p}), so the
|
|
2899
|
-
* same key material protected in a secure enclave backs both.
|
|
2900
|
-
*
|
|
2901
|
-
* Exposed so the hosting app retains control of owner genesis: cadre-core
|
|
2902
|
-
* resolves + protects the identity, then the app sources this pair to drive
|
|
2903
|
-
* `ensureOwnerKey(pub)` + `initializeSeedBootstrap(priv)` itself — cadre-core
|
|
2904
|
-
* never silently runs genesis. A future separate-owner slot would return a
|
|
2905
|
-
* distinct key here instead of the identity-derived one.
|
|
2906
|
-
*
|
|
2907
|
-
* @returns The base64url seed/public-key owner pair.
|
|
2908
|
-
* @throws If called before {@link start} has resolved the identity, or when the
|
|
2909
|
-
* node runs on an ephemeral libp2p key (no `keyStore`/`privateKey` configured),
|
|
2910
|
-
* since that key is internal to libp2p and not exposed.
|
|
2911
|
-
*/
|
|
2912
|
-
getIdentityOwnerKey() {
|
|
2913
|
-
if (!this.identityKey) {
|
|
2914
|
-
throw new Error('getIdentityOwnerKey: node identity not resolved — call start() first, and ' +
|
|
2915
|
-
'configure `keyStore` or `privateKey` (an ephemeral libp2p identity exposes no owner key)');
|
|
2916
|
-
}
|
|
2917
|
-
return ed25519KeyPairFromLibp2p(this.identityKey);
|
|
2991
|
+
return strand.FounderPeerId !== null && strand.FounderPeerId === this.peerId?.toString();
|
|
2918
2992
|
}
|
|
2919
2993
|
/**
|
|
2920
2994
|
* Publish this node's own address record from the reconcile pass until its first publish of
|
|
@@ -3407,22 +3481,13 @@ export class CadreNode {
|
|
|
3407
3481
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
3408
3482
|
return { dialed: [] };
|
|
3409
3483
|
}
|
|
3410
|
-
// 2. Classify backbone (
|
|
3411
|
-
// NOTE: deliberately the REPLICATED table, not the node-local anchor — this
|
|
3412
|
-
// only *prefers* owner peers as dial targets, so pollution costs a wasted
|
|
3413
|
-
// dial while anchoring would drop legitimate co-owners this node never
|
|
3414
|
-
// pinned. Same call as `SeedPeer.isOwner` in seed-bootstrap.ts. Move it to
|
|
3415
|
-
// the anchor if owner status here ever gates something trusted.
|
|
3416
|
-
const ownerKeys = await this.controlDatabase.getOwnerKeys();
|
|
3417
|
-
if (!this._running || !this.controlNode) {
|
|
3418
|
-
return { dialed: [] };
|
|
3419
|
-
}
|
|
3484
|
+
// 2. Classify backbone (always-on) members and select a bounded dial set.
|
|
3420
3485
|
const targetDegree = this.config.network?.controlCohort?.targetDegree
|
|
3421
3486
|
?? DEFAULT_CONTROL_COHORT_TARGET_DEGREE;
|
|
3422
|
-
const { dials,
|
|
3423
|
-
if (
|
|
3487
|
+
const { dials, cappedNonBackbone } = selectControlCohortDials(siblings, targetDegree);
|
|
3488
|
+
if (cappedNonBackbone > 0) {
|
|
3424
3489
|
// Don't silently bound coverage — surface what the out-degree cap dropped.
|
|
3425
|
-
log('reconcileControlCohort: capped %d non-
|
|
3490
|
+
log('reconcileControlCohort: capped %d non-backbone sibling(s) at targetDegree=%d', cappedNonBackbone, targetDegree);
|
|
3426
3491
|
}
|
|
3427
3492
|
// 3. Warm the libp2p address book with EVERY sibling's verified addresses,
|
|
3428
3493
|
// keeping what resolved for the dial loop below.
|
|
@@ -3754,8 +3819,8 @@ export class CadreNode {
|
|
|
3754
3819
|
}
|
|
3755
3820
|
}
|
|
3756
3821
|
/**
|
|
3757
|
-
* Retain a just-applied seed's
|
|
3758
|
-
* dial targets (see {@link bootstrapPeerStore}).
|
|
3822
|
+
* Retain a just-applied seed's preferred peers (`SeedPeer.preferred`) as cold-start
|
|
3823
|
+
* bootstrap dial targets (see {@link bootstrapPeerStore}).
|
|
3759
3824
|
*
|
|
3760
3825
|
* Called for every seed this node accepts, on BOTH intake paths — the
|
|
3761
3826
|
* {@link applySeed} wrapper (which may run on a throwaway service, so the
|
|
@@ -3763,7 +3828,7 @@ export class CadreNode {
|
|
|
3763
3828
|
* `/sereus/seed/1.0.0` handler via `onSeedApplied`. Peers with no address are
|
|
3764
3829
|
* skipped: there is nothing to dial.
|
|
3765
3830
|
*
|
|
3766
|
-
* `
|
|
3831
|
+
* `preferred` is the seed's own claim about its peers, exactly as
|
|
3767
3832
|
* `SeedBootstrapService.applySeed` uses it to choose its dial targets. That is
|
|
3768
3833
|
* sound here for the same reason it is sound there — the whole seed is
|
|
3769
3834
|
* signature-checked against a trust-anchored signer before this runs, and the
|
|
@@ -3773,7 +3838,7 @@ export class CadreNode {
|
|
|
3773
3838
|
*/
|
|
3774
3839
|
recordSeedBootstrapPeers(seed) {
|
|
3775
3840
|
for (const peer of seed.peers) {
|
|
3776
|
-
if (peer.
|
|
3841
|
+
if (peer.preferred) {
|
|
3777
3842
|
this.retainDialTarget(peer.peerId, peer.multiaddrs, 'recordSeedBootstrapPeers');
|
|
3778
3843
|
}
|
|
3779
3844
|
}
|
|
@@ -3815,12 +3880,12 @@ export class CadreNode {
|
|
|
3815
3880
|
}
|
|
3816
3881
|
/**
|
|
3817
3882
|
* Cold-start branch of the reconcile pass: re-dial every retained out-of-band
|
|
3818
|
-
* dial target (see {@link bootstrapPeerStore}) — in practice the
|
|
3819
|
-
* the seeds this node applied — while the control database still has no
|
|
3883
|
+
* dial target (see {@link bootstrapPeerStore}) — in practice the preferred peers
|
|
3884
|
+
* of the seeds this node applied — while the control database still has no
|
|
3820
3885
|
* siblings to dial.
|
|
3821
3886
|
*
|
|
3822
|
-
* `SeedBootstrapService.applySeed` dials those
|
|
3823
|
-
* When that single dial fails —
|
|
3887
|
+
* `SeedBootstrapService.applySeed` dials those peers exactly ONCE, best-effort.
|
|
3888
|
+
* When that single dial fails — peer momentarily down, relay reservation not
|
|
3824
3889
|
* up yet, NAT traversal lost the race — the joining node has an empty
|
|
3825
3890
|
* `CadrePeer` table and no connection, and the steady-state pass above cannot
|
|
3826
3891
|
* help: it dials only siblings enumerated from that very table. Retrying here
|
|
@@ -3830,9 +3895,10 @@ export class CadreNode {
|
|
|
3830
3895
|
* gated by "the control database has no siblings", so it stops the moment the
|
|
3831
3896
|
* node is actually in the party, and a node that is stranded MUST keep trying —
|
|
3832
3897
|
* a give-up rule would turn a transient outage into a permanent one. The steady
|
|
3833
|
-
* cost is one dial per bootstrap peer per pass (
|
|
3834
|
-
*
|
|
3835
|
-
*
|
|
3898
|
+
* cost is one dial per bootstrap peer per pass (a seed prefers its minter and the
|
|
3899
|
+
* party's always-on machines, a few at most), each failing fast against an
|
|
3900
|
+
* unreachable address.
|
|
3901
|
+
* NOTE: if seeds ever carry many preferred peers, or the reconcile cadence tightens
|
|
3836
3902
|
* well below its 15 s default, add per-peer backoff here.
|
|
3837
3903
|
*
|
|
3838
3904
|
* Best-effort per peer, exactly like {@link dialControlSibling}: one dead
|
|
@@ -4114,9 +4180,9 @@ export class CadreNode {
|
|
|
4114
4180
|
*/
|
|
4115
4181
|
async drainPendingRevocations() {
|
|
4116
4182
|
const sweep = !this.reissuedHeldRevocations;
|
|
4117
|
-
// A removed machine
|
|
4118
|
-
//
|
|
4119
|
-
// and drainPendingPeerWrites stop on the same test.
|
|
4183
|
+
// A removed machine is no longer a member, whatever owner key it holds: the others hang up
|
|
4184
|
+
// on it and refuse its control streams, so a re-issue it signs reaches no one.
|
|
4185
|
+
// reconstructAuthoredMembership and drainPendingPeerWrites stop on the same test.
|
|
4120
4186
|
if (this.removal !== null || (!sweep && this.pendingRevocations.size === 0)) {
|
|
4121
4187
|
return;
|
|
4122
4188
|
}
|
|
@@ -4144,8 +4210,8 @@ export class CadreNode {
|
|
|
4144
4210
|
}
|
|
4145
4211
|
}
|
|
4146
4212
|
catch (error) {
|
|
4147
|
-
// NOTE:
|
|
4148
|
-
// so not a removal
|
|
4213
|
+
// NOTE: a machine whose owner key was removed (`removeOwner`; the machine stays a member,
|
|
4214
|
+
// so this is not a removal it reports) keeps running these drains, and every attempt
|
|
4149
4215
|
// fails the schema's live-owner clause and lands here on each growth edge. Noise, not
|
|
4150
4216
|
// harm; if it shows up, gate the drains on this node's key being a live owner.
|
|
4151
4217
|
log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
|
|
@@ -4700,11 +4766,15 @@ export class CadreNode {
|
|
|
4700
4766
|
await this.strandAddrService.shutdown();
|
|
4701
4767
|
this.strandAddrService = null;
|
|
4702
4768
|
}
|
|
4703
|
-
// Unregister the
|
|
4769
|
+
// Unregister the two redemption handlers, for the same reason.
|
|
4704
4770
|
if (this.cadreInviteHandler && this.controlNode) {
|
|
4705
4771
|
await this.cadreInviteHandler.unregister(this.controlNode);
|
|
4706
4772
|
}
|
|
4707
4773
|
this.cadreInviteHandler = null;
|
|
4774
|
+
if (this.ownerGrantHandler && this.controlNode) {
|
|
4775
|
+
await this.ownerGrantHandler.unregister(this.controlNode);
|
|
4776
|
+
}
|
|
4777
|
+
this.ownerGrantHandler = null;
|
|
4708
4778
|
// Tear down the push-wake fan-out (releases the notifier's APNs HTTP/2 session).
|
|
4709
4779
|
if (this.pushFanoutService) {
|
|
4710
4780
|
await this.pushFanoutService.close().catch((err) => log('Push fan-out close failed: %o', err));
|
|
@@ -4733,9 +4803,11 @@ export class CadreNode {
|
|
|
4733
4803
|
// Drop delegate-admission state: the grants are scoped to the session that
|
|
4734
4804
|
// recorded them, so a stop()/start() cycle on this object must not keep
|
|
4735
4805
|
// admitting delegates announced under the previous one, nor carry stale
|
|
4736
|
-
// announce timestamps that would suppress the next
|
|
4806
|
+
// announce timestamps or relay refusals that would suppress the next
|
|
4807
|
+
// session's announcements.
|
|
4737
4808
|
this.delegateAdmission.clear();
|
|
4738
4809
|
this.delegateAnnounceAt.clear();
|
|
4810
|
+
this.relaysRefusingStrandAddr.clear();
|
|
4739
4811
|
// Control backfill first — before the database closes and the control node
|
|
4740
4812
|
// stops — so no NEW catch-up push is issued against a torn-down transport.
|
|
4741
4813
|
// A push already in flight is not awaited; it fails into the module's own
|
|
@@ -5072,7 +5144,7 @@ export class CadreNode {
|
|
|
5072
5144
|
* `Strand` row. A joiner (the row arrived over the control network) wants exactly this; a
|
|
5073
5145
|
* FOUNDER wants {@link foundStrand}, which publishes and attaches in one resumable call.
|
|
5074
5146
|
* With no explicit `founder` flag, founder-ness is derived from the row's
|
|
5075
|
-
* `
|
|
5147
|
+
* `FounderPeerId` (see {@link StrandConfig.founder}) — so attaching a row THIS machine
|
|
5076
5148
|
* published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
|
|
5077
5149
|
* anyone else's row joins, without the caller needing to know which it is.
|
|
5078
5150
|
*
|
|
@@ -5114,7 +5186,7 @@ export class CadreNode {
|
|
|
5114
5186
|
// A row this node offered itself came from the control table or is already a
|
|
5115
5187
|
// remembered join, and a row this node founds is not a join, so only a joined row
|
|
5116
5188
|
// from elsewhere can be a new join to remember. A founded row recorded here would
|
|
5117
|
-
// come back after a restart with
|
|
5189
|
+
// come back after a restart with null founder columns and relaunch as a joiner.
|
|
5118
5190
|
// Captured before the backlog delete below.
|
|
5119
5191
|
const rememberable = founder !== true && !this.discoveredStrands.has(strandRow.Id);
|
|
5120
5192
|
// Store sApp config for this strand. The strand is claimed now, so it leaves the
|
|
@@ -5131,7 +5203,7 @@ export class CadreNode {
|
|
|
5131
5203
|
await this.rememberForeignStrand(strandRow);
|
|
5132
5204
|
}
|
|
5133
5205
|
// An unset `founder` is DERIVED from the row inside launchStrand (this node founds
|
|
5134
|
-
// iff the row's
|
|
5206
|
+
// iff the row's FounderPeerId is its own peer id); an explicit flag wins — the
|
|
5135
5207
|
// formation/responder flows pass one deliberately, since their consent-seated rows
|
|
5136
5208
|
// carry a null column. See StrandConfig.founder. Same rule for the explicit
|
|
5137
5209
|
// partyMemberPrivateKey: it wins over the StrandPartyKey control-row read.
|
|
@@ -5155,7 +5227,7 @@ export class CadreNode {
|
|
|
5155
5227
|
// from the row and arguments passed here — so a caller that enriched either (a
|
|
5156
5228
|
// synthetic `MemberPrivateKey`, an explicit `founder` or `partyMemberPrivateKey`)
|
|
5157
5229
|
// is retried with less than it asked for. Inert today: founder-ness re-derives from
|
|
5158
|
-
// `
|
|
5230
|
+
// `FounderPeerId`, the party key re-reads from `StrandPartyKey`, and the row's
|
|
5159
5231
|
// shared `MemberPrivateKey` is read only by the founder bootstrap. If a joiner
|
|
5160
5232
|
// launch ever starts depending on that key, gate this hand-back on the passed row
|
|
5161
5233
|
// matching the control one.
|
|
@@ -5227,11 +5299,11 @@ export class CadreNode {
|
|
|
5227
5299
|
* fresh publish also clears it for an OPEN strand, but is destructive for a closed one
|
|
5228
5300
|
* (the key exists nowhere else). No app-data wipe is needed for either.
|
|
5229
5301
|
*
|
|
5230
|
-
* The insert is signed
|
|
5231
|
-
*
|
|
5232
|
-
*
|
|
5233
|
-
*
|
|
5234
|
-
*
|
|
5302
|
+
* The insert is signed through this node's owner signer ({@link getOwnerSigner}), in one
|
|
5303
|
+
* approval with a closed strand's party-key mint. The signer's key is persisted on the row
|
|
5304
|
+
* as `FounderOwnerKey` (the schema pins the column to the verified signer), and this node's
|
|
5305
|
+
* peer id as `FounderPeerId` (bound into the signature), which is what later lets any
|
|
5306
|
+
* launch derive "this machine is the founder" from the row alone.
|
|
5235
5307
|
* The key must be enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
|
|
5236
5308
|
* at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
|
|
5237
5309
|
* Failing loudly here is intentional: a silently-unpublished strand would run
|
|
@@ -5247,16 +5319,18 @@ export class CadreNode {
|
|
|
5247
5319
|
* {@link addStrand}).
|
|
5248
5320
|
* @param type - `'o'` for open (default) or `'c'` for closed.
|
|
5249
5321
|
* @param memberPrivateKey - Optional shared membership (read) key for a closed strand.
|
|
5322
|
+
* @param sign - The `sign` of an owner approval the caller already holds
|
|
5323
|
+
* ({@link getOwnerSigner}), so a larger action asks once; omitted, this asks for its own.
|
|
5250
5324
|
* @returns The live `Strand` row — the one just inserted, or the matching one already
|
|
5251
5325
|
* there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
|
|
5252
5326
|
* on a repeat it is the stored key, not the argument.
|
|
5253
|
-
* @throws if the node is not started,
|
|
5327
|
+
* @throws if the node is not started, has no owner signer wired, the id is blank or
|
|
5254
5328
|
* unusable as a storage scope key (`InvalidStrandIdError` — see `storage-scope.ts`), a
|
|
5255
5329
|
* row with the same id holds different content, or the control DB rejects the
|
|
5256
5330
|
* (unauthorized) insert.
|
|
5257
5331
|
*/
|
|
5258
|
-
async publishStrand(strandId, type = 'o', memberPrivateKey) {
|
|
5259
|
-
const
|
|
5332
|
+
async publishStrand(strandId, type = 'o', memberPrivateKey, sign) {
|
|
5333
|
+
const signer = this.requireOwnerSigner(`publish strand ${strandId}`);
|
|
5260
5334
|
// Trim/reject here so the id that lands matches the one unpublishStrand looks up: it
|
|
5261
5335
|
// trims too, and an untrimmed row would be unreachable by the same string.
|
|
5262
5336
|
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
@@ -5265,13 +5339,14 @@ export class CadreNode {
|
|
|
5265
5339
|
// a storage scope key would otherwise reach the whole party's control database and be
|
|
5266
5340
|
// declined once per member. Asserted after the trim so the id checked is the id stored.
|
|
5267
5341
|
assertStrandScopeKey(trimmed);
|
|
5268
|
-
//
|
|
5269
|
-
//
|
|
5342
|
+
// FounderPeerId records THIS machine as the founder, which is what later lets a relaunch
|
|
5343
|
+
// derive founder-ness; ControlDatabase.insertStrand writes the same id (its own node's).
|
|
5270
5344
|
const desired = {
|
|
5271
5345
|
Id: trimmed,
|
|
5272
5346
|
Type: type,
|
|
5273
5347
|
MemberPrivateKey: memberPrivateKey ?? null,
|
|
5274
|
-
FounderOwnerKey:
|
|
5348
|
+
FounderOwnerKey: signer.publicKey,
|
|
5349
|
+
FounderPeerId: this.controlNode.peerId.toString(),
|
|
5275
5350
|
};
|
|
5276
5351
|
// Read first rather than leaning on the insert's collision for BOTH readings. The
|
|
5277
5352
|
// ordinary resume (an app killed after its own publish committed) then needs no error
|
|
@@ -5295,12 +5370,21 @@ export class CadreNode {
|
|
|
5295
5370
|
// is a read-only no-op and a publish interrupted between the row insert and the
|
|
5296
5371
|
// mint heals here. A row another machine published is that machine's to mint for.
|
|
5297
5372
|
if (type === 'c' && this.isSelfFoundedRow(matched)) {
|
|
5298
|
-
await this.ensureStrandPartyKey(trimmed);
|
|
5373
|
+
await this.ensureStrandPartyKey(trimmed, undefined, sign);
|
|
5299
5374
|
}
|
|
5300
5375
|
return matched;
|
|
5301
5376
|
}
|
|
5377
|
+
return await withinOwnerApproval(signer, { partyId: this.partyId, action: `Publish the strand ${trimmed} to the cadre` }, sign, (approved) => this.insertStrandRow(desired, signer, approved));
|
|
5378
|
+
}
|
|
5379
|
+
/**
|
|
5380
|
+
* The write half of {@link publishStrand}: insert `desired`, adopting a matching row a
|
|
5381
|
+
* concurrent founder landed first, then mint a closed strand's party key, all under one
|
|
5382
|
+
* owner approval's `sign`.
|
|
5383
|
+
*/
|
|
5384
|
+
async insertStrandRow(desired, signer, sign) {
|
|
5385
|
+
const { Id: trimmed, Type: type } = desired;
|
|
5302
5386
|
try {
|
|
5303
|
-
await this.controlDatabase.insertStrand(trimmed, type,
|
|
5387
|
+
await this.controlDatabase.insertStrand(trimmed, type, signer.publicKey, sign, desired.MemberPrivateKey ?? undefined);
|
|
5304
5388
|
}
|
|
5305
5389
|
catch (error) {
|
|
5306
5390
|
// Only the uniqueness collision is a candidate for the idempotent reading; every
|
|
@@ -5318,65 +5402,49 @@ export class CadreNode {
|
|
|
5318
5402
|
log('publishStrand(%s): lost a concurrent founding race; re-read the landed row', trimmed);
|
|
5319
5403
|
return requireMatchingStrandRow(landed, desired, 'landed concurrently from another founder');
|
|
5320
5404
|
}
|
|
5321
|
-
log('Published strand %s (type %s) to control DB under owner %s', trimmed, type,
|
|
5405
|
+
log('Published strand %s (type %s) to control DB under owner %s', trimmed, type, signer.publicKey);
|
|
5322
5406
|
// A closed strand's publish also mints this party's own membership identity key
|
|
5323
5407
|
// (StrandPartyKey) — the key the founder bootstrap derives Member/Manager from,
|
|
5324
|
-
// deliberately distinct from the shared
|
|
5408
|
+
// deliberately distinct from the row's shared MemberPrivateKey read secret. A failure
|
|
5325
5409
|
// here surfaces (the strand row has already committed, so a retried publish takes
|
|
5326
5410
|
// the idempotent branch and heals the mint; so does the next founder launch).
|
|
5327
5411
|
if (type === 'c') {
|
|
5328
|
-
await this.ensureStrandPartyKey(trimmed);
|
|
5412
|
+
await this.ensureStrandPartyKey(trimmed, undefined, sign);
|
|
5329
5413
|
}
|
|
5330
5414
|
return desired;
|
|
5331
5415
|
}
|
|
5416
|
+
async foundStrand(config, options) {
|
|
5417
|
+
const tTotal = performance.now();
|
|
5418
|
+
const published = await this.publishFoundingRow(config);
|
|
5419
|
+
const { strandRow, founded } = published;
|
|
5420
|
+
const logTotal = () => timing(`[foundStrand:${strandRow.Id}] total: ${Math.round(performance.now() - tTotal)}ms`);
|
|
5421
|
+
if (options?.background) {
|
|
5422
|
+
const ready = this.attachFoundingRow(published, config);
|
|
5423
|
+
// Without a handler here, a caller that only wanted the row would get an unhandled
|
|
5424
|
+
// rejection. The caller still sees the rejection on `ready` itself.
|
|
5425
|
+
void ready.catch((error) => {
|
|
5426
|
+
log('foundStrand(%s): background start did not complete; the strand stays published: %o', strandRow.Id, error);
|
|
5427
|
+
});
|
|
5428
|
+
logTotal();
|
|
5429
|
+
return { strandRow, founded, ready };
|
|
5430
|
+
}
|
|
5431
|
+
const instance = await this.attachFoundingRow(published, config);
|
|
5432
|
+
logTotal();
|
|
5433
|
+
return { instance, strandRow, founded };
|
|
5434
|
+
}
|
|
5332
5435
|
/**
|
|
5333
|
-
*
|
|
5334
|
-
*
|
|
5335
|
-
*
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
* happened:
|
|
5340
|
-
*
|
|
5341
|
-
* - **row already published** → the stored row is adopted rather than re-published, so a
|
|
5342
|
-
* run killed after the insert committed no longer dies on
|
|
5343
|
-
* `UNIQUE constraint failed: Strand.Id`. For a closed strand the row's STORED
|
|
5344
|
-
* `MemberPrivateKey` wins over `config.memberPrivateKey`: a caller that mints a key per
|
|
5345
|
-
* attempt (as the reference apps do) would otherwise present a key that does not match
|
|
5346
|
-
* the membership already seated in the strand.
|
|
5347
|
-
* - **instance already running** → the tracked instance is returned, and — because the
|
|
5348
|
-
* `Strand` row records its publishing machine (`FounderOwnerKey`) — a founder request
|
|
5349
|
-
* against an instance that was first launched as a joiner now runs the (idempotent,
|
|
5350
|
-
* insert-if-absent) founder bootstrap on it rather than silently skipping it
|
|
5351
|
-
* ({@link StrandInstanceManager.foundExistingStrand}). So a strand that something else
|
|
5352
|
-
* attached first — the reference RN app's `strand:discovered` handler after a restart,
|
|
5353
|
-
* or this node's own {@link StrandWatcher} poll winning `launchStrand`'s
|
|
5354
|
-
* `resolveCohortSeed` window — still ends up with its `Strand.Header` written.
|
|
5355
|
-
*
|
|
5356
|
-
* Founder-ness is resolved FROM THE ROW, not assumed: this machine founds iff the
|
|
5357
|
-
* resolved row's `FounderOwnerKey` is its own owner key. A fresh publish records this
|
|
5358
|
-
* machine, so the common path founds; a machine that lost a concurrent founding race to
|
|
5359
|
-
* a sibling adopts the sibling's row and ATTACHES instead — deliberately, since two
|
|
5360
|
-
* machines bootstrapping the same strand on separate replicas is the double-`Header`
|
|
5361
|
-
* hazard. The returned {@link FoundStrandResult.founded} says which happened.
|
|
5362
|
-
*
|
|
5363
|
-
* `Type` is compared, not adopted: a stored row of the other type means the caller and
|
|
5364
|
-
* the control plane disagree about what this strand IS, so it throws.
|
|
5365
|
-
*
|
|
5366
|
-
* @returns The instance AND the row the strand actually runs under — read the membership
|
|
5367
|
-
* key from the returned row, not from a freshly minted one ({@link FoundStrandResult}).
|
|
5368
|
-
* @throws if the node is not started, the id is blank, a published row of the same id has
|
|
5369
|
-
* a different `Type`, or either half rejects.
|
|
5370
|
-
*/
|
|
5371
|
-
async foundStrand(config) {
|
|
5372
|
-
const { strandId, type = 'o', memberPrivateKey, sAppConfig, awaitFirstSync } = config;
|
|
5436
|
+
* The publish half of {@link foundStrand}: adopt the stored row or publish a new one, and
|
|
5437
|
+
* derive whether this machine founds it. Every check that can refuse the founding is here,
|
|
5438
|
+
* so both forms of `foundStrand` reject on it before returning.
|
|
5439
|
+
*/
|
|
5440
|
+
async publishFoundingRow(config) {
|
|
5441
|
+
const { strandId, type = 'o', memberPrivateKey } = config;
|
|
5373
5442
|
if (!this._running || !this.controlDatabase) {
|
|
5374
5443
|
throw new Error(`CadreNode must be started before attempting to found strand ${strandId}`);
|
|
5375
5444
|
}
|
|
5376
5445
|
const controlDatabase = this.controlDatabase;
|
|
5377
5446
|
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
5378
5447
|
const timed = (step, op) => timedStep('foundStrand', trimmed, step, op);
|
|
5379
|
-
const tTotal = performance.now();
|
|
5380
5448
|
const published = await timed('queryStrand', () => controlDatabase.queryStrand(trimmed));
|
|
5381
5449
|
const strandRow = published
|
|
5382
5450
|
? this.adoptPublishedStrand(published, type, memberPrivateKey)
|
|
@@ -5385,13 +5453,19 @@ export class CadreNode {
|
|
|
5385
5453
|
// machine runs the bootstrap — this one must attach or it would write a second Header.
|
|
5386
5454
|
const founded = this.isSelfFoundedRow(strandRow);
|
|
5387
5455
|
if (!founded) {
|
|
5388
|
-
log('foundStrand(%s): the stored row
|
|
5389
|
-
'(
|
|
5390
|
-
'the founder bootstrap runs on
|
|
5456
|
+
log('foundStrand(%s): the stored row names a different machine as founder ' +
|
|
5457
|
+
'(FounderPeerId is not this node\'s peer id) — attaching as a joiner; ' +
|
|
5458
|
+
'the founder bootstrap runs on that machine', trimmed);
|
|
5391
5459
|
}
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5460
|
+
return { strandRow, founded };
|
|
5461
|
+
}
|
|
5462
|
+
/**
|
|
5463
|
+
* The start half of {@link foundStrand}. Its timing lines (`addStrand: start`, then the
|
|
5464
|
+
* duration or `failed after`) are logged when the start settles, also in the background
|
|
5465
|
+
* form, so a start that hangs still shows as a start line with no end line.
|
|
5466
|
+
*/
|
|
5467
|
+
attachFoundingRow({ strandRow, founded }, { sAppConfig, awaitFirstSync }) {
|
|
5468
|
+
return timedStep('foundStrand', strandRow.Id, 'addStrand', () => this.addStrand({ strandRow, sAppConfig, founder: founded, awaitFirstSync }));
|
|
5395
5469
|
}
|
|
5396
5470
|
/**
|
|
5397
5471
|
* Resume onto an already-published `Strand` row: keep its stored content, reject a type
|
|
@@ -5466,16 +5540,17 @@ export class CadreNode {
|
|
|
5466
5540
|
* step, not a widening of this method.
|
|
5467
5541
|
*
|
|
5468
5542
|
* @param strandId - The `Strand` row id to remove (as passed to {@link publishStrand}).
|
|
5469
|
-
* @throws if the node is not started,
|
|
5543
|
+
* @throws if the node is not started, has no owner signer wired, the id is blank, or
|
|
5470
5544
|
* the signer is not an enrolled owner (the schema's `Strand.AuthorizedDelete` rejects
|
|
5471
5545
|
* the write and the row survives). A rejection does NOT imply the row survived: the
|
|
5472
5546
|
* local stop runs after the control-plane delete has already committed, so a failure
|
|
5473
5547
|
* there throws over a completed removal.
|
|
5474
5548
|
*/
|
|
5475
5549
|
async unpublishStrand(strandId) {
|
|
5476
|
-
const
|
|
5550
|
+
const signer = this.requireOwnerSigner(`unpublish strand ${strandId}`);
|
|
5477
5551
|
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
5478
|
-
|
|
5552
|
+
// One approval: the strand delete and its party key's, each with its tombstone.
|
|
5553
|
+
const removed = await signer.approve({ partyId: this.partyId, action: `Remove the strand ${trimmed} from the cadre` }, (sign) => this.controlDatabase.deleteStrand(trimmed, signer.publicKey, sign));
|
|
5479
5554
|
// Gate on `removed`: the absent-row no-op wrote nothing, so warning about an
|
|
5480
5555
|
// unreplicated deletion there would send an operator chasing a phantom.
|
|
5481
5556
|
if (removed && this.committedAlone()) {
|
|
@@ -5518,12 +5593,12 @@ export class CadreNode {
|
|
|
5518
5593
|
// row is already gone — there is nothing to suppress — and an id re-published before
|
|
5519
5594
|
// the next poll's suppression cleanup would then never be offered again this session.
|
|
5520
5595
|
// (Same reasoning as handleStrandRemoved, which also avoids stopStrand's `_running`
|
|
5521
|
-
// guard; here the guard is already satisfied by
|
|
5596
|
+
// guard; here the guard is already satisfied by requireOwnerSigner above.)
|
|
5522
5597
|
await this.strandWatcher?.forcePoll();
|
|
5523
5598
|
if (this.strandManager.getInstance(trimmed)) {
|
|
5524
5599
|
await this.detachStrand(trimmed);
|
|
5525
5600
|
}
|
|
5526
|
-
log('Unpublished strand %s from control DB under owner %s', trimmed,
|
|
5601
|
+
log('Unpublished strand %s from control DB under owner %s', trimmed, signer.publicKey);
|
|
5527
5602
|
}
|
|
5528
5603
|
/**
|
|
5529
5604
|
* Seat this party's own strand membership identity key — the `StrandPartyKey` row —
|
|
@@ -5549,11 +5624,13 @@ export class CadreNode {
|
|
|
5549
5624
|
* `generateStrandMemberKey` mints). When a row already holds a DIFFERENT key this
|
|
5550
5625
|
* throws — a party has one identity per strand; rotation is a deliberate
|
|
5551
5626
|
* remove-then-insert, not a silent swap.
|
|
5627
|
+
* @param sign - The `sign` of an owner approval the caller already holds; omitted, a
|
|
5628
|
+
* mint asks for its own (a stored key asks for none).
|
|
5552
5629
|
* @returns The live party key: the one just seated, or the stored one.
|
|
5553
5630
|
*/
|
|
5554
|
-
async ensureStrandPartyKey(strandId, partyMemberPrivateKey) {
|
|
5631
|
+
async ensureStrandPartyKey(strandId, partyMemberPrivateKey, sign) {
|
|
5555
5632
|
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
5556
|
-
const
|
|
5633
|
+
const signer = this.requireOwnerSigner(`seat a party key for strand ${trimmed}`);
|
|
5557
5634
|
const existing = await this.controlDatabase.queryStrandPartyKey(trimmed);
|
|
5558
5635
|
if (existing !== null) {
|
|
5559
5636
|
if (partyMemberPrivateKey !== undefined && partyMemberPrivateKey !== existing) {
|
|
@@ -5566,7 +5643,7 @@ export class CadreNode {
|
|
|
5566
5643
|
}
|
|
5567
5644
|
const key = partyMemberPrivateKey ?? await generateStrandMemberKey();
|
|
5568
5645
|
try {
|
|
5569
|
-
await this.controlDatabase.insertStrandPartyKey(trimmed, key,
|
|
5646
|
+
await withinOwnerApproval(signer, { partyId: this.partyId, action: `Seat this cadre's membership key for the strand ${trimmed}` }, sign, (approved) => this.controlDatabase.insertStrandPartyKey(trimmed, key, signer.publicKey, approved));
|
|
5570
5647
|
}
|
|
5571
5648
|
catch (error) {
|
|
5572
5649
|
// The read and the insert are not atomic; a concurrent seat of the same strand's
|
|
@@ -5580,15 +5657,15 @@ export class CadreNode {
|
|
|
5580
5657
|
log('ensureStrandPartyKey(%s): lost a concurrent seat race; adopting the landed key', trimmed);
|
|
5581
5658
|
return landed;
|
|
5582
5659
|
}
|
|
5583
|
-
log('Seated strand party key for %s under owner %s', trimmed,
|
|
5660
|
+
log('Seated strand party key for %s under owner %s', trimmed, signer.publicKey);
|
|
5584
5661
|
return key;
|
|
5585
5662
|
}
|
|
5586
5663
|
/**
|
|
5587
5664
|
* Resolve the party membership key a closed strand's launch threads into the founder
|
|
5588
5665
|
* bootstrap: the explicit attach-time key when given, else the party's persisted
|
|
5589
|
-
* `StrandPartyKey` row, else — on the one machine
|
|
5590
|
-
*
|
|
5591
|
-
*
|
|
5666
|
+
* `StrandPartyKey` row, else — on the one machine the row names as founder (`FounderPeerId`)
|
|
5667
|
+
* — a freshly minted-and-persisted key, which seats the identity for a publish that was
|
|
5668
|
+
* interrupted before its mint. Everyone else resolves undefined: non-founding
|
|
5592
5669
|
* machines never mint (no mint race between a party's machines), and only a founder
|
|
5593
5670
|
* bootstrap needs the key at all.
|
|
5594
5671
|
*
|
|
@@ -5618,6 +5695,9 @@ export class CadreNode {
|
|
|
5618
5695
|
if (!this.isSelfFoundedRow(strand)) {
|
|
5619
5696
|
return undefined;
|
|
5620
5697
|
}
|
|
5698
|
+
// NOTE: founder-ness no longer waits for the owner signer, so a founding machine's launch
|
|
5699
|
+
// that runs before the embedder wires it throws here and the watcher retries on its backoff;
|
|
5700
|
+
// if that shows up as restart noise, skip the heal until `getOwnerSigner()` is set.
|
|
5621
5701
|
return await this.ensureStrandPartyKey(strand.Id);
|
|
5622
5702
|
}
|
|
5623
5703
|
/**
|
|
@@ -5632,24 +5712,25 @@ export class CadreNode {
|
|
|
5632
5712
|
* from this row. A host minting a closed-strand invite does both (mint +
|
|
5633
5713
|
* publish), exactly as the integration harness's `createInvitation` does.
|
|
5634
5714
|
*
|
|
5635
|
-
* Signs
|
|
5636
|
-
*
|
|
5637
|
-
* Throws loudly if the node isn't started or exposes no signing key.
|
|
5715
|
+
* Signs through the same owner signer as {@link publishStrand}, whose key must be an
|
|
5716
|
+
* enrolled `OwnerKey`. Throws loudly if the node isn't started or has no owner signer wired.
|
|
5638
5717
|
*
|
|
5639
5718
|
* @param token - Invitation token (the `FormationInvite` primary key); use the
|
|
5640
5719
|
* `token` of the {@link OpenInvitation} from {@link createOpenInvitation}.
|
|
5641
5720
|
* @param sAppId - The sApp a redeemed strand will use.
|
|
5642
5721
|
* @param options - Optional `expiresAtMs` (epoch ms), `totalUses`, `validationUrl`,
|
|
5643
5722
|
* `strandId` (bind a closed/pre-existing host strand for provision-then-record).
|
|
5723
|
+
* @param sign - The `sign` of an owner approval the caller already holds; omitted, this
|
|
5724
|
+
* asks for its own.
|
|
5644
5725
|
*/
|
|
5645
|
-
async publishFormationInvite(token, sAppId, options = {}) {
|
|
5646
|
-
const
|
|
5647
|
-
await this.controlDatabase.insertFormationInvite(token, sAppId,
|
|
5726
|
+
async publishFormationInvite(token, sAppId, options = {}, sign) {
|
|
5727
|
+
const signer = this.requireOwnerSigner(`publish formation invite ${token}`);
|
|
5728
|
+
await withinOwnerApproval(signer, { partyId: this.partyId, action: `Publish an invitation to the app ${sAppId}` }, sign, (approved) => this.controlDatabase.insertFormationInvite(token, sAppId, signer.publicKey, approved, options));
|
|
5648
5729
|
// A host may publish an invite whose token was minted elsewhere; registering
|
|
5649
5730
|
// it locally opens the connection gate's formation exemption immediately
|
|
5650
5731
|
// rather than waiting for the durable row to become readable.
|
|
5651
5732
|
this.strandSolicitationService?.registerMintedInvitation(token, options.expiresAtMs ?? Number.POSITIVE_INFINITY);
|
|
5652
|
-
log('Published formation invite %s (sApp %s) under owner %s', token, sAppId,
|
|
5733
|
+
log('Published formation invite %s (sApp %s) under owner %s', token, sAppId, signer.publicKey);
|
|
5653
5734
|
}
|
|
5654
5735
|
/**
|
|
5655
5736
|
* Enroll an approver public key allowed to sign off on `ValidationUrl` redemptions.
|
|
@@ -5659,10 +5740,10 @@ export class CadreNode {
|
|
|
5659
5740
|
* — this is how a party says which outside approver it trusts. Without an enrollment,
|
|
5660
5741
|
* every such invitation is unredeemable.
|
|
5661
5742
|
*
|
|
5662
|
-
* Signs
|
|
5663
|
-
*
|
|
5664
|
-
*
|
|
5665
|
-
*
|
|
5743
|
+
* Signs through the same owner signer as {@link publishStrand}, whose key must be an
|
|
5744
|
+
* enrolled `OwnerKey`. Throws loudly if the node isn't started, has no owner signer wired,
|
|
5745
|
+
* or the key is blank — a blank key would reach the database and fail the signature CHECK
|
|
5746
|
+
* as an opaque constraint error.
|
|
5666
5747
|
*
|
|
5667
5748
|
* Enrolling this node's OWN owner key is permitted and harmless: the domain/action tags
|
|
5668
5749
|
* baked into each authorization digest mean an owner-key signature can never satisfy the
|
|
@@ -5677,10 +5758,10 @@ export class CadreNode {
|
|
|
5677
5758
|
* @param key - Approver public key to enroll (base64url ed25519 public key).
|
|
5678
5759
|
*/
|
|
5679
5760
|
async enrollValidationKey(key) {
|
|
5680
|
-
const
|
|
5761
|
+
const signer = this.requireOwnerSigner('enroll a validation key');
|
|
5681
5762
|
const trimmed = requireEd25519PublicKeyB64(key, 'validation key');
|
|
5682
|
-
await this.controlDatabase.insertValidationKey(trimmed,
|
|
5683
|
-
log('Enrolled validation key %s under owner %s', trimmed,
|
|
5763
|
+
await signer.approve({ partyId: this.partyId, action: `Trust the approver key ${trimmed}` }, (sign) => this.controlDatabase.insertValidationKey(trimmed, signer.publicKey, sign));
|
|
5764
|
+
log('Enrolled validation key %s under owner %s', trimmed, signer.publicKey);
|
|
5684
5765
|
}
|
|
5685
5766
|
/**
|
|
5686
5767
|
* Remove an approver public key.
|
|
@@ -5699,15 +5780,15 @@ export class CadreNode {
|
|
|
5699
5780
|
* @param key - Approver public key to remove (base64url ed25519 public key).
|
|
5700
5781
|
*/
|
|
5701
5782
|
async removeValidationKey(key) {
|
|
5702
|
-
const
|
|
5783
|
+
const signer = this.requireOwnerSigner('remove a validation key');
|
|
5703
5784
|
const trimmed = requireNonBlank(key, 'validation key');
|
|
5704
|
-
await this.controlDatabase.deleteValidationKey(trimmed,
|
|
5705
|
-
log('Removed validation key %s under owner %s', trimmed,
|
|
5785
|
+
await signer.approve({ partyId: this.partyId, action: `Stop trusting the approver key ${trimmed}` }, (sign) => this.controlDatabase.deleteValidationKey(trimmed, signer.publicKey, sign));
|
|
5786
|
+
log('Removed validation key %s under owner %s', trimmed, signer.publicKey);
|
|
5706
5787
|
}
|
|
5707
5788
|
/**
|
|
5708
5789
|
* The approver keys currently enrolled, sorted. Read-only, so unlike
|
|
5709
|
-
* {@link enrollValidationKey} / {@link removeValidationKey} it needs no owner
|
|
5710
|
-
*
|
|
5790
|
+
* {@link enrollValidationKey} / {@link removeValidationKey} it needs no owner signer —
|
|
5791
|
+
* only a started node.
|
|
5711
5792
|
*/
|
|
5712
5793
|
async listValidationKeys() {
|
|
5713
5794
|
if (!this._running || !this.controlDatabase) {
|
|
@@ -5716,28 +5797,27 @@ export class CadreNode {
|
|
|
5716
5797
|
return await this.controlDatabase.queryValidationKeys();
|
|
5717
5798
|
}
|
|
5718
5799
|
/**
|
|
5719
|
-
*
|
|
5720
|
-
* {@link unpublishStrand}, {@link
|
|
5721
|
-
* {@link removeValidationKey}
|
|
5722
|
-
*
|
|
5723
|
-
*
|
|
5800
|
+
* The owner signer every owner-signed strand write needs — {@link publishStrand},
|
|
5801
|
+
* {@link unpublishStrand}, {@link ensureStrandPartyKey}, {@link publishFormationInvite},
|
|
5802
|
+
* {@link enrollValidationKey}, {@link removeValidationKey} — failing loudly in one two-part
|
|
5803
|
+
* shape (not started / no signer wired, naming the wiring call as the fix). Enrollment is the
|
|
5804
|
+
* schema's to judge. Narrows `controlDatabase` for the caller: a return means
|
|
5724
5805
|
* `this.controlDatabase` is non-null too.
|
|
5725
5806
|
*
|
|
5726
5807
|
* @param action - Infinitive phrase naming the attempted write, e.g. `'enroll a
|
|
5727
5808
|
* validation key'`; interpolated into both messages.
|
|
5728
5809
|
*/
|
|
5729
|
-
|
|
5810
|
+
requireOwnerSigner(action) {
|
|
5730
5811
|
if (!this._running || !this.controlDatabase) {
|
|
5731
5812
|
throw new Error(`CadreNode must be started before attempting to ${action}`);
|
|
5732
5813
|
}
|
|
5733
|
-
const
|
|
5734
|
-
if (!
|
|
5735
|
-
throw new Error(`Cannot ${action}:
|
|
5736
|
-
'(
|
|
5737
|
-
'
|
|
5738
|
-
'control state.');
|
|
5814
|
+
const signer = this.getOwnerSigner();
|
|
5815
|
+
if (!signer) {
|
|
5816
|
+
throw new Error(`Cannot ${action}: the owner signer is not initialized. Wire this node's owner key ` +
|
|
5817
|
+
'(useOwnerSigner, or initializeSeedBootstrap for a key held in memory) before writing ' +
|
|
5818
|
+
'owner-signed control state.');
|
|
5739
5819
|
}
|
|
5740
|
-
return
|
|
5820
|
+
return signer;
|
|
5741
5821
|
}
|
|
5742
5822
|
/**
|
|
5743
5823
|
* Shared strand launch path for both the explicit (`addStrand`) and the
|
|
@@ -5746,10 +5826,10 @@ export class CadreNode {
|
|
|
5746
5826
|
* before emitting `strand:started`.
|
|
5747
5827
|
*
|
|
5748
5828
|
* Founder-ness: an explicit `founder` argument wins (the formation/responder flows pass
|
|
5749
|
-
* one deliberately — their consent-seated rows carry a null `
|
|
5829
|
+
* one deliberately — their consent-seated rows carry a null `FounderPeerId`); when
|
|
5750
5830
|
* unset it is DERIVED from the row, so the control-discovered path
|
|
5751
5831
|
* (`handleStrandAdded`) and a restart's re-attach found this machine's own strands
|
|
5752
|
-
* without the caller having to know. The derivation is pure
|
|
5832
|
+
* without the caller having to know. The derivation is a pure peer-id comparison — no I/O.
|
|
5753
5833
|
*
|
|
5754
5834
|
* Idempotent when the strand manager already tracks `strand.Id` — the watcher
|
|
5755
5835
|
* rediscovers a row this node already started and calls this again via
|
|
@@ -5998,10 +6078,11 @@ export class CadreNode {
|
|
|
5998
6078
|
* receiver records an admission grant BEFORE `libp2p.start()` (re-)dials the
|
|
5999
6079
|
* relay reservation — the gate denial there is fatal, not degraded. A
|
|
6000
6080
|
* party-member relay answers the RPC (its control node admits us as a
|
|
6001
|
-
* member); a dedicated ops/ relay does not speak the protocol
|
|
6002
|
-
*
|
|
6003
|
-
*
|
|
6004
|
-
*
|
|
6081
|
+
* member); a dedicated ops/ relay does not speak the protocol, comes back
|
|
6082
|
+
* `unsupported`, and is left out of later passes
|
|
6083
|
+
* ({@link relaysRefusingStrandAddr}) — it has no membership gate and needs no
|
|
6084
|
+
* grant. The relay's direct addr rides along as the dial fallback for a relay
|
|
6085
|
+
* we are not yet connected to.
|
|
6005
6086
|
*/
|
|
6006
6087
|
async resolveCohortSeed(strandId, delegatePeerId) {
|
|
6007
6088
|
const siblings = await this.resolveSiblingSeed(strandId, delegatePeerId);
|
|
@@ -6028,16 +6109,17 @@ export class CadreNode {
|
|
|
6028
6109
|
targets.push(relayStrandAddrPeer(relay));
|
|
6029
6110
|
}
|
|
6030
6111
|
}
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
|
|
6112
|
+
if (targets.length === 0) {
|
|
6113
|
+
return [];
|
|
6114
|
+
}
|
|
6115
|
+
const { addrs, outcomes } = await this.collectSiblingStrandAddrs(this.controlNode, targets, strandId, delegatePeerId);
|
|
6034
6116
|
// Throttle state for the RELAY targets only — refreshDelegateGrants never
|
|
6035
6117
|
// looks up a sibling key, so recording one would only be dead weight. The
|
|
6036
6118
|
// siblings' refresh due times are deliberately left alone too: stamping here
|
|
6037
6119
|
// would race the refresh pass's pruning while the strand node is still coming
|
|
6038
6120
|
// up, and all it would save is one extra RPC per sibling on the first tick.
|
|
6039
|
-
this.recordDelegateAnnounces(relays.map((r) => r.relayPeerId), strandId);
|
|
6040
|
-
return
|
|
6121
|
+
this.recordDelegateAnnounces(relays.map((r) => r.relayPeerId), strandId, outcomes);
|
|
6122
|
+
return addrs;
|
|
6041
6123
|
}
|
|
6042
6124
|
/**
|
|
6043
6125
|
* The co-cadre siblings worth sending a strand-addr RPC to right now: cohort
|
|
@@ -6102,34 +6184,41 @@ export class CadreNode {
|
|
|
6102
6184
|
* `libp2p.start()`. Kept because it costs one spread and this is the one place
|
|
6103
6185
|
* that would silently stop announcing if that rejection is ever relaxed.
|
|
6104
6186
|
*
|
|
6187
|
+
* Less the relays remembered as refusing the strand-addr protocol
|
|
6188
|
+
* ({@link relaysRefusingStrandAddr}): both callers are announcement passes, and
|
|
6189
|
+
* those relays hold no grant.
|
|
6190
|
+
*
|
|
6105
6191
|
* NOTE: a relay the STRAND node discovers on its own (autorelay — in neither
|
|
6106
6192
|
* source) gets no announcement, and a membership-gated one will deny it; fine
|
|
6107
6193
|
* now, every realistic topology feeds one of the two sources.
|
|
6108
6194
|
*/
|
|
6109
6195
|
circuitRelayTargets() {
|
|
6110
|
-
|
|
6196
|
+
const relays = extractCircuitRelayTargets([
|
|
6111
6197
|
...relayCircuitAddrs(this.config.network?.relayAddrs ?? []),
|
|
6112
6198
|
...(this.config.network?.listenAddrs ?? []),
|
|
6113
6199
|
...(this.controlNode?.getMultiaddrs().map(String) ?? [])
|
|
6114
6200
|
]);
|
|
6201
|
+
return relaysNotRefusing(relays, this.relaysRefusingStrandAddr);
|
|
6115
6202
|
}
|
|
6116
6203
|
/**
|
|
6117
|
-
* Record
|
|
6118
|
-
*
|
|
6204
|
+
* Record what a delegate-carrying announce pass learned about the relays it
|
|
6205
|
+
* dialed: the announce timestamps {@link refreshDelegateGrants} throttles on,
|
|
6206
|
+
* and from `outcomes` which relays refuse the strand-addr protocol
|
|
6207
|
+
* ({@link relaysRefusingStrandAddr}). Every announce site records through here.
|
|
6119
6208
|
*
|
|
6120
|
-
*
|
|
6121
|
-
*
|
|
6122
|
-
*
|
|
6123
|
-
*
|
|
6124
|
-
* costs the strand supervisor its first attempt only (the relay denies the
|
|
6209
|
+
* Timestamps are recorded OPTIMISTICALLY, whatever `collectStrandAddrs` reports
|
|
6210
|
+
* per peer: recording only on success would re-announce to an unreachable relay
|
|
6211
|
+
* on every reconcile tick, one dial timeout each. A failed INITIAL announce costs
|
|
6212
|
+
* the strand supervisor its first attempt only (the relay denies the
|
|
6125
6213
|
* reservation; every re-drive re-announces first through
|
|
6126
6214
|
* {@link announceDelegateToRelay}); a failed REFRESH retries within
|
|
6127
6215
|
* `DELEGATE_GRANT_TTL_MS / 2` (15 min), still inside the 30 min TTL.
|
|
6128
6216
|
*/
|
|
6129
|
-
recordDelegateAnnounces(relayPeerIds, strandId, now = Date.now()) {
|
|
6217
|
+
recordDelegateAnnounces(relayPeerIds, strandId, outcomes, now = Date.now()) {
|
|
6130
6218
|
for (const relayPeerId of relayPeerIds) {
|
|
6131
6219
|
this.delegateAnnounceAt.set(peerStrandKey(relayPeerId, strandId), now);
|
|
6132
6220
|
}
|
|
6221
|
+
recordRelayRefusals(this.relaysRefusingStrandAddr, relayPeerIds, outcomes);
|
|
6133
6222
|
}
|
|
6134
6223
|
/**
|
|
6135
6224
|
* Re-announce every running strand's delegate peerId to this node's circuit
|
|
@@ -6182,11 +6271,12 @@ export class CadreNode {
|
|
|
6182
6271
|
* The throttle map is updated afterwards, so the periodic pass does not announce
|
|
6183
6272
|
* again right away.
|
|
6184
6273
|
*
|
|
6185
|
-
*
|
|
6186
|
-
*
|
|
6187
|
-
*
|
|
6188
|
-
*
|
|
6189
|
-
*
|
|
6274
|
+
* Unlike the other announce passes, this asks a relay remembered in
|
|
6275
|
+
* {@link relaysRefusingStrandAddr} too, and its reply clears the entry — see
|
|
6276
|
+
* there for why. Against a dedicated ops relay (no strand-addr RPC) that costs
|
|
6277
|
+
* one refused protocol negotiation (`unsupported`) per re-drive attempt, bounded
|
|
6278
|
+
* by the supervisor's backoff. Never throws on that path; a relay addr that names
|
|
6279
|
+
* no peer id is logged and skipped (the hook's caller runs the drive regardless).
|
|
6190
6280
|
*
|
|
6191
6281
|
* NOTE: against a relay that is DOWN this hook costs up to two strand-addr
|
|
6192
6282
|
* timeouts (dial by peer id, then by addr; 28.5 s each at the default declared link round trip)
|
|
@@ -6208,8 +6298,8 @@ export class CadreNode {
|
|
|
6208
6298
|
log('announceDelegateToRelay: relay addr %s names no peer id; strand %s not announced', relayAddr, strandId);
|
|
6209
6299
|
return;
|
|
6210
6300
|
}
|
|
6211
|
-
await this.collectSiblingStrandAddrs(controlNode, [{ peerId: relayPeerId, addrs: [multiaddr(relayAddr)] }], strandId, delegatePeerId);
|
|
6212
|
-
this.recordDelegateAnnounces([relayPeerId], strandId);
|
|
6301
|
+
const { outcomes } = await this.collectSiblingStrandAddrs(controlNode, [{ peerId: relayPeerId, addrs: [multiaddr(relayAddr)] }], strandId, delegatePeerId);
|
|
6302
|
+
this.recordDelegateAnnounces([relayPeerId], strandId, outcomes);
|
|
6213
6303
|
}
|
|
6214
6304
|
/** One strand's share of {@link refreshDelegateGrants}: announce to the relays whose grant is due. */
|
|
6215
6305
|
async announceDelegateToDueRelays(strandId, delegatePeerId, relays, now) {
|
|
@@ -6221,8 +6311,8 @@ export class CadreNode {
|
|
|
6221
6311
|
if (due.length === 0) {
|
|
6222
6312
|
return;
|
|
6223
6313
|
}
|
|
6224
|
-
await this.collectSiblingStrandAddrs(controlNode, due.map(relayStrandAddrPeer), strandId, delegatePeerId);
|
|
6225
|
-
this.recordDelegateAnnounces(due.map((relay) => relay.relayPeerId), strandId, now);
|
|
6314
|
+
const { outcomes } = await this.collectSiblingStrandAddrs(controlNode, due.map(relayStrandAddrPeer), strandId, delegatePeerId);
|
|
6315
|
+
this.recordDelegateAnnounces(due.map((relay) => relay.relayPeerId), strandId, outcomes, now);
|
|
6226
6316
|
}
|
|
6227
6317
|
/**
|
|
6228
6318
|
* Keep each running strand's own libp2p address book warm: re-merge the address
|
|
@@ -7081,12 +7171,19 @@ export class CadreNode {
|
|
|
7081
7171
|
// Seed Bootstrap API
|
|
7082
7172
|
// ============================================================================
|
|
7083
7173
|
/**
|
|
7084
|
-
*
|
|
7085
|
-
*
|
|
7174
|
+
* Make `signer` this node's owner signer (`owner-signer.ts`): every owner signature the node
|
|
7175
|
+
* produces from now on goes through it — seeds, vouches, removals, invitations, strand and
|
|
7176
|
+
* join rows, and the background re-issues. Builds the owner-capable seed bootstrap service
|
|
7177
|
+
* around it, so this must come before any of those. One signer per node; a node started
|
|
7178
|
+
* with `claim` is refused (its claimant owns it).
|
|
7179
|
+
*
|
|
7180
|
+
* Anchors the signer's key in the node-local trusted-owner store as `genesis`: wiring a key
|
|
7181
|
+
* declares it an authority of this party.
|
|
7086
7182
|
*
|
|
7087
|
-
* @
|
|
7183
|
+
* @throws when the node is not started, was started with `claim`, or already holds the
|
|
7184
|
+
* seed protocol handler (a second wiring, or {@link enableSeedListener} first).
|
|
7088
7185
|
*/
|
|
7089
|
-
async
|
|
7186
|
+
async useOwnerSigner(signer) {
|
|
7090
7187
|
if (!this.controlNode || !this.controlDatabase) {
|
|
7091
7188
|
throw new Error('CadreNode must be started before initializing seed bootstrap');
|
|
7092
7189
|
}
|
|
@@ -7096,9 +7193,9 @@ export class CadreNode {
|
|
|
7096
7193
|
if (this.config.claim) {
|
|
7097
7194
|
throw new Error('CadreNode: a node started with `claim` is owned by its claimant and cannot initialize seed bootstrap with an owner key');
|
|
7098
7195
|
}
|
|
7099
|
-
// A node wiring
|
|
7100
|
-
//
|
|
7101
|
-
//
|
|
7196
|
+
// A node wiring an owner signer is declaring its key an authority of its own
|
|
7197
|
+
// party (founder genesis / self-signing owner) — anchor it in the node-local
|
|
7198
|
+
// store. The in-memory set updates
|
|
7102
7199
|
// synchronously (see TrustedOwnerStore.trust); only the file persist is
|
|
7103
7200
|
// fire-and-forget, logged on failure.
|
|
7104
7201
|
// NOTE: wiring a key that is not a live owner here self-anchors a key that is
|
|
@@ -7106,11 +7203,11 @@ export class CadreNode {
|
|
|
7106
7203
|
// it: a `genesis` entry is base, kept whether or not a live `OwnerKey` row
|
|
7107
7204
|
// vouches it. A machine that joined by invitation reaches this only through
|
|
7108
7205
|
// `wireOwnKeyIfOwner`, which checks the row first, so a joined non-owner never
|
|
7109
|
-
// self-anchors.
|
|
7110
|
-
//
|
|
7111
|
-
//
|
|
7112
|
-
//
|
|
7113
|
-
//
|
|
7206
|
+
// self-anchors. The founded-party starts (cadre-cli `start-owner.ts`, the phone
|
|
7207
|
+
// kit's `runOwnerGenesis`, the web app's `cadre-web.ts`) wire a key only after
|
|
7208
|
+
// inserting it as the founding row or finding it a live owner, but they judge
|
|
7209
|
+
// "founding" on local rows, so one started on a store that has not synced a party
|
|
7210
|
+
// founded elsewhere genesis-inserts and self-anchors its key. Contained while the store does
|
|
7114
7211
|
// not replicate and such a node cannot mint: `createCadreInvitation` hands out
|
|
7115
7212
|
// the anchor as the device's pins, but the `CadreInvite` insert needs a live
|
|
7116
7213
|
// owner as issuer. If anything hands out the anchor without that check, gate
|
|
@@ -7120,23 +7217,34 @@ export class CadreNode {
|
|
|
7120
7217
|
// identity key alone.
|
|
7121
7218
|
if (this.trustedOwnerStore) {
|
|
7122
7219
|
void this.trustedOwnerStore
|
|
7123
|
-
.trust(
|
|
7220
|
+
.trust(signer.publicKey, 'genesis')
|
|
7124
7221
|
.catch((error) => log('Trusted-owner genesis anchor persist failed: %o', error));
|
|
7125
7222
|
}
|
|
7126
7223
|
await this.installSeedBootstrapService(new SeedBootstrapService({
|
|
7127
7224
|
partyId: this.config.controlNetwork.partyId,
|
|
7128
|
-
|
|
7225
|
+
ownerSigner: signer,
|
|
7129
7226
|
...this.seedServiceBudgets(),
|
|
7130
7227
|
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
7131
7228
|
// Seed trust anchors on the node-local store (seeded just above with this
|
|
7132
7229
|
// node's own genesis key), never on the replicated OwnerKey table.
|
|
7133
7230
|
...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
|
|
7134
7231
|
}), this.controlNode, this.controlDatabase);
|
|
7135
|
-
log('
|
|
7232
|
+
log('Owner signer wired (owner key %s)', signer.publicKey);
|
|
7136
7233
|
// Embedders wire the owner key after start(), so the runner's first pass usually found
|
|
7137
7234
|
// this machine not yet an owner; without this it would wait a full poll interval.
|
|
7138
7235
|
this.pendingJoinRunner?.kick();
|
|
7139
7236
|
}
|
|
7237
|
+
/**
|
|
7238
|
+
* {@link useOwnerSigner} with a software signer over `ownerPrivateKey` (`softwareOwnerSigner`):
|
|
7239
|
+
* the owner key held in this process's memory. Kept as a convenience because most tests and
|
|
7240
|
+
* the embedders that hold the key themselves wire it this way; one that keeps the key
|
|
7241
|
+
* elsewhere wires its own signer.
|
|
7242
|
+
*
|
|
7243
|
+
* @param ownerPrivateKey - The owner key's 32-byte Ed25519 seed, base64url.
|
|
7244
|
+
*/
|
|
7245
|
+
async initializeSeedBootstrap(ownerPrivateKey) {
|
|
7246
|
+
await this.useOwnerSigner(softwareOwnerSigner(ownerPrivateKey));
|
|
7247
|
+
}
|
|
7140
7248
|
/**
|
|
7141
7249
|
* Push the multiaddrs that future invites should advertise. Pass `null` to
|
|
7142
7250
|
* revert to the libp2p-reported addresses (the default). The host calls this
|
|
@@ -7211,7 +7319,7 @@ export class CadreNode {
|
|
|
7211
7319
|
* never reach here: {@link ControlDatabase.queryCadrePeers} excludes them, so a
|
|
7212
7320
|
* revoked peer is not addressable either.
|
|
7213
7321
|
*
|
|
7214
|
-
* NOTE: answers the whole `CadrePeerRow`, past the
|
|
7322
|
+
* NOTE: answers the whole `CadrePeerRow`, past the fields declared; the integration
|
|
7215
7323
|
* harness (`owner-cli-node.ts`, `OwnerCliMemberRow.sig`) reads `sig` off it through the admin
|
|
7216
7324
|
* channel to tell a row another member seated from one the subject published itself. Narrow
|
|
7217
7325
|
* this to the declared fields only together with that reader.
|
|
@@ -7308,7 +7416,7 @@ export class CadreNode {
|
|
|
7308
7416
|
}
|
|
7309
7417
|
/** The invitation chain, loaded only when some row in `rows` is invitation-admitted. */
|
|
7310
7418
|
async chainFor(rows, loadChain) {
|
|
7311
|
-
return rows.some(
|
|
7419
|
+
return rows.some(isConsentSeated) ? await loadChain() : null;
|
|
7312
7420
|
}
|
|
7313
7421
|
/**
|
|
7314
7422
|
* The authorized members among `rows`, already read: checks 1 to 4 of
|
|
@@ -7339,7 +7447,7 @@ export class CadreNode {
|
|
|
7339
7447
|
* one an owner re-vouch leaves behind.
|
|
7340
7448
|
*/
|
|
7341
7449
|
hasAnchoredProof(row, chain) {
|
|
7342
|
-
if (!
|
|
7450
|
+
if (!isConsentSeated(row)) {
|
|
7343
7451
|
return this.hasAnchoredVoucher(row);
|
|
7344
7452
|
}
|
|
7345
7453
|
const usage = chain?.usages.get(row.vouchUsage);
|
|
@@ -7378,17 +7486,15 @@ export class CadreNode {
|
|
|
7378
7486
|
* NOTE: cost per membership refresh is one `OwnerKey` read and one `Revocation` read (the
|
|
7379
7487
|
* tombstone list, whose `OwnerKey` stamps are handed to `queryOwnerKeyRows` so it does not
|
|
7380
7488
|
* read the retired set again, and which the hang-up of removed peers shares through
|
|
7381
|
-
* `loadTombstones`), plus the
|
|
7382
|
-
*
|
|
7383
|
-
*
|
|
7384
|
-
* marker is filed that one read is a cohort consult per refresh, which
|
|
7385
|
-
* `control-founding-consult-budget.spec.ts` pins.
|
|
7489
|
+
* `loadTombstones`), plus the owner-grant chain when some owner row is grant-seated (the
|
|
7490
|
+
* NOTE in the body). Before the Revocation ledger marker is filed that one read is a cohort
|
|
7491
|
+
* consult per refresh, which `control-founding-consult-budget.spec.ts` pins.
|
|
7386
7492
|
*
|
|
7387
7493
|
* @param retry - forwarded to every read; `false` on the refresh path, for the reason on
|
|
7388
7494
|
* {@link refreshAuthorizedControlPeers}.
|
|
7389
7495
|
* @param loadTombstones - the refresh's shared tombstone read ({@link revocationSource}).
|
|
7390
7496
|
*/
|
|
7391
|
-
async syncOwnerAnchor(reason, retry,
|
|
7497
|
+
async syncOwnerAnchor(reason, retry, loadTombstones) {
|
|
7392
7498
|
const store = this.trustedOwnerStore;
|
|
7393
7499
|
const controlDatabase = this.controlDatabase;
|
|
7394
7500
|
if (!store || !controlDatabase) {
|
|
@@ -7403,8 +7509,11 @@ export class CadreNode {
|
|
|
7403
7509
|
const tombstones = await loadTombstones();
|
|
7404
7510
|
const retired = new Set(tombstones.filter(tombstone => tombstone.tableName === 'OwnerKey').map(tombstone => tombstone.stampId));
|
|
7405
7511
|
const rows = await controlDatabase.queryOwnerKeyRows(retry, retired);
|
|
7406
|
-
|
|
7407
|
-
|
|
7512
|
+
// NOTE: the grant chain is two whole-table reads per membership refresh while any owner
|
|
7513
|
+
// row is grant-seated, over tables that are append-only; the memo suggested on
|
|
7514
|
+
// loadInvitationChain would cover it too.
|
|
7515
|
+
const grantChain = rows.some(isConsentSeated) ? await this.loadOwnerGrantChain(controlDatabase, retry) : null;
|
|
7516
|
+
const derivation = deriveOwnerAnchor({ partyId: this.partyId, anchor: before, rows, tombstones, grantChain });
|
|
7408
7517
|
if (derivation.refusedRemovals.length > 0) {
|
|
7409
7518
|
log('syncOwnerAnchor(%s): refused %d owner removal(s) that would empty the anchor: %o', reason, derivation.refusedRemovals.length, derivation.refusedRemovals);
|
|
7410
7519
|
}
|
|
@@ -7458,9 +7567,9 @@ export class CadreNode {
|
|
|
7458
7567
|
return () => (loading ?? (loading = controlDatabase.queryRevocations(retry)));
|
|
7459
7568
|
}
|
|
7460
7569
|
/**
|
|
7461
|
-
* The usage and invitation rows {@link hasAnchoredProof}
|
|
7462
|
-
*
|
|
7463
|
-
*
|
|
7570
|
+
* The usage and invitation rows {@link hasAnchoredProof} resolves an invitation-admitted
|
|
7571
|
+
* `CadrePeer` row through, keyed for lookup. Both reads honour `retry` for
|
|
7572
|
+
* {@link listAuthorizedMembers}' reason.
|
|
7464
7573
|
*
|
|
7465
7574
|
* NOTE: once invitations are the only admission route (ticket
|
|
7466
7575
|
* `cadre-invitations-redeemable-by-any-member`), every cadre with a member not yet
|
|
@@ -7480,6 +7589,21 @@ export class CadreNode {
|
|
|
7480
7589
|
invites: new Map(invites.map(invite => [invite.key, invite])),
|
|
7481
7590
|
};
|
|
7482
7591
|
}
|
|
7592
|
+
/**
|
|
7593
|
+
* The usage and grant rows {@link syncOwnerAnchor} resolves a grant-seated `OwnerKey` row
|
|
7594
|
+
* through, keyed for lookup: the owner-grant twin of {@link loadInvitationChain}, read only by
|
|
7595
|
+
* the anchor sync, since a grant admits no machine.
|
|
7596
|
+
*/
|
|
7597
|
+
async loadOwnerGrantChain(controlDatabase, retry) {
|
|
7598
|
+
const [usages, grants] = await Promise.all([
|
|
7599
|
+
controlDatabase.queryOwnerGrantUsages(retry),
|
|
7600
|
+
controlDatabase.queryOwnerGrants(retry),
|
|
7601
|
+
]);
|
|
7602
|
+
return {
|
|
7603
|
+
usages: new Map(usages.map(usage => [usage.usageStampId, usage])),
|
|
7604
|
+
grants: new Map(grants.map(grant => [grant.key, grant])),
|
|
7605
|
+
};
|
|
7606
|
+
}
|
|
7483
7607
|
/**
|
|
7484
7608
|
* The owner-voucher half of {@link hasAnchoredProof}: complete voucher, `VouchOwner` in
|
|
7485
7609
|
* the node-local anchor, signature valid over the row's (PeerId, StampId) voucher digest.
|
|
@@ -7599,11 +7723,11 @@ export class CadreNode {
|
|
|
7599
7723
|
}
|
|
7600
7724
|
/**
|
|
7601
7725
|
* The event callbacks every {@link SeedBootstrapService} this node owns is
|
|
7602
|
-
* wired with — shared by the owner-capable service ({@link
|
|
7726
|
+
* wired with — shared by the owner-capable service ({@link useOwnerSigner})
|
|
7603
7727
|
* and the listener-only one ({@link enableSeedListener}) so the two cannot drift.
|
|
7604
7728
|
*
|
|
7605
7729
|
* A seed applied by the INBOUND protocol handler writes no `CadrePeer` row of
|
|
7606
|
-
* its own (it merges the libp2p peer store and dials
|
|
7730
|
+
* its own (it merges the libp2p peer store and dials the seed's preferred peers), so the automatic
|
|
7607
7731
|
* write-driven refresh never fires for it — yet applying it can ANCHOR a new
|
|
7608
7732
|
* owner key, which flips rows already present from unauthorized to authorized.
|
|
7609
7733
|
* Hence the explicit refresh here; without it a peer the freshly anchored owner
|
|
@@ -7615,7 +7739,7 @@ export class CadreNode {
|
|
|
7615
7739
|
onSeedApplied: (partyId, peersAdded, seed) => {
|
|
7616
7740
|
this.emit('seed:applied', { partyId, peersAdded });
|
|
7617
7741
|
// A wire-delivered seed never passes through the `applySeed` wrapper, so
|
|
7618
|
-
// this is where its
|
|
7742
|
+
// this is where its preferred peers become cold-start bootstrap targets.
|
|
7619
7743
|
this.recordSeedBootstrapPeers(seed);
|
|
7620
7744
|
void this.refreshMembershipGate('seed-applied');
|
|
7621
7745
|
},
|
|
@@ -7751,12 +7875,16 @@ export class CadreNode {
|
|
|
7751
7875
|
* @param multiaddrs - Optional multiaddrs for the peer
|
|
7752
7876
|
*/
|
|
7753
7877
|
async authorizePeer(peerId, multiaddrs) {
|
|
7878
|
+
await this.vouchPeer(peerId, multiaddrs);
|
|
7879
|
+
}
|
|
7880
|
+
/** {@link authorizePeer}'s body, signing inside the caller's owner approval when it passes `sign` ({@link claimNode}). */
|
|
7881
|
+
async vouchPeer(peerId, multiaddrs, sign) {
|
|
7754
7882
|
if (!this.seedBootstrapService) {
|
|
7755
7883
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7756
7884
|
}
|
|
7757
7885
|
// The insert notifies the control DB's membership hub, so the per-stream gate
|
|
7758
7886
|
// has already admitted the just-vouched peer by the time this resolves.
|
|
7759
|
-
await this.seedBootstrapService.authorizePeer({ peerId, multiaddrs });
|
|
7887
|
+
await this.seedBootstrapService.authorizePeer({ peerId, multiaddrs }, sign);
|
|
7760
7888
|
// Queue for re-replication if this committed local-only (no connected cohort).
|
|
7761
7889
|
this.noteControlWrite(peerId, 'authorize');
|
|
7762
7890
|
// NOTE: unlike addDrone, `multiaddrs` is not retained as a dial target: the
|
|
@@ -7768,9 +7896,9 @@ export class CadreNode {
|
|
|
7768
7896
|
* Remove a previously-authorized peer from the cadre.
|
|
7769
7897
|
* Signs the peer ID with the owner key and deletes the CadrePeer row.
|
|
7770
7898
|
*
|
|
7771
|
-
* Removes the `CadrePeer` row only. The call that removes a machine completely (its
|
|
7772
|
-
*
|
|
7773
|
-
*
|
|
7899
|
+
* Removes the `CadrePeer` row only. The call that removes a machine completely (its push
|
|
7900
|
+
* token and the invitations it could come back through too, in one transaction) is
|
|
7901
|
+
* {@link removeMachine}.
|
|
7774
7902
|
*
|
|
7775
7903
|
* @param peerId - The peer ID to remove
|
|
7776
7904
|
*/
|
|
@@ -7796,85 +7924,227 @@ export class CadreNode {
|
|
|
7796
7924
|
* `OwnerKey.Authorized`; the row stores the vouching pair). The write notifies the
|
|
7797
7925
|
* membership hub, so the refresh it triggers has run by the time this resolves.
|
|
7798
7926
|
*
|
|
7927
|
+
* Refused while alone ({@link ownerKeyWriteAlone}): an `OwnerKey` add has no re-issue path,
|
|
7928
|
+
* so one committed with no control connection would stay on this node.
|
|
7929
|
+
*
|
|
7799
7930
|
* @param ownerPublicKeyB64 - the new owner's ed25519 public key (base64url).
|
|
7931
|
+
* @param options.label - the user's name for the key ("phone", "paper backup"), bound into
|
|
7932
|
+
* the signature; trimmed, at most 64 characters, blank meaning none.
|
|
7800
7933
|
* @returns `true` when the row was seated, `false` when the key is already a live owner.
|
|
7934
|
+
* @throws OwnerKeyAddRefusedError `alone`, before any write.
|
|
7801
7935
|
*/
|
|
7802
|
-
async addOwner(ownerPublicKeyB64) {
|
|
7936
|
+
async addOwner(ownerPublicKeyB64, options = {}) {
|
|
7803
7937
|
if (!this.seedBootstrapService) {
|
|
7804
7938
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7805
7939
|
}
|
|
7806
7940
|
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
7941
|
+
if (await this.ownerKeyWriteAlone()) {
|
|
7942
|
+
throw new OwnerKeyAddRefusedError(key, 'alone');
|
|
7943
|
+
}
|
|
7944
|
+
const added = await this.seedBootstrapService.addOwner(key, options.label);
|
|
7945
|
+
// NOTE: the refusal is checked before the approval, and a connection can drop while the
|
|
7946
|
+
// user approves; an add that still commits alone is logged. If that window ever loses an
|
|
7947
|
+
// owner key in practice, re-check inside the write and roll back.
|
|
7811
7948
|
if (added && this.committedAlone()) {
|
|
7812
|
-
log('addOwner(%s) committed while ALONE (
|
|
7813
|
-
'local-only and is not re-issued on cohort growth
|
|
7949
|
+
log('addOwner(%s) committed while ALONE (the last control connection dropped during the add): ' +
|
|
7950
|
+
'the new owner row is local-only and is not re-issued on cohort growth.', key);
|
|
7814
7951
|
}
|
|
7815
7952
|
return added;
|
|
7816
7953
|
}
|
|
7817
7954
|
/**
|
|
7818
|
-
*
|
|
7819
|
-
*
|
|
7820
|
-
*
|
|
7821
|
-
*
|
|
7822
|
-
*
|
|
7955
|
+
* Whether an `OwnerKey` add made now ({@link addOwner}, or the re-add of
|
|
7956
|
+
* {@link removeOwner}'s re-vouch) is refused `alone`: this node holds no control connection
|
|
7957
|
+
* while the party has other members. An `OwnerKey` row has no update to re-touch, so nothing
|
|
7958
|
+
* re-issues it on cohort growth, and a row written now would fork this node's history. A
|
|
7959
|
+
* party with no other members cannot fork, so a solo founder adding an owner key is not
|
|
7960
|
+
* refused.
|
|
7823
7961
|
*
|
|
7824
|
-
*
|
|
7825
|
-
*
|
|
7962
|
+
* Waits out an in-flight membership refresh first: until `start`'s first one lands the
|
|
7963
|
+
* member snapshot is empty, which would read as a party with no other members.
|
|
7964
|
+
*/
|
|
7965
|
+
async ownerKeyWriteAlone() {
|
|
7966
|
+
await this.membershipGateDrain;
|
|
7967
|
+
return this.committedAlone() && this.authorizedControlPeers.size > 0;
|
|
7968
|
+
}
|
|
7969
|
+
/**
|
|
7970
|
+
* Every live owner key with its label, who vouched it, whether this node's owner signer
|
|
7971
|
+
* holds it and whether this node's trusted-owner anchor trusts it ({@link CadreOwnerKey}),
|
|
7972
|
+
* for an app's owner-key screen.
|
|
7973
|
+
*/
|
|
7974
|
+
async listOwnerKeys() {
|
|
7975
|
+
if (!this.controlDatabase) {
|
|
7976
|
+
throw new Error('CadreNode must be started before listing owner keys');
|
|
7977
|
+
}
|
|
7978
|
+
const rows = await this.controlDatabase.queryOwnerKeyRows();
|
|
7979
|
+
return describeOwnerKeys(rows, this.getOwnerSigner()?.publicKey ?? null, this.trustedOwnerStore?.all() ?? new Set());
|
|
7980
|
+
}
|
|
7981
|
+
/**
|
|
7982
|
+
* What {@link removeOwner} would do for `ownerPublicKeyB64`, read without writing, for an
|
|
7983
|
+
* app's confirmation step ({@link OwnerKeyRemovalPlan}): whether this node may remove it,
|
|
7984
|
+
* which rows the key vouched (so the app can offer to keep them by re-vouching), and how this
|
|
7985
|
+
* node's own rows depend on it (`planOwnerKeyRemoval` in `owner-keys.ts`).
|
|
7986
|
+
*/
|
|
7987
|
+
async planOwnerKeyRemoval(ownerPublicKeyB64) {
|
|
7988
|
+
if (!this.controlDatabase) {
|
|
7989
|
+
throw new Error('CadreNode must be started before planning an owner key removal');
|
|
7990
|
+
}
|
|
7991
|
+
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
7992
|
+
return (await this.planOwnerKeyRemovalWith(this.controlDatabase, key)).plan;
|
|
7993
|
+
}
|
|
7994
|
+
/**
|
|
7995
|
+
* Remove an owner key, signed by this node's owner key (which must be a different owner): the
|
|
7996
|
+
* `OwnerKey` row and its `Revocation` tombstone commit together, and the tombstone rides the
|
|
7997
|
+
* committed-delete seam ({@link noteGuardedDelete}), so one committed while alone is re-issued
|
|
7998
|
+
* on cohort growth like every other removal. The machines the key was used on stay members;
|
|
7999
|
+
* removing one is {@link removeMachine}. The owner grants the key issued stop being redeemable
|
|
8000
|
+
* (the plan's `grants` names the live ones) and are not withdrawn: a tombstone over a grant
|
|
8001
|
+
* nobody can redeem would add nothing.
|
|
8002
|
+
*
|
|
8003
|
+
* Steps: re-plan ({@link planOwnerKeyRemoval}) and throw {@link OwnerKeyRemovalRefusedError}
|
|
8004
|
+
* on a refusal; check `options.revouch` against the plan's dependents and throw before any
|
|
8005
|
+
* write when it names anything else; refuse `alone` ({@link ownerKeyWriteAlone}) when an
|
|
8006
|
+
* `OwnerKey` row is to be re-vouched, since that re-add has no re-issue path; **re-vouch
|
|
8007
|
+
* first**, under this node's key, the dependents the caller chose to keep and this node's own
|
|
8008
|
+
* row when the key vouched it (`ControlDatabase.revouchOwnerKey`, `reauthorizePeer`), so they
|
|
8009
|
+
* are never untrusted in between, and stop before the removal if one fails (re-vouches
|
|
8010
|
+
* already made stay, harmlessly); then the removal. The re-vouches and the removal are one owner approval. The
|
|
8011
|
+
* write notifies the membership hub, so the refresh it triggers has run by the time this
|
|
8012
|
+
* resolves.
|
|
7826
8013
|
*
|
|
7827
8014
|
* @param ownerPublicKeyB64 - the owner's ed25519 public key (base64url).
|
|
7828
|
-
* @returns
|
|
8015
|
+
* @returns what was written ({@link OwnerKeyRemovalResult}).
|
|
8016
|
+
* @throws {OwnerKeyRemovalRefusedError} `'not-an-owner'`, `'unknown'`, `'last-owner'`,
|
|
8017
|
+
* `'self'` or `'alone'`, before any write.
|
|
7829
8018
|
*/
|
|
7830
|
-
async removeOwner(ownerPublicKeyB64) {
|
|
7831
|
-
|
|
8019
|
+
async removeOwner(ownerPublicKeyB64, options = {}) {
|
|
8020
|
+
const service = this.seedBootstrapService;
|
|
8021
|
+
if (!service || !this.controlDatabase) {
|
|
7832
8022
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7833
8023
|
}
|
|
7834
8024
|
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
7835
|
-
|
|
8025
|
+
const { plan, rows } = await this.planOwnerKeyRemovalWith(this.controlDatabase, key);
|
|
8026
|
+
if (plan.refusal !== null) {
|
|
8027
|
+
throw new OwnerKeyRemovalRefusedError(key, plan.refusal);
|
|
8028
|
+
}
|
|
8029
|
+
const revouch = revouchWithinPlan(plan, options.revouch, this.peerId?.toString() ?? null);
|
|
8030
|
+
if (revouch.owners.length > 0 && await this.ownerKeyWriteAlone()) {
|
|
8031
|
+
throw new OwnerKeyRemovalRefusedError(key, 'alone');
|
|
8032
|
+
}
|
|
8033
|
+
// The plan already refused 'not-an-owner' when no enrolled signer is wired.
|
|
8034
|
+
const signer = service.ownerSigner;
|
|
8035
|
+
if (!signer) {
|
|
8036
|
+
throw new OwnerKeyRemovalRefusedError(key, 'not-an-owner');
|
|
8037
|
+
}
|
|
8038
|
+
const action = `Remove the owner key ${namedOwnerKey(key, plan.ownerKey?.label ?? null)} from the cadre`;
|
|
8039
|
+
const removed = await signer.approve({ partyId: this.partyId, action }, async (sign) => {
|
|
8040
|
+
await this.revouchDependents(service, revouch, rows, sign);
|
|
8041
|
+
return await service.removeOwner(key, sign);
|
|
8042
|
+
});
|
|
8043
|
+
log('removeOwner(%s): removed=%s revouched owners=%d peers=%d', key, removed, revouch.owners.length, revouch.peers.length);
|
|
8044
|
+
return { removed, revouched: revouch };
|
|
8045
|
+
}
|
|
8046
|
+
/** {@link planOwnerKeyRemoval} with the rows it was judged from, which {@link removeOwner} re-vouches dependents by. */
|
|
8047
|
+
async planOwnerKeyRemovalWith(controlDatabase, key) {
|
|
8048
|
+
const [rows, owners, grants] = await Promise.all([
|
|
8049
|
+
controlDatabase.queryCadrePeers(), controlDatabase.queryOwnerKeyRows(), controlDatabase.listOwnerGrantStatuses()
|
|
8050
|
+
]);
|
|
8051
|
+
const plan = planOwnerKeyRemoval({
|
|
8052
|
+
key,
|
|
8053
|
+
rows,
|
|
8054
|
+
owners,
|
|
8055
|
+
grants,
|
|
8056
|
+
selfPeerId: this.peerId?.toString() ?? null,
|
|
8057
|
+
selfOwnerKey: this.ownerSignerEnrolledIn(new Set(owners.map((owner) => owner.key)))?.publicKey ?? null,
|
|
8058
|
+
anchor: this.trustedOwnerStore?.all() ?? new Set(),
|
|
8059
|
+
});
|
|
8060
|
+
return { plan, rows };
|
|
8061
|
+
}
|
|
8062
|
+
/**
|
|
8063
|
+
* The re-vouch step of {@link removeOwner}: every chosen dependent row is rebound to this
|
|
8064
|
+
* node's key BEFORE the removal, so no reader ever judges it against an owner that is gone.
|
|
8065
|
+
* Owners through `ControlDatabase.revouchOwnerKey` (a delete-and-re-add under a fresh stamp,
|
|
8066
|
+
* the only rebinding `OwnerKey.NoUpdate` allows); peers through the owner re-touch
|
|
8067
|
+
* (`reauthorizePeer`), which rewrites `VouchOwner`/`VouchSig` and clears `VouchUsage`.
|
|
8068
|
+
*
|
|
8069
|
+
* The peer re-touch keeps the row's `UpdatedAt` where it is. The owner branch of
|
|
8070
|
+
* `CadrePeer.AuthorizedUpdate` does not require a bump, and bumping a self-published row
|
|
8071
|
+
* would leave its `Sig` unverifiable, so unresolvable and unlisted as self-published, until
|
|
8072
|
+
* the machine's next heartbeat (the NOTE on that constraint); the write-while-alone drain
|
|
8073
|
+
* bumps because it re-issues an otherwise unchanged row, whereas here the voucher itself
|
|
8074
|
+
* changes. A row never self-published carries no stamp to keep and gets the clock.
|
|
8075
|
+
*
|
|
8076
|
+
* A failure rethrows and stops the call before the removal; re-vouches already made stay
|
|
8077
|
+
* (the row is only re-signed by a current owner) and the caller retries. An owner re-vouch
|
|
8078
|
+
* is refused alone before this runs; one whose last connection drops while it runs is logged,
|
|
8079
|
+
* as {@link addOwner}'s add is.
|
|
8080
|
+
*
|
|
8081
|
+
* NOTE: a peer re-touch committed alone is not queued, and the drain skips self-published
|
|
8082
|
+
* rows anyway, so the other machines may get the removal's tombstone without the new voucher
|
|
8083
|
+
* (`tickets/backlog/bug-owner-key-removal-revouch-made-alone-is-not-reissued`).
|
|
8084
|
+
*/
|
|
8085
|
+
async revouchDependents(service, revouch, rows, sign) {
|
|
8086
|
+
for (const key of revouch.owners) {
|
|
8087
|
+
await service.revouchOwner(key, sign);
|
|
8088
|
+
if (this.committedAlone()) {
|
|
8089
|
+
log('removeOwner: re-vouch of owner %s committed while ALONE (the last control connection dropped during the call): ' +
|
|
8090
|
+
'the re-added owner row is local-only and is not re-issued on cohort growth.', key);
|
|
8091
|
+
}
|
|
8092
|
+
}
|
|
8093
|
+
for (const dependent of revouch.peers) {
|
|
8094
|
+
const row = rows.find((candidate) => candidate.peerId === dependent);
|
|
8095
|
+
const updatedAt = row !== undefined && row.updatedAt > 0 ? row.updatedAt : Date.now();
|
|
8096
|
+
await service.reauthorizePeer(dependent, updatedAt, sign);
|
|
8097
|
+
}
|
|
7836
8098
|
}
|
|
7837
8099
|
/**
|
|
7838
8100
|
* The cadre's machines as an app lists them ({@link CadreMachine}): one entry per live
|
|
7839
|
-
* `CadrePeer` row, judged by this node's membership predicate
|
|
7840
|
-
*
|
|
8101
|
+
* `CadrePeer` row, judged by this node's membership predicate (`describeMachines` in
|
|
8102
|
+
* `machine-removal.ts`). Owner keys are not machines; {@link listOwnerKeys} lists them.
|
|
7841
8103
|
* Unlocked reads with the default retry.
|
|
7842
8104
|
*/
|
|
7843
8105
|
async listMachines() {
|
|
7844
8106
|
if (!this.controlDatabase) {
|
|
7845
8107
|
throw new Error('CadreNode must be started before listing machines');
|
|
7846
8108
|
}
|
|
7847
|
-
return
|
|
8109
|
+
return await this.readMachines(this.controlDatabase);
|
|
7848
8110
|
}
|
|
7849
8111
|
/**
|
|
7850
8112
|
* What {@link removeMachine} would do for `peerId`, read without writing, for an app's
|
|
7851
|
-
* confirmation step ({@link MachineRemovalPlan}): whether this node may remove it
|
|
7852
|
-
*
|
|
7853
|
-
* re-vouching), which invitations it could come back through, and whether this node's own
|
|
7854
|
-
* rows depend on it (`planMachineRemoval` in `machine-removal.ts`).
|
|
8113
|
+
* confirmation step ({@link MachineRemovalPlan}): whether this node may remove it and which
|
|
8114
|
+
* invitations it could come back through (`planMachineRemoval` in `machine-removal.ts`).
|
|
7855
8115
|
*/
|
|
7856
8116
|
async planMachineRemoval(peerId) {
|
|
7857
8117
|
if (!this.controlDatabase) {
|
|
7858
8118
|
throw new Error('CadreNode must be started before planning a machine removal');
|
|
7859
8119
|
}
|
|
7860
|
-
|
|
8120
|
+
const machines = await this.readMachines(this.controlDatabase);
|
|
8121
|
+
const [invites, usages, owners] = await Promise.all([
|
|
8122
|
+
this.controlDatabase.listCadreInviteStatuses(),
|
|
8123
|
+
this.controlDatabase.queryCadreInviteUsages(),
|
|
8124
|
+
this.controlDatabase.getOwnerKeys(),
|
|
8125
|
+
]);
|
|
8126
|
+
return planMachineRemoval({
|
|
8127
|
+
peerId,
|
|
8128
|
+
machines,
|
|
8129
|
+
selfPeerId: this.peerId?.toString() ?? null,
|
|
8130
|
+
canSign: this.ownerSignerEnrolledIn(owners) !== null,
|
|
8131
|
+
invites,
|
|
8132
|
+
usages,
|
|
8133
|
+
});
|
|
7861
8134
|
}
|
|
7862
8135
|
/**
|
|
7863
8136
|
* Remove a machine from the cadre completely, signed by this node's owner key: its
|
|
7864
|
-
* `CadrePeer` row, its `
|
|
7865
|
-
*
|
|
7866
|
-
*
|
|
7867
|
-
*
|
|
7868
|
-
*
|
|
7869
|
-
*
|
|
7870
|
-
*
|
|
7871
|
-
*
|
|
7872
|
-
*
|
|
7873
|
-
*
|
|
7874
|
-
*
|
|
7875
|
-
* made stay, harmlessly); then the removal transaction. Afterwards this node forgets the
|
|
7876
|
-
* machine as a dial target, and the refresh the write notifies hangs up its control
|
|
7877
|
-
* connection ({@link hangUpRevokedControlPeers}) before this resolves.
|
|
8137
|
+
* `CadrePeer` row, its `DeviceToken` row (a removed phone must not be push-woken) and a
|
|
8138
|
+
* withdrawal of every live invitation it could redeem to come back, in ONE transaction
|
|
8139
|
+
* ({@link ControlDatabase.removeMachine}), with a `Revocation` tombstone per row so the
|
|
8140
|
+
* removal holds on every machine ("Deletes made while alone" in docs/architecture.md), under
|
|
8141
|
+
* one owner approval. Afterwards this node forgets the machine as a dial target, and the
|
|
8142
|
+
* refresh the write notifies hangs up its control connection ({@link hangUpRevokedControlPeers})
|
|
8143
|
+
* before this resolves.
|
|
8144
|
+
*
|
|
8145
|
+
* Owner keys stay: they are the person's, not the machine's. A lost phone that held one can
|
|
8146
|
+
* still sign, a fresh machine invitation it could redeem included, so removing that key
|
|
8147
|
+
* ({@link removeOwner}) is the step that locks it out.
|
|
7878
8148
|
*
|
|
7879
8149
|
* Not undone by this call: the machine keeps every row and strand block it already synced
|
|
7880
8150
|
* and the party's strand member keys (`tickets/blocked/decide-removed-machine-strand-access`).
|
|
@@ -7882,28 +8152,22 @@ export class CadreNode {
|
|
|
7882
8152
|
*
|
|
7883
8153
|
* @returns what was written ({@link MachineRemovalResult}); all false when no live row names
|
|
7884
8154
|
* the machine here (already removed elsewhere).
|
|
7885
|
-
* @throws {MachineRemovalRefusedError} `'not-an-owner'
|
|
8155
|
+
* @throws {MachineRemovalRefusedError} `'not-an-owner'` or `'self'`, before any write.
|
|
7886
8156
|
*/
|
|
7887
|
-
async removeMachine(peerId
|
|
8157
|
+
async removeMachine(peerId) {
|
|
7888
8158
|
const service = this.seedBootstrapService;
|
|
7889
8159
|
if (!service || !this.controlDatabase) {
|
|
7890
8160
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7891
8161
|
}
|
|
7892
|
-
const
|
|
8162
|
+
const plan = await this.planMachineRemoval(peerId);
|
|
7893
8163
|
if (plan.refusal !== null) {
|
|
7894
8164
|
throw new MachineRemovalRefusedError(peerId, plan.refusal);
|
|
7895
8165
|
}
|
|
7896
|
-
const revouch = revouchWithinPlan(plan, options.revouch);
|
|
7897
8166
|
if (plan.machine === null) {
|
|
7898
8167
|
log('removeMachine(%s): no live row names it here; nothing to remove', peerId);
|
|
7899
|
-
return { removedPeer: false,
|
|
8168
|
+
return { removedPeer: false, removedDeviceToken: false, withdrawnInvitations: [] };
|
|
7900
8169
|
}
|
|
7901
|
-
await
|
|
7902
|
-
const writes = await service.removeMachine({
|
|
7903
|
-
peerId,
|
|
7904
|
-
ownerKey: plan.removesOwnerKey ? plan.machine.publicKey : null,
|
|
7905
|
-
inviteKeys: plan.invitations,
|
|
7906
|
-
});
|
|
8170
|
+
const writes = await service.removeMachine({ peerId, inviteKeys: plan.invitations });
|
|
7907
8171
|
if (writes.removedPeer) {
|
|
7908
8172
|
// Track + loudly flag a delete that committed local-only (security-relevant).
|
|
7909
8173
|
this.noteControlWrite(peerId, 'remove');
|
|
@@ -7913,75 +8177,23 @@ export class CadreNode {
|
|
|
7913
8177
|
void this.bootstrapPeerStore?.forget(peerId).catch((error) => {
|
|
7914
8178
|
log('removeMachine: persisting the removal of dial target %s failed (forgotten in memory): %o', peerId, error);
|
|
7915
8179
|
});
|
|
7916
|
-
log('removeMachine(%s): peer=%s
|
|
7917
|
-
return
|
|
8180
|
+
log('removeMachine(%s): peer=%s deviceToken=%s invitations=%d', peerId, writes.removedPeer, writes.removedDeviceToken, writes.withdrawnInvitations.length);
|
|
8181
|
+
return writes;
|
|
7918
8182
|
}
|
|
7919
8183
|
/**
|
|
7920
|
-
* The
|
|
7921
|
-
*
|
|
8184
|
+
* The read behind {@link listMachines}: the live `CadrePeer` rows projected as machines, with
|
|
8185
|
+
* this node's membership predicate as the `trusted` test.
|
|
7922
8186
|
*/
|
|
7923
8187
|
async readMachines(controlDatabase) {
|
|
7924
|
-
const
|
|
8188
|
+
const rows = await controlDatabase.queryCadrePeers();
|
|
7925
8189
|
const chain = await this.chainFor(rows, this.invitationChainSource(controlDatabase, true));
|
|
7926
8190
|
const selfPeerId = this.peerId?.toString() ?? null;
|
|
7927
|
-
|
|
8191
|
+
return describeMachines({
|
|
7928
8192
|
partyId: this.partyId,
|
|
7929
8193
|
selfPeerId,
|
|
7930
8194
|
rows,
|
|
7931
|
-
owners,
|
|
7932
8195
|
trusted: (row) => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain),
|
|
7933
8196
|
});
|
|
7934
|
-
return { machines, rows, owners };
|
|
7935
|
-
}
|
|
7936
|
-
/** {@link planMachineRemoval} with the rows it was judged from, which {@link removeMachine} re-vouches dependents by. */
|
|
7937
|
-
async planMachineRemovalWith(controlDatabase, peerId) {
|
|
7938
|
-
const { machines, rows, owners } = await this.readMachines(controlDatabase);
|
|
7939
|
-
const [invites, usages] = await Promise.all([controlDatabase.listCadreInviteStatuses(), controlDatabase.queryCadreInviteUsages()]);
|
|
7940
|
-
const plan = planMachineRemoval({
|
|
7941
|
-
peerId,
|
|
7942
|
-
machines,
|
|
7943
|
-
rows,
|
|
7944
|
-
owners,
|
|
7945
|
-
selfPeerId: this.peerId?.toString() ?? null,
|
|
7946
|
-
selfOwnerKey: this.ownerSigningKeyEnrolledIn(new Set(owners.map((owner) => owner.key)))?.publicKeyB64 ?? null,
|
|
7947
|
-
invites,
|
|
7948
|
-
usages,
|
|
7949
|
-
});
|
|
7950
|
-
return { plan, rows };
|
|
7951
|
-
}
|
|
7952
|
-
/**
|
|
7953
|
-
* The re-vouch step of {@link removeMachine}: every chosen dependent row is rebound to this
|
|
7954
|
-
* node's key BEFORE the removal, so no reader ever judges it against an owner that is gone.
|
|
7955
|
-
* Owners through `ControlDatabase.revouchOwnerKey` (a delete-and-re-add under a fresh stamp,
|
|
7956
|
-
* the only rebinding `OwnerKey.NoUpdate` allows); peers through the owner re-touch
|
|
7957
|
-
* (`reauthorizePeer`), which rewrites `VouchOwner`/`VouchSig` and clears `VouchUsage`.
|
|
7958
|
-
*
|
|
7959
|
-
* The peer re-touch keeps the row's `UpdatedAt` where it is. The owner branch of
|
|
7960
|
-
* `CadrePeer.AuthorizedUpdate` does not require a bump, and bumping a self-published row
|
|
7961
|
-
* would leave its `Sig` unverifiable, so unresolvable and unlisted as self-published, until
|
|
7962
|
-
* the machine's next heartbeat (the NOTE on that constraint); the write-while-alone drain
|
|
7963
|
-
* bumps because it re-issues an otherwise unchanged row, whereas here the voucher itself
|
|
7964
|
-
* changes. A row never self-published carries no stamp to keep and gets the clock.
|
|
7965
|
-
*
|
|
7966
|
-
* A failure rethrows and stops the call before the removal; re-vouches already made stay
|
|
7967
|
-
* (the row is only re-signed by a current owner) and the caller retries. An owner re-vouch
|
|
7968
|
-
* committed alone is not re-issued on growth (the add half has no re-issue path,
|
|
7969
|
-
* `tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue`), so it is logged as
|
|
7970
|
-
* {@link addOwner} logs it; the peer re-touches are re-issued by the existing drain.
|
|
7971
|
-
*/
|
|
7972
|
-
async revouchDependents(service, revouch, rows) {
|
|
7973
|
-
for (const key of revouch.owners) {
|
|
7974
|
-
await service.revouchOwner(key);
|
|
7975
|
-
if (this.committedAlone()) {
|
|
7976
|
-
log('removeMachine: re-vouch of owner %s committed while ALONE (0 control connections): the re-added ' +
|
|
7977
|
-
'owner row is local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
|
|
7978
|
-
}
|
|
7979
|
-
}
|
|
7980
|
-
for (const dependent of revouch.peers) {
|
|
7981
|
-
const row = rows.find((candidate) => candidate.peerId === dependent);
|
|
7982
|
-
const updatedAt = row !== undefined && row.updatedAt > 0 ? row.updatedAt : Date.now();
|
|
7983
|
-
await service.reauthorizePeer(dependent, updatedAt);
|
|
7984
|
-
}
|
|
7985
8197
|
}
|
|
7986
8198
|
/**
|
|
7987
8199
|
* Create a seed from the current control network state.
|
|
@@ -8043,9 +8255,9 @@ export class CadreNode {
|
|
|
8043
8255
|
return result;
|
|
8044
8256
|
}
|
|
8045
8257
|
/**
|
|
8046
|
-
* Post-process an {@link applySeed} outcome: retain the seed's
|
|
8258
|
+
* Post-process an {@link applySeed} outcome: retain the seed's preferred peers as
|
|
8047
8259
|
* cold-start bootstrap targets, and surface a seed that was accepted but whose
|
|
8048
|
-
* every
|
|
8260
|
+
* every dial failed — the node is now seeded yet unconnected, and the
|
|
8049
8261
|
* cold-start reconcile branch is what gets it out of that state.
|
|
8050
8262
|
*/
|
|
8051
8263
|
noteAppliedSeed(result, seed) {
|
|
@@ -8053,8 +8265,8 @@ export class CadreNode {
|
|
|
8053
8265
|
return;
|
|
8054
8266
|
}
|
|
8055
8267
|
this.recordSeedBootstrapPeers(seed);
|
|
8056
|
-
if (result.
|
|
8057
|
-
log('applySeed: seeded but no
|
|
8268
|
+
if (result.preferredDialsAttempted > 0 && result.preferredDialsFailed === result.preferredDialsAttempted) {
|
|
8269
|
+
log('applySeed: seeded but no preferred peer reachable (%d/%d dial(s) failed) — cold-start reconcile will retry', result.preferredDialsFailed, result.preferredDialsAttempted);
|
|
8058
8270
|
}
|
|
8059
8271
|
}
|
|
8060
8272
|
/**
|
|
@@ -8143,9 +8355,10 @@ export class CadreNode {
|
|
|
8143
8355
|
* vouch the node into this cadre and keep its addresses as a dial target. The owner
|
|
8144
8356
|
* side of the claim-secret entry in the Seed Delivery Protocol (`docs/architecture.md`).
|
|
8145
8357
|
*
|
|
8146
|
-
* Requires the seed service with an owner key ({@link
|
|
8147
|
-
* {@link addDrone} does
|
|
8148
|
-
*
|
|
8358
|
+
* Requires the seed service with an owner key ({@link useOwnerSigner}), as
|
|
8359
|
+
* {@link addDrone} does, and like it is one owner approval (the seed and the vouch). The
|
|
8360
|
+
* order is the REVERSE of `addDrone`'s: seed first, row second. A refused claim or a
|
|
8361
|
+
* failed delivery therefore leaves no `CadrePeer` row and
|
|
8149
8362
|
* no retained dial target behind on this node — a phone that typed the wrong secret,
|
|
8150
8363
|
* or scanned a node someone else already owns, has nothing to clean up.
|
|
8151
8364
|
*
|
|
@@ -8169,27 +8382,34 @@ export class CadreNode {
|
|
|
8169
8382
|
* timed reconcile pass; a caller that wants to wait for the connection awaits
|
|
8170
8383
|
* {@link reconcileControlCohort} itself, with `addDrone`'s caveat that a pass already
|
|
8171
8384
|
* in flight is joined rather than restarted. The claimed node dials this one back too,
|
|
8172
|
-
*
|
|
8385
|
+
* as the seed's minter, when this node has a listen or relay address; a phone
|
|
8173
8386
|
* with neither is reached by its own reconcile pass alone.
|
|
8174
8387
|
*
|
|
8175
8388
|
* @throws {ClaimRefusedError} when the node was reached and refused the claim.
|
|
8176
8389
|
* @throws {PeerUnreachableError} when the node was never reached.
|
|
8177
8390
|
*/
|
|
8178
8391
|
async claimNode(target) {
|
|
8179
|
-
|
|
8392
|
+
const service = this.seedBootstrapService;
|
|
8393
|
+
if (!service) {
|
|
8180
8394
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8181
8395
|
}
|
|
8182
8396
|
const secret = parseClaimSecret(target.secret);
|
|
8183
|
-
const
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8188
|
-
|
|
8189
|
-
|
|
8190
|
-
|
|
8191
|
-
|
|
8192
|
-
|
|
8397
|
+
const signer = service.ownerSigner;
|
|
8398
|
+
if (!signer) {
|
|
8399
|
+
throw new Error('Owner key required to create seeds');
|
|
8400
|
+
}
|
|
8401
|
+
await signer.approve({ partyId: this.partyId, action: `Claim the node ${target.peerId} for the cadre` }, async (sign) => {
|
|
8402
|
+
const seed = await service.createSeed(sign);
|
|
8403
|
+
const proof = claimProof(secret, target.peerId, seed.signerKey, seedDigest(seed));
|
|
8404
|
+
const ack = await service.deliverSeed({ peerId: target.peerId, multiaddrs: target.multiaddrs }, seed, { claimProof: proof });
|
|
8405
|
+
if (!ack.accepted) {
|
|
8406
|
+
throw new ClaimRefusedError(target.peerId, ack.reason ?? 'no reason given', ack.code);
|
|
8407
|
+
}
|
|
8408
|
+
log('claimNode: %s accepted the claim; vouching it into the cadre', target.peerId);
|
|
8409
|
+
// The wrapper queues the write for re-replication: a phone claiming its first
|
|
8410
|
+
// always-on node has no control connection yet, so the insert commits local-only.
|
|
8411
|
+
await this.vouchPeer(target.peerId, target.multiaddrs, sign);
|
|
8412
|
+
});
|
|
8193
8413
|
this.retainDialTarget(target.peerId, target.multiaddrs, 'claimNode');
|
|
8194
8414
|
void this.reconcileControlCohort().catch((error) => log('claimNode: reconcile kick after claiming %s failed: %o', target.peerId, error));
|
|
8195
8415
|
}
|
|
@@ -8236,13 +8456,13 @@ export class CadreNode {
|
|
|
8236
8456
|
/**
|
|
8237
8457
|
* Mint an owner-signed cadre invitation that a device can redeem at ANY member machine of
|
|
8238
8458
|
* this party, the owner offline included ({@link redeemCadreInvitation}). Requires the
|
|
8239
|
-
* owner key ({@link
|
|
8459
|
+
* owner key ({@link useOwnerSigner}).
|
|
8240
8460
|
*
|
|
8241
8461
|
* The invitation is an ed25519 keypair: the public half is the `CadreInvite` row's key,
|
|
8242
|
-
* the private half rides in the returned bundle as the proof of possession.
|
|
8243
|
-
*
|
|
8244
|
-
*
|
|
8245
|
-
*
|
|
8462
|
+
* the private half rides in the returned bundle as the proof of possession. It admits a
|
|
8463
|
+
* machine and grants nothing else (ownership is {@link createOwnerGrant}'s), so it defaults
|
|
8464
|
+
* to a 24-hour lifetime ({@link CADRE_INVITATION_DEFAULT_TTL_MS}) and one use unless `uses`
|
|
8465
|
+
* says otherwise. The bundle carries the signed row (so a
|
|
8246
8466
|
* member that has not received it by replication seats it from the bundle), this node's
|
|
8247
8467
|
* anchored owner keys — every current owner this node has verified, the out-of-band ones
|
|
8248
8468
|
* and those derived along verifiable chains ({@link syncOwnerAnchor}) — (the device pins
|
|
@@ -8267,30 +8487,13 @@ export class CadreNode {
|
|
|
8267
8487
|
* @throws when no owner key is wired, the anchor is empty, `uses` is not a positive
|
|
8268
8488
|
* integer, or neither this machine nor any other member has an address.
|
|
8269
8489
|
*/
|
|
8270
|
-
async createCadreInvitation(options) {
|
|
8271
|
-
const service = this.
|
|
8272
|
-
|
|
8273
|
-
throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
|
|
8274
|
-
}
|
|
8275
|
-
const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
|
|
8276
|
-
if (ownerKeys.length === 0) {
|
|
8277
|
-
throw new Error('createCadreInvitation: this node anchors no owner key, and an invitation without owner keys cannot be verified by the device that redeems it');
|
|
8278
|
-
}
|
|
8279
|
-
const uses = options.uses ?? 1;
|
|
8280
|
-
if (!Number.isInteger(uses) || uses < 1) {
|
|
8281
|
-
throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
|
|
8282
|
-
}
|
|
8283
|
-
const { members, reach } = await this.selectInvitationMembers();
|
|
8284
|
-
if (members.length === 0) {
|
|
8285
|
-
throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
|
|
8286
|
-
}
|
|
8287
|
-
const expiresInMs = options.expiresInMs
|
|
8288
|
-
?? (options.grantsOwner && !options.peerId ? OWNER_INVITATION_DEFAULT_TTL_MS : CADRE_INVITATION_DEFAULT_TTL_MS);
|
|
8490
|
+
async createCadreInvitation(options = {}) {
|
|
8491
|
+
const { service, ownerKeys, uses, members, reach } = await this.issuedBundleParts('createCadreInvitation', options.uses);
|
|
8492
|
+
const expiresInMs = options.expiresInMs ?? CADRE_INVITATION_DEFAULT_TTL_MS;
|
|
8289
8493
|
const invitePrivateKey = generatePrivateKey('ed25519', 'base64url');
|
|
8290
8494
|
const row = await service.insertCadreInvite({
|
|
8291
8495
|
key: ed25519PublicKeyFromPrivate(invitePrivateKey),
|
|
8292
8496
|
peerId: options.peerId ?? null,
|
|
8293
|
-
grantsOwner: options.grantsOwner,
|
|
8294
8497
|
expiresAtMs: Date.now() + expiresInMs,
|
|
8295
8498
|
totalUses: uses,
|
|
8296
8499
|
});
|
|
@@ -8302,9 +8505,35 @@ export class CadreNode {
|
|
|
8302
8505
|
ownerKeys,
|
|
8303
8506
|
members,
|
|
8304
8507
|
};
|
|
8305
|
-
log('Cadre invitation minted: %s (peer=%s
|
|
8508
|
+
log('Cadre invitation minted: %s (peer=%s uses=%d, %d member address(es), reach=%s)', row.key, row.peerId ?? 'any', uses, members.length, reach);
|
|
8306
8509
|
return { invitation, encoded: encodeCadreInvitation(invitation), reach };
|
|
8307
8510
|
}
|
|
8511
|
+
/**
|
|
8512
|
+
* What the two credentials an owner mints share ({@link createCadreInvitation},
|
|
8513
|
+
* {@link createOwnerGrant}), checked in this order: the owner-capable service; the anchor's
|
|
8514
|
+
* keys the holder pins (an empty anchor is refused rather than falling back to the table, since
|
|
8515
|
+
* a reply could not be checked against it); the use count; and the member addresses with their
|
|
8516
|
+
* reach, refused when neither this machine nor any other member has one.
|
|
8517
|
+
*/
|
|
8518
|
+
async issuedBundleParts(caller, uses) {
|
|
8519
|
+
const service = this.seedBootstrapService;
|
|
8520
|
+
if (!service?.canAuthorize()) {
|
|
8521
|
+
throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
|
|
8522
|
+
}
|
|
8523
|
+
const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
|
|
8524
|
+
if (ownerKeys.length === 0) {
|
|
8525
|
+
throw new Error(`${caller}: this node anchors no owner key, and a bundle without owner keys cannot be verified by the device that redeems it`);
|
|
8526
|
+
}
|
|
8527
|
+
const checkedUses = uses ?? 1;
|
|
8528
|
+
if (!Number.isInteger(checkedUses) || checkedUses < 1) {
|
|
8529
|
+
throw new Error(`${caller}: uses must be a positive integer (received ${String(uses)})`);
|
|
8530
|
+
}
|
|
8531
|
+
const { members, reach } = await this.selectInvitationMembers();
|
|
8532
|
+
if (members.length === 0) {
|
|
8533
|
+
throw new Error(`${caller}: neither this machine nor any other machine of the party has an address a device could dial`);
|
|
8534
|
+
}
|
|
8535
|
+
return { service, ownerKeys, uses: checkedUses, members, reach };
|
|
8536
|
+
}
|
|
8308
8537
|
/**
|
|
8309
8538
|
* Every cadre invitation this node holds — minted here or replicated in — with its
|
|
8310
8539
|
* standing ({@link CadreInviteStatus}: live, withdrawn, redemptions recorded).
|
|
@@ -8358,11 +8587,9 @@ export class CadreNode {
|
|
|
8358
8587
|
* pass whose reply was lost answers the next as already a member. `options.onProgress`
|
|
8359
8588
|
* reports each pass and pause.
|
|
8360
8589
|
*
|
|
8361
|
-
*
|
|
8362
|
-
*
|
|
8363
|
-
*
|
|
8364
|
-
* machines derive this node's key into their anchors from the row's stored proof on their
|
|
8365
|
-
* next membership refresh ({@link syncOwnerAnchor}).
|
|
8590
|
+
* The invitation admits this node as a machine and nothing else: ownership is an owner
|
|
8591
|
+
* grant ({@link redeemOwnerGrant}, {@link joinWithOwnerGrant}). A key of this node's that is
|
|
8592
|
+
* already an owner here is wired afterwards with {@link wireOwnKeyIfOwner}(signer).
|
|
8366
8593
|
*
|
|
8367
8594
|
* Limit: the reply check ({@link verifyRedeemReply}) does not authenticate the member. A
|
|
8368
8595
|
* forged bundle, or a legitimate one whose addresses were swapped, dials a machine that
|
|
@@ -8400,7 +8627,7 @@ export class CadreNode {
|
|
|
8400
8627
|
...(options.signal ? { signal: options.signal } : {}),
|
|
8401
8628
|
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
8402
8629
|
});
|
|
8403
|
-
log('Cadre invitation %s redeemed at %s
|
|
8630
|
+
log('Cadre invitation %s redeemed at %s', invitation.invite.key, memberAddr);
|
|
8404
8631
|
const selfPeerId = controlNode.peerId.toString();
|
|
8405
8632
|
await mergeSeedPeers(controlNode, reply.peers.filter((peer) => peer.peerId !== selfPeerId));
|
|
8406
8633
|
if (memberPeerId !== null) {
|
|
@@ -8414,41 +8641,338 @@ export class CadreNode {
|
|
|
8414
8641
|
// replicate in are judged against them; refresh so the snapshot is not stale when they land.
|
|
8415
8642
|
await this.refreshMembershipGate('invitation-redeemed');
|
|
8416
8643
|
void this.reconcileControlCohort().catch((error) => log('redeemCadreInvitation: the post-redemption reconcile pass failed: %o', error));
|
|
8417
|
-
return { peerId: memberPeerId,
|
|
8644
|
+
return { peerId: memberPeerId, redeemedAt: new Date().toISOString() };
|
|
8418
8645
|
}
|
|
8419
8646
|
/**
|
|
8420
|
-
* If
|
|
8421
|
-
*
|
|
8422
|
-
*
|
|
8423
|
-
*
|
|
8424
|
-
* `
|
|
8647
|
+
* If `signer`'s key is a live `OwnerKey` here, wire it ({@link useOwnerSigner}) and return
|
|
8648
|
+
* true; otherwise change nothing and return false. For a machine that joined someone else's
|
|
8649
|
+
* cadre: after an owner grant seated its key, and on every later start.
|
|
8650
|
+
*
|
|
8651
|
+
* Without `signer` the candidate is the signer already wired, so the call answers whether
|
|
8652
|
+
* that key is still a live owner; with nothing wired it is false. The node's identity key is
|
|
8653
|
+
* never a candidate: a machine's network identity is never an owner key.
|
|
8425
8654
|
*
|
|
8426
8655
|
* Unlike `ensureOwnerKey` it never inserts an `OwnerKey` row, so a machine that joined a
|
|
8427
8656
|
* cadre can never genesis-insert itself into it, and a non-owner never anchors its own key.
|
|
8428
8657
|
* An owner whose key is already wired (an earlier call) is answered true with nothing redone.
|
|
8429
8658
|
*
|
|
8430
8659
|
* NOTE: a node running the listener-only seed service ({@link enableSeedListener}) cannot be
|
|
8431
|
-
* wired this way: libp2p takes one seed handler, and {@link
|
|
8432
|
-
*
|
|
8433
|
-
*
|
|
8660
|
+
* wired this way: libp2p takes one seed handler, and {@link useOwnerSigner} would register a
|
|
8661
|
+
* second. Nothing that joins by invitation enables the listener today; if one does, replace
|
|
8662
|
+
* the listener here rather than beside it.
|
|
8434
8663
|
*
|
|
8435
|
-
* @throws when the node is not started
|
|
8664
|
+
* @throws when the node is not started, or when `signer` names a different key from the
|
|
8665
|
+
* signer already wired (one signer per node).
|
|
8436
8666
|
*/
|
|
8437
|
-
async wireOwnKeyIfOwner() {
|
|
8667
|
+
async wireOwnKeyIfOwner(signer) {
|
|
8438
8668
|
if (!this.controlDatabase || !this._running) {
|
|
8439
8669
|
throw new Error('CadreNode must be started before wiring its owner key');
|
|
8440
8670
|
}
|
|
8441
|
-
const
|
|
8442
|
-
|
|
8443
|
-
|
|
8671
|
+
const wired = this.getOwnerSigner();
|
|
8672
|
+
const candidate = signer ?? wired;
|
|
8673
|
+
if (!candidate || !(await this.controlDatabase.getOwnerKeys()).has(candidate.publicKey)) {
|
|
8674
|
+
log('wireOwnKeyIfOwner: the candidate owner key is not an owner here; not wiring it');
|
|
8444
8675
|
return false;
|
|
8445
8676
|
}
|
|
8446
|
-
if (
|
|
8447
|
-
await this.
|
|
8677
|
+
if (wired === null) {
|
|
8678
|
+
await this.useOwnerSigner(candidate);
|
|
8679
|
+
}
|
|
8680
|
+
else if (wired.publicKey !== candidate.publicKey) {
|
|
8681
|
+
throw new Error(`wireOwnKeyIfOwner: this node already has the owner key ${wired.publicKey} wired; one owner signer per node`);
|
|
8448
8682
|
}
|
|
8449
8683
|
return true;
|
|
8450
8684
|
}
|
|
8451
8685
|
// ============================================================================
|
|
8686
|
+
// Owner Grant API (redeemable through any member, `/sereus/owner-grant/1.0.0`)
|
|
8687
|
+
// ============================================================================
|
|
8688
|
+
/**
|
|
8689
|
+
* Mint an owner-signed owner grant: whoever holds the returned bundle can seat an owner key of
|
|
8690
|
+
* their own through ANY member machine of this party ({@link redeemOwnerGrant}), or join the
|
|
8691
|
+
* party as a machine and an owner at once ({@link joinWithOwnerGrant}), the owner that minted
|
|
8692
|
+
* it offline or not. Requires the owner key ({@link useOwnerSigner}); one owner approval.
|
|
8693
|
+
*
|
|
8694
|
+
* The grant is an ed25519 keypair: the public half is the `OwnerGrant` row's key, the private
|
|
8695
|
+
* half rides in the bundle as the proof of possession. It admits no machine and names no peer
|
|
8696
|
+
* id. Whoever holds the bundle can become an owner, so it defaults to a 15-minute lifetime
|
|
8697
|
+
* ({@link OWNER_GRANT_DEFAULT_TTL_MS}) and one use; `expiresInMs: null` mints a BACKUP grant
|
|
8698
|
+
* with no expiry, which must be asked for explicitly and kept as carefully as an owner key.
|
|
8699
|
+
* The label is the issuer's choice and goes on every key the grant seats ({@link addOwner}'s
|
|
8700
|
+
* rule: trimmed, at most 64 characters, blank meaning none). The bundle carries the signed
|
|
8701
|
+
* row, this node's anchored owner keys and at most six member addresses, chosen as
|
|
8702
|
+
* {@link createCadreInvitation} chooses them and for the same reasons; `reach` says where a
|
|
8703
|
+
* holder could redeem it once this machine is gone. A backup's addresses are those at mint
|
|
8704
|
+
* time, so it can go stale if every member's address changes.
|
|
8705
|
+
*
|
|
8706
|
+
* @throws when no owner key is wired, the anchor is empty, `uses` is not a positive integer,
|
|
8707
|
+
* the label is over 64 characters, or neither this machine nor any other member has an address.
|
|
8708
|
+
*/
|
|
8709
|
+
async createOwnerGrant(options = {}) {
|
|
8710
|
+
const { service, ownerKeys, uses, members, reach } = await this.issuedBundleParts('createOwnerGrant', options.uses);
|
|
8711
|
+
const expiresInMs = options.expiresInMs === undefined ? OWNER_GRANT_DEFAULT_TTL_MS : options.expiresInMs;
|
|
8712
|
+
const grantPrivateKey = generatePrivateKey('ed25519', 'base64url');
|
|
8713
|
+
const row = await service.insertOwnerGrant({
|
|
8714
|
+
key: ed25519PublicKeyFromPrivate(grantPrivateKey),
|
|
8715
|
+
label: options.label,
|
|
8716
|
+
expiresAtMs: expiresInMs === null ? null : Date.now() + expiresInMs,
|
|
8717
|
+
totalUses: uses,
|
|
8718
|
+
});
|
|
8719
|
+
const grant = {
|
|
8720
|
+
v: 1,
|
|
8721
|
+
partyId: this.config.controlNetwork.partyId,
|
|
8722
|
+
grantPrivateKey,
|
|
8723
|
+
grant: row,
|
|
8724
|
+
ownerKeys,
|
|
8725
|
+
members,
|
|
8726
|
+
};
|
|
8727
|
+
log('Owner grant minted: %s (label=%s expires=%s uses=%d, %d member address(es), reach=%s)', row.key, row.label ?? 'none', row.expiresAt ?? 'never', uses, members.length, reach);
|
|
8728
|
+
return { grant, encoded: encodeOwnerGrant(grant), reach };
|
|
8729
|
+
}
|
|
8730
|
+
/**
|
|
8731
|
+
* Every owner grant this node holds — minted here or replicated in — with its standing
|
|
8732
|
+
* ({@link OwnerGrantStatus}: live, withdrawn, redemptions recorded).
|
|
8733
|
+
*/
|
|
8734
|
+
async listOwnerGrants() {
|
|
8735
|
+
if (!this.controlDatabase) {
|
|
8736
|
+
throw new Error('CadreNode must be started before listing owner grants');
|
|
8737
|
+
}
|
|
8738
|
+
return await this.controlDatabase.listOwnerGrantStatuses();
|
|
8739
|
+
}
|
|
8740
|
+
/**
|
|
8741
|
+
* Withdraw an owner grant: an owner-signed `Revocation` tombstone over its row, which stays as
|
|
8742
|
+
* the proof behind every key it seated; those keys stay owners (removing one is
|
|
8743
|
+
* {@link removeOwner}). No further redemption succeeds anywhere the tombstone reaches, and a
|
|
8744
|
+
* member holding it refuses the bundle's copy of the row. A withdrawal committed while alone is
|
|
8745
|
+
* re-issued on cohort growth like any other tombstone.
|
|
8746
|
+
*
|
|
8747
|
+
* @returns `true` when this call filed the tombstone, `false` when the row is not held here or
|
|
8748
|
+
* was already withdrawn.
|
|
8749
|
+
*/
|
|
8750
|
+
async withdrawOwnerGrant(key) {
|
|
8751
|
+
if (!this.seedBootstrapService) {
|
|
8752
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8753
|
+
}
|
|
8754
|
+
return await this.seedBootstrapService.withdrawOwnerGrant(key);
|
|
8755
|
+
}
|
|
8756
|
+
/**
|
|
8757
|
+
* Become an owner of this node's party with an owner grant: seat `signer`'s key as an
|
|
8758
|
+
* `OwnerKey` row by consent, with no owner online. `signer` holds the key to seat; its one
|
|
8759
|
+
* `approve` names the party and says the key becomes an owner of it, and signs the `'consent'`
|
|
8760
|
+
* digest. The grant's `'redeem'` signature is made with the bundle's private key in memory.
|
|
8761
|
+
* A bundle for another party is refused before anything is pinned, dialed or written.
|
|
8762
|
+
*
|
|
8763
|
+
* Two paths, chosen by whether this node is a live member of the party ({@link isLiveMemberSelf})
|
|
8764
|
+
* or an enrolled owner of it ({@link isOwner}):
|
|
8765
|
+
*
|
|
8766
|
+
* - **Local**, on a member machine: the grant row is seated from the bundle when absent (its
|
|
8767
|
+
* own transaction) and the redemption written in this node's own control database
|
|
8768
|
+
* (`ControlDatabase.redeemOwnerGrant`), replicating like any other write. Refused `alone`
|
|
8769
|
+
* under {@link addOwner}'s rule, before the approval: an `OwnerKey` row has no re-issue
|
|
8770
|
+
* path, so one committed with no control connection while the party has other members would
|
|
8771
|
+
* stay here; connect, then try again. A grant that is not live here (withdrawn, expired,
|
|
8772
|
+
* spent, its issuer no longer an owner) is refused before the approval too, unless the key
|
|
8773
|
+
* is already an owner. The bundle's `ownerKeys` are NOT pinned: this node judges the issuer
|
|
8774
|
+
* by its own `OwnerKey` table, and its anchor must not be widened by pasted text.
|
|
8775
|
+
* - **Protocol**, otherwise (a device with no machine in the cadre; a node started in the
|
|
8776
|
+
* party on an empty store): the bundle's `ownerKeys` are pinned into the node-local anchor
|
|
8777
|
+
* (`'invite'` provenance, the pin a credential the owner handed out carries, as
|
|
8778
|
+
* {@link redeemCadreInvitation} pins), then the members the bundle names are dialed over
|
|
8779
|
+
* `/sereus/owner-grant/1.0.0` (`redeemOwnerGrantInPasses`), one signed request for every
|
|
8780
|
+
* address and every pass as {@link redeemCadreInvitation} dials, so a retry after a lost
|
|
8781
|
+
* reply is answered `alreadyOwner`. The member that accepts pushes its control store here
|
|
8782
|
+
* and writes the row with this node counted in its write cohort, so a member alone with
|
|
8783
|
+
* this node still commits. `options` are the invitation's (`waitForMember`, `signal`,
|
|
8784
|
+
* `onProgress`).
|
|
8785
|
+
*
|
|
8786
|
+
* Neither path wires the signer: call {@link wireOwnKeyIfOwner}(signer) afterwards — at once
|
|
8787
|
+
* on the local path, and on the protocol path once the row has synced here. A key that is
|
|
8788
|
+
* already a live owner is answered `alreadyOwner` with no use spent, on both paths. To join
|
|
8789
|
+
* the party as a machine too, use {@link joinWithOwnerGrant}.
|
|
8790
|
+
*
|
|
8791
|
+
* @throws a plain `Error` for a node not started or a bundle naming another party;
|
|
8792
|
+
* {@link OwnerKeyAddRefusedError} `alone` on the local path, `OwnerGrantIssuerUnknownError`
|
|
8793
|
+
* when the issuer is not an owner here, `OwnerGrantRejectedError` `grant-spent` for a
|
|
8794
|
+
* grant no longer live here, and `OwnerGrantRejectedError` `conflict` (retryable) when the
|
|
8795
|
+
* cadre holds the grant row and this node has not synced it yet; on the protocol path
|
|
8796
|
+
* `OwnerGrantRejectedError` on a final refusal, `OwnerGrantReplyInvalidError` on an
|
|
8797
|
+
* acceptance that fails the reply check,
|
|
8798
|
+
* `OwnerGrantUnreachableError` when every address was tried (one pass only), and the
|
|
8799
|
+
* signal's reason on an abort.
|
|
8800
|
+
*/
|
|
8801
|
+
async redeemOwnerGrant(bundle, signer, options = {}) {
|
|
8802
|
+
const { controlDatabase } = this.requireStartedForGrant(bundle, 'redeeming an owner grant');
|
|
8803
|
+
// An enrolled owner machine is a party machine whether or not its own row is published yet
|
|
8804
|
+
// (the first self-record lands a second after start), so it writes locally too.
|
|
8805
|
+
if (await this.isLiveMemberSelf() || await this.isOwner()) {
|
|
8806
|
+
return await this.redeemOwnerGrantLocally(controlDatabase, bundle, signer);
|
|
8807
|
+
}
|
|
8808
|
+
await this.trustOwnerKeys(bundle.ownerKeys, 'invite');
|
|
8809
|
+
const request = await signer.approve(ownerGrantApproval(bundle), (sign) => this.signOwnerGrantRequest(bundle, signer, sign));
|
|
8810
|
+
const { reply, memberPeerId } = await this.redeemOwnerGrantAtMembers(bundle, request, options);
|
|
8811
|
+
return { ownerKey: signer.publicKey, alreadyOwner: reply.alreadyOwner, memberPeerId };
|
|
8812
|
+
}
|
|
8813
|
+
/**
|
|
8814
|
+
* {@link redeemOwnerGrant}'s local path: the alone refusal, the seat, the liveness refusal,
|
|
8815
|
+
* then one approval around the consent signature and the write. Every refusal comes before
|
|
8816
|
+
* the approval, so a prompting signer never asks for a redemption this node would refuse.
|
|
8817
|
+
*/
|
|
8818
|
+
async redeemOwnerGrantLocally(controlDatabase, bundle, signer) {
|
|
8819
|
+
if (await this.ownerKeyWriteAlone()) {
|
|
8820
|
+
throw new OwnerKeyAddRefusedError(signer.publicKey, 'alone');
|
|
8821
|
+
}
|
|
8822
|
+
await refusingUnsyncedGrant(() => controlDatabase.seatOwnerGrant(bundle.grant));
|
|
8823
|
+
// The write answers a key that is already an owner without reading the grant, so only a
|
|
8824
|
+
// key that is not one needs the grant live (the same permissive pre-check the handler's
|
|
8825
|
+
// admission uses; the write enforces every condition).
|
|
8826
|
+
if (!(await controlDatabase.getOwnerKeys()).has(signer.publicKey) && !await controlDatabase.isOwnerGrantLive(bundle.grant)) {
|
|
8827
|
+
throw new OwnerGrantRejectedError('grant-spent', 'The grant is expired, withdrawn, exhausted, or its issuer is no longer an owner');
|
|
8828
|
+
}
|
|
8829
|
+
const { alreadyOwner } = await signer.approve(ownerGrantApproval(bundle), async (sign) => {
|
|
8830
|
+
const request = await this.signOwnerGrantRequest(bundle, signer, sign);
|
|
8831
|
+
return await refusingUnsyncedGrant(() => controlDatabase.redeemOwnerGrant({
|
|
8832
|
+
grantKey: bundle.grant.key,
|
|
8833
|
+
seatedKey: signer.publicKey,
|
|
8834
|
+
usageStampId: request.usageStampId,
|
|
8835
|
+
grantSig: request.grantSig,
|
|
8836
|
+
ownerSig: request.ownerSig,
|
|
8837
|
+
}));
|
|
8838
|
+
});
|
|
8839
|
+
// NOTE: the refusal is checked before the approval, and a connection can drop while the user
|
|
8840
|
+
// approves; a redemption that still commits alone is logged, as an add's is.
|
|
8841
|
+
if (!alreadyOwner && this.committedAlone()) {
|
|
8842
|
+
log('redeemOwnerGrant(%s) committed while ALONE (the last control connection dropped during the approval): ' +
|
|
8843
|
+
'the new owner row is local-only and is not re-issued on cohort growth.', signer.publicKey);
|
|
8844
|
+
}
|
|
8845
|
+
log('Owner grant %s redeemed locally for %s (alreadyOwner=%s)', bundle.grant.key, signer.publicKey, alreadyOwner);
|
|
8846
|
+
return { ownerKey: signer.publicKey, alreadyOwner, memberPeerId: null };
|
|
8847
|
+
}
|
|
8848
|
+
/**
|
|
8849
|
+
* {@link redeemOwnerGrant}'s protocol path, given the signed request: dial the bundle's members
|
|
8850
|
+
* in passes, then refresh the membership gate, since the rows the member pushed and wrote are
|
|
8851
|
+
* judged against the pins and the anchor derives this node's key from them once its row is here.
|
|
8852
|
+
*/
|
|
8853
|
+
async redeemOwnerGrantAtMembers(bundle, request, options) {
|
|
8854
|
+
const pinned = new Set(bundle.ownerKeys);
|
|
8855
|
+
const redemption = await redeemOwnerGrantInPasses(this.controlNode, {
|
|
8856
|
+
bundle,
|
|
8857
|
+
request,
|
|
8858
|
+
isTrustedIssuer: (ownerKey) => pinned.has(ownerKey),
|
|
8859
|
+
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
8860
|
+
waitForMember: options.waitForMember ?? false,
|
|
8861
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
8862
|
+
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
8863
|
+
});
|
|
8864
|
+
log('Owner grant %s redeemed at %s for %s (alreadyOwner=%s)', bundle.grant.key, redemption.memberAddr, request.ownerKey, redemption.reply.alreadyOwner);
|
|
8865
|
+
await this.refreshMembershipGate('owner-grant-redeemed');
|
|
8866
|
+
return redemption;
|
|
8867
|
+
}
|
|
8868
|
+
/** The signed request for `bundle` seating `signer`'s key, under an open approval's `sign`; the nonce is minted here, by the holder. */
|
|
8869
|
+
signOwnerGrantRequest(bundle, signer, sign) {
|
|
8870
|
+
return signOwnerGrantRequest(bundle, signer.publicKey, sign, generateStampId(this.controlNode.peerId.toString()));
|
|
8871
|
+
}
|
|
8872
|
+
/**
|
|
8873
|
+
* Join this node's party as a machine AND an owner with one owner grant, for a node started in
|
|
8874
|
+
* the grant's party that is not a member: a fresh install recovering with a backup grant, an
|
|
8875
|
+
* app joining as an owner. Needs a started node with a stable identity, as
|
|
8876
|
+
* {@link redeemCadreInvitation} does, and a bundle for this party.
|
|
8877
|
+
*
|
|
8878
|
+
* Steps, the two signatures made under ONE approval of `signer` before any dial, so a
|
|
8879
|
+
* prompting signer asks once and its prompt does not span the network:
|
|
8880
|
+
*
|
|
8881
|
+
* 1. {@link redeemOwnerGrant}'s protocol path: the member that accepts seats `signer`'s key.
|
|
8882
|
+
* 2. A **self-invitation**: a `CadreInvite` row targeted at this node's peer id, one use, a
|
|
8883
|
+
* short expiry ({@link SELF_INVITATION_TTL_MS}), signed by `signer`
|
|
8884
|
+
* (`ControlDatabase.signCadreInvite`; never inserted here, since this store holds no
|
|
8885
|
+
* `OwnerKey` row for the issuer yet). Its bundle carries the grant's owner keys plus the
|
|
8886
|
+
* new key (the device's reply check wants the row's issuer among the pins) and the grant's
|
|
8887
|
+
* members with the member that accepted the grant first, since that member holds the new
|
|
8888
|
+
* key as a live owner and seats the row from the bundle; one the row has not reached yet
|
|
8889
|
+
* answers `issuer-unknown`, which is retryable.
|
|
8890
|
+
* 3. {@link redeemCadreInvitation} with it: the member admits this node.
|
|
8891
|
+
* 4. {@link wireOwnKeyIfOwner}(signer) once the `OwnerKey` row has synced here, asked every
|
|
8892
|
+
* {@link OWNER_ROW_SYNC_RECHECK_MS} up to {@link OWNER_ROW_SYNC_TIMEOUT_MS}; the result's
|
|
8893
|
+
* `ownerWired` says whether it did in time, and a later call wires it otherwise.
|
|
8894
|
+
*
|
|
8895
|
+
* @throws as {@link redeemOwnerGrant} (protocol path) and {@link redeemCadreInvitation} throw,
|
|
8896
|
+
* and a plain `Error` for an ephemeral identity.
|
|
8897
|
+
*/
|
|
8898
|
+
async joinWithOwnerGrant(bundle, signer, options = {}) {
|
|
8899
|
+
const { controlNode, controlDatabase } = this.requireStartedForGrant(bundle, 'joining with an owner grant');
|
|
8900
|
+
if (!this.getSelfSigningKey()) {
|
|
8901
|
+
throw new Error('joinWithOwnerGrant: this node runs on an ephemeral identity; configure `keyStore` or `privateKey` so its key can sign the admission');
|
|
8902
|
+
}
|
|
8903
|
+
await this.trustOwnerKeys(bundle.ownerKeys, 'invite');
|
|
8904
|
+
const invitePrivateKey = generatePrivateKey('ed25519', 'base64url');
|
|
8905
|
+
const { request, selfInvite } = await signer.approve(ownerGrantApproval(bundle, 'join'), async (sign) => ({
|
|
8906
|
+
request: await this.signOwnerGrantRequest(bundle, signer, sign),
|
|
8907
|
+
selfInvite: await controlDatabase.signCadreInvite({
|
|
8908
|
+
key: ed25519PublicKeyFromPrivate(invitePrivateKey),
|
|
8909
|
+
peerId: controlNode.peerId.toString(),
|
|
8910
|
+
expiresAtMs: Date.now() + SELF_INVITATION_TTL_MS,
|
|
8911
|
+
totalUses: 1,
|
|
8912
|
+
}, signer.publicKey, sign),
|
|
8913
|
+
}));
|
|
8914
|
+
const granted = await this.redeemOwnerGrantAtMembers(bundle, request, options);
|
|
8915
|
+
const selfInvitation = {
|
|
8916
|
+
v: 1,
|
|
8917
|
+
partyId: bundle.partyId,
|
|
8918
|
+
invitePrivateKey,
|
|
8919
|
+
invite: selfInvite,
|
|
8920
|
+
ownerKeys: [...bundle.ownerKeys, signer.publicKey],
|
|
8921
|
+
members: addressesOfFirst(bundle.members, granted.memberPeerId),
|
|
8922
|
+
};
|
|
8923
|
+
const joined = await this.redeemCadreInvitation(selfInvitation, options);
|
|
8924
|
+
const ownerWired = await this.wireOwnKeyOnceSynced(signer);
|
|
8925
|
+
log('Joined with owner grant %s: member via %s, owner key %s (wired=%s)', bundle.grant.key, joined.peerId ?? 'unnamed member', signer.publicKey, ownerWired);
|
|
8926
|
+
return { peerId: joined.peerId, redeemedAt: joined.redeemedAt, ownerKey: signer.publicKey, alreadyOwner: granted.reply.alreadyOwner, ownerWired };
|
|
8927
|
+
}
|
|
8928
|
+
/**
|
|
8929
|
+
* {@link wireOwnKeyIfOwner}(signer) once `signer`'s `OwnerKey` row has reached this node, asked
|
|
8930
|
+
* again every {@link OWNER_ROW_SYNC_RECHECK_MS} until it has, the node stops, or
|
|
8931
|
+
* {@link OWNER_ROW_SYNC_TIMEOUT_MS} passes: false then, and a later call wires it. Usually
|
|
8932
|
+
* immediate: the member pushed its whole store, the row included, before the admission write.
|
|
8933
|
+
*/
|
|
8934
|
+
async wireOwnKeyOnceSynced(signer) {
|
|
8935
|
+
const deadline = Date.now() + OWNER_ROW_SYNC_TIMEOUT_MS;
|
|
8936
|
+
while (!(await this.wireOwnKeyIfOwner(signer))) {
|
|
8937
|
+
if (!this._running || Date.now() >= deadline) {
|
|
8938
|
+
log('wireOwnKeyOnceSynced: the OwnerKey row for %s has not reached this node yet; not wired', signer.publicKey);
|
|
8939
|
+
return false;
|
|
8940
|
+
}
|
|
8941
|
+
await new Promise((resolve) => setTimeout(resolve, OWNER_ROW_SYNC_RECHECK_MS));
|
|
8942
|
+
}
|
|
8943
|
+
return true;
|
|
8944
|
+
}
|
|
8945
|
+
/**
|
|
8946
|
+
* The started node's handles for a grant call, the bundle's party checked first: a grant for
|
|
8947
|
+
* another party is refused before any pin, dial or write.
|
|
8948
|
+
*/
|
|
8949
|
+
requireStartedForGrant(bundle, action) {
|
|
8950
|
+
if (!this.controlNode || !this.controlDatabase || !this._running) {
|
|
8951
|
+
throw new Error(`CadreNode must be started before ${action}`);
|
|
8952
|
+
}
|
|
8953
|
+
if (bundle.partyId !== this.config.controlNetwork.partyId) {
|
|
8954
|
+
throw new Error(`Owner grant is for party ${bundle.partyId}; this node serves ${this.config.controlNetwork.partyId}`);
|
|
8955
|
+
}
|
|
8956
|
+
return { controlNode: this.controlNode, controlDatabase: this.controlDatabase };
|
|
8957
|
+
}
|
|
8958
|
+
/**
|
|
8959
|
+
* Is this node a live member of its party: its own `CadrePeer` row is held here and passes the
|
|
8960
|
+
* membership predicate ({@link hasAnchoredProof}, judged as {@link listAuthorizedMembers}
|
|
8961
|
+
* judges every other row; that listing leaves this node out)? What decides
|
|
8962
|
+
* {@link redeemOwnerGrant}'s path. A machine's own row is written by its enrollment (or its
|
|
8963
|
+
* first self-record, for an owner's machine), so a node on an empty store, or one holding only
|
|
8964
|
+
* a store a member pushed it, is not one.
|
|
8965
|
+
*/
|
|
8966
|
+
async isLiveMemberSelf() {
|
|
8967
|
+
const controlDatabase = this.controlDatabase;
|
|
8968
|
+
const self = this.controlNode?.peerId.toString();
|
|
8969
|
+
if (!controlDatabase || self === undefined) {
|
|
8970
|
+
return false;
|
|
8971
|
+
}
|
|
8972
|
+
const own = (await controlDatabase.queryCadrePeers()).find((row) => row.peerId === self);
|
|
8973
|
+
return own !== undefined && this.hasAnchoredProof(own, await this.chainFor([own], this.invitationChainSource(controlDatabase, true)));
|
|
8974
|
+
}
|
|
8975
|
+
// ============================================================================
|
|
8452
8976
|
// Strand Solicitation API (native cadre-core formation transport)
|
|
8453
8977
|
// ============================================================================
|
|
8454
8978
|
/**
|
|
@@ -8661,7 +9185,7 @@ export class CadreNode {
|
|
|
8661
9185
|
*/
|
|
8662
9186
|
async requestJoin(invitation, disclosure = {}) {
|
|
8663
9187
|
const { database, runner } = this.requirePendingJoins();
|
|
8664
|
-
const signer = await this.
|
|
9188
|
+
const signer = await this.requireEnrolledOwnerSigner('requestJoin needs an enrolled owner machine of this party, because the party-wide join request is ' +
|
|
8665
9189
|
'owner-signed. formStrand(invitation, disclosure) joins once from this machine, with no retries.');
|
|
8666
9190
|
const now = Date.now();
|
|
8667
9191
|
const expiresAt = invitation.expiration.getTime();
|
|
@@ -8672,7 +9196,9 @@ export class CadreNode {
|
|
|
8672
9196
|
throw new Error(`The invitation expired at ${invitation.expiration.toISOString()}; ask the inviter for a fresh one`);
|
|
8673
9197
|
}
|
|
8674
9198
|
const requested = requestedPendingJoin(pendingJoinId(invitation.token), this.encodeInvitation(invitation), disclosure, expiresAt, now);
|
|
8675
|
-
|
|
9199
|
+
// The attempt below runs outside the approval: a formation can take a network timeout, and
|
|
9200
|
+
// the outcome it records is the retry loop's own write.
|
|
9201
|
+
const row = await signer.approve({ partyId: this.partyId, action: `Ask to join a strand of the app ${invitation.sAppId}` }, (sign) => this.recordJoinRequest(database, requested, signer.publicKey, sign));
|
|
8676
9202
|
log('requestJoin: pending join %s recorded; attempting now', row.Id);
|
|
8677
9203
|
return runner.attemptNow(row);
|
|
8678
9204
|
}
|
|
@@ -8692,8 +9218,8 @@ export class CadreNode {
|
|
|
8692
9218
|
*/
|
|
8693
9219
|
async dismissPendingJoin(id) {
|
|
8694
9220
|
const { database, runner } = this.requirePendingJoins();
|
|
8695
|
-
const
|
|
8696
|
-
const removed = await database.deletePendingJoin(id,
|
|
9221
|
+
const signer = await this.requireEnrolledOwnerSigner('dismissPendingJoin needs an enrolled owner machine of this party: the removal is owner-signed');
|
|
9222
|
+
const removed = await signer.approve({ partyId: this.partyId, action: `Dismiss the pending join ${id}` }, (sign) => database.deletePendingJoin(id, signer.publicKey, sign));
|
|
8697
9223
|
runner.forgetRow(id);
|
|
8698
9224
|
return removed;
|
|
8699
9225
|
}
|
|
@@ -8703,19 +9229,19 @@ export class CadreNode {
|
|
|
8703
9229
|
}
|
|
8704
9230
|
return { database: this.controlDatabase, runner: this.pendingJoinRunner };
|
|
8705
9231
|
}
|
|
8706
|
-
/** This machine's owner
|
|
8707
|
-
async
|
|
8708
|
-
const
|
|
8709
|
-
if (!
|
|
9232
|
+
/** This machine's enrolled owner signer ({@link enrolledOwnerSigner}), or a throw carrying `refusal`. */
|
|
9233
|
+
async requireEnrolledOwnerSigner(refusal) {
|
|
9234
|
+
const signer = await this.enrolledOwnerSigner();
|
|
9235
|
+
if (!signer) {
|
|
8710
9236
|
throw new Error(refusal);
|
|
8711
9237
|
}
|
|
8712
|
-
return
|
|
9238
|
+
return signer;
|
|
8713
9239
|
}
|
|
8714
9240
|
/**
|
|
8715
9241
|
* Write a join request, or adopt the party's existing request for the same invitation: a
|
|
8716
9242
|
* pending one as it is, a finished one rewritten as `requested` (the user asked again).
|
|
8717
9243
|
*/
|
|
8718
|
-
async recordJoinRequest(database, requested,
|
|
9244
|
+
async recordJoinRequest(database, requested, ownerKey, signMessage) {
|
|
8719
9245
|
let written;
|
|
8720
9246
|
try {
|
|
8721
9247
|
written = await database.insertJoinRequest(requested, ownerKey, signMessage);
|
|
@@ -8745,29 +9271,24 @@ export class CadreNode {
|
|
|
8745
9271
|
}
|
|
8746
9272
|
return this.controlDatabase;
|
|
8747
9273
|
};
|
|
8748
|
-
|
|
9274
|
+
// Each write is its own approval: the retry loop runs unattended.
|
|
9275
|
+
const ownerWrite = async (action, write) => {
|
|
9276
|
+
const signer = await this.requireEnrolledOwnerSigner('Cannot record a pending join outcome: this machine is not an enrolled owner of the party');
|
|
9277
|
+
return await signer.approve({ partyId: this.partyId, action }, (sign) => write(signer.publicKey, sign));
|
|
9278
|
+
};
|
|
8749
9279
|
this.pendingJoinRunner = new PendingJoinRunner({
|
|
8750
9280
|
selfId: this.controlNode.peerId.toString(),
|
|
8751
9281
|
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
8752
|
-
isOwner:
|
|
9282
|
+
isOwner: () => this.isOwner(),
|
|
8753
9283
|
// NOTE: one owner-key read and one pending-join read (three tables) per pass (30 s); for a
|
|
8754
9284
|
// party that never asked for a join the second reads never-written blocks, which consults
|
|
8755
9285
|
// the cohort. If it shows up in a device profile, poll more slowly while the tables read empty.
|
|
8756
9286
|
readRows: () => database().queryPendingJoins(),
|
|
8757
9287
|
readRow: (id) => database().queryPendingJoin(id),
|
|
8758
9288
|
attempt: (join) => this.formStrand(this.pendingJoinInvitation(join), parseStoredDisclosure(join)),
|
|
8759
|
-
record:
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
},
|
|
8763
|
-
rewrite: async (expectedStampId, next) => {
|
|
8764
|
-
const { ownerKey, signMessage } = await outcomeSigner();
|
|
8765
|
-
return database().rewritePendingJoin(expectedStampId, next, ownerKey, signMessage);
|
|
8766
|
-
},
|
|
8767
|
-
remove: async (id) => {
|
|
8768
|
-
const { ownerKey, signMessage } = await outcomeSigner();
|
|
8769
|
-
return database().deletePendingJoin(id, ownerKey, signMessage);
|
|
8770
|
-
},
|
|
9289
|
+
record: (expected, outcome) => ownerWrite(`Record the outcome of the pending join ${expected.Id}`, (ownerKey, sign) => database().recordJoinOutcome(expected, outcome, ownerKey, sign)),
|
|
9290
|
+
rewrite: (expectedStampId, next) => ownerWrite(`Re-send the pending join ${next.Id} to the cadre`, (ownerKey, sign) => database().rewritePendingJoin(expectedStampId, next, ownerKey, sign)),
|
|
9291
|
+
remove: (id) => ownerWrite(`Clean up the finished pending join ${id}`, (ownerKey, sign) => database().deletePendingJoin(id, ownerKey, sign)),
|
|
8771
9292
|
isAlone: () => this.committedAlone(),
|
|
8772
9293
|
sAppIdOf: (row) => this.pendingJoinSAppId(row),
|
|
8773
9294
|
observeRows: (rows) => this.stageMembershipInvitesFromPendingJoins(rows),
|