@daloyjs/core 1.0.0-rc.4 → 1.0.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -22
- package/dist/adapters/bun.d.ts +20 -2
- package/dist/adapters/bun.js +42 -7
- package/dist/adapters/deno.js +24 -7
- package/dist/adapters/lambda.d.ts +59 -2
- package/dist/adapters/lambda.js +136 -20
- package/dist/adapters/node.d.ts +8 -1
- package/dist/adapters/node.js +117 -46
- package/dist/app.d.ts +30 -4
- package/dist/app.js +187 -45
- package/dist/auto-ban.js +1 -3
- package/dist/bot-guard.js +30 -3
- package/dist/cli.js +9 -6
- package/dist/client.d.ts +36 -7
- package/dist/client.js +7 -0
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +72 -1
- package/dist/config.js +1 -3
- package/dist/conn-info.d.ts +5 -2
- package/dist/conn-info.js +5 -2
- package/dist/errors.d.ts +12 -3
- package/dist/errors.js +14 -8
- package/dist/etag.js +12 -2
- package/dist/fetch-guard.d.ts +27 -19
- package/dist/fetch-guard.js +50 -8
- package/dist/geo-block.js +4 -9
- package/dist/hashing.js +1 -1
- package/dist/http-signatures.d.ts +4 -1
- package/dist/http-signatures.js +16 -9
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/ip-reputation.js +1 -1
- package/dist/ip-restriction.js +3 -12
- package/dist/jwt.js +12 -14
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +135 -0
- package/dist/mcp.js +10 -9
- package/dist/middleware.js +33 -3
- package/dist/mtls.js +6 -1
- package/dist/multipart.js +9 -12
- package/dist/openapi.d.ts +1 -1
- package/dist/openapi.js +2 -2
- package/dist/rate-limit-redis.d.ts +4 -4
- package/dist/response-cache.d.ts +179 -21
- package/dist/response-cache.js +338 -29
- package/dist/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +5 -1
- package/dist/safe-redirect.js +27 -3
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security-schemes.js +1 -2
- package/dist/security.d.ts +41 -0
- package/dist/security.js +131 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/subdomains.js +1 -4
- package/dist/tenancy.d.ts +40 -0
- package/dist/tenancy.js +54 -3
- package/dist/time-claims.js +3 -1
- package/dist/waf.js +124 -32
- package/dist/webhook-delivery.js +19 -3
- package/dist/websocket.d.ts +8 -0
- package/dist/websocket.js +19 -4
- package/package.json +6 -5
package/dist/app.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Router } from "./router.js";
|
|
2
2
|
import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
|
|
3
3
|
import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
|
|
4
|
-
import { readBodyLimited, safeJsonParseLimited, randomId,
|
|
5
|
-
import { createLogger, noopLogger } from "./logger.js";
|
|
4
|
+
import { readBodyLimited, safeJsonParseLimited, randomId, assertInboundHeaderGuards, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
|
|
5
|
+
import { createLogger, noopLogger, sanitizeUrlForLog } from "./logger.js";
|
|
6
6
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
7
7
|
import { isSchemaValidatedResponse } from "./internal-response.js";
|
|
8
8
|
import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
@@ -127,6 +127,15 @@ const CANONICAL_HTTP_METHODS = new Set([
|
|
|
127
127
|
* `App` bundle. Must match the string used in `mcpRoutes`.
|
|
128
128
|
*/
|
|
129
129
|
const MCP_ROUTE_MARKER = Symbol.for("daloyjs.mcp.route");
|
|
130
|
+
/**
|
|
131
|
+
* Global-registry symbols stamped by `responseCache()` and `tenancy()` on the
|
|
132
|
+
* `Hooks` bundles they return. Read here — rather than imported from
|
|
133
|
+
* `response-cache.ts` / `tenancy.ts` — so neither module is pulled into the core
|
|
134
|
+
* `App` bundle (which would cost every serverless cold start). Must match the
|
|
135
|
+
* strings used in those modules.
|
|
136
|
+
*/
|
|
137
|
+
const RESPONSE_CACHE_HOOK_MARKER = Symbol.for("daloyjs.response-cache.hook");
|
|
138
|
+
const TENANCY_HOOK_MARKER = Symbol.for("daloyjs.tenancy.hook");
|
|
130
139
|
/**
|
|
131
140
|
* Apply a topology-aware security preset on top of caller-supplied
|
|
132
141
|
* options. Returns a new options object where preset defaults fill in
|
|
@@ -196,6 +205,23 @@ export const DALOY_RAW_BODY = Symbol.for("daloyjs.response.rawBody");
|
|
|
196
205
|
* so first-party adapters can opt in; not part of the userland API surface.
|
|
197
206
|
*/
|
|
198
207
|
export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
|
|
208
|
+
/**
|
|
209
|
+
* Internal Symbol an adapter sets (on its request shim) to expose the request's
|
|
210
|
+
* abort hook: a `(reason: unknown) => void` that aborts the `AbortController`
|
|
211
|
+
* backing `request.signal`. The core invokes it when a request exceeds
|
|
212
|
+
* {@link AppOptions.requestTimeoutMs} so a handler that forwarded
|
|
213
|
+
* `ctx.request.signal` to downstream I/O (`fetch`, a DB driver) sees those
|
|
214
|
+
* calls cancel — cooperative teardown, since single-threaded JS cannot preempt
|
|
215
|
+
* a running handler.
|
|
216
|
+
*
|
|
217
|
+
* The hook is invoked as a method on the request (`this` stays bound to the
|
|
218
|
+
* shim) so it can reach the shim's private controller. Absent on runtimes
|
|
219
|
+
* whose `Request.signal` is managed by the platform (Bun / Deno / Workers) and
|
|
220
|
+
* on direct `app.fetch()` callers, where {@link abortRequest} is a safe no-op
|
|
221
|
+
* and the timeout still resolves as a `408`. Module-public so first-party
|
|
222
|
+
* adapters can opt in; not part of the userland API surface.
|
|
223
|
+
*/
|
|
224
|
+
export const DALOY_REQUEST_ABORT = Symbol.for("daloyjs.request.abort");
|
|
199
225
|
/**
|
|
200
226
|
* Internal Symbol set by handlers/serializers to attach a raw stream
|
|
201
227
|
* (Node `Readable` or Web `ReadableStream`) to a `Response`. The Node
|
|
@@ -932,7 +958,11 @@ export class App {
|
|
|
932
958
|
* auth hook unless it opted out with `mcpRoutes(path, handler, { public: true })`.
|
|
933
959
|
* MCP tools are model-controlled and side-effecting, so a public one is a
|
|
934
960
|
* high-impact default.
|
|
935
|
-
* 3. **
|
|
961
|
+
* 3. **Cache ahead of tenancy** — a `responseCache()` that runs before
|
|
962
|
+
* `tenancy()` builds its key before the tenant exists in `ctx.state`, so
|
|
963
|
+
* every tenant collides on one entry and one tenant's response is served to
|
|
964
|
+
* the next caller (CWE-524).
|
|
965
|
+
* 4. **Missing CSRF** — when `session()` is installed and any route accepts a
|
|
936
966
|
* state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`), a `csrf()` hook
|
|
937
967
|
* (or third-party equivalent stamped with {@link CSRF_HOOK_MARKER}) must
|
|
938
968
|
* also be present. Skipped when `app({ csrf: "off" })`.
|
|
@@ -981,7 +1011,24 @@ export class App {
|
|
|
981
1011
|
this.bootGuard.error = err;
|
|
982
1012
|
throw err;
|
|
983
1013
|
}
|
|
984
|
-
// Guard 3:
|
|
1014
|
+
// Guard 3: responseCache() mounted ahead of tenancy(). The cache partitions
|
|
1015
|
+
// on the tenant automatically, but only if the tenant is already in
|
|
1016
|
+
// ctx.state when the key is built. Mounted first, it would key every
|
|
1017
|
+
// tenant's response identically and serve one tenant's private body to the
|
|
1018
|
+
// next caller (CWE-524) — silently, with a normal-looking cache HIT.
|
|
1019
|
+
const cacheBeforeTenancy = this.routeSecurityMarkers.find((r) => r.cacheBeforeTenancy);
|
|
1020
|
+
if (cacheBeforeTenancy) {
|
|
1021
|
+
const err = new Error(`Route ${cacheBeforeTenancy.method} ${cacheBeforeTenancy.path} runs responseCache() ` +
|
|
1022
|
+
`before tenancy() in its effective hook chain. The cache key is built before the tenant ` +
|
|
1023
|
+
`is resolved, so every tenant would share one cache entry and one tenant's response ` +
|
|
1024
|
+
`would be served to the next caller (CWE-524 cross-tenant cached-response disclosure). ` +
|
|
1025
|
+
`Register tenancy() first — as a global hook (new App({ hooks: tenancy(...) })) or an ` +
|
|
1026
|
+
`earlier app.use(...) — so the tenant is in ctx.state before the cache reads it. ` +
|
|
1027
|
+
`See https://daloyjs.dev/docs/security/boot-guards.`);
|
|
1028
|
+
this.bootGuard.error = err;
|
|
1029
|
+
throw err;
|
|
1030
|
+
}
|
|
1031
|
+
// Guard 4: session() + state-changing route without csrf().
|
|
985
1032
|
if (this.options.csrf === "off")
|
|
986
1033
|
return;
|
|
987
1034
|
const stateChanging = this.routeSecurityMarkers.find((r) => isStateChangingMethod(r.method) && r.hasSession && !r.hasCsrf);
|
|
@@ -1051,13 +1098,21 @@ export class App {
|
|
|
1051
1098
|
this.trustProxyWarned = true;
|
|
1052
1099
|
this.log.warn({ event: "trust-proxy.unconfigured", header: found }, `Request carried ${found} but app({ trustProxy }) is unset; refusing to honour spoofable proxy headers.`);
|
|
1053
1100
|
}
|
|
1054
|
-
|
|
1101
|
+
const refusal = new InternalError(`Refusing to dispatch request: ${found} header is present but app({ trustProxy }) is unconfigured. ` +
|
|
1055
1102
|
`Honouring a spoofable forwarded header would let a client forge its source IP for the rate ` +
|
|
1056
1103
|
`limiter, audit log, and request-id propagation. ` +
|
|
1057
1104
|
`Pass app({ trustProxy: true }) when running behind a trusted reverse proxy, ` +
|
|
1058
1105
|
`or app({ trustProxy: false }) to ignore forwarded headers, ` +
|
|
1059
1106
|
`or app({ secureDefaults: false }) to disable this guard. ` +
|
|
1060
1107
|
`See https://daloyjs.dev/docs/security/boot-guards.`);
|
|
1108
|
+
// Every refused request throws from this one line, so the stack is
|
|
1109
|
+
// identical each time and names framework internals rather than anything
|
|
1110
|
+
// an operator can act on. Keep the 500 and keep a line per request — the
|
|
1111
|
+
// refusal must stay visible — but drop the stack, so a client cannot
|
|
1112
|
+
// multiply the bytes it pushes into the error tier by replaying the header.
|
|
1113
|
+
// The actionable message is logged once per process by the warn above.
|
|
1114
|
+
refusal[OMIT_STACK_IN_LOG] = true;
|
|
1115
|
+
throw refusal;
|
|
1061
1116
|
}
|
|
1062
1117
|
/**
|
|
1063
1118
|
* Resolve the {@link AppOptions.docs} option and, when enabled, register
|
|
@@ -1701,6 +1756,7 @@ export class App {
|
|
|
1701
1756
|
}));
|
|
1702
1757
|
this._coldPathHooksCache = undefined;
|
|
1703
1758
|
const buckets = rateLimitConfig ? new Map() : null;
|
|
1759
|
+
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1704
1760
|
this.route({
|
|
1705
1761
|
method: "GET",
|
|
1706
1762
|
path,
|
|
@@ -1711,7 +1767,7 @@ export class App {
|
|
|
1711
1767
|
acknowledgeNoResponseBodySchema: true,
|
|
1712
1768
|
handler: async ({ request }) => {
|
|
1713
1769
|
if (buckets && rateLimitConfig) {
|
|
1714
|
-
const key = healthRouteKey(request);
|
|
1770
|
+
const key = healthRouteKey(request, trustProxyHeaders);
|
|
1715
1771
|
const now = Date.now();
|
|
1716
1772
|
const entry = buckets.get(key);
|
|
1717
1773
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -1821,6 +1877,7 @@ export class App {
|
|
|
1821
1877
|
`to acknowledge that this probe is reachable without credentials.`);
|
|
1822
1878
|
}
|
|
1823
1879
|
const buckets = rateLimitConfig ? new Map() : null;
|
|
1880
|
+
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1824
1881
|
this.route({
|
|
1825
1882
|
method: "GET",
|
|
1826
1883
|
path,
|
|
@@ -1831,7 +1888,7 @@ export class App {
|
|
|
1831
1888
|
acknowledgeNoResponseBodySchema: true,
|
|
1832
1889
|
handler: async ({ request }) => {
|
|
1833
1890
|
if (buckets && rateLimitConfig) {
|
|
1834
|
-
const key = healthRouteKey(request);
|
|
1891
|
+
const key = healthRouteKey(request, trustProxyHeaders);
|
|
1835
1892
|
const now = Date.now();
|
|
1836
1893
|
const entry = buckets.get(key);
|
|
1837
1894
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -1895,6 +1952,7 @@ export class App {
|
|
|
1895
1952
|
}
|
|
1896
1953
|
const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1897
1954
|
const buckets = rateLimitConfig ? new Map() : null;
|
|
1955
|
+
const trustProxyHeaders = appTrustsProxyHeaders(this.options);
|
|
1898
1956
|
const log = this.log;
|
|
1899
1957
|
// Only log report bodies when explicitly enabled. In
|
|
1900
1958
|
// production this is opt-in; in development the body is included by
|
|
@@ -1908,7 +1966,7 @@ export class App {
|
|
|
1908
1966
|
summary: "CSP / Reporting API violation receiver",
|
|
1909
1967
|
handler: async ({ request }) => {
|
|
1910
1968
|
if (buckets && rateLimitConfig) {
|
|
1911
|
-
const key = healthRouteKey(request);
|
|
1969
|
+
const key = healthRouteKey(request, trustProxyHeaders);
|
|
1912
1970
|
const now = Date.now();
|
|
1913
1971
|
const entry = buckets.get(key);
|
|
1914
1972
|
if (!entry || entry.resetMs <= now) {
|
|
@@ -1944,7 +2002,7 @@ export class App {
|
|
|
1944
2002
|
if (parsed === undefined) {
|
|
1945
2003
|
throw new BadRequestError("Invalid JSON report body");
|
|
1946
2004
|
}
|
|
1947
|
-
const ip = healthRouteKey(request);
|
|
2005
|
+
const ip = healthRouteKey(request, trustProxyHeaders);
|
|
1948
2006
|
const userAgent = request.headers.get("user-agent");
|
|
1949
2007
|
try {
|
|
1950
2008
|
if (opts.onReport) {
|
|
@@ -2385,7 +2443,11 @@ export class App {
|
|
|
2385
2443
|
: baseLog.child({
|
|
2386
2444
|
requestId,
|
|
2387
2445
|
method: request.method,
|
|
2388
|
-
|
|
2446
|
+
// Never bind the raw request URL: query strings commonly carry
|
|
2447
|
+
// OAuth codes, API keys, and signed-URL tokens. sanitizeUrlForLog
|
|
2448
|
+
// keeps origin+path and redacts sensitive query values so 4xx/5xx
|
|
2449
|
+
// lines cannot become a credential sink under the field name `url`.
|
|
2450
|
+
url: sanitizeUrlForLog(request.url),
|
|
2389
2451
|
});
|
|
2390
2452
|
const stripFingerprint = this.options.stripServerHeaders !== false;
|
|
2391
2453
|
let ctx;
|
|
@@ -2394,9 +2456,10 @@ export class App {
|
|
|
2394
2456
|
let activeResponseHook = globalHooks.onResponse;
|
|
2395
2457
|
let activeSendHook = globalHooks.onSend;
|
|
2396
2458
|
try {
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2459
|
+
// Singleton-duplicate + reserved-prefix + header-count cap share ONE
|
|
2460
|
+
// Headers.forEach walk (assertInboundHeaderGuards) instead of a
|
|
2461
|
+
// three-Headers.get() pass plus a separate walk.
|
|
2462
|
+
assertInboundHeaderGuards(request.headers, this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT);
|
|
2400
2463
|
this.assertTrustProxyConfigured(request);
|
|
2401
2464
|
this.assertBootGuards();
|
|
2402
2465
|
if (globalHooks.onRequest !== undefined) {
|
|
@@ -2862,8 +2925,8 @@ export class App {
|
|
|
2862
2925
|
* "draining" signal); then the app waits up to `timeoutMs` for in-flight
|
|
2863
2926
|
* requests to settle; finally, {@link App.onClose} cleanups run.
|
|
2864
2927
|
*
|
|
2865
|
-
*
|
|
2866
|
-
* Call it manually from custom runtimes or integration tests.
|
|
2928
|
+
* The Node, Bun, and Deno adapters call this automatically on `SIGINT` /
|
|
2929
|
+
* `SIGTERM`. Call it manually from custom runtimes or integration tests.
|
|
2867
2930
|
*
|
|
2868
2931
|
* @param timeoutMs - Maximum time (ms) to wait for inflight requests. Default: `10_000`.
|
|
2869
2932
|
* @param reason - Optional human-readable reason forwarded to listeners.
|
|
@@ -2954,14 +3017,30 @@ function joinPath(a, b) {
|
|
|
2954
3017
|
const joined = `${left}${right}`;
|
|
2955
3018
|
return joined === "" ? "/" : joined;
|
|
2956
3019
|
}
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
3020
|
+
/**
|
|
3021
|
+
* Rate-limit / attribution key for built-in observability routes
|
|
3022
|
+
* (`/healthz`, `/readyz`, `/metrics`, CSP report).
|
|
3023
|
+
*
|
|
3024
|
+
* Secure default: a single shared `"global"` bucket. Spoofable platform
|
|
3025
|
+
* headers (`X-Real-IP`, `Fly-Client-IP`) are only read when the app has an
|
|
3026
|
+
* explicit trusted-proxy posture (`trustProxy: true` or `behindProxy` set).
|
|
3027
|
+
* `X-Forwarded-For` is never used here — probes and scrapers often hit the
|
|
3028
|
+
* process directly, and a free-form XFF chain would let an attacker rotate
|
|
3029
|
+
* identities to bypass the cap.
|
|
3030
|
+
*
|
|
3031
|
+
* @param request - Inbound request.
|
|
3032
|
+
* @param trustProxyHeaders - When true, platform client-IP headers may be used.
|
|
3033
|
+
* @returns A stable string key for the in-memory rate-limit map.
|
|
3034
|
+
*/
|
|
3035
|
+
function healthRouteKey(request, trustProxyHeaders) {
|
|
3036
|
+
if (!trustProxyHeaders)
|
|
3037
|
+
return "global";
|
|
2963
3038
|
return request.headers.get("x-real-ip") ?? request.headers.get("fly-client-ip") ?? "global";
|
|
2964
3039
|
}
|
|
3040
|
+
/** True when the app declared a trusted reverse-proxy posture. */
|
|
3041
|
+
function appTrustsProxyHeaders(options) {
|
|
3042
|
+
return options.trustProxy === true || options.behindProxy !== undefined;
|
|
3043
|
+
}
|
|
2965
3044
|
function corsOriginAllowsFromHooks(layers) {
|
|
2966
3045
|
const allows = [];
|
|
2967
3046
|
for (const hooks of layers) {
|
|
@@ -3102,16 +3181,29 @@ function securityMarkersFromHooks(layers) {
|
|
|
3102
3181
|
let hasSession = false;
|
|
3103
3182
|
let hasCsrf = false;
|
|
3104
3183
|
let hasAuth = false;
|
|
3105
|
-
|
|
3106
|
-
|
|
3184
|
+
// `layers` is in execution order, so the first index of each marker is enough
|
|
3185
|
+
// to tell whether the cache reads state before tenancy has written it.
|
|
3186
|
+
let cacheIndex = -1;
|
|
3187
|
+
let tenancyIndex = -1;
|
|
3188
|
+
for (let i = 0; i < layers.length; i++) {
|
|
3189
|
+
const record = layers[i];
|
|
3107
3190
|
if (record[SESSION_HOOK_MARKER] === true)
|
|
3108
3191
|
hasSession = true;
|
|
3109
3192
|
if (record[CSRF_HOOK_MARKER] === true)
|
|
3110
3193
|
hasCsrf = true;
|
|
3111
3194
|
if (record[AUTH_HOOK_MARKER] === true)
|
|
3112
3195
|
hasAuth = true;
|
|
3196
|
+
if (cacheIndex === -1 && record[RESPONSE_CACHE_HOOK_MARKER] === true)
|
|
3197
|
+
cacheIndex = i;
|
|
3198
|
+
if (tenancyIndex === -1 && record[TENANCY_HOOK_MARKER] === true)
|
|
3199
|
+
tenancyIndex = i;
|
|
3113
3200
|
}
|
|
3114
|
-
return {
|
|
3201
|
+
return {
|
|
3202
|
+
hasSession,
|
|
3203
|
+
hasCsrf,
|
|
3204
|
+
hasAuth,
|
|
3205
|
+
cacheBeforeTenancy: cacheIndex !== -1 && tenancyIndex !== -1 && cacheIndex < tenancyIndex,
|
|
3206
|
+
};
|
|
3115
3207
|
}
|
|
3116
3208
|
function isStateChangingMethod(method) {
|
|
3117
3209
|
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
@@ -3335,34 +3427,32 @@ function scalarConfigurationWithPreferredAuth(configuration, schemes) {
|
|
|
3335
3427
|
}
|
|
3336
3428
|
function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
|
|
3337
3429
|
let final = res;
|
|
3338
|
-
const finish = (f) => {
|
|
3339
|
-
if (stripFingerprint) {
|
|
3340
|
-
f.headers.delete("server");
|
|
3341
|
-
f.headers.delete("x-powered-by");
|
|
3342
|
-
}
|
|
3343
|
-
if (hooks.onResponse !== undefined) {
|
|
3344
|
-
const onResponseResult = hooks.onResponse(f);
|
|
3345
|
-
if (isPromiseLike(onResponseResult)) {
|
|
3346
|
-
return onResponseResult.then(() => f);
|
|
3347
|
-
}
|
|
3348
|
-
}
|
|
3349
|
-
return f;
|
|
3350
|
-
};
|
|
3351
3430
|
if (hooks.onSend !== undefined) {
|
|
3352
3431
|
const sentResult = hooks.onSend(res, ctx);
|
|
3353
3432
|
if (isPromiseLike(sentResult)) {
|
|
3354
3433
|
return sentResult.then((sent) => {
|
|
3355
3434
|
if (sent instanceof Response)
|
|
3356
3435
|
final = sent;
|
|
3357
|
-
return
|
|
3436
|
+
return finishFinalize(final, hooks, stripFingerprint);
|
|
3358
3437
|
});
|
|
3359
3438
|
}
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3439
|
+
if (sentResult instanceof Response)
|
|
3440
|
+
final = sentResult;
|
|
3441
|
+
}
|
|
3442
|
+
return finishFinalize(final, hooks, stripFingerprint);
|
|
3443
|
+
}
|
|
3444
|
+
function finishFinalize(res, hooks, stripFingerprint) {
|
|
3445
|
+
if (stripFingerprint) {
|
|
3446
|
+
res.headers.delete("server");
|
|
3447
|
+
res.headers.delete("x-powered-by");
|
|
3448
|
+
}
|
|
3449
|
+
if (hooks.onResponse !== undefined) {
|
|
3450
|
+
const onResponseResult = hooks.onResponse(res);
|
|
3451
|
+
if (isPromiseLike(onResponseResult)) {
|
|
3452
|
+
return onResponseResult.then(() => res);
|
|
3363
3453
|
}
|
|
3364
3454
|
}
|
|
3365
|
-
return
|
|
3455
|
+
return res;
|
|
3366
3456
|
}
|
|
3367
3457
|
function isPromiseLike(value) {
|
|
3368
3458
|
return value !== null && typeof value === "object" && typeof value.then === "function";
|
|
@@ -3991,11 +4081,46 @@ function runHandler(def, ctx, requestTimeoutMs) {
|
|
|
3991
4081
|
if (requestTimeoutMs === 0 || !isPromiseLike(result)) {
|
|
3992
4082
|
return result;
|
|
3993
4083
|
}
|
|
3994
|
-
return withTimeout(result, requestTimeoutMs);
|
|
4084
|
+
return withTimeout(result, requestTimeoutMs, ctx.request);
|
|
4085
|
+
}
|
|
4086
|
+
/**
|
|
4087
|
+
* Fire an adapter's request abort hook (see {@link DALOY_REQUEST_ABORT}) if the
|
|
4088
|
+
* request shim exposes one. Called as a method so `this` stays bound to the
|
|
4089
|
+
* request. A no-op when the hook is absent (platform-managed `Request.signal`
|
|
4090
|
+
* or a direct `app.fetch()` caller), so the caller must not depend on the
|
|
4091
|
+
* signal actually firing.
|
|
4092
|
+
*
|
|
4093
|
+
* @param request - The in-flight request whose signal should be aborted.
|
|
4094
|
+
* @param reason - Abort reason surfaced on `request.signal.reason`.
|
|
4095
|
+
*/
|
|
4096
|
+
function abortRequest(request, reason) {
|
|
4097
|
+
const hooked = request;
|
|
4098
|
+
hooked[DALOY_REQUEST_ABORT]?.(reason);
|
|
3995
4099
|
}
|
|
3996
|
-
|
|
4100
|
+
/**
|
|
4101
|
+
* Race a handler promise against the per-request timeout.
|
|
4102
|
+
*
|
|
4103
|
+
* On timeout the request's {@link DALOY_REQUEST_ABORT} hook is fired first —
|
|
4104
|
+
* aborting `request.signal` with a `TimeoutError` `DOMException` (the same
|
|
4105
|
+
* reason shape as `AbortSignal.timeout()`) so cooperative downstream I/O the
|
|
4106
|
+
* handler forwarded the signal to unwinds — and then the returned promise
|
|
4107
|
+
* rejects with a {@link RequestTimeoutError} so the client receives a `408`.
|
|
4108
|
+
* The handler promise itself keeps a rejection handler attached, so a late
|
|
4109
|
+
* settle (including the `AbortError` from the work it just cancelled) never
|
|
4110
|
+
* surfaces as an unhandled rejection.
|
|
4111
|
+
*
|
|
4112
|
+
* @typeParam T - The handler's resolved value type.
|
|
4113
|
+
* @param p - The handler (or hook chain) promise to bound.
|
|
4114
|
+
* @param ms - Timeout in milliseconds; assumed non-zero by the caller.
|
|
4115
|
+
* @param request - The in-flight request, used to fire the abort hook.
|
|
4116
|
+
* @returns A promise that settles with the handler result or a 408 timeout.
|
|
4117
|
+
*/
|
|
4118
|
+
function withTimeout(p, ms, request) {
|
|
3997
4119
|
return new Promise((resolve, reject) => {
|
|
3998
|
-
const t = setTimeout(() =>
|
|
4120
|
+
const t = setTimeout(() => {
|
|
4121
|
+
abortRequest(request, new DOMException(`Request exceeded ${ms}ms`, "TimeoutError"));
|
|
4122
|
+
reject(new RequestTimeoutError(ms));
|
|
4123
|
+
}, ms);
|
|
3999
4124
|
p.then((v) => {
|
|
4000
4125
|
clearTimeout(t);
|
|
4001
4126
|
resolve(v);
|
|
@@ -4005,8 +4130,25 @@ function withTimeout(p, ms) {
|
|
|
4005
4130
|
});
|
|
4006
4131
|
});
|
|
4007
4132
|
}
|
|
4133
|
+
/**
|
|
4134
|
+
* Marker set on an error whose stack carries no incident information because
|
|
4135
|
+
* the same framework line throws it for every offending request — a rejected
|
|
4136
|
+
* *configuration* or *request shape*, not a fault in the app's code.
|
|
4137
|
+
*
|
|
4138
|
+
* `serializeErr` omits the stack for these. The stack is byte-identical on every
|
|
4139
|
+
* occurrence and points at framework internals, so logging it per request just
|
|
4140
|
+
* multiplies the volume an unauthenticated client can push into the error tier
|
|
4141
|
+
* (the expensive, alerting one) without telling an operator anything the
|
|
4142
|
+
* message does not already say.
|
|
4143
|
+
*
|
|
4144
|
+
* @internal
|
|
4145
|
+
*/
|
|
4146
|
+
const OMIT_STACK_IN_LOG = Symbol.for("daloyjs.error.omitStackInLog");
|
|
4008
4147
|
function serializeErr(err) {
|
|
4009
4148
|
if (err instanceof Error) {
|
|
4149
|
+
if (err[OMIT_STACK_IN_LOG] === true) {
|
|
4150
|
+
return { name: err.name, message: err.message };
|
|
4151
|
+
}
|
|
4010
4152
|
return { name: err.name, message: err.message, stack: err.stack };
|
|
4011
4153
|
}
|
|
4012
4154
|
return { value: String(err) };
|
package/dist/auto-ban.js
CHANGED
|
@@ -207,9 +207,7 @@ export function autoBan(opts = {}) {
|
|
|
207
207
|
opts.onStrike?.({ key, strikes, status: res.status });
|
|
208
208
|
if (strikes >= maxStrikes) {
|
|
209
209
|
banCount += 1;
|
|
210
|
-
const duration = escalate
|
|
211
|
-
? Math.min(maxBanMs, banMs * 2 ** (banCount - 1))
|
|
212
|
-
: banMs;
|
|
210
|
+
const duration = escalate ? Math.min(maxBanMs, banMs * 2 ** (banCount - 1)) : banMs;
|
|
213
211
|
bannedUntilMs = now + duration;
|
|
214
212
|
strikes = 0;
|
|
215
213
|
opts.onBan?.({ key, banCount, banDurationMs: duration, bannedUntilMs });
|
package/dist/bot-guard.js
CHANGED
|
@@ -76,8 +76,14 @@ function matchesUserAgent(ua, patterns) {
|
|
|
76
76
|
if (pattern && lower.includes(pattern.toLowerCase()))
|
|
77
77
|
return true;
|
|
78
78
|
}
|
|
79
|
-
else
|
|
80
|
-
|
|
79
|
+
else {
|
|
80
|
+
// Reset lastIndex so caller-supplied /g or /y regexes cannot flip-flop
|
|
81
|
+
// between match and miss across requests (intermittent allowlist bypass).
|
|
82
|
+
pattern.lastIndex = 0;
|
|
83
|
+
const hit = pattern.test(ua);
|
|
84
|
+
pattern.lastIndex = 0;
|
|
85
|
+
if (hit)
|
|
86
|
+
return true;
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
return false;
|
|
@@ -225,12 +231,28 @@ export function botGuard(opts = {}) {
|
|
|
225
231
|
};
|
|
226
232
|
const writeCache = (key, verified) => {
|
|
227
233
|
const now = Date.now();
|
|
234
|
+
// Move this key to the newest insertion slot on every (re)write. Eviction
|
|
235
|
+
// below is therefore FIFO over WRITE-recency (Map preserves insertion
|
|
236
|
+
// order), not true LRU: cache *reads* on the verification path do not
|
|
237
|
+
// reorder entries, so a frequently-read-but-never-rewritten key can still
|
|
238
|
+
// be evicted. That is intentional — reordering on read would add a Map
|
|
239
|
+
// delete+set to the hot lookup path for no security benefit.
|
|
240
|
+
if (cache.has(key))
|
|
241
|
+
cache.delete(key);
|
|
228
242
|
cache.set(key, { verified, expiresMs: now + cacheTtlMs });
|
|
229
243
|
if (cache.size > cacheMax) {
|
|
230
244
|
for (const [k, v] of cache)
|
|
231
245
|
if (v.expiresMs <= now)
|
|
232
246
|
cache.delete(k);
|
|
233
247
|
}
|
|
248
|
+
// Still over the cap after pruning expired entries: evict the
|
|
249
|
+
// oldest-written live keys (front of insertion order) until within cacheMax.
|
|
250
|
+
while (cache.size > cacheMax) {
|
|
251
|
+
const oldest = cache.keys().next().value;
|
|
252
|
+
if (oldest === undefined)
|
|
253
|
+
break;
|
|
254
|
+
cache.delete(oldest);
|
|
255
|
+
}
|
|
234
256
|
};
|
|
235
257
|
const reject = (event) => {
|
|
236
258
|
opts.onBlock?.(event);
|
|
@@ -252,7 +274,12 @@ export function botGuard(opts = {}) {
|
|
|
252
274
|
reject({ reason: "blocked-user-agent", userAgent: ua });
|
|
253
275
|
return undefined;
|
|
254
276
|
}
|
|
255
|
-
const rule = verifiedBots.find((r) =>
|
|
277
|
+
const rule = verifiedBots.find((r) => {
|
|
278
|
+
r.userAgent.lastIndex = 0;
|
|
279
|
+
const hit = r.userAgent.test(ua);
|
|
280
|
+
r.userAgent.lastIndex = 0;
|
|
281
|
+
return hit;
|
|
282
|
+
});
|
|
256
283
|
if (!rule)
|
|
257
284
|
return undefined;
|
|
258
285
|
const ip = resolveIp(ctx);
|
package/dist/cli.js
CHANGED
|
@@ -274,7 +274,11 @@ export function parseArgs(argv) {
|
|
|
274
274
|
};
|
|
275
275
|
let command = "inspect";
|
|
276
276
|
let i = 0;
|
|
277
|
-
if (argv[0] === "inspect" ||
|
|
277
|
+
if (argv[0] === "inspect" ||
|
|
278
|
+
argv[0] === "dev" ||
|
|
279
|
+
argv[0] === "help" ||
|
|
280
|
+
argv[0] === "doctor" ||
|
|
281
|
+
argv[0] === "diff") {
|
|
278
282
|
command = argv[0];
|
|
279
283
|
i = 1;
|
|
280
284
|
}
|
|
@@ -593,7 +597,8 @@ async function runDoctor(opts, io) {
|
|
|
593
597
|
const o = app.options;
|
|
594
598
|
const isProd = o.env === "production" ||
|
|
595
599
|
o.production === true ||
|
|
596
|
-
globalThis.process?.env?.NODE_ENV ===
|
|
600
|
+
globalThis.process?.env?.NODE_ENV ===
|
|
601
|
+
"production";
|
|
597
602
|
if (opts.noAuditDefaults !== true) {
|
|
598
603
|
if (isProd && o.trustProxy === undefined && o.behindProxy === undefined) {
|
|
599
604
|
findings.push({
|
|
@@ -907,10 +912,8 @@ export function buildAiDump(app, opts) {
|
|
|
907
912
|
method: def.method,
|
|
908
913
|
path: def.path,
|
|
909
914
|
...(def.operationId ? { operationId: def.operationId } : {}),
|
|
910
|
-
...(def.summary ?? meta?.summary
|
|
911
|
-
|
|
912
|
-
: {}),
|
|
913
|
-
...(def.description ?? meta?.description
|
|
915
|
+
...((def.summary ?? meta?.summary) ? { summary: def.summary ?? meta?.summary } : {}),
|
|
916
|
+
...((def.description ?? meta?.description)
|
|
914
917
|
? { description: def.description ?? meta?.description }
|
|
915
918
|
: {}),
|
|
916
919
|
tags: dedupeTags(def.tags, meta?.tags),
|
package/dist/client.d.ts
CHANGED
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
* can still be generated from the OpenAPI doc for non-TS clients).
|
|
11
11
|
*/
|
|
12
12
|
import type { App } from "./app.js";
|
|
13
|
-
import type { HandlerReturn, InferRequest, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
|
|
13
|
+
import type { HandlerReturn, InferRequest, ParamsOf, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
|
|
14
14
|
/** Union of every {@link RouteDefinition} registered on an `App`. */
|
|
15
15
|
export type RoutesOf<A extends App> = A["routes"][number];
|
|
16
16
|
/**
|
|
17
17
|
* Typed client surface generated from an `App`. The result is a record keyed
|
|
18
18
|
* by each route's `operationId` whose values are async methods inferred from
|
|
19
|
-
* the route's request and response schemas.
|
|
19
|
+
* the route's request and response schemas. Required query and header fields
|
|
20
|
+
* remain required on the client input, while schemas that accept an empty
|
|
21
|
+
* object keep their corresponding client field optional.
|
|
20
22
|
*
|
|
21
23
|
* The per-method types are recovered from the `App`'s accumulated route tuple,
|
|
22
24
|
* built by chained registrations or `app.registerRoutes([...])`. If the result
|
|
@@ -28,18 +30,38 @@ export type ClientFor<A extends App> = {
|
|
|
28
30
|
operationId: string;
|
|
29
31
|
}> as R["operationId"]]: ClientMethod<R>;
|
|
30
32
|
};
|
|
31
|
-
type ClientMethod<R> = R extends RouteDefinition<infer P, infer _M, infer Req, infer Res> ? (input: ClientInput<P, Req>) => Promise<ClientOutput<Res>> : never;
|
|
32
|
-
type ClientInput<P extends string, Req extends RequestSchemas | undefined> =
|
|
33
|
+
type ClientMethod<R> = R extends RouteDefinition<infer P, infer _M, infer Req, infer Res> ? {} extends ClientInput<P, Req> ? (input?: ClientInput<P, Req>) => Promise<ClientOutput<Res>> : (input: ClientInput<P, Req>) => Promise<ClientOutput<Res>> : never;
|
|
34
|
+
type ClientInput<P extends string, Req extends RequestSchemas | undefined> = ([
|
|
35
|
+
ParamsOf<P>
|
|
36
|
+
] extends [never] ? {
|
|
37
|
+
params?: Record<string, never>;
|
|
38
|
+
} : {
|
|
33
39
|
params: InferRequest<Req, P>["params"];
|
|
34
|
-
|
|
35
|
-
headers?: Record<string, string>;
|
|
36
|
-
} & (Req extends {
|
|
40
|
+
}) & ClientQueryInput<P, Req> & ClientHeadersInput<P, Req> & (Req extends {
|
|
37
41
|
body: infer _B;
|
|
38
42
|
} ? {
|
|
39
43
|
body: InferRequest<Req, P>["body"];
|
|
40
44
|
} : {
|
|
41
45
|
body?: undefined;
|
|
42
46
|
});
|
|
47
|
+
type ClientQueryInput<P extends string, Req extends RequestSchemas | undefined> = Req extends {
|
|
48
|
+
query: infer _Query;
|
|
49
|
+
} ? {} extends NonNullable<InferRequest<Req, P>["query"]> ? {
|
|
50
|
+
query?: NonNullable<InferRequest<Req, P>["query"]>;
|
|
51
|
+
} : {
|
|
52
|
+
query: NonNullable<InferRequest<Req, P>["query"]>;
|
|
53
|
+
} : {
|
|
54
|
+
query?: Record<string, string | string[] | number | boolean | undefined>;
|
|
55
|
+
};
|
|
56
|
+
type ClientHeadersInput<P extends string, Req extends RequestSchemas | undefined> = Req extends {
|
|
57
|
+
headers: infer _Headers;
|
|
58
|
+
} ? {} extends NonNullable<InferRequest<Req, P>["headers"]> ? {
|
|
59
|
+
headers?: NonNullable<InferRequest<Req, P>["headers"]>;
|
|
60
|
+
} : {
|
|
61
|
+
headers: NonNullable<InferRequest<Req, P>["headers"]>;
|
|
62
|
+
} : {
|
|
63
|
+
headers?: Record<string, string>;
|
|
64
|
+
};
|
|
43
65
|
type ClientOutput<Res extends ResponsesMap> = HandlerReturn<Res>;
|
|
44
66
|
/** Options for {@link createClient}. */
|
|
45
67
|
export interface ClientOptions {
|
|
@@ -62,6 +84,9 @@ export interface InProcessClientOptions {
|
|
|
62
84
|
* `operationId`. Parameters and response types are inferred from the same
|
|
63
85
|
* route definitions registered on `app`, so the client and server cannot
|
|
64
86
|
* drift apart at the type level.
|
|
87
|
+
* Required `params`, `query`, `headers`, and `body` inputs are preserved from
|
|
88
|
+
* the route contract; query or header schemas that accept an empty object keep
|
|
89
|
+
* those top-level fields optional.
|
|
65
90
|
*
|
|
66
91
|
* The returned object is a plain `Record<operationId, (input) => Promise<...>>`
|
|
67
92
|
* — each call serializes `params`/`query`/`headers`/`body` and dispatches
|
|
@@ -69,6 +94,8 @@ export interface InProcessClientOptions {
|
|
|
69
94
|
*
|
|
70
95
|
* For non-TypeScript consumers, run `pnpm gen` to emit a fully-typed SDK
|
|
71
96
|
* from the OpenAPI document instead.
|
|
97
|
+
* Routes without path parameters omit the `params` input, and routes with no
|
|
98
|
+
* required request inputs may be called without an argument.
|
|
72
99
|
*
|
|
73
100
|
* @remarks
|
|
74
101
|
* The method signatures are inferred from the `App`'s accumulated route tuple.
|
|
@@ -106,6 +133,8 @@ export declare function createClient<A extends App>(app: A, opts: ClientOptions)
|
|
|
106
133
|
*
|
|
107
134
|
* Requests still traverse the complete validation, middleware, security, and
|
|
108
135
|
* serialization pipeline through {@link "./app.js".App.fetch}.
|
|
136
|
+
* Routes without path parameters omit the `params` input, and routes with no
|
|
137
|
+
* required request inputs may be called without an argument.
|
|
109
138
|
*
|
|
110
139
|
* @param app - App whose registered route tuple drives the client surface.
|
|
111
140
|
* @param opts - Optional synthetic origin and default request headers.
|
package/dist/client.js
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* `operationId`. Parameters and response types are inferred from the same
|
|
15
15
|
* route definitions registered on `app`, so the client and server cannot
|
|
16
16
|
* drift apart at the type level.
|
|
17
|
+
* Required `params`, `query`, `headers`, and `body` inputs are preserved from
|
|
18
|
+
* the route contract; query or header schemas that accept an empty object keep
|
|
19
|
+
* those top-level fields optional.
|
|
17
20
|
*
|
|
18
21
|
* The returned object is a plain `Record<operationId, (input) => Promise<...>>`
|
|
19
22
|
* — each call serializes `params`/`query`/`headers`/`body` and dispatches
|
|
@@ -21,6 +24,8 @@
|
|
|
21
24
|
*
|
|
22
25
|
* For non-TypeScript consumers, run `pnpm gen` to emit a fully-typed SDK
|
|
23
26
|
* from the OpenAPI document instead.
|
|
27
|
+
* Routes without path parameters omit the `params` input, and routes with no
|
|
28
|
+
* required request inputs may be called without an argument.
|
|
24
29
|
*
|
|
25
30
|
* @remarks
|
|
26
31
|
* The method signatures are inferred from the `App`'s accumulated route tuple.
|
|
@@ -98,6 +103,8 @@ export function createClient(app, opts) {
|
|
|
98
103
|
*
|
|
99
104
|
* Requests still traverse the complete validation, middleware, security, and
|
|
100
105
|
* serialization pipeline through {@link "./app.js".App.fetch}.
|
|
106
|
+
* Routes without path parameters omit the `params` input, and routes with no
|
|
107
|
+
* required request inputs may be called without an argument.
|
|
101
108
|
*
|
|
102
109
|
* @param app - App whose registered route tuple drives the client surface.
|
|
103
110
|
* @param opts - Optional synthetic origin and default request headers.
|
package/dist/compression.d.ts
CHANGED
|
@@ -38,6 +38,15 @@ export interface CompressionOptions {
|
|
|
38
38
|
* below `0` or above `2 ** 31 - 1` are refused at construction.
|
|
39
39
|
*/
|
|
40
40
|
minimumSize?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Maximum response body size (in bytes) the middleware will buffer for
|
|
43
|
+
* compression. Larger (or unknown-and-growing) bodies are left uncompressed
|
|
44
|
+
* so a large GET cannot force unbounded heap growth. Default: `1_048_576`
|
|
45
|
+
* (1 MiB). Must be a positive integer.
|
|
46
|
+
*
|
|
47
|
+
* @since 1.0.0
|
|
48
|
+
*/
|
|
49
|
+
maxCompressibleBytes?: number;
|
|
41
50
|
/**
|
|
42
51
|
* Allowed encodings, in caller-preferred order. Defaults to
|
|
43
52
|
* `["br", "gzip", "deflate"]` — the middleware will pick the
|