@daloyjs/core 0.37.0 → 0.38.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DaloyJS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -9,11 +9,18 @@
9
9
 
10
10
  # DaloyJS
11
11
 
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
12
13
  [![CI](https://github.com/daloyjs/daloy/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/daloyjs/daloy/actions/workflows/ci.yml)
13
14
  [![CodeQL](https://github.com/daloyjs/daloy/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/daloyjs/daloy/actions/workflows/codeql.yml)
14
15
  [![Publish](https://github.com/daloyjs/daloy/actions/workflows/release.yml/badge.svg)](https://github.com/daloyjs/daloy/actions/workflows/release.yml)
15
- [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/daloyjs/daloy/badge)](https://securityscorecards.dev/viewer/?uri=github.com/daloyjs/daloy)
16
16
  [![Zizmor](https://github.com/daloyjs/daloy/actions/workflows/zizmor.yml/badge.svg?branch=main)](https://github.com/daloyjs/daloy/actions/workflows/zizmor.yml)
17
+ [![GitHub last commit](https://img.shields.io/github/last-commit/daloyjs/daloy)](https://github.com/daloyjs/daloy/commits/main)
18
+ [![npm version](https://img.shields.io/npm/v/@daloyjs/core)](https://www.npmjs.com/package/@daloyjs/core)
19
+ [![JSR](https://jsr.io/badges/@daloyjs/daloy)](https://jsr.io/@daloyjs/daloy)
20
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13058/badge)](https://www.bestpractices.dev/projects/13058)
21
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/daloyjs/daloy/badge)](https://securityscorecards.dev/viewer/?uri=github.com/daloyjs/daloy)
22
+ [![Security Responsible
23
+ Disclosure](https://img.shields.io/badge/Security-Responsible%20Disclosure-yellow.svg)](https://github.com/daloyjs/daloy/blob/main/SECURITY.md)
17
24
 
18
25
  > A **runtime-portable TypeScript web framework** with built-in **contract-first routing**, **validation**, **OpenAPI (Hey API)**, **typed client generation**, **large-scale maintainability**, and **security-focused runtime plus supply-chain posture**.
19
26
 
@@ -32,6 +39,7 @@ DaloyJS exists to be the framework you'd build if you took the best ideas from e
32
39
  | Mature **Swagger / docs / ops** in Node | [Fastify](https://fastify.dev/docs/latest/Reference/) | Encapsulated plugins, structured logger, graceful shutdown, request ids, and lifecycle hooks — all first-party. |
33
40
  | Modern **TS-first DX**, Bun acceptable | [Elysia](https://elysiajs.com/at-glance.html) | End-to-end typed handlers, typed context, and a typed in-process client — no codegen step required. |
34
41
  | Best-in-class **typed client codegen** for any consumer | [Hey API](https://heyapi.dev/openapi-ts/get-started) | One `pnpm gen` command emits a fully-typed fetch SDK from your live OpenAPI spec. |
42
+ | **Contract-first typed client, no codegen** | [ts-rest](https://ts-rest.com/) | Your route definition *is* the contract: an in-process typed client with zero codegen, plus OpenAPI 3.1 + a Hey API SDK for consumers that can't import your types. |
35
43
  | Opinionated **DI / module architecture** for large teams | [NestJS](https://docs.nestjs.com/) | Plugin encapsulation, `register()` prefixes, and `defineDependency()` typed-DI with per-request dedup — no decorators. |
36
44
  | Minimalist **async middleware cascade** | [Koa](https://koajs.com/) | Koa-style `Context` on a web-standard core, with validation, OpenAPI, errors, and security headers in-box. |
37
45
  | **Services + real-time** API framework | [FeathersJS](https://feathersjs.com/) | First-party `app.ws()` with CSWSH refuse-to-boot guards, plus SSE / NDJSON streaming over explicit OpenAPI routes. |
@@ -55,6 +63,7 @@ Each existing stack is excellent at one thing and forces tradeoffs everywhere el
55
63
  - Fastify has the best Node ops story but is Node-only and validation/types/docs are not unified.
56
64
  - FastAPI has the best docs ergonomics — but it's Python.
57
65
  - Hey API gives you the best typed client — but you still need a server that produces a clean spec.
66
+ - ts-rest gives lovely end-to-end types from a shared contract — but it rides on top of another server (Express/Fastify/Nest/Next), its safety is TypeScript-only, and OpenAPI and security are bring-your-own.
58
67
  - npm leaves supply-chain protection up to you.
59
68
 
60
69
  DaloyJS combines the wins:
@@ -221,6 +230,10 @@ const r = await client.getBookById({ params: { id: "1" } });
221
230
  // ^? { status: 200; body: { id: string; title: string } } | { status: 404; ... }
222
231
  ```
223
232
 
233
+ > Method inference relies on **chaining** your `app.route(...)` calls (`new App().route(a).route(b)`) and letting
234
+ > TypeScript infer the variable's type. A widening `const app: App` annotation, a `: App` factory return type, or
235
+ > registering routes as separate statements erases the per-route types and collapses the client to an untyped surface.
236
+
224
237
  ---
225
238
 
226
239
  ## Built-in docs UI (Scalar / Swagger UI)
@@ -311,6 +324,7 @@ deployment.
311
324
  | **Header / response splitting** | Core header sanitizers reject CRLF + NUL. |
312
325
  | **Path traversal** | Core router rejects `..` segments and `//` before walking. |
313
326
  | **Slow-loris / hung handlers** | Core `requestTimeoutMs` aborts handlers (default 30 s); Node adapter sets `requestTimeout` + `headersTimeout` + `maxHeaderSize`. |
327
+ | **HTTP/2 Bomb / header-count flood** | Core `maxHeaderCount` rejects requests with more than 100 header fields (`431`) before routing; Node adapter sets `server.maxHeadersCount`. See [SECURITY.md](SECURITY.md) for the upstream HTTP/2 mitigations. |
314
328
  | **MIME sniffing** | First-party `secureHeaders()` sets `X-Content-Type-Options: nosniff`; scaffolded apps enable it. |
315
329
  | **Clickjacking** | First-party `secureHeaders()` sets `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`; scaffolded apps enable it. |
316
330
  | **XSS via injected scripts** | First-party `secureHeaders()` provides a strict CSP `default-src 'self'` baseline; the directives-object form supports per-request **nonces** and **Trusted Types** (`require-trusted-types-for 'script'`). |
@@ -328,6 +342,33 @@ deployment.
328
342
 
329
343
  ---
330
344
 
345
+ ## Authentication, OAuth2 & OpenID Connect
346
+
347
+ DaloyJS is a **resource server** (and a toolkit for building a relying party),
348
+ **not** an identity provider. Like Hono, Express, Fastify, or ASP.NET Core, it
349
+ *verifies* and *enforces* tokens on each request — it does **not** ship a login
350
+ UI, a user database, or an OAuth2 authorization server. It is **not** an
351
+ "IdentityServer": it cannot, on its own, do what Duende IdentityServer,
352
+ Keycloak, or Auth0 do (run login pages, manage clients/consent, mint tokens).
353
+
354
+ To add login you bring an **OpenID Connect provider**. It does not have to be
355
+ Auth0/Okta/Clerk specifically — any standards-compliant IdP works, including
356
+ managed (Auth0, Okta, Clerk, Microsoft Entra ID, AWS Cognito) and **self-hosted
357
+ open source** (Keycloak, Zitadel, Ory, Authentik, Logto, SuperTokens, Dex).
358
+ Don't build your own authorization server — verify tokens from a vetted one.
359
+
360
+ - **API as a resource server (default):** verify JWTs with `jwk()` against the
361
+ provider's JWKS (asymmetric-only algorithm allowlist, `issuer`/`audience`
362
+ enforced), then authorize per route with `requireScopes()`.
363
+ - **Browser app:** use the back-end-for-frontend (BFF) pattern — run the
364
+ authorization-code + PKCE flow server-side, keep tokens in a `session()`
365
+ cookie (never in JavaScript), and protect mutations with `csrf()`.
366
+
367
+ Read [Auth architecture: where DaloyJS fits in OAuth2 & OpenID Connect](https://daloyjs.dev/docs/auth/architecture)
368
+ for the full picture, plus the per-provider guides under [`/docs/auth`](https://daloyjs.dev/docs/auth).
369
+
370
+ ---
371
+
331
372
  ## Performance
332
373
 
333
374
  ```text
@@ -418,6 +459,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
418
459
  - Hono — portable web-standard router: <https://hono.dev/docs/>
419
460
  - Elysia — TS-first DX & typed context: <https://elysiajs.com/at-glance.html>
420
461
  - Fastify — production Node web framework: <https://fastify.dev/docs/latest/Reference/>
462
+ - ts-rest — contract-first, RPC-like client/server over REST: <https://ts-rest.com/>
421
463
  - pnpm — strict, secure, content-addressable package manager: <https://pnpm.io/motivation>
422
464
  - Standard Schema — universal validator interface: <https://github.com/standard-schema/standard-schema>
423
465
  - RFC 9457 — Problem Details for HTTP APIs: <https://www.rfc-editor.org/rfc/rfc9457>
@@ -556,6 +598,7 @@ A growing suite of static gates runs on every push and PR:
556
598
  - Parity / governance / runtime-parity / routing-hardening audits: `verify:parity-audits`, `verify:governance-audits`, `verify:runtime-parity-audits`, `verify:routing-hardening-audits`.
557
599
  - Source-tree gates: `verify:no-shrinkwrap`, `verify:no-bin-shadowing`, `verify:no-native-addons`, `verify:no-polyfill-cdns` (hijacked-CDN IOCs and typosquats), `verify:no-redos-patterns`, `verify:no-encoded-payloads`, `verify:no-invisible-unicode`, `verify:no-weak-random`, `verify:no-unsafe-buffer`, `verify:no-leaked-credentials`, `verify:no-vulnerable-sandboxes`.
558
600
  - Agent-skill gates: `verify:no-leaky-agent-skills`, `verify:no-toxic-agent-skills`, `verify:no-toxic-skills` — scanning every agent-instruction surface (`SKILL.md`, `AGENTS.md`, `copilot-instructions.md`, `.cursorrules`, `CLAUDE.md`, `*.instructions.md`, `*.prompt.md`); the `.cursorrules` / `CLAUDE.md` filenames cover the **TrapDoor** crypto-stealer's AI-agent-config prompt-injection persistence ([Socket, 2026-05-24](https://socket.dev/blog/trapdoor-crypto-stealer)).
601
+ - Agent / editor config-autorun gate: `verify:no-agent-config-autorun` — refuses editor / AI-coding-agent config files that auto-execute a command on folder open or session start (VS Code `folderOpen` task, Claude/Gemini `"type": "command"` hook, Cursor `alwaysApply` run-a-script rule, a `package.json` `"test": "node .github/setup.js"` hijack, or a loose `.github/` dropper), covering the **Miasma** worm's config-injection detonation surface ([SafeDep, 2026-06-05](https://safedep.io/miasma-worm-ai-coding-agent-config-injection/)).
559
602
  - Dependency gates: `verify:no-runtime-deps`, `verify:dep-licenses`, `verify:known-dep-names`, `verify:lockfile-sources`, `verify:no-registry-exfiltration`, `verify:no-remote-exec`, `verify:no-lifecycle-scripts`, `verify:runtime-eol` (refuses to release on a Node line past its EOL date).
560
603
  - IOC coverage in `verify:no-registry-exfiltration` and `verify:lockfile-sources` for active campaigns including Beamglea phishing-CDN, `naya-flore` / `nvlore-hsc` WhatsApp remote-kill-switch, the Toptal GitHub-org hijack, `xuxingfeng` and `xlsx-to-json-lh` destructive payloads, `react-login-page` keylogger, `@crypto-exploit` wallet drainers, Vietnam-Telegram-ban Fastlane typosquats, surveillance-malware packages, the Discord-webhook reconnaissance campaign, the `codexui-android` AI-coding-agent token theft (reads of `~/.codex/auth.json` / `~/.claude/`), and npm-package-aliasing dependency-confusion patterns.
561
604
  - `SECURITY-CONTACTS.md` rotation file with a machine-readable ACTIVE block and `<!-- last-exercise: -->` marker; the release workflow refuses to publish when `github.actor` is not on the ACTIVE rotation.
@@ -16,6 +16,20 @@ export interface NodeServerOptions {
16
16
  handleSignals?: boolean;
17
17
  /** Maximum HTTP header size bytes (DoS protection). Default: 16 KiB. */
18
18
  maxHeaderBytes?: number;
19
+ /**
20
+ * Maximum number of incoming HTTP header fields, forwarded to Node's
21
+ * `server.maxHeadersCount`. This is the native, parser-level counterpart to
22
+ * the framework's portable {@link "../app.js".AppOptions.maxHeaderCount}
23
+ * guard: a header-count flood is dropped by the HTTP parser before it ever
24
+ * becomes a `Request`, which is the cheapest place to shed header-count
25
+ * amplification (the dimension abused by the "HTTP/2 Bomb"). Node's own
26
+ * default is `2000`; this adapter tightens it to `100` to mirror the
27
+ * application-tier cap. Set `0` to disable (use Node's unbounded default).
28
+ * Default: 100.
29
+ *
30
+ * @since 0.38.0
31
+ */
32
+ maxHeaderCount?: number;
19
33
  /**
20
34
  * Maximum number of concurrent sockets the server will accept, forwarded to
21
35
  * Node's `server.maxConnections`. Acts as connection-layer admission
@@ -36,6 +36,14 @@ export function serve(app, opts = {}) {
36
36
  server.requestTimeout = opts.connectionTimeoutMs ?? 30_000;
37
37
  server.headersTimeout = opts.connectionTimeoutMs ?? 30_000;
38
38
  server.keepAliveTimeout = 5_000;
39
+ // Native parser-level header-count cap. Drops header-count floods (the
40
+ // "HTTP/2 Bomb" amplification dimension) before they become a Request.
41
+ // `0` opts out and restores Node's unbounded-ish default (2000).
42
+ const maxHeaderCount = opts.maxHeaderCount;
43
+ server.maxHeadersCount =
44
+ typeof maxHeaderCount === "number" && maxHeaderCount >= 0
45
+ ? maxHeaderCount
46
+ : 100;
39
47
  // Connection-layer admission control. Reject overflow sockets at accept time
40
48
  // rather than queuing them into the event loop under overload.
41
49
  if (typeof opts.maxConnections === "number" && opts.maxConnections > 0) {
package/dist/app.d.ts CHANGED
@@ -75,6 +75,19 @@ export interface AppOptions {
75
75
  allowedContentTypes?: string[];
76
76
  /** Per-request timeout in ms (handler + hooks). Default: 30000. Set 0 to disable. */
77
77
  requestTimeoutMs?: number;
78
+ /**
79
+ * Maximum number of distinct request header fields accepted before the
80
+ * request is rejected with `431 Request Header Fields Too Large`. This is
81
+ * the runtime-portable, application-tier defence against header-*count*
82
+ * amplification (the dimension abused by the "HTTP/2 Bomb", where
83
+ * per-header server-side bookkeeping — not header size — is the
84
+ * amplifier). It complements the native header-count caps a runtime/proxy
85
+ * terminating HTTP/2 must apply (NGINX `max_headers`, Node
86
+ * `server.maxHeadersCount`). Set `0` to disable. Default: 100.
87
+ *
88
+ * @since 0.38.0
89
+ */
90
+ maxHeaderCount?: number;
78
91
  /**
79
92
  * Per-request limits applied when parsing `multipart/form-data` bodies.
80
93
  * These run in addition to `bodyLimitBytes`. Use them to cap the size of
@@ -663,13 +676,37 @@ export declare const DALOY_RAW_STREAM: unique symbol;
663
676
  * serve(app, { port: 3000 });
664
677
  * ```
665
678
  *
679
+ /**
680
+ * Append a freshly-registered route to an `App`'s accumulated route tuple.
681
+ *
682
+ * A newly-constructed `App` starts with the permissive default
683
+ * `readonly RouteDefinition<any, any, any, any>[]` so that bare `App`
684
+ * annotations (e.g. `serve(app: App)`) accept any instance. The first
685
+ * {@link App.route} call resets that wide default to a clean single-element
686
+ * tuple, so the typed client (`createClient(app)`) is keyed only by the
687
+ * routes the caller actually registered; subsequent calls append precisely.
688
+ *
689
+ * @typeParam Routes - The current accumulated route tuple.
690
+ * @typeParam R - The route definition being registered.
691
+ */
692
+ type AppendRoute<Routes extends readonly RouteDefinition<any, any, any, any>[], R extends RouteDefinition<any, any, any, any>> = readonly RouteDefinition<any, any, any, any>[] extends Routes ? readonly [R] : readonly [...Routes, R];
693
+ /**
666
694
  * @since 0.1.0
667
695
  */
668
- export declare class App {
696
+ export declare class App<Routes extends readonly RouteDefinition<any, any, any, any>[] = readonly RouteDefinition<any, any, any, any>[]> {
669
697
  readonly options: Required<Pick<AppOptions, "validateResponses" | "bodyLimitBytes" | "requestTimeoutMs">> & AppOptions;
670
698
  readonly log: Logger;
671
- /** Public registry: enables OpenAPI gen, typed-client gen, dead-route detection. */
672
- readonly routes: RouteDefinition<any, any, any, any>[];
699
+ /**
700
+ * Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
701
+ *
702
+ * Statically the property is typed as the `Routes` tuple so that
703
+ * {@link App.route} can accumulate each registered route's literal
704
+ * `operationId`, request, and response types. The typed client
705
+ * (`createClient(app)`) reads this tuple to derive a precisely-typed method
706
+ * per route. At runtime it is an ordinary growable array — the tuple typing
707
+ * is a compile-time view only.
708
+ */
709
+ readonly routes: Routes;
673
710
  private router;
674
711
  /**
675
712
  * Memoized result of `isProduction()`. The inputs (`options.env`,
@@ -797,6 +834,7 @@ export declare class App {
797
834
  trustProxy: true | false | "unconfigured";
798
835
  bodyLimitBytes: number;
799
836
  requestTimeoutMs: number;
837
+ maxHeaderCount: number;
800
838
  stripServerHeaders: boolean;
801
839
  production: boolean;
802
840
  };
@@ -921,10 +959,22 @@ export declare class App {
921
959
  * });
922
960
  * ```
923
961
  *
962
+ * The return type widens `Routes` with the freshly-registered route so
963
+ * that chained registration (`new App().route(a).route(b)`) accumulates a
964
+ * precise tuple. The typed client (`createClient(app)`) consumes that tuple
965
+ * to expose a method per `operationId` with parameters and responses
966
+ * inferred from the route's own schemas. Non-chained calls
967
+ * (`app.route(a); app.route(b);`) keep the variable's original type, so
968
+ * chain the calls when you want the inferred client surface.
969
+ *
924
970
  * @param def - The route definition.
925
- * @returns This `App` instance for chaining.
971
+ * @returns This `App` instance (widened with the new route) for chaining.
926
972
  */
927
- route<P extends PathString, M extends HttpMethod, Req extends RequestSchemas | undefined, Res extends ResponsesMap>(def: RouteDefinition<P, M, Req, Res>): this;
973
+ route<P extends PathString, M extends HttpMethod, Req extends RequestSchemas | undefined, Res extends ResponsesMap, const Op extends string | undefined = undefined>(def: RouteDefinition<P, M, Req, Res> & {
974
+ operationId?: Op;
975
+ }): App<AppendRoute<Routes, RouteDefinition<P, M, Req, Res> & {
976
+ operationId: Op;
977
+ }>>;
928
978
  /**
929
979
  * Register a WebSocket route. The handler runs when an HTTP client sends an
930
980
  * `Upgrade: websocket` request to `path`; the adapter performs the RFC 6455
@@ -1332,3 +1382,4 @@ export declare function createApp(options?: AppOptions): App;
1332
1382
  * @internal
1333
1383
  */
1334
1384
  export declare function _resetPackageJsonCacheForTests(): void;
1385
+ export {};
package/dist/app.js CHANGED
@@ -2,7 +2,7 @@ import { Router } from "./router.js";
2
2
  import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
3
3
  import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
4
4
  import { validate } from "./schema.js";
5
- import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey } from "./security.js";
5
+ import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey } from "./security.js";
6
6
  import { createLogger, noopLogger } from "./logger.js";
7
7
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
8
8
  import { docsContentSecurityPolicy, scalarHtml, swaggerUiHtml, } from "./docs.js";
@@ -149,6 +149,7 @@ function applySecurityPreset(options) {
149
149
  const DEFAULTS = {
150
150
  bodyLimitBytes: 1024 * 1024,
151
151
  requestTimeoutMs: 30_000,
152
+ maxHeaderCount: DEFAULT_MAX_HEADER_COUNT,
152
153
  validateResponses: true,
153
154
  };
154
155
  const TEXT_ENCODER = new TextEncoder();
@@ -181,58 +182,21 @@ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
181
182
  */
182
183
  export const DALOY_RAW_STREAM = Symbol.for("daloyjs.response.rawStream");
183
184
  /**
184
- * Contract-first HTTP application.
185
- *
186
- * `App` is the top-level entry point: register {@link RouteDefinition routes}
187
- * with {@link App.route}, layer cross-cutting behavior with
188
- * {@link App.use}/{@link App.register}, then expose the application to a
189
- * runtime via {@link App.fetch} (Web standard) or one of the adapter subpaths
190
- * such as `@daloyjs/core/node`, `@daloyjs/core/cloudflare`, or
191
- * `@daloyjs/core/lambda`.
192
- *
193
- * The same `App` instance powers:
194
- *
195
- * - request routing (`Router` under the hood)
196
- * - request/response validation against Standard-Schema validators
197
- * - OpenAPI 3.1 generation (`generateOpenAPI(app)`)
198
- * - typed in-process client (`createClient(app)`) and generated SDK
199
- * - graceful shutdown and lifecycle observability
200
- *
201
- * `App` is **runtime-agnostic**: the same instance runs on Node, Bun, Deno,
202
- * Cloudflare Workers, Vercel Edge, AWS Lambda, and Fastly Compute via the
203
- * dedicated adapters.
204
- *
205
- * @example
206
- * ```ts
207
- * import { App, secureHeaders } from "@daloyjs/core";
208
- * import { z } from "zod";
209
- *
210
- * const app = new App({ title: "Books API", version: "1.0.0" });
211
- *
212
- * app.use(secureHeaders());
213
- *
214
- * app.route({
215
- * method: "GET",
216
- * path: "/books/:id",
217
- * operationId: "getBook",
218
- * request: { params: z.object({ id: z.uuid() }) },
219
- * responses: {
220
- * 200: { description: "OK", body: z.object({ id: z.string(), title: z.string() }) },
221
- * },
222
- * handler: ({ params }) => ({ status: 200, body: { id: params.id, title: "Dune" } }),
223
- * });
224
- *
225
- * // Node:
226
- * import { serve } from "@daloyjs/core/node";
227
- * serve(app, { port: 3000 });
228
- * ```
229
- *
230
185
  * @since 0.1.0
231
186
  */
232
187
  export class App {
233
188
  options;
234
189
  log;
235
- /** Public registry: enables OpenAPI gen, typed-client gen, dead-route detection. */
190
+ /**
191
+ * Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
192
+ *
193
+ * Statically the property is typed as the `Routes` tuple so that
194
+ * {@link App.route} can accumulate each registered route's literal
195
+ * `operationId`, request, and response types. The typed client
196
+ * (`createClient(app)`) reads this tuple to derive a precisely-typed method
197
+ * per route. At runtime it is an ordinary growable array — the tuple typing
198
+ * is a compile-time view only.
199
+ */
236
200
  routes = [];
237
201
  router = new Router();
238
202
  /**
@@ -328,6 +292,7 @@ export class App {
328
292
  validateResponses: resolved.validateResponses ?? DEFAULTS.validateResponses,
329
293
  bodyLimitBytes: resolved.bodyLimitBytes ?? DEFAULTS.bodyLimitBytes,
330
294
  requestTimeoutMs: resolved.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
295
+ maxHeaderCount: resolved.maxHeaderCount ?? DEFAULTS.maxHeaderCount,
331
296
  ...resolved,
332
297
  };
333
298
  this.log =
@@ -386,7 +351,8 @@ export class App {
386
351
  "If you really need this in production, also pass " +
387
352
  "acknowledgeInsecureDefaults: true to confirm. Prefer per-feature opt-outs " +
388
353
  "(secureHeaders: false, corsCrossOriginGuard: false, crashOnUnhandledRejection: false, " +
389
- "trustProxy: false, csrf: \"off\") instead.");
354
+ "trustProxy: false, csrf: \"off\") instead. " +
355
+ "See https://daloyjs.dev/docs/security/secure-defaults-enforcement.");
390
356
  }
391
357
  if (!insecureDefaultsLoggedThisProcess) {
392
358
  insecureDefaultsLoggedThisProcess = true;
@@ -456,6 +422,7 @@ export class App {
456
422
  trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
457
423
  bodyLimitBytes: this.options.bodyLimitBytes,
458
424
  requestTimeoutMs: this.options.requestTimeoutMs,
425
+ maxHeaderCount: this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT,
459
426
  stripServerHeaders: o.stripServerHeaders !== false,
460
427
  production: this.isProduction(),
461
428
  });
@@ -700,8 +667,11 @@ export class App {
700
667
  return;
701
668
  const err = new Error(`session() is registered in the hook chain for a state-changing route ` +
702
669
  `(${stateChanging.method} ${stateChanging.path}) but no csrf() hook is installed. ` +
670
+ `Without CSRF protection a browser can be tricked into making authenticated ` +
671
+ `state-changing requests cross-site. ` +
703
672
  `Register csrf() via app.use(csrf({ strategy: "fetch-metadata", allowedOrigins: [...] })), ` +
704
- `or pass app({ csrf: "off" }) to acknowledge that this app is not browser-facing.`);
673
+ `or pass app({ csrf: "off" }) to acknowledge that this app is not browser-facing. ` +
674
+ `See https://daloyjs.dev/docs/security/boot-guards.`);
705
675
  this.bootGuard.error = err;
706
676
  throw err;
707
677
  }
@@ -751,9 +721,12 @@ export class App {
751
721
  this.log.warn({ event: "trust-proxy.unconfigured", header: found }, `Request carried ${found} but app({ trustProxy }) is unset; refusing to honour spoofable proxy headers.`);
752
722
  }
753
723
  throw new InternalError(`Refusing to dispatch request: ${found} header is present but app({ trustProxy }) is unconfigured. ` +
724
+ `Honouring a spoofable forwarded header would let a client forge its source IP for the rate ` +
725
+ `limiter, audit log, and request-id propagation. ` +
754
726
  `Pass app({ trustProxy: true }) when running behind a trusted reverse proxy, ` +
755
727
  `or app({ trustProxy: false }) to ignore forwarded headers, ` +
756
- `or app({ secureDefaults: false }) to disable this guard.`);
728
+ `or app({ secureDefaults: false }) to disable this guard. ` +
729
+ `See https://daloyjs.dev/docs/security/boot-guards.`);
757
730
  }
758
731
  /**
759
732
  * Resolve the {@link AppOptions.docs} option and, when enabled, register
@@ -915,8 +888,16 @@ export class App {
915
888
  * });
916
889
  * ```
917
890
  *
891
+ * The return type widens `Routes` with the freshly-registered route so
892
+ * that chained registration (`new App().route(a).route(b)`) accumulates a
893
+ * precise tuple. The typed client (`createClient(app)`) consumes that tuple
894
+ * to expose a method per `operationId` with parameters and responses
895
+ * inferred from the route's own schemas. Non-chained calls
896
+ * (`app.route(a); app.route(b);`) keep the variable's original type, so
897
+ * chain the calls when you want the inferred client surface.
898
+ *
918
899
  * @param def - The route definition.
919
- * @returns This `App` instance for chaining.
900
+ * @returns This `App` instance (widened with the new route) for chaining.
920
901
  */
921
902
  route(def) {
922
903
  // Refuse non-canonical HTTP methods at runtime.
@@ -965,6 +946,9 @@ export class App {
965
946
  ...sources,
966
947
  ]);
967
948
  this.router.add(def.method, fullPath, { def: merged, hooks, mergedHooks, hasFinalizeHook, corsOriginAllows, fullCorsOriginAllows }, def.operationId);
949
+ // `routes` is statically a readonly tuple so the typed client can infer
950
+ // per-route methods; at runtime it is a growable array, so we push through
951
+ // a mutable view.
968
952
  this.routes.push(merged);
969
953
  this.routeSecurityMarkers.push({
970
954
  method: merged.method,
@@ -1834,6 +1818,7 @@ export class App {
1834
1818
  try {
1835
1819
  assertNoDuplicateSingletonHeaders(request.headers);
1836
1820
  assertNoReservedInternalHeaders(request.headers);
1821
+ assertHeaderCountWithinLimit(request.headers, this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT);
1837
1822
  this.assertTrustProxyConfigured(request);
1838
1823
  this.assertBootGuards();
1839
1824
  if (globalHooks.onRequest !== undefined) {
package/dist/cli.js CHANGED
@@ -651,6 +651,31 @@ async function runDoctor(opts, io) {
651
651
  "JSON parsers are not DoS-amplified by a multipart-sized blob.",
652
652
  });
653
653
  }
654
+ // Header-count cap audit. The framework's portable maxHeaderCount
655
+ // guard is the application-tier defence against header-*count*
656
+ // amplification (the "HTTP/2 Bomb" dimension). Surface a finding when
657
+ // it is disabled (0) or raised to an implausibly generous value, both
658
+ // of which let a header flood reach routing.
659
+ const maxHeaderCount = o.maxHeaderCount;
660
+ if (maxHeaderCount === 0) {
661
+ findings.push({
662
+ level: "warn",
663
+ code: "audit.maxHeaderCount.disabled",
664
+ message: "maxHeaderCount is 0 — the header-count flood guard is disabled. " +
665
+ "A request carrying thousands of header fields reaches routing. " +
666
+ "Keep a finite cap (default 100) unless an upstream proxy already " +
667
+ "enforces one (NGINX max_headers, Node server.maxHeadersCount).",
668
+ });
669
+ }
670
+ else if (typeof maxHeaderCount === "number" && maxHeaderCount > 1000) {
671
+ findings.push({
672
+ level: "warn",
673
+ code: "audit.maxHeaderCount.blanket",
674
+ message: `maxHeaderCount is ${maxHeaderCount} (> 1000). Realistic requests ` +
675
+ "carry a few dozen headers; a cap this high weakens the " +
676
+ "header-count amplification defence.",
677
+ });
678
+ }
654
679
  // Idle-timeout / request-timeout audit. Reaffirms the
655
680
  // existing requestTimeoutMs check; also surface an explicit zero
656
681
  // idleTimeoutMs in production. The framework also keeps adapter
package/dist/client.d.ts CHANGED
@@ -17,6 +17,13 @@ export type RoutesOf<A extends App> = A["routes"][number];
17
17
  * Typed client surface generated from an `App`. The result is a record keyed
18
18
  * by each route's `operationId` whose values are async methods inferred from
19
19
  * the route's request and response schemas.
20
+ *
21
+ * The per-method types are recovered from the `App`'s accumulated route tuple,
22
+ * which is built up as you **chain** `app.route(...)` calls. If the `App` type
23
+ * is widened back to its bare default — e.g. a `const app: App` annotation, a
24
+ * `: App` factory return type, or registering routes as separate statements
25
+ * rather than a chain — the tuple is erased and this type collapses to an
26
+ * untyped, string-indexed record.
20
27
  */
21
28
  export type ClientFor<A extends App> = {
22
29
  [R in Extract<RoutesOf<A>, {
@@ -58,10 +65,26 @@ export interface ClientOptions {
58
65
  * For non-TypeScript consumers, run `pnpm gen` to emit a fully-typed SDK
59
66
  * from the OpenAPI document instead.
60
67
  *
68
+ * @remarks
69
+ * The method signatures are inferred from the `App`'s accumulated route tuple,
70
+ * so chain your `app.route(...)` registrations and let TypeScript infer the
71
+ * variable's type. A widening `const app: App` annotation, a `: App` factory
72
+ * return type, or registering routes as separate statements erases the
73
+ * per-route types and yields an untyped client.
74
+ *
61
75
  * @example
62
76
  * ```ts
63
77
  * import { createClient } from "@daloyjs/core/client";
64
78
  *
79
+ * const app = new App().route({
80
+ * method: "GET",
81
+ * path: "/books/:id",
82
+ * operationId: "getBook",
83
+ * request: { params: z.object({ id: z.string() }) },
84
+ * responses: { 200: { description: "OK", body: z.object({ id: z.string(), title: z.string() }) } },
85
+ * handler: ({ params }) => ({ status: 200, body: { id: params.id, title: "Dune" } }),
86
+ * });
87
+ *
65
88
  * const client = createClient(app, { baseUrl: "https://api.example.com" });
66
89
  * const res = await client.getBook({ params: { id: "123" } });
67
90
  * if (res.status === 200) console.log(res.body.title);
package/dist/client.js CHANGED
@@ -22,10 +22,26 @@
22
22
  * For non-TypeScript consumers, run `pnpm gen` to emit a fully-typed SDK
23
23
  * from the OpenAPI document instead.
24
24
  *
25
+ * @remarks
26
+ * The method signatures are inferred from the `App`'s accumulated route tuple,
27
+ * so chain your `app.route(...)` registrations and let TypeScript infer the
28
+ * variable's type. A widening `const app: App` annotation, a `: App` factory
29
+ * return type, or registering routes as separate statements erases the
30
+ * per-route types and yields an untyped client.
31
+ *
25
32
  * @example
26
33
  * ```ts
27
34
  * import { createClient } from "@daloyjs/core/client";
28
35
  *
36
+ * const app = new App().route({
37
+ * method: "GET",
38
+ * path: "/books/:id",
39
+ * operationId: "getBook",
40
+ * request: { params: z.object({ id: z.string() }) },
41
+ * responses: { 200: { description: "OK", body: z.object({ id: z.string(), title: z.string() }) } },
42
+ * handler: ({ params }) => ({ status: 200, body: { id: params.id, title: "Dune" } }),
43
+ * });
44
+ *
29
45
  * const client = createClient(app, { baseUrl: "https://api.example.com" });
30
46
  * const res = await client.getBook({ params: { id: "123" } });
31
47
  * if (res.status === 200) console.log(res.body.title);
package/dist/errors.d.ts CHANGED
@@ -299,6 +299,29 @@ export declare class MethodNotAllowedError extends HttpError {
299
299
  export declare class PayloadTooLargeError extends HttpError {
300
300
  constructor(limit: number);
301
301
  }
302
+ /**
303
+ * `431 Request Header Fields Too Large` — thrown when an incoming request
304
+ * carries more distinct header fields than {@link AppOptions.maxHeaderCount}.
305
+ *
306
+ * This is the portable, JS-layer counterpart to the native "max header
307
+ * count" caps that web servers and proxies expose (e.g. NGINX's
308
+ * `max_headers`, Node's `server.maxHeadersCount`). It is defence-in-depth
309
+ * against header-*count* amplification — the dimension abused by the
310
+ * "HTTP/2 Bomb" (Calif, 2026), where per-header server-side bookkeeping is
311
+ * the amplifier rather than header size. A flood of nearly-empty headers
312
+ * never reaches the router because the count cap fires first.
313
+ *
314
+ * The native HPACK session-memory pinning that the bomb relies on must
315
+ * still be mitigated at the runtime/proxy that terminates HTTP/2 (apply the
316
+ * vendor fix and cap the header count there); this guard protects the
317
+ * application tier on every runtime regardless of upstream configuration.
318
+ *
319
+ * @param limit - The configured maximum header count that was exceeded.
320
+ * @since 0.38.0
321
+ */
322
+ export declare class RequestHeaderFieldsTooLargeError extends HttpError {
323
+ constructor(limit: number);
324
+ }
302
325
  /**
303
326
  * `415 Unsupported Media Type` — thrown when the request `Content-Type` is
304
327
  * not in {@link AppOptions.allowedContentTypes} for a route that declares a
package/dist/errors.js CHANGED
@@ -395,6 +395,36 @@ export class PayloadTooLargeError extends HttpError {
395
395
  this.name = "PayloadTooLargeError";
396
396
  }
397
397
  }
398
+ /**
399
+ * `431 Request Header Fields Too Large` — thrown when an incoming request
400
+ * carries more distinct header fields than {@link AppOptions.maxHeaderCount}.
401
+ *
402
+ * This is the portable, JS-layer counterpart to the native "max header
403
+ * count" caps that web servers and proxies expose (e.g. NGINX's
404
+ * `max_headers`, Node's `server.maxHeadersCount`). It is defence-in-depth
405
+ * against header-*count* amplification — the dimension abused by the
406
+ * "HTTP/2 Bomb" (Calif, 2026), where per-header server-side bookkeeping is
407
+ * the amplifier rather than header size. A flood of nearly-empty headers
408
+ * never reaches the router because the count cap fires first.
409
+ *
410
+ * The native HPACK session-memory pinning that the bomb relies on must
411
+ * still be mitigated at the runtime/proxy that terminates HTTP/2 (apply the
412
+ * vendor fix and cap the header count there); this guard protects the
413
+ * application tier on every runtime regardless of upstream configuration.
414
+ *
415
+ * @param limit - The configured maximum header count that was exceeded.
416
+ * @since 0.38.0
417
+ */
418
+ export class RequestHeaderFieldsTooLargeError extends HttpError {
419
+ constructor(limit) {
420
+ super(431, {
421
+ type: "https://daloyjs.dev/errors/request-header-fields-too-large",
422
+ title: "Request Header Fields Too Large",
423
+ detail: `Request carries more than ${limit} header fields`,
424
+ });
425
+ this.name = "RequestHeaderFieldsTooLargeError";
426
+ }
427
+ }
398
428
  /**
399
429
  * `415 Unsupported Media Type` — thrown when the request `Content-Type` is
400
430
  * not in {@link AppOptions.allowedContentTypes} for a route that declares a
package/dist/index.d.ts CHANGED
@@ -11,13 +11,13 @@ export type { SubdomainsOptions, SubdomainsResult } from "./subdomains.js";
11
11
  export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
12
12
  export type { DependencyHooks, DependencyOptions, } from "./dependency.js";
13
13
  export type { RouteDefinition, HttpMethod, PathString, RequestSchemas, ResponsesMap, ResponseSpec, AuthSpec, Hooks, BaseContext, AppState, AuthScheme, AuthContext, HandlerReturn, InferRequest, ParamsOf, PathParams, CallbackDefinition, CallbackMap, CallbackOperation, RouteExample, RouteMeta, } from "./types.js";
14
- export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
14
+ export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, RequestHeaderFieldsTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
15
15
  export type { ProblemDetails, ProblemRenderOptions, HttpErrorOptions } from "./errors.js";
16
16
  export type { StandardSchemaV1 } from "./schema.js";
17
17
  export { validate, isStandardSchema } from "./schema.js";
18
18
  export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
19
19
  export type { ChangeSeverity, OpenAPIChange, OpenAPIDiffResult, } from "./openapi-diff.js";
20
- export { readBodyLimited, safeJsonParse, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
20
+ export { readBodyLimited, safeJsonParse, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
21
21
  export type { WebhookHmacAlgorithm } from "./security.js";
22
22
  export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
23
23
  export { etag } from "./etag.js";
package/dist/index.js CHANGED
@@ -6,10 +6,10 @@ export { _resetInsecureDefaultsLogForTests } from "./app.js";
6
6
  export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
7
7
  export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
8
8
  export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
9
- export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
9
+ export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, RequestHeaderFieldsTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
10
10
  export { validate, isStandardSchema } from "./schema.js";
11
11
  export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
12
- export { readBodyLimited, safeJsonParse, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
12
+ export { readBodyLimited, safeJsonParse, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
13
13
  export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
14
14
  export { etag } from "./etag.js";
15
15
  export { compression, COMPRESSION_HOOK_MARKER, _resetCompressionRuntimeProbeForTests, } from "./compression.js";
package/dist/jwt.js CHANGED
@@ -236,7 +236,10 @@ export function createJwtSigner(opts) {
236
236
  }
237
237
  const { alg } = opts;
238
238
  if (alg === "none") {
239
- throw new JwtError("alg_none_refused", 'jwt(): alg "none" is refused.');
239
+ throw new JwtError("alg_none_refused", 'jwt(): alg "none" is refused — it disables signature verification, so anyone could forge a ' +
240
+ 'token by setting the header alg to "none" (the classic JWT signature-stripping / algorithm-' +
241
+ "confusion attack). Choose a real signing algorithm such as HS256 (shared secret) or " +
242
+ "RS256 / ES256 (key pair). See https://daloyjs.dev/docs/security/secure-defaults-enforcement.");
240
243
  }
241
244
  if (!ALL_ALGS.has(alg)) {
242
245
  throw new JwtError("invalid_alg", `jwt(): unknown algorithm "${String(alg)}". Allowed: ${[...ALL_ALGS].sort().join(", ")}.`);
@@ -335,7 +338,10 @@ export function createJwtVerifier(opts) {
335
338
  const allow = new Set();
336
339
  for (const alg of opts.algorithms) {
337
340
  if (alg === "none") {
338
- throw new JwtError("alg_none_refused", 'jwt(): alg "none" cannot appear in the allowlist.');
341
+ throw new JwtError("alg_none_refused", 'jwt(): alg "none" cannot appear in the algorithms allowlist — it disables signature ' +
342
+ "verification and would let any caller forge a token. Remove it and list only real " +
343
+ "algorithms such as HS256, RS256, or ES256. " +
344
+ "See https://daloyjs.dev/docs/security/secure-defaults-enforcement.");
339
345
  }
340
346
  if (!ALL_ALGS.has(alg)) {
341
347
  throw new JwtError("invalid_alg", `jwt(): unknown algorithm "${String(alg)}" in allowlist.`);
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:99cd0a75-d472-56c5-bd8e-c5247eb9e1df",
4
+ "serialNumber": "urn:uuid:51e4c48d-6a2e-5180-8452-1c9b9a5987d9",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-05-31T20:28:21.245Z",
7
+ "timestamp": "2026-06-11T09:54:26.041Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "0.37.0"
12
+ "version": "0.38.1"
13
13
  }
14
14
  ],
15
15
  "authors": [
@@ -19,11 +19,11 @@
19
19
  ],
20
20
  "component": {
21
21
  "type": "library",
22
- "bom-ref": "pkg:npm/@daloyjs/core@0.37.0",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@0.38.1",
23
23
  "name": "@daloyjs/core",
24
- "version": "0.37.0",
24
+ "version": "0.38.1",
25
25
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
26
- "purl": "pkg:npm/@daloyjs/core@0.37.0",
26
+ "purl": "pkg:npm/@daloyjs/core@0.38.1",
27
27
  "licenses": [
28
28
  {
29
29
  "license": {
@@ -46,9 +46,9 @@
46
46
  }
47
47
  ],
48
48
  "swid": {
49
- "tagId": "swidtag--daloyjs-core-0.37.0",
49
+ "tagId": "swidtag--daloyjs-core-0.38.1",
50
50
  "name": "@daloyjs/core",
51
- "version": "0.37.0",
51
+ "version": "0.38.1",
52
52
  "tagVersion": 0,
53
53
  "patch": false
54
54
  }
@@ -57,7 +57,7 @@
57
57
  "components": [],
58
58
  "dependencies": [
59
59
  {
60
- "ref": "pkg:npm/@daloyjs/core@0.37.0",
60
+ "ref": "pkg:npm/@daloyjs/core@0.38.1",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "@daloyjs/core-0.37.0",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.37.0-99cd0a75-d472-56c5-bd8e-c5247eb9e1df",
5
+ "name": "@daloyjs/core-0.38.1",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.38.1-51e4c48d-6a2e-5180-8452-1c9b9a5987d9",
7
7
  "creationInfo": {
8
- "created": "2026-05-31T20:28:21.245Z",
8
+ "created": "2026-06-11T09:54:26.041Z",
9
9
  "creators": [
10
10
  "Tool: daloy-generate-sbom",
11
11
  "Organization: DaloyJS"
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "SPDXID": "SPDXRef-Package--daloyjs-core",
18
18
  "name": "@daloyjs/core",
19
- "versionInfo": "0.37.0",
19
+ "versionInfo": "0.38.1",
20
20
  "downloadLocation": "https://github.com/daloyjs/daloy",
21
21
  "filesAnalyzed": false,
22
22
  "licenseConcluded": "MIT",
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "referenceCategory": "PACKAGE-MANAGER",
29
29
  "referenceType": "purl",
30
- "referenceLocator": "pkg:npm/@daloyjs/core@0.37.0"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@0.38.1"
31
31
  }
32
32
  ]
33
33
  }
@@ -166,6 +166,40 @@ export declare const RESERVED_INBOUND_HEADER_PREFIXES: readonly string[];
166
166
  * @since 0.36.0
167
167
  */
168
168
  export declare function assertNoReservedInternalHeaders(headers: Headers): void;
169
+ /**
170
+ * Default cap on the number of distinct request header fields accepted
171
+ * before {@link assertHeaderCountWithinLimit} rejects the request. Chosen to
172
+ * sit far above any realistic legitimate request (browsers, tracing layers,
173
+ * and reverse proxies rarely add more than a few dozen headers) yet far
174
+ * below the thousands-of-headers floods used by header-count amplification
175
+ * attacks such as the "HTTP/2 Bomb".
176
+ *
177
+ * @since 0.38.0
178
+ */
179
+ export declare const DEFAULT_MAX_HEADER_COUNT = 100;
180
+ /**
181
+ * Reject requests that carry more than `limit` distinct header fields.
182
+ *
183
+ * This is the runtime-portable, application-tier defence against
184
+ * header-*count* amplification (the dimension abused by the "HTTP/2 Bomb",
185
+ * where per-entry server bookkeeping — not header size — is the amplifier).
186
+ * It complements, and does not replace, the native header-count caps that a
187
+ * runtime/proxy terminating HTTP/2 must apply (NGINX `max_headers`, Node
188
+ * `server.maxHeadersCount`, etc.). Because the WHATWG `Headers` collection
189
+ * coalesces same-named fields, the count is over distinct header names —
190
+ * the truest signal available once a request has been normalised to a
191
+ * web-standard `Request`.
192
+ *
193
+ * A `limit` of `0` (or any non-positive / non-finite value) disables the
194
+ * check. Throws {@link RequestHeaderFieldsTooLargeError} (`431`) so the
195
+ * framework returns a structured `problem+json` response instead of routing
196
+ * a flood.
197
+ *
198
+ * @param headers - The incoming request headers.
199
+ * @param limit - Maximum distinct header fields to allow. `0` disables.
200
+ * @since 0.38.0
201
+ */
202
+ export declare function assertHeaderCountWithinLimit(headers: Headers, limit: number): void;
169
203
  /**
170
204
  * Minimum acceptable secret length in bytes for HMAC / signing material in
171
205
  * production (boot guard). Matches the OWASP "Secret Management"
package/dist/security.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * - timingSafeEqual: length-independent string compare for fixed-length token checks.
9
9
  * - randomId: cryptographically strong request id.
10
10
  */
11
- import { PayloadTooLargeError, BadRequestError, } from "./errors.js";
11
+ import { PayloadTooLargeError, BadRequestError, RequestHeaderFieldsTooLargeError, } from "./errors.js";
12
12
  // Resolved once at module load. Mirror of `DALOY_REQUEST_RAW_BODY` in
13
13
  // app.ts; defined here via the global Symbol registry to avoid an import
14
14
  // cycle (app.ts -> security.ts). Adapters attach a pre-validated
@@ -321,6 +321,53 @@ export function assertNoReservedInternalHeaders(headers) {
321
321
  }
322
322
  });
323
323
  }
324
+ /**
325
+ * Default cap on the number of distinct request header fields accepted
326
+ * before {@link assertHeaderCountWithinLimit} rejects the request. Chosen to
327
+ * sit far above any realistic legitimate request (browsers, tracing layers,
328
+ * and reverse proxies rarely add more than a few dozen headers) yet far
329
+ * below the thousands-of-headers floods used by header-count amplification
330
+ * attacks such as the "HTTP/2 Bomb".
331
+ *
332
+ * @since 0.38.0
333
+ */
334
+ export const DEFAULT_MAX_HEADER_COUNT = 100;
335
+ /**
336
+ * Reject requests that carry more than `limit` distinct header fields.
337
+ *
338
+ * This is the runtime-portable, application-tier defence against
339
+ * header-*count* amplification (the dimension abused by the "HTTP/2 Bomb",
340
+ * where per-entry server bookkeeping — not header size — is the amplifier).
341
+ * It complements, and does not replace, the native header-count caps that a
342
+ * runtime/proxy terminating HTTP/2 must apply (NGINX `max_headers`, Node
343
+ * `server.maxHeadersCount`, etc.). Because the WHATWG `Headers` collection
344
+ * coalesces same-named fields, the count is over distinct header names —
345
+ * the truest signal available once a request has been normalised to a
346
+ * web-standard `Request`.
347
+ *
348
+ * A `limit` of `0` (or any non-positive / non-finite value) disables the
349
+ * check. Throws {@link RequestHeaderFieldsTooLargeError} (`431`) so the
350
+ * framework returns a structured `problem+json` response instead of routing
351
+ * a flood.
352
+ *
353
+ * @param headers - The incoming request headers.
354
+ * @param limit - Maximum distinct header fields to allow. `0` disables.
355
+ * @since 0.38.0
356
+ */
357
+ export function assertHeaderCountWithinLimit(headers, limit) {
358
+ if (!(limit > 0) || !Number.isFinite(limit))
359
+ return;
360
+ let count = 0;
361
+ // Count via forEach (not keys()) so a same-named coalesced field counts
362
+ // once and the check works on any Headers-shaped object. Throwing from the
363
+ // callback bails the instant the cap is crossed, so a flood pays for at
364
+ // most `limit + 1` iterations rather than walking the whole set.
365
+ headers.forEach(() => {
366
+ if (++count > limit) {
367
+ throw new RequestHeaderFieldsTooLargeError(limit);
368
+ }
369
+ });
370
+ }
324
371
  /**
325
372
  * Minimum acceptable secret length in bytes for HMAC / signing material in
326
373
  * production (boot guard). Matches the OWASP "Secret Management"
package/dist/session.js CHANGED
@@ -57,7 +57,10 @@ function bytesToBase64Url(bytes) {
57
57
  }
58
58
  function makeSigner(secret) {
59
59
  if (typeof secret !== "string" || secret.length < 16) {
60
- throw new Error("session(): each secret must be a string of at least 16 characters.");
60
+ throw new Error("session(): each secret must be a string of at least 16 characters — it is the HMAC key " +
61
+ "that signs every session cookie, so a short or guessable value lets an attacker forge sessions. " +
62
+ "Generate one with `openssl rand -base64 32` and load it from an env var or secret manager " +
63
+ "(never hard-code or commit it). See https://daloyjs.dev/docs/security/session.");
61
64
  }
62
65
  let keyPromise = null;
63
66
  const getKey = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "0.37.0",
3
+ "version": "0.38.1",
4
4
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -229,6 +229,7 @@
229
229
  "devDependencies": {
230
230
  "@hey-api/openapi-ts": "^0.97.1",
231
231
  "@types/node": "^25.7.0",
232
+ "fast-check": "^3.23.2",
232
233
  "prettier": "^3.8.3",
233
234
  "tsx": "^4.22.3",
234
235
  "typescript": "^6.0.3",
@@ -242,7 +243,7 @@
242
243
  "test": "node --import tsx --test tests/**/*.test.ts",
243
244
  "coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-lines=90 --test-coverage-functions=90 tests/**/*.test.ts",
244
245
  "coverage:branches": "tsc -p tsconfig.coverage.json && node --test --experimental-test-coverage --test-coverage-include='dist-coverage/src/**' --test-coverage-branches=92 dist-coverage/tests/**/*.test.js",
245
- "typecheck": "tsc --noEmit",
246
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.typetest.json",
246
247
  "format": "prettier --write .",
247
248
  "gen:openapi": "node --import tsx scripts/dump-openapi.ts",
248
249
  "gen:client": "openapi-ts",
@@ -261,6 +262,7 @@
261
262
  "verify:no-leaked-credentials": "node --import tsx scripts/verify-no-leaked-credentials.ts",
262
263
  "verify:no-leaky-agent-skills": "node --import tsx scripts/verify-no-leaky-agent-skills.ts",
263
264
  "verify:no-toxic-agent-skills": "node --import tsx scripts/verify-no-toxic-agent-skills.ts",
265
+ "verify:no-agent-config-autorun": "node --import tsx scripts/verify-no-agent-config-autorun.ts",
264
266
  "verify:no-invisible-unicode": "node --import tsx scripts/verify-no-invisible-unicode.ts",
265
267
  "verify:no-encoded-payloads": "node --import tsx scripts/verify-no-encoded-payloads.ts",
266
268
  "verify:no-registry-exfiltration": "node --import tsx scripts/verify-no-registry-exfiltration.ts",