@daloyjs/core 0.41.0 → 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 +3 -1
- package/dist/adapters/node.js +11 -0
- package/dist/app.d.ts +88 -1
- package/dist/app.js +143 -1
- package/dist/docs.d.ts +49 -0
- package/dist/docs.js +39 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- 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 +5 -1
package/README.md
CHANGED
|
@@ -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
|
|
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 }`). */
|
|
@@ -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/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";
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:c3e0bc4b-403c-5789-b2e4-dbafa73b03ee",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-06-
|
|
7
|
+
"timestamp": "2026-06-19T08:56:49.249Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "0.
|
|
12
|
+
"version": "0.42.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.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@0.42.0",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "0.
|
|
24
|
+
"version": "0.42.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.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@0.42.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.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-0.42.0",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "0.
|
|
51
|
+
"version": "0.42.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.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@0.42.0",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -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.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.
|
|
5
|
+
"name": "@daloyjs/core-0.42.0",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-0.42.0-c3e0bc4b-403c-5789-b2e4-dbafa73b03ee",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-06-
|
|
8
|
+
"created": "2026-06-19T08:56:49.249Z",
|
|
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.
|
|
19
|
+
"versionInfo": "0.42.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.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@0.42.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;
|
package/dist/tenancy.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.42.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": {
|
|
@@ -113,6 +113,10 @@
|
|
|
113
113
|
"types": "./dist/tracing.d.ts",
|
|
114
114
|
"import": "./dist/tracing.js"
|
|
115
115
|
},
|
|
116
|
+
"./tenancy": {
|
|
117
|
+
"types": "./dist/tenancy.d.ts",
|
|
118
|
+
"import": "./dist/tenancy.js"
|
|
119
|
+
},
|
|
116
120
|
"./multipart": {
|
|
117
121
|
"types": "./dist/multipart.d.ts",
|
|
118
122
|
"import": "./dist/multipart.js"
|