@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/cors.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import {
|
|
2
|
+
definePlugin, parseDuration, Codes, ZenError,
|
|
3
|
+
type AppGraph, type Duration, type HttpMethod, type Plugin, type Reply,
|
|
4
|
+
} from '@erenthedeveloper0/zen-core'
|
|
5
|
+
import { assertCorsCorpConsistent } from './consistency.ts'
|
|
6
|
+
import { type CorsRequest, type Staging, headerOf, isPreflight } from './shared.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CORS — rfcs/0001 §19.2, §4.2 stage 5, §9.2.
|
|
10
|
+
*
|
|
11
|
+
* ### Why this is a hook and not middleware
|
|
12
|
+
*
|
|
13
|
+
* `app.use(corsMiddleware)` — the shape every other framework uses — is
|
|
14
|
+
* **bypassable**, and not in a subtle way. Phase middleware lives inside a
|
|
15
|
+
* route's compiled pipeline, so it runs only when a route matched. A browser's
|
|
16
|
+
* preflight is `OPTIONS /api/things`, and almost no application registers an
|
|
17
|
+
* `OPTIONS` route; the request matches nothing, the pipeline never exists, and
|
|
18
|
+
* the middleware never runs. Measured on this codebase before the design was
|
|
19
|
+
* settled: over a matched `GET`, an unmatched path and a preflight, a `.use()`
|
|
20
|
+
* middleware ran **1 of 3** times and a global `onRequest` hook ran **3 of 3**.
|
|
21
|
+
*
|
|
22
|
+
* §9.2 already says this in the paragraph that made global `onRequest` hooks
|
|
23
|
+
* run on unmatched requests — "`onRequest` is the documented home for rate
|
|
24
|
+
* limiting, CORS and auth, and a rate limiter that only sees matched routes is
|
|
25
|
+
* bypassed by requesting a path that does not exist". CORS is the same defect
|
|
26
|
+
* with a different symptom: not a bypass, but a preflight that never gets an
|
|
27
|
+
* answer, which the browser reports as a CORS failure on the *actual* request
|
|
28
|
+
* and which is therefore debugged in the wrong place.
|
|
29
|
+
*
|
|
30
|
+
* So every member of this pack is a global `onRequest` hook, and therefore a
|
|
31
|
+
* plugin: a plugin is the only registration surface that reaches global scope
|
|
32
|
+
* while still carrying a manifest, a version and a config namespace (§10.1).
|
|
33
|
+
*
|
|
34
|
+
* ### Why it stages headers instead of writing them
|
|
35
|
+
*
|
|
36
|
+
* The response half of CORS is `Access-Control-Allow-Origin` on the *actual*
|
|
37
|
+
* response — including the 404, the 429, the 422 and the 500. A middleware that
|
|
38
|
+
* stamps the reply on the way out misses all of them: §4.6 says the error path
|
|
39
|
+
* never re-enters user middleware, and an `after` middleware on a route that
|
|
40
|
+
* did not match never runs at all. The classic symptom is a service whose
|
|
41
|
+
* errors show up in the browser as CORS failures, so every error looks like a
|
|
42
|
+
* configuration problem and nobody sees the actual status.
|
|
43
|
+
*
|
|
44
|
+
* `ctx.res` already solves this. Staged metadata is applied by `prepareForWire`
|
|
45
|
+
* at egress (§13.6), which is downstream of *every* path — success, error,
|
|
46
|
+
* timeout, and unmatched. So this hook stages, and one invariant covers all of
|
|
47
|
+
* them: **CORS never writes a reply's headers.** The consequence is asserted in
|
|
48
|
+
* `cors.test.ts` against a 404, a 500 and a rate-limited 429.
|
|
49
|
+
*
|
|
50
|
+
* ### The default is the absence of this plugin
|
|
51
|
+
*
|
|
52
|
+
* §19.2 says CORS is "deny all until configured", and that default is what a
|
|
53
|
+
* Zen app already has: with no plugin registered, no `Access-Control-Allow-*`
|
|
54
|
+
* header is ever emitted and every browser denies. Registering the plugin
|
|
55
|
+
* *without* an allowlist is therefore not "the secure default" — it is a line
|
|
56
|
+
* of code that asks for cross-origin access and does not say from where, which
|
|
57
|
+
* is indistinguishable from a bug. It is a boot error naming the fix.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
export type CorsOrigin =
|
|
61
|
+
| '*'
|
|
62
|
+
| string
|
|
63
|
+
| readonly string[]
|
|
64
|
+
| RegExp
|
|
65
|
+
| ((origin: string) => boolean)
|
|
66
|
+
|
|
67
|
+
export interface CorsOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Who may read the response. **Required** — see the note above on why the
|
|
70
|
+
* secure default is not registering this plugin at all.
|
|
71
|
+
*
|
|
72
|
+
* A string or list is matched by exact, case-sensitive comparison against the
|
|
73
|
+
* whole `Origin` header (`https://app.acme.com`), because that is what the
|
|
74
|
+
* header contains: a scheme, a host and an optional port, with no path and no
|
|
75
|
+
* trailing slash. A trailing slash is the single most common way an allowlist
|
|
76
|
+
* silently matches nothing, so it is a boot error rather than a mystery.
|
|
77
|
+
*/
|
|
78
|
+
readonly origin?: CorsOrigin | undefined
|
|
79
|
+
/**
|
|
80
|
+
* Methods advertised on a preflight.
|
|
81
|
+
*
|
|
82
|
+
* Defaults to **the methods this application actually serves**, read off the
|
|
83
|
+
* frozen `AppGraph` at boot. Every other framework hardcodes the same six
|
|
84
|
+
* verbs, which advertises `DELETE` on a read-only API and `PUT` on a service
|
|
85
|
+
* that has never had one — a small thing, but it is free here and it is the
|
|
86
|
+
* kind of answer only a framework with a graph can give (§2.4).
|
|
87
|
+
*/
|
|
88
|
+
readonly methods?: readonly HttpMethod[] | undefined
|
|
89
|
+
/**
|
|
90
|
+
* Request headers a cross-origin caller may send. `'reflect'` (the default)
|
|
91
|
+
* echoes `Access-Control-Request-Headers`.
|
|
92
|
+
*
|
|
93
|
+
* Reflecting is safe and is not the same as trusting: the browser only asks
|
|
94
|
+
* for headers the page itself set, and the server still validates every one
|
|
95
|
+
* of them. What it costs is a `Vary`, which is why an explicit list is the
|
|
96
|
+
* better answer for a cacheable API and why the two differ in what they emit.
|
|
97
|
+
*/
|
|
98
|
+
readonly allowedHeaders?: readonly string[] | 'reflect' | undefined
|
|
99
|
+
/** Response headers JavaScript may read. `Content-Type` and the other CORS-safelisted ones are always readable. */
|
|
100
|
+
readonly exposedHeaders?: readonly string[] | undefined
|
|
101
|
+
/** Allow cookies and `Authorization`. Cannot be combined with `origin: '*'`. */
|
|
102
|
+
readonly credentials?: boolean | undefined
|
|
103
|
+
/**
|
|
104
|
+
* How long a browser may cache a preflight.
|
|
105
|
+
*
|
|
106
|
+
* Defaults to **10 minutes**, which is a deliberate non-zero. With no
|
|
107
|
+
* `Access-Control-Max-Age` a browser re-preflights every few seconds, and
|
|
108
|
+
* "my API is twice as slow from the browser" is the most common CORS
|
|
109
|
+
* complaint that is not a misconfiguration. Ten minutes bounds how long a
|
|
110
|
+
* revoked origin stays cached in one browser; Chrome caps the value at two
|
|
111
|
+
* hours regardless.
|
|
112
|
+
*/
|
|
113
|
+
readonly maxAge?: Duration | undefined
|
|
114
|
+
/** `204` by default. Some legacy XHR stacks require `200` with a body. */
|
|
115
|
+
readonly preflightStatus?: 200 | 204 | undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const DEFAULT_METHODS: readonly HttpMethod[] = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']
|
|
119
|
+
|
|
120
|
+
/** What `cors` publishes for other plugins to read — see `security.ts`. */
|
|
121
|
+
export interface CorsExports {
|
|
122
|
+
readonly origins: readonly string[] | 'any' | 'dynamic'
|
|
123
|
+
readonly credentials: boolean
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Everything decided at boot, so the request path is comparisons and string
|
|
128
|
+
* writes. `matcher` is the only branch that can be user code.
|
|
129
|
+
*/
|
|
130
|
+
interface Compiled {
|
|
131
|
+
readonly anyOrigin: boolean
|
|
132
|
+
readonly matcher: (origin: string) => boolean
|
|
133
|
+
readonly credentials: boolean
|
|
134
|
+
readonly allowHeaders: string | 'reflect' | null
|
|
135
|
+
readonly exposeHeaders: string | null
|
|
136
|
+
readonly maxAge: string | null
|
|
137
|
+
readonly preflightStatus: 200 | 204
|
|
138
|
+
/**
|
|
139
|
+
* What the allowlist is, after configuration has been merged in — the shape
|
|
140
|
+
* `consistency.ts` reads, and it lives here rather than being re-derived from
|
|
141
|
+
* the written options because those are only half the answer once
|
|
142
|
+
* `config.cors.origin` exists.
|
|
143
|
+
*/
|
|
144
|
+
readonly origins: readonly string[] | 'any' | 'dynamic'
|
|
145
|
+
/** True when the method set was named rather than left to the graph. */
|
|
146
|
+
readonly methodsPinned: boolean
|
|
147
|
+
/** Filled from the frozen graph in `onBoot` unless `methodsPinned`. */
|
|
148
|
+
allowMethods: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function cors(options: CorsOptions = {}): Plugin<void, {}> {
|
|
152
|
+
return definePlugin<void, {}>({
|
|
153
|
+
name: 'cors',
|
|
154
|
+
version: '0.1.0',
|
|
155
|
+
// The pack orders itself (§10.5 step 4). A 429 or a 404 must carry
|
|
156
|
+
// `Access-Control-Allow-Origin` or the browser reports a rate limit as a
|
|
157
|
+
// CORS failure, so this hook has to be staged before the limiter can
|
|
158
|
+
// short-circuit — whichever order the two `use()` calls appear in.
|
|
159
|
+
before: ['rate-limit'],
|
|
160
|
+
config: { namespace: 'cors', env: ['CORS_ORIGINS'] },
|
|
161
|
+
|
|
162
|
+
setup(app) {
|
|
163
|
+
const compiled = compile(options, app.config, app.pluginName)
|
|
164
|
+
const exports: CorsExports = {
|
|
165
|
+
origins: compiled.origins,
|
|
166
|
+
credentials: compiled.credentials,
|
|
167
|
+
}
|
|
168
|
+
// The reciprocal half of the check in `consistency.ts`: whichever of the
|
|
169
|
+
// two plugins is registered second sees both sides. `securityHeaders`
|
|
170
|
+
// declares `before: ['cors']`, so in practice that is this one.
|
|
171
|
+
const security = app.exportsOf('security-headers') as { crossOriginResource?: string | false } | undefined
|
|
172
|
+
if (security !== undefined) {
|
|
173
|
+
assertCorsCorpConsistent(security.crossOriginResource ?? 'same-site', exports, app.pluginName)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
app.hook('onRequest', function cors(ctx: CorsRequest & Staging): Reply | undefined {
|
|
177
|
+
// `Vary: Origin` first, and **before** the early return.
|
|
178
|
+
//
|
|
179
|
+
// The tempting shape is to read `Origin`, bail when it is absent, and
|
|
180
|
+
// only then vary — every CORS library does it that way and it is
|
|
181
|
+
// wrong for caching. A request with no `Origin` produces a response
|
|
182
|
+
// with no `Access-Control-Allow-Origin`, which is a *different*
|
|
183
|
+
// response; a shared cache that stored it without `Vary` would replay
|
|
184
|
+
// it to a browser request that needed the header, and the browser
|
|
185
|
+
// would deny a caller that is on the allowlist. The failure only
|
|
186
|
+
// appears behind a CDN, only for some users, and never in a test.
|
|
187
|
+
//
|
|
188
|
+
// So the cost is one array push on every request in the application,
|
|
189
|
+
// paid for a correctness property, and §32.3 measures it rather than
|
|
190
|
+
// asserting it is small. The one configuration that skips it is a
|
|
191
|
+
// literal `*` with no credentials, where the answer genuinely does not
|
|
192
|
+
// depend on the origin.
|
|
193
|
+
if (!compiled.anyOrigin) ctx.res.appendHeader('vary', 'origin')
|
|
194
|
+
|
|
195
|
+
// Read through `raw` rather than `ctx.headers`, which materialises a
|
|
196
|
+
// record of every header on first touch (§7.2). This hook runs on every
|
|
197
|
+
// request in the application and must not be the reason that exists.
|
|
198
|
+
const origin = headerOf(ctx, 'origin')
|
|
199
|
+
if (origin === undefined) return undefined
|
|
200
|
+
|
|
201
|
+
const preflight = isPreflight(ctx)
|
|
202
|
+
const allowed = compiled.matcher(origin)
|
|
203
|
+
|
|
204
|
+
if (allowed) {
|
|
205
|
+
ctx.res.header('access-control-allow-origin', compiled.anyOrigin ? '*' : origin)
|
|
206
|
+
if (compiled.credentials) ctx.res.header('access-control-allow-credentials', 'true')
|
|
207
|
+
if (!preflight && compiled.exposeHeaders !== null) {
|
|
208
|
+
ctx.res.header('access-control-expose-headers', compiled.exposeHeaders)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!preflight) return undefined
|
|
213
|
+
|
|
214
|
+
// ── preflight ───────────────────────────────────────────────────────
|
|
215
|
+
// Answered here, before routing, body intake, validation and the
|
|
216
|
+
// handler — which is the point of §4.2 stage 5. A preflight that
|
|
217
|
+
// reached a route would have to be a route somebody wrote.
|
|
218
|
+
ctx.res.appendHeader('vary', 'access-control-request-method')
|
|
219
|
+
|
|
220
|
+
if (allowed) {
|
|
221
|
+
ctx.res.header('access-control-allow-methods', compiled.allowMethods)
|
|
222
|
+
if (compiled.allowHeaders === 'reflect') {
|
|
223
|
+
const asked = headerOf(ctx, 'access-control-request-headers')
|
|
224
|
+
ctx.res.appendHeader('vary', 'access-control-request-headers')
|
|
225
|
+
if (asked !== undefined) ctx.res.header('access-control-allow-headers', asked)
|
|
226
|
+
} else if (compiled.allowHeaders !== null) {
|
|
227
|
+
ctx.res.header('access-control-allow-headers', compiled.allowHeaders)
|
|
228
|
+
}
|
|
229
|
+
if (compiled.maxAge !== null) ctx.res.header('access-control-max-age', compiled.maxAge)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A disallowed origin still gets a well-formed 204 with no CORS
|
|
233
|
+
// headers, which is what makes the browser deny. A 403 would say
|
|
234
|
+
// "this origin is not on the list", and an allowlist that reports its
|
|
235
|
+
// own contents is an allowlist you can enumerate.
|
|
236
|
+
return ctx.empty(compiled.preflightStatus as 204)
|
|
237
|
+
}, 'cors')
|
|
238
|
+
|
|
239
|
+
// §2.4 — the graph is the one structure everything reads. The advertised
|
|
240
|
+
// method set is a projection of it, computed once, so it cannot claim a
|
|
241
|
+
// verb the router would answer 405 for.
|
|
242
|
+
app.onBoot((graph) => {
|
|
243
|
+
if (compiled.methodsPinned) return
|
|
244
|
+
compiled.allowMethods = servedMethods(graph as AppGraph).join(', ')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// Spread rather than passed by reference: `PluginResult.exports` is a
|
|
248
|
+
// `Record<string, unknown>`, and a named interface has no index signature.
|
|
249
|
+
return { exports: { ...exports } }
|
|
250
|
+
},
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The verbs this application answers, `OPTIONS` excluded.
|
|
256
|
+
*
|
|
257
|
+
* `HEAD` is added whenever any `GET` exists, because §4.2 gives every `GET`
|
|
258
|
+
* route a free `HEAD` that never appears as a `RouteRecord` — advertising the
|
|
259
|
+
* declared set alone would omit a method the server demonstrably serves.
|
|
260
|
+
*/
|
|
261
|
+
function servedMethods(graph: AppGraph): HttpMethod[] {
|
|
262
|
+
const seen = new Set<string>()
|
|
263
|
+
for (const route of graph.routes) seen.add(route.method)
|
|
264
|
+
if (seen.has('GET')) seen.add('HEAD')
|
|
265
|
+
seen.delete('OPTIONS')
|
|
266
|
+
const order: readonly HttpMethod[] = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'TRACE']
|
|
267
|
+
const out = order.filter((m) => seen.has(m))
|
|
268
|
+
return out.length === 0 ? [...DEFAULT_METHODS] : out
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Options → the decided form, with everything that can be wrong reported here.
|
|
273
|
+
*
|
|
274
|
+
* Thrown rather than collected, because §12.7's aggregation is the *app's*
|
|
275
|
+
* list and a plugin reaches it by throwing from `setup` — `ready()` wraps it
|
|
276
|
+
* into a `ZEN_PLUGIN_OPTIONS` diagnostic that names the plugin and everything
|
|
277
|
+
* that will now not load.
|
|
278
|
+
*/
|
|
279
|
+
function compile(
|
|
280
|
+
written: CorsOptions,
|
|
281
|
+
config: Readonly<Record<string, unknown>>,
|
|
282
|
+
pluginName: string,
|
|
283
|
+
): Compiled {
|
|
284
|
+
const options = merge(written, config, pluginName)
|
|
285
|
+
const origin = options.origin
|
|
286
|
+
|
|
287
|
+
if (origin === undefined) {
|
|
288
|
+
throw new ZenError(
|
|
289
|
+
Codes.CONFIG_INVALID,
|
|
290
|
+
`${pluginName} needs an origin allowlist, and refuses to guess one.`,
|
|
291
|
+
{
|
|
292
|
+
status: 500,
|
|
293
|
+
expose: false,
|
|
294
|
+
hint:
|
|
295
|
+
"Pass one — cors({ origin: ['https://app.example.com'] }) — or set `cors.origin` in " +
|
|
296
|
+
'configuration so a deployment can supply it from CORS_ORIGINS.',
|
|
297
|
+
consequence:
|
|
298
|
+
'Not registering this plugin at all is the deny-everything default (§19.2): with no ' +
|
|
299
|
+
'Access-Control-Allow-Origin header, every browser already denies. Registering it ' +
|
|
300
|
+
'without a list is a request for cross-origin access that does not say from where.',
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const anyOrigin = origin === '*'
|
|
306
|
+
const credentials = options.credentials === true
|
|
307
|
+
|
|
308
|
+
if (anyOrigin && credentials) {
|
|
309
|
+
throw new ZenError(
|
|
310
|
+
Codes.CONFIG_INVALID,
|
|
311
|
+
`${pluginName} cannot combine origin: '*' with credentials: true.`,
|
|
312
|
+
{
|
|
313
|
+
status: 500,
|
|
314
|
+
expose: false,
|
|
315
|
+
hint: 'Name the origins that may send credentials, or drop credentials.',
|
|
316
|
+
consequence:
|
|
317
|
+
'Browsers reject the pair outright. The usual library "fix" is to reflect whatever ' +
|
|
318
|
+
'Origin arrives, which turns an allowlist into allow-everyone while still reading ' +
|
|
319
|
+
"like an allowlist — so this is refused rather than quietly repaired.",
|
|
320
|
+
},
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const list = typeof origin === 'string' && !anyOrigin ? [origin]
|
|
325
|
+
: Array.isArray(origin) ? (origin as readonly string[])
|
|
326
|
+
: null
|
|
327
|
+
|
|
328
|
+
if (list !== null) {
|
|
329
|
+
for (const entry of list) {
|
|
330
|
+
if (entry.endsWith('/') || entry.includes('/', 8)) {
|
|
331
|
+
throw new ZenError(
|
|
332
|
+
Codes.CONFIG_INVALID,
|
|
333
|
+
`${pluginName}: "${entry}" is not an origin — an Origin header carries a scheme, host and optional port, and no path.`,
|
|
334
|
+
{
|
|
335
|
+
status: 500,
|
|
336
|
+
expose: false,
|
|
337
|
+
hint: `Use "${entry.replace(/\/+$/, '').replace(/^(\w+:\/\/[^/]+).*$/, '$1')}".`,
|
|
338
|
+
consequence: 'A trailing slash matches no origin, so the allowlist would silently allow nothing.',
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const matcher: (o: string) => boolean =
|
|
346
|
+
anyOrigin ? () => true
|
|
347
|
+
: list !== null
|
|
348
|
+
? list.length === 1
|
|
349
|
+
// One origin is the common case and deserves a comparison rather than
|
|
350
|
+
// a Set lookup; more than one, and the Set wins from about four.
|
|
351
|
+
? ((only) => (o) => o === only)(list[0] as string)
|
|
352
|
+
: ((set) => (o) => set.has(o))(new Set(list))
|
|
353
|
+
: origin instanceof RegExp
|
|
354
|
+
// A fresh test each call: a global regex carries `lastIndex` between
|
|
355
|
+
// calls and would match every other request. §19.3 forbids unbounded
|
|
356
|
+
// backtracking in framework source; a user's own pattern is their
|
|
357
|
+
// choice, and it is named in `explainRoute` as `cors`.
|
|
358
|
+
? ((re) => (o) => new RegExp(re.source, re.flags.replace('g', '')).test(o))(origin)
|
|
359
|
+
: (origin as (o: string) => boolean)
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
anyOrigin,
|
|
363
|
+
matcher,
|
|
364
|
+
credentials,
|
|
365
|
+
allowHeaders:
|
|
366
|
+
options.allowedHeaders === undefined ? 'reflect'
|
|
367
|
+
: options.allowedHeaders === 'reflect' ? 'reflect'
|
|
368
|
+
: options.allowedHeaders.length === 0 ? null
|
|
369
|
+
: options.allowedHeaders.join(', '),
|
|
370
|
+
exposeHeaders:
|
|
371
|
+
options.exposedHeaders === undefined || options.exposedHeaders.length === 0
|
|
372
|
+
? null
|
|
373
|
+
: options.exposedHeaders.join(', '),
|
|
374
|
+
maxAge: maxAgeOf(options.maxAge),
|
|
375
|
+
preflightStatus: options.preflightStatus ?? 204,
|
|
376
|
+
origins:
|
|
377
|
+
anyOrigin ? 'any'
|
|
378
|
+
: list !== null ? [...list]
|
|
379
|
+
: 'dynamic',
|
|
380
|
+
methodsPinned: options.methods !== undefined,
|
|
381
|
+
allowMethods: (options.methods ?? DEFAULT_METHODS).join(', '),
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function maxAgeOf(value: Duration | undefined): string | null {
|
|
386
|
+
const ms = parseDuration(value ?? '10m')
|
|
387
|
+
return ms <= 0 ? null : String(Math.floor(ms / 1000))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* `config.cors.*` under what the call site wrote — §16.1's precedence, applied
|
|
392
|
+
* field by field.
|
|
393
|
+
*
|
|
394
|
+
* The first draft read only `origin` from configuration, and the example caught
|
|
395
|
+
* it immediately: `zen.config.ts` declared `cors.credentials: true`, `cors()`
|
|
396
|
+
* was called with no arguments, and the header was silently absent. Half a
|
|
397
|
+
* feature is worse than none here, because "configuration is ignored" is
|
|
398
|
+
* indistinguishable from "the browser is wrong" from the outside.
|
|
399
|
+
*
|
|
400
|
+
* Field by field rather than a spread for the same reason plugin defaults merge
|
|
401
|
+
* that way (§16.1 layer 2): `cors({ credentials: true })` next to a config that
|
|
402
|
+
* names the origins should end up with both, not with whichever object was
|
|
403
|
+
* spread last.
|
|
404
|
+
*
|
|
405
|
+
* A config value of the wrong *shape* is a boot error rather than something
|
|
406
|
+
* quietly dropped. `cors.origin: 42` is a mistake somebody made, and the two
|
|
407
|
+
* ways to not report it — ignore it, or coerce it — are how an allowlist ends
|
|
408
|
+
* up meaning something nobody wrote.
|
|
409
|
+
*/
|
|
410
|
+
function merge(
|
|
411
|
+
written: CorsOptions,
|
|
412
|
+
config: Readonly<Record<string, unknown>>,
|
|
413
|
+
pluginName: string,
|
|
414
|
+
): CorsOptions {
|
|
415
|
+
const namespace = config['cors']
|
|
416
|
+
if (namespace === undefined || namespace === null) return written
|
|
417
|
+
if (typeof namespace !== 'object' || Array.isArray(namespace)) {
|
|
418
|
+
throw new ZenError(
|
|
419
|
+
Codes.CONFIG_INVALID,
|
|
420
|
+
`${pluginName}: config.cors must be an object of options, and is ${describe(namespace)}.`,
|
|
421
|
+
{
|
|
422
|
+
status: 500,
|
|
423
|
+
expose: false,
|
|
424
|
+
hint: 'Give it a namespace: `cors: { origin: env => env.CORS_ORIGINS.split(",") }`.',
|
|
425
|
+
consequence: 'Nothing under config.cors can be read while it is not an object.',
|
|
426
|
+
},
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const from = namespace as Record<string, unknown>
|
|
431
|
+
const take = <K extends keyof CorsOptions>(key: K, ok: (value: unknown) => boolean): CorsOptions[K] | undefined => {
|
|
432
|
+
const value = from[key]
|
|
433
|
+
if (value === undefined) return undefined
|
|
434
|
+
if (!ok(value)) {
|
|
435
|
+
throw new ZenError(
|
|
436
|
+
Codes.CONFIG_INVALID,
|
|
437
|
+
`${pluginName}: config.cors.${key} is ${describe(value)}, which is not a valid ${key}.`,
|
|
438
|
+
{
|
|
439
|
+
status: 500,
|
|
440
|
+
expose: false,
|
|
441
|
+
hint: `Fix the value in the configuration file, or pass ${key} to cors() directly.`,
|
|
442
|
+
consequence: 'Ignoring it would make the running configuration differ from the written one.',
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
return value as CorsOptions[K]
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const isStringList = (v: unknown): boolean => Array.isArray(v) && v.every((e) => typeof e === 'string')
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
origin: written.origin ?? take('origin', (v) =>
|
|
453
|
+
typeof v === 'string' || isStringList(v) || v instanceof RegExp || typeof v === 'function'),
|
|
454
|
+
methods: written.methods ?? take('methods', isStringList),
|
|
455
|
+
allowedHeaders: written.allowedHeaders ?? take('allowedHeaders', (v) => v === 'reflect' || isStringList(v)),
|
|
456
|
+
exposedHeaders: written.exposedHeaders ?? take('exposedHeaders', isStringList),
|
|
457
|
+
credentials: written.credentials ?? take('credentials', (v) => typeof v === 'boolean'),
|
|
458
|
+
maxAge: written.maxAge ?? take('maxAge', (v) => typeof v === 'number' || typeof v === 'string'),
|
|
459
|
+
preflightStatus: written.preflightStatus ?? take('preflightStatus', (v) => v === 200 || v === 204),
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function describe(value: unknown): string {
|
|
464
|
+
if (value === null) return 'null'
|
|
465
|
+
if (Array.isArray(value)) return `an array (${JSON.stringify(value).slice(0, 40)})`
|
|
466
|
+
if (typeof value === 'object') return 'an object'
|
|
467
|
+
return `${typeof value} ${JSON.stringify(value)}`
|
|
468
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
|
|
24
|
+
export { cors, type CorsOptions, type CorsOrigin, type CorsExports } from './cors.ts'
|
|
25
|
+
export {
|
|
26
|
+
securityHeaders, type SecurityHeadersOptions, type ReferrerPolicy,
|
|
27
|
+
} from './security.ts'
|
|
28
|
+
export { requestId, type RequestIdOptions } from './request-id.ts'
|
|
29
|
+
export {
|
|
30
|
+
rateLimit, type RateLimitOptions, type RateLimitContext,
|
|
31
|
+
} from './rate-limit.ts'
|
|
32
|
+
export {
|
|
33
|
+
MemoryStore, ReferenceStore, type Store, type Tally, type MemoryStoreOptions,
|
|
34
|
+
} from './store.ts'
|