@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
@@ -1,6 +1,6 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
- import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
3
+ import { digest, verify } from '@optimystic/quereus-plugin-crypto';
4
4
  import type { Libp2p, Connection, PeerId, Stream } from '@libp2p/interface';
5
5
  import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
6
6
  import { peerIdFromString } from '@libp2p/peer-id';
@@ -26,14 +26,16 @@ import type {
26
26
  AddDroneOptions,
27
27
  DroneInitResult,
28
28
  CadreInviteRow,
29
+ OwnerGrantRow,
29
30
  NodeProfile,
30
31
  PeerAddressRecord,
31
32
  DeviceTokenRecord,
32
33
  RevocationRow,
33
- RevocationLedgerOpenResult
34
+ RevocationLedgerOpenResult,
35
+ MachineRemovalResult
34
36
  } from './types.js';
35
- import type { ControlDatabase, MachineRemovalWrites } from './control-database.js';
36
- import { generateStampId } from './control-database.js';
37
+ import type { ControlDatabase } from './control-database.js';
38
+ import { generateStampId, ownerKeyLabel } from './control-database.js';
37
39
  import { canonicalJson } from './canonical-json.js';
38
40
  import { deviceTokenAddDigest } from './peer-authorization.js';
39
41
  import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
@@ -43,6 +45,8 @@ import {
43
45
  anchoredTrustPolicy,
44
46
  } from './seed-trust-policy.js';
45
47
  import type { TrustedOwnerStore } from './trusted-owner-store.js';
48
+ import { withinOwnerApproval, type OwnerSign, type OwnerSigner } from './owner-signer.js';
49
+ import { namedOwnerKey } from './owner-keys.js';
46
50
 
47
51
  const log = debug('sereus:cadre:seed-bootstrap');
48
52
 
@@ -113,8 +117,16 @@ export function seedDigest(seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>):
113
117
  return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url') as string;
114
118
  }
115
119
 
120
+ /** Add `relayAddr` to the entry for `selfPeerId` in `peers`, when that entry exists and lacks it. */
121
+ function addSelfRelayAddr(peers: SeedPeer[], selfPeerId: string, relayAddr: string): void {
122
+ const self = peers.find((peer) => peer.peerId === selfPeerId);
123
+ if (self && !self.multiaddrs.includes(relayAddr)) {
124
+ self.multiaddrs.push(relayAddr);
125
+ }
126
+ }
127
+
116
128
  /**
117
- * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
129
+ * An {@link ApplySeedResult} for a seed refused before the dial loop ran,
118
130
  * so the dial counters read zero rather than being absent.
119
131
  */
120
132
  function seedRejected(error: string, code?: SeedRefusalCode): ApplySeedResult {
@@ -123,8 +135,8 @@ function seedRejected(error: string, code?: SeedRefusalCode): ApplySeedResult {
123
135
  peersAdded: 0,
124
136
  error,
125
137
  ...(code !== undefined ? { code } : {}),
126
- ownerDialsAttempted: 0,
127
- ownerDialsFailed: 0,
138
+ preferredDialsAttempted: 0,
139
+ preferredDialsFailed: 0,
128
140
  };
129
141
  }
130
142
 
@@ -138,8 +150,8 @@ interface SeedApplyOptions {
138
150
  remotePeerId?: string;
139
151
  }
140
152
 
141
- /** What dialing a seed's owner peers produced — the owner-dial half of an {@link ApplySeedResult}. */
142
- type OwnerDialCounts = Pick<ApplySeedResult, 'ownerDialsAttempted' | 'ownerDialsFailed'>;
153
+ /** What dialing a seed's preferred peers produced — the dial half of an {@link ApplySeedResult}. */
154
+ type PreferredDialCounts = Pick<ApplySeedResult, 'preferredDialsAttempted' | 'preferredDialsFailed'>;
143
155
 
144
156
  /**
145
157
  * Parse each address, dropping (and logging) any that is malformed, so one bad
@@ -209,10 +221,12 @@ function deliveryDialAddrs(peerId: string, addrs: readonly string[]): Multiaddr[
209
221
  export interface SeedBootstrapConfig {
210
222
  /** Party ID for this cadre */
211
223
  partyId: string;
212
- /** Owner private key for signing seeds and peer authorizations (base64url) */
213
- ownerPrivateKey?: string;
214
- /** Owner public key (base64url) - derived from private key if not provided */
215
- ownerPublicKey?: string;
224
+ /**
225
+ * The owner signer every seed and owner-signed write of this service goes through
226
+ * (`owner-signer.ts`). Unset for a listener-only service, which receives and applies seeds
227
+ * but refuses every owner write.
228
+ */
229
+ ownerSigner?: OwnerSigner;
216
230
  /**
217
231
  * Trust anchor for incoming seeds. Decides whether a signature-verified
218
232
  * `signerKey` should be trusted, against the receiver's anchored owner
@@ -269,7 +283,7 @@ export interface SeedBootstrapConfig {
269
283
  * sized for the forms that dial inside it. Delivery does not set `runOnLimitedConnection`,
270
284
  * so it does not use a limited relayed connection today; the relayed-dial count is the upper
271
285
  * bound on the dial it can use, the same choice `CadreNode.controlDialBudget` makes for every
272
- * address. It holds only link work because the receiver acks before its owner dials.
286
+ * address. It holds only link work because the receiver acks before it dials the seed's peers.
273
287
  *
274
288
  * NOTE: no transfer allowance — a seed is a peer list of a few KB, and `MAX_SEED_SIZE` (1 MiB)
275
289
  * is a defensive cap. If seeds ever grow toward that cap, add an allowance the way
@@ -278,7 +292,7 @@ export interface SeedBootstrapConfig {
278
292
  seedDeliverTimeoutMs?: number;
279
293
  /**
280
294
  * Time limits for each peer this service dials from a list of addresses —
281
- * {@link SeedBootstrapService.applySeed}'s owner dials and
295
+ * {@link SeedBootstrapService.applySeed}'s dials of the seed's preferred peers and
282
296
  * {@link SeedBootstrapService.deliverSeed}'s dial of a peer id with addresses — per
283
297
  * address and per peer (see `peer-dial.ts`). Defaults to {@link DEFAULT_PEER_DIAL_BUDGET}; a `CadreNode`
284
298
  * passes its `network.controlCohort` limits.
@@ -298,7 +312,7 @@ export interface SeedEventCallbacks {
298
312
  * `seed` is the applied seed itself: the inbound protocol handler applies it
299
313
  * INSIDE the service, so this callback is the only seam through which a
300
314
  * `CadreNode` sees a wire-delivered seed's contents — which it needs to
301
- * retain the owner-flagged peers as cold-start bootstrap dial targets.
315
+ * retain the preferred peers as cold-start bootstrap dial targets.
302
316
  */
303
317
  onSeedApplied?: (partyId: string, peersAdded: number, seed: ControlNetworkSeed) => void;
304
318
  /** Called when seed application fails */
@@ -316,7 +330,6 @@ export class SeedBootstrapService {
316
330
  private readonly config: SeedBootstrapConfig;
317
331
  private libp2pNode: Libp2p | null = null;
318
332
  private controlDatabase: ControlDatabase | null = null;
319
- private readonly ownerPublicKey: string | null;
320
333
  private readonly trustPolicy: SeedTrustPolicy;
321
334
  private readonly seedReadTimeoutMs: number;
322
335
  private readonly maxConcurrentSeeds: number;
@@ -333,19 +346,6 @@ export class SeedBootstrapService {
333
346
  this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
334
347
  this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
335
348
  this.dialBudget = config.dialBudget ?? DEFAULT_PEER_DIAL_BUDGET;
336
-
337
- // Derive public key from private key if not provided
338
- if (config.ownerPrivateKey && !config.ownerPublicKey) {
339
- this.ownerPublicKey = getPublicKey(
340
- config.ownerPrivateKey,
341
- 'ed25519',
342
- 'base64url',
343
- 'base64url'
344
- ) as string;
345
- } else {
346
- this.ownerPublicKey = config.ownerPublicKey ?? null;
347
- }
348
-
349
349
  log('SeedBootstrapService created for party: %s', config.partyId);
350
350
  }
351
351
 
@@ -358,15 +358,19 @@ export class SeedBootstrapService {
358
358
  }
359
359
 
360
360
  /**
361
- * Whether this service holds an owner private key, i.e. can produce the
362
- * owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
363
- * and re-authorizations. A seed-listener-only service (`enableSeedListener`,
364
- * no owner key) returns false: it can receive/apply seeds but cannot author
365
- * or re-issue owner writes. Used by the write-while-alone re-replication
366
- * drain to skip owner work on a non-owner node.
361
+ * Whether this service holds an owner signer, i.e. can produce the owner signatures that
362
+ * gate `CadrePeer` / `DeviceToken` inserts, deletes, and re-authorizations. A
363
+ * seed-listener-only service (`enableSeedListener`, no signer) returns false: it can
364
+ * receive/apply seeds but cannot author or re-issue owner writes. Used by the
365
+ * write-while-alone re-replication drain to skip owner work on a non-owner node.
367
366
  */
368
367
  canAuthorize(): boolean {
369
- return !!this.config.ownerPrivateKey;
368
+ return this.config.ownerSigner !== undefined;
369
+ }
370
+
371
+ /** The owner signer this service was built with, or null for a listener-only service. */
372
+ get ownerSigner(): OwnerSigner | null {
373
+ return this.config.ownerSigner ?? null;
370
374
  }
371
375
 
372
376
  /**
@@ -374,7 +378,7 @@ export class SeedBootstrapService {
374
378
  *
375
379
  * `registerHandler` (default true) gates registration of the shared inbound
376
380
  * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
377
- * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
381
+ * (`CadreNode.useOwnerSigner`, `enableSeedListener`) own that handler and leave
378
382
  * it on. The throwaway temp service CadreNode builds in `applySeed` passes
379
383
  * `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
380
384
  * dialing and known-key lookup, and must NOT bind a discarded closure to the
@@ -412,8 +416,11 @@ export class SeedBootstrapService {
412
416
  * `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
413
417
  * the peer's self-signature, so the peer must self-publish (see
414
418
  * {@link CadreNode.registerSelf}) before the row resolves.
419
+ *
420
+ * @param sign - The signature function of an owner approval the caller already holds, so a
421
+ * multi-step action asks once; omitted, this write asks for its own approval.
415
422
  */
416
- async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
423
+ async authorizePeer(options: AuthorizePeerOptions, sign?: OwnerSign): Promise<void> {
417
424
  const { peerId, multiaddrs } = options;
418
425
  log('Authorizing peer: %s', peerId);
419
426
  // CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
@@ -425,7 +432,7 @@ export class SeedBootstrapService {
425
432
  updatedAt: Date.now(),
426
433
  sig: null,
427
434
  profile: null,
428
- });
435
+ }, `Add the machine ${peerId} to the cadre`, sign);
429
436
  log('Peer %s authorized successfully', peerId);
430
437
  }
431
438
 
@@ -442,7 +449,7 @@ export class SeedBootstrapService {
442
449
  * in which case the row in the database is NOT this record and carries whatever
443
450
  * `Sig` that writer had, so the caller must self-update to publish its signature.
444
451
  */
445
- async insertSelfPeerRecord(record: PeerAddressRecord): Promise<boolean> {
452
+ async insertSelfPeerRecord(record: PeerAddressRecord, sign?: OwnerSign): Promise<boolean> {
446
453
  return await this.insertCadrePeerRow({
447
454
  peerId: record.peerId,
448
455
  publicKey: record.publicKey,
@@ -450,19 +457,18 @@ export class SeedBootstrapService {
450
457
  updatedAt: record.updatedAt,
451
458
  sig: record.sig,
452
459
  profile: record.profile,
453
- });
460
+ }, 'Publish this machine\'s own address record', sign);
454
461
  }
455
462
 
456
463
  /**
457
- * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
464
+ * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-signer
458
465
  * precondition; the stamp mint, voucher signature, write lock, existence check and
459
466
  * membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
460
467
  * the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
461
468
  * method: the write is what admits the peer's traffic.
462
469
  *
463
- * The DB check precedes the owner-key check here, matching the order this method has
464
- * always surfaced them (the owner-key error used to come out of the signer, after the
465
- * DB check).
470
+ * The DB check precedes the owner-signer check here, matching the order this method has
471
+ * always surfaced them.
466
472
  *
467
473
  * @returns `true` when this call performed the INSERT, `false` when the row was
468
474
  * already seated by a concurrent writer. The loser needs to know: an authorize seats
@@ -476,14 +482,11 @@ export class SeedBootstrapService {
476
482
  updatedAt: number;
477
483
  sig: string | null;
478
484
  profile: NodeProfile | null;
479
- }): Promise<boolean> {
480
- if (!this.controlDatabase) {
481
- throw new Error('Control database not initialized');
482
- }
483
- const ownerKey = this.requireOwnerPublicKey();
484
- return await this.controlDatabase.insertCadrePeer(
485
- row, ownerKey, message => this.signMessageBytes(message),
486
- );
485
+ }, action: string, sign: OwnerSign | undefined): Promise<boolean> {
486
+ const controlDatabase = this.requireControlDatabase();
487
+ const ownerKey = this.requireOwnerSigner().publicKey;
488
+ return await this.withOwnerSign(action, sign, (approved) =>
489
+ controlDatabase.insertCadrePeer(row, ownerKey, approved));
487
490
  }
488
491
 
489
492
  /**
@@ -503,17 +506,17 @@ export class SeedBootstrapService {
503
506
  * fresh signature — unaffected by the cleared row's retired stamp
504
507
  * (`DeviceToken.NotRevoked`).
505
508
  */
506
- async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
509
+ async insertSelfDeviceToken(record: DeviceTokenRecord, sign?: OwnerSign): Promise<void> {
510
+ const ownerKey = this.requireOwnerSigner().publicKey;
511
+ const controlDatabase = this.requireControlDatabase();
507
512
  const stampId = generateStampId(record.peerId);
508
- const signature = this.signDigest(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }));
509
- if (!this.controlDatabase) {
510
- throw new Error('Control database not initialized');
511
- }
512
- await this.controlDatabase.execWrite(`
513
+ const digestBytes = uint8ArrayFromString(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }), 'base64url');
514
+ const signature = await this.withOwnerSign('Publish this machine\'s push token', sign, (approved) => approved(digestBytes));
515
+ await controlDatabase.execWrite(`
513
516
  insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
514
517
  with context OwnerKey = ?, Signature = ?
515
518
  values (?, ?, ?, ?, ?, ?)
516
- `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
519
+ `, [ownerKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
517
520
  log('Device token inserted (owner-signed): %s', record.peerId);
518
521
  }
519
522
 
@@ -535,79 +538,48 @@ export class SeedBootstrapService {
535
538
  * Both digests, the stamp read, and the transaction come from
536
539
  * {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
537
540
  * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
538
- * owner-key precondition; a no-op on an already-absent row is the shared body's
541
+ * owner-signer precondition; a no-op on an already-absent row is the shared body's
539
542
  * behavior, and unlike {@link removePeer} nothing here rides on it.
540
543
  */
541
- async deleteDeviceToken(peerId: string): Promise<void> {
542
- // Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
543
- // owner-key error rather than a silent no-op on an absent row.
544
- const ownerKey = this.requireOwnerPublicKey();
545
- if (!this.controlDatabase) {
546
- throw new Error('Control database not initialized');
547
- }
548
- await this.controlDatabase.deleteDeviceToken(
549
- peerId, ownerKey, message => this.signMessageBytes(message),
550
- );
544
+ async deleteDeviceToken(peerId: string, sign?: OwnerSign): Promise<void> {
545
+ // Fail fast on a signerless service BEFORE the DB read, so a non-owner gets the
546
+ // owner-signer error rather than a silent no-op on an absent row.
547
+ const ownerKey = this.requireOwnerSigner().publicKey;
548
+ const controlDatabase = this.requireControlDatabase();
549
+ await this.withOwnerSign(`Clear the push token of ${peerId}`, sign, (approved) =>
550
+ controlDatabase.deleteDeviceToken(peerId, ownerKey, approved));
551
551
  }
552
552
 
553
553
  /**
554
- * Return the configured owner private key, or throw if none is set. The single
555
- * precondition gate for every owner-signed write. {@link removePeer} /
556
- * {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
557
- * they call this up front — otherwise a keyless service would either surface the
558
- * wrong "Control database not initialized" error or, worse, silently no-op when the
559
- * target row is absent (the early `stampId === null` return) instead of rejecting.
554
+ * Return the configured owner signer, or throw if none is set. The single precondition
555
+ * gate for every owner-signed write. {@link removePeer} / {@link reauthorizePeer} read the
556
+ * row's `StampId` from the DB BEFORE they sign, so they call this up front — otherwise a
557
+ * signerless service would either surface the wrong "Control database not initialized"
558
+ * error or, worse, silently no-op when the target row is absent (the early
559
+ * `stampId === null` return) instead of rejecting.
560
560
  */
561
- private requireOwnerPrivateKey(): string {
562
- if (!this.config.ownerPrivateKey) {
563
- throw new Error('Owner private key required to authorize peers');
561
+ private requireOwnerSigner(): OwnerSigner {
562
+ if (!this.config.ownerSigner) {
563
+ throw new Error('Owner signer required to authorize peers');
564
564
  }
565
- return this.config.ownerPrivateKey;
565
+ return this.config.ownerSigner;
566
566
  }
567
567
 
568
- /**
569
- * The owner PUBLIC key that rides in every owner-signed write's context, non-null.
570
- *
571
- * The field is nullable because a read-only service carries no owner key at all, but the
572
- * constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
573
- * {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
574
- * unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
575
- * their first line and get the owner-key precondition for free.
576
- */
577
- private requireOwnerPublicKey(): string {
578
- this.requireOwnerPrivateKey();
579
- if (!this.ownerPublicKey) {
580
- throw new Error('Owner public key required to authorize peers');
568
+ /** The control database {@link initialize} attached, or a throw. */
569
+ private requireControlDatabase(): ControlDatabase {
570
+ if (!this.controlDatabase) {
571
+ throw new Error('Control database not initialized');
581
572
  }
582
- return this.ownerPublicKey;
583
- }
584
-
585
- /**
586
- * Sign a base64url digest with the owner key (ed25519). The single place the
587
- * owner private key is applied; callers pass the canonical domain-tagged digest for
588
- * the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
589
- * {@link signMessageBytes}. Throws if no owner key is set.
590
- */
591
- private signDigest(digestB64url: string): string {
592
- return sign(
593
- digestB64url,
594
- this.requireOwnerPrivateKey(),
595
- 'ed25519',
596
- 'base64url',
597
- 'base64url',
598
- 'base64url'
599
- ) as string;
573
+ return this.controlDatabase;
600
574
  }
601
575
 
602
576
  /**
603
- * Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
604
- * takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
605
- * same signed bytes (`sign` decodes its base64url input), so a signature minted here
606
- * satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
607
- * `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
577
+ * Run `body` with an owner signature function: the caller's `sign` when it passes one (a
578
+ * `CadreNode` action already inside its approval), otherwise one approval of this service's
579
+ * signer for `action`, the plain-words description a prompting signer shows.
608
580
  */
609
- private signMessageBytes(message: Uint8Array): string {
610
- return this.signDigest(uint8ArrayToString(message, 'base64url'));
581
+ private async withOwnerSign<T>(action: string, sign: OwnerSign | undefined, body: (sign: OwnerSign) => Promise<T>): Promise<T> {
582
+ return await withinOwnerApproval(this.requireOwnerSigner(), { partyId: this.config.partyId, action }, sign, body);
611
583
  }
612
584
 
613
585
  /**
@@ -632,16 +604,14 @@ export class SeedBootstrapService {
632
604
  * Both digests, the stamp read, the transaction, and the post-commit membership notify
633
605
  * come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
634
606
  * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
635
- * owner-key precondition and the absent-row gate the notify depends on (below).
607
+ * owner-signer precondition and the absent-row gate the notify depends on (below).
636
608
  */
637
- async removePeer(peerId: string): Promise<void> {
638
- // Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
639
- // remove digest, and this precedence (owner key, then control DB) is what the
609
+ async removePeer(peerId: string, sign?: OwnerSign): Promise<void> {
610
+ // Fail fast on a signerless service BEFORE any DB read: a non-owner cannot sign the
611
+ // remove digest, and this precedence (owner signer, then control DB) is what the
640
612
  // unit contract asserts.
641
- const ownerKey = this.requireOwnerPublicKey();
642
- if (!this.controlDatabase) {
643
- throw new Error('Control database not initialized');
644
- }
613
+ const ownerKey = this.requireOwnerSigner().publicKey;
614
+ const controlDatabase = this.requireControlDatabase();
645
615
  // This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
646
616
  // internally: deleteCadrePeer's membership notify fires whenever its body resolves,
647
617
  // with no idea whether the body wrote anything, so delegating an absent peer would
@@ -649,7 +619,7 @@ export class SeedBootstrapService {
649
619
  // NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
650
620
  // between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
651
621
  // window only — the common "already absent" case is caught here.
652
- const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
622
+ const stampId = await controlDatabase.queryCadrePeerStampId(peerId);
653
623
  if (stampId === null) {
654
624
  log('removePeer: no CadrePeer row for %s (already absent)', peerId);
655
625
  return;
@@ -657,83 +627,73 @@ export class SeedBootstrapService {
657
627
 
658
628
  log('Removing peer: %s', peerId);
659
629
 
660
- await this.controlDatabase.deleteCadrePeer(
661
- peerId, ownerKey, message => this.signMessageBytes(message),
662
- );
630
+ await this.withOwnerSign(`Remove the machine ${peerId} from the cadre`, sign, (approved) =>
631
+ controlDatabase.deleteCadrePeer(peerId, ownerKey, approved));
663
632
 
664
633
  log('Peer %s removed successfully (stamp retired)', peerId);
665
634
  }
666
635
 
667
636
  /**
668
- * Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
637
+ * Seat `ownerPublicKey` as a further owner, signed by this service's owner signer. The
669
638
  * liveness check, the stamp, the signature and the membership notify are
670
- * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
639
+ * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-signer
671
640
  * precondition, checked before any read (see {@link removePeer}).
672
641
  *
642
+ * @param label - the user's name for the key, bound into the signature
643
+ * ({@link ControlDatabase.insertOwnerKey} has the rule).
673
644
  * @returns `true` when the row was seated, `false` when the key is already a live owner.
674
645
  */
675
- async addOwner(ownerPublicKey: string): Promise<boolean> {
676
- const ownerKey = this.requireOwnerPublicKey();
677
- if (!this.controlDatabase) {
678
- throw new Error('Control database not initialized');
679
- }
680
- return await this.controlDatabase.insertOwnerKeyVouched(
681
- ownerPublicKey, ownerKey, message => this.signMessageBytes(message),
682
- );
646
+ async addOwner(ownerPublicKey: string, label?: string, sign?: OwnerSign): Promise<boolean> {
647
+ const ownerKey = this.requireOwnerSigner().publicKey;
648
+ const controlDatabase = this.requireControlDatabase();
649
+ // Checked before the approval opens, so a label the row would refuse never prompts the user.
650
+ const storedLabel = ownerKeyLabel(label);
651
+ return await this.withOwnerSign(`Add owner key ${namedOwnerKey(ownerPublicKey, storedLabel)}`, sign, (approved) =>
652
+ controlDatabase.insertOwnerKeyVouched(ownerPublicKey, ownerKey, approved, storedLabel));
683
653
  }
684
654
 
685
655
  /**
686
- * Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
656
+ * Remove `ownerPublicKey` from the owners, signed by this service's owner signer: the delete
687
657
  * and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
688
658
  * the last owner), the digests and the membership notify are
689
- * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
659
+ * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-signer precondition,
690
660
  * checked before any read (see {@link removePeer}).
691
661
  *
692
662
  * @returns `true` when the row was removed, `false` when the key is not a live owner here.
693
663
  */
694
- async removeOwner(ownerPublicKey: string): Promise<boolean> {
695
- const ownerKey = this.requireOwnerPublicKey();
696
- if (!this.controlDatabase) {
697
- throw new Error('Control database not initialized');
698
- }
699
- return await this.controlDatabase.deleteOwnerKey(
700
- ownerPublicKey, ownerKey, message => this.signMessageBytes(message),
701
- );
664
+ async removeOwner(ownerPublicKey: string, sign?: OwnerSign): Promise<boolean> {
665
+ const ownerKey = this.requireOwnerSigner().publicKey;
666
+ const controlDatabase = this.requireControlDatabase();
667
+ return await this.withOwnerSign(`Remove owner key ${ownerPublicKey}`, sign, (approved) =>
668
+ controlDatabase.deleteOwnerKey(ownerPublicKey, ownerKey, approved));
702
669
  }
703
670
 
704
671
  /**
705
- * Re-vouch another owner's row under this service's owner key, so it stands on a remaining
672
+ * Re-vouch another owner's row under this service's owner signer, so it stands on a remaining
706
673
  * owner's signature before the owner that added it is removed
707
- * ({@link ControlDatabase.revouchOwnerKey}; for `CadreNode.removeMachine` only). What stays
708
- * here is the owner-key precondition, checked before any read (see {@link removePeer}).
674
+ * ({@link ControlDatabase.revouchOwnerKey}; for `CadreNode.removeOwner` only). What stays
675
+ * here is the owner-signer precondition, checked before any read (see {@link removePeer}).
709
676
  *
710
677
  * @returns `true` when the row was re-vouched, `false` when the key is not a live owner here.
711
678
  */
712
- async revouchOwner(ownerPublicKey: string): Promise<boolean> {
713
- const ownerKey = this.requireOwnerPublicKey();
714
- if (!this.controlDatabase) {
715
- throw new Error('Control database not initialized');
716
- }
717
- return await this.controlDatabase.revouchOwnerKey(
718
- ownerPublicKey, ownerKey, message => this.signMessageBytes(message),
719
- );
679
+ async revouchOwner(ownerPublicKey: string, sign?: OwnerSign): Promise<boolean> {
680
+ const ownerKey = this.requireOwnerSigner().publicKey;
681
+ const controlDatabase = this.requireControlDatabase();
682
+ return await this.withOwnerSign(`Re-vouch owner key ${ownerPublicKey}`, sign, (approved) =>
683
+ controlDatabase.revouchOwnerKey(ownerPublicKey, ownerKey, approved));
720
684
  }
721
685
 
722
686
  /**
723
- * Remove a machine completely, signed by this service's owner key: its `CadrePeer` row, its
724
- * `OwnerKey` row when `target.ownerKey` names one, its `DeviceToken` row, and the
725
- * withdrawal of every invitation in `target.inviteKeys`, all in one transaction
726
- * ({@link ControlDatabase.removeMachine}). What stays here is the owner-key precondition,
727
- * checked before any read (see {@link removePeer}).
687
+ * Remove a machine completely, signed by this service's owner signer: its `CadrePeer` row, its
688
+ * `DeviceToken` row, and the withdrawal of every invitation in `target.inviteKeys`, all in one
689
+ * transaction ({@link ControlDatabase.removeMachine}). What stays here is the owner-signer
690
+ * precondition, checked before any read (see {@link removePeer}).
728
691
  */
729
- async removeMachine(target: Parameters<ControlDatabase['removeMachine']>[0]): Promise<MachineRemovalWrites> {
730
- const ownerKey = this.requireOwnerPublicKey();
731
- if (!this.controlDatabase) {
732
- throw new Error('Control database not initialized');
733
- }
734
- return await this.controlDatabase.removeMachine(
735
- target, ownerKey, message => this.signMessageBytes(message),
736
- );
692
+ async removeMachine(target: Parameters<ControlDatabase['removeMachine']>[0], sign?: OwnerSign): Promise<MachineRemovalResult> {
693
+ const ownerKey = this.requireOwnerSigner().publicKey;
694
+ const controlDatabase = this.requireControlDatabase();
695
+ return await this.withOwnerSign(`Remove the machine ${target.peerId} from the cadre`, sign, (approved) =>
696
+ controlDatabase.removeMachine(target, ownerKey, approved));
737
697
  }
738
698
 
739
699
  /**
@@ -745,25 +705,22 @@ export class SeedBootstrapService {
745
705
  *
746
706
  * The stamp read, the voucher signature, the write lock and the membership notify are
747
707
  * {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
748
- * `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
708
+ * `VouchOwner` to this node's owner key. What stays here is the owner-signer precondition.
749
709
  *
750
710
  * A no-op (no throw, no notify) when the row does not exist.
751
711
  *
752
712
  * @param peerId - the membership row to re-touch.
753
713
  * @param updatedAt - the strictly-increasing freshness stamp to write.
754
- * @throws if no owner private key is configured (a non-owner cannot
714
+ * @throws if no owner signer is configured (a non-owner cannot
755
715
  * re-sign another peer's row) or the control database is not initialized.
756
716
  */
757
- async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
758
- // Fail fast on a keyless service before any DB read (see removePeer): a non-owner
717
+ async reauthorizePeer(peerId: string, updatedAt: number, sign?: OwnerSign): Promise<void> {
718
+ // Fail fast on a signerless service before any DB read (see removePeer): a non-owner
759
719
  // cannot re-sign the voucher, and must not silently no-op on an absent row.
760
- const ownerKey = this.requireOwnerPublicKey();
761
- if (!this.controlDatabase) {
762
- throw new Error('Control database not initialized');
763
- }
764
- const retouched = await this.controlDatabase.reauthorizeCadrePeer(
765
- peerId, updatedAt, ownerKey, message => this.signMessageBytes(message),
766
- );
720
+ const ownerKey = this.requireOwnerSigner().publicKey;
721
+ const controlDatabase = this.requireControlDatabase();
722
+ const retouched = await this.withOwnerSign(`Re-issue the membership of ${peerId}`, sign, (approved) =>
723
+ controlDatabase.reauthorizeCadrePeer(peerId, updatedAt, ownerKey, approved));
767
724
  if (!retouched) {
768
725
  log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
769
726
  return;
@@ -781,22 +738,19 @@ export class SeedBootstrapService {
781
738
  *
782
739
  * The signatures, the single transaction, and the strictly-monotonic
783
740
  * `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
784
- * stays here is the owner-key precondition.
741
+ * stays here is the owner-signer precondition.
785
742
  *
786
743
  * @returns how many tombstones were re-issued (`rows.length` on success).
787
- * @throws if no owner private key is configured or the control database is not
744
+ * @throws if no owner signer is configured or the control database is not
788
745
  * initialized.
789
746
  */
790
- async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number): Promise<number> {
791
- // Fail fast on a keyless service before any DB work (see removePeer): a
747
+ async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number, sign?: OwnerSign): Promise<number> {
748
+ // Fail fast on a signerless service before any DB work (see removePeer): a
792
749
  // non-owner cannot sign the reissue digests.
793
- const ownerKey = this.requireOwnerPublicKey();
794
- if (!this.controlDatabase) {
795
- throw new Error('Control database not initialized');
796
- }
797
- return this.controlDatabase.reissueRevocations(
798
- rows, reissuedAt, ownerKey, message => this.signMessageBytes(message),
799
- );
750
+ const ownerKey = this.requireOwnerSigner().publicKey;
751
+ const controlDatabase = this.requireControlDatabase();
752
+ return await this.withOwnerSign(`Re-send ${rows.length} removal record(s) to the cadre`, sign, (approved) =>
753
+ controlDatabase.reissueRevocations(rows, reissuedAt, ownerKey, approved));
800
754
  }
801
755
 
802
756
  /**
@@ -804,100 +758,140 @@ export class SeedBootstrapService {
804
758
  * never-written block that the storage layer re-consults on every read.
805
759
  *
806
760
  * The signature, the insert-if-absent guard and the `'already-open'` mapping are
807
- * {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-key
761
+ * {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-signer
808
762
  * precondition.
809
763
  *
810
- * @throws if no owner private key is configured or the control database is not
764
+ * @throws if no owner signer is configured or the control database is not
811
765
  * initialized.
812
766
  */
813
- async openRevocationLedger(): Promise<RevocationLedgerOpenResult> {
814
- // Fail fast on a keyless service before any DB work (see removePeer): a
767
+ async openRevocationLedger(sign?: OwnerSign): Promise<RevocationLedgerOpenResult> {
768
+ // Fail fast on a signerless service before any DB work (see removePeer): a
815
769
  // non-owner cannot sign the marker's append digest.
816
- const ownerKey = this.requireOwnerPublicKey();
817
- if (!this.controlDatabase) {
818
- throw new Error('Control database not initialized');
819
- }
820
- return this.controlDatabase.openRevocationLedger(
821
- ownerKey, message => this.signMessageBytes(message),
822
- );
770
+ const ownerKey = this.requireOwnerSigner().publicKey;
771
+ const controlDatabase = this.requireControlDatabase();
772
+ return await this.withOwnerSign('Open the cadre\'s removal ledger', sign, (approved) =>
773
+ controlDatabase.openRevocationLedger(ownerKey, approved));
823
774
  }
824
775
 
825
776
  /**
826
777
  * Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
827
778
  * on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
828
779
  * expiry and use-count defaults, and the bundle the holder receives are
829
- * `CadreNode.createCadreInvitation`'s; what stays here is the owner-key precondition and
830
- * the signature, as {@link insertSelfDeviceToken} keeps them for device tokens.
780
+ * `CadreNode.createCadreInvitation`'s; what stays here is the owner-signer precondition and
781
+ * the approval, as {@link insertSelfDeviceToken} keeps them for device tokens.
831
782
  *
832
783
  * @returns the row as stored, which the invitation bundle carries verbatim.
833
- * @throws if no owner private key is configured or the control database is not initialized.
784
+ * @throws if no owner signer is configured or the control database is not initialized.
834
785
  */
835
- async insertCadreInvite(invite: Parameters<ControlDatabase['insertCadreInvite']>[0]): Promise<CadreInviteRow> {
836
- // Fail fast on a keyless service before any DB work (see removePeer).
837
- const ownerKey = this.requireOwnerPublicKey();
838
- if (!this.controlDatabase) {
839
- throw new Error('Control database not initialized');
840
- }
841
- return await this.controlDatabase.insertCadreInvite(invite, ownerKey, message => this.signMessageBytes(message));
786
+ async insertCadreInvite(invite: Parameters<ControlDatabase['insertCadreInvite']>[0], sign?: OwnerSign): Promise<CadreInviteRow> {
787
+ // Fail fast on a signerless service before any DB work (see removePeer).
788
+ const ownerKey = this.requireOwnerSigner().publicKey;
789
+ const controlDatabase = this.requireControlDatabase();
790
+ return await this.withOwnerSign('Create a machine invitation to the cadre', sign, (approved) =>
791
+ controlDatabase.insertCadreInvite(invite, ownerKey, approved));
842
792
  }
843
793
 
844
794
  /**
845
795
  * Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
846
796
  * the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
847
- * owner-key precondition.
797
+ * owner-signer precondition.
848
798
  *
849
799
  * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
850
800
  * already withdrawn.
851
- * @throws if no owner private key is configured or the control database is not initialized.
801
+ * @throws if no owner signer is configured or the control database is not initialized.
852
802
  */
853
- async withdrawCadreInvite(key: string): Promise<boolean> {
854
- const ownerKey = this.requireOwnerPublicKey();
855
- if (!this.controlDatabase) {
856
- throw new Error('Control database not initialized');
857
- }
858
- return await this.controlDatabase.withdrawCadreInvite(key, ownerKey, message => this.signMessageBytes(message));
803
+ async withdrawCadreInvite(key: string, sign?: OwnerSign): Promise<boolean> {
804
+ const ownerKey = this.requireOwnerSigner().publicKey;
805
+ const controlDatabase = this.requireControlDatabase();
806
+ return await this.withOwnerSign(`Withdraw the cadre invitation ${key}`, sign, (approved) =>
807
+ controlDatabase.withdrawCadreInvite(key, ownerKey, approved));
808
+ }
809
+
810
+ /**
811
+ * Owner-signed INSERT of an `OwnerGrant` row — a grant of ownership any member machine can
812
+ * redeem on this owner's behalf ({@link ControlDatabase.insertOwnerGrant}). The keypair mint,
813
+ * the lifetime default and the bundle the holder receives are `CadreNode.createOwnerGrant`'s;
814
+ * what stays here is the owner-signer precondition and the approval, as for
815
+ * {@link insertCadreInvite}. The label is checked before the approval opens, so one the row
816
+ * would refuse never prompts the user.
817
+ *
818
+ * @returns the row as stored, which the grant bundle carries verbatim.
819
+ * @throws if no owner signer is configured, the control database is not initialized, or the label is too long.
820
+ */
821
+ async insertOwnerGrant(grant: Parameters<ControlDatabase['insertOwnerGrant']>[0], sign?: OwnerSign): Promise<OwnerGrantRow> {
822
+ const ownerKey = this.requireOwnerSigner().publicKey;
823
+ const controlDatabase = this.requireControlDatabase();
824
+ const label = ownerKeyLabel(grant.label);
825
+ const named = label === null ? 'an owner grant' : `the owner grant "${label}"`;
826
+ return await this.withOwnerSign(`Create ${named}: whoever holds it can become an owner of the cadre`, sign, (approved) =>
827
+ controlDatabase.insertOwnerGrant({ ...grant, label }, ownerKey, approved));
828
+ }
829
+
830
+ /**
831
+ * Owner-signed WITHDRAWAL of an `OwnerGrant`: the `Revocation` tombstone over its stamp, with
832
+ * the row kept ({@link ControlDatabase.withdrawOwnerGrant}). What stays here is the
833
+ * owner-signer precondition.
834
+ *
835
+ * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
836
+ * already withdrawn.
837
+ * @throws if no owner signer is configured or the control database is not initialized.
838
+ */
839
+ async withdrawOwnerGrant(key: string, sign?: OwnerSign): Promise<boolean> {
840
+ const ownerKey = this.requireOwnerSigner().publicKey;
841
+ const controlDatabase = this.requireControlDatabase();
842
+ return await this.withOwnerSign(`Withdraw the owner grant ${key}`, sign, (approved) =>
843
+ controlDatabase.withdrawOwnerGrant(key, ownerKey, approved));
859
844
  }
860
845
 
861
846
  /**
862
847
  * Create a seed from the current control network state.
863
- * The seed contains peer information and is signed by an owner.
848
+ * The seed contains peer information and is signed by an owner; its `signerKey` is the
849
+ * owner signer's key.
850
+ */
851
+ async createSeed(sign?: OwnerSign): Promise<ControlNetworkSeed> {
852
+ return await this.mintSeed(sign, null);
853
+ }
854
+
855
+ /**
856
+ * {@link createSeed}, with `selfRelayAddr` added to this node's own peer entry before the
857
+ * seed is signed: the signature covers every address ({@link seedDigest}), so nothing may
858
+ * be added after it.
864
859
  */
865
- async createSeed(): Promise<ControlNetworkSeed> {
866
- if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
860
+ private async mintSeed(sign: OwnerSign | undefined, selfRelayAddr: string | null): Promise<ControlNetworkSeed> {
861
+ const signer = this.config.ownerSigner;
862
+ if (!signer) {
867
863
  throw new Error('Owner key required to create seeds');
868
864
  }
869
-
865
+
870
866
  if (!this.controlDatabase || !this.libp2pNode) {
871
867
  throw new Error('Service not initialized');
872
868
  }
873
-
869
+
874
870
  log('Creating seed for party: %s', this.config.partyId);
875
-
871
+
876
872
  // Query all peers from the control database
877
- const peers = await this.queryPeers();
878
-
873
+ const selfPeerId = this.libp2pNode.peerId.toString();
874
+ const peers = await this.queryPeers(selfPeerId);
875
+ if (selfRelayAddr !== null) {
876
+ addSelfRelayAddr(peers, selfPeerId, selfRelayAddr);
877
+ }
878
+
879
879
  // Create the seed data (without signature)
880
880
  const seedData = {
881
881
  partyId: this.config.partyId,
882
882
  peers,
883
883
  };
884
-
884
+
885
885
  // Sign the seed over its canonical byte representation
886
- const signature = sign(
887
- seedDigest(seedData),
888
- this.config.ownerPrivateKey,
889
- 'ed25519',
890
- 'base64url',
891
- 'base64url',
892
- 'base64url'
893
- ) as string;
894
-
886
+ const digestBytes = uint8ArrayFromString(seedDigest(seedData), 'base64url');
887
+ const signature = await this.withOwnerSign('Mint a seed for a machine joining the cadre', sign, (approved) => approved(digestBytes));
888
+
895
889
  const seed: ControlNetworkSeed = {
896
890
  ...seedData,
897
891
  signature,
898
- signerKey: this.ownerPublicKey,
892
+ signerKey: signer.publicKey,
899
893
  };
900
-
894
+
901
895
  log('Created seed with %d peers', peers.length);
902
896
  return seed;
903
897
  }
@@ -909,9 +903,8 @@ export class SeedBootstrapService {
909
903
  * `signerKey` that does NOT come from the seed body: the receiver's
910
904
  * node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
911
905
  * augmented by pinned keys or TOFU via the configured/overriding
912
- * `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
913
- * own signer as an owner peer — no longer passes, and neither does one signed
914
- * by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
906
+ * `SeedTrustPolicy`. A forged self-signed seed does not pass, and neither does
907
+ * one signed by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
915
908
  *
916
909
  * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
917
910
  * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
@@ -930,13 +923,13 @@ export class SeedBootstrapService {
930
923
  if (!merged.success) {
931
924
  return merged;
932
925
  }
933
- return { ...merged, ...await this.dialSeedOwners(seed) };
926
+ return { ...merged, ...await this.dialPreferredPeers(seed) };
934
927
  }
935
928
 
936
929
  /**
937
930
  * The first half of {@link applySeed}: check the signature and the signer's trust, then merge
938
931
  * the seed's peer addresses into the peer store. Every rejection happens here, so this result is
939
- * what the inbound handler acks with; the owner dials that follow cannot change it.
932
+ * what the inbound handler acks with; the dials that follow cannot change it.
940
933
  */
941
934
  private async verifyAndMergeSeed(
942
935
  seed: ControlNetworkSeed,
@@ -976,7 +969,7 @@ export class SeedBootstrapService {
976
969
  partyId: seed.partyId,
977
970
  signerKey: seed.signerKey,
978
971
  knownOwnerKeys,
979
- // Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
972
+ // Optional-chained like `dialPreferredPeers`: partial libp2p handles (unit-test doubles)
980
973
  // omit `peerId`. A real node always has one, and a proof bound to a real peer id can
981
974
  // only fail against '' — the safe direction.
982
975
  localPeerId: this.libp2pNode.peerId?.toString() ?? '',
@@ -991,55 +984,56 @@ export class SeedBootstrapService {
991
984
 
992
985
  const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
993
986
  log('Merged seed: %d peers added', peersAdded);
994
- return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
987
+ return { success: true, peersAdded, preferredDialsAttempted: 0, preferredDialsFailed: 0 };
995
988
  }
996
989
 
997
990
  /**
998
- * The second half of {@link applySeed}: dial the seed's owner peers to establish connections.
991
+ * The second half of {@link applySeed}: dial the seed's preferred peers (`SeedPeer.preferred`:
992
+ * the machine that minted it and every always-on machine) to establish connections.
999
993
  *
1000
- * Best-effort and COUNTED: an owner that is momentarily down leaves this node seeded but
994
+ * Best-effort and COUNTED: a peer that is momentarily down leaves this node seeded but
1001
995
  * unconnected, which the caller can only see if the outcome is reported (see
1002
- * `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
996
+ * `ApplySeedResult.preferredDialsFailed`). Recovery is not this loop's job —
1003
997
  * `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
1004
998
  * reconcile pass until the control database has siblings.
1005
999
  *
1006
1000
  * The inbound handler runs this AFTER it has acked and closed the stream
1007
- * ({@link handleSeedStream}), because an unreachable owner can take `dialBudget.totalMs`.
1001
+ * ({@link handleSeedStream}), because an unreachable peer can take `dialBudget.totalMs`.
1008
1002
  */
1009
- private async dialSeedOwners(seed: ControlNetworkSeed): Promise<OwnerDialCounts> {
1003
+ private async dialPreferredPeers(seed: ControlNetworkSeed): Promise<PreferredDialCounts> {
1010
1004
  const node = this.libp2pNode;
1011
1005
  if (!node) {
1012
- log('Seed service shut down before its owner dials; none attempted');
1013
- return { ownerDialsAttempted: 0, ownerDialsFailed: 0 };
1006
+ log('Seed service shut down before its dials; none attempted');
1007
+ return { preferredDialsAttempted: 0, preferredDialsFailed: 0 };
1014
1008
  }
1015
- // `createSeed` projects every non-revoked CadrePeer row, so an owner applying
1016
- // a seed minted after it joined finds ITSELF in the owner list. Dialing self always
1017
- // throws, which would report a healthy owner as "seeded but stranded".
1009
+ // `createSeed` projects every non-revoked CadrePeer row, so a preferred machine applying
1010
+ // a seed minted after it joined finds ITSELF in the list. Dialing self always throws,
1011
+ // which would report a healthy node as "seeded but stranded".
1018
1012
  // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
1019
1013
  // and an undefined self simply matches nothing.
1020
- // Every one of an owner's addresses is a candidate, each on its own time limit
1021
- // (`dialPeerAddrs`), so an owner whose first address never answers neither
1014
+ // Every one of a peer's addresses is a candidate, each on its own time limit
1015
+ // (`dialPeerAddrs`), so a peer whose first address never answers neither
1022
1016
  // stalls seed application nor goes undialed at its other addresses. The seed's
1023
- // addresses carry no peer id; the dial binds each to the owner's.
1017
+ // addresses carry no peer id; the dial binds each to the peer's.
1024
1018
  const selfPeerId = node.peerId?.toString();
1025
- let ownerDialsAttempted = 0;
1026
- let ownerDialsFailed = 0;
1027
- for (const peer of seed.peers.filter(p => p.isOwner)) {
1019
+ let preferredDialsAttempted = 0;
1020
+ let preferredDialsFailed = 0;
1021
+ for (const peer of seed.peers.filter(p => p.preferred)) {
1028
1022
  if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
1029
1023
  continue;
1030
1024
  }
1031
- ownerDialsAttempted++;
1025
+ preferredDialsAttempted++;
1032
1026
  try {
1033
1027
  const addrs = parseDialAddrs(peer.multiaddrs);
1034
1028
 
1035
- log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
1036
- await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
1029
+ log('Dialing preferred seed peer: %s (%d addr(s))', peer.peerId, addrs.length);
1030
+ await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `Seed dial of ${peer.peerId}`);
1037
1031
  } catch (error) {
1038
- // Counted even when the owner reaches us only through our own relay: this node is still
1039
- // not connected to it, and `ownerDialsFailed` is how the caller learns that.
1040
- ownerDialsFailed++;
1032
+ // Counted even when the peer reaches us only through our own relay: this node is still
1033
+ // not connected to it, and `preferredDialsFailed` is how the caller learns that.
1034
+ preferredDialsFailed++;
1041
1035
  if (error instanceof SelfRelayOnlyError) {
1042
- log('Owner peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
1036
+ log('Seed peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
1043
1037
  } else {
1044
1038
  log('Failed to dial peer %s: %o', peer.peerId, error);
1045
1039
  }
@@ -1047,8 +1041,8 @@ export class SeedBootstrapService {
1047
1041
  }
1048
1042
  }
1049
1043
 
1050
- log('Dialed seed owners: %d/%d owner dial(s) failed', ownerDialsFailed, ownerDialsAttempted);
1051
- return { ownerDialsAttempted, ownerDialsFailed };
1044
+ log('Dialed preferred seed peers: %d/%d dial(s) failed', preferredDialsFailed, preferredDialsAttempted);
1045
+ return { preferredDialsAttempted, preferredDialsFailed };
1052
1046
  }
1053
1047
 
1054
1048
  /**
@@ -1165,7 +1159,7 @@ export class SeedBootstrapService {
1165
1159
  }
1166
1160
  const peerId = peerIdFromString(target.peerId);
1167
1161
  const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
1168
- await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
1162
+ await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, preferred: false }]);
1169
1163
  if (addrs.length === 0) {
1170
1164
  return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
1171
1165
  }
@@ -1295,16 +1289,16 @@ export class SeedBootstrapService {
1295
1289
  }
1296
1290
 
1297
1291
  /**
1298
- * The peers a seed carries: the control database's own projection
1292
+ * The peers a seed `minterPeerId` mints carries: the control database's own projection
1299
1293
  * ({@link ControlDatabase.querySeedPeers}, shared with the cadre invitation redemption
1300
- * reply), which reads through the revocation filter and flags owners from the replicated
1301
- * `OwnerKey` table — a dial hint, not a trust decision (the NOTE at `projectSeedPeers`).
1294
+ * reply), which reads through the revocation filter and marks the minter and every
1295
+ * always-on machine preferred — a dial hint, not a trust decision.
1302
1296
  */
1303
- private async queryPeers(): Promise<SeedPeer[]> {
1297
+ private async queryPeers(minterPeerId: string): Promise<SeedPeer[]> {
1304
1298
  if (!this.controlDatabase) {
1305
1299
  return [];
1306
1300
  }
1307
- return await this.controlDatabase.querySeedPeers();
1301
+ return await this.controlDatabase.querySeedPeers(minterPeerId);
1308
1302
  }
1309
1303
 
1310
1304
  /**
@@ -1323,14 +1317,14 @@ export class SeedBootstrapService {
1323
1317
 
1324
1318
  /**
1325
1319
  * Read one inbound seed frame, verify and merge it, write the ack and close the
1326
- * stream, then dial the seed's owners.
1320
+ * stream, then dial the seed's preferred peers.
1327
1321
  *
1328
- * The ack and the close come BEFORE the owner dials: an unreachable owner can hold
1322
+ * The ack and the close come BEFORE those dials: an unreachable peer can hold
1329
1323
  * a dial for `dialBudget.totalMs` (86 s at the default declared link), which is not
1330
1324
  * link time and does not belong inside the sender's delivery deadline. The close is
1331
1325
  * what releases the sender, which reads the ack to end-of-stream. The stream stays
1332
1326
  * counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
1333
- * still bounds concurrent owner-dial phases.
1327
+ * still bounds concurrent dial phases.
1334
1328
  *
1335
1329
  * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
1336
1330
  * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
@@ -1367,7 +1361,7 @@ export class SeedBootstrapService {
1367
1361
  );
1368
1362
 
1369
1363
  if (merged.success) {
1370
- await this.dialSeedOwners(seed);
1364
+ await this.dialPreferredPeers(seed);
1371
1365
  this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
1372
1366
  } else {
1373
1367
  this.eventCallbacks.onSeedError?.(seed.partyId, merged.error ?? 'Unknown error');
@@ -1440,19 +1434,22 @@ export class SeedBootstrapService {
1440
1434
  * does not listen. Call `CadreNode.reconcileControlCohort()` after delivering
1441
1435
  * the seed to dial at once.
1442
1436
  *
1437
+ * The vouch and the seed are one owner approval.
1438
+ *
1443
1439
  * @param options - Drone peer info from provider API
1444
1440
  * @returns Seed and encoded seed for drone initialization
1445
1441
  */
1446
- async addDrone(options: AddDroneOptions): Promise<DroneInitResult> {
1442
+ async addDrone(options: AddDroneOptions, sign?: OwnerSign): Promise<DroneInitResult> {
1447
1443
  const { dronePeerId, droneMultiaddrs } = options;
1448
1444
 
1449
1445
  log('Adding drone: %s', dronePeerId);
1450
1446
 
1451
- // 1. Authorize the new drone peer
1452
- await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs });
1453
-
1454
- // 2. Create seed with current state
1455
- const seed = await this.createSeed();
1447
+ const seed = await this.withOwnerSign(`Add the machine ${dronePeerId} to the cadre`, sign, async (approved) => {
1448
+ // 1. Authorize the new drone peer
1449
+ await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs }, approved);
1450
+ // 2. Create seed with current state
1451
+ return await this.createSeed(approved);
1452
+ });
1456
1453
 
1457
1454
  // 3. Encode for transport
1458
1455
  const encodedSeed = this.encodeSeed(seed);
@@ -1469,29 +1466,21 @@ export class SeedBootstrapService {
1469
1466
  * 1. Authorizes the new phone peer
1470
1467
  * 2. Creates a seed with relay addresses for dialing
1471
1468
  *
1469
+ * The vouch and the seed are one owner approval.
1470
+ *
1472
1471
  * @param phonePeerId - Peer ID of the new phone
1473
1472
  * @returns Seed with relay addresses for out-of-band delivery
1474
1473
  */
1475
- async addPhoneWithRelay(phonePeerId: string): Promise<DroneInitResult> {
1474
+ async addPhoneWithRelay(phonePeerId: string, sign?: OwnerSign): Promise<DroneInitResult> {
1476
1475
  log('Adding phone with relay: %s', phonePeerId);
1477
1476
 
1478
- // 1. Authorize the new phone peer (no multiaddrs - NAT'd)
1479
- await this.authorizePeer({ peerId: phonePeerId });
1480
-
1481
- // 2. Get relay address for this node
1482
1477
  const relayAddr = await this.getRelayAddress();
1483
-
1484
- // 3. Create seed - will include our relay address if available
1485
- const seed = await this.createSeed();
1486
-
1487
- // If we have a relay address, make sure it's in our peer entry
1488
- if (relayAddr && this.libp2pNode) {
1489
- const ourPeerId = this.libp2pNode.peerId.toString();
1490
- const ourPeer = seed.peers.find(p => p.peerId === ourPeerId);
1491
- if (ourPeer && !ourPeer.multiaddrs.includes(relayAddr)) {
1492
- ourPeer.multiaddrs.push(relayAddr);
1493
- }
1494
- }
1478
+ const seed = await this.withOwnerSign(`Add the phone ${phonePeerId} to the cadre`, sign, async (approved) => {
1479
+ // 1. Authorize the new phone peer (no multiaddrs - NAT'd)
1480
+ await this.authorizePeer({ peerId: phonePeerId }, approved);
1481
+ // 2. Create seed - with our relay address in our own entry, when we have one
1482
+ return await this.mintSeed(approved, relayAddr);
1483
+ });
1495
1484
 
1496
1485
  const encodedSeed = this.encodeSeed(seed);
1497
1486