@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 +39 -8
- package/dist/http/httpResolverContext.js +3 -2
- package/dist/http/httpSupport/httpRequest.helper.d.ts +17 -0
- package/dist/http/httpSupport/httpRequest.helper.js +48 -0
- package/dist/http/httpSupport/index.d.ts +9 -0
- package/dist/http/httpSupport/index.js +9 -0
- package/dist/http/httpSupport/tenancyMiddleware.lookup.d.ts +19 -0
- package/dist/http/httpSupport/tenancyMiddleware.lookup.js +25 -0
- package/dist/http/httpTypes.d.ts +22 -8
- package/dist/http/httpTypes.js +3 -1
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.js +1 -0
- package/dist/http/tenancyMiddleware.core.d.ts +12 -4
- package/dist/http/tenancyMiddleware.core.js +17 -21
- package/dist/http/tenancyMiddleware.guard.js +4 -5
- package/dist/resolver/resolvers/domainResolver.core.d.ts +35 -0
- package/dist/resolver/resolvers/domainResolver.core.js +33 -0
- package/dist/resolver/resolvers/index.d.ts +2 -0
- package/dist/resolver/resolvers/index.js +1 -0
- package/dist/resolver/resolvers/subdomainResolver.core.d.ts +6 -0
- package/dist/resolver/resolvers/subdomainResolver.core.js +3 -1
- package/dist/tenancyTypes/index.d.ts +1 -1
- package/dist/tenancyTypes/index.js +1 -1
- package/dist/tenancyTypes/tenantIdentity.d.ts +22 -11
- package/dist/tenancyTypes/tenantIdentity.js +14 -15
- package/package.json +3 -11
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
|
|
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
|
-
`
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
26
|
+
return readRequestHeader(context.request, name);
|
|
26
27
|
},
|
|
27
28
|
getHost() {
|
|
28
|
-
return context.request
|
|
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
|
package/dist/http/httpTypes.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local HTTP type definitions for the middleware adapter.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors @zudojs/http types
|
|
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
|
|
21
|
-
readonly method
|
|
22
|
-
readonly url
|
|
32
|
+
readonly id?: string;
|
|
33
|
+
readonly method?: string;
|
|
34
|
+
readonly url?: string;
|
|
23
35
|
readonly path: string;
|
|
24
|
-
readonly headers:
|
|
25
|
-
readonly params
|
|
26
|
-
readonly query
|
|
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. */
|
package/dist/http/httpTypes.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local HTTP type definitions for the middleware adapter.
|
|
3
3
|
*
|
|
4
|
-
* Mirrors @zudojs/http types
|
|
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
|
*/
|
package/dist/http/index.d.ts
CHANGED
|
@@ -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
|
package/dist/http/index.js
CHANGED
|
@@ -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
|
-
*
|
|
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 `
|
|
29
|
+
* Minimum trust the resolution must carry. Defaults to `verified`.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
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 ?? "
|
|
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
|
|
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
|
|
62
|
-
if (!tenant) {
|
|
63
|
-
return
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
9
|
+
* Maximum accepted tenant id length, and the allowed character pattern.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* at every use
|
|
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
|
-
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
throw new InvalidTenantIdError(value);
|
|
32
|
+
try {
|
|
33
|
+
return createConstantsTenantId(value);
|
|
34
34
|
}
|
|
35
|
-
|
|
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
|
|
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
|
|
29
|
-
"@zudojs/constants": "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"
|