@daloyjs/core 0.39.1 → 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 +3 -1
- package/dist/app.d.ts +43 -0
- package/dist/app.js +116 -17
- package/dist/cli.js +22 -0
- package/dist/idempotency.d.ts +26 -4
- package/dist/idempotency.js +25 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/response-cache.d.ts +26 -2
- package/dist/response-cache.js +14 -2
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +4 -2
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
|
-
|
|
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 (
|
|
2986
|
+
if (outputBody === undefined || outputBody === null) {
|
|
2891
2987
|
body = null;
|
|
2892
2988
|
}
|
|
2893
|
-
else if (!treatAsJson && typeof
|
|
2894
|
-
const bytes = TEXT_ENCODER.encode(
|
|
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 &&
|
|
2900
|
-
setContentLength(headers,
|
|
2901
|
-
body =
|
|
2902
|
-
rawBody =
|
|
2995
|
+
else if (!treatAsJson && outputBody instanceof Uint8Array) {
|
|
2996
|
+
setContentLength(headers, outputBody.byteLength);
|
|
2997
|
+
body = outputBody;
|
|
2998
|
+
rawBody = outputBody;
|
|
2903
2999
|
}
|
|
2904
|
-
else if (!treatAsJson &&
|
|
2905
|
-
setContentLength(headers,
|
|
2906
|
-
body =
|
|
2907
|
-
rawBody = new Uint8Array(
|
|
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 &&
|
|
2910
|
-
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(
|
|
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
|
-
|
|
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 ?? {};
|
package/dist/idempotency.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/idempotency.js
CHANGED
|
@@ -61,8 +61,12 @@ export function _resetSharedIdempotencyStoresForTests() {
|
|
|
61
61
|
*/
|
|
62
62
|
export class MemoryIdempotencyStore {
|
|
63
63
|
map = new Map();
|
|
64
|
-
/**
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/dist/response-cache.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
155
|
-
|
|
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;
|
package/dist/response-cache.js
CHANGED
|
@@ -76,8 +76,12 @@ export class MemoryResponseCacheStore {
|
|
|
76
76
|
}
|
|
77
77
|
return entry;
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
80
|
-
|
|
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;
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:b6ab6390-9d08-59eb-80b2-e39452c91f11",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-06-
|
|
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.
|
|
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.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@0.41.0",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "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.
|
|
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.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-0.41.0",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "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.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@0.41.0",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -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.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|