@daloyjs/core 0.39.0 → 0.41.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.
package/README.md CHANGED
@@ -438,6 +438,8 @@ if (!report.ok) process.exit(1);
438
438
 
439
439
  The contract runner verifies that declared examples actually match their schemas, flags duplicate/missing operationIds, dead routes, and accidental body schemas on safe methods.
440
440
 
441
+ Gate it in CI two ways: `daloy inspect --check <entry>` exits non-zero on any error-level issue, or assert `report.ok` inside your test suite. **Every `create-daloy` template ships a contract-gate test** (`tests/contract.test.ts`, `tests/contract_test.ts` on Deno) wired into its `test` task, so scaffolded projects fail CI on a broken contract out of the box. For a localhost-only gate that runs before code leaves your machine, each template also ships an opt-in `pre-push` hook (`.githooks/pre-push`, enabled with `hooks:install` which points `core.hooksPath` at it); it runs `daloy inspect --check` on every `git push` and is bypassable with `git push --no-verify`.
442
+
441
443
  ---
442
444
 
443
445
  ## Plugin encapsulation (Fastify-style)
@@ -508,7 +510,7 @@ DaloyJS is in **public preview** (`0.x`). The public API may still change betwee
508
510
  - RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
509
511
  - AI-friendly route metadata via optional `meta: { examples, extensions, summary, description, tags }`; examples are validated against your schemas at build time, surfaced as OpenAPI `examples` + `x-daloy-*` extensions, and dumped as `routes.json` / `routes.yaml` via `daloy inspect --ai`.
510
512
  - API lifecycle and breaking-change detection: mark routes `deprecated` or give them a `sunset` date to emit RFC 8594 `Deprecation` / `Sunset` headers and an `x-sunset` OpenAPI extension, then gate CI with `diffOpenAPI()` / the `daloy diff` command, which fail on a breaking change versus the last published spec.
511
- - In-process test client (`app.request()`), contract-test runner, in-process typed client, and Hey API codegen via `pnpm gen`.
513
+ - In-process test client (`app.request()`), contract-test runner (gated in CI via `daloy inspect --check` and shipped as a default test in every `create-daloy` template), in-process typed client, and Hey API codegen via `pnpm gen`.
512
514
 
513
515
  ### Runtimes and deployment
514
516
 
package/dist/app.d.ts CHANGED
@@ -784,6 +784,14 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
784
784
  * dashboards see the misconfiguration without flooding on every retry.
785
785
  */
786
786
  private trustProxyWarned;
787
+ /**
788
+ * One-shot guard for the development-mode warning about `2xx` responses
789
+ * that declare no body schema (OWASP API3 output filtering is absent
790
+ * there). Flipped on the first {@link App.fetch} so all routes are
791
+ * registered by the time the scan runs, and so the scan never repeats on
792
+ * the hot path.
793
+ */
794
+ private responseBodySchemaAuditDone;
787
795
  /**
788
796
  * Cached merge of `options.hooks` only. Used on the cold 404/405 path
789
797
  * and as the baseline for cross-origin guard decisions when no route
@@ -1324,6 +1332,15 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1324
1332
  *
1325
1333
  * @returns Array of one {@link IntrospectedRoute} per registered route.
1326
1334
  */
1335
+ /**
1336
+ * Emit a one-time development warning when any route declares a `2xx`
1337
+ * response without a body schema, because response-field stripping
1338
+ * (OWASP API3) does not run for those responses — a handler returning
1339
+ * undeclared fields would leak them. Silent in production (operators run
1340
+ * `daloy doctor` in CI for the same finding) and when
1341
+ * `secureDefaults: false`. See {@link findRoutesMissingResponseBodySchema}.
1342
+ */
1343
+ private warnMissingResponseBodySchemas;
1327
1344
  introspect(): IntrospectedRoute[];
1328
1345
  /**
1329
1346
  * Begin graceful shutdown.
@@ -1369,6 +1386,32 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1369
1386
  * @internal
1370
1387
  */
1371
1388
  export declare function topoSortExtensions(exts: ReadonlyArray<PluginExtension>): PluginExtension[];
1389
+ /**
1390
+ * Identify registered routes whose successful (`2xx`, excluding the
1391
+ * body-less `204`/`205`) responses declare no `body` schema.
1392
+ *
1393
+ * Response-body validation — and the OWASP API3 ("Broken Object Property
1394
+ * Level Authorization") output filtering that strips fields a handler
1395
+ * returns but the contract does not declare — only runs when a response
1396
+ * `body` schema is present. A `2xx` response without one therefore ships
1397
+ * whatever the handler returns verbatim: a stray `passwordHash` or a
1398
+ * spread ORM row would leak. This helper surfaces those routes so the gap
1399
+ * is visible rather than silent.
1400
+ *
1401
+ * It powers both the `daloy doctor` `audit.response.bodySchema` finding and
1402
+ * the development-mode boot warning emitted on the first request. The result
1403
+ * is advisory — a route may legitimately return no body — so callers treat
1404
+ * it as a `warn`, never a hard error.
1405
+ *
1406
+ * @param routes - Route definitions to inspect (typically `app.routes`).
1407
+ * @returns One entry per offending route with the affected `2xx` status codes.
1408
+ * @since 0.40.0
1409
+ */
1410
+ export declare function findRoutesMissingResponseBodySchema(routes: readonly Pick<RouteDefinition<any, any, any, any>, "method" | "path" | "responses">[]): Array<{
1411
+ method: string;
1412
+ path: string;
1413
+ statuses: number[];
1414
+ }>;
1372
1415
  /**
1373
1416
  * Factory alias for `new App(options)`. Lets callers who prefer a
1374
1417
  * functional style (or who avoid `new`) write:
package/dist/app.js CHANGED
@@ -267,6 +267,14 @@ export class App {
267
267
  * dashboards see the misconfiguration without flooding on every retry.
268
268
  */
269
269
  trustProxyWarned = false;
270
+ /**
271
+ * One-shot guard for the development-mode warning about `2xx` responses
272
+ * that declare no body schema (OWASP API3 output filtering is absent
273
+ * there). Flipped on the first {@link App.fetch} so all routes are
274
+ * registered by the time the scan runs, and so the scan never repeats on
275
+ * the hot path.
276
+ */
277
+ responseBodySchemaAuditDone = false;
270
278
  /**
271
279
  * Cached merge of `options.hooks` only. Used on the cold 404/405 path
272
280
  * and as the baseline for cross-origin guard decisions when no route
@@ -1772,6 +1780,10 @@ export class App {
1772
1780
  * to RFC 9457 `application/problem+json` automatically.
1773
1781
  */
1774
1782
  fetch = async (request) => {
1783
+ if (!this.responseBodySchemaAuditDone) {
1784
+ this.responseBodySchemaAuditDone = true;
1785
+ this.warnMissingResponseBodySchemas();
1786
+ }
1775
1787
  const response = await this.dispatch(request);
1776
1788
  // In-flight responses that finish during draining advertise
1777
1789
  // `Connection: close` so HTTP/1.1 load balancers stop re-using the
@@ -1915,6 +1927,9 @@ export class App {
1915
1927
  body: undefined,
1916
1928
  state: { ...this.decorations, requestId, log },
1917
1929
  set: { headers: new Headers() },
1930
+ // Cast through `unknown`: this is a deliberately minimal bootstrap
1931
+ // context for the cold 404 path (no user state populated yet), so
1932
+ // it must compile even when a consumer augments `AppState`.
1918
1933
  };
1919
1934
  ctx.set.headers.set("x-request-id", requestId);
1920
1935
  }
@@ -1922,6 +1937,8 @@ export class App {
1922
1937
  if (method === "OPTIONS") {
1923
1938
  // Synthesize a preflight: let global hooks (e.g. CORS) intercept;
1924
1939
  // otherwise return 204 with Allow header.
1940
+ // Cast through `unknown` so the synthetic preflight context still
1941
+ // compiles when a consumer augments `AppState` (no user state here).
1925
1942
  const synthCtx = {
1926
1943
  request,
1927
1944
  params: {},
@@ -2127,6 +2144,31 @@ export class App {
2127
2144
  *
2128
2145
  * @returns Array of one {@link IntrospectedRoute} per registered route.
2129
2146
  */
2147
+ /**
2148
+ * Emit a one-time development warning when any route declares a `2xx`
2149
+ * response without a body schema, because response-field stripping
2150
+ * (OWASP API3) does not run for those responses — a handler returning
2151
+ * undeclared fields would leak them. Silent in production (operators run
2152
+ * `daloy doctor` in CI for the same finding) and when
2153
+ * `secureDefaults: false`. See {@link findRoutesMissingResponseBodySchema}.
2154
+ */
2155
+ warnMissingResponseBodySchemas() {
2156
+ if (this.isProduction())
2157
+ return;
2158
+ if (this.options.secureDefaults === false)
2159
+ return;
2160
+ const offending = findRoutesMissingResponseBodySchema(this.routes);
2161
+ if (offending.length === 0)
2162
+ return;
2163
+ this.log.warn({
2164
+ event: "security.response.bodySchemaMissing",
2165
+ count: offending.length,
2166
+ routes: offending.slice(0, 20),
2167
+ }, `${offending.length} route(s) declare a 2xx response with no body schema; ` +
2168
+ "response field-level stripping (OWASP API3) is not applied there, so a handler that " +
2169
+ "returns undeclared fields will leak them. Declare a response body schema, or ignore if " +
2170
+ "the route intentionally returns no body. Run `daloy doctor` to list them.");
2171
+ }
2130
2172
  introspect() {
2131
2173
  return this.routes.map((r) => {
2132
2174
  const route = {
@@ -2628,6 +2670,51 @@ function copyContextHeaders(ctx, res) {
2628
2670
  function hasRequestSchema(request, key) {
2629
2671
  return !!request && !!request[key];
2630
2672
  }
2673
+ /**
2674
+ * Identify registered routes whose successful (`2xx`, excluding the
2675
+ * body-less `204`/`205`) responses declare no `body` schema.
2676
+ *
2677
+ * Response-body validation — and the OWASP API3 ("Broken Object Property
2678
+ * Level Authorization") output filtering that strips fields a handler
2679
+ * returns but the contract does not declare — only runs when a response
2680
+ * `body` schema is present. A `2xx` response without one therefore ships
2681
+ * whatever the handler returns verbatim: a stray `passwordHash` or a
2682
+ * spread ORM row would leak. This helper surfaces those routes so the gap
2683
+ * is visible rather than silent.
2684
+ *
2685
+ * It powers both the `daloy doctor` `audit.response.bodySchema` finding and
2686
+ * the development-mode boot warning emitted on the first request. The result
2687
+ * is advisory — a route may legitimately return no body — so callers treat
2688
+ * it as a `warn`, never a hard error.
2689
+ *
2690
+ * @param routes - Route definitions to inspect (typically `app.routes`).
2691
+ * @returns One entry per offending route with the affected `2xx` status codes.
2692
+ * @since 0.40.0
2693
+ */
2694
+ export function findRoutesMissingResponseBodySchema(routes) {
2695
+ const offending = [];
2696
+ for (const route of routes) {
2697
+ const statuses = [];
2698
+ const responses = route.responses;
2699
+ for (const key of Object.keys(responses)) {
2700
+ const status = Number(key);
2701
+ // Only successful responses can carry an over-exposing body, and 204/205
2702
+ // are body-less by HTTP semantics so they are never flagged.
2703
+ if (!Number.isInteger(status) || status < 200 || status > 299)
2704
+ continue;
2705
+ if (status === 204 || status === 205)
2706
+ continue;
2707
+ const spec = responses[status];
2708
+ if (spec && spec.body === undefined)
2709
+ statuses.push(status);
2710
+ }
2711
+ if (statuses.length > 0) {
2712
+ statuses.sort((a, b) => a - b);
2713
+ offending.push({ method: route.method, path: route.path, statuses });
2714
+ }
2715
+ }
2716
+ return offending;
2717
+ }
2631
2718
  /**
2632
2719
  * Stable-shape per-request context. All fields are initialised in fixed
2633
2720
  * order in the constructor so every dispatched request produces an instance
@@ -2868,7 +2955,16 @@ function serializeResult(result, def, validateResponses) {
2868
2955
  if (!spec) {
2869
2956
  throw new InternalError(`Handler returned status ${result.status} which is not declared in responses for ${def.method} ${def.path}`);
2870
2957
  }
2871
- const finish = () => {
2958
+ // `outputBody` is the value actually serialized onto the wire. It defaults
2959
+ // to the raw handler return, but when response-body validation runs it is
2960
+ // replaced with the validator's parsed `value` (e.g. a Zod object with
2961
+ // unknown keys stripped). This closes the OWASP API3 "excessive data
2962
+ // exposure" hole: a handler that returns extra fields not declared in the
2963
+ // response schema must not leak them — only declared fields are emitted,
2964
+ // exactly as the OpenAPI contract and the security docs promise. Schemas
2965
+ // that opt into pass-through (e.g. Zod `.passthrough()`) keep their extra
2966
+ // keys because the validator itself returns them in `value`.
2967
+ const finish = (outputBody) => {
2872
2968
  const headers = new Headers(result.headers);
2873
2969
  const explicitCt = headers.get("content-type");
2874
2970
  const treatAsJson = !explicitCt || explicitCt.includes("application/json");
@@ -2887,31 +2983,31 @@ function serializeResult(result, def, validateResponses) {
2887
2983
  let body;
2888
2984
  let rawBody = null;
2889
2985
  let isStream = false;
2890
- if (result.body === undefined || result.body === null) {
2986
+ if (outputBody === undefined || outputBody === null) {
2891
2987
  body = null;
2892
2988
  }
2893
- else if (!treatAsJson && typeof result.body === "string") {
2894
- const bytes = TEXT_ENCODER.encode(result.body);
2989
+ else if (!treatAsJson && typeof outputBody === "string") {
2990
+ const bytes = TEXT_ENCODER.encode(outputBody);
2895
2991
  setContentLength(headers, bytes.byteLength);
2896
2992
  body = bytes;
2897
2993
  rawBody = bytes;
2898
2994
  }
2899
- else if (!treatAsJson && result.body instanceof Uint8Array) {
2900
- setContentLength(headers, result.body.byteLength);
2901
- body = result.body;
2902
- rawBody = result.body;
2995
+ else if (!treatAsJson && outputBody instanceof Uint8Array) {
2996
+ setContentLength(headers, outputBody.byteLength);
2997
+ body = outputBody;
2998
+ rawBody = outputBody;
2903
2999
  }
2904
- else if (!treatAsJson && result.body instanceof ArrayBuffer) {
2905
- setContentLength(headers, result.body.byteLength);
2906
- body = result.body;
2907
- rawBody = new Uint8Array(result.body);
3000
+ else if (!treatAsJson && outputBody instanceof ArrayBuffer) {
3001
+ setContentLength(headers, outputBody.byteLength);
3002
+ body = outputBody;
3003
+ rawBody = new Uint8Array(outputBody);
2908
3004
  }
2909
- else if (!treatAsJson && result.body instanceof ReadableStream) {
2910
- body = result.body;
3005
+ else if (!treatAsJson && outputBody instanceof ReadableStream) {
3006
+ body = outputBody;
2911
3007
  isStream = true;
2912
3008
  }
2913
3009
  else {
2914
- const bytes = TEXT_ENCODER.encode(JSON.stringify(result.body));
3010
+ const bytes = TEXT_ENCODER.encode(JSON.stringify(outputBody));
2915
3011
  setContentLength(headers, bytes.byteLength);
2916
3012
  body = bytes;
2917
3013
  rawBody = bytes;
@@ -2935,7 +3031,9 @@ function serializeResult(result, def, validateResponses) {
2935
3031
  .map((i) => i.message)
2936
3032
  .join("; ")}`);
2937
3033
  }
2938
- return finish();
3034
+ // Serialize the validated (and, for object schemas, key-stripped)
3035
+ // value so undeclared fields never reach the client.
3036
+ return finish(resolved.value);
2939
3037
  });
2940
3038
  }
2941
3039
  else {
@@ -2944,9 +3042,10 @@ function serializeResult(result, def, validateResponses) {
2944
3042
  .map((i) => i.message)
2945
3043
  .join("; ")}`);
2946
3044
  }
3045
+ return finish(r.value);
2947
3046
  }
2948
3047
  }
2949
- return finish();
3048
+ return finish(result.body);
2950
3049
  }
2951
3050
  function setContentLength(headers, byteLength) {
2952
3051
  if (!headers.has("content-length"))
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * a child process. The thin shim in `bin/daloy.mjs` wires this up to
10
10
  * `process.argv`, `process.stdout`, dynamic `import()`, and `process.exit`.
11
11
  */
12
+ import { findRoutesMissingResponseBodySchema } from "./app.js";
12
13
  import { runContractTests } from "./contract.js";
13
14
  import { diffOpenAPI } from "./openapi-diff.js";
14
15
  import { generateOpenAPI, openapiToYAML } from "./openapi.js";
@@ -726,6 +727,27 @@ async function runDoctor(opts, io) {
726
727
  "Disable or gate behind authenticated routes.",
727
728
  });
728
729
  }
730
+ // Response-body-schema coverage audit (OWASP API3 — Broken Object
731
+ // Property Level Authorization). Response-field stripping only runs when
732
+ // a 2xx response declares a body schema; a schema-less 2xx ships whatever
733
+ // the handler returns, so a stray `passwordHash` or spread ORM row would
734
+ // leak. Advisory (warn) because a route may legitimately return no body.
735
+ const routes = app.routes ?? [];
736
+ const missingBody = findRoutesMissingResponseBodySchema(routes);
737
+ if (missingBody.length > 0) {
738
+ const sample = missingBody
739
+ .slice(0, 5)
740
+ .map((r) => `${r.method} ${r.path} (${r.statuses.join("/")})`)
741
+ .join(", ");
742
+ findings.push({
743
+ level: "warn",
744
+ code: "audit.response.bodySchema",
745
+ message: `${missingBody.length} route(s) declare a 2xx response with no body schema, so ` +
746
+ `response field-level stripping (OWASP API3) is not applied: ${sample}` +
747
+ `${missingBody.length > 5 ? ", …" : ""}. Declare a response body schema so undeclared ` +
748
+ "handler fields cannot leak, or ignore if the route intentionally returns no body.",
749
+ });
750
+ }
729
751
  }
730
752
  if (opts.auditSecrets === true) {
731
753
  const env = globalThis.process?.env ?? {};
@@ -31,7 +31,7 @@
31
31
  * @module
32
32
  * @since 0.37.0
33
33
  */
34
- import type { Hooks } from "./types.js";
34
+ import type { BaseContext, Hooks } from "./types.js";
35
35
  /**
36
36
  * Test-only helper that clears the process-wide shared stores used by
37
37
  * `idempotency({ groupId })`. Not part of the documented public API.
@@ -149,6 +149,24 @@ export interface IdempotencyOptions {
149
149
  * store; supply an explicit `store` to coordinate across processes.
150
150
  */
151
151
  groupId?: string;
152
+ /**
153
+ * Namespace idempotency keys by the calling principal so one client can
154
+ * never replay another client's stored response by reusing the same key
155
+ * (CWE-524 — cross-tenant cached-response disclosure). The returned string
156
+ * is mixed into the store key, so two principals using the *same*
157
+ * `Idempotency-Key` get independent reservations.
158
+ *
159
+ * Defaults to the request's `Authorization` header value, which scopes the
160
+ * common bearer- / API-key-authenticated case (Stripe-style idempotency)
161
+ * out of the box. Override it when identity lives elsewhere, e.g.
162
+ * `scope: (ctx) => ctx.state.session?.id` for cookie-based sessions, or
163
+ * return `undefined` to opt a request out of scoping (e.g. truly public,
164
+ * unauthenticated idempotent writes). Returning a stable per-user id is
165
+ * preferable to the raw credential when tokens rotate between retries.
166
+ *
167
+ * @since 0.40.0
168
+ */
169
+ scope?: (ctx: BaseContext<any, any>) => string | undefined | Promise<string | undefined>;
152
170
  }
153
171
  /**
154
172
  * In-memory {@link IdempotencyStore}. Suitable for tests and single-process
@@ -157,10 +175,14 @@ export interface IdempotencyOptions {
157
175
  */
158
176
  export declare class MemoryIdempotencyStore implements IdempotencyStore {
159
177
  private readonly map;
178
+ /**
179
+ * @inheritDoc
180
+ * `_ttlMs` is part of the {@link IdempotencyStore} contract but unused here:
181
+ * the in-memory store derives expiry from `record.expiresAt`.
182
+ */
183
+ reserve(key: string, record: IdempotencyRecord, _ttlMs?: number): IdempotencyRecord | null;
160
184
  /** @inheritDoc */
161
- reserve(key: string, record: IdempotencyRecord): IdempotencyRecord | null;
162
- /** @inheritDoc */
163
- complete(key: string, record: IdempotencyRecord): void;
185
+ complete(key: string, record: IdempotencyRecord, _ttlMs?: number): void;
164
186
  /** @inheritDoc */
165
187
  release(key: string): void;
166
188
  private read;
@@ -61,8 +61,12 @@ export function _resetSharedIdempotencyStoresForTests() {
61
61
  */
62
62
  export class MemoryIdempotencyStore {
63
63
  map = new Map();
64
- /** @inheritDoc */
65
- reserve(key, record) {
64
+ /**
65
+ * @inheritDoc
66
+ * `_ttlMs` is part of the {@link IdempotencyStore} contract but unused here:
67
+ * the in-memory store derives expiry from `record.expiresAt`.
68
+ */
69
+ reserve(key, record, _ttlMs) {
66
70
  const existing = this.read(key);
67
71
  if (existing)
68
72
  return existing;
@@ -72,7 +76,7 @@ export class MemoryIdempotencyStore {
72
76
  return null;
73
77
  }
74
78
  /** @inheritDoc */
75
- complete(key, record) {
79
+ complete(key, record, _ttlMs) {
76
80
  this.map.set(key, record);
77
81
  }
78
82
  /** @inheritDoc */
@@ -154,7 +158,16 @@ function stableStringify(value) {
154
158
  async function computeFingerprint(method, ctx) {
155
159
  const url = new URL(ctx.request.url);
156
160
  const material = `${method}\n${url.pathname}${url.search}\n${stableStringify(ctx.body)}`;
157
- const digest = new Uint8Array(await getSubtle().digest("SHA-256", enc.encode(material)));
161
+ return sha256Hex(material);
162
+ }
163
+ /**
164
+ * SHA-256 hex of an arbitrary string. Used to fingerprint requests and to
165
+ * derive a fixed-length, delimiter-safe tag for the caller-scope namespace so
166
+ * a long or attacker-controlled `Authorization` value cannot inject into or
167
+ * bloat the store key.
168
+ */
169
+ async function sha256Hex(input) {
170
+ const digest = new Uint8Array(await getSubtle().digest("SHA-256", enc.encode(input)));
158
171
  return bytesToHex(digest);
159
172
  }
160
173
  // Printable ASCII only (no control chars / whitespace). Anchored + bounded to
@@ -271,7 +284,14 @@ export function idempotency(opts = {}) {
271
284
  const key = rawKey.trim();
272
285
  validateKey(key, headerName, maxKeyLength);
273
286
  const fingerprint = await computeFingerprint(method, ctx);
274
- const storeKey = `${keyPrefix}${key}`;
287
+ // Namespace the key by the calling principal so client B can never
288
+ // replay client A's stored response by reusing the same Idempotency-Key
289
+ // (CWE-524). Defaults to the Authorization header; `scope` overrides.
290
+ const scopeRaw = opts.scope
291
+ ? await opts.scope(ctx)
292
+ : (ctx.request.headers.get("authorization") ?? undefined);
293
+ const scopeTag = scopeRaw ? `${await sha256Hex(scopeRaw)}:` : "";
294
+ const storeKey = `${keyPrefix}${scopeTag}${key}`;
275
295
  const now = Date.now();
276
296
  const record = {
277
297
  fingerprint,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { App } from "./app.js";
2
2
  export { createApp } from "./app.js";
3
+ export { findRoutesMissingResponseBodySchema } from "./app.js";
3
4
  export { _resetPackageJsonCacheForTests } from "./app.js";
4
5
  export { _resetCrashHandlersForTests } from "./app.js";
5
6
  export { _resetInsecureDefaultsLogForTests } from "./app.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { App } from "./app.js";
2
2
  export { createApp } from "./app.js";
3
+ export { findRoutesMissingResponseBodySchema } from "./app.js";
3
4
  export { _resetPackageJsonCacheForTests } from "./app.js";
4
5
  export { _resetCrashHandlersForTests } from "./app.js";
5
6
  export { _resetInsecureDefaultsLogForTests } from "./app.js";
@@ -141,6 +141,26 @@ export interface ResponseCacheOptions {
141
141
  * store.
142
142
  */
143
143
  groupId?: string;
144
+ /**
145
+ * Whether to cache responses to requests that carry an `Authorization`
146
+ * header. Default: `false`.
147
+ *
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.
155
+ *
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.
160
+ *
161
+ * @since 0.40.0
162
+ */
163
+ cacheAuthenticatedRequests?: boolean;
144
164
  }
145
165
  /**
146
166
  * In-memory {@link ResponseCacheStore}. Suitable for tests and single-process
@@ -151,8 +171,12 @@ export declare class MemoryResponseCacheStore implements ResponseCacheStore {
151
171
  private readonly map;
152
172
  /** @inheritDoc */
153
173
  get(key: string): CachedResponse | null;
154
- /** @inheritDoc */
155
- set(key: string, entry: CachedResponse): void;
174
+ /**
175
+ * @inheritDoc
176
+ * `_ttlMs` is part of the {@link ResponseCacheStore} contract but unused
177
+ * here: the in-memory store derives freshness from `entry.freshUntil`.
178
+ */
179
+ set(key: string, entry: CachedResponse, _ttlMs?: number): void;
156
180
  /** @inheritDoc */
157
181
  delete(key: string): void;
158
182
  private prune;
@@ -76,8 +76,12 @@ export class MemoryResponseCacheStore {
76
76
  }
77
77
  return entry;
78
78
  }
79
- /** @inheritDoc */
80
- set(key, entry) {
79
+ /**
80
+ * @inheritDoc
81
+ * `_ttlMs` is part of the {@link ResponseCacheStore} contract but unused
82
+ * here: the in-memory store derives freshness from `entry.freshUntil`.
83
+ */
84
+ set(key, entry, _ttlMs) {
81
85
  this.map.set(key, entry);
82
86
  if (this.map.size > 10_000)
83
87
  this.prune();
@@ -245,6 +249,7 @@ export function responseCache(opts = {}) {
245
249
  throw new Error("responseCache(): maxBodyBytes must be a positive integer.");
246
250
  }
247
251
  const methods = new Set((opts.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()));
252
+ const cacheAuthenticatedRequests = opts.cacheAuthenticatedRequests === true;
248
253
  const cacheableStatus = opts.cacheableStatus ?? ((status) => status === 200);
249
254
  const varyHeaders = (opts.varyHeaders ?? []).map((h) => h.toLowerCase());
250
255
  const statusHeaderName = opts.statusHeaderName === null ? null : (opts.statusHeaderName ?? "x-cache").toLowerCase();
@@ -289,6 +294,13 @@ export function responseCache(opts = {}) {
289
294
  const method = ctx.request.method.toUpperCase();
290
295
  if (!methods.has(method))
291
296
  return undefined;
297
+ // RFC 9111 §3.5 / CWE-524: a shared cache keyed on method+URL must not
298
+ // store or reuse a response to an Authorization-bearing request, or it
299
+ // would serve one principal's private data to the next caller. Opt in
300
+ // via `cacheAuthenticatedRequests` for genuinely shareable content.
301
+ if (!cacheAuthenticatedRequests && ctx.request.headers.has("authorization")) {
302
+ return undefined;
303
+ }
292
304
  const reqCc = parseCacheControl(ctx.request.headers.get("cache-control"));
293
305
  if (reqCc.has("no-store"))
294
306
  return undefined;
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:deb5f2f9-6f46-5dd8-9ab5-2c301ddf6df0",
4
+ "serialNumber": "urn:uuid:b6ab6390-9d08-59eb-80b2-e39452c91f11",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-06-17T18:06:01.531Z",
7
+ "timestamp": "2026-06-18T16:40:32.542Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "0.39.0"
12
+ "version": "0.41.0"
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@0.39.0",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@0.41.0",
23
23
  "name": "@daloyjs/core",
24
- "version": "0.39.0",
24
+ "version": "0.41.0",
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@0.39.0",
26
+ "purl": "pkg:npm/@daloyjs/core@0.41.0",
27
27
  "licenses": [
28
28
  {
29
29
  "license": {
@@ -46,9 +46,9 @@
46
46
  }
47
47
  ],
48
48
  "swid": {
49
- "tagId": "swidtag--daloyjs-core-0.39.0",
49
+ "tagId": "swidtag--daloyjs-core-0.41.0",
50
50
  "name": "@daloyjs/core",
51
- "version": "0.39.0",
51
+ "version": "0.41.0",
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@0.39.0",
60
+ "ref": "pkg:npm/@daloyjs/core@0.41.0",
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-0.39.0",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.39.0-deb5f2f9-6f46-5dd8-9ab5-2c301ddf6df0",
5
+ "name": "@daloyjs/core-0.41.0",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.41.0-b6ab6390-9d08-59eb-80b2-e39452c91f11",
7
7
  "creationInfo": {
8
- "created": "2026-06-17T18:06:01.531Z",
8
+ "created": "2026-06-18T16:40:32.542Z",
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": "0.39.0",
19
+ "versionInfo": "0.41.0",
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@0.39.0"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@0.41.0"
31
31
  }
32
32
  ]
33
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -241,9 +241,11 @@
241
241
  "example": "node --import tsx examples/basic.ts",
242
242
  "bench": "node --import tsx bench/router.bench.ts",
243
243
  "test": "node --import tsx --test tests/**/*.test.ts",
244
+ "test:red-team": "node --import tsx --test tests/red-team-attacks.test.ts tests/red-team-attacks-2.test.ts tests/red-team-attacks-3.test.ts tests/red-team-attacks-4.test.ts tests/red-team-attacks-5.test.ts tests/red-team-attacks-6.test.ts tests/red-team-attacks-7.test.ts",
244
245
  "coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
245
246
  "coverage:branches": "tsc -p tsconfig.coverage.json && node --test --experimental-test-coverage --test-coverage-include='dist-coverage/src/**' --test-coverage-branches=92 dist-coverage/tests/**/*.test.js",
246
- "typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json",
247
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json && tsc -p tests/tsconfig.json --noEmit",
248
+ "typecheck:tests": "tsc -p tests/tsconfig.json --noEmit",
247
249
  "format": "prettier --write .",
248
250
  "gen:openapi": "node --import tsx scripts/dump-openapi.ts",
249
251
  "gen:client": "openapi-ts",