@daloyjs/core 1.0.0-rc.3 → 1.0.0-rc.4

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.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Responses reconstructed by first-party cache/idempotency middleware from a
3
+ * previously finalized response. The original response already crossed the
4
+ * route's schema-validation boundary, so replaying its stored bytes does not
5
+ * create the opaque-success bypass guarded by `App`.
6
+ */
7
+ const schemaValidatedResponses = new WeakSet();
8
+ /**
9
+ * Mark a framework-generated replay as having crossed response validation.
10
+ * This is an internal capability and is not exported from the package barrel.
11
+ *
12
+ * @param response - Reconstructed response containing previously validated bytes.
13
+ * @returns The same response for allocation-free call-site composition.
14
+ */
15
+ export function markSchemaValidatedResponse(response) {
16
+ schemaValidatedResponses.add(response);
17
+ return response;
18
+ }
19
+ /**
20
+ * Test whether a response is a trusted first-party replay of validated bytes.
21
+ *
22
+ * @param response - Hook response being considered for fail-closed handling.
23
+ * @returns `true` only for responses marked by first-party replay middleware.
24
+ */
25
+ export function isSchemaValidatedResponse(response) {
26
+ return schemaValidatedResponses.has(response);
27
+ }
package/dist/jwk.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type JwtAlgorithm } from "./jwt.js";
2
- import type { BaseContext, Hooks } from "./types.js";
2
+ import type { Hooks, PreBodyContext } from "./types.js";
3
3
  /** Asymmetric algorithms accepted by {@link jwk}. */
4
4
  export type JwkAlgorithm = Exclude<JwtAlgorithm, "HS256" | "HS384" | "HS512">;
5
5
  /** Minimal JWKS document shape (RFC 7517 §5). */
@@ -12,8 +12,12 @@ export interface JwkSet {
12
12
  * `https://` URL (fetched with TTL caching), or a custom async resolver.
13
13
  */
14
14
  export type JwkSource = JwkSet | string | (() => JwkSet | Promise<JwkSet>);
15
- /** Per-request payload-revalidation hook. */
16
- export type JwkVerifyHook = (payload: Record<string, unknown>, ctx: BaseContext<any, any>) => boolean | void | Promise<boolean | void>;
15
+ /**
16
+ * Per-request payload-revalidation hook run before request-body I/O.
17
+ * The supplied {@link PreBodyContext} exposes raw headers/query/params and an
18
+ * always-`undefined` body.
19
+ */
20
+ export type JwkVerifyHook = (payload: Record<string, unknown>, ctx: PreBodyContext<any>) => boolean | void | Promise<boolean | void>;
17
21
  /**
18
22
  * Options for {@link jwk}: the JWKS source and asymmetric algorithm allowlist
19
23
  * are required; issuer / audience / clock-skew checks, JWKS fetch caching,
@@ -61,10 +65,10 @@ export interface JwkOptions {
61
65
  */
62
66
  fetch?: typeof fetch;
63
67
  /**
64
- * Optional per-request revalidation hook. Returning `false` rejects the
65
- * request with `403`; returning `true` or `undefined` accepts. Use for
66
- * revocation lists / token-version counters / "user changed password since
67
- * this JWT was issued".
68
+ * Optional per-request revalidation hook. Returning `false` rejects the
69
+ * request with `403`; returning `true` or `undefined` accepts. Use for
70
+ * revocation lists / token-version counters / "user changed password since
71
+ * this JWT was issued". Runs before request-body I/O.
68
72
  *
69
73
  * @since 0.22.0
70
74
  */
package/dist/jwk.js CHANGED
@@ -30,9 +30,15 @@
30
30
  import { ForbiddenError } from "./errors.js";
31
31
  import { createJwtVerifier, JwtError, } from "./jwt.js";
32
32
  const ALLOWED_JWK_ALGS = new Set([
33
- "RS256", "RS384", "RS512",
34
- "PS256", "PS384", "PS512",
35
- "ES256", "ES384", "ES512",
33
+ "RS256",
34
+ "RS384",
35
+ "RS512",
36
+ "PS256",
37
+ "PS384",
38
+ "PS512",
39
+ "ES256",
40
+ "ES384",
41
+ "ES512",
36
42
  "EdDSA",
37
43
  ]);
38
44
  function unauthorized(realm, errorCode, description) {
@@ -59,9 +65,7 @@ function sanitizeAuthParam(value) {
59
65
  return value.replace(/[\u0000-\u001f\u007f"\\]/g, "");
60
66
  }
61
67
  function isJwkSet(value) {
62
- return (typeof value === "object" &&
63
- value !== null &&
64
- Array.isArray(value.keys));
68
+ return (typeof value === "object" && value !== null && Array.isArray(value.keys));
65
69
  }
66
70
  function findJwkByKid(jwks, kid) {
67
71
  for (const k of jwks.keys) {
@@ -236,7 +240,7 @@ export function jwk(opts) {
236
240
  return cachedVerifier;
237
241
  }
238
242
  const authHooks = {
239
- async beforeHandle(ctx) {
243
+ async preBody(ctx) {
240
244
  const header = ctx.request.headers.get("authorization") ?? "";
241
245
  const match = /^Bearer\s+(.+)$/i.exec(header);
242
246
  if (!match) {
package/dist/mcp.js CHANGED
@@ -1,4 +1,4 @@
1
- import { safeJsonParse } from "./security.js";
1
+ import { safeJsonParseLimited } from "./security.js";
2
2
  /**
3
3
  * Latest MCP protocol version DaloyJS negotiates by default.
4
4
  *
@@ -693,11 +693,10 @@ export function createMcpHandler(options) {
693
693
  }
694
694
  let message;
695
695
  try {
696
- // `safeJsonParse` strips `__proto__` / `constructor` / `prototype` keys so
697
- // an untrusted MCP client cannot smuggle prototype-pollution-shaped keys
698
- // into a tool handler's arguments matching the REST body parsers'
699
- // secure-by-default posture (see `safeJsonParse` in security.ts).
700
- message = safeJsonParse(raw);
696
+ // Use the limited parser (proto stripping + key/depth bounds) so an
697
+ // untrusted MCP client cannot DoS us with wide or deeply-nested JSON-RPC
698
+ // payloads, even within the MCP body cap. Matches the REST body parsers.
699
+ message = safeJsonParseLimited(raw);
701
700
  }
702
701
  catch {
703
702
  return rpcError(null, PARSE_ERROR, "Invalid JSON in request body.", undefined, 400, headers);
@@ -789,6 +788,10 @@ export function mcpRoutes(path, handler, options = {}) {
789
788
  path,
790
789
  operationId: "mcpPost",
791
790
  summary: "MCP Streamable HTTP endpoint",
791
+ // The transport handler owns JSON-RPC serialization and may also emit
792
+ // empty/streaming responses, so its web-standard Response is
793
+ // intentionally opaque to Daloy's response serializer.
794
+ acknowledgeNoResponseBodySchema: true,
792
795
  responses,
793
796
  handler: ({ request }) => handler(request),
794
797
  },
@@ -797,6 +800,7 @@ export function mcpRoutes(path, handler, options = {}) {
797
800
  path,
798
801
  operationId: "mcpGet",
799
802
  summary: "MCP Streamable HTTP discovery hint",
803
+ acknowledgeNoResponseBodySchema: true,
800
804
  responses,
801
805
  handler: ({ request }) => handler(request),
802
806
  },
@@ -805,6 +809,7 @@ export function mcpRoutes(path, handler, options = {}) {
805
809
  path,
806
810
  operationId: "mcpOptions",
807
811
  summary: "MCP Streamable HTTP preflight",
812
+ acknowledgeNoResponseBodySchema: true,
808
813
  responses,
809
814
  handler: ({ request }) => handler(request),
810
815
  },
@@ -4,7 +4,7 @@
4
4
  * All middlewares return `Hooks` objects so they compose with `app.use(...)`,
5
5
  * groups, and per-route hooks identically.
6
6
  */
7
- import type { Hooks, BaseContext } from "./types.js";
7
+ import type { Hooks, BaseContext, PreBodyContext } from "./types.js";
8
8
  import { timingSafeEqual } from "./security.js";
9
9
  /** Options for {@link requestId}. */
10
10
  export interface RequestIdOptions {
@@ -327,6 +327,23 @@ export declare const CSRF_HOOK_MARKER: unique symbol;
327
327
  * @since 1.0.0
328
328
  */
329
329
  export declare const AUTH_HOOK_MARKER: unique symbol;
330
+ /**
331
+ * Internal list of request-budget hooks that must run when a later `preBody`
332
+ * guard rejects. This preserves registration order for `rateLimit()` before
333
+ * authentication without moving body-aware middleware ahead of validation.
334
+ *
335
+ * @internal
336
+ */
337
+ export declare const EARLY_REJECTION_HOOK_MARKER: unique symbol;
338
+ /**
339
+ * Compose `preBody` hooks while honoring request-budget hooks registered
340
+ * before the guard that rejects. Used internally by App and hook combinators.
341
+ *
342
+ * @param layers - Hook layers in registration order.
343
+ * @returns A composed `preBody` hook, or `undefined` when no layer has one.
344
+ * @internal
345
+ */
346
+ export declare function _mergePreBodyWithEarlyRejections(layers: Hooks[]): NonNullable<Hooks["preBody"]> | undefined;
330
347
  /**
331
348
  * Mark a custom {@link Hooks} bundle as performing request authentication.
332
349
  *
@@ -343,7 +360,7 @@ export declare const AUTH_HOOK_MARKER: unique symbol;
343
360
  * @example
344
361
  * ```ts
345
362
  * app.use(markAuthHook({
346
- * async beforeHandle(ctx) {
363
+ * async preBody(ctx) {
347
364
  * if (!(await myVerify(ctx.request))) throw new UnauthorizedError();
348
365
  * },
349
366
  * }));
@@ -426,14 +443,27 @@ export interface RateLimitStore {
426
443
  resetMs: number;
427
444
  }>;
428
445
  }
446
+ /**
447
+ * Context accepted by rate-limit key generators. It is validated on the
448
+ * ordinary path, but may be a pre-body context when a limiter registered
449
+ * before authentication counts an early credential rejection.
450
+ *
451
+ * @since 1.0.0
452
+ */
453
+ export type RateLimitContext = PreBodyContext<any> | BaseContext<any, any>;
429
454
  /** Options for {@link rateLimit}. */
430
455
  export interface RateLimitOptions {
431
456
  /** Rolling-window width in milliseconds (e.g. `60_000` for one minute). */
432
457
  windowMs: number;
433
458
  /** Maximum allowed requests per `windowMs` per key. */
434
459
  max: number;
435
- /** Derive the bucket key from `ctx`. Default returns `"global"`. */
436
- keyGenerator?: (ctx: BaseContext<any, any>) => string;
460
+ /**
461
+ * Derive the bucket key from `ctx`. Default returns `"global"`. When the
462
+ * limiter precedes `preBody` authentication, it also receives failed
463
+ * attempts before body I/O; rely only on the raw request and state populated
464
+ * by earlier `preBody` hooks.
465
+ */
466
+ keyGenerator?: (ctx: RateLimitContext) => string;
437
467
  /** Custom backend (default: shared in-memory store). */
438
468
  store?: RateLimitStore;
439
469
  /**
@@ -483,6 +513,11 @@ export declare function _resetSharedRateLimitStoresForTests(): void;
483
513
  * `X-Forwarded-For` / `X-Real-IP` when behind a trusted proxy, or supply a
484
514
  * custom `keyGenerator` (e.g. derive from the authenticated user id).
485
515
  *
516
+ * Registration order remains security-significant: a limiter placed before a
517
+ * `preBody` auth hook counts rejected credentials and can replace the later
518
+ * `401` with `429`, without consuming the request body. On ordinary accepted
519
+ * requests it retains the validated `beforeHandle` timing.
520
+ *
486
521
  * @example
487
522
  * ```ts
488
523
  * import { rateLimit } from "@daloyjs/core";
@@ -508,7 +543,7 @@ export interface LoginThrottleOptions {
508
543
  /** Shared bucket id. Default: `"login"`. */
509
544
  groupId?: string;
510
545
  /** Derive the caller key. Defaults to trusted proxy headers only when enabled. */
511
- keyGenerator?: (ctx: BaseContext<any, any>) => string;
546
+ keyGenerator?: (ctx: RateLimitContext) => string;
512
547
  /** Shared store for the hard limit. Uses rateLimit()'s in-memory group bucket by default. */
513
548
  store?: RateLimitStore;
514
549
  /** Trust x-forwarded-for / x-real-ip when deriving the default key. Default: false. */
@@ -530,6 +565,8 @@ export interface LoginThrottleOptions {
530
565
  * Mount the same `loginThrottle()` instance (or multiple instances with the
531
566
  * same `groupId`) across related routes so an attacker cannot bypass the limit
532
567
  * by rotating between password, OTP, and reset endpoints.
568
+ * When registered before `preBody` authentication it counts and progressively
569
+ * delays rejected credentials without consuming a declared request body.
533
570
  *
534
571
  * @param opts - Throttle tuning (see {@link LoginThrottleOptions}); every field has a safe default.
535
572
  * @returns A {@link Hooks} bundle ready for `app.use(...)` or per-route `hooks`.
@@ -588,7 +625,7 @@ export declare function timing(headerName?: string): Hooks;
588
625
  *
589
626
  * @since 0.22.0
590
627
  */
591
- export type BearerAuthVerifyHook<TCredentials = string> = (credentials: TCredentials, ctx: BaseContext<any, any>) => boolean | void | Promise<boolean | void>;
628
+ export type BearerAuthVerifyHook<TCredentials = string> = (credentials: TCredentials, ctx: PreBodyContext<any>) => boolean | void | Promise<boolean | void>;
592
629
  /** Options for {@link bearerAuth}. */
593
630
  export interface BearerAuthOptions {
594
631
  /** Cheap, stateless token check (signature / format). */
@@ -616,6 +653,8 @@ export interface BearerAuthOptions {
616
653
  * optional `verify` hook is the integration point for revocation
617
654
  * lists, token-version counters, and other per-request invalidation checks
618
655
  * that `validate` cannot answer statelessly.
656
+ * Both checks run before request-body I/O; `verify` receives a
657
+ * {@link PreBodyContext} whose `body` is always `undefined`.
619
658
  *
620
659
  * @example
621
660
  * ```ts
@@ -757,13 +796,15 @@ export interface BasicAuthOptions {
757
796
  * after the framework has stamped `ctx.state.user`. Use this to decorate
758
797
  * `ctx.state` with extra fields (typed via `AppState`) so handlers do not
759
798
  * re-parse the `Authorization` header in every route.
799
+ * Runs before request-body I/O with a {@link PreBodyContext}; `ctx.body` is
800
+ * always `undefined`.
760
801
  *
761
802
  * @since 0.22.0
762
803
  */
763
804
  onAuthSuccess?: (creds: {
764
805
  username: string;
765
806
  password: string;
766
- }, ctx: BaseContext<any, any>) => void | Promise<void>;
807
+ }, ctx: PreBodyContext<any>) => void | Promise<void>;
767
808
  }
768
809
  /**
769
810
  * HTTP Basic Authentication middleware (RFC 7617).
@@ -31,7 +31,7 @@ export function requestId(opts = {}) {
31
31
  const header = (opts.header ?? "x-request-id").toLowerCase();
32
32
  const gen = opts.generator ?? randomId;
33
33
  return {
34
- beforeHandle(ctx) {
34
+ preBody(ctx) {
35
35
  const incoming = opts.trustIncoming ? ctx.request.headers.get(header) : null;
36
36
  const id = incoming && /^[A-Za-z0-9._-]{1,200}$/.test(incoming) ? incoming : gen();
37
37
  ctx.state.requestId = id;
@@ -460,6 +460,51 @@ export const CSRF_HOOK_MARKER = Symbol.for("daloyjs.middleware.csrf");
460
460
  * @since 1.0.0
461
461
  */
462
462
  export const AUTH_HOOK_MARKER = Symbol.for("daloyjs.auth.hook");
463
+ /**
464
+ * Internal list of request-budget hooks that must run when a later `preBody`
465
+ * guard rejects. This preserves registration order for `rateLimit()` before
466
+ * authentication without moving body-aware middleware ahead of validation.
467
+ *
468
+ * @internal
469
+ */
470
+ export const EARLY_REJECTION_HOOK_MARKER = Symbol.for("daloyjs.middleware.earlyRejectionHooks");
471
+ /**
472
+ * Compose `preBody` hooks while honoring request-budget hooks registered
473
+ * before the guard that rejects. Used internally by App and hook combinators.
474
+ *
475
+ * @param layers - Hook layers in registration order.
476
+ * @returns A composed `preBody` hook, or `undefined` when no layer has one.
477
+ * @internal
478
+ */
479
+ export function _mergePreBodyWithEarlyRejections(layers) {
480
+ if (!layers.some((hooks) => hooks.preBody !== undefined))
481
+ return undefined;
482
+ return async (ctx) => {
483
+ const pending = [];
484
+ for (const hooks of layers) {
485
+ const early = hooks[EARLY_REJECTION_HOOK_MARKER];
486
+ if (Array.isArray(early)) {
487
+ for (const candidate of early) {
488
+ if (typeof candidate === "function")
489
+ pending.push(candidate);
490
+ }
491
+ }
492
+ if (hooks.preBody === undefined)
493
+ continue;
494
+ const result = await hooks.preBody(ctx);
495
+ if (!(result instanceof Response))
496
+ continue;
497
+ let response = result;
498
+ for (const hook of pending) {
499
+ const replacement = await hook(ctx);
500
+ if (replacement instanceof Response)
501
+ response = replacement;
502
+ }
503
+ return response;
504
+ }
505
+ return undefined;
506
+ };
507
+ }
463
508
  /**
464
509
  * Mark a custom {@link Hooks} bundle as performing request authentication.
465
510
  *
@@ -476,7 +521,7 @@ export const AUTH_HOOK_MARKER = Symbol.for("daloyjs.auth.hook");
476
521
  * @example
477
522
  * ```ts
478
523
  * app.use(markAuthHook({
479
- * async beforeHandle(ctx) {
524
+ * async preBody(ctx) {
480
525
  * if (!(await myVerify(ctx.request))) throw new UnauthorizedError();
481
526
  * },
482
527
  * }));
@@ -663,6 +708,11 @@ class MemoryStore {
663
708
  * `X-Forwarded-For` / `X-Real-IP` when behind a trusted proxy, or supply a
664
709
  * custom `keyGenerator` (e.g. derive from the authenticated user id).
665
710
  *
711
+ * Registration order remains security-significant: a limiter placed before a
712
+ * `preBody` auth hook counts rejected credentials and can replace the later
713
+ * `401` with `429`, without consuming the request body. On ordinary accepted
714
+ * requests it retains the validated `beforeHandle` timing.
715
+ *
666
716
  * @example
667
717
  * ```ts
668
718
  * import { rateLimit } from "@daloyjs/core";
@@ -704,21 +754,22 @@ export function rateLimit(opts) {
704
754
  }
705
755
  return "global";
706
756
  });
707
- return {
708
- async beforeHandle(ctx) {
709
- const key = `${groupPrefix}${keyOf(ctx)}`;
710
- const { count, resetMs } = await store.hit(key, opts.windowMs);
711
- const remaining = Math.max(0, opts.max - count);
712
- ctx.set.headers.set("x-ratelimit-limit", String(opts.max));
713
- ctx.set.headers.set("x-ratelimit-remaining", String(remaining));
714
- ctx.set.headers.set("x-ratelimit-reset", String(Math.ceil(resetMs / 1000)));
715
- if (count > opts.max) {
716
- const retry = Math.ceil((resetMs - Date.now()) / 1000);
717
- throw new TooManyRequestsError(opts.retryAfter !== false ? retry : undefined);
718
- }
719
- return undefined;
720
- },
757
+ const enforce = async (ctx) => {
758
+ const key = `${groupPrefix}${keyOf(ctx)}`;
759
+ const { count, resetMs } = await store.hit(key, opts.windowMs);
760
+ const remaining = Math.max(0, opts.max - count);
761
+ ctx.set.headers.set("x-ratelimit-limit", String(opts.max));
762
+ ctx.set.headers.set("x-ratelimit-remaining", String(remaining));
763
+ ctx.set.headers.set("x-ratelimit-reset", String(Math.ceil(resetMs / 1000)));
764
+ if (count > opts.max) {
765
+ const retry = Math.ceil((resetMs - Date.now()) / 1000);
766
+ throw new TooManyRequestsError(opts.retryAfter !== false ? retry : undefined);
767
+ }
768
+ return undefined;
721
769
  };
770
+ const hooks = { beforeHandle: enforce };
771
+ hooks[EARLY_REJECTION_HOOK_MARKER] = [enforce];
772
+ return hooks;
722
773
  }
723
774
  function assertNonNegativeInteger(name, value) {
724
775
  if (!Number.isInteger(value) || value < 0) {
@@ -751,6 +802,8 @@ function wait(ms) {
751
802
  * Mount the same `loginThrottle()` instance (or multiple instances with the
752
803
  * same `groupId`) across related routes so an attacker cannot bypass the limit
753
804
  * by rotating between password, OTP, and reset endpoints.
805
+ * When registered before `preBody` authentication it counts and progressively
806
+ * delays rejected credentials without consuming a declared request body.
754
807
  *
755
808
  * @param opts - Throttle tuning (see {@link LoginThrottleOptions}); every field has a safe default.
756
809
  * @returns A {@link Hooks} bundle ready for `app.use(...)` or per-route `hooks`.
@@ -784,30 +837,31 @@ export function loginThrottle(opts = {}) {
784
837
  slowdownBuckets = new Map();
785
838
  SHARED_LOGIN_THROTTLE_BUCKETS.set(groupId, slowdownBuckets);
786
839
  }
787
- return {
788
- async beforeHandle(ctx) {
789
- const now = Date.now();
790
- const key = `${groupId}:${keyGenerator(ctx)}`;
791
- let bucket = slowdownBuckets.get(key);
792
- if (!bucket || bucket.resetMs <= now) {
793
- bucket = { count: 0, resetMs: now + windowMs };
794
- slowdownBuckets.set(key, bucket);
795
- }
796
- bucket.count += 1;
797
- if (slowdownBuckets.size > 10_000) {
798
- for (const [bucketKey, value] of slowdownBuckets) {
799
- if (value.resetMs <= now)
800
- slowdownBuckets.delete(bucketKey);
801
- }
802
- }
803
- if (bucket.count > delayAfter && delayMs > 0 && maxDelayMs > 0) {
804
- const delay = Math.min(maxDelayMs, (bucket.count - delayAfter) * delayMs);
805
- if (delay > 0)
806
- await wait(delay);
840
+ const enforce = async (ctx) => {
841
+ const now = Date.now();
842
+ const key = `${groupId}:${keyGenerator(ctx)}`;
843
+ let bucket = slowdownBuckets.get(key);
844
+ if (!bucket || bucket.resetMs <= now) {
845
+ bucket = { count: 0, resetMs: now + windowMs };
846
+ slowdownBuckets.set(key, bucket);
847
+ }
848
+ bucket.count += 1;
849
+ if (slowdownBuckets.size > 10_000) {
850
+ for (const [bucketKey, value] of slowdownBuckets) {
851
+ if (value.resetMs <= now)
852
+ slowdownBuckets.delete(bucketKey);
807
853
  }
808
- return limiter.beforeHandle?.(ctx);
809
- },
854
+ }
855
+ if (bucket.count > delayAfter && delayMs > 0 && maxDelayMs > 0) {
856
+ const delay = Math.min(maxDelayMs, (bucket.count - delayAfter) * delayMs);
857
+ if (delay > 0)
858
+ await wait(delay);
859
+ }
860
+ return limiter.beforeHandle?.(ctx);
810
861
  };
862
+ const hooks = { beforeHandle: enforce };
863
+ hooks[EARLY_REJECTION_HOOK_MARKER] = [enforce];
864
+ return hooks;
811
865
  }
812
866
  // ---------- Timing ----------
813
867
  /**
@@ -851,6 +905,8 @@ export function timing(headerName = "server-timing") {
851
905
  * optional `verify` hook is the integration point for revocation
852
906
  * lists, token-version counters, and other per-request invalidation checks
853
907
  * that `validate` cannot answer statelessly.
908
+ * Both checks run before request-body I/O; `verify` receives a
909
+ * {@link PreBodyContext} whose `body` is always `undefined`.
854
910
  *
855
911
  * @example
856
912
  * ```ts
@@ -880,7 +936,7 @@ export function bearerAuth(opts) {
880
936
  throw new Error("bearerAuth(): realm must not contain quotes, CR, LF, or NUL bytes.");
881
937
  }
882
938
  return markAuthHook({
883
- async beforeHandle(ctx) {
939
+ async preBody(ctx) {
884
940
  const h = ctx.request.headers.get("authorization") ?? "";
885
941
  const m = /^Bearer\s+(.+)$/i.exec(h);
886
942
  if (!m) {
@@ -1157,7 +1213,7 @@ export function basicAuth(opts) {
1157
1213
  throw new Error("basicAuth(): maxCredentialBytes must be a positive integer.");
1158
1214
  }
1159
1215
  return markAuthHook({
1160
- async beforeHandle(ctx) {
1216
+ async preBody(ctx) {
1161
1217
  const header = ctx.request.headers.get("authorization") ?? "";
1162
1218
  const match = BASIC_AUTH_TOKEN_RE.exec(header);
1163
1219
  if (!match || match[1].length > maxBytes)
package/dist/mtls.d.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * @module
28
28
  * @since 0.37.0
29
29
  */
30
- import type { BaseContext, Hooks } from "./types.js";
30
+ import type { Hooks, PreBodyContext } from "./types.js";
31
31
  /**
32
32
  * Normalized view of a TLS client certificate, independent of how it was
33
33
  * obtained (native socket vs. forwarded proxy header). Every field except
@@ -197,9 +197,9 @@ export interface ClientCertAuthOptions {
197
197
  /**
198
198
  * Override how the certificate is sourced. Defaults to reading whatever the
199
199
  * adapter attached via {@link setClientCertificate} (native TLS), falling
200
- * back to {@link header} parsing when configured.
200
+ * back to {@link header} parsing when configured. Runs before body I/O.
201
201
  */
202
- resolve?: (ctx: BaseContext<any, any>) => ClientCertificate | undefined;
202
+ resolve?: (ctx: PreBodyContext<any>) => ClientCertificate | undefined;
203
203
  /**
204
204
  * Read the certificate from a trusted-proxy header instead of (or in
205
205
  * addition to) the native adapter source. **Spoofable** unless the app is
@@ -237,9 +237,10 @@ export interface ClientCertAuthOptions {
237
237
  checkValidity?: boolean;
238
238
  /**
239
239
  * Custom per-request check, run after all built-in checks pass. Returning
240
- * `false` rejects with `403`; `true`/`undefined` accepts.
240
+ * `false` rejects with `403`; `true`/`undefined` accepts. The context body is
241
+ * unavailable because certificate authentication runs before body I/O.
241
242
  */
242
- verify?: (cert: ClientCertificate, ctx: BaseContext<any, any>) => boolean | void | Promise<boolean | void>;
243
+ verify?: (cert: ClientCertificate, ctx: PreBodyContext<any>) => boolean | void | Promise<boolean | void>;
243
244
  /** Rejection message for the `403` responses. Default: `"Client certificate not permitted"`. */
244
245
  message?: string;
245
246
  /** `ctx.state` key the accepted certificate is stamped on. Default: `"clientCertificate"`. */
package/dist/mtls.js CHANGED
@@ -348,7 +348,7 @@ export function clientCertAuth(opts = {}) {
348
348
  return undefined;
349
349
  });
350
350
  const authHooks = {
351
- async beforeHandle(ctx) {
351
+ async preBody(ctx) {
352
352
  const cert = resolve(ctx);
353
353
  if (!cert) {
354
354
  return new Response(MISSING_CERT_BODY, {
@@ -396,11 +396,7 @@ function assertHeaderConfig(cfg) {
396
396
  if (cfg.format === "xfcc")
397
397
  return;
398
398
  if (cfg.format === "structured") {
399
- if (!cfg.subjectDN &&
400
- !cfg.fingerprint &&
401
- !cfg.san &&
402
- !cfg.serialNumber &&
403
- !cfg.issuerDN) {
399
+ if (!cfg.subjectDN && !cfg.fingerprint && !cfg.san && !cfg.serialNumber && !cfg.issuerDN) {
404
400
  throw new Error("clientCertAuth(): structured header config must name at least one of subjectDN/issuerDN/fingerprint/serialNumber/san.");
405
401
  }
406
402
  return;
@@ -419,9 +415,7 @@ function certFromHeaders(request, cfg) {
419
415
  const sanRaw = readHeader(request, cfg.san);
420
416
  const verifyRaw = cfg.verify ? readHeader(request, cfg.verify) : undefined;
421
417
  const successValue = (cfg.verifySuccessValue ?? "SUCCESS").toLowerCase();
422
- const verified = cfg.verify === undefined
423
- ? true
424
- : (verifyRaw ?? "").toLowerCase() === successValue;
418
+ const verified = cfg.verify === undefined ? true : (verifyRaw ?? "").toLowerCase() === successValue;
425
419
  const sans = [];
426
420
  if (sanRaw) {
427
421
  for (const piece of sanRaw.split(",")) {
package/dist/openapi.js CHANGED
@@ -208,7 +208,7 @@ function buildOperation(route, path) {
208
208
  ? { ...(metaResponseExamples ?? {}), ...(spec.examples ?? {}) }
209
209
  : undefined;
210
210
  responses[status] = {
211
- description: spec.description,
211
+ description: spec.description ?? `HTTP ${status} response`,
212
212
  ...(spec.body
213
213
  ? {
214
214
  content: {
@@ -28,6 +28,7 @@
28
28
  * @since 0.37.0
29
29
  */
30
30
  import { BadRequestError } from "./errors.js";
31
+ import { safeJsonParseLimited } from "./security.js";
31
32
  import { isForbiddenObjectKey } from "./security.js";
32
33
  /**
33
34
  * Hard cap on the length of an encoded cursor string accepted by
@@ -91,7 +92,9 @@ export function decodeCursor(cursor) {
91
92
  }
92
93
  let parsed;
93
94
  try {
94
- parsed = JSON.parse(json);
95
+ // Use limited parse for structural safety (wide/deep cursors).
96
+ // Cursors are length-capped at 4k so even default limits are very generous here.
97
+ parsed = safeJsonParseLimited(json, 1000, 20);
95
98
  }
96
99
  catch {
97
100
  throw new BadRequestError("Malformed pagination cursor.");
@@ -39,6 +39,7 @@
39
39
  * @module
40
40
  * @since 0.37.0
41
41
  */
42
+ import { markSchemaValidatedResponse } from "./internal-response.js";
42
43
  /** Internal `ctx.state` key carrying the pending cache key between hooks. */
43
44
  const PENDING_STATE_KEY = "__responseCachePending";
44
45
  /**
@@ -183,7 +184,7 @@ function buildResponseFromCache(entry, outcome, statusHeaderName, isHead) {
183
184
  if (statusHeaderName)
184
185
  headers.set(statusHeaderName, outcome);
185
186
  const body = isHead || entry.body === "" ? null : base64ToBytes(entry.body);
186
- return new Response(body, { status: entry.status, headers });
187
+ return markSchemaValidatedResponse(new Response(body, { status: entry.status, headers }));
187
188
  }
188
189
  function isPromiseLike(value) {
189
190
  return (value !== null &&
@@ -31,7 +31,10 @@
31
31
  * ```ts
32
32
  * import { safeRedirect } from "@daloyjs/core";
33
33
  *
34
- * app.get("/login/callback", (ctx) => {
34
+ * app.get("/login/callback", {
35
+ * acknowledgeNoResponseBodySchema: true,
36
+ * responses: { 303: {} },
37
+ * }, (ctx) => {
35
38
  * const next = new URL(ctx.request.url).searchParams.get("next") ?? "/";
36
39
  * return safeRedirect(next, {
37
40
  * allowedPaths: ["/", "/dashboard", "/account"],
@@ -31,7 +31,10 @@
31
31
  * ```ts
32
32
  * import { safeRedirect } from "@daloyjs/core";
33
33
  *
34
- * app.get("/login/callback", (ctx) => {
34
+ * app.get("/login/callback", {
35
+ * acknowledgeNoResponseBodySchema: true,
36
+ * responses: { 303: {} },
37
+ * }, (ctx) => {
35
38
  * const next = new URL(ctx.request.url).searchParams.get("next") ?? "/";
36
39
  * return safeRedirect(next, {
37
40
  * allowedPaths: ["/", "/dashboard", "/account"],