@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,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
+ }