@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/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, } from "./session.js";
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, } from "./scheduler.js";
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
- * 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.
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
- * 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.
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
- "trustProxy: false, csrf: \"off\") instead. " +
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
- "{ allowedOrigins: \"same-origin\" } or an explicit origin allowlist, " +
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
- const buckets = rateLimitConfig
1446
- ? new Map()
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
- * the browser to this endpoint. The route is registered as publicly
1627
- * reachable; that is required for the browser
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
- ? null
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, [...this.globalCorsAllows, ...this.corsOriginAllows]);
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) { _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) { _headers = v; },
2186
+ set headers(v) {
2187
+ _headers = v;
2188
+ },
2202
2189
  body: undefined,
2203
2190
  state: { ...this.decorations, requestId, log },
2204
- set: { headers: new Headers() },
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: { headers: new Headers() },
2223
+ set: new LazyResponseSet(),
2225
2224
  };
2226
- const preflightHooks = mergeHooks([
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) ? await interceptedResult : 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) ? await serializeResultRes : 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 (request.headers.get("x-real-ip") ??
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 (method === "POST" ||
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 ?? opts.allowedContentTypes ?? [
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 {