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,224 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { Buffer } from 'node:buffer'
|
|
3
|
+
import { IngeniumError } from '../errors.ts'
|
|
4
|
+
import type { IngeniumMiddleware } from '../middleware/types.ts'
|
|
5
|
+
import type { IngeniumContext } from '../context/context.ts'
|
|
6
|
+
import type { CsrfCookieOptions, CsrfOptions, CsrfValueReader } from './types.ts'
|
|
7
|
+
|
|
8
|
+
/** 403 Forbidden — CSRF token missing or mismatched. */
|
|
9
|
+
export class IngeniumCsrfError extends IngeniumError {
|
|
10
|
+
constructor(message = 'CSRF token validation failed') {
|
|
11
|
+
super(403, 'CSRF_FAILED', message)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TOKEN_BYTES = 18
|
|
16
|
+
const SAFE_METHODS_DEFAULT: readonly string[] = ['GET', 'HEAD', 'OPTIONS', 'TRACE']
|
|
17
|
+
const COOKIE_NAME_DEFAULT = 'ingenium.csrf'
|
|
18
|
+
const HEADER_NAMES_DEFAULT: readonly string[] = ['x-csrf-token', 'x-xsrf-token']
|
|
19
|
+
|
|
20
|
+
interface ResolvedOptions {
|
|
21
|
+
secrets: string[] // first signs, all verify (rotation)
|
|
22
|
+
storage: 'cookie' | 'session'
|
|
23
|
+
cookie: Required<CsrfCookieOptions>
|
|
24
|
+
ignoreMethods: Set<string>
|
|
25
|
+
value: CsrfValueReader
|
|
26
|
+
skip: ((ctx: IngeniumContext) => boolean | Promise<boolean>) | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CSRF protection middleware. Two modes:
|
|
31
|
+
*
|
|
32
|
+
* - `storage: 'cookie'` (default) — double-submit cookie pattern. A
|
|
33
|
+
* randomly-generated token is HMAC-signed, written to a non-HttpOnly
|
|
34
|
+
* cookie on safe requests, and the client must echo the cookie value
|
|
35
|
+
* back in a header (`X-CSRF-Token`) on unsafe requests. The signature
|
|
36
|
+
* prevents client-side forgery; the same-origin policy prevents
|
|
37
|
+
* cross-origin sites from reading the cookie.
|
|
38
|
+
*
|
|
39
|
+
* - `storage: 'session'` — synchronizer pattern. The token is stored on
|
|
40
|
+
* `ctx.session` and matched against the submitted token. Requires
|
|
41
|
+
* `sessionMiddleware` to run before this middleware.
|
|
42
|
+
*
|
|
43
|
+
* Use `ctx.state.csrfToken` (or call `(ctx as IngeniumContext & { csrfToken(): string }).csrfToken()`)
|
|
44
|
+
* to read the current token to embed in HTML forms or send to a JS client.
|
|
45
|
+
*/
|
|
46
|
+
export function csrfMiddleware(opts: CsrfOptions = {}): IngeniumMiddleware {
|
|
47
|
+
const resolved = resolveOptions(opts)
|
|
48
|
+
if (resolved.storage === 'cookie' && resolved.secrets.length === 0) {
|
|
49
|
+
throw new Error("csrfMiddleware: `secret` is required when storage is 'cookie'")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return async (ctx, next) => {
|
|
53
|
+
if (resolved.skip && (await resolved.skip(ctx))) {
|
|
54
|
+
await next()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve / mint the expected token for this request.
|
|
59
|
+
let expected = readExpectedToken(ctx, resolved)
|
|
60
|
+
let mintedThisRequest = false
|
|
61
|
+
if (!expected) {
|
|
62
|
+
expected = mintToken(resolved)
|
|
63
|
+
mintedThisRequest = true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Expose token to handlers via ctx.state.csrfToken AND a method.
|
|
67
|
+
ctx.state.csrfToken = expected
|
|
68
|
+
;(ctx as IngeniumContext & { csrfToken: () => string }).csrfToken = () => expected as string
|
|
69
|
+
|
|
70
|
+
const isUnsafe = !resolved.ignoreMethods.has(ctx.method)
|
|
71
|
+
if (isUnsafe) {
|
|
72
|
+
const submitted = await resolved.value(ctx)
|
|
73
|
+
if (!submitted || !tokenMatches(submitted, expected)) {
|
|
74
|
+
throw new IngeniumCsrfError()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await next()
|
|
79
|
+
|
|
80
|
+
// Issue (or refresh) the cookie on cookie-storage mode.
|
|
81
|
+
if (resolved.storage === 'cookie' && (mintedThisRequest || isUnsafe)) {
|
|
82
|
+
writeCookie(ctx, expected, resolved.cookie)
|
|
83
|
+
} else if (resolved.storage === 'session' && mintedThisRequest) {
|
|
84
|
+
writeSession(ctx, expected)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ───── Token mint / verify ─────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function mintToken(opts: ResolvedOptions): string {
|
|
92
|
+
const raw = randomBytes(TOKEN_BYTES).toString('base64url')
|
|
93
|
+
if (opts.storage === 'session' || opts.secrets.length === 0) return raw
|
|
94
|
+
// Signed for double-submit so a forged cookie value can't pass verification.
|
|
95
|
+
const sig = signToken(raw, opts.secrets[0]!)
|
|
96
|
+
return `${raw}.${sig}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function signToken(raw: string, secret: string): string {
|
|
100
|
+
return createHmac('sha256', secret).update(raw).digest('base64url')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function tokenMatches(submitted: string, expected: string): boolean {
|
|
104
|
+
const a = Buffer.from(submitted)
|
|
105
|
+
const b = Buffer.from(expected)
|
|
106
|
+
// Length-mismatch already rules out a match; timingSafeEqual requires equal length.
|
|
107
|
+
if (a.length !== b.length) return false
|
|
108
|
+
return timingSafeEqual(a, b)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function verifySignedToken(token: string, secrets: readonly string[]): boolean {
|
|
112
|
+
const dot = token.lastIndexOf('.')
|
|
113
|
+
if (dot <= 0) return false
|
|
114
|
+
const raw = token.slice(0, dot)
|
|
115
|
+
const sig = token.slice(dot + 1)
|
|
116
|
+
for (const secret of secrets) {
|
|
117
|
+
const expected = signToken(raw, secret)
|
|
118
|
+
if (expected.length !== sig.length) continue
|
|
119
|
+
if (timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return true
|
|
120
|
+
}
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ───── Storage ─────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function readExpectedToken(ctx: IngeniumContext, opts: ResolvedOptions): string | null {
|
|
127
|
+
if (opts.storage === 'cookie') {
|
|
128
|
+
const cookies = parseCookies(ctx.headers['cookie'])
|
|
129
|
+
const token = cookies[opts.cookie.name]
|
|
130
|
+
if (!token) return null
|
|
131
|
+
if (opts.secrets.length > 0 && !verifySignedToken(token, opts.secrets)) return null
|
|
132
|
+
return token
|
|
133
|
+
}
|
|
134
|
+
// Session storage
|
|
135
|
+
const session = (ctx as IngeniumContext & { session?: { get: (k: string) => unknown } }).session
|
|
136
|
+
if (!session) {
|
|
137
|
+
throw new Error("csrfMiddleware: storage='session' requires sessionMiddleware to run first")
|
|
138
|
+
}
|
|
139
|
+
const token = session.get('csrfToken')
|
|
140
|
+
return typeof token === 'string' && token.length > 0 ? token : null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// TODO: migrate to ctx.cookies — kept inline because csrf uses the
|
|
144
|
+
// double-submit pattern with a non-HttpOnly cookie + HMAC value, which the
|
|
145
|
+
// generic cookie API doesn't model directly.
|
|
146
|
+
function writeCookie(ctx: IngeniumContext, token: string, cookie: Required<CsrfCookieOptions>): void {
|
|
147
|
+
const parts: string[] = [`${cookie.name}=${encodeURIComponent(token)}`]
|
|
148
|
+
parts.push(`Path=${cookie.path}`)
|
|
149
|
+
if (cookie.domain) parts.push(`Domain=${cookie.domain}`)
|
|
150
|
+
parts.push(`Max-Age=${cookie.maxAgeSeconds}`)
|
|
151
|
+
parts.push(`SameSite=${cookie.sameSite[0]!.toUpperCase() + cookie.sameSite.slice(1)}`)
|
|
152
|
+
if (cookie.secure) parts.push('Secure')
|
|
153
|
+
if (cookie.httpOnly) parts.push('HttpOnly')
|
|
154
|
+
appendSetCookie(ctx, parts.join('; '))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function writeSession(ctx: IngeniumContext, token: string): void {
|
|
158
|
+
const session = (ctx as IngeniumContext & { session?: { set: (k: string, v: unknown) => void } }).session
|
|
159
|
+
if (!session) return
|
|
160
|
+
session.set('csrfToken', token)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function appendSetCookie(ctx: IngeniumContext, value: string): void {
|
|
164
|
+
const existing = ctx._headers['set-cookie']
|
|
165
|
+
if (!existing) {
|
|
166
|
+
ctx._headers['set-cookie'] = [value]
|
|
167
|
+
} else if (Array.isArray(existing)) {
|
|
168
|
+
existing.push(value)
|
|
169
|
+
} else {
|
|
170
|
+
ctx._headers['set-cookie'] = [existing, value]
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseCookies(header: string | string[] | undefined): Record<string, string> {
|
|
175
|
+
const out: Record<string, string> = {}
|
|
176
|
+
if (!header) return out
|
|
177
|
+
const flat = Array.isArray(header) ? header.join('; ') : header
|
|
178
|
+
for (const piece of flat.split(';')) {
|
|
179
|
+
const eq = piece.indexOf('=')
|
|
180
|
+
if (eq < 0) continue
|
|
181
|
+
const k = piece.slice(0, eq).trim()
|
|
182
|
+
const v = piece.slice(eq + 1).trim()
|
|
183
|
+
if (!k || k in out) continue // first occurrence wins
|
|
184
|
+
try {
|
|
185
|
+
out[k] = decodeURIComponent(v)
|
|
186
|
+
} catch {
|
|
187
|
+
out[k] = v
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ───── Options resolution ──────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
function resolveOptions(opts: CsrfOptions): ResolvedOptions {
|
|
196
|
+
const secrets =
|
|
197
|
+
typeof opts.secret === 'string'
|
|
198
|
+
? [opts.secret]
|
|
199
|
+
: Array.isArray(opts.secret)
|
|
200
|
+
? [...opts.secret]
|
|
201
|
+
: []
|
|
202
|
+
const storage = opts.storage ?? 'cookie'
|
|
203
|
+
const cookie: Required<CsrfCookieOptions> = {
|
|
204
|
+
name: opts.cookie?.name ?? COOKIE_NAME_DEFAULT,
|
|
205
|
+
path: opts.cookie?.path ?? '/',
|
|
206
|
+
domain: opts.cookie?.domain ?? '',
|
|
207
|
+
sameSite: opts.cookie?.sameSite ?? 'lax',
|
|
208
|
+
secure: opts.cookie?.secure ?? false,
|
|
209
|
+
httpOnly: opts.cookie?.httpOnly ?? false,
|
|
210
|
+
maxAgeSeconds: opts.cookie?.maxAgeSeconds ?? 7 * 24 * 60 * 60,
|
|
211
|
+
}
|
|
212
|
+
const ignoreMethods = new Set((opts.ignoreMethods ?? SAFE_METHODS_DEFAULT).map((m) => m.toUpperCase()))
|
|
213
|
+
const value = opts.value ?? defaultValueReader
|
|
214
|
+
return { secrets, storage, cookie, ignoreMethods, value, skip: opts.skip ?? null }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const defaultValueReader: CsrfValueReader = (ctx) => {
|
|
218
|
+
for (const name of HEADER_NAMES_DEFAULT) {
|
|
219
|
+
const v = ctx.headers[name]
|
|
220
|
+
if (v) return Array.isArray(v) ? v[0] : v
|
|
221
|
+
}
|
|
222
|
+
const q = ctx.query.get('_csrf')
|
|
223
|
+
return q ?? undefined
|
|
224
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { IngeniumContext } from '../context/context.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the CSRF token lives between requests.
|
|
5
|
+
*
|
|
6
|
+
* - `'cookie'` (default): double-submit cookie pattern. Token is generated
|
|
7
|
+
* on safe requests, written to a non-HttpOnly cookie, and the client must
|
|
8
|
+
* echo it back via a header on unsafe requests. No session required.
|
|
9
|
+
* - `'session'`: synchronizer pattern. Token is stored on `ctx.session`
|
|
10
|
+
* and validated against the submitted token on unsafe requests. Requires
|
|
11
|
+
* `sessionMiddleware` to run before this middleware.
|
|
12
|
+
*/
|
|
13
|
+
export type CsrfStorage = 'cookie' | 'session'
|
|
14
|
+
|
|
15
|
+
/** How to extract the submitted token from an incoming request. */
|
|
16
|
+
export type CsrfValueReader = (ctx: IngeniumContext) => string | undefined | Promise<string | undefined>
|
|
17
|
+
|
|
18
|
+
export interface CsrfCookieOptions {
|
|
19
|
+
/** Cookie name. Default `ingenium.csrf`. */
|
|
20
|
+
name?: string
|
|
21
|
+
/** Restrict cookie to a single subpath. Default `/`. */
|
|
22
|
+
path?: string
|
|
23
|
+
/** Restrict cookie to a domain. Default unset. */
|
|
24
|
+
domain?: string
|
|
25
|
+
/** SameSite policy. Default `'lax'`. */
|
|
26
|
+
sameSite?: 'lax' | 'strict' | 'none'
|
|
27
|
+
/** Mark cookie Secure. Default `false`; set `true` behind TLS. */
|
|
28
|
+
secure?: boolean
|
|
29
|
+
/**
|
|
30
|
+
* Mark cookie HttpOnly. **Default `false`** — clients must read the cookie
|
|
31
|
+
* to copy the value into the request header. Setting `true` would break the
|
|
32
|
+
* double-submit pattern; only enable with a custom value reader that pulls
|
|
33
|
+
* the token from elsewhere.
|
|
34
|
+
*/
|
|
35
|
+
httpOnly?: boolean
|
|
36
|
+
/** Cookie max-age (seconds). Default 7 days. */
|
|
37
|
+
maxAgeSeconds?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CsrfOptions {
|
|
41
|
+
/**
|
|
42
|
+
* HMAC secret used to sign the token. Required for the cookie storage
|
|
43
|
+
* mode (signed double-submit). For session storage the secret is optional
|
|
44
|
+
* — the session id already authenticates the binding.
|
|
45
|
+
*/
|
|
46
|
+
secret?: string | string[]
|
|
47
|
+
/** Token storage strategy. Default `'cookie'`. */
|
|
48
|
+
storage?: CsrfStorage
|
|
49
|
+
/** Cookie options when `storage === 'cookie'`. */
|
|
50
|
+
cookie?: CsrfCookieOptions
|
|
51
|
+
/** Methods that bypass validation. Default `['GET', 'HEAD', 'OPTIONS', 'TRACE']`. */
|
|
52
|
+
ignoreMethods?: readonly string[]
|
|
53
|
+
/**
|
|
54
|
+
* How to extract the submitted token. Default reads (in order):
|
|
55
|
+
* 1. `X-CSRF-Token` header
|
|
56
|
+
* 2. `X-XSRF-Token` header (Angular convention)
|
|
57
|
+
* 3. `_csrf` query string parameter
|
|
58
|
+
*/
|
|
59
|
+
value?: CsrfValueReader
|
|
60
|
+
/**
|
|
61
|
+
* Per-request opt-out. Return `true` to skip validation entirely for
|
|
62
|
+
* this request (and skip token issuance).
|
|
63
|
+
*/
|
|
64
|
+
skip?: (ctx: IngeniumContext) => boolean | Promise<boolean>
|
|
65
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base error class for all framework-emitted errors. Errors that extend
|
|
3
|
+
* `IngeniumError` are caught by the global error boundary and serialized to the
|
|
4
|
+
* client according to their `statusCode` and `code`.
|
|
5
|
+
*/
|
|
6
|
+
export class IngeniumError extends Error {
|
|
7
|
+
/**
|
|
8
|
+
* @param statusCode HTTP status code to send to the client.
|
|
9
|
+
* @param code Machine-readable error code (UPPER_SNAKE_CASE convention).
|
|
10
|
+
* @param message Human-readable error message.
|
|
11
|
+
* @param cause Optional underlying error.
|
|
12
|
+
*/
|
|
13
|
+
constructor(
|
|
14
|
+
public readonly statusCode: number,
|
|
15
|
+
public readonly code: string,
|
|
16
|
+
message: string,
|
|
17
|
+
public override readonly cause?: unknown,
|
|
18
|
+
) {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = new.target.name
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 404 — no route matched. */
|
|
25
|
+
export class IngeniumNotFoundError extends IngeniumError {
|
|
26
|
+
constructor(message = 'Not Found') {
|
|
27
|
+
super(404, 'NOT_FOUND', message)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 401 — authentication required or invalid. */
|
|
32
|
+
export class IngeniumUnauthorizedError extends IngeniumError {
|
|
33
|
+
constructor(message = 'Unauthorized') {
|
|
34
|
+
super(401, 'UNAUTHORIZED', message)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 405 — path matched but method did not. Includes the list of allowed methods,
|
|
40
|
+
* which the framework writes into the `Allow` response header automatically.
|
|
41
|
+
*/
|
|
42
|
+
export class IngeniumMethodNotAllowedError extends IngeniumError {
|
|
43
|
+
constructor(public readonly allowed: readonly string[], message = 'Method Not Allowed') {
|
|
44
|
+
super(405, 'METHOD_NOT_ALLOWED', message)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 413 — request body exceeded the configured `maxBytes` limit. */
|
|
49
|
+
export class IngeniumPayloadTooLargeError extends IngeniumError {
|
|
50
|
+
constructor(message = 'Payload Too Large') {
|
|
51
|
+
super(413, 'PAYLOAD_TOO_LARGE', message)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 422 — request body parsed successfully but failed validation. The `fields`
|
|
57
|
+
* map is serialized into the response body so clients can render field-level
|
|
58
|
+
* error messages.
|
|
59
|
+
*/
|
|
60
|
+
export class IngeniumValidationError extends IngeniumError {
|
|
61
|
+
constructor(public readonly fields: Record<string, string>, message = 'Validation Failed') {
|
|
62
|
+
super(422, 'VALIDATION_FAILED', message)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 400 — request was malformed (bad JSON, invalid content-type, etc). */
|
|
67
|
+
export class IngeniumBadRequestError extends IngeniumError {
|
|
68
|
+
constructor(message = 'Bad Request', cause?: unknown) {
|
|
69
|
+
super(400, 'BAD_REQUEST', message, cause)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 500 — caller attempted to write a header name or value containing CR or
|
|
75
|
+
* LF. Node would eventually reject these at the wire level, but the late
|
|
76
|
+
* throw produces a useless stack — we fail fast at the call site so the
|
|
77
|
+
* offending header (and the route that set it) shows up in the trace.
|
|
78
|
+
*/
|
|
79
|
+
export class IngeniumHeaderInjectionError extends IngeniumError {
|
|
80
|
+
constructor(message = 'Header value contains CR/LF (possible header injection)') {
|
|
81
|
+
super(500, 'HEADER_INJECTION', message)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 500 — `ctx.json` (or `respondJsonWithEtag`) was handed a value that
|
|
87
|
+
* `JSON.stringify` cannot serialize: a circular structure, a `BigInt`, or
|
|
88
|
+
* any other unsupported shape. The original `TypeError` is attached as
|
|
89
|
+
* `cause` and emitted via `process.emitWarning` for diagnostics.
|
|
90
|
+
*/
|
|
91
|
+
export class IngeniumUnserializableError extends IngeniumError {
|
|
92
|
+
constructor(message: string, cause?: unknown) {
|
|
93
|
+
super(500, 'UNSERIALIZABLE_RESPONSE', message, cause)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Sinatra-style `halt` short-circuit. Thrown by `ctx.halt(status, body?)`;
|
|
99
|
+
* caught by the default error boundary and serialized according to `bodyShape`:
|
|
100
|
+
*
|
|
101
|
+
* - `'none'` → boundary uses default `{ error, code: 'HALT' }` JSON shape.
|
|
102
|
+
* - `'text'` → boundary writes `body` as `text/plain` verbatim.
|
|
103
|
+
* - `'json'` → boundary writes `body` as `application/json`.
|
|
104
|
+
*
|
|
105
|
+
* The body shape is decided at the call site (string ⇒ text, object ⇒ json,
|
|
106
|
+
* undefined ⇒ none) so the boundary can branch without re-inspecting types.
|
|
107
|
+
* Custom `app.onError` handlers still receive the error and can override it
|
|
108
|
+
* (e.g. add a header, reshape the body) by writing the response themselves.
|
|
109
|
+
*/
|
|
110
|
+
export class IngeniumHaltError extends IngeniumError {
|
|
111
|
+
/** What the default error boundary should do with `body`. */
|
|
112
|
+
readonly bodyShape: 'none' | 'text' | 'json'
|
|
113
|
+
/** The body argument from `ctx.halt(status, body?)`. */
|
|
114
|
+
readonly body: string | Record<string, unknown> | undefined
|
|
115
|
+
|
|
116
|
+
constructor(statusCode: number, body?: string | Record<string, unknown>) {
|
|
117
|
+
let shape: 'none' | 'text' | 'json'
|
|
118
|
+
let message: string
|
|
119
|
+
if (body === undefined) {
|
|
120
|
+
shape = 'none'
|
|
121
|
+
message = `Halted with status ${statusCode}`
|
|
122
|
+
} else if (typeof body === 'string') {
|
|
123
|
+
shape = 'text'
|
|
124
|
+
message = body
|
|
125
|
+
} else {
|
|
126
|
+
shape = 'json'
|
|
127
|
+
// Best-effort message for ctx.error / logging; the JSON body is the
|
|
128
|
+
// wire-level payload regardless.
|
|
129
|
+
message = typeof body['error'] === 'string' ? (body['error'] as string) : 'HALT'
|
|
130
|
+
}
|
|
131
|
+
super(statusCode, 'HALT', message)
|
|
132
|
+
this.bodyShape = shape
|
|
133
|
+
this.body = body
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 503 — handler exceeded the configured `requestTimeoutMs` ceiling. The
|
|
139
|
+
* orphaned handler is NOT cancelled (JavaScript can't safely cancel a
|
|
140
|
+
* Promise); the framework just stops waiting for it. Late writes from the
|
|
141
|
+
* orphaned handler are guarded by the per-request epoch counter on the
|
|
142
|
+
* context and discarded with a `process.emitWarning`.
|
|
143
|
+
*/
|
|
144
|
+
export class IngeniumTimeoutError extends IngeniumError {
|
|
145
|
+
constructor(timeoutMs: number, message?: string) {
|
|
146
|
+
super(503, 'REQUEST_TIMEOUT', message ?? `Request exceeded ${timeoutMs}ms`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import type { IngeniumContext, ResponseBody } from '../context/context.ts'
|
|
3
|
+
import type { IngeniumMiddleware } from '../middleware/types.ts'
|
|
4
|
+
import type { HttpMethod } from '../router/types.ts'
|
|
5
|
+
import { IdempotencyMemoryStore } from './store.ts'
|
|
6
|
+
import type {
|
|
7
|
+
CachedResponse,
|
|
8
|
+
IdempotencyOptions,
|
|
9
|
+
ResolvedIdempotencyOptions,
|
|
10
|
+
} from './types.ts'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_METHODS: readonly HttpMethod[] = ['POST', 'PATCH', 'DELETE']
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default cacheable predicate: cache 2xx/3xx/4xx, NOT 5xx. Stripe
|
|
16
|
+
* convention — a transient 500 must not be replayed forever.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_CACHEABLE = (status: number): boolean => status >= 200 && status < 500
|
|
19
|
+
|
|
20
|
+
/** Authorization-header-derived scope; falls back to `'anon'`. */
|
|
21
|
+
function defaultScope(ctx: IngeniumContext): string {
|
|
22
|
+
const auth = ctx.headers['authorization']
|
|
23
|
+
if (typeof auth === 'string' && auth.length > 0) return auth
|
|
24
|
+
if (Array.isArray(auth) && auth.length > 0 && typeof auth[0] === 'string') return auth[0]
|
|
25
|
+
return 'anon'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Pull a header value as a single string (first element if it came as an array). */
|
|
29
|
+
function readHeader(ctx: IngeniumContext, lowerName: string): string | undefined {
|
|
30
|
+
const v = ctx.headers[lowerName]
|
|
31
|
+
if (typeof v === 'string') return v
|
|
32
|
+
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : undefined
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Snapshot whatever the handler wrote to `ctx`. Streams are NOT cached —
|
|
38
|
+
* we cannot rewind a `Readable`, so a streamed response makes the request
|
|
39
|
+
* non-idempotent (the second call will run the handler again).
|
|
40
|
+
*
|
|
41
|
+
* Returns `null` to signal "do not cache" (stream / nothing written).
|
|
42
|
+
*/
|
|
43
|
+
function snapshot(ctx: IngeniumContext): CachedResponse | null {
|
|
44
|
+
if (!ctx._written) return null
|
|
45
|
+
const body = ctx._body
|
|
46
|
+
let serialized: string | Buffer | null
|
|
47
|
+
switch (body.kind) {
|
|
48
|
+
case 'none':
|
|
49
|
+
serialized = null
|
|
50
|
+
break
|
|
51
|
+
case 'string':
|
|
52
|
+
serialized = body.data
|
|
53
|
+
break
|
|
54
|
+
case 'buffer':
|
|
55
|
+
// Copy the buffer — caller may reuse the underlying memory.
|
|
56
|
+
serialized = Buffer.from(body.data)
|
|
57
|
+
break
|
|
58
|
+
case 'stream':
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
// Shallow-copy headers; values are strings or string[] (immutable in practice).
|
|
62
|
+
const headersCopy: Record<string, string | string[]> = Object.create(null)
|
|
63
|
+
for (const k of Object.keys(ctx._headers)) {
|
|
64
|
+
const v = ctx._headers[k]
|
|
65
|
+
if (v === undefined) continue
|
|
66
|
+
headersCopy[k] = Array.isArray(v) ? [...v] : v
|
|
67
|
+
}
|
|
68
|
+
return { statusCode: ctx._statusCode, headers: headersCopy, body: serialized }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Replay a cached response onto a fresh `ctx`. */
|
|
72
|
+
function replay(ctx: IngeniumContext, cached: CachedResponse): void {
|
|
73
|
+
ctx._statusCode = cached.statusCode
|
|
74
|
+
// Replace, not merge — replayed response is authoritative.
|
|
75
|
+
ctx._headers = Object.create(null) as Record<string, string | string[]>
|
|
76
|
+
for (const k of Object.keys(cached.headers)) {
|
|
77
|
+
const v = cached.headers[k]
|
|
78
|
+
if (v === undefined) continue
|
|
79
|
+
ctx._headers[k] = Array.isArray(v) ? [...v] : v
|
|
80
|
+
}
|
|
81
|
+
ctx._headers['idempotent-replayed'] = 'true'
|
|
82
|
+
let nextBody: ResponseBody
|
|
83
|
+
if (cached.body === null) {
|
|
84
|
+
nextBody = { kind: 'none' }
|
|
85
|
+
} else if (typeof cached.body === 'string') {
|
|
86
|
+
nextBody = { kind: 'string', data: cached.body }
|
|
87
|
+
} else {
|
|
88
|
+
nextBody = { kind: 'buffer', data: Buffer.from(cached.body) }
|
|
89
|
+
}
|
|
90
|
+
ctx._body = nextBody
|
|
91
|
+
ctx._written = true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Idempotency-Key middleware (per Stripe / IETF idempotency-key draft).
|
|
96
|
+
*
|
|
97
|
+
* Behavior:
|
|
98
|
+
* - Non-mutating method or missing header → pass through.
|
|
99
|
+
* - Mutating method WITH header:
|
|
100
|
+
* 1. Build cache key: `<scope>:<method>:<path>:<idempotency-key>`.
|
|
101
|
+
* 2. Cache hit → replay the cached (status, headers, body) and set
|
|
102
|
+
* `Idempotent-Replayed: true`. Handler does NOT run.
|
|
103
|
+
* 3. Cache miss → run handler. If the response is cacheable (i.e. not a
|
|
104
|
+
* stream and something was written), persist it under the key with
|
|
105
|
+
* the configured TTL.
|
|
106
|
+
* 4. Concurrent in-flight requests for the same key are coordinated via
|
|
107
|
+
* an in-process Promise map: the second request awaits the first and
|
|
108
|
+
* replays its result.
|
|
109
|
+
*
|
|
110
|
+
* Note: the cache key intentionally does NOT include the request body —
|
|
111
|
+
* the spec assumes the client guarantees byte-for-byte identical retries,
|
|
112
|
+
* and reading the body at middleware-entry time would defeat lazy parsing.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* app.use(ingenium.idempotency({
|
|
116
|
+
* store: new IdempotencyMemoryStore(),
|
|
117
|
+
* ttlSeconds: 86_400,
|
|
118
|
+
* }))
|
|
119
|
+
*/
|
|
120
|
+
export function idempotencyMiddleware(opts: IdempotencyOptions = {}): IngeniumMiddleware {
|
|
121
|
+
const resolved: ResolvedIdempotencyOptions = {
|
|
122
|
+
header: (opts.header ?? 'Idempotency-Key').toLowerCase(),
|
|
123
|
+
store: opts.store ?? new IdempotencyMemoryStore(),
|
|
124
|
+
ttlMs: (opts.ttlSeconds ?? 86_400) * 1000,
|
|
125
|
+
scope: opts.scope ?? defaultScope,
|
|
126
|
+
methodSet: new Set(opts.methods ?? DEFAULT_METHODS),
|
|
127
|
+
cacheable: opts.cacheable ?? DEFAULT_CACHEABLE,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (resolved.ttlMs <= 0) {
|
|
131
|
+
throw new Error('idempotency: ttlSeconds must be > 0')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Per-key in-flight map. The promise resolves once the first handler
|
|
135
|
+
// finishes and its response has been snapshotted (or with `null` if the
|
|
136
|
+
// response wasn't cacheable — second request then runs the handler).
|
|
137
|
+
const inflight: Map<string, Promise<CachedResponse | null>> = new Map()
|
|
138
|
+
|
|
139
|
+
return async (ctx, next) => {
|
|
140
|
+
if (!resolved.methodSet.has(ctx.method)) {
|
|
141
|
+
return next()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const headerValue = readHeader(ctx, resolved.header)
|
|
145
|
+
if (!headerValue || headerValue.length === 0) {
|
|
146
|
+
return next()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const scope = resolved.scope(ctx)
|
|
150
|
+
const cacheKey = `${scope}:${ctx.method}:${ctx.path}:${headerValue}`
|
|
151
|
+
|
|
152
|
+
// 1. Persisted cache hit?
|
|
153
|
+
const existing = await resolved.store.get(cacheKey)
|
|
154
|
+
if (existing) {
|
|
155
|
+
replay(ctx, existing)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. In-flight from a concurrent request?
|
|
160
|
+
const pending = inflight.get(cacheKey)
|
|
161
|
+
if (pending) {
|
|
162
|
+
const result = await pending
|
|
163
|
+
if (result) {
|
|
164
|
+
replay(ctx, result)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
// First request wasn't cacheable — fall through and run the handler.
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 3. Cache miss + no in-flight: take ownership.
|
|
171
|
+
let resolveInflight!: (value: CachedResponse | null) => void
|
|
172
|
+
const ownPromise = new Promise<CachedResponse | null>((res) => { resolveInflight = res })
|
|
173
|
+
inflight.set(cacheKey, ownPromise)
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
await next()
|
|
177
|
+
const captured = snapshot(ctx)
|
|
178
|
+
// Honor the `cacheable` predicate — by default 5xx is NOT cached so a
|
|
179
|
+
// transient failure can't poison the key for the entire TTL. When
|
|
180
|
+
// skipped, resolve the in-flight promise with `null` so any waiter
|
|
181
|
+
// re-runs the handler instead of replaying a stale failure.
|
|
182
|
+
if (captured && resolved.cacheable(captured.statusCode)) {
|
|
183
|
+
await resolved.store.set(cacheKey, captured, resolved.ttlMs)
|
|
184
|
+
resolveInflight(captured)
|
|
185
|
+
} else {
|
|
186
|
+
resolveInflight(null)
|
|
187
|
+
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// Don't cache failures — clear the in-flight slot so retries can run
|
|
190
|
+
// the handler fresh, and let the error propagate.
|
|
191
|
+
resolveInflight(null)
|
|
192
|
+
throw err
|
|
193
|
+
} finally {
|
|
194
|
+
inflight.delete(cacheKey)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|