@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/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@erenthedeveloper0/zen-middleware` — the first-party pack (rfcs/0001 §24.2, §25 M4, §32).
3
+ *
4
+ * Four plugins, one shape: **a global `onRequest` hook that stages response
5
+ * metadata**. Both halves of that sentence are load-bearing and both were
6
+ * decided by measurement rather than by taste — see §32.1 and §32.2, or the
7
+ * header comment on `cors.ts`, which carries the numbers.
8
+ *
9
+ * What is *not* here, named rather than left as a silent gap:
10
+ *
11
+ * - **`compression` and `static`.** Both need a platform: `node:zlib` and
12
+ * `node:fs`. §14.1 already puts compression on the adapter boundary as a
13
+ * capability (`compression: 'native' | 'library' | 'none'`), which is the
14
+ * right home for it — a middleware package that imported `node:zlib` would
15
+ * be a package the edge adapters cannot load. They belong to an
16
+ * adapter-coupled package and are recorded in §28.8.
17
+ * - **`timeout` and `body-limit`**, which §24.2 lists here. Both are already
18
+ * built into core as first-class route policy — §4.4's deadlines and §19.2's
19
+ * body limits — and a middleware wrapping them would be a second way to say
20
+ * the same thing, with its own precedence rules for the case where both are
21
+ * set. §24.2's row predates both features.
22
+ */
23
+ export { cors } from "./cors.js";
24
+ export { securityHeaders, } from "./security.js";
25
+ export { requestId } from "./request-id.js";
26
+ export { rateLimit, } from "./rate-limit.js";
27
+ export { MemoryStore, ReferenceStore, } from "./store.js";
28
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,IAAI,EAAuD,MAAM,WAAW,CAAA;AACrF,OAAO,EACL,eAAe,GAChB,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,SAAS,EAAyB,MAAM,iBAAiB,CAAA;AAClE,OAAO,EACL,SAAS,GACV,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,WAAW,EAAE,cAAc,GAC5B,MAAM,YAAY,CAAA"}
@@ -0,0 +1,70 @@
1
+ import { type Duration, type Plugin } from '@erenthedeveloper0/zen-core';
2
+ import { type Store } from './store.ts';
3
+ /**
4
+ * Rate limiting — rfcs/0001 §9.2, §19.2, §19.4, Annex B `ZEN_RATE_LIMITED`.
5
+ *
6
+ * ### Why it is a hook, and why that is the whole feature
7
+ *
8
+ * §9.2 states the defect this design exists to avoid, in the paragraph that
9
+ * made global `onRequest` hooks run on unmatched requests: *"a rate limiter
10
+ * that only sees matched routes is bypassed by requesting a path that does not
11
+ * exist."* Every framework whose rate limiter is route middleware has that
12
+ * hole, and it is not theoretical — `GET /aaaa` is unmatched, so the limiter
13
+ * never runs, so a 404 flood is free. A global `onRequest` hook counts it.
14
+ *
15
+ * §4.2 stage 5 puts it before body intake as well, so a request that is going
16
+ * to be refused is refused **without its body being read**. A limiter that
17
+ * counted after parsing would have already paid the expensive part.
18
+ *
19
+ * ### `Codes.RATE_LIMITED` was already here
20
+ *
21
+ * `TooManyRequests` and `ZEN_RATE_LIMITED` have been exported from
22
+ * `@erenthedeveloper0/zen-core` since 0.1 and read by nothing — the same state `COERCION_DEFAULTS`
23
+ * and `Codes.CONFIG_INVALID` were in before the two features that needed them.
24
+ * Using it rather than inventing a 429 means the refusal is an ordinary
25
+ * `HttpError`: it goes through the error engine, the RFC 9457 envelope, the
26
+ * registered `onError` hooks and `onSend`, and it appears in the same error
27
+ * dashboards as everything else. A rate limiter that answers with its own
28
+ * hand-built response is one whose refusals no error rate counts.
29
+ *
30
+ * ### What it does not do
31
+ *
32
+ * It is a **fixed-window counter**, and `store.ts` explains what that costs at
33
+ * the boundary. It is not distributed unless the `Store` is (§3.5). It does not
34
+ * read `X-Forwarded-For` — that is `ctx.ip`, and §19.4 makes it a deliberate
35
+ * `trustProxy` decision — but it *does* notice when the combination is the one
36
+ * §19.4 warns about, once, at the moment it is provably real. See {@link warnProxy}.
37
+ */
38
+ export interface RateLimitOptions {
39
+ /** Requests per window, per key. */
40
+ readonly limit?: number | undefined;
41
+ /** Window length. Fixed, not sliding — see `store.ts`. */
42
+ readonly window?: Duration | undefined;
43
+ /**
44
+ * What to count by. Defaults to `ctx.ip`.
45
+ *
46
+ * Return `null` to exempt a request entirely — that is how an authenticated
47
+ * service account or an internal health poller opts out, and it is a
48
+ * deliberate hole rather than a second allowlist option nobody would find.
49
+ */
50
+ readonly key?: ((ctx: RateLimitContext) => string | null) | undefined;
51
+ /** Where counters live. Defaults to an in-process {@link MemoryStore}. */
52
+ readonly store?: Store | undefined;
53
+ /** Also emit `X-RateLimit-*`. Off by default: they were never standardised and they double the header cost. */
54
+ readonly legacyHeaders?: boolean | undefined;
55
+ /** Emit `RateLimit` / `RateLimit-Policy` (IETF draft). On by default. */
56
+ readonly standardHeaders?: boolean | undefined;
57
+ /** Message on the 429. The status, code and envelope are not configurable. */
58
+ readonly message?: string | undefined;
59
+ }
60
+ export interface RateLimitContext {
61
+ readonly ip: string;
62
+ readonly method: string;
63
+ readonly path: string;
64
+ readonly id: string;
65
+ readonly raw: {
66
+ header(name: never): string | undefined;
67
+ };
68
+ }
69
+ export declare function rateLimit(options?: RateLimitOptions): Plugin<void, {}>;
70
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,QAAQ,EAAE,KAAK,MAAM,EAC3B,MAAM,6BAA6B,CAAA;AACpC,OAAO,EAAe,KAAK,KAAK,EAAc,MAAM,YAAY,CAAA;AAGhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,oCAAoC;IACpC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACnC,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAA;IACtC;;;;;;OAMG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,EAAE,gBAAgB,KAAK,MAAM,GAAG,IAAI,CAAC,GAAG,SAAS,CAAA;IACrE,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,GAAG,SAAS,CAAA;IAClC,+GAA+G;IAC/G,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IAC5C,yEAAyE;IACzE,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IAC9C,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CACtC;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,GAAG,EAAE;QAAE,MAAM,CAAC,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,SAAS,CAAA;KAAE,CAAA;CAC1D;AAID,wBAAgB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CA4F1E"}
@@ -0,0 +1,125 @@
1
+ import { definePlugin, parseDuration, Codes, TooManyRequests, ZenError, } from '@erenthedeveloper0/zen-core';
2
+ import { MemoryStore } from "./store.js";
3
+ const DEFAULTS = { limit: 100, window: '1m' };
4
+ export function rateLimit(options = {}) {
5
+ return definePlugin({
6
+ name: 'rate-limit',
7
+ version: '0.1.0',
8
+ config: { namespace: 'rateLimit', defaults: { limit: DEFAULTS.limit, window: DEFAULTS.window } },
9
+ setup(app) {
10
+ // §16.1 layer order: an explicit option beats the plugin's own layer-2
11
+ // defaults, which a deployment can beat from configuration. Readable at
12
+ // setup because §16.2 resolved the environment before any plugin ran.
13
+ const namespace = (app.config['rateLimit'] ?? {});
14
+ const limit = options.limit ?? numberOr(namespace.limit, DEFAULTS.limit);
15
+ const windowMs = parseDuration(options.window ?? namespace.window ?? DEFAULTS.window);
16
+ if (!Number.isInteger(limit) || limit < 1) {
17
+ throw new ZenError(Codes.CONFIG_INVALID, `rateLimit needs a positive whole limit; got ${JSON.stringify(limit)}.`, {
18
+ status: 500,
19
+ expose: false,
20
+ hint: 'Pass rateLimit({ limit: 100, window: "1m" }), or set rateLimit.limit in configuration.',
21
+ consequence: 'A limit below one refuses every request, including the health probes an orchestrator uses to decide the pod is broken.',
22
+ });
23
+ }
24
+ // Annotated `Store`, not inferred: the inferred union of the supplied
25
+ // store and `MemoryStore` drops the optional members, so `store.close?.()`
26
+ // below would stop type-checking the moment the default is in play — for
27
+ // an interface whose whole point is that the two are interchangeable.
28
+ const store = options.store ?? new MemoryStore({ windowMs });
29
+ const keyOf = options.key ?? ((ctx) => ctx.ip);
30
+ const standard = options.standardHeaders !== false;
31
+ const legacy = options.legacyHeaders === true;
32
+ const policy = `${limit};w=${Math.floor(windowMs / 1000)}`;
33
+ const message = options.message ?? `Rate limit exceeded: ${limit} requests per ${options.window ?? namespace.window ?? DEFAULTS.window}.`;
34
+ // One warning per process, not per request — §12.7's rule applied to a
35
+ // runtime condition. See `warnProxy`.
36
+ let warned = false;
37
+ app.hook('onRequest', function rateLimit(ctx) {
38
+ const key = keyOf(ctx);
39
+ if (key === null)
40
+ return undefined;
41
+ if (!warned && options.key === undefined)
42
+ warned = warnProxy(ctx);
43
+ const tally = store.hit(key, Date.now());
44
+ // The in-memory store is synchronous and must stay on §8.4's fast path;
45
+ // a Redis store is a round trip. Branching on the *value* rather than
46
+ // declaring the hook async is what lets one implementation serve both
47
+ // without making every app that uses the default await a resolved
48
+ // promise per request.
49
+ return isThenable(tally)
50
+ ? tally.then((settled) => verdict(ctx, settled))
51
+ : verdict(ctx, tally);
52
+ }, 'rate-limit');
53
+ app.hook('onClose', async () => { await store.close?.(); });
54
+ function verdict(ctx, tally) {
55
+ const remaining = Math.max(0, limit - tally.count);
56
+ const resetSeconds = Math.max(0, Math.ceil((tally.resetAt - Date.now()) / 1000));
57
+ // Staged, not stamped: the headers have to be on the 429 *and* on the
58
+ // 200, and the 429 leaves through the error engine rather than through
59
+ // this hook's return value. `ctx.res` is downstream of both (§13.6).
60
+ if (standard) {
61
+ ctx.res.header('ratelimit', `limit=${limit}, remaining=${remaining}, reset=${resetSeconds}`);
62
+ ctx.res.header('ratelimit-policy', policy);
63
+ }
64
+ if (legacy) {
65
+ ctx.res.header('x-ratelimit-limit', String(limit));
66
+ ctx.res.header('x-ratelimit-remaining', String(remaining));
67
+ ctx.res.header('x-ratelimit-reset', String(Math.ceil(tally.resetAt / 1000)));
68
+ }
69
+ if (tally.count <= limit)
70
+ return undefined;
71
+ ctx.res.header('retry-after', String(resetSeconds));
72
+ // Thrown, not returned. A returned `Reply` would leave the error engine,
73
+ // the problem-details envelope and every registered `onError` hook out
74
+ // of the one response class an operator most wants counted.
75
+ throw new TooManyRequests(message, { retryable: true });
76
+ }
77
+ return { exports: { limit, windowMs, store } };
78
+ },
79
+ });
80
+ }
81
+ /**
82
+ * §19.4's detection, without the traffic sampling.
83
+ *
84
+ * The dangerous combination is: rate limiting keyed on the client IP,
85
+ * `trustProxy` off, and a proxy in front adding `X-Forwarded-For`. Then
86
+ * `ctx.ip` is the load balancer for every request, every caller shares one
87
+ * counter, and the limiter is globally throttling the service instead of
88
+ * limiting anybody — which looks exactly like the service being slow.
89
+ *
90
+ * §19.4 assigns this to `zen doctor` "with traffic samples", and that is the
91
+ * right home for the general check. But the specific one costs a header read on
92
+ * the first request that could possibly exhibit it, and it fires only when the
93
+ * misconfiguration is **already real**: a forwarding header is present and is
94
+ * being ignored. A boot-time version could not do that — at boot, "trustProxy
95
+ * is off" is also the correct configuration for a directly-exposed server, so a
96
+ * warning then would fire on every correct app until people turned it off.
97
+ *
98
+ * Returns `true` so the caller latches it: one line per process, naming the fix.
99
+ */
100
+ function warnProxy(ctx) {
101
+ const forwarded = ctx.raw.header('x-forwarded-for');
102
+ if (forwarded === undefined)
103
+ return false;
104
+ // `ctx.ip` falls back to the socket address when trustProxy is off (§19.4),
105
+ // so the two disagreeing *is* the condition — no need to read the option.
106
+ const claimed = forwarded.split(',')[0]?.trim();
107
+ if (claimed === undefined || claimed === ctx.ip)
108
+ return false;
109
+ console.warn(`[zen] ${Codes.RATE_LIMITED}: rate limiting is keyed on ctx.ip, this request carried ` +
110
+ `X-Forwarded-For: ${claimed}, and trustProxy is off — so it was counted as ${ctx.ip}. ` +
111
+ 'fix: set trustProxy to the number of proxies in front of the app — trustProxy: 1 for one ' +
112
+ 'load balancer — so ctx.ip is the address your proxy saw; not `true`, which reads the ' +
113
+ 'leftmost entry, and a client that writes its own X-Forwarded-For then gets a fresh budget ' +
114
+ 'per request. Or pass rateLimit({ key }) to count by something you control. ' +
115
+ 'also: until then every caller behind the proxy shares one counter, which throttles the ' +
116
+ 'whole service at the configured limit instead of limiting anyone. Reported once per process.');
117
+ return true;
118
+ }
119
+ function numberOr(value, fallback) {
120
+ return typeof value === 'number' ? value : fallback;
121
+ }
122
+ function isThenable(value) {
123
+ return typeof value?.then === 'function';
124
+ }
125
+ //# sourceMappingURL=rate-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import { type Plugin } from '@erenthedeveloper0/zen-core';
2
+ export interface RequestIdOptions {
3
+ /** Header to echo on the reply. `false` echoes nothing. */
4
+ readonly header?: string | false | undefined;
5
+ /**
6
+ * Adopt an inbound id when it is well-formed. Off by default — see above.
7
+ *
8
+ * `true` reads the same header as `header`. A string reads that one instead,
9
+ * which is what a deployment behind a load balancer that stamps its own
10
+ * (`x-amzn-trace-id`, `x-cloud-trace-context`) needs.
11
+ */
12
+ readonly trustHeader?: boolean | string | undefined;
13
+ /** Header carrying the rejection notice. `false` stays silent. */
14
+ readonly rejectedHeader?: string | false | undefined;
15
+ }
16
+ export declare function requestId(options?: RequestIdOptions): Plugin<void, {}>;
17
+ //# sourceMappingURL=request-id.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-id.d.ts","sourceRoot":"","sources":["../src/request-id.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiC,KAAK,MAAM,EAAE,MAAM,6BAA6B,CAAA;AAwDxF,MAAM,WAAW,gBAAgB;IAC/B,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,SAAS,CAAA;IAC5C;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAAA;IACnD,kEAAkE;IAClE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,SAAS,CAAA;CACrD;AAaD,wBAAgB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CA8C1E"}
@@ -0,0 +1,53 @@
1
+ import { definePlugin, Codes, ZenError } from '@erenthedeveloper0/zen-core';
2
+ /**
3
+ * 8–128 of `[A-Za-z0-9._-]`.
4
+ *
5
+ * Anchored, no alternation, no nested quantifier: linear in the input and
6
+ * therefore inside §19.3's bounded-work rule, which forbids backtracking
7
+ * regexes anywhere on the request path. The upper bound is part of the
8
+ * validation, not a courtesy — an id is copied into every log line for the
9
+ * request, so an unbounded one is an amplification the caller chooses.
10
+ */
11
+ const ACCEPTABLE = /^[A-Za-z0-9._-]{8,128}$/;
12
+ export function requestId(options = {}) {
13
+ const echo = options.header === undefined ? 'x-request-id' : options.header;
14
+ const trust = options.trustHeader === undefined || options.trustHeader === false ? null
15
+ : options.trustHeader === true
16
+ ? (echo === false ? 'x-request-id' : echo)
17
+ : options.trustHeader;
18
+ const rejected = options.rejectedHeader === undefined ? 'x-request-id-rejected' : options.rejectedHeader;
19
+ if (echo === false && trust === null) {
20
+ throw new ZenError(Codes.CONFIG_INVALID, 'requestId() was configured to neither echo an id nor adopt one, which leaves it with nothing to do.', {
21
+ status: 500,
22
+ expose: false,
23
+ hint: 'Drop the registration — Zen assigns ctx.id with or without this plugin — or turn one of the two back on.',
24
+ consequence: 'As configured it registers a hook on every route that returns immediately.',
25
+ });
26
+ }
27
+ return definePlugin({
28
+ name: 'request-id',
29
+ version: '0.1.0',
30
+ // First in the pack: a preflight answered by `cors` and a 429 refused by
31
+ // `rate-limit` both short-circuit, and both should still carry the id that
32
+ // the log line for them will be filed under.
33
+ before: ['cors', 'rate-limit', 'security-headers'],
34
+ setup(app) {
35
+ app.hook('onRequest', function requestId(ctx) {
36
+ if (trust !== null) {
37
+ const inbound = ctx.raw.header(trust);
38
+ if (inbound !== undefined) {
39
+ if (ACCEPTABLE.test(inbound))
40
+ ctx.id = inbound;
41
+ else if (rejected !== false)
42
+ ctx.res.header(rejected, '1');
43
+ }
44
+ }
45
+ if (echo !== false)
46
+ ctx.res.header(echo, ctx.id);
47
+ return undefined;
48
+ }, 'request-id');
49
+ return { exports: { header: echo, trusts: trust } };
50
+ },
51
+ });
52
+ }
53
+ //# sourceMappingURL=request-id.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-id.js","sourceRoot":"","sources":["../src/request-id.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAe,MAAM,6BAA6B,CAAA;AAuExF;;;;;;;;GAQG;AACH,MAAM,UAAU,GAAG,yBAAyB,CAAA;AAE5C,MAAM,UAAU,SAAS,CAAC,UAA4B,EAAE;IACtD,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAA;IAC3E,MAAM,KAAK,GACT,OAAO,CAAC,WAAW,KAAK,SAAS,IAAI,OAAO,CAAC,WAAW,KAAK,KAAK,CAAC,CAAC,CAAC,IAAI;QACzE,CAAC,CAAC,OAAO,CAAC,WAAW,KAAK,IAAI;YAC5B,CAAC,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,IAAI,CAAC;YAC1C,CAAC,CAAC,OAAO,CAAC,WAAW,CAAA;IACzB,MAAM,QAAQ,GAAG,OAAO,CAAC,cAAc,KAAK,SAAS,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,OAAO,CAAC,cAAc,CAAA;IAExG,IAAI,IAAI,KAAK,KAAK,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QACrC,MAAM,IAAI,QAAQ,CAChB,KAAK,CAAC,cAAc,EACpB,qGAAqG,EACrG;YACE,MAAM,EAAE,GAAG;YACX,MAAM,EAAE,KAAK;YACb,IAAI,EAAE,0GAA0G;YAChH,WAAW,EAAE,4EAA4E;SAC1F,CACF,CAAA;IACH,CAAC;IAED,OAAO,YAAY,CAAW;QAC5B,IAAI,EAAE,YAAY;QAClB,OAAO,EAAE,OAAO;QAChB,yEAAyE;QACzE,2EAA2E;QAC3E,6CAA6C;QAC7C,MAAM,EAAE,CAAC,MAAM,EAAE,YAAY,EAAE,kBAAkB,CAAC;QAElD,KAAK,CAAC,GAAG;YACP,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,SAAS,SAAS,CAAC,GAAqC;gBAC5E,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;oBACnB,MAAM,OAAO,GAAG,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;oBACrC,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;wBAC1B,IAAI,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC;4BAAE,GAAG,CAAC,EAAE,GAAG,OAAO,CAAA;6BACzC,IAAI,QAAQ,KAAK,KAAK;4BAAE,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;oBAC5D,CAAC;gBACH,CAAC;gBACD,IAAI,IAAI,KAAK,KAAK;oBAAE,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,CAAC,CAAA;gBAChD,OAAO,SAAS,CAAA;YAClB,CAAC,EAAE,YAAY,CAAC,CAAA;YAEhB,OAAO,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,CAAA;QACrD,CAAC;KACF,CAAC,CAAA;AACJ,CAAC"}
@@ -0,0 +1,80 @@
1
+ import { type Plugin } from '@erenthedeveloper0/zen-core';
2
+ /**
3
+ * Security response headers — rfcs/0001 §19.2.
4
+ *
5
+ * The header set is §19.2's table, and the table is the specification: every
6
+ * default here appears there with the sentence explaining why it is not looser.
7
+ * Nothing is invented in this file.
8
+ *
9
+ * ### Staged, so failures carry them too
10
+ *
11
+ * Same reason as `cors.ts`: an `after` middleware never runs on the error path
12
+ * (§4.6) or on an unmatched request, so a service that stamps `nosniff` in
13
+ * middleware does not have it on its 404s, its 500s or its validation errors —
14
+ * which are precisely the responses most likely to contain reflected input.
15
+ * `ctx.res` stages, and `prepareForWire` applies at egress on every path.
16
+ *
17
+ * ### It runs first, and that is a correctness requirement
18
+ *
19
+ * Staging is only half the answer. `cors` answers a preflight by returning a
20
+ * `Reply`, and `rate-limit` refuses by throwing — both short-circuit the
21
+ * `onRequest` chain, so a hook registered after them does not run at all on the
22
+ * responses that need it most. The first draft of this pack ordered
23
+ * `securityHeaders` last and every preflight and every 429 went out without
24
+ * `nosniff`; the manual end-to-end check found it before any test did, which is
25
+ * convention #2 again. Hence `before:` on all three siblings.
26
+ *
27
+ * ### The contradiction it can catch that a library cannot
28
+ *
29
+ * See `consistency.ts`. Both this plugin and `cors` call the same check with
30
+ * whatever the other has published, so whichever is registered second finds
31
+ * both halves — the ordering above means that is normally `cors`.
32
+ */
33
+ export type ReferrerPolicy = 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url';
34
+ export interface SecurityHeadersOptions {
35
+ /** `X-Content-Type-Options: nosniff`. Off is not a supported configuration; the flag exists for tests. */
36
+ readonly noSniff?: boolean | undefined;
37
+ /** `X-Frame-Options`. `false` omits it — do that only when a CSP `frame-ancestors` replaces it. */
38
+ readonly frameOptions?: 'DENY' | 'SAMEORIGIN' | false | undefined;
39
+ readonly referrerPolicy?: ReferrerPolicy | false | undefined;
40
+ /** `Cross-Origin-Opener-Policy`. */
41
+ readonly crossOriginOpener?: 'same-origin' | 'same-origin-allow-popups' | 'unsafe-none' | false | undefined;
42
+ /**
43
+ * `Cross-Origin-Resource-Policy`. Defaults to `same-site` rather than
44
+ * `same-origin`: `same-origin` is the stricter reading of §19.2's
45
+ * "conservative", and it is also the value that silently breaks a CDN
46
+ * subdomain serving the same site's assets. `same-site` blocks the
47
+ * cross-*site* read that CORP exists to prevent and leaves the arrangement
48
+ * every real deployment has intact.
49
+ */
50
+ readonly crossOriginResource?: 'same-origin' | 'same-site' | 'cross-origin' | false | undefined;
51
+ /**
52
+ * `Strict-Transport-Security`. **Off by default**, and this is the one
53
+ * default in the table that looks wrong until you have been bitten by it.
54
+ *
55
+ * §19.2 says "HSTS on when `secure: true`". A framework cannot tell whether
56
+ * it is behind TLS — `ctx.secure` reads `X-Forwarded-Proto`, which §19.4
57
+ * refuses to trust unless `trustProxy` is configured — so "on when secure"
58
+ * resolves to "on when a header we do not trust says so". And HSTS is not a
59
+ * header you can take back: a browser that has seen `max-age=31536000`
60
+ * refuses plain HTTP to that host for a year, including on the developer's
61
+ * own machine if it ever reached one. So it is opt-in, one line, in the file
62
+ * that already knows it is behind a load balancer.
63
+ */
64
+ readonly hsts?: {
65
+ readonly maxAge?: number;
66
+ readonly includeSubDomains?: boolean;
67
+ readonly preload?: boolean;
68
+ } | false | undefined;
69
+ /**
70
+ * `Content-Security-Policy`, verbatim. **Not set by default** — §19.2: "a
71
+ * wrong CSP is worse than none; we prompt instead of guessing." There is no
72
+ * builder here for the same reason: a policy assembled from options reads as
73
+ * if the framework vouched for it.
74
+ */
75
+ readonly contentSecurityPolicy?: string | false | undefined;
76
+ /** Extra headers, staged with the rest. */
77
+ readonly headers?: Readonly<Record<string, string>> | undefined;
78
+ }
79
+ export declare function securityHeaders(options?: SecurityHeadersOptions): Plugin<void, {}>;
80
+ //# sourceMappingURL=security.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../src/security.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,6BAA6B,CAAA;AAKvE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,MAAM,cAAc,GACtB,aAAa,GACb,4BAA4B,GAC5B,QAAQ,GACR,0BAA0B,GAC1B,aAAa,GACb,eAAe,GACf,iCAAiC,GACjC,YAAY,CAAA;AAEhB,MAAM,WAAW,sBAAsB;IACrC,0GAA0G;IAC1G,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IACtC,mGAAmG;IACnG,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,YAAY,GAAG,KAAK,GAAG,SAAS,CAAA;IACjE,QAAQ,CAAC,cAAc,CAAC,EAAE,cAAc,GAAG,KAAK,GAAG,SAAS,CAAA;IAC5D,oCAAoC;IACpC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,aAAa,GAAG,0BAA0B,GAAG,aAAa,GAAG,KAAK,GAAG,SAAS,CAAA;IAC3G;;;;;;;OAOG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,aAAa,GAAG,WAAW,GAAG,cAAc,GAAG,KAAK,GAAG,SAAS,CAAA;IAC/F;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,KAAK,GAAG,SAAS,CAAA;IAClI;;;;;OAKG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,SAAS,CAAA;IAC3D,2CAA2C;IAC3C,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAA;CAChE;AAKD,wBAAgB,eAAe,CAAC,OAAO,GAAE,sBAA2B,GAAG,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,CA8BtF"}
@@ -0,0 +1,65 @@
1
+ import { definePlugin } from '@erenthedeveloper0/zen-core';
2
+ import { assertCorsCorpConsistent } from "./consistency.js";
3
+ export function securityHeaders(options = {}) {
4
+ return definePlugin({
5
+ name: 'security-headers',
6
+ version: '0.1.0',
7
+ // Before everything that can short-circuit. `cors` answers preflights and
8
+ // `rate-limit` throws 429s; a hook that runs after either is absent from
9
+ // exactly the responses §19.2 most wants these headers on. Hints on
10
+ // unregistered plugins are ignored (§10.5 step 4), so this costs nothing
11
+ // when the siblings are not installed.
12
+ before: ['cors', 'rate-limit'],
13
+ config: { namespace: 'security' },
14
+ setup(app) {
15
+ const pairs = resolve(options);
16
+ const corp = options.crossOriginResource ?? 'same-site';
17
+ assertCorsCorpConsistent(corp, app.exportsOf('cors'), app.pluginName);
18
+ // Unrolled at boot into a fixed array; the hook is one loop over a frozen
19
+ // list of string pairs, with no option reads and no branches per request.
20
+ app.hook('onRequest', function securityHeaders(ctx) {
21
+ for (let i = 0; i < pairs.length; i++) {
22
+ const pair = pairs[i];
23
+ ctx.res.header(pair[0], pair[1]);
24
+ }
25
+ return undefined;
26
+ }, 'security-headers');
27
+ return { exports: { headers: pairs.map(([name]) => name), crossOriginResource: corp } };
28
+ },
29
+ });
30
+ }
31
+ function resolve(options) {
32
+ const out = [];
33
+ if (options.noSniff !== false)
34
+ out.push(['x-content-type-options', 'nosniff']);
35
+ const frame = options.frameOptions ?? 'DENY';
36
+ if (frame !== false)
37
+ out.push(['x-frame-options', frame]);
38
+ const referrer = options.referrerPolicy ?? 'no-referrer';
39
+ if (referrer !== false)
40
+ out.push(['referrer-policy', referrer]);
41
+ const coop = options.crossOriginOpener ?? 'same-origin';
42
+ if (coop !== false)
43
+ out.push(['cross-origin-opener-policy', coop]);
44
+ const corp = options.crossOriginResource ?? 'same-site';
45
+ if (corp !== false)
46
+ out.push(['cross-origin-resource-policy', corp]);
47
+ const hsts = options.hsts;
48
+ if (hsts !== undefined && hsts !== false) {
49
+ const maxAge = hsts.maxAge ?? 15_552_000; // 180 days
50
+ out.push([
51
+ 'strict-transport-security',
52
+ `max-age=${maxAge}` +
53
+ (hsts.includeSubDomains === true ? '; includeSubDomains' : '') +
54
+ (hsts.preload === true ? '; preload' : ''),
55
+ ]);
56
+ }
57
+ const csp = options.contentSecurityPolicy;
58
+ if (typeof csp === 'string' && csp.length > 0)
59
+ out.push(['content-security-policy', csp]);
60
+ for (const [name, value] of Object.entries(options.headers ?? {})) {
61
+ out.push([name.toLowerCase(), value]);
62
+ }
63
+ return Object.freeze(out);
64
+ }
65
+ //# sourceMappingURL=security.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"security.js","sourceRoot":"","sources":["../src/security.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,55 @@
1
+ import type { ReplyBuilder, Reply, LowercaseName } from '@erenthedeveloper0/zen-core';
2
+ /**
3
+ * What this pack touches on a context, declared structurally — §10.4.
4
+ *
5
+ * `Registrar.hook` takes a `Function` on purpose: a plugin is written before
6
+ * the application's decoration set exists, so pinning its hooks to
7
+ * `Context<never, X>` would reject the pattern for an `X` the author cannot
8
+ * know. The convention the examples already follow is to declare exactly the
9
+ * surface the hook uses (`examples/openapi/src/plugins/request-id.ts` declares
10
+ * `{ id: string }` and nothing else), and the point of doing it is that a
11
+ * middleware cannot quietly start depending on `ctx.user` later.
12
+ *
13
+ * These are split by concern rather than merged into one context type for the
14
+ * same reason: `securityHeaders` may not read the request, and the type says so.
15
+ */
16
+ /** Reading the request without materialising the header record. */
17
+ export interface RawReading {
18
+ readonly method: string;
19
+ readonly raw: {
20
+ header(name: LowercaseName): string | undefined;
21
+ };
22
+ }
23
+ /** Staging response metadata (§13.6) — applied at egress on every path. */
24
+ export interface Staging {
25
+ readonly res: ReplyBuilder;
26
+ }
27
+ /** Producing a reply from a hook, which is how a phase short-circuits (§9.2). */
28
+ export interface Answering {
29
+ empty(status?: 204 | 205 | 304): Reply<null>;
30
+ json<T>(body: T, init?: {
31
+ status?: number;
32
+ }): Reply<T>;
33
+ }
34
+ export type CorsRequest = RawReading & Answering;
35
+ /**
36
+ * `ctx.raw.header` rather than `ctx.headers[name]`.
37
+ *
38
+ * `ctx.headers` is lazy and memoised, but the first touch walks every header
39
+ * the adapter received and builds a record (`buildHeaders`, §7.2). These hooks
40
+ * run on *every* request in the application, including the ones whose handlers
41
+ * never look at a header, so making them the reason that record exists would be
42
+ * a cost the application did not ask for — §9.4's rule applied to a plugin
43
+ * rather than to the compiler.
44
+ */
45
+ export declare function headerOf(ctx: RawReading, name: LowercaseName): string | undefined;
46
+ /**
47
+ * A CORS preflight — an `OPTIONS` carrying `Access-Control-Request-Method`.
48
+ *
49
+ * Both halves are required by the Fetch standard and both are load-bearing
50
+ * here: an `OPTIONS` without the header is an ordinary request for a resource's
51
+ * options and may well have a route, and answering it with 204 would shadow
52
+ * that route with something the application did not write.
53
+ */
54
+ export declare function isPreflight(ctx: RawReading): boolean;
55
+ //# sourceMappingURL=shared.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAErF;;;;;;;;;;;;;GAaG;AAEH,mEAAmE;AACnE,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,GAAG,EAAE;QAAE,MAAM,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,CAAA;KAAE,CAAA;CAClE;AAED,2EAA2E;AAC3E,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,GAAG,EAAE,YAAY,CAAA;CAC3B;AAED,iFAAiF;AACjF,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,MAAM,CAAC,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,KAAK,CAAC,IAAI,CAAC,CAAA;IAC5C,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAC,CAAC,CAAC,CAAA;CACvD;AAED,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,SAAS,CAAA;AAEhD;;;;;;;;;GASG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,CAEjF;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAEpD"}
package/dist/shared.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `ctx.raw.header` rather than `ctx.headers[name]`.
3
+ *
4
+ * `ctx.headers` is lazy and memoised, but the first touch walks every header
5
+ * the adapter received and builds a record (`buildHeaders`, §7.2). These hooks
6
+ * run on *every* request in the application, including the ones whose handlers
7
+ * never look at a header, so making them the reason that record exists would be
8
+ * a cost the application did not ask for — §9.4's rule applied to a plugin
9
+ * rather than to the compiler.
10
+ */
11
+ export function headerOf(ctx, name) {
12
+ return ctx.raw.header(name);
13
+ }
14
+ /**
15
+ * A CORS preflight — an `OPTIONS` carrying `Access-Control-Request-Method`.
16
+ *
17
+ * Both halves are required by the Fetch standard and both are load-bearing
18
+ * here: an `OPTIONS` without the header is an ordinary request for a resource's
19
+ * options and may well have a route, and answering it with 204 would shadow
20
+ * that route with something the application did not write.
21
+ */
22
+ export function isPreflight(ctx) {
23
+ return ctx.method === 'OPTIONS' && ctx.raw.header('access-control-request-method') !== undefined;
24
+ }
25
+ //# sourceMappingURL=shared.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared.js","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAoCA;;;;;;;;;GASG;AACH,MAAM,UAAU,QAAQ,CAAC,GAAe,EAAE,IAAmB;IAC3D,OAAO,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;AAC7B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,WAAW,CAAC,GAAe;IACzC,OAAO,GAAG,CAAC,MAAM,KAAK,SAAS,IAAI,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,+BAA+B,CAAC,KAAK,SAAS,CAAA;AAClG,CAAC"}