@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.
- package/README.md +3 -3
- package/dist/adapters/bun.d.ts.map +1 -1
- package/dist/adapters/bun.js +38 -7
- package/dist/adapters/bun.js.map +1 -1
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +29 -7
- package/dist/adapters/node.js.map +1 -1
- package/dist/app.d.ts +412 -3
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +895 -29
- package/dist/app.js.map +1 -1
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +218 -1
- package/dist/cli.js.map +1 -1
- package/dist/combine.d.ts +97 -0
- package/dist/combine.d.ts.map +1 -0
- package/dist/combine.js +247 -0
- package/dist/combine.js.map +1 -0
- package/dist/compression.d.ts +127 -0
- package/dist/compression.d.ts.map +1 -0
- package/dist/compression.js +368 -0
- package/dist/compression.js.map +1 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +132 -0
- package/dist/config.js.map +1 -0
- package/dist/conn-info.d.ts +121 -0
- package/dist/conn-info.d.ts.map +1 -0
- package/dist/conn-info.js +145 -0
- package/dist/conn-info.js.map +1 -0
- package/dist/cookie.d.ts +112 -0
- package/dist/cookie.d.ts.map +1 -0
- package/dist/cookie.js +185 -0
- package/dist/cookie.js.map +1 -0
- package/dist/dependency.d.ts +47 -0
- package/dist/dependency.d.ts.map +1 -0
- package/dist/dependency.js +68 -0
- package/dist/dependency.js.map +1 -0
- package/dist/etag.d.ts +48 -0
- package/dist/etag.d.ts.map +1 -0
- package/dist/etag.js +117 -0
- package/dist/etag.js.map +1 -0
- package/dist/index.d.ts +41 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -5
- package/dist/index.js.map +1 -1
- package/dist/ip-restriction.d.ts +75 -0
- package/dist/ip-restriction.d.ts.map +1 -0
- package/dist/ip-restriction.js +203 -0
- package/dist/ip-restriction.js.map +1 -0
- package/dist/jwk.d.ts +82 -0
- package/dist/jwk.d.ts.map +1 -0
- package/dist/jwk.js +269 -0
- package/dist/jwk.js.map +1 -0
- package/dist/jwt.d.ts +103 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +437 -0
- package/dist/jwt.js.map +1 -0
- package/dist/load-shedding.d.ts +73 -0
- package/dist/load-shedding.d.ts.map +1 -0
- package/dist/load-shedding.js +171 -0
- package/dist/load-shedding.js.map +1 -0
- package/dist/middleware.d.ts +205 -2
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +399 -78
- package/dist/middleware.js.map +1 -1
- package/dist/multipart.d.ts +17 -0
- package/dist/multipart.d.ts.map +1 -1
- package/dist/multipart.js +117 -1
- package/dist/multipart.js.map +1 -1
- package/dist/openapi.d.ts +8 -0
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +18 -1
- package/dist/openapi.js.map +1 -1
- package/dist/security-schemes.d.ts +21 -5
- package/dist/security-schemes.d.ts.map +1 -1
- package/dist/security-schemes.js +32 -5
- package/dist/security-schemes.js.map +1 -1
- package/dist/security.d.ts +32 -0
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +79 -0
- package/dist/security.js.map +1 -1
- package/dist/session.d.ts +43 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +130 -87
- package/dist/session.js.map +1 -1
- package/dist/subdomains.d.ts +97 -0
- package/dist/subdomains.d.ts.map +1 -0
- package/dist/subdomains.js +157 -0
- package/dist/subdomains.js.map +1 -0
- package/dist/time-claims.d.ts +72 -0
- package/dist/time-claims.d.ts.map +1 -0
- package/dist/time-claims.js +88 -0
- package/dist/time-claims.js.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/websocket.d.ts +51 -34
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +162 -2
- package/dist/websocket.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
|
568
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
752
|
-
status:
|
|
753
|
-
|
|
1436
|
+
return new Response(null, {
|
|
1437
|
+
status: finalized.status,
|
|
1438
|
+
statusText: finalized.statusText,
|
|
1439
|
+
headers: finalized.headers,
|
|
754
1440
|
});
|
|
755
1441
|
}
|
|
756
|
-
|
|
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
|
-
|
|
1751
|
+
const requiredScopes = requiredScopesFromHooks(layers);
|
|
1752
|
+
const beforeHandle = mergeBeforeHandle(firstResponse(pick("beforeHandle")), requiredScopes);
|
|
1753
|
+
const hooks = {
|
|
925
1754
|
onRequest: chain(pick("onRequest")),
|
|
926
|
-
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)
|