@serfab/cadre-core 1.16.0 → 1.18.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 (268) hide show
  1. package/README.md +4 -3
  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 +81 -142
  6. package/dist/cadre-invite-protocol.js +168 -458
  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 +1089 -475
  12. package/dist/cadre-node.js +2170 -1037
  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 +88 -32
  18. package/dist/control-cohort.js +91 -33
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +856 -318
  21. package/dist/control-database.js +1422 -701
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-formation-recorder.d.ts +46 -43
  24. package/dist/control-formation-recorder.js +77 -78
  25. package/dist/control-formation-recorder.js.map +1 -1
  26. package/dist/control-protocol-guard.d.ts +11 -4
  27. package/dist/control-protocol-guard.js +20 -5
  28. package/dist/control-protocol-guard.js.map +1 -1
  29. package/dist/control-schema-version.d.ts +97 -0
  30. package/dist/control-schema-version.js +225 -0
  31. package/dist/control-schema-version.js.map +1 -0
  32. package/dist/control-schema.d.ts +45 -5
  33. package/dist/control-schema.js +262 -78
  34. package/dist/control-schema.js.map +1 -1
  35. package/dist/control-write-retry.d.ts +45 -10
  36. package/dist/control-write-retry.js +92 -22
  37. package/dist/control-write-retry.js.map +1 -1
  38. package/dist/delegate-admission.d.ts +13 -0
  39. package/dist/delegate-admission.js +27 -0
  40. package/dist/delegate-admission.js.map +1 -1
  41. package/dist/ed25519-key.d.ts +10 -7
  42. package/dist/ed25519-key.js +23 -20
  43. package/dist/ed25519-key.js.map +1 -1
  44. package/dist/edge-node/config.d.ts +122 -0
  45. package/dist/edge-node/config.js +199 -0
  46. package/dist/edge-node/config.js.map +1 -0
  47. package/dist/edge-node/dissolve.d.ts +95 -0
  48. package/dist/edge-node/dissolve.js +184 -0
  49. package/dist/edge-node/dissolve.js.map +1 -0
  50. package/dist/edge-node/edge-join.d.ts +44 -0
  51. package/dist/edge-node/edge-join.js +227 -0
  52. package/dist/edge-node/edge-join.js.map +1 -0
  53. package/dist/edge-node/edge-node.d.ts +295 -0
  54. package/dist/edge-node/edge-node.js +704 -0
  55. package/dist/edge-node/edge-node.js.map +1 -0
  56. package/dist/edge-node/index.d.ts +18 -0
  57. package/dist/edge-node/index.js +18 -0
  58. package/dist/edge-node/index.js.map +1 -0
  59. package/dist/edge-node/join-credential.d.ts +11 -0
  60. package/dist/edge-node/join-credential.js +11 -0
  61. package/dist/edge-node/join-credential.js.map +1 -0
  62. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  63. package/dist/edge-node/lazy-raw-storage.js +134 -0
  64. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  65. package/dist/edge-node/node-local.d.ts +60 -0
  66. package/dist/edge-node/node-local.js +58 -0
  67. package/dist/edge-node/node-local.js.map +1 -0
  68. package/dist/edge-node/options.d.ts +83 -0
  69. package/dist/edge-node/options.js +110 -0
  70. package/dist/edge-node/options.js.map +1 -0
  71. package/dist/edge-node/owner-grant.d.ts +41 -0
  72. package/dist/edge-node/owner-grant.js +38 -0
  73. package/dist/edge-node/owner-grant.js.map +1 -0
  74. package/dist/edge-node/platform.d.ts +140 -0
  75. package/dist/edge-node/platform.js +12 -0
  76. package/dist/edge-node/platform.js.map +1 -0
  77. package/dist/edge-node/strand-move.d.ts +115 -0
  78. package/dist/edge-node/strand-move.js +223 -0
  79. package/dist/edge-node/strand-move.js.map +1 -0
  80. package/dist/edge-node/strands.d.ts +28 -0
  81. package/dist/edge-node/strands.js +60 -0
  82. package/dist/edge-node/strands.js.map +1 -0
  83. package/dist/enrolled-machine-store.d.ts +3 -3
  84. package/dist/enrolled-machine-store.js +3 -3
  85. package/dist/formation-token.d.ts +48 -0
  86. package/dist/formation-token.js +26 -0
  87. package/dist/formation-token.js.map +1 -0
  88. package/dist/index.d.ts +27 -17
  89. package/dist/index.js +39 -17
  90. package/dist/index.js.map +1 -1
  91. package/dist/joined-strand-store.d.ts +29 -12
  92. package/dist/joined-strand-store.js +66 -27
  93. package/dist/joined-strand-store.js.map +1 -1
  94. package/dist/key-store.d.ts +9 -4
  95. package/dist/key-store.js +9 -4
  96. package/dist/key-store.js.map +1 -1
  97. package/dist/link-budget.d.ts +9 -7
  98. package/dist/link-budget.js +9 -7
  99. package/dist/link-budget.js.map +1 -1
  100. package/dist/machine-removal.d.ts +19 -39
  101. package/dist/machine-removal.js +24 -98
  102. package/dist/machine-removal.js.map +1 -1
  103. package/dist/member-redemption.d.ts +256 -0
  104. package/dist/member-redemption.js +490 -0
  105. package/dist/member-redemption.js.map +1 -0
  106. package/dist/membership-view.d.ts +91 -0
  107. package/dist/membership-view.js +110 -0
  108. package/dist/membership-view.js.map +1 -0
  109. package/dist/owner-anchor-sync.d.ts +4 -4
  110. package/dist/owner-anchor-sync.js +16 -15
  111. package/dist/owner-anchor-sync.js.map +1 -1
  112. package/dist/owner-grant-protocol.d.ts +251 -0
  113. package/dist/owner-grant-protocol.js +344 -0
  114. package/dist/owner-grant-protocol.js.map +1 -0
  115. package/dist/owner-key-export.d.ts +70 -0
  116. package/dist/owner-key-export.js +279 -0
  117. package/dist/owner-key-export.js.map +1 -0
  118. package/dist/owner-key-flows.d.ts +55 -0
  119. package/dist/owner-key-flows.js +60 -0
  120. package/dist/owner-key-flows.js.map +1 -0
  121. package/dist/owner-key-store.d.ts +31 -0
  122. package/dist/owner-key-store.js +81 -0
  123. package/dist/owner-key-store.js.map +1 -0
  124. package/dist/owner-keys.d.ts +78 -0
  125. package/dist/owner-keys.js +133 -0
  126. package/dist/owner-keys.js.map +1 -0
  127. package/dist/owner-signer.d.ts +54 -0
  128. package/dist/owner-signer.js +51 -0
  129. package/dist/owner-signer.js.map +1 -0
  130. package/dist/peer-addr-book.d.ts +10 -1
  131. package/dist/peer-addr-book.js +4 -3
  132. package/dist/peer-addr-book.js.map +1 -1
  133. package/dist/peer-authorization.d.ts +83 -18
  134. package/dist/peer-authorization.js +123 -53
  135. package/dist/peer-authorization.js.map +1 -1
  136. package/dist/peer-join-backfill.d.ts +89 -20
  137. package/dist/peer-join-backfill.js +265 -108
  138. package/dist/peer-join-backfill.js.map +1 -1
  139. package/dist/pending-join-runner.d.ts +48 -18
  140. package/dist/pending-join-runner.js +74 -56
  141. package/dist/pending-join-runner.js.map +1 -1
  142. package/dist/provider/rent-node.js +1 -1
  143. package/dist/provider/rent-node.js.map +1 -1
  144. package/dist/seed-bootstrap.d.ts +162 -133
  145. package/dist/seed-bootstrap.js +300 -283
  146. package/dist/seed-bootstrap.js.map +1 -1
  147. package/dist/seed-trust-policy.js +4 -4
  148. package/dist/strand-addr-protocol.d.ts +4 -1
  149. package/dist/strand-addr-protocol.js +8 -2
  150. package/dist/strand-addr-protocol.js.map +1 -1
  151. package/dist/strand-cohort.d.ts +3 -0
  152. package/dist/strand-cohort.js.map +1 -1
  153. package/dist/strand-database.d.ts +24 -2
  154. package/dist/strand-database.js +29 -6
  155. package/dist/strand-database.js.map +1 -1
  156. package/dist/strand-first-sync-gate.d.ts +176 -88
  157. package/dist/strand-first-sync-gate.js +288 -121
  158. package/dist/strand-first-sync-gate.js.map +1 -1
  159. package/dist/strand-formation-deadlines.d.ts +9 -9
  160. package/dist/strand-formation-deadlines.js +9 -9
  161. package/dist/strand-formation-manager.d.ts +23 -17
  162. package/dist/strand-formation-manager.js +46 -44
  163. package/dist/strand-formation-manager.js.map +1 -1
  164. package/dist/strand-formation-protocol.d.ts +5 -8
  165. package/dist/strand-formation-protocol.js +101 -16
  166. package/dist/strand-formation-protocol.js.map +1 -1
  167. package/dist/strand-instance-manager.d.ts +27 -18
  168. package/dist/strand-instance-manager.js +119 -50
  169. package/dist/strand-instance-manager.js.map +1 -1
  170. package/dist/strand-membership-reconciler.d.ts +16 -13
  171. package/dist/strand-membership-reconciler.js +30 -22
  172. package/dist/strand-membership-reconciler.js.map +1 -1
  173. package/dist/strand-membership-writer.d.ts +45 -22
  174. package/dist/strand-membership-writer.js +226 -131
  175. package/dist/strand-membership-writer.js.map +1 -1
  176. package/dist/strand-move.d.ts +6 -5
  177. package/dist/strand-move.js +36 -32
  178. package/dist/strand-move.js.map +1 -1
  179. package/dist/strand-peer-redial.js +2 -3
  180. package/dist/strand-peer-redial.js.map +1 -1
  181. package/dist/strand-revocation-enforcer.d.ts +134 -60
  182. package/dist/strand-revocation-enforcer.js +144 -71
  183. package/dist/strand-revocation-enforcer.js.map +1 -1
  184. package/dist/strand-solicitation.d.ts +50 -39
  185. package/dist/strand-solicitation.js +10 -3
  186. package/dist/strand-solicitation.js.map +1 -1
  187. package/dist/strand-watcher.d.ts +80 -13
  188. package/dist/strand-watcher.js +152 -48
  189. package/dist/strand-watcher.js.map +1 -1
  190. package/dist/table-change-watch.d.ts +63 -0
  191. package/dist/table-change-watch.js +209 -0
  192. package/dist/table-change-watch.js.map +1 -0
  193. package/dist/timeout-scheduler.d.ts +4 -3
  194. package/dist/timeout-scheduler.js +4 -3
  195. package/dist/timeout-scheduler.js.map +1 -1
  196. package/dist/types.d.ts +374 -150
  197. package/dist/types.js +4 -4
  198. package/dist/types.js.map +1 -1
  199. package/package.json +17 -8
  200. package/src/bootstrap-peer-store-file.ts +44 -44
  201. package/src/bootstrap-peer-store.ts +214 -214
  202. package/src/cadre-invite-protocol.ts +740 -1052
  203. package/src/cadre-join-request.ts +24 -3
  204. package/src/cadre-node.ts +2402 -1079
  205. package/src/control-authorization.ts +47 -14
  206. package/src/control-cohort.ts +204 -128
  207. package/src/control-database.ts +1749 -820
  208. package/src/control-formation-recorder.ts +359 -353
  209. package/src/control-protocol-guard.ts +23 -6
  210. package/src/control-schema-version.ts +301 -0
  211. package/src/control-schema.ts +273 -78
  212. package/src/control-write-retry.ts +641 -567
  213. package/src/delegate-admission.ts +351 -314
  214. package/src/ed25519-key.ts +174 -171
  215. package/src/edge-node/config.ts +277 -0
  216. package/src/edge-node/dissolve.ts +251 -0
  217. package/src/edge-node/edge-join.ts +319 -0
  218. package/src/edge-node/edge-node.ts +1045 -0
  219. package/src/edge-node/index.ts +59 -0
  220. package/src/edge-node/join-credential.ts +16 -0
  221. package/src/edge-node/lazy-raw-storage.ts +178 -0
  222. package/src/edge-node/node-local.ts +84 -0
  223. package/src/edge-node/options.ts +179 -0
  224. package/src/edge-node/owner-grant.ts +63 -0
  225. package/src/edge-node/platform.ts +151 -0
  226. package/src/edge-node/strand-move.ts +270 -0
  227. package/src/edge-node/strands.ts +72 -0
  228. package/src/enrolled-machine-store.ts +3 -3
  229. package/src/formation-token.ts +63 -0
  230. package/src/index.ts +899 -789
  231. package/src/joined-strand-store.ts +73 -28
  232. package/src/key-store.ts +10 -4
  233. package/src/link-budget.ts +9 -7
  234. package/src/machine-removal.ts +26 -111
  235. package/src/member-redemption.ts +649 -0
  236. package/src/membership-view.ts +144 -0
  237. package/src/owner-anchor-sync.ts +19 -18
  238. package/src/owner-grant-protocol.ts +547 -0
  239. package/src/owner-key-export.ts +348 -0
  240. package/src/owner-key-flows.ts +109 -0
  241. package/src/owner-key-store.ts +85 -0
  242. package/src/owner-keys.ts +178 -0
  243. package/src/owner-signer.ts +94 -0
  244. package/src/peer-addr-book.ts +4 -3
  245. package/src/peer-authorization.ts +558 -488
  246. package/src/peer-join-backfill.ts +971 -761
  247. package/src/pending-join-runner.ts +76 -53
  248. package/src/provider/rent-node.ts +1 -1
  249. package/src/seed-bootstrap.ts +343 -349
  250. package/src/seed-trust-policy.ts +4 -4
  251. package/src/strand-addr-protocol.ts +547 -538
  252. package/src/strand-cohort.ts +4 -0
  253. package/src/strand-database.ts +352 -319
  254. package/src/strand-first-sync-gate.ts +334 -133
  255. package/src/strand-formation-deadlines.ts +227 -227
  256. package/src/strand-formation-manager.ts +731 -725
  257. package/src/strand-formation-protocol.ts +122 -24
  258. package/src/strand-instance-manager.ts +2092 -2026
  259. package/src/strand-membership-reconciler.ts +33 -25
  260. package/src/strand-membership-writer.ts +2093 -1974
  261. package/src/strand-move.ts +50 -38
  262. package/src/strand-peer-redial.ts +2 -3
  263. package/src/strand-revocation-enforcer.ts +740 -654
  264. package/src/strand-solicitation.ts +61 -34
  265. package/src/strand-watcher.ts +175 -56
  266. package/src/table-change-watch.ts +285 -0
  267. package/src/timeout-scheduler.ts +29 -28
  268. package/src/types.ts +391 -152
@@ -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';
@@ -17,6 +17,7 @@ import { relayedRequestBudgetMs } from './link-budget.js';
17
17
  import type {
18
18
  ControlNetworkSeed,
19
19
  SeedPeer,
20
+ SeedDialTarget,
20
21
  SeedMessage,
21
22
  SeedAckMessage,
22
23
  SeedRefusalCode,
@@ -26,14 +27,15 @@ import type {
26
27
  AddDroneOptions,
27
28
  DroneInitResult,
28
29
  CadreInviteRow,
30
+ OwnerGrantRow,
29
31
  NodeProfile,
30
32
  PeerAddressRecord,
31
33
  DeviceTokenRecord,
32
34
  RevocationRow,
33
- 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 { CadrePeerRetouchMode, CadrePeerRetouchOutcome, 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
 
@@ -114,7 +118,29 @@ export function seedDigest(seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>):
114
118
  }
115
119
 
116
120
  /**
117
- * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
121
+ * The peers `seed` nominates for dialing: its preferred peers (`SeedPeer.preferred`: the machine
122
+ * that minted it and every always-on machine) that have an address and are not `selfPeerId`.
123
+ *
124
+ * Self is dropped because `createSeed` projects every non-revoked CadrePeer row, so a preferred
125
+ * machine applying a seed minted after it joined finds itself in the list, and a dial of self
126
+ * always throws. An undefined `selfPeerId` matches nothing.
127
+ */
128
+ export function seedDialTargets(seed: ControlNetworkSeed, selfPeerId: string | undefined): SeedDialTarget[] {
129
+ return seed.peers
130
+ .filter((peer) => peer.preferred && peer.multiaddrs.length > 0 && peer.peerId !== selfPeerId)
131
+ .map(({ peerId, multiaddrs }) => ({ peerId, multiaddrs }));
132
+ }
133
+
134
+ /** Add `relayAddr` to the entry for `selfPeerId` in `peers`, when that entry exists and lacks it. */
135
+ function addSelfRelayAddr(peers: SeedPeer[], selfPeerId: string, relayAddr: string): void {
136
+ const self = peers.find((peer) => peer.peerId === selfPeerId);
137
+ if (self && !self.multiaddrs.includes(relayAddr)) {
138
+ self.multiaddrs.push(relayAddr);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * An {@link ApplySeedResult} for a seed refused before the dial loop ran,
118
144
  * so the dial counters read zero rather than being absent.
119
145
  */
120
146
  function seedRejected(error: string, code?: SeedRefusalCode): ApplySeedResult {
@@ -123,8 +149,8 @@ function seedRejected(error: string, code?: SeedRefusalCode): ApplySeedResult {
123
149
  peersAdded: 0,
124
150
  error,
125
151
  ...(code !== undefined ? { code } : {}),
126
- ownerDialsAttempted: 0,
127
- ownerDialsFailed: 0,
152
+ preferredDialsAttempted: 0,
153
+ preferredDialsFailed: 0,
128
154
  };
129
155
  }
130
156
 
@@ -138,8 +164,8 @@ interface SeedApplyOptions {
138
164
  remotePeerId?: string;
139
165
  }
140
166
 
141
- /** What dialing a seed's owner peers produced — the owner-dial half of an {@link ApplySeedResult}. */
142
- type OwnerDialCounts = Pick<ApplySeedResult, 'ownerDialsAttempted' | 'ownerDialsFailed'>;
167
+ /** What dialing a seed's preferred peers produced — the dial half of an {@link ApplySeedResult}. */
168
+ type PreferredDialCounts = Pick<ApplySeedResult, 'preferredDialsAttempted' | 'preferredDialsFailed'>;
143
169
 
144
170
  /**
145
171
  * Parse each address, dropping (and logging) any that is malformed, so one bad
@@ -209,10 +235,12 @@ function deliveryDialAddrs(peerId: string, addrs: readonly string[]): Multiaddr[
209
235
  export interface SeedBootstrapConfig {
210
236
  /** Party ID for this cadre */
211
237
  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;
238
+ /**
239
+ * The owner signer every seed and owner-signed write of this service goes through
240
+ * (`owner-signer.ts`). Unset for a listener-only service, which receives and applies seeds
241
+ * but refuses every owner write.
242
+ */
243
+ ownerSigner?: OwnerSigner;
216
244
  /**
217
245
  * Trust anchor for incoming seeds. Decides whether a signature-verified
218
246
  * `signerKey` should be trusted, against the receiver's anchored owner
@@ -269,7 +297,7 @@ export interface SeedBootstrapConfig {
269
297
  * sized for the forms that dial inside it. Delivery does not set `runOnLimitedConnection`,
270
298
  * so it does not use a limited relayed connection today; the relayed-dial count is the upper
271
299
  * 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.
300
+ * address. It holds only link work because the receiver acks before it dials the seed's peers.
273
301
  *
274
302
  * NOTE: no transfer allowance — a seed is a peer list of a few KB, and `MAX_SEED_SIZE` (1 MiB)
275
303
  * is a defensive cap. If seeds ever grow toward that cap, add an allowance the way
@@ -278,7 +306,7 @@ export interface SeedBootstrapConfig {
278
306
  seedDeliverTimeoutMs?: number;
279
307
  /**
280
308
  * Time limits for each peer this service dials from a list of addresses —
281
- * {@link SeedBootstrapService.applySeed}'s owner dials and
309
+ * {@link SeedBootstrapService.applySeed}'s dials of the seed's preferred peers and
282
310
  * {@link SeedBootstrapService.deliverSeed}'s dial of a peer id with addresses — per
283
311
  * address and per peer (see `peer-dial.ts`). Defaults to {@link DEFAULT_PEER_DIAL_BUDGET}; a `CadreNode`
284
312
  * passes its `network.controlCohort` limits.
@@ -293,12 +321,13 @@ export interface SeedEventCallbacks {
293
321
  /** Called when a seed is received via the protocol */
294
322
  onSeedReceived?: (partyId: string, peerId: string) => void;
295
323
  /**
296
- * Called when a seed is successfully applied.
324
+ * Called when a seed delivered over the protocol has been verified, merged and
325
+ * acknowledged; the dials of its preferred peers follow, so this waits on no connection.
297
326
  *
298
327
  * `seed` is the applied seed itself: the inbound protocol handler applies it
299
328
  * INSIDE the service, so this callback is the only seam through which a
300
329
  * `CadreNode` sees a wire-delivered seed's contents — which it needs to
301
- * retain the owner-flagged peers as cold-start bootstrap dial targets.
330
+ * retain the preferred peers as cold-start bootstrap dial targets.
302
331
  */
303
332
  onSeedApplied?: (partyId: string, peersAdded: number, seed: ControlNetworkSeed) => void;
304
333
  /** Called when seed application fails */
@@ -316,7 +345,6 @@ export class SeedBootstrapService {
316
345
  private readonly config: SeedBootstrapConfig;
317
346
  private libp2pNode: Libp2p | null = null;
318
347
  private controlDatabase: ControlDatabase | null = null;
319
- private readonly ownerPublicKey: string | null;
320
348
  private readonly trustPolicy: SeedTrustPolicy;
321
349
  private readonly seedReadTimeoutMs: number;
322
350
  private readonly maxConcurrentSeeds: number;
@@ -333,19 +361,6 @@ export class SeedBootstrapService {
333
361
  this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
334
362
  this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
335
363
  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
364
  log('SeedBootstrapService created for party: %s', config.partyId);
350
365
  }
351
366
 
@@ -358,15 +373,19 @@ export class SeedBootstrapService {
358
373
  }
359
374
 
360
375
  /**
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.
376
+ * Whether this service holds an owner signer, i.e. can produce the owner signatures that
377
+ * gate `CadrePeer` / `DeviceToken` inserts, deletes, and re-authorizations. A
378
+ * seed-listener-only service (`enableSeedListener`, no signer) returns false: it can
379
+ * receive/apply seeds but cannot author or re-issue owner writes. Used by the
380
+ * write-while-alone re-replication drain to skip owner work on a non-owner node.
367
381
  */
368
382
  canAuthorize(): boolean {
369
- return !!this.config.ownerPrivateKey;
383
+ return this.config.ownerSigner !== undefined;
384
+ }
385
+
386
+ /** The owner signer this service was built with, or null for a listener-only service. */
387
+ get ownerSigner(): OwnerSigner | null {
388
+ return this.config.ownerSigner ?? null;
370
389
  }
371
390
 
372
391
  /**
@@ -374,7 +393,7 @@ export class SeedBootstrapService {
374
393
  *
375
394
  * `registerHandler` (default true) gates registration of the shared inbound
376
395
  * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
377
- * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
396
+ * (`CadreNode.useOwnerSigner`, `enableSeedListener`) own that handler and leave
378
397
  * it on. The throwaway temp service CadreNode builds in `applySeed` passes
379
398
  * `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
380
399
  * dialing and known-key lookup, and must NOT bind a discarded closure to the
@@ -412,8 +431,11 @@ export class SeedBootstrapService {
412
431
  * `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
413
432
  * the peer's self-signature, so the peer must self-publish (see
414
433
  * {@link CadreNode.registerSelf}) before the row resolves.
434
+ *
435
+ * @param sign - The signature function of an owner approval the caller already holds, so a
436
+ * multi-step action asks once; omitted, this write asks for its own approval.
415
437
  */
416
- async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
438
+ async authorizePeer(options: AuthorizePeerOptions, sign?: OwnerSign): Promise<void> {
417
439
  const { peerId, multiaddrs } = options;
418
440
  log('Authorizing peer: %s', peerId);
419
441
  // CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
@@ -425,7 +447,7 @@ export class SeedBootstrapService {
425
447
  updatedAt: Date.now(),
426
448
  sig: null,
427
449
  profile: null,
428
- });
450
+ }, `Add the machine ${peerId} to the cadre`, sign);
429
451
  log('Peer %s authorized successfully', peerId);
430
452
  }
431
453
 
@@ -442,7 +464,7 @@ export class SeedBootstrapService {
442
464
  * in which case the row in the database is NOT this record and carries whatever
443
465
  * `Sig` that writer had, so the caller must self-update to publish its signature.
444
466
  */
445
- async insertSelfPeerRecord(record: PeerAddressRecord): Promise<boolean> {
467
+ async insertSelfPeerRecord(record: PeerAddressRecord, sign?: OwnerSign): Promise<boolean> {
446
468
  return await this.insertCadrePeerRow({
447
469
  peerId: record.peerId,
448
470
  publicKey: record.publicKey,
@@ -450,19 +472,18 @@ export class SeedBootstrapService {
450
472
  updatedAt: record.updatedAt,
451
473
  sig: record.sig,
452
474
  profile: record.profile,
453
- });
475
+ }, 'Publish this machine\'s own address record', sign);
454
476
  }
455
477
 
456
478
  /**
457
- * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
479
+ * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-signer
458
480
  * precondition; the stamp mint, voucher signature, write lock, existence check and
459
481
  * membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
460
482
  * the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
461
483
  * method: the write is what admits the peer's traffic.
462
484
  *
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).
485
+ * The DB check precedes the owner-signer check here, matching the order this method has
486
+ * always surfaced them.
466
487
  *
467
488
  * @returns `true` when this call performed the INSERT, `false` when the row was
468
489
  * already seated by a concurrent writer. The loser needs to know: an authorize seats
@@ -476,14 +497,11 @@ export class SeedBootstrapService {
476
497
  updatedAt: number;
477
498
  sig: string | null;
478
499
  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
- );
500
+ }, action: string, sign: OwnerSign | undefined): Promise<boolean> {
501
+ const controlDatabase = this.requireControlDatabase();
502
+ const ownerKey = this.requireOwnerSigner().publicKey;
503
+ return await this.withOwnerSign(action, sign, (approved) =>
504
+ controlDatabase.insertCadrePeer(row, ownerKey, approved));
487
505
  }
488
506
 
489
507
  /**
@@ -503,17 +521,17 @@ export class SeedBootstrapService {
503
521
  * fresh signature — unaffected by the cleared row's retired stamp
504
522
  * (`DeviceToken.NotRevoked`).
505
523
  */
506
- async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
524
+ async insertSelfDeviceToken(record: DeviceTokenRecord, sign?: OwnerSign): Promise<void> {
525
+ const ownerKey = this.requireOwnerSigner().publicKey;
526
+ const controlDatabase = this.requireControlDatabase();
507
527
  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(`
528
+ const digestBytes = uint8ArrayFromString(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }), 'base64url');
529
+ const signature = await this.withOwnerSign('Publish this machine\'s push token', sign, (approved) => approved(digestBytes));
530
+ await controlDatabase.execWrite(`
513
531
  insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
514
532
  with context OwnerKey = ?, Signature = ?
515
533
  values (?, ?, ?, ?, ?, ?)
516
- `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
534
+ `, [ownerKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
517
535
  log('Device token inserted (owner-signed): %s', record.peerId);
518
536
  }
519
537
 
@@ -535,79 +553,48 @@ export class SeedBootstrapService {
535
553
  * Both digests, the stamp read, and the transaction come from
536
554
  * {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
537
555
  * `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
556
+ * owner-signer precondition; a no-op on an already-absent row is the shared body's
539
557
  * behavior, and unlike {@link removePeer} nothing here rides on it.
540
558
  */
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
- );
559
+ async deleteDeviceToken(peerId: string, sign?: OwnerSign): Promise<void> {
560
+ // Fail fast on a signerless service BEFORE the DB read, so a non-owner gets the
561
+ // owner-signer error rather than a silent no-op on an absent row.
562
+ const ownerKey = this.requireOwnerSigner().publicKey;
563
+ const controlDatabase = this.requireControlDatabase();
564
+ await this.withOwnerSign(`Clear the push token of ${peerId}`, sign, (approved) =>
565
+ controlDatabase.deleteDeviceToken(peerId, ownerKey, approved));
551
566
  }
552
567
 
553
568
  /**
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.
569
+ * Return the configured owner signer, or throw if none is set. The single precondition
570
+ * gate for every owner-signed write. {@link removePeer} / {@link reauthorizePeer} read the
571
+ * row from the DB BEFORE they sign, so they call this up front — otherwise a signerless
572
+ * service would either surface the wrong "Control database not initialized" error or,
573
+ * worse, silently no-op when the target row is absent (their early absent return) instead
574
+ * of rejecting.
560
575
  */
561
- private requireOwnerPrivateKey(): string {
562
- if (!this.config.ownerPrivateKey) {
563
- throw new Error('Owner private key required to authorize peers');
576
+ private requireOwnerSigner(): OwnerSigner {
577
+ if (!this.config.ownerSigner) {
578
+ throw new Error('Owner signer required to authorize peers');
564
579
  }
565
- return this.config.ownerPrivateKey;
580
+ return this.config.ownerSigner;
566
581
  }
567
582
 
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');
583
+ /** The control database {@link initialize} attached, or a throw. */
584
+ private requireControlDatabase(): ControlDatabase {
585
+ if (!this.controlDatabase) {
586
+ throw new Error('Control database not initialized');
581
587
  }
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;
588
+ return this.controlDatabase;
600
589
  }
601
590
 
602
591
  /**
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".
592
+ * Run `body` with an owner signature function: the caller's `sign` when it passes one (a
593
+ * `CadreNode` action already inside its approval), otherwise one approval of this service's
594
+ * signer for `action`, the plain-words description a prompting signer shows.
608
595
  */
609
- private signMessageBytes(message: Uint8Array): string {
610
- return this.signDigest(uint8ArrayToString(message, 'base64url'));
596
+ private async withOwnerSign<T>(action: string, sign: OwnerSign | undefined, body: (sign: OwnerSign) => Promise<T>): Promise<T> {
597
+ return await withinOwnerApproval(this.requireOwnerSigner(), { partyId: this.config.partyId, action }, sign, body);
611
598
  }
612
599
 
613
600
  /**
@@ -632,16 +619,14 @@ export class SeedBootstrapService {
632
619
  * Both digests, the stamp read, the transaction, and the post-commit membership notify
633
620
  * come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
634
621
  * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
635
- * owner-key precondition and the absent-row gate the notify depends on (below).
622
+ * owner-signer precondition and the absent-row gate the notify depends on (below).
636
623
  */
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
624
+ async removePeer(peerId: string, sign?: OwnerSign): Promise<void> {
625
+ // Fail fast on a signerless service BEFORE any DB read: a non-owner cannot sign the
626
+ // remove digest, and this precedence (owner signer, then control DB) is what the
640
627
  // unit contract asserts.
641
- const ownerKey = this.requireOwnerPublicKey();
642
- if (!this.controlDatabase) {
643
- throw new Error('Control database not initialized');
644
- }
628
+ const ownerKey = this.requireOwnerSigner().publicKey;
629
+ const controlDatabase = this.requireControlDatabase();
645
630
  // This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
646
631
  // internally: deleteCadrePeer's membership notify fires whenever its body resolves,
647
632
  // with no idea whether the body wrote anything, so delegating an absent peer would
@@ -649,7 +634,7 @@ export class SeedBootstrapService {
649
634
  // NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
650
635
  // between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
651
636
  // window only — the common "already absent" case is caught here.
652
- const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
637
+ const stampId = await controlDatabase.queryCadrePeerStampId(peerId);
653
638
  if (stampId === null) {
654
639
  log('removePeer: no CadrePeer row for %s (already absent)', peerId);
655
640
  return;
@@ -657,118 +642,117 @@ export class SeedBootstrapService {
657
642
 
658
643
  log('Removing peer: %s', peerId);
659
644
 
660
- await this.controlDatabase.deleteCadrePeer(
661
- peerId, ownerKey, message => this.signMessageBytes(message),
662
- );
645
+ await this.withOwnerSign(`Remove the machine ${peerId} from the cadre`, sign, (approved) =>
646
+ controlDatabase.deleteCadrePeer(peerId, ownerKey, approved));
663
647
 
664
648
  log('Peer %s removed successfully (stamp retired)', peerId);
665
649
  }
666
650
 
667
651
  /**
668
- * Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
652
+ * Seat `ownerPublicKey` as a further owner, signed by this service's owner signer. The
669
653
  * liveness check, the stamp, the signature and the membership notify are
670
- * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
654
+ * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-signer
671
655
  * precondition, checked before any read (see {@link removePeer}).
672
656
  *
657
+ * @param label - the user's name for the key, bound into the signature
658
+ * ({@link ControlDatabase.insertOwnerKey} has the rule).
673
659
  * @returns `true` when the row was seated, `false` when the key is already a live owner.
674
660
  */
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
- );
661
+ async addOwner(ownerPublicKey: string, label?: string, sign?: OwnerSign): Promise<boolean> {
662
+ const ownerKey = this.requireOwnerSigner().publicKey;
663
+ const controlDatabase = this.requireControlDatabase();
664
+ // Checked before the approval opens, so a label the row would refuse never prompts the user.
665
+ const storedLabel = ownerKeyLabel(label);
666
+ return await this.withOwnerSign(`Add owner key ${namedOwnerKey(ownerPublicKey, storedLabel)}`, sign, (approved) =>
667
+ controlDatabase.insertOwnerKeyVouched(ownerPublicKey, ownerKey, approved, storedLabel));
683
668
  }
684
669
 
685
670
  /**
686
- * Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
671
+ * Remove `ownerPublicKey` from the owners, signed by this service's owner signer: the delete
687
672
  * and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
688
673
  * the last owner), the digests and the membership notify are
689
- * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
674
+ * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-signer precondition,
690
675
  * checked before any read (see {@link removePeer}).
691
676
  *
692
677
  * @returns `true` when the row was removed, `false` when the key is not a live owner here.
693
678
  */
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
- );
679
+ async removeOwner(ownerPublicKey: string, sign?: OwnerSign): Promise<boolean> {
680
+ const ownerKey = this.requireOwnerSigner().publicKey;
681
+ const controlDatabase = this.requireControlDatabase();
682
+ return await this.withOwnerSign(`Remove owner key ${ownerPublicKey}`, sign, (approved) =>
683
+ controlDatabase.deleteOwnerKey(ownerPublicKey, ownerKey, approved));
702
684
  }
703
685
 
704
686
  /**
705
- * Re-vouch another owner's row under this service's owner key, so it stands on a remaining
687
+ * Re-vouch another owner's row under this service's owner signer, so it stands on a remaining
706
688
  * 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}).
689
+ * ({@link ControlDatabase.revouchOwnerKey}; for `CadreNode.removeOwner` only). What stays
690
+ * here is the owner-signer precondition, checked before any read (see {@link removePeer}).
709
691
  *
710
692
  * @returns `true` when the row was re-vouched, `false` when the key is not a live owner here.
711
693
  */
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
- );
694
+ async revouchOwner(ownerPublicKey: string, sign?: OwnerSign): Promise<boolean> {
695
+ const ownerKey = this.requireOwnerSigner().publicKey;
696
+ const controlDatabase = this.requireControlDatabase();
697
+ return await this.withOwnerSign(`Re-vouch owner key ${ownerPublicKey}`, sign, (approved) =>
698
+ controlDatabase.revouchOwnerKey(ownerPublicKey, ownerKey, approved));
720
699
  }
721
700
 
722
701
  /**
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}).
702
+ * Remove a machine completely, signed by this service's owner signer: its `CadrePeer` row, its
703
+ * `DeviceToken` row, and the withdrawal of every invitation in `target.inviteKeys`, all in one
704
+ * transaction ({@link ControlDatabase.removeMachine}). What stays here is the owner-signer
705
+ * precondition, checked before any read (see {@link removePeer}).
728
706
  */
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
- );
707
+ async removeMachine(target: Parameters<ControlDatabase['removeMachine']>[0], sign?: OwnerSign): Promise<MachineRemovalResult> {
708
+ const ownerKey = this.requireOwnerSigner().publicKey;
709
+ const controlDatabase = this.requireControlDatabase();
710
+ return await this.withOwnerSign(`Remove the machine ${target.peerId} from the cadre`, sign, (approved) =>
711
+ controlDatabase.removeMachine(target, ownerKey, approved));
737
712
  }
738
713
 
739
714
  /**
740
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
741
- * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
742
- * the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
743
- * a membership row that committed local-only (its block's cluster ≤1 at insert) is
744
- * pushed to the cohort once it grows.
715
+ * Owner "re-touch" of an existing `CadrePeer` membership row, in one of two modes
716
+ * ({@link CadrePeerRetouchMode}): `'rebroadcast'` bumps `UpdatedAt` and re-vouches the row so
717
+ * it is re-emitted as a fresh, broadcasting transaction — the write-while-alone re-replication
718
+ * primitive (`control-write-ensure-replicated`): a membership row that committed local-only
719
+ * (its block's cluster ≤1 at insert) is pushed to the cohort once it grows — and writes only
720
+ * while the machine has not self-published; `'revouch'` rebinds the voucher to this node's
721
+ * owner key, leaving `UpdatedAt` alone, whether or not the machine has self-published
722
+ * (`CadreNode.removeOwner`).
745
723
  *
746
- * The stamp read, the voucher signature, the write lock and the membership notify are
724
+ * The row read, the mode's decision, the stamp bump, the voucher signature, the write lock,
725
+ * the lost-update retry and the membership notify are
747
726
  * {@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.
749
- *
750
- * A no-op (no throw, no notify) when the row does not exist.
727
+ * `VouchOwner` to this node's owner key. What stays here is the owner-signer precondition.
751
728
  *
752
729
  * @param peerId - the membership row to re-touch.
753
- * @param updatedAt - the strictly-increasing freshness stamp to write.
754
- * @throws if no owner private key is configured (a non-owner cannot
730
+ * @param mode - re-broadcast or re-vouch.
731
+ * @returns what was found and done ({@link CadrePeerRetouchOutcome}); an absent row, or a
732
+ * self-published one in `'rebroadcast'` mode, writes nothing and does not throw.
733
+ * @throws if no owner signer is configured (a non-owner cannot
755
734
  * re-sign another peer's row) or the control database is not initialized.
756
735
  */
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
736
+ async reauthorizePeer(peerId: string, mode: CadrePeerRetouchMode, sign?: OwnerSign): Promise<CadrePeerRetouchOutcome> {
737
+ // Fail fast on a signerless service before any DB read (see removePeer): a non-owner
759
738
  // 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
- );
767
- if (!retouched) {
768
- log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
769
- return;
739
+ const ownerKey = this.requireOwnerSigner().publicKey;
740
+ const controlDatabase = this.requireControlDatabase();
741
+ const action = mode === 'revouch' ? `Re-vouch the machine ${peerId}` : `Re-issue the membership of ${peerId}`;
742
+ const outcome = await this.withOwnerSign(action, sign, (approved) =>
743
+ controlDatabase.reauthorizeCadrePeer(peerId, mode, ownerKey, approved));
744
+ switch (outcome) {
745
+ case 'absent':
746
+ log('reauthorizePeer(%s): no CadrePeer row for %s (nothing to re-touch)', mode, peerId);
747
+ break;
748
+ case 'self-published':
749
+ log('reauthorizePeer(%s): %s has self-published; its row is its own to refresh', mode, peerId);
750
+ break;
751
+ case 'retouched':
752
+ log('Peer %s re-authorized (%s)', peerId, mode);
753
+ break;
770
754
  }
771
- log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
755
+ return outcome;
772
756
  }
773
757
 
774
758
  /**
@@ -781,123 +765,140 @@ export class SeedBootstrapService {
781
765
  *
782
766
  * The signatures, the single transaction, and the strictly-monotonic
783
767
  * `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
784
- * stays here is the owner-key precondition.
768
+ * stays here is the owner-signer precondition.
785
769
  *
786
770
  * @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
771
+ * @throws if no owner signer is configured or the control database is not
788
772
  * initialized.
789
773
  */
790
- async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number): Promise<number> {
791
- // Fail fast on a keyless service before any DB work (see removePeer): a
774
+ async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number, sign?: OwnerSign): Promise<number> {
775
+ // Fail fast on a signerless service before any DB work (see removePeer): a
792
776
  // 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
- );
800
- }
801
-
802
- /**
803
- * Owner filing of the singleton `Revocation` ledger marker, so the table is never a
804
- * never-written block that the storage layer re-consults on every read.
805
- *
806
- * 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
808
- * precondition.
809
- *
810
- * @throws if no owner private key is configured or the control database is not
811
- * initialized.
812
- */
813
- async openRevocationLedger(): Promise<RevocationLedgerOpenResult> {
814
- // Fail fast on a keyless service before any DB work (see removePeer): a
815
- // 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
- );
777
+ const ownerKey = this.requireOwnerSigner().publicKey;
778
+ const controlDatabase = this.requireControlDatabase();
779
+ return await this.withOwnerSign(`Re-send ${rows.length} removal record(s) to the cadre`, sign, (approved) =>
780
+ controlDatabase.reissueRevocations(rows, reissuedAt, ownerKey, approved));
823
781
  }
824
782
 
825
783
  /**
826
784
  * Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
827
785
  * on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
828
786
  * 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.
787
+ * `CadreNode.createCadreInvitation`'s; what stays here is the owner-signer precondition and
788
+ * the approval, as {@link insertSelfDeviceToken} keeps them for device tokens.
831
789
  *
832
790
  * @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.
791
+ * @throws if no owner signer is configured or the control database is not initialized.
834
792
  */
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));
793
+ async insertCadreInvite(invite: Parameters<ControlDatabase['insertCadreInvite']>[0], sign?: OwnerSign): Promise<CadreInviteRow> {
794
+ // Fail fast on a signerless service before any DB work (see removePeer).
795
+ const ownerKey = this.requireOwnerSigner().publicKey;
796
+ const controlDatabase = this.requireControlDatabase();
797
+ return await this.withOwnerSign('Create a machine invitation to the cadre', sign, (approved) =>
798
+ controlDatabase.insertCadreInvite(invite, ownerKey, approved));
842
799
  }
843
800
 
844
801
  /**
845
802
  * Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
846
803
  * the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
847
- * owner-key precondition.
804
+ * owner-signer precondition.
848
805
  *
849
806
  * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
850
807
  * already withdrawn.
851
- * @throws if no owner private key is configured or the control database is not initialized.
808
+ * @throws if no owner signer is configured or the control database is not initialized.
852
809
  */
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));
810
+ async withdrawCadreInvite(key: string, sign?: OwnerSign): Promise<boolean> {
811
+ const ownerKey = this.requireOwnerSigner().publicKey;
812
+ const controlDatabase = this.requireControlDatabase();
813
+ return await this.withOwnerSign(`Withdraw the cadre invitation ${key}`, sign, (approved) =>
814
+ controlDatabase.withdrawCadreInvite(key, ownerKey, approved));
815
+ }
816
+
817
+ /**
818
+ * Owner-signed INSERT of an `OwnerGrant` row — a grant of ownership any member machine can
819
+ * redeem on this owner's behalf ({@link ControlDatabase.insertOwnerGrant}). The keypair mint,
820
+ * the lifetime default and the bundle the holder receives are `CadreNode.createOwnerGrant`'s;
821
+ * what stays here is the owner-signer precondition and the approval, as for
822
+ * {@link insertCadreInvite}. The label is checked before the approval opens, so one the row
823
+ * would refuse never prompts the user.
824
+ *
825
+ * @returns the row as stored, which the grant bundle carries verbatim.
826
+ * @throws if no owner signer is configured, the control database is not initialized, or the label is too long.
827
+ */
828
+ async insertOwnerGrant(grant: Parameters<ControlDatabase['insertOwnerGrant']>[0], sign?: OwnerSign): Promise<OwnerGrantRow> {
829
+ const ownerKey = this.requireOwnerSigner().publicKey;
830
+ const controlDatabase = this.requireControlDatabase();
831
+ const label = ownerKeyLabel(grant.label);
832
+ const named = label === null ? 'an owner grant' : `the owner grant "${label}"`;
833
+ return await this.withOwnerSign(`Create ${named}: whoever holds it can become an owner of the cadre`, sign, (approved) =>
834
+ controlDatabase.insertOwnerGrant({ ...grant, label }, ownerKey, approved));
835
+ }
836
+
837
+ /**
838
+ * Owner-signed WITHDRAWAL of an `OwnerGrant`: the `Revocation` tombstone over its stamp, with
839
+ * the row kept ({@link ControlDatabase.withdrawOwnerGrant}). What stays here is the
840
+ * owner-signer precondition.
841
+ *
842
+ * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
843
+ * already withdrawn.
844
+ * @throws if no owner signer is configured or the control database is not initialized.
845
+ */
846
+ async withdrawOwnerGrant(key: string, sign?: OwnerSign): Promise<boolean> {
847
+ const ownerKey = this.requireOwnerSigner().publicKey;
848
+ const controlDatabase = this.requireControlDatabase();
849
+ return await this.withOwnerSign(`Withdraw the owner grant ${key}`, sign, (approved) =>
850
+ controlDatabase.withdrawOwnerGrant(key, ownerKey, approved));
859
851
  }
860
852
 
861
853
  /**
862
854
  * Create a seed from the current control network state.
863
- * The seed contains peer information and is signed by an owner.
855
+ * The seed contains peer information and is signed by an owner; its `signerKey` is the
856
+ * owner signer's key.
864
857
  */
865
- async createSeed(): Promise<ControlNetworkSeed> {
866
- if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
858
+ async createSeed(sign?: OwnerSign): Promise<ControlNetworkSeed> {
859
+ return await this.mintSeed(sign, null);
860
+ }
861
+
862
+ /**
863
+ * {@link createSeed}, with `selfRelayAddr` added to this node's own peer entry before the
864
+ * seed is signed: the signature covers every address ({@link seedDigest}), so nothing may
865
+ * be added after it.
866
+ */
867
+ private async mintSeed(sign: OwnerSign | undefined, selfRelayAddr: string | null): Promise<ControlNetworkSeed> {
868
+ const signer = this.config.ownerSigner;
869
+ if (!signer) {
867
870
  throw new Error('Owner key required to create seeds');
868
871
  }
869
-
872
+
870
873
  if (!this.controlDatabase || !this.libp2pNode) {
871
874
  throw new Error('Service not initialized');
872
875
  }
873
-
876
+
874
877
  log('Creating seed for party: %s', this.config.partyId);
875
-
878
+
876
879
  // Query all peers from the control database
877
- const peers = await this.queryPeers();
878
-
880
+ const selfPeerId = this.libp2pNode.peerId.toString();
881
+ const peers = await this.queryPeers(selfPeerId);
882
+ if (selfRelayAddr !== null) {
883
+ addSelfRelayAddr(peers, selfPeerId, selfRelayAddr);
884
+ }
885
+
879
886
  // Create the seed data (without signature)
880
887
  const seedData = {
881
888
  partyId: this.config.partyId,
882
889
  peers,
883
890
  };
884
-
891
+
885
892
  // 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
-
893
+ const digestBytes = uint8ArrayFromString(seedDigest(seedData), 'base64url');
894
+ const signature = await this.withOwnerSign('Mint a seed for a machine joining the cadre', sign, (approved) => approved(digestBytes));
895
+
895
896
  const seed: ControlNetworkSeed = {
896
897
  ...seedData,
897
898
  signature,
898
- signerKey: this.ownerPublicKey,
899
+ signerKey: signer.publicKey,
899
900
  };
900
-
901
+
901
902
  log('Created seed with %d peers', peers.length);
902
903
  return seed;
903
904
  }
@@ -909,9 +910,8 @@ export class SeedBootstrapService {
909
910
  * `signerKey` that does NOT come from the seed body: the receiver's
910
911
  * node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
911
912
  * 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.
913
+ * `SeedTrustPolicy`. A forged self-signed seed does not pass, and neither does
914
+ * one signed by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
915
915
  *
916
916
  * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
917
917
  * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
@@ -930,13 +930,13 @@ export class SeedBootstrapService {
930
930
  if (!merged.success) {
931
931
  return merged;
932
932
  }
933
- return { ...merged, ...await this.dialSeedOwners(seed) };
933
+ return { ...merged, ...await this.dialPreferredPeers(seed) };
934
934
  }
935
935
 
936
936
  /**
937
937
  * The first half of {@link applySeed}: check the signature and the signer's trust, then merge
938
938
  * 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.
939
+ * what the inbound handler acks with; the dials that follow cannot change it.
940
940
  */
941
941
  private async verifyAndMergeSeed(
942
942
  seed: ControlNetworkSeed,
@@ -976,7 +976,7 @@ export class SeedBootstrapService {
976
976
  partyId: seed.partyId,
977
977
  signerKey: seed.signerKey,
978
978
  knownOwnerKeys,
979
- // Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
979
+ // Optional-chained like `dialPreferredPeers`: partial libp2p handles (unit-test doubles)
980
980
  // omit `peerId`. A real node always has one, and a proof bound to a real peer id can
981
981
  // only fail against '' — the safe direction.
982
982
  localPeerId: this.libp2pNode.peerId?.toString() ?? '',
@@ -991,55 +991,52 @@ export class SeedBootstrapService {
991
991
 
992
992
  const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
993
993
  log('Merged seed: %d peers added', peersAdded);
994
- return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
994
+ return { success: true, peersAdded, preferredDialsAttempted: 0, preferredDialsFailed: 0 };
995
995
  }
996
996
 
997
997
  /**
998
- * The second half of {@link applySeed}: dial the seed's owner peers to establish connections.
998
+ * The second half of {@link applySeed}: dial the seed's preferred peers (`SeedPeer.preferred`:
999
+ * the machine that minted it and every always-on machine) to establish connections.
999
1000
  *
1000
- * Best-effort and COUNTED: an owner that is momentarily down leaves this node seeded but
1001
+ * Best-effort and COUNTED: a peer that is momentarily down leaves this node seeded but
1001
1002
  * unconnected, which the caller can only see if the outcome is reported (see
1002
- * `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
1003
+ * `ApplySeedResult.preferredDialsFailed`). Recovery is not this loop's job —
1003
1004
  * `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
1004
1005
  * reconcile pass until the control database has siblings.
1005
1006
  *
1006
- * The inbound handler runs this AFTER it has acked and closed the stream
1007
- * ({@link handleSeedStream}), because an unreachable owner can take `dialBudget.totalMs`.
1007
+ * The inbound handler runs this AFTER it has acked, closed the stream and reported the seed
1008
+ * applied ({@link handleSeedStream}), because an unreachable peer can take `dialBudget.totalMs`.
1009
+ * The node may stop while these dials are in flight (cadre-cli restarts a claimed node on
1010
+ * `seed:applied`): the node's stop aborts the dial in flight and every later one is refused
1011
+ * at once (`dialAddr`), and each is counted as failed here.
1008
1012
  */
1009
- private async dialSeedOwners(seed: ControlNetworkSeed): Promise<OwnerDialCounts> {
1013
+ private async dialPreferredPeers(seed: ControlNetworkSeed): Promise<PreferredDialCounts> {
1010
1014
  const node = this.libp2pNode;
1011
1015
  if (!node) {
1012
- log('Seed service shut down before its owner dials; none attempted');
1013
- return { ownerDialsAttempted: 0, ownerDialsFailed: 0 };
1016
+ log('Seed service shut down before its dials; none attempted');
1017
+ return { preferredDialsAttempted: 0, preferredDialsFailed: 0 };
1014
1018
  }
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".
1018
- // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
1019
- // 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
1019
+ // Self is not dialed: dialing self always throws, which would report a healthy node as
1020
+ // "seeded but stranded". Optional-chained: partial libp2p handles (unit-test doubles)
1021
+ // omit `peerId`.
1022
+ // Every one of a peer's addresses is a candidate, each on its own time limit
1023
+ // (`dialPeerAddrs`), so a peer whose first address never answers neither
1022
1024
  // 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.
1024
- const selfPeerId = node.peerId?.toString();
1025
- let ownerDialsAttempted = 0;
1026
- let ownerDialsFailed = 0;
1027
- for (const peer of seed.peers.filter(p => p.isOwner)) {
1028
- if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
1029
- continue;
1030
- }
1031
- ownerDialsAttempted++;
1025
+ // addresses carry no peer id; the dial binds each to the peer's.
1026
+ const targets = seedDialTargets(seed, node.peerId?.toString());
1027
+ let preferredDialsFailed = 0;
1028
+ for (const peer of targets) {
1032
1029
  try {
1033
1030
  const addrs = parseDialAddrs(peer.multiaddrs);
1034
1031
 
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}`);
1032
+ log('Dialing preferred seed peer: %s (%d addr(s))', peer.peerId, addrs.length);
1033
+ await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `Seed dial of ${peer.peerId}`);
1037
1034
  } 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++;
1035
+ // Counted even when the peer reaches us only through our own relay: this node is still
1036
+ // not connected to it, and `preferredDialsFailed` is how the caller learns that.
1037
+ preferredDialsFailed++;
1041
1038
  if (error instanceof SelfRelayOnlyError) {
1042
- log('Owner peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
1039
+ log('Seed peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
1043
1040
  } else {
1044
1041
  log('Failed to dial peer %s: %o', peer.peerId, error);
1045
1042
  }
@@ -1047,8 +1044,8 @@ export class SeedBootstrapService {
1047
1044
  }
1048
1045
  }
1049
1046
 
1050
- log('Dialed seed owners: %d/%d owner dial(s) failed', ownerDialsFailed, ownerDialsAttempted);
1051
- return { ownerDialsAttempted, ownerDialsFailed };
1047
+ log('Dialed preferred seed peers: %d/%d dial(s) failed', preferredDialsFailed, targets.length);
1048
+ return { preferredDialsAttempted: targets.length, preferredDialsFailed };
1052
1049
  }
1053
1050
 
1054
1051
  /**
@@ -1165,7 +1162,7 @@ export class SeedBootstrapService {
1165
1162
  }
1166
1163
  const peerId = peerIdFromString(target.peerId);
1167
1164
  const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
1168
- await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
1165
+ await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, preferred: false }]);
1169
1166
  if (addrs.length === 0) {
1170
1167
  return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
1171
1168
  }
@@ -1295,16 +1292,16 @@ export class SeedBootstrapService {
1295
1292
  }
1296
1293
 
1297
1294
  /**
1298
- * The peers a seed carries: the control database's own projection
1295
+ * The peers a seed `minterPeerId` mints carries: the control database's own projection
1299
1296
  * ({@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`).
1297
+ * reply), which reads through the revocation filter and marks the minter and every
1298
+ * always-on machine preferred — a dial hint, not a trust decision.
1302
1299
  */
1303
- private async queryPeers(): Promise<SeedPeer[]> {
1300
+ private async queryPeers(minterPeerId: string): Promise<SeedPeer[]> {
1304
1301
  if (!this.controlDatabase) {
1305
1302
  return [];
1306
1303
  }
1307
- return await this.controlDatabase.querySeedPeers();
1304
+ return await this.controlDatabase.querySeedPeers(minterPeerId);
1308
1305
  }
1309
1306
 
1310
1307
  /**
@@ -1323,14 +1320,16 @@ export class SeedBootstrapService {
1323
1320
 
1324
1321
  /**
1325
1322
  * Read one inbound seed frame, verify and merge it, write the ack and close the
1326
- * stream, then dial the seed's owners.
1323
+ * stream, report the seed applied, then dial the seed's preferred peers.
1327
1324
  *
1328
- * The ack and the close come BEFORE the owner dials: an unreachable owner can hold
1329
- * a dial for `dialBudget.totalMs` (86 s at the default declared link), which is not
1330
- * link time and does not belong inside the sender's delivery deadline. The close is
1331
- * what releases the sender, which reads the ack to end-of-stream. The stream stays
1332
- * counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
1333
- * still bounds concurrent owner-dial phases.
1325
+ * The ack, the close and `onSeedApplied` come BEFORE those dials: an unreachable peer
1326
+ * can hold a dial for `dialBudget.totalMs` (86 s at the default declared link), which is
1327
+ * not link time and does not belong inside the sender's delivery deadline, nor ahead of
1328
+ * a claimed node's restart (which stops this node, so its dials would be wasted). The
1329
+ * close is what releases the sender, which reads the ack to end-of-stream, and it is
1330
+ * awaited before `onSeedApplied`, so the ack is flushed before anything the callback
1331
+ * starts. The stream stays counted in {@link activeStreams} through the dials, so
1332
+ * {@link maxConcurrentSeeds} still bounds concurrent dial phases.
1334
1333
  *
1335
1334
  * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
1336
1335
  * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
@@ -1367,8 +1366,8 @@ export class SeedBootstrapService {
1367
1366
  );
1368
1367
 
1369
1368
  if (merged.success) {
1370
- await this.dialSeedOwners(seed);
1371
1369
  this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
1370
+ await this.dialPreferredPeers(seed);
1372
1371
  } else {
1373
1372
  this.eventCallbacks.onSeedError?.(seed.partyId, merged.error ?? 'Unknown error');
1374
1373
  }
@@ -1440,19 +1439,22 @@ export class SeedBootstrapService {
1440
1439
  * does not listen. Call `CadreNode.reconcileControlCohort()` after delivering
1441
1440
  * the seed to dial at once.
1442
1441
  *
1442
+ * The vouch and the seed are one owner approval.
1443
+ *
1443
1444
  * @param options - Drone peer info from provider API
1444
1445
  * @returns Seed and encoded seed for drone initialization
1445
1446
  */
1446
- async addDrone(options: AddDroneOptions): Promise<DroneInitResult> {
1447
+ async addDrone(options: AddDroneOptions, sign?: OwnerSign): Promise<DroneInitResult> {
1447
1448
  const { dronePeerId, droneMultiaddrs } = options;
1448
1449
 
1449
1450
  log('Adding drone: %s', dronePeerId);
1450
1451
 
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();
1452
+ const seed = await this.withOwnerSign(`Add the machine ${dronePeerId} to the cadre`, sign, async (approved) => {
1453
+ // 1. Authorize the new drone peer
1454
+ await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs }, approved);
1455
+ // 2. Create seed with current state
1456
+ return await this.createSeed(approved);
1457
+ });
1456
1458
 
1457
1459
  // 3. Encode for transport
1458
1460
  const encodedSeed = this.encodeSeed(seed);
@@ -1469,29 +1471,21 @@ export class SeedBootstrapService {
1469
1471
  * 1. Authorizes the new phone peer
1470
1472
  * 2. Creates a seed with relay addresses for dialing
1471
1473
  *
1474
+ * The vouch and the seed are one owner approval.
1475
+ *
1472
1476
  * @param phonePeerId - Peer ID of the new phone
1473
1477
  * @returns Seed with relay addresses for out-of-band delivery
1474
1478
  */
1475
- async addPhoneWithRelay(phonePeerId: string): Promise<DroneInitResult> {
1479
+ async addPhoneWithRelay(phonePeerId: string, sign?: OwnerSign): Promise<DroneInitResult> {
1476
1480
  log('Adding phone with relay: %s', phonePeerId);
1477
1481
 
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
1482
  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
- }
1483
+ const seed = await this.withOwnerSign(`Add the phone ${phonePeerId} to the cadre`, sign, async (approved) => {
1484
+ // 1. Authorize the new phone peer (no multiaddrs - NAT'd)
1485
+ await this.authorizePeer({ peerId: phonePeerId }, approved);
1486
+ // 2. Create seed - with our relay address in our own entry, when we have one
1487
+ return await this.mintSeed(approved, relayAddr);
1488
+ });
1495
1489
 
1496
1490
  const encodedSeed = this.encodeSeed(seed);
1497
1491