@serfab/cadre-core 0.8.1 → 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 (197) hide show
  1. package/README.md +6 -4
  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 +1006 -101
  9. package/dist/cadre-node.js +1977 -283
  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-cohort.d.ts +15 -15
  15. package/dist/control-cohort.js +14 -14
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +1033 -76
  18. package/dist/control-database.js +1520 -153
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +91 -12
  21. package/dist/control-formation-recorder.js +131 -16
  22. package/dist/control-formation-recorder.js.map +1 -1
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +774 -186
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-stream.d.ts +38 -4
  27. package/dist/control-stream.js +70 -4
  28. package/dist/control-stream.js.map +1 -1
  29. package/dist/control-write-retry.d.ts +151 -0
  30. package/dist/control-write-retry.js +411 -0
  31. package/dist/control-write-retry.js.map +1 -0
  32. package/dist/delegate-admission.d.ts +134 -0
  33. package/dist/delegate-admission.js +244 -0
  34. package/dist/delegate-admission.js.map +1 -0
  35. package/dist/device-token.d.ts +12 -12
  36. package/dist/device-token.js +15 -14
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +68 -0
  39. package/dist/ed25519-key.js +106 -0
  40. package/dist/ed25519-key.js.map +1 -0
  41. package/dist/file-durable-slot.d.ts +20 -0
  42. package/dist/file-durable-slot.js +53 -0
  43. package/dist/file-durable-slot.js.map +1 -0
  44. package/dist/formation-approval.d.ts +157 -0
  45. package/dist/formation-approval.js +375 -0
  46. package/dist/formation-approval.js.map +1 -0
  47. package/dist/fs-atomic.d.ts +38 -0
  48. package/dist/fs-atomic.js +97 -0
  49. package/dist/fs-atomic.js.map +1 -0
  50. package/dist/identity-key.d.ts +49 -0
  51. package/dist/identity-key.js +81 -0
  52. package/dist/identity-key.js.map +1 -0
  53. package/dist/index.d.ts +16 -6
  54. package/dist/index.js +44 -10
  55. package/dist/index.js.map +1 -1
  56. package/dist/key-store-file.d.ts +2 -24
  57. package/dist/key-store-file.js +10 -77
  58. package/dist/key-store-file.js.map +1 -1
  59. package/dist/key-store.d.ts +1 -1
  60. package/dist/key-store.js +1 -1
  61. package/dist/membership-connection-gater.d.ts +146 -0
  62. package/dist/membership-connection-gater.js +180 -0
  63. package/dist/membership-connection-gater.js.map +1 -0
  64. package/dist/node-local-snapshot.d.ts +109 -0
  65. package/dist/node-local-snapshot.js +182 -0
  66. package/dist/node-local-snapshot.js.map +1 -0
  67. package/dist/peer-authorization.d.ts +150 -11
  68. package/dist/peer-authorization.js +170 -13
  69. package/dist/peer-authorization.js.map +1 -1
  70. package/dist/peer-record.d.ts +24 -11
  71. package/dist/peer-record.js +29 -13
  72. package/dist/peer-record.js.map +1 -1
  73. package/dist/push-node.d.ts +38 -0
  74. package/dist/push-node.js +48 -0
  75. package/dist/push-node.js.map +1 -0
  76. package/dist/push-notifier-apns.d.ts +10 -2
  77. package/dist/push-notifier-apns.js +14 -10
  78. package/dist/push-notifier-apns.js.map +1 -1
  79. package/dist/push-notifier-fcm.d.ts +7 -1
  80. package/dist/push-notifier-fcm.js +9 -10
  81. package/dist/push-notifier-fcm.js.map +1 -1
  82. package/dist/push-notifier.d.ts +16 -35
  83. package/dist/push-notifier.js +16 -44
  84. package/dist/push-notifier.js.map +1 -1
  85. package/dist/relay-addrs.d.ts +45 -0
  86. package/dist/relay-addrs.js +103 -0
  87. package/dist/relay-addrs.js.map +1 -0
  88. package/dist/relay-reservation.d.ts +266 -0
  89. package/dist/relay-reservation.js +613 -0
  90. package/dist/relay-reservation.js.map +1 -0
  91. package/dist/seed-bootstrap.d.ts +263 -79
  92. package/dist/seed-bootstrap.js +413 -211
  93. package/dist/seed-bootstrap.js.map +1 -1
  94. package/dist/seed-trust-policy.d.ts +50 -24
  95. package/dist/seed-trust-policy.js +28 -19
  96. package/dist/seed-trust-policy.js.map +1 -1
  97. package/dist/strand-addr-protocol.d.ts +42 -13
  98. package/dist/strand-addr-protocol.js +58 -52
  99. package/dist/strand-addr-protocol.js.map +1 -1
  100. package/dist/strand-backfill.d.ts +109 -0
  101. package/dist/strand-backfill.js +279 -0
  102. package/dist/strand-backfill.js.map +1 -0
  103. package/dist/strand-database.d.ts +5 -5
  104. package/dist/strand-database.js +4 -4
  105. package/dist/strand-database.js.map +1 -1
  106. package/dist/strand-formation-manager.d.ts +38 -9
  107. package/dist/strand-formation-manager.js +120 -17
  108. package/dist/strand-formation-manager.js.map +1 -1
  109. package/dist/strand-formation-protocol.d.ts +104 -5
  110. package/dist/strand-formation-protocol.js +233 -5
  111. package/dist/strand-formation-protocol.js.map +1 -1
  112. package/dist/strand-instance-manager.d.ts +40 -2
  113. package/dist/strand-instance-manager.js +93 -15
  114. package/dist/strand-instance-manager.js.map +1 -1
  115. package/dist/strand-member-key.d.ts +6 -6
  116. package/dist/strand-member-key.js +6 -6
  117. package/dist/strand-member-key.js.map +1 -1
  118. package/dist/strand-member-registry.d.ts +12 -12
  119. package/dist/strand-member-registry.js +22 -14
  120. package/dist/strand-member-registry.js.map +1 -1
  121. package/dist/strand-membership-writer.d.ts +532 -138
  122. package/dist/strand-membership-writer.js +800 -179
  123. package/dist/strand-membership-writer.js.map +1 -1
  124. package/dist/strand-solicitation.d.ts +99 -33
  125. package/dist/strand-solicitation.js +100 -52
  126. package/dist/strand-solicitation.js.map +1 -1
  127. package/dist/strand-transport-key.d.ts +40 -0
  128. package/dist/strand-transport-key.js +56 -0
  129. package/dist/strand-transport-key.js.map +1 -0
  130. package/dist/strand-wake-protocol.d.ts +9 -7
  131. package/dist/strand-wake-protocol.js +22 -46
  132. package/dist/strand-wake-protocol.js.map +1 -1
  133. package/dist/strand-watcher.d.ts +17 -2
  134. package/dist/strand-watcher.js +47 -3
  135. package/dist/strand-watcher.js.map +1 -1
  136. package/dist/trusted-owner-store-file.d.ts +12 -0
  137. package/dist/trusted-owner-store-file.js +39 -0
  138. package/dist/trusted-owner-store-file.js.map +1 -0
  139. package/dist/trusted-owner-store.d.ts +64 -0
  140. package/dist/trusted-owner-store.js +126 -0
  141. package/dist/trusted-owner-store.js.map +1 -0
  142. package/dist/types.d.ts +325 -50
  143. package/dist/types.js +13 -0
  144. package/dist/types.js.map +1 -1
  145. package/package.json +23 -8
  146. package/src/bootstrap-peer-store-file.ts +44 -0
  147. package/src/bootstrap-peer-store.ts +172 -0
  148. package/src/cadre-node.ts +2129 -295
  149. package/src/control-authorization.ts +114 -0
  150. package/src/control-cohort.ts +17 -17
  151. package/src/control-database.ts +1766 -191
  152. package/src/control-formation-recorder.ts +174 -21
  153. package/src/control-schema.ts +786 -198
  154. package/src/control-stream.ts +81 -4
  155. package/src/control-write-retry.ts +473 -0
  156. package/src/delegate-admission.ts +302 -0
  157. package/src/device-token.ts +19 -14
  158. package/src/ed25519-key.ts +134 -0
  159. package/src/file-durable-slot.ts +61 -0
  160. package/src/formation-approval.ts +604 -0
  161. package/src/fs-atomic.ts +107 -0
  162. package/src/identity-key.ts +109 -0
  163. package/src/index.ts +157 -24
  164. package/src/key-store-file.ts +10 -81
  165. package/src/key-store.ts +1 -1
  166. package/src/membership-connection-gater.ts +204 -0
  167. package/src/node-local-snapshot.ts +260 -0
  168. package/src/peer-authorization.ts +219 -13
  169. package/src/peer-record.ts +31 -13
  170. package/src/push-node.ts +64 -0
  171. package/src/push-notifier-apns.ts +16 -12
  172. package/src/push-notifier-fcm.ts +9 -10
  173. package/src/push-notifier.ts +16 -57
  174. package/src/relay-addrs.ts +111 -0
  175. package/src/relay-reservation.ts +823 -0
  176. package/src/seed-bootstrap.ts +490 -226
  177. package/src/seed-trust-policy.ts +65 -32
  178. package/src/strand-addr-protocol.ts +84 -53
  179. package/src/strand-backfill.ts +366 -0
  180. package/src/strand-database.ts +6 -6
  181. package/src/strand-formation-manager.ts +145 -19
  182. package/src/strand-formation-protocol.ts +310 -9
  183. package/src/strand-instance-manager.ts +113 -16
  184. package/src/strand-member-key.ts +7 -7
  185. package/src/strand-member-registry.ts +28 -23
  186. package/src/strand-membership-writer.ts +1430 -640
  187. package/src/strand-solicitation.ts +155 -90
  188. package/src/strand-transport-key.ts +58 -0
  189. package/src/strand-wake-protocol.ts +29 -46
  190. package/src/strand-watcher.ts +66 -3
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +342 -51
  194. package/dist/authority-key.d.ts +0 -41
  195. package/dist/authority-key.js +0 -52
  196. package/dist/authority-key.js.map +0 -1
  197. package/src/authority-key.ts +0 -68
@@ -2,9 +2,9 @@ import debug from 'debug';
2
2
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
3
  import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
4
  import type { Libp2p, Connection } from '@libp2p/interface';
5
- import { multiaddr } from '@multiformats/multiaddr';
5
+ import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
6
6
  import { peerIdFromString } from '@libp2p/peer-id';
7
- import { type ControlStream, writeFrame, readStreamToEnd } from './control-stream.js';
7
+ import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
8
8
  import type {
9
9
  ControlNetworkSeed,
10
10
  SeedPeer,
@@ -18,15 +18,19 @@ import type {
18
18
  InviteResult,
19
19
  CadreInvite,
20
20
  PeerAddressRecord,
21
- DeviceTokenRecord
21
+ DeviceTokenRecord,
22
+ RevocationRow
22
23
  } from './types.js';
23
24
  import type { ControlDatabase } from './control-database.js';
25
+ import { generateStampId } from './control-database.js';
24
26
  import { canonicalJson } from './canonical-json.js';
25
- import { peerAuthorizationDigest } from './peer-authorization.js';
27
+ import { deviceTokenAddDigest } from './peer-authorization.js';
26
28
  import {
27
29
  type SeedTrustPolicy,
28
- dbAnchoredTrustPolicy,
30
+ type SeedTrustDecision,
31
+ anchoredTrustPolicy,
29
32
  } from './seed-trust-policy.js';
33
+ import type { TrustedOwnerStore } from './trusted-owner-store.js';
30
34
 
31
35
  const log = debug('sereus:cadre:seed-bootstrap');
32
36
 
@@ -42,6 +46,9 @@ const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
42
46
  /** Default cap on concurrent inbound seed streams a single peer can pin open. */
43
47
  const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
44
48
 
49
+ /** Default time the sender waits for a seed delivery (dial + ack read) before aborting (ms). */
50
+ const DEFAULT_SEED_DELIVER_TIMEOUT_MS = 10_000;
51
+
45
52
  /**
46
53
  * Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
47
54
  *
@@ -73,10 +80,10 @@ export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED
73
80
  *
74
81
  * An Ed25519 PeerId is an identity multihash of the public key, so
75
82
  * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
76
- * base64url form matches the `AuthorityKey.Key` representation (and
77
- * `authorityKeyFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
83
+ * base64url form matches the `OwnerKey.Key` representation (and
84
+ * `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
78
85
  * id, a missing embedded key, or any parse failure — callers treat null as
79
- * "not an authority" rather than throwing.
86
+ * "not an owner" rather than throwing.
80
87
  */
81
88
  export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
82
89
  try {
@@ -105,16 +112,24 @@ export function canonicalSeedPayload(
105
112
  return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
106
113
  }
107
114
 
115
+ /**
116
+ * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
117
+ * so the dial counters read zero rather than being absent.
118
+ */
119
+ function seedRejected(error: string): ApplySeedResult {
120
+ return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
121
+ }
122
+
108
123
  /**
109
124
  * Configuration for the SeedBootstrapService
110
125
  */
111
126
  export interface SeedBootstrapConfig {
112
127
  /** Party ID for this cadre */
113
128
  partyId: string;
114
- /** Authority private key for signing seeds and peer authorizations (base64url) */
115
- authorityPrivateKey?: string;
116
- /** Authority public key (base64url) - derived from private key if not provided */
117
- authorityPublicKey?: string;
129
+ /** Owner private key for signing seeds and peer authorizations (base64url) */
130
+ ownerPrivateKey?: string;
131
+ /** Owner public key (base64url) - derived from private key if not provided */
132
+ ownerPublicKey?: string;
118
133
  /**
119
134
  * Optional async resolver returning the multiaddrs to embed in invites.
120
135
  * When unset, `libp2pNode.getMultiaddrs()` is used. Hosts behind NAT supply
@@ -124,9 +139,9 @@ export interface SeedBootstrapConfig {
124
139
  inviteAddressResolver?: () => Promise<string[]>;
125
140
  /**
126
141
  * Trust anchor for incoming seeds. Decides whether a signature-verified
127
- * `signerKey` should be trusted, against the receiver's known authority
128
- * keys (NOT the seed body). Defaults to `dbAnchoredTrustPolicy()`, which
129
- * rejects any signer not already in the `AuthorityKey` table. An enrollment
142
+ * `signerKey` should be trusted, against the receiver's anchored owner
143
+ * keys (NOT the seed body). Defaults to `anchoredTrustPolicy()`, which
144
+ * rejects any signer not already in {@link trustedOwners}. An enrollment
130
145
  * caller can pass a per-seed override to `applySeed` instead.
131
146
  *
132
147
  * A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
@@ -134,6 +149,19 @@ export interface SeedBootstrapConfig {
134
149
  * since a network-delivered seed has no per-call override.
135
150
  */
136
151
  trustPolicy?: SeedTrustPolicy;
152
+ /**
153
+ * The node-local, NON-replicated trusted-owner anchor. Supplies
154
+ * `SeedTrustContext.knownOwnerKeys` for every {@link applySeed}, and receives
155
+ * a key accepted via a pin/TOFU (see `SeedTrustDecision.anchorAs`).
156
+ *
157
+ * Deliberately NOT `ControlDatabase.getOwnerKeys()`: the replicated
158
+ * `OwnerKey` table is pollutable — any connecting node can genesis-insert its
159
+ * own key and let it replicate — so a seed signed by a stranger's self-issued
160
+ * owner key would pass a table-anchored check. A `CadreNode` passes its
161
+ * `getTrustedOwnerStore()` here. Unset (e.g. a directly-constructed service in
162
+ * a test) means an EMPTY anchor: only a pinned/TOFU policy can accept a seed.
163
+ */
164
+ trustedOwners?: TrustedOwnerStore;
137
165
  /**
138
166
  * Time the inbound seed handler waits for the seed frame before aborting the
139
167
  * read (ms). Defaults to {@link DEFAULT_SEED_READ_TIMEOUT_MS}. Bounds a
@@ -146,6 +174,15 @@ export interface SeedBootstrapConfig {
146
174
  * returned without applying any seed.
147
175
  */
148
176
  maxConcurrentSeeds?: number;
177
+ /**
178
+ * Time {@link SeedBootstrapService.deliverSeed} waits for the whole exchange —
179
+ * dial, write, ack read — before aborting (ms). Defaults to
180
+ * {@link DEFAULT_SEED_DELIVER_TIMEOUT_MS}. Bounds the SENDER against a seed
181
+ * target that accepts the stream and then never replies; the target is a
182
+ * not-yet-trusted node during onboarding, so this is the more exposed
183
+ * direction than the receiver knobs above.
184
+ */
185
+ seedDeliverTimeoutMs?: number;
149
186
  }
150
187
 
151
188
  /**
@@ -154,8 +191,15 @@ export interface SeedBootstrapConfig {
154
191
  export interface SeedEventCallbacks {
155
192
  /** Called when a seed is received via the protocol */
156
193
  onSeedReceived?: (partyId: string, peerId: string) => void;
157
- /** Called when a seed is successfully applied */
158
- onSeedApplied?: (partyId: string, peersAdded: number) => void;
194
+ /**
195
+ * Called when a seed is successfully applied.
196
+ *
197
+ * `seed` is the applied seed itself: the inbound protocol handler applies it
198
+ * INSIDE the service, so this callback is the only seam through which a
199
+ * `CadreNode` sees a wire-delivered seed's contents — which it needs to
200
+ * retain the owner-flagged peers as cold-start bootstrap dial targets.
201
+ */
202
+ onSeedApplied?: (partyId: string, peersAdded: number, seed: ControlNetworkSeed) => void;
159
203
  /** Called when seed application fails */
160
204
  onSeedError?: (partyId: string, error: string) => void;
161
205
  }
@@ -171,30 +215,32 @@ export class SeedBootstrapService {
171
215
  private readonly config: SeedBootstrapConfig;
172
216
  private libp2pNode: Libp2p | null = null;
173
217
  private controlDatabase: ControlDatabase | null = null;
174
- private readonly authorityPublicKey: string | null;
218
+ private readonly ownerPublicKey: string | null;
175
219
  private readonly trustPolicy: SeedTrustPolicy;
176
220
  private readonly seedReadTimeoutMs: number;
177
221
  private readonly maxConcurrentSeeds: number;
222
+ private readonly seedDeliverTimeoutMs: number;
178
223
  /** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
179
224
  private activeStreams = 0;
180
225
  private eventCallbacks: SeedEventCallbacks = {};
181
226
 
182
227
  constructor(config: SeedBootstrapConfig) {
183
228
  this.config = config;
184
- this.trustPolicy = config.trustPolicy ?? dbAnchoredTrustPolicy();
229
+ this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
185
230
  this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
186
231
  this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
232
+ this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? DEFAULT_SEED_DELIVER_TIMEOUT_MS;
187
233
 
188
234
  // Derive public key from private key if not provided
189
- if (config.authorityPrivateKey && !config.authorityPublicKey) {
190
- this.authorityPublicKey = getPublicKey(
191
- config.authorityPrivateKey,
235
+ if (config.ownerPrivateKey && !config.ownerPublicKey) {
236
+ this.ownerPublicKey = getPublicKey(
237
+ config.ownerPrivateKey,
192
238
  'ed25519',
193
239
  'base64url',
194
240
  'base64url'
195
241
  ) as string;
196
242
  } else {
197
- this.authorityPublicKey = config.authorityPublicKey ?? null;
243
+ this.ownerPublicKey = config.ownerPublicKey ?? null;
198
244
  }
199
245
 
200
246
  log('SeedBootstrapService created for party: %s', config.partyId);
@@ -209,15 +255,15 @@ export class SeedBootstrapService {
209
255
  }
210
256
 
211
257
  /**
212
- * Whether this service holds an authority private key, i.e. can produce the
213
- * authority signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
258
+ * Whether this service holds an owner private key, i.e. can produce the
259
+ * owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
214
260
  * and re-authorizations. A seed-listener-only service (`enableSeedListener`,
215
- * no authority key) returns false: it can receive/apply seeds but cannot author
216
- * or re-issue authority writes. Used by the write-while-alone re-replication
217
- * drain to skip authority work on a non-authority node.
261
+ * no owner key) returns false: it can receive/apply seeds but cannot author
262
+ * or re-issue owner writes. Used by the write-while-alone re-replication
263
+ * drain to skip owner work on a non-owner node.
218
264
  */
219
265
  canAuthorize(): boolean {
220
- return !!this.config.authorityPrivateKey;
266
+ return !!this.config.ownerPrivateKey;
221
267
  }
222
268
 
223
269
  /**
@@ -250,14 +296,14 @@ export class SeedBootstrapService {
250
296
 
251
297
  /**
252
298
  * Authorize a new peer to join the cadre.
253
- * Signs the peer ID with the authority key and inserts into CadrePeer table.
299
+ * Signs a membership voucher with the owner key and inserts into CadrePeer table.
254
300
  *
255
- * The authority vouches the `PublicKey <-> PeerId` binding: rather than trust a
301
+ * The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
256
302
  * caller-supplied key, the binding is enforced by construction — `PublicKey` is
257
303
  * DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
258
304
  * `PublicKey`, and such a row can never be self-updated (it has no key to
259
305
  * verify against), which is correct. The row is inserted with a fresh
260
- * `UpdatedAt` but no self-signature (`Sig` null) — the authority cannot produce
306
+ * `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
261
307
  * the peer's self-signature, so the peer must self-publish (see
262
308
  * {@link CadreNode.registerSelf}) before the row resolves.
263
309
  */
@@ -277,15 +323,20 @@ export class SeedBootstrapService {
277
323
  }
278
324
 
279
325
  /**
280
- * Authority-signed INSERT of this node's OWN self-signed address record.
326
+ * Owner-signed INSERT of this node's OWN self-signed address record.
281
327
  *
282
328
  * Used by {@link CadreNode.registerSelf} when the node is not yet a member and
283
- * is its own authority (it holds the authority key): the row is authority-signed
329
+ * is its own owner (it holds the owner key): the row is owner-signed
284
330
  * (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
285
331
  * immediately without a follow-up self-update.
332
+ *
333
+ * @returns `true` when this call seated the row, `false` when a concurrent writer
334
+ * (e.g. an {@link authorizePeer} of this node's own id) had already seated it —
335
+ * in which case the row in the database is NOT this record and carries whatever
336
+ * `Sig` that writer had, so the caller must self-update to publish its signature.
286
337
  */
287
- async insertSelfPeerRecord(record: PeerAddressRecord): Promise<void> {
288
- await this.insertCadrePeerRow({
338
+ async insertSelfPeerRecord(record: PeerAddressRecord): Promise<boolean> {
339
+ return await this.insertCadrePeerRow({
289
340
  peerId: record.peerId,
290
341
  publicKey: record.publicKey,
291
342
  multiaddr: record.addrs.join(','),
@@ -295,11 +346,20 @@ export class SeedBootstrapService {
295
346
  }
296
347
 
297
348
  /**
298
- * Shared authority-signed `CadrePeer` INSERT. Signs `digest(peerId)` with the
299
- * authority key (satisfying `AuthorizedInsert`) and writes the full record
300
- * row. The authority signature does NOT cover the address columns — those are
301
- * vouched only as far as the authority asserts them, and a peer's own `Sig`
302
- * (when present) is what makes the row resolvable.
349
+ * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
350
+ * precondition; the stamp mint, voucher signature, write lock, existence check and
351
+ * membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
352
+ * the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
353
+ * method: the write is what admits the peer's traffic.
354
+ *
355
+ * The DB check precedes the owner-key check here, matching the order this method has
356
+ * always surfaced them (the owner-key error used to come out of the signer, after the
357
+ * DB check).
358
+ *
359
+ * @returns `true` when this call performed the INSERT, `false` when the row was
360
+ * already seated by a concurrent writer. The loser needs to know: an authorize seats
361
+ * a row with a null `Sig`, so a self-publish that lost the race must fall through to
362
+ * a self-update or its record never lands.
303
363
  */
304
364
  private async insertCadrePeerRow(row: {
305
365
  peerId: string;
@@ -307,78 +367,122 @@ export class SeedBootstrapService {
307
367
  multiaddr: string;
308
368
  updatedAt: number;
309
369
  sig: string | null;
310
- }): Promise<void> {
311
- const signature = this.signPeerAuthorization(row.peerId);
370
+ }): Promise<boolean> {
312
371
  if (!this.controlDatabase) {
313
372
  throw new Error('Control database not initialized');
314
373
  }
315
- const db = this.controlDatabase.getDatabase();
316
- await db.exec(`
317
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig)
318
- with context AuthorityKey = ?, Signature = ?
319
- values (?, ?, ?, ?, ?)
320
- `, [this.authorityPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig]);
374
+ const ownerKey = this.requireOwnerPublicKey();
375
+ return await this.controlDatabase.insertCadrePeer(
376
+ row, ownerKey, message => this.signMessageBytes(message),
377
+ );
321
378
  }
322
379
 
323
380
  /**
324
- * Authority-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
381
+ * Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
325
382
  *
326
383
  * Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
327
- * row is authority-signed (satisfying `DeviceToken.AuthorizedInsert`, which vouches
328
- * membership exactly as `CadrePeer.AuthorizedInsert` does) AND carries the peer's
329
- * own self-`Sig` over the token payload. The authority signature covers only the
330
- * PeerId — it does NOT vouch the token contents; the peer's `Sig` (verified at
384
+ * row is owner-signed (satisfying `DeviceToken.AuthorizedInsert` via the
385
+ * 'add'-tagged digest, {@link deviceTokenAddDigest}) AND carries the peer's
386
+ * own self-`Sig` over the token payload. The owner signature covers the WHOLE row
387
+ * (every column, ending in a freshly minted single-use `StampId`) — but covering the
388
+ * token contents is not the same as vouching them: the peer's `Sig` (verified at
331
389
  * resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
332
390
  * resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
333
- * when the node is its own authority.
391
+ * when the node is its own owner.
392
+ *
393
+ * The stamp is per-INSERT, so a re-register after a clear mints a fresh one and a
394
+ * fresh signature — unaffected by the cleared row's retired stamp
395
+ * (`DeviceToken.NotRevoked`).
334
396
  */
335
397
  async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
336
- const signature = this.signPeerAuthorization(record.peerId);
398
+ const stampId = generateStampId(record.peerId);
399
+ const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
337
400
  if (!this.controlDatabase) {
338
401
  throw new Error('Control database not initialized');
339
402
  }
340
- const db = this.controlDatabase.getDatabase();
341
- await db.exec(`
342
- insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig)
343
- with context AuthorityKey = ?, Signature = ?
344
- values (?, ?, ?, ?, ?)
345
- `, [this.authorityPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig]);
346
- log('Device token inserted (authority-signed): %s', record.peerId);
403
+ await this.controlDatabase.execWrite(`
404
+ insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
405
+ with context OwnerKey = ?, Signature = ?
406
+ values (?, ?, ?, ?, ?, ?)
407
+ `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId]);
408
+ log('Device token inserted (owner-signed): %s', record.peerId);
347
409
  }
348
410
 
349
411
  /**
350
- * Authority-signed DELETE of a peer's `DeviceToken` row (logout / token
351
- * invalidation). The `DeviceToken.AuthorizedInsert` constraint gates both insert
352
- * AND delete on an authority signature over `digest(old.PeerId)`, so — like
353
- * {@link removePeer} for `CadrePeer` — clearing a token requires the authority key.
412
+ * Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
413
+ * invalidation). The `DeviceToken.AuthorizedDelete` constraint validates an owner
414
+ * signature over the 'remove'-tagged digest bound to the STORED row's
415
+ * (PeerId, StampId) — deliberately distinct from the insert digest, so a captured
416
+ * insert approval can never be replayed to clear a token. Like {@link removePeer}
417
+ * for `CadrePeer`, clearing a token requires the owner key.
418
+ *
419
+ * The delete and the `Revocation` tombstone retiring the row's `StampId` commit in
420
+ * ONE transaction — `DeviceToken.RevocationRecorded` refuses a bare delete, and
421
+ * without the tombstone the stamp would free up and the never-expiring insert
422
+ * approval (which the cleared device holds a copy of) would re-seat the token. The
423
+ * tombstone carries its OWN owner signature: retiring a stamp permanently forecloses
424
+ * that row, so it is an owner action in its own right.
425
+ *
426
+ * Both digests, the stamp read, and the transaction come from
427
+ * {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
428
+ * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
429
+ * owner-key precondition; a no-op on an already-absent row is the shared body's
430
+ * behavior, and unlike {@link removePeer} nothing here rides on it.
354
431
  */
355
432
  async deleteDeviceToken(peerId: string): Promise<void> {
356
- const signature = this.signPeerAuthorization(peerId);
433
+ // Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
434
+ // owner-key error rather than a silent no-op on an absent row.
435
+ const ownerKey = this.requireOwnerPublicKey();
357
436
  if (!this.controlDatabase) {
358
437
  throw new Error('Control database not initialized');
359
438
  }
360
- const db = this.controlDatabase.getDatabase();
361
- await db.exec(`
362
- delete from CadreControl.DeviceToken
363
- with context AuthorityKey = ?, Signature = ?
364
- where PeerId = ?
365
- `, [this.authorityPublicKey, signature, peerId]);
366
- log('Device token removed (authority-signed): %s', peerId);
439
+ await this.controlDatabase.deleteDeviceToken(
440
+ peerId, ownerKey, message => this.signMessageBytes(message),
441
+ );
367
442
  }
368
443
 
369
444
  /**
370
- * Sign a peer ID with the authority key for a `CadrePeer` / `DeviceToken`
371
- * insert-or-delete. The signed bytes come from the shared
372
- * {@link peerAuthorizationDigest} helper so the offline `cadre enroll register`
373
- * verifier checks the exact same construction. Throws if no authority key is set.
445
+ * Return the configured owner private key, or throw if none is set. The single
446
+ * precondition gate for every owner-signed write. {@link removePeer} /
447
+ * {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
448
+ * they call this up front — otherwise a keyless service would either surface the
449
+ * wrong "Control database not initialized" error or, worse, silently no-op when the
450
+ * target row is absent (the early `stampId === null` return) instead of rejecting.
374
451
  */
375
- private signPeerAuthorization(peerId: string): string {
376
- if (!this.config.authorityPrivateKey) {
377
- throw new Error('Authority private key required to authorize peers');
452
+ private requireOwnerPrivateKey(): string {
453
+ if (!this.config.ownerPrivateKey) {
454
+ throw new Error('Owner private key required to authorize peers');
378
455
  }
456
+ return this.config.ownerPrivateKey;
457
+ }
458
+
459
+ /**
460
+ * The owner PUBLIC key that rides in every owner-signed write's context, non-null.
461
+ *
462
+ * The field is nullable because a read-only service carries no owner key at all, but the
463
+ * constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
464
+ * {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
465
+ * unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
466
+ * their first line and get the owner-key precondition for free.
467
+ */
468
+ private requireOwnerPublicKey(): string {
469
+ this.requireOwnerPrivateKey();
470
+ if (!this.ownerPublicKey) {
471
+ throw new Error('Owner public key required to authorize peers');
472
+ }
473
+ return this.ownerPublicKey;
474
+ }
475
+
476
+ /**
477
+ * Sign a base64url digest with the owner key (ed25519). The single place the
478
+ * owner private key is applied; callers pass the canonical domain-tagged digest for
479
+ * the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
480
+ * {@link signMessageBytes}. Throws if no owner key is set.
481
+ */
482
+ private signDigest(digestB64url: string): string {
379
483
  return sign(
380
- peerAuthorizationDigest(peerId),
381
- this.config.authorityPrivateKey,
484
+ digestB64url,
485
+ this.requireOwnerPrivateKey(),
382
486
  'ed25519',
383
487
  'base64url',
384
488
  'base64url',
@@ -387,78 +491,140 @@ export class SeedBootstrapService {
387
491
  }
388
492
 
389
493
  /**
390
- * Remove a peer from the cadre by authority signature.
494
+ * Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
495
+ * takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
496
+ * same signed bytes (`sign` decodes its base64url input), so a signature minted here
497
+ * satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
498
+ * `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
499
+ */
500
+ private signMessageBytes(message: Uint8Array): string {
501
+ return this.signDigest(uint8ArrayToString(message, 'base64url'));
502
+ }
503
+
504
+ /**
505
+ * Remove a peer from the cadre by owner signature.
391
506
  *
392
- * The constraint over CadrePeer's `check on insert, delete` validates a
393
- * signature over `digest(old.PeerId)` by an authority key. We use the same
394
- * digest pattern as authorizePeer.
507
+ * The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
508
+ * signature over the DISTINCT 'remove'-tagged digest
509
+ * `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
510
+ * key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
511
+ * never be replayed to delete.
512
+ *
513
+ * The delete and the `Revocation` tombstone retiring the row's `StampId` commit in ONE
514
+ * transaction — `CadrePeer.RevocationRecorded` refuses a bare delete, and without the
515
+ * tombstone the stamp would free up and the original admission approval (which never
516
+ * expires, and which the removed peer holds a copy of) would re-seat the row.
517
+ *
518
+ * The tombstone is separately owner-signed (satisfying `Revocation.Authorized`):
519
+ * retiring a stamp evicts that peer party-wide and permanently forecloses re-admitting
520
+ * the row, so it is an owner action in its own right, not a side effect the delete's
521
+ * signature covers.
522
+ *
523
+ * Both digests, the stamp read, the transaction, and the post-commit membership notify
524
+ * come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
525
+ * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
526
+ * owner-key precondition and the absent-row gate the notify depends on (below).
395
527
  */
396
528
  async removePeer(peerId: string): Promise<void> {
397
- // Same canonical digest as the authorizing INSERT (see peerAuthorizationDigest);
398
- // also validates the authority key is present before touching the database.
399
- const signature = this.signPeerAuthorization(peerId);
529
+ // Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
530
+ // remove digest, and this precedence (owner key, then control DB) is what the
531
+ // unit contract asserts.
532
+ const ownerKey = this.requireOwnerPublicKey();
400
533
  if (!this.controlDatabase) {
401
534
  throw new Error('Control database not initialized');
402
535
  }
536
+ // This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
537
+ // internally: deleteCadrePeer's membership notify fires whenever its body resolves,
538
+ // with no idea whether the body wrote anything, so delegating an absent peer would
539
+ // fire a spurious membership notification.
540
+ // NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
541
+ // between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
542
+ // window only — the common "already absent" case is caught here.
543
+ const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
544
+ if (stampId === null) {
545
+ log('removePeer: no CadrePeer row for %s (already absent)', peerId);
546
+ return;
547
+ }
403
548
 
404
549
  log('Removing peer: %s', peerId);
405
550
 
406
- const db = this.controlDatabase.getDatabase();
407
- await db.exec(`
408
- delete from CadreControl.CadrePeer
409
- with context AuthorityKey = ?, Signature = ?
410
- where PeerId = ?
411
- `, [this.authorityPublicKey, signature, peerId]);
551
+ await this.controlDatabase.deleteCadrePeer(
552
+ peerId, ownerKey, message => this.signMessageBytes(message),
553
+ );
412
554
 
413
- log('Peer %s removed successfully', peerId);
555
+ log('Peer %s removed successfully (stamp retired)', peerId);
414
556
  }
415
557
 
416
558
  /**
417
- * Authority "re-touch" of an existing `CadrePeer` membership row: bump
418
- * `UpdatedAt` under the authority branch of `CadrePeer.AuthorizedUpdate` (a
419
- * signature over `digest(peerId)` — the same construction {@link authorizePeer}
420
- * uses) so the row is re-emitted as a fresh, broadcasting transaction.
559
+ * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
560
+ * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
561
+ * the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
562
+ * a membership row that committed local-only (its block's cluster ≤1 at insert) is
563
+ * pushed to the cohort once it grows.
421
564
  *
422
- * This is the write-while-alone re-replication primitive
423
- * (`control-write-ensure-replicated`): a membership row that committed
424
- * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
425
- * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original
426
- * INSERT) because the row already exists locally; a re-INSERT would hit the
427
- * `PeerId` PK. Only the freshness stamp changes — `PublicKey` / `Multiaddr` /
428
- * `Sig` are left intact — so it is safe over a row whose peer has not
429
- * self-published (`Sig` null); the caller must skip a row that already carries a
430
- * self-`Sig` (that row is the owning peer's to refresh, and bumping `UpdatedAt`
431
- * without re-signing would invalidate its self-signature).
565
+ * The stamp read, the voucher signature, the write lock and the membership notify are
566
+ * {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
567
+ * `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
568
+ *
569
+ * A no-op (no throw, no notify) when the row does not exist.
432
570
  *
433
571
  * @param peerId - the membership row to re-touch.
434
572
  * @param updatedAt - the strictly-increasing freshness stamp to write.
435
- * @throws if no authority private key is configured (a non-authority cannot
573
+ * @throws if no owner private key is configured (a non-owner cannot
436
574
  * re-sign another peer's row) or the control database is not initialized.
437
575
  */
438
576
  async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
439
- // Reuses the exact digest authorizePeer signs (peerAuthorizationDigest), which
440
- // the authority branch of AuthorizedUpdate verifies; throws if no authority key.
441
- const signature = this.signPeerAuthorization(peerId);
577
+ // Fail fast on a keyless service before any DB read (see removePeer): a non-owner
578
+ // cannot re-sign the voucher, and must not silently no-op on an absent row.
579
+ const ownerKey = this.requireOwnerPublicKey();
442
580
  if (!this.controlDatabase) {
443
581
  throw new Error('Control database not initialized');
444
582
  }
445
- const db = this.controlDatabase.getDatabase();
446
- await db.exec(`
447
- update CadreControl.CadrePeer
448
- with context AuthorityKey = ?, Signature = ?
449
- set UpdatedAt = ?
450
- where PeerId = ?
451
- `, [this.authorityPublicKey, signature, updatedAt, peerId]);
583
+ const retouched = await this.controlDatabase.reauthorizeCadrePeer(
584
+ peerId, updatedAt, ownerKey, message => this.signMessageBytes(message),
585
+ );
586
+ if (!retouched) {
587
+ log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
588
+ return;
589
+ }
452
590
  log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
453
591
  }
454
592
 
593
+ /**
594
+ * Owner re-issue of a batch of `Revocation` tombstones: bump each row's
595
+ * `ReissuedAt` so the storage layer re-broadcasts a tombstone that committed
596
+ * while the node was alone. The delete-while-alone counterpart of
597
+ * {@link reauthorizePeer} — a removed row cannot be re-touched (it is gone),
598
+ * but its tombstone can, and every membership read treats a retired stamp as
599
+ * absent.
600
+ *
601
+ * The signatures, the single transaction, and the strictly-monotonic
602
+ * `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
603
+ * stays here is the owner-key precondition.
604
+ *
605
+ * @returns how many tombstones were re-issued (`rows.length` on success).
606
+ * @throws if no owner private key is configured or the control database is not
607
+ * initialized.
608
+ */
609
+ async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number): Promise<number> {
610
+ // Fail fast on a keyless service before any DB work (see removePeer): a
611
+ // non-owner cannot sign the reissue digests.
612
+ const ownerKey = this.requireOwnerPublicKey();
613
+ if (!this.controlDatabase) {
614
+ throw new Error('Control database not initialized');
615
+ }
616
+ return this.controlDatabase.reissueRevocations(
617
+ rows, reissuedAt, ownerKey, message => this.signMessageBytes(message),
618
+ );
619
+ }
620
+
455
621
  /**
456
622
  * Create a seed from the current control network state.
457
- * The seed contains peer information and is signed by an authority.
623
+ * The seed contains peer information and is signed by an owner.
458
624
  */
459
625
  async createSeed(): Promise<ControlNetworkSeed> {
460
- if (!this.config.authorityPrivateKey || !this.authorityPublicKey) {
461
- throw new Error('Authority key required to create seeds');
626
+ if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
627
+ throw new Error('Owner key required to create seeds');
462
628
  }
463
629
 
464
630
  if (!this.controlDatabase || !this.libp2pNode) {
@@ -481,7 +647,7 @@ export class SeedBootstrapService {
481
647
  const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
482
648
  const signature = sign(
483
649
  seedDigest,
484
- this.config.authorityPrivateKey,
650
+ this.config.ownerPrivateKey,
485
651
  'ed25519',
486
652
  'base64url',
487
653
  'base64url',
@@ -491,7 +657,7 @@ export class SeedBootstrapService {
491
657
  const seed: ControlNetworkSeed = {
492
658
  ...seedData,
493
659
  signature,
494
- signerKey: this.authorityPublicKey,
660
+ signerKey: this.ownerPublicKey,
495
661
  };
496
662
 
497
663
  log('Created seed with %d peers', peers.length);
@@ -503,10 +669,15 @@ export class SeedBootstrapService {
503
669
  *
504
670
  * Validates the seed signature, then evaluates a trust anchor for the
505
671
  * `signerKey` that does NOT come from the seed body: the receiver's
506
- * `AuthorityKey` table (DB-anchored), optionally augmented by pinned keys or
507
- * TOFU via the configured/overriding `SeedTrustPolicy`. A forged
508
- * self-asserting seed — one that merely lists its own signer as an authority
509
- * peer — no longer passes.
672
+ * node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
673
+ * augmented by pinned keys or TOFU via the configured/overriding
674
+ * `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
675
+ * own signer as an owner peer — no longer passes, and neither does one signed
676
+ * by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
677
+ *
678
+ * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
679
+ * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
680
+ * anchored without re-supplying the invite.
510
681
  *
511
682
  * @param seed - The seed to apply (already transport-decoded).
512
683
  * @param options.trustPolicy - Per-call policy override (e.g. a
@@ -518,35 +689,39 @@ export class SeedBootstrapService {
518
689
  options?: { trustPolicy?: SeedTrustPolicy }
519
690
  ): Promise<ApplySeedResult> {
520
691
  if (!this.libp2pNode) {
521
- return { success: false, peersAdded: 0, error: 'Service not initialized' };
692
+ return seedRejected('Service not initialized');
522
693
  }
523
694
 
695
+ // NOTE: `seed.partyId` is never checked against `config.partyId`. Nothing
696
+ // downstream reads it — trust is keyed on `signerKey` vs the anchor, and the
697
+ // anchor a stray-party seed could write into belongs to THIS party, which
698
+ // only a caller-supplied pin for that signer can reach. If applying a seed
699
+ // ever branches on its partyId (or the anchor becomes multi-party), reject a
700
+ // mismatch here instead.
524
701
  log('Applying seed for party: %s', seed.partyId);
525
702
 
526
703
  // Validate the seed signature
527
704
  if (!this.validateSeedSignature(seed)) {
528
- return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
705
+ return seedRejected('Invalid seed signature');
529
706
  }
530
707
 
531
- // Evaluate the trust anchor for the signer key. The known-authority set is
532
- // sourced from the receiver's control DB, never from the seed itself; a
533
- // cold-start node with no DB and no override therefore sees an empty set.
534
- const knownAuthorityKeys = this.controlDatabase
535
- ? await this.controlDatabase.getAuthorityKeys()
536
- : new Set<string>();
708
+ // Evaluate the trust anchor for the signer key. The known-owner set comes
709
+ // from the receiver's NODE-LOCAL anchor — never from the seed itself, and
710
+ // never from the replicated OwnerKey table (a stranger can genesis-insert
711
+ // its own key there and let it replicate into every peer's copy). A node
712
+ // whose anchor was never seeded, with no policy override, sees an empty set
713
+ // and rejects.
714
+ const knownOwnerKeys = this.config.trustedOwners?.all() ?? new Set<string>();
537
715
  const policy = options?.trustPolicy ?? this.trustPolicy;
538
716
  const decision = await policy.evaluate({
539
717
  partyId: seed.partyId,
540
718
  signerKey: seed.signerKey,
541
- knownAuthorityKeys,
719
+ knownOwnerKeys,
542
720
  });
543
721
  if (!decision.trusted) {
544
- return {
545
- success: false,
546
- peersAdded: 0,
547
- error: decision.reason ?? 'Signer key not trusted by trust policy',
548
- };
722
+ return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
549
723
  }
724
+ await this.anchorAcceptedSigner(seed.signerKey, decision);
550
725
 
551
726
  let peersAdded = 0;
552
727
 
@@ -570,23 +745,71 @@ export class SeedBootstrapService {
570
745
  }
571
746
  }
572
747
 
573
- // Dial authority peers to establish connections
574
- for (const peer of seed.peers.filter(p => p.isAuthority)) {
748
+ // Dial owner peers to establish connections. Best-effort and COUNTED: an
749
+ // owner that is momentarily down leaves this node seeded but unconnected,
750
+ // which the caller can only see if the outcome is reported (see
751
+ // `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
752
+ // `CadreNode.dialColdStartBootstrap` retries these same addresses on every
753
+ // control-cohort reconcile pass until the control database has siblings.
754
+ // `createSeed` projects EVERY CadrePeer row, so an owner applying a seed
755
+ // minted after it joined finds ITSELF in the owner list. Dialing self always
756
+ // throws, which would report a healthy owner as "seeded but stranded".
757
+ // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
758
+ // and an undefined self simply matches nothing.
759
+ const selfPeerId = this.libp2pNode.peerId?.toString();
760
+ let ownerDialsAttempted = 0;
761
+ let ownerDialsFailed = 0;
762
+ for (const peer of seed.peers.filter(p => p.isOwner)) {
763
+ if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
764
+ continue;
765
+ }
766
+ ownerDialsAttempted++;
575
767
  try {
576
- if (peer.multiaddrs.length > 0) {
577
- const addr = multiaddr(peer.multiaddrs[0]);
768
+ const addr = multiaddr(peer.multiaddrs[0]);
578
769
 
579
- log('Dialing authority peer: %s', peer.peerId);
580
- await this.libp2pNode.dial(addr);
581
- }
770
+ log('Dialing owner peer: %s', peer.peerId);
771
+ await this.libp2pNode.dial(addr);
582
772
  } catch (error) {
773
+ ownerDialsFailed++;
583
774
  log('Failed to dial peer %s: %o', peer.peerId, error);
584
775
  // Continue - not all peers need to be reachable
585
776
  }
586
777
  }
587
778
 
588
- log('Applied seed: %d peers added', peersAdded);
589
- return { success: true, peersAdded };
779
+ log('Applied seed: %d peers added, %d/%d owner dial(s) failed',
780
+ peersAdded, ownerDialsFailed, ownerDialsAttempted);
781
+ return { success: true, peersAdded, ownerDialsAttempted, ownerDialsFailed };
782
+ }
783
+
784
+ /**
785
+ * Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
786
+ * later seed from the same owner is anchored without re-supplying the invite
787
+ * or re-prompting. Only the policy decides this happens (`anchorAs` is unset
788
+ * when the key was already anchored, so a plain re-apply writes nothing) and
789
+ * `trust()` is idempotent, keeping the original provenance for a known key.
790
+ *
791
+ * Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
792
+ * in-memory anchor synchronously, so this seed and the rest of the session are
793
+ * unaffected — only durability across a restart is lost, and that is logged.
794
+ *
795
+ * NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
796
+ * unauthorized to authorized, which the write-driven membership-gate refresh
797
+ * (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. Every
798
+ * anchor mutation today rides seed application, and both seed paths refresh the
799
+ * gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`). If some
800
+ * future path anchors an owner OUTSIDE seed application, it owes the same
801
+ * `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
802
+ */
803
+ private async anchorAcceptedSigner(signerKey: string, decision: SeedTrustDecision): Promise<void> {
804
+ if (!decision.anchorAs || !this.config.trustedOwners) {
805
+ return;
806
+ }
807
+ try {
808
+ await this.config.trustedOwners.trust(signerKey, decision.anchorAs);
809
+ log('Anchored seed signer %s as %s', signerKey, decision.anchorAs);
810
+ } catch (error) {
811
+ log('Failed to persist accepted seed signer into the trusted-owner anchor: %o', error);
812
+ }
590
813
  }
591
814
 
592
815
  /**
@@ -608,61 +831,86 @@ export class SeedBootstrapService {
608
831
 
609
832
  /**
610
833
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
834
+ *
835
+ * Sender hardening: the whole exchange — dial, write, ack read — is bounded by
836
+ * {@link seedDeliverTimeoutMs}, and the ack is capped at {@link MAX_SEED_SIZE}.
837
+ * The target is a NOT-YET-TRUSTED node the instigator chose to dial during
838
+ * onboarding, so an unbounded read here is strictly more exposed than the
839
+ * membership-gated receiver paths: without the bound a target that accepts the
840
+ * stream and never replies parks this call forever, and one that streams
841
+ * arbitrary bytes as a fake ack exhausts memory.
611
842
  */
612
843
  async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<SeedAckMessage> {
613
844
  if (!this.libp2pNode) {
614
845
  throw new Error('Service not initialized');
615
846
  }
616
-
847
+ // Capture the node so the closure below needs no non-null assertion.
848
+ const node = this.libp2pNode;
617
849
  const addr = multiaddr(targetMultiaddr);
618
850
 
619
851
  log('Delivering seed to: %s', targetMultiaddr);
620
852
 
621
- // Dial the target and open a stream
622
- const rawStream = await this.libp2pNode.dialProtocol(addr, SEED_PROTOCOL);
623
- const stream = rawStream as unknown as ControlStream;
624
-
625
- try {
626
- // Send the seed message as one length-prefixed frame (shared writer).
627
- const message: SeedMessage = {
628
- partyId: seed.partyId,
629
- peers: seed.peers,
630
- signature: seed.signature,
631
- signerKey: seed.signerKey,
632
- };
633
-
634
- writeFrame(stream, message);
635
-
636
- // In libp2p v3.x, close() closes the write end only (signals EOF),
637
- // while the read end remains open for receiving the ack.
638
- await stream.close();
853
+ return await withDeadline(
854
+ this.seedDeliverTimeoutMs,
855
+ `Seed delivery to ${targetMultiaddr}`,
856
+ (signal) => this.sendSeed(node, addr, seed, signal),
857
+ );
858
+ }
639
859
 
640
- // Read the acknowledgment (stream is AsyncIterable in libp2p 3.x)
641
- const chunks: Uint8Array[] = [];
642
- for await (const chunk of stream) {
643
- const bytes = chunk instanceof Uint8Array ? chunk : (chunk as { subarray(): Uint8Array }).subarray();
644
- chunks.push(bytes);
645
- }
860
+ /**
861
+ * Open one stream to the target, send the seed frame, half-close, and read the ack.
862
+ *
863
+ * `signal` is the deadline from {@link deliverSeed}: it goes to `dialProtocol` so
864
+ * a timeout during connect aborts the dial, and into {@link exchangeFrame} so a
865
+ * timeout after the stream is open resets it — releasing the otherwise unbounded
866
+ * ack-read.
867
+ *
868
+ * Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
869
+ * tiny frame over a relay, whereas a seed is up to 1MB and this delivery path
870
+ * does not dial relay addresses today. Changing that is a separate decision.
871
+ */
872
+ private async sendSeed(
873
+ node: Libp2p,
874
+ addr: Multiaddr,
875
+ seed: ControlNetworkSeed,
876
+ signal: AbortSignal,
877
+ ): Promise<SeedAckMessage> {
878
+ const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
646
879
 
647
- const responseData = new Uint8Array(chunks.reduce((sum, c) => sum + c.length, 0));
648
- let offset = 0;
649
- for (const chunk of chunks) {
650
- responseData.set(chunk, offset);
651
- offset += chunk.length;
652
- }
880
+ const message: SeedMessage = {
881
+ partyId: seed.partyId,
882
+ peers: seed.peers,
883
+ signature: seed.signature,
884
+ signerKey: seed.signerKey,
885
+ };
653
886
 
654
- // Parse length-prefixed response
655
- const responseBody = decodeLengthPrefixedFrame(responseData);
656
- const responseJson = new TextDecoder().decode(responseBody);
657
- const ack = JSON.parse(responseJson) as SeedAckMessage;
887
+ const ack = await exchangeFrame(
888
+ rawStream as unknown as ControlStream,
889
+ signal,
890
+ message,
891
+ (stream) => this.readSeedAck(stream),
892
+ 'Seed delivery aborted by timeout',
893
+ );
658
894
 
659
- log('Seed delivery response: accepted=%s', ack.accepted);
660
- return ack;
895
+ log('Seed delivery response: accepted=%s', ack.accepted);
896
+ return ack;
897
+ }
661
898
 
662
- } catch (err) {
663
- stream.abort(err instanceof Error ? err : new Error(String(err)));
664
- throw err;
665
- }
899
+ /**
900
+ * Read the ack frame a delivery target writes back, bounded by
901
+ * {@link seedDeliverTimeoutMs} and capped at {@link MAX_SEED_SIZE} — an
902
+ * untrusted target must not be able to stream unlimited bytes as a fake ack.
903
+ * Decoding runs inside {@link exchangeFrame}'s `try`, so a malformed or
904
+ * non-JSON ack resets the stream rather than leaking it.
905
+ */
906
+ private async readSeedAck(stream: ControlStream): Promise<SeedAckMessage> {
907
+ const data = await readStreamToEnd(stream, {
908
+ maxBytes: MAX_SEED_SIZE,
909
+ timeoutMs: this.seedDeliverTimeoutMs,
910
+ label: 'Seed ack',
911
+ });
912
+ const body = decodeLengthPrefixedFrame(data, MAX_SEED_SIZE);
913
+ return JSON.parse(new TextDecoder().decode(body)) as SeedAckMessage;
666
914
  }
667
915
 
668
916
  /**
@@ -711,19 +959,27 @@ export class SeedBootstrapService {
711
959
  /**
712
960
  * Query peers from the control database.
713
961
  *
714
- * Authority identity is sourced from the `AuthorityKey` table, not from the
962
+ * Owner identity is sourced from the `OwnerKey` table, not from the
715
963
  * transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
716
964
  * multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
717
- * peer is an authority iff that derived key is in the `AuthorityKey` set.
718
- * This makes any authority node markable — not just the local one — and ties
719
- * `isAuthority` to the control table rather than to `peerId === self`.
965
+ * peer is an owner iff that derived key is in the `OwnerKey` set.
966
+ * This makes any owner node markable — not just the local one — and ties
967
+ * `isOwner` to the control table rather than to `peerId === self`.
968
+ *
969
+ * NOTE: this is the one owner lookup deliberately left on the REPLICATED
970
+ * table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
971
+ * — the receiver dials owner-flagged peers first — not a trust decision, and
972
+ * the receiver re-derives real trust from its own anchor. So a polluted table
973
+ * costs at most a wasted dial, while anchoring here would silently drop
974
+ * legitimate co-owners this node never pinned. If `isOwner` ever gates
975
+ * anything the receiver TRUSTS, move it to the anchor.
720
976
  */
721
977
  private async queryPeers(): Promise<SeedPeer[]> {
722
978
  if (!this.controlDatabase) {
723
979
  return [];
724
980
  }
725
981
 
726
- const authorityKeys = await this.controlDatabase.getAuthorityKeys();
982
+ const ownerKeys = await this.controlDatabase.getOwnerKeys();
727
983
  const db = this.controlDatabase.getDatabase();
728
984
  const peers: SeedPeer[] = [];
729
985
 
@@ -733,16 +989,16 @@ export class SeedBootstrapService {
733
989
  const multiaddr = row.Multiaddr as string | null;
734
990
 
735
991
  // Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
736
- // unparsable id yields null and is treated as a non-authority rather than
992
+ // unparsable id yields null and is treated as a non-owner rather than
737
993
  // failing the whole seed creation.
738
994
  const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
739
- const isAuthority = pubKeyB64 !== null && authorityKeys.has(pubKeyB64);
995
+ const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
740
996
 
741
997
  peers.push({
742
998
  peerId,
743
999
  multiaddrs: multiaddr ? multiaddr.split(',') : [],
744
- isAuthority,
745
- ...(isAuthority ? { publicKey: pubKeyB64 } : {}),
1000
+ isOwner,
1001
+ ...(isOwner ? { publicKey: pubKeyB64 } : {}),
746
1002
  });
747
1003
  }
748
1004
 
@@ -821,7 +1077,7 @@ export class SeedBootstrapService {
821
1077
 
822
1078
  // Emit appropriate event based on result
823
1079
  if (result.success) {
824
- this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
1080
+ this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded, seed);
825
1081
  } else {
826
1082
  this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
827
1083
  }
@@ -921,29 +1177,37 @@ export class SeedBootstrapService {
921
1177
  // configured (typically by `@serfab/cadre-host`'s NatService), it takes
922
1178
  // priority — it may substitute a DDNS hostname and externally-mapped
923
1179
  // port for the raw LAN multiaddrs libp2p reports.
924
- let authorityAddrs: string[];
1180
+ let ownerAddrs: string[];
925
1181
  if (this.config.inviteAddressResolver) {
926
1182
  try {
927
- authorityAddrs = await this.config.inviteAddressResolver();
1183
+ ownerAddrs = await this.config.inviteAddressResolver();
928
1184
  } catch (err) {
929
1185
  log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
930
- authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
1186
+ ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
931
1187
  }
932
1188
  } else {
933
- authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
1189
+ ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
934
1190
  }
935
1191
 
936
- // Carry the cadre's authority keys out-of-band so a cold-start invitee can
937
- // pin the trusted authority set before applying any seed.
938
- const authorityKeys = this.controlDatabase
939
- ? Array.from(await this.controlDatabase.getAuthorityKeys())
940
- : [];
1192
+ // Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
1193
+ // the trusted owner set before applying any seed.
1194
+ //
1195
+ // Sourced ONLY from this node's own anchor, never from the replicated
1196
+ // OwnerKey table: the invitee anchors whatever arrives here
1197
+ // (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
1198
+ // pollutable table would let a stranger's genesis-inserted key ride an
1199
+ // otherwise-legitimate invite straight into the new node's anchor —
1200
+ // poisoning the very store this whole trust chain rests on. No anchor wired
1201
+ // (a directly-constructed service) means no pins to hand out: an invite
1202
+ // without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
1203
+ // table-sourced one silently hands it an unanchored key.
1204
+ const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
941
1205
 
942
1206
  const now = Date.now();
943
1207
  const invite: CadreInvite = {
944
1208
  partyId: this.config.partyId,
945
- authorityAddrs,
946
- authorityKeys: authorityKeys.length ? authorityKeys : undefined,
1209
+ ownerAddrs,
1210
+ ownerKeys: ownerKeys.length ? ownerKeys : undefined,
947
1211
  token,
948
1212
  createdAt: now,
949
1213
  expiresAt: expiresIn ? now + expiresIn : undefined,
@@ -951,7 +1215,7 @@ export class SeedBootstrapService {
951
1215
 
952
1216
  const encodedInvite = this.encodeInvite(invite);
953
1217
 
954
- log('Invite created with %d authority addresses, %d authority keys', authorityAddrs.length, authorityKeys.length);
1218
+ log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
955
1219
 
956
1220
  return { invite, encodedInvite };
957
1221
  }
@@ -1045,11 +1309,11 @@ export class SeedBootstrapService {
1045
1309
  }
1046
1310
 
1047
1311
  /**
1048
- * Dial an authority from an invite.
1049
- * Use this on a phone after receiving an invite to connect to the authority.
1312
+ * Dial an owner from an invite.
1313
+ * Use this on a phone after receiving an invite to connect to the owner.
1050
1314
  *
1051
1315
  * @param invite - The invite received out-of-band
1052
- * @returns Connection to the authority
1316
+ * @returns Connection to the owner
1053
1317
  */
1054
1318
  async dialInvite(invite: CadreInvite): Promise<void> {
1055
1319
  if (!this.libp2pNode) {
@@ -1061,15 +1325,15 @@ export class SeedBootstrapService {
1061
1325
  throw new Error('Invite has expired');
1062
1326
  }
1063
1327
 
1064
- log('Dialing invite authority with %d addresses', invite.authorityAddrs.length);
1328
+ log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
1065
1329
 
1066
- // Try each authority address until one succeeds
1330
+ // Try each owner address until one succeeds
1067
1331
  let lastError: Error | null = null;
1068
- for (const addrStr of invite.authorityAddrs) {
1332
+ for (const addrStr of invite.ownerAddrs) {
1069
1333
  try {
1070
1334
  const addr = multiaddr(addrStr);
1071
1335
  await this.libp2pNode.dial(addr);
1072
- log('Connected to authority at: %s', addrStr);
1336
+ log('Connected to owner at: %s', addrStr);
1073
1337
  return;
1074
1338
  } catch (error) {
1075
1339
  lastError = error instanceof Error ? error : new Error(String(error));
@@ -1077,7 +1341,7 @@ export class SeedBootstrapService {
1077
1341
  }
1078
1342
  }
1079
1343
 
1080
- throw lastError ?? new Error('No authority addresses available');
1344
+ throw lastError ?? new Error('No owner addresses available');
1081
1345
  }
1082
1346
  }
1083
1347