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