@zudojs/tenancy 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Multi-tenant context and isolation: tenant resolution, context propagation, resolver chains, and guard middleware.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-tenancy](https://zudojs.oyinlola.site/docs/packages-tenancy) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-tenancy.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -12,32 +18,43 @@ npm install @zudojs/tenancy
12
18
 
13
19
  ```typescript
14
20
  import {
21
+ createDomainResolver,
15
22
  createJwtResolver,
16
23
  createMemoryTenantRepository,
17
24
  createResolveTenantMiddleware,
18
25
  createResolverChain,
19
26
  createSubdomainResolver,
20
27
  createTenantContextStorage,
28
+ createTenantId,
21
29
  } from "@zudojs/tenancy";
22
30
 
23
31
  const storage = createTenantContextStorage();
24
32
  const repository = createMemoryTenantRepository();
33
+ repository.add(
34
+ { id: createTenantId("t-1001"), name: "Acme", slug: "acme", status: "active", metadata: {} },
35
+ ["acme.io"], // custom domain
36
+ );
25
37
 
26
38
  const resolver = createResolverChain([
27
39
  createJwtResolver(), // priority 100, trusted
40
+ createDomainResolver({ repository }), // priority 75: acme.io → t-1001
28
41
  createSubdomainResolver({ baseDomain: "example.com" }), // priority 70
29
42
  ]);
30
43
 
31
- // Resolves the tenant, enforces its status and the route's trust floor,
32
- // then runs the rest of the request inside the tenant context.
44
+ // Resolves the tenant, enforces its status and the route's trust floor
45
+ // (default "verified"), then runs the rest of the request inside the tenant
46
+ // context. acme.example.com reaches t-1001 through its slug.
33
47
  const middleware = createResolveTenantMiddleware({
34
48
  resolver: resolver.asResolver(),
35
49
  repository,
36
50
  storage,
37
- minimumTrust: "verified",
38
51
  });
39
52
  ```
40
53
 
54
+ The middleware runs inside the real `@zudojs/http` pipeline without
55
+ depending on it: headers are read through `request.getHeader()` when present,
56
+ and otherwise from a plain object or a `Map`, case-insensitively.
57
+
41
58
  Read the current tenant anywhere downstream:
42
59
 
43
60
  ```typescript
@@ -55,13 +72,17 @@ const tenant = context.requireCurrentTenant();
55
72
  | `subdomain`, `domain` | `verified` | host-derived |
56
73
  | `header`, `path` | `untrusted` | client-supplied on the wire |
57
74
 
58
- `x-tenant-id` is untrusted by default. Raise it with
59
- `createHeaderResolver({ trust: "verified" })` only where a trusted proxy
60
- strips and re-sets the header at the edge.
75
+ `createResolveTenantMiddleware` requires `verified` trust by default, so a
76
+ tenant named only by `x-tenant-id` or a URL path is refused (403) — otherwise
77
+ an unauthenticated client picks the tenant its request runs in. Raise the
78
+ header with `createHeaderResolver({ trust: "verified" })` only where a trusted
79
+ proxy strips and re-sets it at the edge, or pass `minimumTrust: "untrusted"`
80
+ explicitly on routes where something else ties the tenant to the principal.
61
81
 
62
82
  ## Features
63
83
 
64
- - Tenant resolution from JWT claims, subdomain, custom domain, header, or path
84
+ - Tenant resolution from JWT claims, subdomain (by id or slug), custom domain
85
+ (`createDomainResolver`), header, or path
65
86
  - Resolver chains with trust grading and conflict detection
66
87
  - AsyncLocalStorage context propagation
67
88
  - Tenant repository with slug and custom-domain indexes
@@ -73,12 +94,22 @@ strips and re-sets the header at the edge.
73
94
  returning `undefined` means "found nothing" and advances to the next
74
95
  resolver — so a failed JWT verification can never fall through to a
75
96
  client-supplied header.
76
- - Resolvers disagreeing about the tenant throws by default.
97
+ - Conflict detection is **opt-in**: by default a chain stops at the first
98
+ resolver that finds a tenant (highest priority wins) and does not look at
99
+ the others. Pass `createResolverChain(resolvers, { detectConflicts: true })`
100
+ to run them all and throw `TenantResolutionConflictError` when they
101
+ disagree; the middleware answers that with 403.
77
102
  - Tenant ids are normalized (NFKC, trimmed, lowercased) and constrained to
78
103
  `^[a-z0-9][a-z0-9_-]*$`, so an id cannot forge a separator in a cache key,
79
104
  a schema name, or a path. `tenantKey` escapes its segments as well.
80
105
  - Non-active tenants are refused during resolution. Pass `allowInactive` on
81
106
  routes that exist to serve suspended tenants.
107
+ - An unknown tenant and a non-active one get the same `404 Tenant not found`,
108
+ and the guard middleware answers `403 Tenant is not available` without the
109
+ id or status, so a caller cannot enumerate tenants or learn which are
110
+ suspended.
111
+ - A subdomain or path value is looked up by id first, then by slug
112
+ (`repository.findBySlug`); pass `slugLookup: false` to turn that off.
82
113
  - A request that resolves to no tenant at all is answered `404`. Pass
83
114
  `optional: true` to `createResolveTenantMiddleware` on routes where a tenant
84
115
  may be absent; a tenant that _was_ named but is unknown, untrusted or
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * @module http/httpResolverContext
10
10
  */
11
+ import { readRequestHeader } from "./httpSupport/index.js";
11
12
  /** State key under which upstream auth middleware publishes token claims. */
12
13
  export const TENANT_CLAIMS_STATE_KEY = "tenancy:claims";
13
14
  /**
@@ -22,10 +23,10 @@ export const TENANT_CLAIMS_STATE_KEY = "tenancy:claims";
22
23
  export function createHttpResolverContext(context, getClaims) {
23
24
  return {
24
25
  getHeader(name) {
25
- return context.request.headers.get(name.toLowerCase());
26
+ return readRequestHeader(context.request, name);
26
27
  },
27
28
  getHost() {
28
- return context.request.headers.get("host");
29
+ return readRequestHeader(context.request, "host");
29
30
  },
30
31
  getPath() {
31
32
  return context.request.path;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Portable reads from an HTTP request's headers.
3
+ *
4
+ * @module http/httpRequest.helper
5
+ */
6
+ import type { HttpRequestContext } from "../httpTypes.js";
7
+ /**
8
+ * Read a request header, whatever shape the request carries.
9
+ *
10
+ * The real `@zudojs/http` request exposes `headers` as a frozen plain object
11
+ * and a `getHeader(name)` accessor; the local mirror used to type `headers`
12
+ * as a `ReadonlyMap` and call `.get()`, which threw on every real request.
13
+ * `getHeader` is preferred when present, then a `Map`/`Headers`-like `get`,
14
+ * then a plain object — each case-insensitive.
15
+ */
16
+ export declare function readRequestHeader(request: HttpRequestContext, name: string): string | undefined;
17
+ //# sourceMappingURL=httpRequest.helper.d.ts.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Portable reads from an HTTP request's headers.
3
+ *
4
+ * @module http/httpRequest.helper
5
+ */
6
+ /** Read one entry from a map-shaped or record-shaped bag, case-insensitively. */
7
+ function readBag(bag, name) {
8
+ if (!bag)
9
+ return undefined;
10
+ const lower = name.toLowerCase();
11
+ if (typeof bag.get === "function") {
12
+ const map = bag;
13
+ const direct = map.get(lower) ?? map.get(name);
14
+ if (direct !== undefined)
15
+ return direct;
16
+ for (const [key, value] of map) {
17
+ if (key.toLowerCase() === lower)
18
+ return value;
19
+ }
20
+ return undefined;
21
+ }
22
+ const record = bag;
23
+ if (Object.hasOwn(record, lower))
24
+ return record[lower];
25
+ for (const key of Object.keys(record)) {
26
+ if (key.toLowerCase() === lower)
27
+ return record[key];
28
+ }
29
+ return undefined;
30
+ }
31
+ /**
32
+ * Read a request header, whatever shape the request carries.
33
+ *
34
+ * The real `@zudojs/http` request exposes `headers` as a frozen plain object
35
+ * and a `getHeader(name)` accessor; the local mirror used to type `headers`
36
+ * as a `ReadonlyMap` and call `.get()`, which threw on every real request.
37
+ * `getHeader` is preferred when present, then a `Map`/`Headers`-like `get`,
38
+ * then a plain object — each case-insensitive.
39
+ */
40
+ export function readRequestHeader(request, name) {
41
+ if (typeof request.getHeader === "function") {
42
+ const value = request.getHeader(name);
43
+ if (value !== undefined)
44
+ return value;
45
+ }
46
+ return readBag(request.headers, name);
47
+ }
48
+ //# sourceMappingURL=httpRequest.helper.js.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Support for the tenancy HTTP middleware: portable header reads, and loading
3
+ * the tenant a resolution names (by id, then by slug).
4
+ *
5
+ * @module http/httpSupport
6
+ */
7
+ export { readRequestHeader } from "./httpRequest.helper.js";
8
+ export { loadResolvedTenant } from "./tenancyMiddleware.lookup.js";
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Support for the tenancy HTTP middleware: portable header reads, and loading
3
+ * the tenant a resolution names (by id, then by slug).
4
+ *
5
+ * @module http/httpSupport
6
+ */
7
+ export { readRequestHeader } from "./httpRequest.helper.js";
8
+ export { loadResolvedTenant } from "./tenancyMiddleware.lookup.js";
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Loading the tenant a resolution names.
3
+ *
4
+ * @module http/tenancyMiddleware.lookup
5
+ */
6
+ import type { Tenant } from "../../tenancyTypes/tenantInterface.js";
7
+ import type { TenantResolution } from "../../tenancyTypes/resolverTypes.js";
8
+ import type { TenantRepository } from "../../tenancyTypes/repositoryTypes.js";
9
+ /**
10
+ * Load the tenant for a resolution.
11
+ *
12
+ * A subdomain or path segment names a tenant by its slug as often as by its
13
+ * id — `acme.example.com` for the tenant whose id is `t-1001`. The id is
14
+ * tried first; for those two sources the repository's `findBySlug` is the
15
+ * fallback, when it has one and `slugLookup` is not `false`. Without it,
16
+ * every tenant whose id differs from its slug was unreachable.
17
+ */
18
+ export declare function loadResolvedTenant(repository: TenantRepository, resolution: TenantResolution, slugLookup: boolean): Promise<Tenant | undefined>;
19
+ //# sourceMappingURL=tenancyMiddleware.lookup.d.ts.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Loading the tenant a resolution names.
3
+ *
4
+ * @module http/tenancyMiddleware.lookup
5
+ */
6
+ /** Sources whose value is a human-facing name, not necessarily the id. */
7
+ const SLUG_SOURCES = new Set(["subdomain", "path"]);
8
+ /**
9
+ * Load the tenant for a resolution.
10
+ *
11
+ * A subdomain or path segment names a tenant by its slug as often as by its
12
+ * id — `acme.example.com` for the tenant whose id is `t-1001`. The id is
13
+ * tried first; for those two sources the repository's `findBySlug` is the
14
+ * fallback, when it has one and `slugLookup` is not `false`. Without it,
15
+ * every tenant whose id differs from its slug was unreachable.
16
+ */
17
+ export async function loadResolvedTenant(repository, resolution, slugLookup) {
18
+ const byId = await repository.findById(resolution.tenantId);
19
+ if (byId)
20
+ return byId;
21
+ if (!slugLookup || !SLUG_SOURCES.has(resolution.source))
22
+ return undefined;
23
+ return repository.findBySlug?.(resolution.tenantId);
24
+ }
25
+ //# sourceMappingURL=tenancyMiddleware.lookup.js.map
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Local HTTP type definitions for the middleware adapter.
3
3
  *
4
- * Mirrors @zudojs/http types to avoid hard dependency.
4
+ * Mirrors @zudojs/http types structurally. http sits in a higher architecture
5
+ * tier, so tenancy may not depend on it, even as a peer. A test runs the
6
+ * middleware inside the real `HttpMiddlewarePipeline` to keep the two in step.
5
7
  *
6
8
  * @module http/httpTypes
7
9
  */
@@ -15,20 +17,32 @@ export interface HttpMiddlewareContext {
15
17
  readonly signal: AbortSignal;
16
18
  readonly metadata: Readonly<Record<string, unknown>>;
17
19
  }
20
+ /**
21
+ * A request's headers, params or query in either shape a caller may hold.
22
+ *
23
+ * The real `@zudojs/http` request exposes plain frozen objects
24
+ * (`Readonly<Record<…>>`). This mirror used to say `ReadonlyMap`, so the
25
+ * middleware called `.get()` on an object that has none and threw on every
26
+ * real request. Both shapes are accepted; read headers through
27
+ * `readRequestHeader`.
28
+ */
29
+ export type HttpRequestBag<V> = ReadonlyMap<string, V> | Readonly<Record<string, V | undefined>>;
18
30
  /** HTTP request context from @zudojs/http. */
19
31
  export interface HttpRequestContext {
20
- readonly id: string;
21
- readonly method: string;
22
- readonly url: string;
32
+ readonly id?: string;
33
+ readonly method?: string;
34
+ readonly url?: string;
23
35
  readonly path: string;
24
- readonly headers: ReadonlyMap<string, string>;
25
- readonly params: ReadonlyMap<string, string>;
26
- readonly query: ReadonlyMap<string, string | readonly string[] | undefined>;
36
+ readonly headers: HttpRequestBag<string>;
37
+ readonly params?: HttpRequestBag<string>;
38
+ readonly query?: HttpRequestBag<string | readonly string[]>;
39
+ /** Case-insensitive header lookup, as `@zudojs/http` provides it. */
40
+ getHeader?(name: string): string | undefined;
27
41
  }
28
42
  /** HTTP response context from @zudojs/http. */
29
43
  export interface HttpResponseContext {
30
44
  readonly status: number;
31
- readonly headers: Headers | Record<string, string>;
45
+ readonly headers: Headers | Readonly<Record<string, string | readonly string[] | undefined>>;
32
46
  readonly body?: unknown;
33
47
  }
34
48
  /** HTTP middleware state from @zudojs/http. */
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Local HTTP type definitions for the middleware adapter.
3
3
  *
4
- * Mirrors @zudojs/http types to avoid hard dependency.
4
+ * Mirrors @zudojs/http types structurally. http sits in a higher architecture
5
+ * tier, so tenancy may not depend on it, even as a peer. A test runs the
6
+ * middleware inside the real `HttpMiddlewarePipeline` to keep the two in step.
5
7
  *
6
8
  * @module http/httpTypes
7
9
  */
@@ -7,6 +7,7 @@ export { createTenantGuardMiddleware, createTenantPropagationMiddleware, } from
7
7
  export type { TenantGuardMiddlewareOptions } from "./tenancyMiddleware.guard.js";
8
8
  export { TENANT_CLAIMS_STATE_KEY, createHttpResolverContext, } from "./httpResolverContext.js";
9
9
  export type { HttpResolverContext, TenantClaims, } from "./httpResolverContext.js";
10
+ export { readRequestHeader } from "./httpSupport/index.js";
10
11
  export { createBadRequest, createForbidden, createJsonErrorResponse, createNotFound, createUnauthorized, } from "./httpHelpers.js";
11
12
  export type * from "./httpTypes.js";
12
13
  //# sourceMappingURL=index.d.ts.map
@@ -4,5 +4,6 @@
4
4
  export { TENANT_CONTEXT_STATE_KEY, TENANT_STATE_KEY, createRequireTenantMiddleware, createResolveTenantMiddleware, } from "./tenancyMiddleware.core.js";
5
5
  export { createTenantGuardMiddleware, createTenantPropagationMiddleware, } from "./tenancyMiddleware.guard.js";
6
6
  export { TENANT_CLAIMS_STATE_KEY, createHttpResolverContext, } from "./httpResolverContext.js";
7
+ export { readRequestHeader } from "./httpSupport/index.js";
7
8
  export { createBadRequest, createForbidden, createJsonErrorResponse, createNotFound, createUnauthorized, } from "./httpHelpers.js";
8
9
  //# sourceMappingURL=index.js.map
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @module http/tenancyMiddleware
7
7
  *
8
- * Requires @zudojs/http as a peer dependency.
8
+ * Composes with the @zudojs/http pipeline structurally; no dependency on it.
9
9
  */
10
10
  import type { Tenant, TenantRequirement, TenantTrustLevel } from "../tenancyTypes/tenantInterface.js";
11
11
  import type { TenantResolver, TenantResolution } from "../tenancyTypes/resolverTypes.js";
@@ -26,12 +26,20 @@ export interface ResolveTenantMiddlewareOptions {
26
26
  /** Tenant context storage for propagation. */
27
27
  readonly storage: TenantContextStorage;
28
28
  /**
29
- * Minimum trust the resolution must carry. Defaults to `untrusted`.
29
+ * Minimum trust the resolution must carry. Defaults to `verified`.
30
30
  *
31
- * Set this on any route where a tenant resolved from a URL path or an
32
- * unverified header must not be honoured.
31
+ * A tenant resolved from a client-supplied header or a URL path is
32
+ * `untrusted`, so by default it is refused (403): the client would be
33
+ * choosing which tenant's context its request runs in. Pass
34
+ * `minimumTrust: "untrusted"` to opt down explicitly, only on routes where
35
+ * something else ties the tenant to the principal.
33
36
  */
34
37
  readonly minimumTrust?: TenantTrustLevel;
38
+ /**
39
+ * Fall back to `repository.findBySlug` when a subdomain or path resolution
40
+ * names no tenant by id. Default: `true`.
41
+ */
42
+ readonly slugLookup?: boolean;
35
43
  /**
36
44
  * Whether a non-active tenant may proceed. Defaults to false.
37
45
  *
@@ -5,11 +5,12 @@
5
5
  *
6
6
  * @module http/tenancyMiddleware
7
7
  *
8
- * Requires @zudojs/http as a peer dependency.
8
+ * Composes with the @zudojs/http pipeline structurally; no dependency on it.
9
9
  */
10
10
  import { createHttpResolverContext } from "./httpResolverContext.js";
11
11
  import { createBadRequest, createForbidden, createNotFound, createUnauthorized, } from "./httpHelpers.js";
12
12
  import { meetsTrustLevel } from "../security/guard.core.js";
13
+ import { loadResolvedTenant } from "./httpSupport/index.js";
13
14
  import { TenantResolutionConflictError, TenantResolutionError, } from "../tenancyErrors/tenancyError.types.js";
14
15
  // ─── State Keys ───────────────────────────────────────────────────────────
15
16
  /** State key for the resolved tenant. */
@@ -25,7 +26,17 @@ export const TENANT_CONTEXT_STATE_KEY = "tenancy:context";
25
26
  * middleware being installed: the safe behaviour has to be the default.
26
27
  */
27
28
  export function createResolveTenantMiddleware(options) {
28
- const minimumTrust = options.minimumTrust ?? "untrusted";
29
+ const minimumTrust = options.minimumTrust ?? "verified";
30
+ const slugLookup = options.slugLookup !== false;
31
+ // Unknown and unavailable tenants get the same answer, so a caller cannot
32
+ // learn which tenant ids exist or which of them are suspended.
33
+ const notFound = (resolution) => options.notFoundResponse
34
+ ? {
35
+ status: 404,
36
+ body: options.notFoundResponse(resolution),
37
+ headers: { "content-type": "application/json" },
38
+ }
39
+ : createNotFound("Tenant not found");
29
40
  return async (context, next) => {
30
41
  let resolution;
31
42
  try {
@@ -47,29 +58,14 @@ export function createResolveTenantMiddleware(options) {
47
58
  if (!resolution) {
48
59
  if (options.optional)
49
60
  return next();
50
- return options.notFoundResponse
51
- ? {
52
- status: 404,
53
- body: options.notFoundResponse(resolution),
54
- headers: { "content-type": "application/json" },
55
- }
56
- : createNotFound("Tenant not found");
61
+ return notFound(resolution);
57
62
  }
58
63
  if (!meetsTrustLevel(resolution.trust, minimumTrust)) {
59
64
  return createForbidden("Tenant could not be established for this route");
60
65
  }
61
- const tenant = await options.repository.findById(resolution.tenantId);
62
- if (!tenant) {
63
- return options.notFoundResponse
64
- ? {
65
- status: 404,
66
- body: options.notFoundResponse(resolution),
67
- headers: { "content-type": "application/json" },
68
- }
69
- : createNotFound("Tenant not found");
70
- }
71
- if (!options.allowInactive && tenant.status !== "active") {
72
- return createForbidden("Tenant is not available");
66
+ const tenant = await loadResolvedTenant(options.repository, resolution, slugLookup);
67
+ if (!tenant || (!options.allowInactive && tenant.status !== "active")) {
68
+ return notFound(resolution);
73
69
  }
74
70
  const tenantContext = {
75
71
  tenantId: tenant.id,
@@ -21,11 +21,10 @@ export function createTenantGuardMiddleware(options = {}) {
21
21
  const current = options.repository
22
22
  ? await options.repository.findById(tenant.id)
23
23
  : tenant;
24
- if (!current) {
25
- return createForbidden(`Tenant "${tenant.id}" is no longer available`);
26
- }
27
- if (current.status !== "active") {
28
- return createForbidden(`Tenant "${current.id}" is not available (status: ${current.status})`);
24
+ // One answer for "gone" and "not active", naming neither the tenant nor
25
+ // its status: the body goes to the client, which must not learn either.
26
+ if (!current || current.status !== "active") {
27
+ return createForbidden("Tenant is not available");
29
28
  }
30
29
  return next();
31
30
  };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Custom-domain tenant resolver.
3
+ *
4
+ * @module resolvers/domainResolver
5
+ */
6
+ import type { TenantResolver } from "../../tenancyTypes/resolverTypes.js";
7
+ import type { TenantId } from "../../tenancyTypes/tenantIdentity.js";
8
+ import type { Tenant } from "../../tenancyTypes/tenantInterface.js";
9
+ /** Context type with a getHost method. */
10
+ export interface DomainContext {
11
+ getHost(): string | undefined;
12
+ }
13
+ /** Where a domain is looked up. Supply one of the two. */
14
+ export interface DomainResolverOptions {
15
+ /** A registry from `createDomainRegistry()`, or anything shaped like it. */
16
+ readonly registry?: {
17
+ resolve(domain: string): TenantId | undefined;
18
+ };
19
+ /** A repository with `findByDomain`, e.g. `createMemoryTenantRepository()`. */
20
+ readonly repository?: {
21
+ findByDomain?(domain: string): Promise<Tenant | undefined>;
22
+ };
23
+ /** Defaults to 75, between the header (80) and subdomain (70) resolvers. */
24
+ readonly priority?: number;
25
+ }
26
+ /**
27
+ * Create a tenant resolver that maps the request's host to a tenant through
28
+ * a registered custom domain.
29
+ *
30
+ * Example: `acme.io` registered to tenant `t-1001` → tenant `t-1001`, source
31
+ * `domain`, trust `verified`. A host nobody registered resolves to nothing,
32
+ * so the chain moves on.
33
+ */
34
+ export declare function createDomainResolver(options: DomainResolverOptions): TenantResolver<DomainContext>;
35
+ //# sourceMappingURL=domainResolver.core.d.ts.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Custom-domain tenant resolver.
3
+ *
4
+ * @module resolvers/domainResolver
5
+ */
6
+ import { hostnameOf } from "./subdomainResolver.core.js";
7
+ /**
8
+ * Create a tenant resolver that maps the request's host to a tenant through
9
+ * a registered custom domain.
10
+ *
11
+ * Example: `acme.io` registered to tenant `t-1001` → tenant `t-1001`, source
12
+ * `domain`, trust `verified`. A host nobody registered resolves to nothing,
13
+ * so the chain moves on.
14
+ */
15
+ export function createDomainResolver(options) {
16
+ const priority = options.priority ?? 75;
17
+ return {
18
+ name: "domain",
19
+ priority,
20
+ async resolve(context) {
21
+ const host = context.getHost();
22
+ const hostname = host ? hostnameOf(host)?.toLowerCase() : undefined;
23
+ if (!hostname)
24
+ return undefined;
25
+ const tenantId = options.registry?.resolve(hostname) ??
26
+ (await options.repository?.findByDomain?.(hostname))?.id;
27
+ if (!tenantId)
28
+ return undefined;
29
+ return { tenantId, source: "domain", trust: "verified" };
30
+ },
31
+ };
32
+ }
33
+ //# sourceMappingURL=domainResolver.core.js.map
@@ -7,6 +7,8 @@ export { createHeaderResolver } from "./headerResolver.core.js";
7
7
  export type { HeaderContext, HeaderResolverOptions, } from "./headerResolver.core.js";
8
8
  export { createSubdomainResolver } from "./subdomainResolver.core.js";
9
9
  export type { SubdomainContext, SubdomainResolverOptions, } from "./subdomainResolver.core.js";
10
+ export { createDomainResolver } from "./domainResolver.core.js";
11
+ export type { DomainContext, DomainResolverOptions, } from "./domainResolver.core.js";
10
12
  export { createPathResolver } from "./pathResolver.core.js";
11
13
  export type { PathContext, PathResolverOptions } from "./pathResolver.core.js";
12
14
  export { createJwtResolver } from "./jwtResolver.core.js";
@@ -5,6 +5,7 @@
5
5
  */
6
6
  export { createHeaderResolver } from "./headerResolver.core.js";
7
7
  export { createSubdomainResolver } from "./subdomainResolver.core.js";
8
+ export { createDomainResolver } from "./domainResolver.core.js";
8
9
  export { createPathResolver } from "./pathResolver.core.js";
9
10
  export { createJwtResolver } from "./jwtResolver.core.js";
10
11
  //# sourceMappingURL=index.js.map
@@ -24,6 +24,12 @@ export interface SubdomainResolverOptions {
24
24
  */
25
25
  readonly allowMultiLabel?: boolean;
26
26
  }
27
+ /**
28
+ * Strip the port from an authority, handling bracketed IPv6 literals.
29
+ *
30
+ * Shared with the domain resolver; not part of the public API.
31
+ */
32
+ export declare function hostnameOf(host: string): string | undefined;
27
33
  /**
28
34
  * Create a tenant resolver that extracts tenant from subdomain.
29
35
  *
@@ -6,8 +6,10 @@
6
6
  import { tryCreateTenantId } from "../../tenancyTypes/tenantIdentity.js";
7
7
  /**
8
8
  * Strip the port from an authority, handling bracketed IPv6 literals.
9
+ *
10
+ * Shared with the domain resolver; not part of the public API.
9
11
  */
10
- function hostnameOf(host) {
12
+ export function hostnameOf(host) {
11
13
  const trimmed = host.trim();
12
14
  if (trimmed.length === 0)
13
15
  return undefined;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @module tenancyTypes
5
5
  */
6
- export { type TenantId, MAX_TENANT_ID_LENGTH, createTenantId, tryCreateTenantId, isValidTenantId, type TenantStatus, } from "./tenantIdentity.js";
6
+ export { type TenantId, MAX_TENANT_ID_LENGTH, TENANT_ID_PATTERN, createTenantId, tryCreateTenantId, isValidTenantId, type TenantStatus, } from "./tenantIdentity.js";
7
7
  export { type Tenant, type TenantResolutionSource, type TenantTrustLevel, type TenantContext, type SystemContext, type TenantExecutionContext, type ExecutionTenantContext, type TenantRequirement, type TenantResource, } from "./tenantInterface.js";
8
8
  export { type TenantResolution, type TenantResolutionResult, type TenantResolver, type ResolverChainOptions, } from "./resolverTypes.js";
9
9
  export { type TenantRepository, type TenantCache, } from "./repositoryTypes.js";
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @module tenancyTypes
5
5
  */
6
- export { MAX_TENANT_ID_LENGTH, createTenantId, tryCreateTenantId, isValidTenantId, } from "./tenantIdentity.js";
6
+ export { MAX_TENANT_ID_LENGTH, TENANT_ID_PATTERN, createTenantId, tryCreateTenantId, isValidTenantId, } from "./tenantIdentity.js";
7
7
  export {} from "./tenantInterface.js";
8
8
  export {} from "./resolverTypes.js";
9
9
  export {} from "./repositoryTypes.js";
@@ -3,19 +3,31 @@
3
3
  *
4
4
  * @module tenancyTypes/tenantIdentity
5
5
  */
6
- /** Branded tenant ID type. */
7
- declare const TenantIdBrand: unique symbol;
8
- /** A unique, validated tenant identifier. */
9
- export type TenantId = string & {
10
- readonly [TenantIdBrand]: true;
11
- };
12
- /** Maximum accepted tenant id length. */
13
- export declare const MAX_TENANT_ID_LENGTH = 64;
6
+ import { MAX_TENANT_ID_LENGTH, TENANT_ID_PATTERN, type TenantId } from "@zudojs/constants";
7
+ /**
8
+ * A tenant identifier.
9
+ *
10
+ * Owned by `@zudojs/constants` and re-exported here, so the monorepo has one
11
+ * branded `TenantId`: a value typed by either package is accepted by both.
12
+ * Tenancy used to declare its own, incompatible brand.
13
+ */
14
+ export type { TenantId };
15
+ /**
16
+ * Maximum accepted tenant id length, and the allowed character pattern.
17
+ *
18
+ * Both are owned by `@zudojs/constants` and re-exported, so tenancy and
19
+ * constants apply one rule. A tenant id is concatenated into cache keys, log
20
+ * lines, schema names and file paths, so anything that could act as a
21
+ * separator or a path segment is rejected rather than escaped at every use.
22
+ */
23
+ export { MAX_TENANT_ID_LENGTH, TENANT_ID_PATTERN };
14
24
  /**
15
25
  * Create a validated TenantId.
16
26
  *
17
- * Input is trimmed, Unicode-normalized and lowercased before validation, so
18
- * two spellings of the same identifier cannot become two tenants.
27
+ * Delegates to `createTenantId` in `@zudojs/constants` (NFKC-normalize,
28
+ * trim, lowercase, then {@link TENANT_ID_PATTERN} and
29
+ * {@link MAX_TENANT_ID_LENGTH}), so the two packages cannot disagree, and
30
+ * rethrows its rejection as this package's {@link InvalidTenantIdError}.
19
31
  *
20
32
  * @param value - The candidate identifier.
21
33
  * @returns The normalized, validated tenant id.
@@ -36,5 +48,4 @@ export declare function tryCreateTenantId(value: unknown): TenantId | undefined;
36
48
  export declare function isValidTenantId(value: unknown): value is TenantId;
37
49
  /** Tenant lifecycle status. */
38
50
  export type TenantStatus = "provisioning" | "active" | "inactive" | "suspended" | "deleting" | "deleted";
39
- export {};
40
51
  //# sourceMappingURL=tenantIdentity.d.ts.map
@@ -3,23 +3,24 @@
3
3
  *
4
4
  * @module tenancyTypes/tenantIdentity
5
5
  */
6
+ import { createTenantId as createConstantsTenantId, MAX_TENANT_ID_LENGTH, TENANT_ID_PATTERN, } from "@zudojs/constants";
6
7
  import { InvalidTenantIdError } from "../tenancyErrors/tenancyError.types.js";
7
8
  /**
8
- * Characters a tenant id may contain.
9
+ * Maximum accepted tenant id length, and the allowed character pattern.
9
10
  *
10
- * Deliberately narrow. A tenant id is concatenated into cache keys, log lines,
11
- * schema names and file paths, so anything that could act as a separator or a
12
- * path segment in one of those contexts is rejected here rather than escaped
13
- * at every use site.
11
+ * Both are owned by `@zudojs/constants` and re-exported, so tenancy and
12
+ * constants apply one rule. A tenant id is concatenated into cache keys, log
13
+ * lines, schema names and file paths, so anything that could act as a
14
+ * separator or a path segment is rejected rather than escaped at every use.
14
15
  */
15
- const TENANT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u;
16
- /** Maximum accepted tenant id length. */
17
- export const MAX_TENANT_ID_LENGTH = 64;
16
+ export { MAX_TENANT_ID_LENGTH, TENANT_ID_PATTERN };
18
17
  /**
19
18
  * Create a validated TenantId.
20
19
  *
21
- * Input is trimmed, Unicode-normalized and lowercased before validation, so
22
- * two spellings of the same identifier cannot become two tenants.
20
+ * Delegates to `createTenantId` in `@zudojs/constants` (NFKC-normalize,
21
+ * trim, lowercase, then {@link TENANT_ID_PATTERN} and
22
+ * {@link MAX_TENANT_ID_LENGTH}), so the two packages cannot disagree, and
23
+ * rethrows its rejection as this package's {@link InvalidTenantIdError}.
23
24
  *
24
25
  * @param value - The candidate identifier.
25
26
  * @returns The normalized, validated tenant id.
@@ -28,14 +29,12 @@ export const MAX_TENANT_ID_LENGTH = 64;
28
29
  export function createTenantId(value) {
29
30
  if (typeof value !== "string")
30
31
  throw new InvalidTenantIdError(String(value));
31
- const normalized = value.normalize("NFKC").trim().toLowerCase();
32
- if (normalized.length === 0 || normalized.length > MAX_TENANT_ID_LENGTH) {
33
- throw new InvalidTenantIdError(value);
32
+ try {
33
+ return createConstantsTenantId(value);
34
34
  }
35
- if (!TENANT_ID_PATTERN.test(normalized)) {
35
+ catch {
36
36
  throw new InvalidTenantIdError(value);
37
37
  }
38
- return normalized;
39
38
  }
40
39
  /**
41
40
  * Create a validated TenantId, or undefined when the value is unusable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/tenancy",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Multi-tenant context and isolation with tenant resolution, AsyncLocalStorage propagation, resolver chains, trust levels, and guard middleware.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -25,16 +25,8 @@
25
25
  "!dist/.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@zudojs/errors": "1.0.1",
29
- "@zudojs/constants": "1.0.1"
30
- },
31
- "peerDependencies": {
32
- "@zudojs/http": "1.1.0"
33
- },
34
- "peerDependenciesMeta": {
35
- "@zudojs/http": {
36
- "optional": true
37
- }
28
+ "@zudojs/errors": "1.2.0",
29
+ "@zudojs/constants": "1.1.1"
38
30
  },
39
31
  "engines": {
40
32
  "node": ">=24.0.0"