@serfab/cadre-core 1.14.0 → 1.15.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 +293 -274
- package/dist/cadre-invite-protocol.d.ts +64 -29
- package/dist/cadre-invite-protocol.js +207 -38
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +42 -0
- package/dist/cadre-join-request.js +144 -0
- package/dist/cadre-join-request.js.map +1 -0
- package/dist/cadre-node.d.ts +375 -79
- package/dist/cadre-node.js +758 -186
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +92 -12
- package/dist/control-database.js +323 -142
- package/dist/control-database.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +19 -3
- package/dist/control-schema.js.map +1 -1
- package/dist/ed25519-key.d.ts +7 -9
- package/dist/ed25519-key.js +20 -10
- package/dist/ed25519-key.js.map +1 -1
- package/dist/fetch-impl.d.ts +12 -0
- package/dist/fetch-impl.js +23 -0
- package/dist/fetch-impl.js.map +1 -0
- package/dist/file-durable-slot.js +2 -1
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/file-safe-name.d.ts +25 -0
- package/dist/file-safe-name.js +29 -0
- package/dist/file-safe-name.js.map +1 -0
- package/dist/formation-approval.js +5 -11
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +0 -19
- package/dist/fs-atomic.js +3 -24
- package/dist/fs-atomic.js.map +1 -1
- package/dist/index.d.ts +12 -5
- package/dist/index.js +31 -6
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +50 -12
- package/dist/invitation-bootstrap.js +76 -13
- package/dist/invitation-bootstrap.js.map +1 -1
- package/dist/joined-strand-store.d.ts +7 -0
- package/dist/joined-strand-store.js +9 -0
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store-file.js +2 -1
- package/dist/key-store-file.js.map +1 -1
- package/dist/link-budget.d.ts +9 -4
- package/dist/link-budget.js +9 -4
- package/dist/link-budget.js.map +1 -1
- package/dist/machine-removal.d.ts +78 -0
- package/dist/machine-removal.js +166 -0
- package/dist/machine-removal.js.map +1 -0
- package/dist/membership-connection-gater.d.ts +6 -5
- package/dist/membership-connection-gater.js +6 -5
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +55 -0
- package/dist/node-claim-payload.js +108 -4
- package/dist/node-claim-payload.js.map +1 -1
- package/dist/node-default-transports.d.ts +27 -0
- package/dist/node-default-transports.js +35 -0
- package/dist/node-default-transports.js.map +1 -0
- package/dist/node-transports.d.ts +30 -0
- package/dist/node-transports.js +40 -0
- package/dist/node-transports.js.map +1 -0
- package/dist/peer-dial.d.ts +124 -27
- package/dist/peer-dial.js +254 -30
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.js +6 -0
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +6 -45
- package/dist/peer-record.js +8 -50
- package/dist/peer-record.js.map +1 -1
- package/dist/primary-lan-address.d.ts +8 -0
- package/dist/primary-lan-address.js +46 -0
- package/dist/primary-lan-address.js.map +1 -0
- package/dist/provider/client.d.ts +131 -0
- package/dist/provider/client.js +226 -0
- package/dist/provider/client.js.map +1 -0
- package/dist/provider/descriptor.d.ts +41 -0
- package/dist/provider/descriptor.js +103 -0
- package/dist/provider/descriptor.js.map +1 -0
- package/dist/provider/index.d.ts +9 -0
- package/dist/provider/index.js +10 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/provider/oauth.d.ts +125 -0
- package/dist/provider/oauth.js +199 -0
- package/dist/provider/oauth.js.map +1 -0
- package/dist/provider/rent-node.d.ts +101 -0
- package/dist/provider/rent-node.js +220 -0
- package/dist/provider/rent-node.js.map +1 -0
- package/dist/relay-addrs.d.ts +9 -4
- package/dist/relay-addrs.js +9 -4
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +51 -16
- package/dist/relay-reservation.js +107 -24
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +22 -5
- package/dist/seed-bootstrap.js +48 -25
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/strand-commit-durability.d.ts +54 -0
- package/dist/strand-commit-durability.js +74 -0
- package/dist/strand-commit-durability.js.map +1 -0
- package/dist/strand-database.d.ts +23 -8
- package/dist/strand-database.js +41 -13
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +7 -1
- package/dist/strand-first-sync-gate.js +7 -1
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-protocol.js +4 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-fret-addrs.d.ts +19 -1
- package/dist/strand-fret-addrs.js +27 -9
- package/dist/strand-fret-addrs.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +81 -19
- package/dist/strand-instance-manager.js +216 -105
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-move.d.ts +124 -0
- package/dist/strand-move.js +438 -0
- package/dist/strand-move.js.map +1 -0
- package/dist/strand-network-config.js +7 -6
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-peer-redial.d.ts +113 -0
- package/dist/strand-peer-redial.js +236 -0
- package/dist/strand-peer-redial.js.map +1 -0
- package/dist/strand-wake-protocol.js +10 -7
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +212 -4
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -1
- package/package.json +18 -10
- package/src/cadre-invite-protocol.ts +233 -45
- package/src/cadre-join-request.ts +167 -0
- package/src/cadre-node.ts +10003 -9381
- package/src/control-database.ts +4663 -4451
- package/src/control-schema.ts +19 -3
- package/src/ed25519-key.ts +20 -10
- package/src/fetch-impl.ts +23 -0
- package/src/file-durable-slot.ts +64 -63
- package/src/file-safe-name.ts +32 -0
- package/src/formation-approval.ts +604 -610
- package/src/fs-atomic.ts +92 -117
- package/src/index.ts +789 -722
- package/src/invitation-bootstrap.ts +91 -13
- package/src/joined-strand-store.ts +508 -498
- package/src/key-store-file.ts +142 -141
- package/src/link-budget.ts +9 -4
- package/src/machine-removal.ts +216 -0
- package/src/membership-connection-gater.ts +638 -637
- package/src/node-claim-payload.ts +138 -4
- package/src/node-default-transports.ts +38 -0
- package/src/node-transports.ts +49 -0
- package/src/peer-dial.ts +604 -336
- package/src/peer-join-backfill.ts +6 -0
- package/src/peer-record.ts +159 -202
- package/src/primary-lan-address.ts +47 -0
- package/src/provider/client.ts +326 -0
- package/src/provider/descriptor.ts +129 -0
- package/src/provider/index.ts +59 -0
- package/src/provider/oauth.ts +285 -0
- package/src/provider/rent-node.ts +275 -0
- package/src/relay-addrs.ts +9 -4
- package/src/relay-reservation.ts +1211 -1121
- package/src/seed-bootstrap.ts +1502 -1471
- package/src/strand-commit-durability.ts +116 -0
- package/src/strand-database.ts +46 -14
- package/src/strand-first-sync-gate.ts +7 -1
- package/src/strand-formation-protocol.ts +1039 -1036
- package/src/strand-fret-addrs.ts +179 -145
- package/src/strand-instance-manager.ts +2026 -1883
- package/src/strand-move.ts +585 -0
- package/src/strand-network-config.ts +7 -6
- package/src/strand-peer-redial.ts +307 -0
- package/src/strand-wake-protocol.ts +10 -7
- package/src/types.ts +220 -4
|
@@ -1,610 +1,604 @@
|
|
|
1
|
-
import debug from 'debug';
|
|
2
|
-
import {
|
|
3
|
-
concat as uint8ArrayConcat,
|
|
4
|
-
fromString as uint8ArrayFromString,
|
|
5
|
-
toString as uint8ArrayToString
|
|
6
|
-
} from 'uint8arrays';
|
|
7
|
-
import { sign, verify } from '@optimystic/quereus-plugin-crypto';
|
|
8
|
-
import { formationVouchMessage } from './control-database.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
token
|
|
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
|
-
* {@link
|
|
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
|
-
|
|
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
|
-
* @param
|
|
161
|
-
* @param
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
'
|
|
173
|
-
'
|
|
174
|
-
'base64url'
|
|
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
|
-
approval.
|
|
202
|
-
|
|
203
|
-
'
|
|
204
|
-
'
|
|
205
|
-
'base64url'
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (response.
|
|
395
|
-
throw new FormationApprovalError(
|
|
396
|
-
'
|
|
397
|
-
`Approval hook ${origin}
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
} finally {
|
|
606
|
-
budget.dispose();
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
};
|
|
610
|
-
}
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import {
|
|
3
|
+
concat as uint8ArrayConcat,
|
|
4
|
+
fromString as uint8ArrayFromString,
|
|
5
|
+
toString as uint8ArrayToString
|
|
6
|
+
} from 'uint8arrays';
|
|
7
|
+
import { sign, verify } from '@optimystic/quereus-plugin-crypto';
|
|
8
|
+
import { formationVouchMessage } from './control-database.js';
|
|
9
|
+
import { resolveFetch as resolveFetchImpl } from './fetch-impl.js';
|
|
10
|
+
|
|
11
|
+
const log = debug('sereus:cadre:formation-approval');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Abort an unanswered approval request after this long when no `timeoutMs` is supplied.
|
|
15
|
+
* Exported because the formation responder's provisioning budget has to contain it:
|
|
16
|
+
* `formationDeadlines` (`strand-formation-deadlines.ts`) adds it as a flat term, since an HTTP
|
|
17
|
+
* call to the hook does not cross the link between the two parties' machines.
|
|
18
|
+
*/
|
|
19
|
+
// eslint-disable-next-line no-restricted-syntax -- link-independent: an outbound HTTP call to the approval hook, not the libp2p link between machines
|
|
20
|
+
export const DEFAULT_APPROVAL_TIMEOUT_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Largest approval response body this client will read (64 KiB). An approval is two short
|
|
24
|
+
* base64url strings; anything approaching this is a broken or hostile hook, and reading it
|
|
25
|
+
* unbounded would let that hook stream a redeeming node out of memory mid-formation.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Everything ONE approval is bound to — the five fields inside the approver's signed digest
|
|
31
|
+
* (see `formationVouchMessage`), plus the hook to ask.
|
|
32
|
+
*
|
|
33
|
+
* Every field is fixed BEFORE the hook is contacted: the JOINER mints the nonce (and signs its
|
|
34
|
+
* own consent over it) before its contact message is sent, and the redeeming node knows the
|
|
35
|
+
* strand, the joiner's key, and the disclosure text it is about to write. That ordering is
|
|
36
|
+
* what makes the resulting signature usable — an approval signed over a nonce the node did not
|
|
37
|
+
* insert fails `FormationUsage.Authorized` at commit.
|
|
38
|
+
*/
|
|
39
|
+
export interface FormationApprovalRequest {
|
|
40
|
+
/** Invitation token being redeemed. */
|
|
41
|
+
token: string;
|
|
42
|
+
/** Single-use nonce for THIS redemption, already minted by the JOINING peer. */
|
|
43
|
+
usageStampId: string;
|
|
44
|
+
/** The strand (network) being joined. */
|
|
45
|
+
strandId: string;
|
|
46
|
+
/** The joining peer's own ed25519 public key (written to `FormationUsage.PeerKey`). */
|
|
47
|
+
peerKey: string;
|
|
48
|
+
/**
|
|
49
|
+
* The EXACT text that will be written to `FormationUsage.Disclosure`. The approver signs
|
|
50
|
+
* these bytes verbatim and MUST NOT re-serialize them — the redeeming node computes this
|
|
51
|
+
* string once and uses the identical string for both the signature and the insert.
|
|
52
|
+
*/
|
|
53
|
+
disclosure: string;
|
|
54
|
+
/**
|
|
55
|
+
* The invite's `ValidationUrl` (the hook to contact). NOT part of the signed digest: it says
|
|
56
|
+
* where to ask, not what is being approved.
|
|
57
|
+
*/
|
|
58
|
+
validationUrl: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The five signed fields on their own — what `formationVouchMessage` consumes, and the exact
|
|
63
|
+
* JSON body posted to the hook. Derived from {@link FormationApprovalRequest} by subtraction so
|
|
64
|
+
* a field added to the request must be consciously routed here (or the build breaks) rather
|
|
65
|
+
* than silently dropping out of the digest.
|
|
66
|
+
*
|
|
67
|
+
* This is what an APPROVER receives: a hook is posted these five fields and nothing else, so it
|
|
68
|
+
* has no `validationUrl` to supply and should type its request body as this, not as the full
|
|
69
|
+
* {@link FormationApprovalRequest}.
|
|
70
|
+
*/
|
|
71
|
+
export type FormationVouchFields = Omit<FormationApprovalRequest, 'validationUrl'>;
|
|
72
|
+
|
|
73
|
+
/** An approver's answer: which key vouched, and its signature over the request's digest. */
|
|
74
|
+
export interface FormationApproval {
|
|
75
|
+
/** Public key the approval claims; must match an enrolled `ValidationKey` row. */
|
|
76
|
+
validationKey: string;
|
|
77
|
+
/** base64url ed25519 signature over `formationVouchMessage(request)`. */
|
|
78
|
+
validationSignature: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Something that can obtain an approval for a redemption. The HTTP hook
|
|
83
|
+
* ({@link createHttpFormationApprover}) is the production implementation; tests and embedded
|
|
84
|
+
* deployments can supply their own.
|
|
85
|
+
*
|
|
86
|
+
* Rejects with a {@link FormationApprovalError} — never resolves to a "no". A caller that
|
|
87
|
+
* receives a resolved {@link FormationApproval} may still find it does not verify; use
|
|
88
|
+
* {@link verifyFormationApproval} before writing.
|
|
89
|
+
*/
|
|
90
|
+
export interface FormationApprover {
|
|
91
|
+
/**
|
|
92
|
+
* `signal` is the caller's cancellation (the formation responder's work budget expiring):
|
|
93
|
+
* a pre-aborted or mid-flight abort rejects with an `unavailable`
|
|
94
|
+
* {@link FormationApprovalError} without (further) contacting the hook. Optional so
|
|
95
|
+
* signal-unaware implementations stay assignable.
|
|
96
|
+
*/
|
|
97
|
+
requestApproval(request: FormationApprovalRequest, signal?: AbortSignal): Promise<FormationApproval>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Why an approval could not be obtained. The cases route differently: `refused` is a
|
|
102
|
+
* final answer, `unavailable` may succeed on a later attempt, and `malformed` /
|
|
103
|
+
* `unenrolled` / `misconfigured` are operator errors that retrying never fixes.
|
|
104
|
+
*
|
|
105
|
+
* `unenrolled` is thrown by the redeeming node's LOCAL enrollment pre-check (the recorder
|
|
106
|
+
* asking its control database whether the approval's key is an enrolled `ValidationKey`
|
|
107
|
+
* row), never by the HTTP client — a hook cannot know or report it.
|
|
108
|
+
*/
|
|
109
|
+
export type FormationApprovalFailure =
|
|
110
|
+
/** The hook answered, and the answer is no. */
|
|
111
|
+
| 'refused'
|
|
112
|
+
/** Could not get an answer (network error, timeout, non-2xx status, redirect). */
|
|
113
|
+
| 'unavailable'
|
|
114
|
+
/** Got an answer that is not a usable approval (not JSON, missing/blank fields, oversized). */
|
|
115
|
+
| 'malformed'
|
|
116
|
+
/** The approval's `validationKey` is not an enrolled `ValidationKey` row (local pre-check). */
|
|
117
|
+
| 'unenrolled'
|
|
118
|
+
/** The `ValidationUrl` or the runtime is unusable (bad scheme, no `fetch`). */
|
|
119
|
+
| 'misconfigured';
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Failure to obtain an approval, carrying the {@link FormationApprovalFailure} category the
|
|
123
|
+
* caller switches on to pick the rejection reason a would-be joiner is told.
|
|
124
|
+
*/
|
|
125
|
+
export class FormationApprovalError extends Error {
|
|
126
|
+
constructor(
|
|
127
|
+
readonly failure: FormationApprovalFailure,
|
|
128
|
+
message: string,
|
|
129
|
+
options?: { cause?: unknown }
|
|
130
|
+
) {
|
|
131
|
+
super(message, options);
|
|
132
|
+
this.name = 'FormationApprovalError';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The five fields the approver signs, lifted out of the request. Written as an explicit
|
|
138
|
+
* destructure (not a spread-and-delete) so the object posted to the hook can never pick up a
|
|
139
|
+
* field the digest does not cover — in particular the `validationUrl`.
|
|
140
|
+
*/
|
|
141
|
+
function vouchFields(request: FormationApprovalRequest): FormationVouchFields {
|
|
142
|
+
const { token, usageStampId, strandId, peerKey, disclosure } = request;
|
|
143
|
+
return { token, usageStampId, strandId, peerKey, disclosure };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Approver side: produce the approval for a request. Used by tests, and by anyone writing a
|
|
148
|
+
* hook in TypeScript.
|
|
149
|
+
*
|
|
150
|
+
* `privateKeyB64`'s public half must be the key enrolled as the `ValidationKey` row the
|
|
151
|
+
* redeeming node's control database will verify against — pass that same public key as
|
|
152
|
+
* `validationKey` (derive it with `ed25519PublicKeyFromPrivate` if you only hold the seed).
|
|
153
|
+
* Signing with a key that is not enrolled produces a perfectly valid signature that the
|
|
154
|
+
* database still rejects.
|
|
155
|
+
*
|
|
156
|
+
* Takes the five signed fields, not a full {@link FormationApprovalRequest}: an approver is
|
|
157
|
+
* posted only those five and has no `validationUrl` to hand back. A redeeming node holding a
|
|
158
|
+
* whole request can still pass it straight in.
|
|
159
|
+
*
|
|
160
|
+
* @param fields - The redemption being approved (the five signed fields).
|
|
161
|
+
* @param validationKey - base64url ed25519 public key that this approval claims.
|
|
162
|
+
* @param privateKeyB64 - base64url 32-byte ed25519 seed to sign with.
|
|
163
|
+
*/
|
|
164
|
+
export function signFormationApproval(
|
|
165
|
+
fields: FormationVouchFields,
|
|
166
|
+
validationKey: string,
|
|
167
|
+
privateKeyB64: string
|
|
168
|
+
): FormationApproval {
|
|
169
|
+
const validationSignature = sign(
|
|
170
|
+
formationVouchMessage(fields),
|
|
171
|
+
privateKeyB64,
|
|
172
|
+
'ed25519',
|
|
173
|
+
'bytes',
|
|
174
|
+
'base64url',
|
|
175
|
+
'base64url'
|
|
176
|
+
) as string;
|
|
177
|
+
return { validationKey, validationSignature };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Redeeming side: does this approval actually verify against the bytes we are about to write?
|
|
182
|
+
*
|
|
183
|
+
* A LOCAL PRE-CHECK, not the authority. It turns a bad approval into a legible rejection
|
|
184
|
+
* instead of an opaque `CHECK constraint failed: Authorized` at commit, and it catches the
|
|
185
|
+
* ordinary drift case (the hook signed a different nonce/strand/peer than the one we hold).
|
|
186
|
+
* It cannot substitute for the database check: this verifies against the key the approval
|
|
187
|
+
* itself names, whereas `FormationUsage.Authorized` verifies against the STORED
|
|
188
|
+
* `ValidationKey` row — which is what stops a redeemer approving itself with a key of its own
|
|
189
|
+
* choosing.
|
|
190
|
+
*
|
|
191
|
+
* Returns a boolean and never throws (same contract as `verifyPeerAuthorization`): malformed
|
|
192
|
+
* base64url, a garbage key, or any crypto failure resolves to `false`, logged at debug.
|
|
193
|
+
*/
|
|
194
|
+
export function verifyFormationApproval(
|
|
195
|
+
fields: FormationVouchFields,
|
|
196
|
+
approval: FormationApproval
|
|
197
|
+
): boolean {
|
|
198
|
+
try {
|
|
199
|
+
return verify(
|
|
200
|
+
formationVouchMessage(fields),
|
|
201
|
+
approval.validationSignature,
|
|
202
|
+
approval.validationKey,
|
|
203
|
+
'ed25519',
|
|
204
|
+
'bytes',
|
|
205
|
+
'base64url',
|
|
206
|
+
'base64url'
|
|
207
|
+
);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
log('verifyFormationApproval failed: %o', error);
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve and validate the hook URL before anything is sent.
|
|
216
|
+
*
|
|
217
|
+
* `http:` stays permitted — a self-hosted hook on a LAN is a real deployment — but note that
|
|
218
|
+
* it puts the disclosure text on the wire in clear, and lets anyone on the path see who is
|
|
219
|
+
* joining which strand. Prefer `https:` for a hook reachable off-link.
|
|
220
|
+
*/
|
|
221
|
+
function parseHookUrl(validationUrl: string): URL {
|
|
222
|
+
let url: URL;
|
|
223
|
+
try {
|
|
224
|
+
url = new URL(validationUrl);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
throw new FormationApprovalError(
|
|
227
|
+
'misconfigured',
|
|
228
|
+
`ValidationUrl is not a valid URL: ${validationUrl}`,
|
|
229
|
+
{ cause: error }
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
233
|
+
throw new FormationApprovalError(
|
|
234
|
+
'misconfigured',
|
|
235
|
+
`ValidationUrl scheme "${url.protocol}" is not supported; use http: or https:`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return url;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The `fetch` this approver will call (`fetch-impl.ts`); none at all is a misconfiguration. */
|
|
242
|
+
function resolveFetch(fetchImpl?: typeof fetch): typeof fetch {
|
|
243
|
+
const resolved = resolveFetchImpl(fetchImpl);
|
|
244
|
+
if (!resolved) {
|
|
245
|
+
throw new FormationApprovalError(
|
|
246
|
+
'misconfigured',
|
|
247
|
+
'No fetch implementation: globalThis.fetch is missing and no fetchImpl was supplied'
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return resolved;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Read the response body as text, refusing anything over {@link MAX_RESPONSE_BYTES}.
|
|
255
|
+
*
|
|
256
|
+
* Two paths, because `Response.body` is not universally a stream (React Native's fetch has no
|
|
257
|
+
* `body` reader): stream and stop at the cap where possible, otherwise read to completion and
|
|
258
|
+
* measure. The declared `content-length` is checked first so an honest oversized response is
|
|
259
|
+
* rejected without reading it at all — but it is only a hint, so the actual bytes are counted
|
|
260
|
+
* either way.
|
|
261
|
+
*
|
|
262
|
+
* NOTE: on the no-reader path the cap is enforced only AFTER the runtime has buffered the whole
|
|
263
|
+
* body, so a hook that under-declares its `content-length` can still make that runtime hold an
|
|
264
|
+
* arbitrary amount. Harmless where it applies today (React Native clients are not formation
|
|
265
|
+
* responders); if a responder ever runs on a platform without a body reader, that path needs a
|
|
266
|
+
* real streaming read rather than a post-hoc measurement.
|
|
267
|
+
*/
|
|
268
|
+
async function readCappedText(response: Response, budget: ApprovalBudget): Promise<string> {
|
|
269
|
+
const declared = Number(response.headers.get('content-length'));
|
|
270
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
271
|
+
throw new FormationApprovalError(
|
|
272
|
+
'malformed',
|
|
273
|
+
`Approval response declares ${declared} bytes, over the ${MAX_RESPONSE_BYTES}-byte cap`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const body = response.body;
|
|
278
|
+
if (!body || typeof body.getReader !== 'function') {
|
|
279
|
+
// NOTE: nothing releases this body when the budget wins — `text()` has already locked it and
|
|
280
|
+
// the runtime owns the buffering. It only applies to readerless-fetch runtimes (React
|
|
281
|
+
// Native), which are not formation responders; a responder on such a platform would need a
|
|
282
|
+
// real streaming read here, as the cap note above already says.
|
|
283
|
+
const text = await budget.race(response.text());
|
|
284
|
+
assertUnderCap(uint8ArrayFromString(text, 'utf8').byteLength);
|
|
285
|
+
return text;
|
|
286
|
+
}
|
|
287
|
+
return readCappedStream(body.getReader(), budget);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Drain a body stream, aborting the read the moment the accumulated bytes pass the cap. */
|
|
291
|
+
async function readCappedStream(
|
|
292
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
293
|
+
budget: ApprovalBudget
|
|
294
|
+
): Promise<string> {
|
|
295
|
+
const chunks: Uint8Array[] = [];
|
|
296
|
+
let total = 0;
|
|
297
|
+
try {
|
|
298
|
+
for (;;) {
|
|
299
|
+
// Raced, not just aborted: a `fetch` that drops the abort leaves this read pending
|
|
300
|
+
// forever, and the budget is the only thing that ends it. The `finally` below cancels
|
|
301
|
+
// the reader, which is what actually gives the connection back.
|
|
302
|
+
const { done, value } = await budget.race(reader.read());
|
|
303
|
+
if (done) {
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
total += value.byteLength;
|
|
307
|
+
assertUnderCap(total);
|
|
308
|
+
chunks.push(value);
|
|
309
|
+
}
|
|
310
|
+
} finally {
|
|
311
|
+
// Releases the connection when we bailed early; a no-op on a fully-drained stream.
|
|
312
|
+
void reader.cancel().catch((error: unknown) => log('response cancel failed: %o', error));
|
|
313
|
+
}
|
|
314
|
+
return uint8ArrayToString(uint8ArrayConcat(chunks, total), 'utf8');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Release a body we are never going to read, so the runtime can put the connection back in its
|
|
319
|
+
* pool. Every failure decided from the status line (a refusal, a 5xx, a redirect) and the
|
|
320
|
+
* declared-oversize rejection throw before touching the body, and an undrained body keeps a
|
|
321
|
+
* socket checked out until GC — a node that asks a flapping hook once per redemption would
|
|
322
|
+
* accumulate them. Skips a body already locked by {@link readCappedStream}, which cancels its
|
|
323
|
+
* own reader.
|
|
324
|
+
*
|
|
325
|
+
* NOTE: awaited by `readApproval`, so it sits OUTSIDE the budget — a runtime whose `cancel()`
|
|
326
|
+
* never settles would hold the caller past `timeoutMs` even though the deadline already fired.
|
|
327
|
+
* Every runtime this ships on settles it promptly. If a request is ever seen outliving its budget
|
|
328
|
+
* despite the budget firing, stop awaiting this; the two tests that assert the body is released
|
|
329
|
+
* by the time the rejection surfaces would then have to poll instead.
|
|
330
|
+
*/
|
|
331
|
+
async function discardBody(response: Response): Promise<void> {
|
|
332
|
+
const body = response.body;
|
|
333
|
+
if (!body || body.locked) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
await body.cancel();
|
|
338
|
+
} catch (error) {
|
|
339
|
+
log('discarding unread response body failed: %o', error);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function assertUnderCap(bytes: number): void {
|
|
344
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
345
|
+
throw new FormationApprovalError(
|
|
346
|
+
'malformed',
|
|
347
|
+
`Approval response exceeds the ${MAX_RESPONSE_BYTES}-byte cap`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** A blank (or whitespace-only) field is as unusable as a missing one — both are `malformed`. */
|
|
353
|
+
function requireField(body: Record<string, unknown>, field: string): string {
|
|
354
|
+
const value = body[field];
|
|
355
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
356
|
+
throw new FormationApprovalError(
|
|
357
|
+
'malformed',
|
|
358
|
+
`Approval response is missing a usable "${field}"`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Parse a 2xx body into an approval, rejecting anything that is not the documented shape. */
|
|
365
|
+
function parseApproval(text: string): FormationApproval {
|
|
366
|
+
let parsed: unknown;
|
|
367
|
+
try {
|
|
368
|
+
parsed = JSON.parse(text);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
throw new FormationApprovalError('malformed', 'Approval response is not JSON', { cause: error });
|
|
371
|
+
}
|
|
372
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
373
|
+
throw new FormationApprovalError('malformed', 'Approval response is not a JSON object');
|
|
374
|
+
}
|
|
375
|
+
const body = parsed as Record<string, unknown>;
|
|
376
|
+
return {
|
|
377
|
+
validationKey: requireField(body, 'validationKey'),
|
|
378
|
+
validationSignature: requireField(body, 'validationSignature')
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Map a response's STATUS — never its body shape — onto a failure. A `403` carrying a
|
|
384
|
+
* well-formed approval is still a refusal: the hook said no, and a redeeming node that read
|
|
385
|
+
* the body anyway would let a hook accidentally approve by echoing.
|
|
386
|
+
*/
|
|
387
|
+
function assertApprovingStatus(response: Response, origin: string): void {
|
|
388
|
+
if (response.status === 401 || response.status === 403) {
|
|
389
|
+
throw new FormationApprovalError(
|
|
390
|
+
'refused',
|
|
391
|
+
`Approval hook ${origin} refused this redemption (HTTP ${response.status})`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
if (!response.ok) {
|
|
395
|
+
throw new FormationApprovalError(
|
|
396
|
+
'unavailable',
|
|
397
|
+
`Approval hook ${origin} answered HTTP ${response.status}`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
// Belt and braces for runtimes that ignore `redirect: 'error'` rather than rejecting.
|
|
401
|
+
if (response.redirected) {
|
|
402
|
+
throw new FormationApprovalError(
|
|
403
|
+
'unavailable',
|
|
404
|
+
`Approval hook ${origin} redirected; re-publish the ValidationUrl instead of chasing it`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Turn a response into an approval, releasing the body on every path that rejects it.
|
|
411
|
+
*/
|
|
412
|
+
async function readApproval(
|
|
413
|
+
response: Response,
|
|
414
|
+
origin: string,
|
|
415
|
+
budget: ApprovalBudget
|
|
416
|
+
): Promise<FormationApproval> {
|
|
417
|
+
try {
|
|
418
|
+
assertApprovingStatus(response, origin);
|
|
419
|
+
return parseApproval(await readCappedText(response, budget));
|
|
420
|
+
} catch (error) {
|
|
421
|
+
await discardBody(response);
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* ONE deadline covering a whole approval exchange — connect, headers, AND body read — that fires
|
|
428
|
+
* whether or not the runtime's `fetch` honours an abort.
|
|
429
|
+
*
|
|
430
|
+
* Aborting is still done (it is what releases the socket where it works), but it cannot be the
|
|
431
|
+
* only bound: Node's `fetch` occasionally drops an abort that lands mid-body-read, leaving the
|
|
432
|
+
* read pending until undici's own 300s body timeout. Every await that can outlive the budget is
|
|
433
|
+
* raced against {@link ApprovalBudget.race} instead.
|
|
434
|
+
*/
|
|
435
|
+
interface ApprovalBudget {
|
|
436
|
+
/** `work`, or the budget's `unavailable` rejection — whichever settles first. */
|
|
437
|
+
race<T>(work: Promise<T>): Promise<T>;
|
|
438
|
+
/** True once the deadline has fired, i.e. any result arriving now is unwanted. */
|
|
439
|
+
expired(): boolean;
|
|
440
|
+
/** Clears the timer and the caller's abort listener. Must run on every exit path. */
|
|
441
|
+
dispose(): void;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Arm the budget for one request. The caller's `signal` is relayed by hand rather than with
|
|
446
|
+
* `AbortSignal.any` (not reliably present on React Native/Hermes; this client commits to
|
|
447
|
+
* `fetch` + `AbortController` only).
|
|
448
|
+
*/
|
|
449
|
+
function startBudget(
|
|
450
|
+
timeoutMs: number,
|
|
451
|
+
origin: string,
|
|
452
|
+
controller: AbortController,
|
|
453
|
+
signal?: AbortSignal
|
|
454
|
+
): ApprovalBudget {
|
|
455
|
+
let expired = false;
|
|
456
|
+
let expire!: (error: FormationApprovalError) => void;
|
|
457
|
+
const deadline = new Promise<never>((_resolve, reject) => { expire = reject; });
|
|
458
|
+
// The deadline can fire between two raced awaits, with nothing attached to it; a bare handler
|
|
459
|
+
// keeps that from surfacing as an unhandled rejection without hiding it from `race`.
|
|
460
|
+
void deadline.catch(() => { /* observed by race() */ });
|
|
461
|
+
|
|
462
|
+
const fire = (reason: string): void => {
|
|
463
|
+
if (expired) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
expired = true;
|
|
467
|
+
// Rejected BEFORE the abort, so this reason — not whatever a fetch that DOES honour the
|
|
468
|
+
// abort rejects with — is what the race settles on. Both are queued in the same turn, and
|
|
469
|
+
// the first one queued wins.
|
|
470
|
+
expire(new FormationApprovalError('unavailable', reason));
|
|
471
|
+
// Best-effort, and no longer load-bearing: where the runtime honours it, this hands the
|
|
472
|
+
// socket back instead of leaving it checked out until GC.
|
|
473
|
+
controller.abort();
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const timer = setTimeout(
|
|
477
|
+
() => fire(`Approval hook ${origin} did not answer within ${timeoutMs}ms`),
|
|
478
|
+
timeoutMs
|
|
479
|
+
);
|
|
480
|
+
const onCallerAbort = (): void =>
|
|
481
|
+
fire(`Formation was cancelled while approval hook ${origin} was being asked`);
|
|
482
|
+
signal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
race: <T>(work: Promise<T>): Promise<T> => Promise.race([work, deadline]),
|
|
486
|
+
expired: () => expired,
|
|
487
|
+
dispose: (): void => {
|
|
488
|
+
// A leaked abort timer keeps a node's event loop alive.
|
|
489
|
+
clearTimeout(timer);
|
|
490
|
+
signal?.removeEventListener('abort', onCallerAbort);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Await the response, giving up when the budget expires — and cleaning up after ourselves if it
|
|
497
|
+
* does: a `fetch` that ignored the abort still delivers its response eventually, and an unread
|
|
498
|
+
* body keeps a connection checked out. A late rejection is logged rather than left unhandled.
|
|
499
|
+
*/
|
|
500
|
+
async function fetchWithinBudget(
|
|
501
|
+
pending: Promise<Response>,
|
|
502
|
+
budget: ApprovalBudget
|
|
503
|
+
): Promise<Response> {
|
|
504
|
+
void pending.then(
|
|
505
|
+
(response) => {
|
|
506
|
+
if (budget.expired()) {
|
|
507
|
+
void discardBody(response);
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
(error: unknown) => {
|
|
511
|
+
if (budget.expired()) {
|
|
512
|
+
log('abandoned approval request rejected after its budget expired: %o', error);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
);
|
|
516
|
+
return budget.race(pending);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Ask an outside approval service, over HTTP, whether a would-be joiner may redeem an
|
|
521
|
+
* invitation. Contacted by the INVITING party's node (the formation responder) during
|
|
522
|
+
* redemption — never by the joiner, which neither mints the nonce nor performs the write.
|
|
523
|
+
*
|
|
524
|
+
* Wire contract (documented for hook operators in `docs/api.md`):
|
|
525
|
+
*
|
|
526
|
+
* - `POST <ValidationUrl>`, `content-type: application/json`, `accept: application/json`.
|
|
527
|
+
* - Body is exactly the five signed fields — `{ token, usageStampId, strandId, peerKey,
|
|
528
|
+
* disclosure }` — and nothing else. No owner keys, no bootstrap addresses, no membership
|
|
529
|
+
* keys ever reach the hook.
|
|
530
|
+
* - `200` with `{ "validationKey": "...", "validationSignature": "..." }` is an approval.
|
|
531
|
+
* - `401` / `403` is a refusal; any other non-2xx, a network error, a timeout, or a redirect
|
|
532
|
+
* is `unavailable`; a 2xx that is not a usable approval is `malformed`.
|
|
533
|
+
*
|
|
534
|
+
* Cross-platform by construction: global `fetch` + `AbortController` only, no `node:` imports.
|
|
535
|
+
*/
|
|
536
|
+
export function createHttpFormationApprover(options?: {
|
|
537
|
+
/**
|
|
538
|
+
* Abort the request after this long, INCLUDING the body read. Default 10s. Must stay under
|
|
539
|
+
* the responder's provisioning budget — a hook that stalls must not stall formation.
|
|
540
|
+
*/
|
|
541
|
+
timeoutMs?: number;
|
|
542
|
+
/** Injectable for tests / non-standard runtimes. Defaults to `globalThis.fetch`. */
|
|
543
|
+
fetchImpl?: typeof fetch;
|
|
544
|
+
}): FormationApprover {
|
|
545
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
546
|
+
// A non-positive or non-finite budget would abort every request before it left, turning a
|
|
547
|
+
// config typo into a hook that appears permanently down. Fail where the mistake was made.
|
|
548
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
549
|
+
throw new FormationApprovalError(
|
|
550
|
+
'misconfigured',
|
|
551
|
+
`timeoutMs must be a positive number of milliseconds, got ${timeoutMs}`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
const fetchImpl = options?.fetchImpl;
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
async requestApproval(request: FormationApprovalRequest, signal?: AbortSignal): Promise<FormationApproval> {
|
|
558
|
+
const doFetch = resolveFetch(fetchImpl);
|
|
559
|
+
const url = parseHookUrl(request.validationUrl);
|
|
560
|
+
// Origin only in messages: a ValidationUrl's path/query may carry a hook secret, and
|
|
561
|
+
// these strings reach logs and the joiner's rejection reason.
|
|
562
|
+
const origin = url.origin;
|
|
563
|
+
|
|
564
|
+
if (signal?.aborted) {
|
|
565
|
+
throw new FormationApprovalError(
|
|
566
|
+
'unavailable',
|
|
567
|
+
`Formation was cancelled before approval hook ${origin} was asked`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const controller = new AbortController();
|
|
572
|
+
const budget = startBudget(timeoutMs, origin, controller, signal);
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
const response = await fetchWithinBudget(
|
|
576
|
+
doFetch(url.toString(), {
|
|
577
|
+
method: 'POST',
|
|
578
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
579
|
+
body: JSON.stringify(vouchFields(request)),
|
|
580
|
+
redirect: 'error',
|
|
581
|
+
signal: controller.signal
|
|
582
|
+
}),
|
|
583
|
+
budget
|
|
584
|
+
);
|
|
585
|
+
const approval = await readApproval(response, origin, budget);
|
|
586
|
+
log('approval obtained from %s for token %s', origin, request.token);
|
|
587
|
+
return approval;
|
|
588
|
+
} catch (error) {
|
|
589
|
+
// Includes the budget's own rejection, which already carries the timeout/cancellation
|
|
590
|
+
// wording; anything else got here without the budget firing, so the hook is unreachable.
|
|
591
|
+
if (error instanceof FormationApprovalError) {
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
throw new FormationApprovalError(
|
|
595
|
+
'unavailable',
|
|
596
|
+
`Approval hook ${origin} could not be reached`,
|
|
597
|
+
{ cause: error }
|
|
598
|
+
);
|
|
599
|
+
} finally {
|
|
600
|
+
budget.dispose();
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
}
|