@serfab/cadre-core 1.7.0 → 1.9.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 (188) hide show
  1. package/README.md +8 -6
  2. package/dist/cadre-node.d.ts +527 -206
  3. package/dist/cadre-node.js +1084 -528
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-authorization.d.ts +2 -2
  6. package/dist/control-authorization.js +1 -0
  7. package/dist/control-authorization.js.map +1 -1
  8. package/dist/control-cohort.d.ts +6 -0
  9. package/dist/control-cohort.js +6 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +46 -8
  12. package/dist/control-database.js +97 -18
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +37 -14
  15. package/dist/control-formation-recorder.js +60 -10
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +13 -12
  18. package/dist/control-read-retry.js +14 -12
  19. package/dist/control-read-retry.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +67 -5
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +9 -1
  24. package/dist/control-stream.js +24 -1
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +12 -2
  27. package/dist/control-write-retry.js +13 -2
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +11 -8
  30. package/dist/delegate-admission.js +19 -11
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/diagnostics/connection-path.d.ts +0 -7
  33. package/dist/diagnostics/connection-path.js +0 -7
  34. package/dist/diagnostics/connection-path.js.map +1 -1
  35. package/dist/formation-approval.d.ts +7 -0
  36. package/dist/formation-approval.js +9 -3
  37. package/dist/formation-approval.js.map +1 -1
  38. package/dist/fs-atomic.d.ts +11 -2
  39. package/dist/fs-atomic.js +13 -3
  40. package/dist/fs-atomic.js.map +1 -1
  41. package/dist/hibernation-manager.d.ts +111 -8
  42. package/dist/hibernation-manager.js +234 -49
  43. package/dist/hibernation-manager.js.map +1 -1
  44. package/dist/index.d.ts +10 -10
  45. package/dist/index.js +25 -20
  46. package/dist/index.js.map +1 -1
  47. package/dist/joined-strand-store.d.ts +90 -25
  48. package/dist/joined-strand-store.js +223 -55
  49. package/dist/joined-strand-store.js.map +1 -1
  50. package/dist/key-store-file.js +14 -5
  51. package/dist/key-store-file.js.map +1 -1
  52. package/dist/link-budget.d.ts +367 -69
  53. package/dist/link-budget.js +375 -72
  54. package/dist/link-budget.js.map +1 -1
  55. package/dist/membership-connection-gater.d.ts +30 -43
  56. package/dist/membership-connection-gater.js +31 -42
  57. package/dist/membership-connection-gater.js.map +1 -1
  58. package/dist/node-local-snapshot.d.ts +6 -4
  59. package/dist/node-local-snapshot.js +7 -3
  60. package/dist/node-local-snapshot.js.map +1 -1
  61. package/dist/peer-dial.d.ts +24 -4
  62. package/dist/peer-dial.js +74 -5
  63. package/dist/peer-dial.js.map +1 -1
  64. package/dist/peer-join-backfill.d.ts +2 -10
  65. package/dist/peer-join-backfill.js +8 -8
  66. package/dist/peer-join-backfill.js.map +1 -1
  67. package/dist/push-fanout.d.ts +6 -0
  68. package/dist/push-fanout.js +6 -0
  69. package/dist/push-fanout.js.map +1 -1
  70. package/dist/relay-reservation.d.ts +3 -1
  71. package/dist/relay-reservation.js +3 -1
  72. package/dist/relay-reservation.js.map +1 -1
  73. package/dist/relay-server.d.ts +79 -0
  74. package/dist/relay-server.js +74 -0
  75. package/dist/relay-server.js.map +1 -0
  76. package/dist/seed-bootstrap.d.ts +57 -4
  77. package/dist/seed-bootstrap.js +107 -83
  78. package/dist/seed-bootstrap.js.map +1 -1
  79. package/dist/storage-scope.d.ts +33 -17
  80. package/dist/storage-scope.js +44 -23
  81. package/dist/storage-scope.js.map +1 -1
  82. package/dist/strand-addr-protocol.d.ts +58 -21
  83. package/dist/strand-addr-protocol.js +133 -85
  84. package/dist/strand-addr-protocol.js.map +1 -1
  85. package/dist/strand-database.d.ts +20 -2
  86. package/dist/strand-database.js +42 -5
  87. package/dist/strand-database.js.map +1 -1
  88. package/dist/strand-first-sync-gate.d.ts +59 -21
  89. package/dist/strand-first-sync-gate.js +58 -19
  90. package/dist/strand-first-sync-gate.js.map +1 -1
  91. package/dist/strand-formation-deadlines.d.ts +151 -0
  92. package/dist/strand-formation-deadlines.js +171 -0
  93. package/dist/strand-formation-deadlines.js.map +1 -0
  94. package/dist/strand-formation-manager.d.ts +63 -30
  95. package/dist/strand-formation-manager.js +67 -41
  96. package/dist/strand-formation-manager.js.map +1 -1
  97. package/dist/strand-formation-protocol.d.ts +45 -46
  98. package/dist/strand-formation-protocol.js +62 -126
  99. package/dist/strand-formation-protocol.js.map +1 -1
  100. package/dist/strand-fret-addrs.d.ts +54 -0
  101. package/dist/strand-fret-addrs.js +108 -0
  102. package/dist/strand-fret-addrs.js.map +1 -0
  103. package/dist/strand-instance-manager.d.ts +122 -44
  104. package/dist/strand-instance-manager.js +270 -114
  105. package/dist/strand-instance-manager.js.map +1 -1
  106. package/dist/strand-membership-writer.d.ts +6 -0
  107. package/dist/strand-membership-writer.js +11 -0
  108. package/dist/strand-membership-writer.js.map +1 -1
  109. package/dist/strand-network-state-file.d.ts +12 -0
  110. package/dist/strand-network-state-file.js +38 -0
  111. package/dist/strand-network-state-file.js.map +1 -0
  112. package/dist/strand-network-state.d.ts +111 -0
  113. package/dist/strand-network-state.js +191 -0
  114. package/dist/strand-network-state.js.map +1 -0
  115. package/dist/strand-revocation-enforcer.js +2 -1
  116. package/dist/strand-revocation-enforcer.js.map +1 -1
  117. package/dist/strand-solicitation.d.ts +36 -20
  118. package/dist/strand-solicitation.js +4 -4
  119. package/dist/strand-solicitation.js.map +1 -1
  120. package/dist/strand-wake-protocol.d.ts +80 -23
  121. package/dist/strand-wake-protocol.js +89 -64
  122. package/dist/strand-wake-protocol.js.map +1 -1
  123. package/dist/types.d.ts +148 -61
  124. package/dist/types.js +4 -3
  125. package/dist/types.js.map +1 -1
  126. package/package.json +11 -10
  127. package/src/cadre-node.ts +8400 -7776
  128. package/src/control-authorization.ts +2 -1
  129. package/src/control-cohort.ts +6 -0
  130. package/src/control-database.ts +122 -18
  131. package/src/control-formation-recorder.ts +354 -305
  132. package/src/control-read-retry.ts +14 -12
  133. package/src/control-schema.ts +67 -5
  134. package/src/control-stream.ts +25 -1
  135. package/src/control-write-retry.ts +13 -2
  136. package/src/delegate-admission.ts +23 -13
  137. package/src/diagnostics/connection-path.ts +0 -7
  138. package/src/formation-approval.ts +610 -604
  139. package/src/fs-atomic.ts +14 -4
  140. package/src/hibernation-manager.ts +638 -437
  141. package/src/index.ts +43 -65
  142. package/src/joined-strand-store.ts +247 -57
  143. package/src/key-store-file.ts +14 -5
  144. package/src/link-budget.ts +393 -74
  145. package/src/membership-connection-gater.ts +31 -45
  146. package/src/node-local-snapshot.ts +12 -7
  147. package/src/peer-dial.ts +81 -7
  148. package/src/peer-join-backfill.ts +10 -9
  149. package/src/push-fanout.ts +6 -0
  150. package/src/relay-reservation.ts +3 -1
  151. package/src/relay-server.ts +98 -0
  152. package/src/seed-bootstrap.ts +133 -91
  153. package/src/storage-scope.ts +44 -23
  154. package/src/strand-addr-protocol.ts +547 -458
  155. package/src/strand-database.ts +287 -242
  156. package/src/strand-first-sync-gate.ts +58 -19
  157. package/src/strand-formation-deadlines.ts +227 -0
  158. package/src/strand-formation-manager.ts +700 -647
  159. package/src/strand-formation-protocol.ts +106 -154
  160. package/src/strand-fret-addrs.ts +145 -0
  161. package/src/strand-instance-manager.ts +307 -140
  162. package/src/strand-membership-writer.ts +12 -0
  163. package/src/strand-network-state-file.ts +40 -0
  164. package/src/strand-network-state.ts +243 -0
  165. package/src/strand-revocation-enforcer.ts +2 -1
  166. package/src/strand-solicitation.ts +519 -500
  167. package/src/strand-wake-protocol.ts +401 -355
  168. package/src/types.ts +150 -61
  169. package/dist/strand-peer-book-file.d.ts +0 -12
  170. package/dist/strand-peer-book-file.js +0 -37
  171. package/dist/strand-peer-book-file.js.map +0 -1
  172. package/dist/strand-peer-book-protocol.d.ts +0 -159
  173. package/dist/strand-peer-book-protocol.js +0 -360
  174. package/dist/strand-peer-book-protocol.js.map +0 -1
  175. package/dist/strand-peer-book-swap.d.ts +0 -104
  176. package/dist/strand-peer-book-swap.js +0 -284
  177. package/dist/strand-peer-book-swap.js.map +0 -1
  178. package/dist/strand-peer-book.d.ts +0 -159
  179. package/dist/strand-peer-book.js +0 -354
  180. package/dist/strand-peer-book.js.map +0 -1
  181. package/dist/strand-peer-observer.d.ts +0 -88
  182. package/dist/strand-peer-observer.js +0 -180
  183. package/dist/strand-peer-observer.js.map +0 -1
  184. package/src/strand-peer-book-file.ts +0 -39
  185. package/src/strand-peer-book-protocol.ts +0 -463
  186. package/src/strand-peer-book-swap.ts +0 -338
  187. package/src/strand-peer-book.ts +0 -468
  188. package/src/strand-peer-observer.ts +0 -235
@@ -1,500 +1,519 @@
1
- import debug from 'debug';
2
- import { toString as uint8ArrayToString } from 'uint8arrays';
3
- import { generateKeyPair, privateKeyToProtobuf } from '@libp2p/crypto/keys';
4
- import { peerIdFromPrivateKey } from '@libp2p/peer-id';
5
- import type { Libp2p } from '@libp2p/interface';
6
- import { sign } from '@optimystic/quereus-plugin-crypto';
7
- import type {
8
- OpenInvitation,
9
- FormStrandResult,
10
- StrandFormationDisclosure,
11
- StrandMembershipInvite
12
- } from './types.js';
13
- // control-database does not import this service, so these imports introduce no cycle.
14
- import { generateStampId, formationConsentMessage } from './control-database.js';
15
- import { canonicalJson } from './canonical-json.js';
16
- import { ed25519KeyPairFromLibp2p } from './ed25519-key.js';
17
- import { mintPlaceholderStrandId } from './strand-id.js';
18
- import {
19
- StrandFormationManager,
20
- type StrandFormationManagerConfig
21
- } from './strand-formation-manager.js';
22
- import {
23
- isValidResponderCreatesResult,
24
- type FormationResultMessage
25
- } from './strand-formation-protocol.js';
26
-
27
- const log = debug('sereus:cadre:solicitation');
28
-
29
- /**
30
- * Interface for validating formation disclosures
31
- */
32
- export interface DisclosureValidator {
33
- /**
34
- * Validate the disclosure provided by an initiator
35
- * @param token The invitation token
36
- * @param disclosure The disclosure object from the initiator
37
- * @returns Whether the disclosure is acceptable
38
- */
39
- validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
40
- }
41
-
42
- /**
43
- * Discriminated result of resolving the host strand an invite binds to (responder side):
44
- *
45
- * - `unbound`: the invite carries no `StrandId` → the responder-provisions path (provision
46
- * a NEW strand; unbound/open).
47
- * - `bound`: the invite names a host strand AND that strand row is present on this responder
48
- * → provision-then-record (record consent against it, return it + its membership key).
49
- * - `missing`: the invite names a host strand but the row is ABSENT on this responder (e.g.
50
- * not yet converged) → the manager rejects cleanly rather than recording usage against a
51
- * non-existent strand, which would fail the deferred `FormationUsage.StrandExists` CHECK at
52
- * commit and drop the result frame.
53
- *
54
- * Replaces the older `{ strandId, memberPrivateKey } | null` shape, which conflated `bound`
55
- * and `missing` (both produced a non-null result whenever the invite had a `StrandId`).
56
- *
57
- * The invite's `ValidationUrl` (the outside approval hook) is deliberately NOT surfaced here:
58
- * the recorder that performs the write reads it itself, so the choice of which hook to trust
59
- * never travels through a caller.
60
- */
61
- export type ResolvedHostStrand =
62
- | { kind: 'unbound' }
63
- | { kind: 'bound'; strandId: string; memberPrivateKey: string | null }
64
- | { kind: 'missing'; strandId: string };
65
-
66
- /**
67
- * Interface for recording formation usage
68
- */
69
- export interface FormationUsageRecorder {
70
- /**
71
- * Record that a formation invite was used
72
- */
73
- recordUsage(params: {
74
- token: string;
75
- /** The joining peer's own ed25519 public key — written to `FormationUsage.PeerKey` and inside BOTH signed digests. */
76
- peerKey: string;
77
- /** The joiner's signature over the `'consent'` digest — written to `FormationUsage.PeerSig`. */
78
- peerSignature: string;
79
- /** Joiner-minted single-use nonce; both signed digests cover it. */
80
- usageStampId: string;
81
- strandId: string;
82
- /** Exact text to write to `FormationUsage.Disclosure`; joiner and approver both sign these bytes. */
83
- disclosure: string;
84
- /** Aborted when the caller has given up; observed BEFORE any write so the invite stays unspent. */
85
- signal?: AbortSignal;
86
- }): Promise<void>;
87
-
88
- /**
89
- * Check if a token has already been used (for single-use invites)
90
- */
91
- isTokenUsed(token: string): Promise<boolean>;
92
-
93
- /**
94
- * Check if a token is valid and not expired
95
- */
96
- isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }>;
97
-
98
- /**
99
- * Resolve the host strand an invite binds to, classifying it as unbound / bound /
100
- * missing (see {@link ResolvedHostStrand}). Optional: a recorder that does not bind
101
- * strands omits it, and {@link StrandFormationManager} treats every invite as `unbound`
102
- * and falls back to its provisioner. Keeping resolution behind this interface keeps the
103
- * manager DB-agnostic and unit-testable with an in-memory fake.
104
- */
105
- resolveStrand?(token: string): Promise<ResolvedHostStrand>;
106
-
107
- /**
108
- * Provision a NEW strand for an UNBOUND invite and record consent against it
109
- * ATOMICALLY (single `FormationUsage` row → single-use enforced on the next redemption).
110
- * Optional: a recorder that only supports provision-then-record omits it, and the
111
- * manager falls back to its {@link StrandProvisioner}. Mirrors the create-strand-by-consent
112
- * path the schema's `Strand.AuthorizedInsert` `FormationUsage` branch authorizes. Returns the
113
- * minted strand id plus its membership key (null for an open responder-provisioned strand).
114
- */
115
- provisionAndRecord?(params: {
116
- token: string;
117
- /** The joining peer's own ed25519 public key — written to `FormationUsage.PeerKey` and inside BOTH signed digests. */
118
- peerKey: string;
119
- /** The joiner's signature over the `'consent'` digest — written to `FormationUsage.PeerSig`. */
120
- peerSignature: string;
121
- /** Joiner-minted single-use nonce; both signed digests cover it. */
122
- usageStampId: string;
123
- sAppId: string;
124
- /** Exact text to write to `FormationUsage.Disclosure`; joiner and approver both sign these bytes. */
125
- disclosure: string;
126
- /** Aborted when the caller has given up; observed BEFORE any write so the invite stays unspent. */
127
- signal?: AbortSignal;
128
- }): Promise<{ strandId: string; memberPrivateKey: string | null }>;
129
-
130
- /**
131
- * Is ANY invitation this recorder knows about unexpired and not fully
132
- * consumed? Optional: a recorder that cannot enumerate invitations omits it,
133
- * and only locally-minted invitations hold the connection gate's formation
134
- * exemption open. Distinct from the per-token {@link isTokenValid} /
135
- * {@link isTokenUsed} pair — the connection gate has no token to ask about.
136
- */
137
- hasOutstandingInvitation?(): Promise<boolean>;
138
- }
139
-
140
- /**
141
- * Interface for strand provisioning during formation
142
- */
143
- export interface StrandProvisioner {
144
- /**
145
- * Provision a new strand after formation is validated
146
- */
147
- provisionStrand(
148
- sAppId: string,
149
- initiatorKey: string,
150
- responderKey: string
151
- ): Promise<{ strandId: string }>;
152
- }
153
-
154
- /**
155
- * Interface for validating the responder's formation result on the initiator side.
156
- *
157
- * Symmetric to {@link DisclosureValidator} (which runs responder→initiator): it lets
158
- * the initiator verify the responder's disclosed identity, cadre, and provisioned
159
- * strand against the invitation/disclosure it used to form the strand. The manager
160
- * owns one dialer session per `formStrand` call, so the per-session invitation +
161
- * disclosure context is local — no hook-signature gymnastics needed.
162
- */
163
- export interface FormationResponseValidator {
164
- /** Validate the responder's result against the invitation/disclosure used to form the strand. */
165
- validateResponse(ctx: {
166
- invitation: OpenInvitation;
167
- disclosure: StrandFormationDisclosure;
168
- response: FormationResultMessage;
169
- }): Promise<boolean>;
170
- }
171
-
172
- /**
173
- * Built-in structural {@link FormationResponseValidator}.
174
- *
175
- * `validateResponse` rejects when the responder did not approve, omitted a disclosed
176
- * identity, returned no/placeholder cadre addresses, or returned a missing/empty or
177
- * non-responder-created strand (see {@link isValidResponderCreatesResult}).
178
- * Apps can supply a stricter validator via {@link StrandSolicitationServiceOptions}.
179
- */
180
- export function createDefaultFormationResponseValidator(): FormationResponseValidator {
181
- return {
182
- async validateResponse({ response }) {
183
- return isValidResponderCreatesResult(response);
184
- }
185
- };
186
- }
187
-
188
- export interface StrandSolicitationServiceOptions {
189
- disclosureValidator?: DisclosureValidator;
190
- formationUsageRecorder?: FormationUsageRecorder;
191
- strandProvisioner?: StrandProvisioner;
192
- /** Validates the responder's result on the initiator side (defaults to a structural check) */
193
- formationResponseValidator?: FormationResponseValidator;
194
- /** Party ID for this node (used in protocol messages) */
195
- partyId?: string;
196
- /** Cadre peer addresses for this node */
197
- cadrePeerAddrs?: string[];
198
- /**
199
- * This node's live STRAND-network multiaddrs for a strand it is running (responder
200
- * side), carried back to a validated joiner as its cross-party discovery seed. Wired
201
- * by `CadreNode`; unwired, the responder discloses no strand addresses.
202
- */
203
- resolveStrandAddrs?: (strandId: string) => string[];
204
- /**
205
- * Issue a single-use strand membership invitation against the live host strand a
206
- * bound closed-strand redemption resolved to (responder side). Wired by `CadreNode`;
207
- * see `StrandFormationManagerOptions.issueMembershipInvite` for the full contract
208
- * (null = open strand, throw = reject the redemption retryably, unwired = mock/test
209
- * posture: approve with no invitation).
210
- */
211
- issueMembershipInvite?: (strandId: string) => Promise<StrandMembershipInvite | null>;
212
- /** Configuration for the formation manager */
213
- formationConfig?: StrandFormationManagerConfig;
214
- }
215
-
216
- /**
217
- * Strand Solicitation API for forming strands via open invitations.
218
- *
219
- * This service handles the high-level API defined in api.md:
220
- * - formStrand(invitation, disclosure, node) - called by initiator
221
- *
222
- * Outside approval of a redemption (an invite's `ValidationUrl`) is NOT here: it belongs to
223
- * the redeeming node, which alone mints the nonce the approval is bound to. See
224
- * `formation-approval.ts`.
225
- *
226
- * When a libp2p node is provided, the underlying protocol is handled by the
227
- * native cadre-core formation transport via StrandFormationManager.
228
- */
229
- export class StrandSolicitationService {
230
- private readonly disclosureValidator?: DisclosureValidator;
231
- private readonly formationUsageRecorder?: FormationUsageRecorder;
232
- private readonly strandProvisioner?: StrandProvisioner;
233
- private readonly formationResponseValidator?: FormationResponseValidator;
234
- private readonly partyId: string;
235
- private readonly cadrePeerAddrs: string[];
236
- private readonly resolveStrandAddrs?: (strandId: string) => string[];
237
- private readonly issueMembershipInvite?: (strandId: string) => Promise<StrandMembershipInvite | null>;
238
- private formationManager?: StrandFormationManager;
239
- private readonly formationConfig?: StrandFormationManagerConfig;
240
- /**
241
- * Tokens this process has minted (or published), mapped to their expiry
242
- * epoch-ms — the in-memory half of {@link hasOutstandingInvitation}. Dies with
243
- * the process, exactly like `CadreNode`'s enrollment window; a token that also
244
- * reached the durable `FormationInvite` table survives via the recorder.
245
- *
246
- * NOTE: pruned only while {@link hasOutstandingInvitation} runs, so a node that
247
- * mints steadily and never receives an inbound stranger retains every entry for
248
- * the process's life. Bounded by mint rate today; if a host ever mints at scale,
249
- * prune on a timer (or cap the map) instead of only on read.
250
- */
251
- private readonly mintedInvitations = new Map<string, number>();
252
-
253
- constructor(options?: StrandSolicitationServiceOptions) {
254
- this.disclosureValidator = options?.disclosureValidator;
255
- this.formationUsageRecorder = options?.formationUsageRecorder;
256
- this.strandProvisioner = options?.strandProvisioner;
257
- this.formationResponseValidator = options?.formationResponseValidator;
258
- this.partyId = options?.partyId ?? `party-${Date.now()}`;
259
- this.cadrePeerAddrs = options?.cadrePeerAddrs ?? [];
260
- this.resolveStrandAddrs = options?.resolveStrandAddrs;
261
- this.issueMembershipInvite = options?.issueMembershipInvite;
262
- this.formationConfig = options?.formationConfig;
263
- log('StrandSolicitationService created for party: %s', this.partyId);
264
- }
265
-
266
- /**
267
- * Get or create the StrandFormationManager.
268
- * Lazily initialized to allow configuration after construction.
269
- */
270
- private getFormationManager(): StrandFormationManager {
271
- if (!this.formationManager) {
272
- this.formationManager = new StrandFormationManager({
273
- disclosureValidator: this.disclosureValidator,
274
- formationUsageRecorder: this.formationUsageRecorder,
275
- strandProvisioner: this.strandProvisioner,
276
- formationResponseValidator: this.formationResponseValidator,
277
- partyId: this.partyId,
278
- cadrePeerAddrs: this.cadrePeerAddrs,
279
- resolveStrandAddrs: this.resolveStrandAddrs,
280
- issueMembershipInvite: this.issueMembershipInvite,
281
- config: this.formationConfig
282
- });
283
- }
284
- return this.formationManager;
285
- }
286
-
287
- /**
288
- * Register as a responder on a libp2p node.
289
- * This enables the node to handle incoming strand formation requests.
290
- */
291
- registerResponder(node: Libp2p): void {
292
- this.getFormationManager().registerResponder(node);
293
- log('Registered as responder');
294
- }
295
-
296
- /**
297
- * Unregister as a responder from a libp2p node.
298
- */
299
- unregisterResponder(node: Libp2p): void {
300
- this.getFormationManager().unregisterResponder(node);
301
- log('Unregistered as responder');
302
- }
303
-
304
- /**
305
- * Form a strand with a responder via an open invitation.
306
- *
307
- * Called by the initiator (the party who received an out-of-band invitation).
308
- * This generates a member key, contacts the responder's cadre, and negotiates
309
- * strand formation.
310
- *
311
- * @param invitation The open invitation
312
- * @param disclosure Identity/context information to share with the responder
313
- * @param node Optional libp2p node for real protocol handling
314
- * @returns The member key and strand info if successful
315
- */
316
- async formStrand(
317
- invitation: OpenInvitation,
318
- disclosure: StrandFormationDisclosure,
319
- node?: Libp2p
320
- ): Promise<FormStrandResult> {
321
- const token = invitation.token;
322
- log('Forming strand with token: %s', token);
323
-
324
- // Generate a new keypair for this strand membership
325
- const privateKey = await generateKeyPair('Ed25519');
326
- const peerId = peerIdFromPrivateKey(privateKey);
327
- const privateKeyBytes = privateKeyToProtobuf(privateKey);
328
-
329
- const memberKey = peerId.toString();
330
- const invitePrivateKey = uint8ArrayToString(privateKeyBytes, 'base64');
331
-
332
- log('Generated member key: %s', memberKey);
333
-
334
- // If we have a node, use the real protocol
335
- if (node) {
336
- log('Using native cadre-core formation transport');
337
- const { privateKeyB64, publicKeyB64 } = ed25519KeyPairFromLibp2p(privateKey);
338
- const usageStampId = generateStampId(memberKey);
339
- // Built ONCE, AFTER the partyId override: this exact object travels to the
340
- // responder, and its canonical serialization is the disclosure text the consent
341
- // signature covers and the responder writes to `FormationUsage.Disclosure`.
342
- const fullDisclosure = { ...disclosure, partyId: memberKey };
343
- const peerSignature = sign(
344
- formationConsentMessage({
345
- token,
346
- usageStampId,
347
- peerKey: publicKeyB64,
348
- disclosure: canonicalJson(fullDisclosure)
349
- }),
350
- privateKeyB64,
351
- 'ed25519',
352
- 'bytes',
353
- 'base64url',
354
- 'base64url'
355
- ) as string;
356
- const result = await this.getFormationManager().formStrand(
357
- invitation,
358
- fullDisclosure,
359
- { peerKey: publicKeyB64, usageStampId, peerSignature },
360
- node
361
- );
362
- return {
363
- memberKey,
364
- invitePrivateKey,
365
- strandId: result.strandId,
366
- // Carry the host strand's membership key delivered over the protocol (closed-strand
367
- // provision-then-record). invitePrivateKey stays the initiator's generated signing key.
368
- memberPrivateKey: result.memberPrivateKey,
369
- // The joiner's own single-use membership invitation into a closed host strand
370
- // (already shape-checked by the manager); absent for open/unbound.
371
- membershipInvite: result.membershipInvite,
372
- // The responder's live strand-network addresses, or `[]` when it had none to give.
373
- strandAddrs: result.strandAddrs
374
- };
375
- }
376
-
377
- // Fallback: placeholder strandId (for testing without network)
378
- log('No node provided, using placeholder strandId');
379
- const strandId = mintPlaceholderStrandId();
380
-
381
- return {
382
- memberKey,
383
- invitePrivateKey,
384
- strandId,
385
- // No wire, so nothing was disclosed — an empty seed, never absent.
386
- strandAddrs: []
387
- };
388
- }
389
-
390
- /**
391
- * Create an open invitation for others to form strands with this party.
392
- *
393
- * @param sAppId The sApp to use for formed strands
394
- * @param expirationMs How long the invitation is valid (ms from now)
395
- * @param bootstrap Bootstrap addresses for contacting this party's cadre
396
- * @returns The open invitation to share out-of-band
397
- */
398
- async createOpenInvitation(
399
- sAppId: string,
400
- expirationMs: number,
401
- bootstrap: string[]
402
- ): Promise<OpenInvitation> {
403
- // Generate a unique token
404
- const token = `invite-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
405
- const expiration = new Date(Date.now() + expirationMs);
406
- this.registerMintedInvitation(token, expiration.getTime());
407
-
408
- log('Created open invitation: %s (expires %s)', token, expiration.toISOString());
409
-
410
- return {
411
- token,
412
- sAppId,
413
- expiration,
414
- bootstrap
415
- };
416
- }
417
-
418
- /**
419
- * Remember a token this node minted (or published) so
420
- * {@link hasOutstandingInvitation} can answer "yes" before the durable
421
- * `FormationInvite` row is readable — or when there is no durable store at
422
- * all. `expiresAtMs` may be `Number.POSITIVE_INFINITY` for an invitation that
423
- * never expires.
424
- */
425
- registerMintedInvitation(token: string, expiresAtMs: number): void {
426
- this.mintedInvitations.set(token, expiresAtMs);
427
- log('Registered minted invitation %s (expires %d)', token, expiresAtMs);
428
- }
429
-
430
- /**
431
- * Does this node currently expect a stranger — i.e. is at least one open
432
- * invitation unexpired and not yet fully consumed? Sole input to the
433
- * control-network connection gate's formation exemption.
434
- *
435
- * Two sources, in order:
436
- *
437
- * 1. The local mint registry ({@link registerMintedInvitation}), which is
438
- * in-memory and dies with the process. Expired entries are dropped. With
439
- * no {@link FormationUsageRecorder} configured there is no consumption
440
- * oracle, so an unexpired minted token counts as outstanding by
441
- * construction; with one, a token it reports used is deleted (consumption
442
- * is permanent, so the registry never reconsiders it).
443
- * 2. The recorder's optional {@link FormationUsageRecorder.hasOutstandingInvitation},
444
- * which survives restarts and sees invitations replicated in from siblings.
445
- *
446
- * Re-entrant: concurrent inbound connections each call this independently and
447
- * the only mutation is deleting expired/consumed entries, which is monotonic.
448
- */
449
- async hasOutstandingInvitation(): Promise<boolean> {
450
- const now = Date.now();
451
- for (const [token, expiresAtMs] of this.mintedInvitations) {
452
- if (expiresAtMs <= now) {
453
- this.mintedInvitations.delete(token);
454
- continue;
455
- }
456
- if (!this.formationUsageRecorder) {
457
- return true;
458
- }
459
- if (!(await this.formationUsageRecorder.isTokenUsed(token))) {
460
- return true;
461
- }
462
- this.mintedInvitations.delete(token);
463
- }
464
- return (await this.formationUsageRecorder?.hasOutstandingInvitation?.()) ?? false;
465
- }
466
-
467
- /**
468
- * Record that a formation was completed successfully.
469
- * Called after strand provisioning to track usage.
470
- *
471
- * `consent` carries the joiner-signed fields `recordUsage` now requires
472
- * (see {@link FormationUsageRecorder.recordUsage}); this helper only passes them through.
473
- *
474
- * NOTE: this reaches `recordUsage` without passing through
475
- * `StrandFormationManager.provisionAsResponder`, so `disclosure` skips that path's
476
- * `MAX_DISCLOSURE_BYTES` cap. Harmless today — its only callers are integration-test mocks
477
- * that all default to `''` — but if a production caller ever passes a real disclosure here,
478
- * cap it too (the approver signs those bytes verbatim).
479
- */
480
- async recordFormationComplete(
481
- token: string,
482
- peerKey: string,
483
- strandId: string,
484
- consent: { usageStampId: string; peerSignature: string },
485
- disclosure = ''
486
- ): Promise<void> {
487
- if (this.formationUsageRecorder) {
488
- await this.formationUsageRecorder.recordUsage({
489
- token,
490
- peerKey,
491
- peerSignature: consent.peerSignature,
492
- usageStampId: consent.usageStampId,
493
- strandId,
494
- disclosure
495
- });
496
- log('Recorded formation usage: token=%s strand=%s', token, strandId);
497
- }
498
- }
499
- }
500
-
1
+ import debug from 'debug';
2
+ import { toString as uint8ArrayToString } from 'uint8arrays';
3
+ import { generateKeyPair, privateKeyToProtobuf } from '@libp2p/crypto/keys';
4
+ import { peerIdFromPrivateKey } from '@libp2p/peer-id';
5
+ import type { Libp2p } from '@libp2p/interface';
6
+ import { sign } from '@optimystic/quereus-plugin-crypto';
7
+ import type {
8
+ OpenInvitation,
9
+ FormStrandResult,
10
+ StrandFormationDisclosure
11
+ } from './types.js';
12
+ // control-database does not import this service, so these imports introduce no cycle.
13
+ import { generateStampId, formationConsentMessage } from './control-database.js';
14
+ import { canonicalJson } from './canonical-json.js';
15
+ import { ed25519KeyPairFromLibp2p } from './ed25519-key.js';
16
+ import { mintPlaceholderStrandId } from './strand-id.js';
17
+ import {
18
+ StrandFormationManager,
19
+ type MembershipInviteIssuer,
20
+ type StrandFormationManagerConfig
21
+ } from './strand-formation-manager.js';
22
+ import {
23
+ isValidResponderCreatesResult,
24
+ type FormationResultMessage
25
+ } from './strand-formation-protocol.js';
26
+
27
+ const log = debug('sereus:cadre:solicitation');
28
+
29
+ /**
30
+ * Interface for validating formation disclosures
31
+ */
32
+ export interface DisclosureValidator {
33
+ /**
34
+ * Validate the disclosure provided by an initiator
35
+ * @param token The invitation token
36
+ * @param disclosure The disclosure object from the initiator
37
+ * @returns Whether the disclosure is acceptable
38
+ */
39
+ validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
40
+ }
41
+
42
+ /**
43
+ * Discriminated result of resolving the host strand an invite binds to (responder side):
44
+ *
45
+ * - `unbound`: the invite carries no `StrandId` → the responder-provisions path (provision
46
+ * a NEW strand; unbound/open).
47
+ * - `bound`: the invite names a host strand AND that strand row is present on this responder
48
+ * → provision-then-record (record consent against it, return it + its membership key).
49
+ * - `missing`: the invite names a host strand but the row is ABSENT on this responder (e.g.
50
+ * not yet converged) → the manager rejects cleanly rather than recording usage against a
51
+ * non-existent strand, which would fail the deferred `FormationUsage.StrandExists` CHECK at
52
+ * commit and drop the result frame.
53
+ *
54
+ * Replaces the older `{ strandId, memberPrivateKey } | null` shape, which conflated `bound`
55
+ * and `missing` (both produced a non-null result whenever the invite had a `StrandId`).
56
+ *
57
+ * The invite's `ValidationUrl` (the outside approval hook) is deliberately NOT surfaced here:
58
+ * the recorder that performs the write reads it itself, so the choice of which hook to trust
59
+ * never travels through a caller.
60
+ */
61
+ export type ResolvedHostStrand =
62
+ | { kind: 'unbound' }
63
+ | { kind: 'bound'; strandId: string; memberPrivateKey: string | null }
64
+ | { kind: 'missing'; strandId: string };
65
+
66
+ /** One redemption of an invite against an already-existing host strand (the record-only consent write). */
67
+ export interface FormationUsageParams {
68
+ token: string;
69
+ /** The joining peer's own ed25519 public key — written to `FormationUsage.PeerKey` and inside BOTH signed digests. */
70
+ peerKey: string;
71
+ /** The joiner's signature over the `'consent'` digest — written to `FormationUsage.PeerSig`. */
72
+ peerSignature: string;
73
+ /** Joiner-minted single-use nonce; both signed digests cover it. */
74
+ usageStampId: string;
75
+ strandId: string;
76
+ /** Exact text to write to `FormationUsage.Disclosure`; joiner and approver both sign these bytes. */
77
+ disclosure: string;
78
+ /** Aborted when the caller has given up; observed BEFORE any write so the invite stays unspent. */
79
+ signal?: AbortSignal;
80
+ }
81
+
82
+ /** A redemption the recorder has authorized (approval obtained, a seat still free) but not yet written. */
83
+ export interface AuthorizedFormationUsage {
84
+ /** Write the consent row. Abort and seat budget are re-checked inside the write lock. */
85
+ record(): Promise<void>;
86
+ }
87
+
88
+ /**
89
+ * Interface for recording formation usage
90
+ */
91
+ export interface FormationUsageRecorder {
92
+ /**
93
+ * Record that a formation invite was used. Where {@link authorizeUsage} is implemented, this
94
+ * is the same as authorizing and then calling `record()` straight away.
95
+ */
96
+ recordUsage(params: FormationUsageParams): Promise<void>;
97
+
98
+ /**
99
+ * Everything {@link recordUsage} does short of the write: obtain the outside approval (when
100
+ * the invite demands one) and pre-check the seat budget, returning a handle whose `record()`
101
+ * writes the consent row with exactly the fields that were approved. Lets the manager ask
102
+ * before it writes anything into the host strand, and write consent only after.
103
+ * Optional: a recorder without it is treated as having nothing to ask up front.
104
+ */
105
+ authorizeUsage?(params: FormationUsageParams): Promise<AuthorizedFormationUsage>;
106
+
107
+ /**
108
+ * Check if a token has already been used (for single-use invites)
109
+ */
110
+ isTokenUsed(token: string): Promise<boolean>;
111
+
112
+ /**
113
+ * Check if a token is valid and not expired
114
+ */
115
+ isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }>;
116
+
117
+ /**
118
+ * Resolve the host strand an invite binds to, classifying it as unbound / bound /
119
+ * missing (see {@link ResolvedHostStrand}). Optional: a recorder that does not bind
120
+ * strands omits it, and {@link StrandFormationManager} treats every invite as `unbound`
121
+ * and falls back to its provisioner. Keeping resolution behind this interface keeps the
122
+ * manager DB-agnostic and unit-testable with an in-memory fake.
123
+ */
124
+ resolveStrand?(token: string): Promise<ResolvedHostStrand>;
125
+
126
+ /**
127
+ * Provision a NEW strand for an UNBOUND invite and record consent against it
128
+ * ATOMICALLY (single `FormationUsage` row → single-use enforced on the next redemption).
129
+ * Optional: a recorder that only supports provision-then-record omits it, and the
130
+ * manager falls back to its {@link StrandProvisioner}. Mirrors the create-strand-by-consent
131
+ * path the schema's `Strand.AuthorizedInsert` `FormationUsage` branch authorizes. Returns the
132
+ * minted strand id plus its membership key (null for an open responder-provisioned strand).
133
+ */
134
+ provisionAndRecord?(params: {
135
+ token: string;
136
+ /** The joining peer's own ed25519 public key — written to `FormationUsage.PeerKey` and inside BOTH signed digests. */
137
+ peerKey: string;
138
+ /** The joiner's signature over the `'consent'` digest — written to `FormationUsage.PeerSig`. */
139
+ peerSignature: string;
140
+ /** Joiner-minted single-use nonce; both signed digests cover it. */
141
+ usageStampId: string;
142
+ sAppId: string;
143
+ /** Exact text to write to `FormationUsage.Disclosure`; joiner and approver both sign these bytes. */
144
+ disclosure: string;
145
+ /** Aborted when the caller has given up; observed BEFORE any write so the invite stays unspent. */
146
+ signal?: AbortSignal;
147
+ }): Promise<{ strandId: string; memberPrivateKey: string | null }>;
148
+
149
+ /**
150
+ * Is ANY invitation this recorder knows about unexpired and not fully
151
+ * consumed? Optional: a recorder that cannot enumerate invitations omits it,
152
+ * and only locally-minted invitations hold the connection gate's formation
153
+ * exemption open. Distinct from the per-token {@link isTokenValid} /
154
+ * {@link isTokenUsed} pair — the connection gate has no token to ask about.
155
+ */
156
+ hasOutstandingInvitation?(): Promise<boolean>;
157
+ }
158
+
159
+ /**
160
+ * Interface for strand provisioning during formation
161
+ */
162
+ export interface StrandProvisioner {
163
+ /**
164
+ * Provision a new strand after formation is validated
165
+ */
166
+ provisionStrand(
167
+ sAppId: string,
168
+ initiatorKey: string,
169
+ responderKey: string
170
+ ): Promise<{ strandId: string }>;
171
+ }
172
+
173
+ /**
174
+ * Interface for validating the responder's formation result on the initiator side.
175
+ *
176
+ * Symmetric to {@link DisclosureValidator} (which runs responder→initiator): it lets
177
+ * the initiator verify the responder's disclosed identity, cadre, and provisioned
178
+ * strand against the invitation/disclosure it used to form the strand. The manager
179
+ * owns one dialer session per `formStrand` call, so the per-session invitation +
180
+ * disclosure context is local — no hook-signature gymnastics needed.
181
+ */
182
+ export interface FormationResponseValidator {
183
+ /** Validate the responder's result against the invitation/disclosure used to form the strand. */
184
+ validateResponse(ctx: {
185
+ invitation: OpenInvitation;
186
+ disclosure: StrandFormationDisclosure;
187
+ response: FormationResultMessage;
188
+ }): Promise<boolean>;
189
+ }
190
+
191
+ /**
192
+ * Built-in structural {@link FormationResponseValidator}.
193
+ *
194
+ * `validateResponse` rejects when the responder did not approve, omitted a disclosed
195
+ * identity, returned no/placeholder cadre addresses, or returned a missing/empty or
196
+ * non-responder-created strand (see {@link isValidResponderCreatesResult}).
197
+ * Apps can supply a stricter validator via {@link StrandSolicitationServiceOptions}.
198
+ */
199
+ export function createDefaultFormationResponseValidator(): FormationResponseValidator {
200
+ return {
201
+ async validateResponse({ response }) {
202
+ return isValidResponderCreatesResult(response);
203
+ }
204
+ };
205
+ }
206
+
207
+ export interface StrandSolicitationServiceOptions {
208
+ disclosureValidator?: DisclosureValidator;
209
+ formationUsageRecorder?: FormationUsageRecorder;
210
+ strandProvisioner?: StrandProvisioner;
211
+ /** Validates the responder's result on the initiator side (defaults to a structural check) */
212
+ formationResponseValidator?: FormationResponseValidator;
213
+ /** Party ID for this node (used in protocol messages) */
214
+ partyId?: string;
215
+ /** Cadre peer addresses for this node */
216
+ cadrePeerAddrs?: string[];
217
+ /**
218
+ * This node's live STRAND-network multiaddrs for a strand it is running (responder
219
+ * side), carried back to a validated joiner as its cross-party discovery seed. Wired
220
+ * by `CadreNode`; unwired, the responder discloses no strand addresses.
221
+ */
222
+ resolveStrandAddrs?: (strandId: string) => string[];
223
+ /**
224
+ * Issue a single-use strand membership invitation against the live host strand a
225
+ * bound closed-strand redemption resolved to (responder side). Wired by `CadreNode`;
226
+ * see `StrandFormationManagerOptions.issueMembershipInvite` for the full contract
227
+ * (null = open strand, throw = reject the redemption retryably, unwired = mock/test
228
+ * posture: approve with no invitation).
229
+ */
230
+ issueMembershipInvite?: MembershipInviteIssuer;
231
+ /** Configuration for the formation manager */
232
+ formationConfig?: StrandFormationManagerConfig;
233
+ }
234
+
235
+ /**
236
+ * Strand Solicitation API for forming strands via open invitations.
237
+ *
238
+ * This service handles the high-level API defined in api.md:
239
+ * - formStrand(invitation, disclosure, node) - called by initiator
240
+ *
241
+ * Outside approval of a redemption (an invite's `ValidationUrl`) is NOT here: it belongs to
242
+ * the redeeming node, which alone mints the nonce the approval is bound to. See
243
+ * `formation-approval.ts`.
244
+ *
245
+ * When a libp2p node is provided, the underlying protocol is handled by the
246
+ * native cadre-core formation transport via StrandFormationManager.
247
+ */
248
+ export class StrandSolicitationService {
249
+ private readonly disclosureValidator?: DisclosureValidator;
250
+ private readonly formationUsageRecorder?: FormationUsageRecorder;
251
+ private readonly strandProvisioner?: StrandProvisioner;
252
+ private readonly formationResponseValidator?: FormationResponseValidator;
253
+ private readonly partyId: string;
254
+ private readonly cadrePeerAddrs: string[];
255
+ private readonly resolveStrandAddrs?: (strandId: string) => string[];
256
+ private readonly issueMembershipInvite?: MembershipInviteIssuer;
257
+ private formationManager?: StrandFormationManager;
258
+ private readonly formationConfig?: StrandFormationManagerConfig;
259
+ /**
260
+ * Tokens this process has minted (or published), mapped to their expiry
261
+ * epoch-ms — the in-memory half of {@link hasOutstandingInvitation}. Dies with
262
+ * the process, exactly like `CadreNode`'s enrollment window; a token that also
263
+ * reached the durable `FormationInvite` table survives via the recorder.
264
+ *
265
+ * NOTE: pruned only while {@link hasOutstandingInvitation} runs, so a node that
266
+ * mints steadily and never receives an inbound stranger retains every entry for
267
+ * the process's life. Bounded by mint rate today; if a host ever mints at scale,
268
+ * prune on a timer (or cap the map) instead of only on read.
269
+ */
270
+ private readonly mintedInvitations = new Map<string, number>();
271
+
272
+ constructor(options?: StrandSolicitationServiceOptions) {
273
+ this.disclosureValidator = options?.disclosureValidator;
274
+ this.formationUsageRecorder = options?.formationUsageRecorder;
275
+ this.strandProvisioner = options?.strandProvisioner;
276
+ this.formationResponseValidator = options?.formationResponseValidator;
277
+ this.partyId = options?.partyId ?? `party-${Date.now()}`;
278
+ this.cadrePeerAddrs = options?.cadrePeerAddrs ?? [];
279
+ this.resolveStrandAddrs = options?.resolveStrandAddrs;
280
+ this.issueMembershipInvite = options?.issueMembershipInvite;
281
+ this.formationConfig = options?.formationConfig;
282
+ log('StrandSolicitationService created for party: %s', this.partyId);
283
+ }
284
+
285
+ /**
286
+ * Get or create the StrandFormationManager.
287
+ * Lazily initialized to allow configuration after construction.
288
+ */
289
+ private getFormationManager(): StrandFormationManager {
290
+ if (!this.formationManager) {
291
+ this.formationManager = new StrandFormationManager({
292
+ disclosureValidator: this.disclosureValidator,
293
+ formationUsageRecorder: this.formationUsageRecorder,
294
+ strandProvisioner: this.strandProvisioner,
295
+ formationResponseValidator: this.formationResponseValidator,
296
+ partyId: this.partyId,
297
+ cadrePeerAddrs: this.cadrePeerAddrs,
298
+ resolveStrandAddrs: this.resolveStrandAddrs,
299
+ issueMembershipInvite: this.issueMembershipInvite,
300
+ config: this.formationConfig
301
+ });
302
+ }
303
+ return this.formationManager;
304
+ }
305
+
306
+ /**
307
+ * Register as a responder on a libp2p node.
308
+ * This enables the node to handle incoming strand formation requests.
309
+ */
310
+ async registerResponder(node: Libp2p): Promise<void> {
311
+ await this.getFormationManager().registerResponder(node);
312
+ log('Registered as responder');
313
+ }
314
+
315
+ /**
316
+ * Unregister as a responder from a libp2p node.
317
+ */
318
+ async unregisterResponder(node: Libp2p): Promise<void> {
319
+ await this.getFormationManager().unregisterResponder(node);
320
+ log('Unregistered as responder');
321
+ }
322
+
323
+ /**
324
+ * Form a strand with a responder via an open invitation.
325
+ *
326
+ * Called by the initiator (the party who received an out-of-band invitation).
327
+ * This generates a member key, contacts the responder's cadre, and negotiates
328
+ * strand formation.
329
+ *
330
+ * @param invitation The open invitation
331
+ * @param disclosure Identity/context information to share with the responder
332
+ * @param node Optional libp2p node for real protocol handling
333
+ * @returns The member key and strand info if successful
334
+ */
335
+ async formStrand(
336
+ invitation: OpenInvitation,
337
+ disclosure: StrandFormationDisclosure,
338
+ node?: Libp2p
339
+ ): Promise<FormStrandResult> {
340
+ const token = invitation.token;
341
+ log('Forming strand with token: %s', token);
342
+
343
+ // Generate a new keypair for this strand membership
344
+ const privateKey = await generateKeyPair('Ed25519');
345
+ const peerId = peerIdFromPrivateKey(privateKey);
346
+ const privateKeyBytes = privateKeyToProtobuf(privateKey);
347
+
348
+ const memberKey = peerId.toString();
349
+ const invitePrivateKey = uint8ArrayToString(privateKeyBytes, 'base64');
350
+
351
+ log('Generated member key: %s', memberKey);
352
+
353
+ // If we have a node, use the real protocol
354
+ if (node) {
355
+ log('Using native cadre-core formation transport');
356
+ const { privateKeyB64, publicKeyB64 } = ed25519KeyPairFromLibp2p(privateKey);
357
+ const usageStampId = generateStampId(memberKey);
358
+ // Built ONCE, AFTER the partyId override: this exact object travels to the
359
+ // responder, and its canonical serialization is the disclosure text the consent
360
+ // signature covers and the responder writes to `FormationUsage.Disclosure`.
361
+ const fullDisclosure = { ...disclosure, partyId: memberKey };
362
+ const peerSignature = sign(
363
+ formationConsentMessage({
364
+ token,
365
+ usageStampId,
366
+ peerKey: publicKeyB64,
367
+ disclosure: canonicalJson(fullDisclosure)
368
+ }),
369
+ privateKeyB64,
370
+ 'ed25519',
371
+ 'bytes',
372
+ 'base64url',
373
+ 'base64url'
374
+ ) as string;
375
+ const result = await this.getFormationManager().formStrand(
376
+ invitation,
377
+ fullDisclosure,
378
+ { peerKey: publicKeyB64, usageStampId, peerSignature },
379
+ node
380
+ );
381
+ return {
382
+ memberKey,
383
+ invitePrivateKey,
384
+ strandId: result.strandId,
385
+ // Carry the host strand's membership key delivered over the protocol (closed-strand
386
+ // provision-then-record). invitePrivateKey stays the initiator's generated signing key.
387
+ memberPrivateKey: result.memberPrivateKey,
388
+ // The joiner's own single-use membership invitation into a closed host strand
389
+ // (already shape-checked by the manager); absent for open/unbound.
390
+ membershipInvite: result.membershipInvite,
391
+ // The responder's live strand-network addresses, or `[]` when it had none to give.
392
+ strandAddrs: result.strandAddrs
393
+ };
394
+ }
395
+
396
+ // Fallback: placeholder strandId (for testing without network)
397
+ log('No node provided, using placeholder strandId');
398
+ const strandId = mintPlaceholderStrandId();
399
+
400
+ return {
401
+ memberKey,
402
+ invitePrivateKey,
403
+ strandId,
404
+ // No wire, so nothing was disclosed — an empty seed, never absent.
405
+ strandAddrs: []
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Create an open invitation for others to form strands with this party.
411
+ *
412
+ * @param sAppId The sApp to use for formed strands
413
+ * @param expirationMs How long the invitation is valid (ms from now)
414
+ * @param bootstrap Bootstrap addresses for contacting this party's cadre
415
+ * @returns The open invitation to share out-of-band
416
+ */
417
+ async createOpenInvitation(
418
+ sAppId: string,
419
+ expirationMs: number,
420
+ bootstrap: string[]
421
+ ): Promise<OpenInvitation> {
422
+ // Generate a unique token
423
+ const token = `invite-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
424
+ const expiration = new Date(Date.now() + expirationMs);
425
+ this.registerMintedInvitation(token, expiration.getTime());
426
+
427
+ log('Created open invitation: %s (expires %s)', token, expiration.toISOString());
428
+
429
+ return {
430
+ token,
431
+ sAppId,
432
+ expiration,
433
+ bootstrap
434
+ };
435
+ }
436
+
437
+ /**
438
+ * Remember a token this node minted (or published) so
439
+ * {@link hasOutstandingInvitation} can answer "yes" before the durable
440
+ * `FormationInvite` row is readable — or when there is no durable store at
441
+ * all. `expiresAtMs` may be `Number.POSITIVE_INFINITY` for an invitation that
442
+ * never expires.
443
+ */
444
+ registerMintedInvitation(token: string, expiresAtMs: number): void {
445
+ this.mintedInvitations.set(token, expiresAtMs);
446
+ log('Registered minted invitation %s (expires %d)', token, expiresAtMs);
447
+ }
448
+
449
+ /**
450
+ * Does this node currently expect a stranger — i.e. is at least one open
451
+ * invitation unexpired and not yet fully consumed? Sole input to the
452
+ * control-network connection gate's formation exemption.
453
+ *
454
+ * Two sources, in order:
455
+ *
456
+ * 1. The local mint registry ({@link registerMintedInvitation}), which is
457
+ * in-memory and dies with the process. Expired entries are dropped. With
458
+ * no {@link FormationUsageRecorder} configured there is no consumption
459
+ * oracle, so an unexpired minted token counts as outstanding by
460
+ * construction; with one, a token it reports used is deleted (consumption
461
+ * is permanent, so the registry never reconsiders it).
462
+ * 2. The recorder's optional {@link FormationUsageRecorder.hasOutstandingInvitation},
463
+ * which survives restarts and sees invitations replicated in from siblings.
464
+ *
465
+ * Re-entrant: concurrent inbound connections each call this independently and
466
+ * the only mutation is deleting expired/consumed entries, which is monotonic.
467
+ */
468
+ async hasOutstandingInvitation(): Promise<boolean> {
469
+ const now = Date.now();
470
+ for (const [token, expiresAtMs] of this.mintedInvitations) {
471
+ if (expiresAtMs <= now) {
472
+ this.mintedInvitations.delete(token);
473
+ continue;
474
+ }
475
+ if (!this.formationUsageRecorder) {
476
+ return true;
477
+ }
478
+ if (!(await this.formationUsageRecorder.isTokenUsed(token))) {
479
+ return true;
480
+ }
481
+ this.mintedInvitations.delete(token);
482
+ }
483
+ return (await this.formationUsageRecorder?.hasOutstandingInvitation?.()) ?? false;
484
+ }
485
+
486
+ /**
487
+ * Record that a formation was completed successfully.
488
+ * Called after strand provisioning to track usage.
489
+ *
490
+ * `consent` carries the joiner-signed fields `recordUsage` now requires
491
+ * (see {@link FormationUsageRecorder.recordUsage}); this helper only passes them through.
492
+ *
493
+ * NOTE: this reaches `recordUsage` without passing through
494
+ * `StrandFormationManager.provisionAsResponder`, so `disclosure` skips that path's
495
+ * `MAX_DISCLOSURE_BYTES` cap. Harmless today — its only callers are integration-test mocks
496
+ * that all default to `''` — but if a production caller ever passes a real disclosure here,
497
+ * cap it too (the approver signs those bytes verbatim).
498
+ */
499
+ async recordFormationComplete(
500
+ token: string,
501
+ peerKey: string,
502
+ strandId: string,
503
+ consent: { usageStampId: string; peerSignature: string },
504
+ disclosure = ''
505
+ ): Promise<void> {
506
+ if (this.formationUsageRecorder) {
507
+ await this.formationUsageRecorder.recordUsage({
508
+ token,
509
+ peerKey,
510
+ peerSignature: consent.peerSignature,
511
+ usageStampId: consent.usageStampId,
512
+ strandId,
513
+ disclosure
514
+ });
515
+ log('Recorded formation usage: token=%s strand=%s', token, strandId);
516
+ }
517
+ }
518
+ }
519
+