@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.
Files changed (65) hide show
  1. package/README.md +34 -22
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +42 -7
  4. package/dist/adapters/deno.js +24 -7
  5. package/dist/adapters/lambda.d.ts +59 -2
  6. package/dist/adapters/lambda.js +136 -20
  7. package/dist/adapters/node.d.ts +8 -1
  8. package/dist/adapters/node.js +117 -46
  9. package/dist/app.d.ts +30 -4
  10. package/dist/app.js +187 -45
  11. package/dist/auto-ban.js +1 -3
  12. package/dist/bot-guard.js +30 -3
  13. package/dist/cli.js +9 -6
  14. package/dist/client.d.ts +36 -7
  15. package/dist/client.js +7 -0
  16. package/dist/compression.d.ts +9 -0
  17. package/dist/compression.js +72 -1
  18. package/dist/config.js +1 -3
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/errors.d.ts +12 -3
  22. package/dist/errors.js +14 -8
  23. package/dist/etag.js +12 -2
  24. package/dist/fetch-guard.d.ts +27 -19
  25. package/dist/fetch-guard.js +50 -8
  26. package/dist/geo-block.js +4 -9
  27. package/dist/hashing.js +1 -1
  28. package/dist/http-signatures.d.ts +4 -1
  29. package/dist/http-signatures.js +16 -9
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.js +3 -3
  32. package/dist/ip-reputation.js +1 -1
  33. package/dist/ip-restriction.js +3 -12
  34. package/dist/jwt.js +12 -14
  35. package/dist/logger.d.ts +45 -0
  36. package/dist/logger.js +135 -0
  37. package/dist/mcp.js +10 -9
  38. package/dist/middleware.js +33 -3
  39. package/dist/mtls.js +6 -1
  40. package/dist/multipart.js +9 -12
  41. package/dist/openapi.d.ts +1 -1
  42. package/dist/openapi.js +2 -2
  43. package/dist/rate-limit-redis.d.ts +4 -4
  44. package/dist/response-cache.d.ts +179 -21
  45. package/dist/response-cache.js +338 -29
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +5 -1
  49. package/dist/safe-redirect.js +27 -3
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security-schemes.js +1 -2
  53. package/dist/security.d.ts +41 -0
  54. package/dist/security.js +131 -15
  55. package/dist/session.d.ts +13 -2
  56. package/dist/session.js +111 -17
  57. package/dist/subdomains.js +1 -4
  58. package/dist/tenancy.d.ts +40 -0
  59. package/dist/tenancy.js +54 -3
  60. package/dist/time-claims.js +3 -1
  61. package/dist/waf.js +124 -32
  62. package/dist/webhook-delivery.js +19 -3
  63. package/dist/websocket.d.ts +8 -0
  64. package/dist/websocket.js +19 -4
  65. 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, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
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. **Missing CSRF** — when `session()` is installed and any route accepts a
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: session() + state-changing route without csrf().
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
- throw new InternalError(`Refusing to dispatch request: ${found} header is present but app({ trustProxy }) is unconfigured. ` +
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
- url: request.url,
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
- assertNoDuplicateSingletonHeaders(request.headers);
2398
- assertNoReservedInternalHeaders(request.headers);
2399
- assertHeaderCountWithinLimit(request.headers, this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT);
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
- * Both Node and Bun adapters call this automatically on `SIGINT` / `SIGTERM`.
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
- function healthRouteKey(request) {
2958
- // The probe rate limit deliberately does NOT honour `X-Forwarded-For`
2959
- // health probes typically arrive directly from a sidecar / orchestrator,
2960
- // so even apps that trust forwarded headers should not let an attacker
2961
- // bypass the per-IP cap by spoofing the header. Fall back to a constant
2962
- // key when no proxy header is available (single shared bucket).
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
- for (const hooks of layers) {
3106
- const record = hooks;
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 { hasSession, hasCsrf, hasAuth };
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 finish(final);
3436
+ return finishFinalize(final, hooks, stripFingerprint);
3358
3437
  });
3359
3438
  }
3360
- else {
3361
- if (sentResult instanceof Response)
3362
- final = sentResult;
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 finish(final);
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
- function withTimeout(p, ms) {
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(() => reject(new RequestTimeoutError(ms)), ms);
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 if (pattern.test(ua)) {
80
- return true;
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) => r.userAgent.test(ua));
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" || argv[0] === "dev" || argv[0] === "help" || argv[0] === "doctor" || argv[0] === "diff") {
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 === "production";
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
- ? { summary: def.summary ?? meta?.summary }
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
- query?: Partial<InferRequest<Req, P>["query"]>;
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.
@@ -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