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