@serfab/cadre-core 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +383 -0
  3. package/dist/cadre-invite-protocol.js +774 -0
  4. package/dist/cadre-invite-protocol.js.map +1 -0
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +849 -210
  9. package/dist/cadre-node.js +1574 -374
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/claim-proof.d.ts +45 -0
  12. package/dist/claim-proof.js +132 -0
  13. package/dist/claim-proof.js.map +1 -0
  14. package/dist/control-authorization.d.ts +80 -19
  15. package/dist/control-authorization.js +55 -13
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.js +1 -1
  18. package/dist/control-cohort.js.map +1 -1
  19. package/dist/control-database.d.ts +605 -108
  20. package/dist/control-database.js +1299 -280
  21. package/dist/control-database.js.map +1 -1
  22. package/dist/control-protocol-guard.d.ts +142 -0
  23. package/dist/control-protocol-guard.js +293 -0
  24. package/dist/control-protocol-guard.js.map +1 -0
  25. package/dist/control-schema.d.ts +1 -1
  26. package/dist/control-schema.js +817 -1067
  27. package/dist/control-schema.js.map +1 -1
  28. package/dist/control-stream.d.ts +4 -0
  29. package/dist/control-stream.js +22 -7
  30. package/dist/control-stream.js.map +1 -1
  31. package/dist/delegate-admission.d.ts +1 -1
  32. package/dist/delegate-admission.js +1 -1
  33. package/dist/device-token.d.ts +11 -9
  34. package/dist/device-token.js +14 -12
  35. package/dist/device-token.js.map +1 -1
  36. package/dist/ed25519-key.d.ts +22 -9
  37. package/dist/ed25519-key.js +47 -10
  38. package/dist/ed25519-key.js.map +1 -1
  39. package/dist/fetch-impl.d.ts +12 -0
  40. package/dist/fetch-impl.js +23 -0
  41. package/dist/fetch-impl.js.map +1 -0
  42. package/dist/file-durable-slot.js +2 -1
  43. package/dist/file-durable-slot.js.map +1 -1
  44. package/dist/file-safe-name.d.ts +25 -0
  45. package/dist/file-safe-name.js +29 -0
  46. package/dist/file-safe-name.js.map +1 -0
  47. package/dist/formation-approval.js +5 -11
  48. package/dist/formation-approval.js.map +1 -1
  49. package/dist/fs-atomic.d.ts +0 -19
  50. package/dist/fs-atomic.js +3 -24
  51. package/dist/fs-atomic.js.map +1 -1
  52. package/dist/index.d.ts +22 -10
  53. package/dist/index.js +54 -11
  54. package/dist/index.js.map +1 -1
  55. package/dist/invitation-bootstrap.d.ts +50 -12
  56. package/dist/invitation-bootstrap.js +76 -13
  57. package/dist/invitation-bootstrap.js.map +1 -1
  58. package/dist/joined-strand-store.d.ts +7 -0
  59. package/dist/joined-strand-store.js +9 -0
  60. package/dist/joined-strand-store.js.map +1 -1
  61. package/dist/key-store-file.js +2 -1
  62. package/dist/key-store-file.js.map +1 -1
  63. package/dist/link-budget.d.ts +25 -33
  64. package/dist/link-budget.js +25 -35
  65. package/dist/link-budget.js.map +1 -1
  66. package/dist/machine-removal.d.ts +78 -0
  67. package/dist/machine-removal.js +166 -0
  68. package/dist/machine-removal.js.map +1 -0
  69. package/dist/membership-connection-gater.d.ts +191 -151
  70. package/dist/membership-connection-gater.js +236 -173
  71. package/dist/membership-connection-gater.js.map +1 -1
  72. package/dist/node-claim-payload.d.ts +74 -0
  73. package/dist/node-claim-payload.js +224 -0
  74. package/dist/node-claim-payload.js.map +1 -0
  75. package/dist/node-default-transports.d.ts +27 -0
  76. package/dist/node-default-transports.js +35 -0
  77. package/dist/node-default-transports.js.map +1 -0
  78. package/dist/node-transports.d.ts +30 -0
  79. package/dist/node-transports.js +40 -0
  80. package/dist/node-transports.js.map +1 -0
  81. package/dist/owner-anchor-sync.d.ts +22 -0
  82. package/dist/owner-anchor-sync.js +143 -0
  83. package/dist/owner-anchor-sync.js.map +1 -0
  84. package/dist/peer-authorization.d.ts +113 -18
  85. package/dist/peer-authorization.js +225 -29
  86. package/dist/peer-authorization.js.map +1 -1
  87. package/dist/peer-dial.d.ts +143 -27
  88. package/dist/peer-dial.js +276 -31
  89. package/dist/peer-dial.js.map +1 -1
  90. package/dist/peer-join-backfill.d.ts +26 -2
  91. package/dist/peer-join-backfill.js +60 -12
  92. package/dist/peer-join-backfill.js.map +1 -1
  93. package/dist/peer-record.d.ts +17 -52
  94. package/dist/peer-record.js +20 -58
  95. package/dist/peer-record.js.map +1 -1
  96. package/dist/pending-join-runner.d.ts +66 -58
  97. package/dist/pending-join-runner.js +136 -145
  98. package/dist/pending-join-runner.js.map +1 -1
  99. package/dist/primary-lan-address.d.ts +8 -0
  100. package/dist/primary-lan-address.js +46 -0
  101. package/dist/primary-lan-address.js.map +1 -0
  102. package/dist/provider/client.d.ts +131 -0
  103. package/dist/provider/client.js +226 -0
  104. package/dist/provider/client.js.map +1 -0
  105. package/dist/provider/descriptor.d.ts +41 -0
  106. package/dist/provider/descriptor.js +103 -0
  107. package/dist/provider/descriptor.js.map +1 -0
  108. package/dist/provider/index.d.ts +9 -0
  109. package/dist/provider/index.js +10 -0
  110. package/dist/provider/index.js.map +1 -0
  111. package/dist/provider/oauth.d.ts +125 -0
  112. package/dist/provider/oauth.js +199 -0
  113. package/dist/provider/oauth.js.map +1 -0
  114. package/dist/provider/rent-node.d.ts +101 -0
  115. package/dist/provider/rent-node.js +220 -0
  116. package/dist/provider/rent-node.js.map +1 -0
  117. package/dist/push-fanout.d.ts +8 -2
  118. package/dist/push-fanout.js +8 -2
  119. package/dist/push-fanout.js.map +1 -1
  120. package/dist/relay-addrs.d.ts +9 -4
  121. package/dist/relay-addrs.js +9 -4
  122. package/dist/relay-addrs.js.map +1 -1
  123. package/dist/relay-reservation.d.ts +51 -16
  124. package/dist/relay-reservation.js +107 -24
  125. package/dist/relay-reservation.js.map +1 -1
  126. package/dist/seed-bootstrap.d.ts +165 -123
  127. package/dist/seed-bootstrap.js +313 -275
  128. package/dist/seed-bootstrap.js.map +1 -1
  129. package/dist/seed-trust-policy.d.ts +98 -37
  130. package/dist/seed-trust-policy.js +188 -2
  131. package/dist/seed-trust-policy.js.map +1 -1
  132. package/dist/strand-addr-protocol.d.ts +24 -26
  133. package/dist/strand-addr-protocol.js +19 -26
  134. package/dist/strand-addr-protocol.js.map +1 -1
  135. package/dist/strand-commit-durability.d.ts +54 -0
  136. package/dist/strand-commit-durability.js +74 -0
  137. package/dist/strand-commit-durability.js.map +1 -0
  138. package/dist/strand-database.d.ts +23 -8
  139. package/dist/strand-database.js +41 -13
  140. package/dist/strand-database.js.map +1 -1
  141. package/dist/strand-first-sync-gate.d.ts +7 -1
  142. package/dist/strand-first-sync-gate.js +7 -1
  143. package/dist/strand-first-sync-gate.js.map +1 -1
  144. package/dist/strand-formation-protocol.js +5 -2
  145. package/dist/strand-formation-protocol.js.map +1 -1
  146. package/dist/strand-fret-addrs.d.ts +19 -1
  147. package/dist/strand-fret-addrs.js +27 -9
  148. package/dist/strand-fret-addrs.js.map +1 -1
  149. package/dist/strand-instance-manager.d.ts +81 -19
  150. package/dist/strand-instance-manager.js +216 -105
  151. package/dist/strand-instance-manager.js.map +1 -1
  152. package/dist/strand-move.d.ts +124 -0
  153. package/dist/strand-move.js +438 -0
  154. package/dist/strand-move.js.map +1 -0
  155. package/dist/strand-network-config.js +7 -6
  156. package/dist/strand-network-config.js.map +1 -1
  157. package/dist/strand-peer-redial.d.ts +113 -0
  158. package/dist/strand-peer-redial.js +236 -0
  159. package/dist/strand-peer-redial.js.map +1 -0
  160. package/dist/strand-revocation-enforcer.d.ts +11 -11
  161. package/dist/strand-revocation-enforcer.js +11 -11
  162. package/dist/strand-wake-protocol.d.ts +13 -16
  163. package/dist/strand-wake-protocol.js +23 -27
  164. package/dist/strand-wake-protocol.js.map +1 -1
  165. package/dist/trusted-owner-store.d.ts +35 -5
  166. package/dist/trusted-owner-store.js +50 -8
  167. package/dist/trusted-owner-store.js.map +1 -1
  168. package/dist/types.d.ts +512 -89
  169. package/dist/types.js +10 -0
  170. package/dist/types.js.map +1 -1
  171. package/package.json +19 -10
  172. package/src/cadre-invite-protocol.ts +1052 -0
  173. package/src/cadre-join-request.ts +167 -0
  174. package/src/cadre-node.ts +1703 -386
  175. package/src/claim-proof.ts +147 -0
  176. package/src/control-authorization.ts +203 -117
  177. package/src/control-cohort.ts +1 -1
  178. package/src/control-database.ts +4663 -3453
  179. package/src/control-protocol-guard.ts +348 -0
  180. package/src/control-schema.ts +828 -1078
  181. package/src/control-stream.ts +26 -7
  182. package/src/delegate-admission.ts +1 -1
  183. package/src/device-token.ts +14 -10
  184. package/src/ed25519-key.ts +47 -10
  185. package/src/fetch-impl.ts +23 -0
  186. package/src/file-durable-slot.ts +64 -63
  187. package/src/file-safe-name.ts +32 -0
  188. package/src/formation-approval.ts +604 -610
  189. package/src/fs-atomic.ts +92 -117
  190. package/src/index.ts +789 -648
  191. package/src/invitation-bootstrap.ts +91 -13
  192. package/src/joined-strand-store.ts +508 -498
  193. package/src/key-store-file.ts +142 -141
  194. package/src/link-budget.ts +25 -36
  195. package/src/machine-removal.ts +216 -0
  196. package/src/membership-connection-gater.ts +259 -191
  197. package/src/node-claim-payload.ts +259 -0
  198. package/src/node-default-transports.ts +38 -0
  199. package/src/node-transports.ts +49 -0
  200. package/src/owner-anchor-sync.ts +171 -0
  201. package/src/peer-authorization.ts +258 -29
  202. package/src/peer-dial.ts +604 -315
  203. package/src/peer-join-backfill.ts +62 -11
  204. package/src/peer-record.ts +159 -197
  205. package/src/pending-join-runner.ts +729 -735
  206. package/src/primary-lan-address.ts +47 -0
  207. package/src/provider/client.ts +326 -0
  208. package/src/provider/descriptor.ts +129 -0
  209. package/src/provider/index.ts +59 -0
  210. package/src/provider/oauth.ts +285 -0
  211. package/src/provider/rent-node.ts +275 -0
  212. package/src/push-fanout.ts +8 -2
  213. package/src/relay-addrs.ts +9 -4
  214. package/src/relay-reservation.ts +1211 -1121
  215. package/src/seed-bootstrap.ts +1502 -1448
  216. package/src/seed-trust-policy.ts +353 -140
  217. package/src/strand-addr-protocol.ts +26 -35
  218. package/src/strand-commit-durability.ts +116 -0
  219. package/src/strand-database.ts +46 -14
  220. package/src/strand-first-sync-gate.ts +7 -1
  221. package/src/strand-formation-protocol.ts +1039 -1036
  222. package/src/strand-fret-addrs.ts +179 -145
  223. package/src/strand-instance-manager.ts +2026 -1883
  224. package/src/strand-move.ts +585 -0
  225. package/src/strand-network-config.ts +7 -6
  226. package/src/strand-peer-redial.ts +307 -0
  227. package/src/strand-revocation-enforcer.ts +11 -11
  228. package/src/strand-wake-protocol.ts +23 -30
  229. package/src/trusted-owner-store.ts +242 -171
  230. package/src/types.ts +2687 -2242
@@ -1,1448 +1,1502 @@
1
- import debug from 'debug';
2
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
- import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
- import type { Libp2p, Connection } from '@libp2p/interface';
5
- import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
6
- import { peerIdFromString } from '@libp2p/peer-id';
7
- import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
8
- import { dialPeerAddrs, SelfRelayOnlyError, DEFAULT_PEER_DIAL_BUDGET, type PeerDialBudget } from './peer-dial.js';
9
- import { relayedRequestBudgetMs } from './link-budget.js';
10
- import type {
11
- ControlNetworkSeed,
12
- SeedPeer,
13
- SeedMessage,
14
- SeedAckMessage,
15
- AuthorizePeerOptions,
16
- ApplySeedResult,
17
- AddDroneOptions,
18
- AddPhoneOptions,
19
- DroneInitResult,
20
- InviteResult,
21
- CadreInvite,
22
- PeerAddressRecord,
23
- DeviceTokenRecord,
24
- RevocationRow,
25
- RevocationLedgerOpenResult
26
- } from './types.js';
27
- import type { ControlDatabase } from './control-database.js';
28
- import { generateStampId } from './control-database.js';
29
- import { canonicalJson } from './canonical-json.js';
30
- import { deviceTokenAddDigest } from './peer-authorization.js';
31
- import {
32
- type SeedTrustPolicy,
33
- type SeedTrustDecision,
34
- anchoredTrustPolicy,
35
- } from './seed-trust-policy.js';
36
- import type { TrustedOwnerStore } from './trusted-owner-store.js';
37
-
38
- const log = debug('sereus:cadre:seed-bootstrap');
39
-
40
- /** Protocol ID for seed delivery */
41
- export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
42
-
43
- /** Maximum seed message size (1MB) */
44
- const MAX_SEED_SIZE = 1024 * 1024;
45
-
46
- /**
47
- * Default time the receiver waits for an inbound seed frame before aborting (ms). It covers the
48
- * read only: the trust decision and the peer-store merge run after it.
49
- */
50
- // eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one seed frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
51
- const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
52
-
53
- /** Default cap on concurrent inbound seed streams a single peer can pin open. */
54
- const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
55
-
56
- /**
57
- * Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
58
- *
59
- * Guards every parse site against malformed input: a buffer too short to hold
60
- * the prefix, a declared length exceeding `maxLength`, and a declared length
61
- * exceeding the bytes actually present. Returns a view (`subarray`, no copy) —
62
- * the body is handed straight to `TextDecoder`.
63
- */
64
- export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED_SIZE): Uint8Array {
65
- if (data.length < 4) {
66
- throw new Error(`Seed frame too short: ${data.length} bytes, need ≥4 for length prefix`);
67
- }
68
- // Pass the full (buffer, byteOffset, byteLength) triple so the read is correct
69
- // even for a non-zero-offset view, not just the fresh zero-offset arrays
70
- // current callers pass.
71
- const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0, false);
72
- const available = data.length - 4;
73
- if (length > maxLength) {
74
- throw new Error(`Seed frame declares length ${length} exceeding max ${maxLength}`);
75
- }
76
- if (length > available) {
77
- throw new Error(`Seed frame declares length ${length} but only ${available} body bytes present`);
78
- }
79
- return data.subarray(4, 4 + length);
80
- }
81
-
82
- /**
83
- * Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
84
- *
85
- * An Ed25519 PeerId is an identity multihash of the public key, so
86
- * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
87
- * base64url form matches the `OwnerKey.Key` representation (and
88
- * `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
89
- * id, a missing embedded key, or any parse failure — callers treat null as
90
- * "not an owner" rather than throwing.
91
- */
92
- export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
93
- try {
94
- const parsed = peerIdFromString(peerId);
95
- if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
96
- return null;
97
- }
98
- return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
99
- } catch {
100
- return null;
101
- }
102
- }
103
-
104
- /**
105
- * Canonical byte representation of the authenticated seed fields.
106
- *
107
- * Routes both the creator (`createSeed`) and the verifier
108
- * (`validateSeedSignature`) through one builder so the signed bytes are
109
- * identical regardless of key insertion order. `canonicalJson` sorts keys and
110
- * drops `undefined`, so the signed payload is exactly `{ partyId, peers }` —
111
- * the fields the producer actually emits.
112
- */
113
- export function canonicalSeedPayload(
114
- seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>
115
- ): string {
116
- return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
117
- }
118
-
119
- /**
120
- * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
121
- * so the dial counters read zero rather than being absent.
122
- */
123
- function seedRejected(error: string): ApplySeedResult {
124
- return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
125
- }
126
-
127
- /** What dialing a seed's owner peers produced — the owner-dial half of an {@link ApplySeedResult}. */
128
- type OwnerDialCounts = Pick<ApplySeedResult, 'ownerDialsAttempted' | 'ownerDialsFailed'>;
129
-
130
- /**
131
- * Parse each address, dropping (and logging) any that is malformed, so one bad
132
- * entry cannot keep a peer's valid addresses from being dialed.
133
- */
134
- function parseDialAddrs(addrs: readonly string[]): Multiaddr[] {
135
- const parsed: Multiaddr[] = [];
136
- for (const addr of addrs) {
137
- try {
138
- parsed.push(multiaddr(addr));
139
- } catch (error) {
140
- log('Skipping malformed dial address %s: %o', addr, error);
141
- }
142
- }
143
- return parsed;
144
- }
145
-
146
- /**
147
- * Configuration for the SeedBootstrapService
148
- */
149
- export interface SeedBootstrapConfig {
150
- /** Party ID for this cadre */
151
- partyId: string;
152
- /** Owner private key for signing seeds and peer authorizations (base64url) */
153
- ownerPrivateKey?: string;
154
- /** Owner public key (base64url) - derived from private key if not provided */
155
- ownerPublicKey?: string;
156
- /**
157
- * Optional async resolver returning the multiaddrs to embed in invites.
158
- * When unset, `libp2pNode.getMultiaddrs()` is used. Hosts behind NAT supply
159
- * this (via `@serfab/cadre-host`'s NatService) to substitute the host's
160
- * DDNS hostname and externally-mapped port.
161
- */
162
- inviteAddressResolver?: () => Promise<string[]>;
163
- /**
164
- * Trust anchor for incoming seeds. Decides whether a signature-verified
165
- * `signerKey` should be trusted, against the receiver's anchored owner
166
- * keys (NOT the seed body). Defaults to `anchoredTrustPolicy()`, which
167
- * rejects any signer not already in {@link trustedOwners}. An enrollment
168
- * caller can pass a per-seed override to `applySeed` instead.
169
- *
170
- * A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
171
- * — that is the only seam the inbound libp2p seed-protocol handler can use,
172
- * since a network-delivered seed has no per-call override.
173
- */
174
- trustPolicy?: SeedTrustPolicy;
175
- /**
176
- * The node-local, NON-replicated trusted-owner anchor. Supplies
177
- * `SeedTrustContext.knownOwnerKeys` for every {@link applySeed}, and receives
178
- * a key accepted via a pin/TOFU (see `SeedTrustDecision.anchorAs`).
179
- *
180
- * Deliberately NOT `ControlDatabase.getOwnerKeys()`: the replicated
181
- * `OwnerKey` table is pollutable — any connecting node can genesis-insert its
182
- * own key and let it replicate — so a seed signed by a stranger's self-issued
183
- * owner key would pass a table-anchored check. A `CadreNode` passes its
184
- * `getTrustedOwnerStore()` here. Unset (e.g. a directly-constructed service in
185
- * a test) means an EMPTY anchor: only a pinned/TOFU policy can accept a seed.
186
- */
187
- trustedOwners?: TrustedOwnerStore;
188
- /**
189
- * Time the inbound seed handler waits for the seed frame before aborting the
190
- * read (ms). Defaults to {@link DEFAULT_SEED_READ_TIMEOUT_MS}. Bounds a
191
- * buggy/compromised own-cadre node that opens a stream and never half-closes.
192
- */
193
- seedReadTimeoutMs?: number;
194
- /**
195
- * Cap on concurrent inbound seed streams (defaults to
196
- * {@link DEFAULT_MAX_CONCURRENT_SEEDS}). Over the cap, a non-accepting ack is
197
- * returned without applying any seed.
198
- */
199
- maxConcurrentSeeds?: number;
200
- /**
201
- * The host's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
202
- * {@link seedDeliverTimeoutMs}'s default is derived. Unset means the declared default.
203
- */
204
- linkRoundTripMs?: number;
205
- /**
206
- * Time {@link SeedBootstrapService.deliverSeed} waits for the whole exchange —
207
- * dial, write, ack read — before aborting (ms). Bounds the SENDER against a seed
208
- * target that accepts the stream and then never replies; the target is a
209
- * not-yet-trusted node during onboarding, so this is the more exposed
210
- * direction than the receiver knobs above.
211
- *
212
- * Defaults to `relayedRequestBudgetMs(linkRoundTripMs)` (`link-budget.ts`; 28.5 s at the
213
- * default declaration): one dial that may need a relay, then one request and its answer.
214
- * Delivery does not set `runOnLimitedConnection`, so it does not use a limited relayed
215
- * connection today; the relayed-dial count is the upper bound on the dial it can use, the
216
- * same choice `CadreNode.controlDialBudget` makes for every address. It holds only link work
217
- * because the receiver acks before its owner dials.
218
- *
219
- * NOTE: no transfer allowance — a seed is a peer list of a few KB, and `MAX_SEED_SIZE` (1 MiB)
220
- * is a defensive cap. If seeds ever grow toward that cap, add an allowance the way
221
- * `PUSH_TRANSFER_ALLOWANCE_MS` does.
222
- */
223
- seedDeliverTimeoutMs?: number;
224
- /**
225
- * Time limits for each peer this service dials from a list of addresses —
226
- * {@link SeedBootstrapService.applySeed}'s owner dials and
227
- * {@link SeedBootstrapService.dialInvite} — per address and per peer (see
228
- * `peer-dial.ts`). Defaults to {@link DEFAULT_PEER_DIAL_BUDGET}; a `CadreNode`
229
- * passes its `network.controlCohort` limits.
230
- */
231
- dialBudget?: PeerDialBudget;
232
- }
233
-
234
- /**
235
- * Event callbacks for seed-related events
236
- */
237
- export interface SeedEventCallbacks {
238
- /** Called when a seed is received via the protocol */
239
- onSeedReceived?: (partyId: string, peerId: string) => void;
240
- /**
241
- * Called when a seed is successfully applied.
242
- *
243
- * `seed` is the applied seed itself: the inbound protocol handler applies it
244
- * INSIDE the service, so this callback is the only seam through which a
245
- * `CadreNode` sees a wire-delivered seed's contents — which it needs to
246
- * retain the owner-flagged peers as cold-start bootstrap dial targets.
247
- */
248
- onSeedApplied?: (partyId: string, peersAdded: number, seed: ControlNetworkSeed) => void;
249
- /** Called when seed application fails */
250
- onSeedError?: (partyId: string, error: string) => void;
251
- }
252
-
253
- /**
254
- * SeedBootstrapService handles control network seed generation and delivery.
255
- *
256
- * Seeds solve the cold-start problem: new nodes need control data to validate
257
- * connections, but can't get data without connecting first. Seeds pre-populate
258
- * the new node's cache with peer information.
259
- */
260
- export class SeedBootstrapService {
261
- private readonly config: SeedBootstrapConfig;
262
- private libp2pNode: Libp2p | null = null;
263
- private controlDatabase: ControlDatabase | null = null;
264
- private readonly ownerPublicKey: string | null;
265
- private readonly trustPolicy: SeedTrustPolicy;
266
- private readonly seedReadTimeoutMs: number;
267
- private readonly maxConcurrentSeeds: number;
268
- private readonly seedDeliverTimeoutMs: number;
269
- private readonly dialBudget: PeerDialBudget;
270
- /** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
271
- private activeStreams = 0;
272
- private eventCallbacks: SeedEventCallbacks = {};
273
-
274
- constructor(config: SeedBootstrapConfig) {
275
- this.config = config;
276
- this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
277
- this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
278
- this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
279
- this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
280
- this.dialBudget = config.dialBudget ?? DEFAULT_PEER_DIAL_BUDGET;
281
-
282
- // Derive public key from private key if not provided
283
- if (config.ownerPrivateKey && !config.ownerPublicKey) {
284
- this.ownerPublicKey = getPublicKey(
285
- config.ownerPrivateKey,
286
- 'ed25519',
287
- 'base64url',
288
- 'base64url'
289
- ) as string;
290
- } else {
291
- this.ownerPublicKey = config.ownerPublicKey ?? null;
292
- }
293
-
294
- log('SeedBootstrapService created for party: %s', config.partyId);
295
- }
296
-
297
- /**
298
- * Set event callbacks for seed-related events.
299
- * Used by CadreNode to emit events.
300
- */
301
- setEventCallbacks(callbacks: SeedEventCallbacks): void {
302
- this.eventCallbacks = callbacks;
303
- }
304
-
305
- /**
306
- * Whether this service holds an owner private key, i.e. can produce the
307
- * owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
308
- * and re-authorizations. A seed-listener-only service (`enableSeedListener`,
309
- * no owner key) returns false: it can receive/apply seeds but cannot author
310
- * or re-issue owner writes. Used by the write-while-alone re-replication
311
- * drain to skip owner work on a non-owner node.
312
- */
313
- canAuthorize(): boolean {
314
- return !!this.config.ownerPrivateKey;
315
- }
316
-
317
- /**
318
- * Initialize the service with libp2p node and control database.
319
- *
320
- * `registerHandler` (default true) gates registration of the shared inbound
321
- * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
322
- * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
323
- * it on. The throwaway temp services CadreNode builds in `applySeed` /
324
- * `dialInvite` pass `false`: they only need the stored `libp2pNode` /
325
- * `controlDatabase` for dialing and known-key lookup, and must NOT bind a
326
- * discarded closure to the shared node (a handler leak, and a second
327
- * `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
328
- *
329
- * Rejects when libp2p refuses the registration; the node and database are
330
- * kept only once the handler is in place, so a failed service holds nothing
331
- * for {@link shutdown} to unhandle.
332
- */
333
- async initialize(
334
- libp2pNode: Libp2p,
335
- controlDatabase: ControlDatabase,
336
- options?: { registerHandler?: boolean }
337
- ): Promise<void> {
338
- // Register the seed protocol handler unless the caller opted out (temp services).
339
- if (options?.registerHandler ?? true) {
340
- await this.registerProtocolHandler(libp2pNode);
341
- }
342
-
343
- this.libp2pNode = libp2pNode;
344
- this.controlDatabase = controlDatabase;
345
- log('SeedBootstrapService initialized');
346
- }
347
-
348
- /**
349
- * Authorize a new peer to join the cadre.
350
- * Signs a membership voucher with the owner key and inserts into CadrePeer table.
351
- *
352
- * The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
353
- * caller-supplied key, the binding is enforced by construction — `PublicKey` is
354
- * DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
355
- * `PublicKey`, and such a row can never be self-updated (it has no key to
356
- * verify against), which is correct. The row is inserted with a fresh
357
- * `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
358
- * the peer's self-signature, so the peer must self-publish (see
359
- * {@link CadreNode.registerSelf}) before the row resolves.
360
- */
361
- async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
362
- const { peerId, multiaddrs } = options;
363
- log('Authorizing peer: %s', peerId);
364
- // CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
365
- const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
366
- await this.insertCadrePeerRow({
367
- peerId,
368
- publicKey: ed25519PublicKeyB64FromPeerId(peerId),
369
- multiaddr: multiaddrStr,
370
- updatedAt: Date.now(),
371
- sig: null,
372
- });
373
- log('Peer %s authorized successfully', peerId);
374
- }
375
-
376
- /**
377
- * Owner-signed INSERT of this node's OWN self-signed address record.
378
- *
379
- * Used by {@link CadreNode.registerSelf} when the node is not yet a member and
380
- * is its own owner (it holds the owner key): the row is owner-signed
381
- * (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
382
- * immediately without a follow-up self-update.
383
- *
384
- * @returns `true` when this call seated the row, `false` when a concurrent writer
385
- * (e.g. an {@link authorizePeer} of this node's own id) had already seated it —
386
- * in which case the row in the database is NOT this record and carries whatever
387
- * `Sig` that writer had, so the caller must self-update to publish its signature.
388
- */
389
- async insertSelfPeerRecord(record: PeerAddressRecord): Promise<boolean> {
390
- return await this.insertCadrePeerRow({
391
- peerId: record.peerId,
392
- publicKey: record.publicKey,
393
- multiaddr: record.addrs.join(','),
394
- updatedAt: record.updatedAt,
395
- sig: record.sig,
396
- });
397
- }
398
-
399
- /**
400
- * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
401
- * precondition; the stamp mint, voucher signature, write lock, existence check and
402
- * membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
403
- * the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
404
- * method: the write is what admits the peer's traffic.
405
- *
406
- * The DB check precedes the owner-key check here, matching the order this method has
407
- * always surfaced them (the owner-key error used to come out of the signer, after the
408
- * DB check).
409
- *
410
- * @returns `true` when this call performed the INSERT, `false` when the row was
411
- * already seated by a concurrent writer. The loser needs to know: an authorize seats
412
- * a row with a null `Sig`, so a self-publish that lost the race must fall through to
413
- * a self-update or its record never lands.
414
- */
415
- private async insertCadrePeerRow(row: {
416
- peerId: string;
417
- publicKey: string | null;
418
- multiaddr: string;
419
- updatedAt: number;
420
- sig: string | null;
421
- }): Promise<boolean> {
422
- if (!this.controlDatabase) {
423
- throw new Error('Control database not initialized');
424
- }
425
- const ownerKey = this.requireOwnerPublicKey();
426
- return await this.controlDatabase.insertCadrePeer(
427
- row, ownerKey, message => this.signMessageBytes(message),
428
- );
429
- }
430
-
431
- /**
432
- * Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
433
- *
434
- * Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
435
- * row is owner-signed (satisfying `DeviceToken.AuthorizedInsert` via the
436
- * 'add'-tagged digest, {@link deviceTokenAddDigest}) AND carries the peer's
437
- * own self-`Sig` over the token payload. The owner signature covers the WHOLE row
438
- * (every column, ending in a freshly minted single-use `StampId`) — but covering the
439
- * token contents is not the same as vouching them: the peer's `Sig` (verified at
440
- * resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
441
- * resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
442
- * when the node is its own owner.
443
- *
444
- * The stamp is per-INSERT, so a re-register after a clear mints a fresh one and a
445
- * fresh signature — unaffected by the cleared row's retired stamp
446
- * (`DeviceToken.NotRevoked`).
447
- */
448
- async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
449
- const stampId = generateStampId(record.peerId);
450
- const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
451
- if (!this.controlDatabase) {
452
- throw new Error('Control database not initialized');
453
- }
454
- await this.controlDatabase.execWrite(`
455
- insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
456
- with context OwnerKey = ?, Signature = ?
457
- values (?, ?, ?, ?, ?, ?)
458
- `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
459
- log('Device token inserted (owner-signed): %s', record.peerId);
460
- }
461
-
462
- /**
463
- * Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
464
- * invalidation). The `DeviceToken.AuthorizedDelete` constraint validates an owner
465
- * signature over the 'remove'-tagged digest bound to the STORED row's
466
- * (PeerId, StampId) — deliberately distinct from the insert digest, so a captured
467
- * insert approval can never be replayed to clear a token. Like {@link removePeer}
468
- * for `CadrePeer`, clearing a token requires the owner key.
469
- *
470
- * The delete and the `Revocation` tombstone retiring the row's `StampId` commit in
471
- * ONE transaction — `DeviceToken.RevocationRecorded` refuses a bare delete, and
472
- * without the tombstone the stamp would free up and the never-expiring insert
473
- * approval (which the cleared device holds a copy of) would re-seat the token. The
474
- * tombstone carries its OWN owner signature: retiring a stamp permanently forecloses
475
- * that row, so it is an owner action in its own right.
476
- *
477
- * Both digests, the stamp read, and the transaction come from
478
- * {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
479
- * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
480
- * owner-key precondition; a no-op on an already-absent row is the shared body's
481
- * behavior, and unlike {@link removePeer} nothing here rides on it.
482
- */
483
- async deleteDeviceToken(peerId: string): Promise<void> {
484
- // Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
485
- // owner-key error rather than a silent no-op on an absent row.
486
- const ownerKey = this.requireOwnerPublicKey();
487
- if (!this.controlDatabase) {
488
- throw new Error('Control database not initialized');
489
- }
490
- await this.controlDatabase.deleteDeviceToken(
491
- peerId, ownerKey, message => this.signMessageBytes(message),
492
- );
493
- }
494
-
495
- /**
496
- * Return the configured owner private key, or throw if none is set. The single
497
- * precondition gate for every owner-signed write. {@link removePeer} /
498
- * {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
499
- * they call this up front — otherwise a keyless service would either surface the
500
- * wrong "Control database not initialized" error or, worse, silently no-op when the
501
- * target row is absent (the early `stampId === null` return) instead of rejecting.
502
- */
503
- private requireOwnerPrivateKey(): string {
504
- if (!this.config.ownerPrivateKey) {
505
- throw new Error('Owner private key required to authorize peers');
506
- }
507
- return this.config.ownerPrivateKey;
508
- }
509
-
510
- /**
511
- * The owner PUBLIC key that rides in every owner-signed write's context, non-null.
512
- *
513
- * The field is nullable because a read-only service carries no owner key at all, but the
514
- * constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
515
- * {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
516
- * unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
517
- * their first line and get the owner-key precondition for free.
518
- */
519
- private requireOwnerPublicKey(): string {
520
- this.requireOwnerPrivateKey();
521
- if (!this.ownerPublicKey) {
522
- throw new Error('Owner public key required to authorize peers');
523
- }
524
- return this.ownerPublicKey;
525
- }
526
-
527
- /**
528
- * Sign a base64url digest with the owner key (ed25519). The single place the
529
- * owner private key is applied; callers pass the canonical domain-tagged digest for
530
- * the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
531
- * {@link signMessageBytes}. Throws if no owner key is set.
532
- */
533
- private signDigest(digestB64url: string): string {
534
- return sign(
535
- digestB64url,
536
- this.requireOwnerPrivateKey(),
537
- 'ed25519',
538
- 'base64url',
539
- 'base64url',
540
- 'base64url'
541
- ) as string;
542
- }
543
-
544
- /**
545
- * Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
546
- * takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
547
- * same signed bytes (`sign` decodes its base64url input), so a signature minted here
548
- * satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
549
- * `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
550
- */
551
- private signMessageBytes(message: Uint8Array): string {
552
- return this.signDigest(uint8ArrayToString(message, 'base64url'));
553
- }
554
-
555
- /**
556
- * Remove a peer from the cadre by owner signature.
557
- *
558
- * The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
559
- * signature over the DISTINCT 'remove'-tagged digest
560
- * `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
561
- * key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
562
- * never be replayed to delete.
563
- *
564
- * The delete and the `Revocation` tombstone retiring the row's `StampId` commit in ONE
565
- * transaction — `CadrePeer.RevocationRecorded` refuses a bare delete, and without the
566
- * tombstone the stamp would free up and the original admission approval (which never
567
- * expires, and which the removed peer holds a copy of) would re-seat the row.
568
- *
569
- * The tombstone is separately owner-signed (satisfying `Revocation.Authorized`):
570
- * retiring a stamp evicts that peer party-wide and permanently forecloses re-admitting
571
- * the row, so it is an owner action in its own right, not a side effect the delete's
572
- * signature covers.
573
- *
574
- * Both digests, the stamp read, the transaction, and the post-commit membership notify
575
- * come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
576
- * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
577
- * owner-key precondition and the absent-row gate the notify depends on (below).
578
- */
579
- async removePeer(peerId: string): Promise<void> {
580
- // Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
581
- // remove digest, and this precedence (owner key, then control DB) is what the
582
- // unit contract asserts.
583
- const ownerKey = this.requireOwnerPublicKey();
584
- if (!this.controlDatabase) {
585
- throw new Error('Control database not initialized');
586
- }
587
- // This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
588
- // internally: deleteCadrePeer's membership notify fires whenever its body resolves,
589
- // with no idea whether the body wrote anything, so delegating an absent peer would
590
- // fire a spurious membership notification.
591
- // NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
592
- // between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
593
- // window only — the common "already absent" case is caught here.
594
- const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
595
- if (stampId === null) {
596
- log('removePeer: no CadrePeer row for %s (already absent)', peerId);
597
- return;
598
- }
599
-
600
- log('Removing peer: %s', peerId);
601
-
602
- await this.controlDatabase.deleteCadrePeer(
603
- peerId, ownerKey, message => this.signMessageBytes(message),
604
- );
605
-
606
- log('Peer %s removed successfully (stamp retired)', peerId);
607
- }
608
-
609
- /**
610
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
611
- * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
612
- * the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
613
- * a membership row that committed local-only (its block's cluster ≤1 at insert) is
614
- * pushed to the cohort once it grows.
615
- *
616
- * The stamp read, the voucher signature, the write lock and the membership notify are
617
- * {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
618
- * `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
619
- *
620
- * A no-op (no throw, no notify) when the row does not exist.
621
- *
622
- * @param peerId - the membership row to re-touch.
623
- * @param updatedAt - the strictly-increasing freshness stamp to write.
624
- * @throws if no owner private key is configured (a non-owner cannot
625
- * re-sign another peer's row) or the control database is not initialized.
626
- */
627
- async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
628
- // Fail fast on a keyless service before any DB read (see removePeer): a non-owner
629
- // cannot re-sign the voucher, and must not silently no-op on an absent row.
630
- const ownerKey = this.requireOwnerPublicKey();
631
- if (!this.controlDatabase) {
632
- throw new Error('Control database not initialized');
633
- }
634
- const retouched = await this.controlDatabase.reauthorizeCadrePeer(
635
- peerId, updatedAt, ownerKey, message => this.signMessageBytes(message),
636
- );
637
- if (!retouched) {
638
- log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
639
- return;
640
- }
641
- log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
642
- }
643
-
644
- /**
645
- * Owner re-issue of a batch of `Revocation` tombstones: bump each row's
646
- * `ReissuedAt` so the storage layer re-broadcasts a tombstone that committed
647
- * while the node was alone. The delete-while-alone counterpart of
648
- * {@link reauthorizePeer} — a removed row cannot be re-touched (it is gone),
649
- * but its tombstone can, and every membership read treats a retired stamp as
650
- * absent.
651
- *
652
- * The signatures, the single transaction, and the strictly-monotonic
653
- * `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
654
- * stays here is the owner-key precondition.
655
- *
656
- * @returns how many tombstones were re-issued (`rows.length` on success).
657
- * @throws if no owner private key is configured or the control database is not
658
- * initialized.
659
- */
660
- async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number): Promise<number> {
661
- // Fail fast on a keyless service before any DB work (see removePeer): a
662
- // non-owner cannot sign the reissue digests.
663
- const ownerKey = this.requireOwnerPublicKey();
664
- if (!this.controlDatabase) {
665
- throw new Error('Control database not initialized');
666
- }
667
- return this.controlDatabase.reissueRevocations(
668
- rows, reissuedAt, ownerKey, message => this.signMessageBytes(message),
669
- );
670
- }
671
-
672
- /**
673
- * Owner filing of the singleton `Revocation` ledger marker, so the table is never a
674
- * never-written block that the storage layer re-consults on every read.
675
- *
676
- * The signature, the insert-if-absent guard and the `'already-open'` mapping are
677
- * {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-key
678
- * precondition.
679
- *
680
- * @throws if no owner private key is configured or the control database is not
681
- * initialized.
682
- */
683
- async openRevocationLedger(): Promise<RevocationLedgerOpenResult> {
684
- // Fail fast on a keyless service before any DB work (see removePeer): a
685
- // non-owner cannot sign the marker's append digest.
686
- const ownerKey = this.requireOwnerPublicKey();
687
- if (!this.controlDatabase) {
688
- throw new Error('Control database not initialized');
689
- }
690
- return this.controlDatabase.openRevocationLedger(
691
- ownerKey, message => this.signMessageBytes(message),
692
- );
693
- }
694
-
695
- /**
696
- * Create a seed from the current control network state.
697
- * The seed contains peer information and is signed by an owner.
698
- */
699
- async createSeed(): Promise<ControlNetworkSeed> {
700
- if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
701
- throw new Error('Owner key required to create seeds');
702
- }
703
-
704
- if (!this.controlDatabase || !this.libp2pNode) {
705
- throw new Error('Service not initialized');
706
- }
707
-
708
- log('Creating seed for party: %s', this.config.partyId);
709
-
710
- // Query all peers from the control database
711
- const peers = await this.queryPeers();
712
-
713
- // Create the seed data (without signature)
714
- const seedData = {
715
- partyId: this.config.partyId,
716
- peers,
717
- };
718
-
719
- // Sign the seed over its canonical byte representation
720
- const seedJson = canonicalSeedPayload(seedData);
721
- const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
722
- const signature = sign(
723
- seedDigest,
724
- this.config.ownerPrivateKey,
725
- 'ed25519',
726
- 'base64url',
727
- 'base64url',
728
- 'base64url'
729
- ) as string;
730
-
731
- const seed: ControlNetworkSeed = {
732
- ...seedData,
733
- signature,
734
- signerKey: this.ownerPublicKey,
735
- };
736
-
737
- log('Created seed with %d peers', peers.length);
738
- return seed;
739
- }
740
-
741
- /**
742
- * Apply a seed to populate the peer cache and enable connections.
743
- *
744
- * Validates the seed signature, then evaluates a trust anchor for the
745
- * `signerKey` that does NOT come from the seed body: the receiver's
746
- * node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
747
- * augmented by pinned keys or TOFU via the configured/overriding
748
- * `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
749
- * own signer as an owner peer — no longer passes, and neither does one signed
750
- * by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
751
- *
752
- * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
753
- * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
754
- * anchored without re-supplying the invite.
755
- *
756
- * @param seed - The seed to apply (already transport-decoded).
757
- * @param options.trustPolicy - Per-call policy override (e.g. a
758
- * `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
759
- * service-configured default for this seed only.
760
- */
761
- async applySeed(
762
- seed: ControlNetworkSeed,
763
- options?: { trustPolicy?: SeedTrustPolicy }
764
- ): Promise<ApplySeedResult> {
765
- const merged = await this.verifyAndMergeSeed(seed, options);
766
- if (!merged.success) {
767
- return merged;
768
- }
769
- return { ...merged, ...await this.dialSeedOwners(seed) };
770
- }
771
-
772
- /**
773
- * The first half of {@link applySeed}: check the signature and the signer's trust, then merge
774
- * the seed's peer addresses into the peer store. Every rejection happens here, so this result is
775
- * what the inbound handler acks with; the owner dials that follow cannot change it.
776
- */
777
- private async verifyAndMergeSeed(
778
- seed: ControlNetworkSeed,
779
- options?: { trustPolicy?: SeedTrustPolicy }
780
- ): Promise<ApplySeedResult> {
781
- if (!this.libp2pNode) {
782
- return seedRejected('Service not initialized');
783
- }
784
-
785
- // NOTE: `seed.partyId` is never checked against `config.partyId`. Nothing
786
- // downstream reads it — trust is keyed on `signerKey` vs the anchor, and the
787
- // anchor a stray-party seed could write into belongs to THIS party, which
788
- // only a caller-supplied pin for that signer can reach. If applying a seed
789
- // ever branches on its partyId (or the anchor becomes multi-party), reject a
790
- // mismatch here instead.
791
- log('Applying seed for party: %s', seed.partyId);
792
-
793
- // Validate the seed signature
794
- if (!this.validateSeedSignature(seed)) {
795
- return seedRejected('Invalid seed signature');
796
- }
797
-
798
- // Evaluate the trust anchor for the signer key. The known-owner set comes
799
- // from the receiver's NODE-LOCAL anchor — never from the seed itself, and
800
- // never from the replicated OwnerKey table (a stranger can genesis-insert
801
- // its own key there and let it replicate into every peer's copy). A node
802
- // whose anchor was never seeded, with no policy override, sees an empty set
803
- // and rejects.
804
- const knownOwnerKeys = this.config.trustedOwners?.all() ?? new Set<string>();
805
- const policy = options?.trustPolicy ?? this.trustPolicy;
806
- const decision = await policy.evaluate({
807
- partyId: seed.partyId,
808
- signerKey: seed.signerKey,
809
- knownOwnerKeys,
810
- });
811
- if (!decision.trusted) {
812
- return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
813
- }
814
- await this.anchorAcceptedSigner(seed.signerKey, decision);
815
-
816
- let peersAdded = 0;
817
-
818
- // Add peers to the peer store
819
- for (const peer of seed.peers) {
820
- try {
821
- // Import peer multiaddrs into the peer store
822
- if (peer.multiaddrs.length > 0) {
823
- const peerId = peerIdFromString(peer.peerId);
824
- const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
825
-
826
- await this.libp2pNode.peerStore.merge(peerId, {
827
- multiaddrs: addrs
828
- });
829
-
830
- peersAdded++;
831
- log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
832
- }
833
- } catch (error) {
834
- log('Failed to add peer %s: %o', peer.peerId, error);
835
- }
836
- }
837
-
838
- log('Merged seed: %d peers added', peersAdded);
839
- return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
840
- }
841
-
842
- /**
843
- * The second half of {@link applySeed}: dial the seed's owner peers to establish connections.
844
- *
845
- * Best-effort and COUNTED: an owner that is momentarily down leaves this node seeded but
846
- * unconnected, which the caller can only see if the outcome is reported (see
847
- * `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
848
- * `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
849
- * reconcile pass until the control database has siblings.
850
- *
851
- * The inbound handler runs this AFTER it has acked and closed the stream
852
- * ({@link handleSeedStream}), because an unreachable owner can take `dialBudget.totalMs`.
853
- */
854
- private async dialSeedOwners(seed: ControlNetworkSeed): Promise<OwnerDialCounts> {
855
- const node = this.libp2pNode;
856
- if (!node) {
857
- log('Seed service shut down before its owner dials; none attempted');
858
- return { ownerDialsAttempted: 0, ownerDialsFailed: 0 };
859
- }
860
- // `createSeed` projects every non-revoked CadrePeer row, so an owner applying
861
- // a seed minted after it joined finds ITSELF in the owner list. Dialing self always
862
- // throws, which would report a healthy owner as "seeded but stranded".
863
- // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
864
- // and an undefined self simply matches nothing.
865
- // Every one of an owner's addresses is a candidate, each on its own time limit
866
- // (`dialPeerAddrs`), so an owner whose first address never answers neither
867
- // stalls seed application nor goes undialed at its other addresses.
868
- const selfPeerId = node.peerId?.toString();
869
- let ownerDialsAttempted = 0;
870
- let ownerDialsFailed = 0;
871
- for (const peer of seed.peers.filter(p => p.isOwner)) {
872
- if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
873
- continue;
874
- }
875
- ownerDialsAttempted++;
876
- try {
877
- const addrs = parseDialAddrs(peer.multiaddrs);
878
-
879
- log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
880
- await dialPeerAddrs(node, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
881
- } catch (error) {
882
- // Counted even when the owner reaches us only through our own relay: this node is still
883
- // not connected to it, and `ownerDialsFailed` is how the caller learns that.
884
- ownerDialsFailed++;
885
- if (error instanceof SelfRelayOnlyError) {
886
- log('Owner peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
887
- } else {
888
- log('Failed to dial peer %s: %o', peer.peerId, error);
889
- }
890
- // Continue - not all peers need to be reachable
891
- }
892
- }
893
-
894
- log('Dialed seed owners: %d/%d owner dial(s) failed', ownerDialsFailed, ownerDialsAttempted);
895
- return { ownerDialsAttempted, ownerDialsFailed };
896
- }
897
-
898
- /**
899
- * Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
900
- * later seed from the same owner is anchored without re-supplying the invite
901
- * or re-prompting. Only the policy decides this happens (`anchorAs` is unset
902
- * when the key was already anchored, so a plain re-apply writes nothing) and
903
- * `trust()` is idempotent, keeping the original provenance for a known key.
904
- *
905
- * Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
906
- * in-memory anchor synchronously, so this seed and the rest of the session are
907
- * unaffected — only durability across a restart is lost, and that is logged.
908
- *
909
- * NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
910
- * unauthorized to authorized, which the write-driven membership-gate refresh
911
- * (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. Every
912
- * anchor mutation today rides seed application, and both seed paths refresh the
913
- * gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`). If some
914
- * future path anchors an owner OUTSIDE seed application, it owes the same
915
- * `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
916
- */
917
- private async anchorAcceptedSigner(signerKey: string, decision: SeedTrustDecision): Promise<void> {
918
- if (!decision.anchorAs || !this.config.trustedOwners) {
919
- return;
920
- }
921
- try {
922
- await this.config.trustedOwners.trust(signerKey, decision.anchorAs);
923
- log('Anchored seed signer %s as %s', signerKey, decision.anchorAs);
924
- } catch (error) {
925
- log('Failed to persist accepted seed signer into the trusted-owner anchor: %o', error);
926
- }
927
- }
928
-
929
- /**
930
- * Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
931
- */
932
- encodeSeed(seed: ControlNetworkSeed): string {
933
- const json = JSON.stringify(seed);
934
- return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
935
- }
936
-
937
- /**
938
- * Decode a seed from base64url encoding.
939
- */
940
- decodeSeed(encoded: string): ControlNetworkSeed {
941
- const bytes = uint8ArrayFromString(encoded, 'base64url');
942
- const json = new TextDecoder().decode(bytes);
943
- return JSON.parse(json) as ControlNetworkSeed;
944
- }
945
-
946
- /**
947
- * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
948
- *
949
- * Sender hardening: the whole exchange — dial, write, ack read — is bounded by
950
- * {@link seedDeliverTimeoutMs}, and the ack is capped at {@link MAX_SEED_SIZE}.
951
- * The target is a NOT-YET-TRUSTED node the instigator chose to dial during
952
- * onboarding, so an unbounded read here is strictly more exposed than the
953
- * membership-gated receiver paths: without the bound a target that accepts the
954
- * stream and never replies parks this call forever, and one that streams
955
- * arbitrary bytes as a fake ack exhausts memory.
956
- */
957
- async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<SeedAckMessage> {
958
- if (!this.libp2pNode) {
959
- throw new Error('Service not initialized');
960
- }
961
- // Capture the node so the closure below needs no non-null assertion.
962
- const node = this.libp2pNode;
963
- const addr = multiaddr(targetMultiaddr);
964
-
965
- log('Delivering seed to: %s', targetMultiaddr);
966
-
967
- return await withDeadline(
968
- this.seedDeliverTimeoutMs,
969
- `Seed delivery to ${targetMultiaddr}`,
970
- (signal) => this.sendSeed(node, addr, seed, signal),
971
- );
972
- }
973
-
974
- /**
975
- * Open one stream to the target, send the seed frame, half-close, and read the ack.
976
- *
977
- * `signal` is the deadline from {@link deliverSeed}: it goes to `dialProtocol` so
978
- * a timeout during connect aborts the dial, and into {@link exchangeFrame} so a
979
- * timeout after the stream is open resets it — releasing the otherwise unbounded
980
- * ack-read.
981
- *
982
- * Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
983
- * tiny frame over a relay, whereas a seed is up to 1MB and this delivery path
984
- * does not dial relay addresses today. Changing that is a separate decision.
985
- */
986
- private async sendSeed(
987
- node: Libp2p,
988
- addr: Multiaddr,
989
- seed: ControlNetworkSeed,
990
- signal: AbortSignal,
991
- ): Promise<SeedAckMessage> {
992
- const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
993
-
994
- const message: SeedMessage = {
995
- partyId: seed.partyId,
996
- peers: seed.peers,
997
- signature: seed.signature,
998
- signerKey: seed.signerKey,
999
- };
1000
-
1001
- const ack = await exchangeFrame(
1002
- rawStream as unknown as ControlStream,
1003
- signal,
1004
- message,
1005
- (stream) => this.readSeedAck(stream),
1006
- 'Seed delivery aborted by timeout',
1007
- );
1008
-
1009
- log('Seed delivery response: accepted=%s', ack.accepted);
1010
- return ack;
1011
- }
1012
-
1013
- /**
1014
- * Read the ack frame a delivery target writes back, bounded by
1015
- * {@link seedDeliverTimeoutMs} and capped at {@link MAX_SEED_SIZE} — an
1016
- * untrusted target must not be able to stream unlimited bytes as a fake ack.
1017
- * Decoding runs inside {@link exchangeFrame}'s `try`, so a malformed or
1018
- * non-JSON ack resets the stream rather than leaking it.
1019
- */
1020
- private async readSeedAck(stream: ControlStream): Promise<SeedAckMessage> {
1021
- const data = await readStreamToEnd(stream, {
1022
- maxBytes: MAX_SEED_SIZE,
1023
- timeoutMs: this.seedDeliverTimeoutMs,
1024
- label: 'Seed ack',
1025
- });
1026
- const body = decodeLengthPrefixedFrame(data, MAX_SEED_SIZE);
1027
- return JSON.parse(new TextDecoder().decode(body)) as SeedAckMessage;
1028
- }
1029
-
1030
- /**
1031
- * Get this node's circuit relay address for inclusion in seeds.
1032
- * Returns null if no relay address is available.
1033
- */
1034
- async getRelayAddress(): Promise<string | null> {
1035
- if (!this.libp2pNode) {
1036
- return null;
1037
- }
1038
-
1039
- const addrs = this.libp2pNode.getMultiaddrs();
1040
-
1041
- // Find a circuit relay address
1042
- const relayAddr = addrs.find(addr => addr.toString().includes('/p2p-circuit/'));
1043
-
1044
- return relayAddr?.toString() ?? null;
1045
- }
1046
-
1047
- /**
1048
- * Validate a seed's signature.
1049
- */
1050
- validateSeedSignature(seed: ControlNetworkSeed): boolean {
1051
- try {
1052
- // Reconstruct the signed bytes via the shared canonical payload builder so
1053
- // verification is independent of key order. The payload is the fixed
1054
- // `{ partyId, peers }` the producer emits.
1055
- const seedJson = canonicalSeedPayload(seed);
1056
- const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
1057
-
1058
- return verify(
1059
- seedDigest,
1060
- seed.signature,
1061
- seed.signerKey,
1062
- 'ed25519',
1063
- 'base64url',
1064
- 'base64url',
1065
- 'base64url'
1066
- );
1067
- } catch (error) {
1068
- log('Seed signature validation failed: %o', error);
1069
- return false;
1070
- }
1071
- }
1072
-
1073
- /**
1074
- * Query peers from the control database.
1075
- *
1076
- * Owner identity is sourced from the `OwnerKey` table, not from the
1077
- * transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
1078
- * multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
1079
- * peer is an owner iff that derived key is in the `OwnerKey` set.
1080
- * This makes any owner node markable — not just the local one — and ties
1081
- * `isOwner` to the control table rather than to `peerId === self`.
1082
- *
1083
- * Read through {@link ControlDatabase.queryCadrePeers}, not a raw `CadrePeer`
1084
- * select: that reader drops any row whose `StampId` is retired in
1085
- * `CadreControl.Revocation`, so a removed member's addresses are never packed
1086
- * into a seed and pushed into a joiner's peerstore (`applySeed` adds every
1087
- * seed peer's addrs and dials the owner-flagged ones). A revoked peer is off
1088
- * the addressable surface everywhere, and this is one of its exits.
1089
- *
1090
- * NOTE: this is the one owner lookup deliberately left on the REPLICATED
1091
- * table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
1092
- * — the receiver dials owner-flagged peers first — not a trust decision, and
1093
- * the receiver re-derives real trust from its own anchor. So a polluted table
1094
- * costs at most a wasted dial, while anchoring here would silently drop
1095
- * legitimate co-owners this node never pinned. If `isOwner` ever gates
1096
- * anything the receiver TRUSTS, move it to the anchor.
1097
- */
1098
- private async queryPeers(): Promise<SeedPeer[]> {
1099
- if (!this.controlDatabase) {
1100
- return [];
1101
- }
1102
-
1103
- const ownerKeys = await this.controlDatabase.getOwnerKeys();
1104
- const rows = await this.controlDatabase.queryCadrePeers();
1105
-
1106
- return rows.map(({ peerId, multiaddr }) => {
1107
- // Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
1108
- // unparsable id yields null and is treated as a non-owner rather than
1109
- // failing the whole seed creation.
1110
- const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
1111
- const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
1112
-
1113
- return {
1114
- peerId,
1115
- multiaddrs: multiaddr ? multiaddr.split(',') : [],
1116
- isOwner,
1117
- ...(isOwner ? { publicKey: pubKeyB64 } : {}),
1118
- };
1119
- });
1120
- }
1121
-
1122
- /**
1123
- * Register the seed protocol handler. The inbound closure just delegates to
1124
- * {@link handleSeedStream} — extracted as a method so it has a unit-test seam
1125
- * (mirroring wake's `handleStream`) the inline closure never had.
1126
- */
1127
- private async registerProtocolHandler(libp2pNode: Libp2p): Promise<void> {
1128
- await libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
1129
- const remotePeerId = (rawConnection as Connection).remotePeer.toString();
1130
- await this.handleSeedStream(rawStream as ControlStream, remotePeerId);
1131
- });
1132
-
1133
- log('Registered seed protocol handler: %s', SEED_PROTOCOL);
1134
- }
1135
-
1136
- /**
1137
- * Read one inbound seed frame, verify and merge it, write the ack and close the
1138
- * stream, then dial the seed's owners.
1139
- *
1140
- * The ack and the close come BEFORE the owner dials: an unreachable owner can hold
1141
- * a dial for `dialBudget.totalMs` (86 s at the default declared link), which is not
1142
- * link time and does not belong inside the sender's delivery deadline. The close is
1143
- * what releases the sender, which reads the ack to end-of-stream. The stream stays
1144
- * counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
1145
- * still bounds concurrent owner-dial phases.
1146
- *
1147
- * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
1148
- * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
1149
- * that never half-closes is aborted inside `readStreamToEnd`), and the existing
1150
- * malformed/oversized-frame guard — all reported as a non-accepting
1151
- * {@link SeedAckMessage} rather than a dropped/hung stream.
1152
- *
1153
- * NOTE: a receiver configured with an interactive trust-on-first-use policy asks a
1154
- * human before acking, and that wait sits inside the sender's delivery deadline. If
1155
- * TOFU is ever used on this wire path, ack "pending" or move the confirmation out of
1156
- * the exchange.
1157
- */
1158
- private async handleSeedStream(stream: ControlStream, remotePeerId: string): Promise<void> {
1159
- log('Incoming seed delivery from: %s', remotePeerId);
1160
-
1161
- if (this.activeStreams >= this.maxConcurrentSeeds) {
1162
- log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
1163
- await replyAndClose(stream, { accepted: false, reason: 'Too many concurrent seed deliveries' } satisfies SeedAckMessage, 'Seed');
1164
- return;
1165
- }
1166
-
1167
- this.activeStreams++;
1168
- let acked = false;
1169
- try {
1170
- const seed = await this.readSeedFrame(stream);
1171
- this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
1172
-
1173
- const merged = await this.verifyAndMergeSeed(seed);
1174
- acked = true;
1175
- await replyAndClose(stream, { accepted: merged.success, reason: merged.error } satisfies SeedAckMessage, 'Seed');
1176
-
1177
- if (merged.success) {
1178
- await this.dialSeedOwners(seed);
1179
- this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
1180
- } else {
1181
- this.eventCallbacks.onSeedError?.(seed.partyId, merged.error ?? 'Unknown error');
1182
- }
1183
- } catch (error) {
1184
- log('Error handling seed delivery: %o', error);
1185
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1186
- this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
1187
- // Once acked the stream is closed, so a later failure has no reply to make.
1188
- if (!acked) {
1189
- await replyAndClose(stream, { accepted: false, reason: errorMessage } satisfies SeedAckMessage, 'Seed');
1190
- }
1191
- } finally {
1192
- this.activeStreams--;
1193
- }
1194
- }
1195
-
1196
- /** Read the inbound seed frame to EOF (bounded and size-capped) and decode it. */
1197
- private async readSeedFrame(stream: ControlStream): Promise<ControlNetworkSeed> {
1198
- const data = await readStreamToEnd(stream, {
1199
- maxBytes: MAX_SEED_SIZE,
1200
- timeoutMs: this.seedReadTimeoutMs,
1201
- label: 'Seed',
1202
- });
1203
- const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data))) as SeedMessage;
1204
- return {
1205
- partyId: message.partyId,
1206
- peers: message.peers,
1207
- signature: message.signature,
1208
- signerKey: message.signerKey,
1209
- };
1210
- }
1211
-
1212
- /**
1213
- * Shutdown the service.
1214
- */
1215
- async shutdown(): Promise<void> {
1216
- if (this.libp2pNode) {
1217
- await this.libp2pNode.unhandle(SEED_PROTOCOL);
1218
- }
1219
- this.libp2pNode = null;
1220
- this.controlDatabase = null;
1221
- log('SeedBootstrapService shutdown');
1222
- }
1223
-
1224
- // ============================================================================
1225
- // Helper Functions for Common Scenarios
1226
- // ============================================================================
1227
-
1228
- /**
1229
- * Add a drone to the cadre (phone/server adds provider-hosted node).
1230
- *
1231
- * Use this when you've spawned a drone via provider API and received its
1232
- * peer ID and multiaddrs. This method:
1233
- * 1. Authorizes the drone peer
1234
- * 2. Creates a seed including all current peers
1235
- * 3. Returns the seed for sending to provider API
1236
- *
1237
- * Nothing here dials the drone or remembers its addresses beyond the unsigned
1238
- * `CadrePeer` row, which no resolver accepts. `CadreNode.addDrone` is the entry
1239
- * point that also retains them as a durable dial target, so the adder's
1240
- * reconcile pass can open the connection — the drone cannot dial an owner that
1241
- * does not listen. Call `CadreNode.reconcileControlCohort()` after delivering
1242
- * the seed to dial at once.
1243
- *
1244
- * @param options - Drone peer info from provider API
1245
- * @returns Seed and encoded seed for drone initialization
1246
- */
1247
- async addDrone(options: AddDroneOptions): Promise<DroneInitResult> {
1248
- const { dronePeerId, droneMultiaddrs } = options;
1249
-
1250
- log('Adding drone: %s', dronePeerId);
1251
-
1252
- // 1. Authorize the new drone peer
1253
- await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs });
1254
-
1255
- // 2. Create seed with current state
1256
- const seed = await this.createSeed();
1257
-
1258
- // 3. Encode for transport
1259
- const encodedSeed = this.encodeSeed(seed);
1260
-
1261
- log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
1262
-
1263
- return { seed, encodedSeed };
1264
- }
1265
-
1266
- /**
1267
- * Create an invite for a phone to join the cadre.
1268
- *
1269
- * Use this when a server (public IP) wants to invite a phone (NAT'd).
1270
- * The invite is shared out-of-band (QR code, link, etc.) and contains
1271
- * the server's address so the phone can dial in.
1272
- *
1273
- * @param token - Optional invite token for validation
1274
- * @param expiresIn - Optional expiration time in milliseconds
1275
- * @returns Invite and encoded invite for sharing
1276
- */
1277
- async createInvite(token?: string, expiresIn?: number): Promise<InviteResult> {
1278
- if (!this.libp2pNode) {
1279
- throw new Error('Service not initialized');
1280
- }
1281
-
1282
- log('Creating invite for phone');
1283
-
1284
- // Get this node's dialable addresses. When an inviteAddressResolver is
1285
- // configured (typically by `@serfab/cadre-host`'s NatService), it takes
1286
- // priority — it may substitute a DDNS hostname and externally-mapped
1287
- // port for the raw LAN multiaddrs libp2p reports.
1288
- let ownerAddrs: string[];
1289
- if (this.config.inviteAddressResolver) {
1290
- try {
1291
- ownerAddrs = await this.config.inviteAddressResolver();
1292
- } catch (err) {
1293
- log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
1294
- ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
1295
- }
1296
- } else {
1297
- ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
1298
- }
1299
-
1300
- // Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
1301
- // the trusted owner set before applying any seed.
1302
- //
1303
- // Sourced ONLY from this node's own anchor, never from the replicated
1304
- // OwnerKey table: the invitee anchors whatever arrives here
1305
- // (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
1306
- // pollutable table would let a stranger's genesis-inserted key ride an
1307
- // otherwise-legitimate invite straight into the new node's anchor —
1308
- // poisoning the very store this whole trust chain rests on. No anchor wired
1309
- // (a directly-constructed service) means no pins to hand out: an invite
1310
- // without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
1311
- // table-sourced one silently hands it an unanchored key.
1312
- const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
1313
-
1314
- const now = Date.now();
1315
- const invite: CadreInvite = {
1316
- partyId: this.config.partyId,
1317
- ownerAddrs,
1318
- ownerKeys: ownerKeys.length ? ownerKeys : undefined,
1319
- token,
1320
- createdAt: now,
1321
- expiresAt: expiresIn ? now + expiresIn : undefined,
1322
- };
1323
-
1324
- const encodedInvite = this.encodeInvite(invite);
1325
-
1326
- log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
1327
-
1328
- return { invite, encodedInvite };
1329
- }
1330
-
1331
- /**
1332
- * Accept a phone connection using an invite.
1333
- *
1334
- * Use this when a phone dials in with an invite token. This method:
1335
- * 1. Validates the token if provided
1336
- * 2. Authorizes the phone peer
1337
- *
1338
- * After this, the phone can sync the control database normally.
1339
- *
1340
- * @param options - Phone peer info and invite token
1341
- * @param issuedInvite - The original invite for validation
1342
- */
1343
- async acceptPhone(options: AddPhoneOptions, issuedInvite?: CadreInvite): Promise<void> {
1344
- const { phonePeerId, token } = options;
1345
-
1346
- log('Accepting phone: %s', phonePeerId);
1347
-
1348
- // Validate token if invite provided
1349
- if (issuedInvite) {
1350
- if (issuedInvite.token && issuedInvite.token !== token) {
1351
- throw new Error('Invalid invite token');
1352
- }
1353
- if (issuedInvite.expiresAt && Date.now() > issuedInvite.expiresAt) {
1354
- throw new Error('Invite has expired');
1355
- }
1356
- }
1357
-
1358
- // Authorize the phone peer (no multiaddrs - phone is NAT'd)
1359
- await this.authorizePeer({ peerId: phonePeerId });
1360
-
1361
- log('Phone %s accepted and authorized', phonePeerId);
1362
- }
1363
-
1364
- /**
1365
- * Add a phone to the cadre with relay support.
1366
- *
1367
- * Use this when both nodes are NAT'd (phone-to-phone). This method:
1368
- * 1. Authorizes the new phone peer
1369
- * 2. Creates a seed with relay addresses for dialing
1370
- *
1371
- * @param phonePeerId - Peer ID of the new phone
1372
- * @returns Seed with relay addresses for out-of-band delivery
1373
- */
1374
- async addPhoneWithRelay(phonePeerId: string): Promise<DroneInitResult> {
1375
- log('Adding phone with relay: %s', phonePeerId);
1376
-
1377
- // 1. Authorize the new phone peer (no multiaddrs - NAT'd)
1378
- await this.authorizePeer({ peerId: phonePeerId });
1379
-
1380
- // 2. Get relay address for this node
1381
- const relayAddr = await this.getRelayAddress();
1382
-
1383
- // 3. Create seed - will include our relay address if available
1384
- const seed = await this.createSeed();
1385
-
1386
- // If we have a relay address, make sure it's in our peer entry
1387
- if (relayAddr && this.libp2pNode) {
1388
- const ourPeerId = this.libp2pNode.peerId.toString();
1389
- const ourPeer = seed.peers.find(p => p.peerId === ourPeerId);
1390
- if (ourPeer && !ourPeer.multiaddrs.includes(relayAddr)) {
1391
- ourPeer.multiaddrs.push(relayAddr);
1392
- }
1393
- }
1394
-
1395
- const encodedSeed = this.encodeSeed(seed);
1396
-
1397
- log('Phone %s added with relay, seed created', phonePeerId);
1398
-
1399
- return { seed, encodedSeed };
1400
- }
1401
-
1402
- /**
1403
- * Encode an invite for out-of-band delivery.
1404
- */
1405
- encodeInvite(invite: CadreInvite): string {
1406
- const json = JSON.stringify(invite);
1407
- return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
1408
- }
1409
-
1410
- /**
1411
- * Decode an invite from base64url encoding.
1412
- */
1413
- decodeInvite(encoded: string): CadreInvite {
1414
- const bytes = uint8ArrayFromString(encoded, 'base64url');
1415
- const json = new TextDecoder().decode(bytes);
1416
- return JSON.parse(json) as CadreInvite;
1417
- }
1418
-
1419
- /**
1420
- * Dial an owner from an invite.
1421
- * Use this on a phone after receiving an invite to connect to the owner.
1422
- *
1423
- * @param invite - The invite received out-of-band
1424
- * @returns Connection to the owner
1425
- */
1426
- async dialInvite(invite: CadreInvite): Promise<void> {
1427
- if (!this.libp2pNode) {
1428
- throw new Error('Service not initialized');
1429
- }
1430
-
1431
- // Check expiration
1432
- if (invite.expiresAt && Date.now() > invite.expiresAt) {
1433
- throw new Error('Invite has expired');
1434
- }
1435
-
1436
- log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
1437
-
1438
- const addrs = parseDialAddrs(invite.ownerAddrs);
1439
- if (addrs.length === 0) {
1440
- throw new Error('No owner addresses available');
1441
- }
1442
- // Each address on its own time limit, so one that never answers cannot hold
1443
- // the invitee back from the rest (`dialPeerAddrs`).
1444
- const connection = await dialPeerAddrs(this.libp2pNode, addrs, this.dialBudget, 'Invite owner dial');
1445
- log('Connected to owner at: %s', connection.remoteAddr.toString());
1446
- }
1447
- }
1448
-
1
+ import debug from 'debug';
2
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
+ import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
+ import type { Libp2p, Connection, PeerId, Stream } from '@libp2p/interface';
5
+ import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
6
+ import { peerIdFromString } from '@libp2p/peer-id';
7
+ import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
8
+ import {
9
+ dialPeerAddrs,
10
+ SelfRelayOnlyError,
11
+ PeerUnreachableError,
12
+ DEFAULT_PEER_DIAL_BUDGET,
13
+ type PeerDialBudget,
14
+ withTrailingPeerId,
15
+ } from './peer-dial.js';
16
+ import { relayedRequestBudgetMs } from './link-budget.js';
17
+ import type {
18
+ ControlNetworkSeed,
19
+ SeedPeer,
20
+ SeedMessage,
21
+ SeedAckMessage,
22
+ SeedRefusalCode,
23
+ SeedDeliveryTarget,
24
+ AuthorizePeerOptions,
25
+ ApplySeedResult,
26
+ AddDroneOptions,
27
+ DroneInitResult,
28
+ CadreInviteRow,
29
+ NodeProfile,
30
+ PeerAddressRecord,
31
+ DeviceTokenRecord,
32
+ RevocationRow,
33
+ RevocationLedgerOpenResult
34
+ } from './types.js';
35
+ import type { ControlDatabase, MachineRemovalWrites } from './control-database.js';
36
+ import { generateStampId } from './control-database.js';
37
+ import { canonicalJson } from './canonical-json.js';
38
+ import { deviceTokenAddDigest } from './peer-authorization.js';
39
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
40
+ import {
41
+ type SeedTrustPolicy,
42
+ type SeedTrustDecision,
43
+ anchoredTrustPolicy,
44
+ } from './seed-trust-policy.js';
45
+ import type { TrustedOwnerStore } from './trusted-owner-store.js';
46
+
47
+ const log = debug('sereus:cadre:seed-bootstrap');
48
+
49
+ /** Protocol ID for seed delivery */
50
+ export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
51
+
52
+ /** Maximum seed message size (1MB) */
53
+ const MAX_SEED_SIZE = 1024 * 1024;
54
+
55
+ /**
56
+ * Default time the receiver waits for an inbound seed frame before aborting (ms). It covers the
57
+ * read only: the trust decision and the peer-store merge run after it.
58
+ */
59
+ // eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one seed frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
60
+ const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
61
+
62
+ /** Default cap on concurrent inbound seed streams a single peer can pin open. */
63
+ const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
64
+
65
+ /**
66
+ * Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
67
+ *
68
+ * Guards every parse site against malformed input: a buffer too short to hold
69
+ * the prefix, a declared length exceeding `maxLength`, and a declared length
70
+ * exceeding the bytes actually present. Returns a view (`subarray`, no copy) —
71
+ * the body is handed straight to `TextDecoder`.
72
+ */
73
+ export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED_SIZE): Uint8Array {
74
+ if (data.length < 4) {
75
+ throw new Error(`Seed frame too short: ${data.length} bytes, need ≥4 for length prefix`);
76
+ }
77
+ // Pass the full (buffer, byteOffset, byteLength) triple so the read is correct
78
+ // even for a non-zero-offset view, not just the fresh zero-offset arrays
79
+ // current callers pass.
80
+ const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0, false);
81
+ const available = data.length - 4;
82
+ if (length > maxLength) {
83
+ throw new Error(`Seed frame declares length ${length} exceeding max ${maxLength}`);
84
+ }
85
+ if (length > available) {
86
+ throw new Error(`Seed frame declares length ${length} but only ${available} body bytes present`);
87
+ }
88
+ return data.subarray(4, 4 + length);
89
+ }
90
+
91
+ /**
92
+ * Canonical byte representation of the authenticated seed fields.
93
+ *
94
+ * Routes both the creator (`createSeed`) and the verifier
95
+ * (`validateSeedSignature`) through one builder so the signed bytes are
96
+ * identical regardless of key insertion order. `canonicalJson` sorts keys and
97
+ * drops `undefined`, so the signed payload is exactly `{ partyId, peers }` —
98
+ * the fields the producer actually emits.
99
+ */
100
+ export function canonicalSeedPayload(
101
+ seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>
102
+ ): string {
103
+ return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
104
+ }
105
+
106
+ /**
107
+ * The digest a seed's signature covers: sha256 over {@link canonicalSeedPayload},
108
+ * base64url. One function so the signer (`createSeed`), the verifier
109
+ * (`validateSeedSignature`) and the claim proof (`claim-proof.ts`, which binds a
110
+ * proof to one seed through this value) all name the same bytes.
111
+ */
112
+ export function seedDigest(seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>): string {
113
+ return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url') as string;
114
+ }
115
+
116
+ /**
117
+ * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
118
+ * so the dial counters read zero rather than being absent.
119
+ */
120
+ function seedRejected(error: string, code?: SeedRefusalCode): ApplySeedResult {
121
+ return {
122
+ success: false,
123
+ peersAdded: 0,
124
+ error,
125
+ ...(code !== undefined ? { code } : {}),
126
+ ownerDialsAttempted: 0,
127
+ ownerDialsFailed: 0,
128
+ };
129
+ }
130
+
131
+ /** What rides beside a seed into {@link SeedBootstrapService.verifyAndMergeSeed}. */
132
+ interface SeedApplyOptions {
133
+ /** Per-call policy override, used instead of the service-configured default for this seed only. */
134
+ trustPolicy?: SeedTrustPolicy;
135
+ /** The claim proof the message carried, when the seed came over the wire. */
136
+ claimProof?: string;
137
+ /** The delivering peer, when the seed came over the wire. */
138
+ remotePeerId?: string;
139
+ }
140
+
141
+ /** What dialing a seed's owner peers produced — the owner-dial half of an {@link ApplySeedResult}. */
142
+ type OwnerDialCounts = Pick<ApplySeedResult, 'ownerDialsAttempted' | 'ownerDialsFailed'>;
143
+
144
+ /**
145
+ * Parse each address, dropping (and logging) any that is malformed, so one bad
146
+ * entry cannot keep a peer's valid addresses from being dialed.
147
+ */
148
+ function parseDialAddrs(addrs: readonly string[]): Multiaddr[] {
149
+ const parsed: Multiaddr[] = [];
150
+ for (const addr of addrs) {
151
+ try {
152
+ parsed.push(multiaddr(addr));
153
+ } catch (error) {
154
+ log('Skipping malformed dial address %s: %o', addr, error);
155
+ }
156
+ }
157
+ return parsed;
158
+ }
159
+
160
+ /**
161
+ * Merge a peer list's addresses into `node`'s peer store, so the node can dial them: the
162
+ * seed's peers after its signature and trust checks, the dial hints a member returns on
163
+ * a cadre invitation redemption (`cadre-invite-protocol.ts`), or the node a seed is being
164
+ * delivered to ({@link SeedBootstrapService.deliverSeed}). Best-effort per peer — one
165
+ * unparsable entry costs that peer, not the rest — and a peer with no address is skipped,
166
+ * since there is nothing to dial. Returns how many peers were merged.
167
+ */
168
+ export async function mergeSeedPeers(node: Libp2p, peers: readonly SeedPeer[]): Promise<number> {
169
+ let peersAdded = 0;
170
+ for (const peer of peers) {
171
+ if (peer.multiaddrs.length === 0) {
172
+ continue;
173
+ }
174
+ try {
175
+ const peerId = peerIdFromString(peer.peerId);
176
+ const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
177
+ await node.peerStore.merge(peerId, { multiaddrs: addrs });
178
+ peersAdded++;
179
+ log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
180
+ } catch (error) {
181
+ log('Failed to add peer %s: %o', peer.peerId, error);
182
+ }
183
+ }
184
+ return peersAdded;
185
+ }
186
+
187
+ /** Opens a seed delivery's stream under the delivery deadline's signal. */
188
+ type SeedStreamOpener = (signal: AbortSignal) => Promise<Stream>;
189
+
190
+ /**
191
+ * A delivery target's addresses, each bound to its peer id (`withTrailingPeerId`) so the dial
192
+ * authenticates the node it aims at rather than trusting whoever answers. An address that does
193
+ * not parse, or that names another peer, throws: that is the caller's mistake, not the node
194
+ * being unreachable.
195
+ */
196
+ function deliveryDialAddrs(peerId: string, addrs: readonly string[]): Multiaddr[] {
197
+ return addrs.map((text) => {
198
+ const bound = withTrailingPeerId(multiaddr(text), peerId);
199
+ if (!bound) {
200
+ throw new Error(`Seed delivery to ${peerId}: address ${text} names another peer`);
201
+ }
202
+ return bound;
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Configuration for the SeedBootstrapService
208
+ */
209
+ export interface SeedBootstrapConfig {
210
+ /** Party ID for this cadre */
211
+ partyId: string;
212
+ /** Owner private key for signing seeds and peer authorizations (base64url) */
213
+ ownerPrivateKey?: string;
214
+ /** Owner public key (base64url) - derived from private key if not provided */
215
+ ownerPublicKey?: string;
216
+ /**
217
+ * Trust anchor for incoming seeds. Decides whether a signature-verified
218
+ * `signerKey` should be trusted, against the receiver's anchored owner
219
+ * keys (NOT the seed body). Defaults to `anchoredTrustPolicy()`, which
220
+ * rejects any signer not already in {@link trustedOwners}. An enrollment
221
+ * caller can pass a per-seed override to `applySeed` instead.
222
+ *
223
+ * A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
224
+ * — that is the only seam the inbound libp2p seed-protocol handler can use,
225
+ * since a network-delivered seed has no per-call override.
226
+ */
227
+ trustPolicy?: SeedTrustPolicy;
228
+ /**
229
+ * The node-local, NON-replicated trusted-owner anchor. Supplies
230
+ * `SeedTrustContext.knownOwnerKeys` for every {@link applySeed}, and receives
231
+ * a key accepted via a pin/TOFU (see `SeedTrustDecision.anchorAs`).
232
+ *
233
+ * Deliberately NOT `ControlDatabase.getOwnerKeys()`: the replicated
234
+ * `OwnerKey` table is pollutable — any connecting node can genesis-insert its
235
+ * own key and let it replicate — so a seed signed by a stranger's self-issued
236
+ * owner key would pass a table-anchored check. A `CadreNode` passes its
237
+ * `getTrustedOwnerStore()` here. Unset (e.g. a directly-constructed service in
238
+ * a test) means an EMPTY anchor: only a pinned/TOFU policy can accept a seed.
239
+ */
240
+ trustedOwners?: TrustedOwnerStore;
241
+ /**
242
+ * Time the inbound seed handler waits for the seed frame before aborting the
243
+ * read (ms). Defaults to {@link DEFAULT_SEED_READ_TIMEOUT_MS}. Bounds a
244
+ * buggy/compromised own-cadre node that opens a stream and never half-closes.
245
+ */
246
+ seedReadTimeoutMs?: number;
247
+ /**
248
+ * Cap on concurrent inbound seed streams (defaults to
249
+ * {@link DEFAULT_MAX_CONCURRENT_SEEDS}). Over the cap, a non-accepting ack is
250
+ * returned without applying any seed.
251
+ */
252
+ maxConcurrentSeeds?: number;
253
+ /**
254
+ * The host's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
255
+ * {@link seedDeliverTimeoutMs}'s default is derived. Unset means the declared default.
256
+ */
257
+ linkRoundTripMs?: number;
258
+ /**
259
+ * Time {@link SeedBootstrapService.deliverSeed} waits for the request — opening the
260
+ * stream, write, ack read — before aborting (ms). For a multiaddr string, or a peer id
261
+ * with no addresses, opening the stream includes the dial; for a peer id with addresses
262
+ * the connection is formed first, outside this limit and within {@link dialBudget}.
263
+ * Bounds the SENDER against a seed target that accepts the stream and then never replies;
264
+ * the target is a not-yet-trusted node during onboarding, so this is the more exposed
265
+ * direction than the receiver knobs above.
266
+ *
267
+ * Defaults to `relayedRequestBudgetMs(linkRoundTripMs)` (`link-budget.ts`; 28.5 s at the
268
+ * default declaration): one dial that may need a relay, then one request and its answer —
269
+ * sized for the forms that dial inside it. Delivery does not set `runOnLimitedConnection`,
270
+ * so it does not use a limited relayed connection today; the relayed-dial count is the upper
271
+ * bound on the dial it can use, the same choice `CadreNode.controlDialBudget` makes for every
272
+ * address. It holds only link work because the receiver acks before its owner dials.
273
+ *
274
+ * NOTE: no transfer allowance — a seed is a peer list of a few KB, and `MAX_SEED_SIZE` (1 MiB)
275
+ * is a defensive cap. If seeds ever grow toward that cap, add an allowance the way
276
+ * `PUSH_TRANSFER_ALLOWANCE_MS` does.
277
+ */
278
+ seedDeliverTimeoutMs?: number;
279
+ /**
280
+ * Time limits for each peer this service dials from a list of addresses —
281
+ * {@link SeedBootstrapService.applySeed}'s owner dials and
282
+ * {@link SeedBootstrapService.deliverSeed}'s dial of a peer id with addresses — per
283
+ * address and per peer (see `peer-dial.ts`). Defaults to {@link DEFAULT_PEER_DIAL_BUDGET}; a `CadreNode`
284
+ * passes its `network.controlCohort` limits.
285
+ */
286
+ dialBudget?: PeerDialBudget;
287
+ }
288
+
289
+ /**
290
+ * Event callbacks for seed-related events
291
+ */
292
+ export interface SeedEventCallbacks {
293
+ /** Called when a seed is received via the protocol */
294
+ onSeedReceived?: (partyId: string, peerId: string) => void;
295
+ /**
296
+ * Called when a seed is successfully applied.
297
+ *
298
+ * `seed` is the applied seed itself: the inbound protocol handler applies it
299
+ * INSIDE the service, so this callback is the only seam through which a
300
+ * `CadreNode` sees a wire-delivered seed's contents — which it needs to
301
+ * retain the owner-flagged peers as cold-start bootstrap dial targets.
302
+ */
303
+ onSeedApplied?: (partyId: string, peersAdded: number, seed: ControlNetworkSeed) => void;
304
+ /** Called when seed application fails */
305
+ onSeedError?: (partyId: string, error: string) => void;
306
+ }
307
+
308
+ /**
309
+ * SeedBootstrapService handles control network seed generation and delivery.
310
+ *
311
+ * Seeds solve the cold-start problem: new nodes need control data to validate
312
+ * connections, but can't get data without connecting first. Seeds pre-populate
313
+ * the new node's cache with peer information.
314
+ */
315
+ export class SeedBootstrapService {
316
+ private readonly config: SeedBootstrapConfig;
317
+ private libp2pNode: Libp2p | null = null;
318
+ private controlDatabase: ControlDatabase | null = null;
319
+ private readonly ownerPublicKey: string | null;
320
+ private readonly trustPolicy: SeedTrustPolicy;
321
+ private readonly seedReadTimeoutMs: number;
322
+ private readonly maxConcurrentSeeds: number;
323
+ private readonly seedDeliverTimeoutMs: number;
324
+ private readonly dialBudget: PeerDialBudget;
325
+ /** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
326
+ private activeStreams = 0;
327
+ private eventCallbacks: SeedEventCallbacks = {};
328
+
329
+ constructor(config: SeedBootstrapConfig) {
330
+ this.config = config;
331
+ this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
332
+ this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
333
+ this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
334
+ this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
335
+ this.dialBudget = config.dialBudget ?? DEFAULT_PEER_DIAL_BUDGET;
336
+
337
+ // Derive public key from private key if not provided
338
+ if (config.ownerPrivateKey && !config.ownerPublicKey) {
339
+ this.ownerPublicKey = getPublicKey(
340
+ config.ownerPrivateKey,
341
+ 'ed25519',
342
+ 'base64url',
343
+ 'base64url'
344
+ ) as string;
345
+ } else {
346
+ this.ownerPublicKey = config.ownerPublicKey ?? null;
347
+ }
348
+
349
+ log('SeedBootstrapService created for party: %s', config.partyId);
350
+ }
351
+
352
+ /**
353
+ * Set event callbacks for seed-related events.
354
+ * Used by CadreNode to emit events.
355
+ */
356
+ setEventCallbacks(callbacks: SeedEventCallbacks): void {
357
+ this.eventCallbacks = callbacks;
358
+ }
359
+
360
+ /**
361
+ * Whether this service holds an owner private key, i.e. can produce the
362
+ * owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
363
+ * and re-authorizations. A seed-listener-only service (`enableSeedListener`,
364
+ * no owner key) returns false: it can receive/apply seeds but cannot author
365
+ * or re-issue owner writes. Used by the write-while-alone re-replication
366
+ * drain to skip owner work on a non-owner node.
367
+ */
368
+ canAuthorize(): boolean {
369
+ return !!this.config.ownerPrivateKey;
370
+ }
371
+
372
+ /**
373
+ * Initialize the service with libp2p node and control database.
374
+ *
375
+ * `registerHandler` (default true) gates registration of the shared inbound
376
+ * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
377
+ * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
378
+ * it on. The throwaway temp service CadreNode builds in `applySeed` passes
379
+ * `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
380
+ * dialing and known-key lookup, and must NOT bind a discarded closure to the
381
+ * shared node (a handler leak, and a second
382
+ * `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
383
+ *
384
+ * Rejects when libp2p refuses the registration; the node and database are
385
+ * kept only once the handler is in place, so a failed service holds nothing
386
+ * for {@link shutdown} to unhandle.
387
+ */
388
+ async initialize(
389
+ libp2pNode: Libp2p,
390
+ controlDatabase: ControlDatabase,
391
+ options?: { registerHandler?: boolean }
392
+ ): Promise<void> {
393
+ // Register the seed protocol handler unless the caller opted out (temp services).
394
+ if (options?.registerHandler ?? true) {
395
+ await this.registerProtocolHandler(libp2pNode);
396
+ }
397
+
398
+ this.libp2pNode = libp2pNode;
399
+ this.controlDatabase = controlDatabase;
400
+ log('SeedBootstrapService initialized');
401
+ }
402
+
403
+ /**
404
+ * Authorize a new peer to join the cadre.
405
+ * Signs a membership voucher with the owner key and inserts into CadrePeer table.
406
+ *
407
+ * The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
408
+ * caller-supplied key, the binding is enforced by construction — `PublicKey` is
409
+ * DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
410
+ * `PublicKey`, and such a row can never be self-updated (it has no key to
411
+ * verify against), which is correct. The row is inserted with a fresh
412
+ * `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
413
+ * the peer's self-signature, so the peer must self-publish (see
414
+ * {@link CadreNode.registerSelf}) before the row resolves.
415
+ */
416
+ async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
417
+ const { peerId, multiaddrs } = options;
418
+ log('Authorizing peer: %s', peerId);
419
+ // CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
420
+ const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
421
+ await this.insertCadrePeerRow({
422
+ peerId,
423
+ publicKey: ed25519PublicKeyB64FromPeerId(peerId),
424
+ multiaddr: multiaddrStr,
425
+ updatedAt: Date.now(),
426
+ sig: null,
427
+ profile: null,
428
+ });
429
+ log('Peer %s authorized successfully', peerId);
430
+ }
431
+
432
+ /**
433
+ * Owner-signed INSERT of this node's OWN self-signed address record.
434
+ *
435
+ * Used by {@link CadreNode.registerSelf} when the node is not yet a member and
436
+ * is its own owner (it holds the owner key): the row is owner-signed
437
+ * (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
438
+ * immediately without a follow-up self-update.
439
+ *
440
+ * @returns `true` when this call seated the row, `false` when a concurrent writer
441
+ * (e.g. an {@link authorizePeer} of this node's own id) had already seated it —
442
+ * in which case the row in the database is NOT this record and carries whatever
443
+ * `Sig` that writer had, so the caller must self-update to publish its signature.
444
+ */
445
+ async insertSelfPeerRecord(record: PeerAddressRecord): Promise<boolean> {
446
+ return await this.insertCadrePeerRow({
447
+ peerId: record.peerId,
448
+ publicKey: record.publicKey,
449
+ multiaddr: record.addrs.join(','),
450
+ updatedAt: record.updatedAt,
451
+ sig: record.sig,
452
+ profile: record.profile,
453
+ });
454
+ }
455
+
456
+ /**
457
+ * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
458
+ * precondition; the stamp mint, voucher signature, write lock, existence check and
459
+ * membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
460
+ * the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
461
+ * method: the write is what admits the peer's traffic.
462
+ *
463
+ * The DB check precedes the owner-key check here, matching the order this method has
464
+ * always surfaced them (the owner-key error used to come out of the signer, after the
465
+ * DB check).
466
+ *
467
+ * @returns `true` when this call performed the INSERT, `false` when the row was
468
+ * already seated by a concurrent writer. The loser needs to know: an authorize seats
469
+ * a row with a null `Sig`, so a self-publish that lost the race must fall through to
470
+ * a self-update or its record never lands.
471
+ */
472
+ private async insertCadrePeerRow(row: {
473
+ peerId: string;
474
+ publicKey: string | null;
475
+ multiaddr: string;
476
+ updatedAt: number;
477
+ sig: string | null;
478
+ profile: NodeProfile | null;
479
+ }): Promise<boolean> {
480
+ if (!this.controlDatabase) {
481
+ throw new Error('Control database not initialized');
482
+ }
483
+ const ownerKey = this.requireOwnerPublicKey();
484
+ return await this.controlDatabase.insertCadrePeer(
485
+ row, ownerKey, message => this.signMessageBytes(message),
486
+ );
487
+ }
488
+
489
+ /**
490
+ * Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
491
+ *
492
+ * Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
493
+ * row is owner-signed (satisfying `DeviceToken.AuthorizedInsert` via the
494
+ * 'add'-tagged digest, {@link deviceTokenAddDigest}) AND carries the peer's
495
+ * own self-`Sig` over the token payload. The owner signature covers the WHOLE row
496
+ * (every column, ending in a freshly minted single-use `StampId`) — but covering the
497
+ * token contents is not the same as vouching them: the peer's `Sig` (verified at
498
+ * resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
499
+ * resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
500
+ * when the node is its own owner.
501
+ *
502
+ * The stamp is per-INSERT, so a re-register after a clear mints a fresh one and a
503
+ * fresh signature — unaffected by the cleared row's retired stamp
504
+ * (`DeviceToken.NotRevoked`).
505
+ */
506
+ async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
507
+ const stampId = generateStampId(record.peerId);
508
+ const signature = this.signDigest(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }));
509
+ if (!this.controlDatabase) {
510
+ throw new Error('Control database not initialized');
511
+ }
512
+ await this.controlDatabase.execWrite(`
513
+ insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
514
+ with context OwnerKey = ?, Signature = ?
515
+ values (?, ?, ?, ?, ?, ?)
516
+ `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
517
+ log('Device token inserted (owner-signed): %s', record.peerId);
518
+ }
519
+
520
+ /**
521
+ * Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
522
+ * invalidation). The `DeviceToken.AuthorizedDelete` constraint validates an owner
523
+ * signature over the 'remove'-tagged digest bound to the STORED row's
524
+ * (PeerId, StampId) — deliberately distinct from the insert digest, so a captured
525
+ * insert approval can never be replayed to clear a token. Like {@link removePeer}
526
+ * for `CadrePeer`, clearing a token requires the owner key.
527
+ *
528
+ * The delete and the `Revocation` tombstone retiring the row's `StampId` commit in
529
+ * ONE transaction — `DeviceToken.RevocationRecorded` refuses a bare delete, and
530
+ * without the tombstone the stamp would free up and the never-expiring insert
531
+ * approval (which the cleared device holds a copy of) would re-seat the token. The
532
+ * tombstone carries its OWN owner signature: retiring a stamp permanently forecloses
533
+ * that row, so it is an owner action in its own right.
534
+ *
535
+ * Both digests, the stamp read, and the transaction come from
536
+ * {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
537
+ * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
538
+ * owner-key precondition; a no-op on an already-absent row is the shared body's
539
+ * behavior, and unlike {@link removePeer} nothing here rides on it.
540
+ */
541
+ async deleteDeviceToken(peerId: string): Promise<void> {
542
+ // Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
543
+ // owner-key error rather than a silent no-op on an absent row.
544
+ const ownerKey = this.requireOwnerPublicKey();
545
+ if (!this.controlDatabase) {
546
+ throw new Error('Control database not initialized');
547
+ }
548
+ await this.controlDatabase.deleteDeviceToken(
549
+ peerId, ownerKey, message => this.signMessageBytes(message),
550
+ );
551
+ }
552
+
553
+ /**
554
+ * Return the configured owner private key, or throw if none is set. The single
555
+ * precondition gate for every owner-signed write. {@link removePeer} /
556
+ * {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
557
+ * they call this up front — otherwise a keyless service would either surface the
558
+ * wrong "Control database not initialized" error or, worse, silently no-op when the
559
+ * target row is absent (the early `stampId === null` return) instead of rejecting.
560
+ */
561
+ private requireOwnerPrivateKey(): string {
562
+ if (!this.config.ownerPrivateKey) {
563
+ throw new Error('Owner private key required to authorize peers');
564
+ }
565
+ return this.config.ownerPrivateKey;
566
+ }
567
+
568
+ /**
569
+ * The owner PUBLIC key that rides in every owner-signed write's context, non-null.
570
+ *
571
+ * The field is nullable because a read-only service carries no owner key at all, but the
572
+ * constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
573
+ * {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
574
+ * unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
575
+ * their first line and get the owner-key precondition for free.
576
+ */
577
+ private requireOwnerPublicKey(): string {
578
+ this.requireOwnerPrivateKey();
579
+ if (!this.ownerPublicKey) {
580
+ throw new Error('Owner public key required to authorize peers');
581
+ }
582
+ return this.ownerPublicKey;
583
+ }
584
+
585
+ /**
586
+ * Sign a base64url digest with the owner key (ed25519). The single place the
587
+ * owner private key is applied; callers pass the canonical domain-tagged digest for
588
+ * the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
589
+ * {@link signMessageBytes}. Throws if no owner key is set.
590
+ */
591
+ private signDigest(digestB64url: string): string {
592
+ return sign(
593
+ digestB64url,
594
+ this.requireOwnerPrivateKey(),
595
+ 'ed25519',
596
+ 'base64url',
597
+ 'base64url',
598
+ 'base64url'
599
+ ) as string;
600
+ }
601
+
602
+ /**
603
+ * Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
604
+ * takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
605
+ * same signed bytes (`sign` decodes its base64url input), so a signature minted here
606
+ * satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
607
+ * `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
608
+ */
609
+ private signMessageBytes(message: Uint8Array): string {
610
+ return this.signDigest(uint8ArrayToString(message, 'base64url'));
611
+ }
612
+
613
+ /**
614
+ * Remove a peer from the cadre by owner signature.
615
+ *
616
+ * The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
617
+ * signature over the DISTINCT 'remove'-tagged digest
618
+ * `digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId)` by an owner
619
+ * key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
620
+ * never be replayed to delete.
621
+ *
622
+ * The delete and the `Revocation` tombstone retiring the row's `StampId` commit in ONE
623
+ * transaction — `CadrePeer.RevocationRecorded` refuses a bare delete, and without the
624
+ * tombstone the stamp would free up and the original admission approval (which never
625
+ * expires, and which the removed peer holds a copy of) would re-seat the row.
626
+ *
627
+ * The tombstone is separately owner-signed (satisfying `Revocation.Authorized`):
628
+ * retiring a stamp evicts that peer party-wide and permanently forecloses re-admitting
629
+ * the row, so it is an owner action in its own right, not a side effect the delete's
630
+ * signature covers.
631
+ *
632
+ * Both digests, the stamp read, the transaction, and the post-commit membership notify
633
+ * come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
634
+ * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
635
+ * owner-key precondition and the absent-row gate the notify depends on (below).
636
+ */
637
+ async removePeer(peerId: string): Promise<void> {
638
+ // Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
639
+ // remove digest, and this precedence (owner key, then control DB) is what the
640
+ // unit contract asserts.
641
+ const ownerKey = this.requireOwnerPublicKey();
642
+ if (!this.controlDatabase) {
643
+ throw new Error('Control database not initialized');
644
+ }
645
+ // This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
646
+ // internally: deleteCadrePeer's membership notify fires whenever its body resolves,
647
+ // with no idea whether the body wrote anything, so delegating an absent peer would
648
+ // fire a spurious membership notification.
649
+ // NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
650
+ // between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
651
+ // window only — the common "already absent" case is caught here.
652
+ const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
653
+ if (stampId === null) {
654
+ log('removePeer: no CadrePeer row for %s (already absent)', peerId);
655
+ return;
656
+ }
657
+
658
+ log('Removing peer: %s', peerId);
659
+
660
+ await this.controlDatabase.deleteCadrePeer(
661
+ peerId, ownerKey, message => this.signMessageBytes(message),
662
+ );
663
+
664
+ log('Peer %s removed successfully (stamp retired)', peerId);
665
+ }
666
+
667
+ /**
668
+ * Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
669
+ * liveness check, the stamp, the signature and the membership notify are
670
+ * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
671
+ * precondition, checked before any read (see {@link removePeer}).
672
+ *
673
+ * @returns `true` when the row was seated, `false` when the key is already a live owner.
674
+ */
675
+ async addOwner(ownerPublicKey: string): Promise<boolean> {
676
+ const ownerKey = this.requireOwnerPublicKey();
677
+ if (!this.controlDatabase) {
678
+ throw new Error('Control database not initialized');
679
+ }
680
+ return await this.controlDatabase.insertOwnerKeyVouched(
681
+ ownerPublicKey, ownerKey, message => this.signMessageBytes(message),
682
+ );
683
+ }
684
+
685
+ /**
686
+ * Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
687
+ * and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
688
+ * the last owner), the digests and the membership notify are
689
+ * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
690
+ * checked before any read (see {@link removePeer}).
691
+ *
692
+ * @returns `true` when the row was removed, `false` when the key is not a live owner here.
693
+ */
694
+ async removeOwner(ownerPublicKey: string): Promise<boolean> {
695
+ const ownerKey = this.requireOwnerPublicKey();
696
+ if (!this.controlDatabase) {
697
+ throw new Error('Control database not initialized');
698
+ }
699
+ return await this.controlDatabase.deleteOwnerKey(
700
+ ownerPublicKey, ownerKey, message => this.signMessageBytes(message),
701
+ );
702
+ }
703
+
704
+ /**
705
+ * Re-vouch another owner's row under this service's owner key, so it stands on a remaining
706
+ * owner's signature before the owner that added it is removed
707
+ * ({@link ControlDatabase.revouchOwnerKey}; for `CadreNode.removeMachine` only). What stays
708
+ * here is the owner-key precondition, checked before any read (see {@link removePeer}).
709
+ *
710
+ * @returns `true` when the row was re-vouched, `false` when the key is not a live owner here.
711
+ */
712
+ async revouchOwner(ownerPublicKey: string): Promise<boolean> {
713
+ const ownerKey = this.requireOwnerPublicKey();
714
+ if (!this.controlDatabase) {
715
+ throw new Error('Control database not initialized');
716
+ }
717
+ return await this.controlDatabase.revouchOwnerKey(
718
+ ownerPublicKey, ownerKey, message => this.signMessageBytes(message),
719
+ );
720
+ }
721
+
722
+ /**
723
+ * Remove a machine completely, signed by this service's owner key: its `CadrePeer` row, its
724
+ * `OwnerKey` row when `target.ownerKey` names one, its `DeviceToken` row, and the
725
+ * withdrawal of every invitation in `target.inviteKeys`, all in one transaction
726
+ * ({@link ControlDatabase.removeMachine}). What stays here is the owner-key precondition,
727
+ * checked before any read (see {@link removePeer}).
728
+ */
729
+ async removeMachine(target: Parameters<ControlDatabase['removeMachine']>[0]): Promise<MachineRemovalWrites> {
730
+ const ownerKey = this.requireOwnerPublicKey();
731
+ if (!this.controlDatabase) {
732
+ throw new Error('Control database not initialized');
733
+ }
734
+ return await this.controlDatabase.removeMachine(
735
+ target, ownerKey, message => this.signMessageBytes(message),
736
+ );
737
+ }
738
+
739
+ /**
740
+ * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
741
+ * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
742
+ * the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
743
+ * a membership row that committed local-only (its block's cluster ≤1 at insert) is
744
+ * pushed to the cohort once it grows.
745
+ *
746
+ * The stamp read, the voucher signature, the write lock and the membership notify are
747
+ * {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
748
+ * `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
749
+ *
750
+ * A no-op (no throw, no notify) when the row does not exist.
751
+ *
752
+ * @param peerId - the membership row to re-touch.
753
+ * @param updatedAt - the strictly-increasing freshness stamp to write.
754
+ * @throws if no owner private key is configured (a non-owner cannot
755
+ * re-sign another peer's row) or the control database is not initialized.
756
+ */
757
+ async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
758
+ // Fail fast on a keyless service before any DB read (see removePeer): a non-owner
759
+ // cannot re-sign the voucher, and must not silently no-op on an absent row.
760
+ const ownerKey = this.requireOwnerPublicKey();
761
+ if (!this.controlDatabase) {
762
+ throw new Error('Control database not initialized');
763
+ }
764
+ const retouched = await this.controlDatabase.reauthorizeCadrePeer(
765
+ peerId, updatedAt, ownerKey, message => this.signMessageBytes(message),
766
+ );
767
+ if (!retouched) {
768
+ log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
769
+ return;
770
+ }
771
+ log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
772
+ }
773
+
774
+ /**
775
+ * Owner re-issue of a batch of `Revocation` tombstones: bump each row's
776
+ * `ReissuedAt` so the storage layer re-broadcasts a tombstone that committed
777
+ * while the node was alone. The delete-while-alone counterpart of
778
+ * {@link reauthorizePeer} — a removed row cannot be re-touched (it is gone),
779
+ * but its tombstone can, and every membership read treats a retired stamp as
780
+ * absent.
781
+ *
782
+ * The signatures, the single transaction, and the strictly-monotonic
783
+ * `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
784
+ * stays here is the owner-key precondition.
785
+ *
786
+ * @returns how many tombstones were re-issued (`rows.length` on success).
787
+ * @throws if no owner private key is configured or the control database is not
788
+ * initialized.
789
+ */
790
+ async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number): Promise<number> {
791
+ // Fail fast on a keyless service before any DB work (see removePeer): a
792
+ // non-owner cannot sign the reissue digests.
793
+ const ownerKey = this.requireOwnerPublicKey();
794
+ if (!this.controlDatabase) {
795
+ throw new Error('Control database not initialized');
796
+ }
797
+ return this.controlDatabase.reissueRevocations(
798
+ rows, reissuedAt, ownerKey, message => this.signMessageBytes(message),
799
+ );
800
+ }
801
+
802
+ /**
803
+ * Owner filing of the singleton `Revocation` ledger marker, so the table is never a
804
+ * never-written block that the storage layer re-consults on every read.
805
+ *
806
+ * The signature, the insert-if-absent guard and the `'already-open'` mapping are
807
+ * {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-key
808
+ * precondition.
809
+ *
810
+ * @throws if no owner private key is configured or the control database is not
811
+ * initialized.
812
+ */
813
+ async openRevocationLedger(): Promise<RevocationLedgerOpenResult> {
814
+ // Fail fast on a keyless service before any DB work (see removePeer): a
815
+ // non-owner cannot sign the marker's append digest.
816
+ const ownerKey = this.requireOwnerPublicKey();
817
+ if (!this.controlDatabase) {
818
+ throw new Error('Control database not initialized');
819
+ }
820
+ return this.controlDatabase.openRevocationLedger(
821
+ ownerKey, message => this.signMessageBytes(message),
822
+ );
823
+ }
824
+
825
+ /**
826
+ * Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
827
+ * on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
828
+ * expiry and use-count defaults, and the bundle the holder receives are
829
+ * `CadreNode.createCadreInvitation`'s; what stays here is the owner-key precondition and
830
+ * the signature, as {@link insertSelfDeviceToken} keeps them for device tokens.
831
+ *
832
+ * @returns the row as stored, which the invitation bundle carries verbatim.
833
+ * @throws if no owner private key is configured or the control database is not initialized.
834
+ */
835
+ async insertCadreInvite(invite: Parameters<ControlDatabase['insertCadreInvite']>[0]): Promise<CadreInviteRow> {
836
+ // Fail fast on a keyless service before any DB work (see removePeer).
837
+ const ownerKey = this.requireOwnerPublicKey();
838
+ if (!this.controlDatabase) {
839
+ throw new Error('Control database not initialized');
840
+ }
841
+ return await this.controlDatabase.insertCadreInvite(invite, ownerKey, message => this.signMessageBytes(message));
842
+ }
843
+
844
+ /**
845
+ * Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
846
+ * the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
847
+ * owner-key precondition.
848
+ *
849
+ * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
850
+ * already withdrawn.
851
+ * @throws if no owner private key is configured or the control database is not initialized.
852
+ */
853
+ async withdrawCadreInvite(key: string): Promise<boolean> {
854
+ const ownerKey = this.requireOwnerPublicKey();
855
+ if (!this.controlDatabase) {
856
+ throw new Error('Control database not initialized');
857
+ }
858
+ return await this.controlDatabase.withdrawCadreInvite(key, ownerKey, message => this.signMessageBytes(message));
859
+ }
860
+
861
+ /**
862
+ * Create a seed from the current control network state.
863
+ * The seed contains peer information and is signed by an owner.
864
+ */
865
+ async createSeed(): Promise<ControlNetworkSeed> {
866
+ if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
867
+ throw new Error('Owner key required to create seeds');
868
+ }
869
+
870
+ if (!this.controlDatabase || !this.libp2pNode) {
871
+ throw new Error('Service not initialized');
872
+ }
873
+
874
+ log('Creating seed for party: %s', this.config.partyId);
875
+
876
+ // Query all peers from the control database
877
+ const peers = await this.queryPeers();
878
+
879
+ // Create the seed data (without signature)
880
+ const seedData = {
881
+ partyId: this.config.partyId,
882
+ peers,
883
+ };
884
+
885
+ // Sign the seed over its canonical byte representation
886
+ const signature = sign(
887
+ seedDigest(seedData),
888
+ this.config.ownerPrivateKey,
889
+ 'ed25519',
890
+ 'base64url',
891
+ 'base64url',
892
+ 'base64url'
893
+ ) as string;
894
+
895
+ const seed: ControlNetworkSeed = {
896
+ ...seedData,
897
+ signature,
898
+ signerKey: this.ownerPublicKey,
899
+ };
900
+
901
+ log('Created seed with %d peers', peers.length);
902
+ return seed;
903
+ }
904
+
905
+ /**
906
+ * Apply a seed to populate the peer cache and enable connections.
907
+ *
908
+ * Validates the seed signature, then evaluates a trust anchor for the
909
+ * `signerKey` that does NOT come from the seed body: the receiver's
910
+ * node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
911
+ * augmented by pinned keys or TOFU via the configured/overriding
912
+ * `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
913
+ * own signer as an owner peer — no longer passes, and neither does one signed
914
+ * by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
915
+ *
916
+ * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
917
+ * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
918
+ * anchored without re-supplying the pin.
919
+ *
920
+ * @param seed - The seed to apply (already transport-decoded).
921
+ * @param options.trustPolicy - Per-call policy override (e.g. a
922
+ * `pinnedKeyTrustPolicy` built from an operator pin) used instead of the
923
+ * service-configured default for this seed only.
924
+ */
925
+ async applySeed(
926
+ seed: ControlNetworkSeed,
927
+ options?: { trustPolicy?: SeedTrustPolicy }
928
+ ): Promise<ApplySeedResult> {
929
+ const merged = await this.verifyAndMergeSeed(seed, options);
930
+ if (!merged.success) {
931
+ return merged;
932
+ }
933
+ return { ...merged, ...await this.dialSeedOwners(seed) };
934
+ }
935
+
936
+ /**
937
+ * The first half of {@link applySeed}: check the signature and the signer's trust, then merge
938
+ * the seed's peer addresses into the peer store. Every rejection happens here, so this result is
939
+ * what the inbound handler acks with; the owner dials that follow cannot change it.
940
+ */
941
+ private async verifyAndMergeSeed(
942
+ seed: ControlNetworkSeed,
943
+ options?: SeedApplyOptions
944
+ ): Promise<ApplySeedResult> {
945
+ if (!this.libp2pNode) {
946
+ return seedRejected('Service not initialized');
947
+ }
948
+
949
+ // NOTE: `seed.partyId` is never checked against `config.partyId`. Trust is keyed on
950
+ // `signerKey` vs the anchor, and the anchor a stray-party seed could write into
951
+ // belongs to THIS party, which only a caller-supplied pin for that signer can reach.
952
+ // The one reader of the seed's party is the claim policy, which records it as the
953
+ // party the node now serves (`CadreNodeConfig.claim.record`): a mismatch is still not
954
+ // rejected there because an unclaimed node's own party is a placeholder, and the
955
+ // claim seed is the first thing to name the real one. If any other path ever
956
+ // branches on the seed's partyId (or the anchor becomes multi-party), reject a
957
+ // mismatch here instead.
958
+ log('Applying seed for party: %s', seed.partyId);
959
+
960
+ // One digest serves the signature check and the trust context: a claim proof is
961
+ // bound to the seed through the same value the signature covers.
962
+ const digestB64 = seedDigest(seed);
963
+ if (!this.validateSeedSignature(seed, digestB64)) {
964
+ return seedRejected('Invalid seed signature');
965
+ }
966
+
967
+ // Evaluate the trust anchor for the signer key. The known-owner set comes
968
+ // from the receiver's NODE-LOCAL anchor — never from the seed itself, and
969
+ // never from the replicated OwnerKey table (a stranger can genesis-insert
970
+ // its own key there and let it replicate into every peer's copy). A node
971
+ // whose anchor was never seeded, with no policy override, sees an empty set
972
+ // and rejects.
973
+ const knownOwnerKeys = this.config.trustedOwners?.all() ?? new Set<string>();
974
+ const policy = options?.trustPolicy ?? this.trustPolicy;
975
+ const decision = await policy.evaluate({
976
+ partyId: seed.partyId,
977
+ signerKey: seed.signerKey,
978
+ knownOwnerKeys,
979
+ // Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
980
+ // omit `peerId`. A real node always has one, and a proof bound to a real peer id can
981
+ // only fail against '' — the safe direction.
982
+ localPeerId: this.libp2pNode.peerId?.toString() ?? '',
983
+ seedDigest: digestB64,
984
+ claimProof: options?.claimProof,
985
+ remotePeerId: options?.remotePeerId,
986
+ });
987
+ if (!decision.trusted) {
988
+ return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy', decision.code);
989
+ }
990
+ await this.anchorAcceptedSigner(seed.signerKey, decision);
991
+
992
+ const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
993
+ log('Merged seed: %d peers added', peersAdded);
994
+ return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
995
+ }
996
+
997
+ /**
998
+ * The second half of {@link applySeed}: dial the seed's owner peers to establish connections.
999
+ *
1000
+ * Best-effort and COUNTED: an owner that is momentarily down leaves this node seeded but
1001
+ * unconnected, which the caller can only see if the outcome is reported (see
1002
+ * `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
1003
+ * `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
1004
+ * reconcile pass until the control database has siblings.
1005
+ *
1006
+ * The inbound handler runs this AFTER it has acked and closed the stream
1007
+ * ({@link handleSeedStream}), because an unreachable owner can take `dialBudget.totalMs`.
1008
+ */
1009
+ private async dialSeedOwners(seed: ControlNetworkSeed): Promise<OwnerDialCounts> {
1010
+ const node = this.libp2pNode;
1011
+ if (!node) {
1012
+ log('Seed service shut down before its owner dials; none attempted');
1013
+ return { ownerDialsAttempted: 0, ownerDialsFailed: 0 };
1014
+ }
1015
+ // `createSeed` projects every non-revoked CadrePeer row, so an owner applying
1016
+ // a seed minted after it joined finds ITSELF in the owner list. Dialing self always
1017
+ // throws, which would report a healthy owner as "seeded but stranded".
1018
+ // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
1019
+ // and an undefined self simply matches nothing.
1020
+ // Every one of an owner's addresses is a candidate, each on its own time limit
1021
+ // (`dialPeerAddrs`), so an owner whose first address never answers neither
1022
+ // stalls seed application nor goes undialed at its other addresses. The seed's
1023
+ // addresses carry no peer id; the dial binds each to the owner's.
1024
+ const selfPeerId = node.peerId?.toString();
1025
+ let ownerDialsAttempted = 0;
1026
+ let ownerDialsFailed = 0;
1027
+ for (const peer of seed.peers.filter(p => p.isOwner)) {
1028
+ if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
1029
+ continue;
1030
+ }
1031
+ ownerDialsAttempted++;
1032
+ try {
1033
+ const addrs = parseDialAddrs(peer.multiaddrs);
1034
+
1035
+ log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
1036
+ await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
1037
+ } catch (error) {
1038
+ // Counted even when the owner reaches us only through our own relay: this node is still
1039
+ // not connected to it, and `ownerDialsFailed` is how the caller learns that.
1040
+ ownerDialsFailed++;
1041
+ if (error instanceof SelfRelayOnlyError) {
1042
+ log('Owner peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
1043
+ } else {
1044
+ log('Failed to dial peer %s: %o', peer.peerId, error);
1045
+ }
1046
+ // Continue - not all peers need to be reachable
1047
+ }
1048
+ }
1049
+
1050
+ log('Dialed seed owners: %d/%d owner dial(s) failed', ownerDialsFailed, ownerDialsAttempted);
1051
+ return { ownerDialsAttempted, ownerDialsFailed };
1052
+ }
1053
+
1054
+ /**
1055
+ * Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
1056
+ * later seed from the same owner is anchored without re-supplying the pin
1057
+ * or re-prompting. Only the policy decides this happens (`anchorAs` is unset
1058
+ * when the key was already anchored, so a plain re-apply writes nothing) and
1059
+ * `trust()` is idempotent, keeping the original provenance for a known key.
1060
+ *
1061
+ * Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
1062
+ * in-memory anchor synchronously, so this seed and the rest of the session are
1063
+ * unaffected — only durability across a restart is lost, and that is logged.
1064
+ * That holds for pins and TOFU, which are re-supplied at the next start; a claim
1065
+ * is the opposite case, so `claimSecretTrustPolicy` anchors its signer itself,
1066
+ * awaits durability, and never sets `anchorAs` (the type excludes `'claim'`).
1067
+ *
1068
+ * NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
1069
+ * unauthorized to authorized, which the write-driven membership-gate refresh
1070
+ * (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. The anchor
1071
+ * is mutated from two places: here, on seed application, where both seed paths
1072
+ * refresh the gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`);
1073
+ * and by the anchor sync (`CadreNode.syncOwnerAnchor`), which runs INSIDE the
1074
+ * membership refresh, ahead of the membership read, so its changes are judged by
1075
+ * the same pass. If some future path anchors an owner outside both, it owes the
1076
+ * same `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
1077
+ */
1078
+ private async anchorAcceptedSigner(signerKey: string, decision: SeedTrustDecision): Promise<void> {
1079
+ if (!decision.anchorAs || !this.config.trustedOwners) {
1080
+ return;
1081
+ }
1082
+ try {
1083
+ await this.config.trustedOwners.trust(signerKey, decision.anchorAs);
1084
+ log('Anchored seed signer %s as %s', signerKey, decision.anchorAs);
1085
+ } catch (error) {
1086
+ log('Failed to persist accepted seed signer into the trusted-owner anchor: %o', error);
1087
+ }
1088
+ }
1089
+
1090
+ /**
1091
+ * Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
1092
+ */
1093
+ encodeSeed(seed: ControlNetworkSeed): string {
1094
+ const json = JSON.stringify(seed);
1095
+ return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
1096
+ }
1097
+
1098
+ /**
1099
+ * Decode a seed from base64url encoding.
1100
+ */
1101
+ decodeSeed(encoded: string): ControlNetworkSeed {
1102
+ const bytes = uint8ArrayFromString(encoded, 'base64url');
1103
+ const json = new TextDecoder().decode(bytes);
1104
+ return JSON.parse(json) as ControlNetworkSeed;
1105
+ }
1106
+
1107
+ /**
1108
+ * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
1109
+ *
1110
+ * `target` is a multiaddr string, dialed as given, or a peer id with its addresses
1111
+ * ({@link SeedDeliveryTarget}). For the second, the connection is formed first and on
1112
+ * its own limits ({@link connectForDelivery}): an open one is reused, otherwise each
1113
+ * address is dialed in turn, so one that never answers cannot use up the time a later
1114
+ * one needed. Its addresses are also merged into the peer store, so identify and later
1115
+ * dials see them. A peer id with no addresses is dialed by id, which reuses an open
1116
+ * connection or tries the peer store's addresses. `options.claimProof` rides beside
1117
+ * the seed in the message when the target is a brand-new node being claimed
1118
+ * (`claim-proof.ts`).
1119
+ *
1120
+ * Sender hardening: the request — opening the stream (with its dial, for the forms
1121
+ * that dial by `dialProtocol`), write, ack read — is bounded by {@link seedDeliverTimeoutMs},
1122
+ * and the ack is capped at {@link MAX_SEED_SIZE}. The target is a NOT-YET-TRUSTED node the
1123
+ * instigator chose to dial during onboarding, so an unbounded read here is strictly more
1124
+ * exposed than the membership-gated receiver paths: without the bound a target that accepts
1125
+ * the stream and never replies parks this call forever, and one that streams arbitrary bytes
1126
+ * as a fake ack exhausts memory. A peer id with addresses can therefore take up to
1127
+ * `dialBudget.totalMs` plus {@link seedDeliverTimeoutMs} (114.5 s at the default declared
1128
+ * link) when none of its addresses answers.
1129
+ *
1130
+ * @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it
1131
+ * formed; nothing was sent. Anything else thrown after the connection formed means the
1132
+ * node was reached and the exchange failed.
1133
+ */
1134
+ async deliverSeed(
1135
+ target: SeedDeliveryTarget,
1136
+ seed: ControlNetworkSeed,
1137
+ options?: { claimProof?: string },
1138
+ ): Promise<SeedAckMessage> {
1139
+ if (!this.libp2pNode) {
1140
+ throw new Error('Service not initialized');
1141
+ }
1142
+ const label = typeof target === 'string' ? target : target.peerId;
1143
+ log('Delivering seed to: %s', label);
1144
+
1145
+ const openStream = await this.seedStreamOpener(this.libp2pNode, target);
1146
+ return await withDeadline(
1147
+ this.seedDeliverTimeoutMs,
1148
+ `Seed delivery to ${label}`,
1149
+ (signal) => this.sendSeed(openStream, seed, options?.claimProof, signal),
1150
+ );
1151
+ }
1152
+
1153
+ /**
1154
+ * The part of {@link deliverSeed} that runs before its deadline: how the delivery's stream
1155
+ * will be opened, with any connection it needs already formed.
1156
+ */
1157
+ private async seedStreamOpener(node: Libp2p, target: SeedDeliveryTarget): Promise<SeedStreamOpener> {
1158
+ if (typeof target === 'string') {
1159
+ const addr = multiaddr(target);
1160
+ // NOTE: this one-address form goes through libp2p's dial queue, which joins it to any dial
1161
+ // of the same peer in flight and then waits on that job's address, not this one. Nothing is
1162
+ // skipped (there is one address), only delayed; if a seed delivered to a bare multiaddr is
1163
+ // ever seen timing out while something else dials that peer, open it with `dialAddr`.
1164
+ return (signal) => node.dialProtocol(addr, SEED_PROTOCOL, { signal });
1165
+ }
1166
+ const peerId = peerIdFromString(target.peerId);
1167
+ const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
1168
+ await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
1169
+ if (addrs.length === 0) {
1170
+ return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
1171
+ }
1172
+ const connection = await this.connectForDelivery(node, peerId, addrs);
1173
+ return (signal) => connection.newStream(SEED_PROTOCOL, { signal });
1174
+ }
1175
+
1176
+ /**
1177
+ * A connection to `peerId` for {@link deliverSeed}, from {@link dialPeerAddrs}: one already
1178
+ * open without relay limits, or one dialed from `addrs` — each address on
1179
+ * `dialBudget.perAddressMs`, the whole peer within `dialBudget.totalMs`, direct addresses
1180
+ * before relayed ones and the given order otherwise.
1181
+ *
1182
+ * @throws {PeerUnreachableError} when no connection formed, with the dial's error as `cause`.
1183
+ */
1184
+ private async connectForDelivery(node: Libp2p, peerId: PeerId, addrs: readonly Multiaddr[]): Promise<Connection> {
1185
+ // NOTE: a relayed address can return a limited connection, on which `sendSeed`'s stream
1186
+ // open then fails as a plain error rather than `PeerUnreachableError`, though nothing was
1187
+ // sent. Claim codes carry no relay addresses today; if a delivery target ever may, count a
1188
+ // limited connection as unreachable here.
1189
+ try {
1190
+ return await dialPeerAddrs(node, peerId.toString(), addrs, this.dialBudget, `Seed delivery dial of ${peerId}`);
1191
+ } catch (error) {
1192
+ throw new PeerUnreachableError(peerId.toString(), error);
1193
+ }
1194
+ }
1195
+
1196
+ /**
1197
+ * Open one stream to the target, send the seed frame, half-close, and read the ack.
1198
+ *
1199
+ * `signal` is the deadline from {@link deliverSeed}: it goes to `openStream` so a
1200
+ * timeout while dialing or opening the stream aborts it, and into {@link exchangeFrame}
1201
+ * so a timeout after the stream is open resets it — releasing the otherwise unbounded
1202
+ * ack-read.
1203
+ *
1204
+ * Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
1205
+ * tiny frame over a relay, whereas a seed is up to 1MB, so a relayed address that
1206
+ * forms only a limited connection fails here when the stream is opened. Changing
1207
+ * that is a separate decision.
1208
+ */
1209
+ private async sendSeed(
1210
+ openStream: SeedStreamOpener,
1211
+ seed: ControlNetworkSeed,
1212
+ claimProof: string | undefined,
1213
+ signal: AbortSignal,
1214
+ ): Promise<SeedAckMessage> {
1215
+ const rawStream = await openStream(signal);
1216
+
1217
+ const message: SeedMessage = {
1218
+ partyId: seed.partyId,
1219
+ peers: seed.peers,
1220
+ signature: seed.signature,
1221
+ signerKey: seed.signerKey,
1222
+ ...(claimProof !== undefined ? { claimProof } : {}),
1223
+ };
1224
+
1225
+ const ack = await exchangeFrame(
1226
+ rawStream as unknown as ControlStream,
1227
+ signal,
1228
+ message,
1229
+ (stream) => this.readSeedAck(stream),
1230
+ 'Seed delivery aborted by timeout',
1231
+ );
1232
+
1233
+ log('Seed delivery response: accepted=%s', ack.accepted);
1234
+ return ack;
1235
+ }
1236
+
1237
+ /**
1238
+ * Read the ack frame a delivery target writes back, bounded by
1239
+ * {@link seedDeliverTimeoutMs} and capped at {@link MAX_SEED_SIZE} — an
1240
+ * untrusted target must not be able to stream unlimited bytes as a fake ack.
1241
+ * Decoding runs inside {@link exchangeFrame}'s `try`, so a malformed or
1242
+ * non-JSON ack resets the stream rather than leaking it.
1243
+ */
1244
+ private async readSeedAck(stream: ControlStream): Promise<SeedAckMessage> {
1245
+ const data = await readStreamToEnd(stream, {
1246
+ maxBytes: MAX_SEED_SIZE,
1247
+ timeoutMs: this.seedDeliverTimeoutMs,
1248
+ label: 'Seed ack',
1249
+ });
1250
+ const body = decodeLengthPrefixedFrame(data, MAX_SEED_SIZE);
1251
+ return JSON.parse(new TextDecoder().decode(body)) as SeedAckMessage;
1252
+ }
1253
+
1254
+ /**
1255
+ * Get this node's circuit relay address for inclusion in seeds.
1256
+ * Returns null if no relay address is available.
1257
+ */
1258
+ async getRelayAddress(): Promise<string | null> {
1259
+ if (!this.libp2pNode) {
1260
+ return null;
1261
+ }
1262
+
1263
+ const addrs = this.libp2pNode.getMultiaddrs();
1264
+
1265
+ // Find a circuit relay address
1266
+ const relayAddr = addrs.find(addr => addr.toString().includes('/p2p-circuit/'));
1267
+
1268
+ return relayAddr?.toString() ?? null;
1269
+ }
1270
+
1271
+ /**
1272
+ * Validate a seed's signature.
1273
+ *
1274
+ * @param digestB64 - The seed's {@link seedDigest}, when the caller already computed it
1275
+ * (`verifyAndMergeSeed` shares one digest between this check and the trust context).
1276
+ */
1277
+ validateSeedSignature(seed: ControlNetworkSeed, digestB64 = seedDigest(seed)): boolean {
1278
+ try {
1279
+ // The digest is over the shared canonical payload, so verification is
1280
+ // independent of key order; the payload is the fixed `{ partyId, peers }`
1281
+ // the producer emits.
1282
+ return verify(
1283
+ digestB64,
1284
+ seed.signature,
1285
+ seed.signerKey,
1286
+ 'ed25519',
1287
+ 'base64url',
1288
+ 'base64url',
1289
+ 'base64url'
1290
+ );
1291
+ } catch (error) {
1292
+ log('Seed signature validation failed: %o', error);
1293
+ return false;
1294
+ }
1295
+ }
1296
+
1297
+ /**
1298
+ * The peers a seed carries: the control database's own projection
1299
+ * ({@link ControlDatabase.querySeedPeers}, shared with the cadre invitation redemption
1300
+ * reply), which reads through the revocation filter and flags owners from the replicated
1301
+ * `OwnerKey` table — a dial hint, not a trust decision (the NOTE at `projectSeedPeers`).
1302
+ */
1303
+ private async queryPeers(): Promise<SeedPeer[]> {
1304
+ if (!this.controlDatabase) {
1305
+ return [];
1306
+ }
1307
+ return await this.controlDatabase.querySeedPeers();
1308
+ }
1309
+
1310
+ /**
1311
+ * Register the seed protocol handler. The inbound closure just delegates to
1312
+ * {@link handleSeedStream} — extracted as a method so it has a unit-test seam
1313
+ * (mirroring wake's `handleStream`) the inline closure never had.
1314
+ */
1315
+ private async registerProtocolHandler(libp2pNode: Libp2p): Promise<void> {
1316
+ await libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
1317
+ const remotePeerId = (rawConnection as Connection).remotePeer.toString();
1318
+ await this.handleSeedStream(rawStream as ControlStream, remotePeerId);
1319
+ });
1320
+
1321
+ log('Registered seed protocol handler: %s', SEED_PROTOCOL);
1322
+ }
1323
+
1324
+ /**
1325
+ * Read one inbound seed frame, verify and merge it, write the ack and close the
1326
+ * stream, then dial the seed's owners.
1327
+ *
1328
+ * The ack and the close come BEFORE the owner dials: an unreachable owner can hold
1329
+ * a dial for `dialBudget.totalMs` (86 s at the default declared link), which is not
1330
+ * link time and does not belong inside the sender's delivery deadline. The close is
1331
+ * what releases the sender, which reads the ack to end-of-stream. The stream stays
1332
+ * counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
1333
+ * still bounds concurrent owner-dial phases.
1334
+ *
1335
+ * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
1336
+ * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
1337
+ * that never half-closes is aborted inside `readStreamToEnd`), and the existing
1338
+ * malformed/oversized-frame guard — all reported as a non-accepting
1339
+ * {@link SeedAckMessage} rather than a dropped/hung stream.
1340
+ *
1341
+ * NOTE: a receiver configured with an interactive trust-on-first-use policy asks a
1342
+ * human before acking, and that wait sits inside the sender's delivery deadline. If
1343
+ * TOFU is ever used on this wire path, ack "pending" or move the confirmation out of
1344
+ * the exchange.
1345
+ */
1346
+ private async handleSeedStream(stream: ControlStream, remotePeerId: string): Promise<void> {
1347
+ log('Incoming seed delivery from: %s', remotePeerId);
1348
+
1349
+ if (this.activeStreams >= this.maxConcurrentSeeds) {
1350
+ log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
1351
+ await replyAndClose(stream, { accepted: false, reason: 'Too many concurrent seed deliveries' } satisfies SeedAckMessage, 'Seed');
1352
+ return;
1353
+ }
1354
+
1355
+ this.activeStreams++;
1356
+ let acked = false;
1357
+ try {
1358
+ const { seed, claimProof } = await this.readSeedFrame(stream);
1359
+ this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
1360
+
1361
+ const merged = await this.verifyAndMergeSeed(seed, { claimProof, remotePeerId });
1362
+ acked = true;
1363
+ await replyAndClose(
1364
+ stream,
1365
+ { accepted: merged.success, reason: merged.error, code: merged.code } satisfies SeedAckMessage,
1366
+ 'Seed',
1367
+ );
1368
+
1369
+ if (merged.success) {
1370
+ await this.dialSeedOwners(seed);
1371
+ this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
1372
+ } else {
1373
+ this.eventCallbacks.onSeedError?.(seed.partyId, merged.error ?? 'Unknown error');
1374
+ }
1375
+ } catch (error) {
1376
+ log('Error handling seed delivery: %o', error);
1377
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1378
+ this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
1379
+ // Once acked the stream is closed, so a later failure has no reply to make.
1380
+ if (!acked) {
1381
+ await replyAndClose(stream, { accepted: false, reason: errorMessage } satisfies SeedAckMessage, 'Seed');
1382
+ }
1383
+ } finally {
1384
+ this.activeStreams--;
1385
+ }
1386
+ }
1387
+
1388
+ /**
1389
+ * Read the inbound seed frame to EOF (bounded and size-capped) and decode it. The
1390
+ * claim proof comes back beside the seed: it is not part of what the owner signed, so
1391
+ * `ControlNetworkSeed` does not carry it.
1392
+ */
1393
+ private async readSeedFrame(stream: ControlStream): Promise<{ seed: ControlNetworkSeed; claimProof?: string }> {
1394
+ const data = await readStreamToEnd(stream, {
1395
+ maxBytes: MAX_SEED_SIZE,
1396
+ timeoutMs: this.seedReadTimeoutMs,
1397
+ label: 'Seed',
1398
+ });
1399
+ const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data))) as SeedMessage;
1400
+ return {
1401
+ seed: {
1402
+ partyId: message.partyId,
1403
+ peers: message.peers,
1404
+ signature: message.signature,
1405
+ signerKey: message.signerKey,
1406
+ },
1407
+ claimProof: message.claimProof,
1408
+ };
1409
+ }
1410
+
1411
+ /**
1412
+ * Shutdown the service.
1413
+ */
1414
+ async shutdown(): Promise<void> {
1415
+ if (this.libp2pNode) {
1416
+ await this.libp2pNode.unhandle(SEED_PROTOCOL);
1417
+ }
1418
+ this.libp2pNode = null;
1419
+ this.controlDatabase = null;
1420
+ log('SeedBootstrapService shutdown');
1421
+ }
1422
+
1423
+ // ============================================================================
1424
+ // Helper Functions for Common Scenarios
1425
+ // ============================================================================
1426
+
1427
+ /**
1428
+ * Add a drone to the cadre (phone/server adds provider-hosted node).
1429
+ *
1430
+ * Use this when you've spawned a drone via provider API and received its
1431
+ * peer ID and multiaddrs. This method:
1432
+ * 1. Authorizes the drone peer
1433
+ * 2. Creates a seed including all current peers
1434
+ * 3. Returns the seed for sending to provider API
1435
+ *
1436
+ * Nothing here dials the drone or remembers its addresses beyond the unsigned
1437
+ * `CadrePeer` row, which no resolver accepts. `CadreNode.addDrone` is the entry
1438
+ * point that also retains them as a durable dial target, so the adder's
1439
+ * reconcile pass can open the connection — the drone cannot dial an owner that
1440
+ * does not listen. Call `CadreNode.reconcileControlCohort()` after delivering
1441
+ * the seed to dial at once.
1442
+ *
1443
+ * @param options - Drone peer info from provider API
1444
+ * @returns Seed and encoded seed for drone initialization
1445
+ */
1446
+ async addDrone(options: AddDroneOptions): Promise<DroneInitResult> {
1447
+ const { dronePeerId, droneMultiaddrs } = options;
1448
+
1449
+ log('Adding drone: %s', dronePeerId);
1450
+
1451
+ // 1. Authorize the new drone peer
1452
+ await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs });
1453
+
1454
+ // 2. Create seed with current state
1455
+ const seed = await this.createSeed();
1456
+
1457
+ // 3. Encode for transport
1458
+ const encodedSeed = this.encodeSeed(seed);
1459
+
1460
+ log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
1461
+
1462
+ return { seed, encodedSeed };
1463
+ }
1464
+
1465
+ /**
1466
+ * Add a phone to the cadre with relay support.
1467
+ *
1468
+ * Use this when both nodes are NAT'd (phone-to-phone). This method:
1469
+ * 1. Authorizes the new phone peer
1470
+ * 2. Creates a seed with relay addresses for dialing
1471
+ *
1472
+ * @param phonePeerId - Peer ID of the new phone
1473
+ * @returns Seed with relay addresses for out-of-band delivery
1474
+ */
1475
+ async addPhoneWithRelay(phonePeerId: string): Promise<DroneInitResult> {
1476
+ log('Adding phone with relay: %s', phonePeerId);
1477
+
1478
+ // 1. Authorize the new phone peer (no multiaddrs - NAT'd)
1479
+ await this.authorizePeer({ peerId: phonePeerId });
1480
+
1481
+ // 2. Get relay address for this node
1482
+ const relayAddr = await this.getRelayAddress();
1483
+
1484
+ // 3. Create seed - will include our relay address if available
1485
+ const seed = await this.createSeed();
1486
+
1487
+ // If we have a relay address, make sure it's in our peer entry
1488
+ if (relayAddr && this.libp2pNode) {
1489
+ const ourPeerId = this.libp2pNode.peerId.toString();
1490
+ const ourPeer = seed.peers.find(p => p.peerId === ourPeerId);
1491
+ if (ourPeer && !ourPeer.multiaddrs.includes(relayAddr)) {
1492
+ ourPeer.multiaddrs.push(relayAddr);
1493
+ }
1494
+ }
1495
+
1496
+ const encodedSeed = this.encodeSeed(seed);
1497
+
1498
+ log('Phone %s added with relay, seed created', phonePeerId);
1499
+
1500
+ return { seed, encodedSeed };
1501
+ }
1502
+ }