@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 +6 -2
- package/dist/adapters/node.js +11 -0
- package/dist/app.d.ts +131 -1
- package/dist/app.js +259 -18
- package/dist/cli.js +22 -0
- package/dist/docs.d.ts +49 -0
- package/dist/docs.js +39 -0
- package/dist/idempotency.d.ts +26 -4
- package/dist/idempotency.js +25 -5
- package/dist/index.d.ts +5 -2
- package/dist/index.js +2 -0
- package/dist/response-cache.d.ts +26 -2
- package/dist/response-cache.js +14 -2
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/tenancy.d.ts +243 -0
- package/dist/tenancy.js +293 -0
- package/package.json +8 -2
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
|
|
package/dist/adapters/node.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
3128
|
+
if (outputBody === undefined || outputBody === null) {
|
|
2891
3129
|
body = null;
|
|
2892
3130
|
}
|
|
2893
|
-
else if (!treatAsJson && typeof
|
|
2894
|
-
const bytes = TEXT_ENCODER.encode(
|
|
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 &&
|
|
2900
|
-
setContentLength(headers,
|
|
2901
|
-
body =
|
|
2902
|
-
rawBody =
|
|
3137
|
+
else if (!treatAsJson && outputBody instanceof Uint8Array) {
|
|
3138
|
+
setContentLength(headers, outputBody.byteLength);
|
|
3139
|
+
body = outputBody;
|
|
3140
|
+
rawBody = outputBody;
|
|
2903
3141
|
}
|
|
2904
|
-
else if (!treatAsJson &&
|
|
2905
|
-
setContentLength(headers,
|
|
2906
|
-
body =
|
|
2907
|
-
rawBody = new Uint8Array(
|
|
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 &&
|
|
2910
|
-
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(
|
|
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
|
-
|
|
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 ?? {};
|