@daloyjs/core 1.0.0-rc.2 → 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/README.md +96 -30
- package/dist/adapters/vercel.d.ts +8 -10
- package/dist/adapters/vercel.js +0 -2
- package/dist/app.d.ts +125 -8
- package/dist/app.js +244 -183
- package/dist/cli.js +41 -1
- package/dist/client.d.ts +28 -11
- package/dist/client.js +29 -6
- package/dist/combine.d.ts +11 -11
- package/dist/combine.js +90 -47
- package/dist/docs.d.ts +5 -9
- package/dist/docs.js +36 -14
- package/dist/idempotency.js +2 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/internal-response.d.ts +15 -0
- package/dist/internal-response.js +27 -0
- package/dist/jwk.d.ts +11 -7
- package/dist/jwk.js +11 -7
- package/dist/mcp.js +11 -6
- package/dist/middleware.d.ts +48 -7
- package/dist/middleware.js +96 -40
- package/dist/mtls.d.ts +6 -5
- package/dist/mtls.js +3 -9
- package/dist/openapi.js +1 -1
- package/dist/pagination.js +4 -1
- package/dist/response-cache.js +2 -1
- package/dist/safe-redirect.d.ts +4 -1
- package/dist/safe-redirect.js +4 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +21 -0
- package/dist/security.js +89 -0
- package/dist/tenancy.d.ts +2 -2
- package/dist/types.d.ts +85 -20
- package/dist/types.js +16 -1
- package/package.json +7 -1
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,
|
|
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
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
|
|
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
|
|
1100
|
-
const
|
|
1101
|
-
const
|
|
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:
|
|
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 ??
|
|
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 =
|
|
1261
|
+
const resolveInfo = () => {
|
|
1257
1262
|
const fromOpenapi = this.options.openapi?.info ?? {};
|
|
1258
|
-
const
|
|
1259
|
-
const
|
|
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:
|
|
1269
|
+
info: resolveInfo(),
|
|
1266
1270
|
...(servers ? { servers } : {}),
|
|
1267
1271
|
});
|
|
1268
1272
|
this.route({
|
|
@@ -1433,7 +1437,22 @@ export class App {
|
|
|
1433
1437
|
...corsOriginAllows,
|
|
1434
1438
|
];
|
|
1435
1439
|
const securityMarkers = securityMarkersFromHooks([globalHookLayer, ...sources]);
|
|
1436
|
-
|
|
1440
|
+
// Capture the decorations of this route's scope at registration time so the
|
|
1441
|
+
// dispatch hot path reads the scope-local bag rather than the root app's.
|
|
1442
|
+
// `this.decorations` is this scope's own bag (the root's, or the child's
|
|
1443
|
+
// copy created in `group()`), so plugin-local decorations never leak to
|
|
1444
|
+
// sibling plugins or the root. `undefined` when empty keeps the per-request
|
|
1445
|
+
// `Object.assign` skipped for the common no-decoration case.
|
|
1446
|
+
const decorations = this.decorationsCount === 0 ? undefined : this.decorations;
|
|
1447
|
+
this.router.add(def.method, fullPath, {
|
|
1448
|
+
def: merged,
|
|
1449
|
+
hooks,
|
|
1450
|
+
mergedHooks,
|
|
1451
|
+
hasFinalizeHook,
|
|
1452
|
+
corsOriginAllows,
|
|
1453
|
+
fullCorsOriginAllows,
|
|
1454
|
+
decorations,
|
|
1455
|
+
}, def.operationId);
|
|
1437
1456
|
// `routes` is statically a readonly tuple so the typed client can infer
|
|
1438
1457
|
// per-route methods; at runtime it is a growable array, so we push through
|
|
1439
1458
|
// a mutable view.
|
|
@@ -1448,6 +1467,56 @@ export class App {
|
|
|
1448
1467
|
this.resetBootGuardCache();
|
|
1449
1468
|
return this;
|
|
1450
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
|
+
}
|
|
1451
1520
|
/**
|
|
1452
1521
|
* Register a WebSocket route. The handler runs when an HTTP client sends an
|
|
1453
1522
|
* `Upgrade: websocket` request to `path`; the adapter performs the RFC 6455
|
|
@@ -1867,7 +1936,7 @@ export class App {
|
|
|
1867
1936
|
const rawText = new TextDecoder().decode(rawBytes);
|
|
1868
1937
|
let parsed;
|
|
1869
1938
|
try {
|
|
1870
|
-
parsed =
|
|
1939
|
+
parsed = safeJsonParseLimited(rawText, 1000, 20); // CSP reports are small
|
|
1871
1940
|
}
|
|
1872
1941
|
catch {
|
|
1873
1942
|
throw new BadRequestError("Invalid JSON report body");
|
|
@@ -1946,7 +2015,15 @@ export class App {
|
|
|
1946
2015
|
child.corsOriginAllows = corsOriginAllowsFromHooks(child.groupHooks);
|
|
1947
2016
|
child.groupTags = [...this.groupTags, ...(config.tags ?? [])];
|
|
1948
2017
|
child.groupAuth = config.auth ?? this.groupAuth;
|
|
1949
|
-
|
|
2018
|
+
// Encapsulate decorations (Fastify-style): the child gets its OWN bag
|
|
2019
|
+
// seeded with a copy of the parent's current decorations. App-level
|
|
2020
|
+
// decorations therefore flow inward, while `child.decorate()` mutates only
|
|
2021
|
+
// this copy — it never leaks back to the parent or sideways to sibling
|
|
2022
|
+
// plugins (each sibling copies the parent bag at its own registration).
|
|
2023
|
+
// Routes snapshot this bag in `route()`; the shared reference used before
|
|
2024
|
+
// made decorators app-global instead of scoped.
|
|
2025
|
+
child.decorations = { ...this.decorations };
|
|
2026
|
+
child.decorationsCount = this.decorationsCount;
|
|
1950
2027
|
child.installedPlugins = this.installedPlugins;
|
|
1951
2028
|
child.closeHooks = this.closeHooks;
|
|
1952
2029
|
child.idleConnectionCloseHooks = this.idleConnectionCloseHooks;
|
|
@@ -2032,6 +2109,25 @@ export class App {
|
|
|
2032
2109
|
* });
|
|
2033
2110
|
* ```
|
|
2034
2111
|
*
|
|
2112
|
+
* ### Scoping (Fastify-style encapsulation)
|
|
2113
|
+
*
|
|
2114
|
+
* Decorations are **scoped to the app instance they are declared on**, and
|
|
2115
|
+
* are captured per route at registration time:
|
|
2116
|
+
*
|
|
2117
|
+
* - Calling `decorate()` on the root app makes the value visible to every
|
|
2118
|
+
* route, including routes inside plugins/groups registered *afterwards*
|
|
2119
|
+
* (app-level decorations flow inward).
|
|
2120
|
+
* - Calling `decorate()` on the child app passed to {@link App.register} /
|
|
2121
|
+
* {@link App.group} scopes the value to **that plugin's routes only** — it
|
|
2122
|
+
* does not leak to sibling plugins or back to the root.
|
|
2123
|
+
*
|
|
2124
|
+
* Each route binds to its scope's decorations when it is registered, so
|
|
2125
|
+
* **decorate before registering the routes that consume the value** (the same
|
|
2126
|
+
* ordering Fastify requires). Adding a decoration to a scope that already had
|
|
2127
|
+
* at least one is picked up by that scope's existing routes; but the first
|
|
2128
|
+
* decoration added to a scope *after* its routes were registered will not
|
|
2129
|
+
* reach them.
|
|
2130
|
+
*
|
|
2035
2131
|
* @param key - Property name on `ctx.state`.
|
|
2036
2132
|
* @param value - Value bound to that property on every request.
|
|
2037
2133
|
* @param opts - Pass `{ override: true }` to replace an existing decoration (logged as a warning).
|
|
@@ -2351,10 +2447,13 @@ export class App {
|
|
|
2351
2447
|
// `decorations`, iterate headers, or materialize a `Headers`
|
|
2352
2448
|
// instance just to be thrown away. The 204 OPTIONS preflight branch
|
|
2353
2449
|
// below uses its own `synthCtx`, so this skip is safe for it too.
|
|
2450
|
+
const coldPreBody = method === "OPTIONS" ? undefined : this.coldPathHooks.preBody;
|
|
2354
2451
|
const coldGuards = method === "OPTIONS" ? undefined : this.coldPathHooks.beforeHandle;
|
|
2355
2452
|
const needsCtx = allowed.length > 0 && method === "OPTIONS"
|
|
2356
2453
|
? false // OPTIONS path builds synthCtx
|
|
2357
|
-
: activeErrorHook !== undefined ||
|
|
2454
|
+
: activeErrorHook !== undefined ||
|
|
2455
|
+
coldPreBody !== undefined ||
|
|
2456
|
+
coldGuards !== undefined;
|
|
2358
2457
|
if (needsCtx) {
|
|
2359
2458
|
// `query` and `headers` are materialized lazily — the common
|
|
2360
2459
|
// `onError` hook reads `requestId` / path and never touches them,
|
|
@@ -2396,6 +2495,18 @@ export class App {
|
|
|
2396
2495
|
};
|
|
2397
2496
|
ctx.set.headers.set("x-request-id", requestId);
|
|
2398
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
|
+
}
|
|
2399
2510
|
if (coldGuards !== undefined) {
|
|
2400
2511
|
const guardResult = coldGuards(ctx);
|
|
2401
2512
|
const guarded = isPromiseLike(guardResult) ? await guardResult : guardResult;
|
|
@@ -2455,19 +2566,47 @@ export class App {
|
|
|
2455
2566
|
if (isPromiseLike(routeOnRequestResult))
|
|
2456
2567
|
await routeOnRequestResult;
|
|
2457
2568
|
}
|
|
2458
|
-
//
|
|
2459
|
-
//
|
|
2460
|
-
//
|
|
2461
|
-
|
|
2462
|
-
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);
|
|
2463
2573
|
// Stable two-field write keeps `ctx.state`'s hidden class consistent across
|
|
2464
2574
|
// requests for the common no-decorator case. The decorations spread only
|
|
2465
2575
|
// fires when `app.decorate()` was actually called.
|
|
2466
2576
|
const state = ctx.state;
|
|
2467
2577
|
state.requestId = requestId;
|
|
2468
2578
|
state.log = log;
|
|
2469
|
-
|
|
2470
|
-
|
|
2579
|
+
// Apply the decorations captured for THIS route's scope (see
|
|
2580
|
+
// `CompiledRoute.decorations`) rather than the root app's bag, so a
|
|
2581
|
+
// plugin's decorations reach only that plugin's routes. `undefined` when
|
|
2582
|
+
// the scope had none keeps the common case allocation-free.
|
|
2583
|
+
const routeDecorations = match.handler.decorations;
|
|
2584
|
+
if (routeDecorations !== undefined)
|
|
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;
|
|
2471
2610
|
if (allHooks.beforeHandle !== undefined) {
|
|
2472
2611
|
const beforeResult = allHooks.beforeHandle(ctx);
|
|
2473
2612
|
const before = isPromiseLike(beforeResult) ? await beforeResult : beforeResult;
|
|
@@ -2479,6 +2618,7 @@ export class App {
|
|
|
2479
2618
|
requestId = overriddenId;
|
|
2480
2619
|
}
|
|
2481
2620
|
if (before instanceof Response) {
|
|
2621
|
+
assertAcknowledgedSuccessfulHookResponse(before, def, "beforeHandle");
|
|
2482
2622
|
copyContextHeaders(ctx, before);
|
|
2483
2623
|
if (!before.headers.has("x-request-id"))
|
|
2484
2624
|
before.headers.set("x-request-id", requestId);
|
|
@@ -2499,16 +2639,18 @@ export class App {
|
|
|
2499
2639
|
if (afterReturn !== undefined)
|
|
2500
2640
|
result = afterReturn;
|
|
2501
2641
|
}
|
|
2502
|
-
//
|
|
2503
|
-
// raw web-standard `Response`
|
|
2504
|
-
//
|
|
2505
|
-
//
|
|
2506
|
-
//
|
|
2507
|
-
//
|
|
2508
|
-
//
|
|
2509
|
-
// request id is added when absent, `onSend` / `onResponse` hooks run,
|
|
2510
|
-
// 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.
|
|
2511
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
|
+
}
|
|
2512
2654
|
copyContextHeaders(ctx, result);
|
|
2513
2655
|
if (!result.headers.has("x-request-id")) {
|
|
2514
2656
|
result.headers.set("x-request-id", requestId);
|
|
@@ -2790,6 +2932,22 @@ export class App {
|
|
|
2790
2932
|
}
|
|
2791
2933
|
}
|
|
2792
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
|
+
}
|
|
2793
2951
|
function joinPath(a, b) {
|
|
2794
2952
|
const left = a.replace(/\/+$/, "");
|
|
2795
2953
|
const right = b.startsWith("/") ? b : `/${b}`;
|
|
@@ -3071,6 +3229,7 @@ function mergeHooks(layers) {
|
|
|
3071
3229
|
const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
|
|
3072
3230
|
const hooks = {
|
|
3073
3231
|
onRequest: chain(pick("onRequest")),
|
|
3232
|
+
preBody: _mergePreBodyWithEarlyRejections(layers),
|
|
3074
3233
|
beforeHandle,
|
|
3075
3234
|
afterHandle: pipeline(pick("afterHandle")),
|
|
3076
3235
|
onError: firstResponse(pick("onError")),
|
|
@@ -3299,6 +3458,20 @@ function copyContextHeaders(ctx, res) {
|
|
|
3299
3458
|
res.headers.set(k, v);
|
|
3300
3459
|
});
|
|
3301
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
|
+
}
|
|
3302
3475
|
function hasRequestSchema(request, key) {
|
|
3303
3476
|
return !!request && !!request[key];
|
|
3304
3477
|
}
|
|
@@ -3406,38 +3579,23 @@ class RequestContext {
|
|
|
3406
3579
|
this._hSet = true;
|
|
3407
3580
|
}
|
|
3408
3581
|
}
|
|
3409
|
-
function
|
|
3410
|
-
const set = new LazyResponseSet();
|
|
3411
|
-
const hasHeadersSchema = !!def.request?.headers;
|
|
3412
|
-
const hasQuerySchema = !!def.request?.query;
|
|
3582
|
+
function createPreBodyContext(request, getUrl, rawParams) {
|
|
3413
3583
|
let headersObj;
|
|
3414
3584
|
let queryObj;
|
|
3415
|
-
const
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
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;
|
|
3421
3597
|
const hasSchema = def.request?.params || def.request?.query || def.request?.headers || def.request?.body;
|
|
3422
|
-
const finishContext = () =>
|
|
3423
|
-
const ctx = new RequestContext(request, params, {}, set);
|
|
3424
|
-
ctx.body = body;
|
|
3425
|
-
if (hasQuerySchema) {
|
|
3426
|
-
ctx._q = query;
|
|
3427
|
-
ctx._qSet = true;
|
|
3428
|
-
}
|
|
3429
|
-
else {
|
|
3430
|
-
ctx._qBuilder = buildQuery;
|
|
3431
|
-
}
|
|
3432
|
-
if (hasHeadersSchema) {
|
|
3433
|
-
ctx._h = headers;
|
|
3434
|
-
ctx._hSet = true;
|
|
3435
|
-
}
|
|
3436
|
-
else {
|
|
3437
|
-
ctx._hBuilder = buildHeaders;
|
|
3438
|
-
}
|
|
3439
|
-
return ctx;
|
|
3440
|
-
};
|
|
3598
|
+
const finishContext = () => ctx;
|
|
3441
3599
|
if (!hasSchema) {
|
|
3442
3600
|
return finishContext();
|
|
3443
3601
|
}
|
|
@@ -3450,11 +3608,11 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3450
3608
|
const r = def.request.body["~standard"].validate(raw);
|
|
3451
3609
|
if (isPromiseLike(r)) {
|
|
3452
3610
|
return r.then((resolved) => {
|
|
3453
|
-
body = applyChecked(resolved, "body");
|
|
3611
|
+
ctx.body = applyChecked(resolved, "body");
|
|
3454
3612
|
return finishContext();
|
|
3455
3613
|
});
|
|
3456
3614
|
}
|
|
3457
|
-
body = applyChecked(r, "body");
|
|
3615
|
+
ctx.body = applyChecked(r, "body");
|
|
3458
3616
|
return finishContext();
|
|
3459
3617
|
};
|
|
3460
3618
|
const stepBody = () => {
|
|
@@ -3470,7 +3628,7 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3470
3628
|
if (!allowed.some((a) => ct.includes(a))) {
|
|
3471
3629
|
throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
|
|
3472
3630
|
}
|
|
3473
|
-
const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
|
|
3631
|
+
const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart, opts.jsonMaxKeys, opts.jsonMaxDepth);
|
|
3474
3632
|
if (isPromiseLike(raw))
|
|
3475
3633
|
return raw.then(validateBodyAndFinish);
|
|
3476
3634
|
return validateBodyAndFinish(raw);
|
|
@@ -3481,11 +3639,11 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3481
3639
|
const r = def.request.headers["~standard"].validate(buildHeaders());
|
|
3482
3640
|
if (isPromiseLike(r)) {
|
|
3483
3641
|
return r.then((resolved) => {
|
|
3484
|
-
headers = applyChecked(resolved, "headers");
|
|
3642
|
+
ctx.headers = applyChecked(resolved, "headers");
|
|
3485
3643
|
return stepBody();
|
|
3486
3644
|
});
|
|
3487
3645
|
}
|
|
3488
|
-
headers = applyChecked(r, "headers");
|
|
3646
|
+
ctx.headers = applyChecked(r, "headers");
|
|
3489
3647
|
return stepBody();
|
|
3490
3648
|
};
|
|
3491
3649
|
const stepQuery = () => {
|
|
@@ -3494,22 +3652,22 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3494
3652
|
const r = def.request.query["~standard"].validate(buildQuery());
|
|
3495
3653
|
if (isPromiseLike(r)) {
|
|
3496
3654
|
return r.then((resolved) => {
|
|
3497
|
-
query = applyChecked(resolved, "query");
|
|
3655
|
+
ctx.query = applyChecked(resolved, "query");
|
|
3498
3656
|
return stepHeaders();
|
|
3499
3657
|
});
|
|
3500
3658
|
}
|
|
3501
|
-
query = applyChecked(r, "query");
|
|
3659
|
+
ctx.query = applyChecked(r, "query");
|
|
3502
3660
|
return stepHeaders();
|
|
3503
3661
|
};
|
|
3504
3662
|
if (def.request?.params) {
|
|
3505
3663
|
const r = def.request.params["~standard"].validate(rawParams);
|
|
3506
3664
|
if (isPromiseLike(r)) {
|
|
3507
3665
|
return r.then((resolved) => {
|
|
3508
|
-
params = applyChecked(resolved, "params");
|
|
3666
|
+
ctx.params = applyChecked(resolved, "params");
|
|
3509
3667
|
return stepQuery();
|
|
3510
3668
|
});
|
|
3511
3669
|
}
|
|
3512
|
-
params = applyChecked(r, "params");
|
|
3670
|
+
ctx.params = applyChecked(r, "params");
|
|
3513
3671
|
}
|
|
3514
3672
|
return stepQuery();
|
|
3515
3673
|
}
|
|
@@ -3574,10 +3732,13 @@ function readBodyBytesFast(req, limit) {
|
|
|
3574
3732
|
}
|
|
3575
3733
|
return undefined;
|
|
3576
3734
|
}
|
|
3577
|
-
function parseJsonBodyBytes(bytes) {
|
|
3735
|
+
function parseJsonBodyBytes(bytes, maxKeys, maxDepth) {
|
|
3578
3736
|
if (bytes.byteLength === 0)
|
|
3579
3737
|
return undefined;
|
|
3580
|
-
|
|
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);
|
|
3581
3742
|
}
|
|
3582
3743
|
function parseUrlencodedBodyBytes(bytes) {
|
|
3583
3744
|
const params = new URLSearchParams(TEXT_DECODER.decode(bytes));
|
|
@@ -3599,12 +3760,12 @@ function parseUrlencodedBodyBytes(bytes) {
|
|
|
3599
3760
|
* streaming `readBodyLimited` promise otherwise. All parsing keeps the
|
|
3600
3761
|
* prototype-pollution-safe semantics of the previous implementation.
|
|
3601
3762
|
*/
|
|
3602
|
-
function readBody(req, ct, limit, multipart) {
|
|
3763
|
+
function readBody(req, ct, limit, multipart, jsonMaxKeys = 10_000, jsonMaxDepth = 50) {
|
|
3603
3764
|
if (ct.includes("application/json")) {
|
|
3604
3765
|
const fast = readBodyBytesFast(req, limit);
|
|
3605
3766
|
if (fast !== undefined)
|
|
3606
|
-
return parseJsonBodyBytes(fast);
|
|
3607
|
-
return readBodyLimited(req, limit).then(parseJsonBodyBytes);
|
|
3767
|
+
return parseJsonBodyBytes(fast, jsonMaxKeys, jsonMaxDepth);
|
|
3768
|
+
return readBodyLimited(req, limit).then((b) => parseJsonBodyBytes(b, jsonMaxKeys, jsonMaxDepth));
|
|
3608
3769
|
}
|
|
3609
3770
|
if (ct.includes("application/x-www-form-urlencoded")) {
|
|
3610
3771
|
const fast = readBodyBytesFast(req, limit);
|
|
@@ -3871,103 +4032,3 @@ function serializeErr(err) {
|
|
|
3871
4032
|
export function createApp(options = {}) {
|
|
3872
4033
|
return new App(options);
|
|
3873
4034
|
}
|
|
3874
|
-
const PACKAGE_JSON_CACHE = {};
|
|
3875
|
-
/**
|
|
3876
|
-
* Best-effort lazy read of the host project's `package.json` so that
|
|
3877
|
-
* `new App({ docs: true })` with no explicit `openapi.info` still produces
|
|
3878
|
-
* a spec titled after the user's package. Reads `package.json` first; if
|
|
3879
|
-
* none is found while walking up from `process.cwd()`, falls back to
|
|
3880
|
-
* `deno.json` / `deno.jsonc` so Deno projects get the same DX without a
|
|
3881
|
-
* `package.json`. Returns an empty object on edge runtimes (Cloudflare
|
|
3882
|
-
* Workers) where `node:fs` is absent, on any I/O or parse
|
|
3883
|
-
* error, and when nothing is found.
|
|
3884
|
-
*
|
|
3885
|
-
* The result is memoized at module scope: manifests do not change
|
|
3886
|
-
* during a process lifetime and we never want this to add latency to
|
|
3887
|
-
* subsequent docs requests.
|
|
3888
|
-
*/
|
|
3889
|
-
function readHostPackageJsonInfo() {
|
|
3890
|
-
if (PACKAGE_JSON_CACHE.value !== undefined)
|
|
3891
|
-
return PACKAGE_JSON_CACHE.value;
|
|
3892
|
-
const promise = (async () => {
|
|
3893
|
-
const empty = {};
|
|
3894
|
-
const proc = globalThis.process;
|
|
3895
|
-
if (!proc || typeof proc.cwd !== "function")
|
|
3896
|
-
return empty;
|
|
3897
|
-
let fs;
|
|
3898
|
-
let path;
|
|
3899
|
-
try {
|
|
3900
|
-
fs = await import("node:fs");
|
|
3901
|
-
path = await import("node:path");
|
|
3902
|
-
}
|
|
3903
|
-
catch {
|
|
3904
|
-
return empty;
|
|
3905
|
-
}
|
|
3906
|
-
let dir;
|
|
3907
|
-
try {
|
|
3908
|
-
dir = proc.cwd();
|
|
3909
|
-
}
|
|
3910
|
-
catch {
|
|
3911
|
-
return empty;
|
|
3912
|
-
}
|
|
3913
|
-
const parseManifest = (raw, allowComments) => {
|
|
3914
|
-
// deno.jsonc allows // line comments and /* block */ comments. Strip
|
|
3915
|
-
// them before parsing — naively, but well enough for typical manifests.
|
|
3916
|
-
const text = allowComments
|
|
3917
|
-
? raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\])\/\/.*$/gm, "$1")
|
|
3918
|
-
: raw;
|
|
3919
|
-
return JSON.parse(text);
|
|
3920
|
-
};
|
|
3921
|
-
const extractInfo = (json) => {
|
|
3922
|
-
const result = {};
|
|
3923
|
-
if (typeof json.name === "string" && json.name.length > 0) {
|
|
3924
|
-
result.title = json.name;
|
|
3925
|
-
}
|
|
3926
|
-
if (typeof json.version === "string" && json.version.length > 0) {
|
|
3927
|
-
result.version = json.version;
|
|
3928
|
-
}
|
|
3929
|
-
if (typeof json.description === "string" && json.description.length > 0) {
|
|
3930
|
-
result.description = json.description;
|
|
3931
|
-
}
|
|
3932
|
-
return result;
|
|
3933
|
-
};
|
|
3934
|
-
// Walk up to the filesystem root looking for a manifest. Cap depth so
|
|
3935
|
-
// a deeply-nested cwd can't cause excessive stat calls. At each level,
|
|
3936
|
-
// prefer package.json, then deno.json, then deno.jsonc.
|
|
3937
|
-
for (let i = 0; i < 12; i++) {
|
|
3938
|
-
const pkg = path.join(dir, "package.json");
|
|
3939
|
-
const denoJson = path.join(dir, "deno.json");
|
|
3940
|
-
const denoJsonc = path.join(dir, "deno.jsonc");
|
|
3941
|
-
try {
|
|
3942
|
-
if (fs.existsSync(pkg)) {
|
|
3943
|
-
return extractInfo(parseManifest(fs.readFileSync(pkg, "utf8"), false));
|
|
3944
|
-
}
|
|
3945
|
-
if (fs.existsSync(denoJson)) {
|
|
3946
|
-
return extractInfo(parseManifest(fs.readFileSync(denoJson, "utf8"), false));
|
|
3947
|
-
}
|
|
3948
|
-
if (fs.existsSync(denoJsonc)) {
|
|
3949
|
-
return extractInfo(parseManifest(fs.readFileSync(denoJsonc, "utf8"), true));
|
|
3950
|
-
}
|
|
3951
|
-
}
|
|
3952
|
-
catch {
|
|
3953
|
-
return empty;
|
|
3954
|
-
}
|
|
3955
|
-
const parent = path.dirname(dir);
|
|
3956
|
-
if (parent === dir)
|
|
3957
|
-
break;
|
|
3958
|
-
dir = parent;
|
|
3959
|
-
}
|
|
3960
|
-
return empty;
|
|
3961
|
-
})();
|
|
3962
|
-
PACKAGE_JSON_CACHE.value = promise;
|
|
3963
|
-
return promise;
|
|
3964
|
-
}
|
|
3965
|
-
/**
|
|
3966
|
-
* Test helper: clear the cached package.json read so each test starts
|
|
3967
|
-
* from a fresh lookup. Not part of the public API.
|
|
3968
|
-
*
|
|
3969
|
-
* @internal
|
|
3970
|
-
*/
|
|
3971
|
-
export function _resetPackageJsonCacheForTests() {
|
|
3972
|
-
PACKAGE_JSON_CACHE.value = undefined;
|
|
3973
|
-
}
|