@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.
package/dist/app.js CHANGED
@@ -1,12 +1,13 @@
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, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
4
+ import { readBodyLimited, safeJsonParseLimited, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
5
5
  import { createLogger, noopLogger } from "./logger.js";
6
6
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
7
+ import { isSchemaValidatedResponse } from "./internal-response.js";
7
8
  import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
8
9
  import { generateAsyncAPI, asyncapiToYAML, } from "./asyncapi.js";
9
- import { secureHeaders as secureHeadersMiddleware, AUTH_HOOK_MARKER, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
10
+ import { secureHeaders as secureHeadersMiddleware, AUTH_HOOK_MARKER, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, _mergePreBodyWithEarlyRejections, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
10
11
  import { COMPRESSION_HOOK_MARKER } from "./compression.js";
11
12
  import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
12
13
  import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
@@ -72,6 +73,7 @@ const INTERNAL_SERVICE_PRESET_DISABLED = Object.freeze([
72
73
  */
73
74
  const INTERNAL_SERVICE_PRESET_KEPT = Object.freeze([
74
75
  "bodyLimitBytes (1 MiB default)",
76
+ "jsonMaxKeys (10k) + jsonMaxDepth (50) structural limits",
75
77
  "requestTimeoutMs (30 s default)",
76
78
  "crashOnUnhandledRejection (production)",
77
79
  "weak session secret refuse-to-boot",
@@ -169,6 +171,8 @@ const DEFAULTS = {
169
171
  bodyLimitBytes: 1024 * 1024,
170
172
  requestTimeoutMs: 30_000,
171
173
  maxHeaderCount: DEFAULT_MAX_HEADER_COUNT,
174
+ jsonMaxKeys: 10_000,
175
+ jsonMaxDepth: 50,
172
176
  validateResponses: true,
173
177
  };
174
178
  const TEXT_ENCODER = new TextEncoder();
@@ -444,6 +448,8 @@ export class App {
444
448
  bodyLimitBytes: resolved.bodyLimitBytes ?? DEFAULTS.bodyLimitBytes,
445
449
  requestTimeoutMs: resolved.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
446
450
  maxHeaderCount: resolved.maxHeaderCount ?? DEFAULTS.maxHeaderCount,
451
+ jsonMaxKeys: resolved.jsonMaxKeys ?? DEFAULTS.jsonMaxKeys,
452
+ jsonMaxDepth: resolved.jsonMaxDepth ?? DEFAULTS.jsonMaxDepth,
447
453
  ...resolved,
448
454
  };
449
455
  this.log =
@@ -572,6 +578,8 @@ export class App {
572
578
  bodyLimitBytes: this.options.bodyLimitBytes,
573
579
  requestTimeoutMs: this.options.requestTimeoutMs,
574
580
  maxHeaderCount: this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT,
581
+ jsonMaxKeys: this.options.jsonMaxKeys ?? DEFAULTS.jsonMaxKeys,
582
+ jsonMaxDepth: this.options.jsonMaxDepth ?? DEFAULTS.jsonMaxDepth,
575
583
  stripServerHeaders: o.stripServerHeaders !== false,
576
584
  production: this.isProduction(),
577
585
  });
@@ -775,6 +783,7 @@ export class App {
775
783
  if (hooks !== null && typeof hooks === "object") {
776
784
  const HOOK_KEYS = [
777
785
  "onRequest",
786
+ "preBody",
778
787
  "beforeHandle",
779
788
  "afterHandle",
780
789
  "onError",
@@ -791,7 +800,7 @@ export class App {
791
800
  "route unguarded.");
792
801
  }
793
802
  if (Object.keys(hooks).length > 0) {
794
- throw new Error("Hooks object carries none of the recognized hook keys (onRequest, " +
803
+ throw new Error("Hooks object carries none of the recognized hook keys (onRequest, preBody, " +
795
804
  "beforeHandle, afterHandle, onError, onSend, onResponse), so it would " +
796
805
  "silently apply no hooks. To compose multiple hook bundles use " +
797
806
  "every(...) / some(...) from @daloyjs/core.");
@@ -1089,21 +1098,17 @@ export class App {
1089
1098
  const docsPath = (opts.path ?? "/docs");
1090
1099
  const ui = opts.ui ?? "scalar";
1091
1100
  const tags = opts.tags ?? ["Docs"];
1092
- // Best-effort lazy read of the host project's package.json so that
1093
- // `new App({ docs: true })` with no explicit `openapi.info` still produces
1094
- // a spec titled after the user's package (`name` title,
1095
- // `version` → version, `description` → description). Silently skipped on
1096
- // edge runtimes that lack `node:fs`.
1097
- const resolveInfo = async () => {
1101
+ // Keep docs generation purely web-standard. Host metadata is explicit so
1102
+ // edge bundlers never need to resolve or stub `node:fs` from the core.
1103
+ const resolveInfo = () => {
1098
1104
  const fromOpenapi = this.options.openapi?.info ?? {};
1099
- const fromPkg = await readHostPackageJsonInfo();
1100
- const title = fromOpenapi.title ?? this.options.title ?? fromPkg.title ?? "DaloyJS API";
1101
- const version = fromOpenapi.version ?? this.options.version ?? fromPkg.version ?? "0.0.0";
1102
- const description = fromOpenapi.description ?? this.options.description ?? fromPkg.description;
1105
+ const title = fromOpenapi.title ?? this.options.title ?? "DaloyJS API";
1106
+ const version = fromOpenapi.version ?? this.options.version ?? "0.0.0";
1107
+ const description = fromOpenapi.description ?? this.options.description;
1103
1108
  return description ? { title, version, description } : { title, version };
1104
1109
  };
1105
1110
  const generate = async () => generateOpenAPI(this, {
1106
- info: await resolveInfo(),
1111
+ info: resolveInfo(),
1107
1112
  ...(this.options.openapi?.servers ? { servers: this.options.openapi.servers } : {}),
1108
1113
  ...(this.options.openapi?.securitySchemes
1109
1114
  ? { securitySchemes: this.options.openapi.securitySchemes }
@@ -1178,7 +1183,7 @@ export class App {
1178
1183
  200: { description: "Interactive API documentation UI." },
1179
1184
  },
1180
1185
  handler: async () => {
1181
- const title = opts.title ?? (await resolveInfo()).title;
1186
+ const title = opts.title ?? resolveInfo().title;
1182
1187
  const html = ui === "swagger"
1183
1188
  ? swaggerUiHtml({
1184
1189
  specUrl: openapiPath,
@@ -1253,16 +1258,15 @@ export class App {
1253
1258
  const yamlPath = opts.yamlPath === false ? null : (opts.yamlPath ?? "/asyncapi.yaml");
1254
1259
  const uiPath = (opts.path ?? "/asyncapi");
1255
1260
  const tags = opts.tags ?? ["AsyncAPI"];
1256
- const resolveInfo = async () => {
1261
+ const resolveInfo = () => {
1257
1262
  const fromOpenapi = this.options.openapi?.info ?? {};
1258
- const fromPkg = await readHostPackageJsonInfo();
1259
- const title = fromOpenapi.title ?? this.options.title ?? fromPkg.title ?? "DaloyJS API";
1260
- const version = fromOpenapi.version ?? this.options.version ?? fromPkg.version ?? "0.0.0";
1263
+ const title = fromOpenapi.title ?? this.options.title ?? "DaloyJS API";
1264
+ const version = fromOpenapi.version ?? this.options.version ?? "0.0.0";
1261
1265
  return { title, version };
1262
1266
  };
1263
1267
  const servers = opts.servers ?? this.asyncapiServersFromOpenAPI();
1264
1268
  const generate = async () => generateAsyncAPI(this, {
1265
- info: await resolveInfo(),
1269
+ info: resolveInfo(),
1266
1270
  ...(servers ? { servers } : {}),
1267
1271
  });
1268
1272
  this.route({
@@ -1463,6 +1467,56 @@ export class App {
1463
1467
  this.resetBootGuardCache();
1464
1468
  return this;
1465
1469
  }
1470
+ /**
1471
+ * Register a literal tuple of independently defined route contracts.
1472
+ *
1473
+ * Unlike repeated statements against an already-declared `App` variable,
1474
+ * this method returns an App whose route tuple includes every supplied
1475
+ * contract. That preserves the exact no-codegen client surface across route
1476
+ * files and feature modules.
1477
+ *
1478
+ * @param definitions - Readonly literal tuple of route definitions.
1479
+ * @returns This App instance widened with every supplied route contract.
1480
+ * @since 1.0.0
1481
+ */
1482
+ registerRoutes(definitions) {
1483
+ for (const definition of definitions)
1484
+ this.route(definition);
1485
+ return this;
1486
+ }
1487
+ get(path, options, handler) {
1488
+ return this.addHttpShorthand("GET", path, options, handler);
1489
+ }
1490
+ post(path, options, handler) {
1491
+ return this.addHttpShorthand("POST", path, options, handler);
1492
+ }
1493
+ put(path, options, handler) {
1494
+ return this.addHttpShorthand("PUT", path, options, handler);
1495
+ }
1496
+ patch(path, options, handler) {
1497
+ return this.addHttpShorthand("PATCH", path, options, handler);
1498
+ }
1499
+ delete(path, options, handler) {
1500
+ return this.addHttpShorthand("DELETE", path, options, handler);
1501
+ }
1502
+ head(path, options, handler) {
1503
+ return this.addHttpShorthand("HEAD", path, options, handler);
1504
+ }
1505
+ addHttpShorthand(method, path, options, possibleHandler) {
1506
+ if (options === null || typeof options !== "object" || typeof possibleHandler !== "function") {
1507
+ throw new TypeError(`app.${method.toLowerCase()}(): expected (path, contract, handler); opaque responses require an explicit contract with acknowledgeNoResponseBodySchema: true`);
1508
+ }
1509
+ const contract = options;
1510
+ return this.route({
1511
+ ...contract,
1512
+ method,
1513
+ path,
1514
+ operationId: typeof contract.operationId === "string"
1515
+ ? contract.operationId
1516
+ : inferOperationId(method, path),
1517
+ handler: possibleHandler,
1518
+ });
1519
+ }
1466
1520
  /**
1467
1521
  * Register a WebSocket route. The handler runs when an HTTP client sends an
1468
1522
  * `Upgrade: websocket` request to `path`; the adapter performs the RFC 6455
@@ -1882,7 +1936,7 @@ export class App {
1882
1936
  const rawText = new TextDecoder().decode(rawBytes);
1883
1937
  let parsed;
1884
1938
  try {
1885
- parsed = safeJsonParse(rawText);
1939
+ parsed = safeJsonParseLimited(rawText, 1000, 20); // CSP reports are small
1886
1940
  }
1887
1941
  catch {
1888
1942
  throw new BadRequestError("Invalid JSON report body");
@@ -2393,10 +2447,13 @@ export class App {
2393
2447
  // `decorations`, iterate headers, or materialize a `Headers`
2394
2448
  // instance just to be thrown away. The 204 OPTIONS preflight branch
2395
2449
  // below uses its own `synthCtx`, so this skip is safe for it too.
2450
+ const coldPreBody = method === "OPTIONS" ? undefined : this.coldPathHooks.preBody;
2396
2451
  const coldGuards = method === "OPTIONS" ? undefined : this.coldPathHooks.beforeHandle;
2397
2452
  const needsCtx = allowed.length > 0 && method === "OPTIONS"
2398
2453
  ? false // OPTIONS path builds synthCtx
2399
- : activeErrorHook !== undefined || coldGuards !== undefined;
2454
+ : activeErrorHook !== undefined ||
2455
+ coldPreBody !== undefined ||
2456
+ coldGuards !== undefined;
2400
2457
  if (needsCtx) {
2401
2458
  // `query` and `headers` are materialized lazily — the common
2402
2459
  // `onError` hook reads `requestId` / path and never touches them,
@@ -2438,6 +2495,18 @@ export class App {
2438
2495
  };
2439
2496
  ctx.set.headers.set("x-request-id", requestId);
2440
2497
  }
2498
+ if (coldPreBody !== undefined) {
2499
+ const guardResult = coldPreBody(ctx);
2500
+ const guarded = isPromiseLike(guardResult) ? await guardResult : guardResult;
2501
+ if (guarded instanceof Response) {
2502
+ copyContextHeaders(ctx, guarded);
2503
+ if (!guarded.headers.has("x-request-id")) {
2504
+ guarded.headers.set("x-request-id", requestId);
2505
+ }
2506
+ const fin = finalizeResponse(guarded, ctx, this.coldPathHooks, stripFingerprint);
2507
+ return isPromiseLike(fin) ? await fin : fin;
2508
+ }
2509
+ }
2441
2510
  if (coldGuards !== undefined) {
2442
2511
  const guardResult = coldGuards(ctx);
2443
2512
  const guarded = isPromiseLike(guardResult) ? await guardResult : guardResult;
@@ -2497,11 +2566,10 @@ export class App {
2497
2566
  if (isPromiseLike(routeOnRequestResult))
2498
2567
  await routeOnRequestResult;
2499
2568
  }
2500
- // buildContext is sync unless a schema validator or body read actually
2501
- // suspends branch on the promise so the fully-sync case never
2502
- // schedules a microtask.
2503
- const builtCtx = buildContext(request, getUrl, match.params, def, this.options);
2504
- ctx = isPromiseLike(builtCtx) ? await builtCtx : builtCtx;
2569
+ // Build a minimal route context before validation or body I/O so cheap
2570
+ // perimeter hooks can reject unauthenticated callers without consuming
2571
+ // an attacker-controlled request stream.
2572
+ ctx = createPreBodyContext(request, getUrl, match.params);
2505
2573
  // Stable two-field write keeps `ctx.state`'s hidden class consistent across
2506
2574
  // requests for the common no-decorator case. The decorations spread only
2507
2575
  // fires when `app.decorate()` was actually called.
@@ -2515,6 +2583,30 @@ export class App {
2515
2583
  const routeDecorations = match.handler.decorations;
2516
2584
  if (routeDecorations !== undefined)
2517
2585
  Object.assign(state, routeDecorations);
2586
+ if (allHooks.preBody !== undefined) {
2587
+ const preBodyResult = allHooks.preBody(ctx);
2588
+ const preBody = isPromiseLike(preBodyResult) ? await preBodyResult : preBodyResult;
2589
+ const overriddenId = state.requestId;
2590
+ if (typeof overriddenId === "string" && overriddenId.length > 0) {
2591
+ requestId = overriddenId;
2592
+ }
2593
+ if (preBody instanceof Response) {
2594
+ assertAcknowledgedSuccessfulHookResponse(preBody, def, "preBody");
2595
+ copyContextHeaders(ctx, preBody);
2596
+ if (!preBody.headers.has("x-request-id"))
2597
+ preBody.headers.set("x-request-id", requestId);
2598
+ if (hasFinalizeHook) {
2599
+ const fin = finalizeResponse(preBody, ctx, allHooks, stripFingerprint);
2600
+ return isPromiseLike(fin) ? await fin : fin;
2601
+ }
2602
+ return finalizeFast(preBody, stripFingerprint);
2603
+ }
2604
+ }
2605
+ // Validation remains sync-first; only an async schema or an actual body
2606
+ // stream read suspends. `beforeHandle` still receives the fully validated
2607
+ // context for compatibility with body-aware middleware.
2608
+ const validatedCtx = validateContext(ctx, def, this.options);
2609
+ ctx = isPromiseLike(validatedCtx) ? await validatedCtx : validatedCtx;
2518
2610
  if (allHooks.beforeHandle !== undefined) {
2519
2611
  const beforeResult = allHooks.beforeHandle(ctx);
2520
2612
  const before = isPromiseLike(beforeResult) ? await beforeResult : beforeResult;
@@ -2526,6 +2618,7 @@ export class App {
2526
2618
  requestId = overriddenId;
2527
2619
  }
2528
2620
  if (before instanceof Response) {
2621
+ assertAcknowledgedSuccessfulHookResponse(before, def, "beforeHandle");
2529
2622
  copyContextHeaders(ctx, before);
2530
2623
  if (!before.headers.has("x-request-id"))
2531
2624
  before.headers.set("x-request-id", requestId);
@@ -2546,16 +2639,18 @@ export class App {
2546
2639
  if (afterReturn !== undefined)
2547
2640
  result = afterReturn;
2548
2641
  }
2549
- // Escape hatch: a handler (or an `afterHandle` transform) may return a
2550
- // raw web-standard `Response` an AI SDK stream, a forwarded upstream
2551
- // response, or any pre-built body that no response schema can describe.
2552
- // It bypasses response-schema validation by design, but is finalized
2553
- // through the exact same path as every other response (and as the
2554
- // `beforeHandle` `Response` passthrough above), so no security control
2555
- // is skipped: `ctx.set` headers (secureHeaders / CORS) are copied on, the
2556
- // request id is added when absent, `onSend` / `onResponse` hooks run,
2557
- // fingerprint headers are stripped, and `HEAD` yields an empty body.
2642
+ // Explicit escape hatch: a handler (or an `afterHandle` transform) may
2643
+ // return a raw web-standard `Response` only when the route acknowledges
2644
+ // that its body is opaque and will not be schema-validated. Without the
2645
+ // acknowledgement, fail closed instead of silently bypassing response
2646
+ // field stripping. Acknowledged responses still use the normal finalizer
2647
+ // so headers, request ids, hooks, fingerprint stripping, and HEAD
2648
+ // semantics remain intact.
2558
2649
  if (result instanceof Response) {
2650
+ if (def.acknowledgeNoResponseBodySchema !== true) {
2651
+ throw new InternalError("Raw Response refused: set acknowledgeNoResponseBodySchema: true on the route " +
2652
+ "to explicitly accept that its response body bypasses schema validation.");
2653
+ }
2559
2654
  copyContextHeaders(ctx, result);
2560
2655
  if (!result.headers.has("x-request-id")) {
2561
2656
  result.headers.set("x-request-id", requestId);
@@ -2837,6 +2932,22 @@ export class App {
2837
2932
  }
2838
2933
  }
2839
2934
  // ---------- helpers ----------
2935
+ /** Derive a stable camel-case operation id from an HTTP method and route path. */
2936
+ function inferOperationId(method, path) {
2937
+ if (path === "/")
2938
+ return `${method.toLowerCase()}Root`;
2939
+ const capitalizeWords = (value) => value
2940
+ .split(/[-_]/)
2941
+ .filter(Boolean)
2942
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
2943
+ .join("");
2944
+ const suffix = path
2945
+ .slice(1)
2946
+ .split("/")
2947
+ .map((segment) => segment.startsWith(":") ? `By${capitalizeWords(segment.slice(1))}` : capitalizeWords(segment))
2948
+ .join("");
2949
+ return `${method.toLowerCase()}${suffix}`;
2950
+ }
2840
2951
  function joinPath(a, b) {
2841
2952
  const left = a.replace(/\/+$/, "");
2842
2953
  const right = b.startsWith("/") ? b : `/${b}`;
@@ -3118,6 +3229,7 @@ function mergeHooks(layers) {
3118
3229
  const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
3119
3230
  const hooks = {
3120
3231
  onRequest: chain(pick("onRequest")),
3232
+ preBody: _mergePreBodyWithEarlyRejections(layers),
3121
3233
  beforeHandle,
3122
3234
  afterHandle: pipeline(pick("afterHandle")),
3123
3235
  onError: firstResponse(pick("onError")),
@@ -3346,6 +3458,20 @@ function copyContextHeaders(ctx, res) {
3346
3458
  res.headers.set(k, v);
3347
3459
  });
3348
3460
  }
3461
+ /**
3462
+ * Refuse a successful opaque hook response unless the route explicitly opts
3463
+ * out of response-body schema protection. Error/denial responses remain
3464
+ * available to authentication and authorization hooks without an opt-out.
3465
+ */
3466
+ function assertAcknowledgedSuccessfulHookResponse(response, def, phase) {
3467
+ if (response.status >= 400 ||
3468
+ def.acknowledgeNoResponseBodySchema === true ||
3469
+ isSchemaValidatedResponse(response)) {
3470
+ return;
3471
+ }
3472
+ throw new InternalError(`Raw ${phase} Response refused: set acknowledgeNoResponseBodySchema: true on the route ` +
3473
+ "to explicitly accept that its successful response body bypasses schema validation.");
3474
+ }
3349
3475
  function hasRequestSchema(request, key) {
3350
3476
  return !!request && !!request[key];
3351
3477
  }
@@ -3453,38 +3579,23 @@ class RequestContext {
3453
3579
  this._hSet = true;
3454
3580
  }
3455
3581
  }
3456
- function buildContext(request, getUrl, rawParams, def, opts) {
3457
- const set = new LazyResponseSet();
3458
- const hasHeadersSchema = !!def.request?.headers;
3459
- const hasQuerySchema = !!def.request?.query;
3582
+ function createPreBodyContext(request, getUrl, rawParams) {
3460
3583
  let headersObj;
3461
3584
  let queryObj;
3462
- const buildHeaders = () => (headersObj ??= headersToObject(request.headers));
3463
- const buildQuery = () => (queryObj ??= queryToObject(getUrl().searchParams));
3464
- let params = rawParams;
3465
- let query;
3466
- let headers;
3467
- let body = undefined;
3585
+ const ctx = new RequestContext(request, rawParams, {}, new LazyResponseSet());
3586
+ ctx._hBuilder = () => (headersObj ??= headersToObject(request.headers));
3587
+ ctx._qBuilder = () => (queryObj ??= queryToObject(getUrl().searchParams));
3588
+ return ctx;
3589
+ }
3590
+ function validateContext(ctx, def, opts) {
3591
+ const hasHeadersSchema = !!def.request?.headers;
3592
+ const hasQuerySchema = !!def.request?.query;
3593
+ const request = ctx.request;
3594
+ const rawParams = ctx.params;
3595
+ const buildHeaders = () => ctx.headers;
3596
+ const buildQuery = () => ctx.query;
3468
3597
  const hasSchema = def.request?.params || def.request?.query || def.request?.headers || def.request?.body;
3469
- const finishContext = () => {
3470
- const ctx = new RequestContext(request, params, {}, set);
3471
- ctx.body = body;
3472
- if (hasQuerySchema) {
3473
- ctx._q = query;
3474
- ctx._qSet = true;
3475
- }
3476
- else {
3477
- ctx._qBuilder = buildQuery;
3478
- }
3479
- if (hasHeadersSchema) {
3480
- ctx._h = headers;
3481
- ctx._hSet = true;
3482
- }
3483
- else {
3484
- ctx._hBuilder = buildHeaders;
3485
- }
3486
- return ctx;
3487
- };
3598
+ const finishContext = () => ctx;
3488
3599
  if (!hasSchema) {
3489
3600
  return finishContext();
3490
3601
  }
@@ -3497,11 +3608,11 @@ function buildContext(request, getUrl, rawParams, def, opts) {
3497
3608
  const r = def.request.body["~standard"].validate(raw);
3498
3609
  if (isPromiseLike(r)) {
3499
3610
  return r.then((resolved) => {
3500
- body = applyChecked(resolved, "body");
3611
+ ctx.body = applyChecked(resolved, "body");
3501
3612
  return finishContext();
3502
3613
  });
3503
3614
  }
3504
- body = applyChecked(r, "body");
3615
+ ctx.body = applyChecked(r, "body");
3505
3616
  return finishContext();
3506
3617
  };
3507
3618
  const stepBody = () => {
@@ -3517,7 +3628,7 @@ function buildContext(request, getUrl, rawParams, def, opts) {
3517
3628
  if (!allowed.some((a) => ct.includes(a))) {
3518
3629
  throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
3519
3630
  }
3520
- const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
3631
+ const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart, opts.jsonMaxKeys, opts.jsonMaxDepth);
3521
3632
  if (isPromiseLike(raw))
3522
3633
  return raw.then(validateBodyAndFinish);
3523
3634
  return validateBodyAndFinish(raw);
@@ -3528,11 +3639,11 @@ function buildContext(request, getUrl, rawParams, def, opts) {
3528
3639
  const r = def.request.headers["~standard"].validate(buildHeaders());
3529
3640
  if (isPromiseLike(r)) {
3530
3641
  return r.then((resolved) => {
3531
- headers = applyChecked(resolved, "headers");
3642
+ ctx.headers = applyChecked(resolved, "headers");
3532
3643
  return stepBody();
3533
3644
  });
3534
3645
  }
3535
- headers = applyChecked(r, "headers");
3646
+ ctx.headers = applyChecked(r, "headers");
3536
3647
  return stepBody();
3537
3648
  };
3538
3649
  const stepQuery = () => {
@@ -3541,22 +3652,22 @@ function buildContext(request, getUrl, rawParams, def, opts) {
3541
3652
  const r = def.request.query["~standard"].validate(buildQuery());
3542
3653
  if (isPromiseLike(r)) {
3543
3654
  return r.then((resolved) => {
3544
- query = applyChecked(resolved, "query");
3655
+ ctx.query = applyChecked(resolved, "query");
3545
3656
  return stepHeaders();
3546
3657
  });
3547
3658
  }
3548
- query = applyChecked(r, "query");
3659
+ ctx.query = applyChecked(r, "query");
3549
3660
  return stepHeaders();
3550
3661
  };
3551
3662
  if (def.request?.params) {
3552
3663
  const r = def.request.params["~standard"].validate(rawParams);
3553
3664
  if (isPromiseLike(r)) {
3554
3665
  return r.then((resolved) => {
3555
- params = applyChecked(resolved, "params");
3666
+ ctx.params = applyChecked(resolved, "params");
3556
3667
  return stepQuery();
3557
3668
  });
3558
3669
  }
3559
- params = applyChecked(r, "params");
3670
+ ctx.params = applyChecked(r, "params");
3560
3671
  }
3561
3672
  return stepQuery();
3562
3673
  }
@@ -3621,10 +3732,13 @@ function readBodyBytesFast(req, limit) {
3621
3732
  }
3622
3733
  return undefined;
3623
3734
  }
3624
- function parseJsonBodyBytes(bytes) {
3735
+ function parseJsonBodyBytes(bytes, maxKeys, maxDepth) {
3625
3736
  if (bytes.byteLength === 0)
3626
3737
  return undefined;
3627
- return safeJsonParse(TEXT_DECODER.decode(bytes));
3738
+ const text = TEXT_DECODER.decode(bytes);
3739
+ // Use the limited parser when limits are enabled; falls back to safe behavior
3740
+ // when limits are disabled (0 / negative).
3741
+ return safeJsonParseLimited(text, maxKeys, maxDepth);
3628
3742
  }
3629
3743
  function parseUrlencodedBodyBytes(bytes) {
3630
3744
  const params = new URLSearchParams(TEXT_DECODER.decode(bytes));
@@ -3646,12 +3760,12 @@ function parseUrlencodedBodyBytes(bytes) {
3646
3760
  * streaming `readBodyLimited` promise otherwise. All parsing keeps the
3647
3761
  * prototype-pollution-safe semantics of the previous implementation.
3648
3762
  */
3649
- function readBody(req, ct, limit, multipart) {
3763
+ function readBody(req, ct, limit, multipart, jsonMaxKeys = 10_000, jsonMaxDepth = 50) {
3650
3764
  if (ct.includes("application/json")) {
3651
3765
  const fast = readBodyBytesFast(req, limit);
3652
3766
  if (fast !== undefined)
3653
- return parseJsonBodyBytes(fast);
3654
- return readBodyLimited(req, limit).then(parseJsonBodyBytes);
3767
+ return parseJsonBodyBytes(fast, jsonMaxKeys, jsonMaxDepth);
3768
+ return readBodyLimited(req, limit).then((b) => parseJsonBodyBytes(b, jsonMaxKeys, jsonMaxDepth));
3655
3769
  }
3656
3770
  if (ct.includes("application/x-www-form-urlencoded")) {
3657
3771
  const fast = readBodyBytesFast(req, limit);
@@ -3918,103 +4032,3 @@ function serializeErr(err) {
3918
4032
  export function createApp(options = {}) {
3919
4033
  return new App(options);
3920
4034
  }
3921
- const PACKAGE_JSON_CACHE = {};
3922
- /**
3923
- * Best-effort lazy read of the host project's `package.json` so that
3924
- * `new App({ docs: true })` with no explicit `openapi.info` still produces
3925
- * a spec titled after the user's package. Reads `package.json` first; if
3926
- * none is found while walking up from `process.cwd()`, falls back to
3927
- * `deno.json` / `deno.jsonc` so Deno projects get the same DX without a
3928
- * `package.json`. Returns an empty object on edge runtimes (Cloudflare
3929
- * Workers) where `node:fs` is absent, on any I/O or parse
3930
- * error, and when nothing is found.
3931
- *
3932
- * The result is memoized at module scope: manifests do not change
3933
- * during a process lifetime and we never want this to add latency to
3934
- * subsequent docs requests.
3935
- */
3936
- function readHostPackageJsonInfo() {
3937
- if (PACKAGE_JSON_CACHE.value !== undefined)
3938
- return PACKAGE_JSON_CACHE.value;
3939
- const promise = (async () => {
3940
- const empty = {};
3941
- const proc = globalThis.process;
3942
- if (!proc || typeof proc.cwd !== "function")
3943
- return empty;
3944
- let fs;
3945
- let path;
3946
- try {
3947
- fs = await import("node:fs");
3948
- path = await import("node:path");
3949
- }
3950
- catch {
3951
- return empty;
3952
- }
3953
- let dir;
3954
- try {
3955
- dir = proc.cwd();
3956
- }
3957
- catch {
3958
- return empty;
3959
- }
3960
- const parseManifest = (raw, allowComments) => {
3961
- // deno.jsonc allows // line comments and /* block */ comments. Strip
3962
- // them before parsing — naively, but well enough for typical manifests.
3963
- const text = allowComments
3964
- ? raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\])\/\/.*$/gm, "$1")
3965
- : raw;
3966
- return JSON.parse(text);
3967
- };
3968
- const extractInfo = (json) => {
3969
- const result = {};
3970
- if (typeof json.name === "string" && json.name.length > 0) {
3971
- result.title = json.name;
3972
- }
3973
- if (typeof json.version === "string" && json.version.length > 0) {
3974
- result.version = json.version;
3975
- }
3976
- if (typeof json.description === "string" && json.description.length > 0) {
3977
- result.description = json.description;
3978
- }
3979
- return result;
3980
- };
3981
- // Walk up to the filesystem root looking for a manifest. Cap depth so
3982
- // a deeply-nested cwd can't cause excessive stat calls. At each level,
3983
- // prefer package.json, then deno.json, then deno.jsonc.
3984
- for (let i = 0; i < 12; i++) {
3985
- const pkg = path.join(dir, "package.json");
3986
- const denoJson = path.join(dir, "deno.json");
3987
- const denoJsonc = path.join(dir, "deno.jsonc");
3988
- try {
3989
- if (fs.existsSync(pkg)) {
3990
- return extractInfo(parseManifest(fs.readFileSync(pkg, "utf8"), false));
3991
- }
3992
- if (fs.existsSync(denoJson)) {
3993
- return extractInfo(parseManifest(fs.readFileSync(denoJson, "utf8"), false));
3994
- }
3995
- if (fs.existsSync(denoJsonc)) {
3996
- return extractInfo(parseManifest(fs.readFileSync(denoJsonc, "utf8"), true));
3997
- }
3998
- }
3999
- catch {
4000
- return empty;
4001
- }
4002
- const parent = path.dirname(dir);
4003
- if (parent === dir)
4004
- break;
4005
- dir = parent;
4006
- }
4007
- return empty;
4008
- })();
4009
- PACKAGE_JSON_CACHE.value = promise;
4010
- return promise;
4011
- }
4012
- /**
4013
- * Test helper: clear the cached package.json read so each test starts
4014
- * from a fresh lookup. Not part of the public API.
4015
- *
4016
- * @internal
4017
- */
4018
- export function _resetPackageJsonCacheForTests() {
4019
- PACKAGE_JSON_CACHE.value = undefined;
4020
- }
package/dist/cli.js CHANGED
@@ -704,6 +704,44 @@ async function runDoctor(opts, io) {
704
704
  "header-count amplification defence.",
705
705
  });
706
706
  }
707
+ // JSON structural limits audit. The new jsonMaxKeys / jsonMaxDepth
708
+ // guards protect against hash-flood / deep-nesting DoS inside the byte
709
+ // limit. Surface when disabled (0) or raised to an implausibly high
710
+ // value.
711
+ const jsonMaxKeys = o.jsonMaxKeys;
712
+ if (jsonMaxKeys === 0) {
713
+ findings.push({
714
+ level: "warn",
715
+ code: "audit.jsonMaxKeys.disabled",
716
+ message: "jsonMaxKeys is 0 — the wide-object / hash-flood structural limit " +
717
+ "is disabled. An attacker can send tens or hundreds of thousands " +
718
+ "of keys in a body that still fits under bodyLimitBytes.",
719
+ });
720
+ }
721
+ else if (typeof jsonMaxKeys === "number" && jsonMaxKeys > 100_000) {
722
+ findings.push({
723
+ level: "warn",
724
+ code: "audit.jsonMaxKeys.blanket",
725
+ message: `jsonMaxKeys is ${jsonMaxKeys} (> 100k). A cap this high weakens ` +
726
+ "protection against wide-object DoS payloads.",
727
+ });
728
+ }
729
+ const jsonMaxDepth = o.jsonMaxDepth;
730
+ if (jsonMaxDepth === 0) {
731
+ findings.push({
732
+ level: "warn",
733
+ code: "audit.jsonMaxDepth.disabled",
734
+ message: "jsonMaxDepth is 0 — deep nesting DoS protection is disabled.",
735
+ });
736
+ }
737
+ else if (typeof jsonMaxDepth === "number" && jsonMaxDepth > 200) {
738
+ findings.push({
739
+ level: "warn",
740
+ code: "audit.jsonMaxDepth.blanket",
741
+ message: `jsonMaxDepth is ${jsonMaxDepth} (> 200). Extremely deep JSON is ` +
742
+ "almost never legitimate and can amplify CPU during validation.",
743
+ });
744
+ }
707
745
  // Idle-timeout / request-timeout audit. Reaffirms the
708
746
  // existing requestTimeoutMs check; also surface an explicit zero
709
747
  // idleTimeoutMs in production. The framework also keeps adapter
@@ -921,7 +959,9 @@ function aiResponses(responses) {
921
959
  for (const [status, spec] of Object.entries(responses)) {
922
960
  if (!spec)
923
961
  continue;
924
- const entry = { description: spec.description };
962
+ const entry = {
963
+ description: spec.description ?? `HTTP ${status} response`,
964
+ };
925
965
  if (spec.body)
926
966
  entry.body = aiSchema(spec.body);
927
967
  if (spec.examples)