@optimystic/db-core 0.21.0 → 0.24.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 (185) hide show
  1. package/README.md +336 -336
  2. package/dist/src/btree/btree.d.ts +2 -1
  3. package/dist/src/btree/btree.d.ts.map +1 -1
  4. package/dist/src/btree/btree.js +1 -1
  5. package/dist/src/btree/btree.js.map +1 -1
  6. package/dist/src/chain/chain.d.ts +1 -1
  7. package/dist/src/chain/chain.d.ts.map +1 -1
  8. package/dist/src/chain/chain.js +1 -1
  9. package/dist/src/chain/chain.js.map +1 -1
  10. package/dist/src/cluster/structs.d.ts +39 -1
  11. package/dist/src/cluster/structs.d.ts.map +1 -1
  12. package/dist/src/cluster/structs.js +24 -0
  13. package/dist/src/cluster/structs.js.map +1 -1
  14. package/dist/src/collection/collection.d.ts +20 -1
  15. package/dist/src/collection/collection.d.ts.map +1 -1
  16. package/dist/src/collection/collection.js +31 -4
  17. package/dist/src/collection/collection.js.map +1 -1
  18. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  19. package/dist/src/collections/diary/diary.js +2 -1
  20. package/dist/src/collections/diary/diary.js.map +1 -1
  21. package/dist/src/collections/diary/struct.js +1 -1
  22. package/dist/src/collections/diary/struct.js.map +1 -1
  23. package/dist/src/collections/tree/collection-trunk.js +1 -1
  24. package/dist/src/collections/tree/collection-trunk.js.map +1 -1
  25. package/dist/src/collections/tree/struct.d.ts +1 -1
  26. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  27. package/dist/src/collections/tree/struct.js +2 -1
  28. package/dist/src/collections/tree/struct.js.map +1 -1
  29. package/dist/src/collections/tree/tree.d.ts +5 -0
  30. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  31. package/dist/src/collections/tree/tree.js +7 -0
  32. package/dist/src/collections/tree/tree.js.map +1 -1
  33. package/dist/src/log/log.d.ts +1 -1
  34. package/dist/src/log/log.d.ts.map +1 -1
  35. package/dist/src/log/log.js +2 -2
  36. package/dist/src/log/log.js.map +1 -1
  37. package/dist/src/network/i-peer-network.d.ts +16 -0
  38. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  39. package/dist/src/network/struct.d.ts +39 -2
  40. package/dist/src/network/struct.d.ts.map +1 -1
  41. package/dist/src/network/struct.js +18 -0
  42. package/dist/src/network/struct.js.map +1 -1
  43. package/dist/src/testing/test-transactor.d.ts +95 -8
  44. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  45. package/dist/src/testing/test-transactor.js +133 -12
  46. package/dist/src/testing/test-transactor.js.map +1 -1
  47. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  48. package/dist/src/transaction/coordinator.js +2 -1
  49. package/dist/src/transaction/coordinator.js.map +1 -1
  50. package/dist/src/transaction/transaction.d.ts +1 -1
  51. package/dist/src/transaction/transaction.js +1 -1
  52. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  53. package/dist/src/transactor/network-transactor.js +57 -18
  54. package/dist/src/transactor/network-transactor.js.map +1 -1
  55. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  56. package/dist/src/transactor/transactor-source.js +25 -2
  57. package/dist/src/transactor/transactor-source.js.map +1 -1
  58. package/dist/src/transform/cache-source.js +1 -1
  59. package/dist/src/transform/cache-source.js.map +1 -1
  60. package/dist/src/transform/helpers.d.ts +6 -1
  61. package/dist/src/transform/helpers.d.ts.map +1 -1
  62. package/dist/src/transform/helpers.js +7 -6
  63. package/dist/src/transform/helpers.js.map +1 -1
  64. package/dist/src/transform/tracker.d.ts.map +1 -1
  65. package/dist/src/transform/tracker.js +2 -1
  66. package/dist/src/transform/tracker.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/btree/btree.ts +2 -1
  69. package/src/chain/chain.ts +2 -1
  70. package/src/cluster/membership.ts +85 -85
  71. package/src/cluster/structs.ts +43 -4
  72. package/src/cohort-topic/addressing.ts +120 -120
  73. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  74. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  75. package/src/cohort-topic/antidos/index.ts +5 -5
  76. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  77. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  78. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  79. package/src/cohort-topic/antiflood/index.ts +2 -2
  80. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  81. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  82. package/src/cohort-topic/coldstart.ts +237 -237
  83. package/src/cohort-topic/dmax.ts +88 -88
  84. package/src/cohort-topic/gossip/bus.ts +254 -254
  85. package/src/cohort-topic/gossip/index.ts +3 -3
  86. package/src/cohort-topic/gossip/records.ts +45 -45
  87. package/src/cohort-topic/gossip/view.ts +91 -91
  88. package/src/cohort-topic/index.ts +20 -20
  89. package/src/cohort-topic/load/barometer.ts +134 -134
  90. package/src/cohort-topic/load/index.ts +1 -1
  91. package/src/cohort-topic/member-engine.ts +430 -430
  92. package/src/cohort-topic/membership/index.ts +3 -3
  93. package/src/cohort-topic/membership/publisher.ts +163 -163
  94. package/src/cohort-topic/membership/source.ts +41 -41
  95. package/src/cohort-topic/membership/verifier.ts +461 -461
  96. package/src/cohort-topic/ports.ts +157 -157
  97. package/src/cohort-topic/promotion.ts +405 -405
  98. package/src/cohort-topic/registration/bytes.ts +37 -37
  99. package/src/cohort-topic/registration/handoff.ts +154 -154
  100. package/src/cohort-topic/registration/index.ts +6 -6
  101. package/src/cohort-topic/registration/renewal.ts +495 -495
  102. package/src/cohort-topic/registration/sharding.ts +61 -61
  103. package/src/cohort-topic/registration/store.ts +81 -81
  104. package/src/cohort-topic/registration/types.ts +91 -91
  105. package/src/cohort-topic/ring-hash.ts +50 -50
  106. package/src/cohort-topic/service.ts +416 -416
  107. package/src/cohort-topic/sig/index.ts +2 -2
  108. package/src/cohort-topic/sig/payloads.ts +59 -59
  109. package/src/cohort-topic/sig/threshold.ts +64 -64
  110. package/src/cohort-topic/tiers.ts +74 -74
  111. package/src/cohort-topic/traffic.ts +233 -233
  112. package/src/cohort-topic/walk.ts +326 -326
  113. package/src/cohort-topic/willingness.ts +237 -237
  114. package/src/cohort-topic/wire/codec.ts +216 -216
  115. package/src/cohort-topic/wire/index.ts +18 -18
  116. package/src/cohort-topic/wire/payloads.ts +126 -126
  117. package/src/cohort-topic/wire/primitives.ts +188 -188
  118. package/src/cohort-topic/wire/types.ts +475 -475
  119. package/src/cohort-topic/wire/validate.ts +512 -512
  120. package/src/collection/collection-type-registry.ts +37 -37
  121. package/src/collection/collection.ts +32 -4
  122. package/src/collections/diary/diary.ts +68 -67
  123. package/src/collections/diary/struct.ts +1 -1
  124. package/src/collections/tree/collection-trunk.ts +1 -1
  125. package/src/collections/tree/readme.md +4 -0
  126. package/src/collections/tree/struct.ts +3 -1
  127. package/src/collections/tree/tree.ts +320 -312
  128. package/src/log/log.ts +2 -2
  129. package/src/matchmaking/capability-filter.ts +45 -45
  130. package/src/matchmaking/config.ts +98 -98
  131. package/src/matchmaking/index.ts +21 -21
  132. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  133. package/src/matchmaking/provider.ts +123 -123
  134. package/src/matchmaking/query-eval.ts +105 -105
  135. package/src/matchmaking/seeker-walk.ts +127 -127
  136. package/src/matchmaking/seeker.ts +86 -86
  137. package/src/matchmaking/topic-anchor.ts +90 -90
  138. package/src/matchmaking/voting-quorum.ts +394 -394
  139. package/src/matchmaking/wire.ts +603 -603
  140. package/src/network/i-peer-network.ts +17 -0
  141. package/src/network/stale-failure.ts +43 -43
  142. package/src/network/struct.ts +41 -2
  143. package/src/network/types.ts +37 -37
  144. package/src/reactivity/backfill.ts +220 -220
  145. package/src/reactivity/backpressure.ts +191 -191
  146. package/src/reactivity/checkpoint.ts +308 -308
  147. package/src/reactivity/config.ts +172 -172
  148. package/src/reactivity/dedupe.ts +132 -132
  149. package/src/reactivity/forwarder.ts +87 -87
  150. package/src/reactivity/index.ts +34 -34
  151. package/src/reactivity/notification.ts +123 -123
  152. package/src/reactivity/policy.ts +79 -79
  153. package/src/reactivity/push-state.ts +310 -310
  154. package/src/reactivity/recover.ts +153 -153
  155. package/src/reactivity/replay-buffer.ts +141 -141
  156. package/src/reactivity/resume.ts +549 -549
  157. package/src/reactivity/rotation.ts +415 -415
  158. package/src/reactivity/subscriber.ts +132 -132
  159. package/src/reactivity/subscription.ts +66 -66
  160. package/src/reactivity/topic-anchor.ts +71 -71
  161. package/src/reactivity/verify.ts +73 -73
  162. package/src/reactivity/wire-validate.ts +13 -13
  163. package/src/reactivity/wire.ts +224 -224
  164. package/src/testing/async-wait.ts +65 -65
  165. package/src/testing/index.ts +2 -2
  166. package/src/testing/test-transactor.ts +638 -489
  167. package/src/transaction/coordinator.ts +2 -1
  168. package/src/transaction/errors.ts +91 -91
  169. package/src/transaction/operations-hash.ts +196 -196
  170. package/src/transaction/read-dependency-collector.ts +78 -78
  171. package/src/transaction/transaction.ts +1 -1
  172. package/src/transactor/change-notifier.ts +80 -80
  173. package/src/transactor/index.ts +5 -5
  174. package/src/transactor/network-transactor.ts +58 -19
  175. package/src/transactor/transactor-source.ts +25 -2
  176. package/src/transform/atomic-proxy.ts +92 -92
  177. package/src/transform/cache-source.ts +1 -1
  178. package/src/transform/helpers.ts +159 -158
  179. package/src/transform/tracker.ts +2 -1
  180. package/src/utility/backoff.ts +95 -95
  181. package/src/utility/batch-coordinator.ts +191 -191
  182. package/dist/src/transaction/context.d.ts +0 -60
  183. package/dist/src/transaction/context.d.ts.map +0 -1
  184. package/dist/src/transaction/context.js +0 -91
  185. package/dist/src/transaction/context.js.map +0 -1
@@ -1,512 +1,512 @@
1
- /**
2
- * Cohort-topic substrate — per-message structural validation.
3
- *
4
- * Each validator narrows an already-parsed `unknown` (the output of `JSON.parse` on a decoded
5
- * frame) to a concrete V1 interface, throwing {@link CohortWireError} on any structural defect:
6
- * missing required field, wrong `v`, out-of-enum discriminant, a byte field that does not decode
7
- * as base64url, or an out-of-range numeric. Validators never return partial data — they either
8
- * narrow cleanly or throw.
9
- */
10
-
11
- import { DEFAULT_D_MAX_CAP } from "../dmax.js";
12
- import {
13
- assignDefined,
14
- asObject,
15
- b64urlField,
16
- b64urlFixedLen,
17
- failWire as fail,
18
- optBool,
19
- optFiniteNumber,
20
- optString,
21
- optStringArray,
22
- reqBool,
23
- reqEnum,
24
- reqFiniteNumber,
25
- reqString,
26
- reqStringArray,
27
- requireV1,
28
- } from "./primitives.js";
29
- import type {
30
- ChildLinkRefV1,
31
- ChildLinkReplyV1,
32
- ChildLinkV1,
33
- CohortGossipV1,
34
- CohortTopicSummary,
35
- DemotionNoticeV1,
36
- GossipRecordRefV1,
37
- GossipRecordV1,
38
- MembershipCertV1,
39
- PromotionNoticeV1,
40
- RegisterReplyV1,
41
- RegisterResult,
42
- RegisterV1,
43
- RenewReplyV1,
44
- RenewV1,
45
- SignKind,
46
- SignRequestV1,
47
- SignReplyV1,
48
- TopicTrafficV1,
49
- } from "./types.js";
50
-
51
- // The generic structural-validation primitives (`fail`/`asObject`/`req*`/`opt*`/`b64url*`/`reqEnum`/…)
52
- // live in ./primitives.js and are imported above; only the cohort-topic domain validators and the
53
- // tier-semantics helpers below stay local. `CohortWireError` is re-exported here so existing importers
54
- // of this module (codec, wire/index, matchmaking, reactivity) are unaffected by the move.
55
- export { CohortWireError } from "./primitives.js";
56
-
57
- function tier(value: number, what: string): number {
58
- if (!Number.isInteger(value) || value < 0 || value > 3) {
59
- fail(`${what}: tier must be an integer in 0..3, got ${value}`);
60
- }
61
- return value;
62
- }
63
-
64
- /**
65
- * Validate a `treeTier` (the walk-toward-root start tier `d` a register/redirect targets): an integer in
66
- * `0..DEFAULT_D_MAX_CAP`. `d_max_cap` (60) is the substrate's own ceiling on useful walk depth, so a
67
- * `treeTier` above it cannot correspond to a real walk position. Rejecting here keeps an out-of-range value
68
- * from reaching `addressing.coord()` downstream — whose `coordD` throws a raw `RangeError` for a
69
- * non-integer / negative / `> 255` tier, an unclassified crash rather than a clean malformed-frame rejection.
70
- */
71
- function treeTier(value: number, what: string): number {
72
- if (!Number.isInteger(value) || value < 0 || value > DEFAULT_D_MAX_CAP) {
73
- fail(`${what}: treeTier must be an integer in 0..${DEFAULT_D_MAX_CAP}, got ${value}`);
74
- }
75
- return value;
76
- }
77
-
78
- /** Ring-coord / topic-id / epoch byte width (SHA-256 truncated to the ring width). */
79
- const COORD_BYTES = 32;
80
- /** Correlation-id byte width (a 16-byte nonce minted per walk probe / renew). */
81
- const CORRELATION_BYTES = 16;
82
-
83
- const REGISTER_RESULTS: readonly RegisterResult[] = [
84
- "accepted",
85
- "no_state",
86
- "promoted",
87
- "unwilling_member",
88
- "unwilling_cohort",
89
- ];
90
-
91
- export function validateRegisterV1(value: unknown): RegisterV1 {
92
- const what = "RegisterV1";
93
- const obj = asObject(value, what);
94
- requireV1(obj, what);
95
- const out: RegisterV1 = {
96
- v: 1,
97
- topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
98
- tier: tier(reqFiniteNumber(obj, "tier", what), what),
99
- treeTier: treeTier(reqFiniteNumber(obj, "treeTier", what), what),
100
- participantCoord: b64urlField(reqString(obj, "participantCoord", what), "participantCoord", what),
101
- ttl: reqFiniteNumber(obj, "ttl", what),
102
- timestamp: reqFiniteNumber(obj, "timestamp", what),
103
- correlationId: b64urlFixedLen(reqString(obj, "correlationId", what), "correlationId", CORRELATION_BYTES, what),
104
- signature: b64urlField(reqString(obj, "signature", what), "signature", what),
105
- };
106
- const bootstrap = optBool(obj, "bootstrap", what);
107
- if (bootstrap !== undefined) {
108
- out.bootstrap = bootstrap;
109
- }
110
- const probe = optBool(obj, "probe", what);
111
- if (probe !== undefined) {
112
- out.probe = probe;
113
- }
114
- const followOn = optBool(obj, "followOn", what);
115
- if (followOn !== undefined) {
116
- out.followOn = followOn;
117
- }
118
- // `bootstrap`, `followOn`, and `probe` are pairwise mutually exclusive — the walk sets at most one
119
- // (bootstrap only at the tier-0 root re-issue, followOn only at a `treeTier >= 1` redirect target,
120
- // probe never instantiates). A frame that sets more than one is malformed / adversarial.
121
- if ([bootstrap, followOn, probe].filter((flag) => flag === true).length > 1) {
122
- fail(`${what}: at most one of bootstrap, followOn, probe may be set`);
123
- }
124
- // A follow-on is by definition a deeper-than-root growth point, so `treeTier >= 1`. A `followOn: true`
125
- // at the root (treeTier 0) would be a bootstrap, not a follow-on — reject it as malformed.
126
- if (followOn === true && out.treeTier < 1) {
127
- fail(`${what}: followOn requires treeTier >= 1, got ${out.treeTier}`);
128
- }
129
- const appPayload = optString(obj, "appPayload", what);
130
- if (appPayload !== undefined) {
131
- out.appPayload = b64urlField(appPayload, "appPayload", what);
132
- }
133
- // Bootstrap-evidence envelope (base64url; present only on a `bootstrap: true` register). A non-string
134
- // is rejected as malformed; an empty string is treated as absent so it normalizes to the same signed
135
- // image placeholder as a missing field (see `registerSigningPayload`'s `normalizeEvidence`).
136
- const bootstrapEvidence = optString(obj, "bootstrapEvidence", what);
137
- if (bootstrapEvidence !== undefined && bootstrapEvidence !== "") {
138
- out.bootstrapEvidence = b64urlField(bootstrapEvidence, "bootstrapEvidence", what);
139
- }
140
- return out;
141
- }
142
-
143
- function validateTopicTrafficV1(value: unknown): TopicTrafficV1 {
144
- const what = "TopicTrafficV1";
145
- const obj = asObject(value, what);
146
- return {
147
- windowSeconds: reqFiniteNumber(obj, "windowSeconds", what),
148
- arrivalsPerMin: reqFiniteNumber(obj, "arrivalsPerMin", what),
149
- queriesPerMin: reqFiniteNumber(obj, "queriesPerMin", what),
150
- directParticipants: reqFiniteNumber(obj, "directParticipants", what),
151
- childCohortCount: reqFiniteNumber(obj, "childCohortCount", what),
152
- };
153
- }
154
-
155
- export function validateRegisterReplyV1(value: unknown): RegisterReplyV1 {
156
- const what = "RegisterReplyV1";
157
- const obj = asObject(value, what);
158
- requireV1(obj, what);
159
- const out: RegisterReplyV1 = {
160
- v: 1,
161
- result: reqEnum(obj, "result", REGISTER_RESULTS, what),
162
- };
163
- assignDefined(out, "primary", optString(obj, "primary", what));
164
- assignDefined(out, "backups", optStringArray(obj, "backups", what));
165
- const cohortEpoch = optString(obj, "cohortEpoch", what);
166
- if (cohortEpoch !== undefined) {
167
- // NOTE: cohortEpoch is NOT length-pinned though real epochs are a 32-byte SHA-256 — several
168
- // test fixtures (db-core reactivity + db-p2p) feed 1-byte synthetic epochs. Pinning is tracked
169
- // by debt-cohort-topic-pin-cohort-epoch. See b64urlField's note.
170
- out.cohortEpoch = b64urlField(cohortEpoch, "cohortEpoch", what);
171
- }
172
- assignDefined(out, "cohortMembers", optStringArray(obj, "cohortMembers", what));
173
- if (obj["topicTraffic"] !== undefined) {
174
- out.topicTraffic = validateTopicTrafficV1(obj["topicTraffic"]);
175
- }
176
- // NOTE: `targetTier` is range-checked in the walk loop (walk.ts, case "promoted"), not here — an
177
- // out-of-range redirect must surface as a `retry_later` outcome, not a decode-time throw.
178
- assignDefined(out, "targetTier", optFiniteNumber(obj, "targetTier", what));
179
- assignDefined(out, "candidateMembers", optStringArray(obj, "candidateMembers", what));
180
- assignDefined(out, "retryAfterMs", optFiniteNumber(obj, "retryAfterMs", what));
181
- assignDefined(out, "reason", optString(obj, "reason", what));
182
- return out;
183
- }
184
-
185
- export function validateRenewV1(value: unknown): RenewV1 {
186
- const what = "RenewV1";
187
- const obj = asObject(value, what);
188
- requireV1(obj, what);
189
- const out: RenewV1 = {
190
- v: 1,
191
- topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
192
- participantId: reqString(obj, "participantId", what),
193
- correlationId: b64urlFixedLen(reqString(obj, "correlationId", what), "correlationId", CORRELATION_BYTES, what),
194
- timestamp: reqFiniteNumber(obj, "timestamp", what),
195
- signature: b64urlField(reqString(obj, "signature", what), "signature", what),
196
- };
197
- assignDefined(out, "reattach", optBool(obj, "reattach", what));
198
- assignDefined(out, "withdraw", optBool(obj, "withdraw", what));
199
- return out;
200
- }
201
-
202
- export function validateRenewReplyV1(value: unknown): RenewReplyV1 {
203
- const what = "RenewReplyV1";
204
- const obj = asObject(value, what);
205
- requireV1(obj, what);
206
- const out: RenewReplyV1 = {
207
- v: 1,
208
- result: reqEnum(obj, "result", ["ok", "unknown_registration", "primary_moved", "withdrawn"] as const, what),
209
- };
210
- assignDefined(out, "newPrimary", optString(obj, "newPrimary", what));
211
- assignDefined(out, "newBackups", optStringArray(obj, "newBackups", what));
212
- const cohortEpoch = optString(obj, "cohortEpoch", what);
213
- if (cohortEpoch !== undefined) {
214
- // NOTE: cohortEpoch is NOT length-pinned — see validateRegisterReplyV1's note and
215
- // debt-cohort-topic-pin-cohort-epoch.
216
- out.cohortEpoch = b64urlField(cohortEpoch, "cohortEpoch", what);
217
- }
218
- return out;
219
- }
220
-
221
- export function validatePromotionNoticeV1(value: unknown): PromotionNoticeV1 {
222
- const what = "PromotionNoticeV1";
223
- const obj = asObject(value, what);
224
- requireV1(obj, what);
225
- const fromTier = treeTier(reqFiniteNumber(obj, "fromTier", what), what);
226
- const toTier = treeTier(reqFiniteNumber(obj, "toTier", what), what);
227
- if (toTier !== fromTier + 1) {
228
- fail(`${what}: toTier must equal fromTier + 1, got fromTier=${fromTier} toTier=${toTier}`);
229
- }
230
- return {
231
- v: 1,
232
- topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
233
- fromTier,
234
- toTier,
235
- cohortCoord: b64urlFixedLen(reqString(obj, "cohortCoord", what), "cohortCoord", COORD_BYTES, what),
236
- effectiveAt: reqFiniteNumber(obj, "effectiveAt", what),
237
- thresholdSig: b64urlField(reqString(obj, "thresholdSig", what), "thresholdSig", what),
238
- signers: reqStringArray(obj, "signers", what),
239
- cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
240
- };
241
- }
242
-
243
- export function validateDemotionNoticeV1(value: unknown): DemotionNoticeV1 {
244
- const what = "DemotionNoticeV1";
245
- const obj = asObject(value, what);
246
- requireV1(obj, what);
247
- return {
248
- v: 1,
249
- topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
250
- tier: treeTier(reqFiniteNumber(obj, "tier", what), what),
251
- parentCohortCoord: b64urlFixedLen(reqString(obj, "parentCohortCoord", what), "parentCohortCoord", COORD_BYTES, what),
252
- cohortCoord: b64urlFixedLen(reqString(obj, "cohortCoord", what), "cohortCoord", COORD_BYTES, what),
253
- effectiveAt: reqFiniteNumber(obj, "effectiveAt", what),
254
- thresholdSig: b64urlField(reqString(obj, "thresholdSig", what), "thresholdSig", what),
255
- signers: reqStringArray(obj, "signers", what),
256
- cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
257
- };
258
- }
259
-
260
- /**
261
- * Validate a {@link ChildLinkV1}. Enforces `childTier >= 1` (the root never links), `tier` in 0..3, and
262
- * well-formed base64url on every byte field. The hash-derived fields (`topicId` / `childCohortCoord` /
263
- * `cohortEpoch`) are additionally length-checked to exactly 32 bytes (each is a SHA-256 truncation); the
264
- * `childParticipantCoord` follows the lenient `RegisterV1` convention (base64url only — a participant
265
- * coord is not always 32 raw bytes, e.g. a multihash-encoded peer id in tests). When `minSigs`
266
- * is supplied AND the frame carries a threshold signature (`thresholdSig` non-empty), `signers.length` must
267
- * be `>= minSigs`; a key-less-interim frame carries neither, so that cross-field bound is skipped. `minSigs`
268
- * is optional so a bare structural decode (no quorum context) still narrows the frame.
269
- */
270
- export function validateChildLinkV1(value: unknown, minSigs?: number): ChildLinkV1 {
271
- const what = "ChildLinkV1";
272
- const obj = asObject(value, what);
273
- requireV1(obj, what);
274
- const childTier = reqFiniteNumber(obj, "childTier", what);
275
- if (!Number.isInteger(childTier) || childTier < 1) {
276
- fail(`${what}: childTier must be an integer >= 1, got ${childTier}`);
277
- }
278
- const thresholdSig = b64urlField(reqString(obj, "thresholdSig", what), "thresholdSig", what);
279
- const signers = reqStringArray(obj, "signers", what).map((s) => b64urlField(s, "signers", what));
280
- if (minSigs !== undefined && thresholdSig.length > 0 && signers.length < minSigs) {
281
- fail(`${what}: a signed child-link needs signers.length >= ${minSigs}, got ${signers.length}`);
282
- }
283
- return {
284
- v: 1,
285
- topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
286
- childCohortCoord: b64urlFixedLen(reqString(obj, "childCohortCoord", what), "childCohortCoord", COORD_BYTES, what),
287
- childParticipantCoord: b64urlField(reqString(obj, "childParticipantCoord", what), "childParticipantCoord", what),
288
- childTier,
289
- tier: tier(reqFiniteNumber(obj, "tier", what), what),
290
- effectiveAt: reqFiniteNumber(obj, "effectiveAt", what),
291
- thresholdSig,
292
- signers,
293
- cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
294
- };
295
- }
296
-
297
- /** Validate a {@link ChildLinkReplyV1}: `result` in `linked | rejected`, optional human-readable `reason`. */
298
- export function validateChildLinkReplyV1(value: unknown): ChildLinkReplyV1 {
299
- const what = "ChildLinkReplyV1";
300
- const obj = asObject(value, what);
301
- requireV1(obj, what);
302
- const out: ChildLinkReplyV1 = {
303
- v: 1,
304
- result: reqEnum(obj, "result", ["linked", "rejected"] as const, what),
305
- };
306
- assignDefined(out, "reason", optString(obj, "reason", what));
307
- return out;
308
- }
309
-
310
- function validateCohortTopicSummary(value: unknown): CohortTopicSummary {
311
- const what = "CohortTopicSummary";
312
- const obj = asObject(value, what);
313
- return {
314
- topicId: b64urlField(reqString(obj, "topicId", what), "topicId", what),
315
- tier: tier(reqFiniteNumber(obj, "tier", what), what),
316
- directParticipants: reqFiniteNumber(obj, "directParticipants", what),
317
- arrivalsPerMin: reqFiniteNumber(obj, "arrivalsPerMin", what),
318
- queriesPerMin: reqFiniteNumber(obj, "queriesPerMin", what),
319
- promoted: reqBool(obj, "promoted", what),
320
- childCohortCount: reqFiniteNumber(obj, "childCohortCount", what),
321
- };
322
- }
323
-
324
- function validateGossipRecordV1(value: unknown): GossipRecordV1 {
325
- const what = "GossipRecordV1";
326
- const obj = asObject(value, what);
327
- const out: GossipRecordV1 = {
328
- topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
329
- participantId: b64urlField(reqString(obj, "participantId", what), "participantId", what),
330
- tier: tier(reqFiniteNumber(obj, "tier", what), what),
331
- primary: b64urlField(reqString(obj, "primary", what), "primary", what),
332
- backups: reqStringArray(obj, "backups", what).map((b) => b64urlField(b, "backups", what)),
333
- attachedAt: reqFiniteNumber(obj, "attachedAt", what),
334
- lastPing: reqFiniteNumber(obj, "lastPing", what),
335
- ttl: reqFiniteNumber(obj, "ttl", what),
336
- };
337
- const appState = optString(obj, "appState", what);
338
- if (appState !== undefined) {
339
- out.appState = b64urlField(appState, "appState", what);
340
- }
341
- return out;
342
- }
343
-
344
- function validateGossipRecordRefV1(value: unknown): GossipRecordRefV1 {
345
- const what = "GossipRecordRefV1";
346
- const obj = asObject(value, what);
347
- return {
348
- topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
349
- participantId: b64urlField(reqString(obj, "participantId", what), "participantId", what),
350
- lastPing: reqFiniteNumber(obj, "lastPing", what),
351
- };
352
- }
353
-
354
- function validateChildLinkRefV1(value: unknown): ChildLinkRefV1 {
355
- const what = "ChildLinkRefV1";
356
- const obj = asObject(value, what);
357
- return {
358
- topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
359
- childCohortCoord: b64urlField(reqString(obj, "childCohortCoord", what), "childCohortCoord", what),
360
- effectiveAt: reqFiniteNumber(obj, "effectiveAt", what),
361
- };
362
- }
363
-
364
- /** `willingnessBits` carries exactly 4 bits (T0..T3) as a single hex nibble. */
365
- const WILLINGNESS_RE = /^[0-9a-fA-F]$/;
366
-
367
- export function validateCohortGossipV1(value: unknown): CohortGossipV1 {
368
- const what = "CohortGossipV1";
369
- const obj = asObject(value, what);
370
- requireV1(obj, what);
371
- const willingnessBits = reqString(obj, "willingnessBits", what);
372
- if (!WILLINGNESS_RE.test(willingnessBits)) {
373
- fail(`${what}: field "willingnessBits" must be a single hex nibble (4 bits)`);
374
- }
375
- const loadBuckets = obj["loadBuckets"];
376
- if (!Array.isArray(loadBuckets) || loadBuckets.length !== 4) {
377
- fail(`${what}: field "loadBuckets" must be an array of length 4`);
378
- }
379
- for (const bucket of loadBuckets) {
380
- if (typeof bucket !== "number" || !Number.isInteger(bucket) || bucket < 0 || bucket > 7) {
381
- fail(`${what}: each loadBuckets entry must be an integer in 0..7`);
382
- }
383
- }
384
- const summaries = obj["topicSummaries"];
385
- if (!Array.isArray(summaries)) {
386
- fail(`${what}: field "topicSummaries" must be an array`);
387
- }
388
- const treeTier = reqFiniteNumber(obj, "treeTier", what);
389
- if (!Number.isInteger(treeTier) || treeTier < 0) {
390
- fail(`${what}: field "treeTier" must be a non-negative integer, got ${treeTier}`);
391
- }
392
- const out: CohortGossipV1 = {
393
- v: 1,
394
- fromMember: reqString(obj, "fromMember", what),
395
- coord: b64urlFixedLen(reqString(obj, "coord", what), "coord", COORD_BYTES, what),
396
- cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
397
- treeTier,
398
- willingnessBits,
399
- loadBuckets: loadBuckets as number[],
400
- windowSeconds: reqFiniteNumber(obj, "windowSeconds", what),
401
- topicSummaries: summaries.map(validateCohortTopicSummary),
402
- timestamp: reqFiniteNumber(obj, "timestamp", what),
403
- signature: b64urlField(reqString(obj, "signature", what), "signature", what),
404
- };
405
- const records = obj["records"];
406
- if (records !== undefined) {
407
- if (!Array.isArray(records)) {
408
- fail(`${what}: field "records" must be an array when present`);
409
- }
410
- out.records = records.map(validateGossipRecordV1);
411
- }
412
- const evicted = obj["evicted"];
413
- if (evicted !== undefined) {
414
- if (!Array.isArray(evicted)) {
415
- fail(`${what}: field "evicted" must be an array when present`);
416
- }
417
- out.evicted = evicted.map(validateGossipRecordRefV1);
418
- }
419
- const childLinks = obj["childLinks"];
420
- if (childLinks !== undefined) {
421
- if (!Array.isArray(childLinks)) {
422
- fail(`${what}: field "childLinks" must be an array when present`);
423
- }
424
- out.childLinks = childLinks.map(validateChildLinkRefV1);
425
- }
426
- const childUnlinks = obj["childUnlinks"];
427
- if (childUnlinks !== undefined) {
428
- if (!Array.isArray(childUnlinks)) {
429
- fail(`${what}: field "childUnlinks" must be an array when present`);
430
- }
431
- out.childUnlinks = childUnlinks.map(validateChildLinkRefV1);
432
- }
433
- return out;
434
- }
435
-
436
- const SIGN_KINDS: readonly SignKind[] = ["membership", "promotion", "demotion", "rotation", "childlink"];
437
-
438
- export function validateSignRequestV1(value: unknown): SignRequestV1 {
439
- const what = "SignRequestV1";
440
- const obj = asObject(value, what);
441
- requireV1(obj, what);
442
- return {
443
- v: 1,
444
- kind: reqEnum(obj, "kind", SIGN_KINDS, what),
445
- coord: b64urlFixedLen(reqString(obj, "coord", what), "coord", COORD_BYTES, what),
446
- cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
447
- payload: b64urlField(reqString(obj, "payload", what), "payload", what),
448
- };
449
- }
450
-
451
- export function validateSignReplyV1(value: unknown): SignReplyV1 {
452
- const what = "SignReplyV1";
453
- const obj = asObject(value, what);
454
- requireV1(obj, what);
455
- // Discriminated by `refused`: a refusal carries a reason; an endorsement carries signer + signature.
456
- if (obj["refused"] !== undefined) {
457
- if (obj["refused"] !== true) {
458
- fail(`${what}: field "refused" must be true when present`);
459
- }
460
- return { v: 1, refused: true, reason: reqString(obj, "reason", what) };
461
- }
462
- return {
463
- v: 1,
464
- signer: b64urlField(reqString(obj, "signer", what), "signer", what),
465
- signature: b64urlField(reqString(obj, "signature", what), "signature", what),
466
- };
467
- }
468
-
469
- export function validateMembershipCertV1(value: unknown): MembershipCertV1 {
470
- const what = "MembershipCertV1";
471
- const obj = asObject(value, what);
472
- requireV1(obj, what);
473
- const out: MembershipCertV1 = {
474
- v: 1,
475
- cohortCoord: b64urlFixedLen(reqString(obj, "cohortCoord", what), "cohortCoord", COORD_BYTES, what),
476
- cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
477
- members: reqStringArray(obj, "members", what),
478
- stabilizedAt: reqFiniteNumber(obj, "stabilizedAt", what),
479
- thresholdSig: b64urlField(reqString(obj, "thresholdSig", what), "thresholdSig", what),
480
- signers: reqStringArray(obj, "signers", what),
481
- };
482
- const attestation = optString(obj, "fretAttestation", what);
483
- if (attestation !== undefined) {
484
- out.fretAttestation = b64urlField(attestation, "fretAttestation", what);
485
- }
486
- validateRotationAttestation(obj, out, what);
487
- return out;
488
- }
489
-
490
- /**
491
- * Validate the optional rotation attestation as an all-or-nothing group: either all three of
492
- * `prevEpoch`/`rotationSig`/`rotationSigners` are present (and well-formed) or all are absent. A
493
- * partial set is a {@link CohortWireError}. `rotationSigners` is validated only as a string array (its
494
- * elements are decoded per-element later, mirroring `signers`).
495
- */
496
- function validateRotationAttestation(obj: Record<string, unknown>, out: MembershipCertV1, what: string): void {
497
- const prevEpoch = optString(obj, "prevEpoch", what);
498
- const rotationSig = optString(obj, "rotationSig", what);
499
- const rotationSigners = optStringArray(obj, "rotationSigners", what);
500
- const presentCount = [prevEpoch, rotationSig, rotationSigners].filter((v) => v !== undefined).length;
501
- if (presentCount === 0) {
502
- return;
503
- }
504
- if (presentCount !== 3) {
505
- fail(`${what}: rotation attestation requires all of prevEpoch, rotationSig, rotationSigners — or none`);
506
- }
507
- // prevEpoch is a prior cohortEpoch, so it inherits cohortEpoch's leniency (see the b64urlField note
508
- // and debt-cohort-topic-pin-cohort-epoch).
509
- out.prevEpoch = b64urlField(prevEpoch!, "prevEpoch", what);
510
- out.rotationSig = b64urlField(rotationSig!, "rotationSig", what);
511
- out.rotationSigners = rotationSigners!;
512
- }
1
+ /**
2
+ * Cohort-topic substrate — per-message structural validation.
3
+ *
4
+ * Each validator narrows an already-parsed `unknown` (the output of `JSON.parse` on a decoded
5
+ * frame) to a concrete V1 interface, throwing {@link CohortWireError} on any structural defect:
6
+ * missing required field, wrong `v`, out-of-enum discriminant, a byte field that does not decode
7
+ * as base64url, or an out-of-range numeric. Validators never return partial data — they either
8
+ * narrow cleanly or throw.
9
+ */
10
+
11
+ import { DEFAULT_D_MAX_CAP } from "../dmax.js";
12
+ import {
13
+ assignDefined,
14
+ asObject,
15
+ b64urlField,
16
+ b64urlFixedLen,
17
+ failWire as fail,
18
+ optBool,
19
+ optFiniteNumber,
20
+ optString,
21
+ optStringArray,
22
+ reqBool,
23
+ reqEnum,
24
+ reqFiniteNumber,
25
+ reqString,
26
+ reqStringArray,
27
+ requireV1,
28
+ } from "./primitives.js";
29
+ import type {
30
+ ChildLinkRefV1,
31
+ ChildLinkReplyV1,
32
+ ChildLinkV1,
33
+ CohortGossipV1,
34
+ CohortTopicSummary,
35
+ DemotionNoticeV1,
36
+ GossipRecordRefV1,
37
+ GossipRecordV1,
38
+ MembershipCertV1,
39
+ PromotionNoticeV1,
40
+ RegisterReplyV1,
41
+ RegisterResult,
42
+ RegisterV1,
43
+ RenewReplyV1,
44
+ RenewV1,
45
+ SignKind,
46
+ SignRequestV1,
47
+ SignReplyV1,
48
+ TopicTrafficV1,
49
+ } from "./types.js";
50
+
51
+ // The generic structural-validation primitives (`fail`/`asObject`/`req*`/`opt*`/`b64url*`/`reqEnum`/…)
52
+ // live in ./primitives.js and are imported above; only the cohort-topic domain validators and the
53
+ // tier-semantics helpers below stay local. `CohortWireError` is re-exported here so existing importers
54
+ // of this module (codec, wire/index, matchmaking, reactivity) are unaffected by the move.
55
+ export { CohortWireError } from "./primitives.js";
56
+
57
+ function tier(value: number, what: string): number {
58
+ if (!Number.isInteger(value) || value < 0 || value > 3) {
59
+ fail(`${what}: tier must be an integer in 0..3, got ${value}`);
60
+ }
61
+ return value;
62
+ }
63
+
64
+ /**
65
+ * Validate a `treeTier` (the walk-toward-root start tier `d` a register/redirect targets): an integer in
66
+ * `0..DEFAULT_D_MAX_CAP`. `d_max_cap` (60) is the substrate's own ceiling on useful walk depth, so a
67
+ * `treeTier` above it cannot correspond to a real walk position. Rejecting here keeps an out-of-range value
68
+ * from reaching `addressing.coord()` downstream — whose `coordD` throws a raw `RangeError` for a
69
+ * non-integer / negative / `> 255` tier, an unclassified crash rather than a clean malformed-frame rejection.
70
+ */
71
+ function treeTier(value: number, what: string): number {
72
+ if (!Number.isInteger(value) || value < 0 || value > DEFAULT_D_MAX_CAP) {
73
+ fail(`${what}: treeTier must be an integer in 0..${DEFAULT_D_MAX_CAP}, got ${value}`);
74
+ }
75
+ return value;
76
+ }
77
+
78
+ /** Ring-coord / topic-id / epoch byte width (SHA-256 truncated to the ring width). */
79
+ const COORD_BYTES = 32;
80
+ /** Correlation-id byte width (a 16-byte nonce minted per walk probe / renew). */
81
+ const CORRELATION_BYTES = 16;
82
+
83
+ const REGISTER_RESULTS: readonly RegisterResult[] = [
84
+ "accepted",
85
+ "no_state",
86
+ "promoted",
87
+ "unwilling_member",
88
+ "unwilling_cohort",
89
+ ];
90
+
91
+ export function validateRegisterV1(value: unknown): RegisterV1 {
92
+ const what = "RegisterV1";
93
+ const obj = asObject(value, what);
94
+ requireV1(obj, what);
95
+ const out: RegisterV1 = {
96
+ v: 1,
97
+ topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
98
+ tier: tier(reqFiniteNumber(obj, "tier", what), what),
99
+ treeTier: treeTier(reqFiniteNumber(obj, "treeTier", what), what),
100
+ participantCoord: b64urlField(reqString(obj, "participantCoord", what), "participantCoord", what),
101
+ ttl: reqFiniteNumber(obj, "ttl", what),
102
+ timestamp: reqFiniteNumber(obj, "timestamp", what),
103
+ correlationId: b64urlFixedLen(reqString(obj, "correlationId", what), "correlationId", CORRELATION_BYTES, what),
104
+ signature: b64urlField(reqString(obj, "signature", what), "signature", what),
105
+ };
106
+ const bootstrap = optBool(obj, "bootstrap", what);
107
+ if (bootstrap !== undefined) {
108
+ out.bootstrap = bootstrap;
109
+ }
110
+ const probe = optBool(obj, "probe", what);
111
+ if (probe !== undefined) {
112
+ out.probe = probe;
113
+ }
114
+ const followOn = optBool(obj, "followOn", what);
115
+ if (followOn !== undefined) {
116
+ out.followOn = followOn;
117
+ }
118
+ // `bootstrap`, `followOn`, and `probe` are pairwise mutually exclusive — the walk sets at most one
119
+ // (bootstrap only at the tier-0 root re-issue, followOn only at a `treeTier >= 1` redirect target,
120
+ // probe never instantiates). A frame that sets more than one is malformed / adversarial.
121
+ if ([bootstrap, followOn, probe].filter((flag) => flag === true).length > 1) {
122
+ fail(`${what}: at most one of bootstrap, followOn, probe may be set`);
123
+ }
124
+ // A follow-on is by definition a deeper-than-root growth point, so `treeTier >= 1`. A `followOn: true`
125
+ // at the root (treeTier 0) would be a bootstrap, not a follow-on — reject it as malformed.
126
+ if (followOn === true && out.treeTier < 1) {
127
+ fail(`${what}: followOn requires treeTier >= 1, got ${out.treeTier}`);
128
+ }
129
+ const appPayload = optString(obj, "appPayload", what);
130
+ if (appPayload !== undefined) {
131
+ out.appPayload = b64urlField(appPayload, "appPayload", what);
132
+ }
133
+ // Bootstrap-evidence envelope (base64url; present only on a `bootstrap: true` register). A non-string
134
+ // is rejected as malformed; an empty string is treated as absent so it normalizes to the same signed
135
+ // image placeholder as a missing field (see `registerSigningPayload`'s `normalizeEvidence`).
136
+ const bootstrapEvidence = optString(obj, "bootstrapEvidence", what);
137
+ if (bootstrapEvidence !== undefined && bootstrapEvidence !== "") {
138
+ out.bootstrapEvidence = b64urlField(bootstrapEvidence, "bootstrapEvidence", what);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ function validateTopicTrafficV1(value: unknown): TopicTrafficV1 {
144
+ const what = "TopicTrafficV1";
145
+ const obj = asObject(value, what);
146
+ return {
147
+ windowSeconds: reqFiniteNumber(obj, "windowSeconds", what),
148
+ arrivalsPerMin: reqFiniteNumber(obj, "arrivalsPerMin", what),
149
+ queriesPerMin: reqFiniteNumber(obj, "queriesPerMin", what),
150
+ directParticipants: reqFiniteNumber(obj, "directParticipants", what),
151
+ childCohortCount: reqFiniteNumber(obj, "childCohortCount", what),
152
+ };
153
+ }
154
+
155
+ export function validateRegisterReplyV1(value: unknown): RegisterReplyV1 {
156
+ const what = "RegisterReplyV1";
157
+ const obj = asObject(value, what);
158
+ requireV1(obj, what);
159
+ const out: RegisterReplyV1 = {
160
+ v: 1,
161
+ result: reqEnum(obj, "result", REGISTER_RESULTS, what),
162
+ };
163
+ assignDefined(out, "primary", optString(obj, "primary", what));
164
+ assignDefined(out, "backups", optStringArray(obj, "backups", what));
165
+ const cohortEpoch = optString(obj, "cohortEpoch", what);
166
+ if (cohortEpoch !== undefined) {
167
+ // NOTE: cohortEpoch is NOT length-pinned though real epochs are a 32-byte SHA-256 — several
168
+ // test fixtures (db-core reactivity + db-p2p) feed 1-byte synthetic epochs. Pinning is tracked
169
+ // by debt-cohort-topic-pin-cohort-epoch. See b64urlField's note.
170
+ out.cohortEpoch = b64urlField(cohortEpoch, "cohortEpoch", what);
171
+ }
172
+ assignDefined(out, "cohortMembers", optStringArray(obj, "cohortMembers", what));
173
+ if (obj["topicTraffic"] !== undefined) {
174
+ out.topicTraffic = validateTopicTrafficV1(obj["topicTraffic"]);
175
+ }
176
+ // NOTE: `targetTier` is range-checked in the walk loop (walk.ts, case "promoted"), not here — an
177
+ // out-of-range redirect must surface as a `retry_later` outcome, not a decode-time throw.
178
+ assignDefined(out, "targetTier", optFiniteNumber(obj, "targetTier", what));
179
+ assignDefined(out, "candidateMembers", optStringArray(obj, "candidateMembers", what));
180
+ assignDefined(out, "retryAfterMs", optFiniteNumber(obj, "retryAfterMs", what));
181
+ assignDefined(out, "reason", optString(obj, "reason", what));
182
+ return out;
183
+ }
184
+
185
+ export function validateRenewV1(value: unknown): RenewV1 {
186
+ const what = "RenewV1";
187
+ const obj = asObject(value, what);
188
+ requireV1(obj, what);
189
+ const out: RenewV1 = {
190
+ v: 1,
191
+ topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
192
+ participantId: reqString(obj, "participantId", what),
193
+ correlationId: b64urlFixedLen(reqString(obj, "correlationId", what), "correlationId", CORRELATION_BYTES, what),
194
+ timestamp: reqFiniteNumber(obj, "timestamp", what),
195
+ signature: b64urlField(reqString(obj, "signature", what), "signature", what),
196
+ };
197
+ assignDefined(out, "reattach", optBool(obj, "reattach", what));
198
+ assignDefined(out, "withdraw", optBool(obj, "withdraw", what));
199
+ return out;
200
+ }
201
+
202
+ export function validateRenewReplyV1(value: unknown): RenewReplyV1 {
203
+ const what = "RenewReplyV1";
204
+ const obj = asObject(value, what);
205
+ requireV1(obj, what);
206
+ const out: RenewReplyV1 = {
207
+ v: 1,
208
+ result: reqEnum(obj, "result", ["ok", "unknown_registration", "primary_moved", "withdrawn"] as const, what),
209
+ };
210
+ assignDefined(out, "newPrimary", optString(obj, "newPrimary", what));
211
+ assignDefined(out, "newBackups", optStringArray(obj, "newBackups", what));
212
+ const cohortEpoch = optString(obj, "cohortEpoch", what);
213
+ if (cohortEpoch !== undefined) {
214
+ // NOTE: cohortEpoch is NOT length-pinned — see validateRegisterReplyV1's note and
215
+ // debt-cohort-topic-pin-cohort-epoch.
216
+ out.cohortEpoch = b64urlField(cohortEpoch, "cohortEpoch", what);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ export function validatePromotionNoticeV1(value: unknown): PromotionNoticeV1 {
222
+ const what = "PromotionNoticeV1";
223
+ const obj = asObject(value, what);
224
+ requireV1(obj, what);
225
+ const fromTier = treeTier(reqFiniteNumber(obj, "fromTier", what), what);
226
+ const toTier = treeTier(reqFiniteNumber(obj, "toTier", what), what);
227
+ if (toTier !== fromTier + 1) {
228
+ fail(`${what}: toTier must equal fromTier + 1, got fromTier=${fromTier} toTier=${toTier}`);
229
+ }
230
+ return {
231
+ v: 1,
232
+ topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
233
+ fromTier,
234
+ toTier,
235
+ cohortCoord: b64urlFixedLen(reqString(obj, "cohortCoord", what), "cohortCoord", COORD_BYTES, what),
236
+ effectiveAt: reqFiniteNumber(obj, "effectiveAt", what),
237
+ thresholdSig: b64urlField(reqString(obj, "thresholdSig", what), "thresholdSig", what),
238
+ signers: reqStringArray(obj, "signers", what),
239
+ cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
240
+ };
241
+ }
242
+
243
+ export function validateDemotionNoticeV1(value: unknown): DemotionNoticeV1 {
244
+ const what = "DemotionNoticeV1";
245
+ const obj = asObject(value, what);
246
+ requireV1(obj, what);
247
+ return {
248
+ v: 1,
249
+ topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
250
+ tier: treeTier(reqFiniteNumber(obj, "tier", what), what),
251
+ parentCohortCoord: b64urlFixedLen(reqString(obj, "parentCohortCoord", what), "parentCohortCoord", COORD_BYTES, what),
252
+ cohortCoord: b64urlFixedLen(reqString(obj, "cohortCoord", what), "cohortCoord", COORD_BYTES, what),
253
+ effectiveAt: reqFiniteNumber(obj, "effectiveAt", what),
254
+ thresholdSig: b64urlField(reqString(obj, "thresholdSig", what), "thresholdSig", what),
255
+ signers: reqStringArray(obj, "signers", what),
256
+ cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Validate a {@link ChildLinkV1}. Enforces `childTier >= 1` (the root never links), `tier` in 0..3, and
262
+ * well-formed base64url on every byte field. The hash-derived fields (`topicId` / `childCohortCoord` /
263
+ * `cohortEpoch`) are additionally length-checked to exactly 32 bytes (each is a SHA-256 truncation); the
264
+ * `childParticipantCoord` follows the lenient `RegisterV1` convention (base64url only — a participant
265
+ * coord is not always 32 raw bytes, e.g. a multihash-encoded peer id in tests). When `minSigs`
266
+ * is supplied AND the frame carries a threshold signature (`thresholdSig` non-empty), `signers.length` must
267
+ * be `>= minSigs`; a key-less-interim frame carries neither, so that cross-field bound is skipped. `minSigs`
268
+ * is optional so a bare structural decode (no quorum context) still narrows the frame.
269
+ */
270
+ export function validateChildLinkV1(value: unknown, minSigs?: number): ChildLinkV1 {
271
+ const what = "ChildLinkV1";
272
+ const obj = asObject(value, what);
273
+ requireV1(obj, what);
274
+ const childTier = reqFiniteNumber(obj, "childTier", what);
275
+ if (!Number.isInteger(childTier) || childTier < 1) {
276
+ fail(`${what}: childTier must be an integer >= 1, got ${childTier}`);
277
+ }
278
+ const thresholdSig = b64urlField(reqString(obj, "thresholdSig", what), "thresholdSig", what);
279
+ const signers = reqStringArray(obj, "signers", what).map((s) => b64urlField(s, "signers", what));
280
+ if (minSigs !== undefined && thresholdSig.length > 0 && signers.length < minSigs) {
281
+ fail(`${what}: a signed child-link needs signers.length >= ${minSigs}, got ${signers.length}`);
282
+ }
283
+ return {
284
+ v: 1,
285
+ topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
286
+ childCohortCoord: b64urlFixedLen(reqString(obj, "childCohortCoord", what), "childCohortCoord", COORD_BYTES, what),
287
+ childParticipantCoord: b64urlField(reqString(obj, "childParticipantCoord", what), "childParticipantCoord", what),
288
+ childTier,
289
+ tier: tier(reqFiniteNumber(obj, "tier", what), what),
290
+ effectiveAt: reqFiniteNumber(obj, "effectiveAt", what),
291
+ thresholdSig,
292
+ signers,
293
+ cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
294
+ };
295
+ }
296
+
297
+ /** Validate a {@link ChildLinkReplyV1}: `result` in `linked | rejected`, optional human-readable `reason`. */
298
+ export function validateChildLinkReplyV1(value: unknown): ChildLinkReplyV1 {
299
+ const what = "ChildLinkReplyV1";
300
+ const obj = asObject(value, what);
301
+ requireV1(obj, what);
302
+ const out: ChildLinkReplyV1 = {
303
+ v: 1,
304
+ result: reqEnum(obj, "result", ["linked", "rejected"] as const, what),
305
+ };
306
+ assignDefined(out, "reason", optString(obj, "reason", what));
307
+ return out;
308
+ }
309
+
310
+ function validateCohortTopicSummary(value: unknown): CohortTopicSummary {
311
+ const what = "CohortTopicSummary";
312
+ const obj = asObject(value, what);
313
+ return {
314
+ topicId: b64urlField(reqString(obj, "topicId", what), "topicId", what),
315
+ tier: tier(reqFiniteNumber(obj, "tier", what), what),
316
+ directParticipants: reqFiniteNumber(obj, "directParticipants", what),
317
+ arrivalsPerMin: reqFiniteNumber(obj, "arrivalsPerMin", what),
318
+ queriesPerMin: reqFiniteNumber(obj, "queriesPerMin", what),
319
+ promoted: reqBool(obj, "promoted", what),
320
+ childCohortCount: reqFiniteNumber(obj, "childCohortCount", what),
321
+ };
322
+ }
323
+
324
+ function validateGossipRecordV1(value: unknown): GossipRecordV1 {
325
+ const what = "GossipRecordV1";
326
+ const obj = asObject(value, what);
327
+ const out: GossipRecordV1 = {
328
+ topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
329
+ participantId: b64urlField(reqString(obj, "participantId", what), "participantId", what),
330
+ tier: tier(reqFiniteNumber(obj, "tier", what), what),
331
+ primary: b64urlField(reqString(obj, "primary", what), "primary", what),
332
+ backups: reqStringArray(obj, "backups", what).map((b) => b64urlField(b, "backups", what)),
333
+ attachedAt: reqFiniteNumber(obj, "attachedAt", what),
334
+ lastPing: reqFiniteNumber(obj, "lastPing", what),
335
+ ttl: reqFiniteNumber(obj, "ttl", what),
336
+ };
337
+ const appState = optString(obj, "appState", what);
338
+ if (appState !== undefined) {
339
+ out.appState = b64urlField(appState, "appState", what);
340
+ }
341
+ return out;
342
+ }
343
+
344
+ function validateGossipRecordRefV1(value: unknown): GossipRecordRefV1 {
345
+ const what = "GossipRecordRefV1";
346
+ const obj = asObject(value, what);
347
+ return {
348
+ topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
349
+ participantId: b64urlField(reqString(obj, "participantId", what), "participantId", what),
350
+ lastPing: reqFiniteNumber(obj, "lastPing", what),
351
+ };
352
+ }
353
+
354
+ function validateChildLinkRefV1(value: unknown): ChildLinkRefV1 {
355
+ const what = "ChildLinkRefV1";
356
+ const obj = asObject(value, what);
357
+ return {
358
+ topicId: b64urlFixedLen(reqString(obj, "topicId", what), "topicId", COORD_BYTES, what),
359
+ childCohortCoord: b64urlField(reqString(obj, "childCohortCoord", what), "childCohortCoord", what),
360
+ effectiveAt: reqFiniteNumber(obj, "effectiveAt", what),
361
+ };
362
+ }
363
+
364
+ /** `willingnessBits` carries exactly 4 bits (T0..T3) as a single hex nibble. */
365
+ const WILLINGNESS_RE = /^[0-9a-fA-F]$/;
366
+
367
+ export function validateCohortGossipV1(value: unknown): CohortGossipV1 {
368
+ const what = "CohortGossipV1";
369
+ const obj = asObject(value, what);
370
+ requireV1(obj, what);
371
+ const willingnessBits = reqString(obj, "willingnessBits", what);
372
+ if (!WILLINGNESS_RE.test(willingnessBits)) {
373
+ fail(`${what}: field "willingnessBits" must be a single hex nibble (4 bits)`);
374
+ }
375
+ const loadBuckets = obj["loadBuckets"];
376
+ if (!Array.isArray(loadBuckets) || loadBuckets.length !== 4) {
377
+ fail(`${what}: field "loadBuckets" must be an array of length 4`);
378
+ }
379
+ for (const bucket of loadBuckets) {
380
+ if (typeof bucket !== "number" || !Number.isInteger(bucket) || bucket < 0 || bucket > 7) {
381
+ fail(`${what}: each loadBuckets entry must be an integer in 0..7`);
382
+ }
383
+ }
384
+ const summaries = obj["topicSummaries"];
385
+ if (!Array.isArray(summaries)) {
386
+ fail(`${what}: field "topicSummaries" must be an array`);
387
+ }
388
+ const treeTier = reqFiniteNumber(obj, "treeTier", what);
389
+ if (!Number.isInteger(treeTier) || treeTier < 0) {
390
+ fail(`${what}: field "treeTier" must be a non-negative integer, got ${treeTier}`);
391
+ }
392
+ const out: CohortGossipV1 = {
393
+ v: 1,
394
+ fromMember: reqString(obj, "fromMember", what),
395
+ coord: b64urlFixedLen(reqString(obj, "coord", what), "coord", COORD_BYTES, what),
396
+ cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
397
+ treeTier,
398
+ willingnessBits,
399
+ loadBuckets: loadBuckets as number[],
400
+ windowSeconds: reqFiniteNumber(obj, "windowSeconds", what),
401
+ topicSummaries: summaries.map(validateCohortTopicSummary),
402
+ timestamp: reqFiniteNumber(obj, "timestamp", what),
403
+ signature: b64urlField(reqString(obj, "signature", what), "signature", what),
404
+ };
405
+ const records = obj["records"];
406
+ if (records !== undefined) {
407
+ if (!Array.isArray(records)) {
408
+ fail(`${what}: field "records" must be an array when present`);
409
+ }
410
+ out.records = records.map(validateGossipRecordV1);
411
+ }
412
+ const evicted = obj["evicted"];
413
+ if (evicted !== undefined) {
414
+ if (!Array.isArray(evicted)) {
415
+ fail(`${what}: field "evicted" must be an array when present`);
416
+ }
417
+ out.evicted = evicted.map(validateGossipRecordRefV1);
418
+ }
419
+ const childLinks = obj["childLinks"];
420
+ if (childLinks !== undefined) {
421
+ if (!Array.isArray(childLinks)) {
422
+ fail(`${what}: field "childLinks" must be an array when present`);
423
+ }
424
+ out.childLinks = childLinks.map(validateChildLinkRefV1);
425
+ }
426
+ const childUnlinks = obj["childUnlinks"];
427
+ if (childUnlinks !== undefined) {
428
+ if (!Array.isArray(childUnlinks)) {
429
+ fail(`${what}: field "childUnlinks" must be an array when present`);
430
+ }
431
+ out.childUnlinks = childUnlinks.map(validateChildLinkRefV1);
432
+ }
433
+ return out;
434
+ }
435
+
436
+ const SIGN_KINDS: readonly SignKind[] = ["membership", "promotion", "demotion", "rotation", "childlink"];
437
+
438
+ export function validateSignRequestV1(value: unknown): SignRequestV1 {
439
+ const what = "SignRequestV1";
440
+ const obj = asObject(value, what);
441
+ requireV1(obj, what);
442
+ return {
443
+ v: 1,
444
+ kind: reqEnum(obj, "kind", SIGN_KINDS, what),
445
+ coord: b64urlFixedLen(reqString(obj, "coord", what), "coord", COORD_BYTES, what),
446
+ cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
447
+ payload: b64urlField(reqString(obj, "payload", what), "payload", what),
448
+ };
449
+ }
450
+
451
+ export function validateSignReplyV1(value: unknown): SignReplyV1 {
452
+ const what = "SignReplyV1";
453
+ const obj = asObject(value, what);
454
+ requireV1(obj, what);
455
+ // Discriminated by `refused`: a refusal carries a reason; an endorsement carries signer + signature.
456
+ if (obj["refused"] !== undefined) {
457
+ if (obj["refused"] !== true) {
458
+ fail(`${what}: field "refused" must be true when present`);
459
+ }
460
+ return { v: 1, refused: true, reason: reqString(obj, "reason", what) };
461
+ }
462
+ return {
463
+ v: 1,
464
+ signer: b64urlField(reqString(obj, "signer", what), "signer", what),
465
+ signature: b64urlField(reqString(obj, "signature", what), "signature", what),
466
+ };
467
+ }
468
+
469
+ export function validateMembershipCertV1(value: unknown): MembershipCertV1 {
470
+ const what = "MembershipCertV1";
471
+ const obj = asObject(value, what);
472
+ requireV1(obj, what);
473
+ const out: MembershipCertV1 = {
474
+ v: 1,
475
+ cohortCoord: b64urlFixedLen(reqString(obj, "cohortCoord", what), "cohortCoord", COORD_BYTES, what),
476
+ cohortEpoch: b64urlField(reqString(obj, "cohortEpoch", what), "cohortEpoch", what),
477
+ members: reqStringArray(obj, "members", what),
478
+ stabilizedAt: reqFiniteNumber(obj, "stabilizedAt", what),
479
+ thresholdSig: b64urlField(reqString(obj, "thresholdSig", what), "thresholdSig", what),
480
+ signers: reqStringArray(obj, "signers", what),
481
+ };
482
+ const attestation = optString(obj, "fretAttestation", what);
483
+ if (attestation !== undefined) {
484
+ out.fretAttestation = b64urlField(attestation, "fretAttestation", what);
485
+ }
486
+ validateRotationAttestation(obj, out, what);
487
+ return out;
488
+ }
489
+
490
+ /**
491
+ * Validate the optional rotation attestation as an all-or-nothing group: either all three of
492
+ * `prevEpoch`/`rotationSig`/`rotationSigners` are present (and well-formed) or all are absent. A
493
+ * partial set is a {@link CohortWireError}. `rotationSigners` is validated only as a string array (its
494
+ * elements are decoded per-element later, mirroring `signers`).
495
+ */
496
+ function validateRotationAttestation(obj: Record<string, unknown>, out: MembershipCertV1, what: string): void {
497
+ const prevEpoch = optString(obj, "prevEpoch", what);
498
+ const rotationSig = optString(obj, "rotationSig", what);
499
+ const rotationSigners = optStringArray(obj, "rotationSigners", what);
500
+ const presentCount = [prevEpoch, rotationSig, rotationSigners].filter((v) => v !== undefined).length;
501
+ if (presentCount === 0) {
502
+ return;
503
+ }
504
+ if (presentCount !== 3) {
505
+ fail(`${what}: rotation attestation requires all of prevEpoch, rotationSig, rotationSigners — or none`);
506
+ }
507
+ // prevEpoch is a prior cohortEpoch, so it inherits cohortEpoch's leniency (see the b64urlField note
508
+ // and debt-cohort-topic-pin-cohort-epoch).
509
+ out.prevEpoch = b64urlField(prevEpoch!, "prevEpoch", what);
510
+ out.rotationSig = b64urlField(rotationSig!, "rotationSig", what);
511
+ out.rotationSigners = rotationSigners!;
512
+ }