ingenium 0.0.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 +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- package/src/ws/ws-node-adapter.ts +162 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import type { IngeniumContext } from './context.ts'
|
|
4
|
+
import { IngeniumError } from '../errors.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options accepted by {@link IngeniumCookies.set}. Maps 1:1 to RFC 6265 cookie
|
|
8
|
+
* attributes plus a few modern extensions (`Priority`, `Partitioned`).
|
|
9
|
+
*
|
|
10
|
+
* `sameSite: true` is normalized to `'strict'` (Express compatibility);
|
|
11
|
+
* `sameSite: false` omits the attribute entirely so the browser falls back
|
|
12
|
+
* to its default policy.
|
|
13
|
+
*/
|
|
14
|
+
export interface CookieSetOptions {
|
|
15
|
+
/** `Domain=` attribute. Omitted when undefined. */
|
|
16
|
+
domain?: string
|
|
17
|
+
/** `Path=` attribute. Defaults to `'/'`. */
|
|
18
|
+
path?: string
|
|
19
|
+
/** `Expires=` attribute. Serialized via `Date.toUTCString()`. */
|
|
20
|
+
expires?: Date
|
|
21
|
+
/** `Max-Age=` (seconds). Floored to an integer. */
|
|
22
|
+
maxAge?: number
|
|
23
|
+
/** `HttpOnly` flag. */
|
|
24
|
+
httpOnly?: boolean
|
|
25
|
+
/** `Secure` flag. */
|
|
26
|
+
secure?: boolean
|
|
27
|
+
/** `SameSite=` attribute. `true` → `'strict'`; `false`/omitted → no attr. */
|
|
28
|
+
sameSite?: 'strict' | 'lax' | 'none' | true | false
|
|
29
|
+
/** `Priority=` attribute (CHIPS / RFC 9220). Capitalized on the wire. */
|
|
30
|
+
priority?: 'low' | 'medium' | 'high'
|
|
31
|
+
/** `Partitioned` flag (CHIPS). */
|
|
32
|
+
partitioned?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* When `true`, the cookie value is HMAC-SHA-256 signed with the app's
|
|
35
|
+
* `cookieSecrets[0]`. On the wire: `name=value.signature`. Throws
|
|
36
|
+
* `IngeniumError(500, 'COOKIE_SECRET_MISSING')` if no secrets are configured.
|
|
37
|
+
*/
|
|
38
|
+
signed?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options accepted by {@link IngeniumCookies.get}. */
|
|
42
|
+
export interface CookieGetOptions {
|
|
43
|
+
/**
|
|
44
|
+
* When `true`, the cookie value is treated as `value.signature` and the
|
|
45
|
+
* HMAC is verified against every configured secret (rotation-safe).
|
|
46
|
+
* Returns `null` on tamper, missing signature, or no configured secrets.
|
|
47
|
+
*/
|
|
48
|
+
signed?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* First-class cookie API exposed via `ctx.cookies`. Pool-bound and lazy —
|
|
53
|
+
* the holder is allocated on first access and dropped to `null` on context
|
|
54
|
+
* reset, so routes that never touch cookies pay zero overhead.
|
|
55
|
+
*
|
|
56
|
+
* Read side parses `ctx.headers.cookie` once and caches the resulting record.
|
|
57
|
+
* Write side appends to the response `set-cookie` header, preserving prior
|
|
58
|
+
* values (a single response may carry multiple `Set-Cookie` headers).
|
|
59
|
+
*/
|
|
60
|
+
export interface IngeniumCookies {
|
|
61
|
+
/**
|
|
62
|
+
* Read a cookie by name. With `{ signed: true }`, verifies the HMAC
|
|
63
|
+
* suffix and returns `null` on mismatch. Returns `null` when the cookie
|
|
64
|
+
* is absent.
|
|
65
|
+
*/
|
|
66
|
+
get(name: string, opts?: CookieGetOptions): string | null
|
|
67
|
+
/**
|
|
68
|
+
* Snapshot of all parsed cookies. Signed cookies appear with their raw
|
|
69
|
+
* `value.signature` suffix — call `.get(name, { signed: true })` to verify.
|
|
70
|
+
*/
|
|
71
|
+
all(): Record<string, string>
|
|
72
|
+
/**
|
|
73
|
+
* Write a `Set-Cookie` header. Multiple calls accumulate (the response
|
|
74
|
+
* carries one `Set-Cookie` header per call). With `{ signed: true }`,
|
|
75
|
+
* the value is HMAC-SHA-256 signed.
|
|
76
|
+
*/
|
|
77
|
+
set(name: string, value: string, opts?: CookieSetOptions): void
|
|
78
|
+
/**
|
|
79
|
+
* Expire a cookie. Emits `Max-Age=0` plus an `Expires` in the past, and
|
|
80
|
+
* mirrors `path` / `domain` so the browser actually removes the right
|
|
81
|
+
* cookie (a `Set-Cookie` only matches the existing cookie on those attrs).
|
|
82
|
+
*/
|
|
83
|
+
clear(name: string, opts?: Pick<CookieSetOptions, 'domain' | 'path'>): void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ───── Parser (RFC 6265 §5.2, defensive) ────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a `Cookie` request header into a name → value map. Mirrors the
|
|
90
|
+
* `parseCookieHeader` helper in `session/middleware.ts` but kept inline here
|
|
91
|
+
* so the cookie holder has no cross-module dependency on the session module.
|
|
92
|
+
*
|
|
93
|
+
* - First occurrence wins (RFC 6265 §5.4 typical browser behavior).
|
|
94
|
+
* - Quoted values: surrounding `"` are stripped.
|
|
95
|
+
* - Percent-encoded values are decoded via `decodeURIComponent`; bad encodings
|
|
96
|
+
* fall back to the raw value rather than throwing — this parser is exposed
|
|
97
|
+
* to attacker-controlled input and must never crash dispatch.
|
|
98
|
+
*/
|
|
99
|
+
function parseCookieHeader(header: string | undefined): Record<string, string> {
|
|
100
|
+
const out: Record<string, string> = Object.create(null) as Record<string, string>
|
|
101
|
+
if (!header) return out
|
|
102
|
+
|
|
103
|
+
const parts = header.split(';')
|
|
104
|
+
for (let i = 0; i < parts.length; i++) {
|
|
105
|
+
const part = parts[i]!
|
|
106
|
+
const eq = part.indexOf('=')
|
|
107
|
+
if (eq < 0) continue
|
|
108
|
+
const name = part.slice(0, eq).trim()
|
|
109
|
+
if (!name || name in out) continue
|
|
110
|
+
let value = part.slice(eq + 1).trim()
|
|
111
|
+
if (
|
|
112
|
+
value.length >= 2 &&
|
|
113
|
+
value.charCodeAt(0) === 0x22 &&
|
|
114
|
+
value.charCodeAt(value.length - 1) === 0x22
|
|
115
|
+
) {
|
|
116
|
+
value = value.slice(1, -1)
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
out[name] = decodeURIComponent(value)
|
|
120
|
+
} catch {
|
|
121
|
+
out[name] = value
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ───── Serializer ───────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/** Capitalize the first character; the rest stays as-is. */
|
|
130
|
+
function cap(s: string): string {
|
|
131
|
+
return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Serialize a single `Set-Cookie` value per RFC 6265 §4.1.1. The value is
|
|
136
|
+
* `encodeURIComponent`-escaped so semicolons, whitespace, and control chars
|
|
137
|
+
* cannot break the header (the read side mirrors this with `decodeURIComponent`).
|
|
138
|
+
*/
|
|
139
|
+
function serializeSetCookie(name: string, value: string, opts: CookieSetOptions): string {
|
|
140
|
+
const segments: string[] = [`${name}=${encodeURIComponent(value)}`]
|
|
141
|
+
if (opts.domain) segments.push(`Domain=${opts.domain}`)
|
|
142
|
+
segments.push(`Path=${opts.path ?? '/'}`)
|
|
143
|
+
|
|
144
|
+
if (opts.expires) {
|
|
145
|
+
segments.push(`Expires=${opts.expires.toUTCString()}`)
|
|
146
|
+
}
|
|
147
|
+
if (typeof opts.maxAge === 'number') {
|
|
148
|
+
// Max-Age must be an integer; floor to match RFC behaviour.
|
|
149
|
+
segments.push(`Max-Age=${Math.floor(opts.maxAge)}`)
|
|
150
|
+
}
|
|
151
|
+
if (opts.httpOnly) segments.push('HttpOnly')
|
|
152
|
+
if (opts.secure) segments.push('Secure')
|
|
153
|
+
|
|
154
|
+
if (opts.sameSite !== undefined && opts.sameSite !== false) {
|
|
155
|
+
// `true` → 'strict' for Express compat. Otherwise lowercase → Capitalized.
|
|
156
|
+
const ss = opts.sameSite === true ? 'strict' : opts.sameSite
|
|
157
|
+
segments.push(`SameSite=${cap(ss)}`)
|
|
158
|
+
}
|
|
159
|
+
if (opts.priority) segments.push(`Priority=${cap(opts.priority)}`)
|
|
160
|
+
if (opts.partitioned) segments.push('Partitioned')
|
|
161
|
+
|
|
162
|
+
return segments.join('; ')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Append a `Set-Cookie` value to the response, preserving any existing
|
|
167
|
+
* values. The header bag normalizes to an array on the second `.set()` so
|
|
168
|
+
* the transport writes multiple `Set-Cookie` lines (per RFC 7230 §3.2.2,
|
|
169
|
+
* `Set-Cookie` is the canonical exception to header-folding rules).
|
|
170
|
+
*/
|
|
171
|
+
function appendSetCookie(ctx: IngeniumContext<unknown>, value: string): void {
|
|
172
|
+
const existing = ctx.getHeader('set-cookie')
|
|
173
|
+
if (!existing) {
|
|
174
|
+
ctx.set('set-cookie', value)
|
|
175
|
+
} else if (Array.isArray(existing)) {
|
|
176
|
+
ctx.set('set-cookie', [...existing, value])
|
|
177
|
+
} else {
|
|
178
|
+
ctx.set('set-cookie', [existing, value])
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ───── HMAC sign / verify ───────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/** HMAC-SHA-256(secret, value), base64url-encoded. */
|
|
185
|
+
function sign(value: string, secret: string): string {
|
|
186
|
+
return createHmac('sha256', secret).update(value).digest('base64url')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Verify a `value.signature` cookie against any of the provided secrets.
|
|
191
|
+
* Returns the un-signed value or `null`. Uses {@link timingSafeEqual} to
|
|
192
|
+
* defeat byte-wise timing oracles. Splits on the LAST `.` so the underlying
|
|
193
|
+
* value may itself contain dots.
|
|
194
|
+
*/
|
|
195
|
+
function verifySigned(raw: string, secrets: readonly string[]): string | null {
|
|
196
|
+
const dot = raw.lastIndexOf('.')
|
|
197
|
+
if (dot <= 0 || dot >= raw.length - 1) return null
|
|
198
|
+
const value = raw.slice(0, dot)
|
|
199
|
+
const sig = raw.slice(dot + 1)
|
|
200
|
+
const sigBuf = Buffer.from(sig, 'base64url')
|
|
201
|
+
if (sigBuf.length === 0) return null
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < secrets.length; i++) {
|
|
204
|
+
const expected = Buffer.from(sign(value, secrets[i]!), 'base64url')
|
|
205
|
+
if (expected.length !== sigBuf.length) continue
|
|
206
|
+
if (timingSafeEqual(expected, sigBuf)) return value
|
|
207
|
+
}
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ───── Factory ──────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build the lazy cookie holder bound to `ctx`. The parsed-cookies cache is
|
|
215
|
+
* populated on first read; the closed-over `parsed` reference is local to
|
|
216
|
+
* the holder so a context that's reset and re-acquired gets a fresh holder
|
|
217
|
+
* (because `ctx._cookies` is nulled on `reset()`).
|
|
218
|
+
*
|
|
219
|
+
* Secrets are read from `ctx._cookieSecrets`, which the app stamps at
|
|
220
|
+
* dispatch entry when configured — same pattern as `_trustProxy`. The read
|
|
221
|
+
* happens at sign/verify time (NOT at holder construction) so an app that
|
|
222
|
+
* registers secrets after the holder is allocated still picks them up.
|
|
223
|
+
*/
|
|
224
|
+
export function makeIngeniumCookies<Params>(ctx: IngeniumContext<Params>): IngeniumCookies {
|
|
225
|
+
let parsed: Record<string, string> | null = null
|
|
226
|
+
|
|
227
|
+
const requireSecrets = (): readonly string[] => {
|
|
228
|
+
const secrets = ctx._cookieSecrets
|
|
229
|
+
if (!secrets || secrets.length === 0) {
|
|
230
|
+
throw new IngeniumError(
|
|
231
|
+
500,
|
|
232
|
+
'COOKIE_SECRET_MISSING',
|
|
233
|
+
'Signed cookies require `cookieSecrets` to be configured on the app.',
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
return secrets
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
get(name, opts) {
|
|
241
|
+
if (!parsed) parsed = parseCookieHeader(ctx.headers.cookie as string | undefined)
|
|
242
|
+
const raw = parsed[name]
|
|
243
|
+
if (raw === undefined) return null
|
|
244
|
+
if (opts?.signed) {
|
|
245
|
+
// Verify uses ALL secrets so rotation (new key first, old keys kept)
|
|
246
|
+
// doesn't lock existing clients out mid-deploy.
|
|
247
|
+
const secrets = requireSecrets()
|
|
248
|
+
return verifySigned(raw, secrets)
|
|
249
|
+
}
|
|
250
|
+
return raw
|
|
251
|
+
},
|
|
252
|
+
all() {
|
|
253
|
+
if (!parsed) parsed = parseCookieHeader(ctx.headers.cookie as string | undefined)
|
|
254
|
+
return parsed
|
|
255
|
+
},
|
|
256
|
+
set(name, value, opts) {
|
|
257
|
+
let wireValue = value
|
|
258
|
+
if (opts?.signed) {
|
|
259
|
+
// First secret signs; remaining secrets are verify-only (rotation).
|
|
260
|
+
const secrets = requireSecrets()
|
|
261
|
+
wireValue = `${value}.${sign(value, secrets[0]!)}`
|
|
262
|
+
}
|
|
263
|
+
appendSetCookie(ctx, serializeSetCookie(name, wireValue, opts ?? {}))
|
|
264
|
+
},
|
|
265
|
+
clear(name, opts) {
|
|
266
|
+
// Max-Age=0 + an Expires in the distant past. Browsers only match on
|
|
267
|
+
// (name, domain, path) when expiring, so mirror those from the caller.
|
|
268
|
+
appendSetCookie(
|
|
269
|
+
ctx,
|
|
270
|
+
serializeSetCookie(name, '', {
|
|
271
|
+
// exactOptionalPropertyTypes: only include `domain` when the caller
|
|
272
|
+
// actually supplied one — an explicit `undefined` isn't assignable to
|
|
273
|
+
// the optional `domain?: string` field.
|
|
274
|
+
...(opts?.domain !== undefined ? { domain: opts.domain } : {}),
|
|
275
|
+
path: opts?.path ?? '/',
|
|
276
|
+
maxAge: 0,
|
|
277
|
+
expires: new Date(0),
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { IngeniumContext } from './context.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A bounded free-list of `IngeniumContext` objects. Acquire on each request,
|
|
5
|
+
* release back when the response has been written. If the pool is empty,
|
|
6
|
+
* a fresh context is allocated; if the pool is full on release, the
|
|
7
|
+
* context is discarded (GC handles it). Never blocks.
|
|
8
|
+
*/
|
|
9
|
+
export class IngeniumContextPool {
|
|
10
|
+
private readonly pool: IngeniumContext[] = []
|
|
11
|
+
private readonly max: number
|
|
12
|
+
|
|
13
|
+
constructor(maxSize = 1024) {
|
|
14
|
+
this.max = maxSize
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Acquire a context. Caller must call `release()` when done. */
|
|
18
|
+
acquire(): IngeniumContext {
|
|
19
|
+
return this.pool.pop() ?? new IngeniumContext()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Reset and return the context to the free list (or discard if full). */
|
|
23
|
+
release(ctx: IngeniumContext): void {
|
|
24
|
+
ctx.reset()
|
|
25
|
+
if (this.pool.length < this.max) this.pool.push(ctx)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Current free-list size. Useful for tests and metrics. */
|
|
29
|
+
get size(): number {
|
|
30
|
+
return this.pool.length
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { IngeniumMiddleware } from '../middleware/types.ts'
|
|
2
|
+
import type { IngeniumContext } from '../context/context.ts'
|
|
3
|
+
import type { CorsOptions, CorsOrigin } from './types.ts'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_METHODS: readonly string[] = [
|
|
6
|
+
'GET',
|
|
7
|
+
'HEAD',
|
|
8
|
+
'PUT',
|
|
9
|
+
'PATCH',
|
|
10
|
+
'POST',
|
|
11
|
+
'DELETE',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Append a value to the `Vary` response header, de-duplicating field names
|
|
16
|
+
* (case-insensitive).
|
|
17
|
+
*/
|
|
18
|
+
function appendVary(ctx: IngeniumContext, field: string): void {
|
|
19
|
+
const existing = ctx.getHeader('vary')
|
|
20
|
+
if (!existing) {
|
|
21
|
+
ctx.set('vary', field)
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
const cur = Array.isArray(existing) ? existing.join(', ') : existing
|
|
25
|
+
const seen = cur
|
|
26
|
+
.split(',')
|
|
27
|
+
.map((s) => s.trim().toLowerCase())
|
|
28
|
+
.filter((s) => s.length > 0)
|
|
29
|
+
if (seen.includes(field.toLowerCase())) return
|
|
30
|
+
ctx.set('vary', cur.length > 0 ? `${cur}, ${field}` : field)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the `origin` option against the request's `Origin` header.
|
|
35
|
+
* Returns the literal value to put on `Access-Control-Allow-Origin`, or
|
|
36
|
+
* `null` to omit the header (request is denied / had no `Origin`).
|
|
37
|
+
*
|
|
38
|
+
* Also returns `reflected` — `true` when the value mirrors the request's
|
|
39
|
+
* `Origin`, so the caller knows to add `Vary: Origin`.
|
|
40
|
+
*/
|
|
41
|
+
async function resolveOrigin(
|
|
42
|
+
spec: CorsOrigin,
|
|
43
|
+
reqOrigin: string | undefined,
|
|
44
|
+
ctx: IngeniumContext,
|
|
45
|
+
): Promise<{ value: string | null; reflected: boolean }> {
|
|
46
|
+
// Static wildcard: never depends on the request, never reflects.
|
|
47
|
+
if (spec === '*') return { value: '*', reflected: false }
|
|
48
|
+
if (spec === false) return { value: null, reflected: false }
|
|
49
|
+
|
|
50
|
+
// Anything below requires an Origin header on the request.
|
|
51
|
+
if (typeof reqOrigin !== 'string' || reqOrigin.length === 0) {
|
|
52
|
+
return { value: null, reflected: false }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (spec === true) return { value: reqOrigin, reflected: true }
|
|
56
|
+
|
|
57
|
+
if (typeof spec === 'string') {
|
|
58
|
+
return spec === reqOrigin
|
|
59
|
+
? { value: reqOrigin, reflected: true }
|
|
60
|
+
: { value: null, reflected: true }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(spec)) {
|
|
64
|
+
return spec.includes(reqOrigin)
|
|
65
|
+
? { value: reqOrigin, reflected: true }
|
|
66
|
+
: { value: null, reflected: true }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (spec instanceof RegExp) {
|
|
70
|
+
return spec.test(reqOrigin)
|
|
71
|
+
? { value: reqOrigin, reflected: true }
|
|
72
|
+
: { value: null, reflected: true }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof spec === 'function') {
|
|
76
|
+
const result = await spec(reqOrigin, ctx)
|
|
77
|
+
if (result === true) return { value: reqOrigin, reflected: true }
|
|
78
|
+
if (result === false) return { value: null, reflected: true }
|
|
79
|
+
if (typeof result === 'string') {
|
|
80
|
+
// Custom string — not a literal reflection; only Vary if it's not '*'.
|
|
81
|
+
return { value: result, reflected: result !== '*' }
|
|
82
|
+
}
|
|
83
|
+
return { value: null, reflected: true }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { value: null, reflected: false }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* CORS middleware. Implements the standard CORS protocol (Fetch spec
|
|
91
|
+
* §3.2.4) for both simple requests and preflight (`OPTIONS` +
|
|
92
|
+
* `Access-Control-Request-Method`).
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* app.use(ingenium.cors())
|
|
96
|
+
* app.use(ingenium.cors({ origin: ['https://app.example.com'], credentials: true }))
|
|
97
|
+
*/
|
|
98
|
+
export function corsMiddleware(opts: CorsOptions = {}): IngeniumMiddleware {
|
|
99
|
+
const origin: CorsOrigin = opts.origin ?? '*'
|
|
100
|
+
const methods = opts.methods ?? DEFAULT_METHODS
|
|
101
|
+
const allowedHeaders = opts.allowedHeaders
|
|
102
|
+
const exposedHeaders = opts.exposedHeaders
|
|
103
|
+
const credentials = opts.credentials ?? false
|
|
104
|
+
const maxAge = opts.maxAge
|
|
105
|
+
const optionsSuccessStatus = opts.optionsSuccessStatus ?? 204
|
|
106
|
+
|
|
107
|
+
// Construction-time validation: `credentials: true` + wildcard origin is
|
|
108
|
+
// forbidden by the CORS spec — browsers reject the response.
|
|
109
|
+
if (credentials && origin === '*') {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"ingenium.cors: `credentials: true` is incompatible with `origin: '*'`. " +
|
|
112
|
+
'Specify an explicit origin (string, array, regex, or function) instead.',
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const methodsHeader = methods.join(',')
|
|
117
|
+
const exposedHeader = exposedHeaders && exposedHeaders.length > 0
|
|
118
|
+
? exposedHeaders.join(',')
|
|
119
|
+
: undefined
|
|
120
|
+
const allowedHeader = allowedHeaders && allowedHeaders.length > 0
|
|
121
|
+
? allowedHeaders.join(',')
|
|
122
|
+
: undefined
|
|
123
|
+
const maxAgeHeader = typeof maxAge === 'number' ? String(maxAge) : undefined
|
|
124
|
+
|
|
125
|
+
return async (ctx, next) => {
|
|
126
|
+
const reqOrigin = ctx.headers.origin
|
|
127
|
+
const reqOriginStr = typeof reqOrigin === 'string' ? reqOrigin : undefined
|
|
128
|
+
|
|
129
|
+
const { value: allowOrigin, reflected } = await resolveOrigin(
|
|
130
|
+
origin,
|
|
131
|
+
reqOriginStr,
|
|
132
|
+
ctx,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if (reflected) appendVary(ctx, 'Origin')
|
|
136
|
+
|
|
137
|
+
if (allowOrigin !== null) {
|
|
138
|
+
ctx.set('access-control-allow-origin', allowOrigin)
|
|
139
|
+
if (credentials) {
|
|
140
|
+
ctx.set('access-control-allow-credentials', 'true')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Detect preflight: OPTIONS + Access-Control-Request-Method header.
|
|
145
|
+
const acrm = ctx.headers['access-control-request-method']
|
|
146
|
+
const isPreflight =
|
|
147
|
+
ctx.method === 'OPTIONS' && typeof acrm === 'string' && acrm.length > 0
|
|
148
|
+
|
|
149
|
+
if (isPreflight) {
|
|
150
|
+
ctx.set('access-control-allow-methods', methodsHeader)
|
|
151
|
+
|
|
152
|
+
if (allowedHeader !== undefined) {
|
|
153
|
+
ctx.set('access-control-allow-headers', allowedHeader)
|
|
154
|
+
} else {
|
|
155
|
+
const acrh = ctx.headers['access-control-request-headers']
|
|
156
|
+
if (typeof acrh === 'string' && acrh.length > 0) {
|
|
157
|
+
ctx.set('access-control-allow-headers', acrh)
|
|
158
|
+
// The reflected headers vary with the request, so signal it.
|
|
159
|
+
appendVary(ctx, 'Access-Control-Request-Headers')
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (maxAgeHeader !== undefined) {
|
|
164
|
+
ctx.set('access-control-max-age', maxAgeHeader)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Preflight terminates here — no body, no downstream handlers.
|
|
168
|
+
ctx.status(optionsSuccessStatus)
|
|
169
|
+
ctx.set('content-length', '0')
|
|
170
|
+
ctx._body = { kind: 'none' }
|
|
171
|
+
ctx._written = true
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Simple / actual request: expose headers, then continue the chain.
|
|
176
|
+
if (exposedHeader !== undefined) {
|
|
177
|
+
ctx.set('access-control-expose-headers', exposedHeader)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return next()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { IngeniumContext } from '../context/context.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Function form of the `origin` option. Receives the request's `Origin`
|
|
5
|
+
* header value (always a string — never called when no `Origin` is present)
|
|
6
|
+
* and the active `IngeniumContext`. May return:
|
|
7
|
+
*
|
|
8
|
+
* - `true` — allow the request, reflect the request's `Origin` back.
|
|
9
|
+
* - `false` — deny the request (no `Access-Control-Allow-Origin` header set).
|
|
10
|
+
* - `string` — allow the request, use this exact value as the
|
|
11
|
+
* `Access-Control-Allow-Origin` header (use `'*'` for the wildcard).
|
|
12
|
+
*
|
|
13
|
+
* May be sync or async.
|
|
14
|
+
*/
|
|
15
|
+
export type CorsOriginFn = (
|
|
16
|
+
origin: string,
|
|
17
|
+
ctx: IngeniumContext,
|
|
18
|
+
) => boolean | string | Promise<boolean | string>
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Spec for the `origin` option.
|
|
22
|
+
*
|
|
23
|
+
* - `boolean` — `true` reflects any request `Origin`; `false` disables CORS.
|
|
24
|
+
* - `'*'` — wildcard: `Access-Control-Allow-Origin: *`.
|
|
25
|
+
* - any other `string` — exact match against the request's `Origin`.
|
|
26
|
+
* - `string[]` — allowlist; matched exactly.
|
|
27
|
+
* - `RegExp` — tested against the request's `Origin`.
|
|
28
|
+
* - `CorsOriginFn` — fully custom predicate (see above).
|
|
29
|
+
*/
|
|
30
|
+
export type CorsOrigin =
|
|
31
|
+
| boolean
|
|
32
|
+
| string
|
|
33
|
+
| string[]
|
|
34
|
+
| RegExp
|
|
35
|
+
| CorsOriginFn
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options for `ingenium.cors`. All fields are optional. See README for details.
|
|
39
|
+
*/
|
|
40
|
+
export interface CorsOptions {
|
|
41
|
+
/** Origin policy. Default: `'*'`. */
|
|
42
|
+
origin?: CorsOrigin
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Methods advertised on `Access-Control-Allow-Methods` for preflight.
|
|
46
|
+
* Default: `['GET','HEAD','PUT','PATCH','POST','DELETE']`.
|
|
47
|
+
*/
|
|
48
|
+
methods?: string[]
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Headers advertised on `Access-Control-Allow-Headers` for preflight.
|
|
52
|
+
* If `undefined`, the value of `Access-Control-Request-Headers` from the
|
|
53
|
+
* preflight request is mirrored back. Default: `undefined`.
|
|
54
|
+
*/
|
|
55
|
+
allowedHeaders?: string[]
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Headers advertised on `Access-Control-Expose-Headers` for simple
|
|
59
|
+
* responses. Default: `undefined` (header omitted).
|
|
60
|
+
*/
|
|
61
|
+
exposedHeaders?: string[]
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* If `true`, sets `Access-Control-Allow-Credentials: true`.
|
|
65
|
+
* Incompatible with `origin: '*'` — throws at construction time.
|
|
66
|
+
* Default: `false`.
|
|
67
|
+
*/
|
|
68
|
+
credentials?: boolean
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `Access-Control-Max-Age` (seconds). Default: `undefined` (header omitted).
|
|
72
|
+
*/
|
|
73
|
+
maxAge?: number
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Status code for successful preflight responses. Default: `204`.
|
|
77
|
+
*/
|
|
78
|
+
optionsSuccessStatus?: number
|
|
79
|
+
}
|