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

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.
@@ -40,8 +40,7 @@ export function ipRestriction(opts) {
40
40
  }
41
41
  const allow = (opts.allow ?? []).map(compileCidrMatcher);
42
42
  const deny = (opts.deny ?? []).map(compileCidrMatcher);
43
- const resolveIp = opts.resolveIp ??
44
- (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
43
+ const resolveIp = opts.resolveIp ?? (opts.trustProxyHeaders ? forwardedIpResolver : noIpResolver);
45
44
  const message = opts.message ?? "IP address not permitted";
46
45
  return {
47
46
  beforeHandle(ctx) {
@@ -200,11 +199,7 @@ function parseIPv6(input) {
200
199
  return undefined;
201
200
  const hi = (v4.bytes[0] << 8) | v4.bytes[1];
202
201
  const lo = (v4.bytes[2] << 8) | v4.bytes[3];
203
- working =
204
- working.slice(0, lastColon + 1) +
205
- hi.toString(16) +
206
- ":" +
207
- lo.toString(16);
202
+ working = working.slice(0, lastColon + 1) + hi.toString(16) + ":" + lo.toString(16);
208
203
  }
209
204
  const parts = working.split("::");
210
205
  if (parts.length > 2)
@@ -217,11 +212,7 @@ function parseIPv6(input) {
217
212
  if (parts.length === 1 && explicit !== 8)
218
213
  return undefined;
219
214
  const missing = parts.length === 2 ? 8 - explicit : 0;
220
- const groups = [
221
- ...headGroups,
222
- ...Array.from({ length: missing }, () => "0"),
223
- ...tailGroups,
224
- ];
215
+ const groups = [...headGroups, ...Array.from({ length: missing }, () => "0"), ...tailGroups];
225
216
  if (groups.length !== 8)
226
217
  return undefined;
227
218
  const bytes = new Uint8Array(16);
package/dist/jwt.js CHANGED
@@ -18,11 +18,7 @@
18
18
  * @since 0.21.0
19
19
  */
20
20
  import { assertTemporalClaims, TemporalClaimError } from "./time-claims.js";
21
- const SYMMETRIC = new Set([
22
- "HS256",
23
- "HS384",
24
- "HS512",
25
- ]);
21
+ const SYMMETRIC = new Set(["HS256", "HS384", "HS512"]);
26
22
  const ASYMMETRIC = new Set([
27
23
  "RS256",
28
24
  "RS384",
@@ -254,10 +250,14 @@ export function createJwtSigner(opts) {
254
250
  opts.key.byteLength < MIN_HS_KEY_BYTES) {
255
251
  throw new JwtError("weak_hs_secret", `jwt(): ${alg} secret must be at least ${MIN_HS_KEY_BYTES} bytes (RFC 7518 §3.2); got ${opts.key.byteLength}.`);
256
252
  }
257
- if (typeof opts.maxLifetimeSeconds !== "number" || !Number.isFinite(opts.maxLifetimeSeconds) || opts.maxLifetimeSeconds <= 0) {
253
+ if (typeof opts.maxLifetimeSeconds !== "number" ||
254
+ !Number.isFinite(opts.maxLifetimeSeconds) ||
255
+ opts.maxLifetimeSeconds <= 0) {
258
256
  throw new JwtError("missing_max_lifetime", "jwt(): maxLifetimeSeconds is required and must be a positive number — a token that never expires is wrong in every threat model.");
259
257
  }
260
- if (opts.acknowledgeNoExp === true && isProductionEnv(opts.env) && opts.secureDefaults !== false) {
258
+ if (opts.acknowledgeNoExp === true &&
259
+ isProductionEnv(opts.env) &&
260
+ opts.secureDefaults !== false) {
261
261
  throw new JwtError("ack_no_exp_refused_in_production", "jwt(): acknowledgeNoExp: true is refused in production under secureDefaults — every issued JWT must carry an exp claim.");
262
262
  }
263
263
  const resolved = (async () => {
@@ -359,18 +359,16 @@ export function createJwtVerifier(opts) {
359
359
  allow.add(alg);
360
360
  }
361
361
  const hasSym = [...allow].some((a) => SYMMETRIC.has(a));
362
- if (hasSym &&
363
- opts.key instanceof Uint8Array &&
364
- opts.key.byteLength < MIN_HS_KEY_BYTES) {
362
+ if (hasSym && opts.key instanceof Uint8Array && opts.key.byteLength < MIN_HS_KEY_BYTES) {
365
363
  throw new JwtError("weak_hs_secret", `jwt(): HS* secret must be at least ${MIN_HS_KEY_BYTES} bytes (RFC 7518 §3.2); got ${opts.key.byteLength}.`);
366
364
  }
367
- if (hasSym &&
368
- opts.refuseSymmetricWithJwk !== false &&
369
- looksLikeJwkSource(opts.key)) {
365
+ if (hasSym && opts.refuseSymmetricWithJwk !== false && looksLikeJwkSource(opts.key)) {
370
366
  throw new JwtError("sym_with_jwk_refused", "jwt(): symmetric algorithms (HS*) mixed with a JWK / JWKS key source are refused — this is the documented JWKS confused-deputy attack. Use asymmetric algorithms (RS/PS/ES/EdDSA), or pass refuseSymmetricWithJwk: false to override (not recommended).");
371
367
  }
372
368
  if (opts.clockSkewSeconds !== undefined) {
373
- if (typeof opts.clockSkewSeconds !== "number" || !Number.isFinite(opts.clockSkewSeconds) || opts.clockSkewSeconds < 0) {
369
+ if (typeof opts.clockSkewSeconds !== "number" ||
370
+ !Number.isFinite(opts.clockSkewSeconds) ||
371
+ opts.clockSkewSeconds < 0) {
374
372
  throw new JwtError("invalid_clock_skew", "jwt(): clockSkewSeconds must be a non-negative finite number.");
375
373
  }
376
374
  }
package/dist/logger.js CHANGED
@@ -377,9 +377,7 @@ function isSensitiveUrlQueryKey(lowerKey) {
377
377
  * @since 1.0.0
378
378
  */
379
379
  export function sanitizeUrlForLog(url) {
380
- if (url.indexOf("?") === -1 &&
381
- url.indexOf("#") === -1 &&
382
- url.indexOf("@") === -1) {
380
+ if (url.indexOf("?") === -1 && url.indexOf("#") === -1 && url.indexOf("@") === -1) {
383
381
  return url;
384
382
  }
385
383
  try {
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";