@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/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'