@daloyjs/core 0.39.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -438,6 +438,8 @@ if (!report.ok) process.exit(1);
438
438
 
439
439
  The contract runner verifies that declared examples actually match their schemas, flags duplicate/missing operationIds, dead routes, and accidental body schemas on safe methods.
440
440
 
441
+ Gate it in CI two ways: `daloy inspect --check <entry>` exits non-zero on any error-level issue, or assert `report.ok` inside your test suite. **Every `create-daloy` template ships a contract-gate test** (`tests/contract.test.ts`, `tests/contract_test.ts` on Deno) wired into its `test` task, so scaffolded projects fail CI on a broken contract out of the box. For a localhost-only gate that runs before code leaves your machine, each template also ships an opt-in `pre-push` hook (`.githooks/pre-push`, enabled with `hooks:install` which points `core.hooksPath` at it); it runs `daloy inspect --check` on every `git push` and is bypassable with `git push --no-verify`.
442
+
441
443
  ---
442
444
 
443
445
  ## Plugin encapsulation (Fastify-style)
@@ -508,7 +510,7 @@ DaloyJS is in **public preview** (`0.x`). The public API may still change betwee
508
510
  - RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
509
511
  - 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`.
510
512
  - 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.
511
- - In-process test client (`app.request()`), contract-test runner, in-process typed client, and Hey API codegen via `pnpm gen`.
513
+ - 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`.
512
514
 
513
515
  ### Runtimes and deployment
514
516
 
@@ -566,6 +568,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
566
568
  - `paginationQuery()` / `encodeCursor()` / `decodeCursor()` / `buildPageLinks()` / `buildLinkHeader()` cursor-pagination helpers at `@daloyjs/core/pagination`: opaque base64url cursors (length-capped, prototype-pollution-safe decode → `400` on tamper), RFC 8288 `Link` header emission with CRLF / header-injection guards, and a Standard Schema that validates `cursor`/`limit` and auto-wires both into the OpenAPI spec + typed client via `toJSONSchema()`.
567
569
  - `app.metrics()` + `MetricsRegistry` / `httpMetrics()` Prometheus / OpenMetrics exposition at `@daloyjs/core/metrics`: dependency-free counters / gauges / histograms, RED instrumentation (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`) plus process gauges, exposition-injection-safe name/label validation, a per-metric cardinality cap, and an opt-in `/metrics` route with the same hardened posture as `app.healthcheck()` (bearer token + `timingSafeEqual`, per-IP rate limit, refuse-to-boot unauthenticated in production). The repo ships an `examples/observability/` Docker Compose stack that starts a pre-configured Prometheus + Grafana pair (with an auto-provisioned RED + heatmap dashboard) against any local app via `docker compose -f examples/observability/docker-compose.yml up`.
568
570
  - `otelTracing()` OpenTelemetry-compatible distributed tracing at `@daloyjs/core/tracing`: a dependency-free `Hooks` bundle that opens one `SERVER` span per request, attaches HTTP semantic-convention attributes (`http.request.method`, `url.path`, `server.address` / `server.port`, `http.response.status_code`, …), records exceptions + escalates `5xx` to `ERROR`, guarantees a single `span.end()`, and exposes the live span on `ctx.state.otelSpan`. Bring any tracer matching the small `TracingTracer` interface (the real `@opentelemetry/api` SDK on Node, or a custom exporter on Workers/Deno) plus your own propagator via `contextFromRequest` for `traceparent` continuation — no OTel SDK is forced into your install. The `examples/observability/` stack also runs **Jaeger**, and `examples/otel-tracing-demo.ts` ships a ~120-line dependency-free OTLP/HTTP exporter that streams spans straight to it.
571
+ - `tenancy()` secure-by-default multitenancy at `@daloyjs/core/tenancy`: a dependency-free `Hooks` bundle that resolves the calling tenant once per request and exposes it on `ctx.state.tenant`. Pluggable resolution (`tenantFromSubdomain` PSL-aware, `tenantFromHeader`, `tenantFromPathPrefix`, `tenantFromClaim`, or a custom `(ctx) => string`, tried in array order). **Refuse-unresolved by default** (no ambient "default" tenant leak), **format-validated ids** (rejects key/log-injection + cache-poisoning payloads before they reach a key), **no-enumeration `404`** for unknown tenants, and **host-spoof-safe** subdomain resolution. A `tenantScope()` key helper drops straight into `rateLimit` `keyGenerator` and `concurrencyLimit` / `idempotency` / `responseCache` `scope` to partition each per tenant (CWE-524 cross-tenant cache defense). Runnable `examples/multitenancy-demo.ts`.
569
572
  - `resilientFetch()` + `CircuitBreaker` outbound resilience at `@daloyjs/core/fetch-resilience`: a dependency-free circuit breaker (`closed → open → half-open`), retry-with-backoff (exponential + full jitter, idempotent-method/transient-status scoped, honours `Retry-After`), and a per-call timeout (`AbortController` → `FetchTimeoutError`) designed to layer **on top of** `fetchGuard()` — an `SsrfBlockedError` is a terminal refusal that is never retried and never trips the breaker, so SSRF protection stays intact under the resilience layer.
570
573
  - `createWebhookSender()` + `MemoryWebhookDeadLetterSink` outbound webhook delivery at `@daloyjs/core/webhook-delivery`: the outbound counterpart to `verifyWebhookSignature()` — timestamped HMAC-signed `POST`s (`webhook-id` / `webhook-timestamp` / `webhook-signature`, computed over `"<timestamp>.<body>"` and reused across retries for safe deduping), bounded retry-with-backoff (transient-status + network scoped, honours `Retry-After`), per-attempt timeout, and dead-letter semantics. Transport defaults to `fetchGuard()`, so a subscriber URL pointing at cloud metadata or a private range is refused with a terminal `SsrfBlockedError` (never retried, dead-lettered once). Zero runtime dependencies.
571
574
  - `app.cron()` + standalone `Scheduler` in-process scheduled tasks at `@daloyjs/core/scheduler`: a queue-agnostic schedule primitive for periodic housekeeping (cache sweeps, token refresh, reconciliation). Fixed intervals or 5-field cron expressions (lists / ranges / steps / named months & days / `@hourly`–`@yearly` aliases / optional IANA `timeZone`), arithmetic cron parsing (no backtracking regex), fixed-rate **single-flight** (overlapping ticks are skipped, never run concurrently), per-run `timeoutMs` with `AbortSignal`, and graceful-shutdown integration (stop arming → await in-flight → abort after grace). Timers are `unref`'d. `parseCron()` / `nextCronRun()` exported standalone. Zero runtime dependencies.
@@ -596,7 +599,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
596
599
  - an Origin policy (`allowedOrigins: "same-origin"` / `string[]` / predicate) or `acknowledgeCrossOriginUpgrade: true`.
597
600
 
598
601
  This closes the Cross-Site WebSocket Hijacking (CSWSH) class of bug — Storybook's [CVE-2026-27148](https://www.aikido.dev/blog/storybooks-websockets-attack) is the representative case: cookie auth alone does not stop a malicious site from opening an authenticated WS handshake from a victim's browser. The Origin check runs **before** `beforeUpgrade` in both adapters.
599
- - Contract-first **AsyncAPI 3.0** generation for `app.ws()` surfaces via `@daloyjs/core/asyncapi` (`generateAsyncAPI()` / `asyncapiToYAML()`) and `daloy inspect --asyncapi`. Each route becomes a channel (address + path params) with a `receive` operation for inbound client messages and an optional `send` operation for outbound messages, described via an optional handler `meta` block (`summary` / `description` / `tags` / `send` / `receive` / `operationId`).
602
+ - Contract-first **AsyncAPI 3.0** generation for `app.ws()` surfaces via `@daloyjs/core/asyncapi` (`generateAsyncAPI()` / `asyncapiToYAML()`) and `daloy inspect --asyncapi`. Each route becomes a channel (address + path params) with a `receive` operation for inbound client messages and an optional `send` operation for outbound messages, described via an optional handler `meta` block (`summary` / `description` / `tags` / `send` / `receive` / `operationId`). Set `asyncapi: true` (mirroring `docs: true`) to **auto-mount an interactive AsyncAPI UI** at `/asyncapi` plus `/asyncapi.json` + `/asyncapi.yaml` — the WebSocket counterpart to the Scalar / Swagger / Redoc OpenAPI viewers, served from a CDN with the same SRI + strict-CSP hardening.
600
603
 
601
604
  ### Lifecycle and ops
602
605
 
@@ -611,6 +614,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
611
614
  - Adapter-independent `ConnInfo` abstraction: `getConnInfo()`, lazy `ctx.remoteAddress`, `ctx.remotePort`.
612
615
  - `daloy doctor` production-posture validator with `--audit-secrets` and `--audit-defaults` (flags wildcard-credentials CORS, > 24h CORS `maxAge`, > 25 MiB blanket body limits, zero `idleTimeoutMs` in production, and unsafe opt-ins).
613
616
  - PSL-aware `subdomains()` helper with a `≤ 90 days` snapshot guard.
617
+ - Secure-by-default multitenancy via `tenancy()` + `tenantScope()`: pluggable tenant resolution (subdomain / header / path / JWT claim / custom), refuse-unresolved + format-validated ids + no-enumeration `404` by default, and a key helper that partitions `rateLimit` / `concurrencyLimit` / `idempotency` / `responseCache` per tenant.
614
618
  - `defineDependency()` typed-DI helper with per-request deduplication.
615
619
  - Scheme-aware `ctx.state.auth` typed contract; named, optionally seeded stateful plugins.
616
620
 
@@ -565,6 +565,17 @@ class NodeWebSocketConnection {
565
565
  this._fireClose(WS_CLOSE_CODE.ABNORMAL_CLOSURE, "");
566
566
  }
567
567
  _handleSocketError(err) {
568
+ // A socket error arriving after the connection is already closed — e.g. the
569
+ // peer resets the TCP connection right after the close handshake, or a
570
+ // terminate() raced the OS — is teardown noise. Surfacing it to the
571
+ // handler's error() callback would fire a lifecycle event *after* close(),
572
+ // breaking the "no events after close" contract and risking double cleanup.
573
+ // Swallow it and just make sure the socket is gone.
574
+ if (this.closeHandled || this.readyState === WS_READY_STATE.CLOSED) {
575
+ this.readyState = WS_READY_STATE.CLOSED;
576
+ this.socket.destroy();
577
+ return;
578
+ }
568
579
  this._invokeError(err);
569
580
  this.readyState = WS_READY_STATE.CLOSED;
570
581
  this.socket.destroy();
package/dist/app.d.ts CHANGED
@@ -2,7 +2,8 @@ import { WebSocketRegistry, type WebSocketHandler } from "./websocket.js";
2
2
  import { type Logger } from "./logger.js";
3
3
  import type { HttpMethod, Hooks, PathString, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
4
4
  import { type OpenAPIInfo, type OpenAPIOptions } from "./openapi.js";
5
- import { type DocsAssetOptions, type DocsContentSecurityPolicyOptions, type RedocConfiguration, type ScalarReferenceConfiguration } from "./docs.js";
5
+ import { type DocsAssetOptions, type DocsContentSecurityPolicyOptions, type RedocConfiguration, type ScalarJsonValue, type ScalarReferenceConfiguration } from "./docs.js";
6
+ import { type AsyncAPIServer } from "./asyncapi.js";
6
7
  import { type SecureHeadersOptions } from "./middleware.js";
7
8
  import { type LoadSheddingOptions } from "./load-shedding.js";
8
9
  import { MetricsRegistry, type HttpMetricsOptions } from "./metrics.js";
@@ -320,6 +321,24 @@ export interface AppOptions {
320
321
  * @since 0.3.0
321
322
  */
322
323
  docs?: boolean | "auto" | DocsRouteOptions;
324
+ /**
325
+ * Enable the built-in AsyncAPI surface for your `app.ws()` channels — the
326
+ * WebSocket counterpart to {@link AppOptions.docs}. Mirrors the same modes:
327
+ *
328
+ * - `false` (default) — never mount.
329
+ * - `true` — always mount `/asyncapi.json`, `/asyncapi.yaml`, and an
330
+ * interactive `/asyncapi` UI (the official AsyncAPI React component, loaded
331
+ * from a CDN exactly like the Scalar / Swagger / Redoc OpenAPI viewers).
332
+ * - `"auto"` — mount everywhere except production.
333
+ * - object form — {@link AsyncAPIRouteOptions} for full control.
334
+ *
335
+ * The document is generated lazily per request, so `app.ws()` routes
336
+ * registered after construction are included. With no WebSocket routes the
337
+ * document simply has no channels.
338
+ *
339
+ * @since 0.42.0
340
+ */
341
+ asyncapi?: boolean | "auto" | AsyncAPIRouteOptions;
323
342
  }
324
343
  /**
325
344
  * Subset of {@link OpenAPIOptions} accepted by `new App({ openapi })`. All
@@ -395,6 +414,56 @@ export interface DocsRouteOptions {
395
414
  */
396
415
  assets?: DocsAssetOptions;
397
416
  }
417
+ /**
418
+ * Options for the auto-mounted AsyncAPI surface — the WebSocket-channel
419
+ * counterpart to {@link DocsRouteOptions}. Enabled via {@link AppOptions.asyncapi}.
420
+ *
421
+ * @since 0.42.0
422
+ */
423
+ export interface AsyncAPIRouteOptions {
424
+ /** Path the interactive AsyncAPI UI is served from. Default `"/asyncapi"`. */
425
+ path?: PathString;
426
+ /** Path the AsyncAPI 3.0 JSON document is served from. Default `"/asyncapi.json"`. */
427
+ jsonPath?: PathString;
428
+ /**
429
+ * Path the AsyncAPI 3.0 YAML document is served from. Default
430
+ * `"/asyncapi.yaml"`. Set to `false` to disable the YAML route.
431
+ */
432
+ yamlPath?: PathString | false;
433
+ /** Page `<title>`. Defaults to the resolved AsyncAPI `info.title`. */
434
+ title?: string;
435
+ /**
436
+ * Named AsyncAPI servers (`{ production: { host, protocol } }`). Defaults to
437
+ * the `openapi.servers` mapping when omitted, else no servers.
438
+ */
439
+ servers?: Record<string, AsyncAPIServer>;
440
+ /**
441
+ * Forwarded as the `config` object to `AsyncApiStandalone.render`. Defaults
442
+ * to showing the sidebar and inline errors.
443
+ */
444
+ configuration?: {
445
+ [key: string]: ScalarJsonValue | undefined;
446
+ };
447
+ /**
448
+ * Force the routes to mount regardless of `NODE_ENV`. When `"auto"` (default
449
+ * for the object form), skips mounting in production — same semantics as
450
+ * {@link DocsRouteOptions.enabled}.
451
+ */
452
+ enabled?: boolean | "auto";
453
+ /**
454
+ * Tags attached to the auto-mounted operations in the generated OpenAPI
455
+ * spec. Default: `["AsyncAPI"]`. Pass an empty array to omit tags.
456
+ */
457
+ tags?: string[];
458
+ /** Override the Content-Security-Policy applied to the UI HTML response. */
459
+ csp?: DocsContentSecurityPolicyOptions;
460
+ /**
461
+ * Override the UI asset URLs and pin SRI hashes (see {@link DocsAssetOptions}).
462
+ * The relevant fields are `asyncapiScriptUrl` / `asyncapiScriptIntegrity` /
463
+ * `asyncapiStyleUrl` / `asyncapiStyleIntegrity`.
464
+ */
465
+ assets?: DocsAssetOptions;
466
+ }
398
467
  /** Information passed to {@link App.onPluginInstalled} listeners. */
399
468
  export interface PluginInstalledEvent {
400
469
  /** Name of the plugin (only set when registered with `{ name, register }`). */
@@ -784,6 +853,14 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
784
853
  * dashboards see the misconfiguration without flooding on every retry.
785
854
  */
786
855
  private trustProxyWarned;
856
+ /**
857
+ * One-shot guard for the development-mode warning about `2xx` responses
858
+ * that declare no body schema (OWASP API3 output filtering is absent
859
+ * there). Flipped on the first {@link App.fetch} so all routes are
860
+ * registered by the time the scan runs, and so the scan never repeats on
861
+ * the hot path.
862
+ */
863
+ private responseBodySchemaAuditDone;
787
864
  /**
788
865
  * Cached merge of `options.hooks` only. Used on the cold 404/405 path
789
866
  * and as the baseline for cross-origin guard decisions when no route
@@ -949,6 +1026,24 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
949
1026
  */
950
1027
  private maybeMountDocs;
951
1028
  private mountDocs;
1029
+ /**
1030
+ * Resolve {@link AppOptions.asyncapi} and, when enabled, mount the AsyncAPI
1031
+ * JSON / YAML / UI routes. Called once during construction; the document is
1032
+ * generated lazily so `app.ws()` routes registered afterwards are included.
1033
+ */
1034
+ private maybeMountAsyncAPI;
1035
+ /**
1036
+ * Register the `/asyncapi.json`, `/asyncapi.yaml`, and `/asyncapi` (UI)
1037
+ * routes. The WebSocket-channel counterpart to {@link App.mountDocs} — same
1038
+ * lazy-generation, CDN-hosted-UI, SRI/CSP-hardened posture.
1039
+ */
1040
+ private mountAsyncAPI;
1041
+ /**
1042
+ * Best-effort conversion of the OpenAPI `servers` array into AsyncAPI's
1043
+ * name-keyed server map, so an app that already declares HTTP servers gets
1044
+ * sensible AsyncAPI servers for free. Returns `undefined` when none apply.
1045
+ */
1046
+ private asyncapiServersFromOpenAPI;
952
1047
  /**
953
1048
  * Register a single route on the application.
954
1049
  *
@@ -1324,6 +1419,15 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1324
1419
  *
1325
1420
  * @returns Array of one {@link IntrospectedRoute} per registered route.
1326
1421
  */
1422
+ /**
1423
+ * Emit a one-time development warning when any route declares a `2xx`
1424
+ * response without a body schema, because response-field stripping
1425
+ * (OWASP API3) does not run for those responses — a handler returning
1426
+ * undeclared fields would leak them. Silent in production (operators run
1427
+ * `daloy doctor` in CI for the same finding) and when
1428
+ * `secureDefaults: false`. See {@link findRoutesMissingResponseBodySchema}.
1429
+ */
1430
+ private warnMissingResponseBodySchemas;
1327
1431
  introspect(): IntrospectedRoute[];
1328
1432
  /**
1329
1433
  * Begin graceful shutdown.
@@ -1369,6 +1473,32 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
1369
1473
  * @internal
1370
1474
  */
1371
1475
  export declare function topoSortExtensions(exts: ReadonlyArray<PluginExtension>): PluginExtension[];
1476
+ /**
1477
+ * Identify registered routes whose successful (`2xx`, excluding the
1478
+ * body-less `204`/`205`) responses declare no `body` schema.
1479
+ *
1480
+ * Response-body validation — and the OWASP API3 ("Broken Object Property
1481
+ * Level Authorization") output filtering that strips fields a handler
1482
+ * returns but the contract does not declare — only runs when a response
1483
+ * `body` schema is present. A `2xx` response without one therefore ships
1484
+ * whatever the handler returns verbatim: a stray `passwordHash` or a
1485
+ * spread ORM row would leak. This helper surfaces those routes so the gap
1486
+ * is visible rather than silent.
1487
+ *
1488
+ * It powers both the `daloy doctor` `audit.response.bodySchema` finding and
1489
+ * the development-mode boot warning emitted on the first request. The result
1490
+ * is advisory — a route may legitimately return no body — so callers treat
1491
+ * it as a `warn`, never a hard error.
1492
+ *
1493
+ * @param routes - Route definitions to inspect (typically `app.routes`).
1494
+ * @returns One entry per offending route with the affected `2xx` status codes.
1495
+ * @since 0.40.0
1496
+ */
1497
+ export declare function findRoutesMissingResponseBodySchema(routes: readonly Pick<RouteDefinition<any, any, any, any>, "method" | "path" | "responses">[]): Array<{
1498
+ method: string;
1499
+ path: string;
1500
+ statuses: number[];
1501
+ }>;
1372
1502
  /**
1373
1503
  * Factory alias for `new App(options)`. Lets callers who prefer a
1374
1504
  * functional style (or who avoid `new`) write:
package/dist/app.js CHANGED
@@ -5,7 +5,8 @@ import { validate } from "./schema.js";
5
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
- import { docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
8
+ import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
9
+ import { generateAsyncAPI, asyncapiToYAML, } from "./asyncapi.js";
9
10
  import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
10
11
  import { COMPRESSION_HOOK_MARKER } from "./compression.js";
11
12
  import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
@@ -267,6 +268,14 @@ export class App {
267
268
  * dashboards see the misconfiguration without flooding on every retry.
268
269
  */
269
270
  trustProxyWarned = false;
271
+ /**
272
+ * One-shot guard for the development-mode warning about `2xx` responses
273
+ * that declare no body schema (OWASP API3 output filtering is absent
274
+ * there). Flipped on the first {@link App.fetch} so all routes are
275
+ * registered by the time the scan runs, and so the scan never repeats on
276
+ * the hot path.
277
+ */
278
+ responseBodySchemaAuditDone = false;
270
279
  /**
271
280
  * Cached merge of `options.hooks` only. Used on the cold 404/405 path
272
281
  * and as the baseline for cross-origin guard decisions when no route
@@ -312,6 +321,7 @@ export class App {
312
321
  this.installSecureDefaults();
313
322
  this.maybeInstallCrashHandlers();
314
323
  this.maybeMountDocs();
324
+ this.maybeMountAsyncAPI();
315
325
  }
316
326
  /**
317
327
  * Validate {@link AppOptions.disconnectStatusCode}.
@@ -881,6 +891,146 @@ export class App {
881
891
  },
882
892
  });
883
893
  }
894
+ /**
895
+ * Resolve {@link AppOptions.asyncapi} and, when enabled, mount the AsyncAPI
896
+ * JSON / YAML / UI routes. Called once during construction; the document is
897
+ * generated lazily so `app.ws()` routes registered afterwards are included.
898
+ */
899
+ maybeMountAsyncAPI() {
900
+ const raw = this.options.asyncapi;
901
+ if (raw === undefined || raw === false)
902
+ return;
903
+ let resolvedOpts;
904
+ if (raw === true) {
905
+ resolvedOpts = {};
906
+ }
907
+ else if (raw === "auto") {
908
+ if (this.isProduction())
909
+ return;
910
+ resolvedOpts = {};
911
+ }
912
+ else {
913
+ const enabled = raw.enabled ?? true;
914
+ if (enabled === false)
915
+ return;
916
+ if (enabled === "auto" && this.isProduction())
917
+ return;
918
+ resolvedOpts = raw;
919
+ }
920
+ this.mountAsyncAPI(resolvedOpts);
921
+ }
922
+ /**
923
+ * Register the `/asyncapi.json`, `/asyncapi.yaml`, and `/asyncapi` (UI)
924
+ * routes. The WebSocket-channel counterpart to {@link App.mountDocs} — same
925
+ * lazy-generation, CDN-hosted-UI, SRI/CSP-hardened posture.
926
+ */
927
+ mountAsyncAPI(opts) {
928
+ const jsonPath = (opts.jsonPath ?? "/asyncapi.json");
929
+ const yamlPath = opts.yamlPath === false
930
+ ? null
931
+ : (opts.yamlPath ?? "/asyncapi.yaml");
932
+ const uiPath = (opts.path ?? "/asyncapi");
933
+ const tags = opts.tags ?? ["AsyncAPI"];
934
+ const resolveInfo = async () => {
935
+ const fromOpenapi = this.options.openapi?.info ?? {};
936
+ const fromPkg = await readHostPackageJsonInfo();
937
+ const title = fromOpenapi.title ?? this.options.title ?? fromPkg.title ?? "DaloyJS API";
938
+ const version = fromOpenapi.version ?? this.options.version ?? fromPkg.version ?? "0.0.0";
939
+ return { title, version };
940
+ };
941
+ const servers = opts.servers ?? this.asyncapiServersFromOpenAPI();
942
+ const generate = async () => generateAsyncAPI(this, {
943
+ info: await resolveInfo(),
944
+ ...(servers ? { servers } : {}),
945
+ });
946
+ this.route({
947
+ method: "GET",
948
+ path: jsonPath,
949
+ operationId: "getAsyncAPIDocument",
950
+ ...(tags.length ? { tags } : {}),
951
+ summary: "AsyncAPI 3.0 document",
952
+ responses: {
953
+ 200: { description: "AsyncAPI 3.0 document for this application's WebSocket channels." },
954
+ },
955
+ handler: async () => ({ status: 200, body: await generate() }),
956
+ });
957
+ if (yamlPath) {
958
+ this.route({
959
+ method: "GET",
960
+ path: yamlPath,
961
+ operationId: "getAsyncAPIDocumentYaml",
962
+ ...(tags.length ? { tags } : {}),
963
+ summary: "AsyncAPI 3.0 document (YAML)",
964
+ responses: {
965
+ 200: { description: "AsyncAPI 3.0 document for this application, in YAML." },
966
+ },
967
+ handler: async () => ({
968
+ status: 200,
969
+ body: asyncapiToYAML(await generate()),
970
+ headers: {
971
+ "content-type": "text/yaml; charset=utf-8",
972
+ "content-disposition": "inline",
973
+ "x-content-type-options": "nosniff",
974
+ },
975
+ }),
976
+ });
977
+ }
978
+ const uiCsp = docsContentSecurityPolicy(opts.csp);
979
+ this.route({
980
+ method: "GET",
981
+ path: uiPath,
982
+ operationId: "getAsyncAPIUI",
983
+ ...(tags.length ? { tags } : {}),
984
+ summary: "Interactive AsyncAPI reference",
985
+ responses: {
986
+ 200: { description: "Interactive AsyncAPI documentation UI." },
987
+ },
988
+ handler: async () => {
989
+ const title = opts.title ?? (await resolveInfo()).title;
990
+ const html = asyncapiHtml({
991
+ specUrl: jsonPath,
992
+ title,
993
+ ...(opts.configuration ? { configuration: opts.configuration } : {}),
994
+ ...(opts.assets ? { assets: opts.assets } : {}),
995
+ });
996
+ return {
997
+ status: 200,
998
+ body: html,
999
+ headers: {
1000
+ "content-type": "text/html; charset=utf-8",
1001
+ "content-security-policy": uiCsp,
1002
+ "x-content-type-options": "nosniff",
1003
+ "referrer-policy": "no-referrer",
1004
+ },
1005
+ };
1006
+ },
1007
+ });
1008
+ }
1009
+ /**
1010
+ * Best-effort conversion of the OpenAPI `servers` array into AsyncAPI's
1011
+ * name-keyed server map, so an app that already declares HTTP servers gets
1012
+ * sensible AsyncAPI servers for free. Returns `undefined` when none apply.
1013
+ */
1014
+ asyncapiServersFromOpenAPI() {
1015
+ const servers = this.options.openapi?.servers;
1016
+ if (!servers || servers.length === 0)
1017
+ return undefined;
1018
+ const out = {};
1019
+ servers.forEach((srv, i) => {
1020
+ try {
1021
+ const u = new URL(srv.url);
1022
+ const host = u.host || u.pathname;
1023
+ // Map http(s) → ws(s); leave other schemes as-is minus the trailing ":".
1024
+ const scheme = u.protocol.replace(/:$/, "");
1025
+ const protocol = scheme === "https" ? "wss" : scheme === "http" ? "ws" : scheme;
1026
+ out[`server${i + 1}`] = { host, protocol };
1027
+ }
1028
+ catch {
1029
+ /* skip malformed server URLs */
1030
+ }
1031
+ });
1032
+ return Object.keys(out).length > 0 ? out : undefined;
1033
+ }
884
1034
  // ---------- registration ----------
885
1035
  /**
886
1036
  * Register a single route on the application.
@@ -1772,6 +1922,10 @@ export class App {
1772
1922
  * to RFC 9457 `application/problem+json` automatically.
1773
1923
  */
1774
1924
  fetch = async (request) => {
1925
+ if (!this.responseBodySchemaAuditDone) {
1926
+ this.responseBodySchemaAuditDone = true;
1927
+ this.warnMissingResponseBodySchemas();
1928
+ }
1775
1929
  const response = await this.dispatch(request);
1776
1930
  // In-flight responses that finish during draining advertise
1777
1931
  // `Connection: close` so HTTP/1.1 load balancers stop re-using the
@@ -1915,6 +2069,9 @@ export class App {
1915
2069
  body: undefined,
1916
2070
  state: { ...this.decorations, requestId, log },
1917
2071
  set: { headers: new Headers() },
2072
+ // Cast through `unknown`: this is a deliberately minimal bootstrap
2073
+ // context for the cold 404 path (no user state populated yet), so
2074
+ // it must compile even when a consumer augments `AppState`.
1918
2075
  };
1919
2076
  ctx.set.headers.set("x-request-id", requestId);
1920
2077
  }
@@ -1922,6 +2079,8 @@ export class App {
1922
2079
  if (method === "OPTIONS") {
1923
2080
  // Synthesize a preflight: let global hooks (e.g. CORS) intercept;
1924
2081
  // otherwise return 204 with Allow header.
2082
+ // Cast through `unknown` so the synthetic preflight context still
2083
+ // compiles when a consumer augments `AppState` (no user state here).
1925
2084
  const synthCtx = {
1926
2085
  request,
1927
2086
  params: {},
@@ -2127,6 +2286,31 @@ export class App {
2127
2286
  *
2128
2287
  * @returns Array of one {@link IntrospectedRoute} per registered route.
2129
2288
  */
2289
+ /**
2290
+ * Emit a one-time development warning when any route declares a `2xx`
2291
+ * response without a body schema, because response-field stripping
2292
+ * (OWASP API3) does not run for those responses — a handler returning
2293
+ * undeclared fields would leak them. Silent in production (operators run
2294
+ * `daloy doctor` in CI for the same finding) and when
2295
+ * `secureDefaults: false`. See {@link findRoutesMissingResponseBodySchema}.
2296
+ */
2297
+ warnMissingResponseBodySchemas() {
2298
+ if (this.isProduction())
2299
+ return;
2300
+ if (this.options.secureDefaults === false)
2301
+ return;
2302
+ const offending = findRoutesMissingResponseBodySchema(this.routes);
2303
+ if (offending.length === 0)
2304
+ return;
2305
+ this.log.warn({
2306
+ event: "security.response.bodySchemaMissing",
2307
+ count: offending.length,
2308
+ routes: offending.slice(0, 20),
2309
+ }, `${offending.length} route(s) declare a 2xx response with no body schema; ` +
2310
+ "response field-level stripping (OWASP API3) is not applied there, so a handler that " +
2311
+ "returns undeclared fields will leak them. Declare a response body schema, or ignore if " +
2312
+ "the route intentionally returns no body. Run `daloy doctor` to list them.");
2313
+ }
2130
2314
  introspect() {
2131
2315
  return this.routes.map((r) => {
2132
2316
  const route = {
@@ -2628,6 +2812,51 @@ function copyContextHeaders(ctx, res) {
2628
2812
  function hasRequestSchema(request, key) {
2629
2813
  return !!request && !!request[key];
2630
2814
  }
2815
+ /**
2816
+ * Identify registered routes whose successful (`2xx`, excluding the
2817
+ * body-less `204`/`205`) responses declare no `body` schema.
2818
+ *
2819
+ * Response-body validation — and the OWASP API3 ("Broken Object Property
2820
+ * Level Authorization") output filtering that strips fields a handler
2821
+ * returns but the contract does not declare — only runs when a response
2822
+ * `body` schema is present. A `2xx` response without one therefore ships
2823
+ * whatever the handler returns verbatim: a stray `passwordHash` or a
2824
+ * spread ORM row would leak. This helper surfaces those routes so the gap
2825
+ * is visible rather than silent.
2826
+ *
2827
+ * It powers both the `daloy doctor` `audit.response.bodySchema` finding and
2828
+ * the development-mode boot warning emitted on the first request. The result
2829
+ * is advisory — a route may legitimately return no body — so callers treat
2830
+ * it as a `warn`, never a hard error.
2831
+ *
2832
+ * @param routes - Route definitions to inspect (typically `app.routes`).
2833
+ * @returns One entry per offending route with the affected `2xx` status codes.
2834
+ * @since 0.40.0
2835
+ */
2836
+ export function findRoutesMissingResponseBodySchema(routes) {
2837
+ const offending = [];
2838
+ for (const route of routes) {
2839
+ const statuses = [];
2840
+ const responses = route.responses;
2841
+ for (const key of Object.keys(responses)) {
2842
+ const status = Number(key);
2843
+ // Only successful responses can carry an over-exposing body, and 204/205
2844
+ // are body-less by HTTP semantics so they are never flagged.
2845
+ if (!Number.isInteger(status) || status < 200 || status > 299)
2846
+ continue;
2847
+ if (status === 204 || status === 205)
2848
+ continue;
2849
+ const spec = responses[status];
2850
+ if (spec && spec.body === undefined)
2851
+ statuses.push(status);
2852
+ }
2853
+ if (statuses.length > 0) {
2854
+ statuses.sort((a, b) => a - b);
2855
+ offending.push({ method: route.method, path: route.path, statuses });
2856
+ }
2857
+ }
2858
+ return offending;
2859
+ }
2631
2860
  /**
2632
2861
  * Stable-shape per-request context. All fields are initialised in fixed
2633
2862
  * order in the constructor so every dispatched request produces an instance
@@ -2868,7 +3097,16 @@ function serializeResult(result, def, validateResponses) {
2868
3097
  if (!spec) {
2869
3098
  throw new InternalError(`Handler returned status ${result.status} which is not declared in responses for ${def.method} ${def.path}`);
2870
3099
  }
2871
- const finish = () => {
3100
+ // `outputBody` is the value actually serialized onto the wire. It defaults
3101
+ // to the raw handler return, but when response-body validation runs it is
3102
+ // replaced with the validator's parsed `value` (e.g. a Zod object with
3103
+ // unknown keys stripped). This closes the OWASP API3 "excessive data
3104
+ // exposure" hole: a handler that returns extra fields not declared in the
3105
+ // response schema must not leak them — only declared fields are emitted,
3106
+ // exactly as the OpenAPI contract and the security docs promise. Schemas
3107
+ // that opt into pass-through (e.g. Zod `.passthrough()`) keep their extra
3108
+ // keys because the validator itself returns them in `value`.
3109
+ const finish = (outputBody) => {
2872
3110
  const headers = new Headers(result.headers);
2873
3111
  const explicitCt = headers.get("content-type");
2874
3112
  const treatAsJson = !explicitCt || explicitCt.includes("application/json");
@@ -2887,31 +3125,31 @@ function serializeResult(result, def, validateResponses) {
2887
3125
  let body;
2888
3126
  let rawBody = null;
2889
3127
  let isStream = false;
2890
- if (result.body === undefined || result.body === null) {
3128
+ if (outputBody === undefined || outputBody === null) {
2891
3129
  body = null;
2892
3130
  }
2893
- else if (!treatAsJson && typeof result.body === "string") {
2894
- const bytes = TEXT_ENCODER.encode(result.body);
3131
+ else if (!treatAsJson && typeof outputBody === "string") {
3132
+ const bytes = TEXT_ENCODER.encode(outputBody);
2895
3133
  setContentLength(headers, bytes.byteLength);
2896
3134
  body = bytes;
2897
3135
  rawBody = bytes;
2898
3136
  }
2899
- else if (!treatAsJson && result.body instanceof Uint8Array) {
2900
- setContentLength(headers, result.body.byteLength);
2901
- body = result.body;
2902
- rawBody = result.body;
3137
+ else if (!treatAsJson && outputBody instanceof Uint8Array) {
3138
+ setContentLength(headers, outputBody.byteLength);
3139
+ body = outputBody;
3140
+ rawBody = outputBody;
2903
3141
  }
2904
- else if (!treatAsJson && result.body instanceof ArrayBuffer) {
2905
- setContentLength(headers, result.body.byteLength);
2906
- body = result.body;
2907
- rawBody = new Uint8Array(result.body);
3142
+ else if (!treatAsJson && outputBody instanceof ArrayBuffer) {
3143
+ setContentLength(headers, outputBody.byteLength);
3144
+ body = outputBody;
3145
+ rawBody = new Uint8Array(outputBody);
2908
3146
  }
2909
- else if (!treatAsJson && result.body instanceof ReadableStream) {
2910
- body = result.body;
3147
+ else if (!treatAsJson && outputBody instanceof ReadableStream) {
3148
+ body = outputBody;
2911
3149
  isStream = true;
2912
3150
  }
2913
3151
  else {
2914
- const bytes = TEXT_ENCODER.encode(JSON.stringify(result.body));
3152
+ const bytes = TEXT_ENCODER.encode(JSON.stringify(outputBody));
2915
3153
  setContentLength(headers, bytes.byteLength);
2916
3154
  body = bytes;
2917
3155
  rawBody = bytes;
@@ -2935,7 +3173,9 @@ function serializeResult(result, def, validateResponses) {
2935
3173
  .map((i) => i.message)
2936
3174
  .join("; ")}`);
2937
3175
  }
2938
- return finish();
3176
+ // Serialize the validated (and, for object schemas, key-stripped)
3177
+ // value so undeclared fields never reach the client.
3178
+ return finish(resolved.value);
2939
3179
  });
2940
3180
  }
2941
3181
  else {
@@ -2944,9 +3184,10 @@ function serializeResult(result, def, validateResponses) {
2944
3184
  .map((i) => i.message)
2945
3185
  .join("; ")}`);
2946
3186
  }
3187
+ return finish(r.value);
2947
3188
  }
2948
3189
  }
2949
- return finish();
3190
+ return finish(result.body);
2950
3191
  }
2951
3192
  function setContentLength(headers, byteLength) {
2952
3193
  if (!headers.has("content-length"))
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * a child process. The thin shim in `bin/daloy.mjs` wires this up to
10
10
  * `process.argv`, `process.stdout`, dynamic `import()`, and `process.exit`.
11
11
  */
12
+ import { findRoutesMissingResponseBodySchema } from "./app.js";
12
13
  import { runContractTests } from "./contract.js";
13
14
  import { diffOpenAPI } from "./openapi-diff.js";
14
15
  import { generateOpenAPI, openapiToYAML } from "./openapi.js";
@@ -726,6 +727,27 @@ async function runDoctor(opts, io) {
726
727
  "Disable or gate behind authenticated routes.",
727
728
  });
728
729
  }
730
+ // Response-body-schema coverage audit (OWASP API3 — Broken Object
731
+ // Property Level Authorization). Response-field stripping only runs when
732
+ // a 2xx response declares a body schema; a schema-less 2xx ships whatever
733
+ // the handler returns, so a stray `passwordHash` or spread ORM row would
734
+ // leak. Advisory (warn) because a route may legitimately return no body.
735
+ const routes = app.routes ?? [];
736
+ const missingBody = findRoutesMissingResponseBodySchema(routes);
737
+ if (missingBody.length > 0) {
738
+ const sample = missingBody
739
+ .slice(0, 5)
740
+ .map((r) => `${r.method} ${r.path} (${r.statuses.join("/")})`)
741
+ .join(", ");
742
+ findings.push({
743
+ level: "warn",
744
+ code: "audit.response.bodySchema",
745
+ message: `${missingBody.length} route(s) declare a 2xx response with no body schema, so ` +
746
+ `response field-level stripping (OWASP API3) is not applied: ${sample}` +
747
+ `${missingBody.length > 5 ? ", …" : ""}. Declare a response body schema so undeclared ` +
748
+ "handler fields cannot leak, or ignore if the route intentionally returns no body.",
749
+ });
750
+ }
729
751
  }
730
752
  if (opts.auditSecrets === true) {
731
753
  const env = globalThis.process?.env ?? {};