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,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
|
+
}
|