@daloyjs/core 1.0.0-beta.3 → 1.0.0-beta.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.
- package/README.md +49 -46
- package/dist/app.d.ts +42 -21
- package/dist/app.js +167 -116
- package/dist/docs.d.ts +44 -0
- package/dist/docs.js +47 -8
- package/dist/index.d.ts +17 -17
- package/dist/index.js +5 -5
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/tenancy.d.ts +16 -7
- package/dist/tenancy.js +16 -7
- package/dist/types.d.ts +26 -1
- package/package.json +6 -5
package/dist/app.js
CHANGED
|
@@ -2,17 +2,17 @@ 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
4
|
import { validate } from "./schema.js";
|
|
5
|
-
import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey } from "./security.js";
|
|
5
|
+
import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
|
|
6
6
|
import { createLogger, noopLogger } from "./logger.js";
|
|
7
7
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
8
8
|
import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
9
9
|
import { generateAsyncAPI, asyncapiToYAML, } from "./asyncapi.js";
|
|
10
10
|
import { secureHeaders as secureHeadersMiddleware, 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";
|
|
11
11
|
import { COMPRESSION_HOOK_MARKER } from "./compression.js";
|
|
12
|
-
import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER
|
|
13
|
-
import { loadShedding as loadSheddingMiddleware } from "./load-shedding.js";
|
|
12
|
+
import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
|
|
13
|
+
import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
|
|
14
14
|
import { httpMetrics, MetricsRegistry, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
|
|
15
|
-
import { Scheduler
|
|
15
|
+
import { Scheduler } from "./scheduler.js";
|
|
16
16
|
import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
|
|
17
17
|
import { assertBehindProxy } from "./conn-info.js";
|
|
18
18
|
const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
|
|
@@ -260,10 +260,10 @@ export class App {
|
|
|
260
260
|
inflight = 0;
|
|
261
261
|
draining = false;
|
|
262
262
|
/**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
263
|
+
* CORS origin allowlist predicates from the currently active group-level
|
|
264
|
+
* hooks. Used for unmatched routes; matched routes use the snapshot stored
|
|
265
|
+
* on their compiled route so later `app.use(cors(...))` calls do not
|
|
266
|
+
* retroactively loosen earlier routes.
|
|
267
267
|
*/
|
|
268
268
|
corsOriginAllows = [];
|
|
269
269
|
/**
|
|
@@ -289,9 +289,9 @@ export class App {
|
|
|
289
289
|
*/
|
|
290
290
|
responseBodySchemaAuditDone = false;
|
|
291
291
|
/**
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
292
|
+
* Cached merge of `options.hooks` only. Used on the cold 404/405 path
|
|
293
|
+
* and as the baseline for cross-origin guard decisions when no route
|
|
294
|
+
* matches.
|
|
295
295
|
*/
|
|
296
296
|
_globalHooksCache;
|
|
297
297
|
_globalCorsAllowsCache;
|
|
@@ -307,6 +307,19 @@ export class App {
|
|
|
307
307
|
}
|
|
308
308
|
return this._globalCorsAllowsCache;
|
|
309
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Cached merge of `options.hooks` plus every `app.use()` group hook, used on
|
|
312
|
+
* the cold dispatch path (404, 405, and OPTIONS preflight) so perimeter
|
|
313
|
+
* `beforeHandle` guards registered via `app.use()` still cover requests that
|
|
314
|
+
* match no route.
|
|
315
|
+
*/
|
|
316
|
+
_coldPathHooksCache;
|
|
317
|
+
get coldPathHooks() {
|
|
318
|
+
if (this._coldPathHooksCache === undefined) {
|
|
319
|
+
this._coldPathHooksCache = mergeHooks([this.options.hooks ?? {}, ...this.groupHooks]);
|
|
320
|
+
}
|
|
321
|
+
return this._coldPathHooksCache;
|
|
322
|
+
}
|
|
310
323
|
constructor(options = {}) {
|
|
311
324
|
const resolved = applySecurityPreset(options);
|
|
312
325
|
this.options = {
|
|
@@ -319,8 +332,7 @@ export class App {
|
|
|
319
332
|
this.log =
|
|
320
333
|
options.logger === false
|
|
321
334
|
? noopLogger
|
|
322
|
-
: options.logger &&
|
|
323
|
-
typeof options.logger.info === "function"
|
|
335
|
+
: options.logger && typeof options.logger.info === "function"
|
|
324
336
|
? options.logger
|
|
325
337
|
: createLogger({ level: options.logger?.level ?? "info" });
|
|
326
338
|
this.warnOnEnvMismatch();
|
|
@@ -373,7 +385,7 @@ export class App {
|
|
|
373
385
|
"If you really need this in production, also pass " +
|
|
374
386
|
"acknowledgeInsecureDefaults: true to confirm. Prefer per-feature opt-outs " +
|
|
375
387
|
"(secureHeaders: false, corsCrossOriginGuard: false, crashOnUnhandledRejection: false, " +
|
|
376
|
-
|
|
388
|
+
'trustProxy: false, csrf: "off") instead. ' +
|
|
377
389
|
"See https://daloyjs.dev/docs/security/secure-defaults-enforcement.");
|
|
378
390
|
}
|
|
379
391
|
if (!insecureDefaultsLoggedThisProcess) {
|
|
@@ -438,9 +450,7 @@ export class App {
|
|
|
438
450
|
secureHeaders: o.secureDefaults !== false && o.secureHeaders !== false,
|
|
439
451
|
corsCrossOriginGuard: o.secureDefaults !== false && o.corsCrossOriginGuard !== false,
|
|
440
452
|
csrf: o.csrf === "off" ? "off" : "on",
|
|
441
|
-
crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined
|
|
442
|
-
? "default"
|
|
443
|
-
: o.crashOnUnhandledRejection,
|
|
453
|
+
crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined ? "default" : o.crashOnUnhandledRejection,
|
|
444
454
|
trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
|
|
445
455
|
bodyLimitBytes: this.options.bodyLimitBytes,
|
|
446
456
|
requestTimeoutMs: this.options.requestTimeoutMs,
|
|
@@ -464,8 +474,7 @@ export class App {
|
|
|
464
474
|
if (this.options.secureDefaults === false)
|
|
465
475
|
return;
|
|
466
476
|
if (this.options.secureHeaders !== false) {
|
|
467
|
-
const opts = this.options.secureHeaders &&
|
|
468
|
-
typeof this.options.secureHeaders === "object"
|
|
477
|
+
const opts = this.options.secureHeaders && typeof this.options.secureHeaders === "object"
|
|
469
478
|
? this.options.secureHeaders
|
|
470
479
|
: {};
|
|
471
480
|
const auto = secureHeadersMiddleware(opts);
|
|
@@ -474,9 +483,7 @@ export class App {
|
|
|
474
483
|
}
|
|
475
484
|
// Opt-in load-shedding pressure monitor.
|
|
476
485
|
if (this.options.loadShedding) {
|
|
477
|
-
const lsOpts = typeof this.options.loadShedding === "object"
|
|
478
|
-
? this.options.loadShedding
|
|
479
|
-
: {};
|
|
486
|
+
const lsOpts = typeof this.options.loadShedding === "object" ? this.options.loadShedding : {};
|
|
480
487
|
this.groupHooks.push(loadSheddingMiddleware(lsOpts));
|
|
481
488
|
}
|
|
482
489
|
}
|
|
@@ -583,10 +590,7 @@ export class App {
|
|
|
583
590
|
return;
|
|
584
591
|
if (this.options.corsCrossOriginGuard === false)
|
|
585
592
|
return;
|
|
586
|
-
if (method !== "POST" &&
|
|
587
|
-
method !== "PUT" &&
|
|
588
|
-
method !== "PATCH" &&
|
|
589
|
-
method !== "DELETE") {
|
|
593
|
+
if (method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") {
|
|
590
594
|
return;
|
|
591
595
|
}
|
|
592
596
|
const origin = request.headers.get("origin");
|
|
@@ -623,6 +627,42 @@ export class App {
|
|
|
623
627
|
* a misconfigured surface.
|
|
624
628
|
*/
|
|
625
629
|
assertSecureHookConfig(hooks) {
|
|
630
|
+
// Always-on correctness guard (independent of secureDefaults / environment).
|
|
631
|
+
// A hook bundle must be a single Hooks object. Passing an ARRAY — or any
|
|
632
|
+
// object carrying none of the recognized hook keys — is a silent no-op: the
|
|
633
|
+
// framework reads `.beforeHandle` / `.onSend` / ... off it, finds
|
|
634
|
+
// `undefined`, and applies NOTHING. A route that looks guarded
|
|
635
|
+
// (`hooks: [ipRestriction(...), bearerAuth(...)]`) would then ship wide open.
|
|
636
|
+
// TypeScript already rejects an array literal here; this catches JS callers,
|
|
637
|
+
// spreads, and `as`-casts, where the silent runtime skip is the dangerous part.
|
|
638
|
+
if (hooks !== null && typeof hooks === "object") {
|
|
639
|
+
const HOOK_KEYS = [
|
|
640
|
+
"onRequest",
|
|
641
|
+
"beforeHandle",
|
|
642
|
+
"afterHandle",
|
|
643
|
+
"onError",
|
|
644
|
+
"onSend",
|
|
645
|
+
"onResponse",
|
|
646
|
+
];
|
|
647
|
+
const carriesAHook = HOOK_KEYS.some((k) => typeof hooks[k] === "function");
|
|
648
|
+
if (!carriesAHook) {
|
|
649
|
+
if (Array.isArray(hooks)) {
|
|
650
|
+
throw new Error("Hooks must be a single Hooks object, not an array. To run multiple " +
|
|
651
|
+
"hook bundles (e.g. ipRestriction + bearerAuth) on one route, compose " +
|
|
652
|
+
"them with every(...) (all must pass) or some(...) (any may pass) from " +
|
|
653
|
+
"@daloyjs/core. Passing an array silently applies NO hooks, leaving the " +
|
|
654
|
+
"route unguarded.");
|
|
655
|
+
}
|
|
656
|
+
if (Object.keys(hooks).length > 0) {
|
|
657
|
+
throw new Error("Hooks object carries none of the recognized hook keys (onRequest, " +
|
|
658
|
+
"beforeHandle, afterHandle, onError, onSend, onResponse), so it would " +
|
|
659
|
+
"silently apply no hooks. To compose multiple hook bundles use " +
|
|
660
|
+
"every(...) / some(...) from @daloyjs/core.");
|
|
661
|
+
}
|
|
662
|
+
// An empty object `{}` carries no hook and makes no false promise of one;
|
|
663
|
+
// it is an explicit no-op, equivalent to omitting `hooks`, and is allowed.
|
|
664
|
+
}
|
|
665
|
+
}
|
|
626
666
|
if (this.options.secureDefaults === false)
|
|
627
667
|
return;
|
|
628
668
|
const record = hooks;
|
|
@@ -668,8 +708,7 @@ export class App {
|
|
|
668
708
|
* `test`, which is a known, non-production answer).
|
|
669
709
|
*/
|
|
670
710
|
isEnvIndeterminate() {
|
|
671
|
-
if (this.options.env !== undefined ||
|
|
672
|
-
this.options.production !== undefined) {
|
|
711
|
+
if (this.options.env !== undefined || this.options.production !== undefined) {
|
|
673
712
|
return false;
|
|
674
713
|
}
|
|
675
714
|
const nodeEnv = typeof process !== "undefined" && typeof process.env !== "undefined"
|
|
@@ -881,15 +920,11 @@ export class App {
|
|
|
881
920
|
};
|
|
882
921
|
const generate = async () => generateOpenAPI(this, {
|
|
883
922
|
info: await resolveInfo(),
|
|
884
|
-
...(this.options.openapi?.servers
|
|
885
|
-
? { servers: this.options.openapi.servers }
|
|
886
|
-
: {}),
|
|
923
|
+
...(this.options.openapi?.servers ? { servers: this.options.openapi.servers } : {}),
|
|
887
924
|
...(this.options.openapi?.securitySchemes
|
|
888
925
|
? { securitySchemes: this.options.openapi.securitySchemes }
|
|
889
926
|
: {}),
|
|
890
|
-
...(this.options.openapi?.webhooks
|
|
891
|
-
? { webhooks: this.options.openapi.webhooks }
|
|
892
|
-
: {}),
|
|
927
|
+
...(this.options.openapi?.webhooks ? { webhooks: this.options.openapi.webhooks } : {}),
|
|
893
928
|
});
|
|
894
929
|
this.route({
|
|
895
930
|
method: "GET",
|
|
@@ -961,6 +996,7 @@ export class App {
|
|
|
961
996
|
title,
|
|
962
997
|
configuration: opts.swagger,
|
|
963
998
|
assets: opts.assets,
|
|
999
|
+
auth: opts.auth,
|
|
964
1000
|
})
|
|
965
1001
|
: ui === "redoc"
|
|
966
1002
|
? redocHtml({
|
|
@@ -968,12 +1004,14 @@ export class App {
|
|
|
968
1004
|
title,
|
|
969
1005
|
configuration: opts.redoc,
|
|
970
1006
|
assets: opts.assets,
|
|
1007
|
+
auth: opts.auth,
|
|
971
1008
|
})
|
|
972
1009
|
: scalarHtml({
|
|
973
1010
|
specUrl: openapiPath,
|
|
974
1011
|
title,
|
|
975
1012
|
configuration: scalarConfigurationWithPreferredAuth(opts.scalar, this.options.openapi?.securitySchemes),
|
|
976
1013
|
assets: opts.assets,
|
|
1014
|
+
auth: opts.auth,
|
|
977
1015
|
});
|
|
978
1016
|
return {
|
|
979
1017
|
status: 200,
|
|
@@ -1023,9 +1061,7 @@ export class App {
|
|
|
1023
1061
|
*/
|
|
1024
1062
|
mountAsyncAPI(opts) {
|
|
1025
1063
|
const jsonPath = (opts.jsonPath ?? "/asyncapi.json");
|
|
1026
|
-
const yamlPath = opts.yamlPath === false
|
|
1027
|
-
? null
|
|
1028
|
-
: (opts.yamlPath ?? "/asyncapi.yaml");
|
|
1064
|
+
const yamlPath = opts.yamlPath === false ? null : (opts.yamlPath ?? "/asyncapi.yaml");
|
|
1029
1065
|
const uiPath = (opts.path ?? "/asyncapi");
|
|
1030
1066
|
const tags = opts.tags ?? ["AsyncAPI"];
|
|
1031
1067
|
const resolveInfo = async () => {
|
|
@@ -1203,10 +1239,7 @@ export class App {
|
|
|
1203
1239
|
...corsOriginAllowsFromHooks([globalHookLayer]),
|
|
1204
1240
|
...corsOriginAllows,
|
|
1205
1241
|
];
|
|
1206
|
-
const securityMarkers = securityMarkersFromHooks([
|
|
1207
|
-
globalHookLayer,
|
|
1208
|
-
...sources,
|
|
1209
|
-
]);
|
|
1242
|
+
const securityMarkers = securityMarkersFromHooks([globalHookLayer, ...sources]);
|
|
1210
1243
|
this.router.add(def.method, fullPath, { def: merged, hooks, mergedHooks, hasFinalizeHook, corsOriginAllows, fullCorsOriginAllows }, def.operationId);
|
|
1211
1244
|
// `routes` is statically a readonly tuple so the typed client can infer
|
|
1212
1245
|
// per-route methods; at runtime it is a growable array, so we push through
|
|
@@ -1262,7 +1295,7 @@ export class App {
|
|
|
1262
1295
|
handler.acknowledgeCrossOriginUpgrade !== true) {
|
|
1263
1296
|
throw new Error(`app.ws(${JSON.stringify(fullPath)}): production WebSocket routes must ` +
|
|
1264
1297
|
"guard against Cross-Site WebSocket Hijacking (CSWSH). Set " +
|
|
1265
|
-
|
|
1298
|
+
'{ allowedOrigins: "same-origin" } or an explicit origin allowlist, ' +
|
|
1266
1299
|
"or pass { acknowledgeCrossOriginUpgrade: true } for an intentionally " +
|
|
1267
1300
|
"public route. See https://daloyjs.dev/docs/websocket " +
|
|
1268
1301
|
"and CVE-2026-27148 (Storybook) for the attack pattern.");
|
|
@@ -1332,9 +1365,7 @@ export class App {
|
|
|
1332
1365
|
*/
|
|
1333
1366
|
readinesscheck(opts = {}) {
|
|
1334
1367
|
this.registerHealthRoute("readinesscheck", opts, () => {
|
|
1335
|
-
if (this.draining ||
|
|
1336
|
-
this.pendingPlugins.size > 0 ||
|
|
1337
|
-
this.pluginBootError.failed) {
|
|
1368
|
+
if (this.draining || this.pendingPlugins.size > 0 || this.pluginBootError.failed) {
|
|
1338
1369
|
return {
|
|
1339
1370
|
status: 503,
|
|
1340
1371
|
body: { status: "not-ready" },
|
|
@@ -1379,9 +1410,7 @@ export class App {
|
|
|
1379
1410
|
metrics(opts = {}) {
|
|
1380
1411
|
const path = (opts.path ?? "/metrics");
|
|
1381
1412
|
const registry = opts.registry ?? new MetricsRegistry();
|
|
1382
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
1383
|
-
? null
|
|
1384
|
-
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1413
|
+
const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1385
1414
|
const token = opts.token;
|
|
1386
1415
|
// Refuse-to-boot: an unauthenticated metrics scrape in production is a
|
|
1387
1416
|
// documented info-disclosure surface (route inventory, latency
|
|
@@ -1406,9 +1435,8 @@ export class App {
|
|
|
1406
1435
|
buckets: opts.buckets,
|
|
1407
1436
|
exclude,
|
|
1408
1437
|
}));
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
: null;
|
|
1438
|
+
this._coldPathHooksCache = undefined;
|
|
1439
|
+
const buckets = rateLimitConfig ? new Map() : null;
|
|
1412
1440
|
this.route({
|
|
1413
1441
|
method: "GET",
|
|
1414
1442
|
path,
|
|
@@ -1512,9 +1540,7 @@ export class App {
|
|
|
1512
1540
|
const isHealth = kind === "healthcheck";
|
|
1513
1541
|
const defaultPath = (isHealth ? "/healthz" : "/readyz");
|
|
1514
1542
|
const path = (opts.path ?? defaultPath);
|
|
1515
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
1516
|
-
? null
|
|
1517
|
-
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1543
|
+
const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1518
1544
|
const token = opts.token;
|
|
1519
1545
|
// Refuse-to-boot: unauthenticated health/ready probes in
|
|
1520
1546
|
// production are a documented info-disclosure surface (process uptime,
|
|
@@ -1528,9 +1554,7 @@ export class App {
|
|
|
1528
1554
|
`Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
|
|
1529
1555
|
`to acknowledge that this probe is reachable without credentials.`);
|
|
1530
1556
|
}
|
|
1531
|
-
const buckets = rateLimitConfig
|
|
1532
|
-
? new Map()
|
|
1533
|
-
: null;
|
|
1557
|
+
const buckets = rateLimitConfig ? new Map() : null;
|
|
1534
1558
|
this.route({
|
|
1535
1559
|
method: "GET",
|
|
1536
1560
|
path,
|
|
@@ -1587,8 +1611,8 @@ export class App {
|
|
|
1587
1611
|
* when omitted). Returns `204 No Content` so browsers stop retrying.
|
|
1588
1612
|
*
|
|
1589
1613
|
* Combine with `secureHeaders({ reportingEndpoints, reportTo })` to wire
|
|
1590
|
-
|
|
1591
|
-
|
|
1614
|
+
* the browser to this endpoint. The route is registered as publicly
|
|
1615
|
+
* reachable; that is required for the browser
|
|
1592
1616
|
* Reporting API to send to it.
|
|
1593
1617
|
*
|
|
1594
1618
|
*/
|
|
@@ -1601,12 +1625,8 @@ export class App {
|
|
|
1601
1625
|
if (!Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > HARD_MAX) {
|
|
1602
1626
|
throw new Error(`cspReportRoute(): maxBodyBytes must be a positive integer <= ${HARD_MAX}.`);
|
|
1603
1627
|
}
|
|
1604
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
1605
|
-
|
|
1606
|
-
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1607
|
-
const buckets = rateLimitConfig
|
|
1608
|
-
? new Map()
|
|
1609
|
-
: null;
|
|
1628
|
+
const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1629
|
+
const buckets = rateLimitConfig ? new Map() : null;
|
|
1610
1630
|
const log = this.log;
|
|
1611
1631
|
// Only log report bodies when explicitly enabled. In
|
|
1612
1632
|
// production this is opt-in; in development the body is included by
|
|
@@ -1723,10 +1743,7 @@ export class App {
|
|
|
1723
1743
|
child.bootGuard = this.bootGuard;
|
|
1724
1744
|
child.log = this.log;
|
|
1725
1745
|
child.prefix = joinPath(this.prefix, prefix);
|
|
1726
|
-
child.groupHooks = [
|
|
1727
|
-
...this.groupHooks,
|
|
1728
|
-
...(config.hooks ? [config.hooks] : []),
|
|
1729
|
-
];
|
|
1746
|
+
child.groupHooks = [...this.groupHooks, ...(config.hooks ? [config.hooks] : [])];
|
|
1730
1747
|
child.corsOriginAllows = corsOriginAllowsFromHooks(child.groupHooks);
|
|
1731
1748
|
child.groupTags = [...this.groupTags, ...(config.tags ?? [])];
|
|
1732
1749
|
child.groupAuth = config.auth ?? this.groupAuth;
|
|
@@ -1770,12 +1787,12 @@ export class App {
|
|
|
1770
1787
|
// "set only if absent" semantics mean the second installation would be
|
|
1771
1788
|
// a silent no-op).
|
|
1772
1789
|
if (hooks[SECURE_HEADERS_MARKER] === true) {
|
|
1773
|
-
const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] ===
|
|
1774
|
-
true);
|
|
1790
|
+
const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] === true);
|
|
1775
1791
|
if (autoIdx >= 0)
|
|
1776
1792
|
this.groupHooks.splice(autoIdx, 1);
|
|
1777
1793
|
}
|
|
1778
1794
|
this.groupHooks.push(hooks);
|
|
1795
|
+
this._coldPathHooksCache = undefined;
|
|
1779
1796
|
if (hooks[CORS_HOOK_MARKER] === true) {
|
|
1780
1797
|
this.corsOriginAllows = corsOriginAllowsFromHooks(this.groupHooks);
|
|
1781
1798
|
}
|
|
@@ -1818,10 +1835,10 @@ export class App {
|
|
|
1818
1835
|
const hooks = { [ext.event]: ext.handler };
|
|
1819
1836
|
this.groupHooks.push(hooks);
|
|
1820
1837
|
}
|
|
1838
|
+
this._coldPathHooksCache = undefined;
|
|
1821
1839
|
}
|
|
1822
1840
|
decorate(key, value, opts = {}) {
|
|
1823
|
-
if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
|
|
1824
|
-
opts.override !== true) {
|
|
1841
|
+
if (Object.prototype.hasOwnProperty.call(this.decorations, key) && opts.override !== true) {
|
|
1825
1842
|
// Namespace-protected decorators. Refuse to silently
|
|
1826
1843
|
// shadow an existing decoration; emit a once-per-process warn naming
|
|
1827
1844
|
// both decorators on the explicit-override path.
|
|
@@ -1969,9 +1986,7 @@ export class App {
|
|
|
1969
1986
|
this.log.error({ err, plugin: event.name }, "onPluginInstalled listener failed");
|
|
1970
1987
|
}
|
|
1971
1988
|
}
|
|
1972
|
-
return promises.length > 0
|
|
1973
|
-
? Promise.all(promises).then(() => undefined)
|
|
1974
|
-
: undefined;
|
|
1989
|
+
return promises.length > 0 ? Promise.all(promises).then(() => undefined) : undefined;
|
|
1975
1990
|
}
|
|
1976
1991
|
/**
|
|
1977
1992
|
* Wait until every async plugin registered with {@link App.register} has
|
|
@@ -2108,7 +2123,10 @@ export class App {
|
|
|
2108
2123
|
this.assertCrossOriginAllowed(request, requestUrl, method, match.handler.fullCorsOriginAllows);
|
|
2109
2124
|
}
|
|
2110
2125
|
else {
|
|
2111
|
-
this.assertCrossOriginAllowed(request, requestUrl, method, [
|
|
2126
|
+
this.assertCrossOriginAllowed(request, requestUrl, method, [
|
|
2127
|
+
...this.globalCorsAllows,
|
|
2128
|
+
...this.corsOriginAllows,
|
|
2129
|
+
]);
|
|
2112
2130
|
}
|
|
2113
2131
|
if (!match || internalHidden) {
|
|
2114
2132
|
if (internalHidden) {
|
|
@@ -2132,9 +2150,10 @@ export class App {
|
|
|
2132
2150
|
// `decorations`, iterate headers, or materialize a `Headers`
|
|
2133
2151
|
// instance just to be thrown away. The 204 OPTIONS preflight branch
|
|
2134
2152
|
// below uses its own `synthCtx`, so this skip is safe for it too.
|
|
2153
|
+
const coldGuards = method === "OPTIONS" ? undefined : this.coldPathHooks.beforeHandle;
|
|
2135
2154
|
const needsCtx = allowed.length > 0 && method === "OPTIONS"
|
|
2136
2155
|
? false // OPTIONS path builds synthCtx
|
|
2137
|
-
: activeErrorHook !== undefined;
|
|
2156
|
+
: activeErrorHook !== undefined || coldGuards !== undefined;
|
|
2138
2157
|
if (needsCtx) {
|
|
2139
2158
|
// `query` and `headers` are materialized lazily — the common
|
|
2140
2159
|
// `onError` hook reads `requestId` / path and never touches them,
|
|
@@ -2158,20 +2177,36 @@ export class App {
|
|
|
2158
2177
|
const qs = hi === -1 ? reqUrl.slice(qi + 1) : reqUrl.slice(qi + 1, hi);
|
|
2159
2178
|
return (_query = Object.fromEntries(new URLSearchParams(qs)));
|
|
2160
2179
|
},
|
|
2161
|
-
set query(v) {
|
|
2180
|
+
set query(v) {
|
|
2181
|
+
_query = v;
|
|
2182
|
+
},
|
|
2162
2183
|
get headers() {
|
|
2163
2184
|
return (_headers ??= headersToObject(reqRef.headers));
|
|
2164
2185
|
},
|
|
2165
|
-
set headers(v) {
|
|
2186
|
+
set headers(v) {
|
|
2187
|
+
_headers = v;
|
|
2188
|
+
},
|
|
2166
2189
|
body: undefined,
|
|
2167
2190
|
state: { ...this.decorations, requestId, log },
|
|
2168
|
-
set:
|
|
2191
|
+
set: new LazyResponseSet(),
|
|
2169
2192
|
// Cast through `unknown`: this is a deliberately minimal bootstrap
|
|
2170
2193
|
// context for the cold 404 path (no user state populated yet), so
|
|
2171
2194
|
// it must compile even when a consumer augments `AppState`.
|
|
2172
2195
|
};
|
|
2173
2196
|
ctx.set.headers.set("x-request-id", requestId);
|
|
2174
2197
|
}
|
|
2198
|
+
if (coldGuards !== undefined) {
|
|
2199
|
+
const guardResult = coldGuards(ctx);
|
|
2200
|
+
const guarded = isPromiseLike(guardResult) ? await guardResult : guardResult;
|
|
2201
|
+
if (guarded instanceof Response) {
|
|
2202
|
+
copyContextHeaders(ctx, guarded);
|
|
2203
|
+
if (!guarded.headers.has("x-request-id")) {
|
|
2204
|
+
guarded.headers.set("x-request-id", requestId);
|
|
2205
|
+
}
|
|
2206
|
+
const fin = finalizeResponse(guarded, ctx, this.coldPathHooks, stripFingerprint);
|
|
2207
|
+
return isPromiseLike(fin) ? await fin : fin;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2175
2210
|
if (allowed.length > 0) {
|
|
2176
2211
|
if (method === "OPTIONS") {
|
|
2177
2212
|
// Synthesize a preflight: let global hooks (e.g. CORS) intercept;
|
|
@@ -2185,14 +2220,13 @@ export class App {
|
|
|
2185
2220
|
headers: headersToObject(request.headers),
|
|
2186
2221
|
body: undefined,
|
|
2187
2222
|
state: { ...this.decorations, requestId, log },
|
|
2188
|
-
set:
|
|
2223
|
+
set: new LazyResponseSet(),
|
|
2189
2224
|
};
|
|
2190
|
-
const preflightHooks =
|
|
2191
|
-
this.options.hooks ?? {},
|
|
2192
|
-
...this.groupHooks,
|
|
2193
|
-
]);
|
|
2225
|
+
const preflightHooks = this.coldPathHooks;
|
|
2194
2226
|
const interceptedResult = preflightHooks.beforeHandle?.(synthCtx);
|
|
2195
|
-
const intercepted = isPromiseLike(interceptedResult)
|
|
2227
|
+
const intercepted = isPromiseLike(interceptedResult)
|
|
2228
|
+
? await interceptedResult
|
|
2229
|
+
: interceptedResult;
|
|
2196
2230
|
if (intercepted instanceof Response) {
|
|
2197
2231
|
copyContextHeaders(synthCtx, intercepted);
|
|
2198
2232
|
const fin = finalizeResponse(intercepted, synthCtx, preflightHooks, stripFingerprint);
|
|
@@ -2260,8 +2294,41 @@ export class App {
|
|
|
2260
2294
|
if (afterReturn !== undefined)
|
|
2261
2295
|
result = afterReturn;
|
|
2262
2296
|
}
|
|
2297
|
+
// Escape hatch: a handler (or an `afterHandle` transform) may return a
|
|
2298
|
+
// raw web-standard `Response` — an AI SDK stream, a forwarded upstream
|
|
2299
|
+
// response, or any pre-built body that no response schema can describe.
|
|
2300
|
+
// It bypasses response-schema validation by design, but is finalized
|
|
2301
|
+
// through the exact same path as every other response (and as the
|
|
2302
|
+
// `beforeHandle` `Response` passthrough above), so no security control
|
|
2303
|
+
// is skipped: `ctx.set` headers (secureHeaders / CORS) are copied on, the
|
|
2304
|
+
// request id is added when absent, `onSend` / `onResponse` hooks run,
|
|
2305
|
+
// fingerprint headers are stripped, and `HEAD` yields an empty body.
|
|
2306
|
+
if (result instanceof Response) {
|
|
2307
|
+
copyContextHeaders(ctx, result);
|
|
2308
|
+
if (!result.headers.has("x-request-id")) {
|
|
2309
|
+
result.headers.set("x-request-id", requestId);
|
|
2310
|
+
}
|
|
2311
|
+
let finalizedRaw;
|
|
2312
|
+
if (hasFinalizeHook) {
|
|
2313
|
+
const fin = finalizeResponse(result, ctx, allHooks, stripFingerprint);
|
|
2314
|
+
finalizedRaw = isPromiseLike(fin) ? await fin : fin;
|
|
2315
|
+
}
|
|
2316
|
+
else {
|
|
2317
|
+
finalizedRaw = finalizeFast(result, stripFingerprint);
|
|
2318
|
+
}
|
|
2319
|
+
if (method === "HEAD") {
|
|
2320
|
+
return new Response(null, {
|
|
2321
|
+
status: finalizedRaw.status,
|
|
2322
|
+
statusText: finalizedRaw.statusText,
|
|
2323
|
+
headers: finalizedRaw.headers,
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
return finalizedRaw;
|
|
2327
|
+
}
|
|
2263
2328
|
const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
|
|
2264
|
-
let response = isPromiseLike(serializeResultRes)
|
|
2329
|
+
let response = isPromiseLike(serializeResultRes)
|
|
2330
|
+
? await serializeResultRes
|
|
2331
|
+
: serializeResultRes;
|
|
2265
2332
|
copyContextHeaders(ctx, response);
|
|
2266
2333
|
// `serializeResult` always builds a fresh Response with no request id —
|
|
2267
2334
|
// skip the `has()` probe and set directly. Saves one undici contains()
|
|
@@ -2312,8 +2379,7 @@ export class App {
|
|
|
2312
2379
|
// problem errors short-circuit before any signal/option lookup.
|
|
2313
2380
|
const isHttp = err instanceof HttpError;
|
|
2314
2381
|
const disconnectCode = isHttp ? 0 : (this.options.disconnectStatusCode ?? 499);
|
|
2315
|
-
if (disconnectCode > 0 &&
|
|
2316
|
-
request.signal?.aborted === true) {
|
|
2382
|
+
if (disconnectCode > 0 && request.signal?.aborted === true) {
|
|
2317
2383
|
log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
|
|
2318
2384
|
const res = new Response(null, {
|
|
2319
2385
|
status: disconnectCode,
|
|
@@ -2370,9 +2436,7 @@ export class App {
|
|
|
2370
2436
|
* @returns Fulfills with the `Response` produced by the matching handler.
|
|
2371
2437
|
*/
|
|
2372
2438
|
request(input, init) {
|
|
2373
|
-
const url = typeof input === "string" && input.startsWith("/")
|
|
2374
|
-
? `http://test.local${input}`
|
|
2375
|
-
: input;
|
|
2439
|
+
const url = typeof input === "string" && input.startsWith("/") ? `http://test.local${input}` : input;
|
|
2376
2440
|
const req = url instanceof Request ? url : new Request(url, init);
|
|
2377
2441
|
return this.fetch(req);
|
|
2378
2442
|
}
|
|
@@ -2527,9 +2591,7 @@ function healthRouteKey(request) {
|
|
|
2527
2591
|
// so even apps that trust forwarded headers should not let an attacker
|
|
2528
2592
|
// bypass the per-IP cap by spoofing the header. Fall back to a constant
|
|
2529
2593
|
// key when no proxy header is available (single shared bucket).
|
|
2530
|
-
return
|
|
2531
|
-
request.headers.get("fly-client-ip") ??
|
|
2532
|
-
"global");
|
|
2594
|
+
return request.headers.get("x-real-ip") ?? request.headers.get("fly-client-ip") ?? "global";
|
|
2533
2595
|
}
|
|
2534
2596
|
function corsOriginAllowsFromHooks(layers) {
|
|
2535
2597
|
const allows = [];
|
|
@@ -2643,9 +2705,7 @@ export function topoSortExtensions(exts) {
|
|
|
2643
2705
|
const bHeaders = b.responseHeaders;
|
|
2644
2706
|
if (!bHeaders || bHeaders.length === 0)
|
|
2645
2707
|
continue;
|
|
2646
|
-
const overlap = bHeaders
|
|
2647
|
-
.map((h) => h.toLowerCase())
|
|
2648
|
-
.filter((h) => aSet.has(h));
|
|
2708
|
+
const overlap = bHeaders.map((h) => h.toLowerCase()).filter((h) => aSet.has(h));
|
|
2649
2709
|
if (overlap.length === 0)
|
|
2650
2710
|
continue;
|
|
2651
2711
|
const declared = (a.before ?? []).includes(b.name) ||
|
|
@@ -2676,10 +2736,7 @@ function securityMarkersFromHooks(layers) {
|
|
|
2676
2736
|
return { hasSession, hasCsrf };
|
|
2677
2737
|
}
|
|
2678
2738
|
function isStateChangingMethod(method) {
|
|
2679
|
-
return
|
|
2680
|
-
method === "PUT" ||
|
|
2681
|
-
method === "PATCH" ||
|
|
2682
|
-
method === "DELETE");
|
|
2739
|
+
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
2683
2740
|
}
|
|
2684
2741
|
/**
|
|
2685
2742
|
* Extract the pathname from a fully-qualified request URL without
|
|
@@ -2710,9 +2767,7 @@ function getPathnameFast(url) {
|
|
|
2710
2767
|
return url.slice(pathStart, end);
|
|
2711
2768
|
}
|
|
2712
2769
|
function mergeHooks(layers) {
|
|
2713
|
-
const pick = (key) => layers
|
|
2714
|
-
.map((h) => h[key])
|
|
2715
|
-
.filter((f) => typeof f === "function");
|
|
2770
|
+
const pick = (key) => layers.map((h) => h[key]).filter((f) => typeof f === "function");
|
|
2716
2771
|
const requiredScopes = requiredScopesFromHooks(layers);
|
|
2717
2772
|
const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
|
|
2718
2773
|
const hooks = {
|
|
@@ -2813,9 +2868,7 @@ function scalarConfigurationWithPreferredAuth(configuration, schemes) {
|
|
|
2813
2868
|
return {
|
|
2814
2869
|
...(configuration ?? {}),
|
|
2815
2870
|
authentication: {
|
|
2816
|
-
...(authentication &&
|
|
2817
|
-
typeof authentication === "object" &&
|
|
2818
|
-
!Array.isArray(authentication)
|
|
2871
|
+
...(authentication && typeof authentication === "object" && !Array.isArray(authentication)
|
|
2819
2872
|
? authentication
|
|
2820
2873
|
: {}),
|
|
2821
2874
|
preferredSecurityScheme,
|
|
@@ -3105,7 +3158,8 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3105
3158
|
}
|
|
3106
3159
|
if (def.request?.body) {
|
|
3107
3160
|
const ct = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
3108
|
-
const allowed = def.accepts ??
|
|
3161
|
+
const allowed = def.accepts ??
|
|
3162
|
+
opts.allowedContentTypes ?? [
|
|
3109
3163
|
"application/json",
|
|
3110
3164
|
"application/x-www-form-urlencoded",
|
|
3111
3165
|
"multipart/form-data",
|
|
@@ -3147,7 +3201,7 @@ function toIssues(issues) {
|
|
|
3147
3201
|
return issues.map((i) => ({
|
|
3148
3202
|
message: i.message,
|
|
3149
3203
|
path: (i.path ?? [])
|
|
3150
|
-
.map((p) => typeof p === "object" && p && "key" in p ? p.key : p)
|
|
3204
|
+
.map((p) => (typeof p === "object" && p && "key" in p ? p.key : p))
|
|
3151
3205
|
.join("."),
|
|
3152
3206
|
}));
|
|
3153
3207
|
}
|
|
@@ -3204,8 +3258,7 @@ async function readBody(req, ct, limit, multipart) {
|
|
|
3204
3258
|
typeof v.arrayBuffer === "function";
|
|
3205
3259
|
if (isFile) {
|
|
3206
3260
|
files++;
|
|
3207
|
-
if (multipart?.maxFileBytes !== undefined &&
|
|
3208
|
-
v.size > multipart.maxFileBytes) {
|
|
3261
|
+
if (multipart?.maxFileBytes !== undefined && v.size > multipart.maxFileBytes) {
|
|
3209
3262
|
throw new PayloadTooLargeError(multipart.maxFileBytes);
|
|
3210
3263
|
}
|
|
3211
3264
|
}
|
|
@@ -3444,9 +3497,7 @@ function readHostPackageJsonInfo() {
|
|
|
3444
3497
|
// deno.jsonc allows // line comments and /* block */ comments. Strip
|
|
3445
3498
|
// them before parsing — naively, but well enough for typical manifests.
|
|
3446
3499
|
const text = allowComments
|
|
3447
|
-
? raw
|
|
3448
|
-
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
3449
|
-
.replace(/(^|[^:\\])\/\/.*$/gm, "$1")
|
|
3500
|
+
? raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\])\/\/.*$/gm, "$1")
|
|
3450
3501
|
: raw;
|
|
3451
3502
|
return JSON.parse(text);
|
|
3452
3503
|
};
|