@serfab/cadre-core 0.9.0 → 0.10.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 (162) hide show
  1. package/README.md +4 -2
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +918 -53
  9. package/dist/cadre-node.js +1843 -190
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-database.d.ts +990 -50
  15. package/dist/control-database.js +1459 -108
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -10
  18. package/dist/control-formation-recorder.js +129 -14
  19. package/dist/control-formation-recorder.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +774 -210
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +38 -4
  24. package/dist/control-stream.js +70 -4
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +151 -0
  27. package/dist/control-write-retry.js +411 -0
  28. package/dist/control-write-retry.js.map +1 -0
  29. package/dist/delegate-admission.d.ts +134 -0
  30. package/dist/delegate-admission.js +244 -0
  31. package/dist/delegate-admission.js.map +1 -0
  32. package/dist/device-token.d.ts +11 -11
  33. package/dist/device-token.js +14 -13
  34. package/dist/device-token.js.map +1 -1
  35. package/dist/ed25519-key.d.ts +26 -0
  36. package/dist/ed25519-key.js +56 -1
  37. package/dist/ed25519-key.js.map +1 -1
  38. package/dist/file-durable-slot.d.ts +20 -0
  39. package/dist/file-durable-slot.js +53 -0
  40. package/dist/file-durable-slot.js.map +1 -0
  41. package/dist/formation-approval.d.ts +157 -0
  42. package/dist/formation-approval.js +375 -0
  43. package/dist/formation-approval.js.map +1 -0
  44. package/dist/fs-atomic.d.ts +38 -0
  45. package/dist/fs-atomic.js +97 -0
  46. package/dist/fs-atomic.js.map +1 -0
  47. package/dist/identity-key.d.ts +49 -0
  48. package/dist/identity-key.js +81 -0
  49. package/dist/identity-key.js.map +1 -0
  50. package/dist/index.d.ts +16 -6
  51. package/dist/index.js +39 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/key-store-file.d.ts +2 -24
  54. package/dist/key-store-file.js +10 -77
  55. package/dist/key-store-file.js.map +1 -1
  56. package/dist/membership-connection-gater.d.ts +146 -0
  57. package/dist/membership-connection-gater.js +180 -0
  58. package/dist/membership-connection-gater.js.map +1 -0
  59. package/dist/node-local-snapshot.d.ts +109 -0
  60. package/dist/node-local-snapshot.js +182 -0
  61. package/dist/node-local-snapshot.js.map +1 -0
  62. package/dist/peer-authorization.d.ts +135 -18
  63. package/dist/peer-authorization.js +152 -21
  64. package/dist/peer-authorization.js.map +1 -1
  65. package/dist/peer-record.d.ts +21 -8
  66. package/dist/peer-record.js +26 -10
  67. package/dist/peer-record.js.map +1 -1
  68. package/dist/relay-addrs.d.ts +45 -0
  69. package/dist/relay-addrs.js +103 -0
  70. package/dist/relay-addrs.js.map +1 -0
  71. package/dist/relay-reservation.d.ts +266 -0
  72. package/dist/relay-reservation.js +613 -0
  73. package/dist/relay-reservation.js.map +1 -0
  74. package/dist/seed-bootstrap.d.ts +221 -56
  75. package/dist/seed-bootstrap.js +328 -188
  76. package/dist/seed-bootstrap.js.map +1 -1
  77. package/dist/seed-trust-policy.d.ts +47 -21
  78. package/dist/seed-trust-policy.js +24 -15
  79. package/dist/seed-trust-policy.js.map +1 -1
  80. package/dist/strand-addr-protocol.d.ts +42 -13
  81. package/dist/strand-addr-protocol.js +58 -52
  82. package/dist/strand-addr-protocol.js.map +1 -1
  83. package/dist/strand-backfill.d.ts +109 -0
  84. package/dist/strand-backfill.js +279 -0
  85. package/dist/strand-backfill.js.map +1 -0
  86. package/dist/strand-formation-manager.d.ts +38 -9
  87. package/dist/strand-formation-manager.js +120 -17
  88. package/dist/strand-formation-manager.js.map +1 -1
  89. package/dist/strand-formation-protocol.d.ts +103 -4
  90. package/dist/strand-formation-protocol.js +232 -4
  91. package/dist/strand-formation-protocol.js.map +1 -1
  92. package/dist/strand-instance-manager.d.ts +39 -1
  93. package/dist/strand-instance-manager.js +92 -14
  94. package/dist/strand-instance-manager.js.map +1 -1
  95. package/dist/strand-member-registry.d.ts +5 -5
  96. package/dist/strand-member-registry.js +18 -10
  97. package/dist/strand-member-registry.js.map +1 -1
  98. package/dist/strand-membership-writer.d.ts +482 -88
  99. package/dist/strand-membership-writer.js +757 -136
  100. package/dist/strand-membership-writer.js.map +1 -1
  101. package/dist/strand-solicitation.d.ts +99 -33
  102. package/dist/strand-solicitation.js +100 -52
  103. package/dist/strand-solicitation.js.map +1 -1
  104. package/dist/strand-transport-key.d.ts +40 -0
  105. package/dist/strand-transport-key.js +56 -0
  106. package/dist/strand-transport-key.js.map +1 -0
  107. package/dist/strand-wake-protocol.d.ts +9 -7
  108. package/dist/strand-wake-protocol.js +22 -46
  109. package/dist/strand-wake-protocol.js.map +1 -1
  110. package/dist/strand-watcher.d.ts +17 -2
  111. package/dist/strand-watcher.js +47 -3
  112. package/dist/strand-watcher.js.map +1 -1
  113. package/dist/trusted-owner-store-file.d.ts +12 -0
  114. package/dist/trusted-owner-store-file.js +39 -0
  115. package/dist/trusted-owner-store-file.js.map +1 -0
  116. package/dist/trusted-owner-store.d.ts +64 -0
  117. package/dist/trusted-owner-store.js +126 -0
  118. package/dist/trusted-owner-store.js.map +1 -0
  119. package/dist/types.d.ts +275 -20
  120. package/dist/types.js +13 -0
  121. package/dist/types.js.map +1 -1
  122. package/package.json +19 -8
  123. package/src/bootstrap-peer-store-file.ts +44 -0
  124. package/src/bootstrap-peer-store.ts +172 -0
  125. package/src/cadre-node.ts +1992 -202
  126. package/src/control-authorization.ts +114 -0
  127. package/src/control-database.ts +1704 -146
  128. package/src/control-formation-recorder.ts +172 -19
  129. package/src/control-schema.ts +786 -222
  130. package/src/control-stream.ts +81 -4
  131. package/src/control-write-retry.ts +473 -0
  132. package/src/delegate-admission.ts +302 -0
  133. package/src/device-token.ts +18 -13
  134. package/src/ed25519-key.ts +134 -69
  135. package/src/file-durable-slot.ts +61 -0
  136. package/src/formation-approval.ts +604 -0
  137. package/src/fs-atomic.ts +107 -0
  138. package/src/identity-key.ts +109 -0
  139. package/src/index.ts +136 -5
  140. package/src/key-store-file.ts +10 -81
  141. package/src/membership-connection-gater.ts +204 -0
  142. package/src/node-local-snapshot.ts +260 -0
  143. package/src/peer-authorization.ts +199 -21
  144. package/src/peer-record.ts +28 -10
  145. package/src/relay-addrs.ts +111 -0
  146. package/src/relay-reservation.ts +823 -0
  147. package/src/seed-bootstrap.ts +397 -197
  148. package/src/seed-trust-policy.ts +58 -25
  149. package/src/strand-addr-protocol.ts +84 -53
  150. package/src/strand-backfill.ts +366 -0
  151. package/src/strand-formation-manager.ts +145 -19
  152. package/src/strand-formation-protocol.ts +309 -8
  153. package/src/strand-instance-manager.ts +111 -14
  154. package/src/strand-member-registry.ts +18 -13
  155. package/src/strand-membership-writer.ts +1430 -640
  156. package/src/strand-solicitation.ts +155 -90
  157. package/src/strand-transport-key.ts +58 -0
  158. package/src/strand-wake-protocol.ts +29 -46
  159. package/src/strand-watcher.ts +66 -3
  160. package/src/trusted-owner-store-file.ts +41 -0
  161. package/src/trusted-owner-store.ts +171 -0
  162. package/src/types.ts +292 -21
@@ -3,11 +3,11 @@ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } fr
3
3
  import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
4
  import { multiaddr } from '@multiformats/multiaddr';
5
5
  import { peerIdFromString } from '@libp2p/peer-id';
6
- import { writeFrame, readStreamToEnd } from './control-stream.js';
6
+ import { writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
7
7
  import { generateStampId } from './control-database.js';
8
8
  import { canonicalJson } from './canonical-json.js';
9
- import { peerAuthorizationDigest, cadrePeerVoucherDigest, cadrePeerRemoveDigest } from './peer-authorization.js';
10
- import { dbAnchoredTrustPolicy, } from './seed-trust-policy.js';
9
+ import { deviceTokenAddDigest } from './peer-authorization.js';
10
+ import { anchoredTrustPolicy, } from './seed-trust-policy.js';
11
11
  const log = debug('sereus:cadre:seed-bootstrap');
12
12
  /** Protocol ID for seed delivery */
13
13
  export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
@@ -17,6 +17,8 @@ const MAX_SEED_SIZE = 1024 * 1024;
17
17
  const DEFAULT_SEED_READ_TIMEOUT_MS = 10000;
18
18
  /** Default cap on concurrent inbound seed streams a single peer can pin open. */
19
19
  const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
20
+ /** Default time the sender waits for a seed delivery (dial + ack read) before aborting (ms). */
21
+ const DEFAULT_SEED_DELIVER_TIMEOUT_MS = 10000;
20
22
  /**
21
23
  * Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
22
24
  *
@@ -76,6 +78,13 @@ export function ed25519PublicKeyB64FromPeerId(peerId) {
76
78
  export function canonicalSeedPayload(seed) {
77
79
  return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
78
80
  }
81
+ /**
82
+ * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
83
+ * so the dial counters read zero rather than being absent.
84
+ */
85
+ function seedRejected(error) {
86
+ return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
87
+ }
79
88
  /**
80
89
  * SeedBootstrapService handles control network seed generation and delivery.
81
90
  *
@@ -91,9 +100,10 @@ export class SeedBootstrapService {
91
100
  this.activeStreams = 0;
92
101
  this.eventCallbacks = {};
93
102
  this.config = config;
94
- this.trustPolicy = config.trustPolicy ?? dbAnchoredTrustPolicy();
103
+ this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
95
104
  this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
96
105
  this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
106
+ this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? DEFAULT_SEED_DELIVER_TIMEOUT_MS;
97
107
  // Derive public key from private key if not provided
98
108
  if (config.ownerPrivateKey && !config.ownerPublicKey) {
99
109
  this.ownerPublicKey = getPublicKey(config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url');
@@ -144,7 +154,7 @@ export class SeedBootstrapService {
144
154
  }
145
155
  /**
146
156
  * Authorize a new peer to join the cadre.
147
- * Signs the peer ID with the owner key and inserts into CadrePeer table.
157
+ * Signs a membership voucher with the owner key and inserts into CadrePeer table.
148
158
  *
149
159
  * The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
150
160
  * caller-supplied key, the binding is enforced by construction — `PublicKey` is
@@ -176,9 +186,14 @@ export class SeedBootstrapService {
176
186
  * is its own owner (it holds the owner key): the row is owner-signed
177
187
  * (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
178
188
  * immediately without a follow-up self-update.
189
+ *
190
+ * @returns `true` when this call seated the row, `false` when a concurrent writer
191
+ * (e.g. an {@link authorizePeer} of this node's own id) had already seated it —
192
+ * in which case the row in the database is NOT this record and carries whatever
193
+ * `Sig` that writer had, so the caller must self-update to publish its signature.
179
194
  */
180
195
  async insertSelfPeerRecord(record) {
181
- await this.insertCadrePeerRow({
196
+ return await this.insertCadrePeerRow({
182
197
  peerId: record.peerId,
183
198
  publicKey: record.publicKey,
184
199
  multiaddr: record.addrs.join(','),
@@ -187,91 +202,87 @@ export class SeedBootstrapService {
187
202
  });
188
203
  }
189
204
  /**
190
- * Shared owner-signed `CadrePeer` INSERT. Mints a fresh single-use `StampId`
191
- * and signs the voucher digest `digest(peerId, stampId)` ({@link cadrePeerVoucherDigest})
192
- * with the owner key (satisfying `AuthorizedInsert`), then writes the full record
193
- * row with the vouching (owner, signature) persisted into VouchOwner/VouchSig.
194
- * The owner signature does NOT cover the address columns — those are vouched only
195
- * as far as the owner asserts them, and a peer's own `Sig` (when present) is what
196
- * makes the row resolvable.
205
+ * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
206
+ * precondition; the stamp mint, voucher signature, write lock, existence check and
207
+ * membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
208
+ * the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
209
+ * method: the write is what admits the peer's traffic.
210
+ *
211
+ * The DB check precedes the owner-key check here, matching the order this method has
212
+ * always surfaced them (the owner-key error used to come out of the signer, after the
213
+ * DB check).
214
+ *
215
+ * @returns `true` when this call performed the INSERT, `false` when the row was
216
+ * already seated by a concurrent writer. The loser needs to know: an authorize seats
217
+ * a row with a null `Sig`, so a self-publish that lost the race must fall through to
218
+ * a self-update or its record never lands.
197
219
  */
198
220
  async insertCadrePeerRow(row) {
199
221
  if (!this.controlDatabase) {
200
222
  throw new Error('Control database not initialized');
201
223
  }
202
- // Fresh single-use nonce; the voucher signs digest(peerId, stampId) so a captured
203
- // insert can't be replayed (StampId is unique) and can't be repurposed as a delete
204
- // (which signs a distinct 'remove'-scoped digest).
205
- // NOTE: StampId uniqueness only blocks insert-replay while the row (or its StampId)
206
- // is still present — a delete frees the StampId, so a captured authorized insert could
207
- // be replayed to re-add a peer the owner had removed (a revocation bypass). Fully
208
- // subsumed by the connection gater (ticket 6): with no outsider write there is no
209
- // replay at all. If that gate ever proves insufficient, bind inserts to a persistent
210
- // per-peer sequence / tombstone instead of a row-scoped nonce.
211
- const stampId = generateStampId(row.peerId);
212
- const signature = this.signDigest(cadrePeerVoucherDigest(row.peerId, stampId));
213
- const db = this.controlDatabase.getDatabase();
214
- // Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig)
215
- // — identical to the context pair, which the AuthorizedInsert constraint binds — so a
216
- // reader can later re-check the voucher against its node-local trusted-owner anchor.
217
- await db.exec(`
218
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
219
- with context OwnerKey = ?, Signature = ?
220
- values (?, ?, ?, ?, ?, ?, ?, ?)
221
- `, [this.ownerPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, this.ownerPublicKey, signature]);
224
+ const ownerKey = this.requireOwnerPublicKey();
225
+ return await this.controlDatabase.insertCadrePeer(row, ownerKey, message => this.signMessageBytes(message));
222
226
  }
223
227
  /**
224
228
  * Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
225
229
  *
226
230
  * Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
227
- * row is owner-signed (satisfying `DeviceToken.AuthorizedInsert`, which vouches
228
- * membership exactly as `CadrePeer.AuthorizedInsert` does) AND carries the peer's
229
- * own self-`Sig` over the token payload. The owner signature covers only the
230
- * PeerId — it does NOT vouch the token contents; the peer's `Sig` (verified at
231
+ * row is owner-signed (satisfying `DeviceToken.AuthorizedInsert` via the
232
+ * 'add'-tagged digest, {@link deviceTokenAddDigest}) AND carries the peer's
233
+ * own self-`Sig` over the token payload. The owner signature covers the WHOLE row
234
+ * (every column, ending in a freshly minted single-use `StampId`) — but covering the
235
+ * token contents is not the same as vouching them: the peer's `Sig` (verified at
231
236
  * resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
232
237
  * resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
233
238
  * when the node is its own owner.
239
+ *
240
+ * The stamp is per-INSERT, so a re-register after a clear mints a fresh one and a
241
+ * fresh signature — unaffected by the cleared row's retired stamp
242
+ * (`DeviceToken.NotRevoked`).
234
243
  */
235
244
  async insertSelfDeviceToken(record) {
236
- const signature = this.signPeerAuthorization(record.peerId);
245
+ const stampId = generateStampId(record.peerId);
246
+ const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
237
247
  if (!this.controlDatabase) {
238
248
  throw new Error('Control database not initialized');
239
249
  }
240
- const db = this.controlDatabase.getDatabase();
241
- await db.exec(`
242
- insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig)
250
+ await this.controlDatabase.execWrite(`
251
+ insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
243
252
  with context OwnerKey = ?, Signature = ?
244
- values (?, ?, ?, ?, ?)
245
- `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig]);
253
+ values (?, ?, ?, ?, ?, ?)
254
+ `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId]);
246
255
  log('Device token inserted (owner-signed): %s', record.peerId);
247
256
  }
248
257
  /**
249
258
  * Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
250
- * invalidation). The `DeviceToken.AuthorizedInsert` constraint gates both insert
251
- * AND delete on an owner signature over `digest(old.PeerId)`, so — like
252
- * {@link removePeer} for `CadrePeer` — clearing a token requires the owner key.
259
+ * invalidation). The `DeviceToken.AuthorizedDelete` constraint validates an owner
260
+ * signature over the 'remove'-tagged digest bound to the STORED row's
261
+ * (PeerId, StampId) — deliberately distinct from the insert digest, so a captured
262
+ * insert approval can never be replayed to clear a token. Like {@link removePeer}
263
+ * for `CadrePeer`, clearing a token requires the owner key.
264
+ *
265
+ * The delete and the `Revocation` tombstone retiring the row's `StampId` commit in
266
+ * ONE transaction — `DeviceToken.RevocationRecorded` refuses a bare delete, and
267
+ * without the tombstone the stamp would free up and the never-expiring insert
268
+ * approval (which the cleared device holds a copy of) would re-seat the token. The
269
+ * tombstone carries its OWN owner signature: retiring a stamp permanently forecloses
270
+ * that row, so it is an owner action in its own right.
271
+ *
272
+ * Both digests, the stamp read, and the transaction come from
273
+ * {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
274
+ * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
275
+ * owner-key precondition; a no-op on an already-absent row is the shared body's
276
+ * behavior, and unlike {@link removePeer} nothing here rides on it.
253
277
  */
254
278
  async deleteDeviceToken(peerId) {
255
- const signature = this.signPeerAuthorization(peerId);
279
+ // Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
280
+ // owner-key error rather than a silent no-op on an absent row.
281
+ const ownerKey = this.requireOwnerPublicKey();
256
282
  if (!this.controlDatabase) {
257
283
  throw new Error('Control database not initialized');
258
284
  }
259
- const db = this.controlDatabase.getDatabase();
260
- await db.exec(`
261
- delete from CadreControl.DeviceToken
262
- with context OwnerKey = ?, Signature = ?
263
- where PeerId = ?
264
- `, [this.ownerPublicKey, signature, peerId]);
265
- log('Device token removed (owner-signed): %s', peerId);
266
- }
267
- /**
268
- * Sign a peer ID with the owner key for a `CadrePeer` / `DeviceToken`
269
- * insert-or-delete. The signed bytes come from the shared
270
- * {@link peerAuthorizationDigest} helper so the offline `cadre enroll register`
271
- * verifier checks the exact same construction. Throws if no owner key is set.
272
- */
273
- signPeerAuthorization(peerId) {
274
- return this.signDigest(peerAuthorizationDigest(peerId));
285
+ await this.controlDatabase.deleteDeviceToken(peerId, ownerKey, message => this.signMessageBytes(message));
275
286
  }
276
287
  /**
277
288
  * Return the configured owner private key, or throw if none is set. The single
@@ -287,65 +298,101 @@ export class SeedBootstrapService {
287
298
  }
288
299
  return this.config.ownerPrivateKey;
289
300
  }
301
+ /**
302
+ * The owner PUBLIC key that rides in every owner-signed write's context, non-null.
303
+ *
304
+ * The field is nullable because a read-only service carries no owner key at all, but the
305
+ * constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
306
+ * {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
307
+ * unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
308
+ * their first line and get the owner-key precondition for free.
309
+ */
310
+ requireOwnerPublicKey() {
311
+ this.requireOwnerPrivateKey();
312
+ if (!this.ownerPublicKey) {
313
+ throw new Error('Owner public key required to authorize peers');
314
+ }
315
+ return this.ownerPublicKey;
316
+ }
290
317
  /**
291
318
  * Sign a base64url digest with the owner key (ed25519). The single place the
292
- * owner private key is applied; callers pass the canonical digest for the specific
293
- * action ({@link cadrePeerVoucherDigest} / {@link cadrePeerRemoveDigest} /
294
- * {@link peerAuthorizationDigest}). Throws if no owner key is set.
319
+ * owner private key is applied; callers pass the canonical domain-tagged digest for
320
+ * the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
321
+ * {@link signMessageBytes}. Throws if no owner key is set.
295
322
  */
296
323
  signDigest(digestB64url) {
297
324
  return sign(digestB64url, this.requireOwnerPrivateKey(), 'ed25519', 'base64url', 'base64url', 'base64url');
298
325
  }
326
+ /**
327
+ * Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
328
+ * takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
329
+ * same signed bytes (`sign` decodes its base64url input), so a signature minted here
330
+ * satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
331
+ * `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
332
+ */
333
+ signMessageBytes(message) {
334
+ return this.signDigest(uint8ArrayToString(message, 'base64url'));
335
+ }
299
336
  /**
300
337
  * Remove a peer from the cadre by owner signature.
301
338
  *
302
339
  * The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
303
- * signature over the DISTINCT 'remove'-scoped digest `digest(old.PeerId, old.StampId,
304
- * 'remove')` ({@link cadrePeerRemoveDigest}) by an owner key — deliberately NOT the
305
- * insert voucher digest, so the row's stored `VouchSig` can never be replayed to delete.
340
+ * signature over the DISTINCT 'remove'-tagged digest
341
+ * `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
342
+ * key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
343
+ * never be replayed to delete.
344
+ *
345
+ * The delete and the `Revocation` tombstone retiring the row's `StampId` commit in ONE
346
+ * transaction — `CadrePeer.RevocationRecorded` refuses a bare delete, and without the
347
+ * tombstone the stamp would free up and the original admission approval (which never
348
+ * expires, and which the removed peer holds a copy of) would re-seat the row.
349
+ *
350
+ * The tombstone is separately owner-signed (satisfying `Revocation.Authorized`):
351
+ * retiring a stamp evicts that peer party-wide and permanently forecloses re-admitting
352
+ * the row, so it is an owner action in its own right, not a side effect the delete's
353
+ * signature covers.
354
+ *
355
+ * Both digests, the stamp read, the transaction, and the post-commit membership notify
356
+ * come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
357
+ * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
358
+ * owner-key precondition and the absent-row gate the notify depends on (below).
306
359
  */
307
360
  async removePeer(peerId) {
308
361
  // Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
309
362
  // remove digest, and this precedence (owner key, then control DB) is what the
310
363
  // unit contract asserts.
311
- this.requireOwnerPrivateKey();
364
+ const ownerKey = this.requireOwnerPublicKey();
312
365
  if (!this.controlDatabase) {
313
366
  throw new Error('Control database not initialized');
314
367
  }
315
- // Delete is authorized by a signature over a DISTINCT 'remove'-scoped digest bound to
316
- // the row's CURRENT StampId — so the row's stored voucher (a signature over the
317
- // voucher digest) can never be replayed to authorize a delete.
368
+ // This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
369
+ // internally: deleteCadrePeer's membership notify fires whenever its body resolves,
370
+ // with no idea whether the body wrote anything, so delegating an absent peer would
371
+ // fire a spurious membership notification.
372
+ // NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
373
+ // between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
374
+ // window only — the common "already absent" case is caught here.
318
375
  const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
319
376
  if (stampId === null) {
320
377
  log('removePeer: no CadrePeer row for %s (already absent)', peerId);
321
378
  return;
322
379
  }
323
- const signature = this.signDigest(cadrePeerRemoveDigest(peerId, stampId));
324
380
  log('Removing peer: %s', peerId);
325
- const db = this.controlDatabase.getDatabase();
326
- await db.exec(`
327
- delete from CadreControl.CadrePeer
328
- with context OwnerKey = ?, Signature = ?
329
- where PeerId = ?
330
- `, [this.ownerPublicKey, signature, peerId]);
331
- log('Peer %s removed successfully', peerId);
332
- }
333
- /**
334
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump
335
- * `UpdatedAt` under the owner branch of `CadrePeer.AuthorizedUpdate` (a
336
- * signature over `digest(peerId)` — the same construction {@link authorizePeer}
337
- * uses) so the row is re-emitted as a fresh, broadcasting transaction.
338
- *
339
- * This is the write-while-alone re-replication primitive
340
- * (`control-write-ensure-replicated`): a membership row that committed
341
- * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
342
- * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original
343
- * INSERT) because the row already exists locally; a re-INSERT would hit the
344
- * `PeerId` PK. Only the freshness stamp changes — `PublicKey` / `Multiaddr` /
345
- * `Sig` are left intact — so it is safe over a row whose peer has not
346
- * self-published (`Sig` null); the caller must skip a row that already carries a
347
- * self-`Sig` (that row is the owning peer's to refresh, and bumping `UpdatedAt`
348
- * without re-signing would invalidate its self-signature).
381
+ await this.controlDatabase.deleteCadrePeer(peerId, ownerKey, message => this.signMessageBytes(message));
382
+ log('Peer %s removed successfully (stamp retired)', peerId);
383
+ }
384
+ /**
385
+ * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
386
+ * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
387
+ * the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
388
+ * a membership row that committed local-only (its block's cluster ≤1 at insert) is
389
+ * pushed to the cohort once it grows.
390
+ *
391
+ * The stamp read, the voucher signature, the write lock and the membership notify are
392
+ * {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
393
+ * `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
394
+ *
395
+ * A no-op (no throw, no notify) when the row does not exist.
349
396
  *
350
397
  * @param peerId - the membership row to re-touch.
351
398
  * @param updatedAt - the strictly-increasing freshness stamp to write.
@@ -355,34 +402,42 @@ export class SeedBootstrapService {
355
402
  async reauthorizePeer(peerId, updatedAt) {
356
403
  // Fail fast on a keyless service before any DB read (see removePeer): a non-owner
357
404
  // cannot re-sign the voucher, and must not silently no-op on an absent row.
358
- this.requireOwnerPrivateKey();
405
+ const ownerKey = this.requireOwnerPublicKey();
359
406
  if (!this.controlDatabase) {
360
407
  throw new Error('Control database not initialized');
361
408
  }
362
- // The owner branch of AuthorizedUpdate verifies a voucher over digest(PeerId,
363
- // StampId) and re-binds VouchOwner/VouchSig. Sign over the row's CURRENT StampId
364
- // (unchanged by this re-touch) and re-set the voucher columns so the branch passes.
365
- // NOTE: this rebinds VouchOwner to THIS node's owner key. Benign today — the
366
- // write-while-alone drain only re-touches rows this node itself authored. If a future
367
- // path lets one owner re-touch a row a DIFFERENT owner vouched, the voucher
368
- // silently flips to this owner; that only matters once ticket-4's predicate checks
369
- // VouchOwner against a node-local anchor, and only if the two owners differ in
370
- // anchor membership.
371
- const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
372
- if (stampId === null) {
409
+ const retouched = await this.controlDatabase.reauthorizeCadrePeer(peerId, updatedAt, ownerKey, message => this.signMessageBytes(message));
410
+ if (!retouched) {
373
411
  log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
374
412
  return;
375
413
  }
376
- const signature = this.signDigest(cadrePeerVoucherDigest(peerId, stampId));
377
- const db = this.controlDatabase.getDatabase();
378
- await db.exec(`
379
- update CadreControl.CadrePeer
380
- with context OwnerKey = ?, Signature = ?
381
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
382
- where PeerId = ?
383
- `, [this.ownerPublicKey, signature, updatedAt, this.ownerPublicKey, signature, peerId]);
384
414
  log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
385
415
  }
416
+ /**
417
+ * Owner re-issue of a batch of `Revocation` tombstones: bump each row's
418
+ * `ReissuedAt` so the storage layer re-broadcasts a tombstone that committed
419
+ * while the node was alone. The delete-while-alone counterpart of
420
+ * {@link reauthorizePeer} — a removed row cannot be re-touched (it is gone),
421
+ * but its tombstone can, and every membership read treats a retired stamp as
422
+ * absent.
423
+ *
424
+ * The signatures, the single transaction, and the strictly-monotonic
425
+ * `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
426
+ * stays here is the owner-key precondition.
427
+ *
428
+ * @returns how many tombstones were re-issued (`rows.length` on success).
429
+ * @throws if no owner private key is configured or the control database is not
430
+ * initialized.
431
+ */
432
+ async reissueRevocations(rows, reissuedAt) {
433
+ // Fail fast on a keyless service before any DB work (see removePeer): a
434
+ // non-owner cannot sign the reissue digests.
435
+ const ownerKey = this.requireOwnerPublicKey();
436
+ if (!this.controlDatabase) {
437
+ throw new Error('Control database not initialized');
438
+ }
439
+ return this.controlDatabase.reissueRevocations(rows, reissuedAt, ownerKey, message => this.signMessageBytes(message));
440
+ }
386
441
  /**
387
442
  * Create a seed from the current control network state.
388
443
  * The seed contains peer information and is signed by an owner.
@@ -419,10 +474,15 @@ export class SeedBootstrapService {
419
474
  *
420
475
  * Validates the seed signature, then evaluates a trust anchor for the
421
476
  * `signerKey` that does NOT come from the seed body: the receiver's
422
- * `OwnerKey` table (DB-anchored), optionally augmented by pinned keys or
423
- * TOFU via the configured/overriding `SeedTrustPolicy`. A forged
424
- * self-asserting seed — one that merely lists its own signer as an owner
425
- * peer — no longer passes.
477
+ * node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
478
+ * augmented by pinned keys or TOFU via the configured/overriding
479
+ * `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
480
+ * own signer as an owner peer — no longer passes, and neither does one signed
481
+ * by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
482
+ *
483
+ * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
484
+ * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
485
+ * anchored without re-supplying the invite.
426
486
  *
427
487
  * @param seed - The seed to apply (already transport-decoded).
428
488
  * @param options.trustPolicy - Per-call policy override (e.g. a
@@ -431,19 +491,26 @@ export class SeedBootstrapService {
431
491
  */
432
492
  async applySeed(seed, options) {
433
493
  if (!this.libp2pNode) {
434
- return { success: false, peersAdded: 0, error: 'Service not initialized' };
494
+ return seedRejected('Service not initialized');
435
495
  }
496
+ // NOTE: `seed.partyId` is never checked against `config.partyId`. Nothing
497
+ // downstream reads it — trust is keyed on `signerKey` vs the anchor, and the
498
+ // anchor a stray-party seed could write into belongs to THIS party, which
499
+ // only a caller-supplied pin for that signer can reach. If applying a seed
500
+ // ever branches on its partyId (or the anchor becomes multi-party), reject a
501
+ // mismatch here instead.
436
502
  log('Applying seed for party: %s', seed.partyId);
437
503
  // Validate the seed signature
438
504
  if (!this.validateSeedSignature(seed)) {
439
- return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
440
- }
441
- // Evaluate the trust anchor for the signer key. The known-owner set is
442
- // sourced from the receiver's control DB, never from the seed itself; a
443
- // cold-start node with no DB and no override therefore sees an empty set.
444
- const knownOwnerKeys = this.controlDatabase
445
- ? await this.controlDatabase.getOwnerKeys()
446
- : new Set();
505
+ return seedRejected('Invalid seed signature');
506
+ }
507
+ // Evaluate the trust anchor for the signer key. The known-owner set comes
508
+ // from the receiver's NODE-LOCAL anchor — never from the seed itself, and
509
+ // never from the replicated OwnerKey table (a stranger can genesis-insert
510
+ // its own key there and let it replicate into every peer's copy). A node
511
+ // whose anchor was never seeded, with no policy override, sees an empty set
512
+ // and rejects.
513
+ const knownOwnerKeys = this.config.trustedOwners?.all() ?? new Set();
447
514
  const policy = options?.trustPolicy ?? this.trustPolicy;
448
515
  const decision = await policy.evaluate({
449
516
  partyId: seed.partyId,
@@ -451,12 +518,9 @@ export class SeedBootstrapService {
451
518
  knownOwnerKeys,
452
519
  });
453
520
  if (!decision.trusted) {
454
- return {
455
- success: false,
456
- peersAdded: 0,
457
- error: decision.reason ?? 'Signer key not trusted by trust policy',
458
- };
521
+ return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
459
522
  }
523
+ await this.anchorAcceptedSigner(seed.signerKey, decision);
460
524
  let peersAdded = 0;
461
525
  // Add peers to the peer store
462
526
  for (const peer of seed.peers) {
@@ -476,22 +540,69 @@ export class SeedBootstrapService {
476
540
  log('Failed to add peer %s: %o', peer.peerId, error);
477
541
  }
478
542
  }
479
- // Dial owner peers to establish connections
543
+ // Dial owner peers to establish connections. Best-effort and COUNTED: an
544
+ // owner that is momentarily down leaves this node seeded but unconnected,
545
+ // which the caller can only see if the outcome is reported (see
546
+ // `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
547
+ // `CadreNode.dialColdStartBootstrap` retries these same addresses on every
548
+ // control-cohort reconcile pass until the control database has siblings.
549
+ // `createSeed` projects EVERY CadrePeer row, so an owner applying a seed
550
+ // minted after it joined finds ITSELF in the owner list. Dialing self always
551
+ // throws, which would report a healthy owner as "seeded but stranded".
552
+ // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
553
+ // and an undefined self simply matches nothing.
554
+ const selfPeerId = this.libp2pNode.peerId?.toString();
555
+ let ownerDialsAttempted = 0;
556
+ let ownerDialsFailed = 0;
480
557
  for (const peer of seed.peers.filter(p => p.isOwner)) {
558
+ if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
559
+ continue;
560
+ }
561
+ ownerDialsAttempted++;
481
562
  try {
482
- if (peer.multiaddrs.length > 0) {
483
- const addr = multiaddr(peer.multiaddrs[0]);
484
- log('Dialing owner peer: %s', peer.peerId);
485
- await this.libp2pNode.dial(addr);
486
- }
563
+ const addr = multiaddr(peer.multiaddrs[0]);
564
+ log('Dialing owner peer: %s', peer.peerId);
565
+ await this.libp2pNode.dial(addr);
487
566
  }
488
567
  catch (error) {
568
+ ownerDialsFailed++;
489
569
  log('Failed to dial peer %s: %o', peer.peerId, error);
490
570
  // Continue - not all peers need to be reachable
491
571
  }
492
572
  }
493
- log('Applied seed: %d peers added', peersAdded);
494
- return { success: true, peersAdded };
573
+ log('Applied seed: %d peers added, %d/%d owner dial(s) failed', peersAdded, ownerDialsFailed, ownerDialsAttempted);
574
+ return { success: true, peersAdded, ownerDialsAttempted, ownerDialsFailed };
575
+ }
576
+ /**
577
+ * Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
578
+ * later seed from the same owner is anchored without re-supplying the invite
579
+ * or re-prompting. Only the policy decides this happens (`anchorAs` is unset
580
+ * when the key was already anchored, so a plain re-apply writes nothing) and
581
+ * `trust()` is idempotent, keeping the original provenance for a known key.
582
+ *
583
+ * Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
584
+ * in-memory anchor synchronously, so this seed and the rest of the session are
585
+ * unaffected — only durability across a restart is lost, and that is logged.
586
+ *
587
+ * NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
588
+ * unauthorized to authorized, which the write-driven membership-gate refresh
589
+ * (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. Every
590
+ * anchor mutation today rides seed application, and both seed paths refresh the
591
+ * gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`). If some
592
+ * future path anchors an owner OUTSIDE seed application, it owes the same
593
+ * `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
594
+ */
595
+ async anchorAcceptedSigner(signerKey, decision) {
596
+ if (!decision.anchorAs || !this.config.trustedOwners) {
597
+ return;
598
+ }
599
+ try {
600
+ await this.config.trustedOwners.trust(signerKey, decision.anchorAs);
601
+ log('Anchored seed signer %s as %s', signerKey, decision.anchorAs);
602
+ }
603
+ catch (error) {
604
+ log('Failed to persist accepted seed signer into the trusted-owner anchor: %o', error);
605
+ }
495
606
  }
496
607
  /**
497
608
  * Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
@@ -510,51 +621,64 @@ export class SeedBootstrapService {
510
621
  }
511
622
  /**
512
623
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
624
+ *
625
+ * Sender hardening: the whole exchange — dial, write, ack read — is bounded by
626
+ * {@link seedDeliverTimeoutMs}, and the ack is capped at {@link MAX_SEED_SIZE}.
627
+ * The target is a NOT-YET-TRUSTED node the instigator chose to dial during
628
+ * onboarding, so an unbounded read here is strictly more exposed than the
629
+ * membership-gated receiver paths: without the bound a target that accepts the
630
+ * stream and never replies parks this call forever, and one that streams
631
+ * arbitrary bytes as a fake ack exhausts memory.
513
632
  */
514
633
  async deliverSeed(targetMultiaddr, seed) {
515
634
  if (!this.libp2pNode) {
516
635
  throw new Error('Service not initialized');
517
636
  }
637
+ // Capture the node so the closure below needs no non-null assertion.
638
+ const node = this.libp2pNode;
518
639
  const addr = multiaddr(targetMultiaddr);
519
640
  log('Delivering seed to: %s', targetMultiaddr);
520
- // Dial the target and open a stream
521
- const rawStream = await this.libp2pNode.dialProtocol(addr, SEED_PROTOCOL);
522
- const stream = rawStream;
523
- try {
524
- // Send the seed message as one length-prefixed frame (shared writer).
525
- const message = {
526
- partyId: seed.partyId,
527
- peers: seed.peers,
528
- signature: seed.signature,
529
- signerKey: seed.signerKey,
530
- };
531
- writeFrame(stream, message);
532
- // In libp2p v3.x, close() closes the write end only (signals EOF),
533
- // while the read end remains open for receiving the ack.
534
- await stream.close();
535
- // Read the acknowledgment (stream is AsyncIterable in libp2p 3.x)
536
- const chunks = [];
537
- for await (const chunk of stream) {
538
- const bytes = chunk instanceof Uint8Array ? chunk : chunk.subarray();
539
- chunks.push(bytes);
540
- }
541
- const responseData = new Uint8Array(chunks.reduce((sum, c) => sum + c.length, 0));
542
- let offset = 0;
543
- for (const chunk of chunks) {
544
- responseData.set(chunk, offset);
545
- offset += chunk.length;
546
- }
547
- // Parse length-prefixed response
548
- const responseBody = decodeLengthPrefixedFrame(responseData);
549
- const responseJson = new TextDecoder().decode(responseBody);
550
- const ack = JSON.parse(responseJson);
551
- log('Seed delivery response: accepted=%s', ack.accepted);
552
- return ack;
553
- }
554
- catch (err) {
555
- stream.abort(err instanceof Error ? err : new Error(String(err)));
556
- throw err;
557
- }
641
+ return await withDeadline(this.seedDeliverTimeoutMs, `Seed delivery to ${targetMultiaddr}`, (signal) => this.sendSeed(node, addr, seed, signal));
642
+ }
643
+ /**
644
+ * Open one stream to the target, send the seed frame, half-close, and read the ack.
645
+ *
646
+ * `signal` is the deadline from {@link deliverSeed}: it goes to `dialProtocol` so
647
+ * a timeout during connect aborts the dial, and into {@link exchangeFrame} so a
648
+ * timeout after the stream is open resets it — releasing the otherwise unbounded
649
+ * ack-read.
650
+ *
651
+ * Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
652
+ * tiny frame over a relay, whereas a seed is up to 1MB and this delivery path
653
+ * does not dial relay addresses today. Changing that is a separate decision.
654
+ */
655
+ async sendSeed(node, addr, seed, signal) {
656
+ const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
657
+ const message = {
658
+ partyId: seed.partyId,
659
+ peers: seed.peers,
660
+ signature: seed.signature,
661
+ signerKey: seed.signerKey,
662
+ };
663
+ const ack = await exchangeFrame(rawStream, signal, message, (stream) => this.readSeedAck(stream), 'Seed delivery aborted by timeout');
664
+ log('Seed delivery response: accepted=%s', ack.accepted);
665
+ return ack;
666
+ }
667
+ /**
668
+ * Read the ack frame a delivery target writes back, bounded by
669
+ * {@link seedDeliverTimeoutMs} and capped at {@link MAX_SEED_SIZE} — an
670
+ * untrusted target must not be able to stream unlimited bytes as a fake ack.
671
+ * Decoding runs inside {@link exchangeFrame}'s `try`, so a malformed or
672
+ * non-JSON ack resets the stream rather than leaking it.
673
+ */
674
+ async readSeedAck(stream) {
675
+ const data = await readStreamToEnd(stream, {
676
+ maxBytes: MAX_SEED_SIZE,
677
+ timeoutMs: this.seedDeliverTimeoutMs,
678
+ label: 'Seed ack',
679
+ });
680
+ const body = decodeLengthPrefixedFrame(data, MAX_SEED_SIZE);
681
+ return JSON.parse(new TextDecoder().decode(body));
558
682
  }
559
683
  /**
560
684
  * Get this node's circuit relay address for inclusion in seeds.
@@ -595,6 +719,14 @@ export class SeedBootstrapService {
595
719
  * peer is an owner iff that derived key is in the `OwnerKey` set.
596
720
  * This makes any owner node markable — not just the local one — and ties
597
721
  * `isOwner` to the control table rather than to `peerId === self`.
722
+ *
723
+ * NOTE: this is the one owner lookup deliberately left on the REPLICATED
724
+ * table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
725
+ * — the receiver dials owner-flagged peers first — not a trust decision, and
726
+ * the receiver re-derives real trust from its own anchor. So a polluted table
727
+ * costs at most a wasted dial, while anchoring here would silently drop
728
+ * legitimate co-owners this node never pinned. If `isOwner` ever gates
729
+ * anything the receiver TRUSTS, move it to the anchor.
598
730
  */
599
731
  async queryPeers() {
600
732
  if (!this.controlDatabase) {
@@ -686,7 +818,7 @@ export class SeedBootstrapService {
686
818
  const result = await this.applySeed(seed);
687
819
  // Emit appropriate event based on result
688
820
  if (result.success) {
689
- this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
821
+ this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded, seed);
690
822
  }
691
823
  else {
692
824
  this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
@@ -789,11 +921,19 @@ export class SeedBootstrapService {
789
921
  else {
790
922
  ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
791
923
  }
792
- // Carry the cadre's owner keys out-of-band so a cold-start invitee can
793
- // pin the trusted owner set before applying any seed.
794
- const ownerKeys = this.controlDatabase
795
- ? Array.from(await this.controlDatabase.getOwnerKeys())
796
- : [];
924
+ // Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
925
+ // the trusted owner set before applying any seed.
926
+ //
927
+ // Sourced ONLY from this node's own anchor, never from the replicated
928
+ // OwnerKey table: the invitee anchors whatever arrives here
929
+ // (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
930
+ // pollutable table would let a stranger's genesis-inserted key ride an
931
+ // otherwise-legitimate invite straight into the new node's anchor —
932
+ // poisoning the very store this whole trust chain rests on. No anchor wired
933
+ // (a directly-constructed service) means no pins to hand out: an invite
934
+ // without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
935
+ // table-sourced one silently hands it an unanchored key.
936
+ const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
797
937
  const now = Date.now();
798
938
  const invite = {
799
939
  partyId: this.config.partyId,