@daloyjs/core 1.0.0-rc.0 → 1.0.0-rc.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/README.md CHANGED
@@ -509,7 +509,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
509
509
 
510
510
  ## Status
511
511
 
512
- DaloyJS is now at its **first `1.0.0` release candidate** (`1.0.0-rc.0`). The public API is frozen for the 1.0 line: only bug fixes and documentation land between RC and GA, and from `1.0.0` onward breaking changes follow SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
512
+ DaloyJS is at **`1.0.0-rc.1`**, a security-hardening release candidate. Because the framework has no external users yet, this RC ships a few intentional breaking changes (see the [CHANGELOG](CHANGELOG.md)) to get the secure-by-default posture right before GA rather than deferring them; the generated OpenAPI contract is unchanged. From `1.0.0` GA onward, breaking changes follow SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
513
513
 
514
514
  **Release quality bar.** Every release ships with **≥90% line + function coverage and ≥90% branch coverage**, strict TypeScript, OpenSSF Scorecard, CodeQL + Opengrep dual SAST, zizmor workflow linting, and npm provenance. Coverage was relaxed from a former 100% gate so complex security work isn't blocked chasing throwaway tests for unreachable defensive branches or tsx source-map phantoms; see [AGENTS.md](AGENTS.md) for the policy.
515
515
 
@@ -520,7 +520,7 @@ DaloyJS is now at its **first `1.0.0` release candidate** (`1.0.0-rc.0`). The pu
520
520
  - Zero-config OpenAPI `info` autofill from `package.json` (Node / Bun) or `deno.json` / `deno.jsonc` (Deno); explicit `openapi.info` values always win.
521
521
  - RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
522
522
  - AI-friendly route metadata via optional `meta: { examples, extensions, summary, description, tags }`; examples are validated against your schemas at build time, surfaced as OpenAPI `examples` + `x-daloy-*` extensions, and dumped as `routes.json` / `routes.yaml` via `daloy inspect --ai`.
523
- - Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app.
523
+ - Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app. Every `tools/call` argument is validated server-side against the tool's `inputSchema` (a dependency-free JSON-Schema subset — `type`/`required`/`properties`/`additionalProperties`/`enum`/`const`/bounds; exposed as `validateMcpInput()`) before the handler runs, rejecting a mismatch with JSON-RPC `-32602`; the JSON-RPC body is parsed with prototype-pollution-safe `safeJsonParse`; and an unauthenticated `mcpRoutes()` endpoint refuses to boot in production unless opted out with `mcpRoutes(path, handler, { public: true })`.
524
524
  - API lifecycle and breaking-change detection: mark routes `deprecated` or give them a `sunset` date to emit RFC 8594 `Deprecation` / `Sunset` headers and an `x-sunset` OpenAPI extension, then gate CI with `diffOpenAPI()` / the `daloy diff` command, which fail on a breaking change versus the last published spec.
525
525
  - In-process test client (`app.request()`), contract-test runner (gated in CI via `daloy inspect --check` and shipped as a default test in every `create-daloy` template), in-process typed client, and Hey API codegen via `pnpm gen`.
526
526
 
@@ -549,7 +549,9 @@ DaloyJS is now at its **first `1.0.0` release candidate** (`1.0.0-rc.0`). The pu
549
549
 
550
550
  The framework refuses to start (or to construct) when configuration is unsafe:
551
551
 
552
- - Weak session secrets, `cors({ origin: "*" })` with credentials, `session()` + state-changing route without `csrf()`, and unconfigured `X-Forwarded-*` in production.
552
+ - Weak session secrets, `cors({ origin: "*" })` with credentials, `session()` + state-changing route without `csrf()`, and unconfigured forwarded / client-IP headers (`X-Forwarded-*`, `X-Real-IP`, and vendor headers `CF-Connecting-IP` / `Fly-Client-IP` / `True-Client-IP`) in production.
553
+ - **Shadow auth**: a route that declares an `auth:` requirement (advertised as protected in the OpenAPI `security` list) but installs no authentication hook to enforce it. Built-in auth middlewares (`bearerAuth` / `basicAuth` / `jwk` / `httpSignatureAuth` / `clientCertAuth`) satisfy the guard automatically; mark a custom auth hook (or upstream-gateway-enforced auth) with `markAuthHook()`.
554
+ - **Unauthenticated MCP**: an `mcpRoutes()` endpoint with no auth hook — MCP tools are model-controlled and side-effecting. Opt out for a genuinely public server with `mcpRoutes(path, handler, { public: true })`.
553
555
  - `secureDefaults: false` in production unless `acknowledgeInsecureDefaults: true` is set, plus a once-per-process `error` log naming every disabled default.
554
556
  - `preset: "internal-service"` topology preset for service-to-service deployments behind a mesh / sidecar / private network: turns OFF the browser-only guards (auto `secureHeaders`, `corsCrossOriginGuard`, `csrf` boot guard, unconfigured `X-Forwarded-*` guard) while keeping every input, parser, credential, SSRF, weak-secret, and refuse-to-boot guard ON. Per-knob options still win, the choice is logged at boot under `event: "security.preset.applied"`, and the live posture is auditable via `app.getSecurityPosture()`.
555
557
  - `createJwtSigner()` / `createJwtVerifier()` refuse `alg: "none"`, accept only an explicit allowlist, refuse HS + JWK combinations, refuse to sign without `exp`, and refuse HS-shaped secrets under 32 bytes (RFC 7518 §3.2).
@@ -587,13 +589,13 @@ The framework refuses to start (or to construct) when configuration is unsafe:
587
589
  - `clientCertAuth()` mTLS / client-certificate auth at `@daloyjs/core/mtls`: authenticate zero-trust / service-to-service callers by their TLS client certificate from two sources — **native TLS** (the Node adapter lazily reads the peer cert off the socket; plain requests pay nothing) or a **TLS-terminating proxy** (Envoy `X-Forwarded-Client-Cert` and nginx/HAProxy-style structured headers). `requireVerified` by default, exact `allowSubjectCNs` / `allowIssuerCNs`, **constant-time** `allowFingerprints`, `allowSANs` (SPIFFE/DNS/URI/IP, `TYPE:value` or bare), validity-window enforcement, and a custom async `verify()` hook. Missing cert → `401` problem+json with `Cache-Control: no-store`; any failed check → `403` (never echoes cert details). The accepted `ClientCertificate` is stamped on `ctx.state`. `parseForwardedClientCert()` / `normalizePeerCertificate()` exported standalone. Zero runtime dependencies.
588
590
  - `autoBan()` adaptive auto-ban (fail2ban-style) at `@daloyjs/core/auto-ban`: temporarily ban abusive clients after repeated suspicious responses (default `401` / `403` / `429`, configurable `watchStatuses`) within a rolling `windowMs`. Bans **escalate** exponentially for repeat offenders (`banMs` → `2×` → `4×`, capped at `maxBanMs`) and **decay** once the client goes quiet. Observes the outgoing status via `onSend` (counts failures from any downstream middleware/handler), enforces in `beforeHandle`. Secure-by-default identity attribution — refuses to construct without `keyGenerator` or `trustProxyHeaders` so one offender can never ban everyone; unattributable requests are skipped. Pluggable `AutoBanStore` (mirrors the `rateLimit()` store; in-memory default, Redis-able for multi-instance), `groupId` sharing across route groups, `429`/`403` ban response with `Retry-After`, and `onBan` / `onStrike` hooks. Zero runtime dependencies.
589
591
  - `botGuard()` bot / User-Agent management at `@daloyjs/core/bot-guard`: the in-app equivalent of Nginx/WAF bot rules. Blocks empty/missing `User-Agent` (default on) and known-abusive `User-Agent` strings / `RegExp`s, and **verifies declared crawlers** — a request claiming to be Googlebot/Bingbot is confirmed via reverse-DNS + forward-confirm (the method Google and Bing document), so a spoofed `User-Agent` can't impersonate a trusted crawler. Ships `GOOGLEBOT` / `BINGBOT` / `WELL_KNOWN_BOTS` presets and accepts custom `VerifiedBotRule`s. Allowlist-first (`allowUserAgents` bypasses every rule), secure-by-default (`verifiedBots` refuses to construct without an IP source; unverifiable crawlers blocked unless `blockUnverifiableBots: false`), subdomain-boundary-safe domain matching, per-IP verification cache to keep DNS off the hot path, `mode: "log"` monitor mode, `onBlock` callback, and a pluggable `BotResolver` (default lazy `node:dns/promises`). Zero runtime dependencies.
590
- - `ipReputation()` IP reputation / dynamic denylist feed at `@daloyjs/core/ip-reputation`: wires pluggable, periodically-refreshed abuse feeds (Tor exit lists, Spamhaus DROP, cloud-abuse ranges, or your own threat intel) into the request path without a redeploy, reusing the same SSRF-grade CIDR matcher as `ipRestriction()`. Ships `urlFeed()` (fetches newline / Spamhaus-DROP-style lists, skips comment lines, keeps good rows from a partially-malformed feed) plus a custom `IpReputationFeed` interface. **Fail-open by design** — a feed that can't be loaded (initial or refresh) never blocks traffic; the last-known-good list is retained per feed. Periodic `unref`'d refresh, `mode: "log"` monitor mode, `onMatch` / `onError` callbacks, manual `refresh()` / `stop()` / `has()` / `size` controller, and pluggable IP resolution (`trustProxyHeaders` / `resolveIp`). Zero runtime dependencies.
592
+ - `ipReputation()` IP reputation / dynamic denylist feed at `@daloyjs/core/ip-reputation`: wires pluggable, periodically-refreshed abuse feeds (Tor exit lists, Spamhaus DROP, cloud-abuse ranges, or your own threat intel) into the request path without a redeploy, reusing the same SSRF-grade CIDR matcher as `ipRestriction()`. Ships `urlFeed()` (fetches newline / Spamhaus-DROP-style lists, skips comment lines, keeps good rows from a partially-malformed feed; **SSRF-hardened by default** — the outbound fetch runs through `fetchGuard()`, so a compromised feed host can't redirect it into cloud-metadata / internal space; override via `fetchImpl`) plus a custom `IpReputationFeed` interface. **Fail-open by design** — a feed that can't be loaded (initial or refresh) never blocks traffic; the last-known-good list is retained per feed. Periodic `unref`'d refresh, `mode: "log"` monitor mode, `onMatch` / `onError` callbacks, manual `refresh()` / `stop()` / `has()` / `size` controller, and pluggable IP resolution (`trustProxyHeaders` / `resolveIp`). Zero runtime dependencies.
591
593
  - `geoBlock()` GeoIP / geo-blocking at `@daloyjs/core/geo-block`: country allow/deny middleware that maps the client IP to an ISO 3166-1 alpha-2 country and rejects (or logs) traffic from countries you don't serve. **No bundled GeoIP database and no runtime dependency** — supply either an operator-owned `lookupCountry(ip)` (a MaxMind / `ip2location` reader, or your own table, reusing the trusted-proxy `X-Forwarded-For` / `X-Real-IP` IP resolution) or a `resolveCountry(ctx)` that reads an edge-injected header (`CF-IPCountry`, `CloudFront-Viewer-Country`, `x-vercel-ip-country`). Deny wins over allow (least privilege); **allow-lists fail closed** on an unknown country while deny-only fails open (overridable via `allowUnknownCountry`). Country codes are validated at construction so typos throw instead of silently never matching. `mode: "log"` monitor mode with an `onBlock` decision hook (`denied_country` / `not_in_allowlist` / `unknown_country`), the resolved country stamped on `ctx.state.geo` for allowed requests, and a `403` problem+json rejection that never echoes the country/IP. Zero runtime dependencies.
592
594
  - `concurrencyLimit()` per-route / per-client concurrency limits + queueing at `@daloyjs/core/concurrency-limit`: HAProxy `maxconn`/queue parity at the app layer. Bounds in-flight requests through a surface with a per-bucket semaphore (`maxConcurrent`), a bounded FIFO queue (`maxQueue`) with an optional `queueTimeoutMs`, and a fast `503` + `Retry-After` once the queue is full or the wait times out. Partition the budget with `scope`: `"global"` (default), `"route"` (per `method + path`), `"client"` (per identity, needs `trustProxyHeaders`/`keyGenerator`), or a custom function (`undefined` skips limiting, fail-open). Acquires in `beforeHandle` and releases in `onSend`, so slots are freed on success, error, and short-circuit paths alike — never leaked. `onReject` observability hook, configurable `retryAfterSeconds`/`message`. Complements the `maxConnections` socket cap and `loadShedding()`. Zero runtime dependencies. HAProxy `maxconn`/queue parity at the app layer. Bounds in-flight requests through a surface with a per-bucket semaphore (`maxConcurrent`), a bounded FIFO queue (`maxQueue`) with an optional `queueTimeoutMs`, and a fast `503` + `Retry-After` once the queue is full or the wait times out. Partition the budget with `scope`: `"global"` (default), `"route"` (per `method + path`), `"client"` (per identity, needs `trustProxyHeaders`/`keyGenerator`), or a custom function (`undefined` skips limiting, fail-open). Acquires in `beforeHandle` and releases in `onSend`, so slots are freed on success, error, and short-circuit paths alike — never leaked. `onReject` observability hook, configurable `retryAfterSeconds`/`message`. Complements the `maxConnections` socket cap and `loadShedding()`. Zero runtime dependencies.
593
595
  - `requestDecompression()` inbound decompression-bomb guard at `@daloyjs/core/request-decompression`: core is safe by omission (it never decompresses request bodies), so this is the opt-in middleware for services that must accept compressed uploads. Inflates `gzip` / `deflate` bodies behind two caps enforced **during** inflation so a zip bomb is aborted before it is fully materialised: an absolute `maxDecompressedBytes` (required) and an expansion-ratio `maxRatio` (default `100`), both rejecting with `413`. The compressed upload itself is bounded by `maxCompressedBytes` (default 1 MiB) before a byte is inflated. Unknown, non-allowlisted, runtime-unsupported, or **layered** (`gzip, gzip`) encodings are refused `415`; malformed streams `400`; bodyless / uncompressed / `identity` / `GET` / `HEAD` traffic passes through untouched. Runs in `onRequest` and stashes the inflated bytes so schema-validated bodies and raw-body handlers both see the decompressed payload. `onBomb` observability hook, exported `decompressRequestBody()` for custom flows. Built on web-standard `DecompressionStream` (brotli excluded — not in the spec). Zero runtime dependencies.
594
596
  - `waf()` opt-in WAF-lite signature/anomaly inbound-inspection middleware at `@daloyjs/core/waf`: a first-party defense-in-depth layer for teams without an edge WAF (it does **not** replace ModSecurity / a CDN WAF). Wires DaloyJS' high-confidence injection signatures — SQLi, XSS, NoSQL-operator injection (reusing `hasMongoOperatorKeys` for a structural body check), and command injection — into a single scored inbound-inspection pass over the decoded path, the raw + decoded query string, an opt-in header allowlist, and the validated body. Each rule that fires adds an anomaly `score`; reaching `blockThreshold` (default `5`) rejects with a generic `403` (block mode) or merely reports via `onMatch` (log mode) so operators can tune against real traffic first. Per-rule enable/disable + score overrides, inspection-surface toggles, control-character-stripped log samples, and bounded scanning (`maxValueLength` / `maxBodyNodes`) keep a hostile payload from becoming CPU-DoS. The `403` body never names the rule that fired. Zero runtime dependencies.
595
597
  - Built-in docs UI Subresource Integrity (SRI): `DocsAssetOptions` lets `scalarHtml()` / `swaggerUiHtml()` / `redocHtml()` and the `docs: { assets }` auto-mount pin version-exact `*Integrity` hashes (`sha256`/`sha384`/`sha512`) plus a `crossOrigin` value (default `"anonymous"`) on the CDN-loaded Scalar / Swagger UI / Redoc `<script>` / `<link>` tags, so a poisoned jsDelivr asset can't execute. Malformed SRI values throw a `TypeError` at startup (browsers ignore unparseable `integrity`, so failing loud avoids a false sense of protection); self-hosting the assets via the same `assets` URLs stays supported. Zero runtime dependencies.
596
- - HTTP Message Signatures (RFC 9421) at `@daloyjs/core/http-signatures`: first-party sign/verify for server-to-server request authentication via the standard `Signature` / `Signature-Input` headers — complements the inbound-only webhook HMAC and `clientCertAuth()` mTLS. `signMessage()` / `signRequest()` build an RFC 9421 signature base over derived components (`@method`, `@target-uri`, `@authority`, `@scheme`, `@request-target`, `@path`, `@query`, `@query-param`, `@status`) and HTTP fields with Structured-Fields header serialization; `verifyMessage()` / `verifyRequest()` and the `httpSignatureAuth()` middleware check them. Algorithms `hmac-sha256` / `ed25519` / `ecdsa-p256-sha256` / `ecdsa-p384-sha384` / `rsa-pss-sha512` / `rsa-v1_5-sha256` via WebCrypto (no `node:` imports). Secure-by-default verify: a **mandatory `algorithms` allowlist** (no implicit "any alg"), optional per-key alg pinning to defeat algorithm-confusion, a required `created` timestamp with a 300s freshness window, `created`-in-future / `expires` skew rejection, configurable `requiredComponents`, a 32-byte raw-HMAC floor, and `nonce` replay defense; the middleware answers a missing/invalid signature with `401` + `Cache-Control: no-store` and stamps the verified result on `ctx.state.httpSignature`. Ships RFC 9530 `contentDigest()` / `verifyContentDigest()` to bind the request body. Zero runtime dependencies.
598
+ - HTTP Message Signatures (RFC 9421) at `@daloyjs/core/http-signatures`: first-party sign/verify for server-to-server request authentication via the standard `Signature` / `Signature-Input` headers — complements the inbound-only webhook HMAC and `clientCertAuth()` mTLS. `signMessage()` / `signRequest()` build an RFC 9421 signature base over derived components (`@method`, `@target-uri`, `@authority`, `@scheme`, `@request-target`, `@path`, `@query`, `@query-param`, `@status`) and HTTP fields with Structured-Fields header serialization; `verifyMessage()` / `verifyRequest()` and the `httpSignatureAuth()` middleware check them. Algorithms `hmac-sha256` / `ed25519` / `ecdsa-p256-sha256` / `ecdsa-p384-sha384` / `rsa-pss-sha512` / `rsa-v1_5-sha256` via WebCrypto (no `node:` imports). Secure-by-default verify: a **mandatory `algorithms` allowlist** (no implicit "any alg"), optional per-key alg pinning to defeat algorithm-confusion, a required `created` timestamp with a 300s freshness window, `created`-in-future / `expires` skew rejection, configurable `requiredComponents`, a 32-byte raw-HMAC floor, a 2048-bit RSA modulus floor (NIST SP 800-131A, parity with the JWT verifier), and `nonce` replay defense; the middleware answers a missing/invalid signature with `401` + `Cache-Control: no-store` and stamps the verified result on `ctx.state.httpSignature`. Ships RFC 9530 `contentDigest()` / `verifyContentDigest()` to bind the request body. Zero runtime dependencies.
597
599
  - `compression()` built on web-standard `CompressionStream` (prefers `br` > `gzip` > `deflate`), with BREACH-aware always-on guards (skips `Set-Cookie`, `Authorization`, session / CSRF cookies, already-compressed content types), `minimumSize: 1024`, negative-compression-ratio post-check, no configurable `compressLevel` knob (CPU-DoS defense — `level: 9` is refused at construction), always-on `Vary: Accept-Encoding`, and strong → weak ETag downgrade per RFC 9110 §8.8.3.
598
600
  - `etag()` helper auto-skips on `Set-Cookie` and private / no-store / no-cache `Cache-Control` (cross-tenant fingerprinting defense).
599
601
  - `timing` / `timingSafeEqual` helpers.
package/dist/app.d.ts CHANGED
@@ -1107,19 +1107,32 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1107
1107
  /**
1108
1108
  * First-request boot guard. Verifies that the assembled hook
1109
1109
  * chain + route table is internally consistent before any user handler
1110
- * runs. Currently checks: when `session()` is installed and any route
1111
- * accepts a state-changing method (`POST` / `PUT` / `PATCH` / `DELETE`),
1112
- * a `csrf()` hook (or third-party equivalent stamped with
1113
- * {@link CSRF_HOOK_MARKER}) must also be present in that route's effective
1114
- * hook chain. Opt out with `app({ csrf: "off" })` or
1115
- * `app({ secureDefaults: false })`. Runs once per App between registration
1116
- * changes; the result is cached so the fast path is a single boolean check.
1110
+ * runs. Currently checks (production + `secureDefaults` only):
1111
+ *
1112
+ * 1. **Shadow auth** — a route that declares an `auth:` requirement (so it is
1113
+ * advertised as protected in the OpenAPI `security` list) must have an
1114
+ * authentication hook ({@link AUTH_HOOK_MARKER}) in its effective chain.
1115
+ * Otherwise it accepts unauthenticated requests while claiming protection.
1116
+ * 2. **Unauthenticated MCP** a route from {@link mcpRoutes} must have an
1117
+ * auth hook unless it opted out with `mcpRoutes(path, handler, { public: true })`.
1118
+ * MCP tools are model-controlled and side-effecting, so a public one is a
1119
+ * high-impact default.
1120
+ * 3. **Missing CSRF** — when `session()` is installed and any route accepts a
1121
+ * state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`), a `csrf()` hook
1122
+ * (or third-party equivalent stamped with {@link CSRF_HOOK_MARKER}) must
1123
+ * also be present. Skipped when `app({ csrf: "off" })`.
1124
+ *
1125
+ * Opt out of all guards with `app({ secureDefaults: false })`. Runs once per
1126
+ * App between registration changes; the result is cached so the fast path is a
1127
+ * single boolean check.
1117
1128
  */
1118
1129
  private assertBootGuards;
1119
1130
  /**
1120
1131
  * Per-request guard for spoofed proxy headers. When the App was
1121
1132
  * constructed without an explicit {@link AppOptions.trustProxy} value
1122
- * and a request arrives carrying an `X-Forwarded-*` header, refuse to
1133
+ * and a request arrives carrying a spoofable forwarded / client-IP header
1134
+ * (`X-Forwarded-*`, `X-Real-IP`, or a vendor header like `CF-Connecting-IP`,
1135
+ * `Fly-Client-IP`, `True-Client-IP`), refuse to
1123
1136
  * dispatch it: the rate limiter, audit log, and request-id propagation
1124
1137
  * would otherwise honour the attacker-supplied IP. Returns a structured
1125
1138
  * `500 problem+json` so the failure is loud at the network boundary.
package/dist/app.js CHANGED
@@ -6,7 +6,7 @@ import { createLogger, noopLogger } from "./logger.js";
6
6
  import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
7
7
  import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
8
8
  import { generateAsyncAPI, asyncapiToYAML, } from "./asyncapi.js";
9
- import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
9
+ import { secureHeaders as secureHeadersMiddleware, AUTH_HOOK_MARKER, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
10
10
  import { COMPRESSION_HOOK_MARKER } from "./compression.js";
11
11
  import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
12
12
  import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
@@ -118,6 +118,13 @@ const CANONICAL_HTTP_METHODS = new Set([
118
118
  "HEAD",
119
119
  "OPTIONS",
120
120
  ]);
121
+ /**
122
+ * Global-registry symbol stamped by `mcpRoutes()` on the route definitions it
123
+ * produces (unless the caller opts out with `public: true`). Read here — rather
124
+ * than importing from `mcp.ts` — so the MCP module is never pulled into the core
125
+ * `App` bundle. Must match the string used in `mcpRoutes`.
126
+ */
127
+ const MCP_ROUTE_MARKER = Symbol.for("daloyjs.mcp.route");
121
128
  /**
122
129
  * Apply a topology-aware security preset on top of caller-supplied
123
130
  * options. Returns a new options object where preset defaults fill in
@@ -906,13 +913,24 @@ export class App {
906
913
  /**
907
914
  * First-request boot guard. Verifies that the assembled hook
908
915
  * chain + route table is internally consistent before any user handler
909
- * runs. Currently checks: when `session()` is installed and any route
910
- * accepts a state-changing method (`POST` / `PUT` / `PATCH` / `DELETE`),
911
- * a `csrf()` hook (or third-party equivalent stamped with
912
- * {@link CSRF_HOOK_MARKER}) must also be present in that route's effective
913
- * hook chain. Opt out with `app({ csrf: "off" })` or
914
- * `app({ secureDefaults: false })`. Runs once per App between registration
915
- * changes; the result is cached so the fast path is a single boolean check.
916
+ * runs. Currently checks (production + `secureDefaults` only):
917
+ *
918
+ * 1. **Shadow auth** — a route that declares an `auth:` requirement (so it is
919
+ * advertised as protected in the OpenAPI `security` list) must have an
920
+ * authentication hook ({@link AUTH_HOOK_MARKER}) in its effective chain.
921
+ * Otherwise it accepts unauthenticated requests while claiming protection.
922
+ * 2. **Unauthenticated MCP** a route from {@link mcpRoutes} must have an
923
+ * auth hook unless it opted out with `mcpRoutes(path, handler, { public: true })`.
924
+ * MCP tools are model-controlled and side-effecting, so a public one is a
925
+ * high-impact default.
926
+ * 3. **Missing CSRF** — when `session()` is installed and any route accepts a
927
+ * state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`), a `csrf()` hook
928
+ * (or third-party equivalent stamped with {@link CSRF_HOOK_MARKER}) must
929
+ * also be present. Skipped when `app({ csrf: "off" })`.
930
+ *
931
+ * Opt out of all guards with `app({ secureDefaults: false })`. Runs once per
932
+ * App between registration changes; the result is cached so the fast path is a
933
+ * single boolean check.
916
934
  */
917
935
  assertBootGuards() {
918
936
  if (this.bootGuard.checked) {
@@ -923,13 +941,40 @@ export class App {
923
941
  this.bootGuard.checked = true;
924
942
  if (this.options.secureDefaults === false)
925
943
  return;
926
- if (this.options.csrf === "off")
927
- return;
928
944
  // Per the risk register: boot guards only fire in production so CI /
929
945
  // staging surfaces that ship sample secrets / no CSRF token while
930
946
  // iterating do not pay the refuse-to-boot cost.
931
947
  if (!this.isProduction())
932
948
  return;
949
+ // Guard 1: shadow auth — declared `auth:` with nothing enforcing it.
950
+ const shadowAuth = this.routeSecurityMarkers.find((r) => r.declaresAuth && !r.hasAuth);
951
+ if (shadowAuth) {
952
+ const err = new Error(`Route ${shadowAuth.method} ${shadowAuth.path} declares an auth requirement (auth: ...) ` +
953
+ `but no authentication hook is installed in its effective hook chain, so it is advertised ` +
954
+ `as protected while accepting unauthenticated requests. ` +
955
+ `Install an auth middleware (bearerAuth/basicAuth/jwk/httpSignatureAuth/clientCertAuth), ` +
956
+ `wrap a custom auth hook with markAuthHook(...), remove the route's auth: declaration, ` +
957
+ `or pass app({ secureDefaults: false }) to disable this guard. ` +
958
+ `See https://daloyjs.dev/docs/security/boot-guards.`);
959
+ this.bootGuard.error = err;
960
+ throw err;
961
+ }
962
+ // Guard 2: unauthenticated MCP tool endpoint.
963
+ const mcpNoAuth = this.routeSecurityMarkers.find((r) => r.isMcp && !r.hasAuth);
964
+ if (mcpNoAuth) {
965
+ const err = new Error(`MCP route ${mcpNoAuth.method} ${mcpNoAuth.path} (from mcpRoutes()) has no authentication ` +
966
+ `hook in its effective hook chain. MCP tools are model-controlled and can trigger side ` +
967
+ `effects, so an unauthenticated endpoint is a high-impact default. ` +
968
+ `Install an auth middleware covering the MCP route (e.g. app.use(bearerAuth({ ... }))), ` +
969
+ `wrap a custom auth hook with markAuthHook(...), pass mcpRoutes(path, handler, { public: true }) ` +
970
+ `to intentionally expose it, or pass app({ secureDefaults: false }) to disable this guard. ` +
971
+ `See https://daloyjs.dev/docs/security/boot-guards.`);
972
+ this.bootGuard.error = err;
973
+ throw err;
974
+ }
975
+ // Guard 3: session() + state-changing route without csrf().
976
+ if (this.options.csrf === "off")
977
+ return;
933
978
  const stateChanging = this.routeSecurityMarkers.find((r) => isStateChangingMethod(r.method) && r.hasSession && !r.hasCsrf);
934
979
  if (!stateChanging)
935
980
  return;
@@ -946,7 +991,9 @@ export class App {
946
991
  /**
947
992
  * Per-request guard for spoofed proxy headers. When the App was
948
993
  * constructed without an explicit {@link AppOptions.trustProxy} value
949
- * and a request arrives carrying an `X-Forwarded-*` header, refuse to
994
+ * and a request arrives carrying a spoofable forwarded / client-IP header
995
+ * (`X-Forwarded-*`, `X-Real-IP`, or a vendor header like `CF-Connecting-IP`,
996
+ * `Fly-Client-IP`, `True-Client-IP`), refuse to
950
997
  * dispatch it: the rate limiter, audit log, and request-id propagation
951
998
  * would otherwise honour the attacker-supplied IP. Returns a structured
952
999
  * `500 problem+json` so the failure is loud at the network boundary.
@@ -976,6 +1023,13 @@ export class App {
976
1023
  "x-forwarded-proto",
977
1024
  "x-forwarded-port",
978
1025
  "x-real-ip",
1026
+ // Platform-specific client-IP headers are just as spoofable as
1027
+ // X-Forwarded-* when the app is not actually behind that platform's
1028
+ // proxy. Refuse them too so a client cannot forge its source IP via a
1029
+ // vendor header the operator never configured trust for.
1030
+ "cf-connecting-ip",
1031
+ "fly-client-ip",
1032
+ "true-client-ip",
979
1033
  ]) {
980
1034
  if (headers.has(name)) {
981
1035
  found = name;
@@ -1388,6 +1442,8 @@ export class App {
1388
1442
  method: merged.method,
1389
1443
  path: merged.path,
1390
1444
  ...securityMarkers,
1445
+ declaresAuth: merged.auth !== undefined && merged.auth !== null,
1446
+ isMcp: merged[MCP_ROUTE_MARKER] === true,
1391
1447
  });
1392
1448
  this.resetBootGuardCache();
1393
1449
  return this;
@@ -2887,14 +2943,17 @@ export function topoSortExtensions(exts) {
2887
2943
  function securityMarkersFromHooks(layers) {
2888
2944
  let hasSession = false;
2889
2945
  let hasCsrf = false;
2946
+ let hasAuth = false;
2890
2947
  for (const hooks of layers) {
2891
2948
  const record = hooks;
2892
2949
  if (record[SESSION_HOOK_MARKER] === true)
2893
2950
  hasSession = true;
2894
2951
  if (record[CSRF_HOOK_MARKER] === true)
2895
2952
  hasCsrf = true;
2953
+ if (record[AUTH_HOOK_MARKER] === true)
2954
+ hasAuth = true;
2896
2955
  }
2897
- return { hasSession, hasCsrf };
2956
+ return { hasSession, hasCsrf, hasAuth };
2898
2957
  }
2899
2958
  function isStateChangingMethod(method) {
2900
2959
  return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
@@ -48,6 +48,14 @@ export const DEFAULT_SIGNATURE_CLOCK_SKEW_SECONDS = 60;
48
48
  const MAX_HEADER_LENGTH = 8192;
49
49
  /** Minimum byte length for a raw HMAC secret (RFC 7518 §3.2). */
50
50
  const MIN_HMAC_KEY_BYTES = 32;
51
+ /**
52
+ * Minimum RSA modulus size accepted for `rsa-*` signature algorithms. NIST
53
+ * SP 800-131A has disallowed RSA keys shorter than 2048 bits since 2014; the
54
+ * JWT verifier ({@link file://./jwt.ts}) enforces the same floor, so the HTTP
55
+ * Message Signatures path holds the parity to keep undersized (crackable) RSA
56
+ * keys out of every signature-verification surface in the framework.
57
+ */
58
+ const MIN_RSA_KEY_BITS = 2048;
51
59
  const ENC = new TextEncoder();
52
60
  // ---------------------------------------------------------------------------
53
61
  // WebCrypto + encoding helpers
@@ -132,11 +140,37 @@ function algSpec(alg) {
132
140
  };
133
141
  }
134
142
  }
143
+ /**
144
+ * Refuse RSA keys whose modulus is shorter than {@link MIN_RSA_KEY_BITS}.
145
+ *
146
+ * Only applies to the `rsa-*` algorithms — non-RSA keys are ignored. Every RSA
147
+ * `CryptoKey` carries a numeric `algorithm.modulusLength`; when WebCrypto
148
+ * reports a length below the floor the key is refused for both signing and
149
+ * verification. Mirrors the JWT verifier's `assertRsaModulusFloor` so no
150
+ * signature surface in the framework accepts an undersized RSA key.
151
+ *
152
+ * @param alg - The HTTP signature algorithm the key will be used with.
153
+ * @param key - The imported (or caller-supplied) `CryptoKey`.
154
+ * @throws {TypeError} When `alg` is RSA and the modulus is under the floor.
155
+ */
156
+ function assertRsaModulusFloor(alg, key) {
157
+ if (alg !== "rsa-pss-sha512" && alg !== "rsa-v1_5-sha256")
158
+ return;
159
+ const algorithm = key.algorithm;
160
+ const modulusLength = algorithm?.modulusLength;
161
+ if (typeof modulusLength !== "number" || !Number.isFinite(modulusLength))
162
+ return;
163
+ if (modulusLength < MIN_RSA_KEY_BITS) {
164
+ throw new TypeError(`http-signatures: ${alg} key modulus must be at least ${MIN_RSA_KEY_BITS} bits (NIST SP 800-131A); got ${modulusLength}.`);
165
+ }
166
+ }
135
167
  async function importKey(alg, material, usage) {
136
168
  const spec = algSpec(alg);
137
169
  const c = getCrypto();
138
- if (isCryptoKey(material))
170
+ if (isCryptoKey(material)) {
171
+ assertRsaModulusFloor(alg, material);
139
172
  return material;
173
+ }
140
174
  if (material instanceof Uint8Array) {
141
175
  if (!spec.symmetric) {
142
176
  throw new TypeError(`http-signatures: raw byte keys are only supported for hmac-sha256; got ${alg}.`);
@@ -147,9 +181,11 @@ async function importKey(alg, material, usage) {
147
181
  return c.subtle.importKey("raw", material, spec.importParams, false, [usage]);
148
182
  }
149
183
  if (isJsonWebKey(material)) {
150
- return c.subtle.importKey("jwk", material, spec.importParams, false, [
184
+ const key = await c.subtle.importKey("jwk", material, spec.importParams, false, [
151
185
  usage,
152
186
  ]);
187
+ assertRsaModulusFloor(alg, key);
188
+ return key;
153
189
  }
154
190
  throw new TypeError("http-signatures: unsupported key material.");
155
191
  }
@@ -731,7 +767,7 @@ export function verifyRequest(request, opts) {
731
767
  export function httpSignatureAuth(opts) {
732
768
  const stateKey = opts.stateKey ?? "httpSignature";
733
769
  const message = opts.message ?? "Valid HTTP message signature required";
734
- return {
770
+ const authHooks = {
735
771
  async beforeHandle(ctx) {
736
772
  const headers = ctx.request.headers;
737
773
  if (opts.optional && !headers.has("signature"))
@@ -744,6 +780,11 @@ export function httpSignatureAuth(opts) {
744
780
  return undefined;
745
781
  },
746
782
  };
783
+ // Same global symbol as middleware's AUTH_HOOK_MARKER (stamped inline to keep
784
+ // the middleware module out of this bundle): lets the route-auth boot guard
785
+ // recognize that a route declaring `auth:` is actually enforced here.
786
+ authHooks[Symbol.for("daloyjs.auth.hook")] = true;
787
+ return authHooks;
747
788
  }
748
789
  const CONTENT_DIGEST_HASH = {
749
790
  "sha-256": "SHA-256",
package/dist/index.d.ts CHANGED
@@ -19,11 +19,11 @@ export type { StandardSchemaV1 } from "./schema.js";
19
19
  export { validate, isStandardSchema } from "./schema.js";
20
20
  export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
21
21
  export type { ChangeSeverity, OpenAPIChange, OpenAPIDiffResult } from "./openapi-diff.js";
22
- export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, } from "./mcp.js";
23
- export type { McpContent, McpEmbeddedResourceContent, McpHandler, McpHandlerOptions, McpIcon, McpImageContent, McpJsonObject, McpJsonRpcId, McpJsonSchema, McpJsonValue, McpPrompt, McpPromptArgument, McpPromptDefinition, McpPromptMessage, McpPromptResult, McpRequestContext, McpResource, McpResourceContents, McpResourceDefinition, McpResourceTemplate, McpResourceTemplateDefinition, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolHandler, McpToolResult, } from "./mcp.js";
22
+ export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, validateMcpInput, } from "./mcp.js";
23
+ export type { McpContent, McpEmbeddedResourceContent, McpHandler, McpHandlerOptions, McpIcon, McpImageContent, McpJsonObject, McpJsonRpcId, McpJsonSchema, McpJsonValue, McpPrompt, McpPromptArgument, McpPromptDefinition, McpPromptMessage, McpPromptResult, McpRequestContext, McpResource, McpResourceContents, McpResourceDefinition, McpResourceTemplate, McpResourceTemplateDefinition, McpRoutesOptions, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolHandler, McpToolResult, } from "./mcp.js";
24
24
  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";
25
25
  export type { WebhookHmacAlgorithm } from "./security.js";
26
- 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";
26
+ export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, markAuthHook, AUTH_HOOK_MARKER, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
27
27
  export { etag } from "./etag.js";
28
28
  export type { ETagOptions } from "./etag.js";
29
29
  export { compression, COMPRESSION_HOOK_MARKER, _resetCompressionRuntimeProbeForTests, } from "./compression.js";
package/dist/index.js CHANGED
@@ -11,9 +11,9 @@ export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
11
11
  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";
12
12
  export { validate, isStandardSchema } from "./schema.js";
13
13
  export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
14
- export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, } from "./mcp.js";
14
+ export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, validateMcpInput, } from "./mcp.js";
15
15
  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";
16
- 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";
16
+ export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, markAuthHook, AUTH_HOOK_MARKER, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
17
17
  export { etag } from "./etag.js";
18
18
  export { compression, COMPRESSION_HOOK_MARKER, _resetCompressionRuntimeProbeForTests, } from "./compression.js";
19
19
  export { createJwtSigner, createJwtVerifier, JwtError, DEFAULT_JWT_MAX_LIFETIME_SECONDS, } from "./jwt.js";
@@ -160,8 +160,12 @@ export interface UrlFeedOptions {
160
160
  /** Feed name. Defaults to the URL. */
161
161
  name?: string;
162
162
  /**
163
- * Custom `fetch` implementation. Defaults to the platform `fetch`. Provide an
164
- * SSRF-guarded fetch or a non-standard-runtime client here.
163
+ * Custom `fetch` implementation. Defaults to an SSRF-hardened
164
+ * {@link fetchGuard} instance so a compromised or malicious feed host cannot
165
+ * redirect the request into internal/link-local space (cloud metadata, etc.).
166
+ * Override with your own client for a non-standard runtime, or with
167
+ * `fetchGuard({ allowPrivate: true })` for an intentionally internal feed
168
+ * mirror.
165
169
  */
166
170
  fetchImpl?: typeof fetch;
167
171
  /** Extra request headers (e.g. an API token for a commercial feed). */
@@ -174,6 +178,8 @@ export interface UrlFeedOptions {
174
178
  * are skipped by {@link ipReputation}, so a partially-malformed feed still loads
175
179
  * its good entries.
176
180
  *
181
+ * The outbound fetch is SSRF-hardened by default (see {@link UrlFeedOptions.fetchImpl}).
182
+ *
177
183
  * @param url - The feed URL.
178
184
  * @param opts - Optional feed name, custom `fetch`, and request headers.
179
185
  * @returns A feed ready to pass to {@link IpReputationOptions.feeds}.
@@ -45,6 +45,7 @@
45
45
  * @since 0.37.0
46
46
  */
47
47
  import { ForbiddenError } from "./errors.js";
48
+ import { fetchGuard } from "./fetch-guard.js";
48
49
  import { compileCidrMatcher, matchesMatcher, parseIp, } from "./ip-restriction.js";
49
50
  const DEFAULT_REFRESH_MS = 60 * 60_000;
50
51
  const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
@@ -74,6 +75,8 @@ function parseFeedLine(line) {
74
75
  * are skipped by {@link ipReputation}, so a partially-malformed feed still loads
75
76
  * its good entries.
76
77
  *
78
+ * The outbound fetch is SSRF-hardened by default (see {@link UrlFeedOptions.fetchImpl}).
79
+ *
77
80
  * @param url - The feed URL.
78
81
  * @param opts - Optional feed name, custom `fetch`, and request headers.
79
82
  * @returns A feed ready to pass to {@link IpReputationOptions.feeds}.
@@ -81,7 +84,10 @@ function parseFeedLine(line) {
81
84
  */
82
85
  export function urlFeed(url, opts = {}) {
83
86
  const name = opts.name ?? url;
84
- const doFetch = opts.fetchImpl ?? globalThis.fetch;
87
+ // Secure-by-default: route the outbound feed fetch through fetchGuard() so
88
+ // redirects are re-validated per hop and internal/metadata targets are
89
+ // refused, matching createWebhookSender's posture. Callers can override.
90
+ const doFetch = opts.fetchImpl ?? fetchGuard();
85
91
  return {
86
92
  name,
87
93
  async fetch(signal) {
package/dist/jwk.js CHANGED
@@ -235,7 +235,7 @@ export function jwk(opts) {
235
235
  });
236
236
  return cachedVerifier;
237
237
  }
238
- return {
238
+ const authHooks = {
239
239
  async beforeHandle(ctx) {
240
240
  const header = ctx.request.headers.get("authorization") ?? "";
241
241
  const match = /^Bearer\s+(.+)$/i.exec(header);
@@ -268,6 +268,11 @@ export function jwk(opts) {
268
268
  return undefined;
269
269
  },
270
270
  };
271
+ // Same global symbol as middleware's AUTH_HOOK_MARKER (stamped inline to keep
272
+ // the middleware module out of jwk's bundle): lets the route-auth boot guard
273
+ // recognize that a route declaring `auth:` is actually enforced here.
274
+ authHooks[Symbol.for("daloyjs.auth.hook")] = true;
275
+ return authHooks;
271
276
  }
272
277
  function extractScopes(payload) {
273
278
  // RFC 8693 / OAuth2: `scope` is a space-delimited string; some IdPs emit
package/dist/mcp.d.ts CHANGED
@@ -39,9 +39,16 @@ export type McpJsonObject = {
39
39
  };
40
40
  /**
41
41
  * JSON Schema fragment advertised to MCP clients for a tool or prompt
42
- * argument object. DaloyJS does not bundle a schema validator here, so the
43
- * schema is documentation and client-side guidance. Validate sensitive inputs
44
- * inside your handler before touching databases, files, or remote services.
42
+ * argument object.
43
+ *
44
+ * For a tool's `inputSchema`, DaloyJS enforces the commonly-used,
45
+ * security-relevant subset of JSON Schema server-side (see
46
+ * {@link validateMcpInput}) BEFORE the tool handler runs, rejecting a
47
+ * `tools/call` whose arguments violate it with JSON-RPC `-32602`. Keywords
48
+ * outside that subset (`pattern`, `format`, `$ref`,
49
+ * `anyOf`/`oneOf`/`allOf`, …) are advertised to clients but NOT enforced —
50
+ * validate any constraint expressed only through those keywords inside your
51
+ * handler before touching databases, files, or remote services.
45
52
  *
46
53
  * @since 1.0.0
47
54
  */
@@ -195,8 +202,11 @@ export interface McpToolAnnotations {
195
202
  * Handler for a single MCP tool.
196
203
  *
197
204
  * @typeParam TArgs - Type expected in `params.arguments` for this tool.
198
- * @param args - Tool arguments supplied by the MCP client. They are typed for
199
- * developer experience but are still untrusted JSON at runtime.
205
+ * @param args - Tool arguments supplied by the MCP client. They have already
206
+ * been validated against this tool's `inputSchema` (enforced subset — see
207
+ * {@link validateMcpInput}) and had prototype-pollution keys stripped, so the
208
+ * declared shape holds at runtime. Constraints expressed only through
209
+ * unsupported schema keywords (e.g. `pattern`) remain the handler's job.
200
210
  * @param ctx - Request metadata and the original HTTP request.
201
211
  * @returns Text shorthand or a full {@link McpToolResult}.
202
212
  * @throws {McpToolError} for caller-correctable failures that should be
@@ -209,9 +219,11 @@ export type McpToolHandler<TArgs extends Record<string, unknown> = Record<string
209
219
  * Definition of a callable MCP tool.
210
220
  *
211
221
  * Tools are model-controlled in MCP: clients may let the language model decide
212
- * when to call them. Treat every tool as a public API operation and enforce
213
- * authentication, authorization, rate limits, and validation before side
214
- * effects.
222
+ * when to call them. Treat every tool as a public API operation. DaloyJS
223
+ * enforces the tool's `inputSchema` (enforced subset see
224
+ * {@link validateMcpInput}) before the handler runs; you remain responsible for
225
+ * authentication, authorization, rate limits, and any validation beyond that
226
+ * subset before side effects.
215
227
  *
216
228
  * @typeParam TArgs - Type expected by this tool's handler.
217
229
  * @since 1.0.0
@@ -492,6 +504,31 @@ export interface McpHandlerOptions {
492
504
  * @since 1.0.0
493
505
  */
494
506
  export type McpHandler = (request: Request) => Promise<Response>;
507
+ /**
508
+ * Minimal, dependency-free JSON Schema validator for MCP tool arguments.
509
+ *
510
+ * DaloyJS core bundles no third-party schema library, so this implements the
511
+ * commonly-used, security-relevant subset of JSON Schema — enough to reject the
512
+ * untrusted `tools/call` argument shapes that matter before a tool handler
513
+ * runs: wrong `type` (including `integer`), missing `required` properties,
514
+ * unexpected keys under `additionalProperties: false`, `enum`/`const`
515
+ * violations, and basic string/number/array bounds (`minLength`/`maxLength`,
516
+ * `minimum`/`maximum`, `minItems`/`maxItems`). Nested `properties`, `items`,
517
+ * and object-form `additionalProperties` are validated recursively.
518
+ *
519
+ * Keywords outside this subset (`pattern`, `format`, `$ref`,
520
+ * `anyOf`/`oneOf`/`allOf`, etc.) are intentionally NOT enforced — notably
521
+ * `pattern` is skipped so a developer-authored regex can never become a ReDoS
522
+ * sink against attacker-controlled input. Handlers must still validate any
523
+ * constraint expressed only through those keywords.
524
+ *
525
+ * @param schema - The tool's advertised `inputSchema`.
526
+ * @param value - The untrusted `params.arguments` value from the client.
527
+ * @returns A list of human-readable validation errors; empty when the value
528
+ * satisfies the enforced subset of the schema.
529
+ * @since 1.0.0
530
+ */
531
+ export declare function validateMcpInput(schema: McpJsonSchema, value: unknown): string[];
495
532
  /**
496
533
  * Create a dependency-free MCP Streamable HTTP endpoint handler.
497
534
  *
@@ -544,6 +581,25 @@ export type McpHandler = (request: Request) => Promise<Response>;
544
581
  * @since 1.0.0
545
582
  */
546
583
  export declare function createMcpHandler(options: McpHandlerOptions): McpHandler;
584
+ /**
585
+ * Options for {@link mcpRoutes}.
586
+ *
587
+ * @since 1.0.0
588
+ */
589
+ export interface McpRoutesOptions {
590
+ /**
591
+ * Set `true` to intentionally expose the MCP endpoint WITHOUT authentication,
592
+ * opting the `POST` transport out of the App's production route-auth boot
593
+ * guard. Only do this for a genuinely public MCP server — MCP tools are
594
+ * model-controlled and can trigger side effects, so an unauthenticated
595
+ * endpoint is a high-impact default. When left `false` (the default), a
596
+ * production `secureDefaults` App refuses to boot unless an authentication
597
+ * hook covers the MCP route.
598
+ *
599
+ * @defaultValue false
600
+ */
601
+ public?: boolean;
602
+ }
547
603
  /**
548
604
  * Build the Daloy route definitions for a Streamable HTTP MCP endpoint.
549
605
  *
@@ -552,8 +608,16 @@ export declare function createMcpHandler(options: McpHandlerOptions): McpHandler
552
608
  * its public contract and auth policy, while the MCP server can use its own
553
609
  * bearer token, rate limit, network allowlist, and tool set.
554
610
  *
611
+ * By default the `POST` transport route is stamped so that a production
612
+ * `secureDefaults` App **refuses to boot** unless an authentication hook covers
613
+ * it — MCP tools are model-controlled and side-effecting. Cover the route with
614
+ * an auth middleware (e.g. `app.use(bearerAuth({ ... }))`), or pass
615
+ * `{ public: true }` to intentionally expose a public MCP server.
616
+ *
555
617
  * @param path - Public MCP endpoint path, usually `"/mcp"`.
556
618
  * @param handler - Handler returned by {@link createMcpHandler}.
619
+ * @param options - See {@link McpRoutesOptions}; pass `{ public: true }` to opt
620
+ * out of the auth boot guard.
557
621
  * @returns Route definitions for `POST`, `GET`, and `OPTIONS` on the same
558
622
  * path. `POST` is the actual MCP transport; `GET` gives a human-readable
559
623
  * 405 hint because this helper does not open server-initiated SSE streams;
@@ -564,11 +628,18 @@ export declare function createMcpHandler(options: McpHandlerOptions): McpHandler
564
628
  * const app = new App();
565
629
  * const mcp = createMcpHandler({ serverInfo, tools });
566
630
  *
631
+ * // Authenticated MCP server (satisfies the production boot guard):
632
+ * app.use(bearerAuth({ validate: (t) => timingSafeEqual(t, process.env.MCP_TOKEN!) }));
567
633
  * for (const route of mcpRoutes("/mcp", mcp)) {
568
634
  * app.route(route);
569
635
  * }
636
+ *
637
+ * // ...or an intentionally public MCP server:
638
+ * for (const route of mcpRoutes("/mcp", mcp, { public: true })) {
639
+ * app.route(route);
640
+ * }
570
641
  * ```
571
642
  *
572
643
  * @since 1.0.0
573
644
  */
574
- export declare function mcpRoutes(path: PathString, handler: McpHandler): RouteDefinition<PathString, "GET" | "POST" | "OPTIONS">[];
645
+ export declare function mcpRoutes(path: PathString, handler: McpHandler, options?: McpRoutesOptions): RouteDefinition<PathString, "GET" | "POST" | "OPTIONS">[];
package/dist/mcp.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { safeJsonParse } from "./security.js";
1
2
  /**
2
3
  * Latest MCP protocol version DaloyJS negotiates by default.
3
4
  *
@@ -125,6 +126,165 @@ function asRecord(value) {
125
126
  ? value
126
127
  : {};
127
128
  }
129
+ /** Hard cap on reported validation errors so a hostile payload can't inflate the response. */
130
+ const MAX_MCP_VALIDATION_ERRORS = 20;
131
+ /** Recursion-depth cap so a deeply-nested payload can't exhaust the stack. */
132
+ const MAX_MCP_SCHEMA_DEPTH = 64;
133
+ /** Narrow an arbitrary JSON value to a schema object (`{}`), excluding arrays/null. */
134
+ function isSchemaObject(v) {
135
+ return v !== null && typeof v === "object" && !Array.isArray(v);
136
+ }
137
+ /** Report the JSON type of a value using JSON Schema's type names. */
138
+ function jsonTypeOf(v) {
139
+ if (v === null)
140
+ return "null";
141
+ if (Array.isArray(v))
142
+ return "array";
143
+ return typeof v;
144
+ }
145
+ /** Test a value against a single JSON Schema `type` keyword. */
146
+ function matchesJsonType(type, value) {
147
+ switch (type) {
148
+ case "integer":
149
+ return typeof value === "number" && Number.isInteger(value);
150
+ case "number":
151
+ return typeof value === "number" && Number.isFinite(value);
152
+ case "string":
153
+ return typeof value === "string";
154
+ case "boolean":
155
+ return typeof value === "boolean";
156
+ case "null":
157
+ return value === null;
158
+ case "object":
159
+ return value !== null && typeof value === "object" && !Array.isArray(value);
160
+ case "array":
161
+ return Array.isArray(value);
162
+ default:
163
+ // Unknown type keyword — do not reject; treat as unconstrained.
164
+ return true;
165
+ }
166
+ }
167
+ /** Structural equality for `enum`/`const` comparison (sufficient for JSON scalars/objects). */
168
+ function deepEqualJson(a, b) {
169
+ return JSON.stringify(a) === JSON.stringify(b);
170
+ }
171
+ /** Recursive worker for {@link validateMcpInput}. Pushes human-readable errors into `errors`. */
172
+ function validateSchemaNode(schema, value, path, errors, depth) {
173
+ if (errors.length >= MAX_MCP_VALIDATION_ERRORS)
174
+ return;
175
+ if (depth > MAX_MCP_SCHEMA_DEPTH) {
176
+ errors.push(`${path}: exceeds maximum validation depth`);
177
+ return;
178
+ }
179
+ // type (string or array-of-strings). A type mismatch stops deeper,
180
+ // type-dependent checks for this node to avoid a cascade of noise.
181
+ const typeKw = schema.type;
182
+ if (typeof typeKw === "string") {
183
+ if (!matchesJsonType(typeKw, value)) {
184
+ errors.push(`${path}: expected ${typeKw}, got ${jsonTypeOf(value)}`);
185
+ return;
186
+ }
187
+ }
188
+ else if (Array.isArray(typeKw)) {
189
+ const types = typeKw.filter((t) => typeof t === "string");
190
+ if (types.length > 0 && !types.some((t) => matchesJsonType(t, value))) {
191
+ errors.push(`${path}: expected one of [${types.join(", ")}], got ${jsonTypeOf(value)}`);
192
+ return;
193
+ }
194
+ }
195
+ if (Array.isArray(schema.enum) && !schema.enum.some((e) => deepEqualJson(e, value))) {
196
+ errors.push(`${path}: value is not one of the allowed enum values`);
197
+ }
198
+ if ("const" in schema && !deepEqualJson(schema.const, value)) {
199
+ errors.push(`${path}: value does not equal the required constant`);
200
+ }
201
+ if (typeof value === "string") {
202
+ if (typeof schema.minLength === "number" && value.length < schema.minLength) {
203
+ errors.push(`${path}: string shorter than minLength ${schema.minLength}`);
204
+ }
205
+ if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
206
+ errors.push(`${path}: string longer than maxLength ${schema.maxLength}`);
207
+ }
208
+ }
209
+ if (typeof value === "number") {
210
+ if (typeof schema.minimum === "number" && value < schema.minimum) {
211
+ errors.push(`${path}: number below minimum ${schema.minimum}`);
212
+ }
213
+ if (typeof schema.maximum === "number" && value > schema.maximum) {
214
+ errors.push(`${path}: number above maximum ${schema.maximum}`);
215
+ }
216
+ }
217
+ if (Array.isArray(value)) {
218
+ if (typeof schema.minItems === "number" && value.length < schema.minItems) {
219
+ errors.push(`${path}: array has fewer than minItems ${schema.minItems}`);
220
+ }
221
+ if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
222
+ errors.push(`${path}: array has more than maxItems ${schema.maxItems}`);
223
+ }
224
+ if (isSchemaObject(schema.items)) {
225
+ for (let i = 0; i < value.length; i++) {
226
+ validateSchemaNode(schema.items, value[i], `${path}[${i}]`, errors, depth + 1);
227
+ if (errors.length >= MAX_MCP_VALIDATION_ERRORS)
228
+ return;
229
+ }
230
+ }
231
+ }
232
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
233
+ const obj = value;
234
+ const props = isSchemaObject(schema.properties) ? schema.properties : undefined;
235
+ if (Array.isArray(schema.required)) {
236
+ for (const req of schema.required) {
237
+ if (typeof req === "string" && !Object.prototype.hasOwnProperty.call(obj, req)) {
238
+ errors.push(`${path}.${req}: required property is missing`);
239
+ }
240
+ }
241
+ }
242
+ const addl = schema.additionalProperties;
243
+ for (const key of Object.keys(obj)) {
244
+ const sub = props && isSchemaObject(props[key]) ? props[key] : undefined;
245
+ if (sub) {
246
+ validateSchemaNode(sub, obj[key], `${path}.${key}`, errors, depth + 1);
247
+ }
248
+ else if (addl === false) {
249
+ errors.push(`${path}.${key}: unexpected property (additionalProperties is false)`);
250
+ }
251
+ else if (isSchemaObject(addl)) {
252
+ validateSchemaNode(addl, obj[key], `${path}.${key}`, errors, depth + 1);
253
+ }
254
+ if (errors.length >= MAX_MCP_VALIDATION_ERRORS)
255
+ return;
256
+ }
257
+ }
258
+ }
259
+ /**
260
+ * Minimal, dependency-free JSON Schema validator for MCP tool arguments.
261
+ *
262
+ * DaloyJS core bundles no third-party schema library, so this implements the
263
+ * commonly-used, security-relevant subset of JSON Schema — enough to reject the
264
+ * untrusted `tools/call` argument shapes that matter before a tool handler
265
+ * runs: wrong `type` (including `integer`), missing `required` properties,
266
+ * unexpected keys under `additionalProperties: false`, `enum`/`const`
267
+ * violations, and basic string/number/array bounds (`minLength`/`maxLength`,
268
+ * `minimum`/`maximum`, `minItems`/`maxItems`). Nested `properties`, `items`,
269
+ * and object-form `additionalProperties` are validated recursively.
270
+ *
271
+ * Keywords outside this subset (`pattern`, `format`, `$ref`,
272
+ * `anyOf`/`oneOf`/`allOf`, etc.) are intentionally NOT enforced — notably
273
+ * `pattern` is skipped so a developer-authored regex can never become a ReDoS
274
+ * sink against attacker-controlled input. Handlers must still validate any
275
+ * constraint expressed only through those keywords.
276
+ *
277
+ * @param schema - The tool's advertised `inputSchema`.
278
+ * @param value - The untrusted `params.arguments` value from the client.
279
+ * @returns A list of human-readable validation errors; empty when the value
280
+ * satisfies the enforced subset of the schema.
281
+ * @since 1.0.0
282
+ */
283
+ export function validateMcpInput(schema, value) {
284
+ const errors = [];
285
+ validateSchemaNode(schema, value, "arguments", errors, 0);
286
+ return errors;
287
+ }
128
288
  function publicTool(tool) {
129
289
  const { handler: _handler, ...rest } = tool;
130
290
  return rest;
@@ -382,8 +542,18 @@ export function createMcpHandler(options) {
382
542
  if (!tool) {
383
543
  return rpcError(id, INVALID_PARAMS, `Unknown tool: ${name || "<missing>"}`, undefined, 200, headers);
384
544
  }
545
+ // Enforce the tool's advertised inputSchema on the untrusted client
546
+ // arguments BEFORE the handler runs, so a handler is never handed a
547
+ // payload that violates its own contract (wrong types, missing required
548
+ // fields, unexpected keys). Protocol-level validation failures map to
549
+ // JSON-RPC -32602 (Invalid params).
550
+ const rawArgs = params.arguments === undefined ? {} : params.arguments;
551
+ const validationErrors = validateMcpInput(tool.inputSchema, rawArgs);
552
+ if (validationErrors.length > 0) {
553
+ return rpcError(id, INVALID_PARAMS, `Invalid arguments for tool "${name}": ${validationErrors[0]}`, { validationErrors }, 200, headers);
554
+ }
385
555
  try {
386
- const result = await tool.handler(asRecord(params.arguments), ctx);
556
+ const result = await tool.handler(asRecord(rawArgs), ctx);
387
557
  return rpcResult(id, normalizeToolResult(result), headers);
388
558
  }
389
559
  catch (error) {
@@ -523,7 +693,11 @@ export function createMcpHandler(options) {
523
693
  }
524
694
  let message;
525
695
  try {
526
- message = JSON.parse(raw);
696
+ // `safeJsonParse` strips `__proto__` / `constructor` / `prototype` keys so
697
+ // an untrusted MCP client cannot smuggle prototype-pollution-shaped keys
698
+ // into a tool handler's arguments — matching the REST body parsers'
699
+ // secure-by-default posture (see `safeJsonParse` in security.ts).
700
+ message = safeJsonParse(raw);
527
701
  }
528
702
  catch {
529
703
  return rpcError(null, PARSE_ERROR, "Invalid JSON in request body.", undefined, 400, headers);
@@ -565,8 +739,16 @@ export function createMcpHandler(options) {
565
739
  * its public contract and auth policy, while the MCP server can use its own
566
740
  * bearer token, rate limit, network allowlist, and tool set.
567
741
  *
742
+ * By default the `POST` transport route is stamped so that a production
743
+ * `secureDefaults` App **refuses to boot** unless an authentication hook covers
744
+ * it — MCP tools are model-controlled and side-effecting. Cover the route with
745
+ * an auth middleware (e.g. `app.use(bearerAuth({ ... }))`), or pass
746
+ * `{ public: true }` to intentionally expose a public MCP server.
747
+ *
568
748
  * @param path - Public MCP endpoint path, usually `"/mcp"`.
569
749
  * @param handler - Handler returned by {@link createMcpHandler}.
750
+ * @param options - See {@link McpRoutesOptions}; pass `{ public: true }` to opt
751
+ * out of the auth boot guard.
570
752
  * @returns Route definitions for `POST`, `GET`, and `OPTIONS` on the same
571
753
  * path. `POST` is the actual MCP transport; `GET` gives a human-readable
572
754
  * 405 hint because this helper does not open server-initiated SSE streams;
@@ -577,14 +759,21 @@ export function createMcpHandler(options) {
577
759
  * const app = new App();
578
760
  * const mcp = createMcpHandler({ serverInfo, tools });
579
761
  *
762
+ * // Authenticated MCP server (satisfies the production boot guard):
763
+ * app.use(bearerAuth({ validate: (t) => timingSafeEqual(t, process.env.MCP_TOKEN!) }));
580
764
  * for (const route of mcpRoutes("/mcp", mcp)) {
581
765
  * app.route(route);
582
766
  * }
767
+ *
768
+ * // ...or an intentionally public MCP server:
769
+ * for (const route of mcpRoutes("/mcp", mcp, { public: true })) {
770
+ * app.route(route);
771
+ * }
583
772
  * ```
584
773
  *
585
774
  * @since 1.0.0
586
775
  */
587
- export function mcpRoutes(path, handler) {
776
+ export function mcpRoutes(path, handler, options = {}) {
588
777
  const responses = {
589
778
  200: { description: "MCP JSON-RPC response", body: MCP_JSON_RESPONSE_SCHEMA },
590
779
  202: { description: "MCP notification accepted", body: MCP_JSON_RESPONSE_SCHEMA },
@@ -594,7 +783,7 @@ export function mcpRoutes(path, handler) {
594
783
  405: { description: "Unsupported MCP transport method" },
595
784
  413: { description: "MCP request body too large" },
596
785
  };
597
- return [
786
+ const routes = [
598
787
  {
599
788
  method: "POST",
600
789
  path,
@@ -620,4 +809,17 @@ export function mcpRoutes(path, handler) {
620
809
  handler: ({ request }) => handler(request),
621
810
  },
622
811
  ];
812
+ // Unless explicitly public, stamp the POST transport (the route that executes
813
+ // tools/call) with the global-registry marker the App boot guard reads. GET
814
+ // (405 hint) and OPTIONS (preflight) are not marked: preflight must stay
815
+ // credential-free. Uses the same string as app.ts's MCP_ROUTE_MARKER; kept as
816
+ // a bare Symbol.for so the App core never imports this module.
817
+ if (options.public !== true) {
818
+ for (const route of routes) {
819
+ if (route.method === "POST") {
820
+ route[Symbol.for("daloyjs.mcp.route")] = true;
821
+ }
822
+ }
823
+ }
824
+ return routes;
623
825
  }
@@ -314,6 +314,44 @@ export declare const CORS_WILDCARD_ORIGIN_MARKER: unique symbol;
314
314
  * @since 0.17.0
315
315
  */
316
316
  export declare const CSRF_HOOK_MARKER: unique symbol;
317
+ /**
318
+ * Marker stamped on a {@link Hooks} bundle that authenticates the request —
319
+ * i.e. rejects callers without valid credentials. Built-in auth middlewares
320
+ * (`bearerAuth`, `basicAuth`, `jwk`, `httpSignatureAuth`, `clientCertAuth`)
321
+ * stamp it so the framework's route-auth boot guard can confirm that any route
322
+ * declaring an `auth:` requirement is actually enforced by a hook rather than
323
+ * being silently public (a `security` entry in the OpenAPI doc with no runtime
324
+ * check). Wrap a custom authentication hook with {@link markAuthHook} to opt it
325
+ * into the same guard.
326
+ *
327
+ * @since 1.0.0
328
+ */
329
+ export declare const AUTH_HOOK_MARKER: unique symbol;
330
+ /**
331
+ * Mark a custom {@link Hooks} bundle as performing request authentication.
332
+ *
333
+ * Use this when you authenticate with your own hook (not one of the built-in
334
+ * auth middlewares) but still declare `auth:` on the protected routes: it
335
+ * stamps {@link AUTH_HOOK_MARKER} so the production route-auth boot guard treats
336
+ * those routes as enforced. It is also the correct escape hatch when
337
+ * authentication is performed by an upstream gateway/mesh and the in-app hook
338
+ * is intentionally a pass-through.
339
+ *
340
+ * @param hooks - The hook bundle to mark (mutated in place and returned).
341
+ * @returns The same `hooks` object, now stamped as an auth hook.
342
+ *
343
+ * @example
344
+ * ```ts
345
+ * app.use(markAuthHook({
346
+ * async beforeHandle(ctx) {
347
+ * if (!(await myVerify(ctx.request))) throw new UnauthorizedError();
348
+ * },
349
+ * }));
350
+ * ```
351
+ *
352
+ * @since 1.0.0
353
+ */
354
+ export declare function markAuthHook(hooks: Hooks): Hooks;
317
355
  /** Predicate stamped on a CORS `Hooks` object that returns `true` for allowed origins. */
318
356
  export type CorsOriginAllow = (origin: string) => boolean;
319
357
  /** Options for {@link cors}. */
@@ -447,6 +447,47 @@ export const CORS_WILDCARD_ORIGIN_MARKER = Symbol.for("daloyjs.middleware.cors.w
447
447
  * @since 0.17.0
448
448
  */
449
449
  export const CSRF_HOOK_MARKER = Symbol.for("daloyjs.middleware.csrf");
450
+ /**
451
+ * Marker stamped on a {@link Hooks} bundle that authenticates the request —
452
+ * i.e. rejects callers without valid credentials. Built-in auth middlewares
453
+ * (`bearerAuth`, `basicAuth`, `jwk`, `httpSignatureAuth`, `clientCertAuth`)
454
+ * stamp it so the framework's route-auth boot guard can confirm that any route
455
+ * declaring an `auth:` requirement is actually enforced by a hook rather than
456
+ * being silently public (a `security` entry in the OpenAPI doc with no runtime
457
+ * check). Wrap a custom authentication hook with {@link markAuthHook} to opt it
458
+ * into the same guard.
459
+ *
460
+ * @since 1.0.0
461
+ */
462
+ export const AUTH_HOOK_MARKER = Symbol.for("daloyjs.auth.hook");
463
+ /**
464
+ * Mark a custom {@link Hooks} bundle as performing request authentication.
465
+ *
466
+ * Use this when you authenticate with your own hook (not one of the built-in
467
+ * auth middlewares) but still declare `auth:` on the protected routes: it
468
+ * stamps {@link AUTH_HOOK_MARKER} so the production route-auth boot guard treats
469
+ * those routes as enforced. It is also the correct escape hatch when
470
+ * authentication is performed by an upstream gateway/mesh and the in-app hook
471
+ * is intentionally a pass-through.
472
+ *
473
+ * @param hooks - The hook bundle to mark (mutated in place and returned).
474
+ * @returns The same `hooks` object, now stamped as an auth hook.
475
+ *
476
+ * @example
477
+ * ```ts
478
+ * app.use(markAuthHook({
479
+ * async beforeHandle(ctx) {
480
+ * if (!(await myVerify(ctx.request))) throw new UnauthorizedError();
481
+ * },
482
+ * }));
483
+ * ```
484
+ *
485
+ * @since 1.0.0
486
+ */
487
+ export function markAuthHook(hooks) {
488
+ hooks[AUTH_HOOK_MARKER] = true;
489
+ return hooks;
490
+ }
450
491
  /**
451
492
  * Cross-Origin Resource Sharing (CORS) middleware. Handles both preflight
452
493
  * (`OPTIONS`) and actual requests, attaching the correct
@@ -838,7 +879,7 @@ export function bearerAuth(opts) {
838
879
  if (/["\r\n\0]/.test(realm)) {
839
880
  throw new Error("bearerAuth(): realm must not contain quotes, CR, LF, or NUL bytes.");
840
881
  }
841
- return {
882
+ return markAuthHook({
842
883
  async beforeHandle(ctx) {
843
884
  const h = ctx.request.headers.get("authorization") ?? "";
844
885
  const m = /^Bearer\s+(.+)$/i.exec(h);
@@ -866,7 +907,7 @@ export function bearerAuth(opts) {
866
907
  }
867
908
  return undefined;
868
909
  },
869
- };
910
+ });
870
911
  }
871
912
  const CSRF_STATE_TOKEN = "csrfToken";
872
913
  const CSRF_STATE_ISSUED = "__csrfIssued";
@@ -1115,7 +1156,7 @@ export function basicAuth(opts) {
1115
1156
  if (!Number.isInteger(maxBytes) || maxBytes < 1) {
1116
1157
  throw new Error("basicAuth(): maxCredentialBytes must be a positive integer.");
1117
1158
  }
1118
- return {
1159
+ return markAuthHook({
1119
1160
  async beforeHandle(ctx) {
1120
1161
  const header = ctx.request.headers.get("authorization") ?? "";
1121
1162
  const match = BASIC_AUTH_TOKEN_RE.exec(header);
@@ -1134,7 +1175,7 @@ export function basicAuth(opts) {
1134
1175
  }
1135
1176
  return undefined;
1136
1177
  },
1137
- };
1178
+ });
1138
1179
  }
1139
1180
  // ---------- requireScopes ----------
1140
1181
  /**
package/dist/mtls.js CHANGED
@@ -347,7 +347,7 @@ export function clientCertAuth(opts = {}) {
347
347
  return certFromHeaders(ctx.request, headerConfig);
348
348
  return undefined;
349
349
  });
350
- return {
350
+ const authHooks = {
351
351
  async beforeHandle(ctx) {
352
352
  const cert = resolve(ctx);
353
353
  if (!cert) {
@@ -386,6 +386,11 @@ export function clientCertAuth(opts = {}) {
386
386
  return undefined;
387
387
  },
388
388
  };
389
+ // Same global symbol as middleware's AUTH_HOOK_MARKER (stamped inline to keep
390
+ // the middleware module out of this bundle): lets the route-auth boot guard
391
+ // recognize that a route declaring `auth:` is actually enforced here.
392
+ authHooks[Symbol.for("daloyjs.auth.hook")] = true;
393
+ return authHooks;
389
394
  }
390
395
  function assertHeaderConfig(cfg) {
391
396
  if (cfg.format === "xfcc")
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:e297e402-67fc-54e1-a292-02441ebb5d71",
4
+ "serialNumber": "urn:uuid:63bae5ad-16c4-5db2-9929-3188903cb4f9",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-07-03T11:51:00.910Z",
7
+ "timestamp": "2026-07-04T14:31:15.314Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "1.0.0-rc.0"
12
+ "version": "1.0.0-rc.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@1.0.0-rc.0",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.1",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.0.0-rc.0",
24
+ "version": "1.0.0-rc.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@1.0.0-rc.0",
26
+ "purl": "pkg:npm/@daloyjs/core@1.0.0-rc.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-1.0.0-rc.0",
49
+ "tagId": "swidtag--daloyjs-core-1.0.0-rc.1",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.0.0-rc.0",
51
+ "version": "1.0.0-rc.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@1.0.0-rc.0",
60
+ "ref": "pkg:npm/@daloyjs/core@1.0.0-rc.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-1.0.0-rc.0",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.0-e297e402-67fc-54e1-a292-02441ebb5d71",
5
+ "name": "@daloyjs/core-1.0.0-rc.1",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.1-63bae5ad-16c4-5db2-9929-3188903cb4f9",
7
7
  "creationInfo": {
8
- "created": "2026-07-03T11:51:00.910Z",
8
+ "created": "2026-07-04T14:31:15.314Z",
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": "1.0.0-rc.0",
19
+ "versionInfo": "1.0.0-rc.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@1.0.0-rc.0"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.1"
31
31
  }
32
32
  ]
33
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.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": {