@optimystic/db-core 0.22.0 → 0.24.1

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 (141) hide show
  1. package/README.md +336 -336
  2. package/dist/src/cluster/structs.d.ts +39 -1
  3. package/dist/src/cluster/structs.d.ts.map +1 -1
  4. package/dist/src/cluster/structs.js +24 -0
  5. package/dist/src/cluster/structs.js.map +1 -1
  6. package/dist/src/collection/collection.d.ts +17 -0
  7. package/dist/src/collection/collection.d.ts.map +1 -1
  8. package/dist/src/collection/collection.js +24 -2
  9. package/dist/src/collection/collection.js.map +1 -1
  10. package/dist/src/collections/tree/tree.d.ts +5 -0
  11. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  12. package/dist/src/collections/tree/tree.js +7 -0
  13. package/dist/src/collections/tree/tree.js.map +1 -1
  14. package/dist/src/network/i-peer-network.d.ts +16 -0
  15. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  16. package/dist/src/network/struct.d.ts +39 -2
  17. package/dist/src/network/struct.d.ts.map +1 -1
  18. package/dist/src/network/struct.js +18 -0
  19. package/dist/src/network/struct.js.map +1 -1
  20. package/dist/src/testing/test-transactor.d.ts +95 -8
  21. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  22. package/dist/src/testing/test-transactor.js +121 -8
  23. package/dist/src/testing/test-transactor.js.map +1 -1
  24. package/dist/src/transaction/transaction.d.ts +1 -1
  25. package/dist/src/transaction/transaction.js +1 -1
  26. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  27. package/dist/src/transactor/network-transactor.js +48 -13
  28. package/dist/src/transactor/network-transactor.js.map +1 -1
  29. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  30. package/dist/src/transactor/transactor-source.js +25 -2
  31. package/dist/src/transactor/transactor-source.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cluster/membership.ts +85 -85
  34. package/src/cluster/structs.ts +43 -4
  35. package/src/cohort-topic/addressing.ts +120 -120
  36. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  37. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  38. package/src/cohort-topic/antidos/index.ts +5 -5
  39. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  40. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  41. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  42. package/src/cohort-topic/antiflood/index.ts +2 -2
  43. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  44. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  45. package/src/cohort-topic/coldstart.ts +237 -237
  46. package/src/cohort-topic/dmax.ts +88 -88
  47. package/src/cohort-topic/gossip/bus.ts +254 -254
  48. package/src/cohort-topic/gossip/index.ts +3 -3
  49. package/src/cohort-topic/gossip/records.ts +45 -45
  50. package/src/cohort-topic/gossip/view.ts +91 -91
  51. package/src/cohort-topic/index.ts +20 -20
  52. package/src/cohort-topic/load/barometer.ts +134 -134
  53. package/src/cohort-topic/load/index.ts +1 -1
  54. package/src/cohort-topic/member-engine.ts +430 -430
  55. package/src/cohort-topic/membership/index.ts +3 -3
  56. package/src/cohort-topic/membership/publisher.ts +163 -163
  57. package/src/cohort-topic/membership/source.ts +41 -41
  58. package/src/cohort-topic/membership/verifier.ts +461 -461
  59. package/src/cohort-topic/ports.ts +157 -157
  60. package/src/cohort-topic/promotion.ts +405 -405
  61. package/src/cohort-topic/registration/bytes.ts +37 -37
  62. package/src/cohort-topic/registration/handoff.ts +154 -154
  63. package/src/cohort-topic/registration/index.ts +6 -6
  64. package/src/cohort-topic/registration/renewal.ts +495 -495
  65. package/src/cohort-topic/registration/sharding.ts +61 -61
  66. package/src/cohort-topic/registration/store.ts +81 -81
  67. package/src/cohort-topic/registration/types.ts +91 -91
  68. package/src/cohort-topic/ring-hash.ts +50 -50
  69. package/src/cohort-topic/service.ts +416 -416
  70. package/src/cohort-topic/sig/index.ts +2 -2
  71. package/src/cohort-topic/sig/payloads.ts +59 -59
  72. package/src/cohort-topic/sig/threshold.ts +64 -64
  73. package/src/cohort-topic/tiers.ts +74 -74
  74. package/src/cohort-topic/traffic.ts +233 -233
  75. package/src/cohort-topic/walk.ts +326 -326
  76. package/src/cohort-topic/willingness.ts +237 -237
  77. package/src/cohort-topic/wire/codec.ts +216 -216
  78. package/src/cohort-topic/wire/index.ts +18 -18
  79. package/src/cohort-topic/wire/payloads.ts +126 -126
  80. package/src/cohort-topic/wire/primitives.ts +188 -188
  81. package/src/cohort-topic/wire/types.ts +475 -475
  82. package/src/cohort-topic/wire/validate.ts +512 -512
  83. package/src/collection/collection-type-registry.ts +37 -37
  84. package/src/collection/collection.ts +25 -2
  85. package/src/collections/diary/diary.ts +68 -68
  86. package/src/collections/tree/readme.md +4 -0
  87. package/src/collections/tree/tree.ts +320 -312
  88. package/src/matchmaking/capability-filter.ts +45 -45
  89. package/src/matchmaking/config.ts +98 -98
  90. package/src/matchmaking/index.ts +21 -21
  91. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  92. package/src/matchmaking/provider.ts +123 -123
  93. package/src/matchmaking/query-eval.ts +105 -105
  94. package/src/matchmaking/seeker-walk.ts +127 -127
  95. package/src/matchmaking/seeker.ts +86 -86
  96. package/src/matchmaking/topic-anchor.ts +90 -90
  97. package/src/matchmaking/voting-quorum.ts +394 -394
  98. package/src/matchmaking/wire.ts +603 -603
  99. package/src/network/i-peer-network.ts +17 -0
  100. package/src/network/stale-failure.ts +43 -43
  101. package/src/network/struct.ts +41 -2
  102. package/src/network/types.ts +37 -37
  103. package/src/reactivity/backfill.ts +220 -220
  104. package/src/reactivity/backpressure.ts +191 -191
  105. package/src/reactivity/checkpoint.ts +308 -308
  106. package/src/reactivity/config.ts +172 -172
  107. package/src/reactivity/dedupe.ts +132 -132
  108. package/src/reactivity/forwarder.ts +87 -87
  109. package/src/reactivity/index.ts +34 -34
  110. package/src/reactivity/notification.ts +123 -123
  111. package/src/reactivity/policy.ts +79 -79
  112. package/src/reactivity/push-state.ts +310 -310
  113. package/src/reactivity/recover.ts +153 -153
  114. package/src/reactivity/replay-buffer.ts +141 -141
  115. package/src/reactivity/resume.ts +549 -549
  116. package/src/reactivity/rotation.ts +415 -415
  117. package/src/reactivity/subscriber.ts +132 -132
  118. package/src/reactivity/subscription.ts +66 -66
  119. package/src/reactivity/topic-anchor.ts +71 -71
  120. package/src/reactivity/verify.ts +73 -73
  121. package/src/reactivity/wire-validate.ts +13 -13
  122. package/src/reactivity/wire.ts +224 -224
  123. package/src/testing/async-wait.ts +65 -65
  124. package/src/testing/index.ts +2 -2
  125. package/src/testing/test-transactor.ts +638 -502
  126. package/src/transaction/errors.ts +91 -91
  127. package/src/transaction/operations-hash.ts +196 -196
  128. package/src/transaction/read-dependency-collector.ts +78 -78
  129. package/src/transaction/transaction.ts +1 -1
  130. package/src/transactor/change-notifier.ts +80 -80
  131. package/src/transactor/index.ts +5 -5
  132. package/src/transactor/network-transactor.ts +49 -14
  133. package/src/transactor/transactor-source.ts +25 -2
  134. package/src/transform/atomic-proxy.ts +92 -92
  135. package/src/transform/helpers.ts +159 -159
  136. package/src/utility/backoff.ts +95 -95
  137. package/src/utility/batch-coordinator.ts +191 -191
  138. package/dist/src/transaction/context.d.ts +0 -60
  139. package/dist/src/transaction/context.d.ts.map +0 -1
  140. package/dist/src/transaction/context.js +0 -91
  141. package/dist/src/transaction/context.js.map +0 -1
@@ -1,126 +1,126 @@
1
- /**
2
- * Cohort-topic substrate — canonical participant-signature payloads.
3
- *
4
- * A participant peer-key-signs its outbound `RegisterV1` / `RenewV1` bodies (db-p2p supplies the
5
- * libp2p peer key); a cohort member recomputes the identical byte image to verify that signature
6
- * before admitting a register (anti-DoS) or honoring a crash-failover `reattach` attestation (so a
7
- * stray/MITM'd ping cannot usurp a live primary — `docs/cohort-topic.md` §TTL and renewal).
8
- *
9
- * Signer and verifier must agree byte-for-byte. Exactly like {@link import("../sig/payloads.js")}
10
- * for the threshold-signed notices, determinism comes from encoding an explicitly-ordered JSON
11
- * array (array order is stable, unlike object key order) as UTF-8 — never the `signature` envelope.
12
- * Optional fields are normalized to a fixed placeholder (`bootstrap`→`false`, `probe`→`false`,
13
- * `followOn`→`false`, `appPayload`→`null`, `bootstrapEvidence`→`null` with an empty string treated as
14
- * absent, `reattach`→`false`, `withdraw`→`false`) so an absent optional and a present-but-default
15
- * optional can never disagree across the wire round-trip.
16
- */
17
-
18
- import type { CohortGossipV1, RegisterV1, RenewV1 } from "./types.js";
19
-
20
- const utf8 = new TextEncoder();
21
-
22
- /** A `RegisterV1` minus its `signature` envelope — the bytes the participant peer-key-signs. */
23
- export type RegisterSignable = Omit<RegisterV1, "signature">;
24
-
25
- /** A `RenewV1` minus its `signature` envelope — the bytes the participant peer-key-signs. */
26
- export type RenewSignable = Omit<RenewV1, "signature">;
27
-
28
- /** Normalize the optional bootstrap-evidence slot: absent and empty-string both map to `null`. */
29
- function normalizeEvidence(bootstrapEvidence: string | undefined): string | null {
30
- return bootstrapEvidence === undefined || bootstrapEvidence === "" ? null : bootstrapEvidence;
31
- }
32
-
33
- /** Canonical signed byte image of a `RegisterV1` body (every field except `signature`). */
34
- export function registerSigningPayload(body: RegisterSignable): Uint8Array {
35
- return utf8.encode(JSON.stringify([
36
- "RegisterV1",
37
- body.v,
38
- body.topicId,
39
- body.tier,
40
- body.treeTier,
41
- body.participantCoord,
42
- body.ttl,
43
- body.bootstrap ?? false,
44
- body.appPayload ?? null,
45
- normalizeEvidence(body.bootstrapEvidence),
46
- body.timestamp,
47
- body.correlationId,
48
- // A read-only probe signs `probe: true`; a normal register signs `false`. Both sides recompute
49
- // this image identically, so a probe and a register over the same fields produce distinct signatures.
50
- body.probe ?? false,
51
- // A follow-on cold-start re-issue signs `followOn: true`, everything else `false`. Strictly appended
52
- // (sibling to `probe`/`withdraw`) so a MITM cannot strip or flip the follow-on assertion and the
53
- // signer (participant) + verifier (db-p2p cohort) agree byte-for-byte. Safe to append: no register
54
- // signatures are persisted (each is recomputed per verify), so there is no cross-version image concern.
55
- body.followOn ?? false,
56
- ]));
57
- }
58
-
59
- /** Canonical signed byte image of a `RenewV1` body (every field except `signature`). */
60
- export function renewSigningPayload(body: RenewSignable): Uint8Array {
61
- return utf8.encode(JSON.stringify([
62
- "RenewV1",
63
- body.v,
64
- body.topicId,
65
- body.participantId,
66
- body.correlationId,
67
- body.timestamp,
68
- body.reattach ?? false,
69
- // A withdraw tombstone signs `withdraw: true`, a ping/reattach signs `false`. Strictly appended
70
- // (array length 7→8) so the reattach-vs-ping distinction is preserved and a third party cannot
71
- // evict someone else's registration. Signer (participant) + verifier (db-p2p) move together; no
72
- // signatures are persisted, so there is no cross-version concern.
73
- body.withdraw ?? false,
74
- ]));
75
- }
76
-
77
- /** A `CohortGossipV1` minus its `signature` envelope — the bytes the gossiping member peer-key-signs. */
78
- export type CohortGossipSignable = Omit<CohortGossipV1, "signature">;
79
-
80
- /**
81
- * Canonical signed byte image of a `CohortGossipV1` (every field except `signature`). Intra-cohort
82
- * gossip is peer-key-signed by the originating member so a receiver can drop a frame whose `fromMember`
83
- * signature does not verify or that comes from a non-cohort member (it can never spoof willingness/load
84
- * or replicate forged records). Like the other payload helpers, determinism comes from an
85
- * explicitly-ordered array — nested records/summaries/evictions are emitted as fixed ordered tuples so
86
- * the signer and the receiver (which re-derives this image from the validated frame) agree byte-for-byte.
87
- * Absent optionals (`records`/`evicted`/`childLinks`/`childUnlinks`) normalize to `[]`, and `appState` to
88
- * `null`. `childLinks`/`childUnlinks` are covered so a MITM cannot strip or inject a child link/unlink.
89
- */
90
- export function cohortGossipSigningPayload(g: CohortGossipSignable): Uint8Array {
91
- return utf8.encode(JSON.stringify([
92
- "CohortGossipV1",
93
- g.v,
94
- g.fromMember,
95
- g.coord,
96
- g.cohortEpoch,
97
- g.treeTier,
98
- g.willingnessBits,
99
- g.loadBuckets,
100
- g.windowSeconds,
101
- g.topicSummaries.map((s) => [
102
- s.topicId,
103
- s.tier,
104
- s.directParticipants,
105
- s.arrivalsPerMin,
106
- s.queriesPerMin,
107
- s.promoted,
108
- s.childCohortCount,
109
- ]),
110
- (g.records ?? []).map((r) => [
111
- r.topicId,
112
- r.participantId,
113
- r.tier,
114
- r.primary,
115
- r.backups,
116
- r.attachedAt,
117
- r.lastPing,
118
- r.ttl,
119
- r.appState ?? null,
120
- ]),
121
- (g.evicted ?? []).map((e) => [e.topicId, e.participantId, e.lastPing]),
122
- (g.childLinks ?? []).map((c) => [c.topicId, c.childCohortCoord, c.effectiveAt]),
123
- (g.childUnlinks ?? []).map((c) => [c.topicId, c.childCohortCoord, c.effectiveAt]),
124
- g.timestamp,
125
- ]));
126
- }
1
+ /**
2
+ * Cohort-topic substrate — canonical participant-signature payloads.
3
+ *
4
+ * A participant peer-key-signs its outbound `RegisterV1` / `RenewV1` bodies (db-p2p supplies the
5
+ * libp2p peer key); a cohort member recomputes the identical byte image to verify that signature
6
+ * before admitting a register (anti-DoS) or honoring a crash-failover `reattach` attestation (so a
7
+ * stray/MITM'd ping cannot usurp a live primary — `docs/cohort-topic.md` §TTL and renewal).
8
+ *
9
+ * Signer and verifier must agree byte-for-byte. Exactly like {@link import("../sig/payloads.js")}
10
+ * for the threshold-signed notices, determinism comes from encoding an explicitly-ordered JSON
11
+ * array (array order is stable, unlike object key order) as UTF-8 — never the `signature` envelope.
12
+ * Optional fields are normalized to a fixed placeholder (`bootstrap`→`false`, `probe`→`false`,
13
+ * `followOn`→`false`, `appPayload`→`null`, `bootstrapEvidence`→`null` with an empty string treated as
14
+ * absent, `reattach`→`false`, `withdraw`→`false`) so an absent optional and a present-but-default
15
+ * optional can never disagree across the wire round-trip.
16
+ */
17
+
18
+ import type { CohortGossipV1, RegisterV1, RenewV1 } from "./types.js";
19
+
20
+ const utf8 = new TextEncoder();
21
+
22
+ /** A `RegisterV1` minus its `signature` envelope — the bytes the participant peer-key-signs. */
23
+ export type RegisterSignable = Omit<RegisterV1, "signature">;
24
+
25
+ /** A `RenewV1` minus its `signature` envelope — the bytes the participant peer-key-signs. */
26
+ export type RenewSignable = Omit<RenewV1, "signature">;
27
+
28
+ /** Normalize the optional bootstrap-evidence slot: absent and empty-string both map to `null`. */
29
+ function normalizeEvidence(bootstrapEvidence: string | undefined): string | null {
30
+ return bootstrapEvidence === undefined || bootstrapEvidence === "" ? null : bootstrapEvidence;
31
+ }
32
+
33
+ /** Canonical signed byte image of a `RegisterV1` body (every field except `signature`). */
34
+ export function registerSigningPayload(body: RegisterSignable): Uint8Array {
35
+ return utf8.encode(JSON.stringify([
36
+ "RegisterV1",
37
+ body.v,
38
+ body.topicId,
39
+ body.tier,
40
+ body.treeTier,
41
+ body.participantCoord,
42
+ body.ttl,
43
+ body.bootstrap ?? false,
44
+ body.appPayload ?? null,
45
+ normalizeEvidence(body.bootstrapEvidence),
46
+ body.timestamp,
47
+ body.correlationId,
48
+ // A read-only probe signs `probe: true`; a normal register signs `false`. Both sides recompute
49
+ // this image identically, so a probe and a register over the same fields produce distinct signatures.
50
+ body.probe ?? false,
51
+ // A follow-on cold-start re-issue signs `followOn: true`, everything else `false`. Strictly appended
52
+ // (sibling to `probe`/`withdraw`) so a MITM cannot strip or flip the follow-on assertion and the
53
+ // signer (participant) + verifier (db-p2p cohort) agree byte-for-byte. Safe to append: no register
54
+ // signatures are persisted (each is recomputed per verify), so there is no cross-version image concern.
55
+ body.followOn ?? false,
56
+ ]));
57
+ }
58
+
59
+ /** Canonical signed byte image of a `RenewV1` body (every field except `signature`). */
60
+ export function renewSigningPayload(body: RenewSignable): Uint8Array {
61
+ return utf8.encode(JSON.stringify([
62
+ "RenewV1",
63
+ body.v,
64
+ body.topicId,
65
+ body.participantId,
66
+ body.correlationId,
67
+ body.timestamp,
68
+ body.reattach ?? false,
69
+ // A withdraw tombstone signs `withdraw: true`, a ping/reattach signs `false`. Strictly appended
70
+ // (array length 7→8) so the reattach-vs-ping distinction is preserved and a third party cannot
71
+ // evict someone else's registration. Signer (participant) + verifier (db-p2p) move together; no
72
+ // signatures are persisted, so there is no cross-version concern.
73
+ body.withdraw ?? false,
74
+ ]));
75
+ }
76
+
77
+ /** A `CohortGossipV1` minus its `signature` envelope — the bytes the gossiping member peer-key-signs. */
78
+ export type CohortGossipSignable = Omit<CohortGossipV1, "signature">;
79
+
80
+ /**
81
+ * Canonical signed byte image of a `CohortGossipV1` (every field except `signature`). Intra-cohort
82
+ * gossip is peer-key-signed by the originating member so a receiver can drop a frame whose `fromMember`
83
+ * signature does not verify or that comes from a non-cohort member (it can never spoof willingness/load
84
+ * or replicate forged records). Like the other payload helpers, determinism comes from an
85
+ * explicitly-ordered array — nested records/summaries/evictions are emitted as fixed ordered tuples so
86
+ * the signer and the receiver (which re-derives this image from the validated frame) agree byte-for-byte.
87
+ * Absent optionals (`records`/`evicted`/`childLinks`/`childUnlinks`) normalize to `[]`, and `appState` to
88
+ * `null`. `childLinks`/`childUnlinks` are covered so a MITM cannot strip or inject a child link/unlink.
89
+ */
90
+ export function cohortGossipSigningPayload(g: CohortGossipSignable): Uint8Array {
91
+ return utf8.encode(JSON.stringify([
92
+ "CohortGossipV1",
93
+ g.v,
94
+ g.fromMember,
95
+ g.coord,
96
+ g.cohortEpoch,
97
+ g.treeTier,
98
+ g.willingnessBits,
99
+ g.loadBuckets,
100
+ g.windowSeconds,
101
+ g.topicSummaries.map((s) => [
102
+ s.topicId,
103
+ s.tier,
104
+ s.directParticipants,
105
+ s.arrivalsPerMin,
106
+ s.queriesPerMin,
107
+ s.promoted,
108
+ s.childCohortCount,
109
+ ]),
110
+ (g.records ?? []).map((r) => [
111
+ r.topicId,
112
+ r.participantId,
113
+ r.tier,
114
+ r.primary,
115
+ r.backups,
116
+ r.attachedAt,
117
+ r.lastPing,
118
+ r.ttl,
119
+ r.appState ?? null,
120
+ ]),
121
+ (g.evicted ?? []).map((e) => [e.topicId, e.participantId, e.lastPing]),
122
+ (g.childLinks ?? []).map((c) => [c.topicId, c.childCohortCoord, c.effectiveAt]),
123
+ (g.childUnlinks ?? []).map((c) => [c.topicId, c.childCohortCoord, c.effectiveAt]),
124
+ g.timestamp,
125
+ ]));
126
+ }
@@ -1,188 +1,188 @@
1
- /**
2
- * Cohort-topic wire substrate — shared structural-validation primitives.
3
- *
4
- * The generic per-field checks every wire codec across the substrate (cohort-topic, matchmaking,
5
- * reactivity) uses to narrow an already-parsed `unknown` (the output of `JSON.parse` on a decoded
6
- * frame) into a validated V1 shape. Each helper throws {@link CohortWireError} on a structural defect:
7
- * missing required field, wrong `v`, out-of-enum discriminant, a byte field that does not decode as
8
- * base64url, or an out-of-range numeric.
9
- *
10
- * These are the *only* generic primitives — domain-specific narrowing (the `validate*V1` functions, the
11
- * cohort-topic `tier` / `treeTier` semantics, matchmaking utf8 coders, reactivity notification shapes)
12
- * lives in the modules that own those message shapes. Keeping the primitives in one place means a
13
- * hardening tweak to a check (e.g. the fixed-length byte-width guard in {@link b64urlFixedLen}) lands
14
- * once and every consumer inherits it.
15
- */
16
-
17
- import { b64urlToBytes } from "./codec.js";
18
-
19
- /** Thrown for any malformed, oversized, or structurally invalid cohort-topic frame. */
20
- export class CohortWireError extends Error {
21
- constructor(message: string) {
22
- super(message);
23
- this.name = "CohortWireError";
24
- }
25
- }
26
-
27
- /** Throw a {@link CohortWireError} with `message`. */
28
- export function failWire(message: string): never {
29
- throw new CohortWireError(message);
30
- }
31
-
32
- /** Set `obj[key]` only when `value` is defined — keeps absent optionals off the decoded object. */
33
- export function assignDefined<T extends object, K extends keyof T>(obj: T, key: K, value: T[K] | undefined): void {
34
- if (value !== undefined) {
35
- obj[key] = value;
36
- }
37
- }
38
-
39
- /** Narrow `value` to a plain object (not null, not an array), or throw. */
40
- export function asObject(value: unknown, what: string): Record<string, unknown> {
41
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
42
- failWire(`${what}: expected an object`);
43
- }
44
- return value as Record<string, unknown>;
45
- }
46
-
47
- /** Require `obj.v === 1`. */
48
- export function requireV1(obj: Record<string, unknown>, what: string): void {
49
- if (obj["v"] !== 1) {
50
- failWire(`${what}: expected v === 1, got ${JSON.stringify(obj["v"])}`);
51
- }
52
- }
53
-
54
- /** Require `obj[key]` to be a string; returns it. */
55
- export function reqString(obj: Record<string, unknown>, key: string, what: string): string {
56
- const value = obj[key];
57
- if (typeof value !== "string") {
58
- failWire(`${what}: field "${key}" must be a string`);
59
- }
60
- return value;
61
- }
62
-
63
- /** Return `obj[key]` when it is a string, `undefined` when absent; throw on any other type. */
64
- export function optString(obj: Record<string, unknown>, key: string, what: string): string | undefined {
65
- const value = obj[key];
66
- if (value === undefined) {
67
- return undefined;
68
- }
69
- if (typeof value !== "string") {
70
- failWire(`${what}: field "${key}" must be a string when present`);
71
- }
72
- return value;
73
- }
74
-
75
- /** Require `obj[key]` to be a finite number; returns it. */
76
- export function reqFiniteNumber(obj: Record<string, unknown>, key: string, what: string): number {
77
- const value = obj[key];
78
- if (typeof value !== "number" || !Number.isFinite(value)) {
79
- failWire(`${what}: field "${key}" must be a finite number`);
80
- }
81
- return value;
82
- }
83
-
84
- /** Return `obj[key]` when it is a finite number, `undefined` when absent; throw on any other type. */
85
- export function optFiniteNumber(obj: Record<string, unknown>, key: string, what: string): number | undefined {
86
- const value = obj[key];
87
- if (value === undefined) {
88
- return undefined;
89
- }
90
- if (typeof value !== "number" || !Number.isFinite(value)) {
91
- failWire(`${what}: field "${key}" must be a finite number when present`);
92
- }
93
- return value;
94
- }
95
-
96
- /** Require `obj[key]` to be a boolean; returns it. */
97
- export function reqBool(obj: Record<string, unknown>, key: string, what: string): boolean {
98
- const value = obj[key];
99
- if (typeof value !== "boolean") {
100
- failWire(`${what}: field "${key}" must be a boolean`);
101
- }
102
- return value;
103
- }
104
-
105
- /** Return `obj[key]` when it is a boolean, `undefined` when absent; throw on any other type. */
106
- export function optBool(obj: Record<string, unknown>, key: string, what: string): boolean | undefined {
107
- const value = obj[key];
108
- if (value === undefined) {
109
- return undefined;
110
- }
111
- if (typeof value !== "boolean") {
112
- failWire(`${what}: field "${key}" must be a boolean when present`);
113
- }
114
- return value;
115
- }
116
-
117
- /**
118
- * Require `obj[key]` to be an integer `>= min` (and `<= max` when supplied); returns it. Reads and
119
- * finite-checks the field itself (via {@link reqFiniteNumber}) so callers pass the object + key rather
120
- * than a pre-extracted value — this composes with the other `req*` helpers.
121
- */
122
- export function reqIntInRange(obj: Record<string, unknown>, key: string, what: string, min: number, max?: number): number {
123
- const value = reqFiniteNumber(obj, key, what);
124
- if (!Number.isInteger(value) || value < min || (max !== undefined && value > max)) {
125
- const bound = max === undefined ? `>= ${min}` : `in ${min}..${max}`;
126
- failWire(`${what}: field "${key}" must be an integer ${bound}, got ${value}`);
127
- }
128
- return value;
129
- }
130
-
131
- /** Require `obj[key]` to be an array of strings; returns it. */
132
- export function reqStringArray(obj: Record<string, unknown>, key: string, what: string): string[] {
133
- const value = obj[key];
134
- if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
135
- failWire(`${what}: field "${key}" must be an array of strings`);
136
- }
137
- return value as string[];
138
- }
139
-
140
- /** Return `obj[key]` when it is an array of strings, `undefined` when absent; throw on any other type. */
141
- export function optStringArray(obj: Record<string, unknown>, key: string, what: string): string[] | undefined {
142
- if (obj[key] === undefined) {
143
- return undefined;
144
- }
145
- return reqStringArray(obj, key, what);
146
- }
147
-
148
- /** Require `obj[key]` to be one of `allowed`; returns it narrowed to the enum type. */
149
- export function reqEnum<T extends string>(obj: Record<string, unknown>, key: string, allowed: readonly T[], what: string): T {
150
- const value = obj[key];
151
- if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
152
- failWire(`${what}: field "${key}" must be one of ${allowed.join(" | ")}`);
153
- }
154
- return value as T;
155
- }
156
-
157
- /**
158
- * Assert a base64url string decodes cleanly; returns it unchanged. Used for variable-width fields —
159
- * peer ids (multihash-encoded, not 32 raw bytes), signatures, and opaque application payloads.
160
- *
161
- * NOTE: no max-length bound here. A hostile peer can still bloat one of these variable-width fields
162
- * (e.g. `participantCoord`, a signature) into a large map key in the store / rate limiter / replay
163
- * guard. Their widths aren't pinned by the spec, so a ceiling would be a chosen policy value rather
164
- * than a decode of the format. If a bloated one is ever seen as a map key in practice, add a
165
- * `b64urlMaxLen` ceiling here. (Fixed-width hash-derived fields go through `b64urlFixedLen` instead.)
166
- */
167
- export function b64urlField(value: string, key: string, what: string): string {
168
- try {
169
- b64urlToBytes(value);
170
- } catch {
171
- failWire(`${what}: field "${key}" is not valid base64url`);
172
- }
173
- return value;
174
- }
175
-
176
- /** Assert a base64url string decodes cleanly to exactly `len` bytes; returns it unchanged. */
177
- export function b64urlFixedLen(value: string, key: string, len: number, what: string): string {
178
- let bytes: Uint8Array;
179
- try {
180
- bytes = b64urlToBytes(value);
181
- } catch {
182
- return failWire(`${what}: field "${key}" is not valid base64url`);
183
- }
184
- if (bytes.length !== len) {
185
- failWire(`${what}: field "${key}" must decode to ${len} bytes, got ${bytes.length}`);
186
- }
187
- return value;
188
- }
1
+ /**
2
+ * Cohort-topic wire substrate — shared structural-validation primitives.
3
+ *
4
+ * The generic per-field checks every wire codec across the substrate (cohort-topic, matchmaking,
5
+ * reactivity) uses to narrow an already-parsed `unknown` (the output of `JSON.parse` on a decoded
6
+ * frame) into a validated V1 shape. Each helper throws {@link CohortWireError} on a structural defect:
7
+ * missing required field, wrong `v`, out-of-enum discriminant, a byte field that does not decode as
8
+ * base64url, or an out-of-range numeric.
9
+ *
10
+ * These are the *only* generic primitives — domain-specific narrowing (the `validate*V1` functions, the
11
+ * cohort-topic `tier` / `treeTier` semantics, matchmaking utf8 coders, reactivity notification shapes)
12
+ * lives in the modules that own those message shapes. Keeping the primitives in one place means a
13
+ * hardening tweak to a check (e.g. the fixed-length byte-width guard in {@link b64urlFixedLen}) lands
14
+ * once and every consumer inherits it.
15
+ */
16
+
17
+ import { b64urlToBytes } from "./codec.js";
18
+
19
+ /** Thrown for any malformed, oversized, or structurally invalid cohort-topic frame. */
20
+ export class CohortWireError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = "CohortWireError";
24
+ }
25
+ }
26
+
27
+ /** Throw a {@link CohortWireError} with `message`. */
28
+ export function failWire(message: string): never {
29
+ throw new CohortWireError(message);
30
+ }
31
+
32
+ /** Set `obj[key]` only when `value` is defined — keeps absent optionals off the decoded object. */
33
+ export function assignDefined<T extends object, K extends keyof T>(obj: T, key: K, value: T[K] | undefined): void {
34
+ if (value !== undefined) {
35
+ obj[key] = value;
36
+ }
37
+ }
38
+
39
+ /** Narrow `value` to a plain object (not null, not an array), or throw. */
40
+ export function asObject(value: unknown, what: string): Record<string, unknown> {
41
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
42
+ failWire(`${what}: expected an object`);
43
+ }
44
+ return value as Record<string, unknown>;
45
+ }
46
+
47
+ /** Require `obj.v === 1`. */
48
+ export function requireV1(obj: Record<string, unknown>, what: string): void {
49
+ if (obj["v"] !== 1) {
50
+ failWire(`${what}: expected v === 1, got ${JSON.stringify(obj["v"])}`);
51
+ }
52
+ }
53
+
54
+ /** Require `obj[key]` to be a string; returns it. */
55
+ export function reqString(obj: Record<string, unknown>, key: string, what: string): string {
56
+ const value = obj[key];
57
+ if (typeof value !== "string") {
58
+ failWire(`${what}: field "${key}" must be a string`);
59
+ }
60
+ return value;
61
+ }
62
+
63
+ /** Return `obj[key]` when it is a string, `undefined` when absent; throw on any other type. */
64
+ export function optString(obj: Record<string, unknown>, key: string, what: string): string | undefined {
65
+ const value = obj[key];
66
+ if (value === undefined) {
67
+ return undefined;
68
+ }
69
+ if (typeof value !== "string") {
70
+ failWire(`${what}: field "${key}" must be a string when present`);
71
+ }
72
+ return value;
73
+ }
74
+
75
+ /** Require `obj[key]` to be a finite number; returns it. */
76
+ export function reqFiniteNumber(obj: Record<string, unknown>, key: string, what: string): number {
77
+ const value = obj[key];
78
+ if (typeof value !== "number" || !Number.isFinite(value)) {
79
+ failWire(`${what}: field "${key}" must be a finite number`);
80
+ }
81
+ return value;
82
+ }
83
+
84
+ /** Return `obj[key]` when it is a finite number, `undefined` when absent; throw on any other type. */
85
+ export function optFiniteNumber(obj: Record<string, unknown>, key: string, what: string): number | undefined {
86
+ const value = obj[key];
87
+ if (value === undefined) {
88
+ return undefined;
89
+ }
90
+ if (typeof value !== "number" || !Number.isFinite(value)) {
91
+ failWire(`${what}: field "${key}" must be a finite number when present`);
92
+ }
93
+ return value;
94
+ }
95
+
96
+ /** Require `obj[key]` to be a boolean; returns it. */
97
+ export function reqBool(obj: Record<string, unknown>, key: string, what: string): boolean {
98
+ const value = obj[key];
99
+ if (typeof value !== "boolean") {
100
+ failWire(`${what}: field "${key}" must be a boolean`);
101
+ }
102
+ return value;
103
+ }
104
+
105
+ /** Return `obj[key]` when it is a boolean, `undefined` when absent; throw on any other type. */
106
+ export function optBool(obj: Record<string, unknown>, key: string, what: string): boolean | undefined {
107
+ const value = obj[key];
108
+ if (value === undefined) {
109
+ return undefined;
110
+ }
111
+ if (typeof value !== "boolean") {
112
+ failWire(`${what}: field "${key}" must be a boolean when present`);
113
+ }
114
+ return value;
115
+ }
116
+
117
+ /**
118
+ * Require `obj[key]` to be an integer `>= min` (and `<= max` when supplied); returns it. Reads and
119
+ * finite-checks the field itself (via {@link reqFiniteNumber}) so callers pass the object + key rather
120
+ * than a pre-extracted value — this composes with the other `req*` helpers.
121
+ */
122
+ export function reqIntInRange(obj: Record<string, unknown>, key: string, what: string, min: number, max?: number): number {
123
+ const value = reqFiniteNumber(obj, key, what);
124
+ if (!Number.isInteger(value) || value < min || (max !== undefined && value > max)) {
125
+ const bound = max === undefined ? `>= ${min}` : `in ${min}..${max}`;
126
+ failWire(`${what}: field "${key}" must be an integer ${bound}, got ${value}`);
127
+ }
128
+ return value;
129
+ }
130
+
131
+ /** Require `obj[key]` to be an array of strings; returns it. */
132
+ export function reqStringArray(obj: Record<string, unknown>, key: string, what: string): string[] {
133
+ const value = obj[key];
134
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
135
+ failWire(`${what}: field "${key}" must be an array of strings`);
136
+ }
137
+ return value as string[];
138
+ }
139
+
140
+ /** Return `obj[key]` when it is an array of strings, `undefined` when absent; throw on any other type. */
141
+ export function optStringArray(obj: Record<string, unknown>, key: string, what: string): string[] | undefined {
142
+ if (obj[key] === undefined) {
143
+ return undefined;
144
+ }
145
+ return reqStringArray(obj, key, what);
146
+ }
147
+
148
+ /** Require `obj[key]` to be one of `allowed`; returns it narrowed to the enum type. */
149
+ export function reqEnum<T extends string>(obj: Record<string, unknown>, key: string, allowed: readonly T[], what: string): T {
150
+ const value = obj[key];
151
+ if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
152
+ failWire(`${what}: field "${key}" must be one of ${allowed.join(" | ")}`);
153
+ }
154
+ return value as T;
155
+ }
156
+
157
+ /**
158
+ * Assert a base64url string decodes cleanly; returns it unchanged. Used for variable-width fields —
159
+ * peer ids (multihash-encoded, not 32 raw bytes), signatures, and opaque application payloads.
160
+ *
161
+ * NOTE: no max-length bound here. A hostile peer can still bloat one of these variable-width fields
162
+ * (e.g. `participantCoord`, a signature) into a large map key in the store / rate limiter / replay
163
+ * guard. Their widths aren't pinned by the spec, so a ceiling would be a chosen policy value rather
164
+ * than a decode of the format. If a bloated one is ever seen as a map key in practice, add a
165
+ * `b64urlMaxLen` ceiling here. (Fixed-width hash-derived fields go through `b64urlFixedLen` instead.)
166
+ */
167
+ export function b64urlField(value: string, key: string, what: string): string {
168
+ try {
169
+ b64urlToBytes(value);
170
+ } catch {
171
+ failWire(`${what}: field "${key}" is not valid base64url`);
172
+ }
173
+ return value;
174
+ }
175
+
176
+ /** Assert a base64url string decodes cleanly to exactly `len` bytes; returns it unchanged. */
177
+ export function b64urlFixedLen(value: string, key: string, len: number, what: string): string {
178
+ let bytes: Uint8Array;
179
+ try {
180
+ bytes = b64urlToBytes(value);
181
+ } catch {
182
+ return failWire(`${what}: field "${key}" is not valid base64url`);
183
+ }
184
+ if (bytes.length !== len) {
185
+ failWire(`${what}: field "${key}" must decode to ${len} bytes, got ${bytes.length}`);
186
+ }
187
+ return value;
188
+ }