@daloyjs/core 0.35.2 → 0.37.0

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 (77) hide show
  1. package/README.md +22 -2
  2. package/bin/daloy.mjs +2 -0
  3. package/dist/adapters/bun.js +16 -9
  4. package/dist/adapters/deno.js +7 -1
  5. package/dist/adapters/node.d.ts +11 -0
  6. package/dist/adapters/node.js +24 -0
  7. package/dist/app.d.ts +223 -1
  8. package/dist/app.js +358 -8
  9. package/dist/asyncapi.d.ts +98 -0
  10. package/dist/asyncapi.js +212 -0
  11. package/dist/auto-ban.d.ts +205 -0
  12. package/dist/auto-ban.js +222 -0
  13. package/dist/bot-guard.d.ts +209 -0
  14. package/dist/bot-guard.js +291 -0
  15. package/dist/cli.d.ts +8 -0
  16. package/dist/cli.js +88 -4
  17. package/dist/concurrency-limit.d.ts +135 -0
  18. package/dist/concurrency-limit.js +254 -0
  19. package/dist/docs.d.ts +57 -6
  20. package/dist/docs.js +34 -3
  21. package/dist/errors.d.ts +20 -0
  22. package/dist/errors.js +27 -0
  23. package/dist/fetch-guard.js +4 -0
  24. package/dist/fetch-resilience.d.ts +295 -0
  25. package/dist/fetch-resilience.js +485 -0
  26. package/dist/geo-block.d.ts +184 -0
  27. package/dist/geo-block.js +153 -0
  28. package/dist/hashing.d.ts +2 -1
  29. package/dist/hashing.js +12 -1
  30. package/dist/http-signatures.d.ts +303 -0
  31. package/dist/http-signatures.js +782 -0
  32. package/dist/idempotency.d.ts +204 -0
  33. package/dist/idempotency.js +341 -0
  34. package/dist/index.d.ts +38 -4
  35. package/dist/index.js +18 -1
  36. package/dist/ip-reputation.d.ts +198 -0
  37. package/dist/ip-reputation.js +253 -0
  38. package/dist/jwk.d.ts +15 -0
  39. package/dist/jwk.js +24 -2
  40. package/dist/load-shedding.d.ts +5 -0
  41. package/dist/logger.js +6 -2
  42. package/dist/metrics.d.ts +208 -0
  43. package/dist/metrics.js +452 -0
  44. package/dist/middleware.js +0 -10
  45. package/dist/mtls.d.ts +266 -0
  46. package/dist/mtls.js +488 -0
  47. package/dist/multipart.js +1 -1
  48. package/dist/openapi-diff.d.ts +79 -0
  49. package/dist/openapi-diff.js +246 -0
  50. package/dist/openapi.js +4 -1
  51. package/dist/pagination.d.ts +210 -0
  52. package/dist/pagination.js +353 -0
  53. package/dist/rate-limit-redis.d.ts +8 -0
  54. package/dist/rate-limit-redis.js +8 -0
  55. package/dist/request-decompression.d.ts +200 -0
  56. package/dist/request-decompression.js +363 -0
  57. package/dist/response-cache.d.ts +205 -0
  58. package/dist/response-cache.js +374 -0
  59. package/dist/router.d.ts +22 -0
  60. package/dist/router.js +64 -7
  61. package/dist/safe-redirect.d.ts +2 -2
  62. package/dist/safe-redirect.js +3 -8
  63. package/dist/sbom.cdx.json +9 -9
  64. package/dist/sbom.spdx.json +5 -5
  65. package/dist/scheduler.d.ts +315 -0
  66. package/dist/scheduler.js +546 -0
  67. package/dist/security.d.ts +27 -7
  68. package/dist/security.js +27 -7
  69. package/dist/session.js +3 -3
  70. package/dist/types.d.ts +33 -0
  71. package/dist/waf.d.ts +213 -0
  72. package/dist/waf.js +334 -0
  73. package/dist/webhook-delivery.d.ts +263 -0
  74. package/dist/webhook-delivery.js +311 -0
  75. package/dist/websocket.d.ts +52 -0
  76. package/dist/websocket.js +13 -0
  77. package/package.json +76 -2
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Cursor pagination helpers for DaloyJS.
3
+ *
4
+ * Contract-first list endpoints need three things the framework did not yet
5
+ * ship: an **opaque cursor** the client can echo back without depending on its
6
+ * internals, an **RFC 8288 `Link` header** advertising the `next` / `prev` /
7
+ * `first` pages, and **OpenAPI parameter wiring** so the `cursor` / `limit`
8
+ * query parameters appear in the generated spec and typed client. This module
9
+ * provides all three with zero runtime dependencies:
10
+ *
11
+ * - {@link encodeCursor} / {@link decodeCursor} — base64url-encode an arbitrary
12
+ * JSON-serializable payload (typically the sort key of the last row) into an
13
+ * opaque, URL-safe token, and decode it back with prototype-pollution-safe
14
+ * parsing and a hard size cap.
15
+ * - {@link buildLinkHeader} / {@link buildPageLinks} — assemble a Web-standard
16
+ * `Link` header, with CRLF / angle-bracket header-injection guards baked in.
17
+ * - {@link paginationQuery} — a Standard Schema validator for the `cursor` +
18
+ * `limit` query parameters that both validates at runtime (clamping `limit`
19
+ * to a safe range) **and** advertises itself to the OpenAPI generator via a
20
+ * `toJSONSchema()` method, so `request: { query: paginationQuery() }` wires
21
+ * the parameters into the contract with no extra code.
22
+ *
23
+ * Everything here is built on Web-standard `URL` / `Request` and `btoa` /
24
+ * `atob`, so it runs unchanged on Node, Bun, Deno, Cloudflare Workers, and
25
+ * Vercel Edge.
26
+ *
27
+ * @module
28
+ * @since 0.37.0
29
+ */
30
+ import { BadRequestError } from "./errors.js";
31
+ import { isForbiddenObjectKey } from "./security.js";
32
+ /**
33
+ * Hard cap on the length of an encoded cursor string accepted by
34
+ * {@link decodeCursor}. Bounds the work an attacker can force by sending a
35
+ * giant `cursor` query parameter. 4 KiB is far larger than any legitimate
36
+ * sort-key payload.
37
+ */
38
+ export const MAX_CURSOR_LENGTH = 4096;
39
+ // ---------- Opaque cursor codec ----------
40
+ /**
41
+ * Encode an arbitrary JSON-serializable value into an opaque, URL-safe cursor
42
+ * token (base64url, no padding).
43
+ *
44
+ * The token is **opaque, not secret**: it is encoded, not encrypted or signed.
45
+ * Never trust a decoded cursor for authorization — always re-scope the
46
+ * underlying query by the authenticated principal on the server. Put only the
47
+ * data you need to resume a scan (e.g. `{ id, createdAt }`) inside it.
48
+ *
49
+ * @param payload - Any JSON-serializable value (object, array, string, …).
50
+ * @returns A base64url cursor string safe to place in a URL or `Link` header.
51
+ * @throws {TypeError} If `payload` cannot be JSON-serialized (e.g. a `BigInt`
52
+ * or a circular structure).
53
+ * @since 0.37.0
54
+ */
55
+ export function encodeCursor(payload) {
56
+ const json = JSON.stringify(payload);
57
+ if (json === undefined) {
58
+ throw new TypeError("encodeCursor(): payload is not JSON-serializable.");
59
+ }
60
+ return base64UrlEncode(json);
61
+ }
62
+ /**
63
+ * Decode an opaque cursor produced by {@link encodeCursor} back into its
64
+ * original value.
65
+ *
66
+ * Parsing is hardened: the input length is capped at {@link MAX_CURSOR_LENGTH},
67
+ * decoding rejects malformed base64url, and any `__proto__` / `constructor` /
68
+ * `prototype` keys in the decoded object graph are stripped (prototype-
69
+ * pollution defense, mirroring the core body parsers).
70
+ *
71
+ * @typeParam T - The expected shape of the decoded payload (caller-asserted).
72
+ * @param cursor - The opaque cursor string from the request.
73
+ * @returns The decoded payload.
74
+ * @throws {BadRequestError} If the cursor is missing, over-long, or malformed —
75
+ * a `400` so a tampered cursor surfaces as a client error, not a `500`.
76
+ * @since 0.37.0
77
+ */
78
+ export function decodeCursor(cursor) {
79
+ if (typeof cursor !== "string" || cursor.length === 0) {
80
+ throw new BadRequestError("Invalid pagination cursor.");
81
+ }
82
+ if (cursor.length > MAX_CURSOR_LENGTH) {
83
+ throw new BadRequestError("Pagination cursor is too long.");
84
+ }
85
+ let json;
86
+ try {
87
+ json = base64UrlDecode(cursor);
88
+ }
89
+ catch {
90
+ throw new BadRequestError("Malformed pagination cursor.");
91
+ }
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(json);
95
+ }
96
+ catch {
97
+ throw new BadRequestError("Malformed pagination cursor.");
98
+ }
99
+ return stripForbiddenKeys(parsed);
100
+ }
101
+ // Reject control characters and the structural delimiters that would let a
102
+ // crafted URL or title break out of the header (CRLF injection, `<`/`>`).
103
+ const LINK_URL_FORBIDDEN = /[\u0000-\u001f\u007f<>]/;
104
+ const LINK_TOKEN_FORBIDDEN = /[\u0000-\u001f\u007f"\\]/;
105
+ /**
106
+ * Serialize a list of links into a single RFC 8288 `Link` header value.
107
+ *
108
+ * Each entry renders as `<url>; rel="rel"` (plus `; title="…"` when present).
109
+ * URLs containing control characters, `<`, or `>` and rel/title values
110
+ * containing control characters, `"`, or `\` are rejected — a structural
111
+ * defense against `Link`-header / response-splitting injection.
112
+ *
113
+ * @param links - The links to emit. An empty array yields an empty string.
114
+ * @returns The comma-joined `Link` header value.
115
+ * @throws {Error} If any URL or token contains forbidden characters.
116
+ * @since 0.37.0
117
+ */
118
+ export function buildLinkHeader(links) {
119
+ const parts = [];
120
+ for (const link of links) {
121
+ if (LINK_URL_FORBIDDEN.test(link.url)) {
122
+ throw new Error("buildLinkHeader(): link URL contains forbidden characters.");
123
+ }
124
+ if (LINK_TOKEN_FORBIDDEN.test(link.rel)) {
125
+ throw new Error("buildLinkHeader(): link rel contains forbidden characters.");
126
+ }
127
+ let part = `<${link.url}>; rel="${link.rel}"`;
128
+ if (link.title !== undefined) {
129
+ if (LINK_TOKEN_FORBIDDEN.test(link.title)) {
130
+ throw new Error("buildLinkHeader(): link title contains forbidden characters.");
131
+ }
132
+ part += `; title="${link.title}"`;
133
+ }
134
+ parts.push(part);
135
+ }
136
+ return parts.join(", ");
137
+ }
138
+ /**
139
+ * Build the `next` / `prev` / `first` page URLs for a list response by cloning
140
+ * the current request URL and swapping its cursor query parameter, then
141
+ * serialize them into an RFC 8288 `Link` header.
142
+ *
143
+ * All other query parameters (filters, `limit`, …) are preserved, so the
144
+ * generated links are drop-in "give me the same query, next page" URLs.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * const { linkHeader } = buildPageLinks({
149
+ * url: ctx.request.url,
150
+ * next: nextCursor, // from encodeCursor(...)
151
+ * prev: prevCursor,
152
+ * first: true,
153
+ * });
154
+ * set.headers.set("Link", linkHeader);
155
+ * ```
156
+ *
157
+ * @param opts - Current URL plus the cursors to advertise.
158
+ * @returns The structured links, the `Link` header string, and the page URLs.
159
+ * @since 0.37.0
160
+ */
161
+ export function buildPageLinks(opts) {
162
+ const cursorParam = opts.cursorParam ?? "cursor";
163
+ const base = new URL(typeof opts.url === "string" ? opts.url : opts.url.href);
164
+ const self = base.href;
165
+ const links = [];
166
+ const urls = { self };
167
+ if (opts.next !== undefined && opts.next !== null) {
168
+ const u = new URL(base.href);
169
+ u.searchParams.set(cursorParam, opts.next);
170
+ urls.next = u.href;
171
+ links.push({ url: u.href, rel: "next" });
172
+ }
173
+ if (opts.prev !== undefined && opts.prev !== null) {
174
+ const u = new URL(base.href);
175
+ u.searchParams.set(cursorParam, opts.prev);
176
+ urls.prev = u.href;
177
+ links.push({ url: u.href, rel: "prev" });
178
+ }
179
+ if (opts.first === true) {
180
+ const u = new URL(base.href);
181
+ u.searchParams.delete(cursorParam);
182
+ urls.first = u.href;
183
+ links.push({ url: u.href, rel: "first" });
184
+ }
185
+ if (opts.extraLinks)
186
+ links.push(...opts.extraLinks);
187
+ return { links, linkHeader: buildLinkHeader(links), urls };
188
+ }
189
+ /**
190
+ * Build a Standard Schema validator for cursor-pagination query parameters.
191
+ *
192
+ * Use it as a route's `request.query`. At runtime it parses and validates
193
+ * `limit` (coerced from its string query value to an integer and clamped to
194
+ * `[minLimit, maxLimit]`, defaulting to `defaultLimit` when absent) and passes
195
+ * `cursor` through as an optional opaque string. Because it also exposes
196
+ * `toJSONSchema()`, the same call wires both parameters into the generated
197
+ * OpenAPI document and typed client — no duplicate parameter declarations.
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * app.route({
202
+ * method: "GET",
203
+ * path: "/books",
204
+ * operationId: "listBooks",
205
+ * request: { query: paginationQuery({ defaultLimit: 25, maxLimit: 100 }) },
206
+ * responses: { 200: { description: "ok", body: pageSchema } },
207
+ * handler: async ({ query }) => {
208
+ * const { limit, cursor } = query; // fully typed + validated
209
+ * // ...
210
+ * },
211
+ * });
212
+ * ```
213
+ *
214
+ * @param opts - Parameter names and page-size bounds.
215
+ * @returns A Standard Schema usable as `request.query`.
216
+ * @throws {Error} If the configured bounds are not positive integers or are
217
+ * inconsistent (`minLimit > maxLimit`, `defaultLimit` out of range).
218
+ * @since 0.37.0
219
+ */
220
+ export function paginationQuery(opts = {}) {
221
+ const cursorParam = opts.cursorParam ?? "cursor";
222
+ const limitParam = opts.limitParam ?? "limit";
223
+ const minLimit = opts.minLimit ?? 1;
224
+ const maxLimit = opts.maxLimit ?? 100;
225
+ const defaultLimit = opts.defaultLimit ?? Math.min(20, maxLimit);
226
+ for (const [label, n] of [
227
+ ["minLimit", minLimit],
228
+ ["maxLimit", maxLimit],
229
+ ["defaultLimit", defaultLimit],
230
+ ]) {
231
+ if (!Number.isInteger(n) || n <= 0) {
232
+ throw new Error(`paginationQuery(): ${label} must be a positive integer.`);
233
+ }
234
+ }
235
+ if (minLimit > maxLimit) {
236
+ throw new Error("paginationQuery(): minLimit must not exceed maxLimit.");
237
+ }
238
+ if (defaultLimit < minLimit || defaultLimit > maxLimit) {
239
+ throw new Error("paginationQuery(): defaultLimit must be within [minLimit, maxLimit].");
240
+ }
241
+ return {
242
+ "~standard": {
243
+ version: 1,
244
+ vendor: "daloyjs",
245
+ validate(value) {
246
+ if (value === null || typeof value !== "object") {
247
+ return { issues: [{ message: "Expected a query object" }] };
248
+ }
249
+ const input = value;
250
+ const out = { limit: defaultLimit };
251
+ const rawLimit = input[limitParam];
252
+ if (rawLimit !== undefined && rawLimit !== "") {
253
+ const limitStr = Array.isArray(rawLimit) ? rawLimit[0] : rawLimit;
254
+ const n = Number(limitStr);
255
+ if (!Number.isInteger(n)) {
256
+ return {
257
+ issues: [{ message: `${limitParam} must be an integer`, path: [limitParam] }],
258
+ };
259
+ }
260
+ if (n < minLimit || n > maxLimit) {
261
+ return {
262
+ issues: [
263
+ {
264
+ message: `${limitParam} must be between ${minLimit} and ${maxLimit}`,
265
+ path: [limitParam],
266
+ },
267
+ ],
268
+ };
269
+ }
270
+ out.limit = n;
271
+ }
272
+ const rawCursor = input[cursorParam];
273
+ if (rawCursor !== undefined && rawCursor !== "") {
274
+ const cursorStr = Array.isArray(rawCursor) ? rawCursor[0] : rawCursor;
275
+ if (typeof cursorStr !== "string") {
276
+ return {
277
+ issues: [{ message: `${cursorParam} must be a string`, path: [cursorParam] }],
278
+ };
279
+ }
280
+ if (cursorStr.length > MAX_CURSOR_LENGTH) {
281
+ return {
282
+ issues: [{ message: `${cursorParam} is too long`, path: [cursorParam] }],
283
+ };
284
+ }
285
+ out.cursor = cursorStr;
286
+ }
287
+ return { value: out };
288
+ },
289
+ },
290
+ toJSONSchema() {
291
+ return {
292
+ type: "object",
293
+ properties: {
294
+ [limitParam]: {
295
+ type: "integer",
296
+ minimum: minLimit,
297
+ maximum: maxLimit,
298
+ default: defaultLimit,
299
+ description: "Maximum number of items to return.",
300
+ },
301
+ [cursorParam]: {
302
+ type: "string",
303
+ maxLength: MAX_CURSOR_LENGTH,
304
+ description: "Opaque cursor identifying the page to return.",
305
+ },
306
+ },
307
+ required: [],
308
+ };
309
+ },
310
+ };
311
+ }
312
+ // ---------- Internal helpers ----------
313
+ /** Encode a UTF-8 string as base64url without padding. */
314
+ function base64UrlEncode(input) {
315
+ const bytes = new TextEncoder().encode(input);
316
+ let bin = "";
317
+ const CHUNK = 0x8000;
318
+ for (let i = 0; i < bytes.length; i += CHUNK) {
319
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
320
+ }
321
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
322
+ }
323
+ /** Decode a base64url (optionally padded) string back to a UTF-8 string. */
324
+ function base64UrlDecode(input) {
325
+ if (!/^[A-Za-z0-9_-]+$/.test(input)) {
326
+ throw new Error("invalid base64url");
327
+ }
328
+ const padded = input.replace(/-/g, "+").replace(/_/g, "/");
329
+ const bin = atob(padded);
330
+ const bytes = new Uint8Array(bin.length);
331
+ for (let i = 0; i < bin.length; i++)
332
+ bytes[i] = bin.charCodeAt(i);
333
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
334
+ }
335
+ /**
336
+ * Recursively remove prototype-pollution sink keys (`__proto__`,
337
+ * `constructor`, `prototype`) from a decoded cursor payload.
338
+ */
339
+ function stripForbiddenKeys(value) {
340
+ if (Array.isArray(value)) {
341
+ return value.map((v) => stripForbiddenKeys(v));
342
+ }
343
+ if (value !== null && typeof value === "object") {
344
+ const out = {};
345
+ for (const [k, v] of Object.entries(value)) {
346
+ if (isForbiddenObjectKey(k))
347
+ continue;
348
+ out[k] = stripForbiddenKeys(v);
349
+ }
350
+ return out;
351
+ }
352
+ return value;
353
+ }
@@ -75,6 +75,14 @@ export interface RedisRateLimitStoreOptions {
75
75
  * The returned store is safe to share between requests and replicas. Errors
76
76
  * from Redis are fail-open by default (see {@link RedisRateLimitStoreOptions.onError});
77
77
  * pass a custom handler to fail-closed (return `"fail-closed"`).
78
+ *
79
+ * @remarks
80
+ * Security: the default fail-open posture biases toward availability — while
81
+ * Redis is unreachable the limiter stops enforcing and every request is
82
+ * allowed (reported as the first hit of a fresh local window). For
83
+ * abuse-sensitive limiters in front of auth, password-reset, or other
84
+ * credential endpoints, pass `onError: () => "fail-closed"` so a Redis
85
+ * outage rejects rather than silently disables the limit.
78
86
  */
79
87
  export declare function redisRateLimitStore(opts: RedisRateLimitStoreOptions): RateLimitStore;
80
88
  /**
@@ -76,6 +76,14 @@ function toNumber(value) {
76
76
  * The returned store is safe to share between requests and replicas. Errors
77
77
  * from Redis are fail-open by default (see {@link RedisRateLimitStoreOptions.onError});
78
78
  * pass a custom handler to fail-closed (return `"fail-closed"`).
79
+ *
80
+ * @remarks
81
+ * Security: the default fail-open posture biases toward availability — while
82
+ * Redis is unreachable the limiter stops enforcing and every request is
83
+ * allowed (reported as the first hit of a fresh local window). For
84
+ * abuse-sensitive limiters in front of auth, password-reset, or other
85
+ * credential endpoints, pass `onError: () => "fail-closed"` so a Redis
86
+ * outage rejects rather than silently disables the limit.
79
87
  */
80
88
  export function redisRateLimitStore(opts) {
81
89
  const prefix = opts.prefix ?? "daloy:rl:";
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Inbound request-decompression bomb guard.
3
+ *
4
+ * DaloyJS core deliberately does **not** decompress request bodies — it is safe
5
+ * by omission, so a `Content-Encoding: gzip` request body is read as-is and a
6
+ * schema parse simply fails on the compressed bytes. Some services, though,
7
+ * genuinely need to accept compressed uploads (chatty IoT clients, log
8
+ * shippers, mobile apps on slow links). The moment you inflate attacker-supplied
9
+ * bytes you inherit the classic **decompression bomb** (a.k.a. "zip bomb"): a
10
+ * few kilobytes of crafted gzip can expand to gigabytes and blow straight past
11
+ * {@link "./app.js".AppOptions.bodyLimitBytes}, which only ever sees the small
12
+ * compressed payload.
13
+ *
14
+ * {@link requestDecompression} is the opt-in middleware that adds request
15
+ * decompression **with the bomb guard baked in**. It inflates the body with two
16
+ * independent caps enforced *during* inflation (so a bomb is aborted long before
17
+ * it is fully materialised):
18
+ *
19
+ * - an **absolute** cap (`maxDecompressedBytes`) — the inflated body may never
20
+ * exceed this many bytes; and
21
+ * - a **ratio** cap (`maxRatio`) — the inflated size may never exceed
22
+ * `compressedBytes * maxRatio`, which catches small-but-explosive payloads
23
+ * that stay under the absolute cap in isolation but would amplify wildly.
24
+ *
25
+ * The compressed input itself is bounded by `maxCompressedBytes` before a single
26
+ * byte is inflated. Built on the web-standard `DecompressionStream`, so the same
27
+ * line works on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge. Zero
28
+ * runtime dependencies.
29
+ *
30
+ * The middleware runs in the {@link "./types.js".Hooks.onRequest} phase — before
31
+ * the per-request context (and therefore before schema-body validation) is
32
+ * built — and stashes the inflated bytes on the request so the framework's own
33
+ * body reader transparently sees the decompressed payload. That means it works
34
+ * for both schema-validated bodies and handlers that read the raw body
35
+ * themselves. Register it globally with `app.use(requestDecompression(...))`.
36
+ *
37
+ * Secure-by-default posture:
38
+ * - Only `gzip` and `deflate` are accepted (the encodings `DecompressionStream`
39
+ * implements across runtimes). An unknown, unsupported, or **layered**
40
+ * (`gzip, gzip`) `Content-Encoding` is refused with `415` — never inflated.
41
+ * - Malformed compressed input is refused with `400`, never silently treated as
42
+ * an empty body.
43
+ * - The bomb caps are mandatory: there is no "unlimited" mode.
44
+ *
45
+ * @module
46
+ * @since 0.37.0
47
+ */
48
+ import { HttpError } from "./errors.js";
49
+ import type { Hooks } from "./types.js";
50
+ /**
51
+ * Request `Content-Encoding` values this guard can safely inflate. Limited to
52
+ * the formats the web-standard `DecompressionStream` implements consistently
53
+ * across runtimes (brotli is intentionally excluded — it is not part of the
54
+ * Compression Streams spec and is unavailable on most runtimes).
55
+ *
56
+ * @since 0.37.0
57
+ */
58
+ export type RequestDecompressionEncoding = "gzip" | "deflate";
59
+ /**
60
+ * Details of a rejected decompression bomb, passed to
61
+ * {@link RequestDecompressionOptions.onBomb} and carried by
62
+ * {@link DecompressionBombError}.
63
+ *
64
+ * @since 0.37.0
65
+ */
66
+ export interface DecompressionBombInfo {
67
+ /** The declared request `Content-Encoding` that was being inflated. */
68
+ encoding: RequestDecompressionEncoding;
69
+ /** Size of the compressed input, in bytes. */
70
+ compressedBytes: number;
71
+ /** Inflated bytes produced before the guard aborted (always over a cap). */
72
+ decompressedBytes: number;
73
+ /** Which cap tripped: the absolute byte cap or the expansion-ratio cap. */
74
+ reason: "absolute" | "ratio";
75
+ }
76
+ /**
77
+ * `413 Payload Too Large` raised when an inflating request body crosses either
78
+ * the absolute (`maxDecompressedBytes`) or ratio (`maxRatio`) cap. Thrown
79
+ * *during* inflation, so the full bomb is never materialised in memory.
80
+ *
81
+ * @since 0.37.0
82
+ */
83
+ export declare class DecompressionBombError extends HttpError {
84
+ /** Structured details about the rejected bomb. */
85
+ readonly info: DecompressionBombInfo;
86
+ constructor(info: DecompressionBombInfo);
87
+ }
88
+ /**
89
+ * `415 Unsupported Media Type` raised when a request declares a
90
+ * `Content-Encoding` this guard cannot safely inflate — an unknown encoding, an
91
+ * encoding not in the configured allowlist, an encoding the runtime's
92
+ * `DecompressionStream` does not implement, or a layered encoding such as
93
+ * `gzip, gzip`. The body is refused, never inflated.
94
+ *
95
+ * @since 0.37.0
96
+ */
97
+ export declare class UnsupportedContentEncodingError extends HttpError {
98
+ constructor(encoding: string, allowed: readonly string[]);
99
+ }
100
+ /**
101
+ * `400 Bad Request` raised when the compressed request body is not valid for its
102
+ * declared `Content-Encoding` (truncated or corrupt stream). Refusing — rather
103
+ * than treating a malformed body as empty — prevents request-smuggling-style
104
+ * desync between this guard and any downstream parser.
105
+ *
106
+ * @since 0.37.0
107
+ */
108
+ export declare class MalformedCompressedBodyError extends HttpError {
109
+ constructor(encoding: RequestDecompressionEncoding);
110
+ }
111
+ /**
112
+ * Options for {@link requestDecompression} and {@link decompressRequestBody}.
113
+ *
114
+ * @since 0.37.0
115
+ */
116
+ export interface RequestDecompressionOptions {
117
+ /**
118
+ * Absolute hard cap (in bytes) on the inflated body. Required — there is no
119
+ * unlimited mode. Inflation aborts the moment output crosses this value, so a
120
+ * bomb is never fully materialised. Must be a positive integer.
121
+ *
122
+ * Set this at or below your {@link "./app.js".AppOptions.bodyLimitBytes} so the
123
+ * inflated payload still fits the body the rest of the app expects.
124
+ */
125
+ maxDecompressedBytes: number;
126
+ /**
127
+ * Cap (in bytes) on the *compressed* input accepted before inflation. The
128
+ * compressed body is read with this limit, so an oversized upload is rejected
129
+ * with `413` without inflating anything. Default `1048576` (1 MiB). Must be a
130
+ * positive integer.
131
+ */
132
+ maxCompressedBytes?: number;
133
+ /**
134
+ * Maximum allowed inflated:compressed expansion ratio. The inflated body may
135
+ * not exceed `compressedBytes * maxRatio`; crossing it aborts inflation with
136
+ * `413`. Default `100`. Must be a finite number `>= 1`.
137
+ */
138
+ maxRatio?: number;
139
+ /**
140
+ * Allowed request encodings. Defaults to `["gzip", "deflate"]`. Any encoding
141
+ * outside this set (or unsupported by the runtime) is refused with `415`.
142
+ */
143
+ encodings?: readonly RequestDecompressionEncoding[];
144
+ /**
145
+ * Optional observability callback invoked when a bomb is rejected, before the
146
+ * `413` is thrown. Receives the structured {@link DecompressionBombInfo}. Must
147
+ * not throw.
148
+ */
149
+ onBomb?: (info: DecompressionBombInfo) => void;
150
+ }
151
+ /**
152
+ * @internal Reset the cached `DecompressionStream` runtime probe. Test-only.
153
+ * @since 0.37.0
154
+ */
155
+ export declare function _resetRequestDecompressionProbeForTests(): void;
156
+ /**
157
+ * Inflate `compressed` under `encoding` while enforcing the absolute-size and
158
+ * expansion-ratio caps *during* decompression. This is the low-level guard used
159
+ * by {@link requestDecompression}; it is exported so handlers that read raw
160
+ * bodies (or custom flows) can decompress request bytes with the same
161
+ * bomb-resistant semantics.
162
+ *
163
+ * @param compressed - The compressed request bytes.
164
+ * @param encoding - The declared `Content-Encoding` (`"gzip"` or `"deflate"`).
165
+ * @param opts - Caps; only the size/ratio/`onBomb` fields are consulted here.
166
+ * @returns The inflated body as a `Uint8Array`.
167
+ * @throws {DecompressionBombError} When an inflating cap is exceeded (`413`).
168
+ * @throws {MalformedCompressedBodyError} When the input is not a valid stream (`400`).
169
+ * @throws {UnsupportedContentEncodingError} When the runtime cannot inflate the encoding (`415`).
170
+ * @since 0.37.0
171
+ */
172
+ export declare function decompressRequestBody(compressed: Uint8Array, encoding: RequestDecompressionEncoding, opts: RequestDecompressionOptions): Promise<Uint8Array>;
173
+ /**
174
+ * Opt-in middleware that decompresses inbound request bodies behind a
175
+ * decompression-bomb guard. Inflates `gzip` / `deflate` request bodies under an
176
+ * absolute size cap and an expansion-ratio cap, then hands the inflated bytes to
177
+ * the framework's normal body pipeline so schema validation and raw-body reads
178
+ * both see the decompressed payload.
179
+ *
180
+ * Register it globally so it runs before the per-request context is built:
181
+ *
182
+ * ```ts
183
+ * app.use(requestDecompression({
184
+ * maxDecompressedBytes: 1024 * 1024, // inflated body never exceeds 1 MiB
185
+ * maxCompressedBytes: 64 * 1024, // reject compressed uploads over 64 KiB
186
+ * maxRatio: 50, // and never expand more than 50x
187
+ * }));
188
+ * ```
189
+ *
190
+ * Requests without a `Content-Encoding` (or `identity`) pass through untouched.
191
+ * `GET` / `HEAD` requests are never decompressed. Unknown, unsupported, or
192
+ * layered encodings are refused with `415`; malformed streams with `400`; bombs
193
+ * with `413` (thrown mid-inflation).
194
+ *
195
+ * @param opts - Bomb-guard caps and the encoding allowlist. `maxDecompressedBytes` is required.
196
+ * @returns A {@link "./types.js".Hooks} bundle exposing only an `onRequest` hook.
197
+ * @throws {TypeError} At construction when a cap is invalid.
198
+ * @since 0.37.0
199
+ */
200
+ export declare function requestDecompression(opts: RequestDecompressionOptions): Hooks;