@serfab/cadre-core 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/README.md +1 -1
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +55 -138
  6. package/dist/cadre-invite-protocol.js +94 -449
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +530 -247
  12. package/dist/cadre-node.js +1046 -525
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +22 -23
  18. package/dist/control-cohort.js +24 -24
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +416 -136
  21. package/dist/control-database.js +757 -378
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-protocol-guard.d.ts +5 -4
  24. package/dist/control-protocol-guard.js +6 -3
  25. package/dist/control-protocol-guard.js.map +1 -1
  26. package/dist/control-schema-version.d.ts +80 -0
  27. package/dist/control-schema-version.js +203 -0
  28. package/dist/control-schema-version.js.map +1 -0
  29. package/dist/control-schema.d.ts +45 -5
  30. package/dist/control-schema.js +254 -70
  31. package/dist/control-schema.js.map +1 -1
  32. package/dist/control-write-retry.d.ts +5 -5
  33. package/dist/control-write-retry.js +10 -8
  34. package/dist/control-write-retry.js.map +1 -1
  35. package/dist/delegate-admission.d.ts +13 -0
  36. package/dist/delegate-admission.js +27 -0
  37. package/dist/delegate-admission.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +10 -7
  39. package/dist/ed25519-key.js +23 -20
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/edge-node/config.d.ts +122 -0
  42. package/dist/edge-node/config.js +199 -0
  43. package/dist/edge-node/config.js.map +1 -0
  44. package/dist/edge-node/dissolve.d.ts +95 -0
  45. package/dist/edge-node/dissolve.js +184 -0
  46. package/dist/edge-node/dissolve.js.map +1 -0
  47. package/dist/edge-node/edge-join.d.ts +44 -0
  48. package/dist/edge-node/edge-join.js +227 -0
  49. package/dist/edge-node/edge-join.js.map +1 -0
  50. package/dist/edge-node/edge-node.d.ts +295 -0
  51. package/dist/edge-node/edge-node.js +704 -0
  52. package/dist/edge-node/edge-node.js.map +1 -0
  53. package/dist/edge-node/index.d.ts +18 -0
  54. package/dist/edge-node/index.js +18 -0
  55. package/dist/edge-node/index.js.map +1 -0
  56. package/dist/edge-node/join-credential.d.ts +11 -0
  57. package/dist/edge-node/join-credential.js +11 -0
  58. package/dist/edge-node/join-credential.js.map +1 -0
  59. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  60. package/dist/edge-node/lazy-raw-storage.js +134 -0
  61. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  62. package/dist/edge-node/node-local.d.ts +60 -0
  63. package/dist/edge-node/node-local.js +58 -0
  64. package/dist/edge-node/node-local.js.map +1 -0
  65. package/dist/edge-node/options.d.ts +83 -0
  66. package/dist/edge-node/options.js +110 -0
  67. package/dist/edge-node/options.js.map +1 -0
  68. package/dist/edge-node/owner-grant.d.ts +41 -0
  69. package/dist/edge-node/owner-grant.js +38 -0
  70. package/dist/edge-node/owner-grant.js.map +1 -0
  71. package/dist/edge-node/platform.d.ts +140 -0
  72. package/dist/edge-node/platform.js +12 -0
  73. package/dist/edge-node/platform.js.map +1 -0
  74. package/dist/edge-node/strand-move.d.ts +115 -0
  75. package/dist/edge-node/strand-move.js +223 -0
  76. package/dist/edge-node/strand-move.js.map +1 -0
  77. package/dist/edge-node/strands.d.ts +28 -0
  78. package/dist/edge-node/strands.js +60 -0
  79. package/dist/edge-node/strands.js.map +1 -0
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +26 -7
  82. package/dist/index.js.map +1 -1
  83. package/dist/joined-strand-store.d.ts +3 -3
  84. package/dist/joined-strand-store.js +3 -3
  85. package/dist/joined-strand-store.js.map +1 -1
  86. package/dist/key-store.d.ts +9 -4
  87. package/dist/key-store.js +9 -4
  88. package/dist/key-store.js.map +1 -1
  89. package/dist/machine-removal.d.ts +19 -39
  90. package/dist/machine-removal.js +24 -98
  91. package/dist/machine-removal.js.map +1 -1
  92. package/dist/member-redemption.d.ts +250 -0
  93. package/dist/member-redemption.js +485 -0
  94. package/dist/member-redemption.js.map +1 -0
  95. package/dist/owner-anchor-sync.d.ts +4 -4
  96. package/dist/owner-anchor-sync.js +16 -15
  97. package/dist/owner-anchor-sync.js.map +1 -1
  98. package/dist/owner-grant-protocol.d.ts +247 -0
  99. package/dist/owner-grant-protocol.js +330 -0
  100. package/dist/owner-grant-protocol.js.map +1 -0
  101. package/dist/owner-key-export.d.ts +70 -0
  102. package/dist/owner-key-export.js +279 -0
  103. package/dist/owner-key-export.js.map +1 -0
  104. package/dist/owner-key-flows.d.ts +55 -0
  105. package/dist/owner-key-flows.js +60 -0
  106. package/dist/owner-key-flows.js.map +1 -0
  107. package/dist/owner-key-store.d.ts +31 -0
  108. package/dist/owner-key-store.js +81 -0
  109. package/dist/owner-key-store.js.map +1 -0
  110. package/dist/owner-keys.d.ts +78 -0
  111. package/dist/owner-keys.js +133 -0
  112. package/dist/owner-keys.js.map +1 -0
  113. package/dist/owner-signer.d.ts +54 -0
  114. package/dist/owner-signer.js +51 -0
  115. package/dist/owner-signer.js.map +1 -0
  116. package/dist/peer-authorization.d.ts +83 -18
  117. package/dist/peer-authorization.js +123 -53
  118. package/dist/peer-authorization.js.map +1 -1
  119. package/dist/provider/rent-node.js +1 -1
  120. package/dist/provider/rent-node.js.map +1 -1
  121. package/dist/seed-bootstrap.d.ts +131 -109
  122. package/dist/seed-bootstrap.js +260 -248
  123. package/dist/seed-bootstrap.js.map +1 -1
  124. package/dist/seed-trust-policy.js +4 -4
  125. package/dist/strand-addr-protocol.d.ts +4 -1
  126. package/dist/strand-addr-protocol.js +8 -2
  127. package/dist/strand-addr-protocol.js.map +1 -1
  128. package/dist/strand-cohort.d.ts +3 -0
  129. package/dist/strand-cohort.js.map +1 -1
  130. package/dist/strand-database.d.ts +1 -0
  131. package/dist/strand-database.js +3 -0
  132. package/dist/strand-database.js.map +1 -1
  133. package/dist/strand-first-sync-gate.d.ts +4 -3
  134. package/dist/strand-first-sync-gate.js +4 -3
  135. package/dist/strand-first-sync-gate.js.map +1 -1
  136. package/dist/strand-instance-manager.d.ts +1 -1
  137. package/dist/strand-membership-writer.d.ts +13 -2
  138. package/dist/strand-membership-writer.js +52 -16
  139. package/dist/strand-membership-writer.js.map +1 -1
  140. package/dist/strand-move.d.ts +6 -5
  141. package/dist/strand-move.js +31 -30
  142. package/dist/strand-move.js.map +1 -1
  143. package/dist/strand-peer-redial.js +2 -3
  144. package/dist/strand-peer-redial.js.map +1 -1
  145. package/dist/types.d.ts +286 -103
  146. package/dist/types.js.map +1 -1
  147. package/package.json +11 -2
  148. package/src/bootstrap-peer-store-file.ts +44 -44
  149. package/src/bootstrap-peer-store.ts +214 -214
  150. package/src/cadre-invite-protocol.ts +652 -1052
  151. package/src/cadre-join-request.ts +24 -3
  152. package/src/cadre-node.ts +1166 -541
  153. package/src/control-authorization.ts +47 -14
  154. package/src/control-cohort.ts +127 -128
  155. package/src/control-database.ts +940 -456
  156. package/src/control-protocol-guard.ts +7 -4
  157. package/src/control-schema-version.ts +251 -0
  158. package/src/control-schema.ts +265 -70
  159. package/src/control-write-retry.ts +569 -567
  160. package/src/delegate-admission.ts +37 -0
  161. package/src/ed25519-key.ts +174 -171
  162. package/src/edge-node/config.ts +277 -0
  163. package/src/edge-node/dissolve.ts +251 -0
  164. package/src/edge-node/edge-join.ts +319 -0
  165. package/src/edge-node/edge-node.ts +1045 -0
  166. package/src/edge-node/index.ts +59 -0
  167. package/src/edge-node/join-credential.ts +16 -0
  168. package/src/edge-node/lazy-raw-storage.ts +178 -0
  169. package/src/edge-node/node-local.ts +84 -0
  170. package/src/edge-node/options.ts +179 -0
  171. package/src/edge-node/owner-grant.ts +63 -0
  172. package/src/edge-node/platform.ts +151 -0
  173. package/src/edge-node/strand-move.ts +270 -0
  174. package/src/edge-node/strands.ts +72 -0
  175. package/src/index.ts +886 -789
  176. package/src/joined-strand-store.ts +4 -4
  177. package/src/key-store.ts +10 -4
  178. package/src/machine-removal.ts +26 -111
  179. package/src/member-redemption.ts +642 -0
  180. package/src/owner-anchor-sync.ts +19 -18
  181. package/src/owner-grant-protocol.ts +532 -0
  182. package/src/owner-key-export.ts +348 -0
  183. package/src/owner-key-flows.ts +109 -0
  184. package/src/owner-key-store.ts +85 -0
  185. package/src/owner-keys.ts +178 -0
  186. package/src/owner-signer.ts +94 -0
  187. package/src/peer-authorization.ts +558 -488
  188. package/src/provider/rent-node.ts +1 -1
  189. package/src/seed-bootstrap.ts +301 -312
  190. package/src/seed-trust-policy.ts +4 -4
  191. package/src/strand-addr-protocol.ts +547 -538
  192. package/src/strand-cohort.ts +4 -0
  193. package/src/strand-database.ts +3 -0
  194. package/src/strand-first-sync-gate.ts +4 -3
  195. package/src/strand-instance-manager.ts +1 -1
  196. package/src/strand-membership-writer.ts +61 -16
  197. package/src/strand-move.ts +45 -36
  198. package/src/strand-peer-redial.ts +2 -3
  199. package/src/types.ts +304 -103
@@ -1,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';
@@ -70,8 +72,15 @@ export function canonicalSeedPayload(seed) {
70
72
  export function seedDigest(seed) {
71
73
  return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url');
72
74
  }
75
+ /** Add `relayAddr` to the entry for `selfPeerId` in `peers`, when that entry exists and lacks it. */
76
+ function addSelfRelayAddr(peers, selfPeerId, relayAddr) {
77
+ const self = peers.find((peer) => peer.peerId === selfPeerId);
78
+ if (self && !self.multiaddrs.includes(relayAddr)) {
79
+ self.multiaddrs.push(relayAddr);
80
+ }
81
+ }
73
82
  /**
74
- * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
83
+ * An {@link ApplySeedResult} for a seed refused before the dial loop ran,
75
84
  * so the dial counters read zero rather than being absent.
76
85
  */
77
86
  function seedRejected(error, code) {
@@ -80,8 +89,8 @@ function seedRejected(error, code) {
80
89
  peersAdded: 0,
81
90
  error,
82
91
  ...(code !== undefined ? { code } : {}),
83
- ownerDialsAttempted: 0,
84
- ownerDialsFailed: 0,
92
+ preferredDialsAttempted: 0,
93
+ preferredDialsFailed: 0,
85
94
  };
86
95
  }
87
96
  /**
@@ -162,13 +171,6 @@ export class SeedBootstrapService {
162
171
  this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
163
172
  this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
164
173
  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
174
  log('SeedBootstrapService created for party: %s', config.partyId);
173
175
  }
174
176
  /**
@@ -179,22 +181,25 @@ export class SeedBootstrapService {
179
181
  this.eventCallbacks = callbacks;
180
182
  }
181
183
  /**
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.
184
+ * Whether this service holds an owner signer, i.e. can produce the owner signatures that
185
+ * gate `CadrePeer` / `DeviceToken` inserts, deletes, and re-authorizations. A
186
+ * seed-listener-only service (`enableSeedListener`, no signer) returns false: it can
187
+ * receive/apply seeds but cannot author or re-issue owner writes. Used by the
188
+ * write-while-alone re-replication drain to skip owner work on a non-owner node.
188
189
  */
189
190
  canAuthorize() {
190
- return !!this.config.ownerPrivateKey;
191
+ return this.config.ownerSigner !== undefined;
192
+ }
193
+ /** The owner signer this service was built with, or null for a listener-only service. */
194
+ get ownerSigner() {
195
+ return this.config.ownerSigner ?? null;
191
196
  }
192
197
  /**
193
198
  * Initialize the service with libp2p node and control database.
194
199
  *
195
200
  * `registerHandler` (default true) gates registration of the shared inbound
196
201
  * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
197
- * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
202
+ * (`CadreNode.useOwnerSigner`, `enableSeedListener`) own that handler and leave
198
203
  * it on. The throwaway temp service CadreNode builds in `applySeed` passes
199
204
  * `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
200
205
  * dialing and known-key lookup, and must NOT bind a discarded closure to the
@@ -226,8 +231,11 @@ export class SeedBootstrapService {
226
231
  * `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
227
232
  * the peer's self-signature, so the peer must self-publish (see
228
233
  * {@link CadreNode.registerSelf}) before the row resolves.
234
+ *
235
+ * @param sign - The signature function of an owner approval the caller already holds, so a
236
+ * multi-step action asks once; omitted, this write asks for its own approval.
229
237
  */
230
- async authorizePeer(options) {
238
+ async authorizePeer(options, sign) {
231
239
  const { peerId, multiaddrs } = options;
232
240
  log('Authorizing peer: %s', peerId);
233
241
  // CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
@@ -239,7 +247,7 @@ export class SeedBootstrapService {
239
247
  updatedAt: Date.now(),
240
248
  sig: null,
241
249
  profile: null,
242
- });
250
+ }, `Add the machine ${peerId} to the cadre`, sign);
243
251
  log('Peer %s authorized successfully', peerId);
244
252
  }
245
253
  /**
@@ -255,7 +263,7 @@ export class SeedBootstrapService {
255
263
  * in which case the row in the database is NOT this record and carries whatever
256
264
  * `Sig` that writer had, so the caller must self-update to publish its signature.
257
265
  */
258
- async insertSelfPeerRecord(record) {
266
+ async insertSelfPeerRecord(record, sign) {
259
267
  return await this.insertCadrePeerRow({
260
268
  peerId: record.peerId,
261
269
  publicKey: record.publicKey,
@@ -263,30 +271,27 @@ export class SeedBootstrapService {
263
271
  updatedAt: record.updatedAt,
264
272
  sig: record.sig,
265
273
  profile: record.profile,
266
- });
274
+ }, 'Publish this machine\'s own address record', sign);
267
275
  }
268
276
  /**
269
- * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
277
+ * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-signer
270
278
  * precondition; the stamp mint, voucher signature, write lock, existence check and
271
279
  * membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
272
280
  * the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
273
281
  * method: the write is what admits the peer's traffic.
274
282
  *
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).
283
+ * The DB check precedes the owner-signer check here, matching the order this method has
284
+ * always surfaced them.
278
285
  *
279
286
  * @returns `true` when this call performed the INSERT, `false` when the row was
280
287
  * already seated by a concurrent writer. The loser needs to know: an authorize seats
281
288
  * a row with a null `Sig`, so a self-publish that lost the race must fall through to
282
289
  * a self-update or its record never lands.
283
290
  */
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));
291
+ async insertCadrePeerRow(row, action, sign) {
292
+ const controlDatabase = this.requireControlDatabase();
293
+ const ownerKey = this.requireOwnerSigner().publicKey;
294
+ return await this.withOwnerSign(action, sign, (approved) => controlDatabase.insertCadrePeer(row, ownerKey, approved));
290
295
  }
291
296
  /**
292
297
  * Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
@@ -305,17 +310,17 @@ export class SeedBootstrapService {
305
310
  * fresh signature — unaffected by the cleared row's retired stamp
306
311
  * (`DeviceToken.NotRevoked`).
307
312
  */
308
- async insertSelfDeviceToken(record) {
313
+ async insertSelfDeviceToken(record, sign) {
314
+ const ownerKey = this.requireOwnerSigner().publicKey;
315
+ const controlDatabase = this.requireControlDatabase();
309
316
  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(`
317
+ const digestBytes = uint8ArrayFromString(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }), 'base64url');
318
+ const signature = await this.withOwnerSign('Publish this machine\'s push token', sign, (approved) => approved(digestBytes));
319
+ await controlDatabase.execWrite(`
315
320
  insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
316
321
  with context OwnerKey = ?, Signature = ?
317
322
  values (?, ?, ?, ?, ?, ?)
318
- `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
323
+ `, [ownerKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
319
324
  log('Device token inserted (owner-signed): %s', record.peerId);
320
325
  }
321
326
  /**
@@ -336,66 +341,44 @@ export class SeedBootstrapService {
336
341
  * Both digests, the stamp read, and the transaction come from
337
342
  * {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
338
343
  * `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
344
+ * owner-signer precondition; a no-op on an already-absent row is the shared body's
340
345
  * behavior, and unlike {@link removePeer} nothing here rides on it.
341
346
  */
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));
347
+ async deleteDeviceToken(peerId, sign) {
348
+ // Fail fast on a signerless service BEFORE the DB read, so a non-owner gets the
349
+ // owner-signer error rather than a silent no-op on an absent row.
350
+ const ownerKey = this.requireOwnerSigner().publicKey;
351
+ const controlDatabase = this.requireControlDatabase();
352
+ await this.withOwnerSign(`Clear the push token of ${peerId}`, sign, (approved) => controlDatabase.deleteDeviceToken(peerId, ownerKey, approved));
350
353
  }
351
354
  /**
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.
355
+ * Return the configured owner signer, or throw if none is set. The single precondition
356
+ * gate for every owner-signed write. {@link removePeer} / {@link reauthorizePeer} read the
357
+ * row's `StampId` from the DB BEFORE they sign, so they call this up front — otherwise a
358
+ * signerless service would either surface the wrong "Control database not initialized"
359
+ * error or, worse, silently no-op when the target row is absent (the early
360
+ * `stampId === null` return) instead of rejecting.
358
361
  */
359
- requireOwnerPrivateKey() {
360
- if (!this.config.ownerPrivateKey) {
361
- throw new Error('Owner private key required to authorize peers');
362
+ requireOwnerSigner() {
363
+ if (!this.config.ownerSigner) {
364
+ throw new Error('Owner signer required to authorize peers');
362
365
  }
363
- return this.config.ownerPrivateKey;
366
+ return this.config.ownerSigner;
364
367
  }
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');
368
+ /** The control database {@link initialize} attached, or a throw. */
369
+ requireControlDatabase() {
370
+ if (!this.controlDatabase) {
371
+ throw new Error('Control database not initialized');
378
372
  }
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');
373
+ return this.controlDatabase;
389
374
  }
390
375
  /**
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".
376
+ * Run `body` with an owner signature function: the caller's `sign` when it passes one (a
377
+ * `CadreNode` action already inside its approval), otherwise one approval of this service's
378
+ * signer for `action`, the plain-words description a prompting signer shows.
396
379
  */
397
- signMessageBytes(message) {
398
- return this.signDigest(uint8ArrayToString(message, 'base64url'));
380
+ async withOwnerSign(action, sign, body) {
381
+ return await withinOwnerApproval(this.requireOwnerSigner(), { partyId: this.config.partyId, action }, sign, body);
399
382
  }
400
383
  /**
401
384
  * Remove a peer from the cadre by owner signature.
@@ -419,16 +402,14 @@ export class SeedBootstrapService {
419
402
  * Both digests, the stamp read, the transaction, and the post-commit membership notify
420
403
  * come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
421
404
  * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
422
- * owner-key precondition and the absent-row gate the notify depends on (below).
405
+ * owner-signer precondition and the absent-row gate the notify depends on (below).
423
406
  */
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
407
+ async removePeer(peerId, sign) {
408
+ // Fail fast on a signerless service BEFORE any DB read: a non-owner cannot sign the
409
+ // remove digest, and this precedence (owner signer, then control DB) is what the
427
410
  // unit contract asserts.
428
- const ownerKey = this.requireOwnerPublicKey();
429
- if (!this.controlDatabase) {
430
- throw new Error('Control database not initialized');
431
- }
411
+ const ownerKey = this.requireOwnerSigner().publicKey;
412
+ const controlDatabase = this.requireControlDatabase();
432
413
  // This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
433
414
  // internally: deleteCadrePeer's membership notify fires whenever its body resolves,
434
415
  // with no idea whether the body wrote anything, so delegating an absent peer would
@@ -436,74 +417,69 @@ export class SeedBootstrapService {
436
417
  // NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
437
418
  // between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
438
419
  // window only — the common "already absent" case is caught here.
439
- const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
420
+ const stampId = await controlDatabase.queryCadrePeerStampId(peerId);
440
421
  if (stampId === null) {
441
422
  log('removePeer: no CadrePeer row for %s (already absent)', peerId);
442
423
  return;
443
424
  }
444
425
  log('Removing peer: %s', peerId);
445
- await this.controlDatabase.deleteCadrePeer(peerId, ownerKey, message => this.signMessageBytes(message));
426
+ await this.withOwnerSign(`Remove the machine ${peerId} from the cadre`, sign, (approved) => controlDatabase.deleteCadrePeer(peerId, ownerKey, approved));
446
427
  log('Peer %s removed successfully (stamp retired)', peerId);
447
428
  }
448
429
  /**
449
- * Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
430
+ * Seat `ownerPublicKey` as a further owner, signed by this service's owner signer. The
450
431
  * liveness check, the stamp, the signature and the membership notify are
451
- * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
432
+ * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-signer
452
433
  * precondition, checked before any read (see {@link removePeer}).
453
434
  *
435
+ * @param label - the user's name for the key, bound into the signature
436
+ * ({@link ControlDatabase.insertOwnerKey} has the rule).
454
437
  * @returns `true` when the row was seated, `false` when the key is already a live owner.
455
438
  */
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));
439
+ async addOwner(ownerPublicKey, label, sign) {
440
+ const ownerKey = this.requireOwnerSigner().publicKey;
441
+ const controlDatabase = this.requireControlDatabase();
442
+ // Checked before the approval opens, so a label the row would refuse never prompts the user.
443
+ const storedLabel = ownerKeyLabel(label);
444
+ return await this.withOwnerSign(`Add owner key ${namedOwnerKey(ownerPublicKey, storedLabel)}`, sign, (approved) => controlDatabase.insertOwnerKeyVouched(ownerPublicKey, ownerKey, approved, storedLabel));
462
445
  }
463
446
  /**
464
- * Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
447
+ * Remove `ownerPublicKey` from the owners, signed by this service's owner signer: the delete
465
448
  * and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
466
449
  * the last owner), the digests and the membership notify are
467
- * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
450
+ * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-signer precondition,
468
451
  * checked before any read (see {@link removePeer}).
469
452
  *
470
453
  * @returns `true` when the row was removed, `false` when the key is not a live owner here.
471
454
  */
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));
455
+ async removeOwner(ownerPublicKey, sign) {
456
+ const ownerKey = this.requireOwnerSigner().publicKey;
457
+ const controlDatabase = this.requireControlDatabase();
458
+ return await this.withOwnerSign(`Remove owner key ${ownerPublicKey}`, sign, (approved) => controlDatabase.deleteOwnerKey(ownerPublicKey, ownerKey, approved));
478
459
  }
479
460
  /**
480
- * Re-vouch another owner's row under this service's owner key, so it stands on a remaining
461
+ * Re-vouch another owner's row under this service's owner signer, so it stands on a remaining
481
462
  * 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}).
463
+ * ({@link ControlDatabase.revouchOwnerKey}; for `CadreNode.removeOwner` only). What stays
464
+ * here is the owner-signer precondition, checked before any read (see {@link removePeer}).
484
465
  *
485
466
  * @returns `true` when the row was re-vouched, `false` when the key is not a live owner here.
486
467
  */
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));
468
+ async revouchOwner(ownerPublicKey, sign) {
469
+ const ownerKey = this.requireOwnerSigner().publicKey;
470
+ const controlDatabase = this.requireControlDatabase();
471
+ return await this.withOwnerSign(`Re-vouch owner key ${ownerPublicKey}`, sign, (approved) => controlDatabase.revouchOwnerKey(ownerPublicKey, ownerKey, approved));
493
472
  }
494
473
  /**
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}).
474
+ * Remove a machine completely, signed by this service's owner signer: its `CadrePeer` row, its
475
+ * `DeviceToken` row, and the withdrawal of every invitation in `target.inviteKeys`, all in one
476
+ * transaction ({@link ControlDatabase.removeMachine}). What stays here is the owner-signer
477
+ * precondition, checked before any read (see {@link removePeer}).
500
478
  */
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));
479
+ async removeMachine(target, sign) {
480
+ const ownerKey = this.requireOwnerSigner().publicKey;
481
+ const controlDatabase = this.requireControlDatabase();
482
+ return await this.withOwnerSign(`Remove the machine ${target.peerId} from the cadre`, sign, (approved) => controlDatabase.removeMachine(target, ownerKey, approved));
507
483
  }
508
484
  /**
509
485
  * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
@@ -514,23 +490,21 @@ export class SeedBootstrapService {
514
490
  *
515
491
  * The stamp read, the voucher signature, the write lock and the membership notify are
516
492
  * {@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.
493
+ * `VouchOwner` to this node's owner key. What stays here is the owner-signer precondition.
518
494
  *
519
495
  * A no-op (no throw, no notify) when the row does not exist.
520
496
  *
521
497
  * @param peerId - the membership row to re-touch.
522
498
  * @param updatedAt - the strictly-increasing freshness stamp to write.
523
- * @throws if no owner private key is configured (a non-owner cannot
499
+ * @throws if no owner signer is configured (a non-owner cannot
524
500
  * re-sign another peer's row) or the control database is not initialized.
525
501
  */
526
- async reauthorizePeer(peerId, updatedAt) {
527
- // Fail fast on a keyless service before any DB read (see removePeer): a non-owner
502
+ async reauthorizePeer(peerId, updatedAt, sign) {
503
+ // Fail fast on a signerless service before any DB read (see removePeer): a non-owner
528
504
  // 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));
505
+ const ownerKey = this.requireOwnerSigner().publicKey;
506
+ const controlDatabase = this.requireControlDatabase();
507
+ const retouched = await this.withOwnerSign(`Re-issue the membership of ${peerId}`, sign, (approved) => controlDatabase.reauthorizeCadrePeer(peerId, updatedAt, ownerKey, approved));
534
508
  if (!retouched) {
535
509
  log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
536
510
  return;
@@ -547,81 +521,115 @@ export class SeedBootstrapService {
547
521
  *
548
522
  * The signatures, the single transaction, and the strictly-monotonic
549
523
  * `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
550
- * stays here is the owner-key precondition.
524
+ * stays here is the owner-signer precondition.
551
525
  *
552
526
  * @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
527
+ * @throws if no owner signer is configured or the control database is not
554
528
  * initialized.
555
529
  */
556
- async reissueRevocations(rows, reissuedAt) {
557
- // Fail fast on a keyless service before any DB work (see removePeer): a
530
+ async reissueRevocations(rows, reissuedAt, sign) {
531
+ // Fail fast on a signerless service before any DB work (see removePeer): a
558
532
  // 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));
533
+ const ownerKey = this.requireOwnerSigner().publicKey;
534
+ const controlDatabase = this.requireControlDatabase();
535
+ return await this.withOwnerSign(`Re-send ${rows.length} removal record(s) to the cadre`, sign, (approved) => controlDatabase.reissueRevocations(rows, reissuedAt, ownerKey, approved));
564
536
  }
565
537
  /**
566
538
  * Owner filing of the singleton `Revocation` ledger marker, so the table is never a
567
539
  * never-written block that the storage layer re-consults on every read.
568
540
  *
569
541
  * 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
542
+ * {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-signer
571
543
  * precondition.
572
544
  *
573
- * @throws if no owner private key is configured or the control database is not
545
+ * @throws if no owner signer is configured or the control database is not
574
546
  * initialized.
575
547
  */
576
- async openRevocationLedger() {
577
- // Fail fast on a keyless service before any DB work (see removePeer): a
548
+ async openRevocationLedger(sign) {
549
+ // Fail fast on a signerless service before any DB work (see removePeer): a
578
550
  // 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));
551
+ const ownerKey = this.requireOwnerSigner().publicKey;
552
+ const controlDatabase = this.requireControlDatabase();
553
+ return await this.withOwnerSign('Open the cadre\'s removal ledger', sign, (approved) => controlDatabase.openRevocationLedger(ownerKey, approved));
584
554
  }
585
555
  /**
586
556
  * Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
587
557
  * on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
588
558
  * 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.
559
+ * `CadreNode.createCadreInvitation`'s; what stays here is the owner-signer precondition and
560
+ * the approval, as {@link insertSelfDeviceToken} keeps them for device tokens.
591
561
  *
592
562
  * @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.
563
+ * @throws if no owner signer is configured or the control database is not initialized.
594
564
  */
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));
565
+ async insertCadreInvite(invite, sign) {
566
+ // Fail fast on a signerless service before any DB work (see removePeer).
567
+ const ownerKey = this.requireOwnerSigner().publicKey;
568
+ const controlDatabase = this.requireControlDatabase();
569
+ return await this.withOwnerSign('Create a machine invitation to the cadre', sign, (approved) => controlDatabase.insertCadreInvite(invite, ownerKey, approved));
602
570
  }
603
571
  /**
604
572
  * Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
605
573
  * the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
606
- * owner-key precondition.
574
+ * owner-signer precondition.
607
575
  *
608
576
  * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
609
577
  * already withdrawn.
610
- * @throws if no owner private key is configured or the control database is not initialized.
578
+ * @throws if no owner signer is configured or the control database is not initialized.
611
579
  */
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));
580
+ async withdrawCadreInvite(key, sign) {
581
+ const ownerKey = this.requireOwnerSigner().publicKey;
582
+ const controlDatabase = this.requireControlDatabase();
583
+ return await this.withOwnerSign(`Withdraw the cadre invitation ${key}`, sign, (approved) => controlDatabase.withdrawCadreInvite(key, ownerKey, approved));
584
+ }
585
+ /**
586
+ * Owner-signed INSERT of an `OwnerGrant` row — a grant of ownership any member machine can
587
+ * redeem on this owner's behalf ({@link ControlDatabase.insertOwnerGrant}). The keypair mint,
588
+ * the lifetime default and the bundle the holder receives are `CadreNode.createOwnerGrant`'s;
589
+ * what stays here is the owner-signer precondition and the approval, as for
590
+ * {@link insertCadreInvite}. The label is checked before the approval opens, so one the row
591
+ * would refuse never prompts the user.
592
+ *
593
+ * @returns the row as stored, which the grant bundle carries verbatim.
594
+ * @throws if no owner signer is configured, the control database is not initialized, or the label is too long.
595
+ */
596
+ async insertOwnerGrant(grant, sign) {
597
+ const ownerKey = this.requireOwnerSigner().publicKey;
598
+ const controlDatabase = this.requireControlDatabase();
599
+ const label = ownerKeyLabel(grant.label);
600
+ const named = label === null ? 'an owner grant' : `the owner grant "${label}"`;
601
+ return await this.withOwnerSign(`Create ${named}: whoever holds it can become an owner of the cadre`, sign, (approved) => controlDatabase.insertOwnerGrant({ ...grant, label }, ownerKey, approved));
602
+ }
603
+ /**
604
+ * Owner-signed WITHDRAWAL of an `OwnerGrant`: the `Revocation` tombstone over its stamp, with
605
+ * the row kept ({@link ControlDatabase.withdrawOwnerGrant}). What stays here is the
606
+ * owner-signer precondition.
607
+ *
608
+ * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
609
+ * already withdrawn.
610
+ * @throws if no owner signer is configured or the control database is not initialized.
611
+ */
612
+ async withdrawOwnerGrant(key, sign) {
613
+ const ownerKey = this.requireOwnerSigner().publicKey;
614
+ const controlDatabase = this.requireControlDatabase();
615
+ return await this.withOwnerSign(`Withdraw the owner grant ${key}`, sign, (approved) => controlDatabase.withdrawOwnerGrant(key, ownerKey, approved));
618
616
  }
619
617
  /**
620
618
  * Create a seed from the current control network state.
621
- * The seed contains peer information and is signed by an owner.
619
+ * The seed contains peer information and is signed by an owner; its `signerKey` is the
620
+ * owner signer's key.
622
621
  */
623
- async createSeed() {
624
- if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
622
+ async createSeed(sign) {
623
+ return await this.mintSeed(sign, null);
624
+ }
625
+ /**
626
+ * {@link createSeed}, with `selfRelayAddr` added to this node's own peer entry before the
627
+ * seed is signed: the signature covers every address ({@link seedDigest}), so nothing may
628
+ * be added after it.
629
+ */
630
+ async mintSeed(sign, selfRelayAddr) {
631
+ const signer = this.config.ownerSigner;
632
+ if (!signer) {
625
633
  throw new Error('Owner key required to create seeds');
626
634
  }
627
635
  if (!this.controlDatabase || !this.libp2pNode) {
@@ -629,18 +637,23 @@ export class SeedBootstrapService {
629
637
  }
630
638
  log('Creating seed for party: %s', this.config.partyId);
631
639
  // Query all peers from the control database
632
- const peers = await this.queryPeers();
640
+ const selfPeerId = this.libp2pNode.peerId.toString();
641
+ const peers = await this.queryPeers(selfPeerId);
642
+ if (selfRelayAddr !== null) {
643
+ addSelfRelayAddr(peers, selfPeerId, selfRelayAddr);
644
+ }
633
645
  // Create the seed data (without signature)
634
646
  const seedData = {
635
647
  partyId: this.config.partyId,
636
648
  peers,
637
649
  };
638
650
  // Sign the seed over its canonical byte representation
639
- const signature = sign(seedDigest(seedData), this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
651
+ const digestBytes = uint8ArrayFromString(seedDigest(seedData), 'base64url');
652
+ const signature = await this.withOwnerSign('Mint a seed for a machine joining the cadre', sign, (approved) => approved(digestBytes));
640
653
  const seed = {
641
654
  ...seedData,
642
655
  signature,
643
- signerKey: this.ownerPublicKey,
656
+ signerKey: signer.publicKey,
644
657
  };
645
658
  log('Created seed with %d peers', peers.length);
646
659
  return seed;
@@ -652,9 +665,8 @@ export class SeedBootstrapService {
652
665
  * `signerKey` that does NOT come from the seed body: the receiver's
653
666
  * node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
654
667
  * 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.
668
+ * `SeedTrustPolicy`. A forged self-signed seed does not pass, and neither does
669
+ * one signed by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
658
670
  *
659
671
  * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
660
672
  * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
@@ -670,12 +682,12 @@ export class SeedBootstrapService {
670
682
  if (!merged.success) {
671
683
  return merged;
672
684
  }
673
- return { ...merged, ...await this.dialSeedOwners(seed) };
685
+ return { ...merged, ...await this.dialPreferredPeers(seed) };
674
686
  }
675
687
  /**
676
688
  * The first half of {@link applySeed}: check the signature and the signer's trust, then merge
677
689
  * 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.
690
+ * what the inbound handler acks with; the dials that follow cannot change it.
679
691
  */
680
692
  async verifyAndMergeSeed(seed, options) {
681
693
  if (!this.libp2pNode) {
@@ -709,7 +721,7 @@ export class SeedBootstrapService {
709
721
  partyId: seed.partyId,
710
722
  signerKey: seed.signerKey,
711
723
  knownOwnerKeys,
712
- // Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
724
+ // Optional-chained like `dialPreferredPeers`: partial libp2p handles (unit-test doubles)
713
725
  // omit `peerId`. A real node always has one, and a proof bound to a real peer id can
714
726
  // only fail against '' — the safe direction.
715
727
  localPeerId: this.libp2pNode.peerId?.toString() ?? '',
@@ -723,54 +735,55 @@ export class SeedBootstrapService {
723
735
  await this.anchorAcceptedSigner(seed.signerKey, decision);
724
736
  const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
725
737
  log('Merged seed: %d peers added', peersAdded);
726
- return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
738
+ return { success: true, peersAdded, preferredDialsAttempted: 0, preferredDialsFailed: 0 };
727
739
  }
728
740
  /**
729
- * The second half of {@link applySeed}: dial the seed's owner peers to establish connections.
741
+ * The second half of {@link applySeed}: dial the seed's preferred peers (`SeedPeer.preferred`:
742
+ * the machine that minted it and every always-on machine) to establish connections.
730
743
  *
731
- * Best-effort and COUNTED: an owner that is momentarily down leaves this node seeded but
744
+ * Best-effort and COUNTED: a peer that is momentarily down leaves this node seeded but
732
745
  * unconnected, which the caller can only see if the outcome is reported (see
733
- * `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
746
+ * `ApplySeedResult.preferredDialsFailed`). Recovery is not this loop's job —
734
747
  * `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
735
748
  * reconcile pass until the control database has siblings.
736
749
  *
737
750
  * The inbound handler runs this AFTER it has acked and closed the stream
738
- * ({@link handleSeedStream}), because an unreachable owner can take `dialBudget.totalMs`.
751
+ * ({@link handleSeedStream}), because an unreachable peer can take `dialBudget.totalMs`.
739
752
  */
740
- async dialSeedOwners(seed) {
753
+ async dialPreferredPeers(seed) {
741
754
  const node = this.libp2pNode;
742
755
  if (!node) {
743
- log('Seed service shut down before its owner dials; none attempted');
744
- return { ownerDialsAttempted: 0, ownerDialsFailed: 0 };
756
+ log('Seed service shut down before its dials; none attempted');
757
+ return { preferredDialsAttempted: 0, preferredDialsFailed: 0 };
745
758
  }
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".
759
+ // `createSeed` projects every non-revoked CadrePeer row, so a preferred machine applying
760
+ // a seed minted after it joined finds ITSELF in the list. Dialing self always throws,
761
+ // which would report a healthy node as "seeded but stranded".
749
762
  // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
750
763
  // 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
764
+ // Every one of a peer's addresses is a candidate, each on its own time limit
765
+ // (`dialPeerAddrs`), so a peer whose first address never answers neither
753
766
  // 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.
767
+ // addresses carry no peer id; the dial binds each to the peer's.
755
768
  const selfPeerId = node.peerId?.toString();
756
- let ownerDialsAttempted = 0;
757
- let ownerDialsFailed = 0;
758
- for (const peer of seed.peers.filter(p => p.isOwner)) {
769
+ let preferredDialsAttempted = 0;
770
+ let preferredDialsFailed = 0;
771
+ for (const peer of seed.peers.filter(p => p.preferred)) {
759
772
  if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
760
773
  continue;
761
774
  }
762
- ownerDialsAttempted++;
775
+ preferredDialsAttempted++;
763
776
  try {
764
777
  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}`);
778
+ log('Dialing preferred seed peer: %s (%d addr(s))', peer.peerId, addrs.length);
779
+ await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `Seed dial of ${peer.peerId}`);
767
780
  }
768
781
  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++;
782
+ // Counted even when the peer reaches us only through our own relay: this node is still
783
+ // not connected to it, and `preferredDialsFailed` is how the caller learns that.
784
+ preferredDialsFailed++;
772
785
  if (error instanceof SelfRelayOnlyError) {
773
- log('Owner peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
786
+ log('Seed peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
774
787
  }
775
788
  else {
776
789
  log('Failed to dial peer %s: %o', peer.peerId, error);
@@ -778,8 +791,8 @@ export class SeedBootstrapService {
778
791
  // Continue - not all peers need to be reachable
779
792
  }
780
793
  }
781
- log('Dialed seed owners: %d/%d owner dial(s) failed', ownerDialsFailed, ownerDialsAttempted);
782
- return { ownerDialsAttempted, ownerDialsFailed };
794
+ log('Dialed preferred seed peers: %d/%d dial(s) failed', preferredDialsFailed, preferredDialsAttempted);
795
+ return { preferredDialsAttempted, preferredDialsFailed };
783
796
  }
784
797
  /**
785
798
  * Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
@@ -883,7 +896,7 @@ export class SeedBootstrapService {
883
896
  }
884
897
  const peerId = peerIdFromString(target.peerId);
885
898
  const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
886
- await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
899
+ await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, preferred: false }]);
887
900
  if (addrs.length === 0) {
888
901
  return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
889
902
  }
@@ -984,16 +997,16 @@ export class SeedBootstrapService {
984
997
  }
985
998
  }
986
999
  /**
987
- * The peers a seed carries: the control database's own projection
1000
+ * The peers a seed `minterPeerId` mints carries: the control database's own projection
988
1001
  * ({@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`).
1002
+ * reply), which reads through the revocation filter and marks the minter and every
1003
+ * always-on machine preferred — a dial hint, not a trust decision.
991
1004
  */
992
- async queryPeers() {
1005
+ async queryPeers(minterPeerId) {
993
1006
  if (!this.controlDatabase) {
994
1007
  return [];
995
1008
  }
996
- return await this.controlDatabase.querySeedPeers();
1009
+ return await this.controlDatabase.querySeedPeers(minterPeerId);
997
1010
  }
998
1011
  /**
999
1012
  * Register the seed protocol handler. The inbound closure just delegates to
@@ -1009,14 +1022,14 @@ export class SeedBootstrapService {
1009
1022
  }
1010
1023
  /**
1011
1024
  * Read one inbound seed frame, verify and merge it, write the ack and close the
1012
- * stream, then dial the seed's owners.
1025
+ * stream, then dial the seed's preferred peers.
1013
1026
  *
1014
- * The ack and the close come BEFORE the owner dials: an unreachable owner can hold
1027
+ * The ack and the close come BEFORE those dials: an unreachable peer can hold
1015
1028
  * a dial for `dialBudget.totalMs` (86 s at the default declared link), which is not
1016
1029
  * link time and does not belong inside the sender's delivery deadline. The close is
1017
1030
  * what releases the sender, which reads the ack to end-of-stream. The stream stays
1018
1031
  * counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
1019
- * still bounds concurrent owner-dial phases.
1032
+ * still bounds concurrent dial phases.
1020
1033
  *
1021
1034
  * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
1022
1035
  * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
@@ -1045,7 +1058,7 @@ export class SeedBootstrapService {
1045
1058
  acked = true;
1046
1059
  await replyAndClose(stream, { accepted: merged.success, reason: merged.error, code: merged.code }, 'Seed');
1047
1060
  if (merged.success) {
1048
- await this.dialSeedOwners(seed);
1061
+ await this.dialPreferredPeers(seed);
1049
1062
  this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
1050
1063
  }
1051
1064
  else {
@@ -1117,16 +1130,20 @@ export class SeedBootstrapService {
1117
1130
  * does not listen. Call `CadreNode.reconcileControlCohort()` after delivering
1118
1131
  * the seed to dial at once.
1119
1132
  *
1133
+ * The vouch and the seed are one owner approval.
1134
+ *
1120
1135
  * @param options - Drone peer info from provider API
1121
1136
  * @returns Seed and encoded seed for drone initialization
1122
1137
  */
1123
- async addDrone(options) {
1138
+ async addDrone(options, sign) {
1124
1139
  const { dronePeerId, droneMultiaddrs } = options;
1125
1140
  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();
1141
+ const seed = await this.withOwnerSign(`Add the machine ${dronePeerId} to the cadre`, sign, async (approved) => {
1142
+ // 1. Authorize the new drone peer
1143
+ await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs }, approved);
1144
+ // 2. Create seed with current state
1145
+ return await this.createSeed(approved);
1146
+ });
1130
1147
  // 3. Encode for transport
1131
1148
  const encodedSeed = this.encodeSeed(seed);
1132
1149
  log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
@@ -1139,25 +1156,20 @@ export class SeedBootstrapService {
1139
1156
  * 1. Authorizes the new phone peer
1140
1157
  * 2. Creates a seed with relay addresses for dialing
1141
1158
  *
1159
+ * The vouch and the seed are one owner approval.
1160
+ *
1142
1161
  * @param phonePeerId - Peer ID of the new phone
1143
1162
  * @returns Seed with relay addresses for out-of-band delivery
1144
1163
  */
1145
- async addPhoneWithRelay(phonePeerId) {
1164
+ async addPhoneWithRelay(phonePeerId, sign) {
1146
1165
  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
1166
  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
- }
1167
+ const seed = await this.withOwnerSign(`Add the phone ${phonePeerId} to the cadre`, sign, async (approved) => {
1168
+ // 1. Authorize the new phone peer (no multiaddrs - NAT'd)
1169
+ await this.authorizePeer({ peerId: phonePeerId }, approved);
1170
+ // 2. Create seed - with our relay address in our own entry, when we have one
1171
+ return await this.mintSeed(approved, relayAddr);
1172
+ });
1161
1173
  const encodedSeed = this.encodeSeed(seed);
1162
1174
  log('Phone %s added with relay, seed created', phonePeerId);
1163
1175
  return { seed, encodedSeed };