@optimystic/db-core 0.25.0 → 0.26.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/dist/src/cohort-topic/membership/verifier.d.ts +8 -0
- package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
- package/dist/src/cohort-topic/membership/verifier.js +17 -0
- package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
- package/dist/src/cohort-topic/promotion.d.ts +25 -0
- package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
- package/dist/src/cohort-topic/promotion.js +58 -11
- package/dist/src/cohort-topic/promotion.js.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.d.ts +6 -0
- package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.js +6 -0
- package/dist/src/cohort-topic/wire/primitives.js.map +1 -1
- package/dist/src/cohort-topic/wire/types.d.ts +5 -0
- package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.js +10 -19
- package/dist/src/cohort-topic/wire/validate.js.map +1 -1
- package/dist/src/collection/collection.d.ts +8 -1
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +19 -3
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/matchmaking/wire.d.ts +2 -2
- package/dist/src/matchmaking/wire.d.ts.map +1 -1
- package/dist/src/matchmaking/wire.js +3 -3
- package/dist/src/matchmaking/wire.js.map +1 -1
- package/dist/src/network/struct.d.ts +5 -5
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/testing/index.d.ts +1 -0
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/testing/refresh-probe.d.ts +31 -0
- package/dist/src/testing/refresh-probe.d.ts.map +1 -0
- package/dist/src/testing/refresh-probe.js +39 -0
- package/dist/src/testing/refresh-probe.js.map +1 -0
- package/dist/src/testing/test-transactor.d.ts +58 -5
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +87 -4
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +90 -6
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +311 -90
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/errors.d.ts +42 -0
- package/dist/src/transaction/errors.d.ts.map +1 -1
- package/dist/src/transaction/errors.js +50 -0
- package/dist/src/transaction/errors.js.map +1 -1
- package/dist/src/transaction/index.d.ts +1 -1
- package/dist/src/transaction/index.d.ts.map +1 -1
- package/dist/src/transaction/index.js +1 -1
- package/dist/src/transaction/index.js.map +1 -1
- package/dist/src/transaction/session.d.ts +7 -3
- package/dist/src/transaction/session.d.ts.map +1 -1
- package/dist/src/transaction/session.js +7 -3
- package/dist/src/transaction/session.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +12 -2
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transaction/transaction.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +6 -26
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +4 -6
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +4 -6
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/atomic.d.ts.map +1 -1
- package/dist/src/transform/atomic.js +7 -0
- package/dist/src/transform/atomic.js.map +1 -1
- package/dist/src/transform/base-pins.d.ts +48 -0
- package/dist/src/transform/base-pins.d.ts.map +1 -0
- package/dist/src/transform/base-pins.js +65 -0
- package/dist/src/transform/base-pins.js.map +1 -0
- package/dist/src/transform/digest.d.ts +4 -2
- package/dist/src/transform/digest.d.ts.map +1 -1
- package/dist/src/transform/digest.js +14 -19
- package/dist/src/transform/digest.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +31 -3
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +105 -13
- package/dist/src/transform/tracker.js.map +1 -1
- package/package.json +1 -1
- package/src/cohort-topic/membership/verifier.ts +487 -461
- package/src/cohort-topic/promotion.ts +86 -14
- package/src/cohort-topic/wire/primitives.ts +195 -188
- package/src/cohort-topic/wire/types.ts +480 -475
- package/src/cohort-topic/wire/validate.ts +504 -512
- package/src/collection/collection.ts +19 -3
- package/src/matchmaking/wire.ts +605 -603
- package/src/network/struct.ts +5 -5
- package/src/testing/index.ts +1 -0
- package/src/testing/refresh-probe.ts +52 -0
- package/src/testing/test-transactor.ts +100 -4
- package/src/transaction/coordinator.ts +312 -99
- package/src/transaction/errors.ts +141 -91
- package/src/transaction/index.ts +1 -1
- package/src/transaction/session.ts +7 -3
- package/src/transaction/transaction.ts +12 -2
- package/src/transactor/network-transactor.ts +1025 -1045
- package/src/transactor/transactor-source.ts +170 -172
- package/src/transform/atomic.ts +6 -0
- package/src/transform/base-pins.ts +83 -0
- package/src/transform/digest.ts +14 -19
- package/src/transform/tracker.ts +108 -15
package/src/matchmaking/wire.ts
CHANGED
|
@@ -1,603 +1,605 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Matchmaking — V1 wire types, codecs, and per-message validation.
|
|
3
|
-
*
|
|
4
|
-
* Transcribed from `docs/matchmaking.md` §Wire formats. Matchmaking reuses the cohort-topic
|
|
5
|
-
* `RegisterV1` / `RenewV1` envelopes **unchanged**; the matchmaking-specific shape lives in two
|
|
6
|
-
* places:
|
|
7
|
-
*
|
|
8
|
-
* 1. **App payloads** ({@link ProviderAppPayloadV1} / {@link SeekerAppPayloadV1}) carried opaquely
|
|
9
|
-
* inside `RegisterV1.appPayload`. These are serialized to UTF-8 JSON bytes and handed to the
|
|
10
|
-
* cohort-topic {@link import("../cohort-topic/service.js").RegisterRequest}`.appPayload` slot,
|
|
11
|
-
* which base64url-encodes them on the wire. They are therefore **not** length-framed here — the
|
|
12
|
-
* cohort-topic `RegisterV1` frame wraps them.
|
|
13
|
-
*
|
|
14
|
-
* 2. **Query-protocol messages** ({@link QueryV1} / {@link QueryReplyV1} / {@link AggregateCountV1})
|
|
15
|
-
* sent as standalone RPCs over the matchmaking application protocol. These ride the same
|
|
16
|
-
* length-prefixed UTF-8 JSON framing as cohort-topic messages ({@link encodeCohortMessage}).
|
|
17
|
-
*
|
|
18
|
-
* Conventions (matching the cohort-topic wire conventions): all JSON, byte fields base64url (no
|
|
19
|
-
* padding), unix-millisecond timestamps, per-message structural validation on decode. Byte fidelity
|
|
20
|
-
* round-trips (encode→decode→encode is stable). The query-protocol producers (cohort-side reply,
|
|
21
|
-
* root-cohort aggregate sweep) land in later tickets; their decoders live here so the seeker side can
|
|
22
|
-
* be unit-tested against fixtures.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import {
|
|
26
|
-
bytesToB64url,
|
|
27
|
-
b64urlToBytes,
|
|
28
|
-
decodeCohortMessage,
|
|
29
|
-
encodeCohortMessage,
|
|
30
|
-
DEFAULT_MAX_MESSAGE_BYTES,
|
|
31
|
-
} from "../cohort-topic/wire/codec.js";
|
|
32
|
-
import {
|
|
33
|
-
asObject,
|
|
34
|
-
b64urlField,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
/** Tags that must
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
/**
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
/** base64url. */
|
|
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
|
-
}
|
|
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
|
-
const
|
|
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
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
const
|
|
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
|
-
reply.
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
reply.topicTraffic.
|
|
557
|
-
reply.topicTraffic.
|
|
558
|
-
reply.topicTraffic.
|
|
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
|
-
unsigned.
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Matchmaking — V1 wire types, codecs, and per-message validation.
|
|
3
|
+
*
|
|
4
|
+
* Transcribed from `docs/matchmaking.md` §Wire formats. Matchmaking reuses the cohort-topic
|
|
5
|
+
* `RegisterV1` / `RenewV1` envelopes **unchanged**; the matchmaking-specific shape lives in two
|
|
6
|
+
* places:
|
|
7
|
+
*
|
|
8
|
+
* 1. **App payloads** ({@link ProviderAppPayloadV1} / {@link SeekerAppPayloadV1}) carried opaquely
|
|
9
|
+
* inside `RegisterV1.appPayload`. These are serialized to UTF-8 JSON bytes and handed to the
|
|
10
|
+
* cohort-topic {@link import("../cohort-topic/service.js").RegisterRequest}`.appPayload` slot,
|
|
11
|
+
* which base64url-encodes them on the wire. They are therefore **not** length-framed here — the
|
|
12
|
+
* cohort-topic `RegisterV1` frame wraps them.
|
|
13
|
+
*
|
|
14
|
+
* 2. **Query-protocol messages** ({@link QueryV1} / {@link QueryReplyV1} / {@link AggregateCountV1})
|
|
15
|
+
* sent as standalone RPCs over the matchmaking application protocol. These ride the same
|
|
16
|
+
* length-prefixed UTF-8 JSON framing as cohort-topic messages ({@link encodeCohortMessage}).
|
|
17
|
+
*
|
|
18
|
+
* Conventions (matching the cohort-topic wire conventions): all JSON, byte fields base64url (no
|
|
19
|
+
* padding), unix-millisecond timestamps, per-message structural validation on decode. Byte fidelity
|
|
20
|
+
* round-trips (encode→decode→encode is stable). The query-protocol producers (cohort-side reply,
|
|
21
|
+
* root-cohort aggregate sweep) land in later tickets; their decoders live here so the seeker side can
|
|
22
|
+
* be unit-tested against fixtures.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
bytesToB64url,
|
|
27
|
+
b64urlToBytes,
|
|
28
|
+
decodeCohortMessage,
|
|
29
|
+
encodeCohortMessage,
|
|
30
|
+
DEFAULT_MAX_MESSAGE_BYTES,
|
|
31
|
+
} from "../cohort-topic/wire/codec.js";
|
|
32
|
+
import {
|
|
33
|
+
asObject,
|
|
34
|
+
b64urlField,
|
|
35
|
+
b64urlFixedLen,
|
|
36
|
+
COORD_BYTES,
|
|
37
|
+
failWire as fail,
|
|
38
|
+
optBool,
|
|
39
|
+
optFiniteNumber,
|
|
40
|
+
reqBool,
|
|
41
|
+
reqFiniteNumber,
|
|
42
|
+
reqIntInRange,
|
|
43
|
+
reqString,
|
|
44
|
+
reqStringArray,
|
|
45
|
+
requireV1,
|
|
46
|
+
} from "../cohort-topic/wire/primitives.js";
|
|
47
|
+
import type { TopicTrafficV1 } from "../cohort-topic/wire/types.js";
|
|
48
|
+
import { QUERY_LIMIT_MAX } from "./config.js";
|
|
49
|
+
|
|
50
|
+
// --- App-payload variants (carried inside cohort-topic RegisterV1.appPayload) ---
|
|
51
|
+
|
|
52
|
+
/** A provider's registration payload (`SubscribeAppPayloadV1.appPayload`, `kind == "match-provider"`). */
|
|
53
|
+
export interface ProviderAppPayloadV1 {
|
|
54
|
+
kind: "match-provider";
|
|
55
|
+
/** Application-defined attribute tags. */
|
|
56
|
+
capabilities: string[];
|
|
57
|
+
/** Concurrent tasks accepted; `0` == "listed but full". */
|
|
58
|
+
capacityBudget: number;
|
|
59
|
+
/** Unix ms, soft expiry hint to seekers. */
|
|
60
|
+
serviceUntil?: number;
|
|
61
|
+
/** Multiaddr or PeerId-based callback. */
|
|
62
|
+
contactHint: string;
|
|
63
|
+
/** base64url, over `(topicId, capabilities, capacityBudget)` — see {@link providerSigningPayload}. */
|
|
64
|
+
signature: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A seeker's registration payload (`SubscribeAppPayloadV1.appPayload`, `kind == "match-seeker"`). */
|
|
68
|
+
export interface SeekerAppPayloadV1 {
|
|
69
|
+
kind: "match-seeker";
|
|
70
|
+
/** Number of providers desired. */
|
|
71
|
+
wantCount: number;
|
|
72
|
+
/** Optional capability filter. */
|
|
73
|
+
filter?: CapabilityFilter;
|
|
74
|
+
/** For collective-assembly use. */
|
|
75
|
+
contactHint: string;
|
|
76
|
+
/** Opt into arrival pushes; default false (poll path). */
|
|
77
|
+
pushOnArrival?: boolean;
|
|
78
|
+
/** base64url. */
|
|
79
|
+
signature: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A capability filter, evaluated locally at the cohort (advisory — the seeker re-validates). */
|
|
83
|
+
export interface CapabilityFilter {
|
|
84
|
+
/** Tags that must all be present. */
|
|
85
|
+
must: string[];
|
|
86
|
+
/** Tags that must not be present. */
|
|
87
|
+
mustNot: string[];
|
|
88
|
+
/** Skip providers whose `capacityBudget` is below this. */
|
|
89
|
+
minBudget?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Query protocol (evaluation/sweep land in later tickets; codecs land here) ---
|
|
93
|
+
|
|
94
|
+
/** A seeker's query against a cohort for the providers/seekers it holds. */
|
|
95
|
+
export interface QueryV1 {
|
|
96
|
+
v: 1;
|
|
97
|
+
/** Topic id, base64url. */
|
|
98
|
+
topicId: string;
|
|
99
|
+
includeProviders: boolean;
|
|
100
|
+
includeSeekers: boolean;
|
|
101
|
+
filter?: CapabilityFilter;
|
|
102
|
+
/** `<= query_limit_max` (256). */
|
|
103
|
+
limit: number;
|
|
104
|
+
/** Requesting PeerId. */
|
|
105
|
+
requesterId: string;
|
|
106
|
+
/** Unix ms. */
|
|
107
|
+
timestamp: number;
|
|
108
|
+
/** base64url. */
|
|
109
|
+
signature: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A cohort's advisory reply to a {@link QueryV1} (signed by the cohort primary, not threshold). */
|
|
113
|
+
export interface QueryReplyV1 {
|
|
114
|
+
v: 1;
|
|
115
|
+
providers?: ProviderEntryV1[];
|
|
116
|
+
seekers?: SeekerEntryV1[];
|
|
117
|
+
truncated: boolean;
|
|
118
|
+
/** Cohort epoch the reply was computed under, 32 bytes, base64url. */
|
|
119
|
+
cohortEpoch: string;
|
|
120
|
+
/** From cohort-topic; consumed by the hang-out engine (next ticket). */
|
|
121
|
+
topicTraffic: TopicTrafficV1;
|
|
122
|
+
/** Cohort PRIMARY single-member signature (NOT threshold), base64url. */
|
|
123
|
+
signature: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A provider entry in a {@link QueryReplyV1}. */
|
|
127
|
+
export interface ProviderEntryV1 {
|
|
128
|
+
/** PeerId. */
|
|
129
|
+
participantId: string;
|
|
130
|
+
capabilities: string[];
|
|
131
|
+
capacityBudget: number;
|
|
132
|
+
contactHint: string;
|
|
133
|
+
/** Unix ms. */
|
|
134
|
+
attachedAt: number;
|
|
135
|
+
/** Provider's original signature, forwarded for seeker re-validation, base64url. */
|
|
136
|
+
registrationSig: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A seeker entry in a {@link QueryReplyV1} (collective-assembly discovery). */
|
|
140
|
+
export interface SeekerEntryV1 {
|
|
141
|
+
/** PeerId. */
|
|
142
|
+
participantId: string;
|
|
143
|
+
wantCount: number;
|
|
144
|
+
contactHint: string;
|
|
145
|
+
/** Unix ms. */
|
|
146
|
+
attachedAt: number;
|
|
147
|
+
/** base64url. */
|
|
148
|
+
registrationSig: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Root-cohort multi-cohort-sweep summary (`docs/matchmaking.md` §Aggregated provider counts).
|
|
153
|
+
*
|
|
154
|
+
* Unlike {@link QueryReplyV1}, this is **threshold-signed** — it attests a cohort-agreed *registered*
|
|
155
|
+
* provider count, not one primary's advisory view — so it carries the same `(thresholdSig, signers)`
|
|
156
|
+
* envelope the cohort-topic {@link import("../cohort-topic/wire/types.js").PromotionNoticeV1} uses:
|
|
157
|
+
* `signature` is the concatenated cohort multisig blob and `signers` is the distinct `>= minSigs` member
|
|
158
|
+
* subset that produced it. A verifier splits the blob by `signers` and checks the subset against the
|
|
159
|
+
* cohort membership certificate (db-p2p binds the crypto). `signers` is required precisely because a
|
|
160
|
+
* threshold blob is unverifiable without the signer set that aligns chunk `i` ↔ `signers[i]`.
|
|
161
|
+
*/
|
|
162
|
+
export interface AggregateCountV1 {
|
|
163
|
+
v: 1;
|
|
164
|
+
/** Topic id, base64url. */
|
|
165
|
+
topicId: string;
|
|
166
|
+
/** `count` is log-bucketed (see {@link logBucketCount}). */
|
|
167
|
+
bucketCounts: AggregateBucketV1[];
|
|
168
|
+
/** Cohort threshold-signature blob over {@link aggregateCountSigningPayload}, base64url. */
|
|
169
|
+
signature: string;
|
|
170
|
+
/** PeerIds of the `>= minSigs` threshold signers, base64url (aligns the `signature` blob). */
|
|
171
|
+
signers: string[];
|
|
172
|
+
/** Cohort epoch the counts were aggregated under, 32 bytes, base64url. */
|
|
173
|
+
cohortEpoch: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One bucketed shard count inside an {@link AggregateCountV1}. */
|
|
177
|
+
export interface AggregateBucketV1 {
|
|
178
|
+
targetTier: number;
|
|
179
|
+
prefixSlot: number;
|
|
180
|
+
/** Log-bucketed. */
|
|
181
|
+
count: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- matchmaking-local wire state (generic validation primitives live in cohort-topic/wire/primitives.js) ---
|
|
185
|
+
|
|
186
|
+
const utf8Encoder = new TextEncoder();
|
|
187
|
+
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
188
|
+
|
|
189
|
+
/** Ceiling for an opaque app payload (provider/seeker), guarding decode allocation. */
|
|
190
|
+
export const DEFAULT_MAX_APP_PAYLOAD_BYTES = 64 * 1024;
|
|
191
|
+
|
|
192
|
+
function validateCapabilityFilter(value: unknown, what: string): CapabilityFilter {
|
|
193
|
+
const obj = asObject(value, what);
|
|
194
|
+
const out: CapabilityFilter = {
|
|
195
|
+
must: reqStringArray(obj, "must", what),
|
|
196
|
+
mustNot: reqStringArray(obj, "mustNot", what),
|
|
197
|
+
};
|
|
198
|
+
const minBudget = optFiniteNumber(obj, "minBudget", what);
|
|
199
|
+
if (minBudget !== undefined) {
|
|
200
|
+
out.minBudget = reqIntInRange(obj, "minBudget", what, 0);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Provider / Seeker app payloads (opaque RegisterV1.appPayload bytes) ---
|
|
206
|
+
|
|
207
|
+
/** Narrow an already-parsed value to {@link ProviderAppPayloadV1}, throwing on any defect. */
|
|
208
|
+
export function validateProviderAppPayloadV1(value: unknown): ProviderAppPayloadV1 {
|
|
209
|
+
const what = "ProviderAppPayloadV1";
|
|
210
|
+
const obj = asObject(value, what);
|
|
211
|
+
if (obj["kind"] !== "match-provider") {
|
|
212
|
+
fail(`${what}: field "kind" must be exactly "match-provider"`);
|
|
213
|
+
}
|
|
214
|
+
const out: ProviderAppPayloadV1 = {
|
|
215
|
+
kind: "match-provider",
|
|
216
|
+
capabilities: reqStringArray(obj, "capabilities", what),
|
|
217
|
+
capacityBudget: reqIntInRange(obj, "capacityBudget", what, 0),
|
|
218
|
+
contactHint: reqString(obj, "contactHint", what),
|
|
219
|
+
signature: b64urlField(reqString(obj, "signature", what), "signature", what),
|
|
220
|
+
};
|
|
221
|
+
const serviceUntil = optFiniteNumber(obj, "serviceUntil", what);
|
|
222
|
+
if (serviceUntil !== undefined) {
|
|
223
|
+
out.serviceUntil = serviceUntil;
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Narrow an already-parsed value to {@link SeekerAppPayloadV1}, throwing on any defect. */
|
|
229
|
+
export function validateSeekerAppPayloadV1(value: unknown): SeekerAppPayloadV1 {
|
|
230
|
+
const what = "SeekerAppPayloadV1";
|
|
231
|
+
const obj = asObject(value, what);
|
|
232
|
+
if (obj["kind"] !== "match-seeker") {
|
|
233
|
+
fail(`${what}: field "kind" must be exactly "match-seeker"`);
|
|
234
|
+
}
|
|
235
|
+
const out: SeekerAppPayloadV1 = {
|
|
236
|
+
kind: "match-seeker",
|
|
237
|
+
wantCount: reqIntInRange(obj, "wantCount", what, 1),
|
|
238
|
+
contactHint: reqString(obj, "contactHint", what),
|
|
239
|
+
signature: b64urlField(reqString(obj, "signature", what), "signature", what),
|
|
240
|
+
};
|
|
241
|
+
if (obj["filter"] !== undefined) {
|
|
242
|
+
out.filter = validateCapabilityFilter(obj["filter"], `${what}.filter`);
|
|
243
|
+
}
|
|
244
|
+
const pushOnArrival = optBool(obj, "pushOnArrival", what);
|
|
245
|
+
if (pushOnArrival !== undefined) {
|
|
246
|
+
out.pushOnArrival = pushOnArrival;
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Serialize a {@link ProviderAppPayloadV1} to the opaque UTF-8 JSON bytes for `RegisterV1.appPayload`. */
|
|
252
|
+
export function encodeProviderAppPayload(payload: ProviderAppPayloadV1): Uint8Array {
|
|
253
|
+
return utf8Encoder.encode(JSON.stringify(validateProviderAppPayloadV1(payload)));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Decode opaque `RegisterV1.appPayload` bytes back to a validated {@link ProviderAppPayloadV1}. */
|
|
257
|
+
export function decodeProviderAppPayload(bytes: Uint8Array, maxBytes: number = DEFAULT_MAX_APP_PAYLOAD_BYTES): ProviderAppPayloadV1 {
|
|
258
|
+
return validateProviderAppPayloadV1(parseJsonBytes(bytes, maxBytes, "ProviderAppPayloadV1"));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Serialize a {@link SeekerAppPayloadV1} to the opaque UTF-8 JSON bytes for `RegisterV1.appPayload`. */
|
|
262
|
+
export function encodeSeekerAppPayload(payload: SeekerAppPayloadV1): Uint8Array {
|
|
263
|
+
return utf8Encoder.encode(JSON.stringify(validateSeekerAppPayloadV1(payload)));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Decode opaque `RegisterV1.appPayload` bytes back to a validated {@link SeekerAppPayloadV1}. */
|
|
267
|
+
export function decodeSeekerAppPayload(bytes: Uint8Array, maxBytes: number = DEFAULT_MAX_APP_PAYLOAD_BYTES): SeekerAppPayloadV1 {
|
|
268
|
+
return validateSeekerAppPayloadV1(parseJsonBytes(bytes, maxBytes, "SeekerAppPayloadV1"));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Either matchmaking registration payload, discriminated by `kind`. */
|
|
272
|
+
export type MatchAppPayloadV1 = ProviderAppPayloadV1 | SeekerAppPayloadV1;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Decode an opaque `RegisterV1.appPayload` to whichever matchmaking payload it carries, dispatched on
|
|
276
|
+
* `kind` (parsed once — no exception-as-control-flow). The cohort query handler uses this to classify a
|
|
277
|
+
* registration record into a provider vs. seeker entry. Throws
|
|
278
|
+
* {@link import("../cohort-topic/wire/primitives.js").CohortWireError} on an unknown
|
|
279
|
+
* `kind` or a malformed payload.
|
|
280
|
+
*/
|
|
281
|
+
export function decodeMatchAppPayload(bytes: Uint8Array, maxBytes: number = DEFAULT_MAX_APP_PAYLOAD_BYTES): MatchAppPayloadV1 {
|
|
282
|
+
const value = parseJsonBytes(bytes, maxBytes, "MatchAppPayloadV1");
|
|
283
|
+
const obj = asObject(value, "MatchAppPayloadV1");
|
|
284
|
+
if (obj["kind"] === "match-provider") {
|
|
285
|
+
return validateProviderAppPayloadV1(value);
|
|
286
|
+
}
|
|
287
|
+
if (obj["kind"] === "match-seeker") {
|
|
288
|
+
return validateSeekerAppPayloadV1(value);
|
|
289
|
+
}
|
|
290
|
+
fail(`MatchAppPayloadV1: field "kind" must be "match-provider" or "match-seeker", got ${JSON.stringify(obj["kind"])}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Parse opaque (non-framed) UTF-8 JSON payload bytes, rejecting oversized/invalid input. */
|
|
294
|
+
function parseJsonBytes(bytes: Uint8Array, maxBytes: number, what: string): unknown {
|
|
295
|
+
if (bytes.length > maxBytes) {
|
|
296
|
+
fail(`${what}: payload ${bytes.length} exceeds max ${maxBytes} bytes`);
|
|
297
|
+
}
|
|
298
|
+
let text: string;
|
|
299
|
+
try {
|
|
300
|
+
text = utf8Decoder.decode(bytes);
|
|
301
|
+
} catch {
|
|
302
|
+
fail(`${what}: payload is not valid UTF-8`);
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
return JSON.parse(text) as unknown;
|
|
306
|
+
} catch {
|
|
307
|
+
fail(`${what}: payload is not valid JSON`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- Query-protocol messages (length-framed RPCs) ---
|
|
312
|
+
|
|
313
|
+
/** Narrow an already-parsed value to {@link QueryV1}, throwing on any defect. */
|
|
314
|
+
export function validateQueryV1(value: unknown): QueryV1 {
|
|
315
|
+
const what = "QueryV1";
|
|
316
|
+
const obj = asObject(value, what);
|
|
317
|
+
requireV1(obj, what);
|
|
318
|
+
const out: QueryV1 = {
|
|
319
|
+
v: 1,
|
|
320
|
+
topicId: b64urlField(reqString(obj, "topicId", what), "topicId", what),
|
|
321
|
+
includeProviders: reqBool(obj, "includeProviders", what),
|
|
322
|
+
includeSeekers: reqBool(obj, "includeSeekers", what),
|
|
323
|
+
limit: reqIntInRange(obj, "limit", what, 1, QUERY_LIMIT_MAX),
|
|
324
|
+
requesterId: reqString(obj, "requesterId", what),
|
|
325
|
+
timestamp: reqFiniteNumber(obj, "timestamp", what),
|
|
326
|
+
signature: b64urlField(reqString(obj, "signature", what), "signature", what),
|
|
327
|
+
};
|
|
328
|
+
if (obj["filter"] !== undefined) {
|
|
329
|
+
out.filter = validateCapabilityFilter(obj["filter"], `${what}.filter`);
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function validateTopicTrafficV1(value: unknown, what: string): TopicTrafficV1 {
|
|
335
|
+
const obj = asObject(value, what);
|
|
336
|
+
return {
|
|
337
|
+
windowSeconds: reqFiniteNumber(obj, "windowSeconds", what),
|
|
338
|
+
arrivalsPerMin: reqFiniteNumber(obj, "arrivalsPerMin", what),
|
|
339
|
+
queriesPerMin: reqFiniteNumber(obj, "queriesPerMin", what),
|
|
340
|
+
directParticipants: reqFiniteNumber(obj, "directParticipants", what),
|
|
341
|
+
childCohortCount: reqFiniteNumber(obj, "childCohortCount", what),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function validateProviderEntryV1(value: unknown): ProviderEntryV1 {
|
|
346
|
+
const what = "ProviderEntryV1";
|
|
347
|
+
const obj = asObject(value, what);
|
|
348
|
+
return {
|
|
349
|
+
participantId: reqString(obj, "participantId", what),
|
|
350
|
+
capabilities: reqStringArray(obj, "capabilities", what),
|
|
351
|
+
capacityBudget: reqIntInRange(obj, "capacityBudget", what, 0),
|
|
352
|
+
contactHint: reqString(obj, "contactHint", what),
|
|
353
|
+
attachedAt: reqFiniteNumber(obj, "attachedAt", what),
|
|
354
|
+
registrationSig: b64urlField(reqString(obj, "registrationSig", what), "registrationSig", what),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function validateSeekerEntryV1(value: unknown): SeekerEntryV1 {
|
|
359
|
+
const what = "SeekerEntryV1";
|
|
360
|
+
const obj = asObject(value, what);
|
|
361
|
+
return {
|
|
362
|
+
participantId: reqString(obj, "participantId", what),
|
|
363
|
+
wantCount: reqIntInRange(obj, "wantCount", what, 1),
|
|
364
|
+
contactHint: reqString(obj, "contactHint", what),
|
|
365
|
+
attachedAt: reqFiniteNumber(obj, "attachedAt", what),
|
|
366
|
+
registrationSig: b64urlField(reqString(obj, "registrationSig", what), "registrationSig", what),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Narrow an already-parsed value to {@link QueryReplyV1}, throwing on any defect. */
|
|
371
|
+
export function validateQueryReplyV1(value: unknown): QueryReplyV1 {
|
|
372
|
+
const what = "QueryReplyV1";
|
|
373
|
+
const obj = asObject(value, what);
|
|
374
|
+
requireV1(obj, what);
|
|
375
|
+
const out: QueryReplyV1 = {
|
|
376
|
+
v: 1,
|
|
377
|
+
truncated: reqBool(obj, "truncated", what),
|
|
378
|
+
cohortEpoch: b64urlFixedLen(reqString(obj, "cohortEpoch", what), "cohortEpoch", COORD_BYTES, what),
|
|
379
|
+
topicTraffic: validateTopicTrafficV1(obj["topicTraffic"], `${what}.topicTraffic`),
|
|
380
|
+
signature: b64urlField(reqString(obj, "signature", what), "signature", what),
|
|
381
|
+
};
|
|
382
|
+
if (obj["providers"] !== undefined) {
|
|
383
|
+
if (!Array.isArray(obj["providers"])) {
|
|
384
|
+
fail(`${what}: field "providers" must be an array when present`);
|
|
385
|
+
}
|
|
386
|
+
out.providers = obj["providers"].map(validateProviderEntryV1);
|
|
387
|
+
}
|
|
388
|
+
if (obj["seekers"] !== undefined) {
|
|
389
|
+
if (!Array.isArray(obj["seekers"])) {
|
|
390
|
+
fail(`${what}: field "seekers" must be an array when present`);
|
|
391
|
+
}
|
|
392
|
+
out.seekers = obj["seekers"].map(validateSeekerEntryV1);
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function validateAggregateBucketV1(value: unknown): AggregateBucketV1 {
|
|
398
|
+
const what = "AggregateBucketV1";
|
|
399
|
+
const obj = asObject(value, what);
|
|
400
|
+
return {
|
|
401
|
+
targetTier: reqIntInRange(obj, "targetTier", what, 0),
|
|
402
|
+
prefixSlot: reqIntInRange(obj, "prefixSlot", what, 0),
|
|
403
|
+
count: reqIntInRange(obj, "count", what, 0),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Narrow an already-parsed value to {@link AggregateCountV1}, throwing on any defect. */
|
|
408
|
+
export function validateAggregateCountV1(value: unknown): AggregateCountV1 {
|
|
409
|
+
const what = "AggregateCountV1";
|
|
410
|
+
const obj = asObject(value, what);
|
|
411
|
+
requireV1(obj, what);
|
|
412
|
+
const buckets = obj["bucketCounts"];
|
|
413
|
+
if (!Array.isArray(buckets)) {
|
|
414
|
+
fail(`${what}: field "bucketCounts" must be an array`);
|
|
415
|
+
}
|
|
416
|
+
const signers = obj["signers"];
|
|
417
|
+
if (!Array.isArray(signers) || signers.some((s) => typeof s !== "string")) {
|
|
418
|
+
fail(`${what}: field "signers" must be an array of strings`);
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
v: 1,
|
|
422
|
+
topicId: b64urlField(reqString(obj, "topicId", what), "topicId", what),
|
|
423
|
+
bucketCounts: buckets.map(validateAggregateBucketV1),
|
|
424
|
+
signature: b64urlField(reqString(obj, "signature", what), "signature", what),
|
|
425
|
+
signers: (signers as string[]).map((s, i) => b64urlField(s, `signers[${i}]`, what)),
|
|
426
|
+
cohortEpoch: b64urlFixedLen(reqString(obj, "cohortEpoch", what), "cohortEpoch", COORD_BYTES, what),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Encode a {@link QueryV1} as a length-prefixed UTF-8 JSON frame. */
|
|
431
|
+
export function encodeQueryV1(msg: QueryV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
|
|
432
|
+
return encodeCohortMessage(validateQueryV1(msg), maxMessageBytes);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Decode a length-prefixed {@link QueryV1} frame. */
|
|
436
|
+
export function decodeQueryV1(bytes: Uint8Array, maxMessageBytes?: number): QueryV1 {
|
|
437
|
+
return validateQueryV1(decodeCohortMessage(bytes, maxMessageBytes));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Encode a {@link QueryReplyV1} as a length-prefixed UTF-8 JSON frame. */
|
|
441
|
+
export function encodeQueryReplyV1(msg: QueryReplyV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
|
|
442
|
+
return encodeCohortMessage(validateQueryReplyV1(msg), maxMessageBytes);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Decode a length-prefixed {@link QueryReplyV1} frame. */
|
|
446
|
+
export function decodeQueryReplyV1(bytes: Uint8Array, maxMessageBytes?: number): QueryReplyV1 {
|
|
447
|
+
return validateQueryReplyV1(decodeCohortMessage(bytes, maxMessageBytes));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Encode an {@link AggregateCountV1} as a length-prefixed UTF-8 JSON frame. */
|
|
451
|
+
export function encodeAggregateCountV1(msg: AggregateCountV1, maxMessageBytes: number = DEFAULT_MAX_MESSAGE_BYTES): Uint8Array {
|
|
452
|
+
return encodeCohortMessage(validateAggregateCountV1(msg), maxMessageBytes);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Decode a length-prefixed {@link AggregateCountV1} frame. */
|
|
456
|
+
export function decodeAggregateCountV1(bytes: Uint8Array, maxMessageBytes?: number): AggregateCountV1 {
|
|
457
|
+
return validateAggregateCountV1(decodeCohortMessage(bytes, maxMessageBytes));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// --- canonical participant-signature payloads (provider/seeker registration sigs) ---
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Canonical signed byte image of a provider registration — `(topicId, capabilities, capacityBudget)`
|
|
464
|
+
* per `docs/matchmaking.md` §Wire formats.
|
|
465
|
+
*
|
|
466
|
+
* **Signing scope (resolved):** the signed image deliberately excludes the matchmaking
|
|
467
|
+
* `correlationId`. The advisory trust model (matchmaking.md §Wire formats) requires a seeker to
|
|
468
|
+
* re-validate each forwarded {@link ProviderEntryV1}'s `registrationSig`, and a {@link ProviderEntryV1}
|
|
469
|
+
* carries **no** `correlationId` field — so binding the signature over `correlationId` would leave the
|
|
470
|
+
* seeker unable to reconstruct the exact signed image. Dropping it (option (b) of the implement ticket)
|
|
471
|
+
* keeps the signed image fully self-contained in the forwarded entry: `topicId` (the topic the seeker
|
|
472
|
+
* queried) + `capabilities` + `capacityBudget`. Replay-binding of the *registration* is handled
|
|
473
|
+
* independently by the cohort-topic `RegisterV1` envelope (its own `correlationId` + replay guard +
|
|
474
|
+
* peer-key signature); the matchmaking signature only attests provider authorship of the advertised
|
|
475
|
+
* capabilities, which the participant peer key (the entry's `participantId`) anchors.
|
|
476
|
+
*
|
|
477
|
+
* Determinism comes from an explicitly-ordered array (stable, unlike object key order), exactly like
|
|
478
|
+
* the cohort-topic `registerSigningPayload`. `topicId` is passed as raw bytes and emitted as base64url
|
|
479
|
+
* so signer and verifier agree byte-for-byte.
|
|
480
|
+
*/
|
|
481
|
+
export function providerSigningPayload(topicId: Uint8Array, capabilities: readonly string[], capacityBudget: number): Uint8Array {
|
|
482
|
+
return utf8Encoder.encode(JSON.stringify([
|
|
483
|
+
"ProviderAppPayloadV1",
|
|
484
|
+
bytesToB64url(topicId),
|
|
485
|
+
[...capabilities],
|
|
486
|
+
capacityBudget,
|
|
487
|
+
]));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Canonical signed byte image of a seeker registration — `(topicId, wantCount)`. Mirrors the provider
|
|
492
|
+
* scope (option (b), see {@link providerSigningPayload}): the image excludes `correlationId` so it is
|
|
493
|
+
* fully reconstructable from a forwarded {@link SeekerEntryV1} (`participantId` + `wantCount`) for
|
|
494
|
+
* collective-assembly re-validation.
|
|
495
|
+
*/
|
|
496
|
+
export function seekerSigningPayload(topicId: Uint8Array, wantCount: number): Uint8Array {
|
|
497
|
+
return utf8Encoder.encode(JSON.stringify([
|
|
498
|
+
"SeekerAppPayloadV1",
|
|
499
|
+
bytesToB64url(topicId),
|
|
500
|
+
wantCount,
|
|
501
|
+
]));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// --- seeker-side entry re-validation (advisory trust model) ---
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Verifies a forwarded entry's `registrationSig`. db-core is crypto-free, so the actual peer-key check
|
|
508
|
+
* is injected (db-p2p binds it to {@link import("@optimystic/db-p2p").verifyPeerSig} over the
|
|
509
|
+
* participant's Ed25519 peer key). `signerId` is the entry's `participantId` (a peer-id string); the
|
|
510
|
+
* verifier resolves the public key from it. Returns `false` (never throws) on a malformed signature.
|
|
511
|
+
*/
|
|
512
|
+
export type EntrySigVerifier = (signerId: string, payload: Uint8Array, signature: Uint8Array) => boolean;
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Re-validate a forwarded {@link ProviderEntryV1} against the topic it was returned for: reconstruct the
|
|
516
|
+
* provider signing image from the entry's own fields and verify `registrationSig` against the entry's
|
|
517
|
+
* `participantId`. This is the seeker-side check the advisory trust model hinges on — the cohort vouches
|
|
518
|
+
* only for "these were the registrations I held", never for provider authenticity.
|
|
519
|
+
*/
|
|
520
|
+
export function verifyProviderEntry(topicId: Uint8Array, entry: ProviderEntryV1, verify: EntrySigVerifier): boolean {
|
|
521
|
+
let sig: Uint8Array;
|
|
522
|
+
try {
|
|
523
|
+
sig = b64urlToBytes(entry.registrationSig);
|
|
524
|
+
} catch {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
return verify(entry.participantId, providerSigningPayload(topicId, entry.capabilities, entry.capacityBudget), sig);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Re-validate a forwarded {@link SeekerEntryV1} (collective-assembly discovery), mirroring {@link verifyProviderEntry}. */
|
|
531
|
+
export function verifySeekerEntry(topicId: Uint8Array, entry: SeekerEntryV1, verify: EntrySigVerifier): boolean {
|
|
532
|
+
let sig: Uint8Array;
|
|
533
|
+
try {
|
|
534
|
+
sig = b64urlToBytes(entry.registrationSig);
|
|
535
|
+
} catch {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
return verify(entry.participantId, seekerSigningPayload(topicId, entry.wantCount), sig);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Canonical signed byte image of a {@link QueryReplyV1} — the cohort **primary's** single-member
|
|
543
|
+
* signature (not threshold), per `docs/matchmaking.md` §Wire formats. The primary vouches only for the
|
|
544
|
+
* *set it held*; provider authenticity is re-validated per entry via {@link verifyProviderEntry}. The
|
|
545
|
+
* image binds the epoch, truncation flag, traffic snapshot, and the participant ids of each returned
|
|
546
|
+
* entry (order-sensitive) so a tampered reply is detectable, while staying independent of the
|
|
547
|
+
* advisory per-entry signatures.
|
|
548
|
+
*/
|
|
549
|
+
export function queryReplySigningPayload(reply: Omit<QueryReplyV1, "signature">): Uint8Array {
|
|
550
|
+
return utf8Encoder.encode(JSON.stringify([
|
|
551
|
+
"QueryReplyV1",
|
|
552
|
+
reply.v,
|
|
553
|
+
reply.cohortEpoch,
|
|
554
|
+
reply.truncated,
|
|
555
|
+
[
|
|
556
|
+
reply.topicTraffic.windowSeconds,
|
|
557
|
+
reply.topicTraffic.arrivalsPerMin,
|
|
558
|
+
reply.topicTraffic.queriesPerMin,
|
|
559
|
+
reply.topicTraffic.directParticipants,
|
|
560
|
+
reply.topicTraffic.childCohortCount,
|
|
561
|
+
],
|
|
562
|
+
(reply.providers ?? []).map((p) => p.participantId),
|
|
563
|
+
(reply.seekers ?? []).map((s) => s.participantId),
|
|
564
|
+
]));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// --- aggregate-count log-bucketing + canonical threshold-signing image (multi-cohort sweep) ---
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Log-bucket a raw provider count for an {@link AggregateCountV1} bucket: the largest power of two
|
|
571
|
+
* `<= n` (and `0` for `n <= 0`). The root reports bucketed, not exact, per-shard populations
|
|
572
|
+
* (`docs/matchmaking.md` §Multi-cohort sweep) — both to compress the summary and to avoid leaking exact
|
|
573
|
+
* counts.
|
|
574
|
+
*
|
|
575
|
+
* The bucketing rounds **down** and is monotonic non-decreasing, so a consumer summing bucketed counts
|
|
576
|
+
* (`multi-cohort-seeker.selectShards`) *under*-estimates the true population. That bias is the safe
|
|
577
|
+
* direction for shard selection: the real population is always `>=` the reported one, so selecting until
|
|
578
|
+
* the bucketed sum reaches `wantCount` naturally over-provisions rather than under-selecting.
|
|
579
|
+
*/
|
|
580
|
+
export function logBucketCount(n: number): number {
|
|
581
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
582
|
+
return 0;
|
|
583
|
+
}
|
|
584
|
+
return 2 ** Math.floor(Math.log2(Math.floor(n)));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Canonical threshold-signed byte image of an {@link AggregateCountV1} — covers the semantic fields
|
|
589
|
+
* (`v`, `topicId`, `cohortEpoch`, and the bucket set) but never the `signature`/`signers` envelope,
|
|
590
|
+
* exactly as the cohort-topic `sig/payloads.ts` builders do for their threshold-signed notices. The
|
|
591
|
+
* bucket set is sorted by `(targetTier, prefixSlot)` so the image is independent of bucket emission
|
|
592
|
+
* order — signer and verifier recompute identical bytes.
|
|
593
|
+
*/
|
|
594
|
+
export function aggregateCountSigningPayload(unsigned: Omit<AggregateCountV1, "signature" | "signers">): Uint8Array {
|
|
595
|
+
const buckets = [...unsigned.bucketCounts]
|
|
596
|
+
.sort((a, b) => a.targetTier - b.targetTier || a.prefixSlot - b.prefixSlot)
|
|
597
|
+
.map((b) => [b.targetTier, b.prefixSlot, b.count]);
|
|
598
|
+
return utf8Encoder.encode(JSON.stringify([
|
|
599
|
+
"AggregateCountV1",
|
|
600
|
+
unsigned.v,
|
|
601
|
+
unsigned.topicId,
|
|
602
|
+
unsigned.cohortEpoch,
|
|
603
|
+
buckets,
|
|
604
|
+
]));
|
|
605
|
+
}
|