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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. 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
+ }