@daloyjs/core 0.37.0 → 0.38.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/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,17 @@
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
+ [![JSR](https://jsr.io/badges/@daloyjs/daloy)](https://jsr.io/@daloyjs/daloy)
19
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13058/badge)](https://www.bestpractices.dev/projects/13058)
20
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/daloyjs/daloy/badge)](https://securityscorecards.dev/viewer/?uri=github.com/daloyjs/daloy)
21
+ [![Security Responsible
22
+ Disclosure](https://img.shields.io/badge/Security-Responsible%20Disclosure-yellow.svg)](https://github.com/daloyjs/daloy/blob/main/SECURITY.md)
17
23
 
18
24
  > 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
25
 
@@ -221,6 +227,10 @@ const r = await client.getBookById({ params: { id: "1" } });
221
227
  // ^? { status: 200; body: { id: string; title: string } } | { status: 404; ... }
222
228
  ```
223
229
 
230
+ > Method inference relies on **chaining** your `app.route(...)` calls (`new App().route(a).route(b)`) and letting
231
+ > TypeScript infer the variable's type. A widening `const app: App` annotation, a `: App` factory return type, or
232
+ > registering routes as separate statements erases the per-route types and collapses the client to an untyped surface.
233
+
224
234
  ---
225
235
 
226
236
  ## Built-in docs UI (Scalar / Swagger UI)
@@ -311,6 +321,7 @@ deployment.
311
321
  | **Header / response splitting** | Core header sanitizers reject CRLF + NUL. |
312
322
  | **Path traversal** | Core router rejects `..` segments and `//` before walking. |
313
323
  | **Slow-loris / hung handlers** | Core `requestTimeoutMs` aborts handlers (default 30 s); Node adapter sets `requestTimeout` + `headersTimeout` + `maxHeaderSize`. |
324
+ | **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
325
  | **MIME sniffing** | First-party `secureHeaders()` sets `X-Content-Type-Options: nosniff`; scaffolded apps enable it. |
315
326
  | **Clickjacking** | First-party `secureHeaders()` sets `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`; scaffolded apps enable it. |
316
327
  | **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'`). |
@@ -556,6 +567,7 @@ A growing suite of static gates runs on every push and PR:
556
567
  - Parity / governance / runtime-parity / routing-hardening audits: `verify:parity-audits`, `verify:governance-audits`, `verify:runtime-parity-audits`, `verify:routing-hardening-audits`.
557
568
  - 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
569
  - 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)).
570
+ - 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
571
  - 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
572
  - 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
573
  - `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 =
@@ -456,6 +421,7 @@ export class App {
456
421
  trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
457
422
  bodyLimitBytes: this.options.bodyLimitBytes,
458
423
  requestTimeoutMs: this.options.requestTimeoutMs,
424
+ maxHeaderCount: this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT,
459
425
  stripServerHeaders: o.stripServerHeaders !== false,
460
426
  production: this.isProduction(),
461
427
  });
@@ -915,8 +881,16 @@ export class App {
915
881
  * });
916
882
  * ```
917
883
  *
884
+ * The return type widens `Routes` with the freshly-registered route so
885
+ * that chained registration (`new App().route(a).route(b)`) accumulates a
886
+ * precise tuple. The typed client (`createClient(app)`) consumes that tuple
887
+ * to expose a method per `operationId` with parameters and responses
888
+ * inferred from the route's own schemas. Non-chained calls
889
+ * (`app.route(a); app.route(b);`) keep the variable's original type, so
890
+ * chain the calls when you want the inferred client surface.
891
+ *
918
892
  * @param def - The route definition.
919
- * @returns This `App` instance for chaining.
893
+ * @returns This `App` instance (widened with the new route) for chaining.
920
894
  */
921
895
  route(def) {
922
896
  // Refuse non-canonical HTTP methods at runtime.
@@ -965,6 +939,9 @@ export class App {
965
939
  ...sources,
966
940
  ]);
967
941
  this.router.add(def.method, fullPath, { def: merged, hooks, mergedHooks, hasFinalizeHook, corsOriginAllows, fullCorsOriginAllows }, def.operationId);
942
+ // `routes` is statically a readonly tuple so the typed client can infer
943
+ // per-route methods; at runtime it is a growable array, so we push through
944
+ // a mutable view.
968
945
  this.routes.push(merged);
969
946
  this.routeSecurityMarkers.push({
970
947
  method: merged.method,
@@ -1834,6 +1811,7 @@ export class App {
1834
1811
  try {
1835
1812
  assertNoDuplicateSingletonHeaders(request.headers);
1836
1813
  assertNoReservedInternalHeaders(request.headers);
1814
+ assertHeaderCountWithinLimit(request.headers, this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT);
1837
1815
  this.assertTrustProxyConfigured(request);
1838
1816
  this.assertBootGuards();
1839
1817
  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";
@@ -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:c230d67e-08ae-5162-959c-66cbbb24366a",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-05-31T20:28:21.245Z",
7
+ "timestamp": "2026-06-10T11:58:42.238Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "0.37.0"
12
+ "version": "0.38.0"
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.0",
23
23
  "name": "@daloyjs/core",
24
- "version": "0.37.0",
24
+ "version": "0.38.0",
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.0",
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.0",
50
50
  "name": "@daloyjs/core",
51
- "version": "0.37.0",
51
+ "version": "0.38.0",
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.0",
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.0",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.38.0-c230d67e-08ae-5162-959c-66cbbb24366a",
7
7
  "creationInfo": {
8
- "created": "2026-05-31T20:28:21.245Z",
8
+ "created": "2026-06-10T11:58:42.238Z",
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.0",
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.0"
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
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",