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,189 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+ import type { IngeniumHandler, IngeniumMiddleware } from '../middleware/types.ts'
3
+ import type { RouteBuilder, Router } from '../router/router.ts'
4
+ import type { HttpMethod } from '../router/types.ts'
5
+
6
+ /**
7
+ * Payload fired to `onRoute` hooks each time a route is registered into the
8
+ * trie during composition. Plugins can observe — they MUST NOT mutate.
9
+ */
10
+ export interface RegistrationEvent {
11
+ /** HTTP method (uppercase). */
12
+ readonly method: HttpMethod
13
+ /** Final composed route path (after all prefixes). */
14
+ readonly path: string
15
+ }
16
+
17
+ /**
18
+ * The shape a plugin can rely on regardless of whether it's registered onto
19
+ * the root `IngeniumApp` or onto a `ScopedApp` (via `app.scope(...).register(...)`).
20
+ *
21
+ * Both root and scoped registration targets implement this interface. Plugins
22
+ * that previously took `(app: IngeniumApp, opts)` are source-compatible:
23
+ * `IngeniumApp` implements every member of `PluginTarget`. The only practical
24
+ * difference at the call site is that `target.scope(...)`, `target.use(mw)`,
25
+ * and the verb methods become prefix-relative when `target` is a `ScopedApp`.
26
+ *
27
+ * # Scoping semantics for plugin authors
28
+ *
29
+ * - `target.use(mw)` / `target.use(subprefix, mw)` — middleware is scoped to
30
+ * the target's prefix at compose time. On the root, this is "global". In a
31
+ * scope, it's "applies only to paths under the scope's prefix".
32
+ * - `target.get/post/...` and `target.method(...)` — paths are prefix-
33
+ * relative; the scope prepends its absolute prefix at registration time.
34
+ * - `target.register(plugin, opts)` — runs the plugin against the SAME
35
+ * target. Nested scopes compose as expected.
36
+ * - `target.hooks` — lifecycle hooks are GLOBAL even when called inside a
37
+ * scope. Hooks fire per request, before route dispatch; making them
38
+ * scope-aware would require runtime path-prefix checks on every request.
39
+ * If a plugin needs scope-aware behavior, it should inspect `ctx.path`
40
+ * inside the hook body.
41
+ * - `target.decorate(...)` / `target.decorateRequest(...)` — decorators are
42
+ * GLOBAL even when called inside a scope (see {@link IngeniumPlugin} JSDoc
43
+ * for the rationale). `ScopedApp.decorate` emits a one-shot
44
+ * `process.emitWarning` in non-production environments to surface this
45
+ * footgun.
46
+ */
47
+ export interface PluginTarget {
48
+ /** Lifecycle hooks (global — see interface JSDoc). */
49
+ readonly hooks: Hooks
50
+
51
+ /** Add middleware that runs for every request below this target. */
52
+ use(mw: IngeniumMiddleware): this
53
+ /** Mount middleware or a sub-router at a path prefix (relative to this target). */
54
+ use(prefix: string, mw: IngeniumMiddleware | Router): this
55
+
56
+ /** Register a route under any HTTP method (path is relative to this target). */
57
+ method(method: HttpMethod, path: string, handler: IngeniumHandler): this
58
+ method(
59
+ method: HttpMethod,
60
+ path: string,
61
+ ...args: [...IngeniumMiddleware[], IngeniumHandler]
62
+ ): this
63
+
64
+ /**
65
+ * Chainable per-path builder. Same path-joining rules as the bare verbs —
66
+ * inside a `ScopedApp`, the builder's emitted routes are prefix-relative.
67
+ */
68
+ route<P extends string>(path: P): RouteBuilder<P>
69
+
70
+ /** Convenience verb shortcuts (paths are relative to this target). */
71
+ get(path: string, handler: IngeniumHandler): this
72
+ get(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
73
+ post(path: string, handler: IngeniumHandler): this
74
+ post(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
75
+ put(path: string, handler: IngeniumHandler): this
76
+ put(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
77
+ patch(path: string, handler: IngeniumHandler): this
78
+ patch(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
79
+ delete(path: string, handler: IngeniumHandler): this
80
+ delete(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
81
+ head(path: string, handler: IngeniumHandler): this
82
+ head(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
83
+ options(path: string, handler: IngeniumHandler): this
84
+ options(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
85
+
86
+ /**
87
+ * Pre-handler / post-handler middleware (paths are relative to this target).
88
+ * Inside a `ScopedApp` these are confined to the scope prefix — `s.before(h)`
89
+ * only fires for requests under the scope, mirroring `s.use`.
90
+ */
91
+ before(handler: IngeniumMiddleware): this
92
+ before(pattern: string, handler: IngeniumMiddleware): this
93
+ after(handler: IngeniumMiddleware): this
94
+ after(pattern: string, handler: IngeniumMiddleware): this
95
+
96
+ /** Decorator registration. NOTE: GLOBAL even when called inside a scope. */
97
+ decorate<T>(name: string, factory: LazyDecorator<T>): this
98
+ decorateRequest<T>(name: string, factory: EagerDecorator<T>): this
99
+
100
+ /**
101
+ * Register a plugin against this target. Plugins may be async and the
102
+ * caller should `await` the returned promise.
103
+ */
104
+ register<O>(plugin: IngeniumPlugin<O>, opts: O): Promise<this>
105
+ register(plugin: IngeniumPlugin<void>): Promise<this>
106
+
107
+ /**
108
+ * Open a nested registration scope. All registrations inside `registrar`
109
+ * are prefix-relative to `prefix` (and inherit any outer scope prefix).
110
+ */
111
+ scope(prefix: string, registrar: (scope: PluginTarget) => void): this | Promise<this>
112
+ }
113
+
114
+ /**
115
+ * A plugin is a function that mutates a registration target: it can register
116
+ * routes, middleware, decorators, and hook handlers. Plugins are registered
117
+ * before `compose()` runs; they may be async.
118
+ *
119
+ * The `target` parameter is `PluginTarget` — implemented by both `IngeniumApp`
120
+ * (the root) and `ScopedApp` (created by `app.scope(prefix, ...)`). When a
121
+ * plugin is registered inside a scope, its `target.use(...)` / `target.get(...)`
122
+ * are automatically prefix-scoped at compose time.
123
+ *
124
+ * # Scoped-decorator caveat (V1)
125
+ *
126
+ * Decorators (`target.decorate`, `target.decorateRequest`) install onto the
127
+ * pooled `IngeniumContext` at request start; the registry is per-app, not
128
+ * per-path. That means a plugin registered inside `app.scope('/api', ...)`
129
+ * that calls `target.decorate('user', ...)` will decorate EVERY request,
130
+ * not just `/api/*` requests. The first such call inside a scope emits a
131
+ * `process.emitWarning` in non-production environments. Plugin authors who
132
+ * want per-scope decorator behavior should make the decorator's factory
133
+ * inspect `ctx.path` and return a sentinel for out-of-scope requests.
134
+ *
135
+ * @example
136
+ * const myPlugin: IngeniumPlugin<{ secret: string }> = async (target, opts) => {
137
+ * target.hooks.onRequest((ctx) => { ... })
138
+ * target.use((ctx, next) => next()) // scoped if target is a ScopedApp
139
+ * target.get('/whoami', (ctx) => ...) // path is relative to scope
140
+ * }
141
+ *
142
+ * await app.register(myPlugin, { secret: 'shh' })
143
+ * app.scope('/api', (s) => s.register(myPlugin, { secret: 'shh' }))
144
+ */
145
+ export type IngeniumPlugin<O = void> = (
146
+ target: PluginTarget,
147
+ opts: O,
148
+ ) => void | Promise<void>
149
+
150
+ /** Fires once per route as the trie is built (during `compose()`). */
151
+ export type OnRouteHook = (registration: RegistrationEvent) => void
152
+
153
+ /** Fires before composition runs. May be async. */
154
+ export type OnComposeHook = () => void | Promise<void>
155
+
156
+ /** Fires at the start of every request, before middleware dispatch. */
157
+ export type OnRequestHook = (ctx: IngeniumContext) => void | Promise<void>
158
+
159
+ /** Fires after the handler resolves successfully. */
160
+ export type OnResponseHook = (ctx: IngeniumContext) => void | Promise<void>
161
+
162
+ /**
163
+ * Fires when the handler chain throws. OBSERVATION ONLY — the framework's
164
+ * error boundary still owns the response. Throwing inside an `onError` hook
165
+ * is swallowed; this is by design so observers can't mask the original error.
166
+ */
167
+ export type OnErrorHook = (err: unknown, ctx: IngeniumContext) => void | Promise<void>
168
+
169
+ /**
170
+ * Public hooks API exposed on `app.hooks`. Each method appends a listener;
171
+ * listeners are invoked in registration order, sequentially (`await`-ed in
172
+ * a loop) for predictable ordering.
173
+ */
174
+ export interface Hooks {
175
+ onRoute(fn: OnRouteHook): void
176
+ onCompose(fn: OnComposeHook): void
177
+ onRequest(fn: OnRequestHook): void
178
+ onResponse(fn: OnResponseHook): void
179
+ onError(fn: OnErrorHook): void
180
+ }
181
+
182
+ /** Lazy decorator — computed on first access, then cached on the ctx. */
183
+ export type LazyDecorator<T = unknown> = (ctx: IngeniumContext) => T
184
+
185
+ /** Eager decorator — evaluated at request start, value assigned directly. */
186
+ export type EagerDecorator<T = unknown> = (ctx: IngeniumContext) => T
187
+
188
+ /** Generic decorator factory shape (covers both lazy and eager). */
189
+ export type Decorator<T = unknown> = (ctx: IngeniumContext) => T
@@ -0,0 +1,55 @@
1
+ import type { IngeniumMiddleware } from '../middleware/types.ts'
2
+ import { toProblemDetails } from './serialize.ts'
3
+ import type {
4
+ ProblemDetailsOptions,
5
+ ResolvedProblemDetailsOptions,
6
+ } from './types.ts'
7
+
8
+ const PROBLEM_CONTENT_TYPE = 'application/problem+json; charset=utf-8'
9
+
10
+ /**
11
+ * RFC 7807 Problem Details middleware. Wraps downstream handlers in a
12
+ * try/catch and serializes any `IngeniumError` (or unknown error) as
13
+ * `application/problem+json` instead of the framework's default
14
+ * `{ error, code, fields? }` shape.
15
+ *
16
+ * Composition notes:
17
+ * - This sits as a regular middleware in front of user handlers, NOT in
18
+ * place of `app.onError`. If `app.onError` is configured AND it re-throws
19
+ * (or the user handler throws past the onError), this middleware catches
20
+ * the error before it reaches the default boundary.
21
+ * - Composes cleanly with other middleware (e.g. idempotency) — the
22
+ * try/catch is the only thing it does on the way out.
23
+ *
24
+ * @example
25
+ * app.use(ingenium.problemDetails({
26
+ * typeBaseUrl: 'https://api.example.com/errors/',
27
+ * includeStack: process.env.NODE_ENV !== 'production',
28
+ * }))
29
+ */
30
+ export function problemDetailsMiddleware(opts: ProblemDetailsOptions = {}): IngeniumMiddleware {
31
+ const resolved: ResolvedProblemDetailsOptions = {
32
+ typeBaseUrl: opts.typeBaseUrl ?? 'about:blank',
33
+ includeStack: opts.includeStack ?? false,
34
+ instance: opts.instance ?? ((ctx) => ctx.path),
35
+ }
36
+
37
+ return async (ctx, next) => {
38
+ try {
39
+ await next()
40
+ } catch (err) {
41
+ // If something downstream already wrote a response (e.g. handler
42
+ // partially wrote then threw), don't clobber it.
43
+ if (ctx._written) throw err
44
+
45
+ const problem = toProblemDetails(err, resolved, ctx)
46
+
47
+ // Force the problem+json content-type even if a handler pre-set
48
+ // application/json on the context.
49
+ ctx.set('content-type', PROBLEM_CONTENT_TYPE)
50
+ ctx._statusCode = problem.status
51
+ ctx._body = { kind: 'string', data: JSON.stringify(problem) }
52
+ ctx._written = true
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,121 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+ import {
3
+ IngeniumError,
4
+ IngeniumMethodNotAllowedError,
5
+ IngeniumValidationError,
6
+ } from '../errors.ts'
7
+ import type { ProblemDetails, ResolvedProblemDetailsOptions } from './types.ts'
8
+
9
+ /**
10
+ * Maps known framework error codes to short, human-readable titles. Falls
11
+ * back to the standard HTTP reason phrase, then to the error's own message.
12
+ */
13
+ const TITLES: Readonly<Record<string, string>> = Object.freeze({
14
+ NOT_FOUND: 'Not Found',
15
+ UNAUTHORIZED: 'Unauthorized',
16
+ METHOD_NOT_ALLOWED: 'Method Not Allowed',
17
+ PAYLOAD_TOO_LARGE: 'Payload Too Large',
18
+ VALIDATION_FAILED: 'Validation Failed',
19
+ BAD_REQUEST: 'Bad Request',
20
+ RATE_LIMITED: 'Too Many Requests',
21
+ INTERNAL_ERROR: 'Internal Server Error',
22
+ })
23
+
24
+ /** Generic HTTP status reason phrases for common codes (fallback for unknown errors). */
25
+ const STATUS_REASON: Readonly<Record<number, string>> = Object.freeze({
26
+ 400: 'Bad Request',
27
+ 401: 'Unauthorized',
28
+ 403: 'Forbidden',
29
+ 404: 'Not Found',
30
+ 405: 'Method Not Allowed',
31
+ 409: 'Conflict',
32
+ 413: 'Payload Too Large',
33
+ 415: 'Unsupported Media Type',
34
+ 422: 'Unprocessable Entity',
35
+ 429: 'Too Many Requests',
36
+ 500: 'Internal Server Error',
37
+ 502: 'Bad Gateway',
38
+ 503: 'Service Unavailable',
39
+ 504: 'Gateway Timeout',
40
+ })
41
+
42
+ /** UPPER_SNAKE_CASE → kebab-case path segment for `type` URIs. */
43
+ function codeToSlug(code: string): string {
44
+ return code.toLowerCase().replace(/_/g, '-')
45
+ }
46
+
47
+ /**
48
+ * Build the `type` field. Returns `'about:blank'` (RFC 7807 default) when
49
+ * no `typeBaseUrl` is configured, otherwise prefixes the slugified code.
50
+ */
51
+ function buildType(code: string, baseUrl: string): string {
52
+ if (baseUrl === 'about:blank' || baseUrl === '') return 'about:blank'
53
+ // Avoid double-slash when caller forgot the trailing slash.
54
+ return baseUrl.endsWith('/')
55
+ ? `${baseUrl}${codeToSlug(code)}`
56
+ : `${baseUrl}/${codeToSlug(code)}`
57
+ }
58
+
59
+ /**
60
+ * Convert a thrown value into an RFC 7807 ProblemDetails object. Handles
61
+ * `IngeniumError` and its subclasses with rich extensions; unknown errors are
62
+ * reported as a generic 500 with `type: 'about:blank'`.
63
+ *
64
+ * Side effect: for `IngeniumMethodNotAllowedError`, the `Allow` header is set
65
+ * on the response so it matches the framework's default boundary behavior.
66
+ */
67
+ export function toProblemDetails(
68
+ err: unknown,
69
+ opts: ResolvedProblemDetailsOptions,
70
+ ctx: IngeniumContext,
71
+ ): ProblemDetails {
72
+ if (err instanceof IngeniumError) {
73
+ const title = TITLES[err.code] ?? STATUS_REASON[err.statusCode] ?? err.message
74
+ const problem: ProblemDetails = {
75
+ type: buildType(err.code, opts.typeBaseUrl),
76
+ title,
77
+ status: err.statusCode,
78
+ detail: err.message,
79
+ }
80
+
81
+ const instance = opts.instance(ctx)
82
+ if (instance !== undefined) problem.instance = instance
83
+
84
+ // Carry the framework error code as a non-standard extension so clients
85
+ // can program against it without parsing the `type` URI.
86
+ problem.code = err.code
87
+
88
+ if (err instanceof IngeniumValidationError) {
89
+ problem.fields = err.fields
90
+ }
91
+
92
+ if (err instanceof IngeniumMethodNotAllowedError) {
93
+ problem.allowed = err.allowed
94
+ ctx.set('allow', err.allowed.join(', '))
95
+ }
96
+
97
+ if (opts.includeStack && typeof err.stack === 'string') {
98
+ problem.stack = err.stack
99
+ }
100
+
101
+ return problem
102
+ }
103
+
104
+ // Unknown error — generic 500.
105
+ const message = (err as Error)?.message
106
+ const problem: ProblemDetails = {
107
+ type: 'about:blank',
108
+ title: STATUS_REASON[500]!,
109
+ status: 500,
110
+ detail: typeof message === 'string' && message.length > 0 ? message : 'Internal Server Error',
111
+ }
112
+
113
+ const instance = opts.instance(ctx)
114
+ if (instance !== undefined) problem.instance = instance
115
+
116
+ if (opts.includeStack && err instanceof Error && typeof err.stack === 'string') {
117
+ problem.stack = err.stack
118
+ }
119
+
120
+ return problem
121
+ }
@@ -0,0 +1,68 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+
3
+ /**
4
+ * RFC 7807 Problem Details for HTTP APIs.
5
+ *
6
+ * The five standard members are reserved by the spec; arbitrary additional
7
+ * extension members are permitted via the index signature. See
8
+ * https://datatracker.ietf.org/doc/html/rfc7807#section-3.1.
9
+ */
10
+ export interface ProblemDetails {
11
+ /**
12
+ * A URI reference that identifies the problem type. When dereferenced it
13
+ * SHOULD provide human-readable documentation. Default per spec is
14
+ * `'about:blank'`, indicating that no specific problem-type URL exists
15
+ * (in which case `title` is conventionally the HTTP status reason phrase).
16
+ */
17
+ type: string
18
+
19
+ /**
20
+ * A short, human-readable summary of the problem type. SHOULD NOT change
21
+ * from occurrence to occurrence (use `detail` for instance-specific info).
22
+ */
23
+ title: string
24
+
25
+ /** HTTP status code generated by the origin server. */
26
+ status: number
27
+
28
+ /** Human-readable explanation specific to this occurrence of the problem. */
29
+ detail?: string
30
+
31
+ /** A URI reference identifying the specific occurrence of the problem. */
32
+ instance?: string
33
+
34
+ /** RFC 7807 permits arbitrary extension members. */
35
+ [key: string]: unknown
36
+ }
37
+
38
+ /** Options accepted by `ingenium.problemDetails(...)`. */
39
+ export interface ProblemDetailsOptions {
40
+ /**
41
+ * Prefix used when constructing the `type` URI from an error's `code`.
42
+ * Example: `'https://api.example.com/errors/'` + `NOT_FOUND` →
43
+ * `'https://api.example.com/errors/not-found'`.
44
+ *
45
+ * Default `'about:blank'` (per spec — no problem-specific docs URL).
46
+ */
47
+ typeBaseUrl?: string
48
+
49
+ /**
50
+ * If true, attaches the error's `stack` as an extension member. Useful in
51
+ * development; never enable in production — stack traces leak source paths
52
+ * and internal structure. Default `false`.
53
+ */
54
+ includeStack?: boolean
55
+
56
+ /**
57
+ * Override how the `instance` URI is derived. Default returns `ctx.path`.
58
+ * Return `undefined` to omit the field entirely.
59
+ */
60
+ instance?: (ctx: IngeniumContext) => string | undefined
61
+ }
62
+
63
+ /** Options after defaults have been applied. Internal use. */
64
+ export interface ResolvedProblemDetailsOptions {
65
+ typeBaseUrl: string
66
+ includeStack: boolean
67
+ instance: (ctx: IngeniumContext) => string | undefined
68
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Trust-proxy resolution for `X-Forwarded-*` headers.
3
+ *
4
+ * Mirrors Express's `app.set('trust proxy', ...)` semantics:
5
+ * - `false` (default): never trust XFF — `ctx.ip` always reflects the immediate
6
+ * socket peer.
7
+ * - `true`: trust the entire `X-Forwarded-For` chain — last entry wins.
8
+ * - `number n`: trust `n` upstream hops — return chain entry `n` from the right.
9
+ * - `string` (single CIDR/IP/keyword) or `string[]` (list): trust connections
10
+ * from these addresses; walk the chain skipping trusted IPs.
11
+ * - `(ip, hopIdx) => boolean`: custom predicate, called per chain entry.
12
+ *
13
+ * Supported keywords: `'loopback'` (127.0.0.0/8, ::1), `'linklocal'`
14
+ * (169.254.0.0/16, fe80::/10), `'uniquelocal'` (10/8, 172.16/12, 192.168/16,
15
+ * fc00::/7). CIDRs accepted in IPv4 dotted (`10.0.0.0/8`) and IPv6
16
+ * (`fc00::/7`) form. Single addresses without `/` match exactly.
17
+ */
18
+
19
+ export type TrustProxy =
20
+ | boolean
21
+ | number
22
+ | string
23
+ | string[]
24
+ | ((ip: string, hopIdx: number) => boolean)
25
+
26
+ export interface ForwardedInfo {
27
+ /** The resolved client IP after walking the trusted hop chain. */
28
+ ip: string
29
+ /** Full forwarded chain, left-to-right (closest to client first), plus the immediate peer at the end. */
30
+ ips: readonly string[]
31
+ /** Best-effort protocol: `http` or `https`. */
32
+ protocol: 'http' | 'https'
33
+ /** Best-effort hostname (no port). */
34
+ hostname: string
35
+ }
36
+
37
+ /**
38
+ * Resolve forwarded info from raw headers + the immediate socket peer.
39
+ *
40
+ * @param trust The `trustProxy` configuration.
41
+ * @param remoteAddress The socket-level peer address (always present).
42
+ * @param headers Lowercased request headers (Node convention).
43
+ * @param defaultProtocol The protocol of the underlying transport (`http` for `node:http`,
44
+ * `https` for TLS, `http` for h2c, `https` for h2/TLS).
45
+ */
46
+ export function resolveForwarded(
47
+ trust: TrustProxy,
48
+ remoteAddress: string,
49
+ headers: Readonly<Record<string, string | string[] | undefined>>,
50
+ defaultProtocol: 'http' | 'https' = 'http',
51
+ ): ForwardedInfo {
52
+ if (trust === false || trust === 0 || trust === undefined || trust === null) {
53
+ return {
54
+ ip: remoteAddress,
55
+ ips: [remoteAddress],
56
+ protocol: defaultProtocol,
57
+ hostname: parseHost(headers, false),
58
+ }
59
+ }
60
+
61
+ const xffHeader = headers['x-forwarded-for']
62
+ const xff = parseHeaderList(xffHeader)
63
+ // Append the immediate peer at the end so the chain is complete.
64
+ const fullChain: string[] = [...xff, remoteAddress]
65
+
66
+ let trustedIp = remoteAddress
67
+ if (typeof trust === 'boolean' && trust === true) {
68
+ trustedIp = fullChain[0] ?? remoteAddress
69
+ } else if (typeof trust === 'number') {
70
+ // Skip `trust` hops from the right (the rightmost is the immediate peer).
71
+ const idx = Math.max(0, fullChain.length - 1 - trust)
72
+ trustedIp = fullChain[idx] ?? remoteAddress
73
+ } else if (typeof trust === 'function') {
74
+ trustedIp = walkChainPredicate(fullChain, trust)
75
+ } else {
76
+ const matchers = typeof trust === 'string' ? [trust] : trust
77
+ const compiled = matchers.map(compileTrustEntry)
78
+ const predicate = (ip: string): boolean => compiled.some((m) => m(ip))
79
+ trustedIp = walkChainPredicate(fullChain, (ip) => predicate(ip))
80
+ }
81
+
82
+ const protoHeader = headers['x-forwarded-proto']
83
+ const proto = parseHeaderList(protoHeader)[0]?.toLowerCase()
84
+ const protocol: 'http' | 'https' = proto === 'https' ? 'https' : proto === 'http' ? 'http' : defaultProtocol
85
+
86
+ const hostHeader = headers['x-forwarded-host']
87
+ const xfhFirst = parseHeaderList(hostHeader)[0]
88
+ const hostname = xfhFirst ? stripPort(xfhFirst) : parseHost(headers, false)
89
+
90
+ return { ip: trustedIp, ips: fullChain, protocol, hostname }
91
+ }
92
+
93
+ /**
94
+ * Walk the chain right-to-left while the predicate keeps trusting the
95
+ * current hop. Return the first untrusted address encountered (the real
96
+ * client). If the predicate trusts every hop, return the leftmost entry.
97
+ */
98
+ function walkChainPredicate(
99
+ chain: readonly string[],
100
+ isTrusted: (ip: string, hopIdx: number) => boolean,
101
+ ): string {
102
+ for (let i = chain.length - 1, hop = 0; i >= 0; i--, hop++) {
103
+ const ip = chain[i]!
104
+ if (!isTrusted(ip, hop)) return ip
105
+ }
106
+ return chain[0] ?? ''
107
+ }
108
+
109
+ /** Parse a comma-separated header (or array) into a trimmed list. */
110
+ function parseHeaderList(value: string | string[] | undefined): string[] {
111
+ if (!value) return []
112
+ const flat = Array.isArray(value) ? value.join(',') : value
113
+ return flat
114
+ .split(',')
115
+ .map((s) => s.trim())
116
+ .filter((s) => s.length > 0)
117
+ }
118
+
119
+ function parseHost(
120
+ headers: Readonly<Record<string, string | string[] | undefined>>,
121
+ trustForwarded: boolean,
122
+ ): string {
123
+ if (trustForwarded) {
124
+ const xfh = parseHeaderList(headers['x-forwarded-host'])[0]
125
+ if (xfh) return stripPort(xfh)
126
+ }
127
+ const host = headers['host']
128
+ const flat = Array.isArray(host) ? host[0] : host
129
+ if (!flat) return 'localhost'
130
+ return stripPort(flat)
131
+ }
132
+
133
+ function stripPort(host: string): string {
134
+ // IPv6 literals are bracketed: [::1]:8080
135
+ if (host[0] === '[') {
136
+ const end = host.indexOf(']')
137
+ return end >= 0 ? host.slice(1, end) : host
138
+ }
139
+ const idx = host.lastIndexOf(':')
140
+ return idx > 0 ? host.slice(0, idx) : host
141
+ }
142
+
143
+ // ───────────────────────────────────────────────────────────────────────────
144
+ // CIDR / keyword matchers
145
+ // ───────────────────────────────────────────────────────────────────────────
146
+
147
+ type IpMatcher = (ip: string) => boolean
148
+
149
+ const KEYWORDS: Record<string, string[]> = {
150
+ loopback: ['127.0.0.0/8', '::1/128'],
151
+ linklocal: ['169.254.0.0/16', 'fe80::/10'],
152
+ uniquelocal: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', 'fc00::/7'],
153
+ }
154
+
155
+ function compileTrustEntry(entry: string): IpMatcher {
156
+ const expanded = KEYWORDS[entry] ?? [entry]
157
+ const matchers = expanded.map(compileSingle)
158
+ return (ip) => matchers.some((m) => m(ip))
159
+ }
160
+
161
+ function compileSingle(entry: string): IpMatcher {
162
+ if (entry.includes('/')) return compileCidr(entry)
163
+ return (ip) => ip === entry
164
+ }
165
+
166
+ function compileCidr(cidr: string): IpMatcher {
167
+ const slash = cidr.indexOf('/')
168
+ const network = cidr.slice(0, slash)
169
+ const prefix = Number(cidr.slice(slash + 1))
170
+ if (network.includes(':')) return compileCidrV6(network, prefix)
171
+ return compileCidrV4(network, prefix)
172
+ }
173
+
174
+ function compileCidrV4(network: string, prefix: number): IpMatcher {
175
+ const netBits = ipV4ToInt(network)
176
+ if (netBits === null) return () => false
177
+ const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0
178
+ const target = (netBits & mask) >>> 0
179
+ return (ip) => {
180
+ const bits = ipV4ToInt(stripIpv6Wrap(ip))
181
+ if (bits === null) return false
182
+ return ((bits & mask) >>> 0) === target
183
+ }
184
+ }
185
+
186
+ function compileCidrV6(network: string, prefix: number): IpMatcher {
187
+ const netBytes = ipV6ToBytes(network)
188
+ if (!netBytes) return () => false
189
+ return (ip) => {
190
+ const bytes = ipV6ToBytes(ip)
191
+ if (!bytes) return false
192
+ return cmpPrefix(netBytes, bytes, prefix)
193
+ }
194
+ }
195
+
196
+ function cmpPrefix(a: Uint8Array, b: Uint8Array, prefix: number): boolean {
197
+ const fullBytes = prefix >>> 3
198
+ for (let i = 0; i < fullBytes; i++) if (a[i] !== b[i]) return false
199
+ const rem = prefix & 7
200
+ if (rem === 0) return true
201
+ const shift = 8 - rem
202
+ return (a[fullBytes]! >> shift) === (b[fullBytes]! >> shift)
203
+ }
204
+
205
+ function ipV4ToInt(ip: string): number | null {
206
+ const parts = ip.split('.')
207
+ if (parts.length !== 4) return null
208
+ let n = 0
209
+ for (const p of parts) {
210
+ const v = Number(p)
211
+ if (!Number.isInteger(v) || v < 0 || v > 255) return null
212
+ n = (n << 8) | v
213
+ }
214
+ return n >>> 0
215
+ }
216
+
217
+ /** ::ffff:1.2.3.4 → 1.2.3.4 (so v4 matchers work on v4-mapped addresses). */
218
+ function stripIpv6Wrap(ip: string): string {
219
+ if (ip.startsWith('::ffff:')) return ip.slice(7)
220
+ return ip
221
+ }
222
+
223
+ function ipV6ToBytes(ip: string): Uint8Array | null {
224
+ // Very small implementation: handles standard `a:b:...:h` and `::` shorthand.
225
+ const cleaned = stripIpv6Wrap(ip)
226
+ if (cleaned.includes('.')) return null // v4-mapped already stripped above; bare v4 not v6
227
+ const out = new Uint8Array(16)
228
+ let parts: string[]
229
+ if (ip.includes('::')) {
230
+ const [head, tail] = ip.split('::')
231
+ const headParts = head ? head.split(':') : []
232
+ const tailParts = tail ? tail.split(':') : []
233
+ const fillCount = 8 - (headParts.length + tailParts.length)
234
+ if (fillCount < 0) return null
235
+ parts = [...headParts, ...new Array<string>(fillCount).fill('0'), ...tailParts]
236
+ } else {
237
+ parts = ip.split(':')
238
+ }
239
+ if (parts.length !== 8) return null
240
+ for (let i = 0; i < 8; i++) {
241
+ const v = parseInt(parts[i]!, 16)
242
+ if (!Number.isInteger(v) || v < 0 || v > 0xffff) return null
243
+ out[i * 2] = (v >> 8) & 0xff
244
+ out[i * 2 + 1] = v & 0xff
245
+ }
246
+ return out
247
+ }