@optimystic/db-core 0.22.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 (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,210 +1,210 @@
1
- /**
2
- * Cohort-topic substrate — per-peer registration rate limiter (anti-DoS).
3
- *
4
- * Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 1. A cohort member tracks inbound
5
- * `RegisterV1` rate per `(sourcePeerId, topicId)` and refuses a source that exceeds
6
- * `register_rate_per_peer` (default 4 / min). An over-rate registration draws `UnwillingCohort` with
7
- * an **exponential** `retryAfter`, so a peer hammering one topic at one cohort backs off geometrically
8
- * — the same capped-doubling curve the willingness back-off uses ({@link backoffRetryMs}).
9
- *
10
- * The limiter is a sliding-window counter: it keeps the accept timestamps inside the trailing
11
- * `windowMs` for each key, admits while the window holds `< ratePerWindow`, and on rejection advances
12
- * a per-key strike counter that indexes the back-off curve. A run of strikes decays once the source
13
- * has been quiet for a full window (its window empties and the strike counter resets), so a
14
- * well-behaved peer is never permanently penalized. Each key's accept history is trimmed to the live
15
- * window whenever that key is checked, so a key's footprint stays `O(ratePerWindow)`.
16
- *
17
- * The `states` map is bounded two complementary ways so a long-running host cannot leak memory and a
18
- * flood of attacker-chosen keys cannot exhaust it:
19
- *
20
- * - **Hard LRU cap (`maxKeys`)** — enforced inline in {@link SlidingWindowRateLimiter.check}. Every
21
- * check moves its key to the most-recently-checked end (the {@link import("../../utility/lru-map.js").LruMap}
22
- * delete-then-set trick over `Map` insertion order); when a *new* key would exceed `maxKeys` the
23
- * least-recently-checked keys are evicted until within cap. Recency is refreshed on **every** check,
24
- * rejects included, so a source mid-attack (accumulated `strikes` driving back-off) stays at the hot
25
- * end and is never the eviction victim — only genuinely idle keys age to the cold end.
26
- * - **Idle-TTL sweep (`idleTtlMs`)** — {@link SlidingWindowRateLimiter.sweep} drops keys not checked
27
- * within `idleTtlMs`. Driver-called on the host's gossip cadence, it reclaims steady-state footprint
28
- * proportional to *active* keys.
29
- *
30
- * Eviction is penalty-free when a key is dropped only after a full window of quiet: the window logic
31
- * already forgives a source quiet for a full window (its accepts age out and `strikes` resets), so
32
- * dropping such a key and re-allocating a fresh `{ accepts:[now], strikes:0 }` on its return is
33
- * observationally identical to keeping it — just an earlier reclaim. This holds for the LRU cap (a
34
- * mid-attack key is refreshed to the hot end on every check, so only genuinely idle keys are evicted)
35
- * and for the default `idleTtlMs` (`== windowMs`). A configured `idleTtlMs < windowMs` reclaims more
36
- * aggressively: `sweep` may then drop a key whose accepts have *not* fully aged out, forgiving its
37
- * accumulated `strikes` sooner than the window alone would — an accepted footprint/strike-accounting
38
- * tradeoff, not an identity. The substrate does not defend against unbounded Sybil key creation (that is FRET's /
39
- * the reputation subsystem's concern; see §Anti-DoS closing note).
40
- */
41
-
42
- import { recordKey } from "../registration/bytes.js";
43
- import { backoffRetryMs, type BackoffConfig, DEFAULT_BACKOFF_CONFIG } from "../willingness.js";
44
- import { createLogger } from "../../logger.js";
45
-
46
- const log = createLogger("cohort-topic:antidos");
47
-
48
- /** Default per-peer-per-topic acceptance ceiling per window — `register_rate_per_peer` (4 / min). */
49
- export const DEFAULT_REGISTER_RATE_PER_PEER = 4;
50
- /** Default sliding window the ceiling applies over (ms). */
51
- export const DEFAULT_RATE_WINDOW_MS = 60_000;
52
- /** Default hard cap on tracked `(peer, topic)` keys; the least-recently-checked are evicted beyond this. */
53
- export const DEFAULT_RATE_LIMITER_MAX_KEYS = 100_000;
54
- /** Default idle threshold for {@link RegisterRateLimiter.sweep} — one quiet window makes a key evictable. */
55
- export const DEFAULT_RATE_LIMITER_IDLE_TTL_MS = DEFAULT_RATE_WINDOW_MS;
56
-
57
- export interface RegisterRateLimiterConfig {
58
- /** Accepts permitted per window per `(peer, topic)`. Default {@link DEFAULT_REGISTER_RATE_PER_PEER}. */
59
- ratePerWindow?: number;
60
- /** Sliding-window length (ms). Default {@link DEFAULT_RATE_WINDOW_MS}. */
61
- windowMs?: number;
62
- /** Exponential `retryAfter` curve for over-rate sources. Default {@link DEFAULT_BACKOFF_CONFIG}. */
63
- backoff?: BackoffConfig;
64
- /** Hard cap on tracked `(peer, topic)` keys; least-recently-checked evicted beyond this. Default {@link DEFAULT_RATE_LIMITER_MAX_KEYS}. */
65
- maxKeys?: number;
66
- /**
67
- * A key not checked within this many ms is evictable by {@link RegisterRateLimiter.sweep}. Default
68
- * {@link DEFAULT_RATE_LIMITER_IDLE_TTL_MS} (`== windowMs`). Keep `>= windowMs` to preserve the
69
- * penalty-free invariant; a smaller value reclaims sooner but may forgive an idle source's strikes
70
- * before its window would (see the class doc comment).
71
- */
72
- idleTtlMs?: number;
73
- }
74
-
75
- /** Outcome of a rate check: admitted, or refused with the back-off the caller puts in `UnwillingCohort`. */
76
- export type RateCheckResult = { ok: true } | { ok: false; retryAfterMs: number };
77
-
78
- /** Per-peer-per-topic inbound `RegisterV1` rate limiter. */
79
- export interface RegisterRateLimiter {
80
- /**
81
- * Classify a register from `peerId` for `topicId` at `now`. Records the accept on `{ ok: true }`;
82
- * an over-rate source gets `{ ok: false, retryAfterMs }` with exponential back-off and the strike
83
- * is *not* recorded as an accept (so back-off cannot itself fill the window). Every call — accept
84
- * **or** reject — refreshes the key's recency, so an actively-hammering source is never evicted by
85
- * the LRU cap (which would reset its back-off escalation).
86
- */
87
- check(peerId: Uint8Array, topicId: Uint8Array, now: number): RateCheckResult;
88
- /**
89
- * Evict keys idle (not checked) for `>= idleTtlMs`. Returns the number evicted. Driver-called on
90
- * the host's gossip cadence to reclaim steady-state footprint; the hard `maxKeys` LRU cap bounds
91
- * worst-case footprint even without it.
92
- */
93
- sweep(now: number): number;
94
- /** Tracked `(peer, topic)` key count (test/diagnostic introspection). */
95
- readonly size: number;
96
- }
97
-
98
- /** Sliding-window state for one `(peer, topic)` key. */
99
- interface WindowState {
100
- /** Accept timestamps inside the trailing window, ascending. */
101
- accepts: number[];
102
- /** Consecutive over-rate strikes since the last accept — indexes the back-off curve. */
103
- strikes: number;
104
- /** `now` of the most recent check (accept OR reject) — LRU recency + idle-TTL key for {@link RegisterRateLimiter.sweep}. */
105
- lastSeen: number;
106
- }
107
-
108
- class SlidingWindowRateLimiter implements RegisterRateLimiter {
109
- private readonly states = new Map<string, WindowState>();
110
- private readonly ratePerWindow: number;
111
- private readonly windowMs: number;
112
- private readonly backoff: BackoffConfig;
113
- private readonly maxKeys: number;
114
- private readonly idleTtlMs: number;
115
-
116
- constructor(config: RegisterRateLimiterConfig = {}) {
117
- this.ratePerWindow = config.ratePerWindow ?? DEFAULT_REGISTER_RATE_PER_PEER;
118
- if (!Number.isInteger(this.ratePerWindow) || this.ratePerWindow <= 0) {
119
- throw new RangeError(`ratePerWindow must be a positive integer, got ${this.ratePerWindow}`);
120
- }
121
- this.windowMs = config.windowMs ?? DEFAULT_RATE_WINDOW_MS;
122
- if (!(this.windowMs > 0)) {
123
- throw new RangeError(`windowMs must be > 0, got ${this.windowMs}`);
124
- }
125
- this.backoff = config.backoff ?? DEFAULT_BACKOFF_CONFIG;
126
- this.maxKeys = config.maxKeys ?? DEFAULT_RATE_LIMITER_MAX_KEYS;
127
- if (!Number.isInteger(this.maxKeys) || this.maxKeys <= 0) {
128
- throw new RangeError(`maxKeys must be a positive integer, got ${this.maxKeys}`);
129
- }
130
- this.idleTtlMs = config.idleTtlMs ?? DEFAULT_RATE_LIMITER_IDLE_TTL_MS;
131
- if (!(this.idleTtlMs > 0)) {
132
- throw new RangeError(`idleTtlMs must be > 0, got ${this.idleTtlMs}`);
133
- }
134
- }
135
-
136
- check(peerId: Uint8Array, topicId: Uint8Array, now: number): RateCheckResult {
137
- const key = recordKey(topicId, peerId);
138
- const state = this.states.get(key);
139
- const cutoff = now - this.windowMs;
140
-
141
- if (state === undefined) {
142
- // New key: enforce the hard cap by evicting the least-recently-checked keys (oldest by
143
- // `Map` insertion order) until this insertion stays within `maxKeys`. The evicted keys
144
- // are by construction the coldest — an actively-checked key is refreshed to the hot end
145
- // below and so is never the victim.
146
- while (this.states.size >= this.maxKeys) {
147
- const oldest = this.states.keys().next().value;
148
- if (oldest === undefined) break;
149
- this.states.delete(oldest);
150
- }
151
- this.states.set(key, { accepts: [now], strikes: 0, lastSeen: now });
152
- return { ok: true };
153
- }
154
-
155
- // Refresh LRU recency on every check (accept OR reject): delete-then-set moves this key to the
156
- // most-recently-checked end of the `Map`, and `lastSeen = now` is the idle-TTL key sweep() reads.
157
- // A source mid-attack stays at the hot end here even on a reject, so the cap never evicts (and
158
- // thus never resets the back-off of) an active attacker.
159
- this.states.delete(key);
160
- this.states.set(key, state);
161
- state.lastSeen = now;
162
-
163
- // Drop accepts that have aged out of the trailing window.
164
- let live = 0;
165
- for (const t of state.accepts) {
166
- if (t > cutoff) {
167
- state.accepts[live++] = t;
168
- }
169
- }
170
- state.accepts.length = live;
171
-
172
- if (live === 0) {
173
- // The source has been quiet for a full window — forgive accumulated strikes.
174
- state.strikes = 0;
175
- }
176
-
177
- if (live < this.ratePerWindow) {
178
- state.accepts.push(now);
179
- state.strikes = 0;
180
- return { ok: true };
181
- }
182
-
183
- // Over rate: exponential back-off indexed by the strike count, then advance the strike.
184
- const retryAfterMs = backoffRetryMs(state.strikes, this.backoff);
185
- state.strikes++;
186
- log("rate-limit reject window=%d/%d strikes=%d retryAfter=%d", live, this.ratePerWindow, state.strikes, retryAfterMs);
187
- return { ok: false, retryAfterMs };
188
- }
189
-
190
- sweep(now: number): number {
191
- let evicted = 0;
192
- // Deleting the current key during `Map` iteration is well-defined and does not skip entries.
193
- for (const [key, state] of this.states) {
194
- if (now - state.lastSeen >= this.idleTtlMs) {
195
- this.states.delete(key);
196
- evicted++;
197
- }
198
- }
199
- return evicted;
200
- }
201
-
202
- get size(): number {
203
- return this.states.size;
204
- }
205
- }
206
-
207
- /** Build a {@link RegisterRateLimiter} over the configured ceiling, window, and back-off curve. */
208
- export function createRegisterRateLimiter(config: RegisterRateLimiterConfig = {}): RegisterRateLimiter {
209
- return new SlidingWindowRateLimiter(config);
210
- }
1
+ /**
2
+ * Cohort-topic substrate — per-peer registration rate limiter (anti-DoS).
3
+ *
4
+ * Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 1. A cohort member tracks inbound
5
+ * `RegisterV1` rate per `(sourcePeerId, topicId)` and refuses a source that exceeds
6
+ * `register_rate_per_peer` (default 4 / min). An over-rate registration draws `UnwillingCohort` with
7
+ * an **exponential** `retryAfter`, so a peer hammering one topic at one cohort backs off geometrically
8
+ * — the same capped-doubling curve the willingness back-off uses ({@link backoffRetryMs}).
9
+ *
10
+ * The limiter is a sliding-window counter: it keeps the accept timestamps inside the trailing
11
+ * `windowMs` for each key, admits while the window holds `< ratePerWindow`, and on rejection advances
12
+ * a per-key strike counter that indexes the back-off curve. A run of strikes decays once the source
13
+ * has been quiet for a full window (its window empties and the strike counter resets), so a
14
+ * well-behaved peer is never permanently penalized. Each key's accept history is trimmed to the live
15
+ * window whenever that key is checked, so a key's footprint stays `O(ratePerWindow)`.
16
+ *
17
+ * The `states` map is bounded two complementary ways so a long-running host cannot leak memory and a
18
+ * flood of attacker-chosen keys cannot exhaust it:
19
+ *
20
+ * - **Hard LRU cap (`maxKeys`)** — enforced inline in {@link SlidingWindowRateLimiter.check}. Every
21
+ * check moves its key to the most-recently-checked end (the {@link import("../../utility/lru-map.js").LruMap}
22
+ * delete-then-set trick over `Map` insertion order); when a *new* key would exceed `maxKeys` the
23
+ * least-recently-checked keys are evicted until within cap. Recency is refreshed on **every** check,
24
+ * rejects included, so a source mid-attack (accumulated `strikes` driving back-off) stays at the hot
25
+ * end and is never the eviction victim — only genuinely idle keys age to the cold end.
26
+ * - **Idle-TTL sweep (`idleTtlMs`)** — {@link SlidingWindowRateLimiter.sweep} drops keys not checked
27
+ * within `idleTtlMs`. Driver-called on the host's gossip cadence, it reclaims steady-state footprint
28
+ * proportional to *active* keys.
29
+ *
30
+ * Eviction is penalty-free when a key is dropped only after a full window of quiet: the window logic
31
+ * already forgives a source quiet for a full window (its accepts age out and `strikes` resets), so
32
+ * dropping such a key and re-allocating a fresh `{ accepts:[now], strikes:0 }` on its return is
33
+ * observationally identical to keeping it — just an earlier reclaim. This holds for the LRU cap (a
34
+ * mid-attack key is refreshed to the hot end on every check, so only genuinely idle keys are evicted)
35
+ * and for the default `idleTtlMs` (`== windowMs`). A configured `idleTtlMs < windowMs` reclaims more
36
+ * aggressively: `sweep` may then drop a key whose accepts have *not* fully aged out, forgiving its
37
+ * accumulated `strikes` sooner than the window alone would — an accepted footprint/strike-accounting
38
+ * tradeoff, not an identity. The substrate does not defend against unbounded Sybil key creation (that is FRET's /
39
+ * the reputation subsystem's concern; see §Anti-DoS closing note).
40
+ */
41
+
42
+ import { recordKey } from "../registration/bytes.js";
43
+ import { backoffRetryMs, type BackoffConfig, DEFAULT_BACKOFF_CONFIG } from "../willingness.js";
44
+ import { createLogger } from "../../logger.js";
45
+
46
+ const log = createLogger("cohort-topic:antidos");
47
+
48
+ /** Default per-peer-per-topic acceptance ceiling per window — `register_rate_per_peer` (4 / min). */
49
+ export const DEFAULT_REGISTER_RATE_PER_PEER = 4;
50
+ /** Default sliding window the ceiling applies over (ms). */
51
+ export const DEFAULT_RATE_WINDOW_MS = 60_000;
52
+ /** Default hard cap on tracked `(peer, topic)` keys; the least-recently-checked are evicted beyond this. */
53
+ export const DEFAULT_RATE_LIMITER_MAX_KEYS = 100_000;
54
+ /** Default idle threshold for {@link RegisterRateLimiter.sweep} — one quiet window makes a key evictable. */
55
+ export const DEFAULT_RATE_LIMITER_IDLE_TTL_MS = DEFAULT_RATE_WINDOW_MS;
56
+
57
+ export interface RegisterRateLimiterConfig {
58
+ /** Accepts permitted per window per `(peer, topic)`. Default {@link DEFAULT_REGISTER_RATE_PER_PEER}. */
59
+ ratePerWindow?: number;
60
+ /** Sliding-window length (ms). Default {@link DEFAULT_RATE_WINDOW_MS}. */
61
+ windowMs?: number;
62
+ /** Exponential `retryAfter` curve for over-rate sources. Default {@link DEFAULT_BACKOFF_CONFIG}. */
63
+ backoff?: BackoffConfig;
64
+ /** Hard cap on tracked `(peer, topic)` keys; least-recently-checked evicted beyond this. Default {@link DEFAULT_RATE_LIMITER_MAX_KEYS}. */
65
+ maxKeys?: number;
66
+ /**
67
+ * A key not checked within this many ms is evictable by {@link RegisterRateLimiter.sweep}. Default
68
+ * {@link DEFAULT_RATE_LIMITER_IDLE_TTL_MS} (`== windowMs`). Keep `>= windowMs` to preserve the
69
+ * penalty-free invariant; a smaller value reclaims sooner but may forgive an idle source's strikes
70
+ * before its window would (see the class doc comment).
71
+ */
72
+ idleTtlMs?: number;
73
+ }
74
+
75
+ /** Outcome of a rate check: admitted, or refused with the back-off the caller puts in `UnwillingCohort`. */
76
+ export type RateCheckResult = { ok: true } | { ok: false; retryAfterMs: number };
77
+
78
+ /** Per-peer-per-topic inbound `RegisterV1` rate limiter. */
79
+ export interface RegisterRateLimiter {
80
+ /**
81
+ * Classify a register from `peerId` for `topicId` at `now`. Records the accept on `{ ok: true }`;
82
+ * an over-rate source gets `{ ok: false, retryAfterMs }` with exponential back-off and the strike
83
+ * is *not* recorded as an accept (so back-off cannot itself fill the window). Every call — accept
84
+ * **or** reject — refreshes the key's recency, so an actively-hammering source is never evicted by
85
+ * the LRU cap (which would reset its back-off escalation).
86
+ */
87
+ check(peerId: Uint8Array, topicId: Uint8Array, now: number): RateCheckResult;
88
+ /**
89
+ * Evict keys idle (not checked) for `>= idleTtlMs`. Returns the number evicted. Driver-called on
90
+ * the host's gossip cadence to reclaim steady-state footprint; the hard `maxKeys` LRU cap bounds
91
+ * worst-case footprint even without it.
92
+ */
93
+ sweep(now: number): number;
94
+ /** Tracked `(peer, topic)` key count (test/diagnostic introspection). */
95
+ readonly size: number;
96
+ }
97
+
98
+ /** Sliding-window state for one `(peer, topic)` key. */
99
+ interface WindowState {
100
+ /** Accept timestamps inside the trailing window, ascending. */
101
+ accepts: number[];
102
+ /** Consecutive over-rate strikes since the last accept — indexes the back-off curve. */
103
+ strikes: number;
104
+ /** `now` of the most recent check (accept OR reject) — LRU recency + idle-TTL key for {@link RegisterRateLimiter.sweep}. */
105
+ lastSeen: number;
106
+ }
107
+
108
+ class SlidingWindowRateLimiter implements RegisterRateLimiter {
109
+ private readonly states = new Map<string, WindowState>();
110
+ private readonly ratePerWindow: number;
111
+ private readonly windowMs: number;
112
+ private readonly backoff: BackoffConfig;
113
+ private readonly maxKeys: number;
114
+ private readonly idleTtlMs: number;
115
+
116
+ constructor(config: RegisterRateLimiterConfig = {}) {
117
+ this.ratePerWindow = config.ratePerWindow ?? DEFAULT_REGISTER_RATE_PER_PEER;
118
+ if (!Number.isInteger(this.ratePerWindow) || this.ratePerWindow <= 0) {
119
+ throw new RangeError(`ratePerWindow must be a positive integer, got ${this.ratePerWindow}`);
120
+ }
121
+ this.windowMs = config.windowMs ?? DEFAULT_RATE_WINDOW_MS;
122
+ if (!(this.windowMs > 0)) {
123
+ throw new RangeError(`windowMs must be > 0, got ${this.windowMs}`);
124
+ }
125
+ this.backoff = config.backoff ?? DEFAULT_BACKOFF_CONFIG;
126
+ this.maxKeys = config.maxKeys ?? DEFAULT_RATE_LIMITER_MAX_KEYS;
127
+ if (!Number.isInteger(this.maxKeys) || this.maxKeys <= 0) {
128
+ throw new RangeError(`maxKeys must be a positive integer, got ${this.maxKeys}`);
129
+ }
130
+ this.idleTtlMs = config.idleTtlMs ?? DEFAULT_RATE_LIMITER_IDLE_TTL_MS;
131
+ if (!(this.idleTtlMs > 0)) {
132
+ throw new RangeError(`idleTtlMs must be > 0, got ${this.idleTtlMs}`);
133
+ }
134
+ }
135
+
136
+ check(peerId: Uint8Array, topicId: Uint8Array, now: number): RateCheckResult {
137
+ const key = recordKey(topicId, peerId);
138
+ const state = this.states.get(key);
139
+ const cutoff = now - this.windowMs;
140
+
141
+ if (state === undefined) {
142
+ // New key: enforce the hard cap by evicting the least-recently-checked keys (oldest by
143
+ // `Map` insertion order) until this insertion stays within `maxKeys`. The evicted keys
144
+ // are by construction the coldest — an actively-checked key is refreshed to the hot end
145
+ // below and so is never the victim.
146
+ while (this.states.size >= this.maxKeys) {
147
+ const oldest = this.states.keys().next().value;
148
+ if (oldest === undefined) break;
149
+ this.states.delete(oldest);
150
+ }
151
+ this.states.set(key, { accepts: [now], strikes: 0, lastSeen: now });
152
+ return { ok: true };
153
+ }
154
+
155
+ // Refresh LRU recency on every check (accept OR reject): delete-then-set moves this key to the
156
+ // most-recently-checked end of the `Map`, and `lastSeen = now` is the idle-TTL key sweep() reads.
157
+ // A source mid-attack stays at the hot end here even on a reject, so the cap never evicts (and
158
+ // thus never resets the back-off of) an active attacker.
159
+ this.states.delete(key);
160
+ this.states.set(key, state);
161
+ state.lastSeen = now;
162
+
163
+ // Drop accepts that have aged out of the trailing window.
164
+ let live = 0;
165
+ for (const t of state.accepts) {
166
+ if (t > cutoff) {
167
+ state.accepts[live++] = t;
168
+ }
169
+ }
170
+ state.accepts.length = live;
171
+
172
+ if (live === 0) {
173
+ // The source has been quiet for a full window — forgive accumulated strikes.
174
+ state.strikes = 0;
175
+ }
176
+
177
+ if (live < this.ratePerWindow) {
178
+ state.accepts.push(now);
179
+ state.strikes = 0;
180
+ return { ok: true };
181
+ }
182
+
183
+ // Over rate: exponential back-off indexed by the strike count, then advance the strike.
184
+ const retryAfterMs = backoffRetryMs(state.strikes, this.backoff);
185
+ state.strikes++;
186
+ log("rate-limit reject window=%d/%d strikes=%d retryAfter=%d", live, this.ratePerWindow, state.strikes, retryAfterMs);
187
+ return { ok: false, retryAfterMs };
188
+ }
189
+
190
+ sweep(now: number): number {
191
+ let evicted = 0;
192
+ // Deleting the current key during `Map` iteration is well-defined and does not skip entries.
193
+ for (const [key, state] of this.states) {
194
+ if (now - state.lastSeen >= this.idleTtlMs) {
195
+ this.states.delete(key);
196
+ evicted++;
197
+ }
198
+ }
199
+ return evicted;
200
+ }
201
+
202
+ get size(): number {
203
+ return this.states.size;
204
+ }
205
+ }
206
+
207
+ /** Build a {@link RegisterRateLimiter} over the configured ceiling, window, and back-off curve. */
208
+ export function createRegisterRateLimiter(config: RegisterRateLimiterConfig = {}): RegisterRateLimiter {
209
+ return new SlidingWindowRateLimiter(config);
210
+ }