@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -13
  3. package/dist/context/contextManager.core.d.ts +12 -2
  4. package/dist/context/contextManager.core.js +34 -22
  5. package/dist/http/httpResolverContext.d.ts +35 -0
  6. package/dist/http/httpResolverContext.js +40 -0
  7. package/dist/http/index.d.ts +8 -8
  8. package/dist/http/index.js +4 -5
  9. package/dist/http/tenancyMiddleware.core.d.ts +34 -3
  10. package/dist/http/tenancyMiddleware.core.js +57 -35
  11. package/dist/http/tenancyMiddleware.guard.d.ts +13 -5
  12. package/dist/http/tenancyMiddleware.guard.js +11 -3
  13. package/dist/repository/index.d.ts +2 -1
  14. package/dist/repository/index.js +1 -1
  15. package/dist/repository/repository.core.d.ts +38 -7
  16. package/dist/repository/repository.core.js +69 -15
  17. package/dist/repository/tenantManager.core.d.ts +21 -3
  18. package/dist/repository/tenantManager.core.js +43 -14
  19. package/dist/resolver/index.d.ts +1 -0
  20. package/dist/resolver/resolverChain.core.d.ts +22 -7
  21. package/dist/resolver/resolverChain.core.js +45 -13
  22. package/dist/resolver/resolvers/headerResolver.core.d.ts +10 -2
  23. package/dist/resolver/resolvers/headerResolver.core.js +5 -8
  24. package/dist/resolver/resolvers/index.d.ts +9 -5
  25. package/dist/resolver/resolvers/index.js +5 -5
  26. package/dist/resolver/resolvers/jwtResolver.core.d.ts +1 -1
  27. package/dist/resolver/resolvers/jwtResolver.core.js +4 -8
  28. package/dist/resolver/resolvers/pathResolver.core.d.ts +7 -3
  29. package/dist/resolver/resolvers/pathResolver.core.js +12 -9
  30. package/dist/resolver/resolvers/subdomainResolver.core.d.ts +11 -2
  31. package/dist/resolver/resolvers/subdomainResolver.core.js +31 -14
  32. package/dist/security/guard.core.d.ts +19 -0
  33. package/dist/security/guard.core.js +58 -9
  34. package/dist/security/index.d.ts +1 -1
  35. package/dist/security/index.js +1 -1
  36. package/dist/tenancyErrors/index.d.ts +1 -1
  37. package/dist/tenancyErrors/index.js +1 -1
  38. package/dist/tenancyErrors/tenancyError.types.d.ts +11 -6
  39. package/dist/tenancyErrors/tenancyError.types.js +15 -12
  40. package/dist/tenancyTypes/index.d.ts +3 -3
  41. package/dist/tenancyTypes/index.js +2 -2
  42. package/dist/tenancyTypes/repositoryTypes.d.ts +6 -21
  43. package/dist/tenancyTypes/resolverTypes.d.ts +14 -2
  44. package/dist/tenancyTypes/tenantIdentity.d.ts +24 -5
  45. package/dist/tenancyTypes/tenantIdentity.js +54 -4
  46. package/package.json +26 -15
  47. package/dist/.tsbuildinfo +0 -1
  48. package/dist/context/contextManager.core.d.ts.map +0 -1
  49. package/dist/context/contextManager.core.js.map +0 -1
  50. package/dist/context/contextStorage.core.d.ts.map +0 -1
  51. package/dist/context/contextStorage.core.js.map +0 -1
  52. package/dist/context/index.d.ts.map +0 -1
  53. package/dist/context/index.js.map +0 -1
  54. package/dist/http/httpHelpers.d.ts.map +0 -1
  55. package/dist/http/httpHelpers.js.map +0 -1
  56. package/dist/http/httpTypes.d.ts.map +0 -1
  57. package/dist/http/httpTypes.js.map +0 -1
  58. package/dist/http/index.d.ts.map +0 -1
  59. package/dist/http/index.js.map +0 -1
  60. package/dist/http/tenancyMiddleware.core.d.ts.map +0 -1
  61. package/dist/http/tenancyMiddleware.core.js.map +0 -1
  62. package/dist/http/tenancyMiddleware.guard.d.ts.map +0 -1
  63. package/dist/http/tenancyMiddleware.guard.js.map +0 -1
  64. package/dist/index.d.ts.map +0 -1
  65. package/dist/index.js.map +0 -1
  66. package/dist/repository/index.d.ts.map +0 -1
  67. package/dist/repository/index.js.map +0 -1
  68. package/dist/repository/repository.core.d.ts.map +0 -1
  69. package/dist/repository/repository.core.js.map +0 -1
  70. package/dist/repository/tenantManager.core.d.ts.map +0 -1
  71. package/dist/repository/tenantManager.core.js.map +0 -1
  72. package/dist/resolver/index.d.ts.map +0 -1
  73. package/dist/resolver/index.js.map +0 -1
  74. package/dist/resolver/resolverChain.core.d.ts.map +0 -1
  75. package/dist/resolver/resolverChain.core.js.map +0 -1
  76. package/dist/resolver/resolvers/headerResolver.core.d.ts.map +0 -1
  77. package/dist/resolver/resolvers/headerResolver.core.js.map +0 -1
  78. package/dist/resolver/resolvers/index.d.ts.map +0 -1
  79. package/dist/resolver/resolvers/index.js.map +0 -1
  80. package/dist/resolver/resolvers/jwtResolver.core.d.ts.map +0 -1
  81. package/dist/resolver/resolvers/jwtResolver.core.js.map +0 -1
  82. package/dist/resolver/resolvers/pathResolver.core.d.ts.map +0 -1
  83. package/dist/resolver/resolvers/pathResolver.core.js.map +0 -1
  84. package/dist/resolver/resolvers/subdomainResolver.core.d.ts.map +0 -1
  85. package/dist/resolver/resolvers/subdomainResolver.core.js.map +0 -1
  86. package/dist/security/guard.core.d.ts.map +0 -1
  87. package/dist/security/guard.core.js.map +0 -1
  88. package/dist/security/index.d.ts.map +0 -1
  89. package/dist/security/index.js.map +0 -1
  90. package/dist/tenancyErrors/index.d.ts.map +0 -1
  91. package/dist/tenancyErrors/index.js.map +0 -1
  92. package/dist/tenancyErrors/tenancyError.base.d.ts.map +0 -1
  93. package/dist/tenancyErrors/tenancyError.base.js.map +0 -1
  94. package/dist/tenancyErrors/tenancyError.types.d.ts.map +0 -1
  95. package/dist/tenancyErrors/tenancyError.types.js.map +0 -1
  96. package/dist/tenancyTypes/index.d.ts.map +0 -1
  97. package/dist/tenancyTypes/index.js.map +0 -1
  98. package/dist/tenancyTypes/repositoryTypes.d.ts.map +0 -1
  99. package/dist/tenancyTypes/repositoryTypes.js.map +0 -1
  100. package/dist/tenancyTypes/resolverTypes.d.ts.map +0 -1
  101. package/dist/tenancyTypes/resolverTypes.js.map +0 -1
  102. package/dist/tenancyTypes/tenancyOptions.d.ts +0 -37
  103. package/dist/tenancyTypes/tenancyOptions.d.ts.map +0 -1
  104. package/dist/tenancyTypes/tenancyOptions.js +0 -7
  105. package/dist/tenancyTypes/tenancyOptions.js.map +0 -1
  106. package/dist/tenancyTypes/tenantIdentity.d.ts.map +0 -1
  107. package/dist/tenancyTypes/tenantIdentity.js.map +0 -1
  108. package/dist/tenancyTypes/tenantInterface.d.ts.map +0 -1
  109. package/dist/tenancyTypes/tenantInterface.js.map +0 -1
  110. package/dist/utils/index.d.ts.map +0 -1
  111. package/dist/utils/index.js.map +0 -1
  112. package/dist/utils/utils.helper.d.ts.map +0 -1
  113. 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
- import type { TenantDomain } from "../tenancyTypes/tenancyOptions.js";
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(): TenantRepository & {
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 { TenantNotFoundError } from "../tenancyErrors/tenancyError.types.js";
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
- return bySlug.get(slug);
38
+ const id = bySlug.get(slug.trim().toLowerCase());
39
+ return id ? tenants.get(id) : undefined;
20
40
  },
21
41
  async findByDomain(domain) {
22
- return byDomain.get(domain);
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 (tenant.slug)
27
- bySlug.set(tenant.slug, tenant);
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
- const tenant = tenants.get(id);
31
- if (tenant) {
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
- domains.set(domain, tenantId);
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 { TenantRepository, TenantCache } from "../tenancyTypes/repositoryTypes.js";
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): Promise<Tenant>;
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): void;
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
- // Check cache first
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 (tenant && cache) {
22
- await cache.set(tenant);
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
- async require(id) {
43
- const tenant = await loadTenant(id);
44
- if (!tenant)
45
- throw new TenantNotFoundError(id);
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(tenant) {
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
  },
@@ -4,5 +4,6 @@
4
4
  * @module resolver
5
5
  */
6
6
  export { createResolverChain } from "./resolverChain.core.js";
7
+ export type { TenantResolverChain } from "./resolverChain.core.js";
7
8
  export * from "./resolvers/index.js";
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -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
- * Create a resolver chain that tries resolvers in priority order.
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 declare function createResolverChain<Context = unknown>(resolvers: readonly TenantResolver<Context>[], options?: ResolverChainOptions): {
11
- /**
12
- * Resolve tenant from context using the chain.
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
- return {
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
- const result = await resolver.resolve(context);
22
- if (result) {
23
- candidates.push(result);
24
- }
36
+ result = await resolver.resolve(context);
25
37
  }
26
- catch {
27
- // Skip failed resolvers — continue chain
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
- // Check for conflicts
34
- const uniqueTenantIds = new Set(candidates.map((c) => c.tenantId));
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 { createTenantId } from "../../tenancyTypes/tenantIdentity.js";
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 value = context.getHeader(headerName);
18
- if (!value)
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, type HeaderResolverOptions, } from "./headerResolver.core.js";
7
- export { createSubdomainResolver, type SubdomainResolverOptions, } from "./subdomainResolver.core.js";
8
- export { createPathResolver, type PathResolverOptions, } from "./pathResolver.core.js";
9
- export { createJwtResolver, type JwtResolverOptions, } from "./jwtResolver.core.js";
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, } 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";
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
@@ -10,7 +10,7 @@ interface JwtClaims {
10
10
  readonly [key: string]: unknown;
11
11
  }
12
12
  /** Context with a getClaims method. */
13
- interface JwtContext {
13
+ export interface JwtContext {
14
14
  getClaims(): JwtClaims | undefined;
15
15
  }
16
16
  /** Options for the JWT resolver. */
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @module resolvers/jwtResolver
5
5
  */
6
- import { createTenantId } from "../../tenancyTypes/tenantIdentity.js";
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 (typeof tenantId !== "string" || !tenantId)
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
- /** Prefix to strip (e.g. "/tenant"). */
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