@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.
@@ -0,0 +1,115 @@
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
+ /** One key's standing in the window it is currently inside. */
19
+ export interface Tally {
20
+ /** Hits in this window, **including** the one that produced this tally. */
21
+ readonly count: number;
22
+ /** Epoch milliseconds at which this window ends and the count resets. */
23
+ readonly resetAt: number;
24
+ }
25
+ /**
26
+ * A counter keyed by string, bucketed into fixed windows.
27
+ *
28
+ * `hit` may be async — a Redis store is a round trip — and the rate limiter
29
+ * awaits it only when it actually returns a promise, so the in-memory default
30
+ * keeps the whole limiter on §8.4's synchronous fast path.
31
+ */
32
+ export interface Store {
33
+ hit(key: string, now: number): Tally | Promise<Tally>;
34
+ /** Drop everything. Tests use it; production has no reason to. */
35
+ reset?(): void;
36
+ /** Release connections. Called from the limiter's `onClose` (§4.5). */
37
+ close?(): void | Promise<void>;
38
+ /** Live keys, when the store can say. Read by the memory-bound gate. */
39
+ readonly size?: number;
40
+ }
41
+ export interface MemoryStoreOptions {
42
+ readonly windowMs: number;
43
+ }
44
+ /**
45
+ * The default store: a fixed-window counter that forgets a window when it ends.
46
+ *
47
+ * **Fixed windows, stated plainly.** A key's count resets at a wall-clock
48
+ * boundary, so a client can spend its whole budget in the last millisecond of
49
+ * one window and its whole budget again in the first millisecond of the next —
50
+ * up to `2 × limit` inside one window's worth of time straddling the boundary.
51
+ * That is the known cost of fixed windows and it belongs here rather than in a
52
+ * footnote, because the alternatives are not free: a sliding log keeps one
53
+ * timestamp per request, which is unbounded memory per key and the key is the
54
+ * thing an attacker chooses; and a sliding-window *counter* cannot be expressed
55
+ * as `INCR` + `PEXPIRE`, so it would push every alternate store into a Lua
56
+ * script or a transaction. §28.8 records it as a gap.
57
+ *
58
+ * **Eviction is a dropped reference, not a sweep.** The failure mode of every
59
+ * in-memory limiter is that its map is keyed by something the client chooses —
60
+ * an IP, an API key, a header — so whoever is attacking decides how much memory
61
+ * the process holds. The usual answer is a timer that walks the map, and
62
+ * walking a map with ten million keys is a pause that happens under exactly the
63
+ * load that created the keys.
64
+ *
65
+ * A fixed window needs neither. When the clock crosses into a new window every
66
+ * count in the old one is dead by definition, so the map is replaced whole: one
67
+ * assignment, no scan, no pause, and the collector reclaims a generation at
68
+ * once. Memory is bounded by the distinct keys seen inside a *single* window,
69
+ * and there is no timer to leak — which also means no `unref`, a Node API this
70
+ * package cannot use if it is to run everywhere core does.
71
+ *
72
+ * The saving is that the eviction rule falls out of the counting rule instead
73
+ * of being a second mechanism bolted beside it. Everything a sweep could get
74
+ * wrong — evicting a live key, retaining a dead one, pausing — is unreachable,
75
+ * because there is no sweep. What *is* reachable is losing or double-counting a
76
+ * key across the boundary, and that is what the differential suite fuzzes.
77
+ */
78
+ export declare class MemoryStore implements Store {
79
+ #private;
80
+ constructor(options: MemoryStoreOptions);
81
+ hit(key: string, now: number): Tally;
82
+ reset(): void;
83
+ get size(): number;
84
+ }
85
+ /**
86
+ * The reference twin — rfcs/0001 §14.5, §20.5, I6.
87
+ *
88
+ * Same contract, no eviction: one map keyed by window *and* key, which grows
89
+ * forever. It is obviously correct and completely unusable, which is exactly
90
+ * what a reference implementation is for. `MemoryStore` must agree with it on
91
+ * every verdict over a non-decreasing clock; where they differ, the difference
92
+ * is a count the evicting store lost or repeated at a window boundary, and that
93
+ * is the only bug class this component has.
94
+ *
95
+ * They *do* diverge, in one case, and it is a documented property rather than a
96
+ * defect: on a clock that steps **backwards** across a boundary the reference
97
+ * still remembers the window it left and `MemoryStore` does not. Fuzzing a
98
+ * non-decreasing clock and pinning the backwards case in one named test is the
99
+ * honest split — a fuzzer that quietly avoided the disagreement would be
100
+ * hiding it, and one that asserted equality there would be asserting something
101
+ * neither implementation promises.
102
+ *
103
+ * Exported rather than kept in the test directory for the same reason
104
+ * `PlainContext` and `walkSerializer` are: a twin only the framework's own
105
+ * tests can reach is one nobody verifying an alternate store can use, and
106
+ * §14.5 says every optimised subsystem ships its reference implementation.
107
+ */
108
+ export declare class ReferenceStore implements Store {
109
+ #private;
110
+ constructor(options: MemoryStoreOptions);
111
+ hit(key: string, now: number): Tally;
112
+ reset(): void;
113
+ get size(): number;
114
+ }
115
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,+DAA+D;AAC/D,MAAM,WAAW,KAAK;IACpB,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,KAAK;IACpB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAA;IACrD,kEAAkE;IAClE,KAAK,CAAC,IAAI,IAAI,CAAA;IACd,uEAAuE;IACvE,KAAK,CAAC,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B,wEAAwE;IACxE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,qBAAa,WAAY,YAAW,KAAK;;gBAM3B,OAAO,EAAE,kBAAkB;IAIvC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,KAAK;IAepC,KAAK,IAAI,IAAI;IAKb,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,cAAe,YAAW,KAAK;;gBAI9B,OAAO,EAAE,kBAAkB;IAIvC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,KAAK;IAUpC,KAAK,IAAI,IAAI;IAIb,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF"}
package/dist/store.js ADDED
@@ -0,0 +1,126 @@
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
+ * The default store: a fixed-window counter that forgets a window when it ends.
20
+ *
21
+ * **Fixed windows, stated plainly.** A key's count resets at a wall-clock
22
+ * boundary, so a client can spend its whole budget in the last millisecond of
23
+ * one window and its whole budget again in the first millisecond of the next —
24
+ * up to `2 × limit` inside one window's worth of time straddling the boundary.
25
+ * That is the known cost of fixed windows and it belongs here rather than in a
26
+ * footnote, because the alternatives are not free: a sliding log keeps one
27
+ * timestamp per request, which is unbounded memory per key and the key is the
28
+ * thing an attacker chooses; and a sliding-window *counter* cannot be expressed
29
+ * as `INCR` + `PEXPIRE`, so it would push every alternate store into a Lua
30
+ * script or a transaction. §28.8 records it as a gap.
31
+ *
32
+ * **Eviction is a dropped reference, not a sweep.** The failure mode of every
33
+ * in-memory limiter is that its map is keyed by something the client chooses —
34
+ * an IP, an API key, a header — so whoever is attacking decides how much memory
35
+ * the process holds. The usual answer is a timer that walks the map, and
36
+ * walking a map with ten million keys is a pause that happens under exactly the
37
+ * load that created the keys.
38
+ *
39
+ * A fixed window needs neither. When the clock crosses into a new window every
40
+ * count in the old one is dead by definition, so the map is replaced whole: one
41
+ * assignment, no scan, no pause, and the collector reclaims a generation at
42
+ * once. Memory is bounded by the distinct keys seen inside a *single* window,
43
+ * and there is no timer to leak — which also means no `unref`, a Node API this
44
+ * package cannot use if it is to run everywhere core does.
45
+ *
46
+ * The saving is that the eviction rule falls out of the counting rule instead
47
+ * of being a second mechanism bolted beside it. Everything a sweep could get
48
+ * wrong — evicting a live key, retaining a dead one, pausing — is unreachable,
49
+ * because there is no sweep. What *is* reachable is losing or double-counting a
50
+ * key across the boundary, and that is what the differential suite fuzzes.
51
+ */
52
+ export class MemoryStore {
53
+ #windowMs;
54
+ #counts = new Map();
55
+ /** The window index (`floor(now / windowMs)`) `#counts` belongs to. */
56
+ #window = -1;
57
+ constructor(options) {
58
+ this.#windowMs = options.windowMs;
59
+ }
60
+ hit(key, now) {
61
+ const window = Math.floor(now / this.#windowMs);
62
+ if (window !== this.#window) {
63
+ // Not `.clear()`: replacing the reference lets the collector take the
64
+ // whole map, while `clear` walks it. At the sizes this exists to survive,
65
+ // that is the difference the design is for.
66
+ this.#counts = new Map();
67
+ this.#window = window;
68
+ }
69
+ const count = (this.#counts.get(key) ?? 0) + 1;
70
+ this.#counts.set(key, count);
71
+ return { count, resetAt: (window + 1) * this.#windowMs };
72
+ }
73
+ reset() {
74
+ this.#counts = new Map();
75
+ this.#window = -1;
76
+ }
77
+ get size() {
78
+ return this.#counts.size;
79
+ }
80
+ }
81
+ /**
82
+ * The reference twin — rfcs/0001 §14.5, §20.5, I6.
83
+ *
84
+ * Same contract, no eviction: one map keyed by window *and* key, which grows
85
+ * forever. It is obviously correct and completely unusable, which is exactly
86
+ * what a reference implementation is for. `MemoryStore` must agree with it on
87
+ * every verdict over a non-decreasing clock; where they differ, the difference
88
+ * is a count the evicting store lost or repeated at a window boundary, and that
89
+ * is the only bug class this component has.
90
+ *
91
+ * They *do* diverge, in one case, and it is a documented property rather than a
92
+ * defect: on a clock that steps **backwards** across a boundary the reference
93
+ * still remembers the window it left and `MemoryStore` does not. Fuzzing a
94
+ * non-decreasing clock and pinning the backwards case in one named test is the
95
+ * honest split — a fuzzer that quietly avoided the disagreement would be
96
+ * hiding it, and one that asserted equality there would be asserting something
97
+ * neither implementation promises.
98
+ *
99
+ * Exported rather than kept in the test directory for the same reason
100
+ * `PlainContext` and `walkSerializer` are: a twin only the framework's own
101
+ * tests can reach is one nobody verifying an alternate store can use, and
102
+ * §14.5 says every optimised subsystem ships its reference implementation.
103
+ */
104
+ export class ReferenceStore {
105
+ #windowMs;
106
+ #counts = new Map();
107
+ constructor(options) {
108
+ this.#windowMs = options.windowMs;
109
+ }
110
+ hit(key, now) {
111
+ const window = Math.floor(now / this.#windowMs);
112
+ // The window is always base-10 digits, so the text up to the first colon is
113
+ // unambiguous however exotic the key is — no separator a key could forge.
114
+ const composite = `${window}:${key}`;
115
+ const count = (this.#counts.get(composite) ?? 0) + 1;
116
+ this.#counts.set(composite, count);
117
+ return { count, resetAt: (window + 1) * this.#windowMs };
118
+ }
119
+ reset() {
120
+ this.#counts.clear();
121
+ }
122
+ get size() {
123
+ return this.#counts.size;
124
+ }
125
+ }
126
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AA+BH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,OAAO,WAAW;IACb,SAAS,CAAQ;IAC1B,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAA;IACnC,uEAAuE;IACvE,OAAO,GAAG,CAAC,CAAC,CAAA;IAEZ,YAAY,OAA2B;QACrC,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,QAAQ,CAAA;IACnC,CAAC;IAED,GAAG,CAAC,GAAW,EAAE,GAAW;QAC1B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,CAAA;QAC/C,IAAI,MAAM,KAAK,IAAI,CAAC,OAAO,EAAE,CAAC;YAC5B,sEAAsE;YACtE,0EAA0E;YAC1E,4CAA4C;YAC5C,IAAI,CAAC,OAAO,GAAG,IAAI,GAAG,EAAE,CAAA;YACxB,IAAI,CAAC,OAAO,GAAG,MAAM,CAAA;QACvB,CAAC;QAED,MAAM,KAAK,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAA;QAC9C,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;QAC5B,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,SAAS,EAAE,CAAA;IAC1D,CAAC;IAED,KAAK;QACH,IAAI,CAAC,OAAO,GAAG,IAAI,GAAG,EAAE,CAAA;QACxB,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC,CAAA;IACnB,CAAC;IAED,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAA;IAC1B,CAAC;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,OAAO,cAAc;IAChB,SAAS,CAAQ;IACjB,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAA;IAE5C,YAAY,OAA2B;QACrC,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,QAAQ,CAAA;IACnC,CAAC;IAED,GAAG,CAAC,GAAW,EAAE,GAAW;QAC1B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,CAAA;QAC/C,4EAA4E;QAC5E,0EAA0E;QAC1E,MAAM,SAAS,GAAG,GAAG,MAAM,IAAI,GAAG,EAAE,CAAA;QACpC,MAAM,KAAK,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAA;QACpD,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,EAAE,KAAK,CAAC,CAAA;QAClC,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,SAAS,EAAE,CAAA;IAC1D,CAAC;IAED,KAAK;QACH,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAA;IACtB,CAAC;IAED,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAA;IAC1B,CAAC;CACF"}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@erenthedeveloper0/zen-middleware",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Zen first-party middleware: CORS, security headers, request ids and rate limiting, as hooks that also run on unmatched requests.",
5
+ "keywords": [
6
+ "cors",
7
+ "security-headers",
8
+ "rate-limit",
9
+ "request-id",
10
+ "middleware",
11
+ "zen"
12
+ ],
13
+ "homepage": "https://github.com/erenthedeveloper0/zen/tree/main/packages/middleware#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/erenthedeveloper0/zen/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/erenthedeveloper0/zen.git",
20
+ "directory": "packages/middleware"
21
+ },
22
+ "license": "MIT",
23
+ "author": {
24
+ "name": "Eren Sümer",
25
+ "url": "https://github.com/erenthedeveloper0"
26
+ },
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "engines": {
45
+ "node": ">=22.6.0"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -b",
52
+ "test": "node --test \"test/**/*.test.ts\""
53
+ },
54
+ "dependencies": {
55
+ "@erenthedeveloper0/zen-core": "0.1.0-alpha.1"
56
+ },
57
+ "peerDependencies": {
58
+ "typescript": ">=5.0"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "typescript": {
62
+ "optional": true
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,62 @@
1
+ import { Codes, ZenError } from '@erenthedeveloper0/zen-core'
2
+ import type { CorsExports } from './cors.ts'
3
+
4
+ /**
5
+ * Cross-plugin consistency — the check §2.4 makes possible.
6
+ *
7
+ * `Cross-Origin-Resource-Policy: same-origin` tells the browser to refuse
8
+ * cross-origin reads of this response. `cors({ origin: [...] })` tells it to
9
+ * permit them. Together they are a configuration that says two opposite things,
10
+ * the CORP one wins, and the symptom is a CORS setup that "does not work" for
11
+ * reasons that appear nowhere in the CORS configuration.
12
+ *
13
+ * In a framework where middleware is a list of opaque functions there is
14
+ * nothing to check. Here both are plugins on one graph and `exportsOf` lets
15
+ * each read the other's decision, so it is a boot error naming both settings.
16
+ *
17
+ * ### Why this is its own module rather than a function in `security.ts`
18
+ *
19
+ * Because only the plugin that runs **second** can see both, and which one that
20
+ * is now depends on hook ordering rather than on this check. `securityHeaders`
21
+ * has to register its hook *first* — its headers must be staged before anything
22
+ * can short-circuit, or a preflight and a 429 go out without `nosniff` — so it
23
+ * can no longer be the one that reads `cors`'s exports.
24
+ *
25
+ * Rather than move the check to `cors.ts` and leave it there until the next
26
+ * ordering change moves it back, both plugins call this with whatever they can
27
+ * see. The one that ran first passes `undefined` for the other half and returns;
28
+ * the one that ran second has both and decides. Order-independent by
29
+ * construction, which is the property that was actually wanted.
30
+ */
31
+ export function assertCorsCorpConsistent(
32
+ corp: string | false,
33
+ cors: CorsExports | undefined,
34
+ reporter: string,
35
+ ): void {
36
+ if (cors === undefined || corp !== 'same-origin') return
37
+
38
+ const allowing = cors.origins === 'any' || cors.origins === 'dynamic' || cors.origins.length > 0
39
+ if (!allowing) return
40
+
41
+ const described =
42
+ cors.origins === 'any' ? "'*'"
43
+ : cors.origins === 'dynamic' ? 'origins chosen by a predicate'
44
+ : cors.origins.join(', ')
45
+
46
+ throw new ZenError(
47
+ Codes.CONFIG_INVALID,
48
+ `security-headers sets Cross-Origin-Resource-Policy: same-origin while cors allows ${described}. ` +
49
+ 'The two instruct the browser to do opposite things, and the CORP header wins.',
50
+ {
51
+ status: 500,
52
+ expose: false,
53
+ hint:
54
+ "Use crossOriginResource: 'same-site' (the default) or 'cross-origin' if these responses are " +
55
+ 'meant to be read cross-origin, or narrow the CORS allowlist if they are not.',
56
+ consequence:
57
+ 'Left as configured, every allowed origin still fails in the browser — and it fails with a ' +
58
+ 'message naming CORS, so the search starts in the file that is correct. ' +
59
+ `Reported by ${reporter}, whichever of the two was registered second.`,
60
+ },
61
+ )
62
+ }