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