@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,16 +1,18 @@
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 { multiaddr } from '@multiformats/multiaddr';
5
5
  import { peerIdFromString } from '@libp2p/peer-id';
6
6
  import { withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
7
7
  import { dialPeerAddrs, SelfRelayOnlyError, PeerUnreachableError, DEFAULT_PEER_DIAL_BUDGET, withTrailingPeerId, } from './peer-dial.js';
8
8
  import { relayedRequestBudgetMs } from './link-budget.js';
9
- import { generateStampId } from './control-database.js';
9
+ import { generateStampId, ownerKeyLabel } from './control-database.js';
10
10
  import { canonicalJson } from './canonical-json.js';
11
11
  import { deviceTokenAddDigest } from './peer-authorization.js';
12
12
  import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
13
13
  import { anchoredTrustPolicy, } from './seed-trust-policy.js';
14
+ import { withinOwnerApproval } from './owner-signer.js';
15
+ import { namedOwnerKey } from './owner-keys.js';
14
16
  const log = debug('sereus:cadre:seed-bootstrap');
15
17
  /** Protocol ID for seed delivery */
16
18
  export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
@@ -71,7 +73,27 @@ export function seedDigest(seed) {
71
73
  return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url');
72
74
  }
73
75
  /**
74
- * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
76
+ * The peers `seed` nominates for dialing: its preferred peers (`SeedPeer.preferred`: the machine
77
+ * that minted it and every always-on machine) that have an address and are not `selfPeerId`.
78
+ *
79
+ * Self is dropped because `createSeed` projects every non-revoked CadrePeer row, so a preferred
80
+ * machine applying a seed minted after it joined finds itself in the list, and a dial of self
81
+ * always throws. An undefined `selfPeerId` matches nothing.
82
+ */
83
+ export function seedDialTargets(seed, selfPeerId) {
84
+ return seed.peers
85
+ .filter((peer) => peer.preferred && peer.multiaddrs.length > 0 && peer.peerId !== selfPeerId)
86
+ .map(({ peerId, multiaddrs }) => ({ peerId, multiaddrs }));
87
+ }
88
+ /** Add `relayAddr` to the entry for `selfPeerId` in `peers`, when that entry exists and lacks it. */
89
+ function addSelfRelayAddr(peers, selfPeerId, relayAddr) {
90
+ const self = peers.find((peer) => peer.peerId === selfPeerId);
91
+ if (self && !self.multiaddrs.includes(relayAddr)) {
92
+ self.multiaddrs.push(relayAddr);
93
+ }
94
+ }
95
+ /**
96
+ * An {@link ApplySeedResult} for a seed refused before the dial loop ran,
75
97
  * so the dial counters read zero rather than being absent.
76
98
  */
77
99
  function seedRejected(error, code) {
@@ -80,8 +102,8 @@ function seedRejected(error, code) {
80
102
  peersAdded: 0,
81
103
  error,
82
104
  ...(code !== undefined ? { code } : {}),
83
- ownerDialsAttempted: 0,
84
- ownerDialsFailed: 0,
105
+ preferredDialsAttempted: 0,
106
+ preferredDialsFailed: 0,
85
107
  };
86
108
  }
87
109
  /**
@@ -162,13 +184,6 @@ export class SeedBootstrapService {
162
184
  this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
163
185
  this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
164
186
  this.dialBudget = config.dialBudget ?? DEFAULT_PEER_DIAL_BUDGET;
165
- // Derive public key from private key if not provided
166
- if (config.ownerPrivateKey && !config.ownerPublicKey) {
167
- this.ownerPublicKey = getPublicKey(config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url');
168
- }
169
- else {
170
- this.ownerPublicKey = config.ownerPublicKey ?? null;
171
- }
172
187
  log('SeedBootstrapService created for party: %s', config.partyId);
173
188
  }
174
189
  /**
@@ -179,22 +194,25 @@ export class SeedBootstrapService {
179
194
  this.eventCallbacks = callbacks;
180
195
  }
181
196
  /**
182
- * Whether this service holds an owner private key, i.e. can produce the
183
- * owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
184
- * and re-authorizations. A seed-listener-only service (`enableSeedListener`,
185
- * no owner key) returns false: it can receive/apply seeds but cannot author
186
- * or re-issue owner writes. Used by the write-while-alone re-replication
187
- * drain to skip owner work on a non-owner node.
197
+ * Whether this service holds an owner signer, i.e. can produce the owner signatures that
198
+ * gate `CadrePeer` / `DeviceToken` inserts, deletes, and re-authorizations. A
199
+ * seed-listener-only service (`enableSeedListener`, no signer) returns false: it can
200
+ * receive/apply seeds but cannot author or re-issue owner writes. Used by the
201
+ * write-while-alone re-replication drain to skip owner work on a non-owner node.
188
202
  */
189
203
  canAuthorize() {
190
- return !!this.config.ownerPrivateKey;
204
+ return this.config.ownerSigner !== undefined;
205
+ }
206
+ /** The owner signer this service was built with, or null for a listener-only service. */
207
+ get ownerSigner() {
208
+ return this.config.ownerSigner ?? null;
191
209
  }
192
210
  /**
193
211
  * Initialize the service with libp2p node and control database.
194
212
  *
195
213
  * `registerHandler` (default true) gates registration of the shared inbound
196
214
  * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
197
- * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
215
+ * (`CadreNode.useOwnerSigner`, `enableSeedListener`) own that handler and leave
198
216
  * it on. The throwaway temp service CadreNode builds in `applySeed` passes
199
217
  * `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
200
218
  * dialing and known-key lookup, and must NOT bind a discarded closure to the
@@ -226,8 +244,11 @@ export class SeedBootstrapService {
226
244
  * `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
227
245
  * the peer's self-signature, so the peer must self-publish (see
228
246
  * {@link CadreNode.registerSelf}) before the row resolves.
247
+ *
248
+ * @param sign - The signature function of an owner approval the caller already holds, so a
249
+ * multi-step action asks once; omitted, this write asks for its own approval.
229
250
  */
230
- async authorizePeer(options) {
251
+ async authorizePeer(options, sign) {
231
252
  const { peerId, multiaddrs } = options;
232
253
  log('Authorizing peer: %s', peerId);
233
254
  // CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
@@ -239,7 +260,7 @@ export class SeedBootstrapService {
239
260
  updatedAt: Date.now(),
240
261
  sig: null,
241
262
  profile: null,
242
- });
263
+ }, `Add the machine ${peerId} to the cadre`, sign);
243
264
  log('Peer %s authorized successfully', peerId);
244
265
  }
245
266
  /**
@@ -255,7 +276,7 @@ export class SeedBootstrapService {
255
276
  * in which case the row in the database is NOT this record and carries whatever
256
277
  * `Sig` that writer had, so the caller must self-update to publish its signature.
257
278
  */
258
- async insertSelfPeerRecord(record) {
279
+ async insertSelfPeerRecord(record, sign) {
259
280
  return await this.insertCadrePeerRow({
260
281
  peerId: record.peerId,
261
282
  publicKey: record.publicKey,
@@ -263,30 +284,27 @@ export class SeedBootstrapService {
263
284
  updatedAt: record.updatedAt,
264
285
  sig: record.sig,
265
286
  profile: record.profile,
266
- });
287
+ }, 'Publish this machine\'s own address record', sign);
267
288
  }
268
289
  /**
269
- * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
290
+ * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-signer
270
291
  * precondition; the stamp mint, voucher signature, write lock, existence check and
271
292
  * membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
272
293
  * the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
273
294
  * method: the write is what admits the peer's traffic.
274
295
  *
275
- * The DB check precedes the owner-key check here, matching the order this method has
276
- * always surfaced them (the owner-key error used to come out of the signer, after the
277
- * DB check).
296
+ * The DB check precedes the owner-signer check here, matching the order this method has
297
+ * always surfaced them.
278
298
  *
279
299
  * @returns `true` when this call performed the INSERT, `false` when the row was
280
300
  * already seated by a concurrent writer. The loser needs to know: an authorize seats
281
301
  * a row with a null `Sig`, so a self-publish that lost the race must fall through to
282
302
  * a self-update or its record never lands.
283
303
  */
284
- async insertCadrePeerRow(row) {
285
- if (!this.controlDatabase) {
286
- throw new Error('Control database not initialized');
287
- }
288
- const ownerKey = this.requireOwnerPublicKey();
289
- return await this.controlDatabase.insertCadrePeer(row, ownerKey, message => this.signMessageBytes(message));
304
+ async insertCadrePeerRow(row, action, sign) {
305
+ const controlDatabase = this.requireControlDatabase();
306
+ const ownerKey = this.requireOwnerSigner().publicKey;
307
+ return await this.withOwnerSign(action, sign, (approved) => controlDatabase.insertCadrePeer(row, ownerKey, approved));
290
308
  }
291
309
  /**
292
310
  * Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
@@ -305,17 +323,17 @@ export class SeedBootstrapService {
305
323
  * fresh signature — unaffected by the cleared row's retired stamp
306
324
  * (`DeviceToken.NotRevoked`).
307
325
  */
308
- async insertSelfDeviceToken(record) {
326
+ async insertSelfDeviceToken(record, sign) {
327
+ const ownerKey = this.requireOwnerSigner().publicKey;
328
+ const controlDatabase = this.requireControlDatabase();
309
329
  const stampId = generateStampId(record.peerId);
310
- const signature = this.signDigest(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }));
311
- if (!this.controlDatabase) {
312
- throw new Error('Control database not initialized');
313
- }
314
- await this.controlDatabase.execWrite(`
330
+ const digestBytes = uint8ArrayFromString(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }), 'base64url');
331
+ const signature = await this.withOwnerSign('Publish this machine\'s push token', sign, (approved) => approved(digestBytes));
332
+ await controlDatabase.execWrite(`
315
333
  insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
316
334
  with context OwnerKey = ?, Signature = ?
317
335
  values (?, ?, ?, ?, ?, ?)
318
- `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
336
+ `, [ownerKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
319
337
  log('Device token inserted (owner-signed): %s', record.peerId);
320
338
  }
321
339
  /**
@@ -336,66 +354,44 @@ export class SeedBootstrapService {
336
354
  * Both digests, the stamp read, and the transaction come from
337
355
  * {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
338
356
  * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
339
- * owner-key precondition; a no-op on an already-absent row is the shared body's
357
+ * owner-signer precondition; a no-op on an already-absent row is the shared body's
340
358
  * behavior, and unlike {@link removePeer} nothing here rides on it.
341
359
  */
342
- async deleteDeviceToken(peerId) {
343
- // Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
344
- // owner-key error rather than a silent no-op on an absent row.
345
- const ownerKey = this.requireOwnerPublicKey();
346
- if (!this.controlDatabase) {
347
- throw new Error('Control database not initialized');
348
- }
349
- await this.controlDatabase.deleteDeviceToken(peerId, ownerKey, message => this.signMessageBytes(message));
360
+ async deleteDeviceToken(peerId, sign) {
361
+ // Fail fast on a signerless service BEFORE the DB read, so a non-owner gets the
362
+ // owner-signer error rather than a silent no-op on an absent row.
363
+ const ownerKey = this.requireOwnerSigner().publicKey;
364
+ const controlDatabase = this.requireControlDatabase();
365
+ await this.withOwnerSign(`Clear the push token of ${peerId}`, sign, (approved) => controlDatabase.deleteDeviceToken(peerId, ownerKey, approved));
350
366
  }
351
367
  /**
352
- * Return the configured owner private key, or throw if none is set. The single
353
- * precondition gate for every owner-signed write. {@link removePeer} /
354
- * {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
355
- * they call this up front — otherwise a keyless service would either surface the
356
- * wrong "Control database not initialized" error or, worse, silently no-op when the
357
- * target row is absent (the early `stampId === null` return) instead of rejecting.
368
+ * Return the configured owner signer, or throw if none is set. The single precondition
369
+ * gate for every owner-signed write. {@link removePeer} / {@link reauthorizePeer} read the
370
+ * row from the DB BEFORE they sign, so they call this up front — otherwise a signerless
371
+ * service would either surface the wrong "Control database not initialized" error or,
372
+ * worse, silently no-op when the target row is absent (their early absent return) instead
373
+ * of rejecting.
358
374
  */
359
- requireOwnerPrivateKey() {
360
- if (!this.config.ownerPrivateKey) {
361
- throw new Error('Owner private key required to authorize peers');
375
+ requireOwnerSigner() {
376
+ if (!this.config.ownerSigner) {
377
+ throw new Error('Owner signer required to authorize peers');
362
378
  }
363
- return this.config.ownerPrivateKey;
379
+ return this.config.ownerSigner;
364
380
  }
365
- /**
366
- * The owner PUBLIC key that rides in every owner-signed write's context, non-null.
367
- *
368
- * The field is nullable because a read-only service carries no owner key at all, but the
369
- * constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
370
- * {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
371
- * unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
372
- * their first line and get the owner-key precondition for free.
373
- */
374
- requireOwnerPublicKey() {
375
- this.requireOwnerPrivateKey();
376
- if (!this.ownerPublicKey) {
377
- throw new Error('Owner public key required to authorize peers');
381
+ /** The control database {@link initialize} attached, or a throw. */
382
+ requireControlDatabase() {
383
+ if (!this.controlDatabase) {
384
+ throw new Error('Control database not initialized');
378
385
  }
379
- return this.ownerPublicKey;
380
- }
381
- /**
382
- * Sign a base64url digest with the owner key (ed25519). The single place the
383
- * owner private key is applied; callers pass the canonical domain-tagged digest for
384
- * the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
385
- * {@link signMessageBytes}. Throws if no owner key is set.
386
- */
387
- signDigest(digestB64url) {
388
- return sign(digestB64url, this.requireOwnerPrivateKey(), 'ed25519', 'base64url', 'base64url', 'base64url');
386
+ return this.controlDatabase;
389
387
  }
390
388
  /**
391
- * Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
392
- * takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
393
- * same signed bytes (`sign` decodes its base64url input), so a signature minted here
394
- * satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
395
- * `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
389
+ * Run `body` with an owner signature function: the caller's `sign` when it passes one (a
390
+ * `CadreNode` action already inside its approval), otherwise one approval of this service's
391
+ * signer for `action`, the plain-words description a prompting signer shows.
396
392
  */
397
- signMessageBytes(message) {
398
- return this.signDigest(uint8ArrayToString(message, 'base64url'));
393
+ async withOwnerSign(action, sign, body) {
394
+ return await withinOwnerApproval(this.requireOwnerSigner(), { partyId: this.config.partyId, action }, sign, body);
399
395
  }
400
396
  /**
401
397
  * Remove a peer from the cadre by owner signature.
@@ -419,16 +415,14 @@ export class SeedBootstrapService {
419
415
  * Both digests, the stamp read, the transaction, and the post-commit membership notify
420
416
  * come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
421
417
  * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
422
- * owner-key precondition and the absent-row gate the notify depends on (below).
418
+ * owner-signer precondition and the absent-row gate the notify depends on (below).
423
419
  */
424
- async removePeer(peerId) {
425
- // Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
426
- // remove digest, and this precedence (owner key, then control DB) is what the
420
+ async removePeer(peerId, sign) {
421
+ // Fail fast on a signerless service BEFORE any DB read: a non-owner cannot sign the
422
+ // remove digest, and this precedence (owner signer, then control DB) is what the
427
423
  // unit contract asserts.
428
- const ownerKey = this.requireOwnerPublicKey();
429
- if (!this.controlDatabase) {
430
- throw new Error('Control database not initialized');
431
- }
424
+ const ownerKey = this.requireOwnerSigner().publicKey;
425
+ const controlDatabase = this.requireControlDatabase();
432
426
  // This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
433
427
  // internally: deleteCadrePeer's membership notify fires whenever its body resolves,
434
428
  // with no idea whether the body wrote anything, so delegating an absent peer would
@@ -436,106 +430,111 @@ export class SeedBootstrapService {
436
430
  // NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
437
431
  // between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
438
432
  // window only — the common "already absent" case is caught here.
439
- const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
433
+ const stampId = await controlDatabase.queryCadrePeerStampId(peerId);
440
434
  if (stampId === null) {
441
435
  log('removePeer: no CadrePeer row for %s (already absent)', peerId);
442
436
  return;
443
437
  }
444
438
  log('Removing peer: %s', peerId);
445
- await this.controlDatabase.deleteCadrePeer(peerId, ownerKey, message => this.signMessageBytes(message));
439
+ await this.withOwnerSign(`Remove the machine ${peerId} from the cadre`, sign, (approved) => controlDatabase.deleteCadrePeer(peerId, ownerKey, approved));
446
440
  log('Peer %s removed successfully (stamp retired)', peerId);
447
441
  }
448
442
  /**
449
- * Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
443
+ * Seat `ownerPublicKey` as a further owner, signed by this service's owner signer. The
450
444
  * liveness check, the stamp, the signature and the membership notify are
451
- * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
445
+ * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-signer
452
446
  * precondition, checked before any read (see {@link removePeer}).
453
447
  *
448
+ * @param label - the user's name for the key, bound into the signature
449
+ * ({@link ControlDatabase.insertOwnerKey} has the rule).
454
450
  * @returns `true` when the row was seated, `false` when the key is already a live owner.
455
451
  */
456
- async addOwner(ownerPublicKey) {
457
- const ownerKey = this.requireOwnerPublicKey();
458
- if (!this.controlDatabase) {
459
- throw new Error('Control database not initialized');
460
- }
461
- return await this.controlDatabase.insertOwnerKeyVouched(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
452
+ async addOwner(ownerPublicKey, label, sign) {
453
+ const ownerKey = this.requireOwnerSigner().publicKey;
454
+ const controlDatabase = this.requireControlDatabase();
455
+ // Checked before the approval opens, so a label the row would refuse never prompts the user.
456
+ const storedLabel = ownerKeyLabel(label);
457
+ return await this.withOwnerSign(`Add owner key ${namedOwnerKey(ownerPublicKey, storedLabel)}`, sign, (approved) => controlDatabase.insertOwnerKeyVouched(ownerPublicKey, ownerKey, approved, storedLabel));
462
458
  }
463
459
  /**
464
- * Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
460
+ * Remove `ownerPublicKey` from the owners, signed by this service's owner signer: the delete
465
461
  * and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
466
462
  * the last owner), the digests and the membership notify are
467
- * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
463
+ * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-signer precondition,
468
464
  * checked before any read (see {@link removePeer}).
469
465
  *
470
466
  * @returns `true` when the row was removed, `false` when the key is not a live owner here.
471
467
  */
472
- async removeOwner(ownerPublicKey) {
473
- const ownerKey = this.requireOwnerPublicKey();
474
- if (!this.controlDatabase) {
475
- throw new Error('Control database not initialized');
476
- }
477
- return await this.controlDatabase.deleteOwnerKey(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
468
+ async removeOwner(ownerPublicKey, sign) {
469
+ const ownerKey = this.requireOwnerSigner().publicKey;
470
+ const controlDatabase = this.requireControlDatabase();
471
+ return await this.withOwnerSign(`Remove owner key ${ownerPublicKey}`, sign, (approved) => controlDatabase.deleteOwnerKey(ownerPublicKey, ownerKey, approved));
478
472
  }
479
473
  /**
480
- * Re-vouch another owner's row under this service's owner key, so it stands on a remaining
474
+ * Re-vouch another owner's row under this service's owner signer, so it stands on a remaining
481
475
  * owner's signature before the owner that added it is removed
482
- * ({@link ControlDatabase.revouchOwnerKey}; for `CadreNode.removeMachine` only). What stays
483
- * here is the owner-key precondition, checked before any read (see {@link removePeer}).
476
+ * ({@link ControlDatabase.revouchOwnerKey}; for `CadreNode.removeOwner` only). What stays
477
+ * here is the owner-signer precondition, checked before any read (see {@link removePeer}).
484
478
  *
485
479
  * @returns `true` when the row was re-vouched, `false` when the key is not a live owner here.
486
480
  */
487
- async revouchOwner(ownerPublicKey) {
488
- const ownerKey = this.requireOwnerPublicKey();
489
- if (!this.controlDatabase) {
490
- throw new Error('Control database not initialized');
491
- }
492
- return await this.controlDatabase.revouchOwnerKey(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
481
+ async revouchOwner(ownerPublicKey, sign) {
482
+ const ownerKey = this.requireOwnerSigner().publicKey;
483
+ const controlDatabase = this.requireControlDatabase();
484
+ return await this.withOwnerSign(`Re-vouch owner key ${ownerPublicKey}`, sign, (approved) => controlDatabase.revouchOwnerKey(ownerPublicKey, ownerKey, approved));
493
485
  }
494
486
  /**
495
- * Remove a machine completely, signed by this service's owner key: its `CadrePeer` row, its
496
- * `OwnerKey` row when `target.ownerKey` names one, its `DeviceToken` row, and the
497
- * withdrawal of every invitation in `target.inviteKeys`, all in one transaction
498
- * ({@link ControlDatabase.removeMachine}). What stays here is the owner-key precondition,
499
- * checked before any read (see {@link removePeer}).
487
+ * Remove a machine completely, signed by this service's owner signer: its `CadrePeer` row, its
488
+ * `DeviceToken` row, and the withdrawal of every invitation in `target.inviteKeys`, all in one
489
+ * transaction ({@link ControlDatabase.removeMachine}). What stays here is the owner-signer
490
+ * precondition, checked before any read (see {@link removePeer}).
500
491
  */
501
- async removeMachine(target) {
502
- const ownerKey = this.requireOwnerPublicKey();
503
- if (!this.controlDatabase) {
504
- throw new Error('Control database not initialized');
505
- }
506
- return await this.controlDatabase.removeMachine(target, ownerKey, message => this.signMessageBytes(message));
492
+ async removeMachine(target, sign) {
493
+ const ownerKey = this.requireOwnerSigner().publicKey;
494
+ const controlDatabase = this.requireControlDatabase();
495
+ return await this.withOwnerSign(`Remove the machine ${target.peerId} from the cadre`, sign, (approved) => controlDatabase.removeMachine(target, ownerKey, approved));
507
496
  }
508
497
  /**
509
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
510
- * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
511
- * the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
512
- * a membership row that committed local-only (its block's cluster ≤1 at insert) is
513
- * pushed to the cohort once it grows.
498
+ * Owner "re-touch" of an existing `CadrePeer` membership row, in one of two modes
499
+ * ({@link CadrePeerRetouchMode}): `'rebroadcast'` bumps `UpdatedAt` and re-vouches the row so
500
+ * it is re-emitted as a fresh, broadcasting transaction — the write-while-alone re-replication
501
+ * primitive (`control-write-ensure-replicated`): a membership row that committed local-only
502
+ * (its block's cluster ≤1 at insert) is pushed to the cohort once it grows — and writes only
503
+ * while the machine has not self-published; `'revouch'` rebinds the voucher to this node's
504
+ * owner key, leaving `UpdatedAt` alone, whether or not the machine has self-published
505
+ * (`CadreNode.removeOwner`).
514
506
  *
515
- * The stamp read, the voucher signature, the write lock and the membership notify are
507
+ * The row read, the mode's decision, the stamp bump, the voucher signature, the write lock,
508
+ * the lost-update retry and the membership notify are
516
509
  * {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
517
- * `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
518
- *
519
- * A no-op (no throw, no notify) when the row does not exist.
510
+ * `VouchOwner` to this node's owner key. What stays here is the owner-signer precondition.
520
511
  *
521
512
  * @param peerId - the membership row to re-touch.
522
- * @param updatedAt - the strictly-increasing freshness stamp to write.
523
- * @throws if no owner private key is configured (a non-owner cannot
513
+ * @param mode - re-broadcast or re-vouch.
514
+ * @returns what was found and done ({@link CadrePeerRetouchOutcome}); an absent row, or a
515
+ * self-published one in `'rebroadcast'` mode, writes nothing and does not throw.
516
+ * @throws if no owner signer is configured (a non-owner cannot
524
517
  * re-sign another peer's row) or the control database is not initialized.
525
518
  */
526
- async reauthorizePeer(peerId, updatedAt) {
527
- // Fail fast on a keyless service before any DB read (see removePeer): a non-owner
519
+ async reauthorizePeer(peerId, mode, sign) {
520
+ // Fail fast on a signerless service before any DB read (see removePeer): a non-owner
528
521
  // cannot re-sign the voucher, and must not silently no-op on an absent row.
529
- const ownerKey = this.requireOwnerPublicKey();
530
- if (!this.controlDatabase) {
531
- throw new Error('Control database not initialized');
532
- }
533
- const retouched = await this.controlDatabase.reauthorizeCadrePeer(peerId, updatedAt, ownerKey, message => this.signMessageBytes(message));
534
- if (!retouched) {
535
- log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
536
- return;
522
+ const ownerKey = this.requireOwnerSigner().publicKey;
523
+ const controlDatabase = this.requireControlDatabase();
524
+ const action = mode === 'revouch' ? `Re-vouch the machine ${peerId}` : `Re-issue the membership of ${peerId}`;
525
+ const outcome = await this.withOwnerSign(action, sign, (approved) => controlDatabase.reauthorizeCadrePeer(peerId, mode, ownerKey, approved));
526
+ switch (outcome) {
527
+ case 'absent':
528
+ log('reauthorizePeer(%s): no CadrePeer row for %s (nothing to re-touch)', mode, peerId);
529
+ break;
530
+ case 'self-published':
531
+ log('reauthorizePeer(%s): %s has self-published; its row is its own to refresh', mode, peerId);
532
+ break;
533
+ case 'retouched':
534
+ log('Peer %s re-authorized (%s)', peerId, mode);
535
+ break;
537
536
  }
538
- log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
537
+ return outcome;
539
538
  }
540
539
  /**
541
540
  * Owner re-issue of a batch of `Revocation` tombstones: bump each row's
@@ -547,81 +546,97 @@ export class SeedBootstrapService {
547
546
  *
548
547
  * The signatures, the single transaction, and the strictly-monotonic
549
548
  * `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
550
- * stays here is the owner-key precondition.
549
+ * stays here is the owner-signer precondition.
551
550
  *
552
551
  * @returns how many tombstones were re-issued (`rows.length` on success).
553
- * @throws if no owner private key is configured or the control database is not
552
+ * @throws if no owner signer is configured or the control database is not
554
553
  * initialized.
555
554
  */
556
- async reissueRevocations(rows, reissuedAt) {
557
- // Fail fast on a keyless service before any DB work (see removePeer): a
555
+ async reissueRevocations(rows, reissuedAt, sign) {
556
+ // Fail fast on a signerless service before any DB work (see removePeer): a
558
557
  // non-owner cannot sign the reissue digests.
559
- const ownerKey = this.requireOwnerPublicKey();
560
- if (!this.controlDatabase) {
561
- throw new Error('Control database not initialized');
562
- }
563
- return this.controlDatabase.reissueRevocations(rows, reissuedAt, ownerKey, message => this.signMessageBytes(message));
564
- }
565
- /**
566
- * Owner filing of the singleton `Revocation` ledger marker, so the table is never a
567
- * never-written block that the storage layer re-consults on every read.
568
- *
569
- * The signature, the insert-if-absent guard and the `'already-open'` mapping are
570
- * {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-key
571
- * precondition.
572
- *
573
- * @throws if no owner private key is configured or the control database is not
574
- * initialized.
575
- */
576
- async openRevocationLedger() {
577
- // Fail fast on a keyless service before any DB work (see removePeer): a
578
- // non-owner cannot sign the marker's append digest.
579
- const ownerKey = this.requireOwnerPublicKey();
580
- if (!this.controlDatabase) {
581
- throw new Error('Control database not initialized');
582
- }
583
- return this.controlDatabase.openRevocationLedger(ownerKey, message => this.signMessageBytes(message));
558
+ const ownerKey = this.requireOwnerSigner().publicKey;
559
+ const controlDatabase = this.requireControlDatabase();
560
+ return await this.withOwnerSign(`Re-send ${rows.length} removal record(s) to the cadre`, sign, (approved) => controlDatabase.reissueRevocations(rows, reissuedAt, ownerKey, approved));
584
561
  }
585
562
  /**
586
563
  * Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
587
564
  * on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
588
565
  * expiry and use-count defaults, and the bundle the holder receives are
589
- * `CadreNode.createCadreInvitation`'s; what stays here is the owner-key precondition and
590
- * the signature, as {@link insertSelfDeviceToken} keeps them for device tokens.
566
+ * `CadreNode.createCadreInvitation`'s; what stays here is the owner-signer precondition and
567
+ * the approval, as {@link insertSelfDeviceToken} keeps them for device tokens.
591
568
  *
592
569
  * @returns the row as stored, which the invitation bundle carries verbatim.
593
- * @throws if no owner private key is configured or the control database is not initialized.
570
+ * @throws if no owner signer is configured or the control database is not initialized.
594
571
  */
595
- async insertCadreInvite(invite) {
596
- // Fail fast on a keyless service before any DB work (see removePeer).
597
- const ownerKey = this.requireOwnerPublicKey();
598
- if (!this.controlDatabase) {
599
- throw new Error('Control database not initialized');
600
- }
601
- return await this.controlDatabase.insertCadreInvite(invite, ownerKey, message => this.signMessageBytes(message));
572
+ async insertCadreInvite(invite, sign) {
573
+ // Fail fast on a signerless service before any DB work (see removePeer).
574
+ const ownerKey = this.requireOwnerSigner().publicKey;
575
+ const controlDatabase = this.requireControlDatabase();
576
+ return await this.withOwnerSign('Create a machine invitation to the cadre', sign, (approved) => controlDatabase.insertCadreInvite(invite, ownerKey, approved));
602
577
  }
603
578
  /**
604
579
  * Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
605
580
  * the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
606
- * owner-key precondition.
581
+ * owner-signer precondition.
607
582
  *
608
583
  * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
609
584
  * already withdrawn.
610
- * @throws if no owner private key is configured or the control database is not initialized.
585
+ * @throws if no owner signer is configured or the control database is not initialized.
611
586
  */
612
- async withdrawCadreInvite(key) {
613
- const ownerKey = this.requireOwnerPublicKey();
614
- if (!this.controlDatabase) {
615
- throw new Error('Control database not initialized');
616
- }
617
- return await this.controlDatabase.withdrawCadreInvite(key, ownerKey, message => this.signMessageBytes(message));
587
+ async withdrawCadreInvite(key, sign) {
588
+ const ownerKey = this.requireOwnerSigner().publicKey;
589
+ const controlDatabase = this.requireControlDatabase();
590
+ return await this.withOwnerSign(`Withdraw the cadre invitation ${key}`, sign, (approved) => controlDatabase.withdrawCadreInvite(key, ownerKey, approved));
591
+ }
592
+ /**
593
+ * Owner-signed INSERT of an `OwnerGrant` row — a grant of ownership any member machine can
594
+ * redeem on this owner's behalf ({@link ControlDatabase.insertOwnerGrant}). The keypair mint,
595
+ * the lifetime default and the bundle the holder receives are `CadreNode.createOwnerGrant`'s;
596
+ * what stays here is the owner-signer precondition and the approval, as for
597
+ * {@link insertCadreInvite}. The label is checked before the approval opens, so one the row
598
+ * would refuse never prompts the user.
599
+ *
600
+ * @returns the row as stored, which the grant bundle carries verbatim.
601
+ * @throws if no owner signer is configured, the control database is not initialized, or the label is too long.
602
+ */
603
+ async insertOwnerGrant(grant, sign) {
604
+ const ownerKey = this.requireOwnerSigner().publicKey;
605
+ const controlDatabase = this.requireControlDatabase();
606
+ const label = ownerKeyLabel(grant.label);
607
+ const named = label === null ? 'an owner grant' : `the owner grant "${label}"`;
608
+ return await this.withOwnerSign(`Create ${named}: whoever holds it can become an owner of the cadre`, sign, (approved) => controlDatabase.insertOwnerGrant({ ...grant, label }, ownerKey, approved));
609
+ }
610
+ /**
611
+ * Owner-signed WITHDRAWAL of an `OwnerGrant`: the `Revocation` tombstone over its stamp, with
612
+ * the row kept ({@link ControlDatabase.withdrawOwnerGrant}). What stays here is the
613
+ * owner-signer precondition.
614
+ *
615
+ * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
616
+ * already withdrawn.
617
+ * @throws if no owner signer is configured or the control database is not initialized.
618
+ */
619
+ async withdrawOwnerGrant(key, sign) {
620
+ const ownerKey = this.requireOwnerSigner().publicKey;
621
+ const controlDatabase = this.requireControlDatabase();
622
+ return await this.withOwnerSign(`Withdraw the owner grant ${key}`, sign, (approved) => controlDatabase.withdrawOwnerGrant(key, ownerKey, approved));
618
623
  }
619
624
  /**
620
625
  * Create a seed from the current control network state.
621
- * The seed contains peer information and is signed by an owner.
626
+ * The seed contains peer information and is signed by an owner; its `signerKey` is the
627
+ * owner signer's key.
622
628
  */
623
- async createSeed() {
624
- if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
629
+ async createSeed(sign) {
630
+ return await this.mintSeed(sign, null);
631
+ }
632
+ /**
633
+ * {@link createSeed}, with `selfRelayAddr` added to this node's own peer entry before the
634
+ * seed is signed: the signature covers every address ({@link seedDigest}), so nothing may
635
+ * be added after it.
636
+ */
637
+ async mintSeed(sign, selfRelayAddr) {
638
+ const signer = this.config.ownerSigner;
639
+ if (!signer) {
625
640
  throw new Error('Owner key required to create seeds');
626
641
  }
627
642
  if (!this.controlDatabase || !this.libp2pNode) {
@@ -629,18 +644,23 @@ export class SeedBootstrapService {
629
644
  }
630
645
  log('Creating seed for party: %s', this.config.partyId);
631
646
  // Query all peers from the control database
632
- const peers = await this.queryPeers();
647
+ const selfPeerId = this.libp2pNode.peerId.toString();
648
+ const peers = await this.queryPeers(selfPeerId);
649
+ if (selfRelayAddr !== null) {
650
+ addSelfRelayAddr(peers, selfPeerId, selfRelayAddr);
651
+ }
633
652
  // Create the seed data (without signature)
634
653
  const seedData = {
635
654
  partyId: this.config.partyId,
636
655
  peers,
637
656
  };
638
657
  // Sign the seed over its canonical byte representation
639
- const signature = sign(seedDigest(seedData), this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
658
+ const digestBytes = uint8ArrayFromString(seedDigest(seedData), 'base64url');
659
+ const signature = await this.withOwnerSign('Mint a seed for a machine joining the cadre', sign, (approved) => approved(digestBytes));
640
660
  const seed = {
641
661
  ...seedData,
642
662
  signature,
643
- signerKey: this.ownerPublicKey,
663
+ signerKey: signer.publicKey,
644
664
  };
645
665
  log('Created seed with %d peers', peers.length);
646
666
  return seed;
@@ -652,9 +672,8 @@ export class SeedBootstrapService {
652
672
  * `signerKey` that does NOT come from the seed body: the receiver's
653
673
  * node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
654
674
  * augmented by pinned keys or TOFU via the configured/overriding
655
- * `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
656
- * own signer as an owner peer — no longer passes, and neither does one signed
657
- * by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
675
+ * `SeedTrustPolicy`. A forged self-signed seed does not pass, and neither does
676
+ * one signed by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
658
677
  *
659
678
  * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
660
679
  * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
@@ -670,12 +689,12 @@ export class SeedBootstrapService {
670
689
  if (!merged.success) {
671
690
  return merged;
672
691
  }
673
- return { ...merged, ...await this.dialSeedOwners(seed) };
692
+ return { ...merged, ...await this.dialPreferredPeers(seed) };
674
693
  }
675
694
  /**
676
695
  * The first half of {@link applySeed}: check the signature and the signer's trust, then merge
677
696
  * the seed's peer addresses into the peer store. Every rejection happens here, so this result is
678
- * what the inbound handler acks with; the owner dials that follow cannot change it.
697
+ * what the inbound handler acks with; the dials that follow cannot change it.
679
698
  */
680
699
  async verifyAndMergeSeed(seed, options) {
681
700
  if (!this.libp2pNode) {
@@ -709,7 +728,7 @@ export class SeedBootstrapService {
709
728
  partyId: seed.partyId,
710
729
  signerKey: seed.signerKey,
711
730
  knownOwnerKeys,
712
- // Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
731
+ // Optional-chained like `dialPreferredPeers`: partial libp2p handles (unit-test doubles)
713
732
  // omit `peerId`. A real node always has one, and a proof bound to a real peer id can
714
733
  // only fail against '' — the safe direction.
715
734
  localPeerId: this.libp2pNode.peerId?.toString() ?? '',
@@ -723,54 +742,51 @@ export class SeedBootstrapService {
723
742
  await this.anchorAcceptedSigner(seed.signerKey, decision);
724
743
  const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
725
744
  log('Merged seed: %d peers added', peersAdded);
726
- return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
745
+ return { success: true, peersAdded, preferredDialsAttempted: 0, preferredDialsFailed: 0 };
727
746
  }
728
747
  /**
729
- * The second half of {@link applySeed}: dial the seed's owner peers to establish connections.
748
+ * The second half of {@link applySeed}: dial the seed's preferred peers (`SeedPeer.preferred`:
749
+ * the machine that minted it and every always-on machine) to establish connections.
730
750
  *
731
- * Best-effort and COUNTED: an owner that is momentarily down leaves this node seeded but
751
+ * Best-effort and COUNTED: a peer that is momentarily down leaves this node seeded but
732
752
  * unconnected, which the caller can only see if the outcome is reported (see
733
- * `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
753
+ * `ApplySeedResult.preferredDialsFailed`). Recovery is not this loop's job —
734
754
  * `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
735
755
  * reconcile pass until the control database has siblings.
736
756
  *
737
- * The inbound handler runs this AFTER it has acked and closed the stream
738
- * ({@link handleSeedStream}), because an unreachable owner can take `dialBudget.totalMs`.
757
+ * The inbound handler runs this AFTER it has acked, closed the stream and reported the seed
758
+ * applied ({@link handleSeedStream}), because an unreachable peer can take `dialBudget.totalMs`.
759
+ * The node may stop while these dials are in flight (cadre-cli restarts a claimed node on
760
+ * `seed:applied`): the node's stop aborts the dial in flight and every later one is refused
761
+ * at once (`dialAddr`), and each is counted as failed here.
739
762
  */
740
- async dialSeedOwners(seed) {
763
+ async dialPreferredPeers(seed) {
741
764
  const node = this.libp2pNode;
742
765
  if (!node) {
743
- log('Seed service shut down before its owner dials; none attempted');
744
- return { ownerDialsAttempted: 0, ownerDialsFailed: 0 };
766
+ log('Seed service shut down before its dials; none attempted');
767
+ return { preferredDialsAttempted: 0, preferredDialsFailed: 0 };
745
768
  }
746
- // `createSeed` projects every non-revoked CadrePeer row, so an owner applying
747
- // a seed minted after it joined finds ITSELF in the owner list. Dialing self always
748
- // throws, which would report a healthy owner as "seeded but stranded".
749
- // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
750
- // and an undefined self simply matches nothing.
751
- // Every one of an owner's addresses is a candidate, each on its own time limit
752
- // (`dialPeerAddrs`), so an owner whose first address never answers neither
769
+ // Self is not dialed: dialing self always throws, which would report a healthy node as
770
+ // "seeded but stranded". Optional-chained: partial libp2p handles (unit-test doubles)
771
+ // omit `peerId`.
772
+ // Every one of a peer's addresses is a candidate, each on its own time limit
773
+ // (`dialPeerAddrs`), so a peer whose first address never answers neither
753
774
  // stalls seed application nor goes undialed at its other addresses. The seed's
754
- // addresses carry no peer id; the dial binds each to the owner's.
755
- const selfPeerId = node.peerId?.toString();
756
- let ownerDialsAttempted = 0;
757
- let ownerDialsFailed = 0;
758
- for (const peer of seed.peers.filter(p => p.isOwner)) {
759
- if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
760
- continue;
761
- }
762
- ownerDialsAttempted++;
775
+ // addresses carry no peer id; the dial binds each to the peer's.
776
+ const targets = seedDialTargets(seed, node.peerId?.toString());
777
+ let preferredDialsFailed = 0;
778
+ for (const peer of targets) {
763
779
  try {
764
780
  const addrs = parseDialAddrs(peer.multiaddrs);
765
- log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
766
- await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
781
+ log('Dialing preferred seed peer: %s (%d addr(s))', peer.peerId, addrs.length);
782
+ await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `Seed dial of ${peer.peerId}`);
767
783
  }
768
784
  catch (error) {
769
- // Counted even when the owner reaches us only through our own relay: this node is still
770
- // not connected to it, and `ownerDialsFailed` is how the caller learns that.
771
- ownerDialsFailed++;
785
+ // Counted even when the peer reaches us only through our own relay: this node is still
786
+ // not connected to it, and `preferredDialsFailed` is how the caller learns that.
787
+ preferredDialsFailed++;
772
788
  if (error instanceof SelfRelayOnlyError) {
773
- log('Owner peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
789
+ log('Seed peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
774
790
  }
775
791
  else {
776
792
  log('Failed to dial peer %s: %o', peer.peerId, error);
@@ -778,8 +794,8 @@ export class SeedBootstrapService {
778
794
  // Continue - not all peers need to be reachable
779
795
  }
780
796
  }
781
- log('Dialed seed owners: %d/%d owner dial(s) failed', ownerDialsFailed, ownerDialsAttempted);
782
- return { ownerDialsAttempted, ownerDialsFailed };
797
+ log('Dialed preferred seed peers: %d/%d dial(s) failed', preferredDialsFailed, targets.length);
798
+ return { preferredDialsAttempted: targets.length, preferredDialsFailed };
783
799
  }
784
800
  /**
785
801
  * Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
@@ -883,7 +899,7 @@ export class SeedBootstrapService {
883
899
  }
884
900
  const peerId = peerIdFromString(target.peerId);
885
901
  const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
886
- await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
902
+ await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, preferred: false }]);
887
903
  if (addrs.length === 0) {
888
904
  return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
889
905
  }
@@ -984,16 +1000,16 @@ export class SeedBootstrapService {
984
1000
  }
985
1001
  }
986
1002
  /**
987
- * The peers a seed carries: the control database's own projection
1003
+ * The peers a seed `minterPeerId` mints carries: the control database's own projection
988
1004
  * ({@link ControlDatabase.querySeedPeers}, shared with the cadre invitation redemption
989
- * reply), which reads through the revocation filter and flags owners from the replicated
990
- * `OwnerKey` table — a dial hint, not a trust decision (the NOTE at `projectSeedPeers`).
1005
+ * reply), which reads through the revocation filter and marks the minter and every
1006
+ * always-on machine preferred — a dial hint, not a trust decision.
991
1007
  */
992
- async queryPeers() {
1008
+ async queryPeers(minterPeerId) {
993
1009
  if (!this.controlDatabase) {
994
1010
  return [];
995
1011
  }
996
- return await this.controlDatabase.querySeedPeers();
1012
+ return await this.controlDatabase.querySeedPeers(minterPeerId);
997
1013
  }
998
1014
  /**
999
1015
  * Register the seed protocol handler. The inbound closure just delegates to
@@ -1009,14 +1025,16 @@ export class SeedBootstrapService {
1009
1025
  }
1010
1026
  /**
1011
1027
  * Read one inbound seed frame, verify and merge it, write the ack and close the
1012
- * stream, then dial the seed's owners.
1028
+ * stream, report the seed applied, then dial the seed's preferred peers.
1013
1029
  *
1014
- * The ack and the close come BEFORE the owner dials: an unreachable owner can hold
1015
- * a dial for `dialBudget.totalMs` (86 s at the default declared link), which is not
1016
- * link time and does not belong inside the sender's delivery deadline. The close is
1017
- * what releases the sender, which reads the ack to end-of-stream. The stream stays
1018
- * counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
1019
- * still bounds concurrent owner-dial phases.
1030
+ * The ack, the close and `onSeedApplied` come BEFORE those dials: an unreachable peer
1031
+ * can hold a dial for `dialBudget.totalMs` (86 s at the default declared link), which is
1032
+ * not link time and does not belong inside the sender's delivery deadline, nor ahead of
1033
+ * a claimed node's restart (which stops this node, so its dials would be wasted). The
1034
+ * close is what releases the sender, which reads the ack to end-of-stream, and it is
1035
+ * awaited before `onSeedApplied`, so the ack is flushed before anything the callback
1036
+ * starts. The stream stays counted in {@link activeStreams} through the dials, so
1037
+ * {@link maxConcurrentSeeds} still bounds concurrent dial phases.
1020
1038
  *
1021
1039
  * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
1022
1040
  * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
@@ -1045,8 +1063,8 @@ export class SeedBootstrapService {
1045
1063
  acked = true;
1046
1064
  await replyAndClose(stream, { accepted: merged.success, reason: merged.error, code: merged.code }, 'Seed');
1047
1065
  if (merged.success) {
1048
- await this.dialSeedOwners(seed);
1049
1066
  this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
1067
+ await this.dialPreferredPeers(seed);
1050
1068
  }
1051
1069
  else {
1052
1070
  this.eventCallbacks.onSeedError?.(seed.partyId, merged.error ?? 'Unknown error');
@@ -1117,16 +1135,20 @@ export class SeedBootstrapService {
1117
1135
  * does not listen. Call `CadreNode.reconcileControlCohort()` after delivering
1118
1136
  * the seed to dial at once.
1119
1137
  *
1138
+ * The vouch and the seed are one owner approval.
1139
+ *
1120
1140
  * @param options - Drone peer info from provider API
1121
1141
  * @returns Seed and encoded seed for drone initialization
1122
1142
  */
1123
- async addDrone(options) {
1143
+ async addDrone(options, sign) {
1124
1144
  const { dronePeerId, droneMultiaddrs } = options;
1125
1145
  log('Adding drone: %s', dronePeerId);
1126
- // 1. Authorize the new drone peer
1127
- await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs });
1128
- // 2. Create seed with current state
1129
- const seed = await this.createSeed();
1146
+ const seed = await this.withOwnerSign(`Add the machine ${dronePeerId} to the cadre`, sign, async (approved) => {
1147
+ // 1. Authorize the new drone peer
1148
+ await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs }, approved);
1149
+ // 2. Create seed with current state
1150
+ return await this.createSeed(approved);
1151
+ });
1130
1152
  // 3. Encode for transport
1131
1153
  const encodedSeed = this.encodeSeed(seed);
1132
1154
  log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
@@ -1139,25 +1161,20 @@ export class SeedBootstrapService {
1139
1161
  * 1. Authorizes the new phone peer
1140
1162
  * 2. Creates a seed with relay addresses for dialing
1141
1163
  *
1164
+ * The vouch and the seed are one owner approval.
1165
+ *
1142
1166
  * @param phonePeerId - Peer ID of the new phone
1143
1167
  * @returns Seed with relay addresses for out-of-band delivery
1144
1168
  */
1145
- async addPhoneWithRelay(phonePeerId) {
1169
+ async addPhoneWithRelay(phonePeerId, sign) {
1146
1170
  log('Adding phone with relay: %s', phonePeerId);
1147
- // 1. Authorize the new phone peer (no multiaddrs - NAT'd)
1148
- await this.authorizePeer({ peerId: phonePeerId });
1149
- // 2. Get relay address for this node
1150
1171
  const relayAddr = await this.getRelayAddress();
1151
- // 3. Create seed - will include our relay address if available
1152
- const seed = await this.createSeed();
1153
- // If we have a relay address, make sure it's in our peer entry
1154
- if (relayAddr && this.libp2pNode) {
1155
- const ourPeerId = this.libp2pNode.peerId.toString();
1156
- const ourPeer = seed.peers.find(p => p.peerId === ourPeerId);
1157
- if (ourPeer && !ourPeer.multiaddrs.includes(relayAddr)) {
1158
- ourPeer.multiaddrs.push(relayAddr);
1159
- }
1160
- }
1172
+ const seed = await this.withOwnerSign(`Add the phone ${phonePeerId} to the cadre`, sign, async (approved) => {
1173
+ // 1. Authorize the new phone peer (no multiaddrs - NAT'd)
1174
+ await this.authorizePeer({ peerId: phonePeerId }, approved);
1175
+ // 2. Create seed - with our relay address in our own entry, when we have one
1176
+ return await this.mintSeed(approved, relayAddr);
1177
+ });
1161
1178
  const encodedSeed = this.encodeSeed(seed);
1162
1179
  log('Phone %s added with relay, seed created', phonePeerId);
1163
1180
  return { seed, encodedSeed };