@zudojs/tenancy 0.1.0 → 1.1.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/LICENSE +21 -0
- package/README.md +70 -13
- package/dist/context/contextManager.core.d.ts +12 -2
- package/dist/context/contextManager.core.js +34 -22
- package/dist/http/httpResolverContext.d.ts +35 -0
- package/dist/http/httpResolverContext.js +40 -0
- package/dist/http/index.d.ts +8 -8
- package/dist/http/index.js +4 -5
- package/dist/http/tenancyMiddleware.core.d.ts +34 -3
- package/dist/http/tenancyMiddleware.core.js +57 -35
- package/dist/http/tenancyMiddleware.guard.d.ts +13 -5
- package/dist/http/tenancyMiddleware.guard.js +11 -3
- package/dist/repository/index.d.ts +2 -1
- package/dist/repository/index.js +1 -1
- package/dist/repository/repository.core.d.ts +38 -7
- package/dist/repository/repository.core.js +69 -15
- package/dist/repository/tenantManager.core.d.ts +21 -3
- package/dist/repository/tenantManager.core.js +43 -14
- package/dist/resolver/index.d.ts +1 -0
- package/dist/resolver/resolverChain.core.d.ts +22 -7
- package/dist/resolver/resolverChain.core.js +45 -13
- package/dist/resolver/resolvers/headerResolver.core.d.ts +10 -2
- package/dist/resolver/resolvers/headerResolver.core.js +5 -8
- package/dist/resolver/resolvers/index.d.ts +9 -5
- package/dist/resolver/resolvers/index.js +5 -5
- package/dist/resolver/resolvers/jwtResolver.core.d.ts +1 -1
- package/dist/resolver/resolvers/jwtResolver.core.js +4 -8
- package/dist/resolver/resolvers/pathResolver.core.d.ts +7 -3
- package/dist/resolver/resolvers/pathResolver.core.js +12 -9
- package/dist/resolver/resolvers/subdomainResolver.core.d.ts +11 -2
- package/dist/resolver/resolvers/subdomainResolver.core.js +31 -14
- package/dist/security/guard.core.d.ts +19 -0
- package/dist/security/guard.core.js +58 -9
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/tenancyErrors/index.d.ts +1 -1
- package/dist/tenancyErrors/index.js +1 -1
- package/dist/tenancyErrors/tenancyError.types.d.ts +11 -6
- package/dist/tenancyErrors/tenancyError.types.js +15 -12
- package/dist/tenancyTypes/index.d.ts +3 -3
- package/dist/tenancyTypes/index.js +2 -2
- package/dist/tenancyTypes/repositoryTypes.d.ts +6 -21
- package/dist/tenancyTypes/resolverTypes.d.ts +14 -2
- package/dist/tenancyTypes/tenantIdentity.d.ts +24 -5
- package/dist/tenancyTypes/tenantIdentity.js +54 -4
- package/package.json +26 -15
- package/dist/.tsbuildinfo +0 -1
- package/dist/context/contextManager.core.d.ts.map +0 -1
- package/dist/context/contextManager.core.js.map +0 -1
- package/dist/context/contextStorage.core.d.ts.map +0 -1
- package/dist/context/contextStorage.core.js.map +0 -1
- package/dist/context/index.d.ts.map +0 -1
- package/dist/context/index.js.map +0 -1
- package/dist/http/httpHelpers.d.ts.map +0 -1
- package/dist/http/httpHelpers.js.map +0 -1
- package/dist/http/httpTypes.d.ts.map +0 -1
- package/dist/http/httpTypes.js.map +0 -1
- package/dist/http/index.d.ts.map +0 -1
- package/dist/http/index.js.map +0 -1
- package/dist/http/tenancyMiddleware.core.d.ts.map +0 -1
- package/dist/http/tenancyMiddleware.core.js.map +0 -1
- package/dist/http/tenancyMiddleware.guard.d.ts.map +0 -1
- package/dist/http/tenancyMiddleware.guard.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/repository/index.d.ts.map +0 -1
- package/dist/repository/index.js.map +0 -1
- package/dist/repository/repository.core.d.ts.map +0 -1
- package/dist/repository/repository.core.js.map +0 -1
- package/dist/repository/tenantManager.core.d.ts.map +0 -1
- package/dist/repository/tenantManager.core.js.map +0 -1
- package/dist/resolver/index.d.ts.map +0 -1
- package/dist/resolver/index.js.map +0 -1
- package/dist/resolver/resolverChain.core.d.ts.map +0 -1
- package/dist/resolver/resolverChain.core.js.map +0 -1
- package/dist/resolver/resolvers/headerResolver.core.d.ts.map +0 -1
- package/dist/resolver/resolvers/headerResolver.core.js.map +0 -1
- package/dist/resolver/resolvers/index.d.ts.map +0 -1
- package/dist/resolver/resolvers/index.js.map +0 -1
- package/dist/resolver/resolvers/jwtResolver.core.d.ts.map +0 -1
- package/dist/resolver/resolvers/jwtResolver.core.js.map +0 -1
- package/dist/resolver/resolvers/pathResolver.core.d.ts.map +0 -1
- package/dist/resolver/resolvers/pathResolver.core.js.map +0 -1
- package/dist/resolver/resolvers/subdomainResolver.core.d.ts.map +0 -1
- package/dist/resolver/resolvers/subdomainResolver.core.js.map +0 -1
- package/dist/security/guard.core.d.ts.map +0 -1
- package/dist/security/guard.core.js.map +0 -1
- package/dist/security/index.d.ts.map +0 -1
- package/dist/security/index.js.map +0 -1
- package/dist/tenancyErrors/index.d.ts.map +0 -1
- package/dist/tenancyErrors/index.js.map +0 -1
- package/dist/tenancyErrors/tenancyError.base.d.ts.map +0 -1
- package/dist/tenancyErrors/tenancyError.base.js.map +0 -1
- package/dist/tenancyErrors/tenancyError.types.d.ts.map +0 -1
- package/dist/tenancyErrors/tenancyError.types.js.map +0 -1
- package/dist/tenancyTypes/index.d.ts.map +0 -1
- package/dist/tenancyTypes/index.js.map +0 -1
- package/dist/tenancyTypes/repositoryTypes.d.ts.map +0 -1
- package/dist/tenancyTypes/repositoryTypes.js.map +0 -1
- package/dist/tenancyTypes/resolverTypes.d.ts.map +0 -1
- package/dist/tenancyTypes/resolverTypes.js.map +0 -1
- package/dist/tenancyTypes/tenancyOptions.d.ts +0 -37
- package/dist/tenancyTypes/tenancyOptions.d.ts.map +0 -1
- package/dist/tenancyTypes/tenancyOptions.js +0 -7
- package/dist/tenancyTypes/tenancyOptions.js.map +0 -1
- package/dist/tenancyTypes/tenantIdentity.d.ts.map +0 -1
- package/dist/tenancyTypes/tenantIdentity.js.map +0 -1
- package/dist/tenancyTypes/tenantInterface.d.ts.map +0 -1
- package/dist/tenancyTypes/tenantInterface.js.map +0 -1
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/utils.helper.d.ts.map +0 -1
- package/dist/utils/utils.helper.js.map +0 -1
|
@@ -5,20 +5,51 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { TenantId } from "../tenancyTypes/tenantIdentity.js";
|
|
7
7
|
import type { Tenant } from "../tenancyTypes/tenantInterface.js";
|
|
8
|
-
import type { TenantRepository } from "../tenancyTypes/repositoryTypes.js";
|
|
9
|
-
|
|
8
|
+
import type { TenantDomain, TenantRepository } from "../tenancyTypes/repositoryTypes.js";
|
|
9
|
+
/** A tenant plus the custom domains that resolve to it. */
|
|
10
|
+
export interface TenantWithDomains {
|
|
11
|
+
readonly tenant: Tenant;
|
|
12
|
+
readonly domains?: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
/** The in-memory repository plus its write surface. */
|
|
15
|
+
export interface MemoryTenantRepository extends TenantRepository {
|
|
16
|
+
/** Look a tenant up by its slug. Always present on this implementation. */
|
|
17
|
+
findBySlug(slug: string): Promise<Tenant | undefined>;
|
|
18
|
+
/** Look a tenant up by a registered custom domain. */
|
|
19
|
+
findByDomain(domain: string): Promise<Tenant | undefined>;
|
|
20
|
+
/**
|
|
21
|
+
* Insert or replace a tenant, re-indexing its slug and domains.
|
|
22
|
+
*
|
|
23
|
+
* @throws {TenantAlreadyExistsError} when the slug or one of the domains is
|
|
24
|
+
* already indexed to a different tenant.
|
|
25
|
+
*/
|
|
26
|
+
add(tenant: Tenant, domains?: readonly string[]): void;
|
|
27
|
+
/** Remove a tenant and every index entry pointing at it. */
|
|
28
|
+
remove(id: TenantId): void;
|
|
29
|
+
/** Every tenant currently stored. */
|
|
30
|
+
all(): readonly Tenant[];
|
|
31
|
+
/** Domains mapped to the given tenant. */
|
|
32
|
+
domainsOf(id: TenantId): readonly string[];
|
|
33
|
+
}
|
|
10
34
|
/**
|
|
11
35
|
* Create an in-memory tenant repository.
|
|
36
|
+
*
|
|
37
|
+
* Secondary indexes are rebuilt on every write. A tenant whose slug or domain
|
|
38
|
+
* changed must not stay reachable under its previous one: the stale entry
|
|
39
|
+
* would keep serving the pre-update record, including its pre-suspension
|
|
40
|
+
* status.
|
|
12
41
|
*/
|
|
13
|
-
export declare function createMemoryTenantRepository():
|
|
14
|
-
add(tenant: Tenant): void;
|
|
15
|
-
remove(id: TenantId): void;
|
|
16
|
-
all(): readonly Tenant[];
|
|
17
|
-
};
|
|
42
|
+
export declare function createMemoryTenantRepository(): MemoryTenantRepository;
|
|
18
43
|
/**
|
|
19
44
|
* Create a domain-to-tenant registry.
|
|
20
45
|
*/
|
|
21
46
|
export declare function createDomainRegistry(): {
|
|
47
|
+
/**
|
|
48
|
+
* Map a domain to a tenant.
|
|
49
|
+
*
|
|
50
|
+
* @throws {TenantAlreadyExistsError} when the domain already resolves to
|
|
51
|
+
* a different tenant. Re-registering the same pair is a no-op.
|
|
52
|
+
*/
|
|
22
53
|
register(domain: string, tenantId: TenantId): void;
|
|
23
54
|
unregister(domain: string): void;
|
|
24
55
|
resolve(domain: string): TenantId | undefined;
|
|
@@ -3,40 +3,83 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module repository/repository
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { TenantAlreadyExistsError } from "../tenancyErrors/tenancyError.types.js";
|
|
7
|
+
/** Normalizes a domain for case-insensitive lookup. */
|
|
8
|
+
function normalizeDomain(domain) {
|
|
9
|
+
return domain.trim().toLowerCase();
|
|
10
|
+
}
|
|
7
11
|
/**
|
|
8
12
|
* Create an in-memory tenant repository.
|
|
13
|
+
*
|
|
14
|
+
* Secondary indexes are rebuilt on every write. A tenant whose slug or domain
|
|
15
|
+
* changed must not stay reachable under its previous one: the stale entry
|
|
16
|
+
* would keep serving the pre-update record, including its pre-suspension
|
|
17
|
+
* status.
|
|
9
18
|
*/
|
|
10
19
|
export function createMemoryTenantRepository() {
|
|
11
20
|
const tenants = new Map();
|
|
12
21
|
const bySlug = new Map();
|
|
13
22
|
const byDomain = new Map();
|
|
23
|
+
function unindex(id) {
|
|
24
|
+
for (const [slug, owner] of bySlug) {
|
|
25
|
+
if (owner === id)
|
|
26
|
+
bySlug.delete(slug);
|
|
27
|
+
}
|
|
28
|
+
for (const [domain, owner] of byDomain) {
|
|
29
|
+
if (owner === id)
|
|
30
|
+
byDomain.delete(domain);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
14
33
|
return {
|
|
15
34
|
async findById(id) {
|
|
16
35
|
return tenants.get(id);
|
|
17
36
|
},
|
|
18
37
|
async findBySlug(slug) {
|
|
19
|
-
|
|
38
|
+
const id = bySlug.get(slug.trim().toLowerCase());
|
|
39
|
+
return id ? tenants.get(id) : undefined;
|
|
20
40
|
},
|
|
21
41
|
async findByDomain(domain) {
|
|
22
|
-
|
|
42
|
+
const id = byDomain.get(normalizeDomain(domain));
|
|
43
|
+
return id ? tenants.get(id) : undefined;
|
|
23
44
|
},
|
|
24
|
-
add(tenant) {
|
|
45
|
+
add(tenant, domains) {
|
|
46
|
+
// A slug or a domain already pointing at a different tenant must not be
|
|
47
|
+
// silently reassigned: `findByDomain` is a tenant resolution path, so a
|
|
48
|
+
// silent steal is a cross-tenant takeover, not a bookkeeping detail.
|
|
49
|
+
const slug = tenant.slug?.trim().toLowerCase();
|
|
50
|
+
if (slug) {
|
|
51
|
+
const owner = bySlug.get(slug);
|
|
52
|
+
if (owner !== undefined && owner !== tenant.id) {
|
|
53
|
+
throw new TenantAlreadyExistsError(`slug "${slug}" is already registered to tenant "${owner}"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const normalizedDomains = (domains ?? []).map(normalizeDomain);
|
|
57
|
+
for (const domain of normalizedDomains) {
|
|
58
|
+
const owner = byDomain.get(domain);
|
|
59
|
+
if (owner !== undefined && owner !== tenant.id) {
|
|
60
|
+
throw new TenantAlreadyExistsError(`domain "${domain}" is already registered to tenant "${owner}"`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
unindex(tenant.id);
|
|
25
64
|
tenants.set(tenant.id, tenant);
|
|
26
|
-
if (
|
|
27
|
-
bySlug.set(
|
|
65
|
+
if (slug)
|
|
66
|
+
bySlug.set(slug, tenant.id);
|
|
67
|
+
for (const domain of normalizedDomains) {
|
|
68
|
+
byDomain.set(domain, tenant.id);
|
|
69
|
+
}
|
|
28
70
|
},
|
|
29
71
|
remove(id) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
tenants.delete(id);
|
|
33
|
-
if (tenant.slug)
|
|
34
|
-
bySlug.delete(tenant.slug);
|
|
35
|
-
}
|
|
72
|
+
unindex(id);
|
|
73
|
+
tenants.delete(id);
|
|
36
74
|
},
|
|
37
75
|
all() {
|
|
38
76
|
return Array.from(tenants.values());
|
|
39
77
|
},
|
|
78
|
+
domainsOf(id) {
|
|
79
|
+
return Array.from(byDomain.entries())
|
|
80
|
+
.filter(([, owner]) => owner === id)
|
|
81
|
+
.map(([domain]) => domain);
|
|
82
|
+
},
|
|
40
83
|
};
|
|
41
84
|
}
|
|
42
85
|
/**
|
|
@@ -45,14 +88,25 @@ export function createMemoryTenantRepository() {
|
|
|
45
88
|
export function createDomainRegistry() {
|
|
46
89
|
const domains = new Map();
|
|
47
90
|
return {
|
|
91
|
+
/**
|
|
92
|
+
* Map a domain to a tenant.
|
|
93
|
+
*
|
|
94
|
+
* @throws {TenantAlreadyExistsError} when the domain already resolves to
|
|
95
|
+
* a different tenant. Re-registering the same pair is a no-op.
|
|
96
|
+
*/
|
|
48
97
|
register(domain, tenantId) {
|
|
49
|
-
|
|
98
|
+
const normalized = normalizeDomain(domain);
|
|
99
|
+
const owner = domains.get(normalized);
|
|
100
|
+
if (owner !== undefined && owner !== tenantId) {
|
|
101
|
+
throw new TenantAlreadyExistsError(`domain "${normalized}" is already registered to tenant "${owner}"`);
|
|
102
|
+
}
|
|
103
|
+
domains.set(normalized, tenantId);
|
|
50
104
|
},
|
|
51
105
|
unregister(domain) {
|
|
52
|
-
domains.delete(domain);
|
|
106
|
+
domains.delete(normalizeDomain(domain));
|
|
53
107
|
},
|
|
54
108
|
resolve(domain) {
|
|
55
|
-
return domains.get(domain);
|
|
109
|
+
return domains.get(normalizeDomain(domain));
|
|
56
110
|
},
|
|
57
111
|
all() {
|
|
58
112
|
return Array.from(domains.entries()).map(([domain, tenantId]) => ({
|
|
@@ -6,13 +6,22 @@
|
|
|
6
6
|
import type { TenantId } from "../tenancyTypes/tenantIdentity.js";
|
|
7
7
|
import type { Tenant } from "../tenancyTypes/tenantInterface.js";
|
|
8
8
|
import type { TenantResolution } from "../tenancyTypes/resolverTypes.js";
|
|
9
|
-
import type {
|
|
9
|
+
import type { TenantCache, TenantRepository } from "../tenancyTypes/repositoryTypes.js";
|
|
10
10
|
import type { TenantContextStorage } from "../context/contextStorage.core.js";
|
|
11
|
+
/** Default lifetime of a cached tenant record, in milliseconds. */
|
|
12
|
+
export declare const DEFAULT_TENANT_CACHE_TTL_MS = 30000;
|
|
11
13
|
/** Options for the tenant manager. */
|
|
12
14
|
export interface TenantManagerOptions {
|
|
13
15
|
readonly repository: TenantRepository;
|
|
14
16
|
readonly cache?: TenantCache;
|
|
15
17
|
readonly storage: TenantContextStorage;
|
|
18
|
+
/**
|
|
19
|
+
* How long a cached tenant may be served before it is re-read.
|
|
20
|
+
*
|
|
21
|
+
* Bounds the window in which a suspended tenant keeps being served from
|
|
22
|
+
* cache. Set to 0 to disable caching entirely.
|
|
23
|
+
*/
|
|
24
|
+
readonly cacheTtlMs?: number;
|
|
16
25
|
}
|
|
17
26
|
/**
|
|
18
27
|
* Create a tenant manager.
|
|
@@ -29,17 +38,26 @@ export declare function createTenantManager(options: TenantManagerOptions): {
|
|
|
29
38
|
/**
|
|
30
39
|
* Require a tenant by ID — throws if not found.
|
|
31
40
|
*/
|
|
32
|
-
require(id: TenantId)
|
|
41
|
+
require: (id: TenantId) => Promise<Tenant>;
|
|
42
|
+
/**
|
|
43
|
+
* Require a tenant that is also in an active state.
|
|
44
|
+
*
|
|
45
|
+
* Does not read `this`, so it survives being detached from the manager.
|
|
46
|
+
*/
|
|
47
|
+
requireActive(id: TenantId): Promise<Tenant>;
|
|
33
48
|
/**
|
|
34
49
|
* Validate that a tenant is in an active state.
|
|
35
50
|
*/
|
|
36
|
-
assertActive(tenant: Tenant)
|
|
51
|
+
assertActive: (tenant: Tenant) => void;
|
|
37
52
|
/**
|
|
38
53
|
* Get the current tenant from context.
|
|
39
54
|
*/
|
|
40
55
|
getCurrent(): Tenant | undefined;
|
|
41
56
|
/**
|
|
42
57
|
* Invalidate a cached tenant.
|
|
58
|
+
*
|
|
59
|
+
* Call after any write that changes a tenant's status, so the change is
|
|
60
|
+
* visible before the TTL would have expired.
|
|
43
61
|
*/
|
|
44
62
|
invalidate(id: TenantId): Promise<void>;
|
|
45
63
|
};
|
|
@@ -4,23 +4,47 @@
|
|
|
4
4
|
* @module repository/tenantManager
|
|
5
5
|
*/
|
|
6
6
|
import { TenantNotFoundError, TenantUnavailableError, } from "../tenancyErrors/tenancyError.types.js";
|
|
7
|
+
/** Default lifetime of a cached tenant record, in milliseconds. */
|
|
8
|
+
export const DEFAULT_TENANT_CACHE_TTL_MS = 30_000;
|
|
7
9
|
/**
|
|
8
10
|
* Create a tenant manager.
|
|
9
11
|
*/
|
|
10
12
|
export function createTenantManager(options) {
|
|
11
13
|
const { repository, cache, storage } = options;
|
|
14
|
+
const ttl = options.cacheTtlMs ?? DEFAULT_TENANT_CACHE_TTL_MS;
|
|
15
|
+
const cachedAt = new Map();
|
|
16
|
+
function isFresh(id) {
|
|
17
|
+
const at = cachedAt.get(id);
|
|
18
|
+
return at !== undefined && Date.now() - at < ttl;
|
|
19
|
+
}
|
|
12
20
|
async function loadTenant(id) {
|
|
13
|
-
|
|
14
|
-
if (cache) {
|
|
21
|
+
if (cache && ttl > 0 && isFresh(id)) {
|
|
15
22
|
const cached = await cache.get(id);
|
|
16
23
|
if (cached)
|
|
17
24
|
return cached;
|
|
18
25
|
}
|
|
19
|
-
// Load from repository
|
|
20
26
|
const tenant = await repository.findById(id);
|
|
21
|
-
if (
|
|
22
|
-
|
|
27
|
+
if (cache && ttl > 0) {
|
|
28
|
+
if (tenant) {
|
|
29
|
+
await cache.set(tenant);
|
|
30
|
+
cachedAt.set(id, Date.now());
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
await cache.delete(id);
|
|
34
|
+
cachedAt.delete(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return tenant;
|
|
38
|
+
}
|
|
39
|
+
function assertActive(tenant) {
|
|
40
|
+
if (tenant.status !== "active") {
|
|
41
|
+
throw new TenantUnavailableError(tenant.id, tenant.status);
|
|
23
42
|
}
|
|
43
|
+
}
|
|
44
|
+
async function require(id) {
|
|
45
|
+
const tenant = await loadTenant(id);
|
|
46
|
+
if (!tenant)
|
|
47
|
+
throw new TenantNotFoundError(id);
|
|
24
48
|
return tenant;
|
|
25
49
|
}
|
|
26
50
|
return {
|
|
@@ -39,20 +63,21 @@ export function createTenantManager(options) {
|
|
|
39
63
|
/**
|
|
40
64
|
* Require a tenant by ID — throws if not found.
|
|
41
65
|
*/
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
66
|
+
require,
|
|
67
|
+
/**
|
|
68
|
+
* Require a tenant that is also in an active state.
|
|
69
|
+
*
|
|
70
|
+
* Does not read `this`, so it survives being detached from the manager.
|
|
71
|
+
*/
|
|
72
|
+
async requireActive(id) {
|
|
73
|
+
const tenant = await require(id);
|
|
74
|
+
assertActive(tenant);
|
|
46
75
|
return tenant;
|
|
47
76
|
},
|
|
48
77
|
/**
|
|
49
78
|
* Validate that a tenant is in an active state.
|
|
50
79
|
*/
|
|
51
|
-
assertActive
|
|
52
|
-
if (tenant.status !== "active") {
|
|
53
|
-
throw new TenantUnavailableError(tenant.id, tenant.status);
|
|
54
|
-
}
|
|
55
|
-
},
|
|
80
|
+
assertActive,
|
|
56
81
|
/**
|
|
57
82
|
* Get the current tenant from context.
|
|
58
83
|
*/
|
|
@@ -64,8 +89,12 @@ export function createTenantManager(options) {
|
|
|
64
89
|
},
|
|
65
90
|
/**
|
|
66
91
|
* Invalidate a cached tenant.
|
|
92
|
+
*
|
|
93
|
+
* Call after any write that changes a tenant's status, so the change is
|
|
94
|
+
* visible before the TTL would have expired.
|
|
67
95
|
*/
|
|
68
96
|
async invalidate(id) {
|
|
97
|
+
cachedAt.delete(id);
|
|
69
98
|
if (cache)
|
|
70
99
|
await cache.delete(id);
|
|
71
100
|
},
|
package/dist/resolver/index.d.ts
CHANGED
|
@@ -3,14 +3,29 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module resolver/resolverChain
|
|
5
5
|
*/
|
|
6
|
-
import type { TenantResolver, TenantResolutionResult, ResolverChainOptions } from "../tenancyTypes/resolverTypes.js";
|
|
6
|
+
import type { TenantResolver, TenantResolution, TenantResolutionResult, ResolverChainOptions } from "../tenancyTypes/resolverTypes.js";
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* A resolver chain.
|
|
9
|
+
*
|
|
10
|
+
* Exposes the full result via `resolve`, and views itself as a single
|
|
11
|
+
* `TenantResolver` via `asResolver()` so chains compose and can be passed
|
|
12
|
+
* directly to the HTTP middleware.
|
|
9
13
|
*/
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*/
|
|
14
|
+
export interface TenantResolverChain<Context = unknown> {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly priority: number;
|
|
14
17
|
resolve(context: Context): Promise<TenantResolutionResult>;
|
|
15
|
-
|
|
18
|
+
resolveTenant(context: Context): Promise<TenantResolution | undefined>;
|
|
19
|
+
asResolver(): TenantResolver<Context>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Create a resolver chain that tries resolvers in priority order.
|
|
23
|
+
*
|
|
24
|
+
* A resolver that returns `undefined` found nothing, and the chain moves on.
|
|
25
|
+
* A resolver that *throws* rejected a credential — an expired JWT, a bad
|
|
26
|
+
* signature — and the chain stops there. Continuing would let a lower-trust
|
|
27
|
+
* source such as a client-supplied header decide the tenant for a request
|
|
28
|
+
* whose credential was just refused.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createResolverChain<Context = unknown>(resolvers: readonly TenantResolver<Context>[], options?: ResolverChainOptions): TenantResolverChain<Context>;
|
|
16
31
|
//# sourceMappingURL=resolverChain.core.d.ts.map
|
|
@@ -3,46 +3,78 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module resolver/resolverChain
|
|
5
5
|
*/
|
|
6
|
-
import { TenantResolutionConflictError } from "../tenancyErrors/tenancyError.types.js";
|
|
6
|
+
import { TenantResolutionConflictError, TenantResolutionError, } from "../tenancyErrors/tenancyError.types.js";
|
|
7
7
|
/**
|
|
8
8
|
* Create a resolver chain that tries resolvers in priority order.
|
|
9
|
+
*
|
|
10
|
+
* A resolver that returns `undefined` found nothing, and the chain moves on.
|
|
11
|
+
* A resolver that *throws* rejected a credential — an expired JWT, a bad
|
|
12
|
+
* signature — and the chain stops there. Continuing would let a lower-trust
|
|
13
|
+
* source such as a client-supplied header decide the tenant for a request
|
|
14
|
+
* whose credential was just refused.
|
|
9
15
|
*/
|
|
10
16
|
export function createResolverChain(resolvers, options) {
|
|
11
17
|
// Sort by priority descending (higher priority first)
|
|
12
18
|
const sorted = [...resolvers].sort((a, b) => b.priority - a.priority);
|
|
13
|
-
|
|
19
|
+
const collectAll = options?.detectConflicts ?? false;
|
|
20
|
+
const chain = {
|
|
21
|
+
name: options?.name ?? "chain",
|
|
22
|
+
priority: options?.priority ??
|
|
23
|
+
sorted.reduce((highest, r) => Math.max(highest, r.priority), 0),
|
|
14
24
|
/**
|
|
15
25
|
* Resolve tenant from context using the chain.
|
|
26
|
+
*
|
|
27
|
+
* @throws {TenantResolutionError} when a resolver rejects a credential.
|
|
28
|
+
* @throws {TenantResolutionConflictError} when sources disagree and
|
|
29
|
+
* `throwOnConflict` is set.
|
|
16
30
|
*/
|
|
17
31
|
async resolve(context) {
|
|
18
32
|
const candidates = [];
|
|
19
33
|
for (const resolver of sorted) {
|
|
34
|
+
let result;
|
|
20
35
|
try {
|
|
21
|
-
|
|
22
|
-
if (result) {
|
|
23
|
-
candidates.push(result);
|
|
24
|
-
}
|
|
36
|
+
result = await resolver.resolve(context);
|
|
25
37
|
}
|
|
26
|
-
catch {
|
|
27
|
-
|
|
38
|
+
catch (error) {
|
|
39
|
+
throw new TenantResolutionError(`Tenant resolver "${resolver.name}" rejected the request`, error);
|
|
28
40
|
}
|
|
41
|
+
if (!result)
|
|
42
|
+
continue;
|
|
43
|
+
candidates.push(result);
|
|
44
|
+
if (!collectAll)
|
|
45
|
+
break;
|
|
29
46
|
}
|
|
30
47
|
if (candidates.length === 0) {
|
|
31
48
|
return { resolution: undefined, candidates: [], conflict: false };
|
|
32
49
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const hasConflict = uniqueTenantIds.size > 1;
|
|
36
|
-
if (hasConflict && options?.throwOnConflict) {
|
|
50
|
+
const hasConflict = new Set(candidates.map((candidate) => candidate.tenantId)).size > 1;
|
|
51
|
+
if (hasConflict && (options?.throwOnConflict ?? true)) {
|
|
37
52
|
throw new TenantResolutionConflictError(candidates.map((c) => `${c.source}:${c.tenantId}`));
|
|
38
53
|
}
|
|
39
|
-
// Use highest priority (first in sorted list)
|
|
40
54
|
return {
|
|
41
55
|
resolution: candidates[0],
|
|
42
56
|
candidates,
|
|
43
57
|
conflict: hasConflict,
|
|
44
58
|
};
|
|
45
59
|
},
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the winning tenant, discarding the candidate detail.
|
|
62
|
+
*
|
|
63
|
+
* This is what makes a chain a `TenantResolver`, so chains compose with
|
|
64
|
+
* each other and can be handed straight to the HTTP middleware.
|
|
65
|
+
*/
|
|
66
|
+
async resolveTenant(context) {
|
|
67
|
+
return (await chain.resolve(context)).resolution;
|
|
68
|
+
},
|
|
69
|
+
/** View the chain as a single resolver. */
|
|
70
|
+
asResolver() {
|
|
71
|
+
return {
|
|
72
|
+
name: chain.name,
|
|
73
|
+
priority: chain.priority,
|
|
74
|
+
resolve: (context) => chain.resolveTenant(context),
|
|
75
|
+
};
|
|
76
|
+
},
|
|
46
77
|
};
|
|
78
|
+
return chain;
|
|
47
79
|
}
|
|
48
80
|
//# sourceMappingURL=resolverChain.core.js.map
|
|
@@ -4,18 +4,26 @@
|
|
|
4
4
|
* @module resolvers/headerResolver
|
|
5
5
|
*/
|
|
6
6
|
import type { TenantResolver } from "../../tenancyTypes/resolverTypes.js";
|
|
7
|
+
import type { TenantTrustLevel } from "../../tenancyTypes/tenantInterface.js";
|
|
7
8
|
/** Context type with a getHeader method. */
|
|
8
|
-
interface HeaderContext {
|
|
9
|
+
export interface HeaderContext {
|
|
9
10
|
getHeader(name: string): string | undefined;
|
|
10
11
|
}
|
|
11
12
|
/** Options for the header resolver. */
|
|
12
13
|
export interface HeaderResolverOptions {
|
|
13
14
|
readonly headerName?: string;
|
|
14
15
|
readonly priority?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Trust to assign to a tenant resolved from this header.
|
|
18
|
+
*
|
|
19
|
+
* Defaults to `untrusted`, because the header is client-supplied on the
|
|
20
|
+
* wire. Raise it to `verified` only when a trusted proxy strips and re-sets
|
|
21
|
+
* the header at the edge, so a client cannot forge it.
|
|
22
|
+
*/
|
|
23
|
+
readonly trust?: TenantTrustLevel;
|
|
15
24
|
}
|
|
16
25
|
/**
|
|
17
26
|
* Create a tenant resolver that reads from an HTTP header.
|
|
18
27
|
*/
|
|
19
28
|
export declare function createHeaderResolver(options?: HeaderResolverOptions): TenantResolver<HeaderContext>;
|
|
20
|
-
export {};
|
|
21
29
|
//# sourceMappingURL=headerResolver.core.d.ts.map
|
|
@@ -3,25 +3,22 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module resolvers/headerResolver
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { tryCreateTenantId } from "../../tenancyTypes/tenantIdentity.js";
|
|
7
7
|
/**
|
|
8
8
|
* Create a tenant resolver that reads from an HTTP header.
|
|
9
9
|
*/
|
|
10
10
|
export function createHeaderResolver(options) {
|
|
11
11
|
const headerName = options?.headerName ?? "x-tenant-id";
|
|
12
12
|
const priority = options?.priority ?? 80;
|
|
13
|
+
const trust = options?.trust ?? "untrusted";
|
|
13
14
|
return {
|
|
14
15
|
name: "header",
|
|
15
16
|
priority,
|
|
16
17
|
async resolve(context) {
|
|
17
|
-
const
|
|
18
|
-
if (!
|
|
18
|
+
const tenantId = tryCreateTenantId(context.getHeader(headerName));
|
|
19
|
+
if (!tenantId)
|
|
19
20
|
return undefined;
|
|
20
|
-
return {
|
|
21
|
-
tenantId: createTenantId(value),
|
|
22
|
-
source: "header",
|
|
23
|
-
trust: "verified",
|
|
24
|
-
};
|
|
21
|
+
return { tenantId, source: "header", trust };
|
|
25
22
|
},
|
|
26
23
|
};
|
|
27
24
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Built-in tenant resolvers.
|
|
3
3
|
*
|
|
4
|
-
* @module resolvers
|
|
4
|
+
* @module resolver/resolvers
|
|
5
5
|
*/
|
|
6
|
-
export { createHeaderResolver
|
|
7
|
-
export {
|
|
8
|
-
export {
|
|
9
|
-
export {
|
|
6
|
+
export { createHeaderResolver } from "./headerResolver.core.js";
|
|
7
|
+
export type { HeaderContext, HeaderResolverOptions, } from "./headerResolver.core.js";
|
|
8
|
+
export { createSubdomainResolver } from "./subdomainResolver.core.js";
|
|
9
|
+
export type { SubdomainContext, SubdomainResolverOptions, } from "./subdomainResolver.core.js";
|
|
10
|
+
export { createPathResolver } from "./pathResolver.core.js";
|
|
11
|
+
export type { PathContext, PathResolverOptions } from "./pathResolver.core.js";
|
|
12
|
+
export { createJwtResolver } from "./jwtResolver.core.js";
|
|
13
|
+
export type { JwtContext, JwtResolverOptions } from "./jwtResolver.core.js";
|
|
10
14
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Built-in tenant resolvers.
|
|
3
3
|
*
|
|
4
|
-
* @module resolvers
|
|
4
|
+
* @module resolver/resolvers
|
|
5
5
|
*/
|
|
6
|
-
export { createHeaderResolver
|
|
7
|
-
export { createSubdomainResolver
|
|
8
|
-
export { createPathResolver
|
|
9
|
-
export { createJwtResolver
|
|
6
|
+
export { createHeaderResolver } from "./headerResolver.core.js";
|
|
7
|
+
export { createSubdomainResolver } from "./subdomainResolver.core.js";
|
|
8
|
+
export { createPathResolver } from "./pathResolver.core.js";
|
|
9
|
+
export { createJwtResolver } from "./jwtResolver.core.js";
|
|
10
10
|
//# sourceMappingURL=index.js.map
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module resolvers/jwtResolver
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { tryCreateTenantId } from "../../tenancyTypes/tenantIdentity.js";
|
|
7
7
|
/**
|
|
8
8
|
* Create a tenant resolver that reads from JWT claims.
|
|
9
9
|
*/
|
|
@@ -17,14 +17,10 @@ export function createJwtResolver(options) {
|
|
|
17
17
|
const claims = context.getClaims();
|
|
18
18
|
if (!claims)
|
|
19
19
|
return undefined;
|
|
20
|
-
const tenantId = claims[claimKey];
|
|
21
|
-
if (
|
|
20
|
+
const tenantId = tryCreateTenantId(claims[claimKey]);
|
|
21
|
+
if (!tenantId)
|
|
22
22
|
return undefined;
|
|
23
|
-
return {
|
|
24
|
-
tenantId: createTenantId(tenantId),
|
|
25
|
-
source: "jwt",
|
|
26
|
-
trust: "trusted",
|
|
27
|
-
};
|
|
23
|
+
return { tenantId, source: "jwt", trust: "trusted" };
|
|
28
24
|
},
|
|
29
25
|
};
|
|
30
26
|
}
|
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { TenantResolver } from "../../tenancyTypes/resolverTypes.js";
|
|
7
7
|
/** Context with getPath method. */
|
|
8
|
-
interface PathContext {
|
|
8
|
+
export interface PathContext {
|
|
9
9
|
getPath(): string | undefined;
|
|
10
10
|
}
|
|
11
11
|
/** Options for the path resolver. */
|
|
12
12
|
export interface PathResolverOptions {
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Prefix to strip (e.g. "/tenant").
|
|
15
|
+
*
|
|
16
|
+
* When set, only paths under the prefix name a tenant; any other path
|
|
17
|
+
* resolves to nothing.
|
|
18
|
+
*/
|
|
14
19
|
readonly prefix?: string;
|
|
15
20
|
readonly priority?: number;
|
|
16
21
|
}
|
|
@@ -20,5 +25,4 @@ export interface PathResolverOptions {
|
|
|
20
25
|
* Example: "/acme/users" → tenant "acme"
|
|
21
26
|
*/
|
|
22
27
|
export declare function createPathResolver(options?: PathResolverOptions): TenantResolver<PathContext>;
|
|
23
|
-
export {};
|
|
24
28
|
//# sourceMappingURL=pathResolver.core.d.ts.map
|