@daloyjs/core 0.16.0 → 0.28.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 (102) 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 +412 -3
  9. package/dist/app.d.ts.map +1 -1
  10. package/dist/app.js +895 -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 +41 -12
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +20 -5
  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 +205 -2
  65. package/dist/middleware.d.ts.map +1 -1
  66. package/dist/middleware.js +399 -78
  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/security.d.ts +32 -0
  81. package/dist/security.d.ts.map +1 -1
  82. package/dist/security.js +79 -0
  83. package/dist/security.js.map +1 -1
  84. package/dist/session.d.ts +43 -1
  85. package/dist/session.d.ts.map +1 -1
  86. package/dist/session.js +130 -87
  87. package/dist/session.js.map +1 -1
  88. package/dist/subdomains.d.ts +97 -0
  89. package/dist/subdomains.d.ts.map +1 -0
  90. package/dist/subdomains.js +157 -0
  91. package/dist/subdomains.js.map +1 -0
  92. package/dist/time-claims.d.ts +72 -0
  93. package/dist/time-claims.d.ts.map +1 -0
  94. package/dist/time-claims.js +88 -0
  95. package/dist/time-claims.js.map +1 -0
  96. package/dist/types.d.ts +50 -0
  97. package/dist/types.d.ts.map +1 -1
  98. package/dist/websocket.d.ts +51 -34
  99. package/dist/websocket.d.ts.map +1 -1
  100. package/dist/websocket.js +162 -2
  101. package/dist/websocket.js.map +1 -1
  102. package/package.json +30 -3
package/dist/app.js CHANGED
@@ -1,13 +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 } 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, 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
+ 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";
10
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
+ ]);
11
57
  const DEFAULTS = {
12
58
  bodyLimitBytes: 1024 * 1024,
13
59
  requestTimeoutMs: 30_000,
@@ -74,15 +120,23 @@ export class App {
74
120
  groupHooks = [];
75
121
  groupTags = [];
76
122
  groupAuth;
123
+ /** Effective security markers for each registered route hook chain. */
124
+ routeSecurityMarkers = [];
77
125
  /** Decorator bag merged into ctx.state on every request. */
78
126
  decorations = {};
79
127
  installedPlugins = new Set();
80
128
  closeHooks = [];
81
129
  closeHooksRun = false;
130
+ /** Wave 4 idle-connection close hooks (adapter-registered, sync). */
131
+ idleConnectionCloseHooks = [];
82
132
  pluginInstalledListeners = [];
83
133
  shutdownListeners = [];
84
134
  shutdownListenersRun = false;
85
- pendingPlugins = [];
135
+ pendingPlugins = new Set();
136
+ pluginBootError = {
137
+ failed: false,
138
+ error: undefined,
139
+ };
86
140
  /** In-flight request count for graceful shutdown. */
87
141
  inflight = 0;
88
142
  draining = false;
@@ -93,6 +147,20 @@ export class App {
93
147
  * retroactively loosen earlier routes.
94
148
  */
95
149
  corsOriginAllows = [];
150
+ /**
151
+ * Whether the Wave 3 once-only boot guard has run (session + CSRF +
152
+ * state-changing-route check). The check is deferred to first request
153
+ * because route registration and `app.use(csrf(...))` can happen in any
154
+ * order after construction; doing it on first `fetch()` is the latest
155
+ * point we still get a 500 before any handler ever runs.
156
+ */
157
+ wave3BootGuard = { checked: false };
158
+ /**
159
+ * Latched marker stamped after the framework has reported the first
160
+ * unconfigured-proxy request. Logged once at `warn` level so production
161
+ * dashboards see the misconfiguration without flooding on every retry.
162
+ */
163
+ trustProxyWarned = false;
96
164
  constructor(options = {}) {
97
165
  this.options = {
98
166
  validateResponses: options.validateResponses ?? DEFAULTS.validateResponses,
@@ -108,9 +176,65 @@ export class App {
108
176
  ? options.logger
109
177
  : createLogger({ level: options.logger?.level ?? "info" });
110
178
  this.warnOnEnvMismatch();
179
+ this.assertDisconnectStatusCode();
180
+ assertBehindProxy(this.options.behindProxy);
181
+ if (this.options.hooks)
182
+ this.assertSecureHookConfig(this.options.hooks);
183
+ this.assertInsecureDefaultsAcknowledged();
111
184
  this.installSecureDefaults();
185
+ this.maybeInstallCrashHandlers();
112
186
  this.maybeMountDocs();
113
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
+ }
114
238
  /**
115
239
  * Install the Wave 2 secure-by-default global hooks. Currently:
116
240
  * - {@link secureHeaders} as a group-level hook so every response carries
@@ -134,6 +258,58 @@ export class App {
134
258
  auto[AUTO_SECURE_HEADERS_MARKER] = true;
135
259
  this.groupHooks.push(auto);
136
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
+ });
137
313
  }
138
314
  /**
139
315
  * Emit a one-time `warn` when the explicit {@link AppOptions.env} option
@@ -211,6 +387,141 @@ export class App {
211
387
  `Register cors({ origin: [...] }) via app.use(...) to allow it, or pass ` +
212
388
  `app({ corsCrossOriginGuard: false }) / app({ secureDefaults: false }) to disable this guard.`);
213
389
  }
390
+ /**
391
+ * Wave 3 sync boot guard. Inspects a hook layer being installed via
392
+ * {@link App.use} and refuses-to-boot when:
393
+ *
394
+ * - `cors({ origin: "*" })` is registered while resolved environment is
395
+ * `production`;
396
+ * - `session({ secret })` is registered while resolved environment is
397
+ * `production` and any secret fails {@link assertStrongSecret}.
398
+ *
399
+ * Disabled when `secureDefaults: false`. Thrown errors propagate out of
400
+ * `app.use(...)` so the process exits during startup rather than serving
401
+ * a misconfigured surface.
402
+ */
403
+ assertSecureHookConfig(hooks) {
404
+ if (this.options.secureDefaults === false)
405
+ return;
406
+ if (!this.isProduction())
407
+ return;
408
+ const record = hooks;
409
+ if (record[CORS_WILDCARD_ORIGIN_MARKER] === true) {
410
+ throw new Error('cors({ origin: "*" }) refused in production: a wildcard CORS origin exposes every state-changing route cross-origin. ' +
411
+ "Replace the wildcard with an explicit allowlist (string[] or predicate), or pass " +
412
+ "app({ secureDefaults: false }) to disable this guard.");
413
+ }
414
+ if (record[SESSION_HOOK_MARKER] === true) {
415
+ const secrets = record[SESSION_SECRETS_MARKER];
416
+ if (Array.isArray(secrets)) {
417
+ for (const s of secrets) {
418
+ assertStrongSecret(s, "session");
419
+ }
420
+ }
421
+ }
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
+ }
434
+ resetWave3BootGuardCache() {
435
+ this.wave3BootGuard.checked = false;
436
+ this.wave3BootGuard.error = undefined;
437
+ }
438
+ /**
439
+ * Wave 3 first-request boot guard. Verifies that the assembled hook
440
+ * chain + route table is internally consistent before any user handler
441
+ * runs. Currently checks: when `session()` is installed and any route
442
+ * accepts a state-changing method (`POST` / `PUT` / `PATCH` / `DELETE`),
443
+ * a `csrf()` hook (or third-party equivalent stamped with
444
+ * {@link CSRF_HOOK_MARKER}) must also be present in that route's effective
445
+ * hook chain. Opt out with `app({ csrf: "off" })` or
446
+ * `app({ secureDefaults: false })`. Runs once per App between registration
447
+ * changes; the result is cached so the fast path is a single boolean check.
448
+ */
449
+ assertWave3BootGuards() {
450
+ if (this.wave3BootGuard.checked) {
451
+ if (this.wave3BootGuard.error)
452
+ throw this.wave3BootGuard.error;
453
+ return;
454
+ }
455
+ this.wave3BootGuard.checked = true;
456
+ if (this.options.secureDefaults === false)
457
+ return;
458
+ if (this.options.csrf === "off")
459
+ return;
460
+ // Per the risk register: boot guards only fire in production so CI /
461
+ // staging surfaces that ship sample secrets / no CSRF token while
462
+ // iterating do not pay the refuse-to-boot cost.
463
+ if (!this.isProduction())
464
+ return;
465
+ const stateChanging = this.routeSecurityMarkers.find((r) => isStateChangingMethod(r.method) && r.hasSession && !r.hasCsrf);
466
+ if (!stateChanging)
467
+ return;
468
+ const err = new Error(`session() is registered in the hook chain for a state-changing route ` +
469
+ `(${stateChanging.method} ${stateChanging.path}) but no csrf() hook is installed. ` +
470
+ `Register csrf() via app.use(csrf({ strategy: "fetch-metadata", allowedOrigins: [...] })), ` +
471
+ `or pass app({ csrf: "off" }) to acknowledge that this app is not browser-facing.`);
472
+ this.wave3BootGuard.error = err;
473
+ throw err;
474
+ }
475
+ /**
476
+ * Wave 3 per-request guard for spoofed proxy headers. When the App was
477
+ * constructed without an explicit {@link AppOptions.trustProxy} value
478
+ * and a request arrives carrying an `X-Forwarded-*` header, refuse to
479
+ * dispatch it: the rate limiter, audit log, and request-id propagation
480
+ * would otherwise honour the attacker-supplied IP. Returns a structured
481
+ * `500 problem+json` so the failure is loud at the network boundary.
482
+ * Disabled when `secureDefaults: false` or when `trustProxy` is set to
483
+ * `true` or `false` explicitly.
484
+ */
485
+ assertTrustProxyConfigured(request) {
486
+ if (this.options.secureDefaults === false)
487
+ return;
488
+ if (this.options.trustProxy !== undefined)
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;
495
+ // Same risk-register clause as the session/CSRF guard: only enforce
496
+ // in production. Dev/CI surfaces routinely test forwarded headers
497
+ // without configuring a reverse-proxy posture.
498
+ if (!this.isProduction())
499
+ return;
500
+ const headers = request.headers;
501
+ let found;
502
+ for (const name of [
503
+ "x-forwarded-for",
504
+ "x-forwarded-host",
505
+ "x-forwarded-proto",
506
+ "x-forwarded-port",
507
+ "x-real-ip",
508
+ ]) {
509
+ if (headers.has(name)) {
510
+ found = name;
511
+ break;
512
+ }
513
+ }
514
+ if (!found)
515
+ return;
516
+ if (!this.trustProxyWarned) {
517
+ this.trustProxyWarned = true;
518
+ this.log.warn({ event: "trust-proxy.unconfigured", header: found }, `Request carried ${found} but app({ trustProxy }) is unset; refusing to honour spoofable proxy headers.`);
519
+ }
520
+ throw new InternalError(`Refusing to dispatch request: ${found} header is present but app({ trustProxy }) is unconfigured. ` +
521
+ `Pass app({ trustProxy: true }) when running behind a trusted reverse proxy, ` +
522
+ `or app({ trustProxy: false }) to ignore forwarded headers, ` +
523
+ `or app({ secureDefaults: false }) to disable this guard.`);
524
+ }
214
525
  /**
215
526
  * Resolve the {@link AppOptions.docs} option and, when enabled, register
216
527
  * the `/openapi.json` + `/docs` routes. Called once during construction so
@@ -370,6 +681,8 @@ export class App {
370
681
  * @returns This `App` instance for chaining.
371
682
  */
372
683
  route(def) {
684
+ if (def.hooks)
685
+ this.assertSecureHookConfig(def.hooks);
373
686
  const fullPath = joinPath(this.prefix, def.path);
374
687
  const merged = {
375
688
  ...def,
@@ -377,11 +690,22 @@ export class App {
377
690
  tags: [...(this.groupTags ?? []), ...(def.tags ?? [])],
378
691
  auth: def.auth ?? this.groupAuth,
379
692
  };
693
+ this.assertRouteAuthPayloadConfig(merged);
380
694
  const sources = [...this.groupHooks, def.hooks ?? {}];
381
695
  const hooks = mergeHooks(sources);
382
696
  const corsOriginAllows = corsOriginAllowsFromHooks(sources);
697
+ const securityMarkers = securityMarkersFromHooks([
698
+ this.options.hooks ?? {},
699
+ ...sources,
700
+ ]);
383
701
  this.router.add(def.method, fullPath, { def: merged, hooks, corsOriginAllows }, def.operationId);
384
702
  this.routes.push(merged);
703
+ this.routeSecurityMarkers.push({
704
+ method: merged.method,
705
+ path: merged.path,
706
+ ...securityMarkers,
707
+ });
708
+ this.resetWave3BootGuardCache();
385
709
  return this;
386
710
  }
387
711
  /**
@@ -393,7 +717,235 @@ export class App {
393
717
  */
394
718
  ws(path, handler) {
395
719
  const fullPath = joinPath(this.prefix, path);
396
- 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
+ });
397
949
  return this;
398
950
  }
399
951
  /**
@@ -421,6 +973,8 @@ export class App {
421
973
  * @returns This `App` instance for chaining.
422
974
  */
423
975
  group(prefix, config, register) {
976
+ if (config.hooks)
977
+ this.assertSecureHookConfig(config.hooks);
424
978
  // Child apps share the parent's router/routes/etc. Disable docs auto-mount
425
979
  // on the child so it does not re-register the parent's `/openapi.json` and
426
980
  // `/docs` routes (which would throw "Duplicate route").
@@ -428,6 +982,8 @@ export class App {
428
982
  child.router = this.router;
429
983
  child.routes = this.routes;
430
984
  child.webSocketRoutes = this.webSocketRoutes;
985
+ child.routeSecurityMarkers = this.routeSecurityMarkers;
986
+ child.wave3BootGuard = this.wave3BootGuard;
431
987
  child.log = this.log;
432
988
  child.prefix = joinPath(this.prefix, prefix);
433
989
  child.groupHooks = [
@@ -440,9 +996,11 @@ export class App {
440
996
  child.decorations = this.decorations;
441
997
  child.installedPlugins = this.installedPlugins;
442
998
  child.closeHooks = this.closeHooks;
999
+ child.idleConnectionCloseHooks = this.idleConnectionCloseHooks;
443
1000
  child.pluginInstalledListeners = this.pluginInstalledListeners;
444
1001
  child.shutdownListeners = this.shutdownListeners;
445
1002
  child.pendingPlugins = this.pendingPlugins;
1003
+ child.pluginBootError = this.pluginBootError;
446
1004
  register(child);
447
1005
  return this;
448
1006
  }
@@ -464,6 +1022,11 @@ export class App {
464
1022
  * @returns This `App` instance for chaining.
465
1023
  */
466
1024
  use(hooks) {
1025
+ // Wave 3 boot guards: refuse to start when the new hook layer is a
1026
+ // known-misconfigured security primitive in production. These checks
1027
+ // run synchronously at registration time so the developer sees the
1028
+ // failure during boot, not on first request.
1029
+ this.assertSecureHookConfig(hooks);
467
1030
  // If the developer installs their own secureHeaders(), drop the
468
1031
  // Wave 2 auto-installed instance so the user's overrides win instead of
469
1032
  // being shadowed (the auto one runs first and the per-header
@@ -479,6 +1042,7 @@ export class App {
479
1042
  if (hooks[CORS_HOOK_MARKER] === true) {
480
1043
  this.corsOriginAllows = corsOriginAllowsFromHooks(this.groupHooks);
481
1044
  }
1045
+ this.resetWave3BootGuardCache();
482
1046
  return this;
483
1047
  }
484
1048
  /**
@@ -505,7 +1069,31 @@ export class App {
505
1069
  * @param value - Value bound to that property on every request.
506
1070
  * @returns This `App` instance for chaining.
507
1071
  */
508
- 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
+ }
509
1097
  this.decorations[key] = value;
510
1098
  return this;
511
1099
  }
@@ -545,16 +1133,53 @@ export class App {
545
1133
  return this;
546
1134
  }
547
1135
  /**
548
- * 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.
549
1154
  */
550
1155
  register(plugin, config = {}) {
551
- const fn = typeof plugin === "function" ? plugin : plugin.register;
552
- const name = typeof plugin === "function" ? undefined : plugin.name;
553
- if (name) {
554
- if (this.installedPlugins.has(name)) {
555
- 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.`);
556
1171
  }
557
- 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);
558
1183
  }
559
1184
  const prefix = config.prefix ?? "/";
560
1185
  const event = {
@@ -564,20 +1189,29 @@ export class App {
564
1189
  this.group(prefix, config, (child) => {
565
1190
  const r = fn(child);
566
1191
  if (r && typeof r.then === "function") {
567
- // Plugin is async caller should await app.ready().
568
- 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)));
569
1194
  }
570
1195
  else {
571
1196
  // Sync plugin: fire listeners immediately. Any returned promise from a
572
1197
  // listener is collected so `app.ready()` can await observers too.
573
1198
  const pending = this.firePluginInstalled(event);
574
1199
  if (pending) {
575
- this.pendingPlugins.push(pending);
1200
+ this.trackPendingPlugin(pending);
576
1201
  }
577
1202
  }
578
1203
  });
579
1204
  return this;
580
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
+ }
581
1215
  firePluginInstalled(event) {
582
1216
  if (this.pluginInstalledListeners.length === 0)
583
1217
  return undefined;
@@ -617,9 +1251,12 @@ export class App {
617
1251
  * @returns Promise that resolves once all pending plugins have settled.
618
1252
  */
619
1253
  ready() {
620
- if (this.pendingPlugins.length === 0)
1254
+ if (this.pluginBootError.failed) {
1255
+ return Promise.reject(this.pluginBootError.error);
1256
+ }
1257
+ if (this.pendingPlugins.size === 0)
621
1258
  return Promise.resolve();
622
- const pending = this.pendingPlugins.splice(0);
1259
+ const pending = Array.from(this.pendingPlugins);
623
1260
  return Promise.all(pending).then(() => undefined);
624
1261
  }
625
1262
  /**
@@ -642,6 +1279,28 @@ export class App {
642
1279
  * to RFC 9457 `application/problem+json` automatically.
643
1280
  */
644
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 = {}) => {
645
1304
  if (this.draining) {
646
1305
  return new Response(JSON.stringify({
647
1306
  type: "https://daloyjs.dev/errors/shutting-down",
@@ -652,6 +1311,10 @@ export class App {
652
1311
  headers: {
653
1312
  "content-type": "application/problem+json",
654
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",
655
1318
  },
656
1319
  });
657
1320
  }
@@ -670,18 +1333,38 @@ export class App {
670
1333
  let activeSendHook = globalHooks.onSend;
671
1334
  try {
672
1335
  assertNoDuplicateSingletonHeaders(request.headers);
1336
+ this.assertTrustProxyConfigured(request);
1337
+ this.assertWave3BootGuards();
673
1338
  await globalHooks.onRequest?.(request);
674
1339
  const url = new URL(request.url);
675
1340
  const method = request.method;
676
1341
  const headFallback = method === "HEAD";
677
1342
  const match = this.router.find(method, url.pathname) ??
678
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;
679
1348
  this.assertCrossOriginAllowed(request, url, method, [
680
1349
  ...corsOriginAllowsFromHooks([this.options.hooks ?? {}]),
681
- ...(match ? match.handler.corsOriginAllows : this.corsOriginAllows),
1350
+ ...(match && !internalHidden ? match.handler.corsOriginAllows : this.corsOriginAllows),
682
1351
  ]);
683
- if (!match) {
684
- 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
+ });
685
1368
  ctx = {
686
1369
  request,
687
1370
  params: {},
@@ -747,14 +1430,16 @@ export class App {
747
1430
  if (afterReturn !== undefined)
748
1431
  result = afterReturn;
749
1432
  let response = await serializeResult(result, def, this.options.validateResponses ?? true);
1433
+ copyContextHeaders(ctx, response);
1434
+ const finalized = await finalizeResponse(response, ctx, allHooks, stripFingerprint);
750
1435
  if (method === "HEAD") {
751
- response = new Response(null, {
752
- status: response.status,
753
- headers: response.headers,
1436
+ return new Response(null, {
1437
+ status: finalized.status,
1438
+ statusText: finalized.statusText,
1439
+ headers: finalized.headers,
754
1440
  });
755
1441
  }
756
- copyContextHeaders(ctx, response);
757
- return await finalizeResponse(response, ctx, allHooks, stripFingerprint);
1442
+ return finalized;
758
1443
  }
759
1444
  catch (err) {
760
1445
  const handled = await activeErrorHook?.(err, ctx);
@@ -768,6 +1453,29 @@ export class App {
768
1453
  onResponse: activeResponseHook,
769
1454
  }, stripFingerprint);
770
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
+ }
771
1479
  const httpErr = err instanceof HttpError
772
1480
  ? err
773
1481
  : new InternalError(err instanceof Error ? err.message : "Unexpected error");
@@ -880,6 +1588,17 @@ export class App {
880
1588
  }
881
1589
  }
882
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
+ }
883
1602
  const start = Date.now();
884
1603
  while (this.inflight > 0 && Date.now() - start < timeoutMs) {
885
1604
  await new Promise((r) => setTimeout(r, 25));
@@ -892,6 +1611,30 @@ export class App {
892
1611
  }
893
1612
  this.log.info({ inflight: this.inflight }, "DaloyJS shutdown complete");
894
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
+ }
895
1638
  }
896
1639
  // ---------- helpers ----------
897
1640
  function joinPath(a, b) {
@@ -900,6 +1643,16 @@ function joinPath(a, b) {
900
1643
  const joined = `${left}${right}`;
901
1644
  return joined === "" ? "/" : joined;
902
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
+ }
903
1656
  function corsOriginAllowsFromHooks(layers) {
904
1657
  const allows = [];
905
1658
  for (const hooks of layers) {
@@ -917,18 +1670,131 @@ function corsOriginAllowsFromHooks(layers) {
917
1670
  }
918
1671
  return allows;
919
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
+ }
1729
+ function securityMarkersFromHooks(layers) {
1730
+ let hasSession = false;
1731
+ let hasCsrf = false;
1732
+ for (const hooks of layers) {
1733
+ const record = hooks;
1734
+ if (record[SESSION_HOOK_MARKER] === true)
1735
+ hasSession = true;
1736
+ if (record[CSRF_HOOK_MARKER] === true)
1737
+ hasCsrf = true;
1738
+ }
1739
+ return { hasSession, hasCsrf };
1740
+ }
1741
+ function isStateChangingMethod(method) {
1742
+ return (method === "POST" ||
1743
+ method === "PUT" ||
1744
+ method === "PATCH" ||
1745
+ method === "DELETE");
1746
+ }
920
1747
  function mergeHooks(layers) {
921
1748
  const pick = (key) => layers
922
1749
  .map((h) => h[key])
923
1750
  .filter((f) => typeof f === "function");
924
- return {
1751
+ const requiredScopes = requiredScopesFromHooks(layers);
1752
+ const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
1753
+ const hooks = {
925
1754
  onRequest: chain(pick("onRequest")),
926
- beforeHandle: firstResponse(pick("beforeHandle")),
1755
+ beforeHandle,
927
1756
  afterHandle: pipeline(pick("afterHandle")),
928
1757
  onError: firstResponse(pick("onError")),
929
1758
  onSend: responsePipeline(pick("onSend")),
930
1759
  onResponse: chain(pick("onResponse")),
931
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
+ };
932
1798
  }
933
1799
  function responsePipeline(fns) {
934
1800
  if (fns.length === 0)