@forinda/kickjs-multi-tenant 2.3.3 → 3.0.2
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 +7 -4
- package/dist/index.d.mts +76 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +39 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -15,15 +15,18 @@ pnpm add @forinda/kickjs-multi-tenant
|
|
|
15
15
|
## Features
|
|
16
16
|
|
|
17
17
|
- `TenantAdapter` — lifecycle adapter that resolves tenant from requests
|
|
18
|
-
- `TENANT_CONTEXT` token for injecting tenant info via DI
|
|
19
|
-
-
|
|
20
|
-
-
|
|
18
|
+
- `TENANT_CONTEXT` token for injecting tenant info via DI (request-scoped via AsyncLocalStorage)
|
|
19
|
+
- `getCurrentTenant()` — functional helper for use outside DI
|
|
20
|
+
- Pluggable resolution strategies: header, subdomain, path, query, or custom
|
|
21
|
+
- Per-tenant database switching: database, schema, or discriminator modes
|
|
22
|
+
- `TENANT_DB` token for injecting per-tenant database connections
|
|
23
|
+
- Integration with `@forinda/kickjs-auth` for tenant-scoped RBAC
|
|
21
24
|
|
|
22
25
|
## Quick Example
|
|
23
26
|
|
|
24
27
|
```typescript
|
|
25
28
|
import { TenantAdapter, TENANT_CONTEXT, type TenantInfo } from '@forinda/kickjs-multi-tenant'
|
|
26
|
-
import { Inject, Service } from '@forinda/kickjs
|
|
29
|
+
import { Inject, Service } from '@forinda/kickjs'
|
|
27
30
|
|
|
28
31
|
bootstrap({
|
|
29
32
|
modules,
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
|
|
2
2
|
import { AdapterContext, AdapterMiddleware, AppAdapter } from "@forinda/kickjs";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
4
|
|
|
5
|
+
//#region src/database.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Per-tenant database switching configuration.
|
|
8
|
+
*
|
|
9
|
+
* Three isolation modes, from strongest to weakest:
|
|
10
|
+
* - `database` — each tenant has its own database
|
|
11
|
+
* - `schema` — shared database, separate schemas (PostgreSQL)
|
|
12
|
+
* - `discriminator` — shared tables with a tenant_id column
|
|
13
|
+
*/
|
|
14
|
+
interface DatabaseConnectionInfo {
|
|
15
|
+
host: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
database: string;
|
|
18
|
+
user: string;
|
|
19
|
+
password: string;
|
|
20
|
+
}
|
|
21
|
+
interface DatabasePerTenantConfig {
|
|
22
|
+
mode: 'database';
|
|
23
|
+
/** Resolve connection info for a tenant */
|
|
24
|
+
resolve: (tenantId: string) => DatabaseConnectionInfo | Promise<DatabaseConnectionInfo>;
|
|
25
|
+
/** Connection pool settings */
|
|
26
|
+
pool?: {
|
|
27
|
+
min?: number;
|
|
28
|
+
max?: number;
|
|
29
|
+
idleTimeout?: number;
|
|
30
|
+
};
|
|
31
|
+
/** Cache resolved connections (default TTL: 300_000ms = 5 min) */
|
|
32
|
+
cache?: {
|
|
33
|
+
ttl?: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
interface SchemaPerTenantConfig {
|
|
37
|
+
mode: 'schema';
|
|
38
|
+
/** Base connection URL (shared database) */
|
|
39
|
+
connection: string;
|
|
40
|
+
/** Schema name template. `${tenantId}` is replaced at runtime. Default: `'tenant_${tenantId}'` */
|
|
41
|
+
schemaTemplate?: string;
|
|
42
|
+
}
|
|
43
|
+
interface DiscriminatorConfig {
|
|
44
|
+
mode: 'discriminator';
|
|
45
|
+
/** Base connection URL (shared everything) */
|
|
46
|
+
connection: string;
|
|
47
|
+
/** Column name used to scope queries. Default: `'tenant_id'` */
|
|
48
|
+
column?: string;
|
|
49
|
+
}
|
|
50
|
+
type TenantDatabase = DatabasePerTenantConfig | SchemaPerTenantConfig | DiscriminatorConfig;
|
|
51
|
+
/** DI token for the current tenant's database connection */
|
|
52
|
+
declare const TENANT_DB: unique symbol;
|
|
53
|
+
//#endregion
|
|
4
54
|
//#region src/types.d.ts
|
|
5
55
|
/** DI token for the current tenant context */
|
|
6
56
|
declare const TENANT_CONTEXT: unique symbol;
|
|
@@ -38,6 +88,12 @@ interface MultiTenantOptions {
|
|
|
38
88
|
required?: boolean;
|
|
39
89
|
/** Routes to skip tenant resolution (e.g., health checks) */
|
|
40
90
|
excludeRoutes?: string[];
|
|
91
|
+
/**
|
|
92
|
+
* Per-tenant database switching configuration.
|
|
93
|
+
* When set, a TENANT_DB DI token is registered that resolves to
|
|
94
|
+
* the current tenant's database connection.
|
|
95
|
+
*/
|
|
96
|
+
database?: TenantDatabase;
|
|
41
97
|
}
|
|
42
98
|
//#endregion
|
|
43
99
|
//#region src/tenant.adapter.d.ts
|
|
@@ -81,5 +137,24 @@ declare class TenantAdapter implements AppAdapter {
|
|
|
81
137
|
private resolveTenant;
|
|
82
138
|
}
|
|
83
139
|
//#endregion
|
|
84
|
-
|
|
140
|
+
//#region src/tenant.context.d.ts
|
|
141
|
+
/**
|
|
142
|
+
* Get the current request's tenant from AsyncLocalStorage.
|
|
143
|
+
*
|
|
144
|
+
* Returns `undefined` when called outside a request scope (e.g.,
|
|
145
|
+
* during startup, in a background job, or in tests without setup).
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```ts
|
|
149
|
+
* import { getCurrentTenant } from '@forinda/kickjs-multi-tenant'
|
|
150
|
+
*
|
|
151
|
+
* function logForTenant(message: string) {
|
|
152
|
+
* const tenant = getCurrentTenant()
|
|
153
|
+
* console.log(`[${tenant?.id ?? 'no-tenant'}] ${message}`)
|
|
154
|
+
* }
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
declare function getCurrentTenant(): TenantInfo | undefined;
|
|
158
|
+
//#endregion
|
|
159
|
+
export { type DatabaseConnectionInfo, type DatabasePerTenantConfig, type DiscriminatorConfig, type MultiTenantOptions, type SchemaPerTenantConfig, TENANT_CONTEXT, TENANT_DB, TenantAdapter, type TenantDatabase, type TenantInfo, type TenantResolutionStrategy, getCurrentTenant };
|
|
85
160
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/tenant.adapter.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/database.ts","../src/types.ts","../src/tenant.adapter.ts","../src/tenant.context.ts"],"mappings":";;;;;;;;;AASA;;;;UAAiB,sBAAA;EACf,IAAA;EACA,IAAA;EACA,QAAA;EACA,IAAA;EACA,QAAA;AAAA;AAAA,UAGe,uBAAA;EACf,IAAA;;EAEA,OAAA,GAAU,QAAA,aAAqB,sBAAA,GAAyB,OAAA,CAAQ,sBAAA;EAAA;EAEhE,IAAA;IAAS,GAAA;IAAc,GAAA;IAAc,WAAA;EAAA;EAF3B;EAIV,KAAA;IAAU,GAAA;EAAA;AAAA;AAAA,UAGK,qBAAA;EACf,IAAA;EANqC;EAQrC,UAAA;EANU;EAQV,cAAA;AAAA;AAAA,UAGe,mBAAA;EACf,IAAA;;EAEA,UAAA;EAVA;EAYA,MAAA;AAAA;AAAA,KAGU,cAAA,GAAiB,uBAAA,GAA0B,qBAAA,GAAwB,mBAAA;;cAGlE,SAAA;;;;cC7CA,cAAA;;UAGI,UAAA;EDKA;ECHf,EAAA;;EAEA,IAAA;EDEA;ECAA,QAAA,GAAW,MAAA;AAAA;;KAID,wBAAA,iDAKN,GAAA,UAAa,UAAA,UAAoB,OAAA,CAAQ,UAAA;AAAA,UAE9B,kBAAA;EDPP;AAGV;;;;;;;ECaE,QAAA,GAAW,wBAAA;EDZX;ECeA,UAAA;EDbU;ECgBV,UAAA;EDhBwD;;;;ECsBxD,gBAAA,IAAoB,MAAA,EAAQ,UAAA,EAAY,GAAA,iBAAoB,OAAA;EDpBvB;ECuBrC,QAAA;EDrBU;ECwBV,aAAA;EDxBa;AAGf;;;;EC4BE,QAAA,GAbmE,cAAA;AAAA;;;;;ADjCrE;;;;;;;;;;;AAQA;;;;;;;;;;;;;;;;cE8Ba,aAAA,YAAyB,UAAA;EACpC,IAAA;EAAA,QACQ,OAAA;cAKI,OAAA,GAAS,kBAAA;EAUrB,UAAA,CAAA,GAAc,iBAAA;EAuCd,WAAA,CAAA;IAAc;EAAA,GAAa,cAAA;EAAA,QAqBb,aAAA;AAAA;;;;;;;;;;;AF3GhB;;;;;;;;iBGQgB,gBAAA,CAAA,GAAoB,UAAA"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-multi-tenant
|
|
2
|
+
* @forinda/kickjs-multi-tenant v3.0.2
|
|
3
3
|
*
|
|
4
4
|
* Copyright (c) Felix Orinda
|
|
5
5
|
*
|
|
@@ -9,10 +9,37 @@
|
|
|
9
9
|
* @license MIT
|
|
10
10
|
*/
|
|
11
11
|
import { Logger, Scope } from "@forinda/kickjs";
|
|
12
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
13
|
//#region src/types.ts
|
|
13
14
|
/** DI token for the current tenant context */
|
|
14
15
|
const TENANT_CONTEXT = Symbol("TenantContext");
|
|
15
16
|
//#endregion
|
|
17
|
+
//#region src/tenant.context.ts
|
|
18
|
+
/**
|
|
19
|
+
* AsyncLocalStorage instance that holds the current request's tenant.
|
|
20
|
+
* Used internally by TenantAdapter to make tenant resolution request-scoped.
|
|
21
|
+
*/
|
|
22
|
+
const tenantStorage = new AsyncLocalStorage();
|
|
23
|
+
/**
|
|
24
|
+
* Get the current request's tenant from AsyncLocalStorage.
|
|
25
|
+
*
|
|
26
|
+
* Returns `undefined` when called outside a request scope (e.g.,
|
|
27
|
+
* during startup, in a background job, or in tests without setup).
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { getCurrentTenant } from '@forinda/kickjs-multi-tenant'
|
|
32
|
+
*
|
|
33
|
+
* function logForTenant(message: string) {
|
|
34
|
+
* const tenant = getCurrentTenant()
|
|
35
|
+
* console.log(`[${tenant?.id ?? 'no-tenant'}] ${message}`)
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
function getCurrentTenant() {
|
|
40
|
+
return tenantStorage.getStore();
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
16
43
|
//#region src/tenant.adapter.ts
|
|
17
44
|
const log = Logger.for("MultiTenant");
|
|
18
45
|
/**
|
|
@@ -70,16 +97,17 @@ var TenantAdapter = class {
|
|
|
70
97
|
}
|
|
71
98
|
req.tenant = tenant;
|
|
72
99
|
if (this.options.onTenantResolved) await this.options.onTenantResolved(tenant, req);
|
|
73
|
-
next();
|
|
100
|
+
tenantStorage.run(tenant, () => next());
|
|
74
101
|
},
|
|
75
102
|
phase: "beforeGlobal"
|
|
76
103
|
}];
|
|
77
104
|
}
|
|
78
105
|
beforeStart({ container }) {
|
|
79
|
-
container.registerFactory(TENANT_CONTEXT, () =>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
106
|
+
container.registerFactory(TENANT_CONTEXT, () => {
|
|
107
|
+
const tenant = getCurrentTenant();
|
|
108
|
+
if (!tenant) throw new Error("TENANT_CONTEXT resolved outside request scope. Ensure TenantAdapter middleware is active and the code runs within a request.");
|
|
109
|
+
return tenant;
|
|
110
|
+
}, Scope.TRANSIENT);
|
|
83
111
|
log.info(`Tenant resolution: ${typeof this.options.strategy === "function" ? "custom" : this.options.strategy}`);
|
|
84
112
|
}
|
|
85
113
|
async resolveTenant(req) {
|
|
@@ -109,6 +137,10 @@ var TenantAdapter = class {
|
|
|
109
137
|
}
|
|
110
138
|
};
|
|
111
139
|
//#endregion
|
|
112
|
-
|
|
140
|
+
//#region src/database.ts
|
|
141
|
+
/** DI token for the current tenant's database connection */
|
|
142
|
+
const TENANT_DB = Symbol("TenantDB");
|
|
143
|
+
//#endregion
|
|
144
|
+
export { TENANT_CONTEXT, TENANT_DB, TenantAdapter, getCurrentTenant };
|
|
113
145
|
|
|
114
146
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/tenant.adapter.ts"],"sourcesContent":["/** DI token for the current tenant context */\nexport const TENANT_CONTEXT = Symbol('TenantContext')\n\n/** Tenant information resolved from the request */\nexport interface TenantInfo {\n /** Unique tenant identifier */\n id: string\n /** Optional tenant name */\n name?: string\n /** Optional tenant-specific config/metadata */\n metadata?: Record<string, any>\n}\n\n/** Strategy for resolving the tenant from a request */\nexport type TenantResolutionStrategy =\n | 'header'\n | 'subdomain'\n | 'path'\n | 'query'\n | ((req: any) => TenantInfo | null | Promise<TenantInfo | null>)\n\nexport interface MultiTenantOptions {\n /**\n * How to resolve the tenant from the request.\n * - 'header' — reads X-Tenant-ID header (default)\n * - 'subdomain' — extracts from subdomain (tenant.example.com)\n * - 'path' — extracts from first path segment (/tenant-id/...)\n * - 'query' — reads ?tenantId= query param\n * - function — custom resolver\n */\n strategy?: TenantResolutionStrategy\n\n /** Header name when strategy is 'header' (default: 'x-tenant-id') */\n headerName?: string\n\n /** Query param name when strategy is 'query' (default: 'tenantId') */\n queryParam?: string\n\n /**\n * Called after tenant is resolved. Use for validation, loading tenant\n * config from DB, or rejecting unknown tenants.\n */\n onTenantResolved?: (tenant: TenantInfo, req: any) => void | Promise<void>\n\n /** Return a 403 if no tenant can be resolved (default: true) */\n required?: boolean\n\n /** Routes to skip tenant resolution (e.g., health checks) */\n excludeRoutes?: string[]\n}\n","import {\n Logger,\n type AppAdapter,\n type AdapterContext,\n type AdapterMiddleware,\n Scope,\n} from '@forinda/kickjs'\nimport type { Request, Response, NextFunction } from 'express'\nimport {\n TENANT_CONTEXT,\n type TenantInfo,\n type MultiTenantOptions,\n type TenantResolutionStrategy,\n} from './types'\n\nconst log = Logger.for('MultiTenant')\n\n/**\n * Multi-tenancy adapter for KickJS.\n *\n * Resolves the tenant from each request and makes it available\n * via DI (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.\n *\n * @example\n * ```ts\n * import { TenantAdapter, TENANT_CONTEXT } from '@forinda/kickjs-multi-tenant'\n *\n * bootstrap({\n * modules,\n * adapters: [\n * new TenantAdapter({\n * strategy: 'header',\n * onTenantResolved: async (tenant) => {\n * // Load tenant config from DB, validate, etc.\n * },\n * }),\n * ],\n * })\n *\n * // In a service:\n * @Service()\n * class UserService {\n * constructor(@Inject(TENANT_CONTEXT) private tenant: TenantInfo) {}\n * }\n * ```\n */\nexport class TenantAdapter implements AppAdapter {\n name = 'TenantAdapter'\n private options: Required<\n Pick<MultiTenantOptions, 'strategy' | 'required' | 'headerName' | 'queryParam'>\n > &\n MultiTenantOptions\n\n constructor(options: MultiTenantOptions = {}) {\n this.options = {\n strategy: options.strategy ?? 'header',\n required: options.required ?? true,\n headerName: options.headerName ?? 'x-tenant-id',\n queryParam: options.queryParam ?? 'tenantId',\n ...options,\n }\n }\n\n middleware(): AdapterMiddleware[] {\n return [\n {\n handler: async (req: Request, res: Response, next: NextFunction) => {\n // Skip excluded routes\n if (this.options.excludeRoutes?.some((r) => req.path.startsWith(r))) {\n return next()\n }\n\n const tenant = await this.resolveTenant(req)\n\n if (!tenant) {\n if (this.options.required) {\n res\n .status(403)\n .json({ message: 'Tenant not found. Provide a valid tenant identifier.' })\n return\n }\n return next()\n }\n\n // Attach to request\n ;(req as any).tenant = tenant\n\n // Call hook\n if (this.options.onTenantResolved) {\n await this.options.onTenantResolved(tenant, req)\n }\n\n next()\n },\n phase: 'beforeGlobal',\n },\n ]\n }\n\n beforeStart({ container }: AdapterContext): void {\n // Register a factory that reads the tenant from the current request context\n // This requires request-scoped resolution — for now, register as a placeholder\n container.registerFactory(\n TENANT_CONTEXT,\n () => ({ id: 'default', name: 'Default Tenant' }) as TenantInfo,\n Scope.SINGLETON,\n )\n log.info(\n `Tenant resolution: ${typeof this.options.strategy === 'function' ? 'custom' : this.options.strategy}`,\n )\n }\n\n private async resolveTenant(req: Request): Promise<TenantInfo | null> {\n const strategy = this.options.strategy\n\n if (typeof strategy === 'function') {\n return strategy(req)\n }\n\n switch (strategy) {\n case 'header': {\n const tenantId = req.get(this.options.headerName)\n return tenantId ? { id: tenantId } : null\n }\n case 'subdomain': {\n const host = req.hostname\n const parts = host.split('.')\n if (parts.length >= 3) {\n return { id: parts[0] }\n }\n return null\n }\n case 'path': {\n const segments = req.path.split('/').filter(Boolean)\n if (segments.length > 0) {\n return { id: segments[0] }\n }\n return null\n }\n case 'query': {\n const tenantId = req.query[this.options.queryParam] as string\n return tenantId ? { id: tenantId } : null\n }\n default:\n return null\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;AACA,MAAa,iBAAiB,OAAO,gBAAgB;;;ACcrD,MAAM,MAAM,OAAO,IAAI,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BrC,IAAa,gBAAb,MAAiD;CAC/C,OAAO;CACP;CAKA,YAAY,UAA8B,EAAE,EAAE;AAC5C,OAAK,UAAU;GACb,UAAU,QAAQ,YAAY;GAC9B,UAAU,QAAQ,YAAY;GAC9B,YAAY,QAAQ,cAAc;GAClC,YAAY,QAAQ,cAAc;GAClC,GAAG;GACJ;;CAGH,aAAkC;AAChC,SAAO,CACL;GACE,SAAS,OAAO,KAAc,KAAe,SAAuB;AAElE,QAAI,KAAK,QAAQ,eAAe,MAAM,MAAM,IAAI,KAAK,WAAW,EAAE,CAAC,CACjE,QAAO,MAAM;IAGf,MAAM,SAAS,MAAM,KAAK,cAAc,IAAI;AAE5C,QAAI,CAAC,QAAQ;AACX,SAAI,KAAK,QAAQ,UAAU;AACzB,UACG,OAAO,IAAI,CACX,KAAK,EAAE,SAAS,wDAAwD,CAAC;AAC5E;;AAEF,YAAO,MAAM;;AAIb,QAAY,SAAS;AAGvB,QAAI,KAAK,QAAQ,iBACf,OAAM,KAAK,QAAQ,iBAAiB,QAAQ,IAAI;AAGlD,UAAM;;GAER,OAAO;GACR,CACF;;CAGH,YAAY,EAAE,aAAmC;AAG/C,YAAU,gBACR,uBACO;GAAE,IAAI;GAAW,MAAM;GAAkB,GAChD,MAAM,UACP;AACD,MAAI,KACF,sBAAsB,OAAO,KAAK,QAAQ,aAAa,aAAa,WAAW,KAAK,QAAQ,WAC7F;;CAGH,MAAc,cAAc,KAA0C;EACpE,MAAM,WAAW,KAAK,QAAQ;AAE9B,MAAI,OAAO,aAAa,WACtB,QAAO,SAAS,IAAI;AAGtB,UAAQ,UAAR;GACE,KAAK,UAAU;IACb,MAAM,WAAW,IAAI,IAAI,KAAK,QAAQ,WAAW;AACjD,WAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;GAEvC,KAAK,aAAa;IAEhB,MAAM,QADO,IAAI,SACE,MAAM,IAAI;AAC7B,QAAI,MAAM,UAAU,EAClB,QAAO,EAAE,IAAI,MAAM,IAAI;AAEzB,WAAO;;GAET,KAAK,QAAQ;IACX,MAAM,WAAW,IAAI,KAAK,MAAM,IAAI,CAAC,OAAO,QAAQ;AACpD,QAAI,SAAS,SAAS,EACpB,QAAO,EAAE,IAAI,SAAS,IAAI;AAE5B,WAAO;;GAET,KAAK,SAAS;IACZ,MAAM,WAAW,IAAI,MAAM,KAAK,QAAQ;AACxC,WAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;GAEvC,QACE,QAAO"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/types.ts","../src/tenant.context.ts","../src/tenant.adapter.ts","../src/database.ts"],"sourcesContent":["/** DI token for the current tenant context */\nexport const TENANT_CONTEXT = Symbol('TenantContext')\n\n/** Tenant information resolved from the request */\nexport interface TenantInfo {\n /** Unique tenant identifier */\n id: string\n /** Optional tenant name */\n name?: string\n /** Optional tenant-specific config/metadata */\n metadata?: Record<string, any>\n}\n\n/** Strategy for resolving the tenant from a request */\nexport type TenantResolutionStrategy =\n | 'header'\n | 'subdomain'\n | 'path'\n | 'query'\n | ((req: any) => TenantInfo | null | Promise<TenantInfo | null>)\n\nexport interface MultiTenantOptions {\n /**\n * How to resolve the tenant from the request.\n * - 'header' — reads X-Tenant-ID header (default)\n * - 'subdomain' — extracts from subdomain (tenant.example.com)\n * - 'path' — extracts from first path segment (/tenant-id/...)\n * - 'query' — reads ?tenantId= query param\n * - function — custom resolver\n */\n strategy?: TenantResolutionStrategy\n\n /** Header name when strategy is 'header' (default: 'x-tenant-id') */\n headerName?: string\n\n /** Query param name when strategy is 'query' (default: 'tenantId') */\n queryParam?: string\n\n /**\n * Called after tenant is resolved. Use for validation, loading tenant\n * config from DB, or rejecting unknown tenants.\n */\n onTenantResolved?: (tenant: TenantInfo, req: any) => void | Promise<void>\n\n /** Return a 403 if no tenant can be resolved (default: true) */\n required?: boolean\n\n /** Routes to skip tenant resolution (e.g., health checks) */\n excludeRoutes?: string[]\n\n /**\n * Per-tenant database switching configuration.\n * When set, a TENANT_DB DI token is registered that resolves to\n * the current tenant's database connection.\n */\n database?: import('./database').TenantDatabase\n}\n","import { AsyncLocalStorage } from 'node:async_hooks'\nimport type { TenantInfo } from './types'\n\n/**\n * AsyncLocalStorage instance that holds the current request's tenant.\n * Used internally by TenantAdapter to make tenant resolution request-scoped.\n */\nexport const tenantStorage = new AsyncLocalStorage<TenantInfo>()\n\n/**\n * Get the current request's tenant from AsyncLocalStorage.\n *\n * Returns `undefined` when called outside a request scope (e.g.,\n * during startup, in a background job, or in tests without setup).\n *\n * @example\n * ```ts\n * import { getCurrentTenant } from '@forinda/kickjs-multi-tenant'\n *\n * function logForTenant(message: string) {\n * const tenant = getCurrentTenant()\n * console.log(`[${tenant?.id ?? 'no-tenant'}] ${message}`)\n * }\n * ```\n */\nexport function getCurrentTenant(): TenantInfo | undefined {\n return tenantStorage.getStore()\n}\n","import {\n Logger,\n type AppAdapter,\n type AdapterContext,\n type AdapterMiddleware,\n Scope,\n} from '@forinda/kickjs'\nimport type { Request, Response, NextFunction } from 'express'\nimport {\n TENANT_CONTEXT,\n type TenantInfo,\n type MultiTenantOptions,\n type TenantResolutionStrategy,\n} from './types'\nimport { tenantStorage, getCurrentTenant } from './tenant.context'\n\nconst log = Logger.for('MultiTenant')\n\n/**\n * Multi-tenancy adapter for KickJS.\n *\n * Resolves the tenant from each request and makes it available\n * via DI (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.\n *\n * @example\n * ```ts\n * import { TenantAdapter, TENANT_CONTEXT } from '@forinda/kickjs-multi-tenant'\n *\n * bootstrap({\n * modules,\n * adapters: [\n * new TenantAdapter({\n * strategy: 'header',\n * onTenantResolved: async (tenant) => {\n * // Load tenant config from DB, validate, etc.\n * },\n * }),\n * ],\n * })\n *\n * // In a service:\n * @Service()\n * class UserService {\n * constructor(@Inject(TENANT_CONTEXT) private tenant: TenantInfo) {}\n * }\n * ```\n */\nexport class TenantAdapter implements AppAdapter {\n name = 'TenantAdapter'\n private options: Required<\n Pick<MultiTenantOptions, 'strategy' | 'required' | 'headerName' | 'queryParam'>\n > &\n MultiTenantOptions\n\n constructor(options: MultiTenantOptions = {}) {\n this.options = {\n strategy: options.strategy ?? 'header',\n required: options.required ?? true,\n headerName: options.headerName ?? 'x-tenant-id',\n queryParam: options.queryParam ?? 'tenantId',\n ...options,\n }\n }\n\n middleware(): AdapterMiddleware[] {\n return [\n {\n handler: async (req: Request, res: Response, next: NextFunction) => {\n // Skip excluded routes\n if (this.options.excludeRoutes?.some((r) => req.path.startsWith(r))) {\n return next()\n }\n\n const tenant = await this.resolveTenant(req)\n\n if (!tenant) {\n if (this.options.required) {\n res\n .status(403)\n .json({ message: 'Tenant not found. Provide a valid tenant identifier.' })\n return\n }\n return next()\n }\n\n // Attach to request\n ;(req as any).tenant = tenant\n\n // Call hook\n if (this.options.onTenantResolved) {\n await this.options.onTenantResolved(tenant, req)\n }\n\n // Wrap the rest of the request in AsyncLocalStorage so\n // @Inject(TENANT_CONTEXT) and getCurrentTenant() return\n // the correct tenant for this request.\n tenantStorage.run(tenant, () => next())\n },\n phase: 'beforeGlobal',\n },\n ]\n }\n\n beforeStart({ container }: AdapterContext): void {\n // Register as TRANSIENT so each resolution reads from AsyncLocalStorage\n container.registerFactory(\n TENANT_CONTEXT,\n () => {\n const tenant = getCurrentTenant()\n if (!tenant) {\n throw new Error(\n 'TENANT_CONTEXT resolved outside request scope. ' +\n 'Ensure TenantAdapter middleware is active and the code runs within a request.',\n )\n }\n return tenant\n },\n Scope.TRANSIENT,\n )\n log.info(\n `Tenant resolution: ${typeof this.options.strategy === 'function' ? 'custom' : this.options.strategy}`,\n )\n }\n\n private async resolveTenant(req: Request): Promise<TenantInfo | null> {\n const strategy = this.options.strategy\n\n if (typeof strategy === 'function') {\n return strategy(req)\n }\n\n switch (strategy) {\n case 'header': {\n const tenantId = req.get(this.options.headerName)\n return tenantId ? { id: tenantId } : null\n }\n case 'subdomain': {\n const host = req.hostname\n const parts = host.split('.')\n if (parts.length >= 3) {\n return { id: parts[0] }\n }\n return null\n }\n case 'path': {\n const segments = req.path.split('/').filter(Boolean)\n if (segments.length > 0) {\n return { id: segments[0] }\n }\n return null\n }\n case 'query': {\n const tenantId = req.query[this.options.queryParam] as string\n return tenantId ? { id: tenantId } : null\n }\n default:\n return null\n }\n }\n}\n","/**\n * Per-tenant database switching configuration.\n *\n * Three isolation modes, from strongest to weakest:\n * - `database` — each tenant has its own database\n * - `schema` — shared database, separate schemas (PostgreSQL)\n * - `discriminator` — shared tables with a tenant_id column\n */\n\nexport interface DatabaseConnectionInfo {\n host: string\n port?: number\n database: string\n user: string\n password: string\n}\n\nexport interface DatabasePerTenantConfig {\n mode: 'database'\n /** Resolve connection info for a tenant */\n resolve: (tenantId: string) => DatabaseConnectionInfo | Promise<DatabaseConnectionInfo>\n /** Connection pool settings */\n pool?: { min?: number; max?: number; idleTimeout?: number }\n /** Cache resolved connections (default TTL: 300_000ms = 5 min) */\n cache?: { ttl?: number }\n}\n\nexport interface SchemaPerTenantConfig {\n mode: 'schema'\n /** Base connection URL (shared database) */\n connection: string\n /** Schema name template. `${tenantId}` is replaced at runtime. Default: `'tenant_${tenantId}'` */\n schemaTemplate?: string\n}\n\nexport interface DiscriminatorConfig {\n mode: 'discriminator'\n /** Base connection URL (shared everything) */\n connection: string\n /** Column name used to scope queries. Default: `'tenant_id'` */\n column?: string\n}\n\nexport type TenantDatabase = DatabasePerTenantConfig | SchemaPerTenantConfig | DiscriminatorConfig\n\n/** DI token for the current tenant's database connection */\nexport const TENANT_DB = Symbol('TenantDB')\n"],"mappings":";;;;;;;;;;;;;;AACA,MAAa,iBAAiB,OAAO,gBAAgB;;;;;;;ACMrD,MAAa,gBAAgB,IAAI,mBAA+B;;;;;;;;;;;;;;;;;AAkBhE,SAAgB,mBAA2C;AACzD,QAAO,cAAc,UAAU;;;;ACVjC,MAAM,MAAM,OAAO,IAAI,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BrC,IAAa,gBAAb,MAAiD;CAC/C,OAAO;CACP;CAKA,YAAY,UAA8B,EAAE,EAAE;AAC5C,OAAK,UAAU;GACb,UAAU,QAAQ,YAAY;GAC9B,UAAU,QAAQ,YAAY;GAC9B,YAAY,QAAQ,cAAc;GAClC,YAAY,QAAQ,cAAc;GAClC,GAAG;GACJ;;CAGH,aAAkC;AAChC,SAAO,CACL;GACE,SAAS,OAAO,KAAc,KAAe,SAAuB;AAElE,QAAI,KAAK,QAAQ,eAAe,MAAM,MAAM,IAAI,KAAK,WAAW,EAAE,CAAC,CACjE,QAAO,MAAM;IAGf,MAAM,SAAS,MAAM,KAAK,cAAc,IAAI;AAE5C,QAAI,CAAC,QAAQ;AACX,SAAI,KAAK,QAAQ,UAAU;AACzB,UACG,OAAO,IAAI,CACX,KAAK,EAAE,SAAS,wDAAwD,CAAC;AAC5E;;AAEF,YAAO,MAAM;;AAIb,QAAY,SAAS;AAGvB,QAAI,KAAK,QAAQ,iBACf,OAAM,KAAK,QAAQ,iBAAiB,QAAQ,IAAI;AAMlD,kBAAc,IAAI,cAAc,MAAM,CAAC;;GAEzC,OAAO;GACR,CACF;;CAGH,YAAY,EAAE,aAAmC;AAE/C,YAAU,gBACR,sBACM;GACJ,MAAM,SAAS,kBAAkB;AACjC,OAAI,CAAC,OACH,OAAM,IAAI,MACR,+HAED;AAEH,UAAO;KAET,MAAM,UACP;AACD,MAAI,KACF,sBAAsB,OAAO,KAAK,QAAQ,aAAa,aAAa,WAAW,KAAK,QAAQ,WAC7F;;CAGH,MAAc,cAAc,KAA0C;EACpE,MAAM,WAAW,KAAK,QAAQ;AAE9B,MAAI,OAAO,aAAa,WACtB,QAAO,SAAS,IAAI;AAGtB,UAAQ,UAAR;GACE,KAAK,UAAU;IACb,MAAM,WAAW,IAAI,IAAI,KAAK,QAAQ,WAAW;AACjD,WAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;GAEvC,KAAK,aAAa;IAEhB,MAAM,QADO,IAAI,SACE,MAAM,IAAI;AAC7B,QAAI,MAAM,UAAU,EAClB,QAAO,EAAE,IAAI,MAAM,IAAI;AAEzB,WAAO;;GAET,KAAK,QAAQ;IACX,MAAM,WAAW,IAAI,KAAK,MAAM,IAAI,CAAC,OAAO,QAAQ;AACpD,QAAI,SAAS,SAAS,EACpB,QAAO,EAAE,IAAI,SAAS,IAAI;AAE5B,WAAO;;GAET,KAAK,SAAS;IACZ,MAAM,WAAW,IAAI,MAAM,KAAK,QAAQ;AACxC,WAAO,WAAW,EAAE,IAAI,UAAU,GAAG;;GAEvC,QACE,QAAO;;;;;;;AC9Gf,MAAa,YAAY,OAAO,WAAW"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forinda/kickjs-multi-tenant",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "Multi-tenancy helpers for KickJS — tenant resolution, scoped DI, and database routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kickjs",
|
|
@@ -69,12 +69,12 @@
|
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
-
"reflect-metadata": "^0.2.2"
|
|
73
|
-
"@forinda/kickjs": "2.3.3"
|
|
72
|
+
"reflect-metadata": "^0.2.2"
|
|
74
73
|
},
|
|
75
74
|
"devDependencies": {
|
|
76
75
|
"@types/node": "^25.0.0",
|
|
77
|
-
"typescript": "^5.9.2"
|
|
76
|
+
"typescript": "^5.9.2",
|
|
77
|
+
"@forinda/kickjs": "3.0.2"
|
|
78
78
|
},
|
|
79
79
|
"publishConfig": {
|
|
80
80
|
"access": "public"
|
|
@@ -93,6 +93,10 @@
|
|
|
93
93
|
"bugs": {
|
|
94
94
|
"url": "https://github.com/forinda/kick-js/issues"
|
|
95
95
|
},
|
|
96
|
+
"peerDependencies": {
|
|
97
|
+
"@forinda/kickjs": ">=2.3.0"
|
|
98
|
+
},
|
|
99
|
+
"peerDependenciesMeta": {},
|
|
96
100
|
"scripts": {
|
|
97
101
|
"build": "wireit",
|
|
98
102
|
"dev": "tsdown --watch",
|