@serfab/cadre-core 0.11.0 → 0.13.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 (121) hide show
  1. package/README.md +3 -2
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +607 -53
  6. package/dist/cadre-node.js +1268 -157
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-database.d.ts +174 -23
  9. package/dist/control-database.js +317 -107
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-read-retry.d.ts +103 -0
  12. package/dist/control-read-retry.js +145 -0
  13. package/dist/control-read-retry.js.map +1 -0
  14. package/dist/control-retry.d.ts +101 -0
  15. package/dist/control-retry.js +108 -0
  16. package/dist/control-retry.js.map +1 -0
  17. package/dist/control-schema.d.ts +1 -1
  18. package/dist/control-schema.js +50 -6
  19. package/dist/control-schema.js.map +1 -1
  20. package/dist/control-write-retry.d.ts +59 -35
  21. package/dist/control-write-retry.js +21 -92
  22. package/dist/control-write-retry.js.map +1 -1
  23. package/dist/delegate-admission.d.ts +17 -15
  24. package/dist/delegate-admission.js +17 -15
  25. package/dist/delegate-admission.js.map +1 -1
  26. package/dist/enrolled-machine-store-file.d.ts +13 -0
  27. package/dist/enrolled-machine-store-file.js +41 -0
  28. package/dist/enrolled-machine-store-file.js.map +1 -0
  29. package/dist/enrolled-machine-store.d.ts +81 -0
  30. package/dist/enrolled-machine-store.js +225 -0
  31. package/dist/enrolled-machine-store.js.map +1 -0
  32. package/dist/file-durable-slot.js +5 -3
  33. package/dist/file-durable-slot.js.map +1 -1
  34. package/dist/index.d.ts +10 -6
  35. package/dist/index.js +39 -12
  36. package/dist/index.js.map +1 -1
  37. package/dist/membership-connection-gater.d.ts +226 -26
  38. package/dist/membership-connection-gater.js +316 -30
  39. package/dist/membership-connection-gater.js.map +1 -1
  40. package/dist/node-local-snapshot.d.ts +6 -0
  41. package/dist/node-local-snapshot.js +7 -1
  42. package/dist/node-local-snapshot.js.map +1 -1
  43. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  44. package/dist/{strand-backfill.js → peer-join-backfill.js} +146 -56
  45. package/dist/peer-join-backfill.js.map +1 -0
  46. package/dist/peer-record.d.ts +26 -0
  47. package/dist/peer-record.js +32 -0
  48. package/dist/peer-record.js.map +1 -1
  49. package/dist/relay-addrs.d.ts +145 -22
  50. package/dist/relay-addrs.js +314 -31
  51. package/dist/relay-addrs.js.map +1 -1
  52. package/dist/relay-reservation.d.ts +20 -15
  53. package/dist/relay-reservation.js +18 -14
  54. package/dist/relay-reservation.js.map +1 -1
  55. package/dist/strand-addr-protocol.js +16 -0
  56. package/dist/strand-addr-protocol.js.map +1 -1
  57. package/dist/strand-database.d.ts +12 -0
  58. package/dist/strand-database.js +16 -0
  59. package/dist/strand-database.js.map +1 -1
  60. package/dist/strand-formation-manager.d.ts +22 -9
  61. package/dist/strand-formation-manager.js +20 -12
  62. package/dist/strand-formation-manager.js.map +1 -1
  63. package/dist/strand-formation-protocol.d.ts +83 -9
  64. package/dist/strand-formation-protocol.js +86 -10
  65. package/dist/strand-formation-protocol.js.map +1 -1
  66. package/dist/strand-instance-manager.d.ts +151 -17
  67. package/dist/strand-instance-manager.js +237 -32
  68. package/dist/strand-instance-manager.js.map +1 -1
  69. package/dist/strand-membership-writer.d.ts +118 -14
  70. package/dist/strand-membership-writer.js +203 -15
  71. package/dist/strand-membership-writer.js.map +1 -1
  72. package/dist/strand-network-config.d.ts +84 -0
  73. package/dist/strand-network-config.js +136 -0
  74. package/dist/strand-network-config.js.map +1 -0
  75. package/dist/strand-revocation-enforcer.d.ts +392 -0
  76. package/dist/strand-revocation-enforcer.js +533 -0
  77. package/dist/strand-revocation-enforcer.js.map +1 -0
  78. package/dist/strand-solicitation.d.ts +10 -3
  79. package/dist/strand-solicitation.js +12 -7
  80. package/dist/strand-solicitation.js.map +1 -1
  81. package/dist/strand-wake-protocol.d.ts +47 -2
  82. package/dist/strand-wake-protocol.js +76 -6
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/strand-watcher.js +6 -1
  85. package/dist/strand-watcher.js.map +1 -1
  86. package/dist/types.d.ts +282 -43
  87. package/dist/types.js +7 -1
  88. package/dist/types.js.map +1 -1
  89. package/package.json +16 -8
  90. package/src/announce-addrs.ts +93 -0
  91. package/src/cadre-node.ts +1366 -171
  92. package/src/control-database.ts +2628 -2406
  93. package/src/control-read-retry.ts +167 -0
  94. package/src/control-retry.ts +190 -0
  95. package/src/control-schema.ts +50 -6
  96. package/src/control-write-retry.ts +30 -130
  97. package/src/delegate-admission.ts +17 -15
  98. package/src/enrolled-machine-store-file.ts +43 -0
  99. package/src/enrolled-machine-store.ts +280 -0
  100. package/src/file-durable-slot.ts +5 -3
  101. package/src/index.ts +477 -407
  102. package/src/membership-connection-gater.ts +539 -204
  103. package/src/node-local-snapshot.ts +13 -1
  104. package/src/peer-addr-book.ts +251 -250
  105. package/src/{strand-backfill.ts → peer-join-backfill.ts} +187 -75
  106. package/src/peer-record.ts +33 -0
  107. package/src/relay-addrs.ts +436 -111
  108. package/src/relay-reservation.ts +828 -823
  109. package/src/strand-addr-protocol.ts +17 -0
  110. package/src/strand-database.ts +17 -0
  111. package/src/strand-formation-manager.ts +511 -491
  112. package/src/strand-formation-protocol.ts +808 -692
  113. package/src/strand-instance-manager.ts +899 -611
  114. package/src/strand-membership-writer.ts +232 -15
  115. package/src/strand-network-config.ts +172 -0
  116. package/src/strand-revocation-enforcer.ts +653 -0
  117. package/src/strand-solicitation.ts +484 -472
  118. package/src/strand-wake-protocol.ts +94 -8
  119. package/src/strand-watcher.ts +319 -314
  120. package/src/types.ts +1554 -1309
  121. package/dist/strand-backfill.js.map +0 -1
@@ -1,491 +1,511 @@
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
- } from './types.js';
9
- import {
10
- FormationApprovalError,
11
- type FormationApprovalFailure
12
- } from './formation-approval.js';
13
- // control-database does not import this manager, so this import introduces no cycle.
14
- import { FormationAbortedError, InvitationExhaustedError } from './control-database.js';
15
- import { canonicalJson } from './canonical-json.js';
16
- import type {
17
- DisclosureValidator,
18
- FormationUsageRecorder,
19
- ResolvedHostStrand,
20
- StrandProvisioner,
21
- FormationResponseValidator
22
- } from './strand-solicitation.js';
23
- import {
24
- FormationListener,
25
- INVALID_TOKEN_REASON,
26
- PROVISION_RESPONSE_TRAVEL_MARGIN_MS,
27
- dialFormation,
28
- isValidResponderCreatesResult,
29
- type FormationContactMessage,
30
- type FormationProvisionResult,
31
- type FormationResultMessage,
32
- type ResponderProvisionOutcome
33
- } from './strand-formation-protocol.js';
34
-
35
- const log = debug('sereus:cadre:formation-manager');
36
-
37
- /**
38
- * Largest serialized disclosure a responder will accept (8 KiB). The disclosure is
39
- * attacker-influenced text destined for a replicated table (`FormationUsage.Disclosure`)
40
- * and for the approval hook's signed digest — the cap bounds both, and is enforced BEFORE
41
- * any database read or hook contact so an oversized blob costs nothing downstream.
42
- */
43
- const MAX_DISCLOSURE_BYTES = 8 * 1024;
44
-
45
- /**
46
- * Rejection reason a would-be joiner is told for each approval-failure category
47
- * (see {@link FormationApprovalFailure}). Distinct per category on purpose: mapping any
48
- * of these onto the generic 'Formation conflict, retry' would tell an operator to retry
49
- * what retrying can never fix (e.g. a non-enrolled validation key).
50
- */
51
- const APPROVAL_REJECTION_REASONS: Record<FormationApprovalFailure, string> = {
52
- refused: 'Formation approval refused',
53
- unavailable: 'Formation approval unavailable, retry',
54
- malformed: 'Formation approval invalid',
55
- unenrolled: 'Formation approval key is not enrolled',
56
- misconfigured: 'Formation approval misconfigured'
57
- };
58
-
59
- /**
60
- * Configuration for StrandFormationManager
61
- */
62
- export interface StrandFormationManagerConfig {
63
- /** Session timeout in milliseconds */
64
- sessionTimeoutMs?: number;
65
- /** Step timeout in milliseconds */
66
- stepTimeoutMs?: number;
67
- /**
68
- * Provisioning budget in milliseconds for the RESPONDER's `provisionStrand` hook call.
69
- * The initiator's `await-response` wait is derived automatically from this value plus
70
- * `PROVISION_RESPONSE_TRAVEL_MARGIN_MS` (`strand-formation-protocol.ts`) — never set
71
- * directly — so the ordering documented there (approval hook < responder provisioning <
72
- * initiator await-response < session) cannot collapse when this is configured.
73
- */
74
- provisionTimeoutMs?: number;
75
- /** Maximum concurrent sessions */
76
- maxConcurrentSessions?: number;
77
- /** Enable debug logging */
78
- enableDebugLogging?: boolean;
79
- /** Protocol ID override */
80
- protocolId?: string;
81
- }
82
-
83
- /**
84
- * Options for creating a StrandFormationManager
85
- */
86
- export interface StrandFormationManagerOptions {
87
- /** Validates disclosures from initiators (responder side) */
88
- disclosureValidator?: DisclosureValidator;
89
- /** Records and validates token usage (responder side) */
90
- formationUsageRecorder?: FormationUsageRecorder;
91
- /** Provisions strands after validation (responder side) */
92
- strandProvisioner?: StrandProvisioner;
93
- /** Validates the responder's result (initiator side); defaults to a structural check */
94
- formationResponseValidator?: FormationResponseValidator;
95
- /** This party's ID for identification */
96
- partyId: string;
97
- /** This party's cadre peer addresses */
98
- cadrePeerAddrs?: string[];
99
- /** Configuration options */
100
- config?: StrandFormationManagerConfig;
101
- }
102
-
103
- /**
104
- * StrandFormationManager drives the native cadre-core formation transport
105
- * (`strand-formation-protocol.ts`) from cadre-core's strand-solicitation interfaces.
106
- *
107
- * Responder side: a {@link FormationListener} wires the inbound protocol to
108
- * {@link FormationUsageRecorder} (token), {@link DisclosureValidator} (identity),
109
- * and {@link StrandProvisioner} (provisioning), disclosing this party's real
110
- * identity + cadre only after validation.
111
- *
112
- * Initiator side: {@link formStrand} dials the responder carrying the real
113
- * disclosure/token/cadre, then validates the responder's result via the
114
- * {@link FormationResponseValidator} (or a built-in structural check).
115
- */
116
- export class StrandFormationManager {
117
- private readonly disclosureValidator?: DisclosureValidator;
118
- private readonly formationUsageRecorder?: FormationUsageRecorder;
119
- private readonly strandProvisioner?: StrandProvisioner;
120
- private readonly formationResponseValidator?: FormationResponseValidator;
121
- private readonly partyId: string;
122
- private readonly cadrePeerAddrs: string[];
123
- private readonly config: StrandFormationManagerConfig;
124
- private readonly listener: FormationListener;
125
- private readonly registeredNodes = new Set<Libp2p>();
126
- private dialerSessions = 0;
127
-
128
- constructor(options: StrandFormationManagerOptions) {
129
- this.disclosureValidator = options.disclosureValidator;
130
- this.formationUsageRecorder = options.formationUsageRecorder;
131
- this.strandProvisioner = options.strandProvisioner;
132
- this.formationResponseValidator = options.formationResponseValidator;
133
- this.partyId = options.partyId;
134
- this.cadrePeerAddrs = options.cadrePeerAddrs ?? [];
135
- this.config = options.config ?? {};
136
-
137
- this.listener = new FormationListener({
138
- validateToken: (token) => this.validateToken(token),
139
- validateDisclosure: (token, disclosure) => this.validateDisclosure(token, disclosure),
140
- provisionStrand: (contact, signal) =>
141
- this.provisionAsResponder(contact, signal),
142
- getResponderIdentity: () => ({ partyId: this.partyId, cadrePeerAddrs: this.cadrePeerAddrs }),
143
- sessionTimeoutMs: this.config.sessionTimeoutMs,
144
- stepTimeoutMs: this.config.stepTimeoutMs,
145
- provisionTimeoutMs: this.config.provisionTimeoutMs,
146
- maxConcurrentSessions: this.config.maxConcurrentSessions
147
- });
148
-
149
- log('StrandFormationManager created for party: %s', this.partyId);
150
- }
151
-
152
- /**
153
- * Register this manager as a protocol handler on a libp2p node.
154
- * Call this on the control network node to handle incoming formation requests.
155
- */
156
- registerResponder(node: Libp2p, protocolId?: string): void {
157
- if (this.registeredNodes.has(node)) {
158
- log('Node already registered');
159
- return;
160
- }
161
- this.listener.register(node, protocolId ?? this.config.protocolId);
162
- this.registeredNodes.add(node);
163
- log('Registered as responder on node');
164
- }
165
-
166
- /**
167
- * Unregister the protocol handler from a libp2p node.
168
- */
169
- unregisterResponder(node: Libp2p, protocolId?: string): void {
170
- if (!this.registeredNodes.has(node)) {
171
- return;
172
- }
173
- this.listener.unregister(node, protocolId ?? this.config.protocolId);
174
- this.registeredNodes.delete(node);
175
- log('Unregistered from node');
176
- }
177
-
178
- /**
179
- * Form a strand with a responder via an open invitation (initiator side).
180
- *
181
- * Builds a contact message carrying the real token + disclosure + this party's
182
- * real cadre addresses + the joiner's consent (minted and signed by the
183
- * solicitation layer — this manager only places it on the contact), dials the
184
- * responder over the native protocol, and validates the responder's result
185
- * before returning.
186
- */
187
- async formStrand(
188
- invitation: OpenInvitation,
189
- disclosure: StrandFormationDisclosure,
190
- consent: { peerKey: string; usageStampId: string; peerSignature: string },
191
- node: Libp2p
192
- ): Promise<FormStrandResult> {
193
- log('Forming strand with invitation token: %s', invitation.token);
194
-
195
- const contact: FormationContactMessage = {
196
- token: invitation.token,
197
- partyId: disclosure.partyId ?? this.partyId,
198
- peerKey: consent.peerKey,
199
- usageStampId: consent.usageStampId,
200
- peerSignature: consent.peerSignature,
201
- disclosure,
202
- cadrePeerAddrs: this.cadrePeerAddrs
203
- };
204
-
205
- this.dialerSessions++;
206
- try {
207
- const provision = await dialFormation(node, {
208
- contact,
209
- responderAddrs: invitation.bootstrap,
210
- validateResponse: (response) => this.validateResponse(invitation, disclosure, response),
211
- sessionTimeoutMs: this.config.sessionTimeoutMs,
212
- stepTimeoutMs: this.config.stepTimeoutMs,
213
- provisionTimeoutMs: this.initiatorProvisionTimeoutMs(),
214
- protocolId: this.config.protocolId
215
- });
216
-
217
- log('Strand formed: %s', provision.strand.strandId);
218
-
219
- return {
220
- memberKey: contact.partyId,
221
- invitePrivateKey: '',
222
- strandId: provision.strand.strandId,
223
- // The host strand's membership key, delivered through the protocol (provision-then-record).
224
- // Undefined for an open strand. Kept separate from invitePrivateKey (the initiator's
225
- // generated signing key), which is set by the StrandSolicitationService layer.
226
- memberPrivateKey: provision.memberPrivateKey
227
- };
228
- } finally {
229
- this.dialerSessions--;
230
- }
231
- }
232
-
233
- /**
234
- * Get the number of active sessions
235
- */
236
- getActiveSessionCounts(): { listeners: number; dialers: number } {
237
- return { listeners: this.listener.activeCount, dialers: this.dialerSessions };
238
- }
239
-
240
- /**
241
- * Derive the initiator's await-response budget from the configured RESPONDER budget —
242
- * never the same number (see `StrandFormationManagerConfig.provisionTimeoutMs`). Mirrors
243
- * `resolveProvisionTimeoutMs`'s own "`0`/negative means unset" rule so an unset config
244
- * still lets both sides fall back to their own independent defaults; must NOT hardcode
245
- * `DEFAULT_PROVISION_TIMEOUT_MS` here, or the per-role clamping downstream is defeated.
246
- * A value too large for the session is clamped per role, and the responder's ceiling holds
247
- * the same margin back, so the ordering survives there too.
248
- */
249
- private initiatorProvisionTimeoutMs(): number | undefined {
250
- const host = this.config.provisionTimeoutMs;
251
- return host && host > 0 ? host + PROVISION_RESPONSE_TRAVEL_MARGIN_MS : undefined;
252
- }
253
-
254
- // ── Responder-side hooks ─────────────────────────────────────────────────────
255
-
256
- private async validateToken(token: string): Promise<{ valid: boolean }> {
257
- if (!this.formationUsageRecorder) {
258
- // No recorder configured — accept all tokens.
259
- return { valid: true };
260
- }
261
-
262
- const tokenCheck = await this.formationUsageRecorder.isTokenValid(token);
263
- if (!tokenCheck.valid) {
264
- log('Token invalid: %s', token);
265
- return { valid: false };
266
- }
267
-
268
- if (await this.formationUsageRecorder.isTokenUsed(token)) {
269
- log('Token already used: %s', token);
270
- return { valid: false };
271
- }
272
-
273
- return { valid: true };
274
- }
275
-
276
- private async validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean> {
277
- if (!this.disclosureValidator) {
278
- // No validator configured — accept all disclosures.
279
- return true;
280
- }
281
- return this.disclosureValidator.validateDisclosure(token, disclosure);
282
- }
283
-
284
- /**
285
- * Responder-side provisioning. Routes on how the invite resolves
286
- * ({@link ResolvedHostStrand}), and can REJECT post-validation by returning a
287
- * {@link ResponderProvisionOutcome} with `approved: false` — `runSession` turns that
288
- * into a clean, non-disclosing reply instead of a dropped result frame.
289
- *
290
- * - **bound** (host strand present): provision-then-record — write the single
291
- * `FormationUsage` consent row against the pre-existing strand (record-only) and
292
- * return it + its membership key (a read-gating secret disclosed only here, behind the
293
- * token + disclosure validation `runSession` already enforced).
294
- * - **missing** (invite names a host strand absent on this responder, e.g. unconverged):
295
- * reject cleanly + retryably, writing NO usage row — recording usage here would fail the
296
- * deferred `StrandExists` CHECK at commit and drop the frame.
297
- * - **unbound** (no binding): the responder-provisions fallback — see {@link provisionUnbound}.
298
- *
299
- * Defense-in-depth: known provisioning/redeem failures are caught and mapped to a logged
300
- * protocol rejection rather than a thrown insert + a silently-closed stream. The mapping is
301
- * per-failure, NOT blanket retry-suggesting: an exhausted invitation
302
- * ({@link InvitationExhaustedError}, raised by `ControlDatabase` when the count of recorded
303
- * uses has reached the invite's seat budget — which is how the loser of a same-node race
304
- * surfaces, the local write queue having serialized the two writes) is reported as
305
- * `'Invalid token'`, because retrying it can never succeed. Concurrent redemptions never
306
- * contend for a shared row key — each writes under its own `UsageStampId` — so there is no
307
- * key collision to surface here at all. The LOG-before-reject keeps this a deliberate
308
- * internal-error→protocol-rejection conversion (AGENTS.md: don't eat exceptions silently),
309
- * not control-flow-by-exception.
310
- */
311
- private async provisionAsResponder(
312
- contact: FormationContactMessage,
313
- signal?: AbortSignal
314
- ): Promise<ResponderProvisionOutcome> {
315
- const token = contact.token;
316
- // CANONICALLY serialized — both sides derive the identical string: this is the exact
317
- // text the joiner signed (consent), the approver signs (vouch), and the recorder writes
318
- // to `FormationUsage.Disclosure` — a re-serialization anywhere below would break the
319
- // signature-over-stored-bytes invariant.
320
- const disclosureText = canonicalJson(contact.disclosure);
321
- if (uint8ArrayFromString(disclosureText, 'utf8').byteLength > MAX_DISCLOSURE_BYTES) {
322
- log('Disclosure over %d bytes; rejecting token %s', MAX_DISCLOSURE_BYTES, token);
323
- return { approved: false, reason: 'Disclosure too large' };
324
- }
325
-
326
- const recorder = this.formationUsageRecorder;
327
- const resolved: ResolvedHostStrand = recorder?.resolveStrand
328
- ? await recorder.resolveStrand(token)
329
- : { kind: 'unbound' };
330
-
331
- try {
332
- switch (resolved.kind) {
333
- case 'bound': {
334
- // recorder is guaranteed non-null here: only resolveStrand can yield 'bound'.
335
- await recorder!.recordUsage({
336
- token,
337
- peerKey: contact.peerKey,
338
- peerSignature: contact.peerSignature,
339
- usageStampId: contact.usageStampId,
340
- strandId: resolved.strandId,
341
- disclosure: disclosureText,
342
- signal
343
- });
344
- return this.approve({
345
- strand: { strandId: resolved.strandId, createdBy: 'responder' },
346
- memberPrivateKey: resolved.memberPrivateKey ?? undefined,
347
- dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
348
- });
349
- }
350
- case 'missing': {
351
- log('Host strand %s not yet available on this responder; rejecting token %s', resolved.strandId, token);
352
- return { approved: false, reason: 'Host strand not yet available on this responder' };
353
- }
354
- case 'unbound':
355
- return await this.provisionUnbound(contact, disclosureText, signal);
356
- }
357
- } catch (err) {
358
- if (err instanceof FormationAbortedError) {
359
- // The listener's timeout path owns the reply for an abandoned provisioning; mapping
360
- // this onto 'Formation conflict, retry' would misreport it as a conflict.
361
- throw err;
362
- }
363
- if (err instanceof FormationApprovalError) {
364
- log('approval failed (%s) for token %s: %o', err.failure, token, err);
365
- return { approved: false, reason: APPROVAL_REJECTION_REASONS[err.failure] };
366
- }
367
- if (err instanceof InvitationExhaustedError) {
368
- // Shares `INVALID_TOKEN_REASON` with the up-front `validateToken` rejection so the loser
369
- // of the race that exposed the spent invite sees exactly what a non-racing latecomer
370
- // sees. No wire-visible distinction between "invalid" and "exhausted" — the
371
- // operator signal lives in this log line instead.
372
- // NOTE: if a joining client ever has to tell "never valid" from "used up" WITHOUT node
373
- // logs, that is a new protocol reason string, not a local change here.
374
- log(
375
- 'invitation exhausted for token %s: %d of %d use(s) already recorded',
376
- err.token,
377
- err.usesRecorded,
378
- err.totalUses
379
- );
380
- return { approved: false, reason: INVALID_TOKEN_REASON };
381
- }
382
- // An approval is never discarded to a lost key race: each redemption writes under its
383
- // own `UsageStampId`, so no other writer can take its row key. Reaching this catch-all
384
- // means the failure was never retryable at the database layer to begin with (or the
385
- // transient-cluster retry inside `ControlDatabase.lockedWithRetry` ran out).
386
- log('provisionAsResponder failed for token %s: %o', token, err);
387
- return { approved: false, reason: 'Formation conflict, retry' };
388
- }
389
- }
390
-
391
- /**
392
- * Responder-provisions fallback (invite binds no host strand). Precedence:
393
- *
394
- * 1. `recorder.provisionAndRecord` present (a real DB recorder) → atomic create-strand +
395
- * record-consent, so the unbound redemption is single-use just like the bound path.
396
- * Returns the new strand (+ key, null for an open responder-provisioned strand).
397
- * 2. else `strandProvisioner` present → the legacy/mock contract: provision a bare strand,
398
- * NO inline usage write (those callers record usage explicitly via
399
- * `recordFormationComplete`, or assert transport invariants only). Threads the invite's
400
- * REAL `sAppId`.
401
- * 3. else → a structural placeholder (no recorder + no provisioner ⇒ no single-use semantics
402
- * exist to enforce).
403
- *
404
- * Plan tradeoff — option (a) "record usage on the fallback" over (b) "remove the fallback":
405
- * (a) closes the single-use hole with a targeted atomic create+record and leaves the
406
- * `StrandProvisioner` mock-transport tests untouched; (b) would delete the provisioner
407
- * surface and churn ~6 unit + ~6 integration sites for ZERO production benefit (production
408
- * — `cadre-web.ts` / `cadre-phone.ts` — always publishes strand-BOUND invites and treats
409
- * the responder-provisions placeholder as failure). The broader "should this fallback exist
410
- * at all?" question lives in backlog `formation-initiatorcreates-cover-or-remove`.
411
- */
412
- private async provisionUnbound(
413
- contact: FormationContactMessage,
414
- disclosureText: string,
415
- signal?: AbortSignal
416
- ): Promise<ResponderProvisionOutcome> {
417
- const token = contact.token;
418
- const recorder = this.formationUsageRecorder;
419
- if (recorder?.provisionAndRecord) {
420
- const sAppId = await this.resolveInviteSAppId(token);
421
- const provisioned = await recorder.provisionAndRecord({
422
- token,
423
- peerKey: contact.peerKey,
424
- peerSignature: contact.peerSignature,
425
- usageStampId: contact.usageStampId,
426
- sAppId,
427
- disclosure: disclosureText,
428
- signal
429
- });
430
- return this.approve({
431
- strand: { strandId: provisioned.strandId, createdBy: 'responder' },
432
- memberPrivateKey: provisioned.memberPrivateKey ?? undefined,
433
- dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
434
- });
435
- }
436
-
437
- if (this.strandProvisioner) {
438
- const sAppId = await this.resolveInviteSAppId(token);
439
- const result = await this.strandProvisioner.provisionStrand(sAppId, contact.partyId, this.partyId);
440
- return this.approve({
441
- strand: { strandId: result.strandId, createdBy: 'responder' },
442
- dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
443
- });
444
- }
445
-
446
- // No recorder + no provisioner — a structural placeholder the initiator can still validate.
447
- const strandId = `strand-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
448
- return this.approve({
449
- strand: { strandId, createdBy: 'responder' },
450
- dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
451
- });
452
- }
453
-
454
- /** Wrap a provision result as an approving {@link ResponderProvisionOutcome}. */
455
- private approve(result: FormationProvisionResult): ResponderProvisionOutcome {
456
- return { approved: true, result };
457
- }
458
-
459
- /**
460
- * The invite's authoritative `sAppId` for the responder-provisions fallback, read back
461
- * from the recorder's `isTokenValid` invitation (the real `FormationInvite.sAppId`).
462
- * Empty string when no recorder is wired or the invitation omits it.
463
- */
464
- private async resolveInviteSAppId(token: string): Promise<string> {
465
- if (!this.formationUsageRecorder) return '';
466
- const check = await this.formationUsageRecorder.isTokenValid(token);
467
- return check.invitation?.sAppId ?? '';
468
- }
469
-
470
- // ── Initiator-side result validation ─────────────────────────────────────────
471
-
472
- private async validateResponse(
473
- invitation: OpenInvitation,
474
- disclosure: StrandFormationDisclosure,
475
- response: FormationResultMessage
476
- ): Promise<boolean> {
477
- if (this.formationResponseValidator) {
478
- return this.formationResponseValidator.validateResponse({ invitation, disclosure, response });
479
- }
480
- return isValidResponderCreatesResult(response);
481
- }
482
- }
483
-
484
- /**
485
- * Create a StrandFormationManager with the given options
486
- */
487
- export function createStrandFormationManager(
488
- options: StrandFormationManagerOptions
489
- ): StrandFormationManager {
490
- return new StrandFormationManager(options);
491
- }
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
+ } from './types.js';
9
+ import {
10
+ FormationApprovalError,
11
+ type FormationApprovalFailure
12
+ } from './formation-approval.js';
13
+ // control-database does not import this manager, so this import introduces no cycle.
14
+ import { FormationAbortedError, InvitationExhaustedError } from './control-database.js';
15
+ import { canonicalJson } from './canonical-json.js';
16
+ import type {
17
+ DisclosureValidator,
18
+ FormationUsageRecorder,
19
+ ResolvedHostStrand,
20
+ StrandProvisioner,
21
+ FormationResponseValidator
22
+ } from './strand-solicitation.js';
23
+ import {
24
+ FormationListener,
25
+ INVALID_TOKEN_REASON,
26
+ PROVISION_RESPONSE_TRAVEL_MARGIN_MS,
27
+ dialFormation,
28
+ isValidResponderCreatesResult,
29
+ type FormationContactMessage,
30
+ type FormationProvisionResult,
31
+ type FormationResultMessage,
32
+ type ResponderProvisionOutcome
33
+ } from './strand-formation-protocol.js';
34
+
35
+ const log = debug('sereus:cadre:formation-manager');
36
+
37
+ /**
38
+ * Largest serialized disclosure a responder will accept (8 KiB). The disclosure is
39
+ * attacker-influenced text destined for a replicated table (`FormationUsage.Disclosure`)
40
+ * and for the approval hook's signed digest — the cap bounds both, and is enforced BEFORE
41
+ * any database read or hook contact so an oversized blob costs nothing downstream.
42
+ */
43
+ const MAX_DISCLOSURE_BYTES = 8 * 1024;
44
+
45
+ /**
46
+ * Rejection reason a would-be joiner is told for each approval-failure category
47
+ * (see {@link FormationApprovalFailure}). Distinct per category on purpose: mapping any
48
+ * of these onto the generic 'Formation conflict, retry' would tell an operator to retry
49
+ * what retrying can never fix (e.g. a non-enrolled validation key).
50
+ */
51
+ const APPROVAL_REJECTION_REASONS: Record<FormationApprovalFailure, string> = {
52
+ refused: 'Formation approval refused',
53
+ unavailable: 'Formation approval unavailable, retry',
54
+ malformed: 'Formation approval invalid',
55
+ unenrolled: 'Formation approval key is not enrolled',
56
+ misconfigured: 'Formation approval misconfigured'
57
+ };
58
+
59
+ /**
60
+ * Configuration for StrandFormationManager
61
+ */
62
+ export interface StrandFormationManagerConfig {
63
+ /** Session timeout in milliseconds */
64
+ sessionTimeoutMs?: number;
65
+ /** Step timeout in milliseconds */
66
+ stepTimeoutMs?: number;
67
+ /**
68
+ * Provisioning budget in milliseconds for the RESPONDER's `provisionStrand` hook call.
69
+ * The initiator's `await-response` wait is derived automatically from this value plus
70
+ * `PROVISION_RESPONSE_TRAVEL_MARGIN_MS` (`strand-formation-protocol.ts`) — never set
71
+ * directly — so the ordering documented there (approval hook < responder provisioning <
72
+ * initiator await-response < session) cannot collapse when this is configured.
73
+ */
74
+ provisionTimeoutMs?: number;
75
+ /** Maximum concurrent sessions */
76
+ maxConcurrentSessions?: number;
77
+ /** Enable debug logging */
78
+ enableDebugLogging?: boolean;
79
+ /** Protocol ID override */
80
+ protocolId?: string;
81
+ }
82
+
83
+ /**
84
+ * Options for creating a StrandFormationManager
85
+ */
86
+ export interface StrandFormationManagerOptions {
87
+ /** Validates disclosures from initiators (responder side) */
88
+ disclosureValidator?: DisclosureValidator;
89
+ /** Records and validates token usage (responder side) */
90
+ formationUsageRecorder?: FormationUsageRecorder;
91
+ /** Provisions strands after validation (responder side) */
92
+ strandProvisioner?: StrandProvisioner;
93
+ /** Validates the responder's result (initiator side); defaults to a structural check */
94
+ formationResponseValidator?: FormationResponseValidator;
95
+ /** This party's ID for identification */
96
+ partyId: string;
97
+ /** This party's cadre peer addresses */
98
+ cadrePeerAddrs?: string[];
99
+ /**
100
+ * This node's live STRAND-network multiaddrs for a strand it is running (responder
101
+ * side). Wired by `CadreNode` to its own per-strand address lookup; the manager has no
102
+ * route to the strand runtime itself.
103
+ *
104
+ * A hook, not a `config` knob, so it sits with the other responder-side seams
105
+ * (`strandProvisioner`, `formationUsageRecorder`) rather than among the timeouts. Left
106
+ * unwired — every mock/transport test — the responder simply discloses no strand
107
+ * addresses and cross-party joiners fall back to an empty seed.
108
+ */
109
+ resolveStrandAddrs?: (strandId: string) => string[];
110
+ /** Configuration options */
111
+ config?: StrandFormationManagerConfig;
112
+ }
113
+
114
+ /**
115
+ * StrandFormationManager drives the native cadre-core formation transport
116
+ * (`strand-formation-protocol.ts`) from cadre-core's strand-solicitation interfaces.
117
+ *
118
+ * Responder side: a {@link FormationListener} wires the inbound protocol to
119
+ * {@link FormationUsageRecorder} (token), {@link DisclosureValidator} (identity),
120
+ * and {@link StrandProvisioner} (provisioning), disclosing this party's real
121
+ * identity + cadre only after validation.
122
+ *
123
+ * Initiator side: {@link formStrand} dials the responder carrying the real
124
+ * disclosure/token/cadre, then validates the responder's result via the
125
+ * {@link FormationResponseValidator} (or a built-in structural check).
126
+ */
127
+ export class StrandFormationManager {
128
+ private readonly disclosureValidator?: DisclosureValidator;
129
+ private readonly formationUsageRecorder?: FormationUsageRecorder;
130
+ private readonly strandProvisioner?: StrandProvisioner;
131
+ private readonly formationResponseValidator?: FormationResponseValidator;
132
+ private readonly partyId: string;
133
+ private readonly cadrePeerAddrs: string[];
134
+ private readonly resolveStrandAddrs?: (strandId: string) => string[];
135
+ private readonly config: StrandFormationManagerConfig;
136
+ private readonly listener: FormationListener;
137
+ private readonly registeredNodes = new Set<Libp2p>();
138
+ private dialerSessions = 0;
139
+
140
+ constructor(options: StrandFormationManagerOptions) {
141
+ this.disclosureValidator = options.disclosureValidator;
142
+ this.formationUsageRecorder = options.formationUsageRecorder;
143
+ this.strandProvisioner = options.strandProvisioner;
144
+ this.formationResponseValidator = options.formationResponseValidator;
145
+ this.partyId = options.partyId;
146
+ this.cadrePeerAddrs = options.cadrePeerAddrs ?? [];
147
+ this.resolveStrandAddrs = options.resolveStrandAddrs;
148
+ this.config = options.config ?? {};
149
+
150
+ this.listener = new FormationListener({
151
+ validateToken: (token) => this.validateToken(token),
152
+ validateDisclosure: (token, disclosure) => this.validateDisclosure(token, disclosure),
153
+ provisionStrand: (contact, signal) =>
154
+ this.provisionAsResponder(contact, signal),
155
+ getResponderIdentity: () => ({ partyId: this.partyId, cadrePeerAddrs: this.cadrePeerAddrs }),
156
+ // Forwarded only when wired, so `FormationListenerOptions.resolveStrandAddrs`
157
+ // stays genuinely absent (and the listener short-circuits) for an unwired manager.
158
+ ...(this.resolveStrandAddrs && { resolveStrandAddrs: this.resolveStrandAddrs }),
159
+ sessionTimeoutMs: this.config.sessionTimeoutMs,
160
+ stepTimeoutMs: this.config.stepTimeoutMs,
161
+ provisionTimeoutMs: this.config.provisionTimeoutMs,
162
+ maxConcurrentSessions: this.config.maxConcurrentSessions
163
+ });
164
+
165
+ log('StrandFormationManager created for party: %s', this.partyId);
166
+ }
167
+
168
+ /**
169
+ * Register this manager as a protocol handler on a libp2p node.
170
+ * Call this on the control network node to handle incoming formation requests.
171
+ */
172
+ registerResponder(node: Libp2p, protocolId?: string): void {
173
+ if (this.registeredNodes.has(node)) {
174
+ log('Node already registered');
175
+ return;
176
+ }
177
+ this.listener.register(node, protocolId ?? this.config.protocolId);
178
+ this.registeredNodes.add(node);
179
+ log('Registered as responder on node');
180
+ }
181
+
182
+ /**
183
+ * Unregister the protocol handler from a libp2p node.
184
+ */
185
+ unregisterResponder(node: Libp2p, protocolId?: string): void {
186
+ if (!this.registeredNodes.has(node)) {
187
+ return;
188
+ }
189
+ this.listener.unregister(node, protocolId ?? this.config.protocolId);
190
+ this.registeredNodes.delete(node);
191
+ log('Unregistered from node');
192
+ }
193
+
194
+ /**
195
+ * Form a strand with a responder via an open invitation (initiator side).
196
+ *
197
+ * Builds a contact message carrying the real token + disclosure + this party's
198
+ * real cadre addresses + the joiner's consent (minted and signed by the
199
+ * solicitation layer — this manager only places it on the contact), dials the
200
+ * responder over the native protocol, and validates the responder's result
201
+ * before returning.
202
+ */
203
+ async formStrand(
204
+ invitation: OpenInvitation,
205
+ disclosure: StrandFormationDisclosure,
206
+ consent: { peerKey: string; usageStampId: string; peerSignature: string },
207
+ node: Libp2p
208
+ ): Promise<FormStrandResult> {
209
+ log('Forming strand with invitation token: %s', invitation.token);
210
+
211
+ const contact: FormationContactMessage = {
212
+ token: invitation.token,
213
+ partyId: disclosure.partyId ?? this.partyId,
214
+ peerKey: consent.peerKey,
215
+ usageStampId: consent.usageStampId,
216
+ peerSignature: consent.peerSignature,
217
+ disclosure,
218
+ cadrePeerAddrs: this.cadrePeerAddrs
219
+ };
220
+
221
+ this.dialerSessions++;
222
+ try {
223
+ const dialed = await dialFormation(node, {
224
+ contact,
225
+ responderAddrs: invitation.bootstrap,
226
+ validateResponse: (response) => this.validateResponse(invitation, disclosure, response),
227
+ sessionTimeoutMs: this.config.sessionTimeoutMs,
228
+ stepTimeoutMs: this.config.stepTimeoutMs,
229
+ provisionTimeoutMs: this.initiatorProvisionTimeoutMs(),
230
+ protocolId: this.config.protocolId
231
+ });
232
+
233
+ const provision = dialed.provision;
234
+ log('Strand formed: %s (%d cross-party strand addr(s))', provision.strand.strandId, dialed.strandAddrs.length);
235
+
236
+ return {
237
+ memberKey: contact.partyId,
238
+ invitePrivateKey: '',
239
+ strandId: provision.strand.strandId,
240
+ // The host strand's membership key, delivered through the protocol (provision-then-record).
241
+ // Undefined for an open strand. Kept separate from invitePrivateKey (the initiator's
242
+ // generated signing key), which is set by the StrandSolicitationService layer.
243
+ memberPrivateKey: provision.memberPrivateKey,
244
+ // Possibly empty — the responder may hold no live strand node yet.
245
+ strandAddrs: dialed.strandAddrs
246
+ };
247
+ } finally {
248
+ this.dialerSessions--;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Get the number of active sessions
254
+ */
255
+ getActiveSessionCounts(): { listeners: number; dialers: number } {
256
+ return { listeners: this.listener.activeCount, dialers: this.dialerSessions };
257
+ }
258
+
259
+ /**
260
+ * Derive the initiator's await-response budget from the configured RESPONDER budget —
261
+ * never the same number (see `StrandFormationManagerConfig.provisionTimeoutMs`). Mirrors
262
+ * `resolveProvisionTimeoutMs`'s own "`0`/negative means unset" rule so an unset config
263
+ * still lets both sides fall back to their own independent defaults; must NOT hardcode
264
+ * `DEFAULT_PROVISION_TIMEOUT_MS` here, or the per-role clamping downstream is defeated.
265
+ * A value too large for the session is clamped per role, and the responder's ceiling holds
266
+ * the same margin back, so the ordering survives there too.
267
+ */
268
+ private initiatorProvisionTimeoutMs(): number | undefined {
269
+ const host = this.config.provisionTimeoutMs;
270
+ return host && host > 0 ? host + PROVISION_RESPONSE_TRAVEL_MARGIN_MS : undefined;
271
+ }
272
+
273
+ // ── Responder-side hooks ─────────────────────────────────────────────────────
274
+
275
+ private async validateToken(token: string): Promise<{ valid: boolean }> {
276
+ if (!this.formationUsageRecorder) {
277
+ // No recorder configured — accept all tokens.
278
+ return { valid: true };
279
+ }
280
+
281
+ const tokenCheck = await this.formationUsageRecorder.isTokenValid(token);
282
+ if (!tokenCheck.valid) {
283
+ log('Token invalid: %s', token);
284
+ return { valid: false };
285
+ }
286
+
287
+ if (await this.formationUsageRecorder.isTokenUsed(token)) {
288
+ log('Token already used: %s', token);
289
+ return { valid: false };
290
+ }
291
+
292
+ return { valid: true };
293
+ }
294
+
295
+ private async validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean> {
296
+ if (!this.disclosureValidator) {
297
+ // No validator configured — accept all disclosures.
298
+ return true;
299
+ }
300
+ return this.disclosureValidator.validateDisclosure(token, disclosure);
301
+ }
302
+
303
+ /**
304
+ * Responder-side provisioning. Routes on how the invite resolves
305
+ * ({@link ResolvedHostStrand}), and can REJECT post-validation by returning a
306
+ * {@link ResponderProvisionOutcome} with `approved: false` — `runSession` turns that
307
+ * into a clean, non-disclosing reply instead of a dropped result frame.
308
+ *
309
+ * - **bound** (host strand present): provision-then-record — write the single
310
+ * `FormationUsage` consent row against the pre-existing strand (record-only) and
311
+ * return it + its membership key (a read-gating secret disclosed only here, behind the
312
+ * token + disclosure validation `runSession` already enforced).
313
+ * - **missing** (invite names a host strand absent on this responder, e.g. unconverged):
314
+ * reject cleanly + retryably, writing NO usage row — recording usage here would fail the
315
+ * deferred `StrandExists` CHECK at commit and drop the frame.
316
+ * - **unbound** (no binding): the responder-provisions fallback — see {@link provisionUnbound}.
317
+ *
318
+ * Defense-in-depth: known provisioning/redeem failures are caught and mapped to a logged
319
+ * protocol rejection rather than a thrown insert + a silently-closed stream. The mapping is
320
+ * per-failure, NOT blanket retry-suggesting: an exhausted invitation
321
+ * ({@link InvitationExhaustedError}, raised by `ControlDatabase` when the count of recorded
322
+ * uses has reached the invite's seat budget — which is how the loser of a same-node race
323
+ * surfaces, the local write queue having serialized the two writes) is reported as
324
+ * `'Invalid token'`, because retrying it can never succeed. Concurrent redemptions never
325
+ * contend for a shared row key — each writes under its own `UsageStampId` — so there is no
326
+ * key collision to surface here at all. The LOG-before-reject keeps this a deliberate
327
+ * internal-error→protocol-rejection conversion (AGENTS.md: don't eat exceptions silently),
328
+ * not control-flow-by-exception.
329
+ */
330
+ private async provisionAsResponder(
331
+ contact: FormationContactMessage,
332
+ signal?: AbortSignal
333
+ ): Promise<ResponderProvisionOutcome> {
334
+ const token = contact.token;
335
+ // CANONICALLY serialized — both sides derive the identical string: this is the exact
336
+ // text the joiner signed (consent), the approver signs (vouch), and the recorder writes
337
+ // to `FormationUsage.Disclosure` — a re-serialization anywhere below would break the
338
+ // signature-over-stored-bytes invariant.
339
+ const disclosureText = canonicalJson(contact.disclosure);
340
+ if (uint8ArrayFromString(disclosureText, 'utf8').byteLength > MAX_DISCLOSURE_BYTES) {
341
+ log('Disclosure over %d bytes; rejecting token %s', MAX_DISCLOSURE_BYTES, token);
342
+ return { approved: false, reason: 'Disclosure too large' };
343
+ }
344
+
345
+ const recorder = this.formationUsageRecorder;
346
+ const resolved: ResolvedHostStrand = recorder?.resolveStrand
347
+ ? await recorder.resolveStrand(token)
348
+ : { kind: 'unbound' };
349
+
350
+ try {
351
+ switch (resolved.kind) {
352
+ case 'bound': {
353
+ // recorder is guaranteed non-null here: only resolveStrand can yield 'bound'.
354
+ await recorder!.recordUsage({
355
+ token,
356
+ peerKey: contact.peerKey,
357
+ peerSignature: contact.peerSignature,
358
+ usageStampId: contact.usageStampId,
359
+ strandId: resolved.strandId,
360
+ disclosure: disclosureText,
361
+ signal
362
+ });
363
+ return this.approve({
364
+ strand: { strandId: resolved.strandId, createdBy: 'responder' },
365
+ memberPrivateKey: resolved.memberPrivateKey ?? undefined,
366
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
367
+ });
368
+ }
369
+ case 'missing': {
370
+ log('Host strand %s not yet available on this responder; rejecting token %s', resolved.strandId, token);
371
+ return { approved: false, reason: 'Host strand not yet available on this responder' };
372
+ }
373
+ case 'unbound':
374
+ return await this.provisionUnbound(contact, disclosureText, signal);
375
+ }
376
+ } catch (err) {
377
+ if (err instanceof FormationAbortedError) {
378
+ // The listener's timeout path owns the reply for an abandoned provisioning; mapping
379
+ // this onto 'Formation conflict, retry' would misreport it as a conflict.
380
+ throw err;
381
+ }
382
+ if (err instanceof FormationApprovalError) {
383
+ log('approval failed (%s) for token %s: %o', err.failure, token, err);
384
+ return { approved: false, reason: APPROVAL_REJECTION_REASONS[err.failure] };
385
+ }
386
+ if (err instanceof InvitationExhaustedError) {
387
+ // Shares `INVALID_TOKEN_REASON` with the up-front `validateToken` rejection so the loser
388
+ // of the race that exposed the spent invite sees exactly what a non-racing latecomer
389
+ // sees. No wire-visible distinction between "invalid" and "exhausted" — the
390
+ // operator signal lives in this log line instead.
391
+ // NOTE: if a joining client ever has to tell "never valid" from "used up" WITHOUT node
392
+ // logs, that is a new protocol reason string, not a local change here.
393
+ log(
394
+ 'invitation exhausted for token %s: %d of %d use(s) already recorded',
395
+ err.token,
396
+ err.usesRecorded,
397
+ err.totalUses
398
+ );
399
+ return { approved: false, reason: INVALID_TOKEN_REASON };
400
+ }
401
+ // An approval is never discarded to a lost key race: each redemption writes under its
402
+ // own `UsageStampId`, so no other writer can take its row key. Reaching this catch-all
403
+ // means the failure was never retryable at the database layer to begin with (or the
404
+ // transient-cluster retry inside `ControlDatabase.lockedWithRetry` ran out).
405
+ log('provisionAsResponder failed for token %s: %o', token, err);
406
+ return { approved: false, reason: 'Formation conflict, retry' };
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Responder-provisions fallback (invite binds no host strand). Precedence:
412
+ *
413
+ * 1. `recorder.provisionAndRecord` present (a real DB recorder) → atomic create-strand +
414
+ * record-consent, so the unbound redemption is single-use just like the bound path.
415
+ * Returns the new strand (+ key, null for an open responder-provisioned strand).
416
+ * 2. else `strandProvisioner` present → the mock/transport-test contract: provision a bare
417
+ * strand, NO inline usage write (those callers record usage explicitly via
418
+ * `recordFormationComplete`, or assert transport invariants only). Threads the invite's
419
+ * REAL `sAppId`.
420
+ * 3. else → a structural placeholder (no recorder + no provisioner ⇒ no single-use semantics
421
+ * exist to enforce).
422
+ *
423
+ * NOTE: accepted tradeoff — the plan chose (a) "record usage on the fallback" over (b)
424
+ * "remove the fallback", so arm 2 (`strandProvisioner`, no recorder) is kept. (a) closes the
425
+ * single-use hole with a targeted atomic create+record and leaves the `StrandProvisioner`
426
+ * mock-transport tests untouched; (b) would delete the provisioner surface and churn ~6 unit
427
+ * + ~6 integration sites for ZERO production benefit (production — `cadre-web.ts` /
428
+ * `cadre-phone.ts` — always publishes strand-BOUND invites and treats the
429
+ * responder-provisions placeholder as failure). Revisit if production starts publishing
430
+ * unbound invites, or the mock-transport tests go away.
431
+ */
432
+ private async provisionUnbound(
433
+ contact: FormationContactMessage,
434
+ disclosureText: string,
435
+ signal?: AbortSignal
436
+ ): Promise<ResponderProvisionOutcome> {
437
+ const token = contact.token;
438
+ const recorder = this.formationUsageRecorder;
439
+ if (recorder?.provisionAndRecord) {
440
+ const sAppId = await this.resolveInviteSAppId(token);
441
+ const provisioned = await recorder.provisionAndRecord({
442
+ token,
443
+ peerKey: contact.peerKey,
444
+ peerSignature: contact.peerSignature,
445
+ usageStampId: contact.usageStampId,
446
+ sAppId,
447
+ disclosure: disclosureText,
448
+ signal
449
+ });
450
+ return this.approve({
451
+ strand: { strandId: provisioned.strandId, createdBy: 'responder' },
452
+ memberPrivateKey: provisioned.memberPrivateKey ?? undefined,
453
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
454
+ });
455
+ }
456
+
457
+ if (this.strandProvisioner) {
458
+ const sAppId = await this.resolveInviteSAppId(token);
459
+ const result = await this.strandProvisioner.provisionStrand(sAppId, contact.partyId, this.partyId);
460
+ return this.approve({
461
+ strand: { strandId: result.strandId, createdBy: 'responder' },
462
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
463
+ });
464
+ }
465
+
466
+ // No recorder + no provisioner — a structural placeholder the initiator can still validate.
467
+ const strandId = `strand-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
468
+ return this.approve({
469
+ strand: { strandId, createdBy: 'responder' },
470
+ dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
471
+ });
472
+ }
473
+
474
+ /** Wrap a provision result as an approving {@link ResponderProvisionOutcome}. */
475
+ private approve(result: FormationProvisionResult): ResponderProvisionOutcome {
476
+ return { approved: true, result };
477
+ }
478
+
479
+ /**
480
+ * The invite's authoritative `sAppId` for the responder-provisions fallback, read back
481
+ * from the recorder's `isTokenValid` invitation (the real `FormationInvite.sAppId`).
482
+ * Empty string when no recorder is wired or the invitation omits it.
483
+ */
484
+ private async resolveInviteSAppId(token: string): Promise<string> {
485
+ if (!this.formationUsageRecorder) return '';
486
+ const check = await this.formationUsageRecorder.isTokenValid(token);
487
+ return check.invitation?.sAppId ?? '';
488
+ }
489
+
490
+ // ── Initiator-side result validation ─────────────────────────────────────────
491
+
492
+ private async validateResponse(
493
+ invitation: OpenInvitation,
494
+ disclosure: StrandFormationDisclosure,
495
+ response: FormationResultMessage
496
+ ): Promise<boolean> {
497
+ if (this.formationResponseValidator) {
498
+ return this.formationResponseValidator.validateResponse({ invitation, disclosure, response });
499
+ }
500
+ return isValidResponderCreatesResult(response);
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Create a StrandFormationManager with the given options
506
+ */
507
+ export function createStrandFormationManager(
508
+ options: StrandFormationManagerOptions
509
+ ): StrandFormationManager {
510
+ return new StrandFormationManager(options);
511
+ }