@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,700 +1,725 @@
1
- import debug from 'debug';
2
- import { fromString as uint8ArrayFromString } from 'uint8arrays';
3
- import type { Libp2p } from '@libp2p/interface';
4
- import type {
5
- OpenInvitation,
6
- FormStrandResult,
7
- StrandFormationDisclosure,
8
- StrandMembershipInvite
9
- } from './types.js';
10
- import {
11
- FormationApprovalError,
12
- type FormationApprovalFailure
13
- } from './formation-approval.js';
14
- // control-database does not import this manager, so this import introduces no cycle.
15
- import { FormationAbortedError, InvitationExhaustedError } from './control-database.js';
16
- import { PreSplitStrandIdentityError } from './strand-membership-writer.js';
17
- import { mintPlaceholderStrandId } from './strand-id.js';
18
- import { canonicalJson } from './canonical-json.js';
19
- import type {
20
- AuthorizedFormationUsage,
21
- DisclosureValidator,
22
- FormationUsageParams,
23
- FormationUsageRecorder,
24
- ResolvedHostStrand,
25
- StrandProvisioner,
26
- FormationResponseValidator
27
- } from './strand-solicitation.js';
28
- import {
29
- FormationListener,
30
- INVALID_TOKEN_REASON,
31
- dialFormation,
32
- isValidResponderCreatesResult,
33
- isWellFormedMembershipInvite,
34
- type FormationContactMessage,
35
- type FormationProvisionResult,
36
- type FormationResultMessage,
37
- type ResponderProvisionOutcome
38
- } from './strand-formation-protocol.js';
39
- import { formationDeadlines } from './strand-formation-deadlines.js';
40
-
41
- const log = debug('sereus:cadre:formation-manager');
42
-
43
- /**
44
- * Largest serialized disclosure a responder will accept (8 KiB). The disclosure is
45
- * attacker-influenced text destined for a replicated table (`FormationUsage.Disclosure`)
46
- * and for the approval hook's signed digest — the cap bounds both, and is enforced BEFORE
47
- * any database read or hook contact so an oversized blob costs nothing downstream.
48
- */
49
- const MAX_DISCLOSURE_BYTES = 8 * 1024;
50
-
51
- /**
52
- * How long a formation-issued strand membership invitation stays redeemable (7 days).
53
- * The responder's {@link StrandFormationManagerOptions.issueMembershipInvite} hook stamps
54
- * `now + this` as the `Strand.Invite.Expiration`. Long enough for a slow joiner to bring
55
- * its strand node up and redeem; short enough that a lost formation result does not leave
56
- * a live bearer credential in the strand forever. An expired invitation rolls the
57
- * redemption back cleanly (the schema's `NotExpired` gate) — the joiner's recovery is a
58
- * fresh formation, which issues a fresh invitation.
59
- */
60
- export const MEMBERSHIP_INVITE_TTL_MS = 7 * 24 * 3600_000;
61
-
62
- /**
63
- * Rejection reason for a bound CLOSED-strand redemption whose responder could not issue
64
- * the joiner's membership invitation — the host strand runtime not live on this
65
- * responder (never launched, mid-start, or a hibernating one that did not wake within the
66
- * provisioning budget; a hibernating runtime is woken, not refused), no party identity key
67
- * yet, or the strand DB refusing the `Strand.Invite` write. Retryable on purpose, and
68
- * rejected BEFORE the consent row is recorded, so the formation token stays unspent:
69
- * approving without an invitation would admit a joiner that looks joined but can never
70
- * become a member, and a responder not running the strand cannot serve the joiner's sync
71
- * anyway. The one permanent failure, a pre-split host strand, gets
72
- * {@link HOST_STRAND_MUST_BE_RECREATED_REASON} instead.
73
- */
74
- export const MEMBERSHIP_INVITE_UNAVAILABLE_REASON = 'Strand membership invitation unavailable, retry';
75
-
76
- /**
77
- * Rejection reason for a bound CLOSED-strand redemption whose host strand was founded
78
- * before the per-party identity split: the responder's founder launch refused it
79
- * (`PreSplitStrandIdentityError`), so no invitation can ever be issued into it. Not
80
- * retryable — the host must recreate the strand and issue a fresh invitation. Like
81
- * {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON}, rejected before consent is recorded, so
82
- * the formation token stays unspent.
83
- */
84
- export const HOST_STRAND_MUST_BE_RECREATED_REASON = 'Host strand must be recreated';
85
-
86
- /**
87
- * Rejection reason a would-be joiner is told for each approval-failure category
88
- * (see {@link FormationApprovalFailure}). Distinct per category on purpose: mapping any
89
- * of these onto the generic 'Formation conflict, retry' would tell an operator to retry
90
- * what retrying can never fix (e.g. a non-enrolled validation key).
91
- */
92
- const APPROVAL_REJECTION_REASONS: Record<FormationApprovalFailure, string> = {
93
- refused: 'Formation approval refused',
94
- unavailable: 'Formation approval unavailable, retry',
95
- malformed: 'Formation approval invalid',
96
- unenrolled: 'Formation approval key is not enrolled',
97
- misconfigured: 'Formation approval misconfigured'
98
- };
99
-
100
- /**
101
- * Responder-side seam that issues a bound joiner's membership invitation — see
102
- * {@link StrandFormationManagerOptions.issueMembershipInvite} for the contract. `signal`
103
- * is the responder's provisioning budget: the issuer stops waiting (and throws) once it
104
- * aborts.
105
- */
106
- export type MembershipInviteIssuer = (strandId: string, signal?: AbortSignal) => Promise<StrandMembershipInvite | null>;
107
-
108
- /**
109
- * Configuration for StrandFormationManager
110
- */
111
- export interface StrandFormationManagerConfig {
112
- /**
113
- * This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
114
- * every formation deadline is derived (`formationDeadlines` in
115
- * `strand-formation-deadlines.ts`). `CadreNode` fills it from its network config; the
116
- * fields below override single rungs of that ladder.
117
- */
118
- linkRoundTripMs?: number;
119
- /** Whole-session budget, both roles, in milliseconds. */
120
- sessionTimeoutMs?: number;
121
- /** The responder's contact-frame read, in milliseconds. */
122
- stepTimeoutMs?: number;
123
- /** The initiator's dial-connect (open the connection, negotiate the protocol), in milliseconds. */
124
- dialTimeoutMs?: number;
125
- /**
126
- * Provisioning budget in milliseconds for the RESPONDER's `provisionStrand` hook call.
127
- * The initiator's `await-response` wait is derived automatically from this value plus the
128
- * travel margin of the derived ladder (`responseTravelMarginMs`) — never set directly — so
129
- * the ordering documented there (approval hook < responder provisioning < initiator
130
- * await-response < session) cannot collapse when this is configured.
131
- */
132
- provisionTimeoutMs?: number;
133
- /** Maximum concurrent sessions */
134
- maxConcurrentSessions?: number;
135
- /** Enable debug logging */
136
- enableDebugLogging?: boolean;
137
- /** Protocol ID override */
138
- protocolId?: string;
139
- }
140
-
141
- /**
142
- * Options for creating a StrandFormationManager
143
- */
144
- export interface StrandFormationManagerOptions {
145
- /** Validates disclosures from initiators (responder side) */
146
- disclosureValidator?: DisclosureValidator;
147
- /** Records and validates token usage (responder side) */
148
- formationUsageRecorder?: FormationUsageRecorder;
149
- /** Provisions strands after validation (responder side) */
150
- strandProvisioner?: StrandProvisioner;
151
- /** Validates the responder's result (initiator side); defaults to a structural check */
152
- formationResponseValidator?: FormationResponseValidator;
153
- /** This party's ID for identification */
154
- partyId: string;
155
- /** This party's cadre peer addresses */
156
- cadrePeerAddrs?: string[];
157
- /**
158
- * This node's live STRAND-network multiaddrs for a strand it is running (responder
159
- * side). Wired by `CadreNode` to its own per-strand address lookup; the manager has no
160
- * route to the strand runtime itself.
161
- *
162
- * A hook, not a `config` knob, so it sits with the other responder-side seams
163
- * (`strandProvisioner`, `formationUsageRecorder`) rather than among the timeouts. Left
164
- * unwired — every mock/transport test — the responder simply discloses no strand
165
- * addresses and cross-party joiners fall back to an empty seed.
166
- */
167
- resolveStrandAddrs?: (strandId: string) => string[];
168
- /**
169
- * Issue a single-use `Strand.Invite` against the live host strand a BOUND redemption
170
- * resolved to (responder side) — the seam that gets the JOINER its own membership.
171
- * Like {@link resolveStrandAddrs}, the formation layer cannot reach the strand
172
- * runtime itself, so `CadreNode` wires this to its own running-instance +
173
- * `StrandPartyKey` lookup.
174
- *
175
- * Contract: return the issued invitation for a CLOSED host strand; return `null`
176
- * for an open one (no members, nothing to invite into); THROW when a closed strand's
177
- * invitation cannot be issued (runtime not live, no party key, strand DB write
178
- * rejected) — the manager then rejects the whole redemption with
179
- * {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON} BEFORE recording consent, so the
180
- * formation token stays unspent and the joiner can retry. Throw a
181
- * `PreSplitStrandIdentityError` for a host strand that can never issue one; that maps
182
- * to the non-retryable {@link HOST_STRAND_MUST_BE_RECREATED_REASON}.
183
- *
184
- * A hibernating host runtime is not a reason to throw: the issuer wakes it first,
185
- * bounded by `signal` (the responder's provisioning budget), and throws only when the
186
- * wake does not finish in time or fails.
187
- *
188
- * Left unwired — mock/transport tests — the bound path approves with no invitation,
189
- * mirroring the unwired {@link resolveStrandAddrs} posture. Production
190
- * (`CadreNode.initializeStrandSolicitation`) always wires it.
191
- */
192
- issueMembershipInvite?: MembershipInviteIssuer;
193
- /** Configuration options */
194
- config?: StrandFormationManagerConfig;
195
- }
196
-
197
- /**
198
- * StrandFormationManager drives the native cadre-core formation transport
199
- * (`strand-formation-protocol.ts`) from cadre-core's strand-solicitation interfaces.
200
- *
201
- * Responder side: a {@link FormationListener} wires the inbound protocol to
202
- * {@link FormationUsageRecorder} (token), {@link DisclosureValidator} (identity),
203
- * and {@link StrandProvisioner} (provisioning), disclosing this party's real
204
- * identity + cadre only after validation.
205
- *
206
- * Initiator side: {@link formStrand} dials the responder carrying the real
207
- * disclosure/token/cadre, then validates the responder's result via the
208
- * {@link FormationResponseValidator} (or a built-in structural check).
209
- */
210
- export class StrandFormationManager {
211
- private readonly disclosureValidator?: DisclosureValidator;
212
- private readonly formationUsageRecorder?: FormationUsageRecorder;
213
- private readonly strandProvisioner?: StrandProvisioner;
214
- private readonly formationResponseValidator?: FormationResponseValidator;
215
- private readonly partyId: string;
216
- private readonly cadrePeerAddrs: string[];
217
- private readonly resolveStrandAddrs?: (strandId: string) => string[];
218
- private readonly issueMembershipInvite?: MembershipInviteIssuer;
219
- private readonly config: StrandFormationManagerConfig;
220
- private readonly listener: FormationListener;
221
- private readonly registeredNodes = new Set<Libp2p>();
222
- private dialerSessions = 0;
223
-
224
- constructor(options: StrandFormationManagerOptions) {
225
- this.disclosureValidator = options.disclosureValidator;
226
- this.formationUsageRecorder = options.formationUsageRecorder;
227
- this.strandProvisioner = options.strandProvisioner;
228
- this.formationResponseValidator = options.formationResponseValidator;
229
- this.partyId = options.partyId;
230
- this.cadrePeerAddrs = options.cadrePeerAddrs ?? [];
231
- this.resolveStrandAddrs = options.resolveStrandAddrs;
232
- this.issueMembershipInvite = options.issueMembershipInvite;
233
- this.config = options.config ?? {};
234
-
235
- this.listener = new FormationListener({
236
- validateToken: (token) => this.validateToken(token),
237
- validateDisclosure: (token, disclosure) => this.validateDisclosure(token, disclosure),
238
- provisionStrand: (contact, signal) =>
239
- this.provisionAsResponder(contact, signal),
240
- getResponderIdentity: () => ({ partyId: this.partyId, cadrePeerAddrs: this.cadrePeerAddrs }),
241
- // Forwarded only when wired, so `FormationListenerOptions.resolveStrandAddrs`
242
- // stays genuinely absent (and the listener short-circuits) for an unwired manager.
243
- ...(this.resolveStrandAddrs && { resolveStrandAddrs: this.resolveStrandAddrs }),
244
- linkRoundTripMs: this.config.linkRoundTripMs,
245
- sessionTimeoutMs: this.config.sessionTimeoutMs,
246
- stepTimeoutMs: this.config.stepTimeoutMs,
247
- provisionTimeoutMs: this.config.provisionTimeoutMs,
248
- maxConcurrentSessions: this.config.maxConcurrentSessions
249
- });
250
-
251
- log('StrandFormationManager created for party: %s', this.partyId);
252
- }
253
-
254
- /**
255
- * Register this manager as a protocol handler on a libp2p node.
256
- * Call this on the control network node to handle incoming formation requests.
257
- */
258
- async registerResponder(node: Libp2p, protocolId?: string): Promise<void> {
259
- if (this.registeredNodes.has(node)) {
260
- log('Node already registered');
261
- return;
262
- }
263
- await this.listener.register(node, protocolId ?? this.config.protocolId);
264
- this.registeredNodes.add(node);
265
- log('Registered as responder on node');
266
- }
267
-
268
- /**
269
- * Unregister the protocol handler from a libp2p node.
270
- */
271
- async unregisterResponder(node: Libp2p, protocolId?: string): Promise<void> {
272
- if (!this.registeredNodes.has(node)) {
273
- return;
274
- }
275
- await this.listener.unregister(node, protocolId ?? this.config.protocolId);
276
- this.registeredNodes.delete(node);
277
- log('Unregistered from node');
278
- }
279
-
280
- /**
281
- * Form a strand with a responder via an open invitation (initiator side).
282
- *
283
- * Builds a contact message carrying the real token + disclosure + this party's
284
- * real cadre addresses + the joiner's consent (minted and signed by the
285
- * solicitation layer — this manager only places it on the contact), dials the
286
- * responder over the native protocol, and validates the responder's result
287
- * before returning.
288
- */
289
- async formStrand(
290
- invitation: OpenInvitation,
291
- disclosure: StrandFormationDisclosure,
292
- consent: { peerKey: string; usageStampId: string; peerSignature: string },
293
- node: Libp2p
294
- ): Promise<FormStrandResult> {
295
- log('Forming strand with invitation token: %s', invitation.token);
296
-
297
- const contact: FormationContactMessage = {
298
- token: invitation.token,
299
- partyId: disclosure.partyId ?? this.partyId,
300
- peerKey: consent.peerKey,
301
- usageStampId: consent.usageStampId,
302
- peerSignature: consent.peerSignature,
303
- disclosure,
304
- cadrePeerAddrs: this.cadrePeerAddrs
305
- };
306
-
307
- this.dialerSessions++;
308
- try {
309
- const dialed = await dialFormation(node, {
310
- contact,
311
- responderAddrs: invitation.bootstrap,
312
- validateResponse: (response) => this.validateResponse(invitation, disclosure, response),
313
- linkRoundTripMs: this.config.linkRoundTripMs,
314
- sessionTimeoutMs: this.config.sessionTimeoutMs,
315
- dialTimeoutMs: this.config.dialTimeoutMs,
316
- provisionTimeoutMs: this.initiatorProvisionTimeoutMs(),
317
- protocolId: this.config.protocolId
318
- });
319
-
320
- const provision = dialed.provision;
321
- log('Strand formed: %s (%d cross-party strand addr(s))', provision.strand.strandId, dialed.strandAddrs.length);
322
-
323
- return {
324
- memberKey: contact.partyId,
325
- invitePrivateKey: '',
326
- strandId: provision.strand.strandId,
327
- // The host strand's membership key, delivered through the protocol (provision-then-record).
328
- // Undefined for an open strand. Kept separate from invitePrivateKey (the initiator's
329
- // generated signing key), which is set by the StrandSolicitationService layer.
330
- memberPrivateKey: provision.memberPrivateKey,
331
- // Carried only when well-formed. The DEFAULT response validator already rejects
332
- // a malformed one; this re-check keeps a custom validator that skips the shape
333
- // check from feeding a hostile responder's object into the joiner's invite cache.
334
- membershipInvite: this.membershipInviteFrom(provision),
335
- // Possibly empty — the responder may hold no live strand node yet.
336
- strandAddrs: dialed.strandAddrs
337
- };
338
- } finally {
339
- this.dialerSessions--;
340
- }
341
- }
342
-
343
- /**
344
- * Get the number of active sessions
345
- */
346
- getActiveSessionCounts(): { listeners: number; dialers: number } {
347
- return { listeners: this.listener.activeCount, dialers: this.dialerSessions };
348
- }
349
-
350
- /**
351
- * The provision result's membership invitation, iff well-formed — a malformed one
352
- * (possible only past a custom {@link FormationResponseValidator} that skipped the
353
- * structural check) is dropped with a log rather than cached: it could never redeem
354
- * anyway, and carrying an unbounded hostile object outlives the session.
355
- */
356
- private membershipInviteFrom(provision: FormationProvisionResult): StrandMembershipInvite | undefined {
357
- if (provision.membershipInvite === undefined) {
358
- return undefined;
359
- }
360
- if (!isWellFormedMembershipInvite(provision.membershipInvite)) {
361
- log('formStrand: dropping malformed membershipInvite from responder for strand %s', provision.strand.strandId);
362
- return undefined;
363
- }
364
- return { inviteKey: provision.membershipInvite.inviteKey, invitePrivateKey: provision.membershipInvite.invitePrivateKey };
365
- }
366
-
367
- /**
368
- * Derive the initiator's await-response budget from the configured RESPONDER budget —
369
- * never the same number (see `StrandFormationManagerConfig.provisionTimeoutMs`): the
370
- * configured value plus the travel margin the ladder derives at this machine's link.
371
- * Mirrors `resolveProvisionTimeoutMs`'s own "`0`/negative means unset" rule so an unset
372
- * config still lets both sides fall back to their own derived defaults; must NOT
373
- * reproduce the derived default here, or the per-role clamping downstream is defeated.
374
- * A value too large for the session is clamped per role, and the responder's ceiling holds
375
- * the margin back, so the ordering survives there too.
376
- */
377
- private initiatorProvisionTimeoutMs(): number | undefined {
378
- const host = this.config.provisionTimeoutMs;
379
- return host && host > 0
380
- ? host + formationDeadlines(this.config.linkRoundTripMs).responseTravelMarginMs
381
- : undefined;
382
- }
383
-
384
- // ── Responder-side hooks ─────────────────────────────────────────────────────
385
-
386
- private async validateToken(token: string): Promise<{ valid: boolean }> {
387
- if (!this.formationUsageRecorder) {
388
- // No recorder configured — accept all tokens.
389
- return { valid: true };
390
- }
391
-
392
- const tokenCheck = await this.formationUsageRecorder.isTokenValid(token);
393
- if (!tokenCheck.valid) {
394
- log('Token invalid: %s', token);
395
- return { valid: false };
396
- }
397
-
398
- if (await this.formationUsageRecorder.isTokenUsed(token)) {
399
- log('Token already used: %s', token);
400
- return { valid: false };
401
- }
402
-
403
- return { valid: true };
404
- }
405
-
406
- private async validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean> {
407
- if (!this.disclosureValidator) {
408
- // No validator configured — accept all disclosures.
409
- return true;
410
- }
411
- return this.disclosureValidator.validateDisclosure(token, disclosure);
412
- }
413
-
414
- /**
415
- * Responder-side provisioning. Routes on how the invite resolves
416
- * ({@link ResolvedHostStrand}), and can REJECT post-validation by returning a
417
- * {@link ResponderProvisionOutcome} with `approved: false` — `runSession` turns that
418
- * into a clean, non-disclosing reply instead of a dropped result frame.
419
- *
420
- * - **bound** (host strand present): provision-then-record, in three steps —
421
- * (1) authorize ({@link authorizeBoundUsage}: the outside approval when the invite
422
- * demands one, and a seat pre-check), so a refused join writes nothing into the host
423
- * strand; (2) issue the joiner's single-use strand membership invitation via the wired
424
- * `issueMembershipInvite` seam ({@link issueBoundMembershipInvite}; a closed strand whose
425
- * invitation cannot be issued rejects retryably HERE, before any consent row spends the
426
- * token); (3) write the single `FormationUsage` consent row against the pre-existing
427
- * strand (record-only) and return it + its membership key + the invitation (all
428
- * read-gating secrets disclosed only here, behind the token + disclosure validation
429
- * `runSession` already enforced).
430
- * - **missing** (invite names a host strand absent on this responder, e.g. unconverged):
431
- * reject cleanly + retryably, writing NO usage row — recording usage here would fail the
432
- * deferred `StrandExists` CHECK at commit and drop the frame.
433
- * - **unbound** (no binding): the responder-provisions fallback — see {@link provisionUnbound}.
434
- *
435
- * Defense-in-depth: known provisioning/redeem failures are caught and mapped to a logged
436
- * protocol rejection rather than a thrown insert + a silently-closed stream. The mapping is
437
- * per-failure, NOT blanket retry-suggesting: an exhausted invitation
438
- * ({@link InvitationExhaustedError}, raised by `ControlDatabase` when the count of recorded
439
- * uses has reached the invite's seat budget — which is how the loser of a same-node race
440
- * surfaces, the local write queue having serialized the two writes) is reported as
441
- * `'Invalid token'`, because retrying it can never succeed. Concurrent redemptions never
442
- * contend for a shared row key — each writes under its own `UsageStampId` — so there is no
443
- * key collision to surface here at all. The LOG-before-reject keeps this a deliberate
444
- * internal-error→protocol-rejection conversion (AGENTS.md: don't eat exceptions silently),
445
- * not control-flow-by-exception.
446
- */
447
- private async provisionAsResponder(
448
- contact: FormationContactMessage,
449
- signal?: AbortSignal
450
- ): Promise<ResponderProvisionOutcome> {
451
- const token = contact.token;
452
- // CANONICALLY serialized — both sides derive the identical string: this is the exact
453
- // text the joiner signed (consent), the approver signs (vouch), and the recorder writes
454
- // to `FormationUsage.Disclosure` — a re-serialization anywhere below would break the
455
- // signature-over-stored-bytes invariant.
456
- const disclosureText = canonicalJson(contact.disclosure);
457
- if (uint8ArrayFromString(disclosureText, 'utf8').byteLength > MAX_DISCLOSURE_BYTES) {
458
- log('Disclosure over %d bytes; rejecting token %s', MAX_DISCLOSURE_BYTES, token);
459
- return { approved: false, reason: 'Disclosure too large' };
460
- }
461
-
462
- const recorder = this.formationUsageRecorder;
463
- const resolved: ResolvedHostStrand = recorder?.resolveStrand
464
- ? await recorder.resolveStrand(token)
465
- : { kind: 'unbound' };
466
-
467
- try {
468
- switch (resolved.kind) {
469
- case 'bound': {
470
- // recorder is guaranteed non-null here: only resolveStrand can yield 'bound'.
471
- const authorized = await this.authorizeBoundUsage(recorder!, {
472
- token,
473
- peerKey: contact.peerKey,
474
- peerSignature: contact.peerSignature,
475
- usageStampId: contact.usageStampId,
476
- strandId: resolved.strandId,
477
- disclosure: disclosureText,
478
- signal
479
- });
480
- // Issued only once authorized (a refused join writes nothing into the strand) and
481
- // BEFORE consent is recorded (an issue failure leaves the token unspent, retryable).
482
- // The remaining window — invite issued, then record() fails or aborts (a write that
483
- // exhausted its retries, an abort, or the same-node seat race noted at
484
- // `ControlFormationUsageRecorder.assertSeatFree`) — orphans a
485
- // live `Strand.Invite` no joiner ever received; bounded deliberately by its expiry
486
- // ({@link MEMBERSHIP_INVITE_TTL_MS}) rather than compensated, since nothing
487
- // here can atomically un-issue a strand-DB row.
488
- const issued = await this.issueBoundMembershipInvite(token, resolved.strandId, signal);
489
- if (!issued.ok) {
490
- return { approved: false, reason: issued.reason };
491
- }
492
- await authorized.record();
493
- return this.approve({
494
- strand: { strandId: resolved.strandId, createdBy: 'responder' },
495
- memberPrivateKey: resolved.memberPrivateKey ?? undefined,
496
- // Omitted (not sent undefined-valued) so rejection-parity and open-strand
497
- // assertions stay plain `toBeUndefined()` on the parsed frame.
498
- ...(issued.invite ? { membershipInvite: issued.invite } : {}),
499
- dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
500
- });
501
- }
502
- case 'missing': {
503
- log('Host strand %s not yet available on this responder; rejecting token %s', resolved.strandId, token);
504
- return { approved: false, reason: 'Host strand not yet available on this responder' };
505
- }
506
- case 'unbound':
507
- return await this.provisionUnbound(contact, disclosureText, signal);
508
- }
509
- } catch (err) {
510
- if (err instanceof FormationAbortedError) {
511
- // The listener's timeout path owns the reply for an abandoned provisioning; mapping
512
- // this onto 'Formation conflict, retry' would misreport it as a conflict.
513
- throw err;
514
- }
515
- if (err instanceof FormationApprovalError) {
516
- log('approval failed (%s) for token %s: %o', err.failure, token, err);
517
- return { approved: false, reason: APPROVAL_REJECTION_REASONS[err.failure] };
518
- }
519
- if (err instanceof InvitationExhaustedError) {
520
- // Shares `INVALID_TOKEN_REASON` with the up-front `validateToken` rejection so the loser
521
- // of the race that exposed the spent invite sees exactly what a non-racing latecomer
522
- // sees. No wire-visible distinction between "invalid" and "exhausted" — the
523
- // operator signal lives in this log line instead.
524
- // NOTE: if a joining client ever has to tell "never valid" from "used up" WITHOUT node
525
- // logs, that is a new protocol reason string, not a local change here.
526
- log(
527
- 'invitation exhausted for token %s: %d of %d use(s) already recorded',
528
- err.token,
529
- err.usesRecorded,
530
- err.totalUses
531
- );
532
- return { approved: false, reason: INVALID_TOKEN_REASON };
533
- }
534
- // An approval is never discarded to a lost key race: each redemption writes under its
535
- // own `UsageStampId`, so no other writer can take its row key. Reaching this catch-all
536
- // means the failure was never retryable at the database layer to begin with (or the
537
- // transient-cluster retry inside `ControlDatabase.lockedWithRetry` ran out).
538
- log('provisionAsResponder failed for token %s: %o', token, err);
539
- return { approved: false, reason: 'Formation conflict, retry' };
540
- }
541
- }
542
-
543
- /**
544
- * Authorize a BOUND redemption without writing it, through the recorder's optional
545
- * {@link FormationUsageRecorder.authorizeUsage}. A recorder without it has nothing to ask up
546
- * front, so its handle defers everything to `recordUsage`.
547
- */
548
- private async authorizeBoundUsage(
549
- recorder: FormationUsageRecorder,
550
- params: FormationUsageParams
551
- ): Promise<AuthorizedFormationUsage> {
552
- if (recorder.authorizeUsage) {
553
- return await recorder.authorizeUsage(params);
554
- }
555
- return { record: () => recorder.recordUsage(params) };
556
- }
557
-
558
- /**
559
- * Issue the joiner's membership invitation for a BOUND redemption via the wired
560
- * {@link StrandFormationManagerOptions.issueMembershipInvite} seam. Runs after the
561
- * redemption is authorized ({@link authorizeBoundUsage}) and before consent is recorded.
562
- *
563
- * - Hook unwired (mock/transport tests): approve with no invitation, the same posture
564
- * as an unwired `resolveStrandAddrs`.
565
- * - Hook returns an invitation (closed host strand): carry it on the approval.
566
- * - Hook returns `null` (open host strand): approve with no invitation.
567
- * - Hook throws (runtime not live — including a hibernating one whose wake outran
568
- * `signal` — no party key, strand-DB write rejected): report
569
- * `ok: false` with {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON} — the caller rejects
570
- * BEFORE any consent row is written, so the formation token stays unspent.
571
- * - Hook throws `PreSplitStrandIdentityError` (the host strand was founded before the
572
- * per-party identity split and can never issue one): same, but with the
573
- * non-retryable {@link HOST_STRAND_MUST_BE_RECREATED_REASON}.
574
- *
575
- * The LOG-before-reject keeps both a deliberate internal-error→protocol-rejection
576
- * conversion, same as the catch-all in {@link provisionAsResponder}.
577
- */
578
- private async issueBoundMembershipInvite(
579
- token: string,
580
- strandId: string,
581
- signal?: AbortSignal
582
- ): Promise<{ ok: true; invite?: StrandMembershipInvite } | { ok: false; reason: string }> {
583
- if (!this.issueMembershipInvite) {
584
- return { ok: true };
585
- }
586
- try {
587
- const invite = await this.issueMembershipInvite(strandId, signal);
588
- return { ok: true, invite: invite ?? undefined };
589
- } catch (err) {
590
- if (err instanceof PreSplitStrandIdentityError) {
591
- log('host strand %s is pre-split (token %s); rejecting — it must be recreated: %o', strandId, token, err);
592
- return { ok: false, reason: HOST_STRAND_MUST_BE_RECREATED_REASON };
593
- }
594
- log('membership-invite issue for strand %s failed (token %s); rejecting retryably: %o', strandId, token, err);
595
- return { ok: false, reason: MEMBERSHIP_INVITE_UNAVAILABLE_REASON };
596
- }
597
- }
598
-
599
- /**
600
- * Responder-provisions fallback (invite binds no host strand). Precedence:
601
- *
602
- * 1. `recorder.provisionAndRecord` present (a real DB recorder) → atomic create-strand +
603
- * record-consent, so the unbound redemption is single-use just like the bound path.
604
- * Returns the new strand (+ key, null for an open responder-provisioned strand).
605
- * 2. else `strandProvisioner` present → the mock/transport-test contract: provision a bare
606
- * strand, NO inline usage write (those callers record usage explicitly via
607
- * `recordFormationComplete`, or assert transport invariants only). Threads the invite's
608
- * REAL `sAppId`.
609
- * 3. else → a structural placeholder (no recorder + no provisioner ⇒ no single-use semantics
610
- * exist to enforce).
611
- *
612
- * NOTE: accepted tradeoff — the plan chose (a) "record usage on the fallback" over (b)
613
- * "remove the fallback", so arm 2 (`strandProvisioner`, no recorder) is kept. (a) closes the
614
- * single-use hole with a targeted atomic create+record and leaves the `StrandProvisioner`
615
- * mock-transport tests untouched; (b) would delete the provisioner surface and churn ~6 unit
616
- * + ~6 integration sites for ZERO production benefit (production — `cadre-web.ts` /
617
- * `cadre-phone.ts` — always publishes strand-BOUND invites and treats the
618
- * responder-provisions placeholder as failure). Revisit if production starts publishing
619
- * unbound invites, or the mock-transport tests go away.
620
- */
621
- private async provisionUnbound(
622
- contact: FormationContactMessage,
623
- disclosureText: string,
624
- signal?: AbortSignal
625
- ): Promise<ResponderProvisionOutcome> {
626
- const token = contact.token;
627
- const recorder = this.formationUsageRecorder;
628
- if (recorder?.provisionAndRecord) {
629
- const sAppId = await this.resolveInviteSAppId(token);
630
- const provisioned = await recorder.provisionAndRecord({
631
- token,
632
- peerKey: contact.peerKey,
633
- peerSignature: contact.peerSignature,
634
- usageStampId: contact.usageStampId,
635
- sAppId,
636
- disclosure: disclosureText,
637
- signal
638
- });
639
- return this.approve({
640
- strand: { strandId: provisioned.strandId, createdBy: 'responder' },
641
- memberPrivateKey: provisioned.memberPrivateKey ?? undefined,
642
- dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
643
- });
644
- }
645
-
646
- if (this.strandProvisioner) {
647
- const sAppId = await this.resolveInviteSAppId(token);
648
- const result = await this.strandProvisioner.provisionStrand(sAppId, contact.partyId, this.partyId);
649
- return this.approve({
650
- strand: { strandId: result.strandId, createdBy: 'responder' },
651
- dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
652
- });
653
- }
654
-
655
- // No recorder + no provisioner — a structural placeholder the initiator can still validate.
656
- const strandId = mintPlaceholderStrandId();
657
- return this.approve({
658
- strand: { strandId, createdBy: 'responder' },
659
- dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
660
- });
661
- }
662
-
663
- /** Wrap a provision result as an approving {@link ResponderProvisionOutcome}. */
664
- private approve(result: FormationProvisionResult): ResponderProvisionOutcome {
665
- return { approved: true, result };
666
- }
667
-
668
- /**
669
- * The invite's authoritative `sAppId` for the responder-provisions fallback, read back
670
- * from the recorder's `isTokenValid` invitation (the real `FormationInvite.sAppId`).
671
- * Empty string when no recorder is wired or the invitation omits it.
672
- */
673
- private async resolveInviteSAppId(token: string): Promise<string> {
674
- if (!this.formationUsageRecorder) return '';
675
- const check = await this.formationUsageRecorder.isTokenValid(token);
676
- return check.invitation?.sAppId ?? '';
677
- }
678
-
679
- // ── Initiator-side result validation ─────────────────────────────────────────
680
-
681
- private async validateResponse(
682
- invitation: OpenInvitation,
683
- disclosure: StrandFormationDisclosure,
684
- response: FormationResultMessage
685
- ): Promise<boolean> {
686
- if (this.formationResponseValidator) {
687
- return this.formationResponseValidator.validateResponse({ invitation, disclosure, response });
688
- }
689
- return isValidResponderCreatesResult(response);
690
- }
691
- }
692
-
693
- /**
694
- * Create a StrandFormationManager with the given options
695
- */
696
- export function createStrandFormationManager(
697
- options: StrandFormationManagerOptions
698
- ): StrandFormationManager {
699
- return new StrandFormationManager(options);
700
- }
1
+ import debug from 'debug';
2
+ import { fromString as uint8ArrayFromString } from 'uint8arrays';
3
+ import type { Libp2p } from '@libp2p/interface';
4
+ import type {
5
+ OpenInvitation,
6
+ FormStrandResult,
7
+ StrandFormationDisclosure,
8
+ StrandMembershipInvite
9
+ } from './types.js';
10
+ import {
11
+ FormationApprovalError,
12
+ type FormationApprovalFailure
13
+ } from './formation-approval.js';
14
+ // control-database does not import this manager, so this import introduces no cycle.
15
+ import { FormationAbortedError, InvitationExhaustedError } from './control-database.js';
16
+ import { PreSplitStrandIdentityError } from './strand-membership-writer.js';
17
+ import { mintPlaceholderStrandId } from './strand-id.js';
18
+ import { canonicalJson } from './canonical-json.js';
19
+ import type {
20
+ AuthorizedFormationUsage,
21
+ DisclosureValidator,
22
+ FormationUsageParams,
23
+ FormationUsageRecorder,
24
+ ResolvedHostStrand,
25
+ StrandProvisioner,
26
+ FormationResponseValidator
27
+ } from './strand-solicitation.js';
28
+ import {
29
+ FormationListener,
30
+ INVALID_TOKEN_REASON,
31
+ dialFormationByMachine,
32
+ isValidResponderCreatesResult,
33
+ isWellFormedMembershipInvite,
34
+ type FormationContactMessage,
35
+ type FormationProvisionResult,
36
+ type FormationRejection,
37
+ type FormationResultMessage,
38
+ type FormationTokenCheck,
39
+ type ResponderProvisionOutcome
40
+ } from './strand-formation-protocol.js';
41
+ import { formationDeadlines } from './strand-formation-deadlines.js';
42
+
43
+ const log = debug('sereus:cadre:formation-manager');
44
+
45
+ /**
46
+ * Largest serialized disclosure a responder will accept (8 KiB). The disclosure is
47
+ * attacker-influenced text destined for a replicated table (`FormationUsage.Disclosure`)
48
+ * and for the approval hook's signed digest — the cap bounds both, and is enforced BEFORE
49
+ * any database read or hook contact so an oversized blob costs nothing downstream.
50
+ */
51
+ const MAX_DISCLOSURE_BYTES = 8 * 1024;
52
+
53
+ /**
54
+ * How long a formation-issued strand membership invitation stays redeemable (7 days).
55
+ * The responder's {@link StrandFormationManagerOptions.issueMembershipInvite} hook stamps
56
+ * `now + this` as the `Strand.Invite.Expiration`. Long enough for a slow joiner to bring
57
+ * its strand node up and redeem; short enough that a lost formation result does not leave
58
+ * a live bearer credential in the strand forever. An expired invitation rolls the
59
+ * redemption back cleanly (the schema's `NotExpired` gate) — the joiner's recovery is a
60
+ * fresh formation, which issues a fresh invitation.
61
+ */
62
+ export const MEMBERSHIP_INVITE_TTL_MS = 7 * 24 * 3600_000;
63
+
64
+ /**
65
+ * Rejection reason for a bound CLOSED-strand redemption whose responder could not issue
66
+ * the joiner's membership invitation — the host strand runtime not live on this
67
+ * responder (never launched, mid-start, or a hibernating one that did not wake within the
68
+ * provisioning budget; a hibernating runtime is woken, not refused), no party identity key
69
+ * yet, or the strand DB refusing the `Strand.Invite` write. Retryable on purpose, and
70
+ * rejected BEFORE the consent row is recorded, so the formation token stays unspent:
71
+ * approving without an invitation would admit a joiner that looks joined but can never
72
+ * become a member, and a responder not running the strand cannot serve the joiner's sync
73
+ * anyway. The one permanent failure, a pre-split host strand, gets
74
+ * {@link HOST_STRAND_MUST_BE_RECREATED_REASON} instead.
75
+ */
76
+ export const MEMBERSHIP_INVITE_UNAVAILABLE_REASON = 'Strand membership invitation unavailable, retry';
77
+
78
+ /**
79
+ * Rejection reason for a bound CLOSED-strand redemption whose host strand was founded
80
+ * before the per-party identity split: the responder's founder launch refused it
81
+ * (`PreSplitStrandIdentityError`), so no invitation can ever be issued into it. Not
82
+ * retryable — the host must recreate the strand and issue a fresh invitation. Like
83
+ * {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON}, rejected before consent is recorded, so
84
+ * the formation token stays unspent.
85
+ */
86
+ export const HOST_STRAND_MUST_BE_RECREATED_REASON = 'Host strand must be recreated';
87
+
88
+ /**
89
+ * The rejection a would-be joiner is told for each approval-failure category
90
+ * (see {@link FormationApprovalFailure}). Distinct per category on purpose: mapping any
91
+ * of these onto the generic `'conflict'` would tell a joiner to retry what retrying can
92
+ * never fix (e.g. a non-enrolled validation key). The three inviter-setup faults share
93
+ * one code; the reason text still names which one for the operator.
94
+ */
95
+ const APPROVAL_REJECTIONS: Record<FormationApprovalFailure, FormationRejection> = {
96
+ refused: { approved: false, code: 'approval-refused', reason: 'Formation approval refused' },
97
+ unavailable: { approved: false, code: 'approval-unavailable', reason: 'Formation approval unavailable, retry' },
98
+ malformed: { approved: false, code: 'approval-invalid', reason: 'Formation approval invalid' },
99
+ unenrolled: { approved: false, code: 'approval-invalid', reason: 'Formation approval key is not enrolled' },
100
+ misconfigured: { approved: false, code: 'approval-invalid', reason: 'Formation approval misconfigured' }
101
+ };
102
+
103
+ /**
104
+ * Responder-side seam that issues a bound joiner's membership invitation — see
105
+ * {@link StrandFormationManagerOptions.issueMembershipInvite} for the contract. `signal`
106
+ * is the responder's provisioning budget: the issuer stops waiting (and throws) once it
107
+ * aborts.
108
+ */
109
+ export type MembershipInviteIssuer = (strandId: string, signal?: AbortSignal) => Promise<StrandMembershipInvite | null>;
110
+
111
+ /**
112
+ * Configuration for StrandFormationManager
113
+ */
114
+ export interface StrandFormationManagerConfig {
115
+ /**
116
+ * This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
117
+ * every formation deadline is derived (`formationDeadlines` in
118
+ * `strand-formation-deadlines.ts`). `CadreNode` fills it from its network config; the
119
+ * fields below override single rungs of that ladder.
120
+ */
121
+ linkRoundTripMs?: number;
122
+ /** Whole-session budget, both roles, in milliseconds. */
123
+ sessionTimeoutMs?: number;
124
+ /** The responder's contact-frame read, in milliseconds. */
125
+ stepTimeoutMs?: number;
126
+ /** The initiator's dial-connect (open the connection, negotiate the protocol), in milliseconds. */
127
+ dialTimeoutMs?: number;
128
+ /**
129
+ * Provisioning budget in milliseconds for the RESPONDER's `provisionStrand` hook call.
130
+ * The initiator's `await-response` wait is derived automatically from this value plus the
131
+ * travel margin of the derived ladder (`responseTravelMarginMs`) — never set directly — so
132
+ * the ordering documented there (approval hook < responder provisioning < initiator
133
+ * await-response < session) cannot collapse when this is configured.
134
+ */
135
+ provisionTimeoutMs?: number;
136
+ /** Maximum concurrent sessions */
137
+ maxConcurrentSessions?: number;
138
+ /** Enable debug logging */
139
+ enableDebugLogging?: boolean;
140
+ /** Protocol ID override */
141
+ protocolId?: string;
142
+ }
143
+
144
+ /**
145
+ * A node's own cadre addresses, sent on every formation. A function is read at each
146
+ * formation, for a node whose addresses change after the manager is built — a
147
+ * `CadreNode` installs its responder before a relay reservation gives it any.
148
+ */
149
+ export type CadrePeerAddrsSource = string[] | (() => string[]);
150
+
151
+ /**
152
+ * Options for creating a StrandFormationManager
153
+ */
154
+ export interface StrandFormationManagerOptions {
155
+ /** Validates disclosures from initiators (responder side) */
156
+ disclosureValidator?: DisclosureValidator;
157
+ /** Records and validates token usage (responder side) */
158
+ formationUsageRecorder?: FormationUsageRecorder;
159
+ /** Provisions strands after validation (responder side) */
160
+ strandProvisioner?: StrandProvisioner;
161
+ /** Validates the responder's result (initiator side); defaults to a structural check */
162
+ formationResponseValidator?: FormationResponseValidator;
163
+ /** This party's ID for identification */
164
+ partyId: string;
165
+ /** This party's cadre peer addresses */
166
+ cadrePeerAddrs?: CadrePeerAddrsSource;
167
+ /**
168
+ * This node's live STRAND-network multiaddrs for a strand it is running (responder
169
+ * side). Wired by `CadreNode` to its own per-strand address lookup; the manager has no
170
+ * route to the strand runtime itself.
171
+ *
172
+ * A hook, not a `config` knob, so it sits with the other responder-side seams
173
+ * (`strandProvisioner`, `formationUsageRecorder`) rather than among the timeouts. Left
174
+ * unwired — every mock/transport test — the responder simply discloses no strand
175
+ * addresses and cross-party joiners fall back to an empty seed.
176
+ */
177
+ resolveStrandAddrs?: (strandId: string) => string[];
178
+ /**
179
+ * Issue a single-use `Strand.Invite` against the live host strand a BOUND redemption
180
+ * resolved to (responder side) — the seam that gets the JOINER its own membership.
181
+ * Like {@link resolveStrandAddrs}, the formation layer cannot reach the strand
182
+ * runtime itself, so `CadreNode` wires this to its own running-instance +
183
+ * `StrandPartyKey` lookup.
184
+ *
185
+ * Contract: return the issued invitation for a CLOSED host strand; return `null`
186
+ * for an open one (no members, nothing to invite into); THROW when a closed strand's
187
+ * invitation cannot be issued (runtime not live, no party key, strand DB write
188
+ * rejected) — the manager then rejects the whole redemption with
189
+ * {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON} BEFORE recording consent, so the
190
+ * formation token stays unspent and the joiner can retry. Throw a
191
+ * `PreSplitStrandIdentityError` for a host strand that can never issue one; that maps
192
+ * to the non-retryable {@link HOST_STRAND_MUST_BE_RECREATED_REASON}.
193
+ *
194
+ * A hibernating host runtime is not a reason to throw: the issuer wakes it first,
195
+ * bounded by `signal` (the responder's provisioning budget), and throws only when the
196
+ * wake does not finish in time or fails.
197
+ *
198
+ * Left unwired — mock/transport tests — the bound path approves with no invitation,
199
+ * mirroring the unwired {@link resolveStrandAddrs} posture. Production
200
+ * (`CadreNode.initializeStrandSolicitation`) always wires it.
201
+ */
202
+ issueMembershipInvite?: MembershipInviteIssuer;
203
+ /** Configuration options */
204
+ config?: StrandFormationManagerConfig;
205
+ }
206
+
207
+ /**
208
+ * StrandFormationManager drives the native cadre-core formation transport
209
+ * (`strand-formation-protocol.ts`) from cadre-core's strand-solicitation interfaces.
210
+ *
211
+ * Responder side: a {@link FormationListener} wires the inbound protocol to
212
+ * {@link FormationUsageRecorder} (token), {@link DisclosureValidator} (identity),
213
+ * and {@link StrandProvisioner} (provisioning), disclosing this party's real
214
+ * identity + cadre only after validation.
215
+ *
216
+ * Initiator side: {@link formStrand} dials the responder carrying the real
217
+ * disclosure/token/cadre, then validates the responder's result via the
218
+ * {@link FormationResponseValidator} (or a built-in structural check).
219
+ */
220
+ export class StrandFormationManager {
221
+ private readonly disclosureValidator?: DisclosureValidator;
222
+ private readonly formationUsageRecorder?: FormationUsageRecorder;
223
+ private readonly strandProvisioner?: StrandProvisioner;
224
+ private readonly formationResponseValidator?: FormationResponseValidator;
225
+ private readonly partyId: string;
226
+ private readonly cadrePeerAddrs: CadrePeerAddrsSource;
227
+ private readonly resolveStrandAddrs?: (strandId: string) => string[];
228
+ private readonly issueMembershipInvite?: MembershipInviteIssuer;
229
+ private readonly config: StrandFormationManagerConfig;
230
+ private readonly listener: FormationListener;
231
+ private readonly registeredNodes = new Set<Libp2p>();
232
+ private dialerSessions = 0;
233
+
234
+ constructor(options: StrandFormationManagerOptions) {
235
+ this.disclosureValidator = options.disclosureValidator;
236
+ this.formationUsageRecorder = options.formationUsageRecorder;
237
+ this.strandProvisioner = options.strandProvisioner;
238
+ this.formationResponseValidator = options.formationResponseValidator;
239
+ this.partyId = options.partyId;
240
+ this.cadrePeerAddrs = options.cadrePeerAddrs ?? [];
241
+ this.resolveStrandAddrs = options.resolveStrandAddrs;
242
+ this.issueMembershipInvite = options.issueMembershipInvite;
243
+ this.config = options.config ?? {};
244
+
245
+ this.listener = new FormationListener({
246
+ validateToken: (token) => this.validateToken(token),
247
+ validateDisclosure: (token, disclosure) => this.validateDisclosure(token, disclosure),
248
+ provisionStrand: (contact, signal) =>
249
+ this.provisionAsResponder(contact, signal),
250
+ getResponderIdentity: () => ({ partyId: this.partyId, cadrePeerAddrs: this.currentCadrePeerAddrs() }),
251
+ // Forwarded only when wired, so `FormationListenerOptions.resolveStrandAddrs`
252
+ // stays genuinely absent (and the listener short-circuits) for an unwired manager.
253
+ ...(this.resolveStrandAddrs && { resolveStrandAddrs: this.resolveStrandAddrs }),
254
+ linkRoundTripMs: this.config.linkRoundTripMs,
255
+ sessionTimeoutMs: this.config.sessionTimeoutMs,
256
+ stepTimeoutMs: this.config.stepTimeoutMs,
257
+ provisionTimeoutMs: this.config.provisionTimeoutMs,
258
+ maxConcurrentSessions: this.config.maxConcurrentSessions
259
+ });
260
+
261
+ log('StrandFormationManager created for party: %s', this.partyId);
262
+ }
263
+
264
+ private currentCadrePeerAddrs(): string[] {
265
+ return typeof this.cadrePeerAddrs === 'function' ? this.cadrePeerAddrs() : this.cadrePeerAddrs;
266
+ }
267
+
268
+ /**
269
+ * Register this manager as a protocol handler on a libp2p node.
270
+ * Call this on the control network node to handle incoming formation requests.
271
+ */
272
+ async registerResponder(node: Libp2p, protocolId?: string): Promise<void> {
273
+ if (this.registeredNodes.has(node)) {
274
+ log('Node already registered');
275
+ return;
276
+ }
277
+ await this.listener.register(node, protocolId ?? this.config.protocolId);
278
+ this.registeredNodes.add(node);
279
+ log('Registered as responder on node');
280
+ }
281
+
282
+ /**
283
+ * Unregister the protocol handler from a libp2p node.
284
+ */
285
+ async unregisterResponder(node: Libp2p, protocolId?: string): Promise<void> {
286
+ if (!this.registeredNodes.has(node)) {
287
+ return;
288
+ }
289
+ await this.listener.unregister(node, protocolId ?? this.config.protocolId);
290
+ this.registeredNodes.delete(node);
291
+ log('Unregistered from node');
292
+ }
293
+
294
+ /**
295
+ * Form a strand with a responder via an open invitation (initiator side).
296
+ *
297
+ * Builds a contact message carrying the real token + disclosure + this party's
298
+ * real cadre addresses + the joiner's consent (minted and signed by the
299
+ * solicitation layer — this manager only places it on the contact), dials the
300
+ * inviting party's machines the invitation names, one at a time, over the native
301
+ * protocol ({@link dialFormationByMachine}), and validates the responder's result
302
+ * before returning. Every machine gets the same contact, so at most one of them can
303
+ * record this redemption.
304
+ */
305
+ async formStrand(
306
+ invitation: OpenInvitation,
307
+ disclosure: StrandFormationDisclosure,
308
+ consent: { peerKey: string; usageStampId: string; peerSignature: string },
309
+ node: Libp2p
310
+ ): Promise<FormStrandResult> {
311
+ log('Forming strand with invitation token: %s', invitation.token);
312
+
313
+ const contact: FormationContactMessage = {
314
+ token: invitation.token,
315
+ partyId: disclosure.partyId ?? this.partyId,
316
+ peerKey: consent.peerKey,
317
+ usageStampId: consent.usageStampId,
318
+ peerSignature: consent.peerSignature,
319
+ disclosure,
320
+ cadrePeerAddrs: this.currentCadrePeerAddrs()
321
+ };
322
+
323
+ this.dialerSessions++;
324
+ try {
325
+ const dialed = await dialFormationByMachine(node, {
326
+ contact,
327
+ responderAddrs: invitation.bootstrap,
328
+ validateResponse: (response) => this.validateResponse(invitation, disclosure, response),
329
+ linkRoundTripMs: this.config.linkRoundTripMs,
330
+ sessionTimeoutMs: this.config.sessionTimeoutMs,
331
+ dialTimeoutMs: this.config.dialTimeoutMs,
332
+ provisionTimeoutMs: this.initiatorProvisionTimeoutMs(),
333
+ protocolId: this.config.protocolId
334
+ });
335
+
336
+ const provision = dialed.provision;
337
+ log('Strand formed: %s (%d cross-party strand addr(s))', provision.strand.strandId, dialed.strandAddrs.length);
338
+
339
+ return {
340
+ memberKey: contact.partyId,
341
+ invitePrivateKey: '',
342
+ strandId: provision.strand.strandId,
343
+ // The host strand's membership key, delivered through the protocol (provision-then-record).
344
+ // Undefined for an open strand. Kept separate from invitePrivateKey (the initiator's
345
+ // generated signing key), which is set by the StrandSolicitationService layer.
346
+ memberPrivateKey: provision.memberPrivateKey,
347
+ // Carried only when well-formed. The DEFAULT response validator already rejects
348
+ // a malformed one; this re-check keeps a custom validator that skips the shape
349
+ // check from feeding a hostile responder's object into the joiner's invite cache.
350
+ membershipInvite: this.membershipInviteFrom(provision),
351
+ // Possibly empty — the responder may hold no live strand node yet.
352
+ strandAddrs: dialed.strandAddrs
353
+ };
354
+ } finally {
355
+ this.dialerSessions--;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Get the number of active sessions
361
+ */
362
+ getActiveSessionCounts(): { listeners: number; dialers: number } {
363
+ return { listeners: this.listener.activeCount, dialers: this.dialerSessions };
364
+ }
365
+
366
+ /**
367
+ * The provision result's membership invitation, iff well-formed — a malformed one
368
+ * (possible only past a custom {@link FormationResponseValidator} that skipped the
369
+ * structural check) is dropped with a log rather than cached: it could never redeem
370
+ * anyway, and carrying an unbounded hostile object outlives the session.
371
+ */
372
+ private membershipInviteFrom(provision: FormationProvisionResult): StrandMembershipInvite | undefined {
373
+ if (provision.membershipInvite === undefined) {
374
+ return undefined;
375
+ }
376
+ if (!isWellFormedMembershipInvite(provision.membershipInvite)) {
377
+ log('formStrand: dropping malformed membershipInvite from responder for strand %s', provision.strand.strandId);
378
+ return undefined;
379
+ }
380
+ return { inviteKey: provision.membershipInvite.inviteKey, invitePrivateKey: provision.membershipInvite.invitePrivateKey };
381
+ }
382
+
383
+ /**
384
+ * Derive the initiator's await-response budget from the configured RESPONDER budget —
385
+ * never the same number (see `StrandFormationManagerConfig.provisionTimeoutMs`): the
386
+ * configured value plus the travel margin the ladder derives at this machine's link.
387
+ * Mirrors `resolveProvisionTimeoutMs`'s own "`0`/negative means unset" rule so an unset
388
+ * config still lets both sides fall back to their own derived defaults; must NOT
389
+ * reproduce the derived default here, or the per-role clamping downstream is defeated.
390
+ * A value too large for the session is clamped per role, and the responder's ceiling holds
391
+ * the margin back, so the ordering survives there too.
392
+ */
393
+ private initiatorProvisionTimeoutMs(): number | undefined {
394
+ const host = this.config.provisionTimeoutMs;
395
+ return host && host > 0
396
+ ? host + formationDeadlines(this.config.linkRoundTripMs).responseTravelMarginMs
397
+ : undefined;
398
+ }
399
+
400
+ // ── Responder-side hooks ─────────────────────────────────────────────────────
401
+
402
+ /**
403
+ * An expired or used-up invitation is `'token-spent'` (final); one with no row here is
404
+ * `'token-unknown'` (retryable — the row may not have replicated to this machine yet), and
405
+ * so is one from a recorder that does not say why.
406
+ */
407
+ private async validateToken(token: string): Promise<FormationTokenCheck> {
408
+ if (!this.formationUsageRecorder) {
409
+ // No recorder configured — accept all tokens.
410
+ return { valid: true };
411
+ }
412
+
413
+ const tokenCheck = await this.formationUsageRecorder.isTokenValid(token);
414
+ if (!tokenCheck.valid) {
415
+ log('Token invalid (%s): %s', tokenCheck.reason ?? 'unknown', token);
416
+ return { valid: false, code: tokenCheck.reason === 'expired' ? 'token-spent' : 'token-unknown' };
417
+ }
418
+
419
+ if (await this.formationUsageRecorder.isTokenUsed(token)) {
420
+ log('Token already used: %s', token);
421
+ return { valid: false, code: 'token-spent' };
422
+ }
423
+
424
+ return { valid: true };
425
+ }
426
+
427
+ private async validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean> {
428
+ if (!this.disclosureValidator) {
429
+ // No validator configured — accept all disclosures.
430
+ return true;
431
+ }
432
+ return this.disclosureValidator.validateDisclosure(token, disclosure);
433
+ }
434
+
435
+ /**
436
+ * Responder-side provisioning. Routes on how the invite resolves
437
+ * ({@link ResolvedHostStrand}), and can REJECT post-validation by returning a
438
+ * {@link ResponderProvisionOutcome} with `approved: false` — `runSession` turns that
439
+ * into a clean, non-disclosing reply instead of a dropped result frame.
440
+ *
441
+ * - **bound** (host strand present): provision-then-record, in three steps —
442
+ * (1) authorize ({@link authorizeBoundUsage}: the outside approval when the invite
443
+ * demands one, and a seat pre-check), so a refused join writes nothing into the host
444
+ * strand; (2) issue the joiner's single-use strand membership invitation via the wired
445
+ * `issueMembershipInvite` seam ({@link issueBoundMembershipInvite}; a closed strand whose
446
+ * invitation cannot be issued rejects retryably HERE, before any consent row spends the
447
+ * token); (3) write the single `FormationUsage` consent row against the pre-existing
448
+ * strand (record-only) and return it + its membership key + the invitation (all
449
+ * read-gating secrets disclosed only here, behind the token + disclosure validation
450
+ * `runSession` already enforced).
451
+ * - **missing** (invite names a host strand absent on this responder, e.g. unconverged):
452
+ * reject cleanly + retryably, writing NO usage row — recording usage here would fail the
453
+ * deferred `StrandExists` CHECK at commit and drop the frame.
454
+ * - **unbound** (no binding): the responder-provisions fallback — see {@link provisionUnbound}.
455
+ *
456
+ * Defense-in-depth: known provisioning/redeem failures are caught and mapped to a logged
457
+ * protocol rejection rather than a thrown insert + a silently-closed stream. The mapping is
458
+ * per-failure, NOT blanket retry-suggesting: an exhausted invitation
459
+ * ({@link InvitationExhaustedError}, raised by `ControlDatabase` when the count of recorded
460
+ * uses has reached the invite's seat budget — which is how the loser of a same-node race
461
+ * surfaces, the local write queue having serialized the two writes) is reported as
462
+ * `'token-spent'`, because retrying it can never succeed. Concurrent redemptions never
463
+ * contend for a shared row key — each writes under its own `UsageStampId` — so there is no
464
+ * key collision to surface here at all. The LOG-before-reject keeps this a deliberate
465
+ * internal-error→protocol-rejection conversion (AGENTS.md: don't eat exceptions silently),
466
+ * not control-flow-by-exception.
467
+ */
468
+ private async provisionAsResponder(
469
+ contact: FormationContactMessage,
470
+ signal?: AbortSignal
471
+ ): Promise<ResponderProvisionOutcome> {
472
+ const token = contact.token;
473
+ // CANONICALLY serialized — both sides derive the identical string: this is the exact
474
+ // text the joiner signed (consent), the approver signs (vouch), and the recorder writes
475
+ // to `FormationUsage.Disclosure` — a re-serialization anywhere below would break the
476
+ // signature-over-stored-bytes invariant.
477
+ const disclosureText = canonicalJson(contact.disclosure);
478
+ if (uint8ArrayFromString(disclosureText, 'utf8').byteLength > MAX_DISCLOSURE_BYTES) {
479
+ log('Disclosure over %d bytes; rejecting token %s', MAX_DISCLOSURE_BYTES, token);
480
+ return { approved: false, code: 'disclosure-too-large', reason: 'Disclosure too large' };
481
+ }
482
+
483
+ const recorder = this.formationUsageRecorder;
484
+ const resolved: ResolvedHostStrand = recorder?.resolveStrand
485
+ ? await recorder.resolveStrand(token)
486
+ : { kind: 'unbound' };
487
+
488
+ try {
489
+ switch (resolved.kind) {
490
+ case 'bound': {
491
+ // recorder is guaranteed non-null here: only resolveStrand can yield 'bound'.
492
+ const authorized = await this.authorizeBoundUsage(recorder!, {
493
+ token,
494
+ peerKey: contact.peerKey,
495
+ peerSignature: contact.peerSignature,
496
+ usageStampId: contact.usageStampId,
497
+ strandId: resolved.strandId,
498
+ disclosure: disclosureText,
499
+ signal
500
+ });
501
+ // Issued only once authorized (a refused join writes nothing into the strand) and
502
+ // BEFORE consent is recorded (an issue failure leaves the token unspent, retryable).
503
+ // The remaining window — invite issued, then record() fails or aborts (a write that
504
+ // exhausted its retries, an abort, or the same-node seat race noted at
505
+ // `ControlFormationUsageRecorder.assertSeatFree`) — orphans a
506
+ // live `Strand.Invite` no joiner ever received; bounded deliberately by its expiry
507
+ // ({@link MEMBERSHIP_INVITE_TTL_MS}) rather than compensated, since nothing
508
+ // here can atomically un-issue a strand-DB row.
509
+ const issued = await this.issueBoundMembershipInvite(token, resolved.strandId, signal);
510
+ if (!issued.ok) {
511
+ return issued.rejection;
512
+ }
513
+ await authorized.record();
514
+ return this.approve({
515
+ strand: { strandId: resolved.strandId, createdBy: 'responder' },
516
+ memberPrivateKey: resolved.memberPrivateKey ?? undefined,
517
+ // Omitted (not sent undefined-valued) so rejection-parity and open-strand
518
+ // assertions stay plain `toBeUndefined()` on the parsed frame.
519
+ ...(issued.invite ? { membershipInvite: issued.invite } : {}),
520
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
521
+ });
522
+ }
523
+ case 'missing': {
524
+ log('Host strand %s not yet available on this responder; rejecting token %s', resolved.strandId, token);
525
+ return { approved: false, code: 'host-strand-unavailable', reason: 'Host strand not yet available on this responder' };
526
+ }
527
+ case 'unbound':
528
+ return await this.provisionUnbound(contact, disclosureText, signal);
529
+ }
530
+ } catch (err) {
531
+ if (err instanceof FormationAbortedError) {
532
+ // The listener's timeout path owns the reply for an abandoned provisioning; mapping
533
+ // this onto 'Formation conflict, retry' would misreport it as a conflict.
534
+ throw err;
535
+ }
536
+ if (err instanceof FormationApprovalError) {
537
+ log('approval failed (%s) for token %s: %o', err.failure, token, err);
538
+ return APPROVAL_REJECTIONS[err.failure];
539
+ }
540
+ if (err instanceof InvitationExhaustedError) {
541
+ // Shares `INVALID_TOKEN_REASON` and `'token-spent'` with the up-front `validateToken`
542
+ // rejection so the loser of the race that exposed the spent invite sees exactly what a
543
+ // non-racing latecomer sees. How many uses were recorded against the total is for the
544
+ // operator, in this log line, not for the joiner.
545
+ log(
546
+ 'invitation exhausted for token %s: %d of %d use(s) already recorded',
547
+ err.token,
548
+ err.usesRecorded,
549
+ err.totalUses
550
+ );
551
+ return { approved: false, code: 'token-spent', reason: INVALID_TOKEN_REASON };
552
+ }
553
+ // An approval is never discarded to a lost key race: each redemption writes under its
554
+ // own `UsageStampId`, so no other writer can take its row key. Reaching this catch-all
555
+ // means the failure was never retryable at the database layer to begin with (or the
556
+ // transient-cluster retry inside `ControlDatabase.lockedWithRetry` ran out).
557
+ log('provisionAsResponder failed for token %s: %o', token, err);
558
+ return { approved: false, code: 'conflict', reason: 'Formation conflict, retry' };
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Authorize a BOUND redemption without writing it, through the recorder's optional
564
+ * {@link FormationUsageRecorder.authorizeUsage}. A recorder without it has nothing to ask up
565
+ * front, so its handle defers everything to `recordUsage`.
566
+ */
567
+ private async authorizeBoundUsage(
568
+ recorder: FormationUsageRecorder,
569
+ params: FormationUsageParams
570
+ ): Promise<AuthorizedFormationUsage> {
571
+ if (recorder.authorizeUsage) {
572
+ return await recorder.authorizeUsage(params);
573
+ }
574
+ return { record: () => recorder.recordUsage(params) };
575
+ }
576
+
577
+ /**
578
+ * Issue the joiner's membership invitation for a BOUND redemption via the wired
579
+ * {@link StrandFormationManagerOptions.issueMembershipInvite} seam. Runs after the
580
+ * redemption is authorized ({@link authorizeBoundUsage}) and before consent is recorded.
581
+ *
582
+ * - Hook unwired (mock/transport tests): approve with no invitation, the same posture
583
+ * as an unwired `resolveStrandAddrs`.
584
+ * - Hook returns an invitation (closed host strand): carry it on the approval.
585
+ * - Hook returns `null` (open host strand): approve with no invitation.
586
+ * - Hook throws (runtime not live — including a hibernating one whose wake outran
587
+ * `signal` — no party key, strand-DB write rejected): report
588
+ * `ok: false` with {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON} — the caller rejects
589
+ * BEFORE any consent row is written, so the formation token stays unspent.
590
+ * - Hook throws `PreSplitStrandIdentityError` (the host strand was founded before the
591
+ * per-party identity split and can never issue one): same, but with the
592
+ * non-retryable {@link HOST_STRAND_MUST_BE_RECREATED_REASON}.
593
+ *
594
+ * The LOG-before-reject keeps both a deliberate internal-error→protocol-rejection
595
+ * conversion, same as the catch-all in {@link provisionAsResponder}.
596
+ */
597
+ private async issueBoundMembershipInvite(
598
+ token: string,
599
+ strandId: string,
600
+ signal?: AbortSignal
601
+ ): Promise<{ ok: true; invite?: StrandMembershipInvite } | { ok: false; rejection: FormationRejection }> {
602
+ if (!this.issueMembershipInvite) {
603
+ return { ok: true };
604
+ }
605
+ try {
606
+ const invite = await this.issueMembershipInvite(strandId, signal);
607
+ return { ok: true, invite: invite ?? undefined };
608
+ } catch (err) {
609
+ if (err instanceof PreSplitStrandIdentityError) {
610
+ log('host strand %s is pre-split (token %s); rejecting — it must be recreated: %o', strandId, token, err);
611
+ return {
612
+ ok: false,
613
+ rejection: { approved: false, code: 'host-strand-must-be-recreated', reason: HOST_STRAND_MUST_BE_RECREATED_REASON }
614
+ };
615
+ }
616
+ log('membership-invite issue for strand %s failed (token %s); rejecting retryably: %o', strandId, token, err);
617
+ return {
618
+ ok: false,
619
+ rejection: { approved: false, code: 'host-strand-unavailable', reason: MEMBERSHIP_INVITE_UNAVAILABLE_REASON }
620
+ };
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Responder-provisions fallback (invite binds no host strand). Precedence:
626
+ *
627
+ * 1. `recorder.provisionAndRecord` present (a real DB recorder) → atomic create-strand +
628
+ * record-consent, so the unbound redemption is single-use just like the bound path.
629
+ * Returns the new strand (+ key, null for an open responder-provisioned strand).
630
+ * 2. else `strandProvisioner` present → the mock/transport-test contract: provision a bare
631
+ * strand, NO inline usage write (those callers record usage explicitly via
632
+ * `recordFormationComplete`, or assert transport invariants only). Threads the invite's
633
+ * REAL `sAppId`.
634
+ * 3. else → a structural placeholder (no recorder + no provisioner ⇒ no single-use semantics
635
+ * exist to enforce).
636
+ *
637
+ * NOTE: accepted tradeoff — the plan chose (a) "record usage on the fallback" over (b)
638
+ * "remove the fallback", so arm 2 (`strandProvisioner`, no recorder) is kept. (a) closes the
639
+ * single-use hole with a targeted atomic create+record and leaves the `StrandProvisioner`
640
+ * mock-transport tests untouched; (b) would delete the provisioner surface and churn ~6 unit
641
+ * + ~6 integration sites for ZERO production benefit (production — `cadre-web.ts` /
642
+ * `cadre-phone.ts` — always publishes strand-BOUND invites and treats the
643
+ * responder-provisions placeholder as failure). Revisit if production starts publishing
644
+ * unbound invites, or the mock-transport tests go away.
645
+ */
646
+ private async provisionUnbound(
647
+ contact: FormationContactMessage,
648
+ disclosureText: string,
649
+ signal?: AbortSignal
650
+ ): Promise<ResponderProvisionOutcome> {
651
+ const token = contact.token;
652
+ const recorder = this.formationUsageRecorder;
653
+ if (recorder?.provisionAndRecord) {
654
+ const sAppId = await this.resolveInviteSAppId(token);
655
+ const provisioned = await recorder.provisionAndRecord({
656
+ token,
657
+ peerKey: contact.peerKey,
658
+ peerSignature: contact.peerSignature,
659
+ usageStampId: contact.usageStampId,
660
+ sAppId,
661
+ disclosure: disclosureText,
662
+ signal
663
+ });
664
+ return this.approve({
665
+ strand: { strandId: provisioned.strandId, createdBy: 'responder' },
666
+ memberPrivateKey: provisioned.memberPrivateKey ?? undefined,
667
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
668
+ });
669
+ }
670
+
671
+ if (this.strandProvisioner) {
672
+ const sAppId = await this.resolveInviteSAppId(token);
673
+ const result = await this.strandProvisioner.provisionStrand(sAppId, contact.partyId, this.partyId);
674
+ return this.approve({
675
+ strand: { strandId: result.strandId, createdBy: 'responder' },
676
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
677
+ });
678
+ }
679
+
680
+ // No recorder + no provisioner — a structural placeholder the initiator can still validate.
681
+ const strandId = mintPlaceholderStrandId();
682
+ return this.approve({
683
+ strand: { strandId, createdBy: 'responder' },
684
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
685
+ });
686
+ }
687
+
688
+ /** Wrap a provision result as an approving {@link ResponderProvisionOutcome}. */
689
+ private approve(result: FormationProvisionResult): ResponderProvisionOutcome {
690
+ return { approved: true, result };
691
+ }
692
+
693
+ /**
694
+ * The invite's authoritative `sAppId` for the responder-provisions fallback, read back
695
+ * from the recorder's `isTokenValid` invitation (the real `FormationInvite.sAppId`).
696
+ * Empty string when no recorder is wired or the invitation omits it.
697
+ */
698
+ private async resolveInviteSAppId(token: string): Promise<string> {
699
+ if (!this.formationUsageRecorder) return '';
700
+ const check = await this.formationUsageRecorder.isTokenValid(token);
701
+ return check.invitation?.sAppId ?? '';
702
+ }
703
+
704
+ // ── Initiator-side result validation ─────────────────────────────────────────
705
+
706
+ private async validateResponse(
707
+ invitation: OpenInvitation,
708
+ disclosure: StrandFormationDisclosure,
709
+ response: FormationResultMessage
710
+ ): Promise<boolean> {
711
+ if (this.formationResponseValidator) {
712
+ return this.formationResponseValidator.validateResponse({ invitation, disclosure, response });
713
+ }
714
+ return isValidResponderCreatesResult(response);
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Create a StrandFormationManager with the given options
720
+ */
721
+ export function createStrandFormationManager(
722
+ options: StrandFormationManagerOptions
723
+ ): StrandFormationManager {
724
+ return new StrandFormationManager(options);
725
+ }