@serfab/cadre-core 0.7.1 → 0.9.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 (170) hide show
  1. package/README.md +11 -10
  2. package/dist/cadre-node.d.ts +818 -19
  3. package/dist/cadre-node.js +1952 -99
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/canonical-datetime.d.ts +33 -0
  6. package/dist/canonical-datetime.js +38 -0
  7. package/dist/canonical-datetime.js.map +1 -0
  8. package/dist/canonical-json.d.ts +12 -0
  9. package/dist/canonical-json.js +33 -0
  10. package/dist/canonical-json.js.map +1 -0
  11. package/dist/control-cohort.d.ts +63 -0
  12. package/dist/control-cohort.js +80 -0
  13. package/dist/control-cohort.js.map +1 -0
  14. package/dist/control-database.d.ts +279 -8
  15. package/dist/control-database.js +563 -134
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -0
  18. package/dist/control-formation-recorder.js +139 -0
  19. package/dist/control-formation-recorder.js.map +1 -0
  20. package/dist/control-schema.d.ts +12 -0
  21. package/dist/control-schema.js +223 -0
  22. package/dist/control-schema.js.map +1 -0
  23. package/dist/control-stream.d.ts +54 -0
  24. package/dist/control-stream.js +104 -0
  25. package/dist/control-stream.js.map +1 -0
  26. package/dist/device-token.d.ts +50 -0
  27. package/dist/device-token.js +66 -0
  28. package/dist/device-token.js.map +1 -0
  29. package/dist/diagnostics/connection-path.d.ts +118 -0
  30. package/dist/diagnostics/connection-path.js +204 -0
  31. package/dist/diagnostics/connection-path.js.map +1 -0
  32. package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
  33. package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
  34. package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
  35. package/dist/ed25519-key.d.ts +42 -0
  36. package/dist/ed25519-key.js +51 -0
  37. package/dist/ed25519-key.js.map +1 -0
  38. package/dist/hibernation-manager.d.ts +79 -0
  39. package/dist/hibernation-manager.js +173 -23
  40. package/dist/hibernation-manager.js.map +1 -1
  41. package/dist/index.d.ts +25 -4
  42. package/dist/index.js +59 -4
  43. package/dist/index.js.map +1 -1
  44. package/dist/key-store-file.d.ts +52 -0
  45. package/dist/key-store-file.js +194 -0
  46. package/dist/key-store-file.js.map +1 -0
  47. package/dist/key-store.d.ts +75 -0
  48. package/dist/key-store.js +66 -0
  49. package/dist/key-store.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +48 -0
  51. package/dist/peer-authorization.js +66 -0
  52. package/dist/peer-authorization.js.map +1 -0
  53. package/dist/peer-record.d.ts +89 -0
  54. package/dist/peer-record.js +117 -0
  55. package/dist/peer-record.js.map +1 -0
  56. package/dist/push-credentials.d.ts +34 -0
  57. package/dist/push-credentials.js +81 -0
  58. package/dist/push-credentials.js.map +1 -0
  59. package/dist/push-fanout.d.ts +137 -0
  60. package/dist/push-fanout.js +197 -0
  61. package/dist/push-fanout.js.map +1 -0
  62. package/dist/push-node.d.ts +38 -0
  63. package/dist/push-node.js +48 -0
  64. package/dist/push-node.js.map +1 -0
  65. package/dist/push-notifier-apns.d.ts +61 -0
  66. package/dist/push-notifier-apns.js +199 -0
  67. package/dist/push-notifier-apns.js.map +1 -0
  68. package/dist/push-notifier-fcm.d.ts +44 -0
  69. package/dist/push-notifier-fcm.js +182 -0
  70. package/dist/push-notifier-fcm.js.map +1 -0
  71. package/dist/push-notifier-shared.d.ts +18 -0
  72. package/dist/push-notifier-shared.js +25 -0
  73. package/dist/push-notifier-shared.js.map +1 -0
  74. package/dist/push-notifier.d.ts +52 -0
  75. package/dist/push-notifier.js +22 -0
  76. package/dist/push-notifier.js.map +1 -0
  77. package/dist/schema-verification.d.ts +11 -2
  78. package/dist/schema-verification.js +15 -4
  79. package/dist/schema-verification.js.map +1 -1
  80. package/dist/seed-bootstrap.d.ts +228 -21
  81. package/dist/seed-bootstrap.js +489 -168
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +63 -0
  84. package/dist/seed-trust-policy.js +56 -0
  85. package/dist/seed-trust-policy.js.map +1 -0
  86. package/dist/strand-addr-protocol.d.ts +132 -0
  87. package/dist/strand-addr-protocol.js +313 -0
  88. package/dist/strand-addr-protocol.js.map +1 -0
  89. package/dist/strand-cohort.d.ts +54 -0
  90. package/dist/strand-cohort.js +38 -0
  91. package/dist/strand-cohort.js.map +1 -0
  92. package/dist/strand-database.d.ts +54 -6
  93. package/dist/strand-database.js +88 -100
  94. package/dist/strand-database.js.map +1 -1
  95. package/dist/strand-formation-manager.d.ts +79 -19
  96. package/dist/strand-formation-manager.js +196 -104
  97. package/dist/strand-formation-manager.js.map +1 -1
  98. package/dist/strand-formation-protocol.d.ts +162 -0
  99. package/dist/strand-formation-protocol.js +279 -0
  100. package/dist/strand-formation-protocol.js.map +1 -0
  101. package/dist/strand-instance-manager.d.ts +73 -4
  102. package/dist/strand-instance-manager.js +167 -41
  103. package/dist/strand-instance-manager.js.map +1 -1
  104. package/dist/strand-member-key.d.ts +29 -0
  105. package/dist/strand-member-key.js +38 -0
  106. package/dist/strand-member-key.js.map +1 -0
  107. package/dist/strand-member-registry.d.ts +98 -0
  108. package/dist/strand-member-registry.js +129 -0
  109. package/dist/strand-member-registry.js.map +1 -0
  110. package/dist/strand-membership-writer.d.ts +361 -0
  111. package/dist/strand-membership-writer.js +449 -0
  112. package/dist/strand-membership-writer.js.map +1 -0
  113. package/dist/strand-solicitation.d.ts +77 -2
  114. package/dist/strand-solicitation.js +25 -4
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-wake-payload.d.ts +30 -0
  117. package/dist/strand-wake-payload.js +20 -0
  118. package/dist/strand-wake-payload.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +114 -0
  120. package/dist/strand-wake-protocol.js +257 -0
  121. package/dist/strand-wake-protocol.js.map +1 -0
  122. package/dist/strand-watcher.d.ts +6 -2
  123. package/dist/strand-watcher.js +57 -17
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/types.d.ts +436 -33
  126. package/dist/types.js +22 -5
  127. package/dist/types.js.map +1 -1
  128. package/package.json +18 -8
  129. package/src/cadre-node.ts +2981 -948
  130. package/src/canonical-datetime.ts +39 -0
  131. package/src/canonical-json.ts +30 -0
  132. package/src/control-cohort.ts +103 -0
  133. package/src/control-database.ts +948 -383
  134. package/src/control-formation-recorder.ts +151 -0
  135. package/src/control-schema.ts +222 -0
  136. package/src/control-stream.ts +128 -0
  137. package/src/device-token.ts +89 -0
  138. package/src/diagnostics/connection-path.ts +277 -0
  139. package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
  140. package/src/ed25519-key.ts +69 -0
  141. package/src/hibernation-manager.ts +197 -28
  142. package/src/index.ts +275 -79
  143. package/src/key-store-file.ts +203 -0
  144. package/src/key-store.ts +99 -0
  145. package/src/peer-authorization.ts +81 -0
  146. package/src/peer-record.ts +146 -0
  147. package/src/push-credentials.ts +84 -0
  148. package/src/push-fanout.ts +272 -0
  149. package/src/push-node.ts +64 -0
  150. package/src/push-notifier-apns.ts +247 -0
  151. package/src/push-notifier-fcm.ts +220 -0
  152. package/src/push-notifier-shared.ts +28 -0
  153. package/src/push-notifier.ts +52 -0
  154. package/src/schema-verification.ts +18 -4
  155. package/src/seed-bootstrap.ts +563 -223
  156. package/src/seed-trust-policy.ts +107 -0
  157. package/src/strand-addr-protocol.ts +410 -0
  158. package/src/strand-cohort.ts +77 -0
  159. package/src/strand-database.ts +113 -141
  160. package/src/strand-formation-manager.ts +233 -142
  161. package/src/strand-formation-protocol.ts +391 -0
  162. package/src/strand-instance-manager.ts +207 -56
  163. package/src/strand-member-key.ts +39 -0
  164. package/src/strand-member-registry.ts +177 -0
  165. package/src/strand-membership-writer.ts +640 -0
  166. package/src/strand-solicitation.ts +93 -4
  167. package/src/strand-wake-payload.ts +32 -0
  168. package/src/strand-wake-protocol.ts +331 -0
  169. package/src/strand-watcher.ts +61 -17
  170. package/src/types.ts +1032 -582
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Native cadre-core strand-formation transport.
3
+ *
4
+ * Mirrors the non-deprecated `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 deprecated `@serfab/strand-proto` 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 (see docs/strand-proto.md "Security & Privacy"): the
16
+ * responder reveals its own party id + cadre addresses — and, for a closed strand
17
+ * returned via provision-then-record, that strand's membership key — ONLY after the
18
+ * token and disclosure validate; a rejection 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
+
27
+ const log = debug('sereus:cadre:formation-proto');
28
+
29
+ /** Protocol id for the native formation transport (parallel to `/sereus/seed/1.0.0`). */
30
+ export const FORMATION_PROTOCOL = '/sereus/formation/1.0.0';
31
+
32
+ /** Maximum formation message size (1MB). */
33
+ const MAX_FORMATION_MSG_SIZE = 1024 * 1024;
34
+
35
+ /** Default whole-session timeout (ms). */
36
+ const DEFAULT_SESSION_TIMEOUT_MS = 30_000;
37
+ /** Default per-step (single read/write) timeout (ms). */
38
+ const DEFAULT_STEP_TIMEOUT_MS = 5_000;
39
+ /** Default cap on concurrent inbound formation sessions. */
40
+ const DEFAULT_MAX_CONCURRENT_SESSIONS = 100;
41
+
42
+ // ── Roles ────────────────────────────────────────────────────────────────────
43
+
44
+ export type FormationParty = 'initiator' | 'responder';
45
+
46
+ // ── Wire messages (length-prefixed JSON frames) ──────────────────────────────
47
+
48
+ export interface FormationStrandInfo {
49
+ strandId: string;
50
+ createdBy: FormationParty;
51
+ }
52
+
53
+ export interface FormationDbConnectionInfo {
54
+ endpoint: string;
55
+ credentialsRef: string;
56
+ }
57
+
58
+ export interface FormationProvisionResult {
59
+ strand: FormationStrandInfo;
60
+ /**
61
+ * The strand's membership key (closed-strand read-gating secret), delivered to a
62
+ * validated invitee for provision-then-record formation. Disclosed only AFTER token
63
+ * + disclosure validation, exactly like the responder identity/cadre — a rejected or
64
+ * already-used token discloses neither identity nor key. Absent for open strands.
65
+ */
66
+ memberPrivateKey?: string;
67
+ dbConnectionInfo: FormationDbConnectionInfo;
68
+ }
69
+
70
+ /**
71
+ * Outcome of the responder's provisioning hook.
72
+ *
73
+ * Distinct from a bare {@link FormationProvisionResult} so the hook can REJECT a
74
+ * formation AFTER token + disclosure validation — e.g. a bound invite naming a host
75
+ * strand this responder has not yet converged on, or a concurrent-redemption collision.
76
+ * Without this channel such cases threw inside provisioning, the stream closed with no
77
+ * result frame, and the initiator saw a read-error/timeout instead of a clean
78
+ * `approved: false`. A rejection still discloses NO responder identity/cadre.
79
+ */
80
+ export type ResponderProvisionOutcome =
81
+ | { approved: true; result: FormationProvisionResult }
82
+ | { approved: false; reason: string };
83
+
84
+ /** Initiator → Responder: carries the real token + disclosure + initiator cadre. */
85
+ export interface FormationContactMessage {
86
+ /** The real invitation token. */
87
+ token: string;
88
+ /** Initiator's member key (peer id). */
89
+ partyId: string;
90
+ /** The real disclosure, carried verbatim. */
91
+ disclosure: StrandFormationDisclosure;
92
+ /** Initiator's real multiaddrs. */
93
+ cadrePeerAddrs: string[];
94
+ }
95
+
96
+ /** Responder → Initiator: responder identity/cadre disclosed only after validation. */
97
+ export interface FormationResultMessage {
98
+ approved: boolean;
99
+ /** Present iff `approved === false`. */
100
+ reason?: string;
101
+ /** Responder's real party id (disclosed only after validation). */
102
+ partyId?: string;
103
+ /** Responder's real multiaddrs (omitted on rejection). */
104
+ cadrePeerAddrs?: string[];
105
+ /** The provisioned strand/db result (always present on approval). */
106
+ provisionResult?: FormationProvisionResult;
107
+ }
108
+
109
+ // ── Stream framing helpers ───────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Reads length-prefixed JSON frames from a libp2p stream one frame at a time.
113
+ *
114
+ * Unlike seed delivery (which reads a stream to EOF then parses once), formation
115
+ * is request/response on a single live stream, so reading must stop at each frame
116
+ * boundary. The reader buffers across chunk boundaries and retains any overflow
117
+ * (a following frame coalesced into the same chunk) for the next read.
118
+ */
119
+ class FrameReader {
120
+ private readonly iterator: AsyncIterator<Uint8Array>;
121
+ private readonly maxLength: number;
122
+ private buffer = new Uint8Array(0);
123
+
124
+ constructor(stream: AsyncIterable<Uint8Array>, maxLength = MAX_FORMATION_MSG_SIZE) {
125
+ this.iterator = stream[Symbol.asyncIterator]();
126
+ this.maxLength = maxLength;
127
+ }
128
+
129
+ private append(chunk: Uint8Array): void {
130
+ const next = new Uint8Array(this.buffer.length + chunk.length);
131
+ next.set(this.buffer, 0);
132
+ next.set(chunk, this.buffer.length);
133
+ this.buffer = next;
134
+ }
135
+
136
+ /** Pull chunks until the buffer holds ≥ `need` bytes; false if the stream ended first. */
137
+ private async fill(need: number): Promise<boolean> {
138
+ while (this.buffer.length < need) {
139
+ const { value, done } = await this.iterator.next();
140
+ if (done) return false;
141
+ const bytes = value instanceof Uint8Array
142
+ ? value
143
+ : (value as { subarray(): Uint8Array }).subarray();
144
+ this.append(bytes);
145
+ }
146
+ return true;
147
+ }
148
+
149
+ async read<T>(): Promise<T> {
150
+ if (!(await this.fill(4))) {
151
+ throw new Error('Formation stream closed before length prefix');
152
+ }
153
+ const length = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength).getUint32(0, false);
154
+ if (length > this.maxLength) {
155
+ throw new Error(`Formation frame declares length ${length} exceeding max ${this.maxLength}`);
156
+ }
157
+ if (!(await this.fill(4 + length))) {
158
+ throw new Error(`Formation frame declares length ${length} but stream ended early`);
159
+ }
160
+ const json = new TextDecoder().decode(this.buffer.subarray(4, 4 + length));
161
+ this.buffer = this.buffer.subarray(4 + length);
162
+ return JSON.parse(json) as T;
163
+ }
164
+ }
165
+
166
+ // ── Result validation ────────────────────────────────────────────────────────
167
+
168
+ /** Matches the deprecated strand-proto placeholder cadre addrs (`cadre-a-1.local`, …). */
169
+ const PLACEHOLDER_CADRE_ADDR = /^cadre-[ab]-\d+\.local$/;
170
+
171
+ /**
172
+ * Structural check on a `responderCreates` result: the responder must have approved,
173
+ * disclosed a non-empty real identity + cadre (not the deprecated `cadre-*.local`
174
+ * placeholders), and returned a strand vouched for by the responder party with a real id.
175
+ *
176
+ * `createdBy: 'responder'` means "the responder party vouches for / returns this strand."
177
+ * Under provision-then-record that strand was minted owner-signed earlier (not created
178
+ * in-session), but it is still the responder party returning its own strand, so the marker
179
+ * — and this structural validator — stay unchanged.
180
+ *
181
+ * The behavioral floor for the initiator: a responder that returns an arbitrary/empty
182
+ * `strandId` or omits its disclosed identity is rejected, not silently accepted.
183
+ */
184
+ export function isValidResponderCreatesResult(response: FormationResultMessage): boolean {
185
+ if (!response.approved) return false;
186
+ if (!response.partyId) return false;
187
+ const addrs = response.cadrePeerAddrs;
188
+ if (!addrs || addrs.length === 0) return false;
189
+ if (addrs.some((a) => PLACEHOLDER_CADRE_ADDR.test(a))) return false;
190
+ const provision = response.provisionResult;
191
+ if (!provision?.strand?.strandId) return false;
192
+ if (provision.strand.createdBy !== 'responder') return false;
193
+ return true;
194
+ }
195
+
196
+ // ── Responder (listener) ─────────────────────────────────────────────────────
197
+
198
+ export interface FormationListenerOptions {
199
+ /** Validate the invitation token; returns whether it is valid. */
200
+ validateToken(token: string): Promise<{ valid: boolean }>;
201
+ /** Validate the initiator's disclosure with the REAL token + disclosure. */
202
+ validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
203
+ /**
204
+ * Provision (or, for provision-then-record, resolve + record consent against) the
205
+ * strand (`responderCreates`) for the given initiator. The REAL token is threaded
206
+ * in so the hook can map it to the bound host strand and its membership key.
207
+ *
208
+ * Returns a {@link ResponderProvisionOutcome}: the hook may REJECT post-validation
209
+ * (e.g. an unconverged host strand) and the listener turns that into a clean,
210
+ * non-disclosing `approved: false` reply rather than dropping the result frame.
211
+ */
212
+ provisionStrand(token: string, initiatorPartyId: string, disclosure: StrandFormationDisclosure): Promise<ResponderProvisionOutcome>;
213
+ /** Responder identity, disclosed only AFTER token + disclosure validation passes. */
214
+ getResponderIdentity(): { partyId: string; cadrePeerAddrs: string[] };
215
+ sessionTimeoutMs?: number;
216
+ stepTimeoutMs?: number;
217
+ maxConcurrentSessions?: number;
218
+ }
219
+
220
+ /**
221
+ * Responder side of the native formation protocol. Registers a libp2p handler and
222
+ * drives each inbound stream through token + disclosure validation, provisioning,
223
+ * and result delivery — enforcing the cadre-disclosure timing rule (responder cadre
224
+ * is revealed only after validation; rejections disclose nothing).
225
+ */
226
+ export class FormationListener {
227
+ private readonly options: FormationListenerOptions;
228
+ private readonly sessionTimeoutMs: number;
229
+ private readonly stepTimeoutMs: number;
230
+ private readonly maxConcurrentSessions: number;
231
+ private readonly registered = new Set<Libp2p>();
232
+ private activeSessions = 0;
233
+ private sessionCounter = 0;
234
+
235
+ constructor(options: FormationListenerOptions) {
236
+ this.options = options;
237
+ this.sessionTimeoutMs = options.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
238
+ this.stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
239
+ this.maxConcurrentSessions = options.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS;
240
+ }
241
+
242
+ /** Number of in-flight inbound sessions. */
243
+ get activeCount(): number {
244
+ return this.activeSessions;
245
+ }
246
+
247
+ register(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): void {
248
+ if (this.registered.has(node)) {
249
+ log('node already registered');
250
+ return;
251
+ }
252
+ void node.handle(protocolId, async (rawStream: unknown, _connection: unknown) => {
253
+ await this.handleStream(rawStream as ControlStream);
254
+ });
255
+ this.registered.add(node);
256
+ log('formation listener registered (%s)', protocolId);
257
+ }
258
+
259
+ unregister(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): void {
260
+ if (!this.registered.has(node)) return;
261
+ void node.unhandle(protocolId);
262
+ this.registered.delete(node);
263
+ log('formation listener unregistered (%s)', protocolId);
264
+ }
265
+
266
+ private async handleStream(stream: ControlStream): Promise<void> {
267
+ if (this.activeSessions >= this.maxConcurrentSessions) {
268
+ const rejection: FormationResultMessage = { approved: false, reason: 'Too many concurrent formation sessions' };
269
+ try { writeFrame(stream, rejection); } catch { /* best effort */ }
270
+ try { await stream.close(); } catch { /* ignore */ }
271
+ return;
272
+ }
273
+ const id = ++this.sessionCounter;
274
+ this.activeSessions++;
275
+ try {
276
+ await withTimeout(this.sessionTimeoutMs, `Formation session#${id}`, () => this.runSession(id, stream));
277
+ } catch (err) {
278
+ log('formation session #%d failed: %o', id, err);
279
+ } finally {
280
+ this.activeSessions--;
281
+ try { await stream.close(); } catch { /* ignore */ }
282
+ }
283
+ }
284
+
285
+ private async runSession(id: number, stream: ControlStream): Promise<void> {
286
+ // Track whether ANY frame has been written so the catch below can convert an
287
+ // unexpected internal error into a non-disclosing rejection ONLY when nothing has
288
+ // gone out yet. This closes the "stream closed with no result frame" class of bug.
289
+ let wroteFrame = false;
290
+ const send = (msg: FormationResultMessage): void => {
291
+ writeFrame(stream, msg);
292
+ wroteFrame = true;
293
+ };
294
+
295
+ try {
296
+ const reader = new FrameReader(stream);
297
+ const contact = await withTimeout(this.stepTimeoutMs, `Formation await-contact#${id}`, () => reader.read<FormationContactMessage>());
298
+ log('formation session #%d contact: token=%s party=%s', id, contact.token, contact.partyId);
299
+
300
+ const tokenResult = await this.options.validateToken(contact.token);
301
+ if (!tokenResult.valid) {
302
+ send({ approved: false, reason: 'Invalid token' });
303
+ return;
304
+ }
305
+
306
+ const disclosureOk = await this.options.validateDisclosure(contact.token, contact.disclosure);
307
+ if (!disclosureOk) {
308
+ send({ approved: false, reason: 'Invalid disclosure' });
309
+ return;
310
+ }
311
+
312
+ const outcome = await this.options.provisionStrand(contact.token, contact.partyId, contact.disclosure);
313
+ if (!outcome.approved) {
314
+ // A post-validation rejection still discloses NEITHER identity NOR cadre,
315
+ // exactly like the token/disclosure rejections above.
316
+ send({ approved: false, reason: outcome.reason });
317
+ return;
318
+ }
319
+ // Validation + provisioning passed → safe to disclose responder identity/cadre.
320
+ const identity = this.options.getResponderIdentity();
321
+ send({
322
+ approved: true,
323
+ partyId: identity.partyId,
324
+ cadrePeerAddrs: identity.cadrePeerAddrs,
325
+ provisionResult: outcome.result
326
+ });
327
+ } catch (err) {
328
+ // Defense-in-depth: if an unexpected internal error escapes BEFORE any frame was
329
+ // written (e.g. a future provisionStrand-hook bug), convert it into a non-disclosing
330
+ // protocol rejection so the initiator sees a clean error instead of a read-error/
331
+ // timeout. If a frame was already sent, leave the stream as-is and let handleStream
332
+ // log + close. Re-throw either way so the failure is still recorded — this is a
333
+ // deliberate, logged conversion, not silent.
334
+ if (!wroteFrame) {
335
+ const internalError: FormationResultMessage = { approved: false, reason: 'Internal formation error' };
336
+ try { writeFrame(stream, internalError); } catch { /* stream already broken */ }
337
+ }
338
+ throw err;
339
+ }
340
+ }
341
+ }
342
+
343
+ // ── Initiator (dialer) ───────────────────────────────────────────────────────
344
+
345
+ export interface FormationDialOptions {
346
+ /** The contact message to send (real token, partyId, disclosure, initiator cadre). */
347
+ contact: FormationContactMessage;
348
+ /** Responder multiaddrs to dial. */
349
+ responderAddrs: string[];
350
+ /** Validate the responder's result; a false return aborts the formation. */
351
+ validateResponse(response: FormationResultMessage): Promise<boolean>;
352
+ sessionTimeoutMs?: number;
353
+ stepTimeoutMs?: number;
354
+ protocolId?: string;
355
+ }
356
+
357
+ /**
358
+ * Initiator side of the native formation protocol. Dials the responder, sends the
359
+ * contact (carrying the real disclosure/token/cadre), validates the responder's
360
+ * result, and returns the strand the responder provisioned.
361
+ */
362
+ export async function dialFormation(node: Libp2p, options: FormationDialOptions): Promise<FormationProvisionResult> {
363
+ if (options.responderAddrs.length === 0) {
364
+ throw new Error('No responder addresses available for formation');
365
+ }
366
+ const protocolId = options.protocolId ?? FORMATION_PROTOCOL;
367
+ const sessionTimeoutMs = options.sessionTimeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
368
+ const stepTimeoutMs = options.stepTimeoutMs ?? DEFAULT_STEP_TIMEOUT_MS;
369
+ const addr = multiaddr(options.responderAddrs[0]);
370
+
371
+ return withTimeout(sessionTimeoutMs, 'Formation dial', async () => {
372
+ const rawStream = await withTimeout(stepTimeoutMs, 'Formation dial-connect', async () => node.dialProtocol(addr, protocolId));
373
+ const stream = rawStream as unknown as ControlStream;
374
+ try {
375
+ const reader = new FrameReader(stream);
376
+ writeFrame(stream, options.contact);
377
+
378
+ const response = await withTimeout(stepTimeoutMs, 'Formation await-response', () => reader.read<FormationResultMessage>());
379
+ if (!response.approved) {
380
+ throw new Error(`Formation rejected: ${response.reason ?? 'no reason provided'}`);
381
+ }
382
+ const ok = await options.validateResponse(response);
383
+ if (!ok) throw new Error('Responder result failed validation');
384
+
385
+ if (!response.provisionResult) throw new Error('Missing provision result for responderCreates mode');
386
+ return response.provisionResult;
387
+ } finally {
388
+ try { await stream.close(); } catch { /* ignore */ }
389
+ }
390
+ });
391
+ }