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,370 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constants as cryptoConstants,
|
|
3
|
+
createHmac,
|
|
4
|
+
createPublicKey,
|
|
5
|
+
verify as cryptoVerify,
|
|
6
|
+
timingSafeEqual,
|
|
7
|
+
type KeyObject,
|
|
8
|
+
} from 'node:crypto'
|
|
9
|
+
import { Buffer } from 'node:buffer'
|
|
10
|
+
import type {
|
|
11
|
+
JwtAlgorithm,
|
|
12
|
+
JwtHeader,
|
|
13
|
+
JwtVerified,
|
|
14
|
+
JwtVerifyError,
|
|
15
|
+
} from './types.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Per-algorithm wire descriptor.
|
|
19
|
+
*
|
|
20
|
+
* `family` selects the verifier branch; the digest / openssl-name fields are
|
|
21
|
+
* only consulted by the matching family.
|
|
22
|
+
*/
|
|
23
|
+
interface AlgSpec {
|
|
24
|
+
family: 'hmac' | 'rsa' | 'rsa-pss' | 'ecdsa'
|
|
25
|
+
/** node:crypto digest name (`sha256`, `sha384`, `sha512`). HMAC + asymmetric both use it. */
|
|
26
|
+
digest: string
|
|
27
|
+
/** Expected raw signature length for ECDSA (r||s, two equal-sized halves). */
|
|
28
|
+
ecSigLen?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ALG_SPEC: Readonly<Record<JwtAlgorithm, AlgSpec>> = {
|
|
32
|
+
HS256: { family: 'hmac', digest: 'sha256' },
|
|
33
|
+
HS384: { family: 'hmac', digest: 'sha384' },
|
|
34
|
+
HS512: { family: 'hmac', digest: 'sha512' },
|
|
35
|
+
RS256: { family: 'rsa', digest: 'sha256' },
|
|
36
|
+
RS384: { family: 'rsa', digest: 'sha384' },
|
|
37
|
+
RS512: { family: 'rsa', digest: 'sha512' },
|
|
38
|
+
PS256: { family: 'rsa-pss', digest: 'sha256' },
|
|
39
|
+
PS384: { family: 'rsa-pss', digest: 'sha384' },
|
|
40
|
+
PS512: { family: 'rsa-pss', digest: 'sha512' },
|
|
41
|
+
// ECDSA: r||s lengths come from the curve order — 32B for P-256,
|
|
42
|
+
// 48B for P-384, 66B for P-521 (the curve is 521 bits, padded to 528 = 66B).
|
|
43
|
+
ES256: { family: 'ecdsa', digest: 'sha256', ecSigLen: 64 },
|
|
44
|
+
ES384: { family: 'ecdsa', digest: 'sha384', ecSigLen: 96 },
|
|
45
|
+
ES512: { family: 'ecdsa', digest: 'sha512', ecSigLen: 132 },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A verification key as supplied by the caller (post-resolution).
|
|
50
|
+
* - `string` / `Buffer` for HMAC secrets and PEM blobs.
|
|
51
|
+
* - `KeyObject` for pre-built node:crypto keys (and JWKS-derived keys).
|
|
52
|
+
*/
|
|
53
|
+
export type VerifyKeyMaterial = string | Buffer | KeyObject
|
|
54
|
+
|
|
55
|
+
/** Optional kid-tagged variant — what middleware passes after kid resolution. */
|
|
56
|
+
export interface KidTaggedKey {
|
|
57
|
+
kid?: string
|
|
58
|
+
key: VerifyKeyMaterial
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Options accepted by {@link verifyJwt}. Mirrors the relevant subset of `JwtOptions`. */
|
|
62
|
+
export interface VerifyOptions {
|
|
63
|
+
algorithms: readonly JwtAlgorithm[]
|
|
64
|
+
audience?: string | readonly string[]
|
|
65
|
+
issuer?: string | readonly string[]
|
|
66
|
+
maxAgeSeconds?: number
|
|
67
|
+
clockSkewSeconds?: number
|
|
68
|
+
/** Override "now" for deterministic tests. Returns seconds since epoch. */
|
|
69
|
+
nowSeconds?: () => number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decode a base64url-encoded JSON segment. Returns `null` on malformed input
|
|
74
|
+
* (so callers can fold it into the generic `'malformed'` failure mode).
|
|
75
|
+
*/
|
|
76
|
+
function decodeJsonSegment<T = unknown>(segment: string): T | null {
|
|
77
|
+
if (!segment) return null
|
|
78
|
+
try {
|
|
79
|
+
const buf = Buffer.from(segment, 'base64url')
|
|
80
|
+
if (buf.length === 0) return null
|
|
81
|
+
const parsed = JSON.parse(buf.toString('utf8')) as unknown
|
|
82
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
83
|
+
return parsed as T
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Constant-time HMAC verification.
|
|
91
|
+
*
|
|
92
|
+
* `timingSafeEqual` requires equal-length buffers — feeding mismatched lengths
|
|
93
|
+
* would itself leak length info via the throw. So we compare `signingInput`'s
|
|
94
|
+
* computed signature against the supplied one only after the explicit length
|
|
95
|
+
* check; both branches return `false` in O(constant) time relative to the
|
|
96
|
+
* caller's view (the throw path never executes).
|
|
97
|
+
*/
|
|
98
|
+
function hmacVerifies(
|
|
99
|
+
digest: string,
|
|
100
|
+
secret: VerifyKeyMaterial,
|
|
101
|
+
signingInput: string,
|
|
102
|
+
sig: Buffer,
|
|
103
|
+
): boolean {
|
|
104
|
+
// HMAC accepts string or Buffer; KeyObject would be unusual but handle it.
|
|
105
|
+
const secretInput: string | Buffer =
|
|
106
|
+
typeof secret === 'string' || Buffer.isBuffer(secret)
|
|
107
|
+
? secret
|
|
108
|
+
: secret.export({ format: 'buffer' } as never)
|
|
109
|
+
const expected = createHmac(digest, secretInput).update(signingInput).digest()
|
|
110
|
+
if (sig.length !== expected.length) return false
|
|
111
|
+
return timingSafeEqual(sig, expected)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Asymmetric verification via OpenSSL. The key may be a PEM (string/Buffer)
|
|
116
|
+
* or a pre-built `KeyObject`; we normalise via `createPublicKey` once. RSA
|
|
117
|
+
* algorithms (RSxxx) use PKCS1-v1_5; PS* uses PSS (with MGF1 + salt = digest);
|
|
118
|
+
* ECDSA decodes from raw r||s (per JOSE) rather than DER.
|
|
119
|
+
*
|
|
120
|
+
* `crypto.verify` from OpenSSL is constant-time relative to the key — we
|
|
121
|
+
* don't add an extra timing shield ourselves.
|
|
122
|
+
*/
|
|
123
|
+
function asymmetricVerifies(
|
|
124
|
+
spec: AlgSpec,
|
|
125
|
+
keyMaterial: VerifyKeyMaterial,
|
|
126
|
+
signingInput: string,
|
|
127
|
+
sig: Buffer,
|
|
128
|
+
): boolean {
|
|
129
|
+
let key: KeyObject
|
|
130
|
+
try {
|
|
131
|
+
key =
|
|
132
|
+
typeof keyMaterial === 'string' || Buffer.isBuffer(keyMaterial)
|
|
133
|
+
? createPublicKey(keyMaterial)
|
|
134
|
+
: keyMaterial
|
|
135
|
+
} catch {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ECDSA: spec mandates raw r||s (concatenation of two fixed-length integers).
|
|
140
|
+
// node:crypto's default DSA encoding is DER; we have to set 'ieee-p1363' to
|
|
141
|
+
// get the JOSE wire format. Length-check up front so a malformed sig never
|
|
142
|
+
// hits openssl with garbage.
|
|
143
|
+
if (spec.family === 'ecdsa') {
|
|
144
|
+
if (typeof spec.ecSigLen === 'number' && sig.length !== spec.ecSigLen) return false
|
|
145
|
+
try {
|
|
146
|
+
return cryptoVerify(spec.digest, Buffer.from(signingInput, 'utf8'), {
|
|
147
|
+
key,
|
|
148
|
+
dsaEncoding: 'ieee-p1363',
|
|
149
|
+
}, sig)
|
|
150
|
+
} catch {
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (spec.family === 'rsa-pss') {
|
|
156
|
+
try {
|
|
157
|
+
return cryptoVerify(spec.digest, Buffer.from(signingInput, 'utf8'), {
|
|
158
|
+
key,
|
|
159
|
+
padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
|
|
160
|
+
// RFC 7518 §3.5: salt length equals the digest output length.
|
|
161
|
+
saltLength: cryptoConstants.RSA_PSS_SALTLEN_DIGEST,
|
|
162
|
+
}, sig)
|
|
163
|
+
} catch {
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Plain RSA-PKCS1-v1_5.
|
|
169
|
+
try {
|
|
170
|
+
return cryptoVerify(spec.digest, Buffer.from(signingInput, 'utf8'), key, sig)
|
|
171
|
+
} catch {
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Allowed claim resolution — both single value and array forms are common in spec. */
|
|
177
|
+
function audienceMatches(claim: unknown, expected: string | readonly string[]): boolean {
|
|
178
|
+
const wanted = typeof expected === 'string' ? [expected] : expected
|
|
179
|
+
if (typeof claim === 'string') return wanted.includes(claim)
|
|
180
|
+
if (Array.isArray(claim)) {
|
|
181
|
+
for (const c of claim) {
|
|
182
|
+
if (typeof c === 'string' && wanted.includes(c)) return true
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function issuerMatches(claim: unknown, expected: string | readonly string[]): boolean {
|
|
189
|
+
if (typeof claim !== 'string') return false
|
|
190
|
+
return typeof expected === 'string' ? expected === claim : expected.includes(claim)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Pure JWT verifier. No I/O, no logging — returns either a `JwtVerified` or
|
|
195
|
+
* a tagged failure object. The middleware layer is responsible for collapsing
|
|
196
|
+
* every failure into the same `IngeniumUnauthorizedError('Invalid token')` so
|
|
197
|
+
* the wire never reveals which check tripped.
|
|
198
|
+
*
|
|
199
|
+
* `keys` is a flat array because key-resolution (rotation, kid-lookup, JWKS)
|
|
200
|
+
* is the caller's responsibility; this function just tries them in order.
|
|
201
|
+
* Each entry may carry an optional `kid` — when present AND `header.kid` is
|
|
202
|
+
* set, only matching entries are considered. Without a `header.kid`, every
|
|
203
|
+
* entry is tried.
|
|
204
|
+
*
|
|
205
|
+
* `alg: 'none'` is rejected unconditionally, even if for some reason the
|
|
206
|
+
* allowlist were extended to include it. Defence in depth.
|
|
207
|
+
*/
|
|
208
|
+
export function verifyJwt<T = Record<string, unknown>>(
|
|
209
|
+
token: string,
|
|
210
|
+
keys: readonly (VerifyKeyMaterial | KidTaggedKey)[],
|
|
211
|
+
opts: VerifyOptions,
|
|
212
|
+
): JwtVerified<T> | JwtVerifyError {
|
|
213
|
+
if (typeof token !== 'string' || token.length === 0) return { error: 'malformed' }
|
|
214
|
+
|
|
215
|
+
// Compact serialization: header.payload.signature
|
|
216
|
+
const firstDot = token.indexOf('.')
|
|
217
|
+
if (firstDot <= 0) return { error: 'malformed' }
|
|
218
|
+
const secondDot = token.indexOf('.', firstDot + 1)
|
|
219
|
+
if (secondDot <= firstDot + 1) return { error: 'malformed' }
|
|
220
|
+
if (token.indexOf('.', secondDot + 1) !== -1) return { error: 'malformed' }
|
|
221
|
+
// The signature segment may legally be empty only for 'alg: none', which
|
|
222
|
+
// we reject anyway. Treat empty as malformed.
|
|
223
|
+
if (secondDot === token.length - 1) return { error: 'malformed' }
|
|
224
|
+
|
|
225
|
+
const headerB64 = token.slice(0, firstDot)
|
|
226
|
+
const payloadB64 = token.slice(firstDot + 1, secondDot)
|
|
227
|
+
const sigB64 = token.slice(secondDot + 1)
|
|
228
|
+
|
|
229
|
+
const header = decodeJsonSegment<JwtHeader>(headerB64)
|
|
230
|
+
if (!header || typeof header.alg !== 'string') return { error: 'malformed' }
|
|
231
|
+
|
|
232
|
+
// Hard-reject 'none' BEFORE the allowlist check — even if a buggy caller
|
|
233
|
+
// somehow lets it through. This is the canonical JWT-library footgun.
|
|
234
|
+
if (header.alg === 'none' || header.alg.toLowerCase() === 'none') {
|
|
235
|
+
return { error: 'unsupported_alg' }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const alg = header.alg as JwtAlgorithm
|
|
239
|
+
if (!opts.algorithms.includes(alg)) return { error: 'unsupported_alg' }
|
|
240
|
+
const spec = ALG_SPEC[alg]
|
|
241
|
+
if (!spec) return { error: 'unsupported_alg' }
|
|
242
|
+
|
|
243
|
+
const payload = decodeJsonSegment<T & Record<string, unknown>>(payloadB64)
|
|
244
|
+
if (!payload) return { error: 'malformed' }
|
|
245
|
+
|
|
246
|
+
const signingInput = `${headerB64}.${payloadB64}`
|
|
247
|
+
|
|
248
|
+
// Decode signature once.
|
|
249
|
+
let sig: Buffer
|
|
250
|
+
try {
|
|
251
|
+
sig = Buffer.from(sigB64, 'base64url')
|
|
252
|
+
} catch {
|
|
253
|
+
return { error: 'malformed' }
|
|
254
|
+
}
|
|
255
|
+
if (sig.length === 0) return { error: 'malformed' }
|
|
256
|
+
|
|
257
|
+
// Filter keys by kid when both sides advertise one. This both narrows the
|
|
258
|
+
// candidate set (perf) and is required for JWKS — the resolver may have
|
|
259
|
+
// returned the entire keyset.
|
|
260
|
+
const headerKid = typeof header.kid === 'string' ? header.kid : null
|
|
261
|
+
const candidates = selectCandidates(keys, headerKid)
|
|
262
|
+
if (candidates.length === 0) {
|
|
263
|
+
// If the caller supplied kid-tagged keys but none matched, we know
|
|
264
|
+
// nothing will verify — surface this as a distinct (internal) reason
|
|
265
|
+
// so the logger can be precise. The wire still gets 'Invalid token'.
|
|
266
|
+
return { error: headerKid ? 'kid_unknown' : 'bad_signature' }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let signatureOk = false
|
|
270
|
+
for (const candidate of candidates) {
|
|
271
|
+
if (spec.family === 'hmac') {
|
|
272
|
+
if (hmacVerifies(spec.digest, candidate, signingInput, sig)) {
|
|
273
|
+
signatureOk = true
|
|
274
|
+
break
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
if (asymmetricVerifies(spec, candidate, signingInput, sig)) {
|
|
278
|
+
signatureOk = true
|
|
279
|
+
break
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!signatureOk) return { error: 'bad_signature' }
|
|
284
|
+
|
|
285
|
+
// Temporal claims.
|
|
286
|
+
const now = (opts.nowSeconds ?? (() => Math.floor(Date.now() / 1000)))()
|
|
287
|
+
const skew = opts.clockSkewSeconds ?? 5
|
|
288
|
+
const claims = payload as Record<string, unknown>
|
|
289
|
+
|
|
290
|
+
if (typeof claims.exp === 'number') {
|
|
291
|
+
if (claims.exp <= now - skew) return { error: 'expired' }
|
|
292
|
+
}
|
|
293
|
+
if (typeof claims.nbf === 'number') {
|
|
294
|
+
if (claims.nbf > now + skew) return { error: 'not_yet_valid' }
|
|
295
|
+
}
|
|
296
|
+
if (typeof opts.maxAgeSeconds === 'number') {
|
|
297
|
+
if (typeof claims.iat !== 'number') return { error: 'too_old' }
|
|
298
|
+
if (claims.iat + opts.maxAgeSeconds <= now - skew) return { error: 'too_old' }
|
|
299
|
+
}
|
|
300
|
+
if (opts.audience !== undefined) {
|
|
301
|
+
if (!audienceMatches(claims.aud, opts.audience)) return { error: 'aud_mismatch' }
|
|
302
|
+
}
|
|
303
|
+
if (opts.issuer !== undefined) {
|
|
304
|
+
if (!issuerMatches(claims.iss, opts.issuer)) return { error: 'iss_mismatch' }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { header, payload, raw: token }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Reduce the supplied key array to the set worth trying:
|
|
312
|
+
* - If `header.kid` is set, prefer entries whose `kid` matches. If at least
|
|
313
|
+
* one tagged key matches, ONLY those are tried (no fallback to untagged
|
|
314
|
+
* keys — an attacker shouldn't be able to coerce a kid-tagged JWKS into
|
|
315
|
+
* trying an unrelated rotation key).
|
|
316
|
+
* - If `header.kid` is set but no tagged key matches, AND the array contains
|
|
317
|
+
* untagged keys, try the untagged ones (legacy / single-key callers).
|
|
318
|
+
* - If `header.kid` is absent, try every entry's `key`.
|
|
319
|
+
*/
|
|
320
|
+
function selectCandidates(
|
|
321
|
+
keys: readonly (VerifyKeyMaterial | KidTaggedKey)[],
|
|
322
|
+
headerKid: string | null,
|
|
323
|
+
): VerifyKeyMaterial[] {
|
|
324
|
+
if (headerKid) {
|
|
325
|
+
const matched: VerifyKeyMaterial[] = []
|
|
326
|
+
let sawAnyTagged = false
|
|
327
|
+
const untagged: VerifyKeyMaterial[] = []
|
|
328
|
+
for (const k of keys) {
|
|
329
|
+
if (isKidTagged(k)) {
|
|
330
|
+
sawAnyTagged = true
|
|
331
|
+
if (k.kid === headerKid) matched.push(k.key)
|
|
332
|
+
} else {
|
|
333
|
+
untagged.push(k)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (matched.length > 0) return matched
|
|
337
|
+
// No tag match — fall back to untagged only when nothing was tagged at
|
|
338
|
+
// all (caller didn't intend kid routing). If they tagged some but the
|
|
339
|
+
// header.kid matches none, refuse.
|
|
340
|
+
if (sawAnyTagged) return []
|
|
341
|
+
return untagged
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const out: VerifyKeyMaterial[] = []
|
|
345
|
+
for (const k of keys) {
|
|
346
|
+
if (isKidTagged(k)) out.push(k.key)
|
|
347
|
+
else out.push(k)
|
|
348
|
+
}
|
|
349
|
+
return out
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isKidTagged(k: VerifyKeyMaterial | KidTaggedKey): k is KidTaggedKey {
|
|
353
|
+
return (
|
|
354
|
+
typeof k === 'object' &&
|
|
355
|
+
k !== null &&
|
|
356
|
+
!Buffer.isBuffer(k) &&
|
|
357
|
+
'key' in (k as object) &&
|
|
358
|
+
'kid' in (k as object)
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Internal helper — exported for tests that want a stable digest map. */
|
|
363
|
+
export function digestFor(alg: JwtAlgorithm): string {
|
|
364
|
+
return ALG_SPEC[alg].digest
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Internal helper — surface the alg family for tests / introspection. */
|
|
368
|
+
export function familyFor(alg: JwtAlgorithm): AlgSpec['family'] {
|
|
369
|
+
return ALG_SPEC[alg].family
|
|
370
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { IngeniumContext } from '../context/context.ts'
|
|
2
|
+
import { reflectReturn } from '../response/reflect.ts'
|
|
3
|
+
import type { ComposedHandler, IngeniumMiddleware } from './types.ts'
|
|
4
|
+
|
|
5
|
+
const NOOP: ComposedHandler = async () => {}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compose an array of middleware into a single async function. Composition
|
|
9
|
+
* runs ONCE at registration / first-request time; the returned function has
|
|
10
|
+
* no per-request `bind`, no index variable, and no `stack[n]` lookups.
|
|
11
|
+
*
|
|
12
|
+
* The dispatcher chain is pre-built bottom-up: `dispatchers[i]` runs
|
|
13
|
+
* `stack[i]` with a `next` that invokes `dispatchers[i + 1]`. Each
|
|
14
|
+
* middleware-level invocation still allocates one closure to capture `ctx`
|
|
15
|
+
* (unavoidable without dropping concurrency safety).
|
|
16
|
+
*
|
|
17
|
+
* Double-`next()` calls are detected only when `process.env.REX_DEBUG` is
|
|
18
|
+
* truthy, to keep the production hot path free of per-call guard variables.
|
|
19
|
+
*/
|
|
20
|
+
export function compose(stack: readonly IngeniumMiddleware[]): ComposedHandler {
|
|
21
|
+
const len = stack.length
|
|
22
|
+
if (len === 0) return NOOP
|
|
23
|
+
|
|
24
|
+
const debug = !!process.env.REX_DEBUG
|
|
25
|
+
|
|
26
|
+
// dispatchers[i] runs middleware[i] and threads control through middleware[i+1..]
|
|
27
|
+
// dispatchers[len] is the terminal noop.
|
|
28
|
+
const dispatchers: ComposedHandler[] = new Array(len + 1)
|
|
29
|
+
dispatchers[len] = NOOP
|
|
30
|
+
|
|
31
|
+
for (let i = len - 1; i >= 0; i--) {
|
|
32
|
+
const fn = stack[i]!
|
|
33
|
+
const nextDispatcher = dispatchers[i + 1]!
|
|
34
|
+
if (debug) {
|
|
35
|
+
dispatchers[i] = async (ctx) => {
|
|
36
|
+
let called = false
|
|
37
|
+
await fn(ctx, () => {
|
|
38
|
+
if (called) {
|
|
39
|
+
throw new Error(`next() called multiple times in middleware at index ${i}`)
|
|
40
|
+
}
|
|
41
|
+
called = true
|
|
42
|
+
return nextDispatcher(ctx)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
dispatchers[i] = async (ctx) => {
|
|
47
|
+
await fn(ctx, () => nextDispatcher(ctx))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return dispatchers[0] ?? NOOP
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compose middleware then append a terminal handler that does not receive a
|
|
57
|
+
* `next` (so a route handler can be the leaf of the chain). The handler's
|
|
58
|
+
* return value is reflected to the response per the contract in
|
|
59
|
+
* `response/reflect.ts` — unless the handler called a `ctx.json/...` helper,
|
|
60
|
+
* in which case the return value is ignored.
|
|
61
|
+
*
|
|
62
|
+
* Hot-path optimization: when there are no middleware, we skip the
|
|
63
|
+
* dispatcher chain entirely and return a thin wrapper that calls the
|
|
64
|
+
* handler directly. We also detect synchronous handler return values
|
|
65
|
+
* (non-thenable) and avoid the `await` microtask in that case — measurable
|
|
66
|
+
* on JSON-returning routes that don't touch the body.
|
|
67
|
+
*/
|
|
68
|
+
export function composeWithHandler(
|
|
69
|
+
middleware: readonly IngeniumMiddleware[],
|
|
70
|
+
handler: (ctx: IngeniumContext) => unknown | Promise<unknown>,
|
|
71
|
+
): ComposedHandler {
|
|
72
|
+
if (middleware.length === 0) {
|
|
73
|
+
return makeFastTerminal(handler)
|
|
74
|
+
}
|
|
75
|
+
const terminal: IngeniumMiddleware = async (ctx) => {
|
|
76
|
+
const result = await handler(ctx)
|
|
77
|
+
reflectReturn(ctx, result)
|
|
78
|
+
}
|
|
79
|
+
return compose([...middleware, terminal])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** No-middleware fast path: skip compose, skip await when handler is sync. */
|
|
83
|
+
function makeFastTerminal(
|
|
84
|
+
handler: (ctx: IngeniumContext) => unknown | Promise<unknown>,
|
|
85
|
+
): ComposedHandler {
|
|
86
|
+
return async (ctx) => {
|
|
87
|
+
const r = handler(ctx)
|
|
88
|
+
if (r !== null && typeof r === 'object' && typeof (r as Promise<unknown>).then === 'function') {
|
|
89
|
+
reflectReturn(ctx, await r)
|
|
90
|
+
} else {
|
|
91
|
+
reflectReturn(ctx, r)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { IngeniumContext } from '../context/context.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A function that runs as part of the middleware chain. Call `next()` to
|
|
5
|
+
* continue to the next middleware; omit it to short-circuit.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const logger: IngeniumMiddleware = async (ctx, next) => {
|
|
9
|
+
* const start = Date.now()
|
|
10
|
+
* await next()
|
|
11
|
+
* ctx.logger?.info(`${ctx.method} ${ctx.path} ${Date.now() - start}ms`)
|
|
12
|
+
* }
|
|
13
|
+
*/
|
|
14
|
+
export type IngeniumMiddleware = (ctx: IngeniumContext, next: () => Promise<void>) => unknown | Promise<unknown>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A composed middleware chain plus terminal handler. Returned by `compose()`.
|
|
18
|
+
* Internally cached on each trie leaf.
|
|
19
|
+
*/
|
|
20
|
+
export type ComposedHandler = (ctx: IngeniumContext) => Promise<void>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A user-facing route handler. Its return value is reflected to the wire by
|
|
24
|
+
* the response-helper dispatcher (see `response/helpers.ts`):
|
|
25
|
+
*
|
|
26
|
+
* - `undefined` → 204 (unless `ctx.json/...` was already called)
|
|
27
|
+
* - `string` → 200 text/plain (or text/html if it starts with `<`)
|
|
28
|
+
* - object → 200 application/json
|
|
29
|
+
* - `Buffer`/`Uint8Array` → 200 application/octet-stream
|
|
30
|
+
* - `Readable` → streamed response
|
|
31
|
+
*
|
|
32
|
+
* For full control, call `ctx.json/text/html/stream/redirect` and return
|
|
33
|
+
* `void` (or any value — return value is ignored once a helper has run).
|
|
34
|
+
*/
|
|
35
|
+
export type IngeniumHandler<Params = Record<string, string>> = (
|
|
36
|
+
ctx: IngeniumContext<Params>,
|
|
37
|
+
) => unknown | Promise<unknown>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parsers and matchers for HTTP `Accept`-family headers.
|
|
3
|
+
*
|
|
4
|
+
* Implemented from scratch — no `negotiator` / `accepts` runtime dep.
|
|
5
|
+
* Used by `negotiate.ts`, `format.ts`, and downstream context helpers.
|
|
6
|
+
*
|
|
7
|
+
* Spec references:
|
|
8
|
+
* - RFC 9110 §12.5.1 (Accept), §12.5.2 (Accept-Charset),
|
|
9
|
+
* §12.5.4 (Accept-Encoding), §12.5.5 (Accept-Language).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A single parsed media-range entry from an `Accept` header. */
|
|
13
|
+
export interface ParsedAccept {
|
|
14
|
+
/** The full media-range string (lowercased), e.g. `text/html`, `text/\*`, `\*\/\*`. */
|
|
15
|
+
type: string
|
|
16
|
+
/** Quality factor from `;q=N`, default `1`. Out-of-range values are clamped. */
|
|
17
|
+
quality: number
|
|
18
|
+
/** Any other extension parameters (e.g. `level=1`). */
|
|
19
|
+
params: Record<string, string>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Express `accepts`-style shorthand → canonical media type.
|
|
24
|
+
* Kept intentionally tiny — covers the 99% case for body responses.
|
|
25
|
+
*/
|
|
26
|
+
const SHORTHAND: Readonly<Record<string, string>> = {
|
|
27
|
+
json: 'application/json',
|
|
28
|
+
html: 'text/html',
|
|
29
|
+
text: 'text/plain',
|
|
30
|
+
xml: 'application/xml',
|
|
31
|
+
form: 'application/x-www-form-urlencoded',
|
|
32
|
+
multipart: 'multipart/form-data',
|
|
33
|
+
csv: 'text/csv',
|
|
34
|
+
'octet-stream': 'application/octet-stream',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve a shorthand (`'json'`) to its canonical mime, or pass through. */
|
|
38
|
+
export function expandShorthand(token: string): string {
|
|
39
|
+
const lower = token.toLowerCase()
|
|
40
|
+
return SHORTHAND[lower] ?? lower
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse a comma-separated `Accept`-family header into a list of entries.
|
|
45
|
+
* Empty / undefined input returns an empty array. Malformed entries are
|
|
46
|
+
* silently dropped (lenient parsing — same as Express).
|
|
47
|
+
*
|
|
48
|
+
* Result is **not** sorted; pass to `sortByPreference` if you need ordering.
|
|
49
|
+
*/
|
|
50
|
+
export function parseAcceptHeader(header: string | undefined): ParsedAccept[] {
|
|
51
|
+
if (!header) return []
|
|
52
|
+
const out: ParsedAccept[] = []
|
|
53
|
+
// Split on commas. Header values don't allow quoted commas in this set,
|
|
54
|
+
// so a plain split is safe.
|
|
55
|
+
const parts = header.split(',')
|
|
56
|
+
for (const raw of parts) {
|
|
57
|
+
const trimmed = raw.trim()
|
|
58
|
+
if (trimmed.length === 0) continue
|
|
59
|
+
const segments = trimmed.split(';')
|
|
60
|
+
const typeSeg = segments[0]?.trim().toLowerCase()
|
|
61
|
+
if (!typeSeg) continue
|
|
62
|
+
let quality = 1
|
|
63
|
+
const params: Record<string, string> = {}
|
|
64
|
+
for (let i = 1; i < segments.length; i++) {
|
|
65
|
+
const seg = segments[i]?.trim()
|
|
66
|
+
if (!seg) continue
|
|
67
|
+
const eq = seg.indexOf('=')
|
|
68
|
+
if (eq === -1) continue
|
|
69
|
+
const key = seg.slice(0, eq).trim().toLowerCase()
|
|
70
|
+
let value = seg.slice(eq + 1).trim()
|
|
71
|
+
// Strip surrounding quotes if present.
|
|
72
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
73
|
+
value = value.slice(1, value.length - 1)
|
|
74
|
+
}
|
|
75
|
+
if (key === 'q') {
|
|
76
|
+
const q = Number(value)
|
|
77
|
+
quality = Number.isFinite(q) ? Math.max(0, Math.min(1, q)) : 0
|
|
78
|
+
} else {
|
|
79
|
+
params[key] = value
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
out.push({ type: typeSeg, quality, params })
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Specificity score for a media-range. Higher is more specific.
|
|
89
|
+
* `*/*` → 0
|
|
90
|
+
* `type/*` → 1
|
|
91
|
+
* `type/sub` → 2 (+ #params for tie-breaking)
|
|
92
|
+
*/
|
|
93
|
+
function specificity(entry: ParsedAccept): number {
|
|
94
|
+
if (entry.type === '*/*' || entry.type === '*') return 0
|
|
95
|
+
if (entry.type.endsWith('/*')) return 1
|
|
96
|
+
return 2 + Object.keys(entry.params).length
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stable sort by RFC preference: highest q first, then most-specific first.
|
|
101
|
+
* Returns a NEW array; does not mutate input.
|
|
102
|
+
*/
|
|
103
|
+
export function sortByPreference(entries: readonly ParsedAccept[]): ParsedAccept[] {
|
|
104
|
+
return [...entries]
|
|
105
|
+
.map((e, i) => ({ e, i }))
|
|
106
|
+
.sort((a, b) => {
|
|
107
|
+
if (b.e.quality !== a.e.quality) return b.e.quality - a.e.quality
|
|
108
|
+
const sb = specificity(b.e)
|
|
109
|
+
const sa = specificity(a.e)
|
|
110
|
+
if (sb !== sa) return sb - sa
|
|
111
|
+
return a.i - b.i // stable
|
|
112
|
+
})
|
|
113
|
+
.map((x) => x.e)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Does an offered concrete type match a parsed Accept entry (incl. wildcards)? */
|
|
117
|
+
function entryMatches(entry: ParsedAccept, offered: string): boolean {
|
|
118
|
+
const offeredLower = offered.toLowerCase()
|
|
119
|
+
if (entry.type === '*/*' || entry.type === '*') return true
|
|
120
|
+
if (entry.type === offeredLower) return true
|
|
121
|
+
if (entry.type.endsWith('/*')) {
|
|
122
|
+
const prefix = entry.type.slice(0, -1) // keep trailing slash
|
|
123
|
+
return offeredLower.startsWith(prefix)
|
|
124
|
+
}
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Return the best match for `offered` against `acceptHeader`, or `false`.
|
|
130
|
+
*
|
|
131
|
+
* Matching algorithm:
|
|
132
|
+
* 1. If `acceptHeader` is missing/empty → first offered wins (Express behavior).
|
|
133
|
+
* 2. Walk parsed entries sorted by quality + specificity.
|
|
134
|
+
* 3. For each entry (in preference order), pick the first offered that matches.
|
|
135
|
+
* Among ties at the same Accept entry, the offered's listed order wins.
|
|
136
|
+
* 4. Entries with `q=0` reject — never match.
|
|
137
|
+
*/
|
|
138
|
+
export function selectBest(
|
|
139
|
+
acceptHeader: string | undefined,
|
|
140
|
+
offered: readonly string[],
|
|
141
|
+
): string | false {
|
|
142
|
+
if (offered.length === 0) return false
|
|
143
|
+
const expanded = offered.map(expandShorthand)
|
|
144
|
+
if (!acceptHeader || acceptHeader.trim() === '') {
|
|
145
|
+
return offered[0] ?? false
|
|
146
|
+
}
|
|
147
|
+
const sorted = sortByPreference(parseAcceptHeader(acceptHeader))
|
|
148
|
+
if (sorted.length === 0) return offered[0] ?? false
|
|
149
|
+
|
|
150
|
+
for (const entry of sorted) {
|
|
151
|
+
if (entry.quality === 0) continue
|
|
152
|
+
for (let i = 0; i < expanded.length; i++) {
|
|
153
|
+
if (entryMatches(entry, expanded[i] as string)) {
|
|
154
|
+
return offered[i] as string
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `computeEtag(body, weak?)` — sha1-based entity tag for response bodies.
|
|
3
|
+
*
|
|
4
|
+
* Format: `W/"<sha1-base64-without-padding>"` (weak) or `"<sha1-base64-without-padding>"`.
|
|
5
|
+
* Weak is the default — fine for JSON where serialization may legitimately vary
|
|
6
|
+
* (key order, whitespace) without representing a different resource.
|
|
7
|
+
*
|
|
8
|
+
* The empty-body ETag is special-cased to a fixed constant so two empty
|
|
9
|
+
* bodies always compare equal without pumping through the hash.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { Buffer } from 'node:buffer'
|
|
14
|
+
|
|
15
|
+
/** Pre-computed sha1("") base64 → `2jmj7l5rSw0yVb/vlWAYkK/YBwk=`. */
|
|
16
|
+
const EMPTY_HASH = '2jmj7l5rSw0yVb/vlWAYkK/YBwk='
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute an ETag for the given body. Strings are treated as UTF-8.
|
|
20
|
+
* @param body Response body — usually `JSON.stringify(...)` or a `Buffer`.
|
|
21
|
+
* @param weak Prefix the tag with `W/`. Defaults to `true` for JSON safety.
|
|
22
|
+
*/
|
|
23
|
+
export function computeEtag(body: string | Buffer, weak = true): string {
|
|
24
|
+
const len = typeof body === 'string' ? Buffer.byteLength(body, 'utf8') : body.length
|
|
25
|
+
if (len === 0) return weak ? `W/"${EMPTY_HASH}"` : `"${EMPTY_HASH}"`
|
|
26
|
+
const hash = createHash('sha1').update(body as Buffer | string).digest('base64')
|
|
27
|
+
// Trim trailing `=` padding for compactness — keeps tag URL-safe-ish and shorter.
|
|
28
|
+
const trimmed = hash.replace(/=+$/g, '')
|
|
29
|
+
return weak ? `W/"${trimmed}"` : `"${trimmed}"`
|
|
30
|
+
}
|