@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,217 @@
1
+ import {
2
+ definePlugin, parseDuration, Codes, TooManyRequests, ZenError,
3
+ type Duration, type Plugin, type Reply,
4
+ } from '@erenthedeveloper0/zen-core'
5
+ import { MemoryStore, type Store, type Tally } from './store.ts'
6
+ import type { Answering, RawReading, Staging } from './shared.ts'
7
+
8
+ /**
9
+ * Rate limiting — rfcs/0001 §9.2, §19.2, §19.4, Annex B `ZEN_RATE_LIMITED`.
10
+ *
11
+ * ### Why it is a hook, and why that is the whole feature
12
+ *
13
+ * §9.2 states the defect this design exists to avoid, in the paragraph that
14
+ * made global `onRequest` hooks run on unmatched requests: *"a rate limiter
15
+ * that only sees matched routes is bypassed by requesting a path that does not
16
+ * exist."* Every framework whose rate limiter is route middleware has that
17
+ * hole, and it is not theoretical — `GET /aaaa` is unmatched, so the limiter
18
+ * never runs, so a 404 flood is free. A global `onRequest` hook counts it.
19
+ *
20
+ * §4.2 stage 5 puts it before body intake as well, so a request that is going
21
+ * to be refused is refused **without its body being read**. A limiter that
22
+ * counted after parsing would have already paid the expensive part.
23
+ *
24
+ * ### `Codes.RATE_LIMITED` was already here
25
+ *
26
+ * `TooManyRequests` and `ZEN_RATE_LIMITED` have been exported from
27
+ * `@erenthedeveloper0/zen-core` since 0.1 and read by nothing — the same state `COERCION_DEFAULTS`
28
+ * and `Codes.CONFIG_INVALID` were in before the two features that needed them.
29
+ * Using it rather than inventing a 429 means the refusal is an ordinary
30
+ * `HttpError`: it goes through the error engine, the RFC 9457 envelope, the
31
+ * registered `onError` hooks and `onSend`, and it appears in the same error
32
+ * dashboards as everything else. A rate limiter that answers with its own
33
+ * hand-built response is one whose refusals no error rate counts.
34
+ *
35
+ * ### What it does not do
36
+ *
37
+ * It is a **fixed-window counter**, and `store.ts` explains what that costs at
38
+ * the boundary. It is not distributed unless the `Store` is (§3.5). It does not
39
+ * read `X-Forwarded-For` — that is `ctx.ip`, and §19.4 makes it a deliberate
40
+ * `trustProxy` decision — but it *does* notice when the combination is the one
41
+ * §19.4 warns about, once, at the moment it is provably real. See {@link warnProxy}.
42
+ */
43
+
44
+ export interface RateLimitOptions {
45
+ /** Requests per window, per key. */
46
+ readonly limit?: number | undefined
47
+ /** Window length. Fixed, not sliding — see `store.ts`. */
48
+ readonly window?: Duration | undefined
49
+ /**
50
+ * What to count by. Defaults to `ctx.ip`.
51
+ *
52
+ * Return `null` to exempt a request entirely — that is how an authenticated
53
+ * service account or an internal health poller opts out, and it is a
54
+ * deliberate hole rather than a second allowlist option nobody would find.
55
+ */
56
+ readonly key?: ((ctx: RateLimitContext) => string | null) | undefined
57
+ /** Where counters live. Defaults to an in-process {@link MemoryStore}. */
58
+ readonly store?: Store | undefined
59
+ /** Also emit `X-RateLimit-*`. Off by default: they were never standardised and they double the header cost. */
60
+ readonly legacyHeaders?: boolean | undefined
61
+ /** Emit `RateLimit` / `RateLimit-Policy` (IETF draft). On by default. */
62
+ readonly standardHeaders?: boolean | undefined
63
+ /** Message on the 429. The status, code and envelope are not configurable. */
64
+ readonly message?: string | undefined
65
+ }
66
+
67
+ export interface RateLimitContext {
68
+ readonly ip: string
69
+ readonly method: string
70
+ readonly path: string
71
+ readonly id: string
72
+ readonly raw: { header(name: never): string | undefined }
73
+ }
74
+
75
+ const DEFAULTS = { limit: 100, window: '1m' } as const
76
+
77
+ export function rateLimit(options: RateLimitOptions = {}): Plugin<void, {}> {
78
+ return definePlugin<void, {}>({
79
+ name: 'rate-limit',
80
+ version: '0.1.0',
81
+ config: { namespace: 'rateLimit', defaults: { limit: DEFAULTS.limit, window: DEFAULTS.window } },
82
+
83
+ setup(app) {
84
+ // §16.1 layer order: an explicit option beats the plugin's own layer-2
85
+ // defaults, which a deployment can beat from configuration. Readable at
86
+ // setup because §16.2 resolved the environment before any plugin ran.
87
+ const namespace = (app.config['rateLimit'] ?? {}) as { limit?: unknown; window?: unknown }
88
+ const limit = options.limit ?? numberOr(namespace.limit, DEFAULTS.limit)
89
+ const windowMs = parseDuration(options.window ?? (namespace.window as Duration | undefined) ?? DEFAULTS.window)
90
+
91
+ if (!Number.isInteger(limit) || limit < 1) {
92
+ throw new ZenError(
93
+ Codes.CONFIG_INVALID,
94
+ `rateLimit needs a positive whole limit; got ${JSON.stringify(limit)}.`,
95
+ {
96
+ status: 500,
97
+ expose: false,
98
+ hint: 'Pass rateLimit({ limit: 100, window: "1m" }), or set rateLimit.limit in configuration.',
99
+ consequence: 'A limit below one refuses every request, including the health probes an orchestrator uses to decide the pod is broken.',
100
+ },
101
+ )
102
+ }
103
+
104
+ // Annotated `Store`, not inferred: the inferred union of the supplied
105
+ // store and `MemoryStore` drops the optional members, so `store.close?.()`
106
+ // below would stop type-checking the moment the default is in play — for
107
+ // an interface whose whole point is that the two are interchangeable.
108
+ const store: Store = options.store ?? new MemoryStore({ windowMs })
109
+ const keyOf = options.key ?? ((ctx: RateLimitContext) => ctx.ip)
110
+ const standard = options.standardHeaders !== false
111
+ const legacy = options.legacyHeaders === true
112
+ const policy = `${limit};w=${Math.floor(windowMs / 1000)}`
113
+ const message = options.message ?? `Rate limit exceeded: ${limit} requests per ${options.window ?? namespace.window ?? DEFAULTS.window}.`
114
+
115
+ // One warning per process, not per request — §12.7's rule applied to a
116
+ // runtime condition. See `warnProxy`.
117
+ let warned = false
118
+
119
+ app.hook('onRequest', function rateLimit(
120
+ ctx: RateLimitContext & RawReading & Staging & Answering,
121
+ ): Reply | undefined | Promise<Reply | undefined> {
122
+ const key = keyOf(ctx)
123
+ if (key === null) return undefined
124
+
125
+ if (!warned && options.key === undefined) warned = warnProxy(ctx)
126
+
127
+ const tally = store.hit(key, Date.now())
128
+ // The in-memory store is synchronous and must stay on §8.4's fast path;
129
+ // a Redis store is a round trip. Branching on the *value* rather than
130
+ // declaring the hook async is what lets one implementation serve both
131
+ // without making every app that uses the default await a resolved
132
+ // promise per request.
133
+ return isThenable(tally)
134
+ ? tally.then((settled) => verdict(ctx, settled))
135
+ : verdict(ctx, tally)
136
+ }, 'rate-limit')
137
+
138
+ app.hook('onClose', async () => { await store.close?.() })
139
+
140
+ function verdict(ctx: Staging & Answering, tally: Tally): Reply | undefined {
141
+ const remaining = Math.max(0, limit - tally.count)
142
+ const resetSeconds = Math.max(0, Math.ceil((tally.resetAt - Date.now()) / 1000))
143
+
144
+ // Staged, not stamped: the headers have to be on the 429 *and* on the
145
+ // 200, and the 429 leaves through the error engine rather than through
146
+ // this hook's return value. `ctx.res` is downstream of both (§13.6).
147
+ if (standard) {
148
+ ctx.res.header('ratelimit', `limit=${limit}, remaining=${remaining}, reset=${resetSeconds}`)
149
+ ctx.res.header('ratelimit-policy', policy)
150
+ }
151
+ if (legacy) {
152
+ ctx.res.header('x-ratelimit-limit', String(limit))
153
+ ctx.res.header('x-ratelimit-remaining', String(remaining))
154
+ ctx.res.header('x-ratelimit-reset', String(Math.ceil(tally.resetAt / 1000)))
155
+ }
156
+
157
+ if (tally.count <= limit) return undefined
158
+
159
+ ctx.res.header('retry-after', String(resetSeconds))
160
+ // Thrown, not returned. A returned `Reply` would leave the error engine,
161
+ // the problem-details envelope and every registered `onError` hook out
162
+ // of the one response class an operator most wants counted.
163
+ throw new TooManyRequests(message, { retryable: true })
164
+ }
165
+
166
+ return { exports: { limit, windowMs, store } }
167
+ },
168
+ })
169
+ }
170
+
171
+ /**
172
+ * §19.4's detection, without the traffic sampling.
173
+ *
174
+ * The dangerous combination is: rate limiting keyed on the client IP,
175
+ * `trustProxy` off, and a proxy in front adding `X-Forwarded-For`. Then
176
+ * `ctx.ip` is the load balancer for every request, every caller shares one
177
+ * counter, and the limiter is globally throttling the service instead of
178
+ * limiting anybody — which looks exactly like the service being slow.
179
+ *
180
+ * §19.4 assigns this to `zen doctor` "with traffic samples", and that is the
181
+ * right home for the general check. But the specific one costs a header read on
182
+ * the first request that could possibly exhibit it, and it fires only when the
183
+ * misconfiguration is **already real**: a forwarding header is present and is
184
+ * being ignored. A boot-time version could not do that — at boot, "trustProxy
185
+ * is off" is also the correct configuration for a directly-exposed server, so a
186
+ * warning then would fire on every correct app until people turned it off.
187
+ *
188
+ * Returns `true` so the caller latches it: one line per process, naming the fix.
189
+ */
190
+ function warnProxy(ctx: RateLimitContext & RawReading): boolean {
191
+ const forwarded = ctx.raw.header('x-forwarded-for')
192
+ if (forwarded === undefined) return false
193
+ // `ctx.ip` falls back to the socket address when trustProxy is off (§19.4),
194
+ // so the two disagreeing *is* the condition — no need to read the option.
195
+ const claimed = forwarded.split(',')[0]?.trim()
196
+ if (claimed === undefined || claimed === ctx.ip) return false
197
+
198
+ console.warn(
199
+ `[zen] ${Codes.RATE_LIMITED}: rate limiting is keyed on ctx.ip, this request carried ` +
200
+ `X-Forwarded-For: ${claimed}, and trustProxy is off — so it was counted as ${ctx.ip}. ` +
201
+ 'fix: set trustProxy to the number of proxies in front of the app — trustProxy: 1 for one ' +
202
+ 'load balancer — so ctx.ip is the address your proxy saw; not `true`, which reads the ' +
203
+ 'leftmost entry, and a client that writes its own X-Forwarded-For then gets a fresh budget ' +
204
+ 'per request. Or pass rateLimit({ key }) to count by something you control. ' +
205
+ 'also: until then every caller behind the proxy shares one counter, which throttles the ' +
206
+ 'whole service at the configured limit instead of limiting anyone. Reported once per process.',
207
+ )
208
+ return true
209
+ }
210
+
211
+ function numberOr(value: unknown, fallback: number): number {
212
+ return typeof value === 'number' ? value : fallback
213
+ }
214
+
215
+ function isThenable(value: unknown): value is Promise<Tally> {
216
+ return typeof (value as { then?: unknown } | null)?.then === 'function'
217
+ }
@@ -0,0 +1,129 @@
1
+ import { definePlugin, Codes, ZenError, type Plugin } from '@erenthedeveloper0/zen-core'
2
+ import type { RawReading, Staging } from './shared.ts'
3
+
4
+ /**
5
+ * Request id — rfcs/0001 §19.4, §31.1.
6
+ *
7
+ * Zen already generates one: the dispatcher assigns `ctx.id` before the first
8
+ * hook runs, so `ctx.log` and the RFC 9457 problem document already carry a
9
+ * correlation key with no plugin at all. This adds the two halves that need a
10
+ * decision rather than a default.
11
+ *
12
+ * ### Echoing it
13
+ *
14
+ * The id is useless to a caller who cannot see it. `x-request-id` on the reply
15
+ * is what turns "something failed at 14:02" into a log query, and staging it
16
+ * through `ctx.res` means it is on the 500 as well as the 200 — which is the
17
+ * only response anybody actually needs it on.
18
+ *
19
+ * ### Accepting one, which is a trust decision
20
+ *
21
+ * Continuing a caller's trace id is the reason people reach for this plugin,
22
+ * and it is **off by default** for the same reason `trustProxy` is (§19.4): an
23
+ * inbound `X-Request-Id` is attacker-controlled. It lands in every log line
24
+ * this request produces, in the problem document, and in whatever index those
25
+ * are shipped to — so an unbounded, unvalidated value is log injection with a
26
+ * framework helpfully doing the writing, and a value chosen to collide with a
27
+ * real id is a way to make one request's trail read as another's.
28
+ *
29
+ * So `trustHeader` is opt-in, and even opted in the value must survive
30
+ * {@link ACCEPTABLE}: 8–128 characters of `[A-Za-z0-9._-]`. That admits every
31
+ * id anybody actually sends — ULIDs, UUIDs, hex, W3C trace ids — and admits no
32
+ * newline, no space and no control character. A value that fails is not an
33
+ * error; the request gets a fresh id, because refusing traffic over the shape
34
+ * of a correlation header would be a worse failure than the one being avoided.
35
+ * `x-request-id-rejected: 1` says it happened, so the caller can find out
36
+ * without reading the server's logs.
37
+ */
38
+
39
+ /**
40
+ * The one field this plugin writes.
41
+ *
42
+ * `ctx.id` is `readonly` on `BaseContext` and is a plain field on both twins,
43
+ * assigned once by the dispatcher. Overwriting it is a same-type store on an
44
+ * existing field, so it changes no hidden class and I2 is untouched (§7.6) —
45
+ * but it is the only place in this package that writes to a context, and
46
+ * naming the type is how that stays deliberate.
47
+ *
48
+ * The alternative was a second id on a slot, and it is worse in the way that
49
+ * matters: two ids means every log line, every problem document and every
50
+ * downstream header has to say which one it means, and the first one that gets
51
+ * it wrong is discovered during an incident.
52
+ */
53
+ interface MutableId {
54
+ id: string
55
+ }
56
+
57
+ export interface RequestIdOptions {
58
+ /** Header to echo on the reply. `false` echoes nothing. */
59
+ readonly header?: string | false | undefined
60
+ /**
61
+ * Adopt an inbound id when it is well-formed. Off by default — see above.
62
+ *
63
+ * `true` reads the same header as `header`. A string reads that one instead,
64
+ * which is what a deployment behind a load balancer that stamps its own
65
+ * (`x-amzn-trace-id`, `x-cloud-trace-context`) needs.
66
+ */
67
+ readonly trustHeader?: boolean | string | undefined
68
+ /** Header carrying the rejection notice. `false` stays silent. */
69
+ readonly rejectedHeader?: string | false | undefined
70
+ }
71
+
72
+ /**
73
+ * 8–128 of `[A-Za-z0-9._-]`.
74
+ *
75
+ * Anchored, no alternation, no nested quantifier: linear in the input and
76
+ * therefore inside §19.3's bounded-work rule, which forbids backtracking
77
+ * regexes anywhere on the request path. The upper bound is part of the
78
+ * validation, not a courtesy — an id is copied into every log line for the
79
+ * request, so an unbounded one is an amplification the caller chooses.
80
+ */
81
+ const ACCEPTABLE = /^[A-Za-z0-9._-]{8,128}$/
82
+
83
+ export function requestId(options: RequestIdOptions = {}): Plugin<void, {}> {
84
+ const echo = options.header === undefined ? 'x-request-id' : options.header
85
+ const trust =
86
+ options.trustHeader === undefined || options.trustHeader === false ? null
87
+ : options.trustHeader === true
88
+ ? (echo === false ? 'x-request-id' : echo)
89
+ : options.trustHeader
90
+ const rejected = options.rejectedHeader === undefined ? 'x-request-id-rejected' : options.rejectedHeader
91
+
92
+ if (echo === false && trust === null) {
93
+ throw new ZenError(
94
+ Codes.CONFIG_INVALID,
95
+ 'requestId() was configured to neither echo an id nor adopt one, which leaves it with nothing to do.',
96
+ {
97
+ status: 500,
98
+ expose: false,
99
+ hint: 'Drop the registration — Zen assigns ctx.id with or without this plugin — or turn one of the two back on.',
100
+ consequence: 'As configured it registers a hook on every route that returns immediately.',
101
+ },
102
+ )
103
+ }
104
+
105
+ return definePlugin<void, {}>({
106
+ name: 'request-id',
107
+ version: '0.1.0',
108
+ // First in the pack: a preflight answered by `cors` and a 429 refused by
109
+ // `rate-limit` both short-circuit, and both should still carry the id that
110
+ // the log line for them will be filed under.
111
+ before: ['cors', 'rate-limit', 'security-headers'],
112
+
113
+ setup(app) {
114
+ app.hook('onRequest', function requestId(ctx: RawReading & Staging & MutableId): undefined {
115
+ if (trust !== null) {
116
+ const inbound = ctx.raw.header(trust)
117
+ if (inbound !== undefined) {
118
+ if (ACCEPTABLE.test(inbound)) ctx.id = inbound
119
+ else if (rejected !== false) ctx.res.header(rejected, '1')
120
+ }
121
+ }
122
+ if (echo !== false) ctx.res.header(echo, ctx.id)
123
+ return undefined
124
+ }, 'request-id')
125
+
126
+ return { exports: { header: echo, trusts: trust } }
127
+ },
128
+ })
129
+ }
@@ -0,0 +1,161 @@
1
+ import { definePlugin, type Plugin } from '@erenthedeveloper0/zen-core'
2
+ import { assertCorsCorpConsistent } from './consistency.ts'
3
+ import type { CorsExports } from './cors.ts'
4
+ import type { Staging } from './shared.ts'
5
+
6
+ /**
7
+ * Security response headers — rfcs/0001 §19.2.
8
+ *
9
+ * The header set is §19.2's table, and the table is the specification: every
10
+ * default here appears there with the sentence explaining why it is not looser.
11
+ * Nothing is invented in this file.
12
+ *
13
+ * ### Staged, so failures carry them too
14
+ *
15
+ * Same reason as `cors.ts`: an `after` middleware never runs on the error path
16
+ * (§4.6) or on an unmatched request, so a service that stamps `nosniff` in
17
+ * middleware does not have it on its 404s, its 500s or its validation errors —
18
+ * which are precisely the responses most likely to contain reflected input.
19
+ * `ctx.res` stages, and `prepareForWire` applies at egress on every path.
20
+ *
21
+ * ### It runs first, and that is a correctness requirement
22
+ *
23
+ * Staging is only half the answer. `cors` answers a preflight by returning a
24
+ * `Reply`, and `rate-limit` refuses by throwing — both short-circuit the
25
+ * `onRequest` chain, so a hook registered after them does not run at all on the
26
+ * responses that need it most. The first draft of this pack ordered
27
+ * `securityHeaders` last and every preflight and every 429 went out without
28
+ * `nosniff`; the manual end-to-end check found it before any test did, which is
29
+ * convention #2 again. Hence `before:` on all three siblings.
30
+ *
31
+ * ### The contradiction it can catch that a library cannot
32
+ *
33
+ * See `consistency.ts`. Both this plugin and `cors` call the same check with
34
+ * whatever the other has published, so whichever is registered second finds
35
+ * both halves — the ordering above means that is normally `cors`.
36
+ */
37
+
38
+ export type ReferrerPolicy =
39
+ | 'no-referrer'
40
+ | 'no-referrer-when-downgrade'
41
+ | 'origin'
42
+ | 'origin-when-cross-origin'
43
+ | 'same-origin'
44
+ | 'strict-origin'
45
+ | 'strict-origin-when-cross-origin'
46
+ | 'unsafe-url'
47
+
48
+ export interface SecurityHeadersOptions {
49
+ /** `X-Content-Type-Options: nosniff`. Off is not a supported configuration; the flag exists for tests. */
50
+ readonly noSniff?: boolean | undefined
51
+ /** `X-Frame-Options`. `false` omits it — do that only when a CSP `frame-ancestors` replaces it. */
52
+ readonly frameOptions?: 'DENY' | 'SAMEORIGIN' | false | undefined
53
+ readonly referrerPolicy?: ReferrerPolicy | false | undefined
54
+ /** `Cross-Origin-Opener-Policy`. */
55
+ readonly crossOriginOpener?: 'same-origin' | 'same-origin-allow-popups' | 'unsafe-none' | false | undefined
56
+ /**
57
+ * `Cross-Origin-Resource-Policy`. Defaults to `same-site` rather than
58
+ * `same-origin`: `same-origin` is the stricter reading of §19.2's
59
+ * "conservative", and it is also the value that silently breaks a CDN
60
+ * subdomain serving the same site's assets. `same-site` blocks the
61
+ * cross-*site* read that CORP exists to prevent and leaves the arrangement
62
+ * every real deployment has intact.
63
+ */
64
+ readonly crossOriginResource?: 'same-origin' | 'same-site' | 'cross-origin' | false | undefined
65
+ /**
66
+ * `Strict-Transport-Security`. **Off by default**, and this is the one
67
+ * default in the table that looks wrong until you have been bitten by it.
68
+ *
69
+ * §19.2 says "HSTS on when `secure: true`". A framework cannot tell whether
70
+ * it is behind TLS — `ctx.secure` reads `X-Forwarded-Proto`, which §19.4
71
+ * refuses to trust unless `trustProxy` is configured — so "on when secure"
72
+ * resolves to "on when a header we do not trust says so". And HSTS is not a
73
+ * header you can take back: a browser that has seen `max-age=31536000`
74
+ * refuses plain HTTP to that host for a year, including on the developer's
75
+ * own machine if it ever reached one. So it is opt-in, one line, in the file
76
+ * that already knows it is behind a load balancer.
77
+ */
78
+ readonly hsts?: { readonly maxAge?: number; readonly includeSubDomains?: boolean; readonly preload?: boolean } | false | undefined
79
+ /**
80
+ * `Content-Security-Policy`, verbatim. **Not set by default** — §19.2: "a
81
+ * wrong CSP is worse than none; we prompt instead of guessing." There is no
82
+ * builder here for the same reason: a policy assembled from options reads as
83
+ * if the framework vouched for it.
84
+ */
85
+ readonly contentSecurityPolicy?: string | false | undefined
86
+ /** Extra headers, staged with the rest. */
87
+ readonly headers?: Readonly<Record<string, string>> | undefined
88
+ }
89
+
90
+ /** One resolved header, so the set is data and the hook is a loop over it. */
91
+ type Pair = readonly [name: string, value: string]
92
+
93
+ export function securityHeaders(options: SecurityHeadersOptions = {}): Plugin<void, {}> {
94
+ return definePlugin<void, {}>({
95
+ name: 'security-headers',
96
+ version: '0.1.0',
97
+ // Before everything that can short-circuit. `cors` answers preflights and
98
+ // `rate-limit` throws 429s; a hook that runs after either is absent from
99
+ // exactly the responses §19.2 most wants these headers on. Hints on
100
+ // unregistered plugins are ignored (§10.5 step 4), so this costs nothing
101
+ // when the siblings are not installed.
102
+ before: ['cors', 'rate-limit'],
103
+ config: { namespace: 'security' },
104
+
105
+ setup(app) {
106
+ const pairs = resolve(options)
107
+ const corp = options.crossOriginResource ?? 'same-site'
108
+ assertCorsCorpConsistent(corp, app.exportsOf('cors') as CorsExports | undefined, app.pluginName)
109
+
110
+ // Unrolled at boot into a fixed array; the hook is one loop over a frozen
111
+ // list of string pairs, with no option reads and no branches per request.
112
+ app.hook('onRequest', function securityHeaders(ctx: Staging): undefined {
113
+ for (let i = 0; i < pairs.length; i++) {
114
+ const pair = pairs[i] as Pair
115
+ ctx.res.header(pair[0], pair[1])
116
+ }
117
+ return undefined
118
+ }, 'security-headers')
119
+
120
+ return { exports: { headers: pairs.map(([name]) => name), crossOriginResource: corp } }
121
+ },
122
+ })
123
+ }
124
+
125
+ function resolve(options: SecurityHeadersOptions): readonly Pair[] {
126
+ const out: Pair[] = []
127
+
128
+ if (options.noSniff !== false) out.push(['x-content-type-options', 'nosniff'])
129
+
130
+ const frame = options.frameOptions ?? 'DENY'
131
+ if (frame !== false) out.push(['x-frame-options', frame])
132
+
133
+ const referrer = options.referrerPolicy ?? 'no-referrer'
134
+ if (referrer !== false) out.push(['referrer-policy', referrer])
135
+
136
+ const coop = options.crossOriginOpener ?? 'same-origin'
137
+ if (coop !== false) out.push(['cross-origin-opener-policy', coop])
138
+
139
+ const corp = options.crossOriginResource ?? 'same-site'
140
+ if (corp !== false) out.push(['cross-origin-resource-policy', corp])
141
+
142
+ const hsts = options.hsts
143
+ if (hsts !== undefined && hsts !== false) {
144
+ const maxAge = hsts.maxAge ?? 15_552_000 // 180 days
145
+ out.push([
146
+ 'strict-transport-security',
147
+ `max-age=${maxAge}` +
148
+ (hsts.includeSubDomains === true ? '; includeSubDomains' : '') +
149
+ (hsts.preload === true ? '; preload' : ''),
150
+ ])
151
+ }
152
+
153
+ const csp = options.contentSecurityPolicy
154
+ if (typeof csp === 'string' && csp.length > 0) out.push(['content-security-policy', csp])
155
+
156
+ for (const [name, value] of Object.entries(options.headers ?? {})) {
157
+ out.push([name.toLowerCase(), value])
158
+ }
159
+
160
+ return Object.freeze(out)
161
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { ReplyBuilder, Reply, LowercaseName } from '@erenthedeveloper0/zen-core'
2
+
3
+ /**
4
+ * What this pack touches on a context, declared structurally — §10.4.
5
+ *
6
+ * `Registrar.hook` takes a `Function` on purpose: a plugin is written before
7
+ * the application's decoration set exists, so pinning its hooks to
8
+ * `Context<never, X>` would reject the pattern for an `X` the author cannot
9
+ * know. The convention the examples already follow is to declare exactly the
10
+ * surface the hook uses (`examples/openapi/src/plugins/request-id.ts` declares
11
+ * `{ id: string }` and nothing else), and the point of doing it is that a
12
+ * middleware cannot quietly start depending on `ctx.user` later.
13
+ *
14
+ * These are split by concern rather than merged into one context type for the
15
+ * same reason: `securityHeaders` may not read the request, and the type says so.
16
+ */
17
+
18
+ /** Reading the request without materialising the header record. */
19
+ export interface RawReading {
20
+ readonly method: string
21
+ readonly raw: { header(name: LowercaseName): string | undefined }
22
+ }
23
+
24
+ /** Staging response metadata (§13.6) — applied at egress on every path. */
25
+ export interface Staging {
26
+ readonly res: ReplyBuilder
27
+ }
28
+
29
+ /** Producing a reply from a hook, which is how a phase short-circuits (§9.2). */
30
+ export interface Answering {
31
+ empty(status?: 204 | 205 | 304): Reply<null>
32
+ json<T>(body: T, init?: { status?: number }): Reply<T>
33
+ }
34
+
35
+ export type CorsRequest = RawReading & Answering
36
+
37
+ /**
38
+ * `ctx.raw.header` rather than `ctx.headers[name]`.
39
+ *
40
+ * `ctx.headers` is lazy and memoised, but the first touch walks every header
41
+ * the adapter received and builds a record (`buildHeaders`, §7.2). These hooks
42
+ * run on *every* request in the application, including the ones whose handlers
43
+ * never look at a header, so making them the reason that record exists would be
44
+ * a cost the application did not ask for — §9.4's rule applied to a plugin
45
+ * rather than to the compiler.
46
+ */
47
+ export function headerOf(ctx: RawReading, name: LowercaseName): string | undefined {
48
+ return ctx.raw.header(name)
49
+ }
50
+
51
+ /**
52
+ * A CORS preflight — an `OPTIONS` carrying `Access-Control-Request-Method`.
53
+ *
54
+ * Both halves are required by the Fetch standard and both are load-bearing
55
+ * here: an `OPTIONS` without the header is an ordinary request for a resource's
56
+ * options and may well have a route, and answering it with 204 would shadow
57
+ * that route with something the application did not write.
58
+ */
59
+ export function isPreflight(ctx: RawReading): boolean {
60
+ return ctx.method === 'OPTIONS' && ctx.raw.header('access-control-request-method') !== undefined
61
+ }