@daloyjs/core 0.17.0 → 0.29.0

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.
Files changed (98) hide show
  1. package/README.md +3 -3
  2. package/dist/adapters/bun.d.ts.map +1 -1
  3. package/dist/adapters/bun.js +38 -7
  4. package/dist/adapters/bun.js.map +1 -1
  5. package/dist/adapters/node.d.ts.map +1 -1
  6. package/dist/adapters/node.js +29 -7
  7. package/dist/adapters/node.js.map +1 -1
  8. package/dist/app.d.ts +322 -3
  9. package/dist/app.d.ts.map +1 -1
  10. package/dist/app.js +715 -29
  11. package/dist/app.js.map +1 -1
  12. package/dist/cli.d.ts +4 -0
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +218 -1
  15. package/dist/cli.js.map +1 -1
  16. package/dist/combine.d.ts +97 -0
  17. package/dist/combine.d.ts.map +1 -0
  18. package/dist/combine.js +247 -0
  19. package/dist/combine.js.map +1 -0
  20. package/dist/compression.d.ts +127 -0
  21. package/dist/compression.d.ts.map +1 -0
  22. package/dist/compression.js +368 -0
  23. package/dist/compression.js.map +1 -0
  24. package/dist/config.d.ts +97 -0
  25. package/dist/config.d.ts.map +1 -0
  26. package/dist/config.js +132 -0
  27. package/dist/config.js.map +1 -0
  28. package/dist/conn-info.d.ts +121 -0
  29. package/dist/conn-info.d.ts.map +1 -0
  30. package/dist/conn-info.js +145 -0
  31. package/dist/conn-info.js.map +1 -0
  32. package/dist/cookie.d.ts +112 -0
  33. package/dist/cookie.d.ts.map +1 -0
  34. package/dist/cookie.js +185 -0
  35. package/dist/cookie.js.map +1 -0
  36. package/dist/dependency.d.ts +47 -0
  37. package/dist/dependency.d.ts.map +1 -0
  38. package/dist/dependency.js +68 -0
  39. package/dist/dependency.js.map +1 -0
  40. package/dist/etag.d.ts +48 -0
  41. package/dist/etag.d.ts.map +1 -0
  42. package/dist/etag.js +117 -0
  43. package/dist/etag.js.map +1 -0
  44. package/dist/index.d.ts +40 -11
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +19 -4
  47. package/dist/index.js.map +1 -1
  48. package/dist/ip-restriction.d.ts +75 -0
  49. package/dist/ip-restriction.d.ts.map +1 -0
  50. package/dist/ip-restriction.js +203 -0
  51. package/dist/ip-restriction.js.map +1 -0
  52. package/dist/jwk.d.ts +82 -0
  53. package/dist/jwk.d.ts.map +1 -0
  54. package/dist/jwk.js +269 -0
  55. package/dist/jwk.js.map +1 -0
  56. package/dist/jwt.d.ts +103 -0
  57. package/dist/jwt.d.ts.map +1 -0
  58. package/dist/jwt.js +437 -0
  59. package/dist/jwt.js.map +1 -0
  60. package/dist/load-shedding.d.ts +73 -0
  61. package/dist/load-shedding.d.ts.map +1 -0
  62. package/dist/load-shedding.js +171 -0
  63. package/dist/load-shedding.js.map +1 -0
  64. package/dist/middleware.d.ts +181 -2
  65. package/dist/middleware.d.ts.map +1 -1
  66. package/dist/middleware.js +367 -77
  67. package/dist/middleware.js.map +1 -1
  68. package/dist/multipart.d.ts +17 -0
  69. package/dist/multipart.d.ts.map +1 -1
  70. package/dist/multipart.js +117 -1
  71. package/dist/multipart.js.map +1 -1
  72. package/dist/openapi.d.ts +8 -0
  73. package/dist/openapi.d.ts.map +1 -1
  74. package/dist/openapi.js +18 -1
  75. package/dist/openapi.js.map +1 -1
  76. package/dist/security-schemes.d.ts +21 -5
  77. package/dist/security-schemes.d.ts.map +1 -1
  78. package/dist/security-schemes.js +32 -5
  79. package/dist/security-schemes.js.map +1 -1
  80. package/dist/session.d.ts +23 -1
  81. package/dist/session.d.ts.map +1 -1
  82. package/dist/session.js +106 -86
  83. package/dist/session.js.map +1 -1
  84. package/dist/subdomains.d.ts +97 -0
  85. package/dist/subdomains.d.ts.map +1 -0
  86. package/dist/subdomains.js +157 -0
  87. package/dist/subdomains.js.map +1 -0
  88. package/dist/time-claims.d.ts +72 -0
  89. package/dist/time-claims.d.ts.map +1 -0
  90. package/dist/time-claims.js +88 -0
  91. package/dist/time-claims.js.map +1 -0
  92. package/dist/types.d.ts +50 -0
  93. package/dist/types.d.ts.map +1 -1
  94. package/dist/websocket.d.ts +51 -34
  95. package/dist/websocket.d.ts.map +1 -1
  96. package/dist/websocket.js +162 -2
  97. package/dist/websocket.js.map +1 -1
  98. package/package.json +31 -3
package/dist/app.js CHANGED
@@ -1,14 +1,59 @@
1
1
  import { Router } from "./router.js";
2
- import { WebSocketRegistry } from "./websocket.js";
3
- import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
2
+ import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
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, assertStrongSecret } from "./security.js";
5
+ import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertStrongSecret, timingSafeEqual } from "./security.js";
6
6
  import { createLogger, noopLogger } from "./logger.js";
7
7
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
8
8
  import { docsContentSecurityPolicy, scalarHtml, swaggerUiHtml, } from "./docs.js";
9
- import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
9
+ 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";
10
10
  import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
11
+ import { loadShedding as loadSheddingMiddleware } from "./load-shedding.js";
12
+ import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
13
+ import { assertBehindProxy } from "./conn-info.js";
11
14
  const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
15
+ /**
16
+ * Module-level latch shared across every {@link App} constructed in the same
17
+ * process. Ensures `unhandledRejection` / `uncaughtException` handlers are
18
+ * registered at most once — installing them twice would log the same crash
19
+ * twice (and once per listener thereafter), polluting the final exit signal.
20
+ */
21
+ let crashHandlersInstalled = false;
22
+ let activeCrashLogger;
23
+ function setActiveCrashLogger(log) {
24
+ activeCrashLogger = log;
25
+ }
26
+ /** @internal Test-only helper to reset the latch between tests. */
27
+ export function _resetCrashHandlersForTests() {
28
+ crashHandlersInstalled = false;
29
+ activeCrashLogger = undefined;
30
+ }
31
+ /**
32
+ * Wave 8 — once-per-process latch for the `secureDefaults: false` warning
33
+ * log. Multiple `new App({ secureDefaults: false })` instances in the same
34
+ * process share one log entry instead of flooding the access log with
35
+ * duplicate warnings on every construction.
36
+ */
37
+ let insecureDefaultsLoggedThisProcess = false;
38
+ /** @internal Test-only helper to reset the latch between tests. */
39
+ export function _resetInsecureDefaultsLogForTests() {
40
+ insecureDefaultsLoggedThisProcess = false;
41
+ }
42
+ /**
43
+ * Wave 8 — list of secure-by-default surfaces disabled when
44
+ * `secureDefaults: false` is set. Surfaced through the once-per-process
45
+ * `error` log so the operator sees exactly which guards are off.
46
+ */
47
+ const DISABLED_BY_INSECURE_DEFAULTS = Object.freeze([
48
+ "secureHeaders auto-install",
49
+ "cross-origin guard for state-changing requests",
50
+ "crash-on-unhandled-rejection (production)",
51
+ "first-request X-Forwarded-* / trustProxy guard",
52
+ "session() + state-changing route requires csrf() boot guard",
53
+ "weak session secret refuse-to-boot",
54
+ "cors({ origin: '*' }) refuse-to-boot",
55
+ "anonymous stateful plugin refuse-to-boot",
56
+ ]);
12
57
  const DEFAULTS = {
13
58
  bodyLimitBytes: 1024 * 1024,
14
59
  requestTimeoutMs: 30_000,
@@ -82,10 +127,16 @@ export class App {
82
127
  installedPlugins = new Set();
83
128
  closeHooks = [];
84
129
  closeHooksRun = false;
130
+ /** Wave 4 idle-connection close hooks (adapter-registered, sync). */
131
+ idleConnectionCloseHooks = [];
85
132
  pluginInstalledListeners = [];
86
133
  shutdownListeners = [];
87
134
  shutdownListenersRun = false;
88
- pendingPlugins = [];
135
+ pendingPlugins = new Set();
136
+ pluginBootError = {
137
+ failed: false,
138
+ error: undefined,
139
+ };
89
140
  /** In-flight request count for graceful shutdown. */
90
141
  inflight = 0;
91
142
  draining = false;
@@ -125,11 +176,65 @@ export class App {
125
176
  ? options.logger
126
177
  : createLogger({ level: options.logger?.level ?? "info" });
127
178
  this.warnOnEnvMismatch();
179
+ this.assertDisconnectStatusCode();
180
+ assertBehindProxy(this.options.behindProxy);
128
181
  if (this.options.hooks)
129
182
  this.assertSecureHookConfig(this.options.hooks);
183
+ this.assertInsecureDefaultsAcknowledged();
130
184
  this.installSecureDefaults();
185
+ this.maybeInstallCrashHandlers();
131
186
  this.maybeMountDocs();
132
187
  }
188
+ /**
189
+ * Wave 4 leftover: validate {@link AppOptions.disconnectStatusCode}.
190
+ * Refuses anything outside `[400, 499]` (except `0`, which disables the
191
+ * rewrite). Throws at construction time so the misconfiguration cannot
192
+ * survive past boot.
193
+ */
194
+ assertDisconnectStatusCode() {
195
+ const v = this.options.disconnectStatusCode;
196
+ if (v === undefined || v === 0)
197
+ return;
198
+ if (!Number.isInteger(v) || v < 400 || v > 499) {
199
+ throw new Error(`disconnectStatusCode must be an integer in [400, 499] or 0; got ${String(v)}.`);
200
+ }
201
+ }
202
+ /**
203
+ * Wave 8 — master-flag escape-hatch enforcement. When
204
+ * `secureDefaults: false` is set, the framework:
205
+ * - refuses-to-construct in production unless
206
+ * `acknowledgeInsecureDefaults: true` is also set; closes the
207
+ * "developer flipped the flag off while debugging and shipped to
208
+ * production" footgun.
209
+ * - emits a once-per-process `error` log naming every default the flag
210
+ * disables, so operators see the blast radius even if the option was
211
+ * set deep in shared configuration.
212
+ */
213
+ assertInsecureDefaultsAcknowledged() {
214
+ if (this.options.secureDefaults !== false)
215
+ return;
216
+ const inProduction = this.isProduction();
217
+ if (inProduction && this.options.acknowledgeInsecureDefaults !== true) {
218
+ throw new Error("app({ secureDefaults: false }) is refused in production. " +
219
+ "secureDefaults turns off the entire Wave 1–7 secure-by-default " +
220
+ "surface (auto secureHeaders, cross-origin guard, crash-on-unhandled-rejection, " +
221
+ "trustProxy guard, csrf/session boot guard, weak-secret refuse-to-boot, " +
222
+ "cors({ origin: '*' }) refuse-to-boot, anonymous-stateful-plugin refuse-to-boot). " +
223
+ "If you really need this in production, also pass " +
224
+ "acknowledgeInsecureDefaults: true to confirm. Prefer per-feature opt-outs " +
225
+ "(secureHeaders: false, corsCrossOriginGuard: false, crashOnUnhandledRejection: false, " +
226
+ "trustProxy: false, csrf: \"off\") instead.");
227
+ }
228
+ if (!insecureDefaultsLoggedThisProcess) {
229
+ insecureDefaultsLoggedThisProcess = true;
230
+ this.log.error({
231
+ event: "secure_defaults.disabled",
232
+ production: inProduction,
233
+ acknowledged: this.options.acknowledgeInsecureDefaults === true,
234
+ disabled: DISABLED_BY_INSECURE_DEFAULTS,
235
+ }, `app({ secureDefaults: false }) disables: ${DISABLED_BY_INSECURE_DEFAULTS.join(", ")}.`);
236
+ }
237
+ }
133
238
  /**
134
239
  * Install the Wave 2 secure-by-default global hooks. Currently:
135
240
  * - {@link secureHeaders} as a group-level hook so every response carries
@@ -153,6 +258,58 @@ export class App {
153
258
  auto[AUTO_SECURE_HEADERS_MARKER] = true;
154
259
  this.groupHooks.push(auto);
155
260
  }
261
+ // Wave 4 leftover: opt-in load-shedding pressure monitor.
262
+ if (this.options.loadShedding) {
263
+ const lsOpts = typeof this.options.loadShedding === "object"
264
+ ? this.options.loadShedding
265
+ : {};
266
+ this.groupHooks.push(loadSheddingMiddleware(lsOpts));
267
+ }
268
+ }
269
+ /**
270
+ * Wave 4 crash-on-unrecoverable-error guard. Installs Node-process-level
271
+ * listeners for `unhandledRejection` and `uncaughtException` that log
272
+ * through the pluggable logger and call `process.exit(1)`. Idempotent via
273
+ * a module-level latch so multiple `new App()` instantiations in the same
274
+ * process do not double-register; the most recently constructed App's
275
+ * logger is used. No-op on runtimes without `process.on` (Workers / Edge
276
+ * / Fastly), in non-production by default, and when
277
+ * `crashOnUnhandledRejection: false` or `secureDefaults: false`.
278
+ */
279
+ maybeInstallCrashHandlers() {
280
+ if (this.options.crashOnUnhandledRejection === false)
281
+ return;
282
+ if (this.options.crashOnUnhandledRejection === undefined &&
283
+ (this.options.secureDefaults === false || !this.isProduction())) {
284
+ return;
285
+ }
286
+ if (typeof process === "undefined" || typeof process.on !== "function") {
287
+ return;
288
+ }
289
+ setActiveCrashLogger(this.log);
290
+ if (crashHandlersInstalled)
291
+ return;
292
+ crashHandlersInstalled = true;
293
+ process.on("unhandledRejection", (reason) => {
294
+ const log = activeCrashLogger ?? this.log;
295
+ try {
296
+ log.fatal({ event: "process.unhandledRejection", err: serializeErr(reason) }, "Unhandled promise rejection — exiting (crashOnUnhandledRejection)");
297
+ }
298
+ catch {
299
+ /* swallow logger failure so we still exit */
300
+ }
301
+ process.exit(1);
302
+ });
303
+ process.on("uncaughtException", (err) => {
304
+ const log = activeCrashLogger ?? this.log;
305
+ try {
306
+ log.fatal({ event: "process.uncaughtException", err: serializeErr(err) }, "Uncaught exception — exiting (crashOnUnhandledRejection)");
307
+ }
308
+ catch {
309
+ /* swallow logger failure so we still exit */
310
+ }
311
+ process.exit(1);
312
+ });
156
313
  }
157
314
  /**
158
315
  * Emit a one-time `warn` when the explicit {@link AppOptions.env} option
@@ -263,6 +420,17 @@ export class App {
263
420
  }
264
421
  }
265
422
  }
423
+ assertRouteAuthPayloadConfig(route) {
424
+ const auth = route.auth;
425
+ if (!auth || auth.payload !== false)
426
+ return;
427
+ const scheme = this.options.openapi?.securitySchemes?.[auth.scheme];
428
+ if (!securitySchemeRequiresPayloadAuth(scheme))
429
+ return;
430
+ throw new Error(`Route ${route.method} ${route.path} declares auth.payload: false, ` +
431
+ `but security scheme "${auth.scheme}" requires payload authentication. ` +
432
+ `Remove the route-level opt-out or use a scheme without requirePayloadAuth: true.`);
433
+ }
266
434
  resetWave3BootGuardCache() {
267
435
  this.wave3BootGuard.checked = false;
268
436
  this.wave3BootGuard.error = undefined;
@@ -319,6 +487,11 @@ export class App {
319
487
  return;
320
488
  if (this.options.trustProxy !== undefined)
321
489
  return;
490
+ // Wave 6: `behindProxy` is the declarative successor — when supplied,
491
+ // the framework already knows how to interpret forwarded headers and
492
+ // the legacy unconfigured-proxy guard is satisfied.
493
+ if (this.options.behindProxy !== undefined)
494
+ return;
322
495
  // Same risk-register clause as the session/CSRF guard: only enforce
323
496
  // in production. Dev/CI surfaces routinely test forwarded headers
324
497
  // without configuring a reverse-proxy posture.
@@ -517,6 +690,7 @@ export class App {
517
690
  tags: [...(this.groupTags ?? []), ...(def.tags ?? [])],
518
691
  auth: def.auth ?? this.groupAuth,
519
692
  };
693
+ this.assertRouteAuthPayloadConfig(merged);
520
694
  const sources = [...this.groupHooks, def.hooks ?? {}];
521
695
  const hooks = mergeHooks(sources);
522
696
  const corsOriginAllows = corsOriginAllowsFromHooks(sources);
@@ -543,7 +717,235 @@ export class App {
543
717
  */
544
718
  ws(path, handler) {
545
719
  const fullPath = joinPath(this.prefix, path);
546
- this.webSocketRoutes.add(fullPath, handler, () => ({ ...this.decorations }));
720
+ const options = normalizeWebSocketOptions(handler, {
721
+ production: this.isProduction(),
722
+ secureDefaults: this.options.secureDefaults !== false,
723
+ });
724
+ this.webSocketRoutes.add(fullPath, handler, () => ({ ...this.decorations }), options);
725
+ return this;
726
+ }
727
+ /**
728
+ * Register a liveness probe route. Returns `200 {"status":"ok"}` while
729
+ * the process is alive, regardless of plugin readiness. Use this for
730
+ * container orchestrator `livenessProbe` configuration — a failing
731
+ * liveness probe restarts the container.
732
+ *
733
+ * Defaults (Wave 4 secure-by-default):
734
+ * - path: `/healthz`
735
+ * - rate-limit: 60 req/min per remote IP, in-memory (per-process)
736
+ * - auth: opt-in via `token`. In production with `secureDefaults: true`,
737
+ * registration refuses to add the route without a `token` unless
738
+ * `acknowledgeUnauthenticated: true` is set, so an unguarded
739
+ * healthcheck cannot ship to production by accident.
740
+ *
741
+ * @example
742
+ * ```ts
743
+ * app.healthcheck({ token: process.env.HEALTH_TOKEN! });
744
+ * ```
745
+ *
746
+ * @since 0.18.0
747
+ */
748
+ healthcheck(opts = {}) {
749
+ this.registerHealthRoute("healthcheck", opts, () => ({
750
+ status: 200,
751
+ body: { status: "ok" },
752
+ }));
753
+ return this;
754
+ }
755
+ /**
756
+ * Register a readiness probe route. Returns `200 {"status":"ready"}`
757
+ * once every async plugin has resolved AND the app is not draining.
758
+ * Returns `503` otherwise. Use this for container orchestrator
759
+ * `readinessProbe` configuration — a failing readiness probe removes
760
+ * the pod from load-balancer rotation without restarting it.
761
+ *
762
+ * Defaults match {@link App.healthcheck} (path defaults to `/readyz`).
763
+ *
764
+ * @since 0.18.0
765
+ */
766
+ readinesscheck(opts = {}) {
767
+ this.registerHealthRoute("readinesscheck", opts, () => {
768
+ if (this.draining ||
769
+ this.pendingPlugins.size > 0 ||
770
+ this.pluginBootError.failed) {
771
+ return {
772
+ status: 503,
773
+ body: { status: "not-ready" },
774
+ headers: { "retry-after": "5" },
775
+ };
776
+ }
777
+ return {
778
+ status: 200,
779
+ body: { status: "ready" },
780
+ };
781
+ });
782
+ return this;
783
+ }
784
+ registerHealthRoute(kind, opts, handler) {
785
+ const isHealth = kind === "healthcheck";
786
+ const defaultPath = (isHealth ? "/healthz" : "/readyz");
787
+ const path = (opts.path ?? defaultPath);
788
+ const rateLimitConfig = opts.rateLimit === false
789
+ ? null
790
+ : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
791
+ const token = opts.token;
792
+ // Wave 4 refuse-to-boot: unauthenticated health/ready probes in
793
+ // production are a documented info-disclosure surface (process uptime,
794
+ // plugin-ready transitions, internal hostnames in some shops). Force
795
+ // an explicit acknowledgement.
796
+ if (this.options.secureDefaults !== false &&
797
+ this.isProduction() &&
798
+ token === undefined &&
799
+ opts.acknowledgeUnauthenticated !== true) {
800
+ throw new Error(`app.${kind}() refused in production: provide opts.token to require ` +
801
+ `Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
802
+ `to acknowledge that this probe is reachable without credentials.`);
803
+ }
804
+ const buckets = rateLimitConfig
805
+ ? new Map()
806
+ : null;
807
+ this.route({
808
+ method: "GET",
809
+ path,
810
+ operationId: isHealth ? "healthcheck" : "readinesscheck",
811
+ tags: ["Health"],
812
+ summary: isHealth ? "Liveness probe" : "Readiness probe",
813
+ handler: async ({ request }) => {
814
+ if (buckets && rateLimitConfig) {
815
+ const key = healthRouteKey(request);
816
+ const now = Date.now();
817
+ const entry = buckets.get(key);
818
+ if (!entry || entry.resetMs <= now) {
819
+ buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
820
+ }
821
+ else {
822
+ entry.count++;
823
+ if (entry.count > rateLimitConfig.limit) {
824
+ throw new TooManyRequestsError(Math.ceil((entry.resetMs - now) / 1000));
825
+ }
826
+ }
827
+ }
828
+ if (token !== undefined) {
829
+ const h = request.headers.get("authorization") ?? "";
830
+ const m = /^Bearer\s+(.+)$/i.exec(h);
831
+ if (!m) {
832
+ throw new HttpError(401, {
833
+ type: "https://daloyjs.dev/errors/unauthorized",
834
+ title: "Unauthorized",
835
+ detail: "Health probe requires a bearer token.",
836
+ }, { "www-authenticate": 'Bearer realm="health"' });
837
+ }
838
+ if (!timingSafeEqual(m[1], token)) {
839
+ throw new ForbiddenError("Invalid health probe token.");
840
+ }
841
+ }
842
+ return handler();
843
+ },
844
+ responses: {
845
+ 200: {
846
+ description: isHealth ? "Service is alive." : "Service is ready.",
847
+ },
848
+ 503: {
849
+ description: "Service is not ready.",
850
+ },
851
+ },
852
+ });
853
+ }
854
+ /**
855
+ * Wave 4 leftover: register a built-in receiver for CSP / Reporting API
856
+ * violation reports. Accepts `application/csp-report` and
857
+ * `application/reports+json` payloads, rate-limits per IP (defaults: 60
858
+ * req/min), caps body size (default 8 KiB), and forwards parsed reports
859
+ * to {@link CspReportRouteOptions.onReport} (or the structured logger
860
+ * when omitted). Returns `204 No Content` so browsers stop retrying.
861
+ *
862
+ * Combine with `secureHeaders({ reportingEndpoints, reportTo })` to wire
863
+ * the browser to this endpoint. The route is registered as `internal:
864
+ * false` (i.e. publicly reachable); that is required for the browser
865
+ * Reporting API to send to it.
866
+ *
867
+ */
868
+ cspReportRoute(opts = {}) {
869
+ const path = (opts.path ?? "/__csp-report");
870
+ const maxBytes = opts.maxBodyBytes ?? 8192;
871
+ const rateLimitConfig = opts.rateLimit === false
872
+ ? null
873
+ : { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
874
+ const buckets = rateLimitConfig
875
+ ? new Map()
876
+ : null;
877
+ const log = this.log;
878
+ this.route({
879
+ method: "POST",
880
+ path,
881
+ operationId: "cspReport",
882
+ tags: ["Reporting"],
883
+ summary: "CSP / Reporting API violation receiver",
884
+ handler: async ({ request }) => {
885
+ if (buckets && rateLimitConfig) {
886
+ const key = healthRouteKey(request);
887
+ const now = Date.now();
888
+ const entry = buckets.get(key);
889
+ if (!entry || entry.resetMs <= now) {
890
+ buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
891
+ }
892
+ else {
893
+ entry.count++;
894
+ if (entry.count > rateLimitConfig.limit) {
895
+ throw new TooManyRequestsError(Math.ceil((entry.resetMs - now) / 1000));
896
+ }
897
+ }
898
+ }
899
+ const contentType = (request.headers.get("content-type") ?? "")
900
+ .split(";")[0]
901
+ .trim()
902
+ .toLowerCase();
903
+ if (contentType !== "application/csp-report" &&
904
+ contentType !== "application/reports+json" &&
905
+ contentType !== "application/json") {
906
+ throw new UnsupportedMediaTypeError(contentType || "<none>", [
907
+ "application/csp-report",
908
+ "application/reports+json",
909
+ "application/json",
910
+ ]);
911
+ }
912
+ const rawBytes = await readBodyLimited(request, maxBytes);
913
+ const rawText = new TextDecoder().decode(rawBytes);
914
+ let parsed;
915
+ try {
916
+ parsed = safeJsonParse(rawText);
917
+ }
918
+ catch {
919
+ throw new BadRequestError("Invalid JSON report body");
920
+ }
921
+ if (parsed === undefined) {
922
+ throw new BadRequestError("Invalid JSON report body");
923
+ }
924
+ const ip = healthRouteKey(request);
925
+ const userAgent = request.headers.get("user-agent");
926
+ try {
927
+ if (opts.onReport) {
928
+ await opts.onReport(parsed, {
929
+ ip: ip === "global" ? null : ip,
930
+ userAgent,
931
+ });
932
+ }
933
+ else {
934
+ log.warn({ event: "csp.report", ip, userAgent, report: parsed }, "CSP violation report received");
935
+ }
936
+ }
937
+ catch (err) {
938
+ log.error({ err: serializeErr(err), event: "csp.report.sinkFailed" }, "cspReportRoute onReport sink failed");
939
+ }
940
+ return { status: 204, body: undefined };
941
+ },
942
+ responses: {
943
+ 204: { description: "Report accepted." },
944
+ 413: { description: "Report body too large." },
945
+ 415: { description: "Unsupported content-type." },
946
+ 429: { description: "Rate limit exceeded." },
947
+ },
948
+ });
547
949
  return this;
548
950
  }
549
951
  /**
@@ -594,9 +996,11 @@ export class App {
594
996
  child.decorations = this.decorations;
595
997
  child.installedPlugins = this.installedPlugins;
596
998
  child.closeHooks = this.closeHooks;
999
+ child.idleConnectionCloseHooks = this.idleConnectionCloseHooks;
597
1000
  child.pluginInstalledListeners = this.pluginInstalledListeners;
598
1001
  child.shutdownListeners = this.shutdownListeners;
599
1002
  child.pendingPlugins = this.pendingPlugins;
1003
+ child.pluginBootError = this.pluginBootError;
600
1004
  register(child);
601
1005
  return this;
602
1006
  }
@@ -665,7 +1069,31 @@ export class App {
665
1069
  * @param value - Value bound to that property on every request.
666
1070
  * @returns This `App` instance for chaining.
667
1071
  */
668
- decorate(key, value) {
1072
+ /**
1073
+ * Apply an ordered list of plugin extensions (Wave 6 item 10) to the
1074
+ * group-level hook chain. Each extension's `handler` is wrapped into a
1075
+ * single-event {@link Hooks} bundle so subsequent route registrations
1076
+ * pick it up via the normal hook composition path.
1077
+ * @internal
1078
+ */
1079
+ applyExtensions(ordered) {
1080
+ for (const ext of ordered) {
1081
+ const hooks = { [ext.event]: ext.handler };
1082
+ this.groupHooks.push(hooks);
1083
+ }
1084
+ }
1085
+ decorate(key, value, opts = {}) {
1086
+ if (Object.prototype.hasOwnProperty.call(this.decorations, key) &&
1087
+ opts.override !== true) {
1088
+ // Wave 6 item 9: namespace-protected decorators. Refuse to silently
1089
+ // shadow an existing decoration; emit a once-per-process warn naming
1090
+ // both decorators on the explicit-override path.
1091
+ throw new Error(`decorate(): key "${key}" is already decorated. ` +
1092
+ `Pass { override: true } to replace, or rename to avoid the collision.`);
1093
+ }
1094
+ if (opts.override === true && Object.prototype.hasOwnProperty.call(this.decorations, key)) {
1095
+ this.log.warn({ event: "decorate.override", key }, `decorate("${key}") replaced an existing decoration.`);
1096
+ }
669
1097
  this.decorations[key] = value;
670
1098
  return this;
671
1099
  }
@@ -705,16 +1133,53 @@ export class App {
705
1133
  return this;
706
1134
  }
707
1135
  /**
708
- * Encapsulated plugin registration (Fastify-style). Receives a child App; routes/hooks are scoped.
1136
+ * Encapsulated plugin registration (Fastify-style). Receives a child App;
1137
+ * routes/hooks declared on the child are scoped to the plugin by default
1138
+ * (Wave 6 item 15 \u2014 encapsulation default `local`).
1139
+ *
1140
+ * Wave 6 (`0.24.0`) additions on the plugin descriptor object:
1141
+ *
1142
+ * - `dependencies: string[]` \u2014 prerequisite plugin names; the framework
1143
+ * refuses-to-boot at registration time when any declared dependency has
1144
+ * not been installed first (item 8).
1145
+ * - `seed: string` \u2014 differentiator for parameterized instances of the
1146
+ * same plugin (item 16). Dedup key becomes `${name}#${seed}`.
1147
+ * - `stateful: boolean` \u2014 when `true` AND `name` is absent AND the app is
1148
+ * in production with `secureDefaults` on, registration refuses-to-boot
1149
+ * so silent double-installs of global-state-mutating plugins are caught
1150
+ * loud (item 16).
1151
+ * - `extensions: [{ event, handler, before?, after? }]` \u2014 declarative
1152
+ * lifecycle-hook ordering with topological-sort + cycle detection
1153
+ * (item 10). Refuses-at-registration on cycles.
709
1154
  */
710
1155
  register(plugin, config = {}) {
711
- const fn = typeof plugin === "function" ? plugin : plugin.register;
712
- const name = typeof plugin === "function" ? undefined : plugin.name;
713
- if (name) {
714
- if (this.installedPlugins.has(name)) {
715
- throw new Error(`Plugin "${name}" already registered`);
1156
+ const fn = typeof plugin === "function" ? plugin : (plugin.register ?? (() => { }));
1157
+ const descriptor = typeof plugin === "function" ? undefined : plugin;
1158
+ const name = descriptor?.name;
1159
+ const seed = descriptor?.seed;
1160
+ const dedupKey = name ? (seed ? `${name}#${seed}` : name) : undefined;
1161
+ const dependencies = descriptor?.dependencies ?? [];
1162
+ const stateful = descriptor?.stateful ?? false;
1163
+ if (stateful && !name && this.isProduction() && this.options.secureDefaults !== false) {
1164
+ throw new Error("register(): anonymous stateful plugin refused in production. " +
1165
+ "Declare { name } (and optional { seed }) so the plugin can be deduplicated.");
1166
+ }
1167
+ for (const dep of dependencies) {
1168
+ if (!this.installedPlugins.has(dep)) {
1169
+ throw new Error(`register(): plugin ${JSON.stringify(name ?? "<anonymous>")} declares ` +
1170
+ `dependency on "${dep}" but no plugin with that name has been registered yet.`);
716
1171
  }
717
- this.installedPlugins.add(name);
1172
+ }
1173
+ if (dedupKey) {
1174
+ if (this.installedPlugins.has(dedupKey)) {
1175
+ throw new Error(`Plugin "${dedupKey}" already registered`);
1176
+ }
1177
+ this.installedPlugins.add(dedupKey);
1178
+ }
1179
+ if (descriptor?.extensions && descriptor.extensions.length > 0) {
1180
+ // Wave 6 item 10: topological sort with cycle detection.
1181
+ const ordered = topoSortExtensions(descriptor.extensions);
1182
+ this.applyExtensions(ordered);
718
1183
  }
719
1184
  const prefix = config.prefix ?? "/";
720
1185
  const event = {
@@ -724,20 +1189,29 @@ export class App {
724
1189
  this.group(prefix, config, (child) => {
725
1190
  const r = fn(child);
726
1191
  if (r && typeof r.then === "function") {
727
- // Plugin is async caller should await app.ready().
728
- this.pendingPlugins.push(r.then(() => this.firePluginInstalled(event)));
1192
+ // Plugin is async - caller should await app.ready().
1193
+ this.trackPendingPlugin(r.then(() => this.firePluginInstalled(event)));
729
1194
  }
730
1195
  else {
731
1196
  // Sync plugin: fire listeners immediately. Any returned promise from a
732
1197
  // listener is collected so `app.ready()` can await observers too.
733
1198
  const pending = this.firePluginInstalled(event);
734
1199
  if (pending) {
735
- this.pendingPlugins.push(pending);
1200
+ this.trackPendingPlugin(pending);
736
1201
  }
737
1202
  }
738
1203
  });
739
1204
  return this;
740
1205
  }
1206
+ trackPendingPlugin(promise) {
1207
+ const tracked = promise.catch((err) => {
1208
+ this.pluginBootError.failed = true;
1209
+ this.pluginBootError.error = err;
1210
+ throw err;
1211
+ });
1212
+ this.pendingPlugins.add(tracked);
1213
+ void tracked.finally(() => this.pendingPlugins.delete(tracked)).catch(() => { });
1214
+ }
741
1215
  firePluginInstalled(event) {
742
1216
  if (this.pluginInstalledListeners.length === 0)
743
1217
  return undefined;
@@ -777,9 +1251,12 @@ export class App {
777
1251
  * @returns Promise that resolves once all pending plugins have settled.
778
1252
  */
779
1253
  ready() {
780
- if (this.pendingPlugins.length === 0)
1254
+ if (this.pluginBootError.failed) {
1255
+ return Promise.reject(this.pluginBootError.error);
1256
+ }
1257
+ if (this.pendingPlugins.size === 0)
781
1258
  return Promise.resolve();
782
- const pending = this.pendingPlugins.splice(0);
1259
+ const pending = Array.from(this.pendingPlugins);
783
1260
  return Promise.all(pending).then(() => undefined);
784
1261
  }
785
1262
  /**
@@ -802,6 +1279,28 @@ export class App {
802
1279
  * to RFC 9457 `application/problem+json` automatically.
803
1280
  */
804
1281
  fetch = async (request) => {
1282
+ const response = await this.dispatch(request);
1283
+ // In-flight responses that finish during draining advertise
1284
+ // `Connection: close` so HTTP/1.1 load balancers stop re-using the
1285
+ // socket for new requests. Wave 4 connection-draining.
1286
+ if (this.draining && !response.headers.has("connection")) {
1287
+ response.headers.set("connection", "close");
1288
+ }
1289
+ return response;
1290
+ };
1291
+ /**
1292
+ * In-process entry point that bypasses the public `404` shield for
1293
+ * routes declared with `internal: true`. Use it for cron jobs, admin
1294
+ * scripts, and integration tests that need to exercise privileged
1295
+ * handlers without exposing them to the network. All other security
1296
+ * middleware (CORS, CSRF, rate limit, etc.) still runs normally.
1297
+ *
1298
+ * @since 0.19.0
1299
+ */
1300
+ inject = async (request) => {
1301
+ return this.dispatch(request, { allowInternal: true });
1302
+ };
1303
+ dispatch = async (request, opts = {}) => {
805
1304
  if (this.draining) {
806
1305
  return new Response(JSON.stringify({
807
1306
  type: "https://daloyjs.dev/errors/shutting-down",
@@ -812,6 +1311,10 @@ export class App {
812
1311
  headers: {
813
1312
  "content-type": "application/problem+json",
814
1313
  "retry-after": "5",
1314
+ // Tell HTTP/1.1 load balancers to close the keep-alive socket
1315
+ // immediately so the next request lands on a healthy instance
1316
+ // rather than coming back to a dying one. Wave 4.
1317
+ connection: "close",
815
1318
  },
816
1319
  });
817
1320
  }
@@ -838,12 +1341,30 @@ export class App {
838
1341
  const headFallback = method === "HEAD";
839
1342
  const match = this.router.find(method, url.pathname) ??
840
1343
  (headFallback ? this.router.find("GET", url.pathname) : undefined);
1344
+ // Hide internal routes from the public adapter surface. The router
1345
+ // still finds them so app.inject() can dispatch normally, but
1346
+ // app.fetch() responds 404 to avoid leaking existence.
1347
+ const internalHidden = match?.handler.def.internal === true && opts.allowInternal !== true;
841
1348
  this.assertCrossOriginAllowed(request, url, method, [
842
1349
  ...corsOriginAllowsFromHooks([this.options.hooks ?? {}]),
843
- ...(match ? match.handler.corsOriginAllows : this.corsOriginAllows),
1350
+ ...(match && !internalHidden ? match.handler.corsOriginAllows : this.corsOriginAllows),
844
1351
  ]);
845
- if (!match) {
846
- const allowed = this.router.allowedMethods(url.pathname);
1352
+ if (!match || internalHidden) {
1353
+ if (internalHidden) {
1354
+ // Don't leak existence via 405/Allow header. Always 404.
1355
+ throw new NotFoundError(`No route for ${request.method} ${url.pathname}`);
1356
+ }
1357
+ const rawAllowed = this.router.allowedMethods(url.pathname);
1358
+ // Filter out methods whose route definitions are marked
1359
+ // `internal: true` unless the caller explicitly opted in via
1360
+ // app.inject(). This prevents 405/Allow from leaking the
1361
+ // existence of hidden admin/cron endpoints.
1362
+ const allowed = opts.allowInternal
1363
+ ? rawAllowed
1364
+ : rawAllowed.filter((m) => {
1365
+ const candidate = this.router.find(m, url.pathname);
1366
+ return candidate?.handler.def.internal !== true;
1367
+ });
847
1368
  ctx = {
848
1369
  request,
849
1370
  params: {},
@@ -909,14 +1430,16 @@ export class App {
909
1430
  if (afterReturn !== undefined)
910
1431
  result = afterReturn;
911
1432
  let response = await serializeResult(result, def, this.options.validateResponses ?? true);
1433
+ copyContextHeaders(ctx, response);
1434
+ const finalized = await finalizeResponse(response, ctx, allHooks, stripFingerprint);
912
1435
  if (method === "HEAD") {
913
- response = new Response(null, {
914
- status: response.status,
915
- headers: response.headers,
1436
+ return new Response(null, {
1437
+ status: finalized.status,
1438
+ statusText: finalized.statusText,
1439
+ headers: finalized.headers,
916
1440
  });
917
1441
  }
918
- copyContextHeaders(ctx, response);
919
- return await finalizeResponse(response, ctx, allHooks, stripFingerprint);
1442
+ return finalized;
920
1443
  }
921
1444
  catch (err) {
922
1445
  const handled = await activeErrorHook?.(err, ctx);
@@ -930,6 +1453,29 @@ export class App {
930
1453
  onResponse: activeResponseHook,
931
1454
  }, stripFingerprint);
932
1455
  }
1456
+ // Wave 4 leftover: when the client has already disconnected, classify
1457
+ // the request at `disconnectStatusCode` (default 499) instead of
1458
+ // letting an AbortError bubble up as a generic 5xx. Logged at `info`
1459
+ // so disconnect storms do not look like service incidents.
1460
+ const disconnectCode = this.options.disconnectStatusCode ?? 499;
1461
+ if (disconnectCode > 0 &&
1462
+ request.signal?.aborted === true &&
1463
+ !(err instanceof HttpError)) {
1464
+ log.info({ event: "request.disconnected", status: disconnectCode }, "Client disconnected before response was sent");
1465
+ const res = new Response(null, {
1466
+ status: disconnectCode,
1467
+ headers: {
1468
+ "content-type": "application/problem+json",
1469
+ "x-request-id": requestId,
1470
+ },
1471
+ });
1472
+ if (ctx)
1473
+ copyContextHeaders(ctx, res);
1474
+ return finalizeResponse(res, ctx, {
1475
+ onSend: activeSendHook,
1476
+ onResponse: activeResponseHook,
1477
+ }, stripFingerprint);
1478
+ }
933
1479
  const httpErr = err instanceof HttpError
934
1480
  ? err
935
1481
  : new InternalError(err instanceof Error ? err.message : "Unexpected error");
@@ -1042,6 +1588,17 @@ export class App {
1042
1588
  }
1043
1589
  }
1044
1590
  }
1591
+ // Wave 4: kill idle keep-alive connections immediately so they cannot
1592
+ // be re-used for a new request that would race with the drain. Adapters
1593
+ // (Node) register a hook here. In-flight requests are unaffected.
1594
+ for (const hook of this.idleConnectionCloseHooks) {
1595
+ try {
1596
+ hook();
1597
+ }
1598
+ catch (err) {
1599
+ this.log.error({ err }, "idleConnectionCloseHook failed");
1600
+ }
1601
+ }
1045
1602
  const start = Date.now();
1046
1603
  while (this.inflight > 0 && Date.now() - start < timeoutMs) {
1047
1604
  await new Promise((r) => setTimeout(r, 25));
@@ -1054,6 +1611,30 @@ export class App {
1054
1611
  }
1055
1612
  this.log.info({ inflight: this.inflight }, "DaloyJS shutdown complete");
1056
1613
  }
1614
+ /**
1615
+ * Alias for {@link App.shutdown}. Matches the Node `Server.close()` shape
1616
+ * and reads more naturally from adapters that want a single "stop"
1617
+ * method.
1618
+ *
1619
+ * @since 0.18.0
1620
+ */
1621
+ async close(timeoutMs = 10_000, reason) {
1622
+ return this.shutdown(timeoutMs, reason);
1623
+ }
1624
+ /**
1625
+ * Adapter-private hook to register a callback that runs synchronously
1626
+ * when {@link App.shutdown} begins draining. The Node adapter uses this
1627
+ * to invoke `server.closeIdleConnections()` so keep-alive sockets without
1628
+ * an in-flight request are killed immediately instead of being held open
1629
+ * until the OS / load balancer notices.
1630
+ *
1631
+ * Not part of the documented public API surface: subject to change.
1632
+ *
1633
+ * @internal
1634
+ */
1635
+ _registerIdleConnectionCloseHook(hook) {
1636
+ this.idleConnectionCloseHooks.push(hook);
1637
+ }
1057
1638
  }
1058
1639
  // ---------- helpers ----------
1059
1640
  function joinPath(a, b) {
@@ -1062,6 +1643,16 @@ function joinPath(a, b) {
1062
1643
  const joined = `${left}${right}`;
1063
1644
  return joined === "" ? "/" : joined;
1064
1645
  }
1646
+ function healthRouteKey(request) {
1647
+ // The probe rate limit deliberately does NOT honour `X-Forwarded-For` —
1648
+ // health probes typically arrive directly from a sidecar / orchestrator,
1649
+ // so even apps that trust forwarded headers should not let an attacker
1650
+ // bypass the per-IP cap by spoofing the header. Fall back to a constant
1651
+ // key when no proxy header is available (single shared bucket).
1652
+ return (request.headers.get("x-real-ip") ??
1653
+ request.headers.get("fly-client-ip") ??
1654
+ "global");
1655
+ }
1065
1656
  function corsOriginAllowsFromHooks(layers) {
1066
1657
  const allows = [];
1067
1658
  for (const hooks of layers) {
@@ -1079,6 +1670,62 @@ function corsOriginAllowsFromHooks(layers) {
1079
1670
  }
1080
1671
  return allows;
1081
1672
  }
1673
+ /**
1674
+ * Topological sort of plugin extensions (Wave 6 item 10). Refuses-at-call
1675
+ * on cyclic ordering with a structured error naming the cycle.
1676
+ * @internal
1677
+ */
1678
+ export function topoSortExtensions(exts) {
1679
+ const byName = new Map();
1680
+ for (const e of exts) {
1681
+ if (byName.has(e.name)) {
1682
+ throw new Error(`Duplicate plugin extension name: ${JSON.stringify(e.name)}.`);
1683
+ }
1684
+ byName.set(e.name, e);
1685
+ }
1686
+ // Build adjacency: edge from A -> B means A must run before B.
1687
+ const edges = new Map();
1688
+ for (const e of exts)
1689
+ edges.set(e.name, new Set());
1690
+ for (const e of exts) {
1691
+ for (const b of e.before ?? []) {
1692
+ if (byName.has(b))
1693
+ edges.get(e.name).add(b);
1694
+ }
1695
+ for (const a of e.after ?? []) {
1696
+ if (byName.has(a))
1697
+ edges.get(a).add(e.name);
1698
+ }
1699
+ }
1700
+ // Kahn's algorithm.
1701
+ const indeg = new Map();
1702
+ for (const name of edges.keys())
1703
+ indeg.set(name, 0);
1704
+ for (const [, outs] of edges) {
1705
+ for (const v of outs)
1706
+ indeg.set(v, (indeg.get(v) ?? 0) + 1);
1707
+ }
1708
+ const queue = [];
1709
+ for (const [name, d] of indeg)
1710
+ if (d === 0)
1711
+ queue.push(name);
1712
+ const out = [];
1713
+ while (queue.length > 0) {
1714
+ const name = queue.shift();
1715
+ out.push(byName.get(name));
1716
+ for (const v of edges.get(name)) {
1717
+ const d = (indeg.get(v) ?? 0) - 1;
1718
+ indeg.set(v, d);
1719
+ if (d === 0)
1720
+ queue.push(v);
1721
+ }
1722
+ }
1723
+ if (out.length !== exts.length) {
1724
+ const remaining = Array.from(byName.keys()).filter((n) => !out.some((e) => e.name === n));
1725
+ throw new Error(`Plugin extension cycle detected among: ${remaining.map((n) => JSON.stringify(n)).join(", ")}.`);
1726
+ }
1727
+ return out;
1728
+ }
1082
1729
  function securityMarkersFromHooks(layers) {
1083
1730
  let hasSession = false;
1084
1731
  let hasCsrf = false;
@@ -1101,14 +1748,53 @@ function mergeHooks(layers) {
1101
1748
  const pick = (key) => layers
1102
1749
  .map((h) => h[key])
1103
1750
  .filter((f) => typeof f === "function");
1104
- return {
1751
+ const requiredScopes = requiredScopesFromHooks(layers);
1752
+ const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
1753
+ const hooks = {
1105
1754
  onRequest: chain(pick("onRequest")),
1106
- beforeHandle: firstResponse(pick("beforeHandle")),
1755
+ beforeHandle,
1107
1756
  afterHandle: pipeline(pick("afterHandle")),
1108
1757
  onError: firstResponse(pick("onError")),
1109
1758
  onSend: responsePipeline(pick("onSend")),
1110
1759
  onResponse: chain(pick("onResponse")),
1111
1760
  };
1761
+ stampRequiredScopes(hooks, requiredScopes);
1762
+ return hooks;
1763
+ }
1764
+ function requiredScopesFromHooks(layers) {
1765
+ const out = [];
1766
+ for (const hooks of layers) {
1767
+ const scopes = hooks[REQUIRE_SCOPES_HOOK_MARKER];
1768
+ if (!Array.isArray(scopes))
1769
+ continue;
1770
+ for (const scope of scopes) {
1771
+ if (typeof scope === "string" && !out.includes(scope))
1772
+ out.push(scope);
1773
+ }
1774
+ }
1775
+ return out;
1776
+ }
1777
+ function stampRequiredScopes(hooks, scopes) {
1778
+ if (scopes.length > 0) {
1779
+ hooks[REQUIRE_SCOPES_HOOK_MARKER] = [...scopes];
1780
+ }
1781
+ }
1782
+ function mergeBeforeHandle(beforeHandle, requiredScopes) {
1783
+ if (!beforeHandle && requiredScopes.length === 0)
1784
+ return undefined;
1785
+ return async (ctx) => {
1786
+ if (requiredScopes.length > 0) {
1787
+ const state = ctx.state;
1788
+ const prior = state[REQUIRE_SCOPES_AGGREGATE_KEY];
1789
+ const aggregate = Array.isArray(prior) ? [...prior] : [];
1790
+ for (const scope of requiredScopes) {
1791
+ if (!aggregate.includes(scope))
1792
+ aggregate.push(scope);
1793
+ }
1794
+ state[REQUIRE_SCOPES_AGGREGATE_KEY] = aggregate;
1795
+ }
1796
+ return beforeHandle?.(ctx);
1797
+ };
1112
1798
  }
1113
1799
  function responsePipeline(fns) {
1114
1800
  if (fns.length === 0)