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

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 (61) hide show
  1. package/README.md +103 -41
  2. package/dist/adapters/bun.d.ts +20 -2
  3. package/dist/adapters/bun.js +41 -5
  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 +104 -19
  9. package/dist/app.d.ts +131 -11
  10. package/dist/app.js +305 -217
  11. package/dist/bot-guard.js +30 -3
  12. package/dist/cli.js +41 -1
  13. package/dist/client.d.ts +64 -18
  14. package/dist/client.js +36 -6
  15. package/dist/combine.d.ts +11 -11
  16. package/dist/combine.js +90 -47
  17. package/dist/compression.d.ts +9 -0
  18. package/dist/compression.js +72 -1
  19. package/dist/conn-info.d.ts +5 -2
  20. package/dist/conn-info.js +5 -2
  21. package/dist/docs.d.ts +5 -9
  22. package/dist/docs.js +36 -14
  23. package/dist/errors.d.ts +12 -3
  24. package/dist/errors.js +12 -3
  25. package/dist/fetch-guard.d.ts +27 -19
  26. package/dist/fetch-guard.js +50 -8
  27. package/dist/http-signatures.d.ts +4 -1
  28. package/dist/http-signatures.js +13 -1
  29. package/dist/idempotency.js +2 -1
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +3 -3
  32. package/dist/internal-response.d.ts +15 -0
  33. package/dist/internal-response.js +27 -0
  34. package/dist/jwk.d.ts +11 -7
  35. package/dist/jwk.js +11 -7
  36. package/dist/logger.d.ts +45 -0
  37. package/dist/logger.js +137 -0
  38. package/dist/mcp.js +21 -15
  39. package/dist/middleware.d.ts +48 -7
  40. package/dist/middleware.js +129 -43
  41. package/dist/mtls.d.ts +6 -5
  42. package/dist/mtls.js +8 -9
  43. package/dist/openapi.js +1 -1
  44. package/dist/pagination.js +4 -1
  45. package/dist/response-cache.js +2 -1
  46. package/dist/router.d.ts +2 -2
  47. package/dist/router.js +24 -9
  48. package/dist/safe-redirect.d.ts +9 -2
  49. package/dist/safe-redirect.js +29 -4
  50. package/dist/sbom.cdx.json +9 -9
  51. package/dist/sbom.spdx.json +5 -5
  52. package/dist/security.d.ts +62 -0
  53. package/dist/security.js +220 -15
  54. package/dist/session.d.ts +13 -2
  55. package/dist/session.js +111 -17
  56. package/dist/tenancy.d.ts +2 -2
  57. package/dist/time-claims.js +3 -1
  58. package/dist/types.d.ts +85 -20
  59. package/dist/types.js +16 -1
  60. package/dist/waf.js +86 -26
  61. package/package.json +11 -4
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/logger.d.ts CHANGED
@@ -133,4 +133,49 @@ export declare function createLogger(opts?: ConsoleLoggerOptions): Logger;
133
133
  * @since 0.1.0
134
134
  */
135
135
  export declare const noopLogger: Logger;
136
+ /**
137
+ * Query parameter names whose values are redacted when a request URL is
138
+ * bound into a log record. Case-insensitive. Covers OAuth redirect params,
139
+ * API keys in query strings, signed-URL tokens, session identifiers, and the
140
+ * exact-named parameters of AWS SigV4 / GCS V4 presigned URLs (the `x-amz-*`
141
+ * and `x-goog-*` families are additionally matched by prefix — see
142
+ * {@link SENSITIVE_URL_QUERY_KEY_PREFIXES}).
143
+ *
144
+ * @since 1.0.0
145
+ */
146
+ export declare const SENSITIVE_URL_QUERY_KEYS: readonly string[];
147
+ /**
148
+ * Case-insensitive query-key prefixes whose values are always redacted in a
149
+ * logged URL. Covers the full AWS SigV4 (`X-Amz-*`) and GCS V4 (`X-Goog-*`)
150
+ * presigned-URL parameter families so a signature never leaks even if a
151
+ * provider adds a new signed parameter name. Redacting the non-secret members
152
+ * of the bundle (`X-Amz-Date`, `X-Amz-Expires`, …) is harmless in a log line.
153
+ *
154
+ * @since 1.0.0
155
+ */
156
+ export declare const SENSITIVE_URL_QUERY_KEY_PREFIXES: readonly string[];
157
+ /**
158
+ * Produce a log-safe form of a request URL.
159
+ *
160
+ * Keeps scheme, host, and path for operability. Redacts values of
161
+ * {@link SENSITIVE_URL_QUERY_KEYS} / {@link SENSITIVE_URL_QUERY_KEY_PREFIXES}
162
+ * (and JWT-like / credential-like query values) so OAuth `?code=`,
163
+ * `?access_token=`, and presigned-URL signatures (`?X-Amz-Signature=`,
164
+ * `?X-Goog-Signature=`) never land in durable error logs under the field name
165
+ * `url` (which the structured redactor does not rename-match).
166
+ *
167
+ * Malformed URLs fall back to the path-only prefix before `?` / `#`.
168
+ *
169
+ * This runs once per request on the logging path, so it fast-paths the common
170
+ * case: a URL with no query, no fragment, and no userinfo (`@`) delimiter is
171
+ * already log-safe and is returned verbatim without the WHATWG URL parse (about
172
+ * an order of magnitude cheaper). The `@` guard preserves userinfo stripping
173
+ * for the rare inputs that carry credentials in the authority — `request.url`
174
+ * itself never does, but this is a public utility.
175
+ *
176
+ * @param url - Absolute or relative request URL (typically `request.url`).
177
+ * @returns A string safe to attach as a logger binding.
178
+ * @since 1.0.0
179
+ */
180
+ export declare function sanitizeUrlForLog(url: string): string;
136
181
  export {};
package/dist/logger.js CHANGED
@@ -34,6 +34,17 @@ export const DEFAULT_REDACT_KEYS = Object.freeze([
34
34
  "refresh_token",
35
35
  "id_token",
36
36
  "client_secret",
37
+ // A structured field literally named `private_key` is always a secret and
38
+ // has a negligible false-positive rate as a log-field name (mirrors the
39
+ // existing `client_secret`). The broader OAuth/session query-string names
40
+ // (`code`, `state`, `id`, `key`, `sid`, `session`, `signature`, `sig`,
41
+ // `auth`, …) are DELIBERATELY NOT here: those are extremely common,
42
+ // non-secret structured field names (record ids, sort keys, UI state) and
43
+ // redacting them at every depth would corrupt normal operational logs.
44
+ // Secrets that ride in a *URL query string* are handled instead by
45
+ // {@link sanitizeUrlForLog} / {@link SENSITIVE_URL_QUERY_KEYS}, which only
46
+ // applies to the `url` field where the query context makes them sensitive.
47
+ "private_key",
37
48
  // AI / LLM provider credential headers and body fields. Added in response
38
49
  // to the LiteLLM 2026 "AI blast radius" incident class (Snyk 2026,
39
50
  // CVE-2026-42208 + CVE-2026-33634) — an AI gateway that brokers prompts
@@ -265,3 +276,129 @@ export const noopLogger = {
265
276
  return noopLogger;
266
277
  },
267
278
  };
279
+ /**
280
+ * Query parameter names whose values are redacted when a request URL is
281
+ * bound into a log record. Case-insensitive. Covers OAuth redirect params,
282
+ * API keys in query strings, signed-URL tokens, session identifiers, and the
283
+ * exact-named parameters of AWS SigV4 / GCS V4 presigned URLs (the `x-amz-*`
284
+ * and `x-goog-*` families are additionally matched by prefix — see
285
+ * {@link SENSITIVE_URL_QUERY_KEY_PREFIXES}).
286
+ *
287
+ * @since 1.0.0
288
+ */
289
+ export const SENSITIVE_URL_QUERY_KEYS = Object.freeze([
290
+ "authorization",
291
+ "access_token",
292
+ "refresh_token",
293
+ "id_token",
294
+ "token",
295
+ "api_key",
296
+ "apikey",
297
+ "api-key",
298
+ "key",
299
+ "password",
300
+ "passwd",
301
+ "secret",
302
+ "client_secret",
303
+ "code",
304
+ "state",
305
+ "session_state",
306
+ "session",
307
+ "sid",
308
+ "signature",
309
+ "sig",
310
+ "auth",
311
+ "private_key",
312
+ "x-api-key",
313
+ // AWS SigV4 presigned URL parameters. `X-Amz-Signature` is the secret; the
314
+ // credential (embeds the access-key id) and session token are equally
315
+ // sensitive. Also covered by the `x-amz-` prefix below.
316
+ "x-amz-signature",
317
+ "x-amz-credential",
318
+ "x-amz-security-token",
319
+ // Google Cloud Storage V4 signed URL parameters. Also covered by `x-goog-`.
320
+ "x-goog-signature",
321
+ "x-goog-credential",
322
+ "googleaccessid",
323
+ ]);
324
+ /**
325
+ * Case-insensitive query-key prefixes whose values are always redacted in a
326
+ * logged URL. Covers the full AWS SigV4 (`X-Amz-*`) and GCS V4 (`X-Goog-*`)
327
+ * presigned-URL parameter families so a signature never leaks even if a
328
+ * provider adds a new signed parameter name. Redacting the non-secret members
329
+ * of the bundle (`X-Amz-Date`, `X-Amz-Expires`, …) is harmless in a log line.
330
+ *
331
+ * @since 1.0.0
332
+ */
333
+ export const SENSITIVE_URL_QUERY_KEY_PREFIXES = Object.freeze([
334
+ "x-amz-",
335
+ "x-goog-",
336
+ ]);
337
+ const SENSITIVE_URL_QUERY_KEY_SET = new Set(SENSITIVE_URL_QUERY_KEYS.map((k) => k.toLowerCase()));
338
+ /**
339
+ * Whether a URL query-parameter name is treated as secret-bearing when a
340
+ * request URL is bound into a log record. True when the lower-cased name is in
341
+ * {@link SENSITIVE_URL_QUERY_KEYS} or starts with a
342
+ * {@link SENSITIVE_URL_QUERY_KEY_PREFIXES} entry.
343
+ *
344
+ * @param lowerKey - Already-lower-cased query-parameter name.
345
+ * @returns `true` if the value should be redacted.
346
+ */
347
+ function isSensitiveUrlQueryKey(lowerKey) {
348
+ if (SENSITIVE_URL_QUERY_KEY_SET.has(lowerKey))
349
+ return true;
350
+ for (const prefix of SENSITIVE_URL_QUERY_KEY_PREFIXES) {
351
+ if (lowerKey.startsWith(prefix))
352
+ return true;
353
+ }
354
+ return false;
355
+ }
356
+ /**
357
+ * Produce a log-safe form of a request URL.
358
+ *
359
+ * Keeps scheme, host, and path for operability. Redacts values of
360
+ * {@link SENSITIVE_URL_QUERY_KEYS} / {@link SENSITIVE_URL_QUERY_KEY_PREFIXES}
361
+ * (and JWT-like / credential-like query values) so OAuth `?code=`,
362
+ * `?access_token=`, and presigned-URL signatures (`?X-Amz-Signature=`,
363
+ * `?X-Goog-Signature=`) never land in durable error logs under the field name
364
+ * `url` (which the structured redactor does not rename-match).
365
+ *
366
+ * Malformed URLs fall back to the path-only prefix before `?` / `#`.
367
+ *
368
+ * This runs once per request on the logging path, so it fast-paths the common
369
+ * case: a URL with no query, no fragment, and no userinfo (`@`) delimiter is
370
+ * already log-safe and is returned verbatim without the WHATWG URL parse (about
371
+ * an order of magnitude cheaper). The `@` guard preserves userinfo stripping
372
+ * for the rare inputs that carry credentials in the authority — `request.url`
373
+ * itself never does, but this is a public utility.
374
+ *
375
+ * @param url - Absolute or relative request URL (typically `request.url`).
376
+ * @returns A string safe to attach as a logger binding.
377
+ * @since 1.0.0
378
+ */
379
+ export function sanitizeUrlForLog(url) {
380
+ if (url.indexOf("?") === -1 &&
381
+ url.indexOf("#") === -1 &&
382
+ url.indexOf("@") === -1) {
383
+ return url;
384
+ }
385
+ try {
386
+ const parsed = new URL(url);
387
+ if (parsed.search === "" && parsed.hash === "") {
388
+ return `${parsed.origin}${parsed.pathname}`;
389
+ }
390
+ const safe = new URL(parsed.origin + parsed.pathname);
391
+ for (const [key, value] of parsed.searchParams) {
392
+ const lower = key.toLowerCase();
393
+ const sensitiveKey = isSensitiveUrlQueryKey(lower);
394
+ const sensitiveValue = JWT_LIKE_RE.test(value) || CREDENTIAL_LIKE_RE.test(value);
395
+ CREDENTIAL_LIKE_RE.lastIndex = 0;
396
+ safe.searchParams.append(key, sensitiveKey || sensitiveValue ? "[REDACTED]" : value);
397
+ }
398
+ return safe.toString();
399
+ }
400
+ catch {
401
+ const cut = url.search(/[?#]/);
402
+ return cut === -1 ? url : url.slice(0, cut);
403
+ }
404
+ }
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
  *
@@ -355,10 +355,16 @@ function compileUriTemplate(template) {
355
355
  const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
356
356
  /**
357
357
  * Streamable HTTP DNS-rebinding defense: decide whether a browser `Origin`
358
- * may talk to this MCP endpoint. Same-origin and loopback origins are always
359
- * allowed; anything else must be explicitly allowlisted.
358
+ * may talk to this MCP endpoint.
359
+ *
360
+ * Loopback origins (`localhost` / `127.0.0.1` / `[::1]` / `*.localhost`) are
361
+ * allowed for local development. Every non-loopback origin must appear in
362
+ * the configured allowlist. We deliberately do **not** treat
363
+ * `Origin.host === request Host` as sufficient: under DNS rebinding both
364
+ * can be the attacker hostname resolving to the target IP, which would
365
+ * silently bypass an implicit same-origin check.
360
366
  */
361
- function isAllowedOrigin(origin, request, allowlist) {
367
+ function isAllowedOrigin(origin, _request, allowlist) {
362
368
  const normalized = origin.toLowerCase();
363
369
  if (allowlist.has(normalized))
364
370
  return true;
@@ -374,12 +380,7 @@ function isAllowedOrigin(origin, request, allowlist) {
374
380
  const hostname = parsed.hostname;
375
381
  if (LOOPBACK_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost"))
376
382
  return true;
377
- try {
378
- return parsed.host === new URL(request.url).host;
379
- }
380
- catch {
381
- return false;
382
- }
383
+ return false;
383
384
  }
384
385
  /**
385
386
  * Create a dependency-free MCP Streamable HTTP endpoint handler.
@@ -693,11 +694,10 @@ export function createMcpHandler(options) {
693
694
  }
694
695
  let message;
695
696
  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);
697
+ // Use the limited parser (proto stripping + key/depth bounds) so an
698
+ // untrusted MCP client cannot DoS us with wide or deeply-nested JSON-RPC
699
+ // payloads, even within the MCP body cap. Matches the REST body parsers.
700
+ message = safeJsonParseLimited(raw);
701
701
  }
702
702
  catch {
703
703
  return rpcError(null, PARSE_ERROR, "Invalid JSON in request body.", undefined, 400, headers);
@@ -789,6 +789,10 @@ export function mcpRoutes(path, handler, options = {}) {
789
789
  path,
790
790
  operationId: "mcpPost",
791
791
  summary: "MCP Streamable HTTP endpoint",
792
+ // The transport handler owns JSON-RPC serialization and may also emit
793
+ // empty/streaming responses, so its web-standard Response is
794
+ // intentionally opaque to Daloy's response serializer.
795
+ acknowledgeNoResponseBodySchema: true,
792
796
  responses,
793
797
  handler: ({ request }) => handler(request),
794
798
  },
@@ -797,6 +801,7 @@ export function mcpRoutes(path, handler, options = {}) {
797
801
  path,
798
802
  operationId: "mcpGet",
799
803
  summary: "MCP Streamable HTTP discovery hint",
804
+ acknowledgeNoResponseBodySchema: true,
800
805
  responses,
801
806
  handler: ({ request }) => handler(request),
802
807
  },
@@ -805,6 +810,7 @@ export function mcpRoutes(path, handler, options = {}) {
805
810
  path,
806
811
  operationId: "mcpOptions",
807
812
  summary: "MCP Streamable HTTP preflight",
813
+ acknowledgeNoResponseBodySchema: true,
808
814
  responses,
809
815
  handler: ({ request }) => handler(request),
810
816
  },
@@ -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).