@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
@@ -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.
@@ -42,6 +67,30 @@
42
67
  import { markSchemaValidatedResponse } from "./internal-response.js";
43
68
  /** Internal `ctx.state` key carrying the pending cache key between hooks. */
44
69
  const PENDING_STATE_KEY = "__responseCachePending";
70
+ /**
71
+ * Marker stamped on the `Hooks` object returned by {@link responseCache}, so the
72
+ * `App` boot guard can detect a cache mounted *ahead of* `tenancy()` — an order
73
+ * in which the tenant is not yet in `ctx.state` when the cache key is built, and
74
+ * automatic tenant partitioning therefore cannot protect the entry.
75
+ *
76
+ * @since 1.0.0
77
+ */
78
+ export const RESPONSE_CACHE_HOOK_MARKER = Symbol.for("daloyjs.response-cache.hook");
79
+ /**
80
+ * `ctx.state` symbol under which `tenancy()` records the tenant it resolved for
81
+ * the request, or its `TENANT_UNRESOLVED` sentinel when it ran and resolved
82
+ * nothing. Either way the value is a string, so it partitions the cache key —
83
+ * which keeps tenant-less traffic out of the resolved tenants' entries without
84
+ * this module needing to know the sentinel's value.
85
+ *
86
+ * Re-derived from the global symbol registry rather than imported from
87
+ * `tenancy.js` so that using `responseCache()` never pulls the tenancy module
88
+ * into the bundle — the same technique `app.ts` uses for the MCP route marker.
89
+ * Must match the string in `tenancy.ts`.
90
+ *
91
+ * @internal
92
+ */
93
+ const TENANCY_RESOLVED_MARKER = Symbol.for("daloyjs.tenancy.resolved");
45
94
  /**
46
95
  * Process-wide registry of in-memory stores shared by
47
96
  * {@link ResponseCacheOptions.groupId}.
@@ -58,21 +107,49 @@ const SHARED_RESPONSE_CACHE_STORES = new Map();
58
107
  export function _resetSharedResponseCacheStoresForTests() {
59
108
  SHARED_RESPONSE_CACHE_STORES.clear();
60
109
  }
61
- // ---------- Default store ----------
62
110
  /**
63
111
  * In-memory {@link ResponseCacheStore}. Suitable for tests and single-process
64
- * deployments. Expired entries are dropped on access; the map is
65
- * opportunistically pruned so it cannot grow without bound.
112
+ * deployments.
113
+ *
114
+ * Expired entries are dropped on access. The map is bounded on **both** entry
115
+ * count and retained body bytes ({@link MemoryResponseCacheStoreOptions}):
116
+ * pruning expired entries alone cannot bound it, because every entry in a burst
117
+ * of requests for distinct URLs is unexpired for the whole TTL. An attacker
118
+ * rotating a query string would otherwise grow the map without limit until the
119
+ * process runs out of memory.
120
+ *
121
+ * Eviction is FIFO over insertion order (expired entries first), which `Map`
122
+ * gives in O(1) per eviction.
66
123
  */
67
124
  export class MemoryResponseCacheStore {
68
125
  map = new Map();
126
+ maxEntries;
127
+ maxBytes;
128
+ /** Running sum of `entry.body.length` over `map`, kept in step with writes. */
129
+ bytes = 0;
130
+ /**
131
+ * @param opts - Capacity limits; see {@link MemoryResponseCacheStoreOptions}.
132
+ * @throws TypeError if either limit is not a positive integer.
133
+ */
134
+ constructor(opts = {}) {
135
+ const maxEntries = opts.maxEntries ?? 10_000;
136
+ const maxBytes = opts.maxBytes ?? 64 * 1024 * 1024;
137
+ if (!Number.isInteger(maxEntries) || maxEntries <= 0) {
138
+ throw new TypeError("MemoryResponseCacheStore: maxEntries must be a positive integer.");
139
+ }
140
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) {
141
+ throw new TypeError("MemoryResponseCacheStore: maxBytes must be a positive integer.");
142
+ }
143
+ this.maxEntries = maxEntries;
144
+ this.maxBytes = maxBytes;
145
+ }
69
146
  /** @inheritDoc */
70
147
  get(key) {
71
148
  const entry = this.map.get(key);
72
149
  if (!entry)
73
150
  return null;
74
151
  if (entry.staleUntil <= Date.now()) {
75
- this.map.delete(key);
152
+ this.drop(key, entry);
76
153
  return null;
77
154
  }
78
155
  return entry;
@@ -83,24 +160,47 @@ export class MemoryResponseCacheStore {
83
160
  * here: the in-memory store derives freshness from `entry.freshUntil`.
84
161
  */
85
162
  set(key, entry, _ttlMs) {
163
+ const existing = this.map.get(key);
164
+ if (existing)
165
+ this.bytes -= existing.body.length;
86
166
  this.map.set(key, entry);
87
- if (this.map.size > 10_000)
88
- this.prune();
167
+ this.bytes += entry.body.length;
168
+ if (this.map.size > this.maxEntries || this.bytes > this.maxBytes)
169
+ this.evict();
89
170
  }
90
171
  /** @inheritDoc */
91
172
  delete(key) {
173
+ const entry = this.map.get(key);
174
+ if (entry)
175
+ this.drop(key, entry);
176
+ }
177
+ /** Remove one entry, keeping the byte counter in step. */
178
+ drop(key, entry) {
92
179
  this.map.delete(key);
180
+ this.bytes -= entry.body.length;
93
181
  }
94
- prune() {
182
+ /**
183
+ * Bring the map back under both limits: expired entries first, then
184
+ * oldest-inserted, since `Map` iterates in insertion order.
185
+ */
186
+ evict() {
95
187
  const now = Date.now();
96
188
  for (const [k, v] of this.map) {
189
+ if (this.map.size <= this.maxEntries && this.bytes <= this.maxBytes)
190
+ return;
97
191
  if (v.staleUntil <= now)
98
- this.map.delete(k);
192
+ this.drop(k, v);
193
+ }
194
+ for (const [k, v] of this.map) {
195
+ if (this.map.size <= this.maxEntries && this.bytes <= this.maxBytes)
196
+ return;
197
+ this.drop(k, v);
99
198
  }
100
199
  }
101
200
  /** Test helper. Remove every entry. */
102
201
  clear() {
103
202
  this.map.clear();
203
+ this.bytes = 0;
104
204
  }
105
205
  /** Test helper. Number of stored entries (including expired). */
106
206
  size() {
@@ -167,14 +267,123 @@ function freshnessFromResponse(res) {
167
267
  // No explicit directive: fall back to the configured ttl (undefined marker).
168
268
  return undefined;
169
269
  }
270
+ /**
271
+ * Build the default cache-key body: the method plus the **effective request
272
+ * URI** (scheme + authority + path + query), plus any {@link
273
+ * ResponseCacheOptions.varyHeaders} values.
274
+ *
275
+ * Including the authority is what keeps one process serving several hostnames
276
+ * from sharing entries across them (RFC 9111 §4 keys a cache on the target URI,
277
+ * which includes the authority). `Request.url` is already an absolute,
278
+ * normalized serialization (host lower-cased, default port elided), so it is
279
+ * used directly — no `URL` object is allocated on this hot path. Only the
280
+ * fragment, which is meaningless to a cache and never sent by HTTP clients, is
281
+ * trimmed.
282
+ */
170
283
  function defaultKey(ctx, varyHeaders) {
171
- const url = new URL(ctx.request.url);
172
- let key = `${ctx.request.method} ${url.pathname}${url.search}`;
284
+ const url = ctx.request.url;
285
+ const hash = url.indexOf("#");
286
+ let key = `${ctx.request.method} ${hash === -1 ? url : url.slice(0, hash)}`;
173
287
  for (const name of varyHeaders) {
174
288
  key += `\n${name}: ${ctx.request.headers.get(name) ?? ""}`;
175
289
  }
176
290
  return key;
177
291
  }
292
+ /**
293
+ * Append a length-prefixed `name=value` component to a cache-key partition.
294
+ *
295
+ * The length prefix makes the component unambiguous, so a principal id
296
+ * containing the delimiter (or a whole forged key fragment) cannot be crafted to
297
+ * collide with a different partition — cache-key injection.
298
+ */
299
+ function appendPartition(partition, name, value) {
300
+ return `${partition}${name}=${value.length}:${value}\n`;
301
+ }
302
+ /**
303
+ * Response headers that must never be persisted in a cache entry, because they
304
+ * describe *this hop* or *this request* rather than the stored representation.
305
+ *
306
+ * - The RFC 9111 §3.1 / RFC 9110 §7.6.1 hop-by-hop set. Replaying a stored
307
+ * `Transfer-Encoding: chunked` onto a fixed-length cached body, or a stored
308
+ * `Connection` token, corrupts message framing for every later caller.
309
+ * - `Age`, which is recomputed from `storedAt` on every serve.
310
+ * - `X-Request-Id`, the default correlation id written by `requestId()`. It
311
+ * identifies the *one* request that populated the entry; replaying it makes
312
+ * every subsequent caller report a trace id belonging to someone else's
313
+ * request, and tells an attacker whether their own seed is still being
314
+ * served (a cache-state oracle).
315
+ *
316
+ * Extend for a custom correlation header via
317
+ * {@link ResponseCacheOptions.excludeHeaders}.
318
+ */
319
+ const NEVER_CACHED_HEADERS = new Set([
320
+ "age",
321
+ "connection",
322
+ "keep-alive",
323
+ "proxy-authenticate",
324
+ "proxy-authorization",
325
+ "te",
326
+ "trailer",
327
+ "transfer-encoding",
328
+ "upgrade",
329
+ "x-request-id",
330
+ ]);
331
+ /**
332
+ * Split a response's `Vary` header into normalized field names.
333
+ *
334
+ * @param raw - Raw `Vary` header value, or `null` when absent.
335
+ * @returns `"*"` when the response is declared unreusable by any secondary key,
336
+ * a sorted, de-duplicated, lower-cased field list otherwise (empty when the
337
+ * header is absent or lists nothing usable). Sorting makes the derived key
338
+ * independent of the order the emitting middleware happened to append in.
339
+ */
340
+ function parseVary(raw) {
341
+ if (!raw)
342
+ return [];
343
+ const fields = new Set();
344
+ for (const part of raw.split(",")) {
345
+ const name = part.trim().toLowerCase();
346
+ if (!name)
347
+ continue;
348
+ if (name === "*")
349
+ return "*";
350
+ fields.add(name);
351
+ }
352
+ return [...fields].sort();
353
+ }
354
+ /**
355
+ * Derive the secondary cache key for a request: the values it carries for each
356
+ * field the stored response varies on.
357
+ *
358
+ * Uses the same length-prefixed encoding as {@link appendPartition}, so a header
359
+ * value containing the delimiter cannot be crafted to collide with a different
360
+ * variant.
361
+ *
362
+ * @param headers - The request's headers.
363
+ * @param fields - Lower-cased field names from {@link parseVary}.
364
+ * @returns An unambiguous encoding of those fields' values.
365
+ */
366
+ function varyKeyFor(headers, fields) {
367
+ let key = "";
368
+ for (const name of fields)
369
+ key = appendPartition(key, name, headers.get(name) ?? "");
370
+ return key;
371
+ }
372
+ /**
373
+ * Store key for one variant of a primary key.
374
+ *
375
+ * Variants live under their own keys so they coexist instead of evicting each
376
+ * other. A single slot per primary key would make every alternation between
377
+ * (say) a gzip client and an identity client a miss — and hand an attacker a
378
+ * cache-defeat DoS: rotate `Origin` or `Accept-Encoding` and every request runs
379
+ * the handler.
380
+ *
381
+ * The separator is a NUL byte, which cannot appear in a header value, so a
382
+ * variant key can never collide with a primary key.
383
+ */
384
+ function variantKey(primary, varyKey) {
385
+ return `${primary}\u0000v\u0000${varyKey}`;
386
+ }
178
387
  function buildResponseFromCache(entry, outcome, statusHeaderName, isHead) {
179
388
  const headers = new Headers();
180
389
  for (const [name, value] of entry.headers)
@@ -208,10 +417,17 @@ function isPromiseLike(value) {
208
417
  *
209
418
  * Request `Cache-Control: no-store` bypasses the cache entirely; `no-cache`
210
419
  * bypasses the read but still refreshes the stored entry. Responses marked
211
- * `no-store` / `private` / `no-cache`, carrying `Set-Cookie`, failing
212
- * {@link ResponseCacheOptions.cacheableStatus}, or larger than
420
+ * `no-store` / `private` / `no-cache`, carrying `Set-Cookie` or `Vary: *`,
421
+ * failing {@link ResponseCacheOptions.cacheableStatus}, or larger than
213
422
  * {@link ResponseCacheOptions.maxBodyBytes} are never cached.
214
423
  *
424
+ * A response that declares `Vary` is stored as a **variant**: the request's
425
+ * values for those fields are recorded alongside it, and the entry is replayed
426
+ * only to a request whose values match. A mismatch is a miss, so the handler
427
+ * runs and the entry is re-stored for that variant. This applies to `Vary`
428
+ * written by any middleware in the chain — notably `cors()` (`Origin`) and
429
+ * `compression()` (`Accept-Encoding`) — with no configuration.
430
+ *
215
431
  * @example
216
432
  * ```ts
217
433
  * import { App, responseCache } from "@daloyjs/core";
@@ -250,9 +466,23 @@ export function responseCache(opts = {}) {
250
466
  throw new Error("responseCache(): maxBodyBytes must be a positive integer.");
251
467
  }
252
468
  const methods = new Set((opts.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()));
253
- const cacheAuthenticatedRequests = opts.cacheAuthenticatedRequests === true;
254
469
  const cacheableStatus = opts.cacheableStatus ?? ((status) => status === 200);
255
470
  const varyHeaders = (opts.varyHeaders ?? []).map((h) => h.toLowerCase());
471
+ const principal = opts.principal;
472
+ const excludedHeaders = opts.excludeHeaders?.length
473
+ ? new Set([...NEVER_CACHED_HEADERS, ...opts.excludeHeaders.map((h) => h.toLowerCase())])
474
+ : NEVER_CACHED_HEADERS;
475
+ // Resolve the per-header credential policy once, at construction. A credential
476
+ // header counts as "handled" when the caller declared it shareable, or when it
477
+ // is in `varyHeaders` (its value then partitions the key by itself).
478
+ const credentialOptIn = opts.cacheAuthenticatedRequests;
479
+ const optInAll = credentialOptIn === true;
480
+ const authorizationHandled = optInAll ||
481
+ (typeof credentialOptIn === "object" && credentialOptIn?.authorization === true) ||
482
+ varyHeaders.includes("authorization");
483
+ const cookieHandled = optInAll ||
484
+ (typeof credentialOptIn === "object" && credentialOptIn?.cookie === true) ||
485
+ varyHeaders.includes("cookie");
256
486
  const statusHeaderName = opts.statusHeaderName === null ? null : (opts.statusHeaderName ?? "x-cache").toLowerCase();
257
487
  const ttlMs = ttlSeconds * 1_000;
258
488
  const swrMs = swrSeconds * 1_000;
@@ -290,37 +520,88 @@ export function responseCache(opts = {}) {
290
520
  .catch(() => undefined)
291
521
  .finally(() => refreshing.delete(key));
292
522
  }
293
- return {
523
+ const hooks = {
294
524
  async beforeHandle(ctx) {
295
525
  const method = ctx.request.method.toUpperCase();
296
526
  if (!methods.has(method))
297
527
  return undefined;
298
- // RFC 9111 §3.5 / CWE-524: a shared cache keyed on method+URL must not
299
- // store or reuse a response to an Authorization-bearing request, or it
300
- // would serve one principal's private data to the next caller. Opt in
301
- // via `cacheAuthenticatedRequests` for genuinely shareable content.
302
- if (!cacheAuthenticatedRequests && ctx.request.headers.has("authorization")) {
303
- return undefined;
304
- }
305
- const reqCc = parseCacheControl(ctx.request.headers.get("cache-control"));
528
+ const headers = ctx.request.headers;
529
+ // Checked before `principal` runs so a fully bypassed request never pays
530
+ // for the caller's callback.
531
+ const reqCc = parseCacheControl(headers.get("cache-control"));
306
532
  if (reqCc.has("no-store"))
307
533
  return undefined;
534
+ // Identify the caller, if the app can. A non-empty principal is folded
535
+ // into the key below, which is what makes caching a credentialed response
536
+ // safe: the entry belongs to that principal alone. Not wrapped in a
537
+ // try/catch, matching `keyGenerator`: a throwing option is a bug in the
538
+ // app, and swallowing it would hide the misconfiguration.
539
+ const principalId = principal ? (principal(ctx) ?? null) : null;
540
+ // RFC 9111 §3.5 / CWE-524: a shared cache keyed on the request URI must not
541
+ // store or reuse a response to a credentialed request, or it would serve
542
+ // one principal's private data to the next caller. `Cookie` counts as a
543
+ // credential alongside `Authorization` — a session cookie is the most
544
+ // common way a response becomes private. An unhandled credential is only
545
+ // safe once `principal` has named the caller.
546
+ if (!principalId &&
547
+ ((!authorizationHandled && headers.has("authorization")) ||
548
+ (!cookieHandled && headers.has("cookie")))) {
549
+ return undefined;
550
+ }
308
551
  const rawKey = opts.keyGenerator ? opts.keyGenerator(ctx) : defaultKey(ctx, varyHeaders);
309
552
  if (rawKey === null)
310
553
  return undefined;
311
- const key = `${keyPrefix}${rawKey}`;
554
+ // Partition the key by everything the framework knows about the caller
555
+ // that the URI does not already express. Applied around `keyGenerator`
556
+ // output too, so a custom generator cannot widen the partition. Skipped
557
+ // entirely — no allocation — for the common unpartitioned public request.
558
+ let partition = "";
559
+ const tenant = ctx.state[TENANCY_RESOLVED_MARKER];
560
+ if (typeof tenant === "string") {
561
+ partition = appendPartition(partition, "tenant", tenant);
562
+ }
563
+ if (principalId) {
564
+ partition = appendPartition(partition, "principal", principalId);
565
+ }
566
+ const key = `${keyPrefix}${partition}${rawKey}`;
312
567
  // `no-cache` bypasses the read but still allows a fresh write below.
313
568
  const bypassRead = reqCc.has("no-cache");
314
569
  if (!bypassRead) {
315
570
  const getResult = store.get(key);
316
- const entry = isPromiseLike(getResult) ? await getResult : getResult;
571
+ let entry = isPromiseLike(getResult) ? await getResult : getResult;
572
+ let servedKey = key;
573
+ // RFC 9111 §4.1: an entry stored for a response that declared `Vary`
574
+ // is reusable only for a request whose values for those fields match
575
+ // the ones it was stored with. Without this, `cors()`'s `Vary: Origin`
576
+ // and `compression()`'s `Vary: Accept-Encoding` are silently ignored
577
+ // and one caller's variant — their `Access-Control-Allow-Origin`, their
578
+ // content-coding, their negotiated language — is served to the next.
579
+ //
580
+ // The entry at the primary key doubles as the hint that tells us *which*
581
+ // fields matter, so the common single-variant case costs one `get`. On a
582
+ // mismatch we know the field list and can look the right variant up
583
+ // directly, which is what keeps several variants of one URL alive at
584
+ // once instead of each evicting the last.
585
+ if (entry?.vary?.length) {
586
+ const wanted = varyKeyFor(headers, entry.vary);
587
+ if (entry.varyKey !== wanted) {
588
+ servedKey = variantKey(key, wanted);
589
+ const variantResult = store.get(servedKey);
590
+ entry = isPromiseLike(variantResult) ? await variantResult : variantResult;
591
+ // A stored variant records the field list it was keyed on; if that
592
+ // has since changed (the handler now varies on something else), the
593
+ // recorded key no longer means what we just computed.
594
+ if (entry && varyKeyFor(headers, entry.vary ?? []) !== entry.varyKey)
595
+ entry = null;
596
+ }
597
+ }
317
598
  if (entry) {
318
599
  const now = Date.now();
319
600
  if (now < entry.freshUntil) {
320
601
  return buildResponseFromCache(entry, "HIT", statusHeaderName, method === "HEAD");
321
602
  }
322
603
  if (revalidate && now < entry.staleUntil) {
323
- backgroundRefresh(key, ctx.request);
604
+ backgroundRefresh(servedKey, ctx.request);
324
605
  return buildResponseFromCache(entry, "STALE", statusHeaderName, method === "HEAD");
325
606
  }
326
607
  }
@@ -351,6 +632,15 @@ export function responseCache(opts = {}) {
351
632
  res.headers.set(statusHeaderName, "MISS");
352
633
  return undefined;
353
634
  }
635
+ // RFC 9111 §4.1: `Vary: *` declares the response unreusable for any other
636
+ // request, whatever its headers. There is no secondary key that can make
637
+ // it safe, so it is never stored.
638
+ const vary = parseVary(res.headers.get("vary"));
639
+ if (vary === "*") {
640
+ if (statusHeaderName)
641
+ res.headers.set(statusHeaderName, "MISS");
642
+ return undefined;
643
+ }
354
644
  const buf = new Uint8Array(await res.clone().arrayBuffer());
355
645
  if (buf.byteLength > maxBodyBytes) {
356
646
  if (statusHeaderName)
@@ -359,22 +649,37 @@ export function responseCache(opts = {}) {
359
649
  }
360
650
  const headers = [];
361
651
  res.headers.forEach((value, name) => {
362
- // `Age` is recomputed on every serve; never persist a stale one.
363
- if (name === "age")
652
+ // Hop-by-hop and per-request headers describe this exchange, not the
653
+ // stored representation; replaying them corrupts framing or leaks one
654
+ // caller's correlation id to the next.
655
+ if (excludedHeaders.has(name))
364
656
  return;
365
657
  headers.push([name, value]);
366
658
  });
367
659
  const now = Date.now();
368
660
  const freshMs = freshness ?? ttlMs;
661
+ const ttl = freshMs + swrMs;
662
+ const varyKey = vary.length ? varyKeyFor(ctx.request.headers, vary) : undefined;
369
663
  const entry = {
370
664
  status: res.status,
371
665
  headers,
372
666
  body: buf.byteLength ? bytesToBase64(buf) : "",
373
667
  storedAt: now,
374
668
  freshUntil: now + freshMs,
375
- staleUntil: now + freshMs + swrMs,
669
+ staleUntil: now + ttl,
670
+ ...(varyKey === undefined ? {} : { vary, varyKey }),
376
671
  };
377
- const setResult = store.set(pending.key, entry, freshMs + swrMs);
672
+ // A varying response is written twice: once under its own variant key, so
673
+ // it survives other variants of the same URL being cached, and once at
674
+ // the primary key, where the next lookup reads it as the hint naming the
675
+ // fields that matter. The primary copy is the most recently stored
676
+ // variant, so that one is served in a single `get`.
677
+ if (varyKey !== undefined) {
678
+ const variantResult = store.set(variantKey(pending.key, varyKey), entry, ttl);
679
+ if (isPromiseLike(variantResult))
680
+ await variantResult;
681
+ }
682
+ const setResult = store.set(pending.key, entry, ttl);
378
683
  if (isPromiseLike(setResult))
379
684
  await setResult;
380
685
  if (statusHeaderName)
@@ -382,4 +687,8 @@ export function responseCache(opts = {}) {
382
687
  return undefined;
383
688
  },
384
689
  };
690
+ // Let the App boot guard see that a response cache is in this hook chain, and
691
+ // where in the order it sits relative to `tenancy()`.
692
+ hooks[RESPONSE_CACHE_HOOK_MARKER] = true;
693
+ return hooks;
385
694
  }
@@ -213,7 +213,9 @@ export function safeRedirect(target, options = {}) {
213
213
  // target (no protocol-relative, no backslash confusion, no controls).
214
214
  // Do not widen Location emission beyond what classify() would accept.
215
215
  const fallbackResult = classify(options.fallback, ["/*"], []);
216
- if (!fallbackResult.ok || !options.fallback.startsWith("/") || options.fallback.startsWith("//")) {
216
+ if (!fallbackResult.ok ||
217
+ !options.fallback.startsWith("/") ||
218
+ options.fallback.startsWith("//")) {
217
219
  throw new TypeError(`safeRedirect: fallback must be a safe same-origin path starting with "/"; got ${options.fallback}`);
218
220
  }
219
221
  return buildResponse(fallbackResult.location, status, options.headers);
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:4d7ef2ca-7207-5a53-933a-75950561ff0c",
4
+ "serialNumber": "urn:uuid:01ee81d6-1bb6-55ca-b05e-3e30279d9f5c",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-07-20T09:04:35.490Z",
7
+ "timestamp": "2026-07-29T20:03:05.244Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.0.0-rc.5"
12
+ "version": "1.0.0-rc.7"
13
13
  }
14
14
  ],
15
15
  "authors": [
@@ -19,11 +19,11 @@
19
19
  ],
20
20
  "component": {
21
21
  "type": "library",
22
- "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.7",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.0.0-rc.5",
24
+ "version": "1.0.0-rc.7",
25
25
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
26
- "purl": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
26
+ "purl": "pkg:npm/@daloyjs/core@1.0.0-rc.7",
27
27
  "licenses": [
28
28
  {
29
29
  "license": {
@@ -46,9 +46,9 @@
46
46
  }
47
47
  ],
48
48
  "swid": {
49
- "tagId": "swidtag--daloyjs-core-1.0.0-rc.5",
49
+ "tagId": "swidtag--daloyjs-core-1.0.0-rc.7",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.0.0-rc.5",
51
+ "version": "1.0.0-rc.7",
52
52
  "tagVersion": 0,
53
53
  "patch": false
54
54
  }
@@ -57,7 +57,7 @@
57
57
  "components": [],
58
58
  "dependencies": [
59
59
  {
60
- "ref": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
60
+ "ref": "pkg:npm/@daloyjs/core@1.0.0-rc.7",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "@daloyjs/core-1.0.0-rc.5",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.5-4d7ef2ca-7207-5a53-933a-75950561ff0c",
5
+ "name": "@daloyjs/core-1.0.0-rc.7",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.7-01ee81d6-1bb6-55ca-b05e-3e30279d9f5c",
7
7
  "creationInfo": {
8
- "created": "2026-07-20T09:04:35.490Z",
8
+ "created": "2026-07-29T20:03:05.244Z",
9
9
  "creators": [
10
10
  "Tool: daloy-generate-sbom",
11
11
  "Organization: DaloyJS"
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "SPDXID": "SPDXRef-Package--daloyjs-core",
18
18
  "name": "@daloyjs/core",
19
- "versionInfo": "1.0.0-rc.5",
19
+ "versionInfo": "1.0.0-rc.7",
20
20
  "downloadLocation": "https://github.com/daloyjs/daloy",
21
21
  "filesAnalyzed": false,
22
22
  "licenseConcluded": "MIT",
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "referenceCategory": "PACKAGE-MANAGER",
29
29
  "referenceType": "purl",
30
- "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.5"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.7"
31
31
  }
32
32
  ]
33
33
  }
@@ -28,8 +28,7 @@ export function securitySchemeRequiresPayloadAuth(scheme) {
28
28
  if (!scheme || typeof scheme !== "object")
29
29
  return false;
30
30
  const record = scheme;
31
- return (record[REQUIRE_PAYLOAD_AUTH_EXTENSION] === true ||
32
- record.requirePayloadAuth === true);
31
+ return record[REQUIRE_PAYLOAD_AUTH_EXTENSION] === true || record.requirePayloadAuth === true;
33
32
  }
34
33
  /**
35
34
  * Normalize a builder output into a spec-compliant OpenAPI security scheme by
@@ -126,10 +126,7 @@ export function subdomains(hostname, opts = {}) {
126
126
  }
127
127
  return resultFor(host, base);
128
128
  }
129
- const suffixes = new Set([
130
- ...PSL_PUBLIC_SUFFIXES,
131
- ...(opts.extraSuffixes ?? []),
132
- ]);
129
+ const suffixes = new Set([...PSL_PUBLIC_SUFFIXES, ...(opts.extraSuffixes ?? [])]);
133
130
  const labels = host.split(".");
134
131
  // Walk from the longest suffix candidate down to the shortest so the
135
132
  // longest match wins (`s3.amazonaws.com` beats `com`).