@serfab/cadre-core 1.13.0 → 1.15.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 (230) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +383 -0
  3. package/dist/cadre-invite-protocol.js +774 -0
  4. package/dist/cadre-invite-protocol.js.map +1 -0
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +849 -210
  9. package/dist/cadre-node.js +1574 -374
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/claim-proof.d.ts +45 -0
  12. package/dist/claim-proof.js +132 -0
  13. package/dist/claim-proof.js.map +1 -0
  14. package/dist/control-authorization.d.ts +80 -19
  15. package/dist/control-authorization.js +55 -13
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.js +1 -1
  18. package/dist/control-cohort.js.map +1 -1
  19. package/dist/control-database.d.ts +605 -108
  20. package/dist/control-database.js +1299 -280
  21. package/dist/control-database.js.map +1 -1
  22. package/dist/control-protocol-guard.d.ts +142 -0
  23. package/dist/control-protocol-guard.js +293 -0
  24. package/dist/control-protocol-guard.js.map +1 -0
  25. package/dist/control-schema.d.ts +1 -1
  26. package/dist/control-schema.js +817 -1067
  27. package/dist/control-schema.js.map +1 -1
  28. package/dist/control-stream.d.ts +4 -0
  29. package/dist/control-stream.js +22 -7
  30. package/dist/control-stream.js.map +1 -1
  31. package/dist/delegate-admission.d.ts +1 -1
  32. package/dist/delegate-admission.js +1 -1
  33. package/dist/device-token.d.ts +11 -9
  34. package/dist/device-token.js +14 -12
  35. package/dist/device-token.js.map +1 -1
  36. package/dist/ed25519-key.d.ts +22 -9
  37. package/dist/ed25519-key.js +47 -10
  38. package/dist/ed25519-key.js.map +1 -1
  39. package/dist/fetch-impl.d.ts +12 -0
  40. package/dist/fetch-impl.js +23 -0
  41. package/dist/fetch-impl.js.map +1 -0
  42. package/dist/file-durable-slot.js +2 -1
  43. package/dist/file-durable-slot.js.map +1 -1
  44. package/dist/file-safe-name.d.ts +25 -0
  45. package/dist/file-safe-name.js +29 -0
  46. package/dist/file-safe-name.js.map +1 -0
  47. package/dist/formation-approval.js +5 -11
  48. package/dist/formation-approval.js.map +1 -1
  49. package/dist/fs-atomic.d.ts +0 -19
  50. package/dist/fs-atomic.js +3 -24
  51. package/dist/fs-atomic.js.map +1 -1
  52. package/dist/index.d.ts +22 -10
  53. package/dist/index.js +54 -11
  54. package/dist/index.js.map +1 -1
  55. package/dist/invitation-bootstrap.d.ts +50 -12
  56. package/dist/invitation-bootstrap.js +76 -13
  57. package/dist/invitation-bootstrap.js.map +1 -1
  58. package/dist/joined-strand-store.d.ts +7 -0
  59. package/dist/joined-strand-store.js +9 -0
  60. package/dist/joined-strand-store.js.map +1 -1
  61. package/dist/key-store-file.js +2 -1
  62. package/dist/key-store-file.js.map +1 -1
  63. package/dist/link-budget.d.ts +25 -33
  64. package/dist/link-budget.js +25 -35
  65. package/dist/link-budget.js.map +1 -1
  66. package/dist/machine-removal.d.ts +78 -0
  67. package/dist/machine-removal.js +166 -0
  68. package/dist/machine-removal.js.map +1 -0
  69. package/dist/membership-connection-gater.d.ts +191 -151
  70. package/dist/membership-connection-gater.js +236 -173
  71. package/dist/membership-connection-gater.js.map +1 -1
  72. package/dist/node-claim-payload.d.ts +74 -0
  73. package/dist/node-claim-payload.js +224 -0
  74. package/dist/node-claim-payload.js.map +1 -0
  75. package/dist/node-default-transports.d.ts +27 -0
  76. package/dist/node-default-transports.js +35 -0
  77. package/dist/node-default-transports.js.map +1 -0
  78. package/dist/node-transports.d.ts +30 -0
  79. package/dist/node-transports.js +40 -0
  80. package/dist/node-transports.js.map +1 -0
  81. package/dist/owner-anchor-sync.d.ts +22 -0
  82. package/dist/owner-anchor-sync.js +143 -0
  83. package/dist/owner-anchor-sync.js.map +1 -0
  84. package/dist/peer-authorization.d.ts +113 -18
  85. package/dist/peer-authorization.js +225 -29
  86. package/dist/peer-authorization.js.map +1 -1
  87. package/dist/peer-dial.d.ts +143 -27
  88. package/dist/peer-dial.js +276 -31
  89. package/dist/peer-dial.js.map +1 -1
  90. package/dist/peer-join-backfill.d.ts +26 -2
  91. package/dist/peer-join-backfill.js +60 -12
  92. package/dist/peer-join-backfill.js.map +1 -1
  93. package/dist/peer-record.d.ts +17 -52
  94. package/dist/peer-record.js +20 -58
  95. package/dist/peer-record.js.map +1 -1
  96. package/dist/pending-join-runner.d.ts +66 -58
  97. package/dist/pending-join-runner.js +136 -145
  98. package/dist/pending-join-runner.js.map +1 -1
  99. package/dist/primary-lan-address.d.ts +8 -0
  100. package/dist/primary-lan-address.js +46 -0
  101. package/dist/primary-lan-address.js.map +1 -0
  102. package/dist/provider/client.d.ts +131 -0
  103. package/dist/provider/client.js +226 -0
  104. package/dist/provider/client.js.map +1 -0
  105. package/dist/provider/descriptor.d.ts +41 -0
  106. package/dist/provider/descriptor.js +103 -0
  107. package/dist/provider/descriptor.js.map +1 -0
  108. package/dist/provider/index.d.ts +9 -0
  109. package/dist/provider/index.js +10 -0
  110. package/dist/provider/index.js.map +1 -0
  111. package/dist/provider/oauth.d.ts +125 -0
  112. package/dist/provider/oauth.js +199 -0
  113. package/dist/provider/oauth.js.map +1 -0
  114. package/dist/provider/rent-node.d.ts +101 -0
  115. package/dist/provider/rent-node.js +220 -0
  116. package/dist/provider/rent-node.js.map +1 -0
  117. package/dist/push-fanout.d.ts +8 -2
  118. package/dist/push-fanout.js +8 -2
  119. package/dist/push-fanout.js.map +1 -1
  120. package/dist/relay-addrs.d.ts +9 -4
  121. package/dist/relay-addrs.js +9 -4
  122. package/dist/relay-addrs.js.map +1 -1
  123. package/dist/relay-reservation.d.ts +51 -16
  124. package/dist/relay-reservation.js +107 -24
  125. package/dist/relay-reservation.js.map +1 -1
  126. package/dist/seed-bootstrap.d.ts +165 -123
  127. package/dist/seed-bootstrap.js +313 -275
  128. package/dist/seed-bootstrap.js.map +1 -1
  129. package/dist/seed-trust-policy.d.ts +98 -37
  130. package/dist/seed-trust-policy.js +188 -2
  131. package/dist/seed-trust-policy.js.map +1 -1
  132. package/dist/strand-addr-protocol.d.ts +24 -26
  133. package/dist/strand-addr-protocol.js +19 -26
  134. package/dist/strand-addr-protocol.js.map +1 -1
  135. package/dist/strand-commit-durability.d.ts +54 -0
  136. package/dist/strand-commit-durability.js +74 -0
  137. package/dist/strand-commit-durability.js.map +1 -0
  138. package/dist/strand-database.d.ts +23 -8
  139. package/dist/strand-database.js +41 -13
  140. package/dist/strand-database.js.map +1 -1
  141. package/dist/strand-first-sync-gate.d.ts +7 -1
  142. package/dist/strand-first-sync-gate.js +7 -1
  143. package/dist/strand-first-sync-gate.js.map +1 -1
  144. package/dist/strand-formation-protocol.js +5 -2
  145. package/dist/strand-formation-protocol.js.map +1 -1
  146. package/dist/strand-fret-addrs.d.ts +19 -1
  147. package/dist/strand-fret-addrs.js +27 -9
  148. package/dist/strand-fret-addrs.js.map +1 -1
  149. package/dist/strand-instance-manager.d.ts +81 -19
  150. package/dist/strand-instance-manager.js +216 -105
  151. package/dist/strand-instance-manager.js.map +1 -1
  152. package/dist/strand-move.d.ts +124 -0
  153. package/dist/strand-move.js +438 -0
  154. package/dist/strand-move.js.map +1 -0
  155. package/dist/strand-network-config.js +7 -6
  156. package/dist/strand-network-config.js.map +1 -1
  157. package/dist/strand-peer-redial.d.ts +113 -0
  158. package/dist/strand-peer-redial.js +236 -0
  159. package/dist/strand-peer-redial.js.map +1 -0
  160. package/dist/strand-revocation-enforcer.d.ts +11 -11
  161. package/dist/strand-revocation-enforcer.js +11 -11
  162. package/dist/strand-wake-protocol.d.ts +13 -16
  163. package/dist/strand-wake-protocol.js +23 -27
  164. package/dist/strand-wake-protocol.js.map +1 -1
  165. package/dist/trusted-owner-store.d.ts +35 -5
  166. package/dist/trusted-owner-store.js +50 -8
  167. package/dist/trusted-owner-store.js.map +1 -1
  168. package/dist/types.d.ts +512 -89
  169. package/dist/types.js +10 -0
  170. package/dist/types.js.map +1 -1
  171. package/package.json +19 -10
  172. package/src/cadre-invite-protocol.ts +1052 -0
  173. package/src/cadre-join-request.ts +167 -0
  174. package/src/cadre-node.ts +1703 -386
  175. package/src/claim-proof.ts +147 -0
  176. package/src/control-authorization.ts +203 -117
  177. package/src/control-cohort.ts +1 -1
  178. package/src/control-database.ts +4663 -3453
  179. package/src/control-protocol-guard.ts +348 -0
  180. package/src/control-schema.ts +828 -1078
  181. package/src/control-stream.ts +26 -7
  182. package/src/delegate-admission.ts +1 -1
  183. package/src/device-token.ts +14 -10
  184. package/src/ed25519-key.ts +47 -10
  185. package/src/fetch-impl.ts +23 -0
  186. package/src/file-durable-slot.ts +64 -63
  187. package/src/file-safe-name.ts +32 -0
  188. package/src/formation-approval.ts +604 -610
  189. package/src/fs-atomic.ts +92 -117
  190. package/src/index.ts +789 -648
  191. package/src/invitation-bootstrap.ts +91 -13
  192. package/src/joined-strand-store.ts +508 -498
  193. package/src/key-store-file.ts +142 -141
  194. package/src/link-budget.ts +25 -36
  195. package/src/machine-removal.ts +216 -0
  196. package/src/membership-connection-gater.ts +259 -191
  197. package/src/node-claim-payload.ts +259 -0
  198. package/src/node-default-transports.ts +38 -0
  199. package/src/node-transports.ts +49 -0
  200. package/src/owner-anchor-sync.ts +171 -0
  201. package/src/peer-authorization.ts +258 -29
  202. package/src/peer-dial.ts +604 -315
  203. package/src/peer-join-backfill.ts +62 -11
  204. package/src/peer-record.ts +159 -197
  205. package/src/pending-join-runner.ts +729 -735
  206. package/src/primary-lan-address.ts +47 -0
  207. package/src/provider/client.ts +326 -0
  208. package/src/provider/descriptor.ts +129 -0
  209. package/src/provider/index.ts +59 -0
  210. package/src/provider/oauth.ts +285 -0
  211. package/src/provider/rent-node.ts +275 -0
  212. package/src/push-fanout.ts +8 -2
  213. package/src/relay-addrs.ts +9 -4
  214. package/src/relay-reservation.ts +1211 -1121
  215. package/src/seed-bootstrap.ts +1502 -1448
  216. package/src/seed-trust-policy.ts +353 -140
  217. package/src/strand-addr-protocol.ts +26 -35
  218. package/src/strand-commit-durability.ts +116 -0
  219. package/src/strand-database.ts +46 -14
  220. package/src/strand-first-sync-gate.ts +7 -1
  221. package/src/strand-formation-protocol.ts +1039 -1036
  222. package/src/strand-fret-addrs.ts +179 -145
  223. package/src/strand-instance-manager.ts +2026 -1883
  224. package/src/strand-move.ts +585 -0
  225. package/src/strand-network-config.ts +7 -6
  226. package/src/strand-peer-redial.ts +307 -0
  227. package/src/strand-revocation-enforcer.ts +11 -11
  228. package/src/strand-wake-protocol.ts +23 -30
  229. package/src/trusted-owner-store.ts +242 -171
  230. package/src/types.ts +2687 -2242
@@ -4,11 +4,12 @@ import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-c
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
- import { dialPeerAddrs, SelfRelayOnlyError, DEFAULT_PEER_DIAL_BUDGET } from './peer-dial.js';
7
+ import { dialPeerAddrs, SelfRelayOnlyError, PeerUnreachableError, DEFAULT_PEER_DIAL_BUDGET, withTrailingPeerId, } from './peer-dial.js';
8
8
  import { relayedRequestBudgetMs } from './link-budget.js';
9
9
  import { generateStampId } from './control-database.js';
10
10
  import { canonicalJson } from './canonical-json.js';
11
11
  import { deviceTokenAddDigest } from './peer-authorization.js';
12
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
12
13
  import { anchoredTrustPolicy, } from './seed-trust-policy.js';
13
14
  const log = debug('sereus:cadre:seed-bootstrap');
14
15
  /** Protocol ID for seed delivery */
@@ -48,28 +49,6 @@ export function decodeLengthPrefixedFrame(data, maxLength = MAX_SEED_SIZE) {
48
49
  }
49
50
  return data.subarray(4, 4 + length);
50
51
  }
51
- /**
52
- * Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
53
- *
54
- * An Ed25519 PeerId is an identity multihash of the public key, so
55
- * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
56
- * base64url form matches the `OwnerKey.Key` representation (and
57
- * `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
58
- * id, a missing embedded key, or any parse failure — callers treat null as
59
- * "not an owner" rather than throwing.
60
- */
61
- export function ed25519PublicKeyB64FromPeerId(peerId) {
62
- try {
63
- const parsed = peerIdFromString(peerId);
64
- if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
65
- return null;
66
- }
67
- return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
68
- }
69
- catch {
70
- return null;
71
- }
72
- }
73
52
  /**
74
53
  * Canonical byte representation of the authenticated seed fields.
75
54
  *
@@ -82,12 +61,28 @@ export function ed25519PublicKeyB64FromPeerId(peerId) {
82
61
  export function canonicalSeedPayload(seed) {
83
62
  return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
84
63
  }
64
+ /**
65
+ * The digest a seed's signature covers: sha256 over {@link canonicalSeedPayload},
66
+ * base64url. One function so the signer (`createSeed`), the verifier
67
+ * (`validateSeedSignature`) and the claim proof (`claim-proof.ts`, which binds a
68
+ * proof to one seed through this value) all name the same bytes.
69
+ */
70
+ export function seedDigest(seed) {
71
+ return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url');
72
+ }
85
73
  /**
86
74
  * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
87
75
  * so the dial counters read zero rather than being absent.
88
76
  */
89
- function seedRejected(error) {
90
- return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
77
+ function seedRejected(error, code) {
78
+ return {
79
+ success: false,
80
+ peersAdded: 0,
81
+ error,
82
+ ...(code !== undefined ? { code } : {}),
83
+ ownerDialsAttempted: 0,
84
+ ownerDialsFailed: 0,
85
+ };
91
86
  }
92
87
  /**
93
88
  * Parse each address, dropping (and logging) any that is malformed, so one bad
@@ -105,6 +100,48 @@ function parseDialAddrs(addrs) {
105
100
  }
106
101
  return parsed;
107
102
  }
103
+ /**
104
+ * Merge a peer list's addresses into `node`'s peer store, so the node can dial them: the
105
+ * seed's peers after its signature and trust checks, the dial hints a member returns on
106
+ * a cadre invitation redemption (`cadre-invite-protocol.ts`), or the node a seed is being
107
+ * delivered to ({@link SeedBootstrapService.deliverSeed}). Best-effort per peer — one
108
+ * unparsable entry costs that peer, not the rest — and a peer with no address is skipped,
109
+ * since there is nothing to dial. Returns how many peers were merged.
110
+ */
111
+ export async function mergeSeedPeers(node, peers) {
112
+ let peersAdded = 0;
113
+ for (const peer of peers) {
114
+ if (peer.multiaddrs.length === 0) {
115
+ continue;
116
+ }
117
+ try {
118
+ const peerId = peerIdFromString(peer.peerId);
119
+ const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
120
+ await node.peerStore.merge(peerId, { multiaddrs: addrs });
121
+ peersAdded++;
122
+ log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
123
+ }
124
+ catch (error) {
125
+ log('Failed to add peer %s: %o', peer.peerId, error);
126
+ }
127
+ }
128
+ return peersAdded;
129
+ }
130
+ /**
131
+ * A delivery target's addresses, each bound to its peer id (`withTrailingPeerId`) so the dial
132
+ * authenticates the node it aims at rather than trusting whoever answers. An address that does
133
+ * not parse, or that names another peer, throws: that is the caller's mistake, not the node
134
+ * being unreachable.
135
+ */
136
+ function deliveryDialAddrs(peerId, addrs) {
137
+ return addrs.map((text) => {
138
+ const bound = withTrailingPeerId(multiaddr(text), peerId);
139
+ if (!bound) {
140
+ throw new Error(`Seed delivery to ${peerId}: address ${text} names another peer`);
141
+ }
142
+ return bound;
143
+ });
144
+ }
108
145
  /**
109
146
  * SeedBootstrapService handles control network seed generation and delivery.
110
147
  *
@@ -158,10 +195,10 @@ export class SeedBootstrapService {
158
195
  * `registerHandler` (default true) gates registration of the shared inbound
159
196
  * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
160
197
  * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
161
- * it on. The throwaway temp services CadreNode builds in `applySeed` /
162
- * `dialInvite` pass `false`: they only need the stored `libp2pNode` /
163
- * `controlDatabase` for dialing and known-key lookup, and must NOT bind a
164
- * discarded closure to the shared node (a handler leak, and a second
198
+ * it on. The throwaway temp service CadreNode builds in `applySeed` passes
199
+ * `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
200
+ * dialing and known-key lookup, and must NOT bind a discarded closure to the
201
+ * shared node (a handler leak, and a second
165
202
  * `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
166
203
  *
167
204
  * Rejects when libp2p refuses the registration; the node and database are
@@ -201,6 +238,7 @@ export class SeedBootstrapService {
201
238
  multiaddr: multiaddrStr,
202
239
  updatedAt: Date.now(),
203
240
  sig: null,
241
+ profile: null,
204
242
  });
205
243
  log('Peer %s authorized successfully', peerId);
206
244
  }
@@ -224,6 +262,7 @@ export class SeedBootstrapService {
224
262
  multiaddr: record.addrs.join(','),
225
263
  updatedAt: record.updatedAt,
226
264
  sig: record.sig,
265
+ profile: record.profile,
227
266
  });
228
267
  }
229
268
  /**
@@ -268,14 +307,14 @@ export class SeedBootstrapService {
268
307
  */
269
308
  async insertSelfDeviceToken(record) {
270
309
  const stampId = generateStampId(record.peerId);
271
- const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
310
+ const signature = this.signDigest(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }));
272
311
  if (!this.controlDatabase) {
273
312
  throw new Error('Control database not initialized');
274
313
  }
275
- await this.controlDatabase.execWrite(`
276
- insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
277
- with context OwnerKey = ?, Signature = ?
278
- values (?, ?, ?, ?, ?, ?)
314
+ await this.controlDatabase.execWrite(`
315
+ insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
316
+ with context OwnerKey = ?, Signature = ?
317
+ values (?, ?, ?, ?, ?, ?)
279
318
  `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
280
319
  log('Device token inserted (owner-signed): %s', record.peerId);
281
320
  }
@@ -363,7 +402,7 @@ export class SeedBootstrapService {
363
402
  *
364
403
  * The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
365
404
  * signature over the DISTINCT 'remove'-tagged digest
366
- * `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
405
+ * `digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId)` by an owner
367
406
  * key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
368
407
  * never be replayed to delete.
369
408
  *
@@ -406,6 +445,66 @@ export class SeedBootstrapService {
406
445
  await this.controlDatabase.deleteCadrePeer(peerId, ownerKey, message => this.signMessageBytes(message));
407
446
  log('Peer %s removed successfully (stamp retired)', peerId);
408
447
  }
448
+ /**
449
+ * Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
450
+ * liveness check, the stamp, the signature and the membership notify are
451
+ * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
452
+ * precondition, checked before any read (see {@link removePeer}).
453
+ *
454
+ * @returns `true` when the row was seated, `false` when the key is already a live owner.
455
+ */
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));
462
+ }
463
+ /**
464
+ * Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
465
+ * and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
466
+ * the last owner), the digests and the membership notify are
467
+ * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
468
+ * checked before any read (see {@link removePeer}).
469
+ *
470
+ * @returns `true` when the row was removed, `false` when the key is not a live owner here.
471
+ */
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));
478
+ }
479
+ /**
480
+ * Re-vouch another owner's row under this service's owner key, so it stands on a remaining
481
+ * 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}).
484
+ *
485
+ * @returns `true` when the row was re-vouched, `false` when the key is not a live owner here.
486
+ */
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));
493
+ }
494
+ /**
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}).
500
+ */
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));
507
+ }
409
508
  /**
410
509
  * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
411
510
  * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
@@ -483,6 +582,40 @@ export class SeedBootstrapService {
483
582
  }
484
583
  return this.controlDatabase.openRevocationLedger(ownerKey, message => this.signMessageBytes(message));
485
584
  }
585
+ /**
586
+ * Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
587
+ * on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
588
+ * 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.
591
+ *
592
+ * @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.
594
+ */
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));
602
+ }
603
+ /**
604
+ * Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
605
+ * the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
606
+ * owner-key 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 private key is configured or the control database is not initialized.
611
+ */
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));
618
+ }
486
619
  /**
487
620
  * Create a seed from the current control network state.
488
621
  * The seed contains peer information and is signed by an owner.
@@ -503,9 +636,7 @@ export class SeedBootstrapService {
503
636
  peers,
504
637
  };
505
638
  // Sign the seed over its canonical byte representation
506
- const seedJson = canonicalSeedPayload(seedData);
507
- const seedDigest = digest([seedJson], 'sha256', 'base64url');
508
- const signature = sign(seedDigest, this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
639
+ const signature = sign(seedDigest(seedData), this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
509
640
  const seed = {
510
641
  ...seedData,
511
642
  signature,
@@ -527,11 +658,11 @@ export class SeedBootstrapService {
527
658
  *
528
659
  * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
529
660
  * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
530
- * anchored without re-supplying the invite.
661
+ * anchored without re-supplying the pin.
531
662
  *
532
663
  * @param seed - The seed to apply (already transport-decoded).
533
664
  * @param options.trustPolicy - Per-call policy override (e.g. a
534
- * `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
665
+ * `pinnedKeyTrustPolicy` built from an operator pin) used instead of the
535
666
  * service-configured default for this seed only.
536
667
  */
537
668
  async applySeed(seed, options) {
@@ -550,15 +681,20 @@ export class SeedBootstrapService {
550
681
  if (!this.libp2pNode) {
551
682
  return seedRejected('Service not initialized');
552
683
  }
553
- // NOTE: `seed.partyId` is never checked against `config.partyId`. Nothing
554
- // downstream reads it — trust is keyed on `signerKey` vs the anchor, and the
555
- // anchor a stray-party seed could write into belongs to THIS party, which
556
- // only a caller-supplied pin for that signer can reach. If applying a seed
557
- // ever branches on its partyId (or the anchor becomes multi-party), reject a
684
+ // NOTE: `seed.partyId` is never checked against `config.partyId`. Trust is keyed on
685
+ // `signerKey` vs the anchor, and the anchor a stray-party seed could write into
686
+ // belongs to THIS party, which only a caller-supplied pin for that signer can reach.
687
+ // The one reader of the seed's party is the claim policy, which records it as the
688
+ // party the node now serves (`CadreNodeConfig.claim.record`): a mismatch is still not
689
+ // rejected there because an unclaimed node's own party is a placeholder, and the
690
+ // claim seed is the first thing to name the real one. If any other path ever
691
+ // branches on the seed's partyId (or the anchor becomes multi-party), reject a
558
692
  // mismatch here instead.
559
693
  log('Applying seed for party: %s', seed.partyId);
560
- // Validate the seed signature
561
- if (!this.validateSeedSignature(seed)) {
694
+ // One digest serves the signature check and the trust context: a claim proof is
695
+ // bound to the seed through the same value the signature covers.
696
+ const digestB64 = seedDigest(seed);
697
+ if (!this.validateSeedSignature(seed, digestB64)) {
562
698
  return seedRejected('Invalid seed signature');
563
699
  }
564
700
  // Evaluate the trust anchor for the signer key. The known-owner set comes
@@ -573,30 +709,19 @@ export class SeedBootstrapService {
573
709
  partyId: seed.partyId,
574
710
  signerKey: seed.signerKey,
575
711
  knownOwnerKeys,
712
+ // Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
713
+ // omit `peerId`. A real node always has one, and a proof bound to a real peer id can
714
+ // only fail against '' — the safe direction.
715
+ localPeerId: this.libp2pNode.peerId?.toString() ?? '',
716
+ seedDigest: digestB64,
717
+ claimProof: options?.claimProof,
718
+ remotePeerId: options?.remotePeerId,
576
719
  });
577
720
  if (!decision.trusted) {
578
- return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
721
+ return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy', decision.code);
579
722
  }
580
723
  await this.anchorAcceptedSigner(seed.signerKey, decision);
581
- let peersAdded = 0;
582
- // Add peers to the peer store
583
- for (const peer of seed.peers) {
584
- try {
585
- // Import peer multiaddrs into the peer store
586
- if (peer.multiaddrs.length > 0) {
587
- const peerId = peerIdFromString(peer.peerId);
588
- const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
589
- await this.libp2pNode.peerStore.merge(peerId, {
590
- multiaddrs: addrs
591
- });
592
- peersAdded++;
593
- log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
594
- }
595
- }
596
- catch (error) {
597
- log('Failed to add peer %s: %o', peer.peerId, error);
598
- }
599
- }
724
+ const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
600
725
  log('Merged seed: %d peers added', peersAdded);
601
726
  return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
602
727
  }
@@ -625,7 +750,8 @@ export class SeedBootstrapService {
625
750
  // and an undefined self simply matches nothing.
626
751
  // Every one of an owner's addresses is a candidate, each on its own time limit
627
752
  // (`dialPeerAddrs`), so an owner whose first address never answers neither
628
- // stalls seed application nor goes undialed at its other addresses.
753
+ // 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.
629
755
  const selfPeerId = node.peerId?.toString();
630
756
  let ownerDialsAttempted = 0;
631
757
  let ownerDialsFailed = 0;
@@ -637,7 +763,7 @@ export class SeedBootstrapService {
637
763
  try {
638
764
  const addrs = parseDialAddrs(peer.multiaddrs);
639
765
  log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
640
- await dialPeerAddrs(node, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
766
+ await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
641
767
  }
642
768
  catch (error) {
643
769
  // Counted even when the owner reaches us only through our own relay: this node is still
@@ -657,7 +783,7 @@ export class SeedBootstrapService {
657
783
  }
658
784
  /**
659
785
  * Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
660
- * later seed from the same owner is anchored without re-supplying the invite
786
+ * later seed from the same owner is anchored without re-supplying the pin
661
787
  * or re-prompting. Only the policy decides this happens (`anchorAs` is unset
662
788
  * when the key was already anchored, so a plain re-apply writes nothing) and
663
789
  * `trust()` is idempotent, keeping the original provenance for a known key.
@@ -665,14 +791,19 @@ export class SeedBootstrapService {
665
791
  * Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
666
792
  * in-memory anchor synchronously, so this seed and the rest of the session are
667
793
  * unaffected — only durability across a restart is lost, and that is logged.
794
+ * That holds for pins and TOFU, which are re-supplied at the next start; a claim
795
+ * is the opposite case, so `claimSecretTrustPolicy` anchors its signer itself,
796
+ * awaits durability, and never sets `anchorAs` (the type excludes `'claim'`).
668
797
  *
669
798
  * NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
670
799
  * unauthorized to authorized, which the write-driven membership-gate refresh
671
- * (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. Every
672
- * anchor mutation today rides seed application, and both seed paths refresh the
673
- * gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`). If some
674
- * future path anchors an owner OUTSIDE seed application, it owes the same
675
- * `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
800
+ * (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. The anchor
801
+ * is mutated from two places: here, on seed application, where both seed paths
802
+ * refresh the gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`);
803
+ * and by the anchor sync (`CadreNode.syncOwnerAnchor`), which runs INSIDE the
804
+ * membership refresh, ahead of the membership read, so its changes are judged by
805
+ * the same pass. If some future path anchors an owner outside both, it owes the
806
+ * same `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
676
807
  */
677
808
  async anchorAcceptedSigner(signerKey, decision) {
678
809
  if (!decision.anchorAs || !this.config.trustedOwners) {
@@ -704,43 +835,102 @@ export class SeedBootstrapService {
704
835
  /**
705
836
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
706
837
  *
707
- * Sender hardening: the whole exchange — dial, write, ack read — is bounded by
708
- * {@link seedDeliverTimeoutMs}, and the ack is capped at {@link MAX_SEED_SIZE}.
709
- * The target is a NOT-YET-TRUSTED node the instigator chose to dial during
710
- * onboarding, so an unbounded read here is strictly more exposed than the
711
- * membership-gated receiver paths: without the bound a target that accepts the
712
- * stream and never replies parks this call forever, and one that streams
713
- * arbitrary bytes as a fake ack exhausts memory.
838
+ * `target` is a multiaddr string, dialed as given, or a peer id with its addresses
839
+ * ({@link SeedDeliveryTarget}). For the second, the connection is formed first and on
840
+ * its own limits ({@link connectForDelivery}): an open one is reused, otherwise each
841
+ * address is dialed in turn, so one that never answers cannot use up the time a later
842
+ * one needed. Its addresses are also merged into the peer store, so identify and later
843
+ * dials see them. A peer id with no addresses is dialed by id, which reuses an open
844
+ * connection or tries the peer store's addresses. `options.claimProof` rides beside
845
+ * the seed in the message when the target is a brand-new node being claimed
846
+ * (`claim-proof.ts`).
847
+ *
848
+ * Sender hardening: the request — opening the stream (with its dial, for the forms
849
+ * that dial by `dialProtocol`), write, ack read — is bounded by {@link seedDeliverTimeoutMs},
850
+ * and the ack is capped at {@link MAX_SEED_SIZE}. The target is a NOT-YET-TRUSTED node the
851
+ * instigator chose to dial during onboarding, so an unbounded read here is strictly more
852
+ * exposed than the membership-gated receiver paths: without the bound a target that accepts
853
+ * the stream and never replies parks this call forever, and one that streams arbitrary bytes
854
+ * as a fake ack exhausts memory. A peer id with addresses can therefore take up to
855
+ * `dialBudget.totalMs` plus {@link seedDeliverTimeoutMs} (114.5 s at the default declared
856
+ * link) when none of its addresses answers.
857
+ *
858
+ * @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it
859
+ * formed; nothing was sent. Anything else thrown after the connection formed means the
860
+ * node was reached and the exchange failed.
714
861
  */
715
- async deliverSeed(targetMultiaddr, seed) {
862
+ async deliverSeed(target, seed, options) {
716
863
  if (!this.libp2pNode) {
717
864
  throw new Error('Service not initialized');
718
865
  }
719
- // Capture the node so the closure below needs no non-null assertion.
720
- const node = this.libp2pNode;
721
- const addr = multiaddr(targetMultiaddr);
722
- log('Delivering seed to: %s', targetMultiaddr);
723
- return await withDeadline(this.seedDeliverTimeoutMs, `Seed delivery to ${targetMultiaddr}`, (signal) => this.sendSeed(node, addr, seed, signal));
866
+ const label = typeof target === 'string' ? target : target.peerId;
867
+ log('Delivering seed to: %s', label);
868
+ const openStream = await this.seedStreamOpener(this.libp2pNode, target);
869
+ return await withDeadline(this.seedDeliverTimeoutMs, `Seed delivery to ${label}`, (signal) => this.sendSeed(openStream, seed, options?.claimProof, signal));
870
+ }
871
+ /**
872
+ * The part of {@link deliverSeed} that runs before its deadline: how the delivery's stream
873
+ * will be opened, with any connection it needs already formed.
874
+ */
875
+ async seedStreamOpener(node, target) {
876
+ if (typeof target === 'string') {
877
+ const addr = multiaddr(target);
878
+ // NOTE: this one-address form goes through libp2p's dial queue, which joins it to any dial
879
+ // of the same peer in flight and then waits on that job's address, not this one. Nothing is
880
+ // skipped (there is one address), only delayed; if a seed delivered to a bare multiaddr is
881
+ // ever seen timing out while something else dials that peer, open it with `dialAddr`.
882
+ return (signal) => node.dialProtocol(addr, SEED_PROTOCOL, { signal });
883
+ }
884
+ const peerId = peerIdFromString(target.peerId);
885
+ const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
886
+ await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
887
+ if (addrs.length === 0) {
888
+ return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
889
+ }
890
+ const connection = await this.connectForDelivery(node, peerId, addrs);
891
+ return (signal) => connection.newStream(SEED_PROTOCOL, { signal });
892
+ }
893
+ /**
894
+ * A connection to `peerId` for {@link deliverSeed}, from {@link dialPeerAddrs}: one already
895
+ * open without relay limits, or one dialed from `addrs` — each address on
896
+ * `dialBudget.perAddressMs`, the whole peer within `dialBudget.totalMs`, direct addresses
897
+ * before relayed ones and the given order otherwise.
898
+ *
899
+ * @throws {PeerUnreachableError} when no connection formed, with the dial's error as `cause`.
900
+ */
901
+ async connectForDelivery(node, peerId, addrs) {
902
+ // NOTE: a relayed address can return a limited connection, on which `sendSeed`'s stream
903
+ // open then fails as a plain error rather than `PeerUnreachableError`, though nothing was
904
+ // sent. Claim codes carry no relay addresses today; if a delivery target ever may, count a
905
+ // limited connection as unreachable here.
906
+ try {
907
+ return await dialPeerAddrs(node, peerId.toString(), addrs, this.dialBudget, `Seed delivery dial of ${peerId}`);
908
+ }
909
+ catch (error) {
910
+ throw new PeerUnreachableError(peerId.toString(), error);
911
+ }
724
912
  }
725
913
  /**
726
914
  * Open one stream to the target, send the seed frame, half-close, and read the ack.
727
915
  *
728
- * `signal` is the deadline from {@link deliverSeed}: it goes to `dialProtocol` so
729
- * a timeout during connect aborts the dial, and into {@link exchangeFrame} so a
730
- * timeout after the stream is open resets it — releasing the otherwise unbounded
916
+ * `signal` is the deadline from {@link deliverSeed}: it goes to `openStream` so a
917
+ * timeout while dialing or opening the stream aborts it, and into {@link exchangeFrame}
918
+ * so a timeout after the stream is open resets it — releasing the otherwise unbounded
731
919
  * ack-read.
732
920
  *
733
921
  * Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
734
- * tiny frame over a relay, whereas a seed is up to 1MB and this delivery path
735
- * does not dial relay addresses today. Changing that is a separate decision.
922
+ * tiny frame over a relay, whereas a seed is up to 1MB, so a relayed address that
923
+ * forms only a limited connection fails here when the stream is opened. Changing
924
+ * that is a separate decision.
736
925
  */
737
- async sendSeed(node, addr, seed, signal) {
738
- const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
926
+ async sendSeed(openStream, seed, claimProof, signal) {
927
+ const rawStream = await openStream(signal);
739
928
  const message = {
740
929
  partyId: seed.partyId,
741
930
  peers: seed.peers,
742
931
  signature: seed.signature,
743
932
  signerKey: seed.signerKey,
933
+ ...(claimProof !== undefined ? { claimProof } : {}),
744
934
  };
745
935
  const ack = await exchangeFrame(rawStream, signal, message, (stream) => this.readSeedAck(stream), 'Seed delivery aborted by timeout');
746
936
  log('Seed delivery response: accepted=%s', ack.accepted);
@@ -777,15 +967,16 @@ export class SeedBootstrapService {
777
967
  }
778
968
  /**
779
969
  * Validate a seed's signature.
970
+ *
971
+ * @param digestB64 - The seed's {@link seedDigest}, when the caller already computed it
972
+ * (`verifyAndMergeSeed` shares one digest between this check and the trust context).
780
973
  */
781
- validateSeedSignature(seed) {
974
+ validateSeedSignature(seed, digestB64 = seedDigest(seed)) {
782
975
  try {
783
- // Reconstruct the signed bytes via the shared canonical payload builder so
784
- // verification is independent of key order. The payload is the fixed
785
- // `{ partyId, peers }` the producer emits.
786
- const seedJson = canonicalSeedPayload(seed);
787
- const seedDigest = digest([seedJson], 'sha256', 'base64url');
788
- return verify(seedDigest, seed.signature, seed.signerKey, 'ed25519', 'base64url', 'base64url', 'base64url');
976
+ // The digest is over the shared canonical payload, so verification is
977
+ // independent of key order; the payload is the fixed `{ partyId, peers }`
978
+ // the producer emits.
979
+ return verify(digestB64, seed.signature, seed.signerKey, 'ed25519', 'base64url', 'base64url', 'base64url');
789
980
  }
790
981
  catch (error) {
791
982
  log('Seed signature validation failed: %o', error);
@@ -793,49 +984,16 @@ export class SeedBootstrapService {
793
984
  }
794
985
  }
795
986
  /**
796
- * Query peers from the control database.
797
- *
798
- * Owner identity is sourced from the `OwnerKey` table, not from the
799
- * transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
800
- * multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
801
- * peer is an owner iff that derived key is in the `OwnerKey` set.
802
- * This makes any owner node markable — not just the local one — and ties
803
- * `isOwner` to the control table rather than to `peerId === self`.
804
- *
805
- * Read through {@link ControlDatabase.queryCadrePeers}, not a raw `CadrePeer`
806
- * select: that reader drops any row whose `StampId` is retired in
807
- * `CadreControl.Revocation`, so a removed member's addresses are never packed
808
- * into a seed and pushed into a joiner's peerstore (`applySeed` adds every
809
- * seed peer's addrs and dials the owner-flagged ones). A revoked peer is off
810
- * the addressable surface everywhere, and this is one of its exits.
811
- *
812
- * NOTE: this is the one owner lookup deliberately left on the REPLICATED
813
- * table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
814
- * — the receiver dials owner-flagged peers first — not a trust decision, and
815
- * the receiver re-derives real trust from its own anchor. So a polluted table
816
- * costs at most a wasted dial, while anchoring here would silently drop
817
- * legitimate co-owners this node never pinned. If `isOwner` ever gates
818
- * anything the receiver TRUSTS, move it to the anchor.
987
+ * The peers a seed carries: the control database's own projection
988
+ * ({@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`).
819
991
  */
820
992
  async queryPeers() {
821
993
  if (!this.controlDatabase) {
822
994
  return [];
823
995
  }
824
- const ownerKeys = await this.controlDatabase.getOwnerKeys();
825
- const rows = await this.controlDatabase.queryCadrePeers();
826
- return rows.map(({ peerId, multiaddr }) => {
827
- // Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
828
- // unparsable id yields null and is treated as a non-owner rather than
829
- // failing the whole seed creation.
830
- const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
831
- const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
832
- return {
833
- peerId,
834
- multiaddrs: multiaddr ? multiaddr.split(',') : [],
835
- isOwner,
836
- ...(isOwner ? { publicKey: pubKeyB64 } : {}),
837
- };
838
- });
996
+ return await this.controlDatabase.querySeedPeers();
839
997
  }
840
998
  /**
841
999
  * Register the seed protocol handler. The inbound closure just delegates to
@@ -881,11 +1039,11 @@ export class SeedBootstrapService {
881
1039
  this.activeStreams++;
882
1040
  let acked = false;
883
1041
  try {
884
- const seed = await this.readSeedFrame(stream);
1042
+ const { seed, claimProof } = await this.readSeedFrame(stream);
885
1043
  this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
886
- const merged = await this.verifyAndMergeSeed(seed);
1044
+ const merged = await this.verifyAndMergeSeed(seed, { claimProof, remotePeerId });
887
1045
  acked = true;
888
- await replyAndClose(stream, { accepted: merged.success, reason: merged.error }, 'Seed');
1046
+ await replyAndClose(stream, { accepted: merged.success, reason: merged.error, code: merged.code }, 'Seed');
889
1047
  if (merged.success) {
890
1048
  await this.dialSeedOwners(seed);
891
1049
  this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
@@ -907,7 +1065,11 @@ export class SeedBootstrapService {
907
1065
  this.activeStreams--;
908
1066
  }
909
1067
  }
910
- /** Read the inbound seed frame to EOF (bounded and size-capped) and decode it. */
1068
+ /**
1069
+ * Read the inbound seed frame to EOF (bounded and size-capped) and decode it. The
1070
+ * claim proof comes back beside the seed: it is not part of what the owner signed, so
1071
+ * `ControlNetworkSeed` does not carry it.
1072
+ */
911
1073
  async readSeedFrame(stream) {
912
1074
  const data = await readStreamToEnd(stream, {
913
1075
  maxBytes: MAX_SEED_SIZE,
@@ -916,10 +1078,13 @@ export class SeedBootstrapService {
916
1078
  });
917
1079
  const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data)));
918
1080
  return {
919
- partyId: message.partyId,
920
- peers: message.peers,
921
- signature: message.signature,
922
- signerKey: message.signerKey,
1081
+ seed: {
1082
+ partyId: message.partyId,
1083
+ peers: message.peers,
1084
+ signature: message.signature,
1085
+ signerKey: message.signerKey,
1086
+ },
1087
+ claimProof: message.claimProof,
923
1088
  };
924
1089
  }
925
1090
  /**
@@ -967,93 +1132,6 @@ export class SeedBootstrapService {
967
1132
  log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
968
1133
  return { seed, encodedSeed };
969
1134
  }
970
- /**
971
- * Create an invite for a phone to join the cadre.
972
- *
973
- * Use this when a server (public IP) wants to invite a phone (NAT'd).
974
- * The invite is shared out-of-band (QR code, link, etc.) and contains
975
- * the server's address so the phone can dial in.
976
- *
977
- * @param token - Optional invite token for validation
978
- * @param expiresIn - Optional expiration time in milliseconds
979
- * @returns Invite and encoded invite for sharing
980
- */
981
- async createInvite(token, expiresIn) {
982
- if (!this.libp2pNode) {
983
- throw new Error('Service not initialized');
984
- }
985
- log('Creating invite for phone');
986
- // Get this node's dialable addresses. When an inviteAddressResolver is
987
- // configured (typically by `@serfab/cadre-host`'s NatService), it takes
988
- // priority — it may substitute a DDNS hostname and externally-mapped
989
- // port for the raw LAN multiaddrs libp2p reports.
990
- let ownerAddrs;
991
- if (this.config.inviteAddressResolver) {
992
- try {
993
- ownerAddrs = await this.config.inviteAddressResolver();
994
- }
995
- catch (err) {
996
- log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
997
- ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
998
- }
999
- }
1000
- else {
1001
- ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
1002
- }
1003
- // Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
1004
- // the trusted owner set before applying any seed.
1005
- //
1006
- // Sourced ONLY from this node's own anchor, never from the replicated
1007
- // OwnerKey table: the invitee anchors whatever arrives here
1008
- // (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
1009
- // pollutable table would let a stranger's genesis-inserted key ride an
1010
- // otherwise-legitimate invite straight into the new node's anchor —
1011
- // poisoning the very store this whole trust chain rests on. No anchor wired
1012
- // (a directly-constructed service) means no pins to hand out: an invite
1013
- // without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
1014
- // table-sourced one silently hands it an unanchored key.
1015
- const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
1016
- const now = Date.now();
1017
- const invite = {
1018
- partyId: this.config.partyId,
1019
- ownerAddrs,
1020
- ownerKeys: ownerKeys.length ? ownerKeys : undefined,
1021
- token,
1022
- createdAt: now,
1023
- expiresAt: expiresIn ? now + expiresIn : undefined,
1024
- };
1025
- const encodedInvite = this.encodeInvite(invite);
1026
- log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
1027
- return { invite, encodedInvite };
1028
- }
1029
- /**
1030
- * Accept a phone connection using an invite.
1031
- *
1032
- * Use this when a phone dials in with an invite token. This method:
1033
- * 1. Validates the token if provided
1034
- * 2. Authorizes the phone peer
1035
- *
1036
- * After this, the phone can sync the control database normally.
1037
- *
1038
- * @param options - Phone peer info and invite token
1039
- * @param issuedInvite - The original invite for validation
1040
- */
1041
- async acceptPhone(options, issuedInvite) {
1042
- const { phonePeerId, token } = options;
1043
- log('Accepting phone: %s', phonePeerId);
1044
- // Validate token if invite provided
1045
- if (issuedInvite) {
1046
- if (issuedInvite.token && issuedInvite.token !== token) {
1047
- throw new Error('Invalid invite token');
1048
- }
1049
- if (issuedInvite.expiresAt && Date.now() > issuedInvite.expiresAt) {
1050
- throw new Error('Invite has expired');
1051
- }
1052
- }
1053
- // Authorize the phone peer (no multiaddrs - phone is NAT'd)
1054
- await this.authorizePeer({ peerId: phonePeerId });
1055
- log('Phone %s accepted and authorized', phonePeerId);
1056
- }
1057
1135
  /**
1058
1136
  * Add a phone to the cadre with relay support.
1059
1137
  *
@@ -1084,45 +1162,5 @@ export class SeedBootstrapService {
1084
1162
  log('Phone %s added with relay, seed created', phonePeerId);
1085
1163
  return { seed, encodedSeed };
1086
1164
  }
1087
- /**
1088
- * Encode an invite for out-of-band delivery.
1089
- */
1090
- encodeInvite(invite) {
1091
- const json = JSON.stringify(invite);
1092
- return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
1093
- }
1094
- /**
1095
- * Decode an invite from base64url encoding.
1096
- */
1097
- decodeInvite(encoded) {
1098
- const bytes = uint8ArrayFromString(encoded, 'base64url');
1099
- const json = new TextDecoder().decode(bytes);
1100
- return JSON.parse(json);
1101
- }
1102
- /**
1103
- * Dial an owner from an invite.
1104
- * Use this on a phone after receiving an invite to connect to the owner.
1105
- *
1106
- * @param invite - The invite received out-of-band
1107
- * @returns Connection to the owner
1108
- */
1109
- async dialInvite(invite) {
1110
- if (!this.libp2pNode) {
1111
- throw new Error('Service not initialized');
1112
- }
1113
- // Check expiration
1114
- if (invite.expiresAt && Date.now() > invite.expiresAt) {
1115
- throw new Error('Invite has expired');
1116
- }
1117
- log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
1118
- const addrs = parseDialAddrs(invite.ownerAddrs);
1119
- if (addrs.length === 0) {
1120
- throw new Error('No owner addresses available');
1121
- }
1122
- // Each address on its own time limit, so one that never answers cannot hold
1123
- // the invitee back from the rest (`dialPeerAddrs`).
1124
- const connection = await dialPeerAddrs(this.libp2pNode, addrs, this.dialBudget, 'Invite owner dial');
1125
- log('Connected to owner at: %s', connection.remoteAddr.toString());
1126
- }
1127
1165
  }
1128
1166
  //# sourceMappingURL=seed-bootstrap.js.map