@daloyjs/core 0.41.0 → 0.43.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
@@ -516,7 +516,7 @@ DaloyJS is in **public preview** (`0.x`). The public API may still change betwee
516
516
 
517
517
  - Adapters for Node (Heroku, Railway, Render, Fly.io), Bun, Deno, Cloudflare Workers, Vercel Node / Edge / Next.js / Netlify Edge, Fastly Compute, and AWS Lambda / Netlify Functions / Lambda Function URLs.
518
518
  - `daloy dev` watch loop delegates to the host runtime's native watcher (`node --import tsx --watch`, `bun --hot`, or `deno run --watch`) with a `--runtime` override for cross-runtime `package.json` scripts.
519
- - `pnpm create daloy` scaffolder with Node, Bun, Deno, Cloudflare Worker, and Vercel Edge templates, plus optional `--with-ci` GitHub Actions / Dependabot / CODEOWNERS / SECURITY.md hardening.
519
+ - `pnpm create daloy` scaffolder with Node, Bun, Deno, Cloudflare Worker, and Vercel Edge templates, plus optional `--with-ci` GitHub Actions / Dependabot / CODEOWNERS / SECURITY.md hardening. The completion summary surfaces official install links (nodejs.org, pnpm.io, bun.sh) for any runtime or package manager your selections need but that is missing from `PATH`, and skips a doomed dependency install when the chosen package manager is absent.
520
520
  - Container-first templates: `HEALTHCHECK` to `/readyz`, `STOPSIGNAL SIGTERM`, non-root user, `tini` as PID 1.
521
521
  - Generated `deploy.yml` for container templates signs every pushed GHCR image with **Sigstore Cosign** (keyless OIDC) and attaches an **SPDX SBOM attestation** so consumers can `cosign verify` and `cosign verify-attestation --type spdxjson` instead of trusting the registry alone.
522
522
  - Pretty `printStartupBanner()` / `formatStartupBanner()` helpers at `@daloyjs/core/banner`, used by every starter template (TTY + `NO_COLOR` / `FORCE_COLOR` aware, ASCII fallback for dumb terminals).
@@ -568,6 +568,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
568
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()`.
569
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`.
570
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`.
571
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.
572
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.
573
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.
@@ -598,7 +599,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
598
599
  - an Origin policy (`allowedOrigins: "same-origin"` / `string[]` / predicate) or `acknowledgeCrossOriginUpgrade: true`.
599
600
 
600
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.
601
- - 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.
602
603
 
603
604
  ### Lifecycle and ops
604
605
 
@@ -613,6 +614,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
613
614
  - Adapter-independent `ConnInfo` abstraction: `getConnInfo()`, lazy `ctx.remoteAddress`, `ctx.remotePort`.
614
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).
615
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.
616
618
  - `defineDependency()` typed-DI helper with per-request deduplication.
617
619
  - Scheme-aware `ctx.state.auth` typed contract; named, optionally seeded stateful plugins.
618
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 }`). */
@@ -957,6 +1026,24 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
957
1026
  */
958
1027
  private maybeMountDocs;
959
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;
960
1047
  /**
961
1048
  * Register a single route on the application.
962
1049
  *
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";
@@ -320,6 +321,7 @@ export class App {
320
321
  this.installSecureDefaults();
321
322
  this.maybeInstallCrashHandlers();
322
323
  this.maybeMountDocs();
324
+ this.maybeMountAsyncAPI();
323
325
  }
324
326
  /**
325
327
  * Validate {@link AppOptions.disconnectStatusCode}.
@@ -889,6 +891,146 @@ export class App {
889
891
  },
890
892
  });
891
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
+ }
892
1034
  // ---------- registration ----------
893
1035
  /**
894
1036
  * Register a single route on the application.
package/dist/banner.js CHANGED
@@ -36,24 +36,44 @@ const GLYPHS_ASCII = {
36
36
  mid: "-",
37
37
  };
38
38
  const ANSI_REGEX = /\u001b\[[0-9;]*m/g;
39
+ /**
40
+ * Read a single environment variable defensively. On runtimes with a
41
+ * capability-based permission model (Deno), reading an env var the process was
42
+ * not granted via `--allow-env` throws a `NotCapable` error. The startup
43
+ * banner is purely cosmetic, so a missing color/locale hint must never crash
44
+ * the host application — swallow the denial and treat the variable as unset.
45
+ * On Node and Bun `process.env` access never throws, so this is a no-op there.
46
+ *
47
+ * @param key - Environment variable name.
48
+ * @returns The value, or `undefined` when unset or inaccessible.
49
+ */
50
+ function readEnv(key) {
51
+ try {
52
+ return process.env[key];
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
39
58
  function detectColor() {
40
- if (process.env.NO_COLOR)
59
+ if (readEnv("NO_COLOR"))
41
60
  return false;
42
- if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0")
61
+ const force = readEnv("FORCE_COLOR");
62
+ if (force && force !== "0")
43
63
  return true;
44
64
  const stdout = process.stdout;
45
65
  return Boolean(stdout && stdout.isTTY);
46
66
  }
47
67
  function detectAscii() {
48
- if (process.env.DALOY_ASCII)
68
+ if (readEnv("DALOY_ASCII"))
49
69
  return true;
50
70
  if (process.platform === "win32") {
51
- return !(process.env.WT_SESSION || process.env.TERM_PROGRAM);
71
+ return !(readEnv("WT_SESSION") || readEnv("TERM_PROGRAM"));
52
72
  }
53
- const lang = process.env.LANG ?? process.env.LC_ALL ?? "";
73
+ const lang = readEnv("LANG") ?? readEnv("LC_ALL") ?? "";
54
74
  if (/UTF-?8/i.test(lang))
55
75
  return false;
56
- if (process.env.TERM_PROGRAM)
76
+ if (readEnv("TERM_PROGRAM"))
57
77
  return false;
58
78
  return true;
59
79
  }
package/dist/cli.js CHANGED
@@ -547,9 +547,14 @@ async function runDiff(opts, io) {
547
547
  }
548
548
  /**
549
549
  * `daloy doctor` — boot-time + CLI audit. Loads the user's
550
- * App entry and runs the secure-by-default checklist. Exits non-zero on any
551
- * finding so the command can guard container `HEALTHCHECK` and CI deploy
552
- * steps.
550
+ * App entry and runs the secure-by-default checklist so the command can guard
551
+ * container `HEALTHCHECK` and CI deploy steps.
552
+ *
553
+ * Exit code: non-zero (`1`) only when at least one **`error`-level** finding is
554
+ * present; `warn`-level findings are advisory and leave the exit code at `0`.
555
+ * The `--json` output reports `ok: true` only when there are **no findings at
556
+ * all** (any level), so `ok` is a stricter signal than the exit code — a
557
+ * warn-only run prints `ok: false` but still exits `0`.
553
558
  *
554
559
  * @internal
555
560
  */
package/dist/docs.d.ts CHANGED
@@ -226,6 +226,24 @@ export interface DocsAssetOptions {
226
226
  * @since 0.39.0
227
227
  */
228
228
  redocScriptIntegrity?: string;
229
+ /** Override the AsyncAPI React standalone bundle URL (useful for self-hosting). */
230
+ asyncapiScriptUrl?: string;
231
+ /**
232
+ * SRI hash for {@link asyncapiScriptUrl}. One or more space-separated
233
+ * `sha256-`/`sha384-`/`sha512-` base64 digests. Invalid values throw.
234
+ *
235
+ * @since 0.42.0
236
+ */
237
+ asyncapiScriptIntegrity?: string;
238
+ /** Override the AsyncAPI React component stylesheet URL (useful for self-hosting). */
239
+ asyncapiStyleUrl?: string;
240
+ /**
241
+ * SRI hash for {@link asyncapiStyleUrl}. One or more space-separated
242
+ * `sha256-`/`sha384-`/`sha512-` base64 digests. Invalid values throw.
243
+ *
244
+ * @since 0.42.0
245
+ */
246
+ asyncapiStyleIntegrity?: string;
229
247
  /**
230
248
  * `crossorigin` attribute value emitted alongside any pinned integrity
231
249
  * hash. SRI on a cross-origin asset requires CORS, so this defaults to
@@ -264,6 +282,20 @@ export interface RedocHtmlOptions extends DocsOptions {
264
282
  /** Forwarded as the options object to `Redoc.init(specUrl, configuration, element)`. */
265
283
  configuration?: RedocConfiguration;
266
284
  }
285
+ /**
286
+ * Options for {@link asyncapiHtml}; adds AsyncAPI-specific UI configuration.
287
+ *
288
+ * @since 0.42.0
289
+ */
290
+ export interface AsyncApiHtmlOptions extends DocsOptions {
291
+ /**
292
+ * Forwarded as the `config` object to `AsyncApiStandalone.render({ schema, config }, el)`.
293
+ * Defaults to showing the sidebar and inline errors.
294
+ */
295
+ configuration?: {
296
+ [key: string]: ScalarJsonValue | undefined;
297
+ };
298
+ }
267
299
  /** Options for {@link docsContentSecurityPolicy}. */
268
300
  export interface DocsContentSecurityPolicyOptions {
269
301
  /** Extra origins to allow for `script-src` / `style-src` (defaults to jsDelivr). */
@@ -316,6 +348,23 @@ export declare function swaggerUiHtml(opts: DocsOptions): string;
316
348
  * @since 0.39.0
317
349
  */
318
350
  export declare function redocHtml(opts: RedocHtmlOptions): string;
351
+ /**
352
+ * Render an AsyncAPI HTML page that loads `opts.specUrl` (an AsyncAPI 3.0
353
+ * document) into the official AsyncAPI React component. Same shape as
354
+ * {@link redocHtml}: a prebuilt standalone bundle is loaded from a CDN via a
355
+ * `<script>` tag (no build step, no extra deps) and the spec URL is handed to
356
+ * `AsyncApiStandalone.render(...)`. This is the AsyncAPI equivalent of the
357
+ * Scalar / Swagger UI / Redoc OpenAPI viewers.
358
+ *
359
+ * Serve it with the same CSP as the OpenAPI docs UIs ({@link docsContentSecurityPolicy}):
360
+ * it needs the asset origin (jsDelivr by default) in `script-src` / `style-src`
361
+ * and `connect-src 'self'` so the component can `fetch` the spec. The spec URL
362
+ * and configuration are embedded with `<`-escaped JSON so an attacker-controlled
363
+ * value cannot break out of the inline `<script>`.
364
+ *
365
+ * @since 0.42.0
366
+ */
367
+ export declare function asyncapiHtml(opts: AsyncApiHtmlOptions): string;
319
368
  /**
320
369
  * Build a Content-Security-Policy string compatible with the docs HTML
321
370
  * produced by {@link scalarHtml} / {@link swaggerUiHtml}.
package/dist/docs.js CHANGED
@@ -123,6 +123,45 @@ export function redocHtml(opts) {
123
123
  <script${nonce}>Redoc.init(${specArg},${optionsArg},document.getElementById("redoc"));</script>
124
124
  </body></html>`;
125
125
  }
126
+ /**
127
+ * Render an AsyncAPI HTML page that loads `opts.specUrl` (an AsyncAPI 3.0
128
+ * document) into the official AsyncAPI React component. Same shape as
129
+ * {@link redocHtml}: a prebuilt standalone bundle is loaded from a CDN via a
130
+ * `<script>` tag (no build step, no extra deps) and the spec URL is handed to
131
+ * `AsyncApiStandalone.render(...)`. This is the AsyncAPI equivalent of the
132
+ * Scalar / Swagger UI / Redoc OpenAPI viewers.
133
+ *
134
+ * Serve it with the same CSP as the OpenAPI docs UIs ({@link docsContentSecurityPolicy}):
135
+ * it needs the asset origin (jsDelivr by default) in `script-src` / `style-src`
136
+ * and `connect-src 'self'` so the component can `fetch` the spec. The spec URL
137
+ * and configuration are embedded with `<`-escaped JSON so an attacker-controlled
138
+ * value cannot break out of the inline `<script>`.
139
+ *
140
+ * @since 0.42.0
141
+ */
142
+ export function asyncapiHtml(opts) {
143
+ const title = escapeHtml(opts.title ?? "AsyncAPI");
144
+ const scriptUrl = escapeHtml(opts.assets?.asyncapiScriptUrl ??
145
+ `${JSDELIVR_ORIGIN}/npm/@asyncapi/react-component/browser/standalone/index.js`);
146
+ const styleUrl = escapeHtml(opts.assets?.asyncapiStyleUrl ??
147
+ `${JSDELIVR_ORIGIN}/npm/@asyncapi/react-component/styles/default.min.css`);
148
+ const scriptSri = integrityAttr(opts.assets?.asyncapiScriptIntegrity, opts.assets?.crossOrigin);
149
+ const styleSri = integrityAttr(opts.assets?.asyncapiStyleIntegrity, opts.assets?.crossOrigin);
150
+ const nonce = nonceAttr(opts.scriptNonce);
151
+ const specArg = jsonForScript(opts.specUrl);
152
+ const configArg = jsonForScript(opts.configuration ?? { show: { sidebar: true, errors: true } });
153
+ return `<!doctype html>
154
+ <html><head>
155
+ <meta charset="utf-8" />
156
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
157
+ <title>${title}</title>
158
+ <link rel="stylesheet" href="${styleUrl}"${styleSri} />
159
+ </head><body>
160
+ <div id="asyncapi"></div>
161
+ <script src="${scriptUrl}"${scriptSri}${nonce}></script>
162
+ <script${nonce}>AsyncApiStandalone.render({schema:{url:${specArg},options:{method:"GET"}},config:${configArg}},document.getElementById("asyncapi"));</script>
163
+ </body></html>`;
164
+ }
126
165
  /**
127
166
  * Build a Content-Security-Policy string compatible with the docs HTML
128
167
  * produced by {@link scalarHtml} / {@link swaggerUiHtml}.
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { findRoutesMissingResponseBodySchema } from "./app.js";
4
4
  export { _resetPackageJsonCacheForTests } from "./app.js";
5
5
  export { _resetCrashHandlersForTests } from "./app.js";
6
6
  export { _resetInsecureDefaultsLogForTests } from "./app.js";
7
- export type { AppOptions, AppOpenAPIOptions, DocsRouteOptions, HealthRouteOptions, CspReportRouteOptions, MetricsRouteOptions, IntrospectedRoute, PluginInstalledEvent, PluginExtension, ShutdownEvent, SecurityPreset, } from "./app.js";
7
+ export type { AppOptions, AppOpenAPIOptions, DocsRouteOptions, AsyncAPIRouteOptions, HealthRouteOptions, CspReportRouteOptions, MetricsRouteOptions, IntrospectedRoute, PluginInstalledEvent, PluginExtension, ShutdownEvent, SecurityPreset, } from "./app.js";
8
8
  export { getConnInfo, setConnInfo, assertBehindProxy, resolveClientIp, readRemoteAddress, readRemotePort, pickForwardedForByHops, } from "./conn-info.js";
9
9
  export type { BehindProxyConfig, ConnInfo } from "./conn-info.js";
10
10
  export { subdomains, PSL_SNAPSHOT_DATE, PSL_PUBLIC_SUFFIXES, MAX_SNAPSHOT_AGE_DAYS, } from "./subdomains.js";
@@ -73,7 +73,7 @@ export type { RequestIdOptions, SecureHeadersOptions, CspDirectivesOptions, Cors
73
73
  export type { BearerAuthOptions, BearerAuthVerifyHook } from "./middleware.js";
74
74
  export { createLogger, noopLogger, DEFAULT_REDACT_KEYS } from "./logger.js";
75
75
  export type { Logger, LogLevel, ConsoleLoggerOptions, LoggerRedactionOptions, } from "./logger.js";
76
- export type { ScalarJsonPrimitive, ScalarJsonValue, ScalarReferenceConfiguration, ScalarTheme, RedocConfiguration, RedocHtmlOptions, DocsAssetOptions, } from "./docs.js";
76
+ export type { ScalarJsonPrimitive, ScalarJsonValue, ScalarReferenceConfiguration, ScalarTheme, RedocConfiguration, RedocHtmlOptions, AsyncApiHtmlOptions, DocsAssetOptions, } from "./docs.js";
77
77
  export { formatStartupBanner, printStartupBanner } from "./banner.js";
78
78
  export type { StartupBannerLink, StartupBannerOptions } from "./banner.js";
79
79
  export { sseStream, sseResponse, ndjsonStream, ndjsonResponse, } from "./streaming.js";
@@ -96,5 +96,7 @@ export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema,
96
96
  export type { FileFieldSchema, FileFieldOptions, FileMagicBytesOption, FileMagicBytesSignature, MultipartObjectOptions, MultipartShape, UploadedFile, } from "./multipart.js";
97
97
  export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
98
98
  export type { OtelTracingOptions, TracingAttributes, TracingAttributeValue, TracingSpan, TracingStartSpanOptions, TracingTracer, } from "./tracing.js";
99
+ export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, } from "./tenancy.js";
100
+ export type { TenancyOptions, TenantResolver, TenantScopeOptions, SubdomainTenantOptions, PathPrefixTenantOptions, ClaimTenantOptions, UnresolvedStatus, InvalidStatus, } from "./tenancy.js";
99
101
  export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
100
102
  export type { WebSocketConnection, WebSocketContext, WebSocketHandler, WebSocketMeta, WebSocketRouteEntry, NormalizedWebSocketOptions, WebSocketBeforeUpgrade, HandshakeResult, ParsedFrame, MessageEvent as WebSocketMessageEvent, FrameSinkEvents, } from "./websocket.js";
package/dist/index.js CHANGED
@@ -48,4 +48,5 @@ export { encodeCursor, decodeCursor, buildLinkHeader, buildPageLinks, pagination
48
48
  export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATION_BUCKETS, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
49
49
  export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
50
50
  export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
51
+ export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, } from "./tenancy.js";
51
52
  export { defineWebSocket, WebSocketRegistry, WebSocketProtocolError, WebSocketPayloadTooLargeError, WS_GUID, WS_READY_STATE, WS_OPCODE, WS_CLOSE_CODE, WS_MAX_CONTROL_PAYLOAD, DEFAULT_WS_BACKPRESSURE_LIMIT, DEFAULT_WS_MAX_PAYLOAD_LENGTH, DEFAULT_WS_IDLE_TIMEOUT_SECONDS, computeAcceptKey, parseSubprotocols, validateSelectedSubprotocol, validateUpgrade, checkWebSocketOrigin, parseFrame, encodeFrame, encodeClosePayload, decodeClosePayload, encodeSendPayload, normalizeWebSocketOptions, wsRateLimit, FrameSink, FRAME_INCOMPLETE, } from "./websocket.js";
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:b6ab6390-9d08-59eb-80b2-e39452c91f11",
4
+ "serialNumber": "urn:uuid:66a4adf1-88f7-56e9-a00f-95633f473b23",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-06-18T16:40:32.542Z",
7
+ "timestamp": "2026-06-20T12:10:13.811Z",
8
8
  "tools": [
9
9
  {
10
10
  "vendor": "DaloyJS",
11
11
  "name": "daloy-generate-sbom",
12
- "version": "0.41.0"
12
+ "version": "0.43.0"
13
13
  }
14
14
  ],
15
15
  "authors": [
@@ -19,11 +19,11 @@
19
19
  ],
20
20
  "component": {
21
21
  "type": "library",
22
- "bom-ref": "pkg:npm/@daloyjs/core@0.41.0",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@0.43.0",
23
23
  "name": "@daloyjs/core",
24
- "version": "0.41.0",
24
+ "version": "0.43.0",
25
25
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
26
- "purl": "pkg:npm/@daloyjs/core@0.41.0",
26
+ "purl": "pkg:npm/@daloyjs/core@0.43.0",
27
27
  "licenses": [
28
28
  {
29
29
  "license": {
@@ -46,9 +46,9 @@
46
46
  }
47
47
  ],
48
48
  "swid": {
49
- "tagId": "swidtag--daloyjs-core-0.41.0",
49
+ "tagId": "swidtag--daloyjs-core-0.43.0",
50
50
  "name": "@daloyjs/core",
51
- "version": "0.41.0",
51
+ "version": "0.43.0",
52
52
  "tagVersion": 0,
53
53
  "patch": false
54
54
  }
@@ -57,7 +57,7 @@
57
57
  "components": [],
58
58
  "dependencies": [
59
59
  {
60
- "ref": "pkg:npm/@daloyjs/core@0.41.0",
60
+ "ref": "pkg:npm/@daloyjs/core@0.43.0",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "@daloyjs/core-0.41.0",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.41.0-b6ab6390-9d08-59eb-80b2-e39452c91f11",
5
+ "name": "@daloyjs/core-0.43.0",
6
+ "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.43.0-66a4adf1-88f7-56e9-a00f-95633f473b23",
7
7
  "creationInfo": {
8
- "created": "2026-06-18T16:40:32.542Z",
8
+ "created": "2026-06-20T12:10:13.811Z",
9
9
  "creators": [
10
10
  "Tool: daloy-generate-sbom",
11
11
  "Organization: DaloyJS"
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "SPDXID": "SPDXRef-Package--daloyjs-core",
18
18
  "name": "@daloyjs/core",
19
- "versionInfo": "0.41.0",
19
+ "versionInfo": "0.43.0",
20
20
  "downloadLocation": "https://github.com/daloyjs/daloy",
21
21
  "filesAnalyzed": false,
22
22
  "licenseConcluded": "MIT",
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "referenceCategory": "PACKAGE-MANAGER",
29
29
  "referenceType": "purl",
30
- "referenceLocator": "pkg:npm/@daloyjs/core@0.41.0"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@0.43.0"
31
31
  }
32
32
  ]
33
33
  }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Multitenancy primitive.
3
+ *
4
+ * `tenancy(opts)` returns a `Hooks` bundle that resolves the calling tenant
5
+ * once per request, validates and normalizes it, and exposes it on
6
+ * `ctx.state.tenant` for handlers and downstream middleware. It is the
7
+ * single source of truth for "who is this request for" so the per-tenant
8
+ * isolation knobs on the rest of the framework (`rateLimit` `keyGenerator`,
9
+ * `concurrencyLimit` / `idempotency` / `responseCache` `scope`) can all key
10
+ * off the same resolved value via {@link tenantScope}.
11
+ *
12
+ * Secure-by-default posture:
13
+ *
14
+ * - **Refuse-unresolved.** With the default `require: true`, a request whose
15
+ * tenant cannot be resolved is rejected (`400`) rather than silently served
16
+ * as some ambient "default" tenant — the failure mode that leaks one
17
+ * tenant's data to another.
18
+ * - **Format-validated ids.** Resolved ids are normalized to a conservative
19
+ * `[a-z0-9_-]` charset before they are stored or used as a key. A tenant id
20
+ * pulled from a spoofable header can otherwise smuggle newlines, `:`, `/`,
21
+ * or `*` into rate-limit keys, cache keys, and log lines (key/log injection,
22
+ * cache poisoning). Anything that fails the pattern is treated as an unknown
23
+ * tenant.
24
+ * - **No enumeration.** An id that resolves but is not in your `allow`
25
+ * list/validator is rejected as `404 Not Found` by default, so probing for
26
+ * valid tenant names cannot be distinguished from hitting a missing route.
27
+ * - **Host-spoof safe.** {@link tenantFromSubdomain} treats a `Host` that is
28
+ * not under the declared `baseDomain` as unresolved instead of trusting it.
29
+ *
30
+ * Ordering: `tenancy()` resolves in `beforeHandle`, and so do the isolation
31
+ * primitives that consume the result. Register `tenancy()` **before** them
32
+ * (as the first group hook, or in `AppOptions.hooks`) so `ctx.state.tenant`
33
+ * is populated by the time their `keyGenerator` / `scope` callbacks run.
34
+ *
35
+ * ```ts
36
+ * import { App, tenancy, tenantFromSubdomain, tenantScope, rateLimit } from "@daloyjs/core";
37
+ *
38
+ * const app = new App({
39
+ * hooks: tenancy({
40
+ * resolve: tenantFromSubdomain({ baseDomain: "example.com" }),
41
+ * allow: ["acme", "globex"],
42
+ * }),
43
+ * });
44
+ *
45
+ * // Per-tenant rate-limit buckets keyed off the resolved tenant.
46
+ * app.use(rateLimit({ windowMs: 60_000, max: 100, keyGenerator: tenantScope() }));
47
+ * ```
48
+ *
49
+ * @since 0.42.0
50
+ */
51
+ import type { BaseContext, Hooks } from "./types.js";
52
+ /**
53
+ * Resolves a raw (un-normalized) tenant id from a request, or a nullish value
54
+ * when this strategy cannot determine one. Resolvers are tried in order and
55
+ * the first non-empty result wins.
56
+ *
57
+ * @since 0.42.0
58
+ */
59
+ export type TenantResolver = (ctx: BaseContext<any, any>) => string | null | undefined | Promise<string | null | undefined>;
60
+ /**
61
+ * Default normalizer: trim, lowercase, and accept only ids matching
62
+ * {@link DEFAULT_TENANT_PATTERN}. Returns `undefined` for anything else, which
63
+ * the middleware treats as an unknown tenant.
64
+ *
65
+ * @param raw - The raw value produced by a {@link TenantResolver}.
66
+ * @returns The normalized id, or `undefined` when it is not a valid tenant id.
67
+ * @since 0.42.0
68
+ */
69
+ export declare function defaultTenantNormalize(raw: string): string | undefined;
70
+ /** Options for {@link tenantFromSubdomain}. @since 0.42.0 */
71
+ export interface SubdomainTenantOptions {
72
+ /**
73
+ * Explicit registrable base domain (e.g. `"example.com"`). Strongly
74
+ * recommended in production: a `Host` that is not under this base is treated
75
+ * as unresolved rather than trusted, which defends against `Host`-header
76
+ * spoofing. When omitted, the PSL snapshot is used to split the host.
77
+ */
78
+ baseDomain?: string;
79
+ /**
80
+ * Which subdomain label to use, counting from the left (`0` = leftmost).
81
+ * For `acme.example.com` the default `0` yields `"acme"`. Default `0`.
82
+ */
83
+ index?: number;
84
+ /** Forwarded to {@link subdomains}: extra public-suffix entries. */
85
+ extraSuffixes?: readonly string[];
86
+ /**
87
+ * Forwarded to {@link subdomains}: enables the production staleness check on
88
+ * the bundled Public Suffix List snapshot. Default `false`.
89
+ */
90
+ production?: boolean;
91
+ }
92
+ /**
93
+ * Resolve the tenant from a request subdomain using the PSL-aware
94
+ * {@link subdomains} helper. `acme.example.com` → `"acme"`.
95
+ *
96
+ * A `Host` that is not under the declared `baseDomain` resolves to `undefined`
97
+ * (unresolved) instead of throwing, so a spoofed `Host` becomes a clean
98
+ * rejection rather than a `500`.
99
+ *
100
+ * @param opts - Subdomain resolution options.
101
+ * @returns A {@link TenantResolver}.
102
+ * @since 0.42.0
103
+ */
104
+ export declare function tenantFromSubdomain(opts?: SubdomainTenantOptions): TenantResolver;
105
+ /**
106
+ * Resolve the tenant from a request header (e.g. `"x-tenant-id"`).
107
+ *
108
+ * **Security:** request headers are client-controlled. Only use this behind a
109
+ * trusted proxy/load balancer that *overwrites* the header on every inbound
110
+ * request — otherwise a caller can set it to any tenant. Pair with
111
+ * {@link TenancyOptions.allow} to bound the accepted values.
112
+ *
113
+ * @param headerName - Header to read (case-insensitive).
114
+ * @returns A {@link TenantResolver}.
115
+ * @since 0.42.0
116
+ */
117
+ export declare function tenantFromHeader(headerName: string): TenantResolver;
118
+ /** Options for {@link tenantFromPathPrefix}. @since 0.42.0 */
119
+ export interface PathPrefixTenantOptions {
120
+ /**
121
+ * Which non-empty path segment to use, counting from the left (`0` = first).
122
+ * For `/acme/orders` the default `0` yields `"acme"`. Default `0`.
123
+ */
124
+ segment?: number;
125
+ }
126
+ /**
127
+ * Resolve the tenant from a path segment. `/acme/orders` → `"acme"`.
128
+ *
129
+ * Note: this only reads the id; it does not rewrite the path, so your routes
130
+ * still include the tenant segment (e.g. register `/:tenant/orders`, or read
131
+ * `ctx.state.tenant` and ignore the segment in the handler).
132
+ *
133
+ * @param opts - Path-prefix resolution options.
134
+ * @returns A {@link TenantResolver}.
135
+ * @since 0.42.0
136
+ */
137
+ export declare function tenantFromPathPrefix(opts?: PathPrefixTenantOptions): TenantResolver;
138
+ /** Options for {@link tenantFromClaim}. @since 0.42.0 */
139
+ export interface ClaimTenantOptions {
140
+ /**
141
+ * `ctx.state` key holding the authenticated principal. Default `"auth"`,
142
+ * matching the first-party auth helpers which write an
143
+ * `{ scheme, credentials }` context to `ctx.state.auth`. The claim is read
144
+ * from `credentials[claim]` when present, otherwise from `node[claim]`.
145
+ */
146
+ stateKey?: string;
147
+ }
148
+ /**
149
+ * Resolve the tenant from a verified auth claim already on `ctx.state`
150
+ * (e.g. an `org` / `tenant` JWT claim). Reads `ctx.state.auth.credentials`
151
+ * (the {@link AuthContext} shape) or, if there is no `credentials` field, the
152
+ * state node itself.
153
+ *
154
+ * **Ordering:** the auth middleware that populates the claim must run *before*
155
+ * `tenancy()`. Register your verifier first, then `tenancy()`.
156
+ *
157
+ * @param claim - Claim/property name carrying the tenant id.
158
+ * @param opts - Where to read the principal from.
159
+ * @returns A {@link TenantResolver}.
160
+ * @since 0.42.0
161
+ */
162
+ export declare function tenantFromClaim(claim: string, opts?: ClaimTenantOptions): TenantResolver;
163
+ /** Status codes acceptable for an unresolved-tenant rejection. @since 0.42.0 */
164
+ export type UnresolvedStatus = 400 | 401 | 403 | 404;
165
+ /** Status codes acceptable for an unknown/disallowed-tenant rejection. @since 0.42.0 */
166
+ export type InvalidStatus = 400 | 403 | 404;
167
+ /** Options for {@link tenancy}. @since 0.42.0 */
168
+ export interface TenancyOptions {
169
+ /**
170
+ * One resolver, or several tried in order until one returns a non-empty
171
+ * value. Combine e.g. `[tenantFromClaim("org"), tenantFromSubdomain(...)]`
172
+ * to prefer a verified claim and fall back to the subdomain.
173
+ */
174
+ resolve: TenantResolver | TenantResolver[];
175
+ /**
176
+ * Reject requests whose tenant cannot be resolved. Default `true`. Set to
177
+ * `false` only when some routes are legitimately tenant-less; the request
178
+ * then proceeds with `ctx.state.tenant` left `undefined`.
179
+ */
180
+ require?: boolean;
181
+ /**
182
+ * Bound the accepted tenant space: an array allowlist, or an (optionally
183
+ * async) validator `(id, ctx) => boolean`. A resolved id that fails is
184
+ * rejected with {@link invalidStatus}. Array entries are validated against
185
+ * the normalizer at construction time (a malformed entry throws).
186
+ */
187
+ allow?: readonly string[] | ((tenantId: string, ctx: BaseContext<any, any>) => boolean | Promise<boolean>);
188
+ /**
189
+ * Normalize/validate a raw resolved id. Return `undefined` to reject it.
190
+ * Default {@link defaultTenantNormalize} (trim + lowercase + strict charset).
191
+ */
192
+ normalize?: (raw: string) => string | undefined;
193
+ /** `ctx.state` key the resolved tenant id is written to. Default `"tenant"`. */
194
+ stateKey?: string;
195
+ /** Status for an unresolved tenant when `require` is true. Default `400`. */
196
+ unresolvedStatus?: UnresolvedStatus;
197
+ /** Status for a resolved-but-disallowed tenant. Default `404` (no enumeration). */
198
+ invalidStatus?: InvalidStatus;
199
+ }
200
+ /**
201
+ * Multitenancy middleware. Resolves, validates, and normalizes the tenant for
202
+ * each request and stores it on `ctx.state[stateKey]` (default `tenant`).
203
+ * See the module overview for the secure-by-default posture and ordering
204
+ * rules.
205
+ *
206
+ * @param opts - Resolution, validation, and rejection configuration.
207
+ * @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
208
+ * @throws If no resolver is supplied, or an `allow` array entry is not a valid
209
+ * tenant id under the configured normalizer.
210
+ * @since 0.42.0
211
+ */
212
+ export declare function tenancy(opts: TenancyOptions): Hooks;
213
+ /** Options for {@link tenantScope}. @since 0.42.0 */
214
+ export interface TenantScopeOptions {
215
+ /** `ctx.state` key the tenant id was written to. Default `"tenant"`. */
216
+ stateKey?: string;
217
+ /**
218
+ * Key returned when no tenant is present (only reachable with
219
+ * `tenancy({ require: false })`). Default `"tenant:unknown"`.
220
+ */
221
+ fallback?: string;
222
+ }
223
+ /**
224
+ * Build a `(ctx) => string` key function that reads the resolved tenant and
225
+ * returns a `tenant:<id>` partition key. Drop it straight into the isolation
226
+ * knobs so each tenant gets its own bucket/namespace and cannot see, exhaust,
227
+ * or poison another tenant's:
228
+ *
229
+ * ```ts
230
+ * rateLimit({ windowMs: 60_000, max: 100, keyGenerator: tenantScope() });
231
+ * concurrencyLimit({ maxConcurrent: 20, scope: tenantScope() });
232
+ * idempotency({ scope: tenantScope() }); // CWE-524 cross-tenant cache defense
233
+ * responseCache({ ttlMs: 30_000, scope: tenantScope() });
234
+ * ```
235
+ *
236
+ * The `tenant:` prefix keeps these keys from colliding with other key spaces
237
+ * (e.g. `concurrencyLimit`'s literal `"global"` bucket).
238
+ *
239
+ * @param opts - Where to read the tenant from and the tenant-less fallback.
240
+ * @returns A key function suitable for `keyGenerator` / `scope`.
241
+ * @since 0.42.0
242
+ */
243
+ export declare function tenantScope(opts?: TenantScopeOptions): (ctx: BaseContext<any, any>) => string;
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Multitenancy primitive.
3
+ *
4
+ * `tenancy(opts)` returns a `Hooks` bundle that resolves the calling tenant
5
+ * once per request, validates and normalizes it, and exposes it on
6
+ * `ctx.state.tenant` for handlers and downstream middleware. It is the
7
+ * single source of truth for "who is this request for" so the per-tenant
8
+ * isolation knobs on the rest of the framework (`rateLimit` `keyGenerator`,
9
+ * `concurrencyLimit` / `idempotency` / `responseCache` `scope`) can all key
10
+ * off the same resolved value via {@link tenantScope}.
11
+ *
12
+ * Secure-by-default posture:
13
+ *
14
+ * - **Refuse-unresolved.** With the default `require: true`, a request whose
15
+ * tenant cannot be resolved is rejected (`400`) rather than silently served
16
+ * as some ambient "default" tenant — the failure mode that leaks one
17
+ * tenant's data to another.
18
+ * - **Format-validated ids.** Resolved ids are normalized to a conservative
19
+ * `[a-z0-9_-]` charset before they are stored or used as a key. A tenant id
20
+ * pulled from a spoofable header can otherwise smuggle newlines, `:`, `/`,
21
+ * or `*` into rate-limit keys, cache keys, and log lines (key/log injection,
22
+ * cache poisoning). Anything that fails the pattern is treated as an unknown
23
+ * tenant.
24
+ * - **No enumeration.** An id that resolves but is not in your `allow`
25
+ * list/validator is rejected as `404 Not Found` by default, so probing for
26
+ * valid tenant names cannot be distinguished from hitting a missing route.
27
+ * - **Host-spoof safe.** {@link tenantFromSubdomain} treats a `Host` that is
28
+ * not under the declared `baseDomain` as unresolved instead of trusting it.
29
+ *
30
+ * Ordering: `tenancy()` resolves in `beforeHandle`, and so do the isolation
31
+ * primitives that consume the result. Register `tenancy()` **before** them
32
+ * (as the first group hook, or in `AppOptions.hooks`) so `ctx.state.tenant`
33
+ * is populated by the time their `keyGenerator` / `scope` callbacks run.
34
+ *
35
+ * ```ts
36
+ * import { App, tenancy, tenantFromSubdomain, tenantScope, rateLimit } from "@daloyjs/core";
37
+ *
38
+ * const app = new App({
39
+ * hooks: tenancy({
40
+ * resolve: tenantFromSubdomain({ baseDomain: "example.com" }),
41
+ * allow: ["acme", "globex"],
42
+ * }),
43
+ * });
44
+ *
45
+ * // Per-tenant rate-limit buckets keyed off the resolved tenant.
46
+ * app.use(rateLimit({ windowMs: 60_000, max: 100, keyGenerator: tenantScope() }));
47
+ * ```
48
+ *
49
+ * @since 0.42.0
50
+ */
51
+ import { BadRequestError, ForbiddenError, NotFoundError, UnauthorizedError, } from "./errors.js";
52
+ import { subdomains } from "./subdomains.js";
53
+ /**
54
+ * Conservative default tenant-id grammar: a DNS-label-like token, lowercase
55
+ * `a-z0-9` with internal `-`/`_`, 1–63 chars, no leading/trailing separator.
56
+ * Deliberately strict so a resolved id is always safe to embed in a key or a
57
+ * log line.
58
+ */
59
+ const DEFAULT_TENANT_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?$/;
60
+ /**
61
+ * Default normalizer: trim, lowercase, and accept only ids matching
62
+ * {@link DEFAULT_TENANT_PATTERN}. Returns `undefined` for anything else, which
63
+ * the middleware treats as an unknown tenant.
64
+ *
65
+ * @param raw - The raw value produced by a {@link TenantResolver}.
66
+ * @returns The normalized id, or `undefined` when it is not a valid tenant id.
67
+ * @since 0.42.0
68
+ */
69
+ export function defaultTenantNormalize(raw) {
70
+ const id = raw.trim().toLowerCase();
71
+ return DEFAULT_TENANT_PATTERN.test(id) ? id : undefined;
72
+ }
73
+ /**
74
+ * Resolve the tenant from a request subdomain using the PSL-aware
75
+ * {@link subdomains} helper. `acme.example.com` → `"acme"`.
76
+ *
77
+ * A `Host` that is not under the declared `baseDomain` resolves to `undefined`
78
+ * (unresolved) instead of throwing, so a spoofed `Host` becomes a clean
79
+ * rejection rather than a `500`.
80
+ *
81
+ * @param opts - Subdomain resolution options.
82
+ * @returns A {@link TenantResolver}.
83
+ * @since 0.42.0
84
+ */
85
+ export function tenantFromSubdomain(opts = {}) {
86
+ const index = opts.index ?? 0;
87
+ const base = opts.baseDomain?.toLowerCase();
88
+ return (ctx) => {
89
+ let hostname;
90
+ try {
91
+ hostname = new URL(ctx.request.url).hostname.toLowerCase();
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ if (!hostname)
97
+ return undefined;
98
+ if (base && hostname !== base && !hostname.endsWith(`.${base}`)) {
99
+ // Host is not under the declared base — possible spoofing. Resolve to
100
+ // unresolved instead of letting subdomains() throw.
101
+ return undefined;
102
+ }
103
+ const { labels } = subdomains(hostname, {
104
+ baseDomain: opts.baseDomain,
105
+ extraSuffixes: opts.extraSuffixes,
106
+ production: opts.production,
107
+ });
108
+ return labels[index];
109
+ };
110
+ }
111
+ /**
112
+ * Resolve the tenant from a request header (e.g. `"x-tenant-id"`).
113
+ *
114
+ * **Security:** request headers are client-controlled. Only use this behind a
115
+ * trusted proxy/load balancer that *overwrites* the header on every inbound
116
+ * request — otherwise a caller can set it to any tenant. Pair with
117
+ * {@link TenancyOptions.allow} to bound the accepted values.
118
+ *
119
+ * @param headerName - Header to read (case-insensitive).
120
+ * @returns A {@link TenantResolver}.
121
+ * @since 0.42.0
122
+ */
123
+ export function tenantFromHeader(headerName) {
124
+ const name = headerName.toLowerCase();
125
+ return (ctx) => ctx.request.headers.get(name) ?? undefined;
126
+ }
127
+ /**
128
+ * Resolve the tenant from a path segment. `/acme/orders` → `"acme"`.
129
+ *
130
+ * Note: this only reads the id; it does not rewrite the path, so your routes
131
+ * still include the tenant segment (e.g. register `/:tenant/orders`, or read
132
+ * `ctx.state.tenant` and ignore the segment in the handler).
133
+ *
134
+ * @param opts - Path-prefix resolution options.
135
+ * @returns A {@link TenantResolver}.
136
+ * @since 0.42.0
137
+ */
138
+ export function tenantFromPathPrefix(opts = {}) {
139
+ const segment = opts.segment ?? 0;
140
+ return (ctx) => {
141
+ let pathname;
142
+ try {
143
+ pathname = new URL(ctx.request.url).pathname;
144
+ }
145
+ catch {
146
+ return undefined;
147
+ }
148
+ const parts = pathname.split("/").filter(Boolean);
149
+ return parts[segment];
150
+ };
151
+ }
152
+ /**
153
+ * Resolve the tenant from a verified auth claim already on `ctx.state`
154
+ * (e.g. an `org` / `tenant` JWT claim). Reads `ctx.state.auth.credentials`
155
+ * (the {@link AuthContext} shape) or, if there is no `credentials` field, the
156
+ * state node itself.
157
+ *
158
+ * **Ordering:** the auth middleware that populates the claim must run *before*
159
+ * `tenancy()`. Register your verifier first, then `tenancy()`.
160
+ *
161
+ * @param claim - Claim/property name carrying the tenant id.
162
+ * @param opts - Where to read the principal from.
163
+ * @returns A {@link TenantResolver}.
164
+ * @since 0.42.0
165
+ */
166
+ export function tenantFromClaim(claim, opts = {}) {
167
+ const stateKey = opts.stateKey ?? "auth";
168
+ return (ctx) => {
169
+ const node = ctx.state[stateKey];
170
+ if (!node || typeof node !== "object")
171
+ return undefined;
172
+ const withCreds = node;
173
+ const source = withCreds.credentials && typeof withCreds.credentials === "object"
174
+ ? withCreds.credentials
175
+ : node;
176
+ const value = source[claim];
177
+ if (typeof value === "string")
178
+ return value;
179
+ if (typeof value === "number" && Number.isFinite(value))
180
+ return String(value);
181
+ return undefined;
182
+ };
183
+ }
184
+ /** Build the right `HttpError` for a configured status. */
185
+ function rejection(status, detail) {
186
+ switch (status) {
187
+ case 401:
188
+ return new UnauthorizedError(detail);
189
+ case 403:
190
+ return new ForbiddenError(detail);
191
+ case 404:
192
+ return new NotFoundError(detail);
193
+ default:
194
+ return new BadRequestError(detail);
195
+ }
196
+ }
197
+ /**
198
+ * Multitenancy middleware. Resolves, validates, and normalizes the tenant for
199
+ * each request and stores it on `ctx.state[stateKey]` (default `tenant`).
200
+ * See the module overview for the secure-by-default posture and ordering
201
+ * rules.
202
+ *
203
+ * @param opts - Resolution, validation, and rejection configuration.
204
+ * @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
205
+ * @throws If no resolver is supplied, or an `allow` array entry is not a valid
206
+ * tenant id under the configured normalizer.
207
+ * @since 0.42.0
208
+ */
209
+ export function tenancy(opts) {
210
+ const resolvers = Array.isArray(opts.resolve) ? opts.resolve : [opts.resolve];
211
+ if (resolvers.length === 0) {
212
+ throw new Error("tenancy(): at least one resolver is required.");
213
+ }
214
+ const stateKey = opts.stateKey ?? "tenant";
215
+ const required = opts.require ?? true;
216
+ const normalize = opts.normalize ?? defaultTenantNormalize;
217
+ const unresolvedStatus = opts.unresolvedStatus ?? 400;
218
+ const invalidStatus = opts.invalidStatus ?? 404;
219
+ // Pre-normalize an array allowlist so comparisons are apples-to-apples, and
220
+ // fail fast on a misconfigured entry rather than silently never matching it.
221
+ let allowSet;
222
+ let allowFn;
223
+ if (Array.isArray(opts.allow)) {
224
+ allowSet = new Set();
225
+ for (const entry of opts.allow) {
226
+ const n = normalize(entry);
227
+ if (n === undefined) {
228
+ throw new Error(`tenancy(): allowlist entry ${JSON.stringify(entry)} is not a valid tenant id.`);
229
+ }
230
+ allowSet.add(n);
231
+ }
232
+ }
233
+ else if (typeof opts.allow === "function") {
234
+ allowFn = opts.allow;
235
+ }
236
+ return {
237
+ async beforeHandle(ctx) {
238
+ let raw;
239
+ for (const resolve of resolvers) {
240
+ raw = await resolve(ctx);
241
+ if (raw != null && raw !== "")
242
+ break;
243
+ }
244
+ if (raw == null || raw === "") {
245
+ if (required) {
246
+ throw rejection(unresolvedStatus, "Could not determine the tenant for this request.");
247
+ }
248
+ return; // optional tenancy: proceed tenant-less
249
+ }
250
+ const id = normalize(raw);
251
+ if (id === undefined) {
252
+ // Malformed id — reject as unknown so a poisoned value never reaches a
253
+ // key or log line, and without revealing it was a format problem.
254
+ throw rejection(invalidStatus, "Unknown tenant.");
255
+ }
256
+ if (allowSet && !allowSet.has(id)) {
257
+ throw rejection(invalidStatus, "Unknown tenant.");
258
+ }
259
+ if (allowFn && !(await allowFn(id, ctx))) {
260
+ throw rejection(invalidStatus, "Unknown tenant.");
261
+ }
262
+ ctx.state[stateKey] = id;
263
+ },
264
+ };
265
+ }
266
+ /**
267
+ * Build a `(ctx) => string` key function that reads the resolved tenant and
268
+ * returns a `tenant:<id>` partition key. Drop it straight into the isolation
269
+ * knobs so each tenant gets its own bucket/namespace and cannot see, exhaust,
270
+ * or poison another tenant's:
271
+ *
272
+ * ```ts
273
+ * rateLimit({ windowMs: 60_000, max: 100, keyGenerator: tenantScope() });
274
+ * concurrencyLimit({ maxConcurrent: 20, scope: tenantScope() });
275
+ * idempotency({ scope: tenantScope() }); // CWE-524 cross-tenant cache defense
276
+ * responseCache({ ttlMs: 30_000, scope: tenantScope() });
277
+ * ```
278
+ *
279
+ * The `tenant:` prefix keeps these keys from colliding with other key spaces
280
+ * (e.g. `concurrencyLimit`'s literal `"global"` bucket).
281
+ *
282
+ * @param opts - Where to read the tenant from and the tenant-less fallback.
283
+ * @returns A key function suitable for `keyGenerator` / `scope`.
284
+ * @since 0.42.0
285
+ */
286
+ export function tenantScope(opts = {}) {
287
+ const stateKey = opts.stateKey ?? "tenant";
288
+ const fallback = opts.fallback ?? "tenant:unknown";
289
+ return (ctx) => {
290
+ const value = ctx.state[stateKey];
291
+ return typeof value === "string" && value.length > 0 ? `tenant:${value}` : fallback;
292
+ };
293
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -45,10 +45,6 @@
45
45
  "types": "./dist/index.d.ts",
46
46
  "import": "./dist/index.js"
47
47
  },
48
- "./app": {
49
- "types": "./dist/app.d.ts",
50
- "import": "./dist/app.js"
51
- },
52
48
  "./node": {
53
49
  "types": "./dist/adapters/node.d.ts",
54
50
  "import": "./dist/adapters/node.js"
@@ -113,6 +109,10 @@
113
109
  "types": "./dist/tracing.d.ts",
114
110
  "import": "./dist/tracing.js"
115
111
  },
112
+ "./tenancy": {
113
+ "types": "./dist/tenancy.d.ts",
114
+ "import": "./dist/tenancy.js"
115
+ },
116
116
  "./multipart": {
117
117
  "types": "./dist/multipart.d.ts",
118
118
  "import": "./dist/multipart.js"