@serfab/cadre-core 1.13.0 → 1.14.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 (120) hide show
  1. package/dist/cadre-invite-protocol.d.ts +348 -0
  2. package/dist/cadre-invite-protocol.js +605 -0
  3. package/dist/cadre-invite-protocol.js.map +1 -0
  4. package/dist/cadre-node.d.ts +523 -180
  5. package/dist/cadre-node.js +921 -293
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/claim-proof.d.ts +45 -0
  8. package/dist/claim-proof.js +132 -0
  9. package/dist/claim-proof.js.map +1 -0
  10. package/dist/control-authorization.d.ts +80 -19
  11. package/dist/control-authorization.js +55 -13
  12. package/dist/control-authorization.js.map +1 -1
  13. package/dist/control-cohort.js +1 -1
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +514 -97
  16. package/dist/control-database.js +1033 -195
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-protocol-guard.d.ts +142 -0
  19. package/dist/control-protocol-guard.js +293 -0
  20. package/dist/control-protocol-guard.js.map +1 -0
  21. package/dist/control-schema.d.ts +1 -1
  22. package/dist/control-schema.js +801 -1067
  23. package/dist/control-schema.js.map +1 -1
  24. package/dist/control-stream.d.ts +4 -0
  25. package/dist/control-stream.js +22 -7
  26. package/dist/control-stream.js.map +1 -1
  27. package/dist/delegate-admission.d.ts +1 -1
  28. package/dist/delegate-admission.js +1 -1
  29. package/dist/device-token.d.ts +11 -9
  30. package/dist/device-token.js +14 -12
  31. package/dist/device-token.js.map +1 -1
  32. package/dist/ed25519-key.d.ts +15 -0
  33. package/dist/ed25519-key.js +28 -1
  34. package/dist/ed25519-key.js.map +1 -1
  35. package/dist/index.d.ts +14 -9
  36. package/dist/index.js +27 -9
  37. package/dist/index.js.map +1 -1
  38. package/dist/link-budget.d.ts +16 -29
  39. package/dist/link-budget.js +16 -31
  40. package/dist/link-budget.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +190 -151
  42. package/dist/membership-connection-gater.js +235 -173
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-claim-payload.d.ts +19 -0
  45. package/dist/node-claim-payload.js +120 -0
  46. package/dist/node-claim-payload.js.map +1 -0
  47. package/dist/owner-anchor-sync.d.ts +22 -0
  48. package/dist/owner-anchor-sync.js +143 -0
  49. package/dist/owner-anchor-sync.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +113 -18
  51. package/dist/peer-authorization.js +225 -29
  52. package/dist/peer-authorization.js.map +1 -1
  53. package/dist/peer-dial.d.ts +19 -0
  54. package/dist/peer-dial.js +22 -1
  55. package/dist/peer-dial.js.map +1 -1
  56. package/dist/peer-join-backfill.d.ts +26 -2
  57. package/dist/peer-join-backfill.js +54 -12
  58. package/dist/peer-join-backfill.js.map +1 -1
  59. package/dist/peer-record.d.ts +13 -9
  60. package/dist/peer-record.js +16 -12
  61. package/dist/peer-record.js.map +1 -1
  62. package/dist/pending-join-runner.d.ts +66 -58
  63. package/dist/pending-join-runner.js +136 -145
  64. package/dist/pending-join-runner.js.map +1 -1
  65. package/dist/push-fanout.d.ts +8 -2
  66. package/dist/push-fanout.js +8 -2
  67. package/dist/push-fanout.js.map +1 -1
  68. package/dist/seed-bootstrap.d.ts +147 -122
  69. package/dist/seed-bootstrap.js +284 -269
  70. package/dist/seed-bootstrap.js.map +1 -1
  71. package/dist/seed-trust-policy.d.ts +98 -37
  72. package/dist/seed-trust-policy.js +188 -2
  73. package/dist/seed-trust-policy.js.map +1 -1
  74. package/dist/strand-addr-protocol.d.ts +24 -26
  75. package/dist/strand-addr-protocol.js +19 -26
  76. package/dist/strand-addr-protocol.js.map +1 -1
  77. package/dist/strand-formation-protocol.js +1 -1
  78. package/dist/strand-formation-protocol.js.map +1 -1
  79. package/dist/strand-revocation-enforcer.d.ts +11 -11
  80. package/dist/strand-revocation-enforcer.js +11 -11
  81. package/dist/strand-wake-protocol.d.ts +13 -16
  82. package/dist/strand-wake-protocol.js +13 -20
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/trusted-owner-store.d.ts +35 -5
  85. package/dist/trusted-owner-store.js +50 -8
  86. package/dist/trusted-owner-store.js.map +1 -1
  87. package/dist/types.d.ts +302 -87
  88. package/dist/types.js.map +1 -1
  89. package/package.json +8 -7
  90. package/src/cadre-invite-protocol.ts +864 -0
  91. package/src/cadre-node.ts +9381 -8686
  92. package/src/claim-proof.ts +147 -0
  93. package/src/control-authorization.ts +203 -117
  94. package/src/control-cohort.ts +1 -1
  95. package/src/control-database.ts +1216 -218
  96. package/src/control-protocol-guard.ts +348 -0
  97. package/src/control-schema.ts +812 -1078
  98. package/src/control-stream.ts +26 -7
  99. package/src/delegate-admission.ts +1 -1
  100. package/src/device-token.ts +14 -10
  101. package/src/ed25519-key.ts +28 -1
  102. package/src/index.ts +84 -10
  103. package/src/link-budget.ts +16 -32
  104. package/src/membership-connection-gater.ts +637 -570
  105. package/src/node-claim-payload.ts +125 -0
  106. package/src/owner-anchor-sync.ts +171 -0
  107. package/src/peer-authorization.ts +258 -29
  108. package/src/peer-dial.ts +22 -1
  109. package/src/peer-join-backfill.ts +56 -11
  110. package/src/peer-record.ts +16 -11
  111. package/src/pending-join-runner.ts +729 -735
  112. package/src/push-fanout.ts +8 -2
  113. package/src/seed-bootstrap.ts +344 -321
  114. package/src/seed-trust-policy.ts +353 -140
  115. package/src/strand-addr-protocol.ts +26 -35
  116. package/src/strand-formation-protocol.ts +1 -1
  117. package/src/strand-revocation-enforcer.ts +11 -11
  118. package/src/strand-wake-protocol.ts +13 -23
  119. package/src/trusted-owner-store.ts +242 -171
  120. package/src/types.ts +2471 -2242
@@ -1,24 +1,31 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
3
  import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
- import type { Libp2p, Connection } from '@libp2p/interface';
4
+ import type { Libp2p, Connection, PeerId, Stream } from '@libp2p/interface';
5
5
  import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
6
6
  import { peerIdFromString } from '@libp2p/peer-id';
7
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';
8
+ import {
9
+ dialPeerAddrs,
10
+ SelfRelayOnlyError,
11
+ PeerUnreachableError,
12
+ DEFAULT_PEER_DIAL_BUDGET,
13
+ type PeerDialBudget,
14
+ } from './peer-dial.js';
15
+ import { withTrailingPeerId } from './peer-record.js';
9
16
  import { relayedRequestBudgetMs } from './link-budget.js';
10
17
  import type {
11
18
  ControlNetworkSeed,
12
19
  SeedPeer,
13
20
  SeedMessage,
14
21
  SeedAckMessage,
22
+ SeedRefusalCode,
23
+ SeedDeliveryTarget,
15
24
  AuthorizePeerOptions,
16
25
  ApplySeedResult,
17
26
  AddDroneOptions,
18
- AddPhoneOptions,
19
27
  DroneInitResult,
20
- InviteResult,
21
- CadreInvite,
28
+ CadreInviteRow,
22
29
  PeerAddressRecord,
23
30
  DeviceTokenRecord,
24
31
  RevocationRow,
@@ -28,6 +35,7 @@ import type { ControlDatabase } from './control-database.js';
28
35
  import { generateStampId } from './control-database.js';
29
36
  import { canonicalJson } from './canonical-json.js';
30
37
  import { deviceTokenAddDigest } from './peer-authorization.js';
38
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
31
39
  import {
32
40
  type SeedTrustPolicy,
33
41
  type SeedTrustDecision,
@@ -79,28 +87,6 @@ export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED
79
87
  return data.subarray(4, 4 + length);
80
88
  }
81
89
 
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
90
  /**
105
91
  * Canonical byte representation of the authenticated seed fields.
106
92
  *
@@ -116,12 +102,39 @@ export function canonicalSeedPayload(
116
102
  return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
117
103
  }
118
104
 
105
+ /**
106
+ * The digest a seed's signature covers: sha256 over {@link canonicalSeedPayload},
107
+ * base64url. One function so the signer (`createSeed`), the verifier
108
+ * (`validateSeedSignature`) and the claim proof (`claim-proof.ts`, which binds a
109
+ * proof to one seed through this value) all name the same bytes.
110
+ */
111
+ export function seedDigest(seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>): string {
112
+ return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url') as string;
113
+ }
114
+
119
115
  /**
120
116
  * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
121
117
  * so the dial counters read zero rather than being absent.
122
118
  */
123
- function seedRejected(error: string): ApplySeedResult {
124
- return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
119
+ function seedRejected(error: string, code?: SeedRefusalCode): ApplySeedResult {
120
+ return {
121
+ success: false,
122
+ peersAdded: 0,
123
+ error,
124
+ ...(code !== undefined ? { code } : {}),
125
+ ownerDialsAttempted: 0,
126
+ ownerDialsFailed: 0,
127
+ };
128
+ }
129
+
130
+ /** What rides beside a seed into {@link SeedBootstrapService.verifyAndMergeSeed}. */
131
+ interface SeedApplyOptions {
132
+ /** Per-call policy override, used instead of the service-configured default for this seed only. */
133
+ trustPolicy?: SeedTrustPolicy;
134
+ /** The claim proof the message carried, when the seed came over the wire. */
135
+ claimProof?: string;
136
+ /** The delivering peer, when the seed came over the wire. */
137
+ remotePeerId?: string;
125
138
  }
126
139
 
127
140
  /** What dialing a seed's owner peers produced — the owner-dial half of an {@link ApplySeedResult}. */
@@ -143,6 +156,60 @@ function parseDialAddrs(addrs: readonly string[]): Multiaddr[] {
143
156
  return parsed;
144
157
  }
145
158
 
159
+ /**
160
+ * Merge a peer list's addresses into `node`'s peer store, so the node can dial them: the
161
+ * seed's peers after its signature and trust checks, the dial hints a member returns on
162
+ * a cadre invitation redemption (`cadre-invite-protocol.ts`), or the node a seed is being
163
+ * delivered to ({@link SeedBootstrapService.deliverSeed}). Best-effort per peer — one
164
+ * unparsable entry costs that peer, not the rest — and a peer with no address is skipped,
165
+ * since there is nothing to dial. Returns how many peers were merged.
166
+ */
167
+ export async function mergeSeedPeers(node: Libp2p, peers: readonly SeedPeer[]): Promise<number> {
168
+ let peersAdded = 0;
169
+ for (const peer of peers) {
170
+ if (peer.multiaddrs.length === 0) {
171
+ continue;
172
+ }
173
+ try {
174
+ const peerId = peerIdFromString(peer.peerId);
175
+ const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
176
+ await node.peerStore.merge(peerId, { multiaddrs: addrs });
177
+ peersAdded++;
178
+ log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
179
+ } catch (error) {
180
+ log('Failed to add peer %s: %o', peer.peerId, error);
181
+ }
182
+ }
183
+ return peersAdded;
184
+ }
185
+
186
+ /** Opens a seed delivery's stream under the delivery deadline's signal. */
187
+ type SeedStreamOpener = (signal: AbortSignal) => Promise<Stream>;
188
+
189
+ /**
190
+ * A delivery target's addresses, each bound to its peer id (`withTrailingPeerId`) so the dial
191
+ * authenticates the node it aims at rather than trusting whoever answers. An address that does
192
+ * not parse, or that names another peer, throws: that is the caller's mistake, not the node
193
+ * being unreachable.
194
+ */
195
+ function deliveryDialAddrs(peerId: string, addrs: readonly string[]): Multiaddr[] {
196
+ return addrs.map((text) => {
197
+ const bound = withTrailingPeerId(multiaddr(text), peerId);
198
+ if (!bound) {
199
+ throw new Error(`Seed delivery to ${peerId}: address ${text} names another peer`);
200
+ }
201
+ return bound;
202
+ });
203
+ }
204
+
205
+ /**
206
+ * A connection to `peerId` that `node` already holds and a seed stream can use: open and without
207
+ * relay limits, the rule libp2p applies when `dialProtocol` reuses a connection.
208
+ */
209
+ function openUnlimitedConnection(node: Libp2p, peerId: PeerId): Connection | undefined {
210
+ return node.getConnections(peerId).find((c) => c.status === 'open' && c.limits === undefined);
211
+ }
212
+
146
213
  /**
147
214
  * Configuration for the SeedBootstrapService
148
215
  */
@@ -153,13 +220,6 @@ export interface SeedBootstrapConfig {
153
220
  ownerPrivateKey?: string;
154
221
  /** Owner public key (base64url) - derived from private key if not provided */
155
222
  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
223
  /**
164
224
  * Trust anchor for incoming seeds. Decides whether a signature-verified
165
225
  * `signerKey` should be trusted, against the receiver's anchored owner
@@ -203,18 +263,20 @@ export interface SeedBootstrapConfig {
203
263
  */
204
264
  linkRoundTripMs?: number;
205
265
  /**
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
266
+ * Time {@link SeedBootstrapService.deliverSeed} waits for the request — opening the
267
+ * stream, write, ack read — before aborting (ms). For a multiaddr string, or a peer id
268
+ * with no addresses, opening the stream includes the dial; for a peer id with addresses
269
+ * the connection is formed first, outside this limit and within {@link dialBudget}.
270
+ * Bounds the SENDER against a seed target that accepts the stream and then never replies;
271
+ * the target is a not-yet-trusted node during onboarding, so this is the more exposed
210
272
  * direction than the receiver knobs above.
211
273
  *
212
274
  * 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.
275
+ * default declaration): one dial that may need a relay, then one request and its answer —
276
+ * sized for the forms that dial inside it. Delivery does not set `runOnLimitedConnection`,
277
+ * so it does not use a limited relayed connection today; the relayed-dial count is the upper
278
+ * bound on the dial it can use, the same choice `CadreNode.controlDialBudget` makes for every
279
+ * address. It holds only link work because the receiver acks before its owner dials.
218
280
  *
219
281
  * NOTE: no transfer allowance — a seed is a peer list of a few KB, and `MAX_SEED_SIZE` (1 MiB)
220
282
  * is a defensive cap. If seeds ever grow toward that cap, add an allowance the way
@@ -224,8 +286,8 @@ export interface SeedBootstrapConfig {
224
286
  /**
225
287
  * Time limits for each peer this service dials from a list of addresses —
226
288
  * {@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`
289
+ * {@link SeedBootstrapService.deliverSeed}'s dial of a peer id with addresses — per
290
+ * address and per peer (see `peer-dial.ts`). Defaults to {@link DEFAULT_PEER_DIAL_BUDGET}; a `CadreNode`
229
291
  * passes its `network.controlCohort` limits.
230
292
  */
231
293
  dialBudget?: PeerDialBudget;
@@ -320,10 +382,10 @@ export class SeedBootstrapService {
320
382
  * `registerHandler` (default true) gates registration of the shared inbound
321
383
  * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
322
384
  * (`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
385
+ * it on. The throwaway temp service CadreNode builds in `applySeed` passes
386
+ * `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
387
+ * dialing and known-key lookup, and must NOT bind a discarded closure to the
388
+ * shared node (a handler leak, and a second
327
389
  * `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
328
390
  *
329
391
  * Rejects when libp2p refuses the registration; the node and database are
@@ -447,7 +509,7 @@ export class SeedBootstrapService {
447
509
  */
448
510
  async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
449
511
  const stampId = generateStampId(record.peerId);
450
- const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
512
+ const signature = this.signDigest(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }));
451
513
  if (!this.controlDatabase) {
452
514
  throw new Error('Control database not initialized');
453
515
  }
@@ -557,7 +619,7 @@ export class SeedBootstrapService {
557
619
  *
558
620
  * The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
559
621
  * signature over the DISTINCT 'remove'-tagged digest
560
- * `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
622
+ * `digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId)` by an owner
561
623
  * key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
562
624
  * never be replayed to delete.
563
625
  *
@@ -606,6 +668,43 @@ export class SeedBootstrapService {
606
668
  log('Peer %s removed successfully (stamp retired)', peerId);
607
669
  }
608
670
 
671
+ /**
672
+ * Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
673
+ * liveness check, the stamp, the signature and the membership notify are
674
+ * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
675
+ * precondition, checked before any read (see {@link removePeer}).
676
+ *
677
+ * @returns `true` when the row was seated, `false` when the key is already a live owner.
678
+ */
679
+ async addOwner(ownerPublicKey: string): Promise<boolean> {
680
+ const ownerKey = this.requireOwnerPublicKey();
681
+ if (!this.controlDatabase) {
682
+ throw new Error('Control database not initialized');
683
+ }
684
+ return await this.controlDatabase.insertOwnerKeyVouched(
685
+ ownerPublicKey, ownerKey, message => this.signMessageBytes(message),
686
+ );
687
+ }
688
+
689
+ /**
690
+ * Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
691
+ * and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
692
+ * the last owner), the digests and the membership notify are
693
+ * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
694
+ * checked before any read (see {@link removePeer}).
695
+ *
696
+ * @returns `true` when the row was removed, `false` when the key is not a live owner here.
697
+ */
698
+ async removeOwner(ownerPublicKey: string): Promise<boolean> {
699
+ const ownerKey = this.requireOwnerPublicKey();
700
+ if (!this.controlDatabase) {
701
+ throw new Error('Control database not initialized');
702
+ }
703
+ return await this.controlDatabase.deleteOwnerKey(
704
+ ownerPublicKey, ownerKey, message => this.signMessageBytes(message),
705
+ );
706
+ }
707
+
609
708
  /**
610
709
  * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
611
710
  * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
@@ -692,6 +791,42 @@ export class SeedBootstrapService {
692
791
  );
693
792
  }
694
793
 
794
+ /**
795
+ * Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
796
+ * on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
797
+ * expiry and use-count defaults, and the bundle the holder receives are
798
+ * `CadreNode.createCadreInvitation`'s; what stays here is the owner-key precondition and
799
+ * the signature, as {@link insertSelfDeviceToken} keeps them for device tokens.
800
+ *
801
+ * @returns the row as stored, which the invitation bundle carries verbatim.
802
+ * @throws if no owner private key is configured or the control database is not initialized.
803
+ */
804
+ async insertCadreInvite(invite: Parameters<ControlDatabase['insertCadreInvite']>[0]): Promise<CadreInviteRow> {
805
+ // Fail fast on a keyless service before any DB work (see removePeer).
806
+ const ownerKey = this.requireOwnerPublicKey();
807
+ if (!this.controlDatabase) {
808
+ throw new Error('Control database not initialized');
809
+ }
810
+ return await this.controlDatabase.insertCadreInvite(invite, ownerKey, message => this.signMessageBytes(message));
811
+ }
812
+
813
+ /**
814
+ * Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
815
+ * the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
816
+ * owner-key precondition.
817
+ *
818
+ * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
819
+ * already withdrawn.
820
+ * @throws if no owner private key is configured or the control database is not initialized.
821
+ */
822
+ async withdrawCadreInvite(key: string): Promise<boolean> {
823
+ const ownerKey = this.requireOwnerPublicKey();
824
+ if (!this.controlDatabase) {
825
+ throw new Error('Control database not initialized');
826
+ }
827
+ return await this.controlDatabase.withdrawCadreInvite(key, ownerKey, message => this.signMessageBytes(message));
828
+ }
829
+
695
830
  /**
696
831
  * Create a seed from the current control network state.
697
832
  * The seed contains peer information and is signed by an owner.
@@ -717,10 +852,8 @@ export class SeedBootstrapService {
717
852
  };
718
853
 
719
854
  // Sign the seed over its canonical byte representation
720
- const seedJson = canonicalSeedPayload(seedData);
721
- const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
722
855
  const signature = sign(
723
- seedDigest,
856
+ seedDigest(seedData),
724
857
  this.config.ownerPrivateKey,
725
858
  'ed25519',
726
859
  'base64url',
@@ -751,11 +884,11 @@ export class SeedBootstrapService {
751
884
  *
752
885
  * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
753
886
  * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
754
- * anchored without re-supplying the invite.
887
+ * anchored without re-supplying the pin.
755
888
  *
756
889
  * @param seed - The seed to apply (already transport-decoded).
757
890
  * @param options.trustPolicy - Per-call policy override (e.g. a
758
- * `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
891
+ * `pinnedKeyTrustPolicy` built from an operator pin) used instead of the
759
892
  * service-configured default for this seed only.
760
893
  */
761
894
  async applySeed(
@@ -776,22 +909,27 @@ export class SeedBootstrapService {
776
909
  */
777
910
  private async verifyAndMergeSeed(
778
911
  seed: ControlNetworkSeed,
779
- options?: { trustPolicy?: SeedTrustPolicy }
912
+ options?: SeedApplyOptions
780
913
  ): Promise<ApplySeedResult> {
781
914
  if (!this.libp2pNode) {
782
915
  return seedRejected('Service not initialized');
783
916
  }
784
917
 
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
918
+ // NOTE: `seed.partyId` is never checked against `config.partyId`. Trust is keyed on
919
+ // `signerKey` vs the anchor, and the anchor a stray-party seed could write into
920
+ // belongs to THIS party, which only a caller-supplied pin for that signer can reach.
921
+ // The one reader of the seed's party is the claim policy, which records it as the
922
+ // party the node now serves (`CadreNodeConfig.claim.record`): a mismatch is still not
923
+ // rejected there because an unclaimed node's own party is a placeholder, and the
924
+ // claim seed is the first thing to name the real one. If any other path ever
925
+ // branches on the seed's partyId (or the anchor becomes multi-party), reject a
790
926
  // mismatch here instead.
791
927
  log('Applying seed for party: %s', seed.partyId);
792
928
 
793
- // Validate the seed signature
794
- if (!this.validateSeedSignature(seed)) {
929
+ // One digest serves the signature check and the trust context: a claim proof is
930
+ // bound to the seed through the same value the signature covers.
931
+ const digestB64 = seedDigest(seed);
932
+ if (!this.validateSeedSignature(seed, digestB64)) {
795
933
  return seedRejected('Invalid seed signature');
796
934
  }
797
935
 
@@ -807,34 +945,20 @@ export class SeedBootstrapService {
807
945
  partyId: seed.partyId,
808
946
  signerKey: seed.signerKey,
809
947
  knownOwnerKeys,
948
+ // Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
949
+ // omit `peerId`. A real node always has one, and a proof bound to a real peer id can
950
+ // only fail against '' — the safe direction.
951
+ localPeerId: this.libp2pNode.peerId?.toString() ?? '',
952
+ seedDigest: digestB64,
953
+ claimProof: options?.claimProof,
954
+ remotePeerId: options?.remotePeerId,
810
955
  });
811
956
  if (!decision.trusted) {
812
- return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
957
+ return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy', decision.code);
813
958
  }
814
959
  await this.anchorAcceptedSigner(seed.signerKey, decision);
815
960
 
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
-
961
+ const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
838
962
  log('Merged seed: %d peers added', peersAdded);
839
963
  return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
840
964
  }
@@ -897,7 +1021,7 @@ export class SeedBootstrapService {
897
1021
 
898
1022
  /**
899
1023
  * 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
1024
+ * later seed from the same owner is anchored without re-supplying the pin
901
1025
  * or re-prompting. Only the policy decides this happens (`anchorAs` is unset
902
1026
  * when the key was already anchored, so a plain re-apply writes nothing) and
903
1027
  * `trust()` is idempotent, keeping the original provenance for a known key.
@@ -905,14 +1029,19 @@ export class SeedBootstrapService {
905
1029
  * Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
906
1030
  * in-memory anchor synchronously, so this seed and the rest of the session are
907
1031
  * unaffected — only durability across a restart is lost, and that is logged.
1032
+ * That holds for pins and TOFU, which are re-supplied at the next start; a claim
1033
+ * is the opposite case, so `claimSecretTrustPolicy` anchors its signer itself,
1034
+ * awaits durability, and never sets `anchorAs` (the type excludes `'claim'`).
908
1035
  *
909
1036
  * NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
910
1037
  * 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.
1038
+ * (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. The anchor
1039
+ * is mutated from two places: here, on seed application, where both seed paths
1040
+ * refresh the gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`);
1041
+ * and by the anchor sync (`CadreNode.syncOwnerAnchor`), which runs INSIDE the
1042
+ * membership refresh, ahead of the membership read, so its changes are judged by
1043
+ * the same pass. If some future path anchors an owner outside both, it owes the
1044
+ * same `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
916
1045
  */
917
1046
  private async anchorAcceptedSigner(signerKey: string, decision: SeedTrustDecision): Promise<void> {
918
1047
  if (!decision.anchorAs || !this.config.trustedOwners) {
@@ -946,56 +1075,120 @@ export class SeedBootstrapService {
946
1075
  /**
947
1076
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
948
1077
  *
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.
1078
+ * `target` is a multiaddr string, dialed as given, or a peer id with its addresses
1079
+ * ({@link SeedDeliveryTarget}). For the second, the connection is formed first and on
1080
+ * its own limits ({@link connectForDelivery}): an open one is reused, otherwise each
1081
+ * address is dialed in turn, so one that never answers cannot use up the time a later
1082
+ * one needed. Its addresses are also merged into the peer store, so identify and later
1083
+ * dials see them. A peer id with no addresses is dialed by id, which reuses an open
1084
+ * connection or tries the peer store's addresses. `options.claimProof` rides beside
1085
+ * the seed in the message when the target is a brand-new node being claimed
1086
+ * (`claim-proof.ts`).
1087
+ *
1088
+ * Sender hardening: the request — opening the stream (with its dial, for the forms
1089
+ * that dial by `dialProtocol`), write, ack read — is bounded by {@link seedDeliverTimeoutMs},
1090
+ * and the ack is capped at {@link MAX_SEED_SIZE}. The target is a NOT-YET-TRUSTED node the
1091
+ * instigator chose to dial during onboarding, so an unbounded read here is strictly more
1092
+ * exposed than the membership-gated receiver paths: without the bound a target that accepts
1093
+ * the stream and never replies parks this call forever, and one that streams arbitrary bytes
1094
+ * as a fake ack exhausts memory. A peer id with addresses can therefore take up to
1095
+ * `dialBudget.totalMs` plus {@link seedDeliverTimeoutMs} (114.5 s at the default declared
1096
+ * link) when none of its addresses answers.
1097
+ *
1098
+ * @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it
1099
+ * formed; nothing was sent. Anything else thrown after the connection formed means the
1100
+ * node was reached and the exchange failed.
956
1101
  */
957
- async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<SeedAckMessage> {
1102
+ async deliverSeed(
1103
+ target: SeedDeliveryTarget,
1104
+ seed: ControlNetworkSeed,
1105
+ options?: { claimProof?: string },
1106
+ ): Promise<SeedAckMessage> {
958
1107
  if (!this.libp2pNode) {
959
1108
  throw new Error('Service not initialized');
960
1109
  }
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);
1110
+ const label = typeof target === 'string' ? target : target.peerId;
1111
+ log('Delivering seed to: %s', label);
966
1112
 
1113
+ const openStream = await this.seedStreamOpener(this.libp2pNode, target);
967
1114
  return await withDeadline(
968
1115
  this.seedDeliverTimeoutMs,
969
- `Seed delivery to ${targetMultiaddr}`,
970
- (signal) => this.sendSeed(node, addr, seed, signal),
1116
+ `Seed delivery to ${label}`,
1117
+ (signal) => this.sendSeed(openStream, seed, options?.claimProof, signal),
971
1118
  );
972
1119
  }
973
1120
 
1121
+ /**
1122
+ * The part of {@link deliverSeed} that runs before its deadline: how the delivery's stream
1123
+ * will be opened, with any connection it needs already formed.
1124
+ */
1125
+ private async seedStreamOpener(node: Libp2p, target: SeedDeliveryTarget): Promise<SeedStreamOpener> {
1126
+ if (typeof target === 'string') {
1127
+ const addr = multiaddr(target);
1128
+ return (signal) => node.dialProtocol(addr, SEED_PROTOCOL, { signal });
1129
+ }
1130
+ const peerId = peerIdFromString(target.peerId);
1131
+ const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
1132
+ await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
1133
+ if (addrs.length === 0) {
1134
+ return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
1135
+ }
1136
+ const connection = await this.connectForDelivery(node, peerId, addrs);
1137
+ return (signal) => connection.newStream(SEED_PROTOCOL, { signal });
1138
+ }
1139
+
1140
+ /**
1141
+ * A connection to `peerId` for {@link deliverSeed}: one already open, or one dialed from
1142
+ * `addrs` by {@link dialPeerAddrs} — each address on `dialBudget.perAddressMs`, the whole
1143
+ * peer within `dialBudget.totalMs`, direct addresses before relayed ones and the given
1144
+ * order otherwise.
1145
+ *
1146
+ * @throws {PeerUnreachableError} when no connection formed, with the dial's error as `cause`.
1147
+ */
1148
+ private async connectForDelivery(node: Libp2p, peerId: PeerId, addrs: readonly Multiaddr[]): Promise<Connection> {
1149
+ const open = openUnlimitedConnection(node, peerId);
1150
+ if (open) {
1151
+ log('Seed delivery to %s reuses an open connection', peerId);
1152
+ return open;
1153
+ }
1154
+ // NOTE: a relayed address can return a limited connection, on which `sendSeed`'s stream
1155
+ // open then fails as a plain error rather than `PeerUnreachableError`, though nothing was
1156
+ // sent. Claim codes carry no relay addresses today; if a delivery target ever may, count a
1157
+ // limited connection as unreachable here.
1158
+ try {
1159
+ return await dialPeerAddrs(node, addrs, this.dialBudget, `Seed delivery dial of ${peerId}`);
1160
+ } catch (error) {
1161
+ throw new PeerUnreachableError(peerId.toString(), error);
1162
+ }
1163
+ }
1164
+
974
1165
  /**
975
1166
  * Open one stream to the target, send the seed frame, half-close, and read the ack.
976
1167
  *
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
1168
+ * `signal` is the deadline from {@link deliverSeed}: it goes to `openStream` so a
1169
+ * timeout while dialing or opening the stream aborts it, and into {@link exchangeFrame}
1170
+ * so a timeout after the stream is open resets it — releasing the otherwise unbounded
980
1171
  * ack-read.
981
1172
  *
982
1173
  * 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.
1174
+ * tiny frame over a relay, whereas a seed is up to 1MB, so a relayed address that
1175
+ * forms only a limited connection fails here when the stream is opened. Changing
1176
+ * that is a separate decision.
985
1177
  */
986
1178
  private async sendSeed(
987
- node: Libp2p,
988
- addr: Multiaddr,
1179
+ openStream: SeedStreamOpener,
989
1180
  seed: ControlNetworkSeed,
1181
+ claimProof: string | undefined,
990
1182
  signal: AbortSignal,
991
1183
  ): Promise<SeedAckMessage> {
992
- const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
1184
+ const rawStream = await openStream(signal);
993
1185
 
994
1186
  const message: SeedMessage = {
995
1187
  partyId: seed.partyId,
996
1188
  peers: seed.peers,
997
1189
  signature: seed.signature,
998
1190
  signerKey: seed.signerKey,
1191
+ ...(claimProof !== undefined ? { claimProof } : {}),
999
1192
  };
1000
1193
 
1001
1194
  const ack = await exchangeFrame(
@@ -1046,17 +1239,17 @@ export class SeedBootstrapService {
1046
1239
 
1047
1240
  /**
1048
1241
  * Validate a seed's signature.
1242
+ *
1243
+ * @param digestB64 - The seed's {@link seedDigest}, when the caller already computed it
1244
+ * (`verifyAndMergeSeed` shares one digest between this check and the trust context).
1049
1245
  */
1050
- validateSeedSignature(seed: ControlNetworkSeed): boolean {
1246
+ validateSeedSignature(seed: ControlNetworkSeed, digestB64 = seedDigest(seed)): boolean {
1051
1247
  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
-
1248
+ // The digest is over the shared canonical payload, so verification is
1249
+ // independent of key order; the payload is the fixed `{ partyId, peers }`
1250
+ // the producer emits.
1058
1251
  return verify(
1059
- seedDigest,
1252
+ digestB64,
1060
1253
  seed.signature,
1061
1254
  seed.signerKey,
1062
1255
  'ed25519',
@@ -1071,52 +1264,16 @@ export class SeedBootstrapService {
1071
1264
  }
1072
1265
 
1073
1266
  /**
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.
1267
+ * The peers a seed carries: the control database's own projection
1268
+ * ({@link ControlDatabase.querySeedPeers}, shared with the cadre invitation redemption
1269
+ * reply), which reads through the revocation filter and flags owners from the replicated
1270
+ * `OwnerKey` table — a dial hint, not a trust decision (the NOTE at `projectSeedPeers`).
1097
1271
  */
1098
1272
  private async queryPeers(): Promise<SeedPeer[]> {
1099
1273
  if (!this.controlDatabase) {
1100
1274
  return [];
1101
1275
  }
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
- });
1276
+ return await this.controlDatabase.querySeedPeers();
1120
1277
  }
1121
1278
 
1122
1279
  /**
@@ -1167,12 +1324,16 @@ export class SeedBootstrapService {
1167
1324
  this.activeStreams++;
1168
1325
  let acked = false;
1169
1326
  try {
1170
- const seed = await this.readSeedFrame(stream);
1327
+ const { seed, claimProof } = await this.readSeedFrame(stream);
1171
1328
  this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
1172
1329
 
1173
- const merged = await this.verifyAndMergeSeed(seed);
1330
+ const merged = await this.verifyAndMergeSeed(seed, { claimProof, remotePeerId });
1174
1331
  acked = true;
1175
- await replyAndClose(stream, { accepted: merged.success, reason: merged.error } satisfies SeedAckMessage, 'Seed');
1332
+ await replyAndClose(
1333
+ stream,
1334
+ { accepted: merged.success, reason: merged.error, code: merged.code } satisfies SeedAckMessage,
1335
+ 'Seed',
1336
+ );
1176
1337
 
1177
1338
  if (merged.success) {
1178
1339
  await this.dialSeedOwners(seed);
@@ -1193,8 +1354,12 @@ export class SeedBootstrapService {
1193
1354
  }
1194
1355
  }
1195
1356
 
1196
- /** Read the inbound seed frame to EOF (bounded and size-capped) and decode it. */
1197
- private async readSeedFrame(stream: ControlStream): Promise<ControlNetworkSeed> {
1357
+ /**
1358
+ * Read the inbound seed frame to EOF (bounded and size-capped) and decode it. The
1359
+ * claim proof comes back beside the seed: it is not part of what the owner signed, so
1360
+ * `ControlNetworkSeed` does not carry it.
1361
+ */
1362
+ private async readSeedFrame(stream: ControlStream): Promise<{ seed: ControlNetworkSeed; claimProof?: string }> {
1198
1363
  const data = await readStreamToEnd(stream, {
1199
1364
  maxBytes: MAX_SEED_SIZE,
1200
1365
  timeoutMs: this.seedReadTimeoutMs,
@@ -1202,10 +1367,13 @@ export class SeedBootstrapService {
1202
1367
  });
1203
1368
  const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data))) as SeedMessage;
1204
1369
  return {
1205
- partyId: message.partyId,
1206
- peers: message.peers,
1207
- signature: message.signature,
1208
- signerKey: message.signerKey,
1370
+ seed: {
1371
+ partyId: message.partyId,
1372
+ peers: message.peers,
1373
+ signature: message.signature,
1374
+ signerKey: message.signerKey,
1375
+ },
1376
+ claimProof: message.claimProof,
1209
1377
  };
1210
1378
  }
1211
1379
 
@@ -1263,104 +1431,6 @@ export class SeedBootstrapService {
1263
1431
  return { seed, encodedSeed };
1264
1432
  }
1265
1433
 
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
1434
  /**
1365
1435
  * Add a phone to the cadre with relay support.
1366
1436
  *
@@ -1398,51 +1468,4 @@ export class SeedBootstrapService {
1398
1468
 
1399
1469
  return { seed, encodedSeed };
1400
1470
  }
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
1471
  }
1448
-