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
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "ingenium",
3
+ "version": "0.0.1",
4
+ "description": "Express DX, Hono/Fastify throughput. A Node.js HTTP framework.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": ["dist", "src", "README.md", "LICENSE"],
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "typecheck": "tsc --noEmit",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "engines": {
23
+ "node": ">=20.0.0"
24
+ },
25
+ "keywords": ["http", "express", "framework", "router", "fast"],
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/Contra-Collective/ingenium.git",
30
+ "directory": "packages/ingenium"
31
+ },
32
+ "homepage": "https://github.com/Contra-Collective/ingenium#readme",
33
+ "bugs": "https://github.com/Contra-Collective/ingenium/issues",
34
+ "peerDependencies": {
35
+ "zod": "^3.23.0",
36
+ "ws": "^8.18.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "zod": { "optional": true },
40
+ "ws": { "optional": true }
41
+ },
42
+ "devDependencies": {
43
+ "zod": "^3.23.0",
44
+ "ws": "^8.18.0",
45
+ "@types/ws": "^8.5.0"
46
+ }
47
+ }
@@ -0,0 +1,157 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import { Buffer } from 'node:buffer'
3
+ import { IngeniumUnauthorizedError } from '../errors.ts'
4
+ import type { IngeniumMiddleware } from '../middleware/types.ts'
5
+ import type { IngeniumContext } from '../context/context.ts'
6
+ import type { ApiKeyLogger, ApiKeyOptions, ApiKeyValidator } from './types.ts'
7
+
8
+ /**
9
+ * API-key authentication middleware.
10
+ *
11
+ * Attaches the validated key string at `ctx.apiKey`. Callers should
12
+ * module-augment `IngeniumContext` for typed access:
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * declare module 'ingenium' {
17
+ * interface IngeniumContext { apiKey?: string }
18
+ * }
19
+ *
20
+ * import { ingenium } from 'ingenium'
21
+ * const app = ingenium()
22
+ * app.use(ingenium.apiKey({
23
+ * keys: process.env.API_KEYS!.split(','),
24
+ * scheme: 'ApiKey',
25
+ * query: 'api_key',
26
+ * }))
27
+ * ```
28
+ *
29
+ * Security choices:
30
+ * - Allow-list comparisons go through `crypto.timingSafeEqual` after an
31
+ * explicit length check, so neither equality nor length leaks via timing.
32
+ * - The wire-facing error is always `IngeniumUnauthorizedError('Invalid API key')`
33
+ * regardless of which lookup failed (header vs scheme vs query) — no
34
+ * oracle for which transport surface the legit key uses.
35
+ * - Custom validators get the candidate key + ctx; their boolean result is
36
+ * trusted as-is. Validators should be constant-time when comparing keys.
37
+ */
38
+ export function apiKeyMiddleware(opts: ApiKeyOptions): IngeniumMiddleware {
39
+ // ── Construction-time validation ─────────────────────────────────────────
40
+ if (opts == null || opts.keys === undefined || opts.keys === null) {
41
+ throw new Error('apiKeyMiddleware: `keys` is required')
42
+ }
43
+ const validator = resolveValidator(opts.keys)
44
+ const headerName = (opts.header ?? 'x-api-key').toLowerCase()
45
+ const queryParam = opts.query ?? null
46
+ const scheme = opts.scheme ?? null
47
+ const schemeLower = scheme ? scheme.toLowerCase() : null
48
+ const required = opts.required ?? true
49
+ const logger: ApiKeyLogger =
50
+ opts.logger ?? ((event) => process.emitWarning(`api-key auth failed: ${event.reason}`, 'IngeniumApiKeyWarning'))
51
+
52
+ return async (ctx, next) => {
53
+ const key = readKey(ctx, headerName, schemeLower, queryParam)
54
+ if (!key) {
55
+ if (required) {
56
+ // Missing-key surface is not an oracle — there is no secret material
57
+ // to compare against, so we use a distinct message.
58
+ throw new IngeniumUnauthorizedError('Missing API key')
59
+ }
60
+ await next()
61
+ return
62
+ }
63
+
64
+ let ok = false
65
+ try {
66
+ ok = await validator(key, ctx)
67
+ } catch (err) {
68
+ logger({ reason: 'validator_threw' })
69
+ throw new IngeniumUnauthorizedError('Invalid API key')
70
+ }
71
+ if (!ok) {
72
+ logger({ reason: 'no_match' })
73
+ throw new IngeniumUnauthorizedError('Invalid API key')
74
+ }
75
+
76
+ ;(ctx as IngeniumContext & { apiKey?: string }).apiKey = key
77
+ await next()
78
+ }
79
+ }
80
+
81
+ /** Build a validator from either an allow-list or a user-supplied function. */
82
+ function resolveValidator(keys: readonly string[] | ApiKeyValidator): ApiKeyValidator {
83
+ if (typeof keys === 'function') return keys
84
+ if (!Array.isArray(keys)) {
85
+ throw new Error('apiKeyMiddleware: `keys` must be a string[] or a validator function')
86
+ }
87
+ if (keys.length === 0) {
88
+ throw new Error('apiKeyMiddleware: `keys` array must contain at least one key')
89
+ }
90
+ // Pre-encode the allow-list once, at construction, so the per-request path
91
+ // does no allocation work beyond hashing the candidate buffer.
92
+ const allow: Buffer[] = []
93
+ for (const k of keys) {
94
+ if (typeof k !== 'string' || k.length === 0) {
95
+ throw new Error('apiKeyMiddleware: every key in the array must be a non-empty string')
96
+ }
97
+ allow.push(Buffer.from(k, 'utf8'))
98
+ }
99
+ return (candidate) => {
100
+ const cand = Buffer.from(candidate, 'utf8')
101
+ // We deliberately walk the entire list even after a match — a length-
102
+ // dependent early-out would let an attacker probe how many keys exist
103
+ // by measuring response time against length-classes. The list is small
104
+ // and bounded by the user.
105
+ let matched = false
106
+ for (const a of allow) {
107
+ if (a.length !== cand.length) continue
108
+ if (timingSafeEqual(a, cand)) matched = true
109
+ }
110
+ return matched
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Read the candidate key from the request, in priority order:
116
+ * 1. The configured header (default `x-api-key`).
117
+ * 2. The configured `Authorization` scheme, if any.
118
+ * 3. The configured query parameter, if any.
119
+ *
120
+ * Returns `null` when no surface produced a non-empty key.
121
+ */
122
+ function readKey(
123
+ ctx: IngeniumContext,
124
+ headerName: string,
125
+ schemeLower: string | null,
126
+ queryParam: string | null,
127
+ ): string | null {
128
+ const headerVal = ctx.headers[headerName]
129
+ if (headerVal) {
130
+ const v = Array.isArray(headerVal) ? headerVal[0] : headerVal
131
+ if (v && v.length > 0) return v
132
+ }
133
+
134
+ if (schemeLower) {
135
+ const auth = ctx.headers['authorization']
136
+ if (auth) {
137
+ const v = Array.isArray(auth) ? auth[0] : auth
138
+ if (v) {
139
+ const space = v.indexOf(' ')
140
+ if (space > 0) {
141
+ const s = v.slice(0, space).toLowerCase()
142
+ if (s === schemeLower) {
143
+ const tail = v.slice(space + 1).trim()
144
+ if (tail.length > 0) return tail
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ if (queryParam) {
152
+ const q = ctx.query.get(queryParam)
153
+ if (q && q.length > 0) return q
154
+ }
155
+
156
+ return null
157
+ }
@@ -0,0 +1,37 @@
1
+ import type { IngeniumContext } from '../context/context.ts'
2
+
3
+ /** Result of a custom API-key validator. */
4
+ export type ApiKeyValidator = (
5
+ key: string,
6
+ ctx: IngeniumContext,
7
+ ) => boolean | Promise<boolean>
8
+
9
+ /** Optional logger for redacted failure reasons. */
10
+ export type ApiKeyLogger = (event: { reason: string }) => void
11
+
12
+ export interface ApiKeyOptions {
13
+ /**
14
+ * Either an allow-list of valid keys (compared with `timingSafeEqual`) or a
15
+ * custom validator. Functions get the candidate key and the request ctx.
16
+ */
17
+ keys: readonly string[] | ApiKeyValidator
18
+ /** Header to read the key from. Default `'x-api-key'`. */
19
+ header?: string
20
+ /**
21
+ * Optional fallback query-string parameter, e.g. `'api_key'`. When set,
22
+ * the middleware checks `?api_key=...` if no header / scheme key matched.
23
+ */
24
+ query?: string
25
+ /**
26
+ * Optional `Authorization` scheme to accept, e.g. `'ApiKey'`. When set,
27
+ * the middleware accepts `Authorization: ApiKey <key>`.
28
+ */
29
+ scheme?: string
30
+ /**
31
+ * If `true` (default), missing keys raise `IngeniumUnauthorizedError`. If
32
+ * `false`, missing keys just call `next()` with no `ctx.apiKey`.
33
+ */
34
+ required?: boolean
35
+ /** Optional sink for redacted failure reasons. Defaults to `process.emitWarning`. */
36
+ logger?: ApiKeyLogger
37
+ }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * `ScopedApp` — registration facade returned to the callback of
3
+ * `app.scope(prefix, registrar)`. Translates every registration call into a
4
+ * prefix-qualified registration on the underlying `IngeniumApp`, leveraging
5
+ * the existing Router scoping primitives (`use-prefix`, prefix-prepended
6
+ * paths) so the COMPOSE-TIME machinery does all the heavy lifting and the
7
+ * per-request hot path stays untouched.
8
+ *
9
+ * # Design
10
+ *
11
+ * - Holds a reference to the root `IngeniumApp` plus the ABSOLUTE prefix
12
+ * (already includes any outer scope prefix). Nested scopes just construct
13
+ * a new `ScopedApp` with `parent.prefix + sub`.
14
+ * - `scope.use(mw)` becomes `app.use(absolutePrefix, mw)` — the existing
15
+ * `Router.use(prefix, mw)` plumbing produces a `use-prefix` registration,
16
+ * and `flattenRouter` emits a `scopedMiddleware` entry that `app.compose()`
17
+ * intersects against route paths via `pathStartsWith`.
18
+ * - `scope.get(path, handler)` becomes `app.method('GET', absolutePrefix + path, handler)`.
19
+ * - `scope.register(plugin, opts)` invokes the plugin with `this` as the
20
+ * target. Any `target.use(...)` inside the plugin body is therefore
21
+ * scope-prefixed; the plugin can't accidentally leak global middleware.
22
+ * - `scope.scope(sub, fn)` constructs a child `ScopedApp` and runs `fn`
23
+ * against it.
24
+ *
25
+ * # Out of scope for V1 (documented footguns)
26
+ *
27
+ * - **Decorators**: `scope.decorate(...)` / `scope.decorateRequest(...)`
28
+ * forward to the root app and decorate EVERY request, not just requests
29
+ * under the scope's prefix. The reason is structural: decorators install
30
+ * onto pooled `IngeniumContext` instances at request start, BEFORE the
31
+ * route is matched — there's no path information available at that point
32
+ * without re-shaping the dispatch path. Per-scope decorators would require
33
+ * either (a) a runtime path check on every property access, or (b) a
34
+ * separate decorator registry per scope keyed by matched route — both of
35
+ * which move work onto the hot path and complicate the pool. For V1 we
36
+ * accept the footgun and emit a one-shot `process.emitWarning` in
37
+ * non-production environments to surface it.
38
+ * - **Hooks**: `scope.hooks` returns the SAME registry the root app uses.
39
+ * Hook registration is global. A plugin that wants scope-aware hook
40
+ * behavior should inspect `ctx.path` inside the hook body.
41
+ */
42
+
43
+ import type { IngeniumApp } from '../app.ts'
44
+ import type { IngeniumHandler, IngeniumMiddleware } from '../middleware/types.ts'
45
+ import { RouteBuilder, type Router } from '../router/router.ts'
46
+ import type { HttpMethod } from '../router/types.ts'
47
+ import type {
48
+ EagerDecorator,
49
+ Hooks,
50
+ IngeniumPlugin,
51
+ LazyDecorator,
52
+ PluginTarget,
53
+ } from '../plugin/types.ts'
54
+ import { registerAfter, registerBefore } from '../sinatra/filters.ts'
55
+
56
+ /**
57
+ * @internal Friend-access surface a `ScopedApp` needs from its parent
58
+ * `IngeniumApp`. Kept narrow so refactors don't accidentally widen the
59
+ * coupling. The methods are implemented on `IngeniumApp` itself.
60
+ */
61
+ export interface ScopeHost {
62
+ use(mw: IngeniumMiddleware): IngeniumApp
63
+ use(prefix: string, mw: IngeniumMiddleware | Router): IngeniumApp
64
+ method(method: HttpMethod, path: string, handler: IngeniumHandler): IngeniumApp
65
+ method(
66
+ method: HttpMethod,
67
+ path: string,
68
+ ...args: [...IngeniumMiddleware[], IngeniumHandler]
69
+ ): IngeniumApp
70
+ decorate<T>(name: string, factory: LazyDecorator<T>): IngeniumApp
71
+ decorateRequest<T>(name: string, factory: EagerDecorator<T>): IngeniumApp
72
+ readonly hooks: Hooks
73
+ /** @internal Marks the app's compose-cache dirty. */
74
+ _markDirty(): void
75
+ }
76
+
77
+ /**
78
+ * Normalize an absolute-or-relative prefix piece so concatenation is clean.
79
+ * Drops the trailing slash, ensures a leading slash. Empty string and `'/'`
80
+ * collapse to `''` (no prefix).
81
+ */
82
+ function normalizePrefix(p: string): string {
83
+ if (p === '' || p === '/') return ''
84
+ let out = p
85
+ if (out[0] !== '/') out = '/' + out
86
+ if (out.length > 1 && out[out.length - 1] === '/') out = out.slice(0, -1)
87
+ return out
88
+ }
89
+
90
+ /**
91
+ * Normalize a route path so `'users'` and `'/users'` both end up `/users` and
92
+ * `''` resolves to `'/'`. Mirrors `Router.normalizePath`.
93
+ */
94
+ function normalizePath(p: string): string {
95
+ if (!p) return '/'
96
+ if (p[0] !== '/') return '/' + p
97
+ return p
98
+ }
99
+
100
+ /**
101
+ * Join the scope's absolute prefix with a relative path. The result is what
102
+ * goes onto the underlying `Router` (so it's the absolute path inside the
103
+ * trie at compose time).
104
+ */
105
+ function joinScopePath(prefix: string, path: string): string {
106
+ const np = normalizePath(path)
107
+ if (prefix === '') return np
108
+ if (np === '/') return prefix
109
+ return prefix + np
110
+ }
111
+
112
+ /**
113
+ * Process-wide flag — true once we've emitted the "decorators are global"
114
+ * warning. Gated on `NODE_ENV !== 'production'` so production deploys aren't
115
+ * spammed. We accept the once-per-process granularity (rather than once per
116
+ * scope) because the message is the same regardless of which scope tripped it.
117
+ */
118
+ let _decoratorWarningEmitted = false
119
+
120
+ function maybeEmitDecoratorWarning(name: string): void {
121
+ if (_decoratorWarningEmitted) return
122
+ if (typeof process === 'undefined') return
123
+ if (process.env?.NODE_ENV === 'production') return
124
+ _decoratorWarningEmitted = true
125
+ try {
126
+ process.emitWarning(
127
+ `ingenium: scope.decorate('${name}', ...) is GLOBAL — decorators apply to every request regardless of scope prefix. ` +
128
+ `Make the decorator's resolver path-aware (read ctx.path) if you want scoped behavior. ` +
129
+ `This warning fires once per process.`,
130
+ { type: 'IngeniumScopedDecoratorWarning' },
131
+ )
132
+ } catch {
133
+ // process.emitWarning may throw in unusual runtimes (workers); swallow.
134
+ }
135
+ }
136
+
137
+ /** @internal Test-only — reset the one-shot decorator warning latch. */
138
+ export function _resetDecoratorWarningLatch(): void {
139
+ _decoratorWarningEmitted = false
140
+ }
141
+
142
+ /**
143
+ * A `ScopedApp` is the registration target passed to the `app.scope(prefix, registrar)`
144
+ * callback. It exposes the registration surface a plugin needs (`use`, verbs,
145
+ * `register`, `decorate`, `before`/`after`, nested `scope`) but NOT the
146
+ * dispatch surface (`compose`, `handle`, `listen`) — those still belong to
147
+ * the root app.
148
+ *
149
+ * Instances are cheap: a couple of fields and method-call forwarding. Do not
150
+ * cache them across recompose boundaries — they hold a reference to the
151
+ * `IngeniumApp` and rely on its mutable router journal.
152
+ */
153
+ export class ScopedApp implements PluginTarget {
154
+ /** @internal The root app this scope translates registrations onto. */
155
+ private readonly _app: ScopeHost
156
+ /** @internal Absolute prefix (already includes any outer scope's prefix). */
157
+ private readonly _prefix: string
158
+
159
+ /** @internal Construct via `app.scope(...)`; not meant to be `new`'d directly. */
160
+ constructor(app: ScopeHost, prefix: string) {
161
+ this._app = app
162
+ this._prefix = normalizePrefix(prefix)
163
+ }
164
+
165
+ /** Absolute prefix this scope rewrites against (for debugging / introspection). */
166
+ get prefix(): string {
167
+ return this._prefix
168
+ }
169
+
170
+ /** Lifecycle hooks. SHARED with the root app — hooks are global by design. */
171
+ get hooks(): Hooks {
172
+ return this._app.hooks
173
+ }
174
+
175
+ // ───── Middleware ──────────────────────────────────────────────────────
176
+
177
+ use(mw: IngeniumMiddleware): this
178
+ use(subPrefix: string, mw: IngeniumMiddleware | Router): this
179
+ use(arg1: string | IngeniumMiddleware, arg2?: IngeniumMiddleware | Router): this {
180
+ if (typeof arg1 === 'string') {
181
+ // Subprefix is relative to this scope's prefix.
182
+ const joined = this._prefix + normalizePrefix(arg1)
183
+ // If both prefix and subPrefix were empty, fall through to global.
184
+ if (joined === '') {
185
+ this._app.use(arg2 as IngeniumMiddleware)
186
+ } else {
187
+ this._app.use(joined, arg2 as IngeniumMiddleware | Router)
188
+ }
189
+ } else {
190
+ // No prefix arg — scope.use(mw) means "scoped to this scope's prefix".
191
+ if (this._prefix === '') {
192
+ this._app.use(arg1)
193
+ } else {
194
+ this._app.use(this._prefix, arg1)
195
+ }
196
+ }
197
+ this._app._markDirty()
198
+ return this
199
+ }
200
+
201
+ // ───── Verbs ───────────────────────────────────────────────────────────
202
+
203
+ get(path: string, handler: IngeniumHandler): this
204
+ get(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
205
+ get(path: string, ...args: unknown[]): this {
206
+ return this.method('GET', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
207
+ }
208
+
209
+ post(path: string, handler: IngeniumHandler): this
210
+ post(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
211
+ post(path: string, ...args: unknown[]): this {
212
+ return this.method('POST', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
213
+ }
214
+
215
+ put(path: string, handler: IngeniumHandler): this
216
+ put(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
217
+ put(path: string, ...args: unknown[]): this {
218
+ return this.method('PUT', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
219
+ }
220
+
221
+ patch(path: string, handler: IngeniumHandler): this
222
+ patch(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
223
+ patch(path: string, ...args: unknown[]): this {
224
+ return this.method('PATCH', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
225
+ }
226
+
227
+ delete(path: string, handler: IngeniumHandler): this
228
+ delete(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
229
+ delete(path: string, ...args: unknown[]): this {
230
+ return this.method('DELETE', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
231
+ }
232
+
233
+ head(path: string, handler: IngeniumHandler): this
234
+ head(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
235
+ head(path: string, ...args: unknown[]): this {
236
+ return this.method('HEAD', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
237
+ }
238
+
239
+ options(path: string, handler: IngeniumHandler): this
240
+ options(path: string, ...args: [...IngeniumMiddleware[], IngeniumHandler]): this
241
+ options(path: string, ...args: unknown[]): this {
242
+ return this.method('OPTIONS', path, ...(args as [...IngeniumMiddleware[], IngeniumHandler]))
243
+ }
244
+
245
+ method(method: HttpMethod, path: string, handler: IngeniumHandler): this
246
+ method(
247
+ method: HttpMethod,
248
+ path: string,
249
+ ...args: [...IngeniumMiddleware[], IngeniumHandler]
250
+ ): this
251
+ method(method: HttpMethod, path: string, ...args: unknown[]): this {
252
+ const absolute = joinScopePath(this._prefix, path)
253
+ // Delegate to the underlying app — which already validates handler-is-
254
+ // function and middleware-is-function (via Router), and sets dirty.
255
+ ;(this._app.method as (m: HttpMethod, p: string, ...a: unknown[]) => IngeniumApp)(
256
+ method,
257
+ absolute,
258
+ ...args,
259
+ )
260
+ return this
261
+ }
262
+
263
+ /**
264
+ * Chainable per-path builder. The builder closes over `this.method`, which
265
+ * already does the scope-prefix join — so the same builder works identically
266
+ * on the root app and inside a scope. Typed params via `ExtractParams<P>`
267
+ * narrow against the RELATIVE path the user wrote, matching what the bare
268
+ * verb form does.
269
+ */
270
+ route<P extends string>(path: P): RouteBuilder<P> {
271
+ return new RouteBuilder<P>((method, args) =>
272
+ (this.method as (m: HttpMethod, p: string, ...a: unknown[]) => unknown)(method, path, ...args),
273
+ )
274
+ }
275
+
276
+ // ───── Decorators (GLOBAL — see file header) ───────────────────────────
277
+
278
+ /**
279
+ * Register a lazy decorator. **WARNING:** decorators are GLOBAL even when
280
+ * registered inside a scope — they apply to every request regardless of
281
+ * the scope's prefix. The first call from inside any scope in a process
282
+ * emits a `process.emitWarning` (non-production only). See file header.
283
+ */
284
+ decorate<T>(name: string, factory: LazyDecorator<T>): this {
285
+ maybeEmitDecoratorWarning(name)
286
+ this._app.decorate(name, factory)
287
+ // Decorators don't affect routing, but they DO affect the per-request
288
+ // dispatch flags (`_hasDecorators`), which are cached at compose time.
289
+ // Mark dirty so a recompose picks up the new decorator if registration
290
+ // happens after the first request.
291
+ this._app._markDirty()
292
+ return this
293
+ }
294
+
295
+ /**
296
+ * Register an eager decorator. **WARNING:** see {@link ScopedApp.decorate}.
297
+ */
298
+ decorateRequest<T>(name: string, factory: EagerDecorator<T>): this {
299
+ maybeEmitDecoratorWarning(name)
300
+ this._app.decorateRequest(name, factory)
301
+ this._app._markDirty()
302
+ return this
303
+ }
304
+
305
+ // ───── Plugin registration ─────────────────────────────────────────────
306
+
307
+ /**
308
+ * Register a plugin against THIS scope. The plugin receives the `ScopedApp`
309
+ * as its `target`, so any `target.use(...)` inside the plugin body is
310
+ * automatically prefix-scoped.
311
+ */
312
+ register<O>(plugin: IngeniumPlugin<O>, opts: O): Promise<this>
313
+ register(plugin: IngeniumPlugin<void>): Promise<this>
314
+ async register<O>(plugin: IngeniumPlugin<O>, opts?: O): Promise<this> {
315
+ await plugin(this, opts as O)
316
+ this._app._markDirty()
317
+ return this
318
+ }
319
+
320
+ // ───── Nested scope ────────────────────────────────────────────────────
321
+
322
+ /**
323
+ * Open a nested scope. `subPrefix` is relative to this scope's prefix.
324
+ * The registrar may be async; the call returns a Promise that resolves
325
+ * once the registrar finishes if it returned one, otherwise resolves
326
+ * synchronously to `this`. We type-erase to `this` to match the
327
+ * `PluginTarget` interface, which can't express the sync-or-async return
328
+ * without polluting every caller.
329
+ */
330
+ scope(
331
+ subPrefix: string,
332
+ registrar: (scope: PluginTarget) => void,
333
+ ): this {
334
+ const child = new ScopedApp(this._app, this._prefix + normalizePrefix(subPrefix))
335
+ const ret: unknown = registrar(child)
336
+ if (ret && typeof (ret as Promise<void>).then === 'function') {
337
+ // Best-effort: surface async failures via the returned promise. We can't
338
+ // wait synchronously, so we annotate the chain to mark dirty after the
339
+ // registrar settles. Tests should `await app.scope(..., async (s) => {})`
340
+ // by chaining via the parent app or by awaiting the registrar themselves.
341
+ ;(ret as Promise<void>).then(() => this._app._markDirty())
342
+ } else {
343
+ this._app._markDirty()
344
+ }
345
+ return this
346
+ }
347
+
348
+ // ───── Sinatra filters ─────────────────────────────────────────────────
349
+
350
+ /**
351
+ * Register a `before` filter scoped to this scope's prefix. If a pattern
352
+ * is given it's appended to the scope's prefix (so `scope('/api').before('/users', h)`
353
+ * matches `/api/users` and below). If omitted, the filter applies to the
354
+ * scope's full subtree.
355
+ */
356
+ before(handler: IngeniumMiddleware): this
357
+ before(pattern: string, handler: IngeniumMiddleware): this
358
+ before(arg1: string | IngeniumMiddleware, arg2?: IngeniumMiddleware): this {
359
+ // We delegate to the existing Sinatra filter implementation, but with the
360
+ // scope's prefix folded in. `registerBefore` takes `(app, pattern, fn)`
361
+ // and uses `app.use(prefix, wrapped)` under the hood — so passing the
362
+ // root app + a prefixed pattern yields exactly the desired scoping.
363
+ const root = this._app as unknown as IngeniumApp
364
+ if (typeof arg1 === 'function') {
365
+ if (this._prefix === '') registerBefore(root, arg1)
366
+ else registerBefore(root, this._prefix, arg1)
367
+ } else {
368
+ const joined = this._prefix + normalizePrefix(arg1)
369
+ if (joined === '') registerBefore(root, arg2 as IngeniumMiddleware)
370
+ else registerBefore(root, joined, arg2 as IngeniumMiddleware)
371
+ }
372
+ this._app._markDirty()
373
+ return this
374
+ }
375
+
376
+ /** Register an `after` filter scoped to this scope's prefix. See {@link ScopedApp.before}. */
377
+ after(handler: IngeniumMiddleware): this
378
+ after(pattern: string, handler: IngeniumMiddleware): this
379
+ after(arg1: string | IngeniumMiddleware, arg2?: IngeniumMiddleware): this {
380
+ const root = this._app as unknown as IngeniumApp
381
+ if (typeof arg1 === 'function') {
382
+ if (this._prefix === '') registerAfter(root, arg1)
383
+ else registerAfter(root, this._prefix, arg1)
384
+ } else {
385
+ const joined = this._prefix + normalizePrefix(arg1)
386
+ if (joined === '') registerAfter(root, arg2 as IngeniumMiddleware)
387
+ else registerAfter(root, joined, arg2 as IngeniumMiddleware)
388
+ }
389
+ this._app._markDirty()
390
+ return this
391
+ }
392
+ }