@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.
Files changed (199) hide show
  1. package/README.md +1 -1
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +55 -138
  6. package/dist/cadre-invite-protocol.js +94 -449
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +530 -247
  12. package/dist/cadre-node.js +1046 -525
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +22 -23
  18. package/dist/control-cohort.js +24 -24
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +416 -136
  21. package/dist/control-database.js +757 -378
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-protocol-guard.d.ts +5 -4
  24. package/dist/control-protocol-guard.js +6 -3
  25. package/dist/control-protocol-guard.js.map +1 -1
  26. package/dist/control-schema-version.d.ts +80 -0
  27. package/dist/control-schema-version.js +203 -0
  28. package/dist/control-schema-version.js.map +1 -0
  29. package/dist/control-schema.d.ts +45 -5
  30. package/dist/control-schema.js +254 -70
  31. package/dist/control-schema.js.map +1 -1
  32. package/dist/control-write-retry.d.ts +5 -5
  33. package/dist/control-write-retry.js +10 -8
  34. package/dist/control-write-retry.js.map +1 -1
  35. package/dist/delegate-admission.d.ts +13 -0
  36. package/dist/delegate-admission.js +27 -0
  37. package/dist/delegate-admission.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +10 -7
  39. package/dist/ed25519-key.js +23 -20
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/edge-node/config.d.ts +122 -0
  42. package/dist/edge-node/config.js +199 -0
  43. package/dist/edge-node/config.js.map +1 -0
  44. package/dist/edge-node/dissolve.d.ts +95 -0
  45. package/dist/edge-node/dissolve.js +184 -0
  46. package/dist/edge-node/dissolve.js.map +1 -0
  47. package/dist/edge-node/edge-join.d.ts +44 -0
  48. package/dist/edge-node/edge-join.js +227 -0
  49. package/dist/edge-node/edge-join.js.map +1 -0
  50. package/dist/edge-node/edge-node.d.ts +295 -0
  51. package/dist/edge-node/edge-node.js +704 -0
  52. package/dist/edge-node/edge-node.js.map +1 -0
  53. package/dist/edge-node/index.d.ts +18 -0
  54. package/dist/edge-node/index.js +18 -0
  55. package/dist/edge-node/index.js.map +1 -0
  56. package/dist/edge-node/join-credential.d.ts +11 -0
  57. package/dist/edge-node/join-credential.js +11 -0
  58. package/dist/edge-node/join-credential.js.map +1 -0
  59. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  60. package/dist/edge-node/lazy-raw-storage.js +134 -0
  61. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  62. package/dist/edge-node/node-local.d.ts +60 -0
  63. package/dist/edge-node/node-local.js +58 -0
  64. package/dist/edge-node/node-local.js.map +1 -0
  65. package/dist/edge-node/options.d.ts +83 -0
  66. package/dist/edge-node/options.js +110 -0
  67. package/dist/edge-node/options.js.map +1 -0
  68. package/dist/edge-node/owner-grant.d.ts +41 -0
  69. package/dist/edge-node/owner-grant.js +38 -0
  70. package/dist/edge-node/owner-grant.js.map +1 -0
  71. package/dist/edge-node/platform.d.ts +140 -0
  72. package/dist/edge-node/platform.js +12 -0
  73. package/dist/edge-node/platform.js.map +1 -0
  74. package/dist/edge-node/strand-move.d.ts +115 -0
  75. package/dist/edge-node/strand-move.js +223 -0
  76. package/dist/edge-node/strand-move.js.map +1 -0
  77. package/dist/edge-node/strands.d.ts +28 -0
  78. package/dist/edge-node/strands.js +60 -0
  79. package/dist/edge-node/strands.js.map +1 -0
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +26 -7
  82. package/dist/index.js.map +1 -1
  83. package/dist/joined-strand-store.d.ts +3 -3
  84. package/dist/joined-strand-store.js +3 -3
  85. package/dist/joined-strand-store.js.map +1 -1
  86. package/dist/key-store.d.ts +9 -4
  87. package/dist/key-store.js +9 -4
  88. package/dist/key-store.js.map +1 -1
  89. package/dist/machine-removal.d.ts +19 -39
  90. package/dist/machine-removal.js +24 -98
  91. package/dist/machine-removal.js.map +1 -1
  92. package/dist/member-redemption.d.ts +250 -0
  93. package/dist/member-redemption.js +485 -0
  94. package/dist/member-redemption.js.map +1 -0
  95. package/dist/owner-anchor-sync.d.ts +4 -4
  96. package/dist/owner-anchor-sync.js +16 -15
  97. package/dist/owner-anchor-sync.js.map +1 -1
  98. package/dist/owner-grant-protocol.d.ts +247 -0
  99. package/dist/owner-grant-protocol.js +330 -0
  100. package/dist/owner-grant-protocol.js.map +1 -0
  101. package/dist/owner-key-export.d.ts +70 -0
  102. package/dist/owner-key-export.js +279 -0
  103. package/dist/owner-key-export.js.map +1 -0
  104. package/dist/owner-key-flows.d.ts +55 -0
  105. package/dist/owner-key-flows.js +60 -0
  106. package/dist/owner-key-flows.js.map +1 -0
  107. package/dist/owner-key-store.d.ts +31 -0
  108. package/dist/owner-key-store.js +81 -0
  109. package/dist/owner-key-store.js.map +1 -0
  110. package/dist/owner-keys.d.ts +78 -0
  111. package/dist/owner-keys.js +133 -0
  112. package/dist/owner-keys.js.map +1 -0
  113. package/dist/owner-signer.d.ts +54 -0
  114. package/dist/owner-signer.js +51 -0
  115. package/dist/owner-signer.js.map +1 -0
  116. package/dist/peer-authorization.d.ts +83 -18
  117. package/dist/peer-authorization.js +123 -53
  118. package/dist/peer-authorization.js.map +1 -1
  119. package/dist/provider/rent-node.js +1 -1
  120. package/dist/provider/rent-node.js.map +1 -1
  121. package/dist/seed-bootstrap.d.ts +131 -109
  122. package/dist/seed-bootstrap.js +260 -248
  123. package/dist/seed-bootstrap.js.map +1 -1
  124. package/dist/seed-trust-policy.js +4 -4
  125. package/dist/strand-addr-protocol.d.ts +4 -1
  126. package/dist/strand-addr-protocol.js +8 -2
  127. package/dist/strand-addr-protocol.js.map +1 -1
  128. package/dist/strand-cohort.d.ts +3 -0
  129. package/dist/strand-cohort.js.map +1 -1
  130. package/dist/strand-database.d.ts +1 -0
  131. package/dist/strand-database.js +3 -0
  132. package/dist/strand-database.js.map +1 -1
  133. package/dist/strand-first-sync-gate.d.ts +4 -3
  134. package/dist/strand-first-sync-gate.js +4 -3
  135. package/dist/strand-first-sync-gate.js.map +1 -1
  136. package/dist/strand-instance-manager.d.ts +1 -1
  137. package/dist/strand-membership-writer.d.ts +13 -2
  138. package/dist/strand-membership-writer.js +52 -16
  139. package/dist/strand-membership-writer.js.map +1 -1
  140. package/dist/strand-move.d.ts +6 -5
  141. package/dist/strand-move.js +31 -30
  142. package/dist/strand-move.js.map +1 -1
  143. package/dist/strand-peer-redial.js +2 -3
  144. package/dist/strand-peer-redial.js.map +1 -1
  145. package/dist/types.d.ts +286 -103
  146. package/dist/types.js.map +1 -1
  147. package/package.json +11 -2
  148. package/src/bootstrap-peer-store-file.ts +44 -44
  149. package/src/bootstrap-peer-store.ts +214 -214
  150. package/src/cadre-invite-protocol.ts +652 -1052
  151. package/src/cadre-join-request.ts +24 -3
  152. package/src/cadre-node.ts +1166 -541
  153. package/src/control-authorization.ts +47 -14
  154. package/src/control-cohort.ts +127 -128
  155. package/src/control-database.ts +940 -456
  156. package/src/control-protocol-guard.ts +7 -4
  157. package/src/control-schema-version.ts +251 -0
  158. package/src/control-schema.ts +265 -70
  159. package/src/control-write-retry.ts +569 -567
  160. package/src/delegate-admission.ts +37 -0
  161. package/src/ed25519-key.ts +174 -171
  162. package/src/edge-node/config.ts +277 -0
  163. package/src/edge-node/dissolve.ts +251 -0
  164. package/src/edge-node/edge-join.ts +319 -0
  165. package/src/edge-node/edge-node.ts +1045 -0
  166. package/src/edge-node/index.ts +59 -0
  167. package/src/edge-node/join-credential.ts +16 -0
  168. package/src/edge-node/lazy-raw-storage.ts +178 -0
  169. package/src/edge-node/node-local.ts +84 -0
  170. package/src/edge-node/options.ts +179 -0
  171. package/src/edge-node/owner-grant.ts +63 -0
  172. package/src/edge-node/platform.ts +151 -0
  173. package/src/edge-node/strand-move.ts +270 -0
  174. package/src/edge-node/strands.ts +72 -0
  175. package/src/index.ts +886 -789
  176. package/src/joined-strand-store.ts +4 -4
  177. package/src/key-store.ts +10 -4
  178. package/src/machine-removal.ts +26 -111
  179. package/src/member-redemption.ts +642 -0
  180. package/src/owner-anchor-sync.ts +19 -18
  181. package/src/owner-grant-protocol.ts +532 -0
  182. package/src/owner-key-export.ts +348 -0
  183. package/src/owner-key-flows.ts +109 -0
  184. package/src/owner-key-store.ts +85 -0
  185. package/src/owner-keys.ts +178 -0
  186. package/src/owner-signer.ts +94 -0
  187. package/src/peer-authorization.ts +558 -488
  188. package/src/provider/rent-node.ts +1 -1
  189. package/src/seed-bootstrap.ts +301 -312
  190. package/src/seed-trust-policy.ts +4 -4
  191. package/src/strand-addr-protocol.ts +547 -538
  192. package/src/strand-cohort.ts +4 -0
  193. package/src/strand-database.ts +3 -0
  194. package/src/strand-first-sync-gate.ts +4 -3
  195. package/src/strand-instance-manager.ts +1 -1
  196. package/src/strand-membership-writer.ts +61 -16
  197. package/src/strand-move.ts +45 -36
  198. package/src/strand-peer-redial.ts +2 -3
  199. 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
- RemoveMachineOptions,
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, sign } from '@optimystic/quereus-plugin-crypto';
58
- import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64, type Ed25519KeyPair } from './ed25519-key.js';
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, revouchWithinPlan, MachineRemovalRefusedError } from './machine-removal.js';
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 UNTARGETED, OWNER-GRANTING cadre invitation
221
- * ({@link CadreNode.createCadreInvitation}): whoever holds the bundle becomes an owner, so
222
- * it is a bearer credential for admin rights and lives only long enough to be pasted or
223
- * scanned by the device it was minted for.
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
- export const OWNER_INVITATION_DEFAULT_TTL_MS = 15 * 60 * 1000;
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 every other cadre invitation: targeted, or granting membership only. */
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
- /** Whether a `CadrePeer` or `OwnerKey` row's proof is an invitation admission rather than an owner voucher (`types.ts` → `CadrePeerRow`, `OwnerKeyRow`). */
293
- function isInvitationAdmitted(row: { vouchSig: string | null; vouchUsage: string | null }): boolean {
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`: that column is provenance (WHICH machine published the row), not
320
- * content. Two machines of one party racing to found the same id must keep resolving as
321
- * "the winner's row stands" — the loser adopts the row and, since the winner's key is on
322
- * it, correctly comes up as a joiner (see `launchStrand`'s founder derivation). Comparing
323
- * it here would turn that benign race into a hard throw. `Id` is the lookup key and
324
- * `StampId` is a single-use nonce, so the two compared columns are all the content there
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 owner-flagged peers of every seed
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 initializeSeedBootstrap}),
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} calls it with the
1891
- * cadre invitation's `ownerKeys` BEFORE dialing a member (so the anchor already
1892
- * holds the pins when the reply and the rows that follow are judged), and an
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 initializeSeedBootstrap}; 'chain' is written only by {@link syncOwnerAnchor}.)
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 authorized-member set is empty — cold start: the rows that would
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
- * 4. the peer IS an authorized member; or
2339
- * 5. an open invitation is OUTSTANDING — at least one unexpired,
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
- * 6. a LIVE cadre invitation exists — a `CadreInvite` row this node holds
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 5, and the same expectation-of-a-stranger key.
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-2 are in-memory, 3/4 share one control-DB read, and only a peer
2356
- * that is otherwise headed for a provisional admission reaches check 5's and
2357
- * 6's invitation lookups.
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 5:
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 6
2371
- * admits) or from a sibling whose membership row is still replicating (check 4
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 3/4 runs a control-DB read per inbound connection
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 5 and 6 add two more control reads (`hasOutstandingFormationInvite`,
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 invitation chain both reads may need, and the tombstone list the sync and
2648
- * the two end-of-refresh checks ({@link hangUpRevokedControlPeers}, then
2649
- * {@link noteOwnRemoval}) read, are each loaded at most once per refresh.
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, loadChain, loadTombstones);
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.enrolledOwnerSigningKey()) !== null,
3075
+ canSign: async () => (await this.enrolledOwnerSigner()) !== null,
2951
3076
  publish: async (record) => {
2952
- const signingKey = await this.enrolledOwnerSigningKey();
2953
- if (!signingKey) {
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 database().insertJoinedStrand(record, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
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 signingKey = await this.enrolledOwnerSigningKey();
2967
- if (signingKey) {
2968
- return database().deleteJoinedStrand(strandId, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
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 signing key when it can make owner-gated control writes: its seed
2984
- * bootstrap holds an owner key (`SeedBootstrapService.canAuthorize`) and the party's
2985
- * `OwnerKey` table enrolls it. A phone that ran self-genesis after another machine
2986
- * founded the party passes the first test and not the second (`runOwnerGenesis` in the
2987
- * reference apps), and a write it signed would only be refused.
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 enrolledOwnerSigningKey(): Promise<Ed25519KeyPair | null> {
2990
- const signingKey = this.ownerSigningKeyCandidate();
2991
- if (!signingKey || !this.controlDatabase) {
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(signingKey.publicKeyB64) ? signingKey : null;
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: its identity key is wired as its
2999
- * owner signing key ({@link initializeSeedBootstrap}) and enrolled in the live `OwnerKey`
3000
- * set ({@link enrolledOwnerSigningKey}). What the owner-only flows (`createCadreInvitation`,
3001
- * `claimNode`, `rentNode`) need; false before `start()`, and on a machine that joined as a
3002
- * member. The reference apps' "is this device an owner" check.
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.enrolledOwnerSigningKey()) !== null;
3132
+ return (await this.enrolledOwnerSigner()) !== null;
3006
3133
  }
3007
3134
 
3008
- /** {@link enrolledOwnerSigningKey} judged against an owner set the caller has already read. */
3009
- private ownerSigningKeyEnrolledIn(ownerKeys: ReadonlySet<string>): Ed25519KeyPair | null {
3010
- const signingKey = this.ownerSigningKeyCandidate();
3011
- return signingKey !== null && ownerKeys.has(signingKey.publicKeyB64) ? signingKey : null;
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
- /** This machine's identity key as an owner signing key, when its seed bootstrap is wired to sign; enrollment not yet checked. */
3015
- private ownerSigningKeyCandidate(): Ed25519KeyPair | null {
3016
- return this.seedBootstrapService?.canAuthorize() ? this.getSelfSigningKey() : null;
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 — i.e. is its
3296
- * `FounderOwnerKey` this node's own owner key (the key behind its PeerId)?
3297
- * A null/absent column (a consent-seated strand, or a hand-built row) and a
3298
- * node with no owner key both derive `false`: without a positive match this
3299
- * machine must attach, never bootstrap. Pure key derivation, no I/O — cheap
3300
- * enough for {@link launchStrand}'s tracked-instance early return.
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
- if (strand.FounderOwnerKey == null) {
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 (owner) members and select a bounded dial set.
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, cappedNonOwner } = selectControlCohortDials(siblings, ownerKeys, targetDegree);
3876
- if (cappedNonOwner > 0) {
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-owner sibling(s) at targetDegree=%d',
3879
- cappedNonOwner, targetDegree);
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 owner-flagged peers as cold-start bootstrap
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
- * `isOwner` is the seed's own claim about its peers, exactly as
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.isOwner) {
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 owner peers of
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 owners exactly ONCE, best-effort.
4305
- * When that single dial fails — owner momentarily down, relay reservation not
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 (seeds nominate one or a few
4316
- * owners), each failing fast against an unreachable address.
4317
- * NOTE: if seeds ever carry many owner peers, or the reconcile cadence tightens
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's owner key went with its row (`removeMachine`), so the schema's
4620
- // live-owner clause refuses every re-issue it could sign; reconstructAuthoredMembership
4621
- // and drainPendingPeerWrites stop on the same test.
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: an owner whose key alone was removed (`removeOwner`, its machine still a member,
4649
- // so not a removal this node reports) keeps running these drains, and every attempt
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 cadre invitation redemption handler, for the same reason.
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 session's refresh.
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
- * `FounderOwnerKey` (see {@link StrandConfig.founder}) — so attaching a row THIS machine
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 `FounderOwnerKey: null` and relaunch as a joiner.
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 FounderOwnerKey is its own owner key); an explicit flag wins — the
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
- // `FounderOwnerKey`, the party key re-reads from `StrandPartyKey`, and the row's
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 with the ed25519 key behind this node's PeerId — which
5815
- * {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
5816
- * so peer identity and owner key are one and the same. That key is also persisted on the
5817
- * row as `FounderOwnerKey` (the schema pins the column to the verified signer), which is
5818
- * what later lets any launch derive "this machine is the founder" from the row alone.
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, exposes no owner signing key, the id is blank or
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 signingKey = this.requireOwnerSigningKey(`publish strand ${strandId}`);
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
- // FounderOwnerKey records THIS machine as the row's publisher (the schema pins it to
5853
- // the signing owner), which is what later lets a relaunch derive founder-ness.
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: signingKey.publicKeyB64,
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, signingKey.publicKeyB64);
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 memberPrivateKey argument above. A failure
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 publishing machine (`FounderOwnerKey`) — a founder request
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 `FounderOwnerKey` is its own owner key. A fresh publish records this
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
- async foundStrand(config: FoundStrandConfig): Promise<FoundStrandResult> {
5964
- const { strandId, type = 'o', memberPrivateKey, sAppConfig, awaitFirstSync } = config;
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 was published by a different machine ' +
5981
- '(FounderOwnerKey is not this node\'s owner key) — attaching as a joiner; ' +
5982
- 'the founder bootstrap runs on the publishing machine', trimmed);
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
- const instance = await timed('addStrand',
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, exposes no owner signing key, the id is blank, or
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 signingKey = this.requireOwnerSigningKey(`unpublish strand ${strandId}`);
6234
+ const signer = this.requireOwnerSigner(`unpublish strand ${strandId}`);
6077
6235
  const trimmed = requireNonBlank(strandId, 'strand id');
6078
- const removed = await this.controlDatabase!.deleteStrand(
6079
- trimmed,
6080
- signingKey.publicKeyB64,
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 requireOwnerSigningKey above.)
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, signingKey.publicKeyB64);
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 signingKey = this.requireOwnerSigningKey(`seat a party key for strand ${trimmed}`);
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.controlDatabase!.insertStrandPartyKey(
6177
- trimmed, key, signingKey.publicKeyB64, signMessageWith(signingKey.privateKeyB64));
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, signingKey.publicKeyB64);
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 whose owner key the row names as
6198
- * founder — a freshly minted-and-persisted key, which seats the identity for a publish
6199
- * that was interrupted before its mint. Everyone else resolves undefined: non-founding
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 with the same self-owner key as {@link publishStrand} (the ed25519
6245
- * key behind this node's PeerId, which must be an enrolled `OwnerKey`).
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 signingKey = this.requireOwnerSigningKey(`publish formation invite ${token}`);
6260
- await this.controlDatabase!.insertFormationInvite(
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, signingKey.publicKeyB64);
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 with the same self-owner key as {@link publishStrand} (the ed25519 key behind
6286
- * this node's PeerId, which must be an enrolled `OwnerKey`). Throws loudly if the node
6287
- * isn't started, exposes no signing key, or the key is blank — a blank key would reach
6288
- * the database and fail the signature CHECK as an opaque constraint error.
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 signingKey = this.requireOwnerSigningKey('enroll a validation key');
6461
+ const signer = this.requireOwnerSigner('enroll a validation key');
6304
6462
  const trimmed = requireEd25519PublicKeyB64(key, 'validation key');
6305
- await this.controlDatabase!.insertValidationKey(
6306
- trimmed,
6307
- signingKey.publicKeyB64,
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 signingKey = this.requireOwnerSigningKey('remove a validation key');
6485
+ const signer = this.requireOwnerSigner('remove a validation key');
6331
6486
  const trimmed = requireNonBlank(key, 'validation key');
6332
- await this.controlDatabase!.deleteValidationKey(
6333
- trimmed,
6334
- signingKey.publicKeyB64,
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 signing
6343
- * key — only a started node.
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
- * Resolve the owner keypair every owner-signed control write needs — {@link publishStrand},
6354
- * {@link unpublishStrand}, {@link publishFormationInvite}, {@link enrollValidationKey},
6355
- * {@link removeValidationKey}
6356
- * — failing loudly in one two-part shape (not started / no signing key, naming owner
6357
- * genesis as the fix). Narrows `controlDatabase` for the caller: a non-null return means
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 requireOwnerSigningKey(action: string): { privateKeyB64: string; publicKeyB64: string } {
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 signingKey = this.getSelfSigningKey();
6368
- if (!signingKey) {
6519
+ const signer = this.getOwnerSigner();
6520
+ if (!signer) {
6369
6521
  throw new Error(
6370
- `Cannot ${action}: no owner signing key available ` +
6371
- '(node identity is unavailable or does not match the node PeerId). Run owner ' +
6372
- 'genesis (ensureOwnerKey + initializeSeedBootstrap) before writing owner-signed ' +
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 signingKey;
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 `FounderOwnerKey`); when
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 key comparison — no I/O.
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 and the
6667
- * per-peer failure comes back `unreachable` — harmless, it has no membership gate and
6668
- * needs no grant. The relay's direct addr rides along as the dial fallback
6669
- * for a relay we are not yet connected to.
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
- const bootstrapNodes = targets.length
6698
- ? (await this.collectSiblingStrandAddrs(this.controlNode, targets, strandId, delegatePeerId)).addrs
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 bootstrapNodes;
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
- return extractCircuitRelayTargets([
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 the announce timestamps {@link refreshDelegateGrants} throttles on,
6787
- * for every relay a delegate-carrying announce pass dialed.
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
- * Recorded OPTIMISTICALLY at announce time, whatever `collectStrandAddrs`
6790
- * reports per peer: a dedicated `ops/` relay never speaks the strand-addr
6791
- * protocol, so recording only on success would re-announce to it on every
6792
- * reconcile tick, one wasted protocol negotiation each. A failed INITIAL announce
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(relayPeerIds: readonly string[], strandId: string, now = Date.now()): void {
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
- * Against a dedicated ops relay (no strand-addr RPC) the request fails per-peer
6859
- * and `collectStrandAddrs` reports it `unreachable`: one wasted protocol negotiation per
6860
- * re-drive attempt, bounded by the supervisor's backoff. Never throws on that
6861
- * path; a relay addr that names no peer id is logged and skipped (the hook's
6862
- * caller runs the drive regardless).
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
- * Initialize the seed bootstrap service with an owner key.
7828
- * Must be called before using seed-related methods that require signing.
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
- * @param ownerPrivateKey - The owner's private key (base64url encoded)
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 initializeSeedBootstrap(ownerPrivateKey: string): Promise<void> {
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 seed-bootstrap with an owner PRIVATE key is declaring that
7844
- // key an authority of its own party (founder genesis / self-signing owner)
7845
- // — anchor it in the node-local store. The in-memory set updates
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. Three callers still wire unconditionally on every start, so a
7854
- // node of theirs started for a party founded elsewhere does: the phone kit's
7855
- // `runOwnerGenesis` (cadre-rn; only for a party its saved start does not record
7856
- // as joined, whose starts call `wireOwnKeyIfOwner` instead), the web reference
7857
- // app's start (`cadre-web.ts`) and `cadre start --owner`. Contained while the store does
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(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
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
- ownerPrivateKey,
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('Seed bootstrap service initialized');
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 two fields declared; the integration
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<Array<{ peerId: string; multiaddr: string | null }>> {
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(isInvitationAdmitted) ? await loadChain() : null;
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 (!isInvitationAdmitted(row)) {
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 invitation chain when some owner row is
8141
- * invitation-admitted — shared with the membership read through `loadChain`; the memo
8142
- * suggestion on {@link loadInvitationChain} covers that part. Before the Revocation ledger
8143
- * marker is filed that one read is a cohort consult per refresh, which
8144
- * `control-founding-consult-budget.spec.ts` pins.
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, loadChain: InvitationChainSource, loadTombstones: RevocationSource): Promise<void> {
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
- const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
8166
- const derivation = deriveOwnerAnchor({ partyId: this.partyId, anchor: before, rows, tombstones, chain });
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} and {@link syncOwnerAnchor}
8229
- * resolve an invitation-admitted row through, keyed for lookup. Both reads honour `retry`
8230
- * for {@link listAuthorizedMembers}' reason.
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 initializeSeedBootstrap})
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 owners), so the automatic
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 owner peers become cold-start bootstrap targets.
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 owner
8554
- * key, its push token and the invitations it could come back through, in one transaction)
8555
- * is {@link removeMachine}.
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
- const added = await this.seedBootstrapService.addOwner(key);
8591
- // NOTE: an OwnerKey add committed alone is local-only and nothing re-issues it (no
8592
- // update branch to re-touch, unlike CadrePeer), so it is only logged;
8593
- // tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue.md owns the fix.
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 (0 control connections): the new owner row is ' +
8596
- 'local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
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
- * Remove an owner key, signed by this node's owner key (which must be a different owner):
8603
- * the delete and its `Revocation` tombstone commit together. Refused by name when the key
8604
- * is this node's own or the only live owner. The write notifies the membership hub; the
8605
- * tombstone rides the committed-delete seam ({@link noteGuardedDelete}), so one committed
8606
- * while alone is re-issued on cohort growth like every other removal.
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
- * Removes the `OwnerKey` row only. The call that removes a machine completely, its owner key
8609
- * included, is {@link removeMachine}.
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 `true` when the row was removed, `false` when the key is not a live owner here.
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<boolean> {
8615
- if (!this.seedBootstrapService) {
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
- return await this.seedBootstrapService.removeOwner(key);
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, plus one per live `OwnerKey`
8625
- * whose key names a machine with no row (`describeMachines` in `machine-removal.ts`).
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 (await this.readMachines(this.controlDatabase)).machines;
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, whether its
8638
- * owner key goes with it, which rows that key vouched (so the app can offer to keep them by
8639
- * re-vouching), which invitations it could come back through, and whether this node's own
8640
- * rows depend on it (`planMachineRemoval` in `machine-removal.ts`).
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
- return (await this.planMachineRemovalWith(this.controlDatabase, peerId)).plan;
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 `OwnerKey` row when its key is an owner, its `DeviceToken` row (a
8652
- * removed phone must not be push-woken) and a withdrawal of every live invitation it could
8653
- * redeem to come back, in ONE transaction ({@link ControlDatabase.removeMachine}), with a
8654
- * `Revocation` tombstone per row so the removal holds on every machine
8655
- * ("Deletes made while alone" in docs/architecture.md).
8656
- *
8657
- * Steps: re-plan ({@link planMachineRemoval}) and throw {@link MachineRemovalRefusedError} on a
8658
- * refusal; check `options.revouch` against the plan's dependents and throw before any write
8659
- * when it names anything else; **re-vouch first**, under this node's key, the dependents the
8660
- * caller chose to keep (`ControlDatabase.revouchOwnerKey`, `reauthorizePeer`), so they are
8661
- * never untrusted in between, and stop before the removal if one fails (re-vouches already
8662
- * made stay, harmlessly); then the removal transaction. Afterwards this node forgets the
8663
- * machine as a dial target, and the refresh the write notifies hangs up its control
8664
- * connection ({@link hangUpRevokedControlPeers}) before this resolves.
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'`, `'self'` or `'last-owner'`.
9031
+ * @throws {MachineRemovalRefusedError} `'not-an-owner'` or `'self'`, before any write.
8673
9032
  */
8674
- async removeMachine(peerId: string, options: RemoveMachineOptions = {}): Promise<MachineRemovalResult> {
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 { plan, rows } = await this.planMachineRemovalWith(this.controlDatabase, peerId);
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, removedOwnerKey: false, removedDeviceToken: false, withdrawnInvitations: [], revouched: { owners: [], peers: [] } };
9044
+ return { removedPeer: false, removedDeviceToken: false, withdrawnInvitations: [] };
8687
9045
  }
8688
- await this.revouchDependents(service, revouch, rows);
8689
- const writes = await service.removeMachine({
8690
- peerId,
8691
- ownerKey: plan.removesOwnerKey ? plan.machine.publicKey : null,
8692
- inviteKeys: plan.invitations,
8693
- });
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 ownerKey=%s deviceToken=%s invitations=%d revouched owners=%d peers=%d', peerId,
8704
- writes.removedPeer, writes.removedOwnerKey, writes.removedDeviceToken, writes.withdrawnInvitations.length,
8705
- revouch.owners.length, revouch.peers.length);
8706
- return { ...writes, revouched: revouch };
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 reads behind {@link listMachines}: the live `CadrePeer` and `OwnerKey` rows and the
8711
- * machines projected from them, with this node's membership predicate as the `trusted` test.
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<{ machines: CadreMachine[]; rows: CadrePeerRow[]; owners: OwnerKeyRow[] }> {
8714
- const [rows, owners] = await Promise.all([controlDatabase.queryCadrePeers(), controlDatabase.queryOwnerKeyRows()]);
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
- const machines = describeMachines({
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 owner peers as
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 owner dial failed — the node is now seeded yet unconnected, and the
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.ownerDialsAttempted > 0 && result.ownerDialsFailed === result.ownerDialsAttempted) {
8859
- log('applySeed: seeded but no owner reachable (%d/%d dial(s) failed) — cold-start reconcile will retry',
8860
- result.ownerDialsFailed, result.ownerDialsAttempted);
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 initializeSeedBootstrap}), as
8962
- * {@link addDrone} does. The order is the REVERSE of `addDrone`'s: seed first, row
8963
- * second. A refused claim or a failed delivery therefore leaves no `CadrePeer` row and
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
- * from the seed's owner list, when this node has a listen or relay address; a phone
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
- if (!this.seedBootstrapService) {
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 seed = await this.seedBootstrapService.createSeed();
8999
- const proof = claimProof(secret, target.peerId, seed.signerKey, seedDigest(seed));
9000
- const ack = await this.seedBootstrapService.deliverSeed(
9001
- { peerId: target.peerId, multiaddrs: target.multiaddrs }, seed, { claimProof: proof });
9002
- if (!ack.accepted) {
9003
- throw new ClaimRefusedError(target.peerId, ack.reason ?? 'no reason given', ack.code);
9004
- }
9005
- log('claimNode: %s accepted the claim; vouching it into the cadre', target.peerId);
9006
- // The wrapper queues the write for re-replication: a phone claiming its first
9007
- // always-on node has no control connection yet, so the insert commits local-only.
9008
- await this.authorizePeer(target.peerId, target.multiaddrs);
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 initializeSeedBootstrap}).
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. Whoever holds
9065
- * the bundle can redeem it, so an UNTARGETED, OWNER-GRANTING invitation — a bearer
9066
- * credential for admin rights — defaults to a 15-minute lifetime; every other kind to 24
9067
- * hours. One use unless `uses` says otherwise. The bundle carries the signed row (so a
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.seedBootstrapService;
9094
- if (!service?.canAuthorize()) {
9095
- throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
9096
- }
9097
- const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
9098
- if (ownerKeys.length === 0) {
9099
- throw new Error('createCadreInvitation: this node anchors no owner key, and an invitation without owner keys cannot be verified by the device that redeems it');
9100
- }
9101
- const uses = options.uses ?? 1;
9102
- if (!Number.isInteger(uses) || uses < 1) {
9103
- throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
9104
- }
9105
- const { members, reach } = await this.selectInvitationMembers();
9106
- if (members.length === 0) {
9107
- throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
9108
- }
9109
- const expiresInMs = options.expiresInMs
9110
- ?? (options.grantsOwner && !options.peerId ? OWNER_INVITATION_DEFAULT_TTL_MS : CADRE_INVITATION_DEFAULT_TTL_MS);
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 owner=%s uses=%d, %d member address(es), reach=%s)',
9128
- row.key, row.peerId ?? 'any', row.grantsOwner, uses, members.length, reach);
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
- * When the invitation grants ownership, the member seats this node's key as an `OwnerKey`
9188
- * row by consent. The caller still has to wire that key for signing afterwards, with
9189
- * {@link wireOwnKeyIfOwner}; never `ensureOwnerKey`, the row is already there. Other
9190
- * machines derive this node's key into their anchors from the row's stored proof on their
9191
- * next membership refresh ({@link syncOwnerAnchor}).
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 (owner=%s)', invitation.invite.key, memberAddr, reply.invite.grantsOwner);
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, grantsOwner: reply.invite.grantsOwner, redeemedAt: new Date().toISOString() };
9563
+ return { peerId: memberPeerId, redeemedAt: new Date().toISOString() };
9251
9564
  }
9252
9565
 
9253
9566
  /**
9254
- * If this node's identity key is a live `OwnerKey` here, wire it for signing
9255
- * ({@link initializeSeedBootstrap}) and return true; otherwise change nothing and return
9256
- * false. For a machine that joined someone else's cadre by invitation: after an
9257
- * owner-granting redemption, and on every later start. A redemption seats the request's
9258
- * `peerKey`, the key behind this node's peer id, so the identity key is the one to look for.
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 initializeSeedBootstrap} would
9266
- * register a second. Nothing that joins by invitation enables the listener today; if one
9267
- * does, replace the listener here rather than beside it.
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 signingKey = this.getSelfSigningKey();
9276
- if (!signingKey || !(await this.controlDatabase.getOwnerKeys()).has(signingKey.publicKeyB64)) {
9277
- log('wireOwnKeyIfOwner: this node\'s key is not an owner here; not wiring it');
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 (!this.seedBootstrapService?.canAuthorize()) {
9281
- await this.initializeSeedBootstrap(signingKey.privateKeyB64);
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.requireOwnerSigner(
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
- const row = await this.recordJoinRequest(database, requested, signer);
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 { ownerKey, signMessage } = await this.requireOwnerSigner(
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 database.deletePendingJoin(id, ownerKey, signMessage);
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 key and signer for an owner-signed control write, or a throw carrying `refusal`. */
9576
- private async requireOwnerSigner(refusal: string): Promise<{ ownerKey: string; signMessage: (message: Uint8Array) => string }> {
9577
- const signingKey = await this.enrolledOwnerSigningKey();
9578
- if (!signingKey) {
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 { ownerKey: signingKey.publicKeyB64, signMessage: signMessageWith(signingKey.privateKeyB64) };
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
- { ownerKey, signMessage }: { ownerKey: string; signMessage: (message: Uint8Array) => string }
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
- const outcomeSigner = (): Promise<{ ownerKey: string; signMessage: (message: Uint8Array) => string }> =>
9626
- this.requireOwnerSigner('Cannot record a pending join outcome: this machine is not an enrolled owner of the party');
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: async () => (await this.enrolledOwnerSigningKey()) !== null,
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: async (expected, outcome) => {
9638
- const { ownerKey, signMessage } = await outcomeSigner();
9639
- return database().recordJoinOutcome(expected, outcome, ownerKey, signMessage);
9640
- },
9641
- rewrite: async (expectedStampId, next) => {
9642
- const { ownerKey, signMessage } = await outcomeSigner();
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),