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