@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,146 +1,146 @@
1
- /**
2
- * Cohort-topic substrate — correlation-id replay guard (anti-DoS).
3
- *
4
- * Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 3. Every `RegisterV1` carries a
5
- * `correlationId` (16 random bytes) and a signature over `(topicId, tier, correlationId, timestamp)`.
6
- * The signature (verified elsewhere — the participant-key check) proves authorship; this guard adds
7
- * **freshness**: it drops a registration whose `timestamp` is outside the accepted skew window
8
- * (stale, or implausibly far in the future) and drops a `correlationId` it has already seen inside the
9
- * window (a captured-and-replayed registration).
10
- *
11
- * Because a stale registration is rejected outright, the guard only needs to remember correlation ids
12
- * for one `maxAgeMs` window: an id older than that would be rejected on timestamp alone, so its record
13
- * can be pruned. Pruning runs on access, bounding memory to the live window's worth of registrations.
14
- *
15
- * On top of the age-based prune, the map carries a **hard LRU `maxKeys` cap** (mirroring the sibling
16
- * {@link import("./rate-limiter.js").RegisterRateLimiter}) so a flood of genuinely-fresh, admitted
17
- * correlationIds cannot grow `seen` without bound before the age prune fires. When a new id would
18
- * exceed the cap, the **oldest-inserted** entries are evicted until within cap. That victim is the
19
- * least-bad one: replay entries are inserted once and never refreshed, so `Map` insertion order tracks
20
- * timestamp order — the oldest entry is the one nearest to aging out of the window and being pruned as
21
- * stale anyway. Evicting it forgives at most that entry's remaining replay-protection window: a bounded,
22
- * documented tradeoff (unlike the rate limiter's fully penalty-free eviction), and one that only
23
- * triggers under a flood of admitted ids — which, in the register pipeline, must also have passed the
24
- * signature, rate, and bootstrap gates before ever reaching this guard.
25
- */
26
-
27
- import { bytesKey } from "../registration/bytes.js";
28
- import { createLogger } from "../../logger.js";
29
-
30
- const log = createLogger("cohort-topic:antidos");
31
-
32
- /** Default acceptance window: a registration timestamped older than this (relative to `now`) is stale. */
33
- export const DEFAULT_REPLAY_MAX_AGE_MS = 60_000;
34
- /** Default tolerated forward clock skew: a timestamp this far past `now` is rejected as implausible. */
35
- export const DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS = 5_000;
36
- /** Default hard cap on remembered correlationIds; the oldest-inserted are evicted beyond this. */
37
- export const DEFAULT_REPLAY_GUARD_MAX_KEYS = 100_000;
38
-
39
- export interface CorrelationReplayGuardConfig {
40
- /** A timestamp older than `now − maxAgeMs` is stale. Default {@link DEFAULT_REPLAY_MAX_AGE_MS}. */
41
- maxAgeMs?: number;
42
- /** A timestamp newer than `now + maxFutureSkewMs` is rejected. Default {@link DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS}. */
43
- maxFutureSkewMs?: number;
44
- /** Hard LRU cap on remembered correlationIds; oldest-inserted (≈ oldest-timestamp) evicted beyond this. Default {@link DEFAULT_REPLAY_GUARD_MAX_KEYS}. */
45
- maxKeys?: number;
46
- }
47
-
48
- /** Freshness + anti-replay gate over registration `correlationId`s and timestamps. */
49
- export interface CorrelationReplayGuard {
50
- /**
51
- * Accept `correlationId` (from `peerId`, stamped `timestamp`) evaluated at `now`. Returns `false`
52
- * — and records nothing — when the timestamp is stale or implausibly future, or when this
53
- * `correlationId` was already accepted inside the window (replay). Returns `true` and remembers the
54
- * id on first sight of a fresh registration.
55
- */
56
- accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean;
57
- /** Remembered correlationId count (test/diagnostic introspection). */
58
- readonly size: number;
59
- }
60
-
61
- /** A remembered acceptance, kept until its timestamp ages out of the window. */
62
- interface SeenEntry {
63
- timestamp: number;
64
- peer: string;
65
- }
66
-
67
- class WindowedReplayGuard implements CorrelationReplayGuard {
68
- private readonly seen = new Map<string, SeenEntry>();
69
- private readonly maxAgeMs: number;
70
- private readonly maxFutureSkewMs: number;
71
- private readonly maxKeys: number;
72
- /** `now` of the last prune, so pruning amortizes rather than scanning on every call. */
73
- private lastPruneAt = -Infinity;
74
-
75
- constructor(config: CorrelationReplayGuardConfig = {}) {
76
- this.maxAgeMs = config.maxAgeMs ?? DEFAULT_REPLAY_MAX_AGE_MS;
77
- if (!(this.maxAgeMs > 0)) {
78
- throw new RangeError(`maxAgeMs must be > 0, got ${this.maxAgeMs}`);
79
- }
80
- this.maxFutureSkewMs = config.maxFutureSkewMs ?? DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS;
81
- if (!(this.maxFutureSkewMs >= 0)) {
82
- throw new RangeError(`maxFutureSkewMs must be >= 0, got ${this.maxFutureSkewMs}`);
83
- }
84
- this.maxKeys = config.maxKeys ?? DEFAULT_REPLAY_GUARD_MAX_KEYS;
85
- if (!Number.isInteger(this.maxKeys) || this.maxKeys <= 0) {
86
- throw new RangeError(`maxKeys must be a positive integer, got ${this.maxKeys}`);
87
- }
88
- }
89
-
90
- accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean {
91
- if (timestamp < now - this.maxAgeMs) {
92
- log("replay-guard reject: stale timestamp age=%d > maxAge=%d", now - timestamp, this.maxAgeMs);
93
- return false; // stale
94
- }
95
- if (timestamp > now + this.maxFutureSkewMs) {
96
- log("replay-guard reject: future timestamp skew=%d > maxSkew=%d", timestamp - now, this.maxFutureSkewMs);
97
- return false; // implausibly future
98
- }
99
- this.maybePrune(now);
100
- const key = bytesKey(correlationId);
101
- const prior = this.seen.get(key);
102
- if (prior !== undefined) {
103
- // Attribute the replay to the *original* accepter: the correlationId (not the peer id) is the
104
- // anti-replay key, so a replayer that spoofs a different source id is still caught here.
105
- log("replay-guard reject: replayed correlationId (first seen from peer=%s)", prior.peer);
106
- return false; // replay
107
- }
108
- // New id: enforce the hard cap by evicting the oldest-inserted entries (oldest by `Map` insertion
109
- // order) until this insertion stays within `maxKeys`. Since entries are inserted once and never
110
- // refreshed, insertion order closely tracks timestamp order, so the victim is ≈ the oldest-timestamp
111
- // — nearest to aging out as stale. (It is only ≈: ids from distinct peers can arrive out of
112
- // timestamp order within the skew window, so a victim may retain slightly more window than a
113
- // strictly-oldest pick. The forgiveness bound — at most one entry's remaining window — holds either way.)
114
- while (this.seen.size >= this.maxKeys) {
115
- const oldest = this.seen.keys().next().value;
116
- if (oldest === undefined) break;
117
- this.seen.delete(oldest);
118
- }
119
- this.seen.set(key, { timestamp, peer: bytesKey(peerId) });
120
- return true;
121
- }
122
-
123
- /** Forget ids whose timestamp has aged past the window — they would be rejected as stale anyway. */
124
- private maybePrune(now: number): void {
125
- // Amortize: prune at most once per window rather than scanning on every accept.
126
- if (now - this.lastPruneAt < this.maxAgeMs) {
127
- return;
128
- }
129
- this.lastPruneAt = now;
130
- const cutoff = now - this.maxAgeMs;
131
- for (const [key, entry] of this.seen) {
132
- if (entry.timestamp < cutoff) {
133
- this.seen.delete(key);
134
- }
135
- }
136
- }
137
-
138
- get size(): number {
139
- return this.seen.size;
140
- }
141
- }
142
-
143
- /** Build a {@link CorrelationReplayGuard} over the configured staleness window and forward skew. */
144
- export function createCorrelationReplayGuard(config: CorrelationReplayGuardConfig = {}): CorrelationReplayGuard {
145
- return new WindowedReplayGuard(config);
146
- }
1
+ /**
2
+ * Cohort-topic substrate — correlation-id replay guard (anti-DoS).
3
+ *
4
+ * Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 3. Every `RegisterV1` carries a
5
+ * `correlationId` (16 random bytes) and a signature over `(topicId, tier, correlationId, timestamp)`.
6
+ * The signature (verified elsewhere — the participant-key check) proves authorship; this guard adds
7
+ * **freshness**: it drops a registration whose `timestamp` is outside the accepted skew window
8
+ * (stale, or implausibly far in the future) and drops a `correlationId` it has already seen inside the
9
+ * window (a captured-and-replayed registration).
10
+ *
11
+ * Because a stale registration is rejected outright, the guard only needs to remember correlation ids
12
+ * for one `maxAgeMs` window: an id older than that would be rejected on timestamp alone, so its record
13
+ * can be pruned. Pruning runs on access, bounding memory to the live window's worth of registrations.
14
+ *
15
+ * On top of the age-based prune, the map carries a **hard LRU `maxKeys` cap** (mirroring the sibling
16
+ * {@link import("./rate-limiter.js").RegisterRateLimiter}) so a flood of genuinely-fresh, admitted
17
+ * correlationIds cannot grow `seen` without bound before the age prune fires. When a new id would
18
+ * exceed the cap, the **oldest-inserted** entries are evicted until within cap. That victim is the
19
+ * least-bad one: replay entries are inserted once and never refreshed, so `Map` insertion order tracks
20
+ * timestamp order — the oldest entry is the one nearest to aging out of the window and being pruned as
21
+ * stale anyway. Evicting it forgives at most that entry's remaining replay-protection window: a bounded,
22
+ * documented tradeoff (unlike the rate limiter's fully penalty-free eviction), and one that only
23
+ * triggers under a flood of admitted ids — which, in the register pipeline, must also have passed the
24
+ * signature, rate, and bootstrap gates before ever reaching this guard.
25
+ */
26
+
27
+ import { bytesKey } from "../registration/bytes.js";
28
+ import { createLogger } from "../../logger.js";
29
+
30
+ const log = createLogger("cohort-topic:antidos");
31
+
32
+ /** Default acceptance window: a registration timestamped older than this (relative to `now`) is stale. */
33
+ export const DEFAULT_REPLAY_MAX_AGE_MS = 60_000;
34
+ /** Default tolerated forward clock skew: a timestamp this far past `now` is rejected as implausible. */
35
+ export const DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS = 5_000;
36
+ /** Default hard cap on remembered correlationIds; the oldest-inserted are evicted beyond this. */
37
+ export const DEFAULT_REPLAY_GUARD_MAX_KEYS = 100_000;
38
+
39
+ export interface CorrelationReplayGuardConfig {
40
+ /** A timestamp older than `now − maxAgeMs` is stale. Default {@link DEFAULT_REPLAY_MAX_AGE_MS}. */
41
+ maxAgeMs?: number;
42
+ /** A timestamp newer than `now + maxFutureSkewMs` is rejected. Default {@link DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS}. */
43
+ maxFutureSkewMs?: number;
44
+ /** Hard LRU cap on remembered correlationIds; oldest-inserted (≈ oldest-timestamp) evicted beyond this. Default {@link DEFAULT_REPLAY_GUARD_MAX_KEYS}. */
45
+ maxKeys?: number;
46
+ }
47
+
48
+ /** Freshness + anti-replay gate over registration `correlationId`s and timestamps. */
49
+ export interface CorrelationReplayGuard {
50
+ /**
51
+ * Accept `correlationId` (from `peerId`, stamped `timestamp`) evaluated at `now`. Returns `false`
52
+ * — and records nothing — when the timestamp is stale or implausibly future, or when this
53
+ * `correlationId` was already accepted inside the window (replay). Returns `true` and remembers the
54
+ * id on first sight of a fresh registration.
55
+ */
56
+ accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean;
57
+ /** Remembered correlationId count (test/diagnostic introspection). */
58
+ readonly size: number;
59
+ }
60
+
61
+ /** A remembered acceptance, kept until its timestamp ages out of the window. */
62
+ interface SeenEntry {
63
+ timestamp: number;
64
+ peer: string;
65
+ }
66
+
67
+ class WindowedReplayGuard implements CorrelationReplayGuard {
68
+ private readonly seen = new Map<string, SeenEntry>();
69
+ private readonly maxAgeMs: number;
70
+ private readonly maxFutureSkewMs: number;
71
+ private readonly maxKeys: number;
72
+ /** `now` of the last prune, so pruning amortizes rather than scanning on every call. */
73
+ private lastPruneAt = -Infinity;
74
+
75
+ constructor(config: CorrelationReplayGuardConfig = {}) {
76
+ this.maxAgeMs = config.maxAgeMs ?? DEFAULT_REPLAY_MAX_AGE_MS;
77
+ if (!(this.maxAgeMs > 0)) {
78
+ throw new RangeError(`maxAgeMs must be > 0, got ${this.maxAgeMs}`);
79
+ }
80
+ this.maxFutureSkewMs = config.maxFutureSkewMs ?? DEFAULT_REPLAY_MAX_FUTURE_SKEW_MS;
81
+ if (!(this.maxFutureSkewMs >= 0)) {
82
+ throw new RangeError(`maxFutureSkewMs must be >= 0, got ${this.maxFutureSkewMs}`);
83
+ }
84
+ this.maxKeys = config.maxKeys ?? DEFAULT_REPLAY_GUARD_MAX_KEYS;
85
+ if (!Number.isInteger(this.maxKeys) || this.maxKeys <= 0) {
86
+ throw new RangeError(`maxKeys must be a positive integer, got ${this.maxKeys}`);
87
+ }
88
+ }
89
+
90
+ accept(correlationId: Uint8Array, peerId: Uint8Array, timestamp: number, now: number): boolean {
91
+ if (timestamp < now - this.maxAgeMs) {
92
+ log("replay-guard reject: stale timestamp age=%d > maxAge=%d", now - timestamp, this.maxAgeMs);
93
+ return false; // stale
94
+ }
95
+ if (timestamp > now + this.maxFutureSkewMs) {
96
+ log("replay-guard reject: future timestamp skew=%d > maxSkew=%d", timestamp - now, this.maxFutureSkewMs);
97
+ return false; // implausibly future
98
+ }
99
+ this.maybePrune(now);
100
+ const key = bytesKey(correlationId);
101
+ const prior = this.seen.get(key);
102
+ if (prior !== undefined) {
103
+ // Attribute the replay to the *original* accepter: the correlationId (not the peer id) is the
104
+ // anti-replay key, so a replayer that spoofs a different source id is still caught here.
105
+ log("replay-guard reject: replayed correlationId (first seen from peer=%s)", prior.peer);
106
+ return false; // replay
107
+ }
108
+ // New id: enforce the hard cap by evicting the oldest-inserted entries (oldest by `Map` insertion
109
+ // order) until this insertion stays within `maxKeys`. Since entries are inserted once and never
110
+ // refreshed, insertion order closely tracks timestamp order, so the victim is ≈ the oldest-timestamp
111
+ // — nearest to aging out as stale. (It is only ≈: ids from distinct peers can arrive out of
112
+ // timestamp order within the skew window, so a victim may retain slightly more window than a
113
+ // strictly-oldest pick. The forgiveness bound — at most one entry's remaining window — holds either way.)
114
+ while (this.seen.size >= this.maxKeys) {
115
+ const oldest = this.seen.keys().next().value;
116
+ if (oldest === undefined) break;
117
+ this.seen.delete(oldest);
118
+ }
119
+ this.seen.set(key, { timestamp, peer: bytesKey(peerId) });
120
+ return true;
121
+ }
122
+
123
+ /** Forget ids whose timestamp has aged past the window — they would be rejected as stale anyway. */
124
+ private maybePrune(now: number): void {
125
+ // Amortize: prune at most once per window rather than scanning on every accept.
126
+ if (now - this.lastPruneAt < this.maxAgeMs) {
127
+ return;
128
+ }
129
+ this.lastPruneAt = now;
130
+ const cutoff = now - this.maxAgeMs;
131
+ for (const [key, entry] of this.seen) {
132
+ if (entry.timestamp < cutoff) {
133
+ this.seen.delete(key);
134
+ }
135
+ }
136
+ }
137
+
138
+ get size(): number {
139
+ return this.seen.size;
140
+ }
141
+ }
142
+
143
+ /** Build a {@link CorrelationReplayGuard} over the configured staleness window and forward skew. */
144
+ export function createCorrelationReplayGuard(config: CorrelationReplayGuardConfig = {}): CorrelationReplayGuard {
145
+ return new WindowedReplayGuard(config);
146
+ }
@@ -1,160 +1,160 @@
1
- /**
2
- * Cohort-topic substrate — per-cohort topic budget (anti-DoS).
3
- *
4
- * Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 2. A cohort holds forwarder state for at
5
- * most `topics_max` (default 2048) topics. Once the budget is full a request to instantiate a **new**
6
- * topic is refused (the caller answers `UnwillingCohort`); topics already in the budget continue to
7
- * serve. To keep room for genuinely active topics, the budget evicts cold ones: eviction order is
8
- * **LRU by participant count**, with **zero-recent-registration topics dropped first** — a topic that
9
- * carries no direct participants is the cheapest to shed and is shed before any populated topic.
10
- *
11
- * `admit` only ever evicts a *zero-participant* topic. If every resident topic still carries
12
- * participants the budget is genuinely saturated and the new instantiation is refused — an attacker
13
- * cannot, by flooding new topic ids, evict a topic that real participants are using. LRU ordering
14
- * uses a monotonic access counter bumped on every `admit`/`touch`, so it is deterministic and needs
15
- * no wall clock.
16
- */
17
-
18
- import { bytesKey } from "../registration/bytes.js";
19
- import { createLogger } from "../../logger.js";
20
-
21
- const log = createLogger("cohort-topic:antidos");
22
-
23
- /** Default per-cohort forwarder-topic ceiling — `topics_max`. */
24
- export const DEFAULT_TOPICS_MAX = 2048;
25
-
26
- export interface TopicBudgetConfig {
27
- /** Max topics with forwarder state. Default {@link DEFAULT_TOPICS_MAX}. */
28
- topicsMax?: number;
29
- /**
30
- * Called with the evicted topic's id just before its slot is freed in {@link TopicBudget.admit}, so
31
- * the caller can tear down the now-unbacked forwarder + traffic window that outlives the slot. Fires
32
- * only for a genuine eviction (a zero-participant victim), never for a plain admit, an already-resident
33
- * re-admit, or a refusal. Absent → no teardown hook.
34
- */
35
- onEvict?: (topicId: Uint8Array) => void;
36
- }
37
-
38
- /** Per-cohort cap on the number of topics with forwarder state, with LRU eviction of cold topics. */
39
- export interface TopicBudget {
40
- /**
41
- * Admit `topicId` to the forwarder-state budget. Returns `true` if it is already resident or there
42
- * is (or can be freed) room; `false` when the budget is full of populated topics, in which case the
43
- * caller answers `UnwillingCohort` for the new instantiation. Admitting a *new* topic when full
44
- * evicts the coldest zero-participant resident first.
45
- */
46
- admit(topicId: Uint8Array): boolean;
47
- /**
48
- * Record the current direct-participant count for a resident topic (LRU bookkeeping). Updates the
49
- * eviction key and bumps the topic's recency; a no-op for a topic that is not resident.
50
- */
51
- touch(topicId: Uint8Array, participantCount: number): void;
52
- /** Whether `topicId` currently holds forwarder state. */
53
- has(topicId: Uint8Array): boolean;
54
- /**
55
- * The direct-participant count last reported for a resident `topicId` (the eviction key), or
56
- * `undefined` when the topic is not resident. A resident reporting `0` is cold/evictable. Read-only
57
- * introspection for tests/diagnostics — eviction policy reads the same field internally.
58
- */
59
- participantCount(topicId: Uint8Array): number | undefined;
60
- /** Number of resident topics. */
61
- readonly size: number;
62
- }
63
-
64
- /** Per-resident eviction bookkeeping. */
65
- interface ResidentState {
66
- /** Direct-participant count last reported via `touch` (eviction primary key; ascending = colder). */
67
- participantCount: number;
68
- /** Monotonic access sequence (LRU tiebreaker; lower = least-recently used). */
69
- seq: number;
70
- /** The original topic-id bytes, captured on admit so `onEvict` can be handed real bytes (residents key by string). */
71
- topicId: Uint8Array;
72
- }
73
-
74
- class LruTopicBudget implements TopicBudget {
75
- private readonly residents = new Map<string, ResidentState>();
76
- private readonly topicsMax: number;
77
- private readonly onEvict?: (topicId: Uint8Array) => void;
78
- private seqCounter = 0;
79
-
80
- constructor(config: TopicBudgetConfig = {}) {
81
- this.topicsMax = config.topicsMax ?? DEFAULT_TOPICS_MAX;
82
- if (!Number.isInteger(this.topicsMax) || this.topicsMax <= 0) {
83
- throw new RangeError(`topicsMax must be a positive integer, got ${this.topicsMax}`);
84
- }
85
- this.onEvict = config.onEvict;
86
- }
87
-
88
- get size(): number {
89
- return this.residents.size;
90
- }
91
-
92
- has(topicId: Uint8Array): boolean {
93
- return this.residents.has(bytesKey(topicId));
94
- }
95
-
96
- participantCount(topicId: Uint8Array): number | undefined {
97
- return this.residents.get(bytesKey(topicId))?.participantCount;
98
- }
99
-
100
- admit(topicId: Uint8Array): boolean {
101
- const key = bytesKey(topicId);
102
- const existing = this.residents.get(key);
103
- if (existing !== undefined) {
104
- existing.seq = ++this.seqCounter; // already resident — refresh recency, always allowed
105
- return true;
106
- }
107
- if (this.residents.size < this.topicsMax) {
108
- this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
109
- return true;
110
- }
111
- // Full: only a zero-participant resident may be evicted to make room for a new topic.
112
- const victim = this.coldestEvictable();
113
- if (victim === undefined) {
114
- log("topic-budget full size=%d max=%d — refuse new topic", this.residents.size, this.topicsMax);
115
- return false;
116
- }
117
- // Fire the teardown hook with the victim's real bytes BEFORE freeing the slot, so the callback
118
- // (drop the now-unbacked forwarder + traffic window) sees a coherent state. Only a genuine
119
- // eviction reaches here — `coldestEvictable()` already refused a full-of-populated budget above.
120
- this.onEvict?.(this.residents.get(victim)!.topicId);
121
- this.residents.delete(victim);
122
- this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
123
- log("topic-budget evicted cold topic to admit new (size=%d max=%d)", this.residents.size, this.topicsMax);
124
- return true;
125
- }
126
-
127
- touch(topicId: Uint8Array, participantCount: number): void {
128
- const key = bytesKey(topicId);
129
- const state = this.residents.get(key);
130
- if (state === undefined) {
131
- return;
132
- }
133
- state.participantCount = participantCount;
134
- state.seq = ++this.seqCounter;
135
- }
136
-
137
- /**
138
- * The coldest evictable resident key: among zero-participant topics, the least-recently used.
139
- * `undefined` when every resident still carries participants (nothing may be evicted for a new topic).
140
- */
141
- private coldestEvictable(): string | undefined {
142
- let victim: string | undefined;
143
- let victimSeq = Infinity;
144
- for (const [key, state] of this.residents) {
145
- if (state.participantCount > 0) {
146
- continue; // populated topics continue — never evicted for a new instantiation
147
- }
148
- if (state.seq < victimSeq) {
149
- victim = key;
150
- victimSeq = state.seq;
151
- }
152
- }
153
- return victim;
154
- }
155
- }
156
-
157
- /** Build a {@link TopicBudget} over the configured `topics_max`. */
158
- export function createTopicBudget(config: TopicBudgetConfig = {}): TopicBudget {
159
- return new LruTopicBudget(config);
160
- }
1
+ /**
2
+ * Cohort-topic substrate — per-cohort topic budget (anti-DoS).
3
+ *
4
+ * Transcribed from `docs/cohort-topic.md` §Anti-DoS bullet 2. A cohort holds forwarder state for at
5
+ * most `topics_max` (default 2048) topics. Once the budget is full a request to instantiate a **new**
6
+ * topic is refused (the caller answers `UnwillingCohort`); topics already in the budget continue to
7
+ * serve. To keep room for genuinely active topics, the budget evicts cold ones: eviction order is
8
+ * **LRU by participant count**, with **zero-recent-registration topics dropped first** — a topic that
9
+ * carries no direct participants is the cheapest to shed and is shed before any populated topic.
10
+ *
11
+ * `admit` only ever evicts a *zero-participant* topic. If every resident topic still carries
12
+ * participants the budget is genuinely saturated and the new instantiation is refused — an attacker
13
+ * cannot, by flooding new topic ids, evict a topic that real participants are using. LRU ordering
14
+ * uses a monotonic access counter bumped on every `admit`/`touch`, so it is deterministic and needs
15
+ * no wall clock.
16
+ */
17
+
18
+ import { bytesKey } from "../registration/bytes.js";
19
+ import { createLogger } from "../../logger.js";
20
+
21
+ const log = createLogger("cohort-topic:antidos");
22
+
23
+ /** Default per-cohort forwarder-topic ceiling — `topics_max`. */
24
+ export const DEFAULT_TOPICS_MAX = 2048;
25
+
26
+ export interface TopicBudgetConfig {
27
+ /** Max topics with forwarder state. Default {@link DEFAULT_TOPICS_MAX}. */
28
+ topicsMax?: number;
29
+ /**
30
+ * Called with the evicted topic's id just before its slot is freed in {@link TopicBudget.admit}, so
31
+ * the caller can tear down the now-unbacked forwarder + traffic window that outlives the slot. Fires
32
+ * only for a genuine eviction (a zero-participant victim), never for a plain admit, an already-resident
33
+ * re-admit, or a refusal. Absent → no teardown hook.
34
+ */
35
+ onEvict?: (topicId: Uint8Array) => void;
36
+ }
37
+
38
+ /** Per-cohort cap on the number of topics with forwarder state, with LRU eviction of cold topics. */
39
+ export interface TopicBudget {
40
+ /**
41
+ * Admit `topicId` to the forwarder-state budget. Returns `true` if it is already resident or there
42
+ * is (or can be freed) room; `false` when the budget is full of populated topics, in which case the
43
+ * caller answers `UnwillingCohort` for the new instantiation. Admitting a *new* topic when full
44
+ * evicts the coldest zero-participant resident first.
45
+ */
46
+ admit(topicId: Uint8Array): boolean;
47
+ /**
48
+ * Record the current direct-participant count for a resident topic (LRU bookkeeping). Updates the
49
+ * eviction key and bumps the topic's recency; a no-op for a topic that is not resident.
50
+ */
51
+ touch(topicId: Uint8Array, participantCount: number): void;
52
+ /** Whether `topicId` currently holds forwarder state. */
53
+ has(topicId: Uint8Array): boolean;
54
+ /**
55
+ * The direct-participant count last reported for a resident `topicId` (the eviction key), or
56
+ * `undefined` when the topic is not resident. A resident reporting `0` is cold/evictable. Read-only
57
+ * introspection for tests/diagnostics — eviction policy reads the same field internally.
58
+ */
59
+ participantCount(topicId: Uint8Array): number | undefined;
60
+ /** Number of resident topics. */
61
+ readonly size: number;
62
+ }
63
+
64
+ /** Per-resident eviction bookkeeping. */
65
+ interface ResidentState {
66
+ /** Direct-participant count last reported via `touch` (eviction primary key; ascending = colder). */
67
+ participantCount: number;
68
+ /** Monotonic access sequence (LRU tiebreaker; lower = least-recently used). */
69
+ seq: number;
70
+ /** The original topic-id bytes, captured on admit so `onEvict` can be handed real bytes (residents key by string). */
71
+ topicId: Uint8Array;
72
+ }
73
+
74
+ class LruTopicBudget implements TopicBudget {
75
+ private readonly residents = new Map<string, ResidentState>();
76
+ private readonly topicsMax: number;
77
+ private readonly onEvict?: (topicId: Uint8Array) => void;
78
+ private seqCounter = 0;
79
+
80
+ constructor(config: TopicBudgetConfig = {}) {
81
+ this.topicsMax = config.topicsMax ?? DEFAULT_TOPICS_MAX;
82
+ if (!Number.isInteger(this.topicsMax) || this.topicsMax <= 0) {
83
+ throw new RangeError(`topicsMax must be a positive integer, got ${this.topicsMax}`);
84
+ }
85
+ this.onEvict = config.onEvict;
86
+ }
87
+
88
+ get size(): number {
89
+ return this.residents.size;
90
+ }
91
+
92
+ has(topicId: Uint8Array): boolean {
93
+ return this.residents.has(bytesKey(topicId));
94
+ }
95
+
96
+ participantCount(topicId: Uint8Array): number | undefined {
97
+ return this.residents.get(bytesKey(topicId))?.participantCount;
98
+ }
99
+
100
+ admit(topicId: Uint8Array): boolean {
101
+ const key = bytesKey(topicId);
102
+ const existing = this.residents.get(key);
103
+ if (existing !== undefined) {
104
+ existing.seq = ++this.seqCounter; // already resident — refresh recency, always allowed
105
+ return true;
106
+ }
107
+ if (this.residents.size < this.topicsMax) {
108
+ this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
109
+ return true;
110
+ }
111
+ // Full: only a zero-participant resident may be evicted to make room for a new topic.
112
+ const victim = this.coldestEvictable();
113
+ if (victim === undefined) {
114
+ log("topic-budget full size=%d max=%d — refuse new topic", this.residents.size, this.topicsMax);
115
+ return false;
116
+ }
117
+ // Fire the teardown hook with the victim's real bytes BEFORE freeing the slot, so the callback
118
+ // (drop the now-unbacked forwarder + traffic window) sees a coherent state. Only a genuine
119
+ // eviction reaches here — `coldestEvictable()` already refused a full-of-populated budget above.
120
+ this.onEvict?.(this.residents.get(victim)!.topicId);
121
+ this.residents.delete(victim);
122
+ this.residents.set(key, { participantCount: 0, seq: ++this.seqCounter, topicId });
123
+ log("topic-budget evicted cold topic to admit new (size=%d max=%d)", this.residents.size, this.topicsMax);
124
+ return true;
125
+ }
126
+
127
+ touch(topicId: Uint8Array, participantCount: number): void {
128
+ const key = bytesKey(topicId);
129
+ const state = this.residents.get(key);
130
+ if (state === undefined) {
131
+ return;
132
+ }
133
+ state.participantCount = participantCount;
134
+ state.seq = ++this.seqCounter;
135
+ }
136
+
137
+ /**
138
+ * The coldest evictable resident key: among zero-participant topics, the least-recently used.
139
+ * `undefined` when every resident still carries participants (nothing may be evicted for a new topic).
140
+ */
141
+ private coldestEvictable(): string | undefined {
142
+ let victim: string | undefined;
143
+ let victimSeq = Infinity;
144
+ for (const [key, state] of this.residents) {
145
+ if (state.participantCount > 0) {
146
+ continue; // populated topics continue — never evicted for a new instantiation
147
+ }
148
+ if (state.seq < victimSeq) {
149
+ victim = key;
150
+ victimSeq = state.seq;
151
+ }
152
+ }
153
+ return victim;
154
+ }
155
+ }
156
+
157
+ /** Build a {@link TopicBudget} over the configured `topics_max`. */
158
+ export function createTopicBudget(config: TopicBudgetConfig = {}): TopicBudget {
159
+ return new LruTopicBudget(config);
160
+ }
@@ -1,2 +1,2 @@
1
- export * from "./jitter.js";
2
- export * from "./invariants.js";
1
+ export * from "./jitter.js";
2
+ export * from "./invariants.js";