@erenthedeveloper0/zen-middleware 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +85 -0
- package/dist/consistency.d.ts +30 -0
- package/dist/consistency.d.ts.map +1 -0
- package/dist/consistency.js +49 -0
- package/dist/consistency.js.map +1 -0
- package/dist/cors.d.ts +110 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +276 -0
- package/dist/cors.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/rate-limit.d.ts +70 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +125 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/request-id.d.ts +17 -0
- package/dist/request-id.d.ts.map +1 -0
- package/dist/request-id.js +53 -0
- package/dist/request-id.js.map +1 -0
- package/dist/security.d.ts +80 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +65 -0
- package/dist/security.js.map +1 -0
- package/dist/shared.d.ts +55 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +25 -0
- package/dist/shared.js.map +1 -0
- package/dist/store.d.ts +115 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +126 -0
- package/dist/store.js.map +1 -0
- package/package.json +65 -0
- package/src/consistency.ts +62 -0
- package/src/cors.ts +468 -0
- package/src/index.ts +34 -0
- package/src/rate-limit.ts +217 -0
- package/src/request-id.ts +129 -0
- package/src/security.ts +161 -0
- package/src/shared.ts +61 -0
- package/src/store.ts +165 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The counter store — rfcs/0001 §3.5, the `Store` seam.
|
|
3
|
+
*
|
|
4
|
+
* §3.5's inventory lists one interface shared by the cache, the rate limiter
|
|
5
|
+
* and the session store, with an in-memory default and Redis / SQLite / D1 /
|
|
6
|
+
* Durable Object as the alternates. This module is the rate limiter's half of
|
|
7
|
+
* it: the narrowest contract that a fixed-window counter needs, chosen so that
|
|
8
|
+
* a Redis implementation is two commands rather than a transaction.
|
|
9
|
+
*
|
|
10
|
+
* INCR zen:rl:{window}:{key}
|
|
11
|
+
* PEXPIRE zen:rl:{window}:{key} {windowMs} // only on the first hit
|
|
12
|
+
*
|
|
13
|
+
* That is the whole implementation, and it is the reason `hit` returns the
|
|
14
|
+
* count *including* the current request rather than taking a "should I allow
|
|
15
|
+
* this" decision. A store that decided would need the limit, and a store that
|
|
16
|
+
* needs the limit cannot be shared with the cache.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One key's standing in the window it is currently inside. */
|
|
20
|
+
export interface Tally {
|
|
21
|
+
/** Hits in this window, **including** the one that produced this tally. */
|
|
22
|
+
readonly count: number
|
|
23
|
+
/** Epoch milliseconds at which this window ends and the count resets. */
|
|
24
|
+
readonly resetAt: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A counter keyed by string, bucketed into fixed windows.
|
|
29
|
+
*
|
|
30
|
+
* `hit` may be async — a Redis store is a round trip — and the rate limiter
|
|
31
|
+
* awaits it only when it actually returns a promise, so the in-memory default
|
|
32
|
+
* keeps the whole limiter on §8.4's synchronous fast path.
|
|
33
|
+
*/
|
|
34
|
+
export interface Store {
|
|
35
|
+
hit(key: string, now: number): Tally | Promise<Tally>
|
|
36
|
+
/** Drop everything. Tests use it; production has no reason to. */
|
|
37
|
+
reset?(): void
|
|
38
|
+
/** Release connections. Called from the limiter's `onClose` (§4.5). */
|
|
39
|
+
close?(): void | Promise<void>
|
|
40
|
+
/** Live keys, when the store can say. Read by the memory-bound gate. */
|
|
41
|
+
readonly size?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MemoryStoreOptions {
|
|
45
|
+
readonly windowMs: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The default store: a fixed-window counter that forgets a window when it ends.
|
|
50
|
+
*
|
|
51
|
+
* **Fixed windows, stated plainly.** A key's count resets at a wall-clock
|
|
52
|
+
* boundary, so a client can spend its whole budget in the last millisecond of
|
|
53
|
+
* one window and its whole budget again in the first millisecond of the next —
|
|
54
|
+
* up to `2 × limit` inside one window's worth of time straddling the boundary.
|
|
55
|
+
* That is the known cost of fixed windows and it belongs here rather than in a
|
|
56
|
+
* footnote, because the alternatives are not free: a sliding log keeps one
|
|
57
|
+
* timestamp per request, which is unbounded memory per key and the key is the
|
|
58
|
+
* thing an attacker chooses; and a sliding-window *counter* cannot be expressed
|
|
59
|
+
* as `INCR` + `PEXPIRE`, so it would push every alternate store into a Lua
|
|
60
|
+
* script or a transaction. §28.8 records it as a gap.
|
|
61
|
+
*
|
|
62
|
+
* **Eviction is a dropped reference, not a sweep.** The failure mode of every
|
|
63
|
+
* in-memory limiter is that its map is keyed by something the client chooses —
|
|
64
|
+
* an IP, an API key, a header — so whoever is attacking decides how much memory
|
|
65
|
+
* the process holds. The usual answer is a timer that walks the map, and
|
|
66
|
+
* walking a map with ten million keys is a pause that happens under exactly the
|
|
67
|
+
* load that created the keys.
|
|
68
|
+
*
|
|
69
|
+
* A fixed window needs neither. When the clock crosses into a new window every
|
|
70
|
+
* count in the old one is dead by definition, so the map is replaced whole: one
|
|
71
|
+
* assignment, no scan, no pause, and the collector reclaims a generation at
|
|
72
|
+
* once. Memory is bounded by the distinct keys seen inside a *single* window,
|
|
73
|
+
* and there is no timer to leak — which also means no `unref`, a Node API this
|
|
74
|
+
* package cannot use if it is to run everywhere core does.
|
|
75
|
+
*
|
|
76
|
+
* The saving is that the eviction rule falls out of the counting rule instead
|
|
77
|
+
* of being a second mechanism bolted beside it. Everything a sweep could get
|
|
78
|
+
* wrong — evicting a live key, retaining a dead one, pausing — is unreachable,
|
|
79
|
+
* because there is no sweep. What *is* reachable is losing or double-counting a
|
|
80
|
+
* key across the boundary, and that is what the differential suite fuzzes.
|
|
81
|
+
*/
|
|
82
|
+
export class MemoryStore implements Store {
|
|
83
|
+
readonly #windowMs: number
|
|
84
|
+
#counts = new Map<string, number>()
|
|
85
|
+
/** The window index (`floor(now / windowMs)`) `#counts` belongs to. */
|
|
86
|
+
#window = -1
|
|
87
|
+
|
|
88
|
+
constructor(options: MemoryStoreOptions) {
|
|
89
|
+
this.#windowMs = options.windowMs
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
hit(key: string, now: number): Tally {
|
|
93
|
+
const window = Math.floor(now / this.#windowMs)
|
|
94
|
+
if (window !== this.#window) {
|
|
95
|
+
// Not `.clear()`: replacing the reference lets the collector take the
|
|
96
|
+
// whole map, while `clear` walks it. At the sizes this exists to survive,
|
|
97
|
+
// that is the difference the design is for.
|
|
98
|
+
this.#counts = new Map()
|
|
99
|
+
this.#window = window
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const count = (this.#counts.get(key) ?? 0) + 1
|
|
103
|
+
this.#counts.set(key, count)
|
|
104
|
+
return { count, resetAt: (window + 1) * this.#windowMs }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
reset(): void {
|
|
108
|
+
this.#counts = new Map()
|
|
109
|
+
this.#window = -1
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get size(): number {
|
|
113
|
+
return this.#counts.size
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The reference twin — rfcs/0001 §14.5, §20.5, I6.
|
|
119
|
+
*
|
|
120
|
+
* Same contract, no eviction: one map keyed by window *and* key, which grows
|
|
121
|
+
* forever. It is obviously correct and completely unusable, which is exactly
|
|
122
|
+
* what a reference implementation is for. `MemoryStore` must agree with it on
|
|
123
|
+
* every verdict over a non-decreasing clock; where they differ, the difference
|
|
124
|
+
* is a count the evicting store lost or repeated at a window boundary, and that
|
|
125
|
+
* is the only bug class this component has.
|
|
126
|
+
*
|
|
127
|
+
* They *do* diverge, in one case, and it is a documented property rather than a
|
|
128
|
+
* defect: on a clock that steps **backwards** across a boundary the reference
|
|
129
|
+
* still remembers the window it left and `MemoryStore` does not. Fuzzing a
|
|
130
|
+
* non-decreasing clock and pinning the backwards case in one named test is the
|
|
131
|
+
* honest split — a fuzzer that quietly avoided the disagreement would be
|
|
132
|
+
* hiding it, and one that asserted equality there would be asserting something
|
|
133
|
+
* neither implementation promises.
|
|
134
|
+
*
|
|
135
|
+
* Exported rather than kept in the test directory for the same reason
|
|
136
|
+
* `PlainContext` and `walkSerializer` are: a twin only the framework's own
|
|
137
|
+
* tests can reach is one nobody verifying an alternate store can use, and
|
|
138
|
+
* §14.5 says every optimised subsystem ships its reference implementation.
|
|
139
|
+
*/
|
|
140
|
+
export class ReferenceStore implements Store {
|
|
141
|
+
readonly #windowMs: number
|
|
142
|
+
readonly #counts = new Map<string, number>()
|
|
143
|
+
|
|
144
|
+
constructor(options: MemoryStoreOptions) {
|
|
145
|
+
this.#windowMs = options.windowMs
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
hit(key: string, now: number): Tally {
|
|
149
|
+
const window = Math.floor(now / this.#windowMs)
|
|
150
|
+
// The window is always base-10 digits, so the text up to the first colon is
|
|
151
|
+
// unambiguous however exotic the key is — no separator a key could forge.
|
|
152
|
+
const composite = `${window}:${key}`
|
|
153
|
+
const count = (this.#counts.get(composite) ?? 0) + 1
|
|
154
|
+
this.#counts.set(composite, count)
|
|
155
|
+
return { count, resetAt: (window + 1) * this.#windowMs }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
reset(): void {
|
|
159
|
+
this.#counts.clear()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get size(): number {
|
|
163
|
+
return this.#counts.size
|
|
164
|
+
}
|
|
165
|
+
}
|