@serfab/cadre-core 0.12.0 → 1.0.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 (155) hide show
  1. package/README.md +23 -8
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +883 -102
  7. package/dist/cadre-node.js +1800 -209
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +318 -44
  16. package/dist/control-database.js +516 -74
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +133 -21
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store-file.d.ts +13 -0
  31. package/dist/enrolled-machine-store-file.js +41 -0
  32. package/dist/enrolled-machine-store-file.js.map +1 -0
  33. package/dist/enrolled-machine-store.d.ts +81 -0
  34. package/dist/enrolled-machine-store.js +225 -0
  35. package/dist/enrolled-machine-store.js.map +1 -0
  36. package/dist/file-durable-slot.js +5 -3
  37. package/dist/file-durable-slot.js.map +1 -1
  38. package/dist/index.d.ts +17 -8
  39. package/dist/index.js +54 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +15 -1
  42. package/dist/membership-connection-gater.js +9 -3
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-local-snapshot.d.ts +21 -7
  45. package/dist/node-local-snapshot.js +30 -9
  46. package/dist/node-local-snapshot.js.map +1 -1
  47. package/dist/peer-dial.d.ts +143 -0
  48. package/dist/peer-dial.js +176 -0
  49. package/dist/peer-dial.js.map +1 -0
  50. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  51. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  52. package/dist/peer-join-backfill.js.map +1 -0
  53. package/dist/relay-addrs.d.ts +160 -49
  54. package/dist/relay-addrs.js +287 -60
  55. package/dist/relay-addrs.js.map +1 -1
  56. package/dist/relay-reservation.d.ts +71 -3
  57. package/dist/relay-reservation.js +116 -8
  58. package/dist/relay-reservation.js.map +1 -1
  59. package/dist/seed-bootstrap.d.ts +30 -1
  60. package/dist/seed-bootstrap.js +62 -17
  61. package/dist/seed-bootstrap.js.map +1 -1
  62. package/dist/storage-scope.d.ts +62 -0
  63. package/dist/storage-scope.js +75 -0
  64. package/dist/storage-scope.js.map +1 -0
  65. package/dist/strand-database.d.ts +41 -11
  66. package/dist/strand-database.js +48 -18
  67. package/dist/strand-database.js.map +1 -1
  68. package/dist/strand-first-sync-gate.d.ts +152 -0
  69. package/dist/strand-first-sync-gate.js +208 -0
  70. package/dist/strand-first-sync-gate.js.map +1 -0
  71. package/dist/strand-formation-manager.d.ts +99 -5
  72. package/dist/strand-formation-manager.js +119 -8
  73. package/dist/strand-formation-manager.js.map +1 -1
  74. package/dist/strand-formation-protocol.d.ts +97 -3
  75. package/dist/strand-formation-protocol.js +108 -2
  76. package/dist/strand-formation-protocol.js.map +1 -1
  77. package/dist/strand-instance-manager.d.ts +389 -19
  78. package/dist/strand-instance-manager.js +678 -65
  79. package/dist/strand-instance-manager.js.map +1 -1
  80. package/dist/strand-member-key.d.ts +20 -12
  81. package/dist/strand-member-key.js +20 -12
  82. package/dist/strand-member-key.js.map +1 -1
  83. package/dist/strand-member-registry.js +3 -0
  84. package/dist/strand-member-registry.js.map +1 -1
  85. package/dist/strand-membership-reconciler.d.ts +361 -0
  86. package/dist/strand-membership-reconciler.js +548 -0
  87. package/dist/strand-membership-reconciler.js.map +1 -0
  88. package/dist/strand-membership-writer.d.ts +143 -11
  89. package/dist/strand-membership-writer.js +419 -135
  90. package/dist/strand-membership-writer.js.map +1 -1
  91. package/dist/strand-network-config.d.ts +116 -0
  92. package/dist/strand-network-config.js +192 -0
  93. package/dist/strand-network-config.js.map +1 -0
  94. package/dist/strand-revocation-enforcer.d.ts +392 -0
  95. package/dist/strand-revocation-enforcer.js +533 -0
  96. package/dist/strand-revocation-enforcer.js.map +1 -0
  97. package/dist/strand-solicitation.d.ts +17 -1
  98. package/dist/strand-solicitation.js +13 -2
  99. package/dist/strand-solicitation.js.map +1 -1
  100. package/dist/strand-wake-protocol.d.ts +11 -7
  101. package/dist/strand-wake-protocol.js +20 -52
  102. package/dist/strand-wake-protocol.js.map +1 -1
  103. package/dist/strand-watcher.js +12 -1
  104. package/dist/strand-watcher.js.map +1 -1
  105. package/dist/timed-step.d.ts +12 -0
  106. package/dist/timed-step.js +28 -0
  107. package/dist/timed-step.js.map +1 -0
  108. package/dist/timeout-scheduler.d.ts +19 -0
  109. package/dist/timeout-scheduler.js +22 -0
  110. package/dist/timeout-scheduler.js.map +1 -0
  111. package/dist/types.d.ts +472 -46
  112. package/dist/types.js +7 -1
  113. package/dist/types.js.map +1 -1
  114. package/package.json +16 -8
  115. package/src/bootstrap-peer-store-file.ts +2 -2
  116. package/src/bootstrap-peer-store.ts +69 -27
  117. package/src/cadre-node.ts +1926 -228
  118. package/src/control-authorization.ts +2 -1
  119. package/src/control-cohort.ts +122 -130
  120. package/src/control-database.ts +586 -74
  121. package/src/control-read-retry.ts +202 -167
  122. package/src/control-retry.ts +297 -190
  123. package/src/control-schema.ts +133 -21
  124. package/src/control-write-retry.ts +525 -383
  125. package/src/enrolled-machine-store-file.ts +43 -0
  126. package/src/enrolled-machine-store.ts +280 -0
  127. package/src/file-durable-slot.ts +5 -3
  128. package/src/index.ts +134 -15
  129. package/src/membership-connection-gater.ts +9 -3
  130. package/src/node-local-snapshot.ts +38 -9
  131. package/src/peer-addr-book.ts +251 -250
  132. package/src/peer-dial.ts +215 -0
  133. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  134. package/src/relay-addrs.ts +458 -218
  135. package/src/relay-reservation.ts +951 -828
  136. package/src/seed-bootstrap.ts +76 -18
  137. package/src/storage-scope.ts +78 -0
  138. package/src/strand-database.ts +61 -21
  139. package/src/strand-first-sync-gate.ts +258 -0
  140. package/src/strand-formation-manager.ts +162 -8
  141. package/src/strand-formation-protocol.ts +850 -692
  142. package/src/strand-instance-manager.ts +877 -74
  143. package/src/strand-member-key.ts +20 -12
  144. package/src/strand-member-registry.ts +3 -0
  145. package/src/strand-membership-reconciler.ts +680 -0
  146. package/src/strand-membership-writer.ts +550 -190
  147. package/src/strand-network-config.ts +250 -0
  148. package/src/strand-revocation-enforcer.ts +653 -0
  149. package/src/strand-solicitation.ts +499 -471
  150. package/src/strand-wake-protocol.ts +21 -66
  151. package/src/strand-watcher.ts +325 -314
  152. package/src/timed-step.ts +28 -0
  153. package/src/timeout-scheduler.ts +28 -0
  154. package/src/types.ts +487 -52
  155. package/dist/strand-backfill.js.map +0 -1
@@ -1,692 +1,850 @@
1
- /**
2
- * Native cadre-core strand-formation transport.
3
- *
4
- * Mirrors the `seed-bootstrap.ts` protocol service: a dedicated
5
- * libp2p protocol id, length-prefixed JSON frames, and small single-purpose
6
- * session helpers. It replaces the removed `@serfab/strand-proto` package's transport,
7
- * carrying the caller's REAL invitation token + disclosure and BOTH parties'
8
- * real cadre peer addresses end-to-end (no `{ partyId: sessionId }` / `cadre-*.local`
9
- * placeholders), and validating the responder's result on the initiator side.
10
- *
11
- * Provisioning flow (`responderCreates`, 2 messages): the responder provisions (or, for
12
- * provision-then-record, resolves + records consent against) the strand and returns the
13
- * result on approval.
14
- *
15
- * Cadre-disclosure timing: the responder reveals its own party id + cadre addresses
16
- * — and, for a closed strand returned via provision-then-record, that strand's
17
- * membership key — ONLY after the token and disclosure validate; a rejection
18
- * discloses none of them.
19
- */
20
-
21
- import debug from 'debug';
22
- import { multiaddr } from '@multiformats/multiaddr';
23
- import type { Libp2p } from '@libp2p/interface';
24
- import type { StrandFormationDisclosure } from './types.js';
25
- import { type ControlStream, writeFrame, withTimeout } from './control-stream.js';
26
- import { canonicalJson } from './canonical-json.js';
27
- import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
28
- import { verifyFormationConsent } from './peer-authorization.js';
29
- // seed-bootstrap imports neither this protocol nor the manager/solicitation layers,
30
- // so this import introduces no cycle.
31
- import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
32
-
33
- const log = debug('sereus:cadre:formation-proto');
34
-
35
- /** Protocol id for the native formation transport (parallel to `/sereus/seed/1.0.0`). */
36
- export const FORMATION_PROTOCOL = '/sereus/formation/1.0.0';
37
-
38
- /**
39
- * Rejection reason for an invitation that cannot be redeemed at all — unknown, expired, or
40
- * fully spent. Shared with `StrandFormationManager`, which reports an
41
- * `InvitationExhaustedError` (an invite spent out from under a redemption already in flight)
42
- * with this SAME wording on purpose: a joiner that lost the race must be indistinguishable
43
- * from a non-racing latecomer, so the two sites must never drift apart.
44
- */
45
- export const INVALID_TOKEN_REASON = 'Invalid token';
46
-
47
- /** Maximum formation message size (1MB). */
48
- const MAX_FORMATION_MSG_SIZE = 1024 * 1024;
49
-
50
- /** Default whole-session timeout (ms). */
51
- const DEFAULT_SESSION_TIMEOUT_MS = 30_000;
52
- /** Default per-step (single read/write) timeout (ms). */
53
- const DEFAULT_STEP_TIMEOUT_MS = 5_000;
54
- /**
55
- * Default provisioning budget (ms) — distinct from {@link DEFAULT_STEP_TIMEOUT_MS} because
56
- * `provisionStrand` is real work (DB writes, and an outbound approval-hook HTTP call when
57
- * the invite carries a `ValidationUrl`), not a bare wire read/write.
58
- *
59
- * Ordering is deliberate — each layer must be able to fail and report before the layer above
60
- * it gives up:
61
- *
62
- * approval hook (10 s, `formation-approval.ts`)
63
- * < responder provisioning (12 s)
64
- * < initiator await-response (15 s)
65
- * < session (30 s)
66
- *
67
- * The 3 s margin between responder provisioning and initiator await-response is the wire
68
- * latency budget for the result frame to travel back.
69
- *
70
- * The last {@link PROVISION_SETTLE_GRACE_MS} of this budget is a settle grace, so the default
71
- * WORK budget (12 s − 2 s = 10 s) EQUALS the hook's own 10 s: a dead hook races 'Formation
72
- * approval unavailable, retry' against 'Formation provisioning timed out'. Both are retryable
73
- * and both leave the invite unspent, so the race is benign.
74
- */
75
- const DEFAULT_PROVISION_TIMEOUT_MS = 12_000;
76
- /**
77
- * Settle grace (ms), carved OUT of `provisionTimeoutMs` — never added on top, so the budget
78
- * ladder above is untouched. The listener aborts the provisioning hook when the work budget
79
- * ({@link splitProvisionBudget}) expires, then waits up to this grace for the work to settle
80
- * anyway: once the `FormationUsage` insert has been issued nothing can un-spend it (the table
81
- * is append-only), so a provisioning that lands late is ADOPTED and reported as a real
82
- * approval rather than lying "timed out" over a spent invite. A hook that observes the
83
- * abort before writing leaves the invite unspent.
84
- */
85
- const PROVISION_SETTLE_GRACE_MS = 2_000;
86
- /**
87
- * Wire-latency margin (ms) added to the responder's provisioning budget to get the
88
- * initiator's await-response budget — the travel time for the result frame to come back
89
- * after the responder finishes (see {@link DEFAULT_PROVISION_TIMEOUT_MS}).
90
- * `StrandFormationManager` reuses this constant so a CONFIGURED budget preserves the same
91
- * margin the defaults do, instead of applying the same number to both sides and racing the
92
- * initiator's own timeout against the responder's clean rejection reply. The responder's
93
- * clamp ({@link provisionCeilingMs}) holds the same margin back, so the ordering survives a
94
- * budget large enough that BOTH roles get clamped.
95
- *
96
- * NOTE: 3 s is a fixed assumption about how long the result frame takes to travel back. If
97
- * formation ever runs over paths where that frame can take longer (a slow circuit relay, a
98
- * congested mobile link), the margin is too thin and the initiator's timeout races the
99
- * responder's reply again — raise it, or derive it from an observed round-trip.
100
- */
101
- export const PROVISION_RESPONSE_TRAVEL_MARGIN_MS = 3_000;
102
- /** Default initiator await-response budget (ms); see {@link DEFAULT_PROVISION_TIMEOUT_MS}. */
103
- const DEFAULT_INITIATOR_PROVISION_TIMEOUT_MS = DEFAULT_PROVISION_TIMEOUT_MS + PROVISION_RESPONSE_TRAVEL_MARGIN_MS;
104
- /**
105
- * Default cap on concurrent inbound formation sessions.
106
- * NOTE: a longer provisioning budget holds a session's slot longer, so a slow hook can
107
- * exhaust this cap and make the listener reject fresh joiners with 'Too many concurrent
108
- * formation sessions'. Fine at the current default; revisit if `provisionTimeoutMs` is
109
- * raised well above its default or this cap is lowered.
110
- */
111
- const DEFAULT_MAX_CONCURRENT_SESSIONS = 100;
112
-
113
- // ── Roles ────────────────────────────────────────────────────────────────────
114
-
115
- export type FormationParty = 'initiator' | 'responder';
116
-
117
- // ── Wire messages (length-prefixed JSON frames) ──────────────────────────────
118
-
119
- export interface FormationStrandInfo {
120
- strandId: string;
121
- createdBy: FormationParty;
122
- }
123
-
124
- export interface FormationDbConnectionInfo {
125
- endpoint: string;
126
- credentialsRef: string;
127
- }
128
-
129
- export interface FormationProvisionResult {
130
- strand: FormationStrandInfo;
131
- /**
132
- * The strand's membership key (closed-strand read-gating secret), delivered to a
133
- * validated invitee for provision-then-record formation. Disclosed only AFTER token
134
- * + disclosure validation, exactly like the responder identity/cadre — a rejected or
135
- * already-used token discloses neither identity nor key. Absent for open strands.
136
- */
137
- memberPrivateKey?: string;
138
- dbConnectionInfo: FormationDbConnectionInfo;
139
- }
140
-
141
- /**
142
- * Outcome of the responder's provisioning hook.
143
- *
144
- * Distinct from a bare {@link FormationProvisionResult} so the hook can REJECT a
145
- * formation AFTER token + disclosure validation — e.g. a bound invite naming a host
146
- * strand this responder has not yet converged on, or a concurrent-redemption collision.
147
- * Without this channel such cases threw inside provisioning, the stream closed with no
148
- * result frame, and the initiator saw a read-error/timeout instead of a clean
149
- * `approved: false`. A rejection still discloses NO responder identity/cadre.
150
- */
151
- export type ResponderProvisionOutcome =
152
- | { approved: true; result: FormationProvisionResult }
153
- | { approved: false; reason: string };
154
-
155
- /** Initiator → Responder: carries the real token + disclosure + initiator cadre. */
156
- export interface FormationContactMessage {
157
- /** The real invitation token. */
158
- token: string;
159
- /** Initiator's member key (peer id) — human-facing identity in logs and `FormStrandResult.memberKey`. */
160
- partyId: string;
161
- /**
162
- * Initiator's base64url ed25519 public key — the key behind `partyId`, written to
163
- * `FormationUsage.PeerKey` and covered by BOTH signed digests.
164
- */
165
- peerKey: string;
166
- /** Joiner-minted single-use nonce for THIS redemption; both signed digests cover it. */
167
- usageStampId: string;
168
- /**
169
- * The joiner's signature over the `'consent'` digest (see `formationConsentMessage`
170
- * in control-database.ts) — proof the named peer agreed to this redemption.
171
- */
172
- peerSignature: string;
173
- /** The real disclosure, carried verbatim. */
174
- disclosure: StrandFormationDisclosure;
175
- /** Initiator's real multiaddrs. */
176
- cadrePeerAddrs: string[];
177
- }
178
-
179
- /** Responder → Initiator: responder identity/cadre disclosed only after validation. */
180
- export interface FormationResultMessage {
181
- approved: boolean;
182
- /** Present iff `approved === false`. */
183
- reason?: string;
184
- /** Responder's real party id (disclosed only after validation). */
185
- partyId?: string;
186
- /** Responder's real multiaddrs (omitted on rejection). */
187
- cadrePeerAddrs?: string[];
188
- /** The provisioned strand/db result (always present on approval). */
189
- provisionResult?: FormationProvisionResult;
190
- }
191
-
192
- // ── Stream framing helpers ───────────────────────────────────────────────────
193
-
194
- /**
195
- * Reads length-prefixed JSON frames from a libp2p stream one frame at a time.
196
- *
197
- * Unlike seed delivery (which reads a stream to EOF then parses once), formation
198
- * is request/response on a single live stream, so reading must stop at each frame
199
- * boundary. The reader buffers across chunk boundaries and retains any overflow
200
- * (a following frame coalesced into the same chunk) for the next read.
201
- */
202
- class FrameReader {
203
- private readonly iterator: AsyncIterator<Uint8Array>;
204
- private readonly maxLength: number;
205
- private buffer = new Uint8Array(0);
206
-
207
- constructor(stream: AsyncIterable<Uint8Array>, maxLength = MAX_FORMATION_MSG_SIZE) {
208
- this.iterator = stream[Symbol.asyncIterator]();
209
- this.maxLength = maxLength;
210
- }
211
-
212
- private append(chunk: Uint8Array): void {
213
- const next = new Uint8Array(this.buffer.length + chunk.length);
214
- next.set(this.buffer, 0);
215
- next.set(chunk, this.buffer.length);
216
- this.buffer = next;
217
- }
218
-
219
- /** Pull chunks until the buffer holds ≥ `need` bytes; false if the stream ended first. */
220
- private async fill(need: number): Promise<boolean> {
221
- while (this.buffer.length < need) {
222
- const { value, done } = await this.iterator.next();
223
- if (done) return false;
224
- const bytes = value instanceof Uint8Array
225
- ? value
226
- : (value as { subarray(): Uint8Array }).subarray();
227
- this.append(bytes);
228
- }
229
- return true;
230
- }
231
-
232
- async read<T>(): Promise<T> {
233
- if (!(await this.fill(4))) {
234
- throw new Error('Formation stream closed before length prefix');
235
- }
236
- const length = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength).getUint32(0, false);
237
- if (length > this.maxLength) {
238
- throw new Error(`Formation frame declares length ${length} exceeding max ${this.maxLength}`);
239
- }
240
- if (!(await this.fill(4 + length))) {
241
- throw new Error(`Formation frame declares length ${length} but stream ended early`);
242
- }
243
- const json = new TextDecoder().decode(this.buffer.subarray(4, 4 + length));
244
- this.buffer = this.buffer.subarray(4 + length);
245
- return JSON.parse(json) as T;
246
- }
247
- }
248
-
249
- // ── Result validation ────────────────────────────────────────────────────────
250
-
251
- /** Matches the placeholder cadre addrs (`cadre-a-1.local`, …) of the removed strand-proto transport. */
252
- const PLACEHOLDER_CADRE_ADDR = /^cadre-[ab]-\d+\.local$/;
253
-
254
- /**
255
- * Structural check on a `responderCreates` result: the responder must have approved,
256
- * disclosed a non-empty real identity + cadre (not the legacy `cadre-*.local`
257
- * placeholders), and returned a strand vouched for by the responder party with a real id.
258
- *
259
- * `createdBy: 'responder'` means "the responder party vouches for / returns this strand."
260
- * Under provision-then-record that strand was minted owner-signed earlier (not created
261
- * in-session), but it is still the responder party returning its own strand, so the marker
262
- * — and this structural validator — stay unchanged.
263
- *
264
- * The behavioral floor for the initiator: a responder that returns an arbitrary/empty
265
- * `strandId` or omits its disclosed identity is rejected, not silently accepted.
266
- */
267
- export function isValidResponderCreatesResult(response: FormationResultMessage): boolean {
268
- if (!response.approved) return false;
269
- if (!response.partyId) return false;
270
- const addrs = response.cadrePeerAddrs;
271
- if (!addrs || addrs.length === 0) return false;
272
- if (addrs.some((a) => PLACEHOLDER_CADRE_ADDR.test(a))) return false;
273
- const provision = response.provisionResult;
274
- if (!provision?.strand?.strandId) return false;
275
- if (provision.strand.createdBy !== 'responder') return false;
276
- return true;
277
- }
278
-
279
- /**
280
- * Ceiling for a resolved provisioning budget.
281
- *
282
- * The session budget also has to cover the wire step that PRECEDES provisioning (the
283
- * responder's contact read, the initiator's dial-connect), so a whole step is held back
284
- * rather than triggering only on a literal overrun.
285
- *
286
- * `reserveMs` is the EXTRA room only the responder holds back, so its own budget still
287
- * lands strictly before the initiator's larger await-response budget even when both are
288
- * clamped — without it, any configured budget at or above `sessionTimeoutMs -
289
- * stepTimeoutMs` clamps both roles onto the same number and the responder's clean
290
- * rejection races the initiator's timeout again. Capped at half the remaining room (like
291
- * {@link splitProvisionBudget}) so a small session config still spends most of it working.
292
- */
293
- function provisionCeilingMs(sessionTimeoutMs: number, stepTimeoutMs: number, reserveMs: number): number {
294
- const roomMs = sessionTimeoutMs - stepTimeoutMs;
295
- return Math.max(1, roomMs - Math.min(reserveMs, Math.floor(roomMs / 2)));
296
- }
297
-
298
- /**
299
- * Resolve a caller-supplied provisioning budget: `0`/negative means "unset" (use
300
- * `defaultMs`). If the result would let provisioning outlive the session — no result
301
- * frame is ever sent, exactly the failure this budget exists to prevent — clamp it to
302
- * {@link provisionCeilingMs} and log a warning.
303
- */
304
- function resolveProvisionTimeoutMs(
305
- configured: number | undefined,
306
- defaultMs: number,
307
- sessionTimeoutMs: number,
308
- stepTimeoutMs: number,
309
- role: string,
310
- reserveMs = 0
311
- ): number {
312
- const requested = configured && configured > 0 ? configured : defaultMs;
313
- const ceilingMs = provisionCeilingMs(sessionTimeoutMs, stepTimeoutMs, reserveMs);
314
- if (requested >= ceilingMs) {
315
- log(
316
- '%s provisionTimeoutMs %dms leaves no room under sessionTimeoutMs %dms (step %dms, reserve %dms); clamping to %dms',
317
- role, requested, sessionTimeoutMs, stepTimeoutMs, reserveMs, ceilingMs
318
- );
319
- return ceilingMs;
320
- }
321
- return requested;
322
- }
323
-
324
- /**
325
- * Split a resolved provisioning budget into the WORK budget and the trailing settle grace.
326
- *
327
- * The grace is carved OUT of the budget (see {@link PROVISION_SETTLE_GRACE_MS}), and capped at
328
- * half of it so a small configured budget still spends at least half its time doing work.
329
- */
330
- function splitProvisionBudget(provisionTimeoutMs: number): { workMs: number; graceMs: number } {
331
- const graceMs = Math.min(PROVISION_SETTLE_GRACE_MS, Math.floor(provisionTimeoutMs / 2));
332
- return { workMs: provisionTimeoutMs - graceMs, graceMs };
333
- }
334
-
335
- // ── Responder (listener) ─────────────────────────────────────────────────────
336
-
337
- export interface FormationListenerOptions {
338
- /** Validate the invitation token; returns whether it is valid. */
339
- validateToken(token: string): Promise<{ valid: boolean }>;
340
- /** Validate the initiator's disclosure with the REAL token + disclosure. */
341
- validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
342
- /**
343
- * Provision (or, for provision-then-record, resolve + record consent against) the
344
- * strand (`responderCreates`) for the given initiator. The WHOLE contact message is
345
- * threaded in: the hook needs the REAL token to map the bound host strand, and the
346
- * joiner's `peerKey`/`usageStampId`/`peerSignature` to write the consent columns.
347
- *
348
- * Returns a {@link ResponderProvisionOutcome}: the hook may REJECT post-validation
349
- * (e.g. an unconverged host strand) and the listener turns that into a clean,
350
- * non-disclosing `approved: false` reply rather than dropping the result frame.
351
- *
352
- * `signal` is aborted when the listener's work budget expires: a hook that observes it
353
- * BEFORE writing abandons the redemption and leaves the invite unspent. Optional so
354
- * signal-unaware hooks (tests, mocks) stay assignable.
355
- */
356
- provisionStrand(contact: FormationContactMessage, signal?: AbortSignal): Promise<ResponderProvisionOutcome>;
357
- /** Responder identity, disclosed only AFTER token + disclosure validation passes. */
358
- getResponderIdentity(): { partyId: string; cadrePeerAddrs: string[] };
359
- sessionTimeoutMs?: number;
360
- stepTimeoutMs?: number;
361
- /**
362
- * Budget for the `provisionStrand` hook call — distinct from `stepTimeoutMs` because
363
- * provisioning is real work, not a bare wire read/write. Default 12 s; `0`/unset uses
364
- * the default; a value that would outlive the session is clamped ({@link
365
- * provisionCeilingMs}). See {@link DEFAULT_PROVISION_TIMEOUT_MS} for the full ordering
366
- * rationale.
367
- */
368
- provisionTimeoutMs?: number;
369
- maxConcurrentSessions?: number;
370
- }
371
-
372
- /**
373
- * Responder side of the native formation protocol. Registers a libp2p handler and
374
- * drives each inbound stream through token + disclosure validation, provisioning,
375
- * and result delivery — enforcing the cadre-disclosure timing rule (responder cadre
376
- * is revealed only after validation; rejections disclose nothing).
377
- */
378
- export class FormationListener {
379
- private readonly options: FormationListenerOptions;
380
- private readonly sessionTimeoutMs: number;
381
- private readonly stepTimeoutMs: number;
382
- private readonly provisionWorkMs: number;
383
- private readonly provisionGraceMs: number;
384
- private readonly maxConcurrentSessions: number;
385
- private readonly registered = new Set<Libp2p>();
386
- private activeSessions = 0;
387
- private sessionCounter = 0;
388
-
389
- constructor(options: FormationListenerOptions) {
390
- this.options = options;
391
- this.sessionTimeoutMs = options.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
392
- this.stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
393
- const split = splitProvisionBudget(resolveProvisionTimeoutMs(
394
- options.provisionTimeoutMs, DEFAULT_PROVISION_TIMEOUT_MS,
395
- this.sessionTimeoutMs, this.stepTimeoutMs, 'FormationListener',
396
- PROVISION_RESPONSE_TRAVEL_MARGIN_MS
397
- ));
398
- this.provisionWorkMs = split.workMs;
399
- this.provisionGraceMs = split.graceMs;
400
- this.maxConcurrentSessions = options.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS;
401
- }
402
-
403
- /** Number of in-flight inbound sessions. */
404
- get activeCount(): number {
405
- return this.activeSessions;
406
- }
407
-
408
- register(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): void {
409
- if (this.registered.has(node)) {
410
- log('node already registered');
411
- return;
412
- }
413
- void node.handle(protocolId, async (rawStream: unknown, _connection: unknown) => {
414
- await this.handleStream(rawStream as ControlStream);
415
- });
416
- this.registered.add(node);
417
- log('formation listener registered (%s)', protocolId);
418
- }
419
-
420
- unregister(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): void {
421
- if (!this.registered.has(node)) return;
422
- void node.unhandle(protocolId);
423
- this.registered.delete(node);
424
- log('formation listener unregistered (%s)', protocolId);
425
- }
426
-
427
- private async handleStream(stream: ControlStream): Promise<void> {
428
- if (this.activeSessions >= this.maxConcurrentSessions) {
429
- const rejection: FormationResultMessage = { approved: false, reason: 'Too many concurrent formation sessions' };
430
- try { writeFrame(stream, rejection); } catch { /* best effort */ }
431
- try { await stream.close(); } catch { /* ignore */ }
432
- return;
433
- }
434
- const id = ++this.sessionCounter;
435
- this.activeSessions++;
436
- try {
437
- await withTimeout(this.sessionTimeoutMs, `Formation session#${id}`, () => this.runSession(id, stream));
438
- } catch (err) {
439
- log('formation session #%d failed: %o', id, err);
440
- } finally {
441
- this.activeSessions--;
442
- try { await stream.close(); } catch { /* ignore */ }
443
- }
444
- }
445
-
446
- /**
447
- * Run the provisioning hook under its WORK budget — the configured provisioning budget minus
448
- * the trailing settle grace ({@link splitProvisionBudget}, {@link PROVISION_SETTLE_GRACE_MS}).
449
- *
450
- * When the work budget expires the hook's signal is aborted, then
451
- * {@link settleWithinGrace} waits out the grace for the aborted work to settle anyway.
452
- * Returns `undefined` for "timed out" — which the caller turns into a retryable rejection
453
- * frame — and rethrows every other failure so it reaches the internal-error path.
454
- *
455
- * Deliberately not `withDeadline`: that exposes neither the timed-out flag nor the signal
456
- * outside its `op`, and both are needed here.
457
- */
458
- private async provision(id: number, contact: FormationContactMessage): Promise<ResponderProvisionOutcome | undefined> {
459
- const controller = new AbortController();
460
- let timedOut = false;
461
- const pending = this.options.provisionStrand(contact, controller.signal);
462
- try {
463
- return await withTimeout(
464
- this.provisionWorkMs,
465
- `Formation provisioning#${id}`,
466
- () => pending,
467
- () => { timedOut = true; controller.abort(); }
468
- );
469
- } catch (err) {
470
- if (!timedOut) throw err;
471
- log(
472
- 'formation session #%d provisioning work budget expired after %dms; aborted, settling up to %dms',
473
- id, this.provisionWorkMs, this.provisionGraceMs
474
- );
475
- return await this.settleWithinGrace(id, pending);
476
- }
477
- }
478
-
479
- /**
480
- * Wait out the settle grace for an ABORTED provisioning, adopting a late success.
481
- *
482
- * A hook that observed the abort before issuing its `FormationUsage` insert rejects (with
483
- * `FormationAbortedError`) and leaves the invite unspent → reported as a timeout. A hook
484
- * that had already written cannot be un-written (the table is append-only), so if it lands
485
- * inside the grace its outcome is ADOPTED — the joiner is told the truth (approved, or the
486
- * hook's own rejection reason) rather than "timed out" over a spent invite.
487
- *
488
- * NOTE: one window stays open — a work budget that expires after the `FormationUsage`
489
- * insert was issued AND a commit that outlasts the grace. The joiner is told 'timed out'
490
- * while its one-time invite is in fact spent, and since every retry mints a fresh keypair
491
- * no recovery path can match it. Nothing can un-spend an append-only row, so the late-settle
492
- * logging below is the observability for it; widen the grace only if it is seen in the wild.
493
- */
494
- private async settleWithinGrace(
495
- id: number,
496
- pending: Promise<ResponderProvisionOutcome>
497
- ): Promise<ResponderProvisionOutcome | undefined> {
498
- let stillPending = false;
499
- try {
500
- const outcome = await withTimeout(
501
- this.provisionGraceMs,
502
- `Formation settle#${id}`,
503
- () => pending,
504
- () => { stillPending = true; }
505
- );
506
- log('formation session #%d provisioning settled within its grace: approved=%s — adopting', id, outcome.approved);
507
- return outcome;
508
- } catch (err) {
509
- if (!stillPending) {
510
- log('formation session #%d provisioning failed after its abort: %o', id, err);
511
- return undefined;
512
- }
513
- log('formation session #%d provisioning still pending after its %dms grace', id, this.provisionGraceMs);
514
- // Log how it eventually settled so a late failure is not swallowed by the two
515
- // abandoned `withTimeout` calls.
516
- void pending.then(
517
- (late) => log('formation session #%d provisioning settled after its grace: approved=%s', id, late.approved),
518
- (err2) => log('formation session #%d provisioning failed after its grace: %o', id, err2)
519
- );
520
- return undefined;
521
- }
522
- }
523
-
524
- /**
525
- * Joiner-consent pre-check — the legibility twin of the schema's
526
- * `FormationUsage.PeerConsented` CHECK, exactly as `verifyFormationApproval` is for the
527
- * approver's vouch. The database constraint stays the authority; this turns a bad consent
528
- * into a clean 'Invalid joiner consent' rejection instead of an opaque CHECK failure at
529
- * commit. Runs BEFORE token/disclosure validation: it is the cheapest check (pure local
530
- * crypto, no DB read) and a rejection discloses nothing.
531
- *
532
- * Also pins `partyId` to `peerKey`: an ed25519 peer id is an identity multihash of the
533
- * very key, and this is the ONLY layer that can check the match — SQL cannot unwrap a
534
- * multihash, and `partyId` itself is never stored.
535
- */
536
- private isJoinerConsentValid(id: number, contact: FormationContactMessage): boolean {
537
- try {
538
- requireEd25519PublicKeyB64(contact.peerKey, 'joiner peer key');
539
- } catch {
540
- // Deliberately not rethrown or echoed: the helper's error message embeds the whole
541
- // rejected value, and this one arrived from a remote peer (the cap-the-echo case its
542
- // own NOTE warns about). Log without the raw value instead.
543
- log('formation session #%d: joiner peer key is not a base64url ed25519 public key', id);
544
- return false;
545
- }
546
- const embedded = ed25519PublicKeyB64FromPeerId(contact.partyId);
547
- if (!embedded || embedded !== contact.peerKey) {
548
- log('formation session #%d: partyId does not embed the presented peer key', id);
549
- return false;
550
- }
551
- const consentOk = verifyFormationConsent({
552
- token: contact.token,
553
- usageStampId: contact.usageStampId,
554
- peerKey: contact.peerKey,
555
- disclosure: canonicalJson(contact.disclosure),
556
- peerSig: contact.peerSignature
557
- });
558
- if (!consentOk) {
559
- log('formation session #%d: joiner consent signature failed verification', id);
560
- return false;
561
- }
562
- return true;
563
- }
564
-
565
- private async runSession(id: number, stream: ControlStream): Promise<void> {
566
- // Track whether ANY frame has been written so the catch below can convert an
567
- // unexpected internal error into a non-disclosing rejection ONLY when nothing has
568
- // gone out yet. This closes the "stream closed with no result frame" class of bug.
569
- let wroteFrame = false;
570
- const send = (msg: FormationResultMessage): void => {
571
- writeFrame(stream, msg);
572
- wroteFrame = true;
573
- };
574
-
575
- try {
576
- const reader = new FrameReader(stream);
577
- const contact = await withTimeout(this.stepTimeoutMs, `Formation await-contact#${id}`, () => reader.read<FormationContactMessage>());
578
- log('formation session #%d contact: token=%s party=%s', id, contact.token, contact.partyId);
579
-
580
- if (!this.isJoinerConsentValid(id, contact)) {
581
- send({ approved: false, reason: 'Invalid joiner consent' });
582
- return;
583
- }
584
-
585
- const tokenResult = await this.options.validateToken(contact.token);
586
- if (!tokenResult.valid) {
587
- send({ approved: false, reason: INVALID_TOKEN_REASON });
588
- return;
589
- }
590
-
591
- const disclosureOk = await this.options.validateDisclosure(contact.token, contact.disclosure);
592
- if (!disclosureOk) {
593
- send({ approved: false, reason: 'Invalid disclosure' });
594
- return;
595
- }
596
-
597
- const outcome = await this.provision(id, contact);
598
- if (!outcome) {
599
- // Reported as its own retryable reason rather than falling through to the generic
600
- // catch below, which would report the misleading 'Internal formation error'.
601
- send({ approved: false, reason: 'Formation provisioning timed out' });
602
- return;
603
- }
604
- if (!outcome.approved) {
605
- // A post-validation rejection still discloses NEITHER identity NOR cadre,
606
- // exactly like the token/disclosure rejections above.
607
- send({ approved: false, reason: outcome.reason });
608
- return;
609
- }
610
- // Validation + provisioning passed → safe to disclose responder identity/cadre.
611
- const identity = this.options.getResponderIdentity();
612
- send({
613
- approved: true,
614
- partyId: identity.partyId,
615
- cadrePeerAddrs: identity.cadrePeerAddrs,
616
- provisionResult: outcome.result
617
- });
618
- } catch (err) {
619
- // Defense-in-depth: if an unexpected internal error escapes BEFORE any frame was
620
- // written (e.g. a future provisionStrand-hook bug), convert it into a non-disclosing
621
- // protocol rejection so the initiator sees a clean error instead of a read-error/
622
- // timeout. If a frame was already sent, leave the stream as-is and let handleStream
623
- // log + close. Re-throw either way so the failure is still recorded — this is a
624
- // deliberate, logged conversion, not silent.
625
- if (!wroteFrame) {
626
- const internalError: FormationResultMessage = { approved: false, reason: 'Internal formation error' };
627
- try { writeFrame(stream, internalError); } catch { /* stream already broken */ }
628
- }
629
- throw err;
630
- }
631
- }
632
- }
633
-
634
- // ── Initiator (dialer) ───────────────────────────────────────────────────────
635
-
636
- export interface FormationDialOptions {
637
- /** The contact message to send (real token, partyId, disclosure, initiator cadre). */
638
- contact: FormationContactMessage;
639
- /** Responder multiaddrs to dial. */
640
- responderAddrs: string[];
641
- /** Validate the responder's result; a false return aborts the formation. */
642
- validateResponse(response: FormationResultMessage): Promise<boolean>;
643
- sessionTimeoutMs?: number;
644
- stepTimeoutMs?: number;
645
- /**
646
- * Budget for the `await-response` read only (the responder's provisioning + reply travel
647
- * time) — `dial-connect` keeps using `stepTimeoutMs`. Default 15 s; `0`/unset uses the
648
- * default. See {@link DEFAULT_PROVISION_TIMEOUT_MS} for the full ordering rationale.
649
- */
650
- provisionTimeoutMs?: number;
651
- protocolId?: string;
652
- }
653
-
654
- /**
655
- * Initiator side of the native formation protocol. Dials the responder, sends the
656
- * contact (carrying the real disclosure/token/cadre), validates the responder's
657
- * result, and returns the strand the responder provisioned.
658
- */
659
- export async function dialFormation(node: Libp2p, options: FormationDialOptions): Promise<FormationProvisionResult> {
660
- if (options.responderAddrs.length === 0) {
661
- throw new Error('No responder addresses available for formation');
662
- }
663
- const protocolId = options.protocolId ?? FORMATION_PROTOCOL;
664
- const sessionTimeoutMs = options.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
665
- const stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
666
- const provisionTimeoutMs = resolveProvisionTimeoutMs(
667
- options.provisionTimeoutMs, DEFAULT_INITIATOR_PROVISION_TIMEOUT_MS,
668
- sessionTimeoutMs, stepTimeoutMs, 'dialFormation'
669
- );
670
- const addr = multiaddr(options.responderAddrs[0]);
671
-
672
- return withTimeout(sessionTimeoutMs, 'Formation dial', async () => {
673
- const rawStream = await withTimeout(stepTimeoutMs, 'Formation dial-connect', async () => node.dialProtocol(addr, protocolId));
674
- const stream = rawStream as unknown as ControlStream;
675
- try {
676
- const reader = new FrameReader(stream);
677
- writeFrame(stream, options.contact);
678
-
679
- const response = await withTimeout(provisionTimeoutMs, 'Formation await-response', () => reader.read<FormationResultMessage>());
680
- if (!response.approved) {
681
- throw new Error(`Formation rejected: ${response.reason ?? 'no reason provided'}`);
682
- }
683
- const ok = await options.validateResponse(response);
684
- if (!ok) throw new Error('Responder result failed validation');
685
-
686
- if (!response.provisionResult) throw new Error('Missing provision result for responderCreates mode');
687
- return response.provisionResult;
688
- } finally {
689
- try { await stream.close(); } catch { /* ignore */ }
690
- }
691
- });
692
- }
1
+ /**
2
+ * Native cadre-core strand-formation transport.
3
+ *
4
+ * Mirrors the `seed-bootstrap.ts` protocol service: a dedicated
5
+ * libp2p protocol id, length-prefixed JSON frames, and small single-purpose
6
+ * session helpers. It replaces the removed `@serfab/strand-proto` package's transport,
7
+ * carrying the caller's REAL invitation token + disclosure and BOTH parties'
8
+ * real cadre peer addresses end-to-end (no `{ partyId: sessionId }` / `cadre-*.local`
9
+ * placeholders), and validating the responder's result on the initiator side.
10
+ *
11
+ * Provisioning flow (`responderCreates`, 2 messages): the responder provisions (or, for
12
+ * provision-then-record, resolves + records consent against) the strand and returns the
13
+ * result on approval.
14
+ *
15
+ * Cadre-disclosure timing: the responder reveals its own party id + cadre addresses
16
+ * — and, for a closed strand returned via provision-then-record, that strand's
17
+ * membership key — ONLY after the token and disclosure validate; a rejection
18
+ * discloses none of them.
19
+ */
20
+
21
+ import debug from 'debug';
22
+ import { multiaddr } from '@multiformats/multiaddr';
23
+ import type { Libp2p } from '@libp2p/interface';
24
+ import type { StrandFormationDisclosure, StrandMembershipInvite } from './types.js';
25
+ import { type ControlStream, writeFrame, withTimeout } from './control-stream.js';
26
+ import { canonicalJson } from './canonical-json.js';
27
+ import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
28
+ import { verifyFormationConsent } from './peer-authorization.js';
29
+ // seed-bootstrap imports neither this protocol nor the manager/solicitation layers,
30
+ // so this import introduces no cycle.
31
+ import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
32
+
33
+ const log = debug('sereus:cadre:formation-proto');
34
+
35
+ /** Protocol id for the native formation transport (parallel to `/sereus/seed/1.0.0`). */
36
+ export const FORMATION_PROTOCOL = '/sereus/formation/1.0.0';
37
+
38
+ /**
39
+ * Rejection reason for an invitation that cannot be redeemed at all — unknown, expired, or
40
+ * fully spent. Shared with `StrandFormationManager`, which reports an
41
+ * `InvitationExhaustedError` (an invite spent out from under a redemption already in flight)
42
+ * with this SAME wording on purpose: a joiner that lost the race must be indistinguishable
43
+ * from a non-racing latecomer, so the two sites must never drift apart.
44
+ */
45
+ export const INVALID_TOKEN_REASON = 'Invalid token';
46
+
47
+ /** Maximum formation message size (1MB). */
48
+ const MAX_FORMATION_MSG_SIZE = 1024 * 1024;
49
+
50
+ /** Default whole-session timeout (ms). */
51
+ const DEFAULT_SESSION_TIMEOUT_MS = 30_000;
52
+ /** Default per-step (single read/write) timeout (ms). */
53
+ const DEFAULT_STEP_TIMEOUT_MS = 5_000;
54
+ /**
55
+ * Default provisioning budget (ms) — distinct from {@link DEFAULT_STEP_TIMEOUT_MS} because
56
+ * `provisionStrand` is real work (DB writes, and an outbound approval-hook HTTP call when
57
+ * the invite carries a `ValidationUrl`), not a bare wire read/write.
58
+ *
59
+ * Ordering is deliberate — each layer must be able to fail and report before the layer above
60
+ * it gives up:
61
+ *
62
+ * approval hook (10 s, `formation-approval.ts`)
63
+ * < responder provisioning (12 s)
64
+ * < initiator await-response (15 s)
65
+ * < session (30 s)
66
+ *
67
+ * The 3 s margin between responder provisioning and initiator await-response is the wire
68
+ * latency budget for the result frame to travel back.
69
+ *
70
+ * The last {@link PROVISION_SETTLE_GRACE_MS} of this budget is a settle grace, so the default
71
+ * WORK budget (12 s − 2 s = 10 s) EQUALS the hook's own 10 s: a dead hook races 'Formation
72
+ * approval unavailable, retry' against 'Formation provisioning timed out'. Both are retryable
73
+ * and both leave the invite unspent, so the race is benign.
74
+ */
75
+ const DEFAULT_PROVISION_TIMEOUT_MS = 12_000;
76
+ /**
77
+ * Settle grace (ms), carved OUT of `provisionTimeoutMs` — never added on top, so the budget
78
+ * ladder above is untouched. The listener aborts the provisioning hook when the work budget
79
+ * ({@link splitProvisionBudget}) expires, then waits up to this grace for the work to settle
80
+ * anyway: once the `FormationUsage` insert has been issued nothing can un-spend it (the table
81
+ * is append-only), so a provisioning that lands late is ADOPTED and reported as a real
82
+ * approval rather than lying "timed out" over a spent invite. A hook that observes the
83
+ * abort before writing leaves the invite unspent.
84
+ */
85
+ const PROVISION_SETTLE_GRACE_MS = 2_000;
86
+ /**
87
+ * Wire-latency margin (ms) added to the responder's provisioning budget to get the
88
+ * initiator's await-response budget — the travel time for the result frame to come back
89
+ * after the responder finishes (see {@link DEFAULT_PROVISION_TIMEOUT_MS}).
90
+ * `StrandFormationManager` reuses this constant so a CONFIGURED budget preserves the same
91
+ * margin the defaults do, instead of applying the same number to both sides and racing the
92
+ * initiator's own timeout against the responder's clean rejection reply. The responder's
93
+ * clamp ({@link provisionCeilingMs}) holds the same margin back, so the ordering survives a
94
+ * budget large enough that BOTH roles get clamped.
95
+ *
96
+ * NOTE: 3 s is a fixed assumption about how long the result frame takes to travel back. If
97
+ * formation ever runs over paths where that frame can take longer (a slow circuit relay, a
98
+ * congested mobile link), the margin is too thin and the initiator's timeout races the
99
+ * responder's reply again — raise it, or derive it from an observed round-trip.
100
+ */
101
+ export const PROVISION_RESPONSE_TRAVEL_MARGIN_MS = 3_000;
102
+ /** Default initiator await-response budget (ms); see {@link DEFAULT_PROVISION_TIMEOUT_MS}. */
103
+ const DEFAULT_INITIATOR_PROVISION_TIMEOUT_MS = DEFAULT_PROVISION_TIMEOUT_MS + PROVISION_RESPONSE_TRAVEL_MARGIN_MS;
104
+ /**
105
+ * Default cap on concurrent inbound formation sessions.
106
+ * NOTE: a longer provisioning budget holds a session's slot longer, so a slow hook can
107
+ * exhaust this cap and make the listener reject fresh joiners with 'Too many concurrent
108
+ * formation sessions'. Fine at the current default; revisit if `provisionTimeoutMs` is
109
+ * raised well above its default or this cap is lowered.
110
+ */
111
+ const DEFAULT_MAX_CONCURRENT_SESSIONS = 100;
112
+
113
+ /**
114
+ * Cap on the strand-network addresses a formation result carries
115
+ * ({@link FormationResultMessage.strandAddrs}). A node's own strand multiaddrs are a
116
+ * handful of entries (one per listen transport, plus a circuit-relay reservation), so
117
+ * 16 is generous for the honest case while keeping the result frame bounded against a
118
+ * peer that would pad it — either direction, since both sides run the list through
119
+ * {@link sanitizeStrandAddrs}. Exported because the same bound has to hold on the
120
+ * initiator's stored copy: `CadreNode` accumulates across repeat formations against one
121
+ * strand, so it caps the accumulation rather than only each arriving list.
122
+ */
123
+ export const MAX_STRAND_ADDRS = 16;
124
+
125
+ // ── Roles ────────────────────────────────────────────────────────────────────
126
+
127
+ export type FormationParty = 'initiator' | 'responder';
128
+
129
+ // ── Wire messages (length-prefixed JSON frames) ──────────────────────────────
130
+
131
+ export interface FormationStrandInfo {
132
+ strandId: string;
133
+ createdBy: FormationParty;
134
+ }
135
+
136
+ export interface FormationDbConnectionInfo {
137
+ endpoint: string;
138
+ credentialsRef: string;
139
+ }
140
+
141
+ export interface FormationProvisionResult {
142
+ strand: FormationStrandInfo;
143
+ /**
144
+ * The strand's membership key (closed-strand read-gating secret), delivered to a
145
+ * validated invitee for provision-then-record formation. Disclosed only AFTER token
146
+ * + disclosure validation, exactly like the responder identity/cadre — a rejected or
147
+ * already-used token discloses neither identity nor key. Absent for open strands.
148
+ */
149
+ memberPrivateKey?: string;
150
+ /**
151
+ * The joiner's single-use membership invitation into the (closed, bound) host
152
+ * strand — issued by the responder's live strand runtime against `Strand.Invite`
153
+ * just before the consent row was recorded. Same disclosure timing as
154
+ * {@link memberPrivateKey}: present only on an approved result, absent for open
155
+ * strands and the responder-provisions path. Bounded and shape-checked on the
156
+ * initiator side ({@link isWellFormedMembershipInvite}) — a hostile responder's
157
+ * malformed field fails validation rather than crashing the initiator.
158
+ */
159
+ membershipInvite?: StrandMembershipInvite;
160
+ dbConnectionInfo: FormationDbConnectionInfo;
161
+ }
162
+
163
+ /**
164
+ * Outcome of the responder's provisioning hook.
165
+ *
166
+ * Distinct from a bare {@link FormationProvisionResult} so the hook can REJECT a
167
+ * formation AFTER token + disclosure validation — e.g. a bound invite naming a host
168
+ * strand this responder has not yet converged on, or a concurrent-redemption collision.
169
+ * Without this channel such cases threw inside provisioning, the stream closed with no
170
+ * result frame, and the initiator saw a read-error/timeout instead of a clean
171
+ * `approved: false`. A rejection still discloses NO responder identity/cadre.
172
+ */
173
+ export type ResponderProvisionOutcome =
174
+ | { approved: true; result: FormationProvisionResult }
175
+ | { approved: false; reason: string };
176
+
177
+ /** Initiator → Responder: carries the real token + disclosure + initiator cadre. */
178
+ export interface FormationContactMessage {
179
+ /** The real invitation token. */
180
+ token: string;
181
+ /** Initiator's member key (peer id) — human-facing identity in logs and `FormStrandResult.memberKey`. */
182
+ partyId: string;
183
+ /**
184
+ * Initiator's base64url ed25519 public key — the key behind `partyId`, written to
185
+ * `FormationUsage.PeerKey` and covered by BOTH signed digests.
186
+ */
187
+ peerKey: string;
188
+ /** Joiner-minted single-use nonce for THIS redemption; both signed digests cover it. */
189
+ usageStampId: string;
190
+ /**
191
+ * The joiner's signature over the `'consent'` digest (see `formationConsentMessage`
192
+ * in control-database.ts) — proof the named peer agreed to this redemption.
193
+ */
194
+ peerSignature: string;
195
+ /** The real disclosure, carried verbatim. */
196
+ disclosure: StrandFormationDisclosure;
197
+ /** Initiator's real multiaddrs. */
198
+ cadrePeerAddrs: string[];
199
+ }
200
+
201
+ /** Responder → Initiator: responder identity/cadre disclosed only after validation. */
202
+ export interface FormationResultMessage {
203
+ approved: boolean;
204
+ /** Present iff `approved === false`. */
205
+ reason?: string;
206
+ /** Responder's real party id (disclosed only after validation). */
207
+ partyId?: string;
208
+ /** Responder's real multiaddrs (omitted on rejection). */
209
+ cadrePeerAddrs?: string[];
210
+ /**
211
+ * The responder's live STRAND-network multiaddrs for the provisioned strand — the
212
+ * initiator's only cross-party discovery seed, since the strand-addr RPC that resolves
213
+ * a sibling's strand addrs is membership-gated and answers own-party callers only.
214
+ *
215
+ * Disclosed on the same terms as `partyId`/`cadrePeerAddrs`: only after token +
216
+ * disclosure validation AND an approving provisioning outcome; a rejection carries
217
+ * none. Signaling-first, each entry ending in `/p2p/<responder strand transport
218
+ * peerId>` so the initiator can attribute it. OMITTED when the responder holds no live
219
+ * strand node for the provisioned strand (the responder-provisions path mints a strand
220
+ * that has not launched yet) — an absent or empty list is legal and means "no
221
+ * cross-party seed", never a protocol error.
222
+ */
223
+ strandAddrs?: string[];
224
+ /** The provisioned strand/db result (always present on approval). */
225
+ provisionResult?: FormationProvisionResult;
226
+ }
227
+
228
+ /**
229
+ * Normalize a strand-address list arriving from — or heading to — the wire: keep only
230
+ * parsable multiaddr strings, drop duplicates, and cap at {@link MAX_STRAND_ADDRS}.
231
+ * Order is preserved, so the responder's signaling-first ordering survives.
232
+ *
233
+ * Applied on BOTH sides on purpose. The responder bounds what it sends; the initiator
234
+ * bounds what it stores, because a peer is free to ignore the cap. A malformed entry is
235
+ * SKIPPED rather than fatal — these are runtime-discovered addresses (same posture as
236
+ * `extractCircuitRelayTargets` in `delegate-admission.ts`), and one bad entry must not
237
+ * cost a formation that has otherwise succeeded.
238
+ */
239
+ export function sanitizeStrandAddrs(addrs: unknown): string[] {
240
+ if (!Array.isArray(addrs)) return [];
241
+ const out: string[] = [];
242
+ const seen = new Set<string>();
243
+ for (const entry of addrs) {
244
+ if (out.length >= MAX_STRAND_ADDRS) break;
245
+ if (typeof entry !== 'string' || entry.length === 0) {
246
+ log('strandAddrs: skipping non-string entry %o', entry);
247
+ continue;
248
+ }
249
+ if (seen.has(entry)) continue;
250
+ try {
251
+ multiaddr(entry);
252
+ } catch (error) {
253
+ // Deliberately not echoing the raw value at error level: it arrived from a remote
254
+ // peer. The log line below is the observability for a responder announcing junk.
255
+ log('strandAddrs: skipping unparsable entry: %o', error);
256
+ continue;
257
+ }
258
+ seen.add(entry);
259
+ out.push(entry);
260
+ }
261
+ return out;
262
+ }
263
+
264
+ // ── Stream framing helpers ───────────────────────────────────────────────────
265
+
266
+ /**
267
+ * Reads length-prefixed JSON frames from a libp2p stream one frame at a time.
268
+ *
269
+ * Unlike seed delivery (which reads a stream to EOF then parses once), formation
270
+ * is request/response on a single live stream, so reading must stop at each frame
271
+ * boundary. The reader buffers across chunk boundaries and retains any overflow
272
+ * (a following frame coalesced into the same chunk) for the next read.
273
+ */
274
+ class FrameReader {
275
+ private readonly iterator: AsyncIterator<Uint8Array>;
276
+ private readonly maxLength: number;
277
+ private buffer = new Uint8Array(0);
278
+
279
+ constructor(stream: AsyncIterable<Uint8Array>, maxLength = MAX_FORMATION_MSG_SIZE) {
280
+ this.iterator = stream[Symbol.asyncIterator]();
281
+ this.maxLength = maxLength;
282
+ }
283
+
284
+ private append(chunk: Uint8Array): void {
285
+ const next = new Uint8Array(this.buffer.length + chunk.length);
286
+ next.set(this.buffer, 0);
287
+ next.set(chunk, this.buffer.length);
288
+ this.buffer = next;
289
+ }
290
+
291
+ /** Pull chunks until the buffer holds ≥ `need` bytes; false if the stream ended first. */
292
+ private async fill(need: number): Promise<boolean> {
293
+ while (this.buffer.length < need) {
294
+ const { value, done } = await this.iterator.next();
295
+ if (done) return false;
296
+ const bytes = value instanceof Uint8Array
297
+ ? value
298
+ : (value as { subarray(): Uint8Array }).subarray();
299
+ this.append(bytes);
300
+ }
301
+ return true;
302
+ }
303
+
304
+ async read<T>(): Promise<T> {
305
+ if (!(await this.fill(4))) {
306
+ throw new Error('Formation stream closed before length prefix');
307
+ }
308
+ const length = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength).getUint32(0, false);
309
+ if (length > this.maxLength) {
310
+ throw new Error(`Formation frame declares length ${length} exceeding max ${this.maxLength}`);
311
+ }
312
+ if (!(await this.fill(4 + length))) {
313
+ throw new Error(`Formation frame declares length ${length} but stream ended early`);
314
+ }
315
+ const json = new TextDecoder().decode(this.buffer.subarray(4, 4 + length));
316
+ this.buffer = this.buffer.subarray(4 + length);
317
+ return JSON.parse(json) as T;
318
+ }
319
+ }
320
+
321
+ // ── Result validation ────────────────────────────────────────────────────────
322
+
323
+ /** Matches the placeholder cadre addrs (`cadre-a-1.local`, …) of the removed strand-proto transport. */
324
+ const PLACEHOLDER_CADRE_ADDR = /^cadre-[ab]-\d+\.local$/;
325
+
326
+ /**
327
+ * Both halves of a {@link StrandMembershipInvite} are base64url ed25519 key encodings —
328
+ * 43 chars for 32 bytes. The bound is deliberately loose (a future encoding change must
329
+ * not silently strand joiners) while still capping what a hostile responder can make the
330
+ * initiator retain.
331
+ */
332
+ const MAX_MEMBERSHIP_INVITE_KEY_LENGTH = 256;
333
+ const BASE64URL_KEY = /^[A-Za-z0-9_-]+$/;
334
+
335
+ /**
336
+ * Is `value` a well-formed {@link StrandMembershipInvite} — exactly two bounded
337
+ * base64url strings? The initiator-side shape check for the one attacker-influenced
338
+ * OBJECT the formation result carries (`strandAddrs` gets the equivalent treatment in
339
+ * {@link sanitizeStrandAddrs}): a malformed field from a hostile responder must fail
340
+ * validation, never crash the initiator or be carried into the joiner's invite cache.
341
+ * `undefined` is NOT well-formed — absence is legal on the result and callers gate on
342
+ * presence first.
343
+ */
344
+ export function isWellFormedMembershipInvite(value: unknown): value is StrandMembershipInvite {
345
+ if (typeof value !== 'object' || value === null) return false;
346
+ const { inviteKey, invitePrivateKey } = value as Record<string, unknown>;
347
+ const wellFormedKey = (key: unknown): boolean =>
348
+ typeof key === 'string' && key.length > 0 && key.length <= MAX_MEMBERSHIP_INVITE_KEY_LENGTH && BASE64URL_KEY.test(key);
349
+ return wellFormedKey(inviteKey) && wellFormedKey(invitePrivateKey);
350
+ }
351
+
352
+ /**
353
+ * Structural check on a `responderCreates` result: the responder must have approved,
354
+ * disclosed a non-empty real identity + cadre (not the legacy `cadre-*.local`
355
+ * placeholders), and returned a strand vouched for by the responder party with a real id.
356
+ *
357
+ * `createdBy: 'responder'` means "the responder party vouches for / returns this strand."
358
+ * Under provision-then-record that strand was minted owner-signed earlier (not created
359
+ * in-session), but it is still the responder party returning its own strand, so the marker
360
+ * — and this structural validator — stay unchanged.
361
+ *
362
+ * The behavioral floor for the initiator: a responder that returns an arbitrary/empty
363
+ * `strandId` or omits its disclosed identity is rejected, not silently accepted.
364
+ */
365
+ export function isValidResponderCreatesResult(response: FormationResultMessage): boolean {
366
+ if (!response.approved) return false;
367
+ if (!response.partyId) return false;
368
+ const addrs = response.cadrePeerAddrs;
369
+ if (!addrs || addrs.length === 0) return false;
370
+ if (addrs.some((a) => PLACEHOLDER_CADRE_ADDR.test(a))) return false;
371
+ const provision = response.provisionResult;
372
+ if (!provision?.strand?.strandId) return false;
373
+ if (provision.strand.createdBy !== 'responder') return false;
374
+ // Optional, but when present it must be well-formed: a malformed invitation is a
375
+ // membership the joiner can never redeem, so reject the result rather than accept a
376
+ // formation that would leave a half-member.
377
+ if (provision.membershipInvite !== undefined && !isWellFormedMembershipInvite(provision.membershipInvite)) {
378
+ return false;
379
+ }
380
+ return true;
381
+ }
382
+
383
+ /**
384
+ * Ceiling for a resolved provisioning budget.
385
+ *
386
+ * The session budget also has to cover the wire step that PRECEDES provisioning (the
387
+ * responder's contact read, the initiator's dial-connect), so a whole step is held back
388
+ * rather than triggering only on a literal overrun.
389
+ *
390
+ * `reserveMs` is the EXTRA room only the responder holds back, so its own budget still
391
+ * lands strictly before the initiator's larger await-response budget even when both are
392
+ * clamped — without it, any configured budget at or above `sessionTimeoutMs -
393
+ * stepTimeoutMs` clamps both roles onto the same number and the responder's clean
394
+ * rejection races the initiator's timeout again. Capped at half the remaining room (like
395
+ * {@link splitProvisionBudget}) so a small session config still spends most of it working.
396
+ */
397
+ function provisionCeilingMs(sessionTimeoutMs: number, stepTimeoutMs: number, reserveMs: number): number {
398
+ const roomMs = sessionTimeoutMs - stepTimeoutMs;
399
+ return Math.max(1, roomMs - Math.min(reserveMs, Math.floor(roomMs / 2)));
400
+ }
401
+
402
+ /**
403
+ * Resolve a caller-supplied provisioning budget: `0`/negative means "unset" (use
404
+ * `defaultMs`). If the result would let provisioning outlive the session — no result
405
+ * frame is ever sent, exactly the failure this budget exists to prevent — clamp it to
406
+ * {@link provisionCeilingMs} and log a warning.
407
+ */
408
+ function resolveProvisionTimeoutMs(
409
+ configured: number | undefined,
410
+ defaultMs: number,
411
+ sessionTimeoutMs: number,
412
+ stepTimeoutMs: number,
413
+ role: string,
414
+ reserveMs = 0
415
+ ): number {
416
+ const requested = configured && configured > 0 ? configured : defaultMs;
417
+ const ceilingMs = provisionCeilingMs(sessionTimeoutMs, stepTimeoutMs, reserveMs);
418
+ if (requested >= ceilingMs) {
419
+ log(
420
+ '%s provisionTimeoutMs %dms leaves no room under sessionTimeoutMs %dms (step %dms, reserve %dms); clamping to %dms',
421
+ role, requested, sessionTimeoutMs, stepTimeoutMs, reserveMs, ceilingMs
422
+ );
423
+ return ceilingMs;
424
+ }
425
+ return requested;
426
+ }
427
+
428
+ /**
429
+ * Split a resolved provisioning budget into the WORK budget and the trailing settle grace.
430
+ *
431
+ * The grace is carved OUT of the budget (see {@link PROVISION_SETTLE_GRACE_MS}), and capped at
432
+ * half of it so a small configured budget still spends at least half its time doing work.
433
+ */
434
+ function splitProvisionBudget(provisionTimeoutMs: number): { workMs: number; graceMs: number } {
435
+ const graceMs = Math.min(PROVISION_SETTLE_GRACE_MS, Math.floor(provisionTimeoutMs / 2));
436
+ return { workMs: provisionTimeoutMs - graceMs, graceMs };
437
+ }
438
+
439
+ // ── Responder (listener) ─────────────────────────────────────────────────────
440
+
441
+ export interface FormationListenerOptions {
442
+ /** Validate the invitation token; returns whether it is valid. */
443
+ validateToken(token: string): Promise<{ valid: boolean }>;
444
+ /** Validate the initiator's disclosure with the REAL token + disclosure. */
445
+ validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
446
+ /**
447
+ * Provision (or, for provision-then-record, resolve + record consent against) the
448
+ * strand (`responderCreates`) for the given initiator. The WHOLE contact message is
449
+ * threaded in: the hook needs the REAL token to map the bound host strand, and the
450
+ * joiner's `peerKey`/`usageStampId`/`peerSignature` to write the consent columns.
451
+ *
452
+ * Returns a {@link ResponderProvisionOutcome}: the hook may REJECT post-validation
453
+ * (e.g. an unconverged host strand) and the listener turns that into a clean,
454
+ * non-disclosing `approved: false` reply rather than dropping the result frame.
455
+ *
456
+ * `signal` is aborted when the listener's work budget expires: a hook that observes it
457
+ * BEFORE writing abandons the redemption and leaves the invite unspent. Optional so
458
+ * signal-unaware hooks (tests, mocks) stay assignable.
459
+ */
460
+ provisionStrand(contact: FormationContactMessage, signal?: AbortSignal): Promise<ResponderProvisionOutcome>;
461
+ /** Responder identity, disclosed only AFTER token + disclosure validation passes. */
462
+ getResponderIdentity(): { partyId: string; cadrePeerAddrs: string[] };
463
+ /**
464
+ * This node's live STRAND-network multiaddrs for a provisioned strand, read on the
465
+ * approval path only (same disclosure timing as {@link getResponderIdentity}) and
466
+ * carried back as {@link FormationResultMessage.strandAddrs}.
467
+ *
468
+ * Optional, and an empty answer is legal: the strand may not be running here yet (the
469
+ * responder-provisions path mints it during this very session). The listener cannot
470
+ * reach the strand runtime itself, so this is the seam `CadreNode` wires to its own
471
+ * per-strand address lookup.
472
+ */
473
+ resolveStrandAddrs?(strandId: string): string[];
474
+ sessionTimeoutMs?: number;
475
+ stepTimeoutMs?: number;
476
+ /**
477
+ * Budget for the `provisionStrand` hook call — distinct from `stepTimeoutMs` because
478
+ * provisioning is real work, not a bare wire read/write. Default 12 s; `0`/unset uses
479
+ * the default; a value that would outlive the session is clamped ({@link
480
+ * provisionCeilingMs}). See {@link DEFAULT_PROVISION_TIMEOUT_MS} for the full ordering
481
+ * rationale.
482
+ */
483
+ provisionTimeoutMs?: number;
484
+ maxConcurrentSessions?: number;
485
+ }
486
+
487
+ /**
488
+ * Responder side of the native formation protocol. Registers a libp2p handler and
489
+ * drives each inbound stream through token + disclosure validation, provisioning,
490
+ * and result delivery — enforcing the cadre-disclosure timing rule (responder cadre
491
+ * is revealed only after validation; rejections disclose nothing).
492
+ */
493
+ export class FormationListener {
494
+ private readonly options: FormationListenerOptions;
495
+ private readonly sessionTimeoutMs: number;
496
+ private readonly stepTimeoutMs: number;
497
+ private readonly provisionWorkMs: number;
498
+ private readonly provisionGraceMs: number;
499
+ private readonly maxConcurrentSessions: number;
500
+ private readonly registered = new Set<Libp2p>();
501
+ private activeSessions = 0;
502
+ private sessionCounter = 0;
503
+
504
+ constructor(options: FormationListenerOptions) {
505
+ this.options = options;
506
+ this.sessionTimeoutMs = options.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
507
+ this.stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
508
+ const split = splitProvisionBudget(resolveProvisionTimeoutMs(
509
+ options.provisionTimeoutMs, DEFAULT_PROVISION_TIMEOUT_MS,
510
+ this.sessionTimeoutMs, this.stepTimeoutMs, 'FormationListener',
511
+ PROVISION_RESPONSE_TRAVEL_MARGIN_MS
512
+ ));
513
+ this.provisionWorkMs = split.workMs;
514
+ this.provisionGraceMs = split.graceMs;
515
+ this.maxConcurrentSessions = options.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS;
516
+ }
517
+
518
+ /** Number of in-flight inbound sessions. */
519
+ get activeCount(): number {
520
+ return this.activeSessions;
521
+ }
522
+
523
+ register(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): void {
524
+ if (this.registered.has(node)) {
525
+ log('node already registered');
526
+ return;
527
+ }
528
+ void node.handle(protocolId, async (rawStream: unknown, _connection: unknown) => {
529
+ await this.handleStream(rawStream as ControlStream);
530
+ });
531
+ this.registered.add(node);
532
+ log('formation listener registered (%s)', protocolId);
533
+ }
534
+
535
+ unregister(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): void {
536
+ if (!this.registered.has(node)) return;
537
+ void node.unhandle(protocolId);
538
+ this.registered.delete(node);
539
+ log('formation listener unregistered (%s)', protocolId);
540
+ }
541
+
542
+ private async handleStream(stream: ControlStream): Promise<void> {
543
+ if (this.activeSessions >= this.maxConcurrentSessions) {
544
+ const rejection: FormationResultMessage = { approved: false, reason: 'Too many concurrent formation sessions' };
545
+ try { writeFrame(stream, rejection); } catch { /* best effort */ }
546
+ try { await stream.close(); } catch { /* ignore */ }
547
+ return;
548
+ }
549
+ const id = ++this.sessionCounter;
550
+ this.activeSessions++;
551
+ try {
552
+ await withTimeout(this.sessionTimeoutMs, `Formation session#${id}`, () => this.runSession(id, stream));
553
+ } catch (err) {
554
+ log('formation session #%d failed: %o', id, err);
555
+ } finally {
556
+ this.activeSessions--;
557
+ try { await stream.close(); } catch { /* ignore */ }
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Run the provisioning hook under its WORK budget — the configured provisioning budget minus
563
+ * the trailing settle grace ({@link splitProvisionBudget}, {@link PROVISION_SETTLE_GRACE_MS}).
564
+ *
565
+ * When the work budget expires the hook's signal is aborted, then
566
+ * {@link settleWithinGrace} waits out the grace for the aborted work to settle anyway.
567
+ * Returns `undefined` for "timed out" — which the caller turns into a retryable rejection
568
+ * frame — and rethrows every other failure so it reaches the internal-error path.
569
+ *
570
+ * Deliberately not `withDeadline`: that exposes neither the timed-out flag nor the signal
571
+ * outside its `op`, and both are needed here.
572
+ */
573
+ private async provision(id: number, contact: FormationContactMessage): Promise<ResponderProvisionOutcome | undefined> {
574
+ const controller = new AbortController();
575
+ let timedOut = false;
576
+ const pending = this.options.provisionStrand(contact, controller.signal);
577
+ try {
578
+ return await withTimeout(
579
+ this.provisionWorkMs,
580
+ `Formation provisioning#${id}`,
581
+ () => pending,
582
+ () => { timedOut = true; controller.abort(); }
583
+ );
584
+ } catch (err) {
585
+ if (!timedOut) throw err;
586
+ log(
587
+ 'formation session #%d provisioning work budget expired after %dms; aborted, settling up to %dms',
588
+ id, this.provisionWorkMs, this.provisionGraceMs
589
+ );
590
+ return await this.settleWithinGrace(id, pending);
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Wait out the settle grace for an ABORTED provisioning, adopting a late success.
596
+ *
597
+ * A hook that observed the abort before issuing its `FormationUsage` insert rejects (with
598
+ * `FormationAbortedError`) and leaves the invite unspent → reported as a timeout. A hook
599
+ * that had already written cannot be un-written (the table is append-only), so if it lands
600
+ * inside the grace its outcome is ADOPTED — the joiner is told the truth (approved, or the
601
+ * hook's own rejection reason) rather than "timed out" over a spent invite.
602
+ *
603
+ * NOTE: one window stays open — a work budget that expires after the `FormationUsage`
604
+ * insert was issued AND a commit that outlasts the grace. The joiner is told 'timed out'
605
+ * while its one-time invite is in fact spent, and since every retry mints a fresh keypair
606
+ * no recovery path can match it. Nothing can un-spend an append-only row, so the late-settle
607
+ * logging below is the observability for it; widen the grace only if it is seen in the wild.
608
+ */
609
+ private async settleWithinGrace(
610
+ id: number,
611
+ pending: Promise<ResponderProvisionOutcome>
612
+ ): Promise<ResponderProvisionOutcome | undefined> {
613
+ let stillPending = false;
614
+ try {
615
+ const outcome = await withTimeout(
616
+ this.provisionGraceMs,
617
+ `Formation settle#${id}`,
618
+ () => pending,
619
+ () => { stillPending = true; }
620
+ );
621
+ log('formation session #%d provisioning settled within its grace: approved=%s — adopting', id, outcome.approved);
622
+ return outcome;
623
+ } catch (err) {
624
+ if (!stillPending) {
625
+ log('formation session #%d provisioning failed after its abort: %o', id, err);
626
+ return undefined;
627
+ }
628
+ log('formation session #%d provisioning still pending after its %dms grace', id, this.provisionGraceMs);
629
+ // Log how it eventually settled so a late failure is not swallowed by the two
630
+ // abandoned `withTimeout` calls.
631
+ void pending.then(
632
+ (late) => log('formation session #%d provisioning settled after its grace: approved=%s', id, late.approved),
633
+ (err2) => log('formation session #%d provisioning failed after its grace: %o', id, err2)
634
+ );
635
+ return undefined;
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Joiner-consent pre-check — the legibility twin of the schema's
641
+ * `FormationUsage.PeerConsented` CHECK, exactly as `verifyFormationApproval` is for the
642
+ * approver's vouch. The database constraint stays the authority; this turns a bad consent
643
+ * into a clean 'Invalid joiner consent' rejection instead of an opaque CHECK failure at
644
+ * commit. Runs BEFORE token/disclosure validation: it is the cheapest check (pure local
645
+ * crypto, no DB read) and a rejection discloses nothing.
646
+ *
647
+ * Also pins `partyId` to `peerKey`: an ed25519 peer id is an identity multihash of the
648
+ * very key, and this is the ONLY layer that can check the match — SQL cannot unwrap a
649
+ * multihash, and `partyId` itself is never stored.
650
+ */
651
+ private isJoinerConsentValid(id: number, contact: FormationContactMessage): boolean {
652
+ try {
653
+ requireEd25519PublicKeyB64(contact.peerKey, 'joiner peer key');
654
+ } catch {
655
+ // Deliberately not rethrown or echoed: the helper's error message embeds the whole
656
+ // rejected value, and this one arrived from a remote peer (the cap-the-echo case its
657
+ // own NOTE warns about). Log without the raw value instead.
658
+ log('formation session #%d: joiner peer key is not a base64url ed25519 public key', id);
659
+ return false;
660
+ }
661
+ const embedded = ed25519PublicKeyB64FromPeerId(contact.partyId);
662
+ if (!embedded || embedded !== contact.peerKey) {
663
+ log('formation session #%d: partyId does not embed the presented peer key', id);
664
+ return false;
665
+ }
666
+ const consentOk = verifyFormationConsent({
667
+ token: contact.token,
668
+ usageStampId: contact.usageStampId,
669
+ peerKey: contact.peerKey,
670
+ disclosure: canonicalJson(contact.disclosure),
671
+ peerSig: contact.peerSignature
672
+ });
673
+ if (!consentOk) {
674
+ log('formation session #%d: joiner consent signature failed verification', id);
675
+ return false;
676
+ }
677
+ return true;
678
+ }
679
+
680
+ /**
681
+ * The strand addresses to disclose for an APPROVED provisioning, sanitized and capped.
682
+ *
683
+ * Never throws: a hook that fails (a strand runtime torn down mid-session) costs the
684
+ * joiner its cross-party seed, not the formation it has already earned — the seed is
685
+ * an optimization, the consent row is the commitment.
686
+ */
687
+ private strandAddrsFor(id: number, strandId: string): string[] {
688
+ if (!this.options.resolveStrandAddrs) return [];
689
+ try {
690
+ return sanitizeStrandAddrs(this.options.resolveStrandAddrs(strandId));
691
+ } catch (err) {
692
+ log('formation session #%d: strand-addr lookup for %s failed (continuing): %o', id, strandId, err);
693
+ return [];
694
+ }
695
+ }
696
+
697
+ private async runSession(id: number, stream: ControlStream): Promise<void> {
698
+ // Track whether ANY frame has been written so the catch below can convert an
699
+ // unexpected internal error into a non-disclosing rejection ONLY when nothing has
700
+ // gone out yet. This closes the "stream closed with no result frame" class of bug.
701
+ let wroteFrame = false;
702
+ const send = (msg: FormationResultMessage): void => {
703
+ writeFrame(stream, msg);
704
+ wroteFrame = true;
705
+ };
706
+
707
+ try {
708
+ const reader = new FrameReader(stream);
709
+ const contact = await withTimeout(this.stepTimeoutMs, `Formation await-contact#${id}`, () => reader.read<FormationContactMessage>());
710
+ log('formation session #%d contact: token=%s party=%s', id, contact.token, contact.partyId);
711
+
712
+ if (!this.isJoinerConsentValid(id, contact)) {
713
+ send({ approved: false, reason: 'Invalid joiner consent' });
714
+ return;
715
+ }
716
+
717
+ const tokenResult = await this.options.validateToken(contact.token);
718
+ if (!tokenResult.valid) {
719
+ send({ approved: false, reason: INVALID_TOKEN_REASON });
720
+ return;
721
+ }
722
+
723
+ const disclosureOk = await this.options.validateDisclosure(contact.token, contact.disclosure);
724
+ if (!disclosureOk) {
725
+ send({ approved: false, reason: 'Invalid disclosure' });
726
+ return;
727
+ }
728
+
729
+ const outcome = await this.provision(id, contact);
730
+ if (!outcome) {
731
+ // Reported as its own retryable reason rather than falling through to the generic
732
+ // catch below, which would report the misleading 'Internal formation error'.
733
+ send({ approved: false, reason: 'Formation provisioning timed out' });
734
+ return;
735
+ }
736
+ if (!outcome.approved) {
737
+ // A post-validation rejection still discloses NEITHER identity NOR cadre,
738
+ // exactly like the token/disclosure rejections above.
739
+ send({ approved: false, reason: outcome.reason });
740
+ return;
741
+ }
742
+ // Validation + provisioning passed → safe to disclose responder identity/cadre.
743
+ const identity = this.options.getResponderIdentity();
744
+ const strandAddrs = this.strandAddrsFor(id, outcome.result.strand.strandId);
745
+ send({
746
+ approved: true,
747
+ partyId: identity.partyId,
748
+ cadrePeerAddrs: identity.cadrePeerAddrs,
749
+ // Omitted rather than sent empty: an absent field and an empty list mean the same
750
+ // thing to the initiator, and omitting keeps every rejection-parity assertion a
751
+ // plain `toBeUndefined()`.
752
+ ...(strandAddrs.length > 0 ? { strandAddrs } : {}),
753
+ provisionResult: outcome.result
754
+ });
755
+ } catch (err) {
756
+ // Defense-in-depth: if an unexpected internal error escapes BEFORE any frame was
757
+ // written (e.g. a future provisionStrand-hook bug), convert it into a non-disclosing
758
+ // protocol rejection so the initiator sees a clean error instead of a read-error/
759
+ // timeout. If a frame was already sent, leave the stream as-is and let handleStream
760
+ // log + close. Re-throw either way so the failure is still recorded — this is a
761
+ // deliberate, logged conversion, not silent.
762
+ if (!wroteFrame) {
763
+ const internalError: FormationResultMessage = { approved: false, reason: 'Internal formation error' };
764
+ try { writeFrame(stream, internalError); } catch { /* stream already broken */ }
765
+ }
766
+ throw err;
767
+ }
768
+ }
769
+ }
770
+
771
+ // ── Initiator (dialer) ───────────────────────────────────────────────────────
772
+
773
+ export interface FormationDialOptions {
774
+ /** The contact message to send (real token, partyId, disclosure, initiator cadre). */
775
+ contact: FormationContactMessage;
776
+ /** Responder multiaddrs to dial. */
777
+ responderAddrs: string[];
778
+ /** Validate the responder's result; a false return aborts the formation. */
779
+ validateResponse(response: FormationResultMessage): Promise<boolean>;
780
+ sessionTimeoutMs?: number;
781
+ stepTimeoutMs?: number;
782
+ /**
783
+ * Budget for the `await-response` read only (the responder's provisioning + reply travel
784
+ * time) — `dial-connect` keeps using `stepTimeoutMs`. Default 15 s; `0`/unset uses the
785
+ * default. See {@link DEFAULT_PROVISION_TIMEOUT_MS} for the full ordering rationale.
786
+ */
787
+ provisionTimeoutMs?: number;
788
+ protocolId?: string;
789
+ }
790
+
791
+ /**
792
+ * What one successful formation dial yields the initiator.
793
+ *
794
+ * The provisioned strand travels in {@link FormationProvisionResult}, but the responder's
795
+ * strand-network addresses are NOT part of that structure — they are a disclosure of the
796
+ * responder's live runtime, alongside `partyId`/`cadrePeerAddrs`, not a property of the
797
+ * strand row. Returning them beside the provision result keeps that separation while
798
+ * still handing the caller the one thing it needs to seed a cross-party mesh.
799
+ *
800
+ * `strandAddrs` is always an array and is frequently EMPTY — see
801
+ * {@link FormationResultMessage.strandAddrs}.
802
+ */
803
+ export interface FormationDialResult {
804
+ provision: FormationProvisionResult;
805
+ /** Sanitized + capped ({@link sanitizeStrandAddrs}); `[]` when the responder sent none. */
806
+ strandAddrs: string[];
807
+ }
808
+
809
+ /**
810
+ * Initiator side of the native formation protocol. Dials the responder, sends the
811
+ * contact (carrying the real disclosure/token/cadre), validates the responder's
812
+ * result, and returns the strand the responder provisioned plus the responder's
813
+ * strand-network addresses (see {@link FormationDialResult}).
814
+ */
815
+ export async function dialFormation(node: Libp2p, options: FormationDialOptions): Promise<FormationDialResult> {
816
+ if (options.responderAddrs.length === 0) {
817
+ throw new Error('No responder addresses available for formation');
818
+ }
819
+ const protocolId = options.protocolId ?? FORMATION_PROTOCOL;
820
+ const sessionTimeoutMs = options.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
821
+ const stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
822
+ const provisionTimeoutMs = resolveProvisionTimeoutMs(
823
+ options.provisionTimeoutMs, DEFAULT_INITIATOR_PROVISION_TIMEOUT_MS,
824
+ sessionTimeoutMs, stepTimeoutMs, 'dialFormation'
825
+ );
826
+ const addr = multiaddr(options.responderAddrs[0]);
827
+
828
+ return withTimeout(sessionTimeoutMs, 'Formation dial', async () => {
829
+ const rawStream = await withTimeout(stepTimeoutMs, 'Formation dial-connect', async () => node.dialProtocol(addr, protocolId));
830
+ const stream = rawStream as unknown as ControlStream;
831
+ try {
832
+ const reader = new FrameReader(stream);
833
+ writeFrame(stream, options.contact);
834
+
835
+ const response = await withTimeout(provisionTimeoutMs, 'Formation await-response', () => reader.read<FormationResultMessage>());
836
+ if (!response.approved) {
837
+ throw new Error(`Formation rejected: ${response.reason ?? 'no reason provided'}`);
838
+ }
839
+ const ok = await options.validateResponse(response);
840
+ if (!ok) throw new Error('Responder result failed validation');
841
+
842
+ if (!response.provisionResult) throw new Error('Missing provision result for responderCreates mode');
843
+ // Sanitized HERE — the wire boundary — so nothing above this call ever handles an
844
+ // unvalidated, unbounded address list from a remote peer.
845
+ return { provision: response.provisionResult, strandAddrs: sanitizeStrandAddrs(response.strandAddrs) };
846
+ } finally {
847
+ try { await stream.close(); } catch { /* ignore */ }
848
+ }
849
+ });
850
+ }