@daloyjs/core 1.0.0-rc.5 → 1.0.0-rc.7

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 (51) hide show
  1. package/README.md +25 -14
  2. package/dist/adapters/bun.js +1 -2
  3. package/dist/adapters/node.js +16 -30
  4. package/dist/app.d.ts +5 -1
  5. package/dist/app.js +74 -6
  6. package/dist/auto-ban.d.ts +16 -0
  7. package/dist/auto-ban.js +20 -12
  8. package/dist/bot-guard.d.ts +14 -0
  9. package/dist/bot-guard.js +12 -12
  10. package/dist/cli.js +9 -6
  11. package/dist/concurrency-limit.d.ts +14 -0
  12. package/dist/concurrency-limit.js +18 -9
  13. package/dist/config.js +1 -3
  14. package/dist/conn-info.d.ts +65 -0
  15. package/dist/conn-info.js +99 -4
  16. package/dist/errors.js +2 -5
  17. package/dist/etag.js +12 -2
  18. package/dist/geo-block.d.ts +15 -0
  19. package/dist/geo-block.js +14 -19
  20. package/dist/hashing.js +1 -1
  21. package/dist/http-signatures.js +3 -8
  22. package/dist/index.d.ts +5 -5
  23. package/dist/index.js +4 -4
  24. package/dist/ip-reputation.d.ts +14 -0
  25. package/dist/ip-reputation.js +11 -11
  26. package/dist/ip-restriction.d.ts +14 -0
  27. package/dist/ip-restriction.js +7 -18
  28. package/dist/jwt.js +12 -14
  29. package/dist/logger.js +1 -3
  30. package/dist/mcp.d.ts +305 -34
  31. package/dist/mcp.js +554 -49
  32. package/dist/middleware.d.ts +31 -1
  33. package/dist/middleware.js +21 -19
  34. package/dist/multipart.js +9 -12
  35. package/dist/openapi.d.ts +1 -1
  36. package/dist/openapi.js +2 -2
  37. package/dist/rate-limit-redis.d.ts +4 -4
  38. package/dist/response-cache.d.ts +179 -21
  39. package/dist/response-cache.js +338 -29
  40. package/dist/safe-redirect.js +3 -1
  41. package/dist/sbom.cdx.json +9 -9
  42. package/dist/sbom.spdx.json +5 -5
  43. package/dist/security-schemes.js +1 -2
  44. package/dist/subdomains.js +1 -4
  45. package/dist/tenancy.d.ts +40 -0
  46. package/dist/tenancy.js +54 -3
  47. package/dist/waf.js +40 -8
  48. package/dist/webhook-delivery.js +19 -3
  49. package/dist/websocket.d.ts +8 -0
  50. package/dist/websocket.js +19 -4
  51. package/package.json +2 -2
@@ -470,8 +470,22 @@ export interface RateLimitOptions {
470
470
  * Trust x-forwarded-for / x-real-ip when deriving the default key.
471
471
  * Off by default because those headers are client-spoofable unless your
472
472
  * reverse proxy strips and rewrites them.
473
+ *
474
+ * When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
475
+ * one your immediate proxy appended — never the attacker-influenceable
476
+ * leftmost one, so rotating spoofed left entries cannot evade the limit.
477
+ * Behind more than one proxy hop, set {@link trustedHops} instead.
473
478
  */
474
479
  trustProxyHeaders?: boolean;
480
+ /**
481
+ * Declare exactly how many proxy hops sit between Daloy and the public
482
+ * internet. Implies proxy-header trust and derives the default key that
483
+ * many entries from the right of `X-Forwarded-For` via
484
+ * {@link "./conn-info.js".resolveForwardedClientIp}. Must be an integer in
485
+ * [1, 64]; validated at construction. Ignored when a custom `keyGenerator`
486
+ * is supplied.
487
+ */
488
+ trustedHops?: number;
475
489
  /** When true, set Retry-After header on 429. Default: true. */
476
490
  retryAfter?: boolean;
477
491
  /**
@@ -546,8 +560,24 @@ export interface LoginThrottleOptions {
546
560
  keyGenerator?: (ctx: RateLimitContext) => string;
547
561
  /** Shared store for the hard limit. Uses rateLimit()'s in-memory group bucket by default. */
548
562
  store?: RateLimitStore;
549
- /** Trust x-forwarded-for / x-real-ip when deriving the default key. Default: false. */
563
+ /** Trust x-forwarded-for / x-real-ip when deriving the default key. Default: false.
564
+ *
565
+ * When enabled, the key is the **rightmost** `X-Forwarded-For` entry — the
566
+ * one your immediate proxy appended — never the attacker-influenceable
567
+ * leftmost one. Behind more than one proxy hop, set {@link trustedHops}
568
+ * instead.
569
+ */
550
570
  trustProxyHeaders?: boolean;
571
+ /**
572
+ * Declare exactly how many proxy hops sit between Daloy and the public
573
+ * internet. Implies proxy-header trust and derives the default key that
574
+ * many entries from the right of `X-Forwarded-For` via
575
+ * {@link "./conn-info.js".resolveForwardedClientIp}, so rotating spoofed
576
+ * left entries cannot evade the throttle. Must be an integer in [1, 64];
577
+ * validated at construction. Ignored when a custom `keyGenerator` is
578
+ * supplied.
579
+ */
580
+ trustedHops?: number;
551
581
  /** When true, set Retry-After header on 429. Default: true. */
552
582
  retryAfter?: boolean;
553
583
  /** Start slowing responses after this many attempts in the same window. Default: 2. */
@@ -7,6 +7,7 @@
7
7
  import { assertCookieAttributes, readRequestCookie, serializeCookie } from "./cookie.js";
8
8
  import { TooManyRequestsError, ForbiddenError } from "./errors.js";
9
9
  import { randomId, sanitizeHeaderName, timingSafeEqual } from "./security.js";
10
+ import { resolveForwardedClientIp, resolveForwardedTrust } from "./conn-info.js";
10
11
  /**
11
12
  * Generate or accept a stable `X-Request-ID` for every request. The id is
12
13
  * stamped on `ctx.state.requestId`, mirrored on outgoing response headers,
@@ -775,15 +776,8 @@ export function rateLimit(opts) {
775
776
  store = new MemoryStore();
776
777
  }
777
778
  const groupPrefix = opts.groupId ? `${opts.groupId}:` : "";
778
- const keyOf = opts.keyGenerator ??
779
- ((ctx) => {
780
- if (opts.trustProxyHeaders) {
781
- const xff = ctx.request.headers.get("x-forwarded-for");
782
- const first = xff ? xff.split(",")[0].trim() : "";
783
- return first || ctx.request.headers.get("x-real-ip") || "global";
784
- }
785
- return "global";
786
- });
779
+ const hops = resolveForwardedTrust("rateLimit()", opts);
780
+ const keyOf = opts.keyGenerator ?? defaultForwardedRateLimitKey(hops);
787
781
  const enforce = async (ctx) => {
788
782
  const key = `${groupPrefix}${keyOf(ctx)}`;
789
783
  const { count, resetMs } = await store.hit(key, opts.windowMs);
@@ -811,15 +805,22 @@ function assertPositiveInteger(name, value) {
811
805
  throw new Error(`loginThrottle(): ${name} must be a positive integer.`);
812
806
  }
813
807
  }
814
- function defaultLoginThrottleKey(trustProxyHeaders) {
815
- return (ctx) => {
816
- if (trustProxyHeaders) {
817
- const forwardedFor = ctx.request.headers.get("x-forwarded-for");
818
- const firstForwarded = forwardedFor ? forwardedFor.split(",")[0].trim() : "";
819
- return firstForwarded || ctx.request.headers.get("x-real-ip") || "global";
820
- }
821
- return "global";
822
- };
808
+ /**
809
+ * Default rate-limit / login-throttle key: the spoof-resistant forwarded client
810
+ * IP, or the shared `"global"` bucket when proxy-header trust is off or the
811
+ * request carries no trustworthy forwarded identity.
812
+ *
813
+ * @param hops - Trusted proxy hop count from
814
+ * {@link "./conn-info.js".resolveForwardedTrust}, or `undefined` when
815
+ * forwarded-header trust is disabled.
816
+ * @returns A key generator suitable for {@link rateLimit} and
817
+ * {@link loginThrottle}.
818
+ * @internal
819
+ */
820
+ function defaultForwardedRateLimitKey(hops) {
821
+ if (hops === undefined)
822
+ return () => "global";
823
+ return (ctx) => resolveForwardedClientIp(ctx.request, hops) ?? "global";
823
824
  }
824
825
  function wait(ms) {
825
826
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -852,7 +853,8 @@ export function loginThrottle(opts = {}) {
852
853
  assertNonNegativeInteger("delayMs", delayMs);
853
854
  assertNonNegativeInteger("maxDelayMs", maxDelayMs);
854
855
  const groupId = opts.groupId ?? "login";
855
- const keyGenerator = opts.keyGenerator ?? defaultLoginThrottleKey(opts.trustProxyHeaders);
856
+ const hops = resolveForwardedTrust("loginThrottle()", opts);
857
+ const keyGenerator = opts.keyGenerator ?? defaultForwardedRateLimitKey(hops);
856
858
  const limiter = rateLimit({
857
859
  windowMs,
858
860
  max,
package/dist/multipart.js CHANGED
@@ -55,9 +55,7 @@ function isBlobLike(v) {
55
55
  if (v == null || typeof v !== "object")
56
56
  return false;
57
57
  const b = v;
58
- return (typeof b.size === "number" &&
59
- typeof b.type === "string" &&
60
- typeof b.arrayBuffer === "function");
58
+ return (typeof b.size === "number" && typeof b.type === "string" && typeof b.arrayBuffer === "function");
61
59
  }
62
60
  function mimeMatches(actual, pattern) {
63
61
  const a = actual.toLowerCase();
@@ -137,11 +135,7 @@ function normalizeCustomMagicSignature(value) {
137
135
  throw new Error("fileField(): magicBytes.bytes entries must be integers in [0, 255].");
138
136
  }
139
137
  }
140
- const mimes = value.mime === undefined
141
- ? []
142
- : typeof value.mime === "string"
143
- ? [value.mime]
144
- : [...value.mime];
138
+ const mimes = value.mime === undefined ? [] : typeof value.mime === "string" ? [value.mime] : [...value.mime];
145
139
  return {
146
140
  label: value.label ?? bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(" "),
147
141
  mimes,
@@ -186,9 +180,10 @@ function asciiPrefix(bytes) {
186
180
  const byte = bytes[i];
187
181
  // Keep printable ASCII + common whitespace; replace everything else with
188
182
  // a space so keyword searches still work across NULs / UTF-16 padding.
189
- out += byte === 0x09 || byte === 0x0a || byte === 0x0d || (byte >= 0x20 && byte <= 0x7e)
190
- ? String.fromCharCode(byte)
191
- : " ";
183
+ out +=
184
+ byte === 0x09 || byte === 0x0a || byte === 0x0d || (byte >= 0x20 && byte <= 0x7e)
185
+ ? String.fromCharCode(byte)
186
+ : " ";
192
187
  }
193
188
  return out.toLowerCase();
194
189
  }
@@ -200,7 +195,9 @@ function detectScriptableImagePayload(bytes) {
200
195
  }
201
196
  // ImageMagick MVG / MSL — vector / scripting formats that can shell out
202
197
  // through the `url:`, `ephemeral:`, `msl:` coders (ImageTragick).
203
- if (prefix.includes("push graphic-context") || prefix.startsWith("<msl>") || prefix.includes("<image ")) {
198
+ if (prefix.includes("push graphic-context") ||
199
+ prefix.startsWith("<msl>") ||
200
+ prefix.includes("<image ")) {
204
201
  return "mvg-or-msl";
205
202
  }
206
203
  // SVG — XML-based and routinely carries `<script>` / external references.
package/dist/openapi.d.ts CHANGED
@@ -14,7 +14,7 @@ export { httpBearerScheme, httpBasicScheme, apiKeyScheme, oauth2Scheme, openIdCo
14
14
  export type { ApiKeyLocation, ApiKeyScheme, ApiKeySchemeOptions, HttpBasicScheme, HttpBasicSchemeOptions, HttpBearerScheme, HttpBearerSchemeOptions, OAuth2AuthorizationCodeFlow, OAuth2ClientCredentialsFlow, OAuth2Flows, OAuth2ImplicitFlow, OAuth2PasswordFlow, OAuth2Scheme, OAuth2SchemeOptions, OpenIdConnectScheme, OpenIdConnectSchemeOptions, SecurityScheme, } from "./security-schemes.js";
15
15
  export { discriminator, discriminatedUnion } from "./discriminator.js";
16
16
  export type { DiscriminatorObject, DiscriminatedUnion, DiscriminatedUnionOptions, } from "./discriminator.js";
17
- export type { CallbackDefinition, CallbackMap, CallbackOperation, } from "./types.js";
17
+ export type { CallbackDefinition, CallbackMap, CallbackOperation } from "./types.js";
18
18
  /** OpenAPI [Info Object](https://spec.openapis.org/oas/v3.1.0#info-object) header fields. */
19
19
  export interface OpenAPIInfo {
20
20
  /** Human-readable API title shown by Swagger UI / Scalar. */
package/dist/openapi.js CHANGED
@@ -102,8 +102,8 @@ function buildOperation(route, path) {
102
102
  const mergedTags = mergeTags(route.tags, meta?.tags);
103
103
  const op = {
104
104
  ...(route.operationId ? { operationId: route.operationId } : {}),
105
- ...(route.summary ?? meta?.summary ? { summary: route.summary ?? meta?.summary } : {}),
106
- ...(route.description ?? meta?.description
105
+ ...((route.summary ?? meta?.summary) ? { summary: route.summary ?? meta?.summary } : {}),
106
+ ...((route.description ?? meta?.description)
107
107
  ? { description: route.description ?? meta?.description }
108
108
  : {}),
109
109
  ...(mergedTags.length ? { tags: mergedTags } : {}),
@@ -64,10 +64,10 @@ export interface RedisRateLimitStoreOptions {
64
64
  */
65
65
  prefix?: string;
66
66
  /**
67
- * Called when the underlying Redis call throws. The default behavior is
68
- * fail-open, which allows the request and reports it as the first hit in a
69
- * fresh local window. Override to fail-closed or to wire into your
70
- * structured logger.
67
+ * Called when the underlying Redis call throws. The default behavior is
68
+ * fail-open, which allows the request and reports it as the first hit in a
69
+ * fresh local window. Override to fail-closed or to wire into your
70
+ * structured logger.
71
71
  */
72
72
  onError?: (err: unknown) => "fail-open" | "fail-closed";
73
73
  }
@@ -32,6 +32,31 @@
32
32
  * the rate-limit store, with an in-memory {@link MemoryResponseCacheStore}
33
33
  * default; supply a shared backend (e.g. Redis) for multi-instance fleets.
34
34
  *
35
+ * ## Cross-principal isolation (CWE-524)
36
+ *
37
+ * A shared response cache is only as safe as its key. Anything that varies the
38
+ * response but not the key becomes a cross-principal disclosure: the next caller
39
+ * of the same URL receives the previous caller's private body. This module is
40
+ * fail-closed on every principal dimension the framework can see:
41
+ *
42
+ * - **Authority.** The key is built from the *effective request URI* (scheme +
43
+ * authority + path + query) per RFC 9111 §4, so one process serving several
44
+ * hostnames (vanity domains, subdomain-per-customer) never shares an entry
45
+ * across them.
46
+ * - **Credentials.** Requests carrying `Authorization` **or** `Cookie` bypass
47
+ * the shared cache entirely unless the caller is identified (see
48
+ * {@link ResponseCacheOptions.principal}) or the header is explicitly declared
49
+ * shareable (see {@link ResponseCacheOptions.cacheAuthenticatedRequests}).
50
+ * - **Tenant.** When `tenancy()` has resolved a tenant for the request, that
51
+ * tenant is folded into the key automatically — no `keyGenerator` wiring
52
+ * required, and it applies to a custom `keyGenerator` too.
53
+ * - **Declared variants.** A response's own `Vary` header is honoured as a
54
+ * secondary key (RFC 9111 §4.1): an entry is replayed only to a request whose
55
+ * values for those fields match the ones it was stored with. `cors()` emits
56
+ * `Vary: Origin` and `compression()` emits `Vary: Accept-Encoding`, so
57
+ * without this one caller's `Access-Control-Allow-Origin` — or their gzipped
58
+ * body — would be served to the next. `Vary: *` is never stored.
59
+ *
35
60
  * This module is dependency-free and uses only Web Standard
36
61
  * `Request`/`Response` + `Headers`, so it runs unchanged on Node, Bun, Deno,
37
62
  * Cloudflare Workers, and Vercel.
@@ -40,6 +65,15 @@
40
65
  * @since 0.37.0
41
66
  */
42
67
  import type { BaseContext, Hooks } from "./types.js";
68
+ /**
69
+ * Marker stamped on the `Hooks` object returned by {@link responseCache}, so the
70
+ * `App` boot guard can detect a cache mounted *ahead of* `tenancy()` — an order
71
+ * in which the tenant is not yet in `ctx.state` when the cache key is built, and
72
+ * automatic tenant partitioning therefore cannot protect the entry.
73
+ *
74
+ * @since 1.0.0
75
+ */
76
+ export declare const RESPONSE_CACHE_HOOK_MARKER: unique symbol;
43
77
  /**
44
78
  * Test-only helper that clears the process-wide shared stores used by
45
79
  * `responseCache({ groupId })`. Not part of the documented public API.
@@ -64,6 +98,22 @@ export interface CachedResponse {
64
98
  freshUntil: number;
65
99
  /** End of the stale-while-revalidate window as ms since epoch. */
66
100
  staleUntil: number;
101
+ /**
102
+ * Lower-cased request-header names from the stored response's `Vary` header —
103
+ * the secondary cache key per RFC 9111 §4.1. Absent when the response
104
+ * declared no `Vary`.
105
+ *
106
+ * @since 1.0.0
107
+ */
108
+ vary?: string[];
109
+ /**
110
+ * The values {@link vary}'s fields had on the request that produced this
111
+ * entry, length-prefix encoded. A stored entry is only reusable for a request
112
+ * whose values re-encode identically.
113
+ *
114
+ * @since 1.0.0
115
+ */
116
+ varyKey?: string;
67
117
  }
68
118
  /**
69
119
  * Pluggable persistence backend for {@link responseCache}. All methods may be
@@ -118,13 +168,62 @@ export interface ResponseCacheOptions {
118
168
  * Request header names whose values partition the cache (e.g.
119
169
  * `["accept-language"]`). Their values are folded into the cache key.
120
170
  * Default: none.
171
+ *
172
+ * @remarks This is the *proactive* dimension list, applied to every request
173
+ * before the handler runs. It is independent of — and additive to — the
174
+ * `Vary` header a response declares for itself, which the cache always
175
+ * honours as a secondary key (see {@link responseCache}).
121
176
  */
122
177
  varyHeaders?: string[];
123
178
  /**
124
- * Derive the cache key from the request. Default: method + URL +
179
+ * Extra response headers to drop before an entry is stored, on top of the
180
+ * built-in hop-by-hop / per-request set (`Age`, `Connection`,
181
+ * `Transfer-Encoding`, `X-Request-Id`, …).
182
+ *
183
+ * Supply the name of a custom correlation or tracing header so it is not
184
+ * frozen into the entry and replayed to every later caller — for example
185
+ * `requestId({ header: "x-correlation-id" })` pairs with
186
+ * `excludeHeaders: ["x-correlation-id"]`.
187
+ *
188
+ * @since 1.0.0
189
+ */
190
+ excludeHeaders?: readonly string[];
191
+ /**
192
+ * Derive the cache key **body** from the request. Default: method + the
193
+ * effective request URI (scheme + authority + path + query) +
125
194
  * {@link varyHeaders} values. Return `null` to skip caching for this request.
195
+ *
196
+ * The resolved tenant and {@link principal} partition is applied *around*
197
+ * whatever this returns, so a custom generator does not have to (and should
198
+ * not bother to) fold them in itself — it cannot accidentally widen the
199
+ * partition below what the framework knows about the caller.
126
200
  */
127
201
  keyGenerator?: (ctx: BaseContext<any, any>) => string | null;
202
+ /**
203
+ * Identify the caller, so responses to credentialed requests can be cached
204
+ * *per principal* instead of bypassing the cache.
205
+ *
206
+ * Return a stable id for the calling principal (user id, tenant id, API-key
207
+ * fingerprint — never the raw credential), or `null` / `undefined` when the
208
+ * request is anonymous. The returned id is folded into the cache key.
209
+ *
210
+ * This is what makes cookie-authenticated caching safe: a session cookie
211
+ * identifies a user that the cache key would otherwise ignore, so without a
212
+ * `principal` such a request is not cached at all.
213
+ *
214
+ * ```ts
215
+ * responseCache({
216
+ * ttlSeconds: 30,
217
+ * principal: (ctx) => ctx.state.session?.get<string>("userId") ?? null,
218
+ * });
219
+ * ```
220
+ *
221
+ * @remarks Returning `null` for a request that *does* carry credentials is
222
+ * treated as "cannot identify this caller", and the request bypasses the cache
223
+ * rather than sharing an anonymous entry.
224
+ * @since 1.0.0
225
+ */
226
+ principal?: (ctx: BaseContext<any, any>) => string | null | undefined;
128
227
  /**
129
228
  * Maximum response body size (bytes) the middleware will buffer and store.
130
229
  * Larger responses pass through uncached. Default: `1048576` (1 MiB).
@@ -142,33 +241,79 @@ export interface ResponseCacheOptions {
142
241
  */
143
242
  groupId?: string;
144
243
  /**
145
- * Whether to cache responses to requests that carry an `Authorization`
146
- * header. Default: `false`.
244
+ * Whether to cache responses to requests that carry credentials — an
245
+ * `Authorization` header or a `Cookie` header. Default: `false` for both.
246
+ *
247
+ * A shared response cache keyed on the request URI does not include the
248
+ * credential, so caching a credentialed response would serve one user's
249
+ * private data to the next caller of the same URL (CWE-524 — cross-principal
250
+ * cached-response disclosure). Per RFC 9111 §3.5 a shared cache MUST NOT
251
+ * reuse a response to an `Authorization`-bearing request unless explicitly
252
+ * permitted; `Cookie` is treated the same way because a session cookie is the
253
+ * single most common way a response is made private.
147
254
  *
148
- * A shared response cache keyed on method + URL (the default) does not
149
- * include the credential, so caching an authenticated response would serve
150
- * one user's private data to the next user requesting the same URL
151
- * (CWE-524 — cross-tenant cached-response disclosure). For that reason, and
152
- * per RFC 9111 §3.5 (a shared cache MUST NOT reuse a response to an
153
- * `Authorization`-bearing request unless explicitly permitted), such
154
- * requests bypass the cache entirely by default.
255
+ * Pass a boolean to set both, or an object to control them independently —
256
+ * useful when a public endpoint receives unrelated analytics cookies but must
257
+ * never cache bearer-authenticated responses:
155
258
  *
156
- * Set this to `true` only when the response is genuinely shareable across
157
- * principals (e.g. public reference data served behind a bearer gate) — and
158
- * then also add the credential to {@link varyHeaders} or a custom
159
- * {@link keyGenerator} so distinct callers cannot collide.
259
+ * ```ts
260
+ * responseCache({ cacheAuthenticatedRequests: { cookie: true } });
261
+ * ```
160
262
  *
161
- * @since 0.40.0
263
+ * Enable a dimension only when the response is genuinely shareable across
264
+ * principals (e.g. public reference data behind a bearer gate). Otherwise
265
+ * prefer {@link principal}, which keeps caching *and* keeps callers apart.
266
+ *
267
+ * @remarks Declaring the credential header in {@link varyHeaders} also counts
268
+ * as handling it, since its value then partitions the key.
269
+ * @since 0.40.0 — extended to `Cookie` and per-header control in 1.0.0.
162
270
  */
163
- cacheAuthenticatedRequests?: boolean;
271
+ cacheAuthenticatedRequests?: boolean | {
272
+ authorization?: boolean;
273
+ cookie?: boolean;
274
+ };
275
+ }
276
+ /** Options for {@link MemoryResponseCacheStore}. */
277
+ export interface MemoryResponseCacheStoreOptions {
278
+ /**
279
+ * Hard ceiling on retained entries. Default: `10_000`. Once reached, the
280
+ * oldest-inserted entries are evicted — expired ones first.
281
+ */
282
+ maxEntries?: number;
283
+ /**
284
+ * Hard ceiling on retained body bytes. Default: `64 * 1024 * 1024` (64 MiB).
285
+ *
286
+ * An entry count alone does not bound memory: with the module's default
287
+ * `maxBodyBytes` of 1 MiB, ten thousand entries is ten gigabytes. This is the
288
+ * limit that actually caps the store's footprint.
289
+ */
290
+ maxBytes?: number;
164
291
  }
165
292
  /**
166
293
  * In-memory {@link ResponseCacheStore}. Suitable for tests and single-process
167
- * deployments. Expired entries are dropped on access; the map is
168
- * opportunistically pruned so it cannot grow without bound.
294
+ * deployments.
295
+ *
296
+ * Expired entries are dropped on access. The map is bounded on **both** entry
297
+ * count and retained body bytes ({@link MemoryResponseCacheStoreOptions}):
298
+ * pruning expired entries alone cannot bound it, because every entry in a burst
299
+ * of requests for distinct URLs is unexpired for the whole TTL. An attacker
300
+ * rotating a query string would otherwise grow the map without limit until the
301
+ * process runs out of memory.
302
+ *
303
+ * Eviction is FIFO over insertion order (expired entries first), which `Map`
304
+ * gives in O(1) per eviction.
169
305
  */
170
306
  export declare class MemoryResponseCacheStore implements ResponseCacheStore {
171
307
  private readonly map;
308
+ private readonly maxEntries;
309
+ private readonly maxBytes;
310
+ /** Running sum of `entry.body.length` over `map`, kept in step with writes. */
311
+ private bytes;
312
+ /**
313
+ * @param opts - Capacity limits; see {@link MemoryResponseCacheStoreOptions}.
314
+ * @throws TypeError if either limit is not a positive integer.
315
+ */
316
+ constructor(opts?: MemoryResponseCacheStoreOptions);
172
317
  /** @inheritDoc */
173
318
  get(key: string): CachedResponse | null;
174
319
  /**
@@ -179,7 +324,13 @@ export declare class MemoryResponseCacheStore implements ResponseCacheStore {
179
324
  set(key: string, entry: CachedResponse, _ttlMs?: number): void;
180
325
  /** @inheritDoc */
181
326
  delete(key: string): void;
182
- private prune;
327
+ /** Remove one entry, keeping the byte counter in step. */
328
+ private drop;
329
+ /**
330
+ * Bring the map back under both limits: expired entries first, then
331
+ * oldest-inserted, since `Map` iterates in insertion order.
332
+ */
333
+ private evict;
183
334
  /** Test helper. Remove every entry. */
184
335
  clear(): void;
185
336
  /** Test helper. Number of stored entries (including expired). */
@@ -201,10 +352,17 @@ export declare class MemoryResponseCacheStore implements ResponseCacheStore {
201
352
  *
202
353
  * Request `Cache-Control: no-store` bypasses the cache entirely; `no-cache`
203
354
  * bypasses the read but still refreshes the stored entry. Responses marked
204
- * `no-store` / `private` / `no-cache`, carrying `Set-Cookie`, failing
205
- * {@link ResponseCacheOptions.cacheableStatus}, or larger than
355
+ * `no-store` / `private` / `no-cache`, carrying `Set-Cookie` or `Vary: *`,
356
+ * failing {@link ResponseCacheOptions.cacheableStatus}, or larger than
206
357
  * {@link ResponseCacheOptions.maxBodyBytes} are never cached.
207
358
  *
359
+ * A response that declares `Vary` is stored as a **variant**: the request's
360
+ * values for those fields are recorded alongside it, and the entry is replayed
361
+ * only to a request whose values match. A mismatch is a miss, so the handler
362
+ * runs and the entry is re-stored for that variant. This applies to `Vary`
363
+ * written by any middleware in the chain — notably `cors()` (`Origin`) and
364
+ * `compression()` (`Accept-Encoding`) — with no configuration.
365
+ *
208
366
  * @example
209
367
  * ```ts
210
368
  * import { App, responseCache } from "@daloyjs/core";