@serfab/cadre-core 1.16.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 +11 -2
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +214 -214
- package/src/cadre-invite-protocol.ts +652 -1052
- package/src/cadre-join-request.ts +24 -3
- 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 +886 -789
- 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/src/cadre-node.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
StrandConfig,
|
|
15
15
|
FoundStrandConfig,
|
|
16
16
|
FoundStrandResult,
|
|
17
|
+
FoundStrandOptions,
|
|
18
|
+
FoundStrandBackgroundResult,
|
|
17
19
|
SAppConfig,
|
|
18
20
|
CadreNodeEvents,
|
|
19
21
|
ControlNetworkSeed,
|
|
@@ -34,28 +36,38 @@ import type {
|
|
|
34
36
|
DeviceTokenRecord,
|
|
35
37
|
CadrePeerVoucherFields,
|
|
36
38
|
CadrePeerRow,
|
|
37
|
-
OwnerKeyRow,
|
|
38
39
|
RevocationRow,
|
|
39
40
|
InvitationChain,
|
|
41
|
+
OwnerGrantChain,
|
|
40
42
|
CadreInviteStatus,
|
|
41
43
|
CadreMachine,
|
|
44
|
+
CadreOwnerKey,
|
|
42
45
|
MachineRemovalPlan,
|
|
43
46
|
MachineRemovalResult,
|
|
44
|
-
|
|
47
|
+
OwnerKeyDependents,
|
|
48
|
+
OwnerKeyRemovalPlan,
|
|
49
|
+
OwnerKeyRemovalResult,
|
|
50
|
+
RemoveOwnerOptions,
|
|
45
51
|
CadreRemoval,
|
|
46
52
|
CadreInvitationReach,
|
|
47
53
|
CreateCadreInvitationOptions,
|
|
48
54
|
CreateCadreInvitationResult,
|
|
49
55
|
RedeemCadreInvitationOptions,
|
|
50
56
|
RedeemCadreInvitationResult,
|
|
57
|
+
CreateOwnerGrantOptions,
|
|
58
|
+
CreateOwnerGrantResult,
|
|
59
|
+
OwnerGrantStatus,
|
|
60
|
+
RedeemOwnerGrantOptions,
|
|
61
|
+
RedeemOwnerGrantResult,
|
|
62
|
+
JoinWithOwnerGrantResult,
|
|
51
63
|
PeerAddressRecord,
|
|
52
64
|
ResolveDeviceTokenOpts,
|
|
53
65
|
PendingJoin,
|
|
54
66
|
PendingJoinStatus
|
|
55
67
|
} from './types.js';
|
|
56
68
|
import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
|
|
57
|
-
import { generatePrivateKey
|
|
58
|
-
import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64
|
|
69
|
+
import { generatePrivateKey } from '@optimystic/quereus-plugin-crypto';
|
|
70
|
+
import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
59
71
|
import { strandTransportKey } from './strand-transport-key.js';
|
|
60
72
|
import {
|
|
61
73
|
assertScopeKeyCharset,
|
|
@@ -70,6 +82,7 @@ import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
|
|
|
70
82
|
import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
|
|
71
83
|
import { loadOrCreateIdentityKey } from './identity-key.js';
|
|
72
84
|
import { MemoryTrustedOwnerStore, type TrustedOwnerStore, type TrustSource } from './trusted-owner-store.js';
|
|
85
|
+
import { softwareOwnerSigner, withinOwnerApproval, type OwnerSign, type OwnerSigner, type OwnerSigningRequest } from './owner-signer.js';
|
|
73
86
|
import { MemoryBootstrapPeerStore, type BootstrapPeerStore } from './bootstrap-peer-store.js';
|
|
74
87
|
import { MemoryStrandNetworkStateStore, type StrandNetworkStateStore } from './strand-network-state.js';
|
|
75
88
|
import { MemoryEnrolledMachineStore, type EnrolledMachineStore } from './enrolled-machine-store.js';
|
|
@@ -85,7 +98,8 @@ import { mergePeerAddrs, groupAddrsByPeerId, type MergeAddrsResult } from './pee
|
|
|
85
98
|
import { strandFretPeerAddrs } from './strand-fret-addrs.js';
|
|
86
99
|
import { verifyCadrePeerVoucher, verifyInvitationAdmission, verifyRevocationSigner } from './peer-authorization.js';
|
|
87
100
|
import { deriveOwnerAnchor } from './owner-anchor-sync.js';
|
|
88
|
-
import { describeMachines, planMachineRemoval,
|
|
101
|
+
import { describeMachines, planMachineRemoval, MachineRemovalRefusedError } from './machine-removal.js';
|
|
102
|
+
import { describeOwnerKeys, namedOwnerKey, planOwnerKeyRemoval, revouchWithinPlan, OwnerKeyAddRefusedError, OwnerKeyRemovalRefusedError } from './owner-keys.js';
|
|
89
103
|
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
90
104
|
import {
|
|
91
105
|
signPeerRecord,
|
|
@@ -124,7 +138,7 @@ import {
|
|
|
124
138
|
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, relayedRequestBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
125
139
|
import { EnrollmentService } from './enrollment.js';
|
|
126
140
|
import { HibernationManager, type HibernationCallbacks } from './hibernation-manager.js';
|
|
127
|
-
import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type JoinRequestFields, type RevokedRowRef } from './control-database.js';
|
|
141
|
+
import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, IssuedRowNotHereYetError, pendingJoinId, type JoinRequestFields, type RevokedRowRef } from './control-database.js';
|
|
128
142
|
import {
|
|
129
143
|
CadreInviteHandler,
|
|
130
144
|
encodeCadreInvitation,
|
|
@@ -132,6 +146,16 @@ import {
|
|
|
132
146
|
signRedeemRequest,
|
|
133
147
|
type CadreInvitation
|
|
134
148
|
} from './cadre-invite-protocol.js';
|
|
149
|
+
import {
|
|
150
|
+
OwnerGrantHandler,
|
|
151
|
+
OwnerGrantRejectedError,
|
|
152
|
+
encodeOwnerGrant,
|
|
153
|
+
redeemOwnerGrantInPasses,
|
|
154
|
+
signOwnerGrantRequest,
|
|
155
|
+
type OwnerGrantBundle,
|
|
156
|
+
type OwnerGrantRedeemRequest,
|
|
157
|
+
type OwnerGrantRedemption
|
|
158
|
+
} from './owner-grant-protocol.js';
|
|
135
159
|
import { FormationPostApprovalError } from './strand-formation-rejection.js';
|
|
136
160
|
import {
|
|
137
161
|
PendingJoinRunner,
|
|
@@ -168,6 +192,8 @@ import {
|
|
|
168
192
|
dueRelayAnnounces,
|
|
169
193
|
prunePeerStrandKeys,
|
|
170
194
|
peerStrandKey,
|
|
195
|
+
recordRelayRefusals,
|
|
196
|
+
relaysNotRefusing,
|
|
171
197
|
type CircuitRelayTarget
|
|
172
198
|
} from './delegate-admission.js';
|
|
173
199
|
import { relayCircuitAddrs, resolveListenAddrs, resolveTransportOptions, RelayReservationFailedError } from './relay-addrs.js';
|
|
@@ -217,14 +243,32 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
|
|
|
217
243
|
export const STRAND_PEER_ADDR_RETRY_MS = 60 * 1000;
|
|
218
244
|
|
|
219
245
|
/**
|
|
220
|
-
* Default lifetime of an
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
246
|
+
* Default lifetime of an owner grant ({@link CadreNode.createOwnerGrant}): whoever holds the
|
|
247
|
+
* bundle can become an owner, so it is a bearer credential for admin rights and lives only
|
|
248
|
+
* long enough to be pasted or scanned by the device it was minted for. A backup grant is minted
|
|
249
|
+
* with no expiry, by explicit choice.
|
|
250
|
+
*/
|
|
251
|
+
export const OWNER_GRANT_DEFAULT_TTL_MS = 15 * 60 * 1000;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Lifetime of the invitation {@link CadreNode.joinWithOwnerGrant} issues to itself: redeemed in
|
|
255
|
+
* the same call at the member that just wrote the grant, so the grant's short default is plenty,
|
|
256
|
+
* and a later call signs a fresh one.
|
|
257
|
+
*/
|
|
258
|
+
const SELF_INVITATION_TTL_MS = OWNER_GRANT_DEFAULT_TTL_MS;
|
|
259
|
+
|
|
260
|
+
/** How often {@link CadreNode.joinWithOwnerGrant} asks again whether its own `OwnerKey` row has reached this node. */
|
|
261
|
+
const OWNER_ROW_SYNC_RECHECK_MS = 2_000;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Bound on that wait, the edge node's owner-step bound (`DEFAULT_OWNER_GENESIS_TIMEOUT_MS` in
|
|
265
|
+
* `edge-node/config.ts`): the join has already succeeded at the member, so past it the call
|
|
266
|
+
* returns with the key unwired rather than wait out a stalled sync.
|
|
224
267
|
*/
|
|
225
|
-
|
|
268
|
+
// 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
|
|
269
|
+
const OWNER_ROW_SYNC_TIMEOUT_MS = 60_000;
|
|
226
270
|
|
|
227
|
-
/** Default lifetime of
|
|
271
|
+
/** Default lifetime of a cadre invitation ({@link CadreNode.createCadreInvitation}): it admits a machine and grants nothing else, so it need not be short. */
|
|
228
272
|
export const CADRE_INVITATION_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
229
273
|
|
|
230
274
|
type EventHandler<T> = (data: T) => void;
|
|
@@ -237,6 +281,43 @@ function relayStrandAddrPeer(relay: CircuitRelayTarget): StrandAddrPeer {
|
|
|
237
281
|
return { peerId: relay.relayPeerId, addrs: [multiaddr(relay.relayAddr)] };
|
|
238
282
|
}
|
|
239
283
|
|
|
284
|
+
/**
|
|
285
|
+
* What the holder of an owner grant approves on the signer whose key it seats: one request for
|
|
286
|
+
* the whole action, naming the party (as every owner signing request does) and the grant.
|
|
287
|
+
*/
|
|
288
|
+
function ownerGrantApproval(bundle: OwnerGrantBundle, scope: 'redeem' | 'join' = 'redeem'): OwnerSigningRequest {
|
|
289
|
+
const grant = namedOwnerKey(bundle.grant.key, bundle.grant.label);
|
|
290
|
+
return {
|
|
291
|
+
partyId: bundle.partyId,
|
|
292
|
+
action: scope === 'join'
|
|
293
|
+
? `Join the cadre with this device and make this key one of its owners, with the owner grant ${grant}`
|
|
294
|
+
: `Make this key an owner of the cadre, with the owner grant ${grant}`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Run one of the local grant path's writes, refusing `conflict` (retryable) when the grant row
|
|
300
|
+
* the cadre holds is not readable here yet, as a member answers the same failure over the protocol.
|
|
301
|
+
*/
|
|
302
|
+
async function refusingUnsyncedGrant<T>(write: () => Promise<T>): Promise<T> {
|
|
303
|
+
try {
|
|
304
|
+
return await write();
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error instanceof IssuedRowNotHereYetError) {
|
|
307
|
+
log('owner grant write refused until this node syncs: %s', error.message);
|
|
308
|
+
throw new OwnerGrantRejectedError('conflict', 'The cadre holds the grant, and this node has not synced it yet; try again shortly');
|
|
309
|
+
}
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** `members` with the addresses naming `peerId` (by their trailing `/p2p/`) first, order otherwise kept; unchanged for a null `peerId`. */
|
|
315
|
+
function addressesOfFirst(members: readonly string[], peerId: string | null): string[] {
|
|
316
|
+
if (peerId === null) return [...members];
|
|
317
|
+
const names = (addr: string): boolean => addr.endsWith(`/p2p/${peerId}`);
|
|
318
|
+
return [...members.filter(names), ...members.filter((addr) => !names(addr))];
|
|
319
|
+
}
|
|
320
|
+
|
|
240
321
|
/**
|
|
241
322
|
* Whether a sibling actually answered a strand-addr RPC — with addresses or
|
|
242
323
|
* without — as opposed to being unreachable, unavailable, or refusing us. Decides
|
|
@@ -270,11 +351,6 @@ function unionAddrs(primary: readonly string[], extra: readonly string[]): strin
|
|
|
270
351
|
return out;
|
|
271
352
|
}
|
|
272
353
|
|
|
273
|
-
/**
|
|
274
|
-
* Build the signing callback the `ControlDatabase` writers expect: they hand it the
|
|
275
|
-
* canonical row-bound message BYTES (see `buildAuthorizationMessage`), and it ed25519-signs
|
|
276
|
-
* them directly (no pre-hash) with the owner private key, returning a base64url signature.
|
|
277
|
-
*/
|
|
278
354
|
/**
|
|
279
355
|
* One membership refresh's invitation chain ({@link InvitationChain}), loaded at most once
|
|
280
356
|
* and shared by the anchor sync (`OwnerKey` rows) and the membership read (`CadrePeer`
|
|
@@ -289,16 +365,15 @@ type InvitationChainSource = () => Promise<InvitationChain>;
|
|
|
289
365
|
*/
|
|
290
366
|
type RevocationSource = () => Promise<RevocationRow[]>;
|
|
291
367
|
|
|
292
|
-
/**
|
|
293
|
-
|
|
368
|
+
/**
|
|
369
|
+
* Whether a `CadrePeer` or `OwnerKey` row's proof is a consent admission rather than an owner
|
|
370
|
+
* voucher (`types.ts` → `CadrePeerRow`, `OwnerKeyRow`): a machine invitation for a `CadrePeer`
|
|
371
|
+
* row, an owner grant for an `OwnerKey` row.
|
|
372
|
+
*/
|
|
373
|
+
function isConsentSeated(row: { vouchSig: string | null; vouchUsage: string | null }): boolean {
|
|
294
374
|
return row.vouchSig === null && row.vouchUsage !== null;
|
|
295
375
|
}
|
|
296
376
|
|
|
297
|
-
function signMessageWith(privateKeyB64: string): (message: Uint8Array) => string {
|
|
298
|
-
return (message: Uint8Array): string =>
|
|
299
|
-
sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
377
|
/**
|
|
303
378
|
* Reject a blank identifier before it reaches the control database, where it would fail an
|
|
304
379
|
* authorization CHECK as an opaque constraint error rather than an actionable message.
|
|
@@ -316,13 +391,13 @@ function requireNonBlank(value: string, label: string): string {
|
|
|
316
391
|
* Is the live `Strand` row the one a publish of `desired` would have produced?
|
|
317
392
|
*
|
|
318
393
|
* "Identical content" means `(Type, MemberPrivateKey)` and deliberately NOT
|
|
319
|
-
* `FounderOwnerKey`:
|
|
320
|
-
* content. Two machines of one party racing to found
|
|
321
|
-
* "the winner's row stands" — the loser adopts the row
|
|
322
|
-
* it, correctly comes up as a joiner (see
|
|
323
|
-
*
|
|
324
|
-
* `StampId` is a single-use nonce, so the two
|
|
325
|
-
* is. A live row matching on both is, by construction, the state a repeat publish would
|
|
394
|
+
* `FounderOwnerKey` or `FounderPeerId`: those columns are provenance (WHICH owner signed the
|
|
395
|
+
* row and WHICH machine founds it), not content. Two machines of one party racing to found
|
|
396
|
+
* the same id must keep resolving as "the winner's row stands" — the loser adopts the row
|
|
397
|
+
* and, since the winner's peer id is on it, correctly comes up as a joiner (see
|
|
398
|
+
* `launchStrand`'s founder derivation). Comparing them here would turn that benign race
|
|
399
|
+
* into a hard throw. `Id` is the lookup key and `StampId` is a single-use nonce, so the two
|
|
400
|
+
* compared columns are all the content there is. A live row matching on both is, by construction, the state a repeat publish would
|
|
326
401
|
* have reached, whichever branch of `Strand.AuthorizedInsert` seated it (owner-signed, or
|
|
327
402
|
* the unsigned consent branch an invite redemption uses).
|
|
328
403
|
*
|
|
@@ -478,6 +553,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
478
553
|
* once that database is up, on every node.
|
|
479
554
|
*/
|
|
480
555
|
private cadreInviteHandler: CadreInviteHandler | null = null;
|
|
556
|
+
/**
|
|
557
|
+
* Owner grant redemption handler (`/sereus/owner-grant/1.0.0`): a holder of a grant dials
|
|
558
|
+
* this machine and proves possession of the grant and of the key it seats; the handler seats
|
|
559
|
+
* the row and writes the `OwnerKey` row against this node's control database. Registered by
|
|
560
|
+
* {@link start} beside the invitation handler, on every node.
|
|
561
|
+
*/
|
|
562
|
+
private ownerGrantHandler: OwnerGrantHandler | null = null;
|
|
481
563
|
/**
|
|
482
564
|
* Server-side push-wake fan-out. Constructed by {@link start} only when
|
|
483
565
|
* `config.push` (an injected `PushNotifier` + policy) is present — without it
|
|
@@ -695,6 +777,26 @@ export class CadreNode implements SAppIdLookup {
|
|
|
695
777
|
*/
|
|
696
778
|
private readonly delegateAnnounceAt = new Map<string, number>();
|
|
697
779
|
|
|
780
|
+
/**
|
|
781
|
+
* Relay peerIds whose protocol negotiation refused the delegate announcement
|
|
782
|
+
* (strand-addr outcome `unsupported`), recorded and cleared by
|
|
783
|
+
* {@link recordDelegateAnnounces} from every announce pass's outcomes. Only a
|
|
784
|
+
* party-run relay has a membership gate that needs a grant, and it serves
|
|
785
|
+
* strand-addr, so a refusal marks a dedicated relay: {@link circuitRelayTargets}
|
|
786
|
+
* leaves it out of the founding/resume and periodic announcements, which would
|
|
787
|
+
* otherwise spend a protocol negotiation on it every time (gotchoices/sereus#49). Not
|
|
788
|
+
* pruned: one entry per relay this node ever named.
|
|
789
|
+
*
|
|
790
|
+
* {@link announceDelegateToRelay} still asks every relay, remembered or not, and
|
|
791
|
+
* its reply is what clears an entry. A party-run relay registers its strand-addr
|
|
792
|
+
* handler late in its own `start()`, after its libp2p node and relay server are
|
|
793
|
+
* up, so an announcement landing in that window is refused by a relay that serves
|
|
794
|
+
* the protocol a moment later. If the re-drive hook skipped remembered relays too,
|
|
795
|
+
* that relay would stay skipped until this node restarts and its gate would deny
|
|
796
|
+
* every reservation through it.
|
|
797
|
+
*/
|
|
798
|
+
private readonly relaysRefusingStrandAddr = new Set<string>();
|
|
799
|
+
|
|
698
800
|
/**
|
|
699
801
|
* When each connected sibling is next due a strand-addr RPC for each running
|
|
700
802
|
* strand, keyed `peerStrandKey(siblingControlPeerId, strandId)` → epoch ms; a
|
|
@@ -827,7 +929,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
827
929
|
/**
|
|
828
930
|
* Dial targets learned out of band, keyed by peer id, valued by the multiaddr
|
|
829
931
|
* strings this node was handed (parsed lazily, at dial time). Two writers:
|
|
830
|
-
* {@link recordSeedBootstrapPeers} retains the
|
|
932
|
+
* {@link recordSeedBootstrapPeers} retains the preferred peers of every seed
|
|
831
933
|
* this node applies, and {@link addDrone} retains the addresses of every node
|
|
832
934
|
* this node adds. Two readers: {@link dialColdStartBootstrap} dials every entry
|
|
833
935
|
* while the control database still has no siblings, and
|
|
@@ -1368,11 +1470,22 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1368
1470
|
// After the control database is up, since the handler seats and redeems against it.
|
|
1369
1471
|
this.cadreInviteHandler = new CadreInviteHandler({
|
|
1370
1472
|
partyId: this.config.controlNetwork.partyId,
|
|
1473
|
+
peerId: this.controlNode.peerId.toString(),
|
|
1371
1474
|
store: this.controlDatabase,
|
|
1372
1475
|
admitDevice: (peerId) => this.admitRedeemingDevice(peerId),
|
|
1373
1476
|
});
|
|
1374
1477
|
await this.cadreInviteHandler.register(this.controlNode);
|
|
1375
1478
|
|
|
1479
|
+
// Answer owner grant redemptions likewise: a holder dials any member and proves possession
|
|
1480
|
+
// of the grant and of the key it seats; no machine is admitted, so the holder's identity
|
|
1481
|
+
// is not checked.
|
|
1482
|
+
this.ownerGrantHandler = new OwnerGrantHandler({
|
|
1483
|
+
partyId: this.config.controlNetwork.partyId,
|
|
1484
|
+
store: this.controlDatabase,
|
|
1485
|
+
admitDevice: (peerId) => this.admitRedeemingDevice(peerId),
|
|
1486
|
+
});
|
|
1487
|
+
await this.ownerGrantHandler.register(this.controlNode);
|
|
1488
|
+
|
|
1376
1489
|
// A node waiting to be claimed listens for its claim seed from the start: nobody
|
|
1377
1490
|
// else will turn the listener on, and a node no seed can reach cannot be claimed.
|
|
1378
1491
|
// Before `control:connected`, so an embedder that reports "awaiting claim" on that
|
|
@@ -1577,7 +1690,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1577
1690
|
/**
|
|
1578
1691
|
* Construct (or adopt) the node-local trusted-owner anchor and seed the
|
|
1579
1692
|
* out-of-band pinned keys from `config.trustedOwners`. The anchor's BASE comes
|
|
1580
|
-
* only from out of band — genesis self-trust ({@link
|
|
1693
|
+
* only from out of band — genesis self-trust ({@link useOwnerSigner}),
|
|
1581
1694
|
* config pins (here), runtime enrollment pins ({@link trustOwnerKeys}) and a
|
|
1582
1695
|
* claim; everything else in it is derived from that base along verifiable
|
|
1583
1696
|
* chains through the replicated `OwnerKey` table ({@link syncOwnerAnchor}),
|
|
@@ -1887,12 +2000,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1887
2000
|
|
|
1888
2001
|
/**
|
|
1889
2002
|
* Persist out-of-band-established owner keys into the node-local anchor —
|
|
1890
|
-
* the runtime enrollment seam: {@link redeemCadreInvitation}
|
|
1891
|
-
*
|
|
1892
|
-
* holds the pins when the reply and the rows
|
|
2003
|
+
* the runtime enrollment seam: {@link redeemCadreInvitation} and
|
|
2004
|
+
* {@link redeemOwnerGrant} call it with their bundle's `ownerKeys` BEFORE dialing
|
|
2005
|
+
* a member (so the anchor already holds the pins when the reply and the rows
|
|
2006
|
+
* that follow are judged), and an
|
|
1893
2007
|
* embedder calls it with an operator-supplied pin. Idempotent. ('genesis'
|
|
1894
2008
|
* provenance is reserved for the node's own founding key, seeded internally by
|
|
1895
|
-
* {@link
|
|
2009
|
+
* {@link useOwnerSigner}; 'chain' is written only by {@link syncOwnerAnchor}.)
|
|
1896
2010
|
*
|
|
1897
2011
|
* Validates every key's shape before trusting any of them (all-or-nothing):
|
|
1898
2012
|
* a malformed entry anywhere in `keys` rejects the whole call before a
|
|
@@ -2333,10 +2447,18 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2333
2447
|
* own strand node, so a NAT'd member's strand node can hold a
|
|
2334
2448
|
* circuit-relay reservation here (see `delegate-admission.ts`).
|
|
2335
2449
|
* Connection only: the per-stream gate below never honors a grant;
|
|
2336
|
-
* 3. the
|
|
2450
|
+
* 3. the peer is a device whose invitation or owner-grant redemption this
|
|
2451
|
+
* node has verified and is writing ({@link admittingDevices}): its
|
|
2452
|
+
* connection carries the exchange, which must not be cut at the
|
|
2453
|
+
* provisional deadline. A LIVE OWNER GRANT does not count as expecting a
|
|
2454
|
+
* stranger the way check 7's invitation does: a backup grant has no
|
|
2455
|
+
* expiry, and counting it would admit every stranger outright for the
|
|
2456
|
+
* party's life, so the holder rides the provisional admission until its
|
|
2457
|
+
* redemption verifies, then this;
|
|
2458
|
+
* 4. the authorized-member set is empty — cold start: the rows that would
|
|
2337
2459
|
* authorize anyone arrive by replication over these very connections;
|
|
2338
|
-
*
|
|
2339
|
-
*
|
|
2460
|
+
* 5. the peer IS an authorized member; or
|
|
2461
|
+
* 6. an open invitation is OUTSTANDING — at least one unexpired,
|
|
2340
2462
|
* not-fully-consumed invitation this node minted or persisted (see
|
|
2341
2463
|
* `StrandSolicitationService.hasOutstandingInvitation`). A formation
|
|
2342
2464
|
* initiator is another party's peer by design and its token is only
|
|
@@ -2344,19 +2466,19 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2344
2466
|
* "does this node expect a stranger at all?". REGISTERING the responder
|
|
2345
2467
|
* does not count: every node registers one at {@link start}, and
|
|
2346
2468
|
* registering mints no invitation; or
|
|
2347
|
-
*
|
|
2469
|
+
* 7. a LIVE cadre invitation exists — a `CadreInvite` row this node holds
|
|
2348
2470
|
* that is not withdrawn, unexpired, has uses left and whose issuer is
|
|
2349
2471
|
* still an owner (`ControlDatabase.hasLiveCadreInvite`). The device that
|
|
2350
2472
|
* redeems it is a stranger until the redemption writes its row, and its
|
|
2351
2473
|
* proof of possession is only checkable inside `/sereus/cadre-invite/1.0.0`;
|
|
2352
|
-
* same reasoning as check
|
|
2474
|
+
* same reasoning as check 6, and the same expectation-of-a-stranger key.
|
|
2353
2475
|
*
|
|
2354
2476
|
* Ordering is semantically free (the checks are OR'd) but decides who pays:
|
|
2355
|
-
* checks 1-
|
|
2356
|
-
* that is otherwise headed for a provisional admission reaches check
|
|
2357
|
-
*
|
|
2477
|
+
* checks 1-3 are in-memory, 4/5 share one control-DB read, and only a peer
|
|
2478
|
+
* that is otherwise headed for a provisional admission reaches check 6's and
|
|
2479
|
+
* 7's invitation lookups.
|
|
2358
2480
|
*
|
|
2359
|
-
* Caveats of check
|
|
2481
|
+
* Caveats of check 6:
|
|
2360
2482
|
* - the in-memory mint registry dies with the process, so after a restart
|
|
2361
2483
|
* only invitations persisted as `FormationInvite` rows still hold the
|
|
2362
2484
|
* window open (re-mint otherwise);
|
|
@@ -2367,8 +2489,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2367
2489
|
* When every check falls through: `'admit-provisionally'`, on every node,
|
|
2368
2490
|
* relay server or not. The gate sees only a peer id, so it cannot tell an
|
|
2369
2491
|
* outsider from a device whose invitation row this node has not received yet
|
|
2370
|
-
* (it redeems here by seating the row from its bundle, after which check
|
|
2371
|
-
* admits) or from a sibling whose membership row is still replicating (check
|
|
2492
|
+
* (it redeems here by seating the row from its bundle, after which check 7
|
|
2493
|
+
* admits) or from a sibling whose membership row is still replicating (check 5
|
|
2372
2494
|
* admits once it lands; if it has not landed by the deadline, the connection
|
|
2373
2495
|
* is closed and either side's next reconcile dial re-forms it, since outbound
|
|
2374
2496
|
* dials are never gated). The protocol guard refuses all of them every
|
|
@@ -2376,12 +2498,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2376
2498
|
* {@link admitControlRelayReservation} disarms the deadline (see
|
|
2377
2499
|
* `membership-connection-gater.ts` → "The relay-reservation seam").
|
|
2378
2500
|
*
|
|
2379
|
-
* NOTE: check
|
|
2501
|
+
* NOTE: check 4/5 runs a control-DB read per inbound connection
|
|
2380
2502
|
* (`listAuthorizedMembers`); connections are rare and cadres small, and this
|
|
2381
2503
|
* layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
|
|
2382
2504
|
* read is safe here — unlike the per-stream gate, which must consult the
|
|
2383
2505
|
* materialized {@link authorizedControlPeers} snapshot instead.
|
|
2384
|
-
* NOTE: checks
|
|
2506
|
+
* NOTE: checks 6 and 7 add two more control reads (`hasOutstandingFormationInvite`,
|
|
2385
2507
|
* `hasLiveCadreInvite`) for a stranger with no locally minted invitation in play, on
|
|
2386
2508
|
* every node, and a provisional connection's deadline runs the whole decision once
|
|
2387
2509
|
* more. If stranger connections ever arrive fast enough for those reads — on arrival
|
|
@@ -2398,6 +2520,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2398
2520
|
if (this.delegateAdmission.has(remotePeerId)) {
|
|
2399
2521
|
return 'admit';
|
|
2400
2522
|
}
|
|
2523
|
+
if (this.admittingDevices.has(remotePeerId)) {
|
|
2524
|
+
return 'admit';
|
|
2525
|
+
}
|
|
2401
2526
|
const authorized = await this.listAuthorizedMembers();
|
|
2402
2527
|
if (authorized.length === 0) {
|
|
2403
2528
|
return 'admit';
|
|
@@ -2644,18 +2769,18 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2644
2769
|
*
|
|
2645
2770
|
* The trusted-owner anchor sync ({@link syncOwnerAnchor}) runs FIRST, so the snapshot
|
|
2646
2771
|
* built here judges `CadrePeer` rows against the anchor as the `OwnerKey` table now
|
|
2647
|
-
* has it. The
|
|
2648
|
-
*
|
|
2649
|
-
*
|
|
2772
|
+
* has it. The tombstone list the sync and the two end-of-refresh checks
|
|
2773
|
+
* ({@link hangUpRevokedControlPeers}, then {@link noteOwnRemoval}) read is loaded at most
|
|
2774
|
+
* once per refresh, as is the invitation chain the membership read may need.
|
|
2650
2775
|
*/
|
|
2651
2776
|
private async refreshAuthorizedControlPeers(reason: string): Promise<void> {
|
|
2652
2777
|
if (!this._running || !this.controlDatabase) {
|
|
2653
2778
|
return;
|
|
2654
2779
|
}
|
|
2655
2780
|
try {
|
|
2656
|
-
const loadChain = this.invitationChainSource(this.controlDatabase, false);
|
|
2657
2781
|
const loadTombstones = this.revocationSource(this.controlDatabase, false);
|
|
2658
|
-
await this.syncOwnerAnchor(reason, false,
|
|
2782
|
+
await this.syncOwnerAnchor(reason, false, loadTombstones);
|
|
2783
|
+
const loadChain = this.invitationChainSource(this.controlDatabase, false);
|
|
2659
2784
|
const rows = await this.controlDatabase.queryCadrePeers(false);
|
|
2660
2785
|
const members = this.authorizedAmong(rows, await this.chainFor(rows, loadChain));
|
|
2661
2786
|
this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
|
|
@@ -2947,14 +3072,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2947
3072
|
list: () => database().queryJoinedStrands(),
|
|
2948
3073
|
names: async (strandId) => (await database().queryStrand(strandId)) !== null
|
|
2949
3074
|
|| (await database().queryJoinedStrand(strandId)) !== null,
|
|
2950
|
-
canSign: async () => (await this.
|
|
3075
|
+
canSign: async () => (await this.enrolledOwnerSigner()) !== null,
|
|
2951
3076
|
publish: async (record) => {
|
|
2952
|
-
const
|
|
2953
|
-
if (!
|
|
3077
|
+
const signer = await this.enrolledOwnerSigner();
|
|
3078
|
+
if (!signer) {
|
|
2954
3079
|
throw new Error(`Cannot publish joined strand ${record.Id}: this machine is not an enrolled owner`);
|
|
2955
3080
|
}
|
|
2956
3081
|
try {
|
|
2957
|
-
await
|
|
3082
|
+
await signer.approve({ partyId: this.partyId, action: `Record the joined strand ${record.Id} for the whole cadre` },
|
|
3083
|
+
(sign) => database().insertJoinedStrand(record, signer.publicKey, sign));
|
|
2958
3084
|
} catch (error) {
|
|
2959
3085
|
// Another machine of the party published this join first; its row wins.
|
|
2960
3086
|
if (!isStrandIdConflict(error, 'JoinedStrand')) {
|
|
@@ -2963,9 +3089,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2963
3089
|
}
|
|
2964
3090
|
},
|
|
2965
3091
|
remove: async (strandId) => {
|
|
2966
|
-
const
|
|
2967
|
-
if (
|
|
2968
|
-
return
|
|
3092
|
+
const signer = await this.enrolledOwnerSigner();
|
|
3093
|
+
if (signer) {
|
|
3094
|
+
return await signer.approve({ partyId: this.partyId, action: `Leave the joined strand ${strandId} for the whole cadre` },
|
|
3095
|
+
(sign) => database().deleteJoinedStrand(strandId, signer.publicKey, sign));
|
|
2969
3096
|
}
|
|
2970
3097
|
if (await database().queryJoinedStrand(strandId)) {
|
|
2971
3098
|
throw new Error(
|
|
@@ -2980,42 +3107,49 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2980
3107
|
}
|
|
2981
3108
|
|
|
2982
3109
|
/**
|
|
2983
|
-
* This machine's owner
|
|
2984
|
-
*
|
|
2985
|
-
*
|
|
2986
|
-
*
|
|
2987
|
-
*
|
|
3110
|
+
* This machine's owner signer when it can make owner-gated control writes: one is wired
|
|
3111
|
+
* ({@link useOwnerSigner}) and the party's `OwnerKey` table enrolls its key. A phone that
|
|
3112
|
+
* ran self-genesis after another machine founded the party passes the first test and not
|
|
3113
|
+
* the second (`runOwnerGenesis` in the reference apps), and a write it signed would only
|
|
3114
|
+
* be refused.
|
|
2988
3115
|
*/
|
|
2989
|
-
private async
|
|
2990
|
-
const
|
|
2991
|
-
if (!
|
|
3116
|
+
private async enrolledOwnerSigner(): Promise<OwnerSigner | null> {
|
|
3117
|
+
const signer = this.getOwnerSigner();
|
|
3118
|
+
if (!signer || !this.controlDatabase) {
|
|
2992
3119
|
return null;
|
|
2993
3120
|
}
|
|
2994
|
-
return (await this.controlDatabase.getOwnerKeys()).has(
|
|
3121
|
+
return (await this.controlDatabase.getOwnerKeys()).has(signer.publicKey) ? signer : null;
|
|
2995
3122
|
}
|
|
2996
3123
|
|
|
2997
3124
|
/**
|
|
2998
|
-
* Whether this node can act as an owner of its cadre:
|
|
2999
|
-
*
|
|
3000
|
-
*
|
|
3001
|
-
*
|
|
3002
|
-
*
|
|
3125
|
+
* Whether this node can act as an owner of its cadre: an owner signer is wired
|
|
3126
|
+
* ({@link useOwnerSigner}) and its key is in the live `OwnerKey` set. What the owner-only
|
|
3127
|
+
* flows (`createCadreInvitation`, `claimNode`, `rentNode`) need; false before `start()`,
|
|
3128
|
+
* and on a machine that joined as a member. The reference apps' "is this device an owner"
|
|
3129
|
+
* check.
|
|
3003
3130
|
*/
|
|
3004
3131
|
async isOwner(): Promise<boolean> {
|
|
3005
|
-
return (await this.
|
|
3132
|
+
return (await this.enrolledOwnerSigner()) !== null;
|
|
3006
3133
|
}
|
|
3007
3134
|
|
|
3008
|
-
/** {@link
|
|
3009
|
-
private
|
|
3010
|
-
const
|
|
3011
|
-
return
|
|
3135
|
+
/** {@link enrolledOwnerSigner} judged against an owner set the caller has already read. */
|
|
3136
|
+
private ownerSignerEnrolledIn(ownerKeys: ReadonlySet<string>): OwnerSigner | null {
|
|
3137
|
+
const signer = this.getOwnerSigner();
|
|
3138
|
+
return signer !== null && ownerKeys.has(signer.publicKey) ? signer : null;
|
|
3012
3139
|
}
|
|
3013
3140
|
|
|
3014
|
-
/**
|
|
3015
|
-
|
|
3016
|
-
|
|
3141
|
+
/**
|
|
3142
|
+
* The owner signer {@link useOwnerSigner} wired, or null: every owner signature this node
|
|
3143
|
+
* makes goes through it. Enrollment is not checked ({@link isOwner} does). A caller that
|
|
3144
|
+
* makes several owner writes as one action opens one `approve` on it and hands the
|
|
3145
|
+
* resulting `sign` to the methods that take one (`publishStrand`, `ensureStrandPartyKey`,
|
|
3146
|
+
* `publishFormationInvite`), as `adoptStrandBundle` does.
|
|
3147
|
+
*/
|
|
3148
|
+
getOwnerSigner(): OwnerSigner | null {
|
|
3149
|
+
return this.seedBootstrapService?.ownerSigner ?? null;
|
|
3017
3150
|
}
|
|
3018
3151
|
|
|
3152
|
+
|
|
3019
3153
|
/**
|
|
3020
3154
|
* Schedule the node's initial self-record publish + ongoing refresh shortly
|
|
3021
3155
|
* after start (non-blocking). {@link registerSelf} is idempotent and safely
|
|
@@ -3292,54 +3426,16 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3292
3426
|
}
|
|
3293
3427
|
|
|
3294
3428
|
/**
|
|
3295
|
-
* Does the `Strand` row name THIS machine as its founder —
|
|
3296
|
-
*
|
|
3297
|
-
*
|
|
3298
|
-
*
|
|
3299
|
-
*
|
|
3300
|
-
* enough for {@link launchStrand}'s tracked-instance
|
|
3301
|
-
*
|
|
3302
|
-
* NOTE: reads "one owner key per machine" — the reference model, where the owner
|
|
3303
|
-
* key IS the key behind the PeerId. Two machines running the SAME identity key
|
|
3304
|
-
* would both derive `true` and each bootstrap on its own replica, which is the
|
|
3305
|
-
* double-`Header` hazard this derivation exists to avoid. Unreachable today: that
|
|
3306
|
-
* configuration also gives both machines one PeerId, which already breaks control
|
|
3307
|
-
* networking well before any strand launches. Revisit if machines ever share an
|
|
3308
|
-
* owner key while holding distinct transport identities — the derivation would
|
|
3309
|
-
* then need a per-machine discriminator (the `CadrePeer` PeerId) on the row.
|
|
3429
|
+
* Does the `Strand` row name THIS machine as its founder — is its `FounderPeerId` this
|
|
3430
|
+
* node's peer id? A null column (a consent-seated strand, or a hand-built row) derives
|
|
3431
|
+
* `false`: without a positive match this machine must attach, never bootstrap. A peer id
|
|
3432
|
+
* rather than an owner key, so two machines holding one owner key never both bootstrap
|
|
3433
|
+
* (the double-`Header` hazard), and a node derives it before any owner signer is wired.
|
|
3434
|
+
* Pure comparison, no I/O — cheap enough for {@link launchStrand}'s tracked-instance
|
|
3435
|
+
* early return.
|
|
3310
3436
|
*/
|
|
3311
3437
|
private isSelfFoundedRow(strand: StrandRow): boolean {
|
|
3312
|
-
|
|
3313
|
-
return false;
|
|
3314
|
-
}
|
|
3315
|
-
return strand.FounderOwnerKey === this.getSelfSigningKey()?.publicKeyB64;
|
|
3316
|
-
}
|
|
3317
|
-
|
|
3318
|
-
/**
|
|
3319
|
-
* The owner keypair (base64url Ed25519) derived from this node's resolved
|
|
3320
|
-
* identity key. In the single-key reference model the owner signing key is
|
|
3321
|
-
* *derived from* the node identity (see {@link ed25519KeyPairFromLibp2p}), so the
|
|
3322
|
-
* same key material protected in a secure enclave backs both.
|
|
3323
|
-
*
|
|
3324
|
-
* Exposed so the hosting app retains control of owner genesis: cadre-core
|
|
3325
|
-
* resolves + protects the identity, then the app sources this pair to drive
|
|
3326
|
-
* `ensureOwnerKey(pub)` + `initializeSeedBootstrap(priv)` itself — cadre-core
|
|
3327
|
-
* never silently runs genesis. A future separate-owner slot would return a
|
|
3328
|
-
* distinct key here instead of the identity-derived one.
|
|
3329
|
-
*
|
|
3330
|
-
* @returns The base64url seed/public-key owner pair.
|
|
3331
|
-
* @throws If called before {@link start} has resolved the identity, or when the
|
|
3332
|
-
* node runs on an ephemeral libp2p key (no `keyStore`/`privateKey` configured),
|
|
3333
|
-
* since that key is internal to libp2p and not exposed.
|
|
3334
|
-
*/
|
|
3335
|
-
getIdentityOwnerKey(): Ed25519KeyPair {
|
|
3336
|
-
if (!this.identityKey) {
|
|
3337
|
-
throw new Error(
|
|
3338
|
-
'getIdentityOwnerKey: node identity not resolved — call start() first, and ' +
|
|
3339
|
-
'configure `keyStore` or `privateKey` (an ephemeral libp2p identity exposes no owner key)'
|
|
3340
|
-
);
|
|
3341
|
-
}
|
|
3342
|
-
return ed25519KeyPairFromLibp2p(this.identityKey);
|
|
3438
|
+
return strand.FounderPeerId !== null && strand.FounderPeerId === this.peerId?.toString();
|
|
3343
3439
|
}
|
|
3344
3440
|
|
|
3345
3441
|
/**
|
|
@@ -3860,23 +3956,14 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3860
3956
|
return { dialed: [] };
|
|
3861
3957
|
}
|
|
3862
3958
|
|
|
3863
|
-
// 2. Classify backbone (
|
|
3864
|
-
// NOTE: deliberately the REPLICATED table, not the node-local anchor — this
|
|
3865
|
-
// only *prefers* owner peers as dial targets, so pollution costs a wasted
|
|
3866
|
-
// dial while anchoring would drop legitimate co-owners this node never
|
|
3867
|
-
// pinned. Same call as `SeedPeer.isOwner` in seed-bootstrap.ts. Move it to
|
|
3868
|
-
// the anchor if owner status here ever gates something trusted.
|
|
3869
|
-
const ownerKeys = await this.controlDatabase.getOwnerKeys();
|
|
3870
|
-
if (!this._running || !this.controlNode) {
|
|
3871
|
-
return { dialed: [] };
|
|
3872
|
-
}
|
|
3959
|
+
// 2. Classify backbone (always-on) members and select a bounded dial set.
|
|
3873
3960
|
const targetDegree = this.config.network?.controlCohort?.targetDegree
|
|
3874
3961
|
?? DEFAULT_CONTROL_COHORT_TARGET_DEGREE;
|
|
3875
|
-
const { dials,
|
|
3876
|
-
if (
|
|
3962
|
+
const { dials, cappedNonBackbone } = selectControlCohortDials(siblings, targetDegree);
|
|
3963
|
+
if (cappedNonBackbone > 0) {
|
|
3877
3964
|
// Don't silently bound coverage — surface what the out-degree cap dropped.
|
|
3878
|
-
log('reconcileControlCohort: capped %d non-
|
|
3879
|
-
|
|
3965
|
+
log('reconcileControlCohort: capped %d non-backbone sibling(s) at targetDegree=%d',
|
|
3966
|
+
cappedNonBackbone, targetDegree);
|
|
3880
3967
|
}
|
|
3881
3968
|
|
|
3882
3969
|
// 3. Warm the libp2p address book with EVERY sibling's verified addresses,
|
|
@@ -4234,8 +4321,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4234
4321
|
}
|
|
4235
4322
|
|
|
4236
4323
|
/**
|
|
4237
|
-
* Retain a just-applied seed's
|
|
4238
|
-
* dial targets (see {@link bootstrapPeerStore}).
|
|
4324
|
+
* Retain a just-applied seed's preferred peers (`SeedPeer.preferred`) as cold-start
|
|
4325
|
+
* bootstrap dial targets (see {@link bootstrapPeerStore}).
|
|
4239
4326
|
*
|
|
4240
4327
|
* Called for every seed this node accepts, on BOTH intake paths — the
|
|
4241
4328
|
* {@link applySeed} wrapper (which may run on a throwaway service, so the
|
|
@@ -4243,7 +4330,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4243
4330
|
* `/sereus/seed/1.0.0` handler via `onSeedApplied`. Peers with no address are
|
|
4244
4331
|
* skipped: there is nothing to dial.
|
|
4245
4332
|
*
|
|
4246
|
-
* `
|
|
4333
|
+
* `preferred` is the seed's own claim about its peers, exactly as
|
|
4247
4334
|
* `SeedBootstrapService.applySeed` uses it to choose its dial targets. That is
|
|
4248
4335
|
* sound here for the same reason it is sound there — the whole seed is
|
|
4249
4336
|
* signature-checked against a trust-anchored signer before this runs, and the
|
|
@@ -4253,7 +4340,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4253
4340
|
*/
|
|
4254
4341
|
private recordSeedBootstrapPeers(seed: ControlNetworkSeed): void {
|
|
4255
4342
|
for (const peer of seed.peers) {
|
|
4256
|
-
if (peer.
|
|
4343
|
+
if (peer.preferred) {
|
|
4257
4344
|
this.retainDialTarget(peer.peerId, peer.multiaddrs, 'recordSeedBootstrapPeers');
|
|
4258
4345
|
}
|
|
4259
4346
|
}
|
|
@@ -4297,12 +4384,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4297
4384
|
|
|
4298
4385
|
/**
|
|
4299
4386
|
* Cold-start branch of the reconcile pass: re-dial every retained out-of-band
|
|
4300
|
-
* dial target (see {@link bootstrapPeerStore}) — in practice the
|
|
4301
|
-
* the seeds this node applied — while the control database still has no
|
|
4387
|
+
* dial target (see {@link bootstrapPeerStore}) — in practice the preferred peers
|
|
4388
|
+
* of the seeds this node applied — while the control database still has no
|
|
4302
4389
|
* siblings to dial.
|
|
4303
4390
|
*
|
|
4304
|
-
* `SeedBootstrapService.applySeed` dials those
|
|
4305
|
-
* When that single dial fails —
|
|
4391
|
+
* `SeedBootstrapService.applySeed` dials those peers exactly ONCE, best-effort.
|
|
4392
|
+
* When that single dial fails — peer momentarily down, relay reservation not
|
|
4306
4393
|
* up yet, NAT traversal lost the race — the joining node has an empty
|
|
4307
4394
|
* `CadrePeer` table and no connection, and the steady-state pass above cannot
|
|
4308
4395
|
* help: it dials only siblings enumerated from that very table. Retrying here
|
|
@@ -4312,9 +4399,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4312
4399
|
* gated by "the control database has no siblings", so it stops the moment the
|
|
4313
4400
|
* node is actually in the party, and a node that is stranded MUST keep trying —
|
|
4314
4401
|
* a give-up rule would turn a transient outage into a permanent one. The steady
|
|
4315
|
-
* cost is one dial per bootstrap peer per pass (
|
|
4316
|
-
*
|
|
4317
|
-
*
|
|
4402
|
+
* cost is one dial per bootstrap peer per pass (a seed prefers its minter and the
|
|
4403
|
+
* party's always-on machines, a few at most), each failing fast against an
|
|
4404
|
+
* unreachable address.
|
|
4405
|
+
* NOTE: if seeds ever carry many preferred peers, or the reconcile cadence tightens
|
|
4318
4406
|
* well below its 15 s default, add per-peer backoff here.
|
|
4319
4407
|
*
|
|
4320
4408
|
* Best-effort per peer, exactly like {@link dialControlSibling}: one dead
|
|
@@ -4616,9 +4704,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4616
4704
|
*/
|
|
4617
4705
|
private async drainPendingRevocations(): Promise<void> {
|
|
4618
4706
|
const sweep = !this.reissuedHeldRevocations;
|
|
4619
|
-
// A removed machine
|
|
4620
|
-
//
|
|
4621
|
-
// and drainPendingPeerWrites stop on the same test.
|
|
4707
|
+
// A removed machine is no longer a member, whatever owner key it holds: the others hang up
|
|
4708
|
+
// on it and refuse its control streams, so a re-issue it signs reaches no one.
|
|
4709
|
+
// reconstructAuthoredMembership and drainPendingPeerWrites stop on the same test.
|
|
4622
4710
|
if (this.removal !== null || (!sweep && this.pendingRevocations.size === 0)) {
|
|
4623
4711
|
return;
|
|
4624
4712
|
}
|
|
@@ -4645,8 +4733,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4645
4733
|
this.pendingRevocations.delete(row.stampId);
|
|
4646
4734
|
}
|
|
4647
4735
|
} catch (error) {
|
|
4648
|
-
// NOTE:
|
|
4649
|
-
// so not a removal
|
|
4736
|
+
// NOTE: a machine whose owner key was removed (`removeOwner`; the machine stays a member,
|
|
4737
|
+
// so this is not a removal it reports) keeps running these drains, and every attempt
|
|
4650
4738
|
// fails the schema's live-owner clause and lands here on each growth edge. Noise, not
|
|
4651
4739
|
// harm; if it shows up, gate the drains on this node's key being a live owner.
|
|
4652
4740
|
log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
|
|
@@ -5244,11 +5332,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5244
5332
|
this.strandAddrService = null;
|
|
5245
5333
|
}
|
|
5246
5334
|
|
|
5247
|
-
// Unregister the
|
|
5335
|
+
// Unregister the two redemption handlers, for the same reason.
|
|
5248
5336
|
if (this.cadreInviteHandler && this.controlNode) {
|
|
5249
5337
|
await this.cadreInviteHandler.unregister(this.controlNode);
|
|
5250
5338
|
}
|
|
5251
5339
|
this.cadreInviteHandler = null;
|
|
5340
|
+
if (this.ownerGrantHandler && this.controlNode) {
|
|
5341
|
+
await this.ownerGrantHandler.unregister(this.controlNode);
|
|
5342
|
+
}
|
|
5343
|
+
this.ownerGrantHandler = null;
|
|
5252
5344
|
|
|
5253
5345
|
// Tear down the push-wake fan-out (releases the notifier's APNs HTTP/2 session).
|
|
5254
5346
|
if (this.pushFanoutService) {
|
|
@@ -5283,9 +5375,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5283
5375
|
// Drop delegate-admission state: the grants are scoped to the session that
|
|
5284
5376
|
// recorded them, so a stop()/start() cycle on this object must not keep
|
|
5285
5377
|
// admitting delegates announced under the previous one, nor carry stale
|
|
5286
|
-
// announce timestamps that would suppress the next
|
|
5378
|
+
// announce timestamps or relay refusals that would suppress the next
|
|
5379
|
+
// session's announcements.
|
|
5287
5380
|
this.delegateAdmission.clear();
|
|
5288
5381
|
this.delegateAnnounceAt.clear();
|
|
5382
|
+
this.relaysRefusingStrandAddr.clear();
|
|
5289
5383
|
|
|
5290
5384
|
// Control backfill first — before the database closes and the control node
|
|
5291
5385
|
// stops — so no NEW catch-up push is issued against a torn-down transport.
|
|
@@ -5646,7 +5740,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5646
5740
|
* `Strand` row. A joiner (the row arrived over the control network) wants exactly this; a
|
|
5647
5741
|
* FOUNDER wants {@link foundStrand}, which publishes and attaches in one resumable call.
|
|
5648
5742
|
* With no explicit `founder` flag, founder-ness is derived from the row's
|
|
5649
|
-
* `
|
|
5743
|
+
* `FounderPeerId` (see {@link StrandConfig.founder}) — so attaching a row THIS machine
|
|
5650
5744
|
* published (e.g. re-attaching its own orphan after a restart) founds it, and attaching
|
|
5651
5745
|
* anyone else's row joins, without the caller needing to know which it is.
|
|
5652
5746
|
*
|
|
@@ -5692,7 +5786,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5692
5786
|
// A row this node offered itself came from the control table or is already a
|
|
5693
5787
|
// remembered join, and a row this node founds is not a join, so only a joined row
|
|
5694
5788
|
// from elsewhere can be a new join to remember. A founded row recorded here would
|
|
5695
|
-
// come back after a restart with
|
|
5789
|
+
// come back after a restart with null founder columns and relaunch as a joiner.
|
|
5696
5790
|
// Captured before the backlog delete below.
|
|
5697
5791
|
const rememberable = founder !== true && !this.discoveredStrands.has(strandRow.Id);
|
|
5698
5792
|
|
|
@@ -5713,7 +5807,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5713
5807
|
}
|
|
5714
5808
|
|
|
5715
5809
|
// An unset `founder` is DERIVED from the row inside launchStrand (this node founds
|
|
5716
|
-
// iff the row's
|
|
5810
|
+
// iff the row's FounderPeerId is its own peer id); an explicit flag wins — the
|
|
5717
5811
|
// formation/responder flows pass one deliberately, since their consent-seated rows
|
|
5718
5812
|
// carry a null column. See StrandConfig.founder. Same rule for the explicit
|
|
5719
5813
|
// partyMemberPrivateKey: it wins over the StrandPartyKey control-row read.
|
|
@@ -5736,7 +5830,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5736
5830
|
// from the row and arguments passed here — so a caller that enriched either (a
|
|
5737
5831
|
// synthetic `MemberPrivateKey`, an explicit `founder` or `partyMemberPrivateKey`)
|
|
5738
5832
|
// is retried with less than it asked for. Inert today: founder-ness re-derives from
|
|
5739
|
-
// `
|
|
5833
|
+
// `FounderPeerId`, the party key re-reads from `StrandPartyKey`, and the row's
|
|
5740
5834
|
// shared `MemberPrivateKey` is read only by the founder bootstrap. If a joiner
|
|
5741
5835
|
// launch ever starts depending on that key, gate this hand-back on the passed row
|
|
5742
5836
|
// matching the control one.
|
|
@@ -5811,11 +5905,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5811
5905
|
* fresh publish also clears it for an OPEN strand, but is destructive for a closed one
|
|
5812
5906
|
* (the key exists nowhere else). No app-data wipe is needed for either.
|
|
5813
5907
|
*
|
|
5814
|
-
* The insert is signed
|
|
5815
|
-
*
|
|
5816
|
-
*
|
|
5817
|
-
*
|
|
5818
|
-
*
|
|
5908
|
+
* The insert is signed through this node's owner signer ({@link getOwnerSigner}), in one
|
|
5909
|
+
* approval with a closed strand's party-key mint. The signer's key is persisted on the row
|
|
5910
|
+
* as `FounderOwnerKey` (the schema pins the column to the verified signer), and this node's
|
|
5911
|
+
* peer id as `FounderPeerId` (bound into the signature), which is what later lets any
|
|
5912
|
+
* launch derive "this machine is the founder" from the row alone.
|
|
5819
5913
|
* The key must be enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
|
|
5820
5914
|
* at genesis) or the schema's `Strand.AuthorizedInsert` constraint rejects the write.
|
|
5821
5915
|
* Failing loudly here is intentional: a silently-unpublished strand would run
|
|
@@ -5831,16 +5925,18 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5831
5925
|
* {@link addStrand}).
|
|
5832
5926
|
* @param type - `'o'` for open (default) or `'c'` for closed.
|
|
5833
5927
|
* @param memberPrivateKey - Optional shared membership (read) key for a closed strand.
|
|
5928
|
+
* @param sign - The `sign` of an owner approval the caller already holds
|
|
5929
|
+
* ({@link getOwnerSigner}), so a larger action asks once; omitted, this asks for its own.
|
|
5834
5930
|
* @returns The live `Strand` row — the one just inserted, or the matching one already
|
|
5835
5931
|
* there. A closed strand's caller should carry THIS row's `MemberPrivateKey` forward:
|
|
5836
5932
|
* on a repeat it is the stored key, not the argument.
|
|
5837
|
-
* @throws if the node is not started,
|
|
5933
|
+
* @throws if the node is not started, has no owner signer wired, the id is blank or
|
|
5838
5934
|
* unusable as a storage scope key (`InvalidStrandIdError` — see `storage-scope.ts`), a
|
|
5839
5935
|
* row with the same id holds different content, or the control DB rejects the
|
|
5840
5936
|
* (unauthorized) insert.
|
|
5841
5937
|
*/
|
|
5842
|
-
async publishStrand(strandId: string, type: 'o' | 'c' = 'o', memberPrivateKey?: string): Promise<StrandRow> {
|
|
5843
|
-
const
|
|
5938
|
+
async publishStrand(strandId: string, type: 'o' | 'c' = 'o', memberPrivateKey?: string, sign?: OwnerSign): Promise<StrandRow> {
|
|
5939
|
+
const signer = this.requireOwnerSigner(`publish strand ${strandId}`);
|
|
5844
5940
|
// Trim/reject here so the id that lands matches the one unpublishStrand looks up: it
|
|
5845
5941
|
// trims too, and an untrimmed row would be unreachable by the same string.
|
|
5846
5942
|
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
@@ -5849,13 +5945,14 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5849
5945
|
// a storage scope key would otherwise reach the whole party's control database and be
|
|
5850
5946
|
// declined once per member. Asserted after the trim so the id checked is the id stored.
|
|
5851
5947
|
assertStrandScopeKey(trimmed);
|
|
5852
|
-
//
|
|
5853
|
-
//
|
|
5948
|
+
// FounderPeerId records THIS machine as the founder, which is what later lets a relaunch
|
|
5949
|
+
// derive founder-ness; ControlDatabase.insertStrand writes the same id (its own node's).
|
|
5854
5950
|
const desired: StrandRow = {
|
|
5855
5951
|
Id: trimmed,
|
|
5856
5952
|
Type: type,
|
|
5857
5953
|
MemberPrivateKey: memberPrivateKey ?? null,
|
|
5858
|
-
FounderOwnerKey:
|
|
5954
|
+
FounderOwnerKey: signer.publicKey,
|
|
5955
|
+
FounderPeerId: this.controlNode!.peerId.toString(),
|
|
5859
5956
|
};
|
|
5860
5957
|
|
|
5861
5958
|
// Read first rather than leaning on the insert's collision for BOTH readings. The
|
|
@@ -5880,19 +5977,24 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5880
5977
|
// is a read-only no-op and a publish interrupted between the row insert and the
|
|
5881
5978
|
// mint heals here. A row another machine published is that machine's to mint for.
|
|
5882
5979
|
if (type === 'c' && this.isSelfFoundedRow(matched)) {
|
|
5883
|
-
await this.ensureStrandPartyKey(trimmed);
|
|
5980
|
+
await this.ensureStrandPartyKey(trimmed, undefined, sign);
|
|
5884
5981
|
}
|
|
5885
5982
|
return matched;
|
|
5886
5983
|
}
|
|
5887
5984
|
|
|
5985
|
+
return await withinOwnerApproval(signer, { partyId: this.partyId, action: `Publish the strand ${trimmed} to the cadre` }, sign,
|
|
5986
|
+
(approved) => this.insertStrandRow(desired, signer, approved));
|
|
5987
|
+
}
|
|
5988
|
+
|
|
5989
|
+
/**
|
|
5990
|
+
* The write half of {@link publishStrand}: insert `desired`, adopting a matching row a
|
|
5991
|
+
* concurrent founder landed first, then mint a closed strand's party key, all under one
|
|
5992
|
+
* owner approval's `sign`.
|
|
5993
|
+
*/
|
|
5994
|
+
private async insertStrandRow(desired: StrandRow, signer: OwnerSigner, sign: OwnerSign): Promise<StrandRow> {
|
|
5995
|
+
const { Id: trimmed, Type: type } = desired;
|
|
5888
5996
|
try {
|
|
5889
|
-
await this.controlDatabase!.insertStrand(
|
|
5890
|
-
trimmed,
|
|
5891
|
-
type,
|
|
5892
|
-
signingKey.publicKeyB64,
|
|
5893
|
-
signMessageWith(signingKey.privateKeyB64),
|
|
5894
|
-
memberPrivateKey
|
|
5895
|
-
);
|
|
5997
|
+
await this.controlDatabase!.insertStrand(trimmed, type, signer.publicKey, sign, desired.MemberPrivateKey ?? undefined);
|
|
5896
5998
|
} catch (error) {
|
|
5897
5999
|
// Only the uniqueness collision is a candidate for the idempotent reading; every
|
|
5898
6000
|
// other rejection (unauthorized signer, retired stamp) must keep surfacing.
|
|
@@ -5909,14 +6011,14 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5909
6011
|
log('publishStrand(%s): lost a concurrent founding race; re-read the landed row', trimmed);
|
|
5910
6012
|
return requireMatchingStrandRow(landed, desired, 'landed concurrently from another founder');
|
|
5911
6013
|
}
|
|
5912
|
-
log('Published strand %s (type %s) to control DB under owner %s', trimmed, type,
|
|
6014
|
+
log('Published strand %s (type %s) to control DB under owner %s', trimmed, type, signer.publicKey);
|
|
5913
6015
|
// A closed strand's publish also mints this party's own membership identity key
|
|
5914
6016
|
// (StrandPartyKey) — the key the founder bootstrap derives Member/Manager from,
|
|
5915
|
-
// deliberately distinct from the shared
|
|
6017
|
+
// deliberately distinct from the row's shared MemberPrivateKey read secret. A failure
|
|
5916
6018
|
// here surfaces (the strand row has already committed, so a retried publish takes
|
|
5917
6019
|
// the idempotent branch and heals the mint; so does the next founder launch).
|
|
5918
6020
|
if (type === 'c') {
|
|
5919
|
-
await this.ensureStrandPartyKey(trimmed);
|
|
6021
|
+
await this.ensureStrandPartyKey(trimmed, undefined, sign);
|
|
5920
6022
|
}
|
|
5921
6023
|
return desired;
|
|
5922
6024
|
}
|
|
@@ -5937,7 +6039,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5937
6039
|
* attempt (as the reference apps do) would otherwise present a key that does not match
|
|
5938
6040
|
* the membership already seated in the strand.
|
|
5939
6041
|
* - **instance already running** → the tracked instance is returned, and — because the
|
|
5940
|
-
* `Strand` row records its
|
|
6042
|
+
* `Strand` row records its founding machine (`FounderPeerId`) — a founder request
|
|
5941
6043
|
* against an instance that was first launched as a joiner now runs the (idempotent,
|
|
5942
6044
|
* insert-if-absent) founder bootstrap on it rather than silently skipping it
|
|
5943
6045
|
* ({@link StrandInstanceManager.foundExistingStrand}). So a strand that something else
|
|
@@ -5946,7 +6048,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5946
6048
|
* `resolveCohortSeed` window — still ends up with its `Strand.Header` written.
|
|
5947
6049
|
*
|
|
5948
6050
|
* Founder-ness is resolved FROM THE ROW, not assumed: this machine founds iff the
|
|
5949
|
-
* resolved row's `
|
|
6051
|
+
* resolved row's `FounderPeerId` is its own peer id. A fresh publish records this
|
|
5950
6052
|
* machine, so the common path founds; a machine that lost a concurrent founding race to
|
|
5951
6053
|
* a sibling adopts the sibling's row and ATTACHES instead — deliberately, since two
|
|
5952
6054
|
* machines bootstrapping the same strand on separate replicas is the double-`Header`
|
|
@@ -5955,20 +6057,66 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5955
6057
|
* `Type` is compared, not adopted: a stored row of the other type means the caller and
|
|
5956
6058
|
* the control plane disagree about what this strand IS, so it throws.
|
|
5957
6059
|
*
|
|
6060
|
+
* **Returning after the publish.** `foundStrand(config, { background: true })` resolves
|
|
6061
|
+
* once the row is published and continues the local start in
|
|
6062
|
+
* {@link FoundStrandBackgroundResult.ready}, for a caller that needs only the row (an
|
|
6063
|
+
* invitation reads just its id and membership key). Publish errors still reject the call
|
|
6064
|
+
* itself. A joiner that redeems an invitation to a closed strand before its start finishes
|
|
6065
|
+
* is refused with the retryable `host-strand-unavailable` and the token stays unspent:
|
|
6066
|
+
* {@link requestJoin} retries that on its backoff, while {@link formStrand} throws it to its
|
|
6067
|
+
* caller once every machine the invitation names has refused.
|
|
6068
|
+
*
|
|
5958
6069
|
* @returns The instance AND the row the strand actually runs under — read the membership
|
|
5959
6070
|
* key from the returned row, not from a freshly minted one ({@link FoundStrandResult}).
|
|
5960
6071
|
* @throws if the node is not started, the id is blank, a published row of the same id has
|
|
5961
6072
|
* a different `Type`, or either half rejects.
|
|
5962
6073
|
*/
|
|
5963
|
-
|
|
5964
|
-
|
|
6074
|
+
foundStrand(config: FoundStrandConfig): Promise<FoundStrandResult>;
|
|
6075
|
+
/**
|
|
6076
|
+
* Publish the strand's row as {@link foundStrand} does, and resolve once it is published;
|
|
6077
|
+
* the local start continues in {@link FoundStrandBackgroundResult.ready}.
|
|
6078
|
+
*
|
|
6079
|
+
* @throws as the default form does for the publish half; the start's own failure rejects
|
|
6080
|
+
* `ready` instead.
|
|
6081
|
+
*/
|
|
6082
|
+
foundStrand(config: FoundStrandConfig, options: FoundStrandOptions): Promise<FoundStrandBackgroundResult>;
|
|
6083
|
+
async foundStrand(
|
|
6084
|
+
config: FoundStrandConfig,
|
|
6085
|
+
options?: FoundStrandOptions
|
|
6086
|
+
): Promise<FoundStrandResult | FoundStrandBackgroundResult> {
|
|
6087
|
+
const tTotal = performance.now();
|
|
6088
|
+
const published = await this.publishFoundingRow(config);
|
|
6089
|
+
const { strandRow, founded } = published;
|
|
6090
|
+
const logTotal = () => timing(`[foundStrand:${strandRow.Id}] total: ${Math.round(performance.now() - tTotal)}ms`);
|
|
6091
|
+
if (options?.background) {
|
|
6092
|
+
const ready = this.attachFoundingRow(published, config);
|
|
6093
|
+
// Without a handler here, a caller that only wanted the row would get an unhandled
|
|
6094
|
+
// rejection. The caller still sees the rejection on `ready` itself.
|
|
6095
|
+
void ready.catch((error: unknown) => {
|
|
6096
|
+
log('foundStrand(%s): background start did not complete; the strand stays published: %o',
|
|
6097
|
+
strandRow.Id, error);
|
|
6098
|
+
});
|
|
6099
|
+
logTotal();
|
|
6100
|
+
return { strandRow, founded, ready };
|
|
6101
|
+
}
|
|
6102
|
+
const instance = await this.attachFoundingRow(published, config);
|
|
6103
|
+
logTotal();
|
|
6104
|
+
return { instance, strandRow, founded };
|
|
6105
|
+
}
|
|
6106
|
+
|
|
6107
|
+
/**
|
|
6108
|
+
* The publish half of {@link foundStrand}: adopt the stored row or publish a new one, and
|
|
6109
|
+
* derive whether this machine founds it. Every check that can refuse the founding is here,
|
|
6110
|
+
* so both forms of `foundStrand` reject on it before returning.
|
|
6111
|
+
*/
|
|
6112
|
+
private async publishFoundingRow(config: FoundStrandConfig): Promise<Pick<FoundStrandResult, 'strandRow' | 'founded'>> {
|
|
6113
|
+
const { strandId, type = 'o', memberPrivateKey } = config;
|
|
5965
6114
|
if (!this._running || !this.controlDatabase) {
|
|
5966
6115
|
throw new Error(`CadreNode must be started before attempting to found strand ${strandId}`);
|
|
5967
6116
|
}
|
|
5968
6117
|
const controlDatabase = this.controlDatabase;
|
|
5969
6118
|
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
5970
6119
|
const timed = <T>(step: string, op: () => Promise<T>) => timedStep('foundStrand', trimmed, step, op);
|
|
5971
|
-
const tTotal = performance.now();
|
|
5972
6120
|
const published = await timed('queryStrand', () => controlDatabase.queryStrand(trimmed));
|
|
5973
6121
|
const strandRow = published
|
|
5974
6122
|
? this.adoptPublishedStrand(published, type, memberPrivateKey)
|
|
@@ -5977,14 +6125,24 @@ export class CadreNode implements SAppIdLookup {
|
|
|
5977
6125
|
// machine runs the bootstrap — this one must attach or it would write a second Header.
|
|
5978
6126
|
const founded = this.isSelfFoundedRow(strandRow);
|
|
5979
6127
|
if (!founded) {
|
|
5980
|
-
log('foundStrand(%s): the stored row
|
|
5981
|
-
'(
|
|
5982
|
-
'the founder bootstrap runs on
|
|
6128
|
+
log('foundStrand(%s): the stored row names a different machine as founder ' +
|
|
6129
|
+
'(FounderPeerId is not this node\'s peer id) — attaching as a joiner; ' +
|
|
6130
|
+
'the founder bootstrap runs on that machine', trimmed);
|
|
5983
6131
|
}
|
|
5984
|
-
|
|
6132
|
+
return { strandRow, founded };
|
|
6133
|
+
}
|
|
6134
|
+
|
|
6135
|
+
/**
|
|
6136
|
+
* The start half of {@link foundStrand}. Its timing lines (`addStrand: start`, then the
|
|
6137
|
+
* duration or `failed after`) are logged when the start settles, also in the background
|
|
6138
|
+
* form, so a start that hangs still shows as a start line with no end line.
|
|
6139
|
+
*/
|
|
6140
|
+
private attachFoundingRow(
|
|
6141
|
+
{ strandRow, founded }: Pick<FoundStrandResult, 'strandRow' | 'founded'>,
|
|
6142
|
+
{ sAppConfig, awaitFirstSync }: FoundStrandConfig
|
|
6143
|
+
): Promise<StrandInstance> {
|
|
6144
|
+
return timedStep('foundStrand', strandRow.Id, 'addStrand',
|
|
5985
6145
|
() => this.addStrand({ strandRow, sAppConfig, founder: founded, awaitFirstSync }));
|
|
5986
|
-
timing(`[foundStrand:${trimmed}] total: ${Math.round(performance.now() - tTotal)}ms`);
|
|
5987
|
-
return { instance, strandRow, founded };
|
|
5988
6146
|
}
|
|
5989
6147
|
|
|
5990
6148
|
/**
|
|
@@ -6066,20 +6224,18 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6066
6224
|
* step, not a widening of this method.
|
|
6067
6225
|
*
|
|
6068
6226
|
* @param strandId - The `Strand` row id to remove (as passed to {@link publishStrand}).
|
|
6069
|
-
* @throws if the node is not started,
|
|
6227
|
+
* @throws if the node is not started, has no owner signer wired, the id is blank, or
|
|
6070
6228
|
* the signer is not an enrolled owner (the schema's `Strand.AuthorizedDelete` rejects
|
|
6071
6229
|
* the write and the row survives). A rejection does NOT imply the row survived: the
|
|
6072
6230
|
* local stop runs after the control-plane delete has already committed, so a failure
|
|
6073
6231
|
* there throws over a completed removal.
|
|
6074
6232
|
*/
|
|
6075
6233
|
async unpublishStrand(strandId: string): Promise<void> {
|
|
6076
|
-
const
|
|
6234
|
+
const signer = this.requireOwnerSigner(`unpublish strand ${strandId}`);
|
|
6077
6235
|
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
signMessageWith(signingKey.privateKeyB64)
|
|
6082
|
-
);
|
|
6236
|
+
// One approval: the strand delete and its party key's, each with its tombstone.
|
|
6237
|
+
const removed = await signer.approve({ partyId: this.partyId, action: `Remove the strand ${trimmed} from the cadre` },
|
|
6238
|
+
(sign) => this.controlDatabase!.deleteStrand(trimmed, signer.publicKey, sign));
|
|
6083
6239
|
// Gate on `removed`: the absent-row no-op wrote nothing, so warning about an
|
|
6084
6240
|
// unreplicated deletion there would send an operator chasing a phantom.
|
|
6085
6241
|
if (removed && this.committedAlone()) {
|
|
@@ -6122,12 +6278,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6122
6278
|
// row is already gone — there is nothing to suppress — and an id re-published before
|
|
6123
6279
|
// the next poll's suppression cleanup would then never be offered again this session.
|
|
6124
6280
|
// (Same reasoning as handleStrandRemoved, which also avoids stopStrand's `_running`
|
|
6125
|
-
// guard; here the guard is already satisfied by
|
|
6281
|
+
// guard; here the guard is already satisfied by requireOwnerSigner above.)
|
|
6126
6282
|
await this.strandWatcher?.forcePoll();
|
|
6127
6283
|
if (this.strandManager.getInstance(trimmed)) {
|
|
6128
6284
|
await this.detachStrand(trimmed);
|
|
6129
6285
|
}
|
|
6130
|
-
log('Unpublished strand %s from control DB under owner %s', trimmed,
|
|
6286
|
+
log('Unpublished strand %s from control DB under owner %s', trimmed, signer.publicKey);
|
|
6131
6287
|
}
|
|
6132
6288
|
|
|
6133
6289
|
/**
|
|
@@ -6154,11 +6310,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6154
6310
|
* `generateStrandMemberKey` mints). When a row already holds a DIFFERENT key this
|
|
6155
6311
|
* throws — a party has one identity per strand; rotation is a deliberate
|
|
6156
6312
|
* remove-then-insert, not a silent swap.
|
|
6313
|
+
* @param sign - The `sign` of an owner approval the caller already holds; omitted, a
|
|
6314
|
+
* mint asks for its own (a stored key asks for none).
|
|
6157
6315
|
* @returns The live party key: the one just seated, or the stored one.
|
|
6158
6316
|
*/
|
|
6159
|
-
async ensureStrandPartyKey(strandId: string, partyMemberPrivateKey?: string): Promise<string> {
|
|
6317
|
+
async ensureStrandPartyKey(strandId: string, partyMemberPrivateKey?: string, sign?: OwnerSign): Promise<string> {
|
|
6160
6318
|
const trimmed = requireNonBlank(strandId, 'strand id');
|
|
6161
|
-
const
|
|
6319
|
+
const signer = this.requireOwnerSigner(`seat a party key for strand ${trimmed}`);
|
|
6162
6320
|
const existing = await this.controlDatabase!.queryStrandPartyKey(trimmed);
|
|
6163
6321
|
if (existing !== null) {
|
|
6164
6322
|
if (partyMemberPrivateKey !== undefined && partyMemberPrivateKey !== existing) {
|
|
@@ -6173,8 +6331,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6173
6331
|
}
|
|
6174
6332
|
const key = partyMemberPrivateKey ?? await generateStrandMemberKey();
|
|
6175
6333
|
try {
|
|
6176
|
-
await this.
|
|
6177
|
-
trimmed, key,
|
|
6334
|
+
await withinOwnerApproval(signer, { partyId: this.partyId, action: `Seat this cadre's membership key for the strand ${trimmed}` }, sign,
|
|
6335
|
+
(approved) => this.controlDatabase!.insertStrandPartyKey(trimmed, key, signer.publicKey, approved));
|
|
6178
6336
|
} catch (error) {
|
|
6179
6337
|
// The read and the insert are not atomic; a concurrent seat of the same strand's
|
|
6180
6338
|
// key can land in between. Adopt the landed row when it satisfies this request —
|
|
@@ -6187,16 +6345,16 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6187
6345
|
log('ensureStrandPartyKey(%s): lost a concurrent seat race; adopting the landed key', trimmed);
|
|
6188
6346
|
return landed;
|
|
6189
6347
|
}
|
|
6190
|
-
log('Seated strand party key for %s under owner %s', trimmed,
|
|
6348
|
+
log('Seated strand party key for %s under owner %s', trimmed, signer.publicKey);
|
|
6191
6349
|
return key;
|
|
6192
6350
|
}
|
|
6193
6351
|
|
|
6194
6352
|
/**
|
|
6195
6353
|
* Resolve the party membership key a closed strand's launch threads into the founder
|
|
6196
6354
|
* bootstrap: the explicit attach-time key when given, else the party's persisted
|
|
6197
|
-
* `StrandPartyKey` row, else — on the one machine
|
|
6198
|
-
*
|
|
6199
|
-
*
|
|
6355
|
+
* `StrandPartyKey` row, else — on the one machine the row names as founder (`FounderPeerId`)
|
|
6356
|
+
* — a freshly minted-and-persisted key, which seats the identity for a publish that was
|
|
6357
|
+
* interrupted before its mint. Everyone else resolves undefined: non-founding
|
|
6200
6358
|
* machines never mint (no mint race between a party's machines), and only a founder
|
|
6201
6359
|
* bootstrap needs the key at all.
|
|
6202
6360
|
*
|
|
@@ -6226,6 +6384,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6226
6384
|
if (!this.isSelfFoundedRow(strand)) {
|
|
6227
6385
|
return undefined;
|
|
6228
6386
|
}
|
|
6387
|
+
// NOTE: founder-ness no longer waits for the owner signer, so a founding machine's launch
|
|
6388
|
+
// that runs before the embedder wires it throws here and the watcher retries on its backoff;
|
|
6389
|
+
// if that shows up as restart noise, skip the heal until `getOwnerSigner()` is set.
|
|
6229
6390
|
return await this.ensureStrandPartyKey(strand.Id);
|
|
6230
6391
|
}
|
|
6231
6392
|
|
|
@@ -6241,29 +6402,26 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6241
6402
|
* from this row. A host minting a closed-strand invite does both (mint +
|
|
6242
6403
|
* publish), exactly as the integration harness's `createInvitation` does.
|
|
6243
6404
|
*
|
|
6244
|
-
* Signs
|
|
6245
|
-
*
|
|
6246
|
-
* Throws loudly if the node isn't started or exposes no signing key.
|
|
6405
|
+
* Signs through the same owner signer as {@link publishStrand}, whose key must be an
|
|
6406
|
+
* enrolled `OwnerKey`. Throws loudly if the node isn't started or has no owner signer wired.
|
|
6247
6407
|
*
|
|
6248
6408
|
* @param token - Invitation token (the `FormationInvite` primary key); use the
|
|
6249
6409
|
* `token` of the {@link OpenInvitation} from {@link createOpenInvitation}.
|
|
6250
6410
|
* @param sAppId - The sApp a redeemed strand will use.
|
|
6251
6411
|
* @param options - Optional `expiresAtMs` (epoch ms), `totalUses`, `validationUrl`,
|
|
6252
6412
|
* `strandId` (bind a closed/pre-existing host strand for provision-then-record).
|
|
6413
|
+
* @param sign - The `sign` of an owner approval the caller already holds; omitted, this
|
|
6414
|
+
* asks for its own.
|
|
6253
6415
|
*/
|
|
6254
6416
|
async publishFormationInvite(
|
|
6255
6417
|
token: string,
|
|
6256
6418
|
sAppId: string,
|
|
6257
|
-
options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string } = {}
|
|
6419
|
+
options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string } = {},
|
|
6420
|
+
sign?: OwnerSign
|
|
6258
6421
|
): Promise<void> {
|
|
6259
|
-
const
|
|
6260
|
-
await this.
|
|
6261
|
-
token,
|
|
6262
|
-
sAppId,
|
|
6263
|
-
signingKey.publicKeyB64,
|
|
6264
|
-
signMessageWith(signingKey.privateKeyB64),
|
|
6265
|
-
options
|
|
6266
|
-
);
|
|
6422
|
+
const signer = this.requireOwnerSigner(`publish formation invite ${token}`);
|
|
6423
|
+
await withinOwnerApproval(signer, { partyId: this.partyId, action: `Publish an invitation to the app ${sAppId}` }, sign,
|
|
6424
|
+
(approved) => this.controlDatabase!.insertFormationInvite(token, sAppId, signer.publicKey, approved, options));
|
|
6267
6425
|
// A host may publish an invite whose token was minted elsewhere; registering
|
|
6268
6426
|
// it locally opens the connection gate's formation exemption immediately
|
|
6269
6427
|
// rather than waiting for the durable row to become readable.
|
|
@@ -6271,7 +6429,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6271
6429
|
token,
|
|
6272
6430
|
options.expiresAtMs ?? Number.POSITIVE_INFINITY
|
|
6273
6431
|
);
|
|
6274
|
-
log('Published formation invite %s (sApp %s) under owner %s', token, sAppId,
|
|
6432
|
+
log('Published formation invite %s (sApp %s) under owner %s', token, sAppId, signer.publicKey);
|
|
6275
6433
|
}
|
|
6276
6434
|
|
|
6277
6435
|
/**
|
|
@@ -6282,10 +6440,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6282
6440
|
* — this is how a party says which outside approver it trusts. Without an enrollment,
|
|
6283
6441
|
* every such invitation is unredeemable.
|
|
6284
6442
|
*
|
|
6285
|
-
* Signs
|
|
6286
|
-
*
|
|
6287
|
-
*
|
|
6288
|
-
*
|
|
6443
|
+
* Signs through the same owner signer as {@link publishStrand}, whose key must be an
|
|
6444
|
+
* enrolled `OwnerKey`. Throws loudly if the node isn't started, has no owner signer wired,
|
|
6445
|
+
* or the key is blank — a blank key would reach the database and fail the signature CHECK
|
|
6446
|
+
* as an opaque constraint error.
|
|
6289
6447
|
*
|
|
6290
6448
|
* Enrolling this node's OWN owner key is permitted and harmless: the domain/action tags
|
|
6291
6449
|
* baked into each authorization digest mean an owner-key signature can never satisfy the
|
|
@@ -6300,14 +6458,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6300
6458
|
* @param key - Approver public key to enroll (base64url ed25519 public key).
|
|
6301
6459
|
*/
|
|
6302
6460
|
async enrollValidationKey(key: string): Promise<void> {
|
|
6303
|
-
const
|
|
6461
|
+
const signer = this.requireOwnerSigner('enroll a validation key');
|
|
6304
6462
|
const trimmed = requireEd25519PublicKeyB64(key, 'validation key');
|
|
6305
|
-
await this.
|
|
6306
|
-
trimmed,
|
|
6307
|
-
|
|
6308
|
-
signMessageWith(signingKey.privateKeyB64)
|
|
6309
|
-
);
|
|
6310
|
-
log('Enrolled validation key %s under owner %s', trimmed, signingKey.publicKeyB64);
|
|
6463
|
+
await signer.approve({ partyId: this.partyId, action: `Trust the approver key ${trimmed}` },
|
|
6464
|
+
(sign) => this.controlDatabase!.insertValidationKey(trimmed, signer.publicKey, sign));
|
|
6465
|
+
log('Enrolled validation key %s under owner %s', trimmed, signer.publicKey);
|
|
6311
6466
|
}
|
|
6312
6467
|
|
|
6313
6468
|
/**
|
|
@@ -6327,20 +6482,17 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6327
6482
|
* @param key - Approver public key to remove (base64url ed25519 public key).
|
|
6328
6483
|
*/
|
|
6329
6484
|
async removeValidationKey(key: string): Promise<void> {
|
|
6330
|
-
const
|
|
6485
|
+
const signer = this.requireOwnerSigner('remove a validation key');
|
|
6331
6486
|
const trimmed = requireNonBlank(key, 'validation key');
|
|
6332
|
-
await this.
|
|
6333
|
-
trimmed,
|
|
6334
|
-
|
|
6335
|
-
signMessageWith(signingKey.privateKeyB64)
|
|
6336
|
-
);
|
|
6337
|
-
log('Removed validation key %s under owner %s', trimmed, signingKey.publicKeyB64);
|
|
6487
|
+
await signer.approve({ partyId: this.partyId, action: `Stop trusting the approver key ${trimmed}` },
|
|
6488
|
+
(sign) => this.controlDatabase!.deleteValidationKey(trimmed, signer.publicKey, sign));
|
|
6489
|
+
log('Removed validation key %s under owner %s', trimmed, signer.publicKey);
|
|
6338
6490
|
}
|
|
6339
6491
|
|
|
6340
6492
|
/**
|
|
6341
6493
|
* The approver keys currently enrolled, sorted. Read-only, so unlike
|
|
6342
|
-
* {@link enrollValidationKey} / {@link removeValidationKey} it needs no owner
|
|
6343
|
-
*
|
|
6494
|
+
* {@link enrollValidationKey} / {@link removeValidationKey} it needs no owner signer —
|
|
6495
|
+
* only a started node.
|
|
6344
6496
|
*/
|
|
6345
6497
|
async listValidationKeys(): Promise<string[]> {
|
|
6346
6498
|
if (!this._running || !this.controlDatabase) {
|
|
@@ -6350,30 +6502,29 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6350
6502
|
}
|
|
6351
6503
|
|
|
6352
6504
|
/**
|
|
6353
|
-
*
|
|
6354
|
-
* {@link unpublishStrand}, {@link
|
|
6355
|
-
* {@link removeValidationKey}
|
|
6356
|
-
*
|
|
6357
|
-
*
|
|
6505
|
+
* The owner signer every owner-signed strand write needs — {@link publishStrand},
|
|
6506
|
+
* {@link unpublishStrand}, {@link ensureStrandPartyKey}, {@link publishFormationInvite},
|
|
6507
|
+
* {@link enrollValidationKey}, {@link removeValidationKey} — failing loudly in one two-part
|
|
6508
|
+
* shape (not started / no signer wired, naming the wiring call as the fix). Enrollment is the
|
|
6509
|
+
* schema's to judge. Narrows `controlDatabase` for the caller: a return means
|
|
6358
6510
|
* `this.controlDatabase` is non-null too.
|
|
6359
6511
|
*
|
|
6360
6512
|
* @param action - Infinitive phrase naming the attempted write, e.g. `'enroll a
|
|
6361
6513
|
* validation key'`; interpolated into both messages.
|
|
6362
6514
|
*/
|
|
6363
|
-
private
|
|
6515
|
+
private requireOwnerSigner(action: string): OwnerSigner {
|
|
6364
6516
|
if (!this._running || !this.controlDatabase) {
|
|
6365
6517
|
throw new Error(`CadreNode must be started before attempting to ${action}`);
|
|
6366
6518
|
}
|
|
6367
|
-
const
|
|
6368
|
-
if (!
|
|
6519
|
+
const signer = this.getOwnerSigner();
|
|
6520
|
+
if (!signer) {
|
|
6369
6521
|
throw new Error(
|
|
6370
|
-
`Cannot ${action}:
|
|
6371
|
-
'(
|
|
6372
|
-
'
|
|
6373
|
-
'control state.'
|
|
6522
|
+
`Cannot ${action}: the owner signer is not initialized. Wire this node's owner key ` +
|
|
6523
|
+
'(useOwnerSigner, or initializeSeedBootstrap for a key held in memory) before writing ' +
|
|
6524
|
+
'owner-signed control state.'
|
|
6374
6525
|
);
|
|
6375
6526
|
}
|
|
6376
|
-
return
|
|
6527
|
+
return signer;
|
|
6377
6528
|
}
|
|
6378
6529
|
|
|
6379
6530
|
/**
|
|
@@ -6383,10 +6534,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6383
6534
|
* before emitting `strand:started`.
|
|
6384
6535
|
*
|
|
6385
6536
|
* Founder-ness: an explicit `founder` argument wins (the formation/responder flows pass
|
|
6386
|
-
* one deliberately — their consent-seated rows carry a null `
|
|
6537
|
+
* one deliberately — their consent-seated rows carry a null `FounderPeerId`); when
|
|
6387
6538
|
* unset it is DERIVED from the row, so the control-discovered path
|
|
6388
6539
|
* (`handleStrandAdded`) and a restart's re-attach found this machine's own strands
|
|
6389
|
-
* without the caller having to know. The derivation is pure
|
|
6540
|
+
* without the caller having to know. The derivation is a pure peer-id comparison — no I/O.
|
|
6390
6541
|
*
|
|
6391
6542
|
* Idempotent when the strand manager already tracks `strand.Id` — the watcher
|
|
6392
6543
|
* rediscovers a row this node already started and calls this again via
|
|
@@ -6663,10 +6814,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6663
6814
|
* receiver records an admission grant BEFORE `libp2p.start()` (re-)dials the
|
|
6664
6815
|
* relay reservation — the gate denial there is fatal, not degraded. A
|
|
6665
6816
|
* party-member relay answers the RPC (its control node admits us as a
|
|
6666
|
-
* member); a dedicated ops/ relay does not speak the protocol
|
|
6667
|
-
*
|
|
6668
|
-
*
|
|
6669
|
-
*
|
|
6817
|
+
* member); a dedicated ops/ relay does not speak the protocol, comes back
|
|
6818
|
+
* `unsupported`, and is left out of later passes
|
|
6819
|
+
* ({@link relaysRefusingStrandAddr}) — it has no membership gate and needs no
|
|
6820
|
+
* grant. The relay's direct addr rides along as the dial fallback for a relay
|
|
6821
|
+
* we are not yet connected to.
|
|
6670
6822
|
*/
|
|
6671
6823
|
private async resolveCohortSeed(strandId: string, delegatePeerId?: string): Promise<string[]> {
|
|
6672
6824
|
const siblings = await this.resolveSiblingSeed(strandId, delegatePeerId);
|
|
@@ -6694,16 +6846,17 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6694
6846
|
targets.push(relayStrandAddrPeer(relay));
|
|
6695
6847
|
}
|
|
6696
6848
|
}
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6849
|
+
if (targets.length === 0) {
|
|
6850
|
+
return [];
|
|
6851
|
+
}
|
|
6852
|
+
const { addrs, outcomes } = await this.collectSiblingStrandAddrs(this.controlNode, targets, strandId, delegatePeerId);
|
|
6700
6853
|
// Throttle state for the RELAY targets only — refreshDelegateGrants never
|
|
6701
6854
|
// looks up a sibling key, so recording one would only be dead weight. The
|
|
6702
6855
|
// siblings' refresh due times are deliberately left alone too: stamping here
|
|
6703
6856
|
// would race the refresh pass's pruning while the strand node is still coming
|
|
6704
6857
|
// up, and all it would save is one extra RPC per sibling on the first tick.
|
|
6705
|
-
this.recordDelegateAnnounces(relays.map((r) => r.relayPeerId), strandId);
|
|
6706
|
-
return
|
|
6858
|
+
this.recordDelegateAnnounces(relays.map((r) => r.relayPeerId), strandId, outcomes);
|
|
6859
|
+
return addrs;
|
|
6707
6860
|
}
|
|
6708
6861
|
|
|
6709
6862
|
/**
|
|
@@ -6770,35 +6923,47 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6770
6923
|
* `libp2p.start()`. Kept because it costs one spread and this is the one place
|
|
6771
6924
|
* that would silently stop announcing if that rejection is ever relaxed.
|
|
6772
6925
|
*
|
|
6926
|
+
* Less the relays remembered as refusing the strand-addr protocol
|
|
6927
|
+
* ({@link relaysRefusingStrandAddr}): both callers are announcement passes, and
|
|
6928
|
+
* those relays hold no grant.
|
|
6929
|
+
*
|
|
6773
6930
|
* NOTE: a relay the STRAND node discovers on its own (autorelay — in neither
|
|
6774
6931
|
* source) gets no announcement, and a membership-gated one will deny it; fine
|
|
6775
6932
|
* now, every realistic topology feeds one of the two sources.
|
|
6776
6933
|
*/
|
|
6777
6934
|
private circuitRelayTargets(): CircuitRelayTarget[] {
|
|
6778
|
-
|
|
6935
|
+
const relays = extractCircuitRelayTargets([
|
|
6779
6936
|
...relayCircuitAddrs(this.config.network?.relayAddrs ?? []),
|
|
6780
6937
|
...(this.config.network?.listenAddrs ?? []),
|
|
6781
6938
|
...(this.controlNode?.getMultiaddrs().map(String) ?? [])
|
|
6782
6939
|
]);
|
|
6940
|
+
return relaysNotRefusing(relays, this.relaysRefusingStrandAddr);
|
|
6783
6941
|
}
|
|
6784
6942
|
|
|
6785
6943
|
/**
|
|
6786
|
-
* Record
|
|
6787
|
-
*
|
|
6944
|
+
* Record what a delegate-carrying announce pass learned about the relays it
|
|
6945
|
+
* dialed: the announce timestamps {@link refreshDelegateGrants} throttles on,
|
|
6946
|
+
* and from `outcomes` which relays refuse the strand-addr protocol
|
|
6947
|
+
* ({@link relaysRefusingStrandAddr}). Every announce site records through here.
|
|
6788
6948
|
*
|
|
6789
|
-
*
|
|
6790
|
-
*
|
|
6791
|
-
*
|
|
6792
|
-
*
|
|
6793
|
-
* costs the strand supervisor its first attempt only (the relay denies the
|
|
6949
|
+
* Timestamps are recorded OPTIMISTICALLY, whatever `collectStrandAddrs` reports
|
|
6950
|
+
* per peer: recording only on success would re-announce to an unreachable relay
|
|
6951
|
+
* on every reconcile tick, one dial timeout each. A failed INITIAL announce costs
|
|
6952
|
+
* the strand supervisor its first attempt only (the relay denies the
|
|
6794
6953
|
* reservation; every re-drive re-announces first through
|
|
6795
6954
|
* {@link announceDelegateToRelay}); a failed REFRESH retries within
|
|
6796
6955
|
* `DELEGATE_GRANT_TTL_MS / 2` (15 min), still inside the 30 min TTL.
|
|
6797
6956
|
*/
|
|
6798
|
-
private recordDelegateAnnounces(
|
|
6957
|
+
private recordDelegateAnnounces(
|
|
6958
|
+
relayPeerIds: readonly string[],
|
|
6959
|
+
strandId: string,
|
|
6960
|
+
outcomes: ReadonlyMap<string, StrandAddrOutcome>,
|
|
6961
|
+
now = Date.now()
|
|
6962
|
+
): void {
|
|
6799
6963
|
for (const relayPeerId of relayPeerIds) {
|
|
6800
6964
|
this.delegateAnnounceAt.set(peerStrandKey(relayPeerId, strandId), now);
|
|
6801
6965
|
}
|
|
6966
|
+
recordRelayRefusals(this.relaysRefusingStrandAddr, relayPeerIds, outcomes);
|
|
6802
6967
|
}
|
|
6803
6968
|
|
|
6804
6969
|
/**
|
|
@@ -6855,11 +7020,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6855
7020
|
* The throttle map is updated afterwards, so the periodic pass does not announce
|
|
6856
7021
|
* again right away.
|
|
6857
7022
|
*
|
|
6858
|
-
*
|
|
6859
|
-
*
|
|
6860
|
-
*
|
|
6861
|
-
*
|
|
6862
|
-
*
|
|
7023
|
+
* Unlike the other announce passes, this asks a relay remembered in
|
|
7024
|
+
* {@link relaysRefusingStrandAddr} too, and its reply clears the entry — see
|
|
7025
|
+
* there for why. Against a dedicated ops relay (no strand-addr RPC) that costs
|
|
7026
|
+
* one refused protocol negotiation (`unsupported`) per re-drive attempt, bounded
|
|
7027
|
+
* by the supervisor's backoff. Never throws on that path; a relay addr that names
|
|
7028
|
+
* no peer id is logged and skipped (the hook's caller runs the drive regardless).
|
|
6863
7029
|
*
|
|
6864
7030
|
* NOTE: against a relay that is DOWN this hook costs up to two strand-addr
|
|
6865
7031
|
* timeouts (dial by peer id, then by addr; 28.5 s each at the default declared link round trip)
|
|
@@ -6881,13 +7047,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6881
7047
|
log('announceDelegateToRelay: relay addr %s names no peer id; strand %s not announced', relayAddr, strandId);
|
|
6882
7048
|
return;
|
|
6883
7049
|
}
|
|
6884
|
-
await this.collectSiblingStrandAddrs(
|
|
7050
|
+
const { outcomes } = await this.collectSiblingStrandAddrs(
|
|
6885
7051
|
controlNode,
|
|
6886
7052
|
[{ peerId: relayPeerId, addrs: [multiaddr(relayAddr)] }],
|
|
6887
7053
|
strandId,
|
|
6888
7054
|
delegatePeerId
|
|
6889
7055
|
);
|
|
6890
|
-
this.recordDelegateAnnounces([relayPeerId], strandId);
|
|
7056
|
+
this.recordDelegateAnnounces([relayPeerId], strandId, outcomes);
|
|
6891
7057
|
}
|
|
6892
7058
|
|
|
6893
7059
|
/** One strand's share of {@link refreshDelegateGrants}: announce to the relays whose grant is due. */
|
|
@@ -6905,8 +7071,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
6905
7071
|
if (due.length === 0) {
|
|
6906
7072
|
return;
|
|
6907
7073
|
}
|
|
6908
|
-
await this.collectSiblingStrandAddrs(controlNode, due.map(relayStrandAddrPeer), strandId, delegatePeerId);
|
|
6909
|
-
this.recordDelegateAnnounces(due.map((relay) => relay.relayPeerId), strandId, now);
|
|
7074
|
+
const { outcomes } = await this.collectSiblingStrandAddrs(controlNode, due.map(relayStrandAddrPeer), strandId, delegatePeerId);
|
|
7075
|
+
this.recordDelegateAnnounces(due.map((relay) => relay.relayPeerId), strandId, outcomes, now);
|
|
6910
7076
|
}
|
|
6911
7077
|
|
|
6912
7078
|
/**
|
|
@@ -7824,12 +7990,19 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7824
7990
|
// ============================================================================
|
|
7825
7991
|
|
|
7826
7992
|
/**
|
|
7827
|
-
*
|
|
7828
|
-
*
|
|
7993
|
+
* Make `signer` this node's owner signer (`owner-signer.ts`): every owner signature the node
|
|
7994
|
+
* produces from now on goes through it — seeds, vouches, removals, invitations, strand and
|
|
7995
|
+
* join rows, and the background re-issues. Builds the owner-capable seed bootstrap service
|
|
7996
|
+
* around it, so this must come before any of those. One signer per node; a node started
|
|
7997
|
+
* with `claim` is refused (its claimant owns it).
|
|
7829
7998
|
*
|
|
7830
|
-
*
|
|
7999
|
+
* Anchors the signer's key in the node-local trusted-owner store as `genesis`: wiring a key
|
|
8000
|
+
* declares it an authority of this party.
|
|
8001
|
+
*
|
|
8002
|
+
* @throws when the node is not started, was started with `claim`, or already holds the
|
|
8003
|
+
* seed protocol handler (a second wiring, or {@link enableSeedListener} first).
|
|
7831
8004
|
*/
|
|
7832
|
-
async
|
|
8005
|
+
async useOwnerSigner(signer: OwnerSigner): Promise<void> {
|
|
7833
8006
|
if (!this.controlNode || !this.controlDatabase) {
|
|
7834
8007
|
throw new Error('CadreNode must be started before initializing seed bootstrap');
|
|
7835
8008
|
}
|
|
@@ -7840,9 +8013,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7840
8013
|
throw new Error('CadreNode: a node started with `claim` is owned by its claimant and cannot initialize seed bootstrap with an owner key');
|
|
7841
8014
|
}
|
|
7842
8015
|
|
|
7843
|
-
// A node wiring
|
|
7844
|
-
//
|
|
7845
|
-
//
|
|
8016
|
+
// A node wiring an owner signer is declaring its key an authority of its own
|
|
8017
|
+
// party (founder genesis / self-signing owner) — anchor it in the node-local
|
|
8018
|
+
// store. The in-memory set updates
|
|
7846
8019
|
// synchronously (see TrustedOwnerStore.trust); only the file persist is
|
|
7847
8020
|
// fire-and-forget, logged on failure.
|
|
7848
8021
|
// NOTE: wiring a key that is not a live owner here self-anchors a key that is
|
|
@@ -7850,11 +8023,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7850
8023
|
// it: a `genesis` entry is base, kept whether or not a live `OwnerKey` row
|
|
7851
8024
|
// vouches it. A machine that joined by invitation reaches this only through
|
|
7852
8025
|
// `wireOwnKeyIfOwner`, which checks the row first, so a joined non-owner never
|
|
7853
|
-
// self-anchors.
|
|
7854
|
-
//
|
|
7855
|
-
//
|
|
7856
|
-
//
|
|
7857
|
-
//
|
|
8026
|
+
// self-anchors. The founded-party starts (cadre-cli `start-owner.ts`, the phone
|
|
8027
|
+
// kit's `runOwnerGenesis`, the web app's `cadre-web.ts`) wire a key only after
|
|
8028
|
+
// inserting it as the founding row or finding it a live owner, but they judge
|
|
8029
|
+
// "founding" on local rows, so one started on a store that has not synced a party
|
|
8030
|
+
// founded elsewhere genesis-inserts and self-anchors its key. Contained while the store does
|
|
7858
8031
|
// not replicate and such a node cannot mint: `createCadreInvitation` hands out
|
|
7859
8032
|
// the anchor as the device's pins, but the `CadreInvite` insert needs a live
|
|
7860
8033
|
// owner as issuer. If anything hands out the anchor without that check, gate
|
|
@@ -7864,25 +8037,37 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7864
8037
|
// identity key alone.
|
|
7865
8038
|
if (this.trustedOwnerStore) {
|
|
7866
8039
|
void this.trustedOwnerStore
|
|
7867
|
-
.trust(
|
|
8040
|
+
.trust(signer.publicKey, 'genesis')
|
|
7868
8041
|
.catch((error) => log('Trusted-owner genesis anchor persist failed: %o', error));
|
|
7869
8042
|
}
|
|
7870
8043
|
|
|
7871
8044
|
await this.installSeedBootstrapService(new SeedBootstrapService({
|
|
7872
8045
|
partyId: this.config.controlNetwork.partyId,
|
|
7873
|
-
|
|
8046
|
+
ownerSigner: signer,
|
|
7874
8047
|
...this.seedServiceBudgets(),
|
|
7875
8048
|
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
7876
8049
|
// Seed trust anchors on the node-local store (seeded just above with this
|
|
7877
8050
|
// node's own genesis key), never on the replicated OwnerKey table.
|
|
7878
8051
|
...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
|
|
7879
8052
|
}), this.controlNode, this.controlDatabase);
|
|
7880
|
-
log('
|
|
8053
|
+
log('Owner signer wired (owner key %s)', signer.publicKey);
|
|
7881
8054
|
// Embedders wire the owner key after start(), so the runner's first pass usually found
|
|
7882
8055
|
// this machine not yet an owner; without this it would wait a full poll interval.
|
|
7883
8056
|
this.pendingJoinRunner?.kick();
|
|
7884
8057
|
}
|
|
7885
8058
|
|
|
8059
|
+
/**
|
|
8060
|
+
* {@link useOwnerSigner} with a software signer over `ownerPrivateKey` (`softwareOwnerSigner`):
|
|
8061
|
+
* the owner key held in this process's memory. Kept as a convenience because most tests and
|
|
8062
|
+
* the embedders that hold the key themselves wire it this way; one that keeps the key
|
|
8063
|
+
* elsewhere wires its own signer.
|
|
8064
|
+
*
|
|
8065
|
+
* @param ownerPrivateKey - The owner key's 32-byte Ed25519 seed, base64url.
|
|
8066
|
+
*/
|
|
8067
|
+
async initializeSeedBootstrap(ownerPrivateKey: string): Promise<void> {
|
|
8068
|
+
await this.useOwnerSigner(softwareOwnerSigner(ownerPrivateKey));
|
|
8069
|
+
}
|
|
8070
|
+
|
|
7886
8071
|
/**
|
|
7887
8072
|
* Push the multiaddrs that future invites should advertise. Pass `null` to
|
|
7888
8073
|
* revert to the libp2p-reported addresses (the default). The host calls this
|
|
@@ -7959,12 +8144,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7959
8144
|
* never reach here: {@link ControlDatabase.queryCadrePeers} excludes them, so a
|
|
7960
8145
|
* revoked peer is not addressable either.
|
|
7961
8146
|
*
|
|
7962
|
-
* NOTE: answers the whole `CadrePeerRow`, past the
|
|
8147
|
+
* NOTE: answers the whole `CadrePeerRow`, past the fields declared; the integration
|
|
7963
8148
|
* harness (`owner-cli-node.ts`, `OwnerCliMemberRow.sig`) reads `sig` off it through the admin
|
|
7964
8149
|
* channel to tell a row another member seated from one the subject published itself. Narrow
|
|
7965
8150
|
* this to the declared fields only together with that reader.
|
|
7966
8151
|
*/
|
|
7967
|
-
async listMembers(): Promise<
|
|
8152
|
+
async listMembers(): Promise<CohortPeerRow[]> {
|
|
7968
8153
|
if (!this.controlDatabase) {
|
|
7969
8154
|
throw new Error('CadreNode must be started before listing members');
|
|
7970
8155
|
}
|
|
@@ -8064,7 +8249,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8064
8249
|
|
|
8065
8250
|
/** The invitation chain, loaded only when some row in `rows` is invitation-admitted. */
|
|
8066
8251
|
private async chainFor(rows: readonly CadrePeerRow[], loadChain: InvitationChainSource): Promise<InvitationChain | null> {
|
|
8067
|
-
return rows.some(
|
|
8252
|
+
return rows.some(isConsentSeated) ? await loadChain() : null;
|
|
8068
8253
|
}
|
|
8069
8254
|
|
|
8070
8255
|
/**
|
|
@@ -8097,7 +8282,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8097
8282
|
* one an owner re-vouch leaves behind.
|
|
8098
8283
|
*/
|
|
8099
8284
|
private hasAnchoredProof(row: CadrePeerVoucherFields, chain: InvitationChain | null): boolean {
|
|
8100
|
-
if (!
|
|
8285
|
+
if (!isConsentSeated(row)) {
|
|
8101
8286
|
return this.hasAnchoredVoucher(row);
|
|
8102
8287
|
}
|
|
8103
8288
|
const usage = chain?.usages.get(row.vouchUsage!);
|
|
@@ -8137,17 +8322,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8137
8322
|
* NOTE: cost per membership refresh is one `OwnerKey` read and one `Revocation` read (the
|
|
8138
8323
|
* tombstone list, whose `OwnerKey` stamps are handed to `queryOwnerKeyRows` so it does not
|
|
8139
8324
|
* read the retired set again, and which the hang-up of removed peers shares through
|
|
8140
|
-
* `loadTombstones`), plus the
|
|
8141
|
-
*
|
|
8142
|
-
*
|
|
8143
|
-
* marker is filed that one read is a cohort consult per refresh, which
|
|
8144
|
-
* `control-founding-consult-budget.spec.ts` pins.
|
|
8325
|
+
* `loadTombstones`), plus the owner-grant chain when some owner row is grant-seated (the
|
|
8326
|
+
* NOTE in the body). Before the Revocation ledger marker is filed that one read is a cohort
|
|
8327
|
+
* consult per refresh, which `control-founding-consult-budget.spec.ts` pins.
|
|
8145
8328
|
*
|
|
8146
8329
|
* @param retry - forwarded to every read; `false` on the refresh path, for the reason on
|
|
8147
8330
|
* {@link refreshAuthorizedControlPeers}.
|
|
8148
8331
|
* @param loadTombstones - the refresh's shared tombstone read ({@link revocationSource}).
|
|
8149
8332
|
*/
|
|
8150
|
-
private async syncOwnerAnchor(reason: string, retry: boolean,
|
|
8333
|
+
private async syncOwnerAnchor(reason: string, retry: boolean, loadTombstones: RevocationSource): Promise<void> {
|
|
8151
8334
|
const store = this.trustedOwnerStore;
|
|
8152
8335
|
const controlDatabase = this.controlDatabase;
|
|
8153
8336
|
if (!store || !controlDatabase) {
|
|
@@ -8162,8 +8345,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8162
8345
|
const tombstones = await loadTombstones();
|
|
8163
8346
|
const retired = new Set(tombstones.filter(tombstone => tombstone.tableName === 'OwnerKey').map(tombstone => tombstone.stampId));
|
|
8164
8347
|
const rows = await controlDatabase.queryOwnerKeyRows(retry, retired);
|
|
8165
|
-
|
|
8166
|
-
|
|
8348
|
+
// NOTE: the grant chain is two whole-table reads per membership refresh while any owner
|
|
8349
|
+
// row is grant-seated, over tables that are append-only; the memo suggested on
|
|
8350
|
+
// loadInvitationChain would cover it too.
|
|
8351
|
+
const grantChain = rows.some(isConsentSeated) ? await this.loadOwnerGrantChain(controlDatabase, retry) : null;
|
|
8352
|
+
const derivation = deriveOwnerAnchor({ partyId: this.partyId, anchor: before, rows, tombstones, grantChain });
|
|
8167
8353
|
if (derivation.refusedRemovals.length > 0) {
|
|
8168
8354
|
log('syncOwnerAnchor(%s): refused %d owner removal(s) that would empty the anchor: %o', reason, derivation.refusedRemovals.length, derivation.refusedRemovals);
|
|
8169
8355
|
}
|
|
@@ -8225,9 +8411,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8225
8411
|
}
|
|
8226
8412
|
|
|
8227
8413
|
/**
|
|
8228
|
-
* The usage and invitation rows {@link hasAnchoredProof}
|
|
8229
|
-
*
|
|
8230
|
-
*
|
|
8414
|
+
* The usage and invitation rows {@link hasAnchoredProof} resolves an invitation-admitted
|
|
8415
|
+
* `CadrePeer` row through, keyed for lookup. Both reads honour `retry` for
|
|
8416
|
+
* {@link listAuthorizedMembers}' reason.
|
|
8231
8417
|
*
|
|
8232
8418
|
* NOTE: once invitations are the only admission route (ticket
|
|
8233
8419
|
* `cadre-invitations-redeemable-by-any-member`), every cadre with a member not yet
|
|
@@ -8248,6 +8434,22 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8248
8434
|
};
|
|
8249
8435
|
}
|
|
8250
8436
|
|
|
8437
|
+
/**
|
|
8438
|
+
* The usage and grant rows {@link syncOwnerAnchor} resolves a grant-seated `OwnerKey` row
|
|
8439
|
+
* through, keyed for lookup: the owner-grant twin of {@link loadInvitationChain}, read only by
|
|
8440
|
+
* the anchor sync, since a grant admits no machine.
|
|
8441
|
+
*/
|
|
8442
|
+
private async loadOwnerGrantChain(controlDatabase: ControlDatabase, retry: boolean): Promise<OwnerGrantChain> {
|
|
8443
|
+
const [usages, grants] = await Promise.all([
|
|
8444
|
+
controlDatabase.queryOwnerGrantUsages(retry),
|
|
8445
|
+
controlDatabase.queryOwnerGrants(retry),
|
|
8446
|
+
]);
|
|
8447
|
+
return {
|
|
8448
|
+
usages: new Map(usages.map(usage => [usage.usageStampId, usage])),
|
|
8449
|
+
grants: new Map(grants.map(grant => [grant.key, grant])),
|
|
8450
|
+
};
|
|
8451
|
+
}
|
|
8452
|
+
|
|
8251
8453
|
/**
|
|
8252
8454
|
* The owner-voucher half of {@link hasAnchoredProof}: complete voucher, `VouchOwner` in
|
|
8253
8455
|
* the node-local anchor, signature valid over the row's (PeerId, StampId) voucher digest.
|
|
@@ -8377,11 +8579,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8377
8579
|
|
|
8378
8580
|
/**
|
|
8379
8581
|
* The event callbacks every {@link SeedBootstrapService} this node owns is
|
|
8380
|
-
* wired with — shared by the owner-capable service ({@link
|
|
8582
|
+
* wired with — shared by the owner-capable service ({@link useOwnerSigner})
|
|
8381
8583
|
* and the listener-only one ({@link enableSeedListener}) so the two cannot drift.
|
|
8382
8584
|
*
|
|
8383
8585
|
* A seed applied by the INBOUND protocol handler writes no `CadrePeer` row of
|
|
8384
|
-
* its own (it merges the libp2p peer store and dials
|
|
8586
|
+
* its own (it merges the libp2p peer store and dials the seed's preferred peers), so the automatic
|
|
8385
8587
|
* write-driven refresh never fires for it — yet applying it can ANCHOR a new
|
|
8386
8588
|
* owner key, which flips rows already present from unauthorized to authorized.
|
|
8387
8589
|
* Hence the explicit refresh here; without it a peer the freshly anchored owner
|
|
@@ -8393,7 +8595,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8393
8595
|
onSeedApplied: (partyId, peersAdded, seed) => {
|
|
8394
8596
|
this.emit('seed:applied', { partyId, peersAdded });
|
|
8395
8597
|
// A wire-delivered seed never passes through the `applySeed` wrapper, so
|
|
8396
|
-
// this is where its
|
|
8598
|
+
// this is where its preferred peers become cold-start bootstrap targets.
|
|
8397
8599
|
this.recordSeedBootstrapPeers(seed);
|
|
8398
8600
|
void this.refreshMembershipGate('seed-applied');
|
|
8399
8601
|
},
|
|
@@ -8532,12 +8734,17 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8532
8734
|
* @param multiaddrs - Optional multiaddrs for the peer
|
|
8533
8735
|
*/
|
|
8534
8736
|
async authorizePeer(peerId: string, multiaddrs?: string[]): Promise<void> {
|
|
8737
|
+
await this.vouchPeer(peerId, multiaddrs);
|
|
8738
|
+
}
|
|
8739
|
+
|
|
8740
|
+
/** {@link authorizePeer}'s body, signing inside the caller's owner approval when it passes `sign` ({@link claimNode}). */
|
|
8741
|
+
private async vouchPeer(peerId: string, multiaddrs: string[] | undefined, sign?: OwnerSign): Promise<void> {
|
|
8535
8742
|
if (!this.seedBootstrapService) {
|
|
8536
8743
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8537
8744
|
}
|
|
8538
8745
|
// The insert notifies the control DB's membership hub, so the per-stream gate
|
|
8539
8746
|
// has already admitted the just-vouched peer by the time this resolves.
|
|
8540
|
-
await this.seedBootstrapService.authorizePeer({ peerId, multiaddrs });
|
|
8747
|
+
await this.seedBootstrapService.authorizePeer({ peerId, multiaddrs }, sign);
|
|
8541
8748
|
// Queue for re-replication if this committed local-only (no connected cohort).
|
|
8542
8749
|
this.noteControlWrite(peerId, 'authorize');
|
|
8543
8750
|
// NOTE: unlike addDrone, `multiaddrs` is not retained as a dial target: the
|
|
@@ -8550,9 +8757,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8550
8757
|
* Remove a previously-authorized peer from the cadre.
|
|
8551
8758
|
* Signs the peer ID with the owner key and deletes the CadrePeer row.
|
|
8552
8759
|
*
|
|
8553
|
-
* Removes the `CadrePeer` row only. The call that removes a machine completely (its
|
|
8554
|
-
*
|
|
8555
|
-
*
|
|
8760
|
+
* Removes the `CadrePeer` row only. The call that removes a machine completely (its push
|
|
8761
|
+
* token and the invitations it could come back through too, in one transaction) is
|
|
8762
|
+
* {@link removeMachine}.
|
|
8556
8763
|
*
|
|
8557
8764
|
* @param peerId - The peer ID to remove
|
|
8558
8765
|
*/
|
|
@@ -8579,89 +8786,241 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8579
8786
|
* `OwnerKey.Authorized`; the row stores the vouching pair). The write notifies the
|
|
8580
8787
|
* membership hub, so the refresh it triggers has run by the time this resolves.
|
|
8581
8788
|
*
|
|
8789
|
+
* Refused while alone ({@link ownerKeyWriteAlone}): an `OwnerKey` add has no re-issue path,
|
|
8790
|
+
* so one committed with no control connection would stay on this node.
|
|
8791
|
+
*
|
|
8582
8792
|
* @param ownerPublicKeyB64 - the new owner's ed25519 public key (base64url).
|
|
8793
|
+
* @param options.label - the user's name for the key ("phone", "paper backup"), bound into
|
|
8794
|
+
* the signature; trimmed, at most 64 characters, blank meaning none.
|
|
8583
8795
|
* @returns `true` when the row was seated, `false` when the key is already a live owner.
|
|
8796
|
+
* @throws OwnerKeyAddRefusedError `alone`, before any write.
|
|
8584
8797
|
*/
|
|
8585
|
-
async addOwner(ownerPublicKeyB64: string): Promise<boolean> {
|
|
8798
|
+
async addOwner(ownerPublicKeyB64: string, options: { label?: string } = {}): Promise<boolean> {
|
|
8586
8799
|
if (!this.seedBootstrapService) {
|
|
8587
8800
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8588
8801
|
}
|
|
8589
8802
|
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8803
|
+
if (await this.ownerKeyWriteAlone()) {
|
|
8804
|
+
throw new OwnerKeyAddRefusedError(key, 'alone');
|
|
8805
|
+
}
|
|
8806
|
+
const added = await this.seedBootstrapService.addOwner(key, options.label);
|
|
8807
|
+
// NOTE: the refusal is checked before the approval, and a connection can drop while the
|
|
8808
|
+
// user approves; an add that still commits alone is logged. If that window ever loses an
|
|
8809
|
+
// owner key in practice, re-check inside the write and roll back.
|
|
8594
8810
|
if (added && this.committedAlone()) {
|
|
8595
|
-
log('addOwner(%s) committed while ALONE (
|
|
8596
|
-
'local-only and is not re-issued on cohort growth
|
|
8811
|
+
log('addOwner(%s) committed while ALONE (the last control connection dropped during the add): ' +
|
|
8812
|
+
'the new owner row is local-only and is not re-issued on cohort growth.', key);
|
|
8597
8813
|
}
|
|
8598
8814
|
return added;
|
|
8599
8815
|
}
|
|
8600
8816
|
|
|
8601
8817
|
/**
|
|
8602
|
-
*
|
|
8603
|
-
*
|
|
8604
|
-
*
|
|
8605
|
-
*
|
|
8606
|
-
*
|
|
8818
|
+
* Whether an `OwnerKey` add made now ({@link addOwner}, or the re-add of
|
|
8819
|
+
* {@link removeOwner}'s re-vouch) is refused `alone`: this node holds no control connection
|
|
8820
|
+
* while the party has other members. An `OwnerKey` row has no update to re-touch, so nothing
|
|
8821
|
+
* re-issues it on cohort growth, and a row written now would fork this node's history. A
|
|
8822
|
+
* party with no other members cannot fork, so a solo founder adding an owner key is not
|
|
8823
|
+
* refused.
|
|
8824
|
+
*
|
|
8825
|
+
* Waits out an in-flight membership refresh first: until `start`'s first one lands the
|
|
8826
|
+
* member snapshot is empty, which would read as a party with no other members.
|
|
8827
|
+
*/
|
|
8828
|
+
private async ownerKeyWriteAlone(): Promise<boolean> {
|
|
8829
|
+
await this.membershipGateDrain;
|
|
8830
|
+
return this.committedAlone() && this.authorizedControlPeers.size > 0;
|
|
8831
|
+
}
|
|
8832
|
+
|
|
8833
|
+
/**
|
|
8834
|
+
* Every live owner key with its label, who vouched it, whether this node's owner signer
|
|
8835
|
+
* holds it and whether this node's trusted-owner anchor trusts it ({@link CadreOwnerKey}),
|
|
8836
|
+
* for an app's owner-key screen.
|
|
8837
|
+
*/
|
|
8838
|
+
async listOwnerKeys(): Promise<CadreOwnerKey[]> {
|
|
8839
|
+
if (!this.controlDatabase) {
|
|
8840
|
+
throw new Error('CadreNode must be started before listing owner keys');
|
|
8841
|
+
}
|
|
8842
|
+
const rows = await this.controlDatabase.queryOwnerKeyRows();
|
|
8843
|
+
return describeOwnerKeys(rows, this.getOwnerSigner()?.publicKey ?? null, this.trustedOwnerStore?.all() ?? new Set());
|
|
8844
|
+
}
|
|
8845
|
+
|
|
8846
|
+
/**
|
|
8847
|
+
* What {@link removeOwner} would do for `ownerPublicKeyB64`, read without writing, for an
|
|
8848
|
+
* app's confirmation step ({@link OwnerKeyRemovalPlan}): whether this node may remove it,
|
|
8849
|
+
* which rows the key vouched (so the app can offer to keep them by re-vouching), and how this
|
|
8850
|
+
* node's own rows depend on it (`planOwnerKeyRemoval` in `owner-keys.ts`).
|
|
8851
|
+
*/
|
|
8852
|
+
async planOwnerKeyRemoval(ownerPublicKeyB64: string): Promise<OwnerKeyRemovalPlan> {
|
|
8853
|
+
if (!this.controlDatabase) {
|
|
8854
|
+
throw new Error('CadreNode must be started before planning an owner key removal');
|
|
8855
|
+
}
|
|
8856
|
+
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
8857
|
+
return (await this.planOwnerKeyRemovalWith(this.controlDatabase, key)).plan;
|
|
8858
|
+
}
|
|
8859
|
+
|
|
8860
|
+
/**
|
|
8861
|
+
* Remove an owner key, signed by this node's owner key (which must be a different owner): the
|
|
8862
|
+
* `OwnerKey` row and its `Revocation` tombstone commit together, and the tombstone rides the
|
|
8863
|
+
* committed-delete seam ({@link noteGuardedDelete}), so one committed while alone is re-issued
|
|
8864
|
+
* on cohort growth like every other removal. The machines the key was used on stay members;
|
|
8865
|
+
* removing one is {@link removeMachine}. The owner grants the key issued stop being redeemable
|
|
8866
|
+
* (the plan's `grants` names the live ones) and are not withdrawn: a tombstone over a grant
|
|
8867
|
+
* nobody can redeem would add nothing.
|
|
8607
8868
|
*
|
|
8608
|
-
*
|
|
8609
|
-
*
|
|
8869
|
+
* Steps: re-plan ({@link planOwnerKeyRemoval}) and throw {@link OwnerKeyRemovalRefusedError}
|
|
8870
|
+
* on a refusal; check `options.revouch` against the plan's dependents and throw before any
|
|
8871
|
+
* write when it names anything else; refuse `alone` ({@link ownerKeyWriteAlone}) when an
|
|
8872
|
+
* `OwnerKey` row is to be re-vouched, since that re-add has no re-issue path; **re-vouch
|
|
8873
|
+
* first**, under this node's key, the dependents the caller chose to keep and this node's own
|
|
8874
|
+
* row when the key vouched it (`ControlDatabase.revouchOwnerKey`, `reauthorizePeer`), so they
|
|
8875
|
+
* are never untrusted in between, and stop before the removal if one fails (re-vouches
|
|
8876
|
+
* already made stay, harmlessly); then the removal. The re-vouches and the removal are one owner approval. The
|
|
8877
|
+
* write notifies the membership hub, so the refresh it triggers has run by the time this
|
|
8878
|
+
* resolves.
|
|
8610
8879
|
*
|
|
8611
8880
|
* @param ownerPublicKeyB64 - the owner's ed25519 public key (base64url).
|
|
8612
|
-
* @returns
|
|
8881
|
+
* @returns what was written ({@link OwnerKeyRemovalResult}).
|
|
8882
|
+
* @throws {OwnerKeyRemovalRefusedError} `'not-an-owner'`, `'unknown'`, `'last-owner'`,
|
|
8883
|
+
* `'self'` or `'alone'`, before any write.
|
|
8613
8884
|
*/
|
|
8614
|
-
async removeOwner(ownerPublicKeyB64: string): Promise<
|
|
8615
|
-
|
|
8885
|
+
async removeOwner(ownerPublicKeyB64: string, options: RemoveOwnerOptions = {}): Promise<OwnerKeyRemovalResult> {
|
|
8886
|
+
const service = this.seedBootstrapService;
|
|
8887
|
+
if (!service || !this.controlDatabase) {
|
|
8616
8888
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8617
8889
|
}
|
|
8618
8890
|
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
8619
|
-
|
|
8891
|
+
const { plan, rows } = await this.planOwnerKeyRemovalWith(this.controlDatabase, key);
|
|
8892
|
+
if (plan.refusal !== null) {
|
|
8893
|
+
throw new OwnerKeyRemovalRefusedError(key, plan.refusal);
|
|
8894
|
+
}
|
|
8895
|
+
const revouch = revouchWithinPlan(plan, options.revouch, this.peerId?.toString() ?? null);
|
|
8896
|
+
if (revouch.owners.length > 0 && await this.ownerKeyWriteAlone()) {
|
|
8897
|
+
throw new OwnerKeyRemovalRefusedError(key, 'alone');
|
|
8898
|
+
}
|
|
8899
|
+
// The plan already refused 'not-an-owner' when no enrolled signer is wired.
|
|
8900
|
+
const signer = service.ownerSigner;
|
|
8901
|
+
if (!signer) {
|
|
8902
|
+
throw new OwnerKeyRemovalRefusedError(key, 'not-an-owner');
|
|
8903
|
+
}
|
|
8904
|
+
const action = `Remove the owner key ${namedOwnerKey(key, plan.ownerKey?.label ?? null)} from the cadre`;
|
|
8905
|
+
const removed = await signer.approve({ partyId: this.partyId, action }, async (sign) => {
|
|
8906
|
+
await this.revouchDependents(service, revouch, rows, sign);
|
|
8907
|
+
return await service.removeOwner(key, sign);
|
|
8908
|
+
});
|
|
8909
|
+
log('removeOwner(%s): removed=%s revouched owners=%d peers=%d', key, removed, revouch.owners.length, revouch.peers.length);
|
|
8910
|
+
return { removed, revouched: revouch };
|
|
8911
|
+
}
|
|
8912
|
+
|
|
8913
|
+
/** {@link planOwnerKeyRemoval} with the rows it was judged from, which {@link removeOwner} re-vouches dependents by. */
|
|
8914
|
+
private async planOwnerKeyRemovalWith(controlDatabase: ControlDatabase, key: string): Promise<{ plan: OwnerKeyRemovalPlan; rows: CadrePeerRow[] }> {
|
|
8915
|
+
const [rows, owners, grants] = await Promise.all([
|
|
8916
|
+
controlDatabase.queryCadrePeers(), controlDatabase.queryOwnerKeyRows(), controlDatabase.listOwnerGrantStatuses()
|
|
8917
|
+
]);
|
|
8918
|
+
const plan = planOwnerKeyRemoval({
|
|
8919
|
+
key,
|
|
8920
|
+
rows,
|
|
8921
|
+
owners,
|
|
8922
|
+
grants,
|
|
8923
|
+
selfPeerId: this.peerId?.toString() ?? null,
|
|
8924
|
+
selfOwnerKey: this.ownerSignerEnrolledIn(new Set(owners.map((owner) => owner.key)))?.publicKey ?? null,
|
|
8925
|
+
anchor: this.trustedOwnerStore?.all() ?? new Set(),
|
|
8926
|
+
});
|
|
8927
|
+
return { plan, rows };
|
|
8928
|
+
}
|
|
8929
|
+
|
|
8930
|
+
/**
|
|
8931
|
+
* The re-vouch step of {@link removeOwner}: every chosen dependent row is rebound to this
|
|
8932
|
+
* node's key BEFORE the removal, so no reader ever judges it against an owner that is gone.
|
|
8933
|
+
* Owners through `ControlDatabase.revouchOwnerKey` (a delete-and-re-add under a fresh stamp,
|
|
8934
|
+
* the only rebinding `OwnerKey.NoUpdate` allows); peers through the owner re-touch
|
|
8935
|
+
* (`reauthorizePeer`), which rewrites `VouchOwner`/`VouchSig` and clears `VouchUsage`.
|
|
8936
|
+
*
|
|
8937
|
+
* The peer re-touch keeps the row's `UpdatedAt` where it is. The owner branch of
|
|
8938
|
+
* `CadrePeer.AuthorizedUpdate` does not require a bump, and bumping a self-published row
|
|
8939
|
+
* would leave its `Sig` unverifiable, so unresolvable and unlisted as self-published, until
|
|
8940
|
+
* the machine's next heartbeat (the NOTE on that constraint); the write-while-alone drain
|
|
8941
|
+
* bumps because it re-issues an otherwise unchanged row, whereas here the voucher itself
|
|
8942
|
+
* changes. A row never self-published carries no stamp to keep and gets the clock.
|
|
8943
|
+
*
|
|
8944
|
+
* A failure rethrows and stops the call before the removal; re-vouches already made stay
|
|
8945
|
+
* (the row is only re-signed by a current owner) and the caller retries. An owner re-vouch
|
|
8946
|
+
* is refused alone before this runs; one whose last connection drops while it runs is logged,
|
|
8947
|
+
* as {@link addOwner}'s add is.
|
|
8948
|
+
*
|
|
8949
|
+
* NOTE: a peer re-touch committed alone is not queued, and the drain skips self-published
|
|
8950
|
+
* rows anyway, so the other machines may get the removal's tombstone without the new voucher
|
|
8951
|
+
* (`tickets/backlog/bug-owner-key-removal-revouch-made-alone-is-not-reissued`).
|
|
8952
|
+
*/
|
|
8953
|
+
private async revouchDependents(
|
|
8954
|
+
service: SeedBootstrapService,
|
|
8955
|
+
revouch: OwnerKeyDependents,
|
|
8956
|
+
rows: readonly CadrePeerRow[],
|
|
8957
|
+
sign: OwnerSign,
|
|
8958
|
+
): Promise<void> {
|
|
8959
|
+
for (const key of revouch.owners) {
|
|
8960
|
+
await service.revouchOwner(key, sign);
|
|
8961
|
+
if (this.committedAlone()) {
|
|
8962
|
+
log('removeOwner: re-vouch of owner %s committed while ALONE (the last control connection dropped during the call): ' +
|
|
8963
|
+
'the re-added owner row is local-only and is not re-issued on cohort growth.', key);
|
|
8964
|
+
}
|
|
8965
|
+
}
|
|
8966
|
+
for (const dependent of revouch.peers) {
|
|
8967
|
+
const row = rows.find((candidate) => candidate.peerId === dependent);
|
|
8968
|
+
const updatedAt = row !== undefined && row.updatedAt > 0 ? row.updatedAt : Date.now();
|
|
8969
|
+
await service.reauthorizePeer(dependent, updatedAt, sign);
|
|
8970
|
+
}
|
|
8620
8971
|
}
|
|
8621
8972
|
|
|
8622
8973
|
/**
|
|
8623
8974
|
* The cadre's machines as an app lists them ({@link CadreMachine}): one entry per live
|
|
8624
|
-
* `CadrePeer` row, judged by this node's membership predicate
|
|
8625
|
-
*
|
|
8975
|
+
* `CadrePeer` row, judged by this node's membership predicate (`describeMachines` in
|
|
8976
|
+
* `machine-removal.ts`). Owner keys are not machines; {@link listOwnerKeys} lists them.
|
|
8626
8977
|
* Unlocked reads with the default retry.
|
|
8627
8978
|
*/
|
|
8628
8979
|
async listMachines(): Promise<CadreMachine[]> {
|
|
8629
8980
|
if (!this.controlDatabase) {
|
|
8630
8981
|
throw new Error('CadreNode must be started before listing machines');
|
|
8631
8982
|
}
|
|
8632
|
-
return
|
|
8983
|
+
return await this.readMachines(this.controlDatabase);
|
|
8633
8984
|
}
|
|
8634
8985
|
|
|
8635
8986
|
/**
|
|
8636
8987
|
* What {@link removeMachine} would do for `peerId`, read without writing, for an app's
|
|
8637
|
-
* confirmation step ({@link MachineRemovalPlan}): whether this node may remove it
|
|
8638
|
-
*
|
|
8639
|
-
* re-vouching), which invitations it could come back through, and whether this node's own
|
|
8640
|
-
* rows depend on it (`planMachineRemoval` in `machine-removal.ts`).
|
|
8988
|
+
* confirmation step ({@link MachineRemovalPlan}): whether this node may remove it and which
|
|
8989
|
+
* invitations it could come back through (`planMachineRemoval` in `machine-removal.ts`).
|
|
8641
8990
|
*/
|
|
8642
8991
|
async planMachineRemoval(peerId: string): Promise<MachineRemovalPlan> {
|
|
8643
8992
|
if (!this.controlDatabase) {
|
|
8644
8993
|
throw new Error('CadreNode must be started before planning a machine removal');
|
|
8645
8994
|
}
|
|
8646
|
-
|
|
8995
|
+
const machines = await this.readMachines(this.controlDatabase);
|
|
8996
|
+
const [invites, usages, owners] = await Promise.all([
|
|
8997
|
+
this.controlDatabase.listCadreInviteStatuses(),
|
|
8998
|
+
this.controlDatabase.queryCadreInviteUsages(),
|
|
8999
|
+
this.controlDatabase.getOwnerKeys(),
|
|
9000
|
+
]);
|
|
9001
|
+
return planMachineRemoval({
|
|
9002
|
+
peerId,
|
|
9003
|
+
machines,
|
|
9004
|
+
selfPeerId: this.peerId?.toString() ?? null,
|
|
9005
|
+
canSign: this.ownerSignerEnrolledIn(owners) !== null,
|
|
9006
|
+
invites,
|
|
9007
|
+
usages,
|
|
9008
|
+
});
|
|
8647
9009
|
}
|
|
8648
9010
|
|
|
8649
9011
|
/**
|
|
8650
9012
|
* Remove a machine from the cadre completely, signed by this node's owner key: its
|
|
8651
|
-
* `CadrePeer` row, its `
|
|
8652
|
-
*
|
|
8653
|
-
*
|
|
8654
|
-
*
|
|
8655
|
-
*
|
|
8656
|
-
*
|
|
8657
|
-
*
|
|
8658
|
-
*
|
|
8659
|
-
*
|
|
8660
|
-
*
|
|
8661
|
-
*
|
|
8662
|
-
* made stay, harmlessly); then the removal transaction. Afterwards this node forgets the
|
|
8663
|
-
* machine as a dial target, and the refresh the write notifies hangs up its control
|
|
8664
|
-
* connection ({@link hangUpRevokedControlPeers}) before this resolves.
|
|
9013
|
+
* `CadrePeer` row, its `DeviceToken` row (a removed phone must not be push-woken) and a
|
|
9014
|
+
* withdrawal of every live invitation it could redeem to come back, in ONE transaction
|
|
9015
|
+
* ({@link ControlDatabase.removeMachine}), with a `Revocation` tombstone per row so the
|
|
9016
|
+
* removal holds on every machine ("Deletes made while alone" in docs/architecture.md), under
|
|
9017
|
+
* one owner approval. Afterwards this node forgets the machine as a dial target, and the
|
|
9018
|
+
* refresh the write notifies hangs up its control connection ({@link hangUpRevokedControlPeers})
|
|
9019
|
+
* before this resolves.
|
|
9020
|
+
*
|
|
9021
|
+
* Owner keys stay: they are the person's, not the machine's. A lost phone that held one can
|
|
9022
|
+
* still sign, a fresh machine invitation it could redeem included, so removing that key
|
|
9023
|
+
* ({@link removeOwner}) is the step that locks it out.
|
|
8665
9024
|
*
|
|
8666
9025
|
* Not undone by this call: the machine keeps every row and strand block it already synced
|
|
8667
9026
|
* and the party's strand member keys (`tickets/blocked/decide-removed-machine-strand-access`).
|
|
@@ -8669,28 +9028,22 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8669
9028
|
*
|
|
8670
9029
|
* @returns what was written ({@link MachineRemovalResult}); all false when no live row names
|
|
8671
9030
|
* the machine here (already removed elsewhere).
|
|
8672
|
-
* @throws {MachineRemovalRefusedError} `'not-an-owner'
|
|
9031
|
+
* @throws {MachineRemovalRefusedError} `'not-an-owner'` or `'self'`, before any write.
|
|
8673
9032
|
*/
|
|
8674
|
-
async removeMachine(peerId: string
|
|
9033
|
+
async removeMachine(peerId: string): Promise<MachineRemovalResult> {
|
|
8675
9034
|
const service = this.seedBootstrapService;
|
|
8676
9035
|
if (!service || !this.controlDatabase) {
|
|
8677
9036
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8678
9037
|
}
|
|
8679
|
-
const
|
|
9038
|
+
const plan = await this.planMachineRemoval(peerId);
|
|
8680
9039
|
if (plan.refusal !== null) {
|
|
8681
9040
|
throw new MachineRemovalRefusedError(peerId, plan.refusal);
|
|
8682
9041
|
}
|
|
8683
|
-
const revouch = revouchWithinPlan(plan, options.revouch);
|
|
8684
9042
|
if (plan.machine === null) {
|
|
8685
9043
|
log('removeMachine(%s): no live row names it here; nothing to remove', peerId);
|
|
8686
|
-
return { removedPeer: false,
|
|
9044
|
+
return { removedPeer: false, removedDeviceToken: false, withdrawnInvitations: [] };
|
|
8687
9045
|
}
|
|
8688
|
-
await
|
|
8689
|
-
const writes = await service.removeMachine({
|
|
8690
|
-
peerId,
|
|
8691
|
-
ownerKey: plan.removesOwnerKey ? plan.machine.publicKey : null,
|
|
8692
|
-
inviteKeys: plan.invitations,
|
|
8693
|
-
});
|
|
9046
|
+
const writes = await service.removeMachine({ peerId, inviteKeys: plan.invitations });
|
|
8694
9047
|
if (writes.removedPeer) {
|
|
8695
9048
|
// Track + loudly flag a delete that committed local-only (security-relevant).
|
|
8696
9049
|
this.noteControlWrite(peerId, 'remove');
|
|
@@ -8700,84 +9053,25 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8700
9053
|
void this.bootstrapPeerStore?.forget(peerId).catch((error: unknown) => {
|
|
8701
9054
|
log('removeMachine: persisting the removal of dial target %s failed (forgotten in memory): %o', peerId, error);
|
|
8702
9055
|
});
|
|
8703
|
-
log('removeMachine(%s): peer=%s
|
|
8704
|
-
writes.removedPeer, writes.
|
|
8705
|
-
|
|
8706
|
-
return { ...writes, revouched: revouch };
|
|
9056
|
+
log('removeMachine(%s): peer=%s deviceToken=%s invitations=%d', peerId,
|
|
9057
|
+
writes.removedPeer, writes.removedDeviceToken, writes.withdrawnInvitations.length);
|
|
9058
|
+
return writes;
|
|
8707
9059
|
}
|
|
8708
9060
|
|
|
8709
9061
|
/**
|
|
8710
|
-
* The
|
|
8711
|
-
*
|
|
9062
|
+
* The read behind {@link listMachines}: the live `CadrePeer` rows projected as machines, with
|
|
9063
|
+
* this node's membership predicate as the `trusted` test.
|
|
8712
9064
|
*/
|
|
8713
|
-
private async readMachines(controlDatabase: ControlDatabase): Promise<
|
|
8714
|
-
const
|
|
9065
|
+
private async readMachines(controlDatabase: ControlDatabase): Promise<CadreMachine[]> {
|
|
9066
|
+
const rows = await controlDatabase.queryCadrePeers();
|
|
8715
9067
|
const chain = await this.chainFor(rows, this.invitationChainSource(controlDatabase, true));
|
|
8716
9068
|
const selfPeerId = this.peerId?.toString() ?? null;
|
|
8717
|
-
|
|
9069
|
+
return describeMachines({
|
|
8718
9070
|
partyId: this.partyId,
|
|
8719
9071
|
selfPeerId,
|
|
8720
9072
|
rows,
|
|
8721
|
-
owners,
|
|
8722
9073
|
trusted: (row) => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain),
|
|
8723
9074
|
});
|
|
8724
|
-
return { machines, rows, owners };
|
|
8725
|
-
}
|
|
8726
|
-
|
|
8727
|
-
/** {@link planMachineRemoval} with the rows it was judged from, which {@link removeMachine} re-vouches dependents by. */
|
|
8728
|
-
private async planMachineRemovalWith(controlDatabase: ControlDatabase, peerId: string): Promise<{ plan: MachineRemovalPlan; rows: CadrePeerRow[] }> {
|
|
8729
|
-
const { machines, rows, owners } = await this.readMachines(controlDatabase);
|
|
8730
|
-
const [invites, usages] = await Promise.all([controlDatabase.listCadreInviteStatuses(), controlDatabase.queryCadreInviteUsages()]);
|
|
8731
|
-
const plan = planMachineRemoval({
|
|
8732
|
-
peerId,
|
|
8733
|
-
machines,
|
|
8734
|
-
rows,
|
|
8735
|
-
owners,
|
|
8736
|
-
selfPeerId: this.peerId?.toString() ?? null,
|
|
8737
|
-
selfOwnerKey: this.ownerSigningKeyEnrolledIn(new Set(owners.map((owner) => owner.key)))?.publicKeyB64 ?? null,
|
|
8738
|
-
invites,
|
|
8739
|
-
usages,
|
|
8740
|
-
});
|
|
8741
|
-
return { plan, rows };
|
|
8742
|
-
}
|
|
8743
|
-
|
|
8744
|
-
/**
|
|
8745
|
-
* The re-vouch step of {@link removeMachine}: every chosen dependent row is rebound to this
|
|
8746
|
-
* node's key BEFORE the removal, so no reader ever judges it against an owner that is gone.
|
|
8747
|
-
* Owners through `ControlDatabase.revouchOwnerKey` (a delete-and-re-add under a fresh stamp,
|
|
8748
|
-
* the only rebinding `OwnerKey.NoUpdate` allows); peers through the owner re-touch
|
|
8749
|
-
* (`reauthorizePeer`), which rewrites `VouchOwner`/`VouchSig` and clears `VouchUsage`.
|
|
8750
|
-
*
|
|
8751
|
-
* The peer re-touch keeps the row's `UpdatedAt` where it is. The owner branch of
|
|
8752
|
-
* `CadrePeer.AuthorizedUpdate` does not require a bump, and bumping a self-published row
|
|
8753
|
-
* would leave its `Sig` unverifiable, so unresolvable and unlisted as self-published, until
|
|
8754
|
-
* the machine's next heartbeat (the NOTE on that constraint); the write-while-alone drain
|
|
8755
|
-
* bumps because it re-issues an otherwise unchanged row, whereas here the voucher itself
|
|
8756
|
-
* changes. A row never self-published carries no stamp to keep and gets the clock.
|
|
8757
|
-
*
|
|
8758
|
-
* A failure rethrows and stops the call before the removal; re-vouches already made stay
|
|
8759
|
-
* (the row is only re-signed by a current owner) and the caller retries. An owner re-vouch
|
|
8760
|
-
* committed alone is not re-issued on growth (the add half has no re-issue path,
|
|
8761
|
-
* `tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue`), so it is logged as
|
|
8762
|
-
* {@link addOwner} logs it; the peer re-touches are re-issued by the existing drain.
|
|
8763
|
-
*/
|
|
8764
|
-
private async revouchDependents(
|
|
8765
|
-
service: SeedBootstrapService,
|
|
8766
|
-
revouch: MachineRemovalPlan['dependents'],
|
|
8767
|
-
rows: readonly CadrePeerRow[],
|
|
8768
|
-
): Promise<void> {
|
|
8769
|
-
for (const key of revouch.owners) {
|
|
8770
|
-
await service.revouchOwner(key);
|
|
8771
|
-
if (this.committedAlone()) {
|
|
8772
|
-
log('removeMachine: re-vouch of owner %s committed while ALONE (0 control connections): the re-added ' +
|
|
8773
|
-
'owner row is local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
|
|
8774
|
-
}
|
|
8775
|
-
}
|
|
8776
|
-
for (const dependent of revouch.peers) {
|
|
8777
|
-
const row = rows.find((candidate) => candidate.peerId === dependent);
|
|
8778
|
-
const updatedAt = row !== undefined && row.updatedAt > 0 ? row.updatedAt : Date.now();
|
|
8779
|
-
await service.reauthorizePeer(dependent, updatedAt);
|
|
8780
|
-
}
|
|
8781
9075
|
}
|
|
8782
9076
|
|
|
8783
9077
|
/**
|
|
@@ -8845,9 +9139,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8845
9139
|
}
|
|
8846
9140
|
|
|
8847
9141
|
/**
|
|
8848
|
-
* Post-process an {@link applySeed} outcome: retain the seed's
|
|
9142
|
+
* Post-process an {@link applySeed} outcome: retain the seed's preferred peers as
|
|
8849
9143
|
* cold-start bootstrap targets, and surface a seed that was accepted but whose
|
|
8850
|
-
* every
|
|
9144
|
+
* every dial failed — the node is now seeded yet unconnected, and the
|
|
8851
9145
|
* cold-start reconcile branch is what gets it out of that state.
|
|
8852
9146
|
*/
|
|
8853
9147
|
private noteAppliedSeed(result: ApplySeedResult, seed: ControlNetworkSeed): void {
|
|
@@ -8855,9 +9149,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8855
9149
|
return;
|
|
8856
9150
|
}
|
|
8857
9151
|
this.recordSeedBootstrapPeers(seed);
|
|
8858
|
-
if (result.
|
|
8859
|
-
log('applySeed: seeded but no
|
|
8860
|
-
result.
|
|
9152
|
+
if (result.preferredDialsAttempted > 0 && result.preferredDialsFailed === result.preferredDialsAttempted) {
|
|
9153
|
+
log('applySeed: seeded but no preferred peer reachable (%d/%d dial(s) failed) — cold-start reconcile will retry',
|
|
9154
|
+
result.preferredDialsFailed, result.preferredDialsAttempted);
|
|
8861
9155
|
}
|
|
8862
9156
|
}
|
|
8863
9157
|
|
|
@@ -8958,9 +9252,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8958
9252
|
* vouch the node into this cadre and keep its addresses as a dial target. The owner
|
|
8959
9253
|
* side of the claim-secret entry in the Seed Delivery Protocol (`docs/architecture.md`).
|
|
8960
9254
|
*
|
|
8961
|
-
* Requires the seed service with an owner key ({@link
|
|
8962
|
-
* {@link addDrone} does
|
|
8963
|
-
*
|
|
9255
|
+
* Requires the seed service with an owner key ({@link useOwnerSigner}), as
|
|
9256
|
+
* {@link addDrone} does, and like it is one owner approval (the seed and the vouch). The
|
|
9257
|
+
* order is the REVERSE of `addDrone`'s: seed first, row second. A refused claim or a
|
|
9258
|
+
* failed delivery therefore leaves no `CadrePeer` row and
|
|
8964
9259
|
* no retained dial target behind on this node — a phone that typed the wrong secret,
|
|
8965
9260
|
* or scanned a node someone else already owns, has nothing to clean up.
|
|
8966
9261
|
*
|
|
@@ -8984,28 +9279,35 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8984
9279
|
* timed reconcile pass; a caller that wants to wait for the connection awaits
|
|
8985
9280
|
* {@link reconcileControlCohort} itself, with `addDrone`'s caveat that a pass already
|
|
8986
9281
|
* in flight is joined rather than restarted. The claimed node dials this one back too,
|
|
8987
|
-
*
|
|
9282
|
+
* as the seed's minter, when this node has a listen or relay address; a phone
|
|
8988
9283
|
* with neither is reached by its own reconcile pass alone.
|
|
8989
9284
|
*
|
|
8990
9285
|
* @throws {ClaimRefusedError} when the node was reached and refused the claim.
|
|
8991
9286
|
* @throws {PeerUnreachableError} when the node was never reached.
|
|
8992
9287
|
*/
|
|
8993
9288
|
async claimNode(target: { peerId: string; multiaddrs: string[]; secret: string }): Promise<void> {
|
|
8994
|
-
|
|
9289
|
+
const service = this.seedBootstrapService;
|
|
9290
|
+
if (!service) {
|
|
8995
9291
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8996
9292
|
}
|
|
8997
9293
|
const secret = parseClaimSecret(target.secret);
|
|
8998
|
-
const
|
|
8999
|
-
|
|
9000
|
-
|
|
9001
|
-
|
|
9002
|
-
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
|
|
9294
|
+
const signer = service.ownerSigner;
|
|
9295
|
+
if (!signer) {
|
|
9296
|
+
throw new Error('Owner key required to create seeds');
|
|
9297
|
+
}
|
|
9298
|
+
await signer.approve({ partyId: this.partyId, action: `Claim the node ${target.peerId} for the cadre` }, async (sign) => {
|
|
9299
|
+
const seed = await service.createSeed(sign);
|
|
9300
|
+
const proof = claimProof(secret, target.peerId, seed.signerKey, seedDigest(seed));
|
|
9301
|
+
const ack = await service.deliverSeed(
|
|
9302
|
+
{ peerId: target.peerId, multiaddrs: target.multiaddrs }, seed, { claimProof: proof });
|
|
9303
|
+
if (!ack.accepted) {
|
|
9304
|
+
throw new ClaimRefusedError(target.peerId, ack.reason ?? 'no reason given', ack.code);
|
|
9305
|
+
}
|
|
9306
|
+
log('claimNode: %s accepted the claim; vouching it into the cadre', target.peerId);
|
|
9307
|
+
// The wrapper queues the write for re-replication: a phone claiming its first
|
|
9308
|
+
// always-on node has no control connection yet, so the insert commits local-only.
|
|
9309
|
+
await this.vouchPeer(target.peerId, target.multiaddrs, sign);
|
|
9310
|
+
});
|
|
9009
9311
|
this.retainDialTarget(target.peerId, target.multiaddrs, 'claimNode');
|
|
9010
9312
|
void this.reconcileControlCohort().catch((error) =>
|
|
9011
9313
|
log('claimNode: reconcile kick after claiming %s failed: %o', target.peerId, error));
|
|
@@ -9058,13 +9360,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9058
9360
|
/**
|
|
9059
9361
|
* Mint an owner-signed cadre invitation that a device can redeem at ANY member machine of
|
|
9060
9362
|
* this party, the owner offline included ({@link redeemCadreInvitation}). Requires the
|
|
9061
|
-
* owner key ({@link
|
|
9363
|
+
* owner key ({@link useOwnerSigner}).
|
|
9062
9364
|
*
|
|
9063
9365
|
* The invitation is an ed25519 keypair: the public half is the `CadreInvite` row's key,
|
|
9064
|
-
* the private half rides in the returned bundle as the proof of possession.
|
|
9065
|
-
*
|
|
9066
|
-
*
|
|
9067
|
-
*
|
|
9366
|
+
* the private half rides in the returned bundle as the proof of possession. It admits a
|
|
9367
|
+
* machine and grants nothing else (ownership is {@link createOwnerGrant}'s), so it defaults
|
|
9368
|
+
* to a 24-hour lifetime ({@link CADRE_INVITATION_DEFAULT_TTL_MS}) and one use unless `uses`
|
|
9369
|
+
* says otherwise. The bundle carries the signed row (so a
|
|
9068
9370
|
* member that has not received it by replication seats it from the bundle), this node's
|
|
9069
9371
|
* anchored owner keys — every current owner this node has verified, the out-of-band ones
|
|
9070
9372
|
* and those derived along verifiable chains ({@link syncOwnerAnchor}) — (the device pins
|
|
@@ -9089,30 +9391,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9089
9391
|
* @throws when no owner key is wired, the anchor is empty, `uses` is not a positive
|
|
9090
9392
|
* integer, or neither this machine nor any other member has an address.
|
|
9091
9393
|
*/
|
|
9092
|
-
async createCadreInvitation(options: CreateCadreInvitationOptions): Promise<CreateCadreInvitationResult> {
|
|
9093
|
-
const service = this.
|
|
9094
|
-
|
|
9095
|
-
throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
|
|
9096
|
-
}
|
|
9097
|
-
const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
|
|
9098
|
-
if (ownerKeys.length === 0) {
|
|
9099
|
-
throw new Error('createCadreInvitation: this node anchors no owner key, and an invitation without owner keys cannot be verified by the device that redeems it');
|
|
9100
|
-
}
|
|
9101
|
-
const uses = options.uses ?? 1;
|
|
9102
|
-
if (!Number.isInteger(uses) || uses < 1) {
|
|
9103
|
-
throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
|
|
9104
|
-
}
|
|
9105
|
-
const { members, reach } = await this.selectInvitationMembers();
|
|
9106
|
-
if (members.length === 0) {
|
|
9107
|
-
throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
|
|
9108
|
-
}
|
|
9109
|
-
const expiresInMs = options.expiresInMs
|
|
9110
|
-
?? (options.grantsOwner && !options.peerId ? OWNER_INVITATION_DEFAULT_TTL_MS : CADRE_INVITATION_DEFAULT_TTL_MS);
|
|
9394
|
+
async createCadreInvitation(options: CreateCadreInvitationOptions = {}): Promise<CreateCadreInvitationResult> {
|
|
9395
|
+
const { service, ownerKeys, uses, members, reach } = await this.issuedBundleParts('createCadreInvitation', options.uses);
|
|
9396
|
+
const expiresInMs = options.expiresInMs ?? CADRE_INVITATION_DEFAULT_TTL_MS;
|
|
9111
9397
|
const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
|
|
9112
9398
|
const row = await service.insertCadreInvite({
|
|
9113
9399
|
key: ed25519PublicKeyFromPrivate(invitePrivateKey),
|
|
9114
9400
|
peerId: options.peerId ?? null,
|
|
9115
|
-
grantsOwner: options.grantsOwner,
|
|
9116
9401
|
expiresAtMs: Date.now() + expiresInMs,
|
|
9117
9402
|
totalUses: uses,
|
|
9118
9403
|
});
|
|
@@ -9124,11 +9409,41 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9124
9409
|
ownerKeys,
|
|
9125
9410
|
members,
|
|
9126
9411
|
};
|
|
9127
|
-
log('Cadre invitation minted: %s (peer=%s
|
|
9128
|
-
row.key, row.peerId ?? 'any',
|
|
9412
|
+
log('Cadre invitation minted: %s (peer=%s uses=%d, %d member address(es), reach=%s)',
|
|
9413
|
+
row.key, row.peerId ?? 'any', uses, members.length, reach);
|
|
9129
9414
|
return { invitation, encoded: encodeCadreInvitation(invitation), reach };
|
|
9130
9415
|
}
|
|
9131
9416
|
|
|
9417
|
+
/**
|
|
9418
|
+
* What the two credentials an owner mints share ({@link createCadreInvitation},
|
|
9419
|
+
* {@link createOwnerGrant}), checked in this order: the owner-capable service; the anchor's
|
|
9420
|
+
* keys the holder pins (an empty anchor is refused rather than falling back to the table, since
|
|
9421
|
+
* a reply could not be checked against it); the use count; and the member addresses with their
|
|
9422
|
+
* reach, refused when neither this machine nor any other member has one.
|
|
9423
|
+
*/
|
|
9424
|
+
private async issuedBundleParts(
|
|
9425
|
+
caller: string,
|
|
9426
|
+
uses: number | undefined
|
|
9427
|
+
): Promise<{ service: SeedBootstrapService; ownerKeys: string[]; uses: number; members: string[]; reach: CadreInvitationReach }> {
|
|
9428
|
+
const service = this.seedBootstrapService;
|
|
9429
|
+
if (!service?.canAuthorize()) {
|
|
9430
|
+
throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
|
|
9431
|
+
}
|
|
9432
|
+
const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
|
|
9433
|
+
if (ownerKeys.length === 0) {
|
|
9434
|
+
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`);
|
|
9435
|
+
}
|
|
9436
|
+
const checkedUses = uses ?? 1;
|
|
9437
|
+
if (!Number.isInteger(checkedUses) || checkedUses < 1) {
|
|
9438
|
+
throw new Error(`${caller}: uses must be a positive integer (received ${String(uses)})`);
|
|
9439
|
+
}
|
|
9440
|
+
const { members, reach } = await this.selectInvitationMembers();
|
|
9441
|
+
if (members.length === 0) {
|
|
9442
|
+
throw new Error(`${caller}: neither this machine nor any other machine of the party has an address a device could dial`);
|
|
9443
|
+
}
|
|
9444
|
+
return { service, ownerKeys, uses: checkedUses, members, reach };
|
|
9445
|
+
}
|
|
9446
|
+
|
|
9132
9447
|
/**
|
|
9133
9448
|
* Every cadre invitation this node holds — minted here or replicated in — with its
|
|
9134
9449
|
* standing ({@link CadreInviteStatus}: live, withdrawn, redemptions recorded).
|
|
@@ -9184,11 +9499,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9184
9499
|
* pass whose reply was lost answers the next as already a member. `options.onProgress`
|
|
9185
9500
|
* reports each pass and pause.
|
|
9186
9501
|
*
|
|
9187
|
-
*
|
|
9188
|
-
*
|
|
9189
|
-
*
|
|
9190
|
-
* machines derive this node's key into their anchors from the row's stored proof on their
|
|
9191
|
-
* next membership refresh ({@link syncOwnerAnchor}).
|
|
9502
|
+
* The invitation admits this node as a machine and nothing else: ownership is an owner
|
|
9503
|
+
* grant ({@link redeemOwnerGrant}, {@link joinWithOwnerGrant}). A key of this node's that is
|
|
9504
|
+
* already an owner here is wired afterwards with {@link wireOwnKeyIfOwner}(signer).
|
|
9192
9505
|
*
|
|
9193
9506
|
* Limit: the reply check ({@link verifyRedeemReply}) does not authenticate the member. A
|
|
9194
9507
|
* forged bundle, or a legitimate one whose addresses were swapped, dials a machine that
|
|
@@ -9231,7 +9544,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9231
9544
|
...(options.signal ? { signal: options.signal } : {}),
|
|
9232
9545
|
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
9233
9546
|
});
|
|
9234
|
-
log('Cadre invitation %s redeemed at %s
|
|
9547
|
+
log('Cadre invitation %s redeemed at %s', invitation.invite.key, memberAddr);
|
|
9235
9548
|
|
|
9236
9549
|
const selfPeerId = controlNode.peerId.toString();
|
|
9237
9550
|
await mergeSeedPeers(controlNode, reply.peers.filter((peer) => peer.peerId !== selfPeerId));
|
|
@@ -9247,42 +9560,351 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9247
9560
|
await this.refreshMembershipGate('invitation-redeemed');
|
|
9248
9561
|
void this.reconcileControlCohort().catch((error) =>
|
|
9249
9562
|
log('redeemCadreInvitation: the post-redemption reconcile pass failed: %o', error));
|
|
9250
|
-
return { peerId: memberPeerId,
|
|
9563
|
+
return { peerId: memberPeerId, redeemedAt: new Date().toISOString() };
|
|
9251
9564
|
}
|
|
9252
9565
|
|
|
9253
9566
|
/**
|
|
9254
|
-
* If
|
|
9255
|
-
*
|
|
9256
|
-
*
|
|
9257
|
-
*
|
|
9258
|
-
* `
|
|
9567
|
+
* If `signer`'s key is a live `OwnerKey` here, wire it ({@link useOwnerSigner}) and return
|
|
9568
|
+
* true; otherwise change nothing and return false. For a machine that joined someone else's
|
|
9569
|
+
* cadre: after an owner grant seated its key, and on every later start.
|
|
9570
|
+
*
|
|
9571
|
+
* Without `signer` the candidate is the signer already wired, so the call answers whether
|
|
9572
|
+
* that key is still a live owner; with nothing wired it is false. The node's identity key is
|
|
9573
|
+
* never a candidate: a machine's network identity is never an owner key.
|
|
9259
9574
|
*
|
|
9260
9575
|
* Unlike `ensureOwnerKey` it never inserts an `OwnerKey` row, so a machine that joined a
|
|
9261
9576
|
* cadre can never genesis-insert itself into it, and a non-owner never anchors its own key.
|
|
9262
9577
|
* An owner whose key is already wired (an earlier call) is answered true with nothing redone.
|
|
9263
9578
|
*
|
|
9264
9579
|
* NOTE: a node running the listener-only seed service ({@link enableSeedListener}) cannot be
|
|
9265
|
-
* wired this way: libp2p takes one seed handler, and {@link
|
|
9266
|
-
*
|
|
9267
|
-
*
|
|
9580
|
+
* wired this way: libp2p takes one seed handler, and {@link useOwnerSigner} would register a
|
|
9581
|
+
* second. Nothing that joins by invitation enables the listener today; if one does, replace
|
|
9582
|
+
* the listener here rather than beside it.
|
|
9268
9583
|
*
|
|
9269
|
-
* @throws when the node is not started
|
|
9584
|
+
* @throws when the node is not started, or when `signer` names a different key from the
|
|
9585
|
+
* signer already wired (one signer per node).
|
|
9270
9586
|
*/
|
|
9271
|
-
async wireOwnKeyIfOwner(): Promise<boolean> {
|
|
9587
|
+
async wireOwnKeyIfOwner(signer?: OwnerSigner): Promise<boolean> {
|
|
9272
9588
|
if (!this.controlDatabase || !this._running) {
|
|
9273
9589
|
throw new Error('CadreNode must be started before wiring its owner key');
|
|
9274
9590
|
}
|
|
9275
|
-
const
|
|
9276
|
-
|
|
9277
|
-
|
|
9591
|
+
const wired = this.getOwnerSigner();
|
|
9592
|
+
const candidate = signer ?? wired;
|
|
9593
|
+
if (!candidate || !(await this.controlDatabase.getOwnerKeys()).has(candidate.publicKey)) {
|
|
9594
|
+
log('wireOwnKeyIfOwner: the candidate owner key is not an owner here; not wiring it');
|
|
9278
9595
|
return false;
|
|
9279
9596
|
}
|
|
9280
|
-
if (
|
|
9281
|
-
await this.
|
|
9597
|
+
if (wired === null) {
|
|
9598
|
+
await this.useOwnerSigner(candidate);
|
|
9599
|
+
} else if (wired.publicKey !== candidate.publicKey) {
|
|
9600
|
+
throw new Error(`wireOwnKeyIfOwner: this node already has the owner key ${wired.publicKey} wired; one owner signer per node`);
|
|
9601
|
+
}
|
|
9602
|
+
return true;
|
|
9603
|
+
}
|
|
9604
|
+
|
|
9605
|
+
// ============================================================================
|
|
9606
|
+
// Owner Grant API (redeemable through any member, `/sereus/owner-grant/1.0.0`)
|
|
9607
|
+
// ============================================================================
|
|
9608
|
+
|
|
9609
|
+
/**
|
|
9610
|
+
* Mint an owner-signed owner grant: whoever holds the returned bundle can seat an owner key of
|
|
9611
|
+
* their own through ANY member machine of this party ({@link redeemOwnerGrant}), or join the
|
|
9612
|
+
* party as a machine and an owner at once ({@link joinWithOwnerGrant}), the owner that minted
|
|
9613
|
+
* it offline or not. Requires the owner key ({@link useOwnerSigner}); one owner approval.
|
|
9614
|
+
*
|
|
9615
|
+
* The grant is an ed25519 keypair: the public half is the `OwnerGrant` row's key, the private
|
|
9616
|
+
* half rides in the bundle as the proof of possession. It admits no machine and names no peer
|
|
9617
|
+
* id. Whoever holds the bundle can become an owner, so it defaults to a 15-minute lifetime
|
|
9618
|
+
* ({@link OWNER_GRANT_DEFAULT_TTL_MS}) and one use; `expiresInMs: null` mints a BACKUP grant
|
|
9619
|
+
* with no expiry, which must be asked for explicitly and kept as carefully as an owner key.
|
|
9620
|
+
* The label is the issuer's choice and goes on every key the grant seats ({@link addOwner}'s
|
|
9621
|
+
* rule: trimmed, at most 64 characters, blank meaning none). The bundle carries the signed
|
|
9622
|
+
* row, this node's anchored owner keys and at most six member addresses, chosen as
|
|
9623
|
+
* {@link createCadreInvitation} chooses them and for the same reasons; `reach` says where a
|
|
9624
|
+
* holder could redeem it once this machine is gone. A backup's addresses are those at mint
|
|
9625
|
+
* time, so it can go stale if every member's address changes.
|
|
9626
|
+
*
|
|
9627
|
+
* @throws when no owner key is wired, the anchor is empty, `uses` is not a positive integer,
|
|
9628
|
+
* the label is over 64 characters, or neither this machine nor any other member has an address.
|
|
9629
|
+
*/
|
|
9630
|
+
async createOwnerGrant(options: CreateOwnerGrantOptions = {}): Promise<CreateOwnerGrantResult> {
|
|
9631
|
+
const { service, ownerKeys, uses, members, reach } = await this.issuedBundleParts('createOwnerGrant', options.uses);
|
|
9632
|
+
const expiresInMs = options.expiresInMs === undefined ? OWNER_GRANT_DEFAULT_TTL_MS : options.expiresInMs;
|
|
9633
|
+
const grantPrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
|
|
9634
|
+
const row = await service.insertOwnerGrant({
|
|
9635
|
+
key: ed25519PublicKeyFromPrivate(grantPrivateKey),
|
|
9636
|
+
label: options.label,
|
|
9637
|
+
expiresAtMs: expiresInMs === null ? null : Date.now() + expiresInMs,
|
|
9638
|
+
totalUses: uses,
|
|
9639
|
+
});
|
|
9640
|
+
const grant: OwnerGrantBundle = {
|
|
9641
|
+
v: 1,
|
|
9642
|
+
partyId: this.config.controlNetwork.partyId,
|
|
9643
|
+
grantPrivateKey,
|
|
9644
|
+
grant: row,
|
|
9645
|
+
ownerKeys,
|
|
9646
|
+
members,
|
|
9647
|
+
};
|
|
9648
|
+
log('Owner grant minted: %s (label=%s expires=%s uses=%d, %d member address(es), reach=%s)',
|
|
9649
|
+
row.key, row.label ?? 'none', row.expiresAt ?? 'never', uses, members.length, reach);
|
|
9650
|
+
return { grant, encoded: encodeOwnerGrant(grant), reach };
|
|
9651
|
+
}
|
|
9652
|
+
|
|
9653
|
+
/**
|
|
9654
|
+
* Every owner grant this node holds — minted here or replicated in — with its standing
|
|
9655
|
+
* ({@link OwnerGrantStatus}: live, withdrawn, redemptions recorded).
|
|
9656
|
+
*/
|
|
9657
|
+
async listOwnerGrants(): Promise<OwnerGrantStatus[]> {
|
|
9658
|
+
if (!this.controlDatabase) {
|
|
9659
|
+
throw new Error('CadreNode must be started before listing owner grants');
|
|
9660
|
+
}
|
|
9661
|
+
return await this.controlDatabase.listOwnerGrantStatuses();
|
|
9662
|
+
}
|
|
9663
|
+
|
|
9664
|
+
/**
|
|
9665
|
+
* Withdraw an owner grant: an owner-signed `Revocation` tombstone over its row, which stays as
|
|
9666
|
+
* the proof behind every key it seated; those keys stay owners (removing one is
|
|
9667
|
+
* {@link removeOwner}). No further redemption succeeds anywhere the tombstone reaches, and a
|
|
9668
|
+
* member holding it refuses the bundle's copy of the row. A withdrawal committed while alone is
|
|
9669
|
+
* re-issued on cohort growth like any other tombstone.
|
|
9670
|
+
*
|
|
9671
|
+
* @returns `true` when this call filed the tombstone, `false` when the row is not held here or
|
|
9672
|
+
* was already withdrawn.
|
|
9673
|
+
*/
|
|
9674
|
+
async withdrawOwnerGrant(key: string): Promise<boolean> {
|
|
9675
|
+
if (!this.seedBootstrapService) {
|
|
9676
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
9677
|
+
}
|
|
9678
|
+
return await this.seedBootstrapService.withdrawOwnerGrant(key);
|
|
9679
|
+
}
|
|
9680
|
+
|
|
9681
|
+
/**
|
|
9682
|
+
* Become an owner of this node's party with an owner grant: seat `signer`'s key as an
|
|
9683
|
+
* `OwnerKey` row by consent, with no owner online. `signer` holds the key to seat; its one
|
|
9684
|
+
* `approve` names the party and says the key becomes an owner of it, and signs the `'consent'`
|
|
9685
|
+
* digest. The grant's `'redeem'` signature is made with the bundle's private key in memory.
|
|
9686
|
+
* A bundle for another party is refused before anything is pinned, dialed or written.
|
|
9687
|
+
*
|
|
9688
|
+
* Two paths, chosen by whether this node is a live member of the party ({@link isLiveMemberSelf})
|
|
9689
|
+
* or an enrolled owner of it ({@link isOwner}):
|
|
9690
|
+
*
|
|
9691
|
+
* - **Local**, on a member machine: the grant row is seated from the bundle when absent (its
|
|
9692
|
+
* own transaction) and the redemption written in this node's own control database
|
|
9693
|
+
* (`ControlDatabase.redeemOwnerGrant`), replicating like any other write. Refused `alone`
|
|
9694
|
+
* under {@link addOwner}'s rule, before the approval: an `OwnerKey` row has no re-issue
|
|
9695
|
+
* path, so one committed with no control connection while the party has other members would
|
|
9696
|
+
* stay here; connect, then try again. A grant that is not live here (withdrawn, expired,
|
|
9697
|
+
* spent, its issuer no longer an owner) is refused before the approval too, unless the key
|
|
9698
|
+
* is already an owner. The bundle's `ownerKeys` are NOT pinned: this node judges the issuer
|
|
9699
|
+
* by its own `OwnerKey` table, and its anchor must not be widened by pasted text.
|
|
9700
|
+
* - **Protocol**, otherwise (a device with no machine in the cadre; a node started in the
|
|
9701
|
+
* party on an empty store): the bundle's `ownerKeys` are pinned into the node-local anchor
|
|
9702
|
+
* (`'invite'` provenance, the pin a credential the owner handed out carries, as
|
|
9703
|
+
* {@link redeemCadreInvitation} pins), then the members the bundle names are dialed over
|
|
9704
|
+
* `/sereus/owner-grant/1.0.0` (`redeemOwnerGrantInPasses`), one signed request for every
|
|
9705
|
+
* address and every pass as {@link redeemCadreInvitation} dials, so a retry after a lost
|
|
9706
|
+
* reply is answered `alreadyOwner`. The member that accepts pushes its control store here
|
|
9707
|
+
* and writes the row with this node counted in its write cohort, so a member alone with
|
|
9708
|
+
* this node still commits. `options` are the invitation's (`waitForMember`, `signal`,
|
|
9709
|
+
* `onProgress`).
|
|
9710
|
+
*
|
|
9711
|
+
* Neither path wires the signer: call {@link wireOwnKeyIfOwner}(signer) afterwards — at once
|
|
9712
|
+
* on the local path, and on the protocol path once the row has synced here. A key that is
|
|
9713
|
+
* already a live owner is answered `alreadyOwner` with no use spent, on both paths. To join
|
|
9714
|
+
* the party as a machine too, use {@link joinWithOwnerGrant}.
|
|
9715
|
+
*
|
|
9716
|
+
* @throws a plain `Error` for a node not started or a bundle naming another party;
|
|
9717
|
+
* {@link OwnerKeyAddRefusedError} `alone` on the local path, `OwnerGrantIssuerUnknownError`
|
|
9718
|
+
* when the issuer is not an owner here, `OwnerGrantRejectedError` `grant-spent` for a
|
|
9719
|
+
* grant no longer live here, and `OwnerGrantRejectedError` `conflict` (retryable) when the
|
|
9720
|
+
* cadre holds the grant row and this node has not synced it yet; on the protocol path
|
|
9721
|
+
* `OwnerGrantRejectedError` on a final refusal, `OwnerGrantReplyInvalidError` on an
|
|
9722
|
+
* acceptance that fails the reply check,
|
|
9723
|
+
* `OwnerGrantUnreachableError` when every address was tried (one pass only), and the
|
|
9724
|
+
* signal's reason on an abort.
|
|
9725
|
+
*/
|
|
9726
|
+
async redeemOwnerGrant(bundle: OwnerGrantBundle, signer: OwnerSigner, options: RedeemOwnerGrantOptions = {}): Promise<RedeemOwnerGrantResult> {
|
|
9727
|
+
const { controlDatabase } = this.requireStartedForGrant(bundle, 'redeeming an owner grant');
|
|
9728
|
+
// An enrolled owner machine is a party machine whether or not its own row is published yet
|
|
9729
|
+
// (the first self-record lands a second after start), so it writes locally too.
|
|
9730
|
+
if (await this.isLiveMemberSelf() || await this.isOwner()) {
|
|
9731
|
+
return await this.redeemOwnerGrantLocally(controlDatabase, bundle, signer);
|
|
9732
|
+
}
|
|
9733
|
+
await this.trustOwnerKeys(bundle.ownerKeys, 'invite');
|
|
9734
|
+
const request = await signer.approve(ownerGrantApproval(bundle), (sign) => this.signOwnerGrantRequest(bundle, signer, sign));
|
|
9735
|
+
const { reply, memberPeerId } = await this.redeemOwnerGrantAtMembers(bundle, request, options);
|
|
9736
|
+
return { ownerKey: signer.publicKey, alreadyOwner: reply.alreadyOwner, memberPeerId };
|
|
9737
|
+
}
|
|
9738
|
+
|
|
9739
|
+
/**
|
|
9740
|
+
* {@link redeemOwnerGrant}'s local path: the alone refusal, the seat, the liveness refusal,
|
|
9741
|
+
* then one approval around the consent signature and the write. Every refusal comes before
|
|
9742
|
+
* the approval, so a prompting signer never asks for a redemption this node would refuse.
|
|
9743
|
+
*/
|
|
9744
|
+
private async redeemOwnerGrantLocally(controlDatabase: ControlDatabase, bundle: OwnerGrantBundle, signer: OwnerSigner): Promise<RedeemOwnerGrantResult> {
|
|
9745
|
+
if (await this.ownerKeyWriteAlone()) {
|
|
9746
|
+
throw new OwnerKeyAddRefusedError(signer.publicKey, 'alone');
|
|
9747
|
+
}
|
|
9748
|
+
await refusingUnsyncedGrant(() => controlDatabase.seatOwnerGrant(bundle.grant));
|
|
9749
|
+
// The write answers a key that is already an owner without reading the grant, so only a
|
|
9750
|
+
// key that is not one needs the grant live (the same permissive pre-check the handler's
|
|
9751
|
+
// admission uses; the write enforces every condition).
|
|
9752
|
+
if (!(await controlDatabase.getOwnerKeys()).has(signer.publicKey) && !await controlDatabase.isOwnerGrantLive(bundle.grant)) {
|
|
9753
|
+
throw new OwnerGrantRejectedError('grant-spent', 'The grant is expired, withdrawn, exhausted, or its issuer is no longer an owner');
|
|
9754
|
+
}
|
|
9755
|
+
const { alreadyOwner } = await signer.approve(ownerGrantApproval(bundle), async (sign) => {
|
|
9756
|
+
const request = await this.signOwnerGrantRequest(bundle, signer, sign);
|
|
9757
|
+
return await refusingUnsyncedGrant(() => controlDatabase.redeemOwnerGrant({
|
|
9758
|
+
grantKey: bundle.grant.key,
|
|
9759
|
+
seatedKey: signer.publicKey,
|
|
9760
|
+
usageStampId: request.usageStampId,
|
|
9761
|
+
grantSig: request.grantSig,
|
|
9762
|
+
ownerSig: request.ownerSig,
|
|
9763
|
+
}));
|
|
9764
|
+
});
|
|
9765
|
+
// NOTE: the refusal is checked before the approval, and a connection can drop while the user
|
|
9766
|
+
// approves; a redemption that still commits alone is logged, as an add's is.
|
|
9767
|
+
if (!alreadyOwner && this.committedAlone()) {
|
|
9768
|
+
log('redeemOwnerGrant(%s) committed while ALONE (the last control connection dropped during the approval): ' +
|
|
9769
|
+
'the new owner row is local-only and is not re-issued on cohort growth.', signer.publicKey);
|
|
9770
|
+
}
|
|
9771
|
+
log('Owner grant %s redeemed locally for %s (alreadyOwner=%s)', bundle.grant.key, signer.publicKey, alreadyOwner);
|
|
9772
|
+
return { ownerKey: signer.publicKey, alreadyOwner, memberPeerId: null };
|
|
9773
|
+
}
|
|
9774
|
+
|
|
9775
|
+
/**
|
|
9776
|
+
* {@link redeemOwnerGrant}'s protocol path, given the signed request: dial the bundle's members
|
|
9777
|
+
* in passes, then refresh the membership gate, since the rows the member pushed and wrote are
|
|
9778
|
+
* judged against the pins and the anchor derives this node's key from them once its row is here.
|
|
9779
|
+
*/
|
|
9780
|
+
private async redeemOwnerGrantAtMembers(bundle: OwnerGrantBundle, request: OwnerGrantRedeemRequest, options: RedeemOwnerGrantOptions): Promise<OwnerGrantRedemption> {
|
|
9781
|
+
const pinned = new Set(bundle.ownerKeys);
|
|
9782
|
+
const redemption = await redeemOwnerGrantInPasses(this.controlNode!, {
|
|
9783
|
+
bundle,
|
|
9784
|
+
request,
|
|
9785
|
+
isTrustedIssuer: (ownerKey) => pinned.has(ownerKey),
|
|
9786
|
+
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
9787
|
+
waitForMember: options.waitForMember ?? false,
|
|
9788
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
9789
|
+
...(options.onProgress ? { onProgress: options.onProgress } : {}),
|
|
9790
|
+
});
|
|
9791
|
+
log('Owner grant %s redeemed at %s for %s (alreadyOwner=%s)', bundle.grant.key, redemption.memberAddr, request.ownerKey, redemption.reply.alreadyOwner);
|
|
9792
|
+
await this.refreshMembershipGate('owner-grant-redeemed');
|
|
9793
|
+
return redemption;
|
|
9794
|
+
}
|
|
9795
|
+
|
|
9796
|
+
/** The signed request for `bundle` seating `signer`'s key, under an open approval's `sign`; the nonce is minted here, by the holder. */
|
|
9797
|
+
private signOwnerGrantRequest(bundle: OwnerGrantBundle, signer: OwnerSigner, sign: OwnerSign): Promise<OwnerGrantRedeemRequest> {
|
|
9798
|
+
return signOwnerGrantRequest(bundle, signer.publicKey, sign, generateStampId(this.controlNode!.peerId.toString()));
|
|
9799
|
+
}
|
|
9800
|
+
|
|
9801
|
+
/**
|
|
9802
|
+
* Join this node's party as a machine AND an owner with one owner grant, for a node started in
|
|
9803
|
+
* the grant's party that is not a member: a fresh install recovering with a backup grant, an
|
|
9804
|
+
* app joining as an owner. Needs a started node with a stable identity, as
|
|
9805
|
+
* {@link redeemCadreInvitation} does, and a bundle for this party.
|
|
9806
|
+
*
|
|
9807
|
+
* Steps, the two signatures made under ONE approval of `signer` before any dial, so a
|
|
9808
|
+
* prompting signer asks once and its prompt does not span the network:
|
|
9809
|
+
*
|
|
9810
|
+
* 1. {@link redeemOwnerGrant}'s protocol path: the member that accepts seats `signer`'s key.
|
|
9811
|
+
* 2. A **self-invitation**: a `CadreInvite` row targeted at this node's peer id, one use, a
|
|
9812
|
+
* short expiry ({@link SELF_INVITATION_TTL_MS}), signed by `signer`
|
|
9813
|
+
* (`ControlDatabase.signCadreInvite`; never inserted here, since this store holds no
|
|
9814
|
+
* `OwnerKey` row for the issuer yet). Its bundle carries the grant's owner keys plus the
|
|
9815
|
+
* new key (the device's reply check wants the row's issuer among the pins) and the grant's
|
|
9816
|
+
* members with the member that accepted the grant first, since that member holds the new
|
|
9817
|
+
* key as a live owner and seats the row from the bundle; one the row has not reached yet
|
|
9818
|
+
* answers `issuer-unknown`, which is retryable.
|
|
9819
|
+
* 3. {@link redeemCadreInvitation} with it: the member admits this node.
|
|
9820
|
+
* 4. {@link wireOwnKeyIfOwner}(signer) once the `OwnerKey` row has synced here, asked every
|
|
9821
|
+
* {@link OWNER_ROW_SYNC_RECHECK_MS} up to {@link OWNER_ROW_SYNC_TIMEOUT_MS}; the result's
|
|
9822
|
+
* `ownerWired` says whether it did in time, and a later call wires it otherwise.
|
|
9823
|
+
*
|
|
9824
|
+
* @throws as {@link redeemOwnerGrant} (protocol path) and {@link redeemCadreInvitation} throw,
|
|
9825
|
+
* and a plain `Error` for an ephemeral identity.
|
|
9826
|
+
*/
|
|
9827
|
+
async joinWithOwnerGrant(bundle: OwnerGrantBundle, signer: OwnerSigner, options: RedeemOwnerGrantOptions = {}): Promise<JoinWithOwnerGrantResult> {
|
|
9828
|
+
const { controlNode, controlDatabase } = this.requireStartedForGrant(bundle, 'joining with an owner grant');
|
|
9829
|
+
if (!this.getSelfSigningKey()) {
|
|
9830
|
+
throw new Error('joinWithOwnerGrant: this node runs on an ephemeral identity; configure `keyStore` or `privateKey` so its key can sign the admission');
|
|
9831
|
+
}
|
|
9832
|
+
await this.trustOwnerKeys(bundle.ownerKeys, 'invite');
|
|
9833
|
+
const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
|
|
9834
|
+
const { request, selfInvite } = await signer.approve(ownerGrantApproval(bundle, 'join'), async (sign) => ({
|
|
9835
|
+
request: await this.signOwnerGrantRequest(bundle, signer, sign),
|
|
9836
|
+
selfInvite: await controlDatabase.signCadreInvite({
|
|
9837
|
+
key: ed25519PublicKeyFromPrivate(invitePrivateKey),
|
|
9838
|
+
peerId: controlNode.peerId.toString(),
|
|
9839
|
+
expiresAtMs: Date.now() + SELF_INVITATION_TTL_MS,
|
|
9840
|
+
totalUses: 1,
|
|
9841
|
+
}, signer.publicKey, sign),
|
|
9842
|
+
}));
|
|
9843
|
+
const granted = await this.redeemOwnerGrantAtMembers(bundle, request, options);
|
|
9844
|
+
const selfInvitation: CadreInvitation = {
|
|
9845
|
+
v: 1,
|
|
9846
|
+
partyId: bundle.partyId,
|
|
9847
|
+
invitePrivateKey,
|
|
9848
|
+
invite: selfInvite,
|
|
9849
|
+
ownerKeys: [...bundle.ownerKeys, signer.publicKey],
|
|
9850
|
+
members: addressesOfFirst(bundle.members, granted.memberPeerId),
|
|
9851
|
+
};
|
|
9852
|
+
const joined = await this.redeemCadreInvitation(selfInvitation, options);
|
|
9853
|
+
const ownerWired = await this.wireOwnKeyOnceSynced(signer);
|
|
9854
|
+
log('Joined with owner grant %s: member via %s, owner key %s (wired=%s)', bundle.grant.key, joined.peerId ?? 'unnamed member', signer.publicKey, ownerWired);
|
|
9855
|
+
return { peerId: joined.peerId, redeemedAt: joined.redeemedAt, ownerKey: signer.publicKey, alreadyOwner: granted.reply.alreadyOwner, ownerWired };
|
|
9856
|
+
}
|
|
9857
|
+
|
|
9858
|
+
/**
|
|
9859
|
+
* {@link wireOwnKeyIfOwner}(signer) once `signer`'s `OwnerKey` row has reached this node, asked
|
|
9860
|
+
* again every {@link OWNER_ROW_SYNC_RECHECK_MS} until it has, the node stops, or
|
|
9861
|
+
* {@link OWNER_ROW_SYNC_TIMEOUT_MS} passes: false then, and a later call wires it. Usually
|
|
9862
|
+
* immediate: the member pushed its whole store, the row included, before the admission write.
|
|
9863
|
+
*/
|
|
9864
|
+
private async wireOwnKeyOnceSynced(signer: OwnerSigner): Promise<boolean> {
|
|
9865
|
+
const deadline = Date.now() + OWNER_ROW_SYNC_TIMEOUT_MS;
|
|
9866
|
+
while (!(await this.wireOwnKeyIfOwner(signer))) {
|
|
9867
|
+
if (!this._running || Date.now() >= deadline) {
|
|
9868
|
+
log('wireOwnKeyOnceSynced: the OwnerKey row for %s has not reached this node yet; not wired', signer.publicKey);
|
|
9869
|
+
return false;
|
|
9870
|
+
}
|
|
9871
|
+
await new Promise<void>((resolve) => setTimeout(resolve, OWNER_ROW_SYNC_RECHECK_MS));
|
|
9282
9872
|
}
|
|
9283
9873
|
return true;
|
|
9284
9874
|
}
|
|
9285
9875
|
|
|
9876
|
+
/**
|
|
9877
|
+
* The started node's handles for a grant call, the bundle's party checked first: a grant for
|
|
9878
|
+
* another party is refused before any pin, dial or write.
|
|
9879
|
+
*/
|
|
9880
|
+
private requireStartedForGrant(bundle: OwnerGrantBundle, action: string): { controlNode: Libp2p; controlDatabase: ControlDatabase } {
|
|
9881
|
+
if (!this.controlNode || !this.controlDatabase || !this._running) {
|
|
9882
|
+
throw new Error(`CadreNode must be started before ${action}`);
|
|
9883
|
+
}
|
|
9884
|
+
if (bundle.partyId !== this.config.controlNetwork.partyId) {
|
|
9885
|
+
throw new Error(`Owner grant is for party ${bundle.partyId}; this node serves ${this.config.controlNetwork.partyId}`);
|
|
9886
|
+
}
|
|
9887
|
+
return { controlNode: this.controlNode, controlDatabase: this.controlDatabase };
|
|
9888
|
+
}
|
|
9889
|
+
|
|
9890
|
+
/**
|
|
9891
|
+
* Is this node a live member of its party: its own `CadrePeer` row is held here and passes the
|
|
9892
|
+
* membership predicate ({@link hasAnchoredProof}, judged as {@link listAuthorizedMembers}
|
|
9893
|
+
* judges every other row; that listing leaves this node out)? What decides
|
|
9894
|
+
* {@link redeemOwnerGrant}'s path. A machine's own row is written by its enrollment (or its
|
|
9895
|
+
* first self-record, for an owner's machine), so a node on an empty store, or one holding only
|
|
9896
|
+
* a store a member pushed it, is not one.
|
|
9897
|
+
*/
|
|
9898
|
+
private async isLiveMemberSelf(): Promise<boolean> {
|
|
9899
|
+
const controlDatabase = this.controlDatabase;
|
|
9900
|
+
const self = this.controlNode?.peerId.toString();
|
|
9901
|
+
if (!controlDatabase || self === undefined) {
|
|
9902
|
+
return false;
|
|
9903
|
+
}
|
|
9904
|
+
const own = (await controlDatabase.queryCadrePeers()).find((row) => row.peerId === self);
|
|
9905
|
+
return own !== undefined && this.hasAnchoredProof(own, await this.chainFor([own], this.invitationChainSource(controlDatabase, true)));
|
|
9906
|
+
}
|
|
9907
|
+
|
|
9286
9908
|
// ============================================================================
|
|
9287
9909
|
// Strand Solicitation API (native cadre-core formation transport)
|
|
9288
9910
|
// ============================================================================
|
|
@@ -9522,7 +10144,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9522
10144
|
*/
|
|
9523
10145
|
async requestJoin(invitation: OpenInvitation, disclosure: StrandFormationDisclosure = {}): Promise<PendingJoinStatus> {
|
|
9524
10146
|
const { database, runner } = this.requirePendingJoins();
|
|
9525
|
-
const signer = await this.
|
|
10147
|
+
const signer = await this.requireEnrolledOwnerSigner(
|
|
9526
10148
|
'requestJoin needs an enrolled owner machine of this party, because the party-wide join request is ' +
|
|
9527
10149
|
'owner-signed. formStrand(invitation, disclosure) joins once from this machine, with no retries.'
|
|
9528
10150
|
);
|
|
@@ -9536,7 +10158,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9536
10158
|
}
|
|
9537
10159
|
const requested = requestedPendingJoin(
|
|
9538
10160
|
pendingJoinId(invitation.token), this.encodeInvitation(invitation), disclosure, expiresAt, now);
|
|
9539
|
-
|
|
10161
|
+
// The attempt below runs outside the approval: a formation can take a network timeout, and
|
|
10162
|
+
// the outcome it records is the retry loop's own write.
|
|
10163
|
+
const row = await signer.approve({ partyId: this.partyId, action: `Ask to join a strand of the app ${invitation.sAppId}` },
|
|
10164
|
+
(sign) => this.recordJoinRequest(database, requested, signer.publicKey, sign));
|
|
9540
10165
|
log('requestJoin: pending join %s recorded; attempting now', row.Id);
|
|
9541
10166
|
return runner.attemptNow(row);
|
|
9542
10167
|
}
|
|
@@ -9558,9 +10183,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9558
10183
|
*/
|
|
9559
10184
|
async dismissPendingJoin(id: string): Promise<boolean> {
|
|
9560
10185
|
const { database, runner } = this.requirePendingJoins();
|
|
9561
|
-
const
|
|
10186
|
+
const signer = await this.requireEnrolledOwnerSigner(
|
|
9562
10187
|
'dismissPendingJoin needs an enrolled owner machine of this party: the removal is owner-signed');
|
|
9563
|
-
const removed = await
|
|
10188
|
+
const removed = await signer.approve({ partyId: this.partyId, action: `Dismiss the pending join ${id}` },
|
|
10189
|
+
(sign) => database.deletePendingJoin(id, signer.publicKey, sign));
|
|
9564
10190
|
runner.forgetRow(id);
|
|
9565
10191
|
return removed;
|
|
9566
10192
|
}
|
|
@@ -9572,13 +10198,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9572
10198
|
return { database: this.controlDatabase, runner: this.pendingJoinRunner };
|
|
9573
10199
|
}
|
|
9574
10200
|
|
|
9575
|
-
/** This machine's owner
|
|
9576
|
-
private async
|
|
9577
|
-
const
|
|
9578
|
-
if (!
|
|
10201
|
+
/** This machine's enrolled owner signer ({@link enrolledOwnerSigner}), or a throw carrying `refusal`. */
|
|
10202
|
+
private async requireEnrolledOwnerSigner(refusal: string): Promise<OwnerSigner> {
|
|
10203
|
+
const signer = await this.enrolledOwnerSigner();
|
|
10204
|
+
if (!signer) {
|
|
9579
10205
|
throw new Error(refusal);
|
|
9580
10206
|
}
|
|
9581
|
-
return
|
|
10207
|
+
return signer;
|
|
9582
10208
|
}
|
|
9583
10209
|
|
|
9584
10210
|
/**
|
|
@@ -9588,7 +10214,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9588
10214
|
private async recordJoinRequest(
|
|
9589
10215
|
database: ControlDatabase,
|
|
9590
10216
|
requested: JoinRequestFields,
|
|
9591
|
-
|
|
10217
|
+
ownerKey: string,
|
|
10218
|
+
signMessage: OwnerSign
|
|
9592
10219
|
): Promise<PendingJoin> {
|
|
9593
10220
|
let written: PendingJoin;
|
|
9594
10221
|
try {
|
|
@@ -9622,30 +10249,28 @@ export class CadreNode implements SAppIdLookup {
|
|
|
9622
10249
|
}
|
|
9623
10250
|
return this.controlDatabase;
|
|
9624
10251
|
};
|
|
9625
|
-
|
|
9626
|
-
|
|
10252
|
+
// Each write is its own approval: the retry loop runs unattended.
|
|
10253
|
+
const ownerWrite = async <T>(action: string, write: (ownerKey: string, sign: OwnerSign) => Promise<T>): Promise<T> => {
|
|
10254
|
+
const signer = await this.requireEnrolledOwnerSigner(
|
|
10255
|
+
'Cannot record a pending join outcome: this machine is not an enrolled owner of the party');
|
|
10256
|
+
return await signer.approve({ partyId: this.partyId, action }, (sign) => write(signer.publicKey, sign));
|
|
10257
|
+
};
|
|
9627
10258
|
this.pendingJoinRunner = new PendingJoinRunner({
|
|
9628
10259
|
selfId: this.controlNode!.peerId.toString(),
|
|
9629
10260
|
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
9630
|
-
isOwner:
|
|
10261
|
+
isOwner: () => this.isOwner(),
|
|
9631
10262
|
// NOTE: one owner-key read and one pending-join read (three tables) per pass (30 s); for a
|
|
9632
10263
|
// party that never asked for a join the second reads never-written blocks, which consults
|
|
9633
10264
|
// the cohort. If it shows up in a device profile, poll more slowly while the tables read empty.
|
|
9634
10265
|
readRows: () => database().queryPendingJoins(),
|
|
9635
10266
|
readRow: (id) => database().queryPendingJoin(id),
|
|
9636
10267
|
attempt: (join) => this.formStrand(this.pendingJoinInvitation(join), parseStoredDisclosure(join)),
|
|
9637
|
-
record:
|
|
9638
|
-
|
|
9639
|
-
|
|
9640
|
-
|
|
9641
|
-
|
|
9642
|
-
|
|
9643
|
-
return database().rewritePendingJoin(expectedStampId, next, ownerKey, signMessage);
|
|
9644
|
-
},
|
|
9645
|
-
remove: async (id) => {
|
|
9646
|
-
const { ownerKey, signMessage } = await outcomeSigner();
|
|
9647
|
-
return database().deletePendingJoin(id, ownerKey, signMessage);
|
|
9648
|
-
},
|
|
10268
|
+
record: (expected, outcome) => ownerWrite(`Record the outcome of the pending join ${expected.Id}`,
|
|
10269
|
+
(ownerKey, sign) => database().recordJoinOutcome(expected, outcome, ownerKey, sign)),
|
|
10270
|
+
rewrite: (expectedStampId, next) => ownerWrite(`Re-send the pending join ${next.Id} to the cadre`,
|
|
10271
|
+
(ownerKey, sign) => database().rewritePendingJoin(expectedStampId, next, ownerKey, sign)),
|
|
10272
|
+
remove: (id) => ownerWrite(`Clean up the finished pending join ${id}`,
|
|
10273
|
+
(ownerKey, sign) => database().deletePendingJoin(id, ownerKey, sign)),
|
|
9649
10274
|
isAlone: () => this.committedAlone(),
|
|
9650
10275
|
sAppIdOf: (row) => this.pendingJoinSAppId(row),
|
|
9651
10276
|
observeRows: (rows) => this.stageMembershipInvitesFromPendingJoins(rows),
|