@daloyjs/core 0.15.0 → 0.17.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 CHANGED
@@ -380,7 +380,7 @@ What works today, at a glance:
380
380
 
381
381
  - Contract-first routing, Standard Schema validation (Zod 4 / Valibot / ArkType / TypeBox), and OpenAPI 3.1 from a single source of truth.
382
382
  - Adapters for Node (Heroku/Railway/Render/Fly.io), Bun, Deno, Cloudflare Workers, Vercel Node / Edge / Next.js / Netlify Edge, Fastly Compute, and AWS Lambda / Netlify Functions / Lambda Function URLs.
383
- - Built-in security primitives (body limits, prototype-pollution-safe JSON, path-traversal guard, request timeouts, header injection guards, **duplicate `Host` / `Content-Length` rejection**, **stripped `Server` / `X-Powered-By` headers by default**, **structured-log redaction defaults** for authorization / cookie / password / token / JWT-shaped values) plus first-party middleware (`secureHeaders` with CSP nonce + Trusted Types, `cors`, `rateLimit`, `requestId`, `bearerAuth`, `basicAuth`, `csrf` with **double-submit cookie** + **Fetch-Metadata** strategies, `session`, `timing` / `timingSafeEqual`) and **zero-knob crypto helpers** (`passwordHash` / `passwordVerify` at `@daloyjs/core/hashing`, `verifyWebhookSignature` / `signWebhookPayload`).
383
+ - Built-in security primitives (body limits, prototype-pollution-safe JSON, path-traversal guard, request timeouts, header injection guards, **duplicate `Host` / `Content-Length` rejection**, **stripped `Server` / `X-Powered-By` headers by default**, **structured-log redaction defaults** for authorization / cookie / password / token / JWT-shaped values, **`secureHeaders()` auto-applied since `0.16.0`** with user-installed overrides automatically replacing the auto instance, **cross-origin state-changing requests rejected with `403` since `0.16.0`** unless a route's `cors()` policy allows the request origin, **refuse-to-boot on weak session secrets, `cors({ origin: "*" })`, `session()` + state-changing route without `csrf()`, and unconfigured `X-Forwarded-*` in production since `0.17.0`** — all four checks opt-out via `app({ secureDefaults: false })`) plus first-party middleware (`secureHeaders` with CSP nonce + Trusted Types, `cors`, `rateLimit`, `requestId`, `bearerAuth`, `basicAuth`, `csrf` with **double-submit cookie** + **Fetch-Metadata** strategies, `session`, `timing` / `timingSafeEqual`) and **zero-knob crypto helpers** (`passwordHash` / `passwordVerify` at `@daloyjs/core/hashing`, `verifyWebhookSignature` / `signWebhookPayload`).
384
384
  - Streaming helpers (SSE + NDJSON), multipart ergonomics, OpenTelemetry-compatible tracing, signed-cookie sessions with pluggable stores, and a Redis-backed rate-limit store at `@daloyjs/core/rate-limit-redis`.
385
385
  - WebSocket primitives with the same Bun-style handler shape (`open`/`message`/`close`/`drain`/`error`) running on both Node and Bun adapters, plus typed `app.ws(path, handler)` registration and route-table awareness so the upgrade listener is only installed when WS routes exist.
386
386
  - Pretty `printStartupBanner()` / `formatStartupBanner()` startup helpers at `@daloyjs/core/banner`, used by every starter template so `pnpm dev` greets you with a colorized boxed panel (TTY + `NO_COLOR` / `FORCE_COLOR` aware, with an ASCII fallback for dumb terminals).
package/dist/app.d.ts CHANGED
@@ -3,6 +3,7 @@ import { type Logger } from "./logger.js";
3
3
  import type { HttpMethod, Hooks, PathString, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
4
4
  import { type OpenAPIInfo, type OpenAPIOptions } from "./openapi.js";
5
5
  import { type DocsContentSecurityPolicyOptions, type ScalarReferenceConfiguration } from "./docs.js";
6
+ import { type SecureHeadersOptions } from "./middleware.js";
6
7
  /**
7
8
  * Configuration accepted by {@link App}'s constructor. Every field is
8
9
  * optional; sensible production defaults are applied.
@@ -58,6 +59,79 @@ export interface AppOptions {
58
59
  * @since 0.15.0
59
60
  */
60
61
  stripServerHeaders?: boolean;
62
+ /**
63
+ * Master switch for the secure-by-default Wave 2 surface (auto-applied
64
+ * {@link secureHeaders}, cross-origin guard for state-changing requests
65
+ * when no {@link cors} hook allows the request origin). Defaults to `true`
66
+ * in `@daloyjs/core@0.16.0` and later. Per-feature opt-outs
67
+ * (`secureHeaders: false`, `corsCrossOriginGuard: false`) remain available
68
+ * when this is left on. Pass `false` to restore the pre-0.16 behavior
69
+ * wholesale.
70
+ *
71
+ * @since 0.16.0
72
+ */
73
+ secureDefaults?: boolean;
74
+ /**
75
+ * Auto-install {@link secureHeaders} as a global hook so every response
76
+ * carries hardened defaults (HSTS, X-Frame-Options, nosniff, strict
77
+ * referrer, COOP/CORP, default CSP, etc.). Active when
78
+ * {@link AppOptions.secureDefaults} is not `false`. Pass `false` to skip
79
+ * the auto-install (you may still register your own `secureHeaders()` via
80
+ * `app.use(...)`). Pass an options object to override the defaults of the
81
+ * auto-installed instance.
82
+ *
83
+ * @since 0.16.0
84
+ */
85
+ secureHeaders?: SecureHeadersOptions | false;
86
+ /**
87
+ * Reject state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) that
88
+ * carry a cross-origin `Origin` header when no {@link cors} hook in the
89
+ * matched route's hook chain allows that origin. Active when
90
+ * {@link AppOptions.secureDefaults} is not `false`. Returns
91
+ * `403 application/problem+json` so the rejection is loud at the network
92
+ * boundary instead of silently allowing a CSRF / SSRF surface. Set to
93
+ * `false` if you intentionally serve a cross-origin API without `cors()`
94
+ * (rare; almost always a misconfiguration).
95
+ *
96
+ * @since 0.16.0
97
+ */
98
+ corsCrossOriginGuard?: boolean;
99
+ /**
100
+ * Declare whether the application sits behind a trusted reverse proxy
101
+ * that populates `X-Forwarded-*` headers (load balancer, CDN, Vercel,
102
+ * Cloudflare, AWS ALB, etc.). The value is opt-in tri-state for the
103
+ * Wave 3 first-request guard:
104
+ *
105
+ * - `undefined` (default) — *unconfigured*. The framework returns
106
+ * `500 problem+json` on the first request that carries an
107
+ * `X-Forwarded-*` header so a misconfigured proxy chain cannot silently
108
+ * leak spoofed client IPs to the rate limiter, audit logs, or
109
+ * request-id propagation. Disabled when {@link AppOptions.secureDefaults}
110
+ * is `false`.
111
+ * - `true` — *trust*. The framework reads `X-Forwarded-*` without
112
+ * suspicion. Use only when every request passes through a proxy chain
113
+ * you control.
114
+ * - `false` — *explicitly do not trust*. The framework ignores
115
+ * `X-Forwarded-*` even when present and silences the unconfigured-proxy
116
+ * guard. Use when the application is exposed directly to the public
117
+ * internet on purpose.
118
+ *
119
+ * @since 0.17.0
120
+ */
121
+ trustProxy?: boolean;
122
+ /**
123
+ * Opt-out for the Wave 3 boot guard that refuses to start an App which
124
+ * registers {@link session} and any state-changing route without a
125
+ * matching {@link csrf} hook. Set to `"off"` to acknowledge that you
126
+ * intentionally accept cookie-authenticated POST / PUT / PATCH / DELETE
127
+ * requests without CSRF protection (SPA + bearer-token apps, internal
128
+ * services on a private network, etc.). Leave unset (the default) for
129
+ * any browser-facing API: the boot error includes one-line copy-paste
130
+ * remediation. Disabled when {@link AppOptions.secureDefaults} is `false`.
131
+ *
132
+ * @since 0.17.0
133
+ */
134
+ csrf?: "off";
61
135
  /** Pluggable logger. Default: structured JSON logger at "info" (or noop in test). */
62
136
  logger?: Logger | {
63
137
  level?: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
@@ -267,6 +341,8 @@ export declare class App {
267
341
  private groupHooks;
268
342
  private groupTags;
269
343
  private groupAuth?;
344
+ /** Effective security markers for each registered route hook chain. */
345
+ private routeSecurityMarkers;
270
346
  /** Decorator bag merged into ctx.state on every request. */
271
347
  private decorations;
272
348
  private installedPlugins;
@@ -279,7 +355,40 @@ export declare class App {
279
355
  /** In-flight request count for graceful shutdown. */
280
356
  private inflight;
281
357
  private draining;
358
+ /**
359
+ * CORS origin allowlist predicates from the currently active group-level
360
+ * hooks. Used for unmatched routes; matched routes use the snapshot stored
361
+ * on their compiled route so later `app.use(cors(...))` calls do not
362
+ * retroactively loosen earlier routes.
363
+ */
364
+ private corsOriginAllows;
365
+ /**
366
+ * Whether the Wave 3 once-only boot guard has run (session + CSRF +
367
+ * state-changing-route check). The check is deferred to first request
368
+ * because route registration and `app.use(csrf(...))` can happen in any
369
+ * order after construction; doing it on first `fetch()` is the latest
370
+ * point we still get a 500 before any handler ever runs.
371
+ */
372
+ private wave3BootGuard;
373
+ /**
374
+ * Latched marker stamped after the framework has reported the first
375
+ * unconfigured-proxy request. Logged once at `warn` level so production
376
+ * dashboards see the misconfiguration without flooding on every retry.
377
+ */
378
+ private trustProxyWarned;
282
379
  constructor(options?: AppOptions);
380
+ /**
381
+ * Install the Wave 2 secure-by-default global hooks. Currently:
382
+ * - {@link secureHeaders} as a group-level hook so every response carries
383
+ * the hardened baseline (HSTS, X-Frame-Options, nosniff, default CSP).
384
+ *
385
+ * Called once during construction — the CORS cross-origin guard lives
386
+ * inside {@link App.fetch} because it needs the live request to decide.
387
+ * The auto-installed `secureHeaders` instance only sets headers when the
388
+ * response does not already carry them, so user-supplied
389
+ * `app.use(secureHeaders({...}))` still wins per-header.
390
+ */
391
+ private installSecureDefaults;
283
392
  /**
284
393
  * Emit a one-time `warn` when the explicit {@link AppOptions.env} option
285
394
  * disagrees with `process.env.NODE_ENV`. Silent when either signal is
@@ -293,6 +402,60 @@ export declare class App {
293
402
  * auto-mount and error response detail stripping.
294
403
  */
295
404
  private isProduction;
405
+ /**
406
+ * Wave 2 cross-origin guard. Rejects state-changing requests (`POST` /
407
+ * `PUT` / `PATCH` / `DELETE`) that carry an `Origin` header pointing at a
408
+ * different origin than the request URL when no {@link cors} hook is
409
+ * registered (neither at the app level nor on the matched route). Throws
410
+ * a {@link ForbiddenError} that surfaces as `403 application/problem+json`
411
+ * so the rejection is loud rather than silently allowing the unintended
412
+ * cross-origin write.
413
+ *
414
+ * Disabled when {@link AppOptions.secureDefaults} is `false` or
415
+ * {@link AppOptions.corsCrossOriginGuard} is `false`. Same-origin
416
+ * requests, GET / HEAD / OPTIONS, requests with no `Origin` header, and
417
+ * routes whose hook chain includes a `cors()` policy allowing the origin
418
+ * are unaffected.
419
+ */
420
+ private assertCrossOriginAllowed;
421
+ /**
422
+ * Wave 3 sync boot guard. Inspects a hook layer being installed via
423
+ * {@link App.use} and refuses-to-boot when:
424
+ *
425
+ * - `cors({ origin: "*" })` is registered while resolved environment is
426
+ * `production`;
427
+ * - `session({ secret })` is registered while resolved environment is
428
+ * `production` and any secret fails {@link assertStrongSecret}.
429
+ *
430
+ * Disabled when `secureDefaults: false`. Thrown errors propagate out of
431
+ * `app.use(...)` so the process exits during startup rather than serving
432
+ * a misconfigured surface.
433
+ */
434
+ private assertSecureHookConfig;
435
+ private resetWave3BootGuardCache;
436
+ /**
437
+ * Wave 3 first-request boot guard. Verifies that the assembled hook
438
+ * chain + route table is internally consistent before any user handler
439
+ * runs. Currently checks: when `session()` is installed and any route
440
+ * accepts a state-changing method (`POST` / `PUT` / `PATCH` / `DELETE`),
441
+ * a `csrf()` hook (or third-party equivalent stamped with
442
+ * {@link CSRF_HOOK_MARKER}) must also be present in that route's effective
443
+ * hook chain. Opt out with `app({ csrf: "off" })` or
444
+ * `app({ secureDefaults: false })`. Runs once per App between registration
445
+ * changes; the result is cached so the fast path is a single boolean check.
446
+ */
447
+ private assertWave3BootGuards;
448
+ /**
449
+ * Wave 3 per-request guard for spoofed proxy headers. When the App was
450
+ * constructed without an explicit {@link AppOptions.trustProxy} value
451
+ * and a request arrives carrying an `X-Forwarded-*` header, refuse to
452
+ * dispatch it: the rate limiter, audit log, and request-id propagation
453
+ * would otherwise honour the attacker-supplied IP. Returns a structured
454
+ * `500 problem+json` so the failure is loud at the network boundary.
455
+ * Disabled when `secureDefaults: false` or when `trustProxy` is set to
456
+ * `true` or `false` explicitly.
457
+ */
458
+ private assertTrustProxyConfigured;
296
459
  /**
297
460
  * Resolve the {@link AppOptions.docs} option and, when enabled, register
298
461
  * the `/openapi.json` + `/docs` routes. Called once during construction so
package/dist/app.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAc1E,OAAO,EAA4B,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,KAAK,EAEV,UAAU,EACV,KAAK,EACL,UAAU,EACV,cAAc,EACd,YAAY,EACZ,eAAe,EAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,cAAc,EACpB,MAAM,cAAc,CAAC;AACtB,OAAO,EAIL,KAAK,gCAAgC,EACrC,KAAK,4BAA4B,EAClC,MAAM,WAAW,CAAC;AAEnB;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,gCAAgC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAE5B,8DAA8D;IAC9D,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB,mGAAmG;IACnG,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAE/B,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;;;OAKG;IACH,SAAS,CAAC,EAAE;QACV,mEAAmE;QACnE,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,yEAAyE;QACzE,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,yEAAyE;QACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,CAAC;IAEF,+FAA+F;IAC/F,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB;;;;;;;;OAQG;IACH,GAAG,CAAC,EAAE,aAAa,GAAG,YAAY,GAAG,MAAM,CAAC;IAE5C;;;;;;;;OAQG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,qFAAqF;IACrF,MAAM,CAAC,EACH,MAAM,GACN;QAAE,KAAK,CAAC,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAA;KAAE,GACnE,KAAK,CAAC;IAEV;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,2CAA2C;IAC3C,KAAK,CAAC,EAAE,KAAK,CAAC;IAEd;;;;;;;;;OASG;IACH,OAAO,CAAC,EAAE,iBAAiB,CAAC;IAE5B;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,gBAAgB,CAAC;CAC5C;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC5B,OAAO,CAAC,EAAE,cAAc,CAAC,SAAS,CAAC,CAAC;IACpC,eAAe,CAAC,EAAE,cAAc,CAAC,iBAAiB,CAAC,CAAC;IACpD,QAAQ,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;CACvC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,0DAA0D;IAC1D,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,gFAAgF;IAChF,WAAW,CAAC,EAAE,UAAU,CAAC;IACzB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,UAAU,GAAG,KAAK,CAAC;IACrC,oFAAoF;IACpF,EAAE,CAAC,EAAE,QAAQ,GAAG,SAAS,CAAC;IAC1B,2EAA2E;IAC3E,MAAM,CAAC,EAAE,4BAA4B,CAAC;IACtC,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC3B;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB;;;OAGG;IACH,GAAG,CAAC,EAAE,gCAAgC,CAAC;CACxC;AAED,qEAAqE;AACrE,MAAM,WAAW,oBAAoB;IACnC,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,8DAA8D;AAC9D,MAAM,WAAW,aAAa;IAC5B,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,IAAI,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IAC7C;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,OAAO,YAAY,EAAE,SAAS,CAAC;CACvC;AAaD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,qBAAa,GAAG;IACd,QAAQ,CAAC,OAAO,EAAE,QAAQ,CACxB,IAAI,CACF,UAAU,EACV,mBAAmB,GAAG,gBAAgB,GAAG,kBAAkB,CAC5D,CACF,GACC,UAAU,CAAC;IACb,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,oFAAoF;IACpF,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,EAAE,CAAM;IAE5D,OAAO,CAAC,MAAM,CAA+B;IAC7C,4FAA4F;IAC5F,QAAQ,CAAC,eAAe,EAAE,iBAAiB,CAA2B;IACtE,OAAO,CAAC,MAAM,CAAM;IACpB,OAAO,CAAC,UAAU,CAAe;IACjC,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,SAAS,CAAC,CAA0B;IAC5C,4DAA4D;IAC5D,OAAO,CAAC,WAAW,CAA+B;IAClD,OAAO,CAAC,gBAAgB,CAAqB;IAC7C,OAAO,CAAC,UAAU,CAAyC;IAC3D,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,wBAAwB,CAEzB;IACP,OAAO,CAAC,iBAAiB,CAElB;IACP,OAAO,CAAC,oBAAoB,CAAS;IACrC,OAAO,CAAC,cAAc,CAA0B;IAChD,qDAAqD;IACrD,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,QAAQ,CAAS;gBAEb,OAAO,GAAE,UAAe;IAoBpC;;;;OAIG;IACH,OAAO,CAAC,iBAAiB;IAezB;;;;;OAKG;IACH,OAAO,CAAC,YAAY;IAUpB;;;;;;OAMG;IACH,OAAO,CAAC,cAAc;IAqBtB,OAAO,CAAC,SAAS;IAoHjB;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,KAAK,CACH,CAAC,SAAS,UAAU,EACpB,CAAC,SAAS,UAAU,EACpB,GAAG,SAAS,cAAc,GAAG,SAAS,EACtC,GAAG,SAAS,YAAY,EACxB,GAAG,EAAE,eAAe,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,IAAI;IAmB7C;;;;;;OAMG;IACH,EAAE,CAAC,CAAC,SAAS,UAAU,EAAE,KAAK,GAAG,OAAO,EACtC,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,gBAAgB,CAAC,CAAC,EAAE,GAAG,EAAE,KAAK,CAAC,GACvC,IAAI;IAUP;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,KAAK,CACH,MAAM,EAAE,UAAU,EAClB,MAAM,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,CAAC,EAAE,KAAK,CAAC;QAAC,IAAI,CAAC,EAAE,eAAe,CAAC,MAAM,CAAC,CAAA;KAAE,EAC1E,QAAQ,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,GAC3B,IAAI;IA0BP;;;;;;;;;;;;;;;;OAgBG;IACH,GAAG,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI;IAKvB;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,QAAQ,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,IAAI;IAKrD;;;;;;;;;;OAUG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;IAK/C;;;;;OAKG;IACH,iBAAiB,CACf,QAAQ,EAAE,CAAC,IAAI,EAAE,oBAAoB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAC7D,IAAI;IAKP;;;;;OAKG;IACH,UAAU,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;IAKzE;;OAEG;IACH,QAAQ,CACN,MAAM,EACF;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,GAC/D,CAAC,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EACxC,MAAM,GAAE;QACN,MAAM,CAAC,EAAE,UAAU,CAAC;QACpB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;QAChB,KAAK,CAAC,EAAE,KAAK,CAAC;QACd,IAAI,CAAC,EAAE,eAAe,CAAC,MAAM,CAAC,CAAC;KAC3B,GACL,IAAI;IAiCP,OAAO,CAAC,mBAAmB;IA8B3B;;;;;;;;;;;;;;;;OAgBG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAKtB;;;;;;;;;;;;;;;;;;OAkBG;IACH,KAAK,GAAU,SAAS,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAwKjD;IAEF;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CACL,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,EAC7B,IAAI,CAAC,EAAE,WAAW,GACjB,OAAO,CAAC,QAAQ,CAAC;IASpB;;;;;;OAMG;IACH,UAAU,IAAI,iBAAiB,EAAE;IAsBjC;;;;;;;;;;;;;;;OAeG;IACG,QAAQ,CAAC,SAAS,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAyBnE;AAwVD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,SAAS,CAAC,OAAO,GAAE,UAAe,GAAG,GAAG,CAEvD;AAoGD;;;;;GAKG;AACH,wBAAgB,8BAA8B,IAAI,IAAI,CAErD"}
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
package/dist/app.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { Router } from "./router.js";
2
2
  import { WebSocketRegistry } from "./websocket.js";
3
- import { BadRequestError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
3
+ import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, 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 } from "./security.js";
6
6
  import { createLogger, noopLogger } from "./logger.js";
7
7
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
8
8
  import { docsContentSecurityPolicy, scalarHtml, swaggerUiHtml, } from "./docs.js";
9
+ import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
10
+ import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
11
+ const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
9
12
  const DEFAULTS = {
10
13
  bodyLimitBytes: 1024 * 1024,
11
14
  requestTimeoutMs: 30_000,
@@ -72,6 +75,8 @@ export class App {
72
75
  groupHooks = [];
73
76
  groupTags = [];
74
77
  groupAuth;
78
+ /** Effective security markers for each registered route hook chain. */
79
+ routeSecurityMarkers = [];
75
80
  /** Decorator bag merged into ctx.state on every request. */
76
81
  decorations = {};
77
82
  installedPlugins = new Set();
@@ -84,6 +89,27 @@ export class App {
84
89
  /** In-flight request count for graceful shutdown. */
85
90
  inflight = 0;
86
91
  draining = false;
92
+ /**
93
+ * CORS origin allowlist predicates from the currently active group-level
94
+ * hooks. Used for unmatched routes; matched routes use the snapshot stored
95
+ * on their compiled route so later `app.use(cors(...))` calls do not
96
+ * retroactively loosen earlier routes.
97
+ */
98
+ corsOriginAllows = [];
99
+ /**
100
+ * Whether the Wave 3 once-only boot guard has run (session + CSRF +
101
+ * state-changing-route check). The check is deferred to first request
102
+ * because route registration and `app.use(csrf(...))` can happen in any
103
+ * order after construction; doing it on first `fetch()` is the latest
104
+ * point we still get a 500 before any handler ever runs.
105
+ */
106
+ wave3BootGuard = { checked: false };
107
+ /**
108
+ * Latched marker stamped after the framework has reported the first
109
+ * unconfigured-proxy request. Logged once at `warn` level so production
110
+ * dashboards see the misconfiguration without flooding on every retry.
111
+ */
112
+ trustProxyWarned = false;
87
113
  constructor(options = {}) {
88
114
  this.options = {
89
115
  validateResponses: options.validateResponses ?? DEFAULTS.validateResponses,
@@ -99,8 +125,35 @@ export class App {
99
125
  ? options.logger
100
126
  : createLogger({ level: options.logger?.level ?? "info" });
101
127
  this.warnOnEnvMismatch();
128
+ if (this.options.hooks)
129
+ this.assertSecureHookConfig(this.options.hooks);
130
+ this.installSecureDefaults();
102
131
  this.maybeMountDocs();
103
132
  }
133
+ /**
134
+ * Install the Wave 2 secure-by-default global hooks. Currently:
135
+ * - {@link secureHeaders} as a group-level hook so every response carries
136
+ * the hardened baseline (HSTS, X-Frame-Options, nosniff, default CSP).
137
+ *
138
+ * Called once during construction — the CORS cross-origin guard lives
139
+ * inside {@link App.fetch} because it needs the live request to decide.
140
+ * The auto-installed `secureHeaders` instance only sets headers when the
141
+ * response does not already carry them, so user-supplied
142
+ * `app.use(secureHeaders({...}))` still wins per-header.
143
+ */
144
+ installSecureDefaults() {
145
+ if (this.options.secureDefaults === false)
146
+ return;
147
+ if (this.options.secureHeaders !== false) {
148
+ const opts = this.options.secureHeaders &&
149
+ typeof this.options.secureHeaders === "object"
150
+ ? this.options.secureHeaders
151
+ : {};
152
+ const auto = secureHeadersMiddleware(opts);
153
+ auto[AUTO_SECURE_HEADERS_MARKER] = true;
154
+ this.groupHooks.push(auto);
155
+ }
156
+ }
104
157
  /**
105
158
  * Emit a one-time `warn` when the explicit {@link AppOptions.env} option
106
159
  * disagrees with `process.env.NODE_ENV`. Silent when either signal is
@@ -132,6 +185,170 @@ export class App {
132
185
  typeof process.env !== "undefined" &&
133
186
  process.env.NODE_ENV === "production");
134
187
  }
188
+ /**
189
+ * Wave 2 cross-origin guard. Rejects state-changing requests (`POST` /
190
+ * `PUT` / `PATCH` / `DELETE`) that carry an `Origin` header pointing at a
191
+ * different origin than the request URL when no {@link cors} hook is
192
+ * registered (neither at the app level nor on the matched route). Throws
193
+ * a {@link ForbiddenError} that surfaces as `403 application/problem+json`
194
+ * so the rejection is loud rather than silently allowing the unintended
195
+ * cross-origin write.
196
+ *
197
+ * Disabled when {@link AppOptions.secureDefaults} is `false` or
198
+ * {@link AppOptions.corsCrossOriginGuard} is `false`. Same-origin
199
+ * requests, GET / HEAD / OPTIONS, requests with no `Origin` header, and
200
+ * routes whose hook chain includes a `cors()` policy allowing the origin
201
+ * are unaffected.
202
+ */
203
+ assertCrossOriginAllowed(request, url, method, corsOriginAllows) {
204
+ if (this.options.secureDefaults === false)
205
+ return;
206
+ if (this.options.corsCrossOriginGuard === false)
207
+ return;
208
+ if (method !== "POST" &&
209
+ method !== "PUT" &&
210
+ method !== "PATCH" &&
211
+ method !== "DELETE") {
212
+ return;
213
+ }
214
+ const origin = request.headers.get("origin");
215
+ if (!origin || origin === "null")
216
+ return;
217
+ let originUrl;
218
+ try {
219
+ originUrl = new URL(origin);
220
+ }
221
+ catch {
222
+ // Malformed Origin header — refuse loudly.
223
+ throw new ForbiddenError("Cross-origin state-changing request rejected: malformed Origin header.");
224
+ }
225
+ if (originUrl.origin === url.origin)
226
+ return;
227
+ if (corsOriginAllows.some((allows) => allows(origin)))
228
+ return;
229
+ throw new ForbiddenError(`Cross-origin ${method} from "${originUrl.origin}" rejected: no registered cors() policy allows that origin. ` +
230
+ `Register cors({ origin: [...] }) via app.use(...) to allow it, or pass ` +
231
+ `app({ corsCrossOriginGuard: false }) / app({ secureDefaults: false }) to disable this guard.`);
232
+ }
233
+ /**
234
+ * Wave 3 sync boot guard. Inspects a hook layer being installed via
235
+ * {@link App.use} and refuses-to-boot when:
236
+ *
237
+ * - `cors({ origin: "*" })` is registered while resolved environment is
238
+ * `production`;
239
+ * - `session({ secret })` is registered while resolved environment is
240
+ * `production` and any secret fails {@link assertStrongSecret}.
241
+ *
242
+ * Disabled when `secureDefaults: false`. Thrown errors propagate out of
243
+ * `app.use(...)` so the process exits during startup rather than serving
244
+ * a misconfigured surface.
245
+ */
246
+ assertSecureHookConfig(hooks) {
247
+ if (this.options.secureDefaults === false)
248
+ return;
249
+ if (!this.isProduction())
250
+ return;
251
+ const record = hooks;
252
+ if (record[CORS_WILDCARD_ORIGIN_MARKER] === true) {
253
+ throw new Error('cors({ origin: "*" }) refused in production: a wildcard CORS origin exposes every state-changing route cross-origin. ' +
254
+ "Replace the wildcard with an explicit allowlist (string[] or predicate), or pass " +
255
+ "app({ secureDefaults: false }) to disable this guard.");
256
+ }
257
+ if (record[SESSION_HOOK_MARKER] === true) {
258
+ const secrets = record[SESSION_SECRETS_MARKER];
259
+ if (Array.isArray(secrets)) {
260
+ for (const s of secrets) {
261
+ assertStrongSecret(s, "session");
262
+ }
263
+ }
264
+ }
265
+ }
266
+ resetWave3BootGuardCache() {
267
+ this.wave3BootGuard.checked = false;
268
+ this.wave3BootGuard.error = undefined;
269
+ }
270
+ /**
271
+ * Wave 3 first-request boot guard. Verifies that the assembled hook
272
+ * chain + route table is internally consistent before any user handler
273
+ * runs. Currently checks: when `session()` is installed and any route
274
+ * accepts a state-changing method (`POST` / `PUT` / `PATCH` / `DELETE`),
275
+ * a `csrf()` hook (or third-party equivalent stamped with
276
+ * {@link CSRF_HOOK_MARKER}) must also be present in that route's effective
277
+ * hook chain. Opt out with `app({ csrf: "off" })` or
278
+ * `app({ secureDefaults: false })`. Runs once per App between registration
279
+ * changes; the result is cached so the fast path is a single boolean check.
280
+ */
281
+ assertWave3BootGuards() {
282
+ if (this.wave3BootGuard.checked) {
283
+ if (this.wave3BootGuard.error)
284
+ throw this.wave3BootGuard.error;
285
+ return;
286
+ }
287
+ this.wave3BootGuard.checked = true;
288
+ if (this.options.secureDefaults === false)
289
+ return;
290
+ if (this.options.csrf === "off")
291
+ return;
292
+ // Per the risk register: boot guards only fire in production so CI /
293
+ // staging surfaces that ship sample secrets / no CSRF token while
294
+ // iterating do not pay the refuse-to-boot cost.
295
+ if (!this.isProduction())
296
+ return;
297
+ const stateChanging = this.routeSecurityMarkers.find((r) => isStateChangingMethod(r.method) && r.hasSession && !r.hasCsrf);
298
+ if (!stateChanging)
299
+ return;
300
+ const err = new Error(`session() is registered in the hook chain for a state-changing route ` +
301
+ `(${stateChanging.method} ${stateChanging.path}) but no csrf() hook is installed. ` +
302
+ `Register csrf() via app.use(csrf({ strategy: "fetch-metadata", allowedOrigins: [...] })), ` +
303
+ `or pass app({ csrf: "off" }) to acknowledge that this app is not browser-facing.`);
304
+ this.wave3BootGuard.error = err;
305
+ throw err;
306
+ }
307
+ /**
308
+ * Wave 3 per-request guard for spoofed proxy headers. When the App was
309
+ * constructed without an explicit {@link AppOptions.trustProxy} value
310
+ * and a request arrives carrying an `X-Forwarded-*` header, refuse to
311
+ * dispatch it: the rate limiter, audit log, and request-id propagation
312
+ * would otherwise honour the attacker-supplied IP. Returns a structured
313
+ * `500 problem+json` so the failure is loud at the network boundary.
314
+ * Disabled when `secureDefaults: false` or when `trustProxy` is set to
315
+ * `true` or `false` explicitly.
316
+ */
317
+ assertTrustProxyConfigured(request) {
318
+ if (this.options.secureDefaults === false)
319
+ return;
320
+ if (this.options.trustProxy !== undefined)
321
+ return;
322
+ // Same risk-register clause as the session/CSRF guard: only enforce
323
+ // in production. Dev/CI surfaces routinely test forwarded headers
324
+ // without configuring a reverse-proxy posture.
325
+ if (!this.isProduction())
326
+ return;
327
+ const headers = request.headers;
328
+ let found;
329
+ for (const name of [
330
+ "x-forwarded-for",
331
+ "x-forwarded-host",
332
+ "x-forwarded-proto",
333
+ "x-forwarded-port",
334
+ "x-real-ip",
335
+ ]) {
336
+ if (headers.has(name)) {
337
+ found = name;
338
+ break;
339
+ }
340
+ }
341
+ if (!found)
342
+ return;
343
+ if (!this.trustProxyWarned) {
344
+ this.trustProxyWarned = true;
345
+ this.log.warn({ event: "trust-proxy.unconfigured", header: found }, `Request carried ${found} but app({ trustProxy }) is unset; refusing to honour spoofable proxy headers.`);
346
+ }
347
+ throw new InternalError(`Refusing to dispatch request: ${found} header is present but app({ trustProxy }) is unconfigured. ` +
348
+ `Pass app({ trustProxy: true }) when running behind a trusted reverse proxy, ` +
349
+ `or app({ trustProxy: false }) to ignore forwarded headers, ` +
350
+ `or app({ secureDefaults: false }) to disable this guard.`);
351
+ }
135
352
  /**
136
353
  * Resolve the {@link AppOptions.docs} option and, when enabled, register
137
354
  * the `/openapi.json` + `/docs` routes. Called once during construction so
@@ -291,6 +508,8 @@ export class App {
291
508
  * @returns This `App` instance for chaining.
292
509
  */
293
510
  route(def) {
511
+ if (def.hooks)
512
+ this.assertSecureHookConfig(def.hooks);
294
513
  const fullPath = joinPath(this.prefix, def.path);
295
514
  const merged = {
296
515
  ...def,
@@ -298,9 +517,21 @@ export class App {
298
517
  tags: [...(this.groupTags ?? []), ...(def.tags ?? [])],
299
518
  auth: def.auth ?? this.groupAuth,
300
519
  };
301
- const hooks = mergeHooks([...this.groupHooks, def.hooks ?? {}]);
302
- this.router.add(def.method, fullPath, { def: merged, hooks }, def.operationId);
520
+ const sources = [...this.groupHooks, def.hooks ?? {}];
521
+ const hooks = mergeHooks(sources);
522
+ const corsOriginAllows = corsOriginAllowsFromHooks(sources);
523
+ const securityMarkers = securityMarkersFromHooks([
524
+ this.options.hooks ?? {},
525
+ ...sources,
526
+ ]);
527
+ this.router.add(def.method, fullPath, { def: merged, hooks, corsOriginAllows }, def.operationId);
303
528
  this.routes.push(merged);
529
+ this.routeSecurityMarkers.push({
530
+ method: merged.method,
531
+ path: merged.path,
532
+ ...securityMarkers,
533
+ });
534
+ this.resetWave3BootGuardCache();
304
535
  return this;
305
536
  }
306
537
  /**
@@ -340,6 +571,8 @@ export class App {
340
571
  * @returns This `App` instance for chaining.
341
572
  */
342
573
  group(prefix, config, register) {
574
+ if (config.hooks)
575
+ this.assertSecureHookConfig(config.hooks);
343
576
  // Child apps share the parent's router/routes/etc. Disable docs auto-mount
344
577
  // on the child so it does not re-register the parent's `/openapi.json` and
345
578
  // `/docs` routes (which would throw "Duplicate route").
@@ -347,12 +580,15 @@ export class App {
347
580
  child.router = this.router;
348
581
  child.routes = this.routes;
349
582
  child.webSocketRoutes = this.webSocketRoutes;
583
+ child.routeSecurityMarkers = this.routeSecurityMarkers;
584
+ child.wave3BootGuard = this.wave3BootGuard;
350
585
  child.log = this.log;
351
586
  child.prefix = joinPath(this.prefix, prefix);
352
587
  child.groupHooks = [
353
588
  ...this.groupHooks,
354
589
  ...(config.hooks ? [config.hooks] : []),
355
590
  ];
591
+ child.corsOriginAllows = corsOriginAllowsFromHooks(child.groupHooks);
356
592
  child.groupTags = [...this.groupTags, ...(config.tags ?? [])];
357
593
  child.groupAuth = config.auth ?? this.groupAuth;
358
594
  child.decorations = this.decorations;
@@ -382,7 +618,27 @@ export class App {
382
618
  * @returns This `App` instance for chaining.
383
619
  */
384
620
  use(hooks) {
621
+ // Wave 3 boot guards: refuse to start when the new hook layer is a
622
+ // known-misconfigured security primitive in production. These checks
623
+ // run synchronously at registration time so the developer sees the
624
+ // failure during boot, not on first request.
625
+ this.assertSecureHookConfig(hooks);
626
+ // If the developer installs their own secureHeaders(), drop the
627
+ // Wave 2 auto-installed instance so the user's overrides win instead of
628
+ // being shadowed (the auto one runs first and the per-header
629
+ // "set only if absent" semantics mean the second installation would be
630
+ // a silent no-op).
631
+ if (hooks[SECURE_HEADERS_MARKER] === true) {
632
+ const autoIdx = this.groupHooks.findIndex((h) => h[AUTO_SECURE_HEADERS_MARKER] ===
633
+ true);
634
+ if (autoIdx >= 0)
635
+ this.groupHooks.splice(autoIdx, 1);
636
+ }
385
637
  this.groupHooks.push(hooks);
638
+ if (hooks[CORS_HOOK_MARKER] === true) {
639
+ this.corsOriginAllows = corsOriginAllowsFromHooks(this.groupHooks);
640
+ }
641
+ this.resetWave3BootGuardCache();
386
642
  return this;
387
643
  }
388
644
  /**
@@ -574,12 +830,18 @@ export class App {
574
830
  let activeSendHook = globalHooks.onSend;
575
831
  try {
576
832
  assertNoDuplicateSingletonHeaders(request.headers);
833
+ this.assertTrustProxyConfigured(request);
834
+ this.assertWave3BootGuards();
577
835
  await globalHooks.onRequest?.(request);
578
836
  const url = new URL(request.url);
579
837
  const method = request.method;
580
838
  const headFallback = method === "HEAD";
581
839
  const match = this.router.find(method, url.pathname) ??
582
840
  (headFallback ? this.router.find("GET", url.pathname) : undefined);
841
+ this.assertCrossOriginAllowed(request, url, method, [
842
+ ...corsOriginAllowsFromHooks([this.options.hooks ?? {}]),
843
+ ...(match ? match.handler.corsOriginAllows : this.corsOriginAllows),
844
+ ]);
583
845
  if (!match) {
584
846
  const allowed = this.router.allowedMethods(url.pathname);
585
847
  ctx = {
@@ -800,6 +1062,41 @@ function joinPath(a, b) {
800
1062
  const joined = `${left}${right}`;
801
1063
  return joined === "" ? "/" : joined;
802
1064
  }
1065
+ function corsOriginAllowsFromHooks(layers) {
1066
+ const allows = [];
1067
+ for (const hooks of layers) {
1068
+ const record = hooks;
1069
+ const allow = record[CORS_ORIGIN_ALLOW_MARKER];
1070
+ if (typeof allow === "function") {
1071
+ allows.push(allow);
1072
+ }
1073
+ else if (record[CORS_HOOK_MARKER] === true) {
1074
+ // Third-party CORS helpers can still opt out of the guard with the
1075
+ // original marker. The first-party cors() stamps a stricter predicate
1076
+ // above, so disallowed origins are rejected before the handler runs.
1077
+ allows.push(() => true);
1078
+ }
1079
+ }
1080
+ return allows;
1081
+ }
1082
+ function securityMarkersFromHooks(layers) {
1083
+ let hasSession = false;
1084
+ let hasCsrf = false;
1085
+ for (const hooks of layers) {
1086
+ const record = hooks;
1087
+ if (record[SESSION_HOOK_MARKER] === true)
1088
+ hasSession = true;
1089
+ if (record[CSRF_HOOK_MARKER] === true)
1090
+ hasCsrf = true;
1091
+ }
1092
+ return { hasSession, hasCsrf };
1093
+ }
1094
+ function isStateChangingMethod(method) {
1095
+ return (method === "POST" ||
1096
+ method === "PUT" ||
1097
+ method === "PATCH" ||
1098
+ method === "DELETE");
1099
+ }
803
1100
  function mergeHooks(layers) {
804
1101
  const pick = (key) => layers
805
1102
  .map((h) => h[key])
@@ -906,7 +1203,7 @@ async function buildContext(request, url, rawParams, def, opts) {
906
1203
  }
907
1204
  if (def.request?.body) {
908
1205
  const ct = (request.headers.get("content-type") ?? "").toLowerCase();
909
- const allowed = opts.allowedContentTypes ?? [
1206
+ const allowed = def.accepts ?? opts.allowedContentTypes ?? [
910
1207
  "application/json",
911
1208
  "application/x-www-form-urlencoded",
912
1209
  "multipart/form-data",