@daloyjs/core 1.0.0-beta.4 → 1.0.0-beta.6
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 +50 -46
- package/dist/app.d.ts +42 -21
- package/dist/app.js +100 -116
- package/dist/docs.d.ts +44 -0
- package/dist/docs.js +47 -8
- package/dist/index.d.ts +19 -17
- package/dist/index.js +6 -5
- package/dist/mcp.d.ts +432 -0
- package/dist/mcp.js +419 -0
- 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/package.json +11 -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");
|
|
@@ -704,8 +708,7 @@ export class App {
|
|
|
704
708
|
* `test`, which is a known, non-production answer).
|
|
705
709
|
*/
|
|
706
710
|
isEnvIndeterminate() {
|
|
707
|
-
if (this.options.env !== undefined ||
|
|
708
|
-
this.options.production !== undefined) {
|
|
711
|
+
if (this.options.env !== undefined || this.options.production !== undefined) {
|
|
709
712
|
return false;
|
|
710
713
|
}
|
|
711
714
|
const nodeEnv = typeof process !== "undefined" && typeof process.env !== "undefined"
|
|
@@ -917,15 +920,11 @@ export class App {
|
|
|
917
920
|
};
|
|
918
921
|
const generate = async () => generateOpenAPI(this, {
|
|
919
922
|
info: await resolveInfo(),
|
|
920
|
-
...(this.options.openapi?.servers
|
|
921
|
-
? { servers: this.options.openapi.servers }
|
|
922
|
-
: {}),
|
|
923
|
+
...(this.options.openapi?.servers ? { servers: this.options.openapi.servers } : {}),
|
|
923
924
|
...(this.options.openapi?.securitySchemes
|
|
924
925
|
? { securitySchemes: this.options.openapi.securitySchemes }
|
|
925
926
|
: {}),
|
|
926
|
-
...(this.options.openapi?.webhooks
|
|
927
|
-
? { webhooks: this.options.openapi.webhooks }
|
|
928
|
-
: {}),
|
|
927
|
+
...(this.options.openapi?.webhooks ? { webhooks: this.options.openapi.webhooks } : {}),
|
|
929
928
|
});
|
|
930
929
|
this.route({
|
|
931
930
|
method: "GET",
|
|
@@ -997,6 +996,7 @@ export class App {
|
|
|
997
996
|
title,
|
|
998
997
|
configuration: opts.swagger,
|
|
999
998
|
assets: opts.assets,
|
|
999
|
+
auth: opts.auth,
|
|
1000
1000
|
})
|
|
1001
1001
|
: ui === "redoc"
|
|
1002
1002
|
? redocHtml({
|
|
@@ -1004,12 +1004,14 @@ export class App {
|
|
|
1004
1004
|
title,
|
|
1005
1005
|
configuration: opts.redoc,
|
|
1006
1006
|
assets: opts.assets,
|
|
1007
|
+
auth: opts.auth,
|
|
1007
1008
|
})
|
|
1008
1009
|
: scalarHtml({
|
|
1009
1010
|
specUrl: openapiPath,
|
|
1010
1011
|
title,
|
|
1011
1012
|
configuration: scalarConfigurationWithPreferredAuth(opts.scalar, this.options.openapi?.securitySchemes),
|
|
1012
1013
|
assets: opts.assets,
|
|
1014
|
+
auth: opts.auth,
|
|
1013
1015
|
});
|
|
1014
1016
|
return {
|
|
1015
1017
|
status: 200,
|
|
@@ -1059,9 +1061,7 @@ export class App {
|
|
|
1059
1061
|
*/
|
|
1060
1062
|
mountAsyncAPI(opts) {
|
|
1061
1063
|
const jsonPath = (opts.jsonPath ?? "/asyncapi.json");
|
|
1062
|
-
const yamlPath = opts.yamlPath === false
|
|
1063
|
-
? null
|
|
1064
|
-
: (opts.yamlPath ?? "/asyncapi.yaml");
|
|
1064
|
+
const yamlPath = opts.yamlPath === false ? null : (opts.yamlPath ?? "/asyncapi.yaml");
|
|
1065
1065
|
const uiPath = (opts.path ?? "/asyncapi");
|
|
1066
1066
|
const tags = opts.tags ?? ["AsyncAPI"];
|
|
1067
1067
|
const resolveInfo = async () => {
|
|
@@ -1239,10 +1239,7 @@ export class App {
|
|
|
1239
1239
|
...corsOriginAllowsFromHooks([globalHookLayer]),
|
|
1240
1240
|
...corsOriginAllows,
|
|
1241
1241
|
];
|
|
1242
|
-
const securityMarkers = securityMarkersFromHooks([
|
|
1243
|
-
globalHookLayer,
|
|
1244
|
-
...sources,
|
|
1245
|
-
]);
|
|
1242
|
+
const securityMarkers = securityMarkersFromHooks([globalHookLayer, ...sources]);
|
|
1246
1243
|
this.router.add(def.method, fullPath, { def: merged, hooks, mergedHooks, hasFinalizeHook, corsOriginAllows, fullCorsOriginAllows }, def.operationId);
|
|
1247
1244
|
// `routes` is statically a readonly tuple so the typed client can infer
|
|
1248
1245
|
// per-route methods; at runtime it is a growable array, so we push through
|
|
@@ -1298,7 +1295,7 @@ export class App {
|
|
|
1298
1295
|
handler.acknowledgeCrossOriginUpgrade !== true) {
|
|
1299
1296
|
throw new Error(`app.ws(${JSON.stringify(fullPath)}): production WebSocket routes must ` +
|
|
1300
1297
|
"guard against Cross-Site WebSocket Hijacking (CSWSH). Set " +
|
|
1301
|
-
|
|
1298
|
+
'{ allowedOrigins: "same-origin" } or an explicit origin allowlist, ' +
|
|
1302
1299
|
"or pass { acknowledgeCrossOriginUpgrade: true } for an intentionally " +
|
|
1303
1300
|
"public route. See https://daloyjs.dev/docs/websocket " +
|
|
1304
1301
|
"and CVE-2026-27148 (Storybook) for the attack pattern.");
|
|
@@ -1368,9 +1365,7 @@ export class App {
|
|
|
1368
1365
|
*/
|
|
1369
1366
|
readinesscheck(opts = {}) {
|
|
1370
1367
|
this.registerHealthRoute("readinesscheck", opts, () => {
|
|
1371
|
-
if (this.draining ||
|
|
1372
|
-
this.pendingPlugins.size > 0 ||
|
|
1373
|
-
this.pluginBootError.failed) {
|
|
1368
|
+
if (this.draining || this.pendingPlugins.size > 0 || this.pluginBootError.failed) {
|
|
1374
1369
|
return {
|
|
1375
1370
|
status: 503,
|
|
1376
1371
|
body: { status: "not-ready" },
|
|
@@ -1415,9 +1410,7 @@ export class App {
|
|
|
1415
1410
|
metrics(opts = {}) {
|
|
1416
1411
|
const path = (opts.path ?? "/metrics");
|
|
1417
1412
|
const registry = opts.registry ?? new MetricsRegistry();
|
|
1418
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
1419
|
-
? null
|
|
1420
|
-
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1413
|
+
const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1421
1414
|
const token = opts.token;
|
|
1422
1415
|
// Refuse-to-boot: an unauthenticated metrics scrape in production is a
|
|
1423
1416
|
// documented info-disclosure surface (route inventory, latency
|
|
@@ -1442,9 +1435,8 @@ export class App {
|
|
|
1442
1435
|
buckets: opts.buckets,
|
|
1443
1436
|
exclude,
|
|
1444
1437
|
}));
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
: null;
|
|
1438
|
+
this._coldPathHooksCache = undefined;
|
|
1439
|
+
const buckets = rateLimitConfig ? new Map() : null;
|
|
1448
1440
|
this.route({
|
|
1449
1441
|
method: "GET",
|
|
1450
1442
|
path,
|
|
@@ -1548,9 +1540,7 @@ export class App {
|
|
|
1548
1540
|
const isHealth = kind === "healthcheck";
|
|
1549
1541
|
const defaultPath = (isHealth ? "/healthz" : "/readyz");
|
|
1550
1542
|
const path = (opts.path ?? defaultPath);
|
|
1551
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
1552
|
-
? null
|
|
1553
|
-
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1543
|
+
const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1554
1544
|
const token = opts.token;
|
|
1555
1545
|
// Refuse-to-boot: unauthenticated health/ready probes in
|
|
1556
1546
|
// production are a documented info-disclosure surface (process uptime,
|
|
@@ -1564,9 +1554,7 @@ export class App {
|
|
|
1564
1554
|
`Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
|
|
1565
1555
|
`to acknowledge that this probe is reachable without credentials.`);
|
|
1566
1556
|
}
|
|
1567
|
-
const buckets = rateLimitConfig
|
|
1568
|
-
? new Map()
|
|
1569
|
-
: null;
|
|
1557
|
+
const buckets = rateLimitConfig ? new Map() : null;
|
|
1570
1558
|
this.route({
|
|
1571
1559
|
method: "GET",
|
|
1572
1560
|
path,
|
|
@@ -1623,8 +1611,8 @@ export class App {
|
|
|
1623
1611
|
* when omitted). Returns `204 No Content` so browsers stop retrying.
|
|
1624
1612
|
*
|
|
1625
1613
|
* Combine with `secureHeaders({ reportingEndpoints, reportTo })` to wire
|
|
1626
|
-
|
|
1627
|
-
|
|
1614
|
+
* the browser to this endpoint. The route is registered as publicly
|
|
1615
|
+
* reachable; that is required for the browser
|
|
1628
1616
|
* Reporting API to send to it.
|
|
1629
1617
|
*
|
|
1630
1618
|
*/
|
|
@@ -1637,12 +1625,8 @@ export class App {
|
|
|
1637
1625
|
if (!Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > HARD_MAX) {
|
|
1638
1626
|
throw new Error(`cspReportRoute(): maxBodyBytes must be a positive integer <= ${HARD_MAX}.`);
|
|
1639
1627
|
}
|
|
1640
|
-
const rateLimitConfig = opts.rateLimit === false
|
|
1641
|
-
|
|
1642
|
-
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1643
|
-
const buckets = rateLimitConfig
|
|
1644
|
-
? new Map()
|
|
1645
|
-
: null;
|
|
1628
|
+
const rateLimitConfig = opts.rateLimit === false ? null : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1629
|
+
const buckets = rateLimitConfig ? new Map() : null;
|
|
1646
1630
|
const log = this.log;
|
|
1647
1631
|
// Only log report bodies when explicitly enabled. In
|
|
1648
1632
|
// production this is opt-in; in development the body is included by
|
|
@@ -1759,10 +1743,7 @@ export class App {
|
|
|
1759
1743
|
child.bootGuard = this.bootGuard;
|
|
1760
1744
|
child.log = this.log;
|
|
1761
1745
|
child.prefix = joinPath(this.prefix, prefix);
|
|
1762
|
-
child.groupHooks = [
|
|
1763
|
-
...this.groupHooks,
|
|
1764
|
-
...(config.hooks ? [config.hooks] : []),
|
|
1765
|
-
];
|
|
1746
|
+
child.groupHooks = [...this.groupHooks, ...(config.hooks ? [config.hooks] : [])];
|
|
1766
1747
|
child.corsOriginAllows = corsOriginAllowsFromHooks(child.groupHooks);
|
|
1767
1748
|
child.groupTags = [...this.groupTags, ...(config.tags ?? [])];
|
|
1768
1749
|
child.groupAuth = config.auth ?? this.groupAuth;
|
|
@@ -1806,12 +1787,12 @@ export class App {
|
|
|
1806
1787
|
// "set only if absent" semantics mean the second installation would be
|
|
1807
1788
|
// a silent no-op).
|
|
1808
1789
|
if (hooks[SECURE_HEADERS_MARKER] === true) {
|
|
1809
|
-
const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] ===
|
|
1810
|
-
true);
|
|
1790
|
+
const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] === true);
|
|
1811
1791
|
if (autoIdx >= 0)
|
|
1812
1792
|
this.groupHooks.splice(autoIdx, 1);
|
|
1813
1793
|
}
|
|
1814
1794
|
this.groupHooks.push(hooks);
|
|
1795
|
+
this._coldPathHooksCache = undefined;
|
|
1815
1796
|
if (hooks[CORS_HOOK_MARKER] === true) {
|
|
1816
1797
|
this.corsOriginAllows = corsOriginAllowsFromHooks(this.groupHooks);
|
|
1817
1798
|
}
|
|
@@ -1854,10 +1835,10 @@ export class App {
|
|
|
1854
1835
|
const hooks = { [ext.event]: ext.handler };
|
|
1855
1836
|
this.groupHooks.push(hooks);
|
|
1856
1837
|
}
|
|
1838
|
+
this._coldPathHooksCache = undefined;
|
|
1857
1839
|
}
|
|
1858
1840
|
decorate(key, value, opts = {}) {
|
|
1859
|
-
if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
|
|
1860
|
-
opts.override !== true) {
|
|
1841
|
+
if (Object.prototype.hasOwnProperty.call(this.decorations, key) && opts.override !== true) {
|
|
1861
1842
|
// Namespace-protected decorators. Refuse to silently
|
|
1862
1843
|
// shadow an existing decoration; emit a once-per-process warn naming
|
|
1863
1844
|
// both decorators on the explicit-override path.
|
|
@@ -2005,9 +1986,7 @@ export class App {
|
|
|
2005
1986
|
this.log.error({ err, plugin: event.name }, "onPluginInstalled listener failed");
|
|
2006
1987
|
}
|
|
2007
1988
|
}
|
|
2008
|
-
return promises.length > 0
|
|
2009
|
-
? Promise.all(promises).then(() => undefined)
|
|
2010
|
-
: undefined;
|
|
1989
|
+
return promises.length > 0 ? Promise.all(promises).then(() => undefined) : undefined;
|
|
2011
1990
|
}
|
|
2012
1991
|
/**
|
|
2013
1992
|
* Wait until every async plugin registered with {@link App.register} has
|
|
@@ -2144,7 +2123,10 @@ export class App {
|
|
|
2144
2123
|
this.assertCrossOriginAllowed(request, requestUrl, method, match.handler.fullCorsOriginAllows);
|
|
2145
2124
|
}
|
|
2146
2125
|
else {
|
|
2147
|
-
this.assertCrossOriginAllowed(request, requestUrl, method, [
|
|
2126
|
+
this.assertCrossOriginAllowed(request, requestUrl, method, [
|
|
2127
|
+
...this.globalCorsAllows,
|
|
2128
|
+
...this.corsOriginAllows,
|
|
2129
|
+
]);
|
|
2148
2130
|
}
|
|
2149
2131
|
if (!match || internalHidden) {
|
|
2150
2132
|
if (internalHidden) {
|
|
@@ -2168,9 +2150,10 @@ export class App {
|
|
|
2168
2150
|
// `decorations`, iterate headers, or materialize a `Headers`
|
|
2169
2151
|
// instance just to be thrown away. The 204 OPTIONS preflight branch
|
|
2170
2152
|
// below uses its own `synthCtx`, so this skip is safe for it too.
|
|
2153
|
+
const coldGuards = method === "OPTIONS" ? undefined : this.coldPathHooks.beforeHandle;
|
|
2171
2154
|
const needsCtx = allowed.length > 0 && method === "OPTIONS"
|
|
2172
2155
|
? false // OPTIONS path builds synthCtx
|
|
2173
|
-
: activeErrorHook !== undefined;
|
|
2156
|
+
: activeErrorHook !== undefined || coldGuards !== undefined;
|
|
2174
2157
|
if (needsCtx) {
|
|
2175
2158
|
// `query` and `headers` are materialized lazily — the common
|
|
2176
2159
|
// `onError` hook reads `requestId` / path and never touches them,
|
|
@@ -2194,20 +2177,36 @@ export class App {
|
|
|
2194
2177
|
const qs = hi === -1 ? reqUrl.slice(qi + 1) : reqUrl.slice(qi + 1, hi);
|
|
2195
2178
|
return (_query = Object.fromEntries(new URLSearchParams(qs)));
|
|
2196
2179
|
},
|
|
2197
|
-
set query(v) {
|
|
2180
|
+
set query(v) {
|
|
2181
|
+
_query = v;
|
|
2182
|
+
},
|
|
2198
2183
|
get headers() {
|
|
2199
2184
|
return (_headers ??= headersToObject(reqRef.headers));
|
|
2200
2185
|
},
|
|
2201
|
-
set headers(v) {
|
|
2186
|
+
set headers(v) {
|
|
2187
|
+
_headers = v;
|
|
2188
|
+
},
|
|
2202
2189
|
body: undefined,
|
|
2203
2190
|
state: { ...this.decorations, requestId, log },
|
|
2204
|
-
set:
|
|
2191
|
+
set: new LazyResponseSet(),
|
|
2205
2192
|
// Cast through `unknown`: this is a deliberately minimal bootstrap
|
|
2206
2193
|
// context for the cold 404 path (no user state populated yet), so
|
|
2207
2194
|
// it must compile even when a consumer augments `AppState`.
|
|
2208
2195
|
};
|
|
2209
2196
|
ctx.set.headers.set("x-request-id", requestId);
|
|
2210
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
|
+
}
|
|
2211
2210
|
if (allowed.length > 0) {
|
|
2212
2211
|
if (method === "OPTIONS") {
|
|
2213
2212
|
// Synthesize a preflight: let global hooks (e.g. CORS) intercept;
|
|
@@ -2221,14 +2220,13 @@ export class App {
|
|
|
2221
2220
|
headers: headersToObject(request.headers),
|
|
2222
2221
|
body: undefined,
|
|
2223
2222
|
state: { ...this.decorations, requestId, log },
|
|
2224
|
-
set:
|
|
2223
|
+
set: new LazyResponseSet(),
|
|
2225
2224
|
};
|
|
2226
|
-
const preflightHooks =
|
|
2227
|
-
this.options.hooks ?? {},
|
|
2228
|
-
...this.groupHooks,
|
|
2229
|
-
]);
|
|
2225
|
+
const preflightHooks = this.coldPathHooks;
|
|
2230
2226
|
const interceptedResult = preflightHooks.beforeHandle?.(synthCtx);
|
|
2231
|
-
const intercepted = isPromiseLike(interceptedResult)
|
|
2227
|
+
const intercepted = isPromiseLike(interceptedResult)
|
|
2228
|
+
? await interceptedResult
|
|
2229
|
+
: interceptedResult;
|
|
2232
2230
|
if (intercepted instanceof Response) {
|
|
2233
2231
|
copyContextHeaders(synthCtx, intercepted);
|
|
2234
2232
|
const fin = finalizeResponse(intercepted, synthCtx, preflightHooks, stripFingerprint);
|
|
@@ -2328,7 +2326,9 @@ export class App {
|
|
|
2328
2326
|
return finalizedRaw;
|
|
2329
2327
|
}
|
|
2330
2328
|
const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
|
|
2331
|
-
let response = isPromiseLike(serializeResultRes)
|
|
2329
|
+
let response = isPromiseLike(serializeResultRes)
|
|
2330
|
+
? await serializeResultRes
|
|
2331
|
+
: serializeResultRes;
|
|
2332
2332
|
copyContextHeaders(ctx, response);
|
|
2333
2333
|
// `serializeResult` always builds a fresh Response with no request id —
|
|
2334
2334
|
// skip the `has()` probe and set directly. Saves one undici contains()
|
|
@@ -2379,8 +2379,7 @@ export class App {
|
|
|
2379
2379
|
// problem errors short-circuit before any signal/option lookup.
|
|
2380
2380
|
const isHttp = err instanceof HttpError;
|
|
2381
2381
|
const disconnectCode = isHttp ? 0 : (this.options.disconnectStatusCode ?? 499);
|
|
2382
|
-
if (disconnectCode > 0 &&
|
|
2383
|
-
request.signal?.aborted === true) {
|
|
2382
|
+
if (disconnectCode > 0 && request.signal?.aborted === true) {
|
|
2384
2383
|
log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
|
|
2385
2384
|
const res = new Response(null, {
|
|
2386
2385
|
status: disconnectCode,
|
|
@@ -2437,9 +2436,7 @@ export class App {
|
|
|
2437
2436
|
* @returns Fulfills with the `Response` produced by the matching handler.
|
|
2438
2437
|
*/
|
|
2439
2438
|
request(input, init) {
|
|
2440
|
-
const url = typeof input === "string" && input.startsWith("/")
|
|
2441
|
-
? `http://test.local${input}`
|
|
2442
|
-
: input;
|
|
2439
|
+
const url = typeof input === "string" && input.startsWith("/") ? `http://test.local${input}` : input;
|
|
2443
2440
|
const req = url instanceof Request ? url : new Request(url, init);
|
|
2444
2441
|
return this.fetch(req);
|
|
2445
2442
|
}
|
|
@@ -2594,9 +2591,7 @@ function healthRouteKey(request) {
|
|
|
2594
2591
|
// so even apps that trust forwarded headers should not let an attacker
|
|
2595
2592
|
// bypass the per-IP cap by spoofing the header. Fall back to a constant
|
|
2596
2593
|
// key when no proxy header is available (single shared bucket).
|
|
2597
|
-
return
|
|
2598
|
-
request.headers.get("fly-client-ip") ??
|
|
2599
|
-
"global");
|
|
2594
|
+
return request.headers.get("x-real-ip") ?? request.headers.get("fly-client-ip") ?? "global";
|
|
2600
2595
|
}
|
|
2601
2596
|
function corsOriginAllowsFromHooks(layers) {
|
|
2602
2597
|
const allows = [];
|
|
@@ -2710,9 +2705,7 @@ export function topoSortExtensions(exts) {
|
|
|
2710
2705
|
const bHeaders = b.responseHeaders;
|
|
2711
2706
|
if (!bHeaders || bHeaders.length === 0)
|
|
2712
2707
|
continue;
|
|
2713
|
-
const overlap = bHeaders
|
|
2714
|
-
.map((h) => h.toLowerCase())
|
|
2715
|
-
.filter((h) => aSet.has(h));
|
|
2708
|
+
const overlap = bHeaders.map((h) => h.toLowerCase()).filter((h) => aSet.has(h));
|
|
2716
2709
|
if (overlap.length === 0)
|
|
2717
2710
|
continue;
|
|
2718
2711
|
const declared = (a.before ?? []).includes(b.name) ||
|
|
@@ -2743,10 +2736,7 @@ function securityMarkersFromHooks(layers) {
|
|
|
2743
2736
|
return { hasSession, hasCsrf };
|
|
2744
2737
|
}
|
|
2745
2738
|
function isStateChangingMethod(method) {
|
|
2746
|
-
return
|
|
2747
|
-
method === "PUT" ||
|
|
2748
|
-
method === "PATCH" ||
|
|
2749
|
-
method === "DELETE");
|
|
2739
|
+
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
2750
2740
|
}
|
|
2751
2741
|
/**
|
|
2752
2742
|
* Extract the pathname from a fully-qualified request URL without
|
|
@@ -2777,9 +2767,7 @@ function getPathnameFast(url) {
|
|
|
2777
2767
|
return url.slice(pathStart, end);
|
|
2778
2768
|
}
|
|
2779
2769
|
function mergeHooks(layers) {
|
|
2780
|
-
const pick = (key) => layers
|
|
2781
|
-
.map((h) => h[key])
|
|
2782
|
-
.filter((f) => typeof f === "function");
|
|
2770
|
+
const pick = (key) => layers.map((h) => h[key]).filter((f) => typeof f === "function");
|
|
2783
2771
|
const requiredScopes = requiredScopesFromHooks(layers);
|
|
2784
2772
|
const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
|
|
2785
2773
|
const hooks = {
|
|
@@ -2880,9 +2868,7 @@ function scalarConfigurationWithPreferredAuth(configuration, schemes) {
|
|
|
2880
2868
|
return {
|
|
2881
2869
|
...(configuration ?? {}),
|
|
2882
2870
|
authentication: {
|
|
2883
|
-
...(authentication &&
|
|
2884
|
-
typeof authentication === "object" &&
|
|
2885
|
-
!Array.isArray(authentication)
|
|
2871
|
+
...(authentication && typeof authentication === "object" && !Array.isArray(authentication)
|
|
2886
2872
|
? authentication
|
|
2887
2873
|
: {}),
|
|
2888
2874
|
preferredSecurityScheme,
|
|
@@ -3172,7 +3158,8 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3172
3158
|
}
|
|
3173
3159
|
if (def.request?.body) {
|
|
3174
3160
|
const ct = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
3175
|
-
const allowed = def.accepts ??
|
|
3161
|
+
const allowed = def.accepts ??
|
|
3162
|
+
opts.allowedContentTypes ?? [
|
|
3176
3163
|
"application/json",
|
|
3177
3164
|
"application/x-www-form-urlencoded",
|
|
3178
3165
|
"multipart/form-data",
|
|
@@ -3214,7 +3201,7 @@ function toIssues(issues) {
|
|
|
3214
3201
|
return issues.map((i) => ({
|
|
3215
3202
|
message: i.message,
|
|
3216
3203
|
path: (i.path ?? [])
|
|
3217
|
-
.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))
|
|
3218
3205
|
.join("."),
|
|
3219
3206
|
}));
|
|
3220
3207
|
}
|
|
@@ -3271,8 +3258,7 @@ async function readBody(req, ct, limit, multipart) {
|
|
|
3271
3258
|
typeof v.arrayBuffer === "function";
|
|
3272
3259
|
if (isFile) {
|
|
3273
3260
|
files++;
|
|
3274
|
-
if (multipart?.maxFileBytes !== undefined &&
|
|
3275
|
-
v.size > multipart.maxFileBytes) {
|
|
3261
|
+
if (multipart?.maxFileBytes !== undefined && v.size > multipart.maxFileBytes) {
|
|
3276
3262
|
throw new PayloadTooLargeError(multipart.maxFileBytes);
|
|
3277
3263
|
}
|
|
3278
3264
|
}
|
|
@@ -3511,9 +3497,7 @@ function readHostPackageJsonInfo() {
|
|
|
3511
3497
|
// deno.jsonc allows // line comments and /* block */ comments. Strip
|
|
3512
3498
|
// them before parsing — naively, but well enough for typical manifests.
|
|
3513
3499
|
const text = allowComments
|
|
3514
|
-
? raw
|
|
3515
|
-
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
3516
|
-
.replace(/(^|[^:\\])\/\/.*$/gm, "$1")
|
|
3500
|
+
? raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\])\/\/.*$/gm, "$1")
|
|
3517
3501
|
: raw;
|
|
3518
3502
|
return JSON.parse(text);
|
|
3519
3503
|
};
|
package/dist/docs.d.ts
CHANGED
|
@@ -254,6 +254,42 @@ export interface DocsAssetOptions {
|
|
|
254
254
|
*/
|
|
255
255
|
crossOrigin?: "anonymous" | "use-credentials";
|
|
256
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Provider-neutral login launcher rendered into generated docs pages.
|
|
259
|
+
*
|
|
260
|
+
* Use this when the OpenAPI docs should expose a visible authorization control
|
|
261
|
+
* that sends developers to a local login form or to an external identity
|
|
262
|
+
* provider such as Entra ID, Auth0, Better Auth, Clerk, Okta, Keycloak, or any
|
|
263
|
+
* other OAuth2/OIDC front end. The launcher only opens the configured URL; it
|
|
264
|
+
* never stores tokens or bypasses the OpenAPI UI's normal security-scheme
|
|
265
|
+
* handling.
|
|
266
|
+
*
|
|
267
|
+
* @since 0.43.0
|
|
268
|
+
*/
|
|
269
|
+
export interface DocsAuthLauncherOptions {
|
|
270
|
+
/**
|
|
271
|
+
* Absolute `http(s)` URL or same-origin/relative URL for the login or
|
|
272
|
+
* authorization entry point. `javascript:`, `data:`, and other executable
|
|
273
|
+
* schemes are refused when the HTML is generated.
|
|
274
|
+
*/
|
|
275
|
+
loginUrl: string;
|
|
276
|
+
/** Button text. Defaults to `"Authorize"`. */
|
|
277
|
+
label?: string;
|
|
278
|
+
/**
|
|
279
|
+
* Accessible helper text shown as the button title and screen-reader label.
|
|
280
|
+
* Defaults to `"Open login or identity provider"`.
|
|
281
|
+
*/
|
|
282
|
+
description?: string;
|
|
283
|
+
/**
|
|
284
|
+
* How to open {@link DocsAuthLauncherOptions.loginUrl}. Defaults to
|
|
285
|
+
* `"popup"` so docs remain open while the provider flow runs.
|
|
286
|
+
*/
|
|
287
|
+
target?: "popup" | "_blank" | "_self";
|
|
288
|
+
/** Popup width in CSS/device pixels. Defaults to `520`. */
|
|
289
|
+
popupWidth?: number;
|
|
290
|
+
/** Popup height in CSS/device pixels. Defaults to `720`. */
|
|
291
|
+
popupHeight?: number;
|
|
292
|
+
}
|
|
257
293
|
/** Shared options for {@link scalarHtml}, {@link swaggerUiHtml}, and {@link redocHtml}. */
|
|
258
294
|
export interface DocsOptions {
|
|
259
295
|
/** Absolute or relative URL of the OpenAPI document to render. */
|
|
@@ -267,6 +303,14 @@ export interface DocsOptions {
|
|
|
267
303
|
assets?: DocsAssetOptions;
|
|
268
304
|
/** CSP `nonce` to apply to inline/script tags; must match the response CSP. */
|
|
269
305
|
scriptNonce?: string;
|
|
306
|
+
/**
|
|
307
|
+
* Optional authorization launcher rendered into the docs page. It gives
|
|
308
|
+
* Scalar, Swagger UI, and Redoc a consistent visible button that opens a
|
|
309
|
+
* local login form or third-party identity-provider authorization URL.
|
|
310
|
+
*
|
|
311
|
+
* @since 0.43.0
|
|
312
|
+
*/
|
|
313
|
+
auth?: DocsAuthLauncherOptions;
|
|
270
314
|
}
|
|
271
315
|
/** Options for {@link scalarHtml}; adds Scalar-specific UI configuration. */
|
|
272
316
|
export interface ScalarHtmlOptions extends DocsOptions {
|