@forinda/kickjs-multi-tenant 1.2.11 → 1.2.13
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 +55 -0
- package/dist/index.js +1 -108
- package/package.json +3 -3
- package/dist/index.js.map +0 -1
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @forinda/kickjs-multi-tenant
|
|
2
|
+
|
|
3
|
+
Multi-tenancy helpers for KickJS — tenant resolution, scoped DI, and database routing.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Using the KickJS CLI (recommended)
|
|
9
|
+
kick add multi-tenant
|
|
10
|
+
|
|
11
|
+
# Manual install
|
|
12
|
+
pnpm add @forinda/kickjs-multi-tenant
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- `TenantAdapter` — lifecycle adapter that resolves tenant from requests
|
|
18
|
+
- `TENANT_CONTEXT` token for injecting tenant info via DI
|
|
19
|
+
- Pluggable resolution strategies: header, subdomain, path, or custom
|
|
20
|
+
- Scoped DI for per-tenant service instances
|
|
21
|
+
|
|
22
|
+
## Quick Example
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { TenantAdapter, TENANT_CONTEXT, type TenantInfo } from '@forinda/kickjs-multi-tenant'
|
|
26
|
+
import { Inject, Service } from '@forinda/kickjs-core'
|
|
27
|
+
|
|
28
|
+
bootstrap({
|
|
29
|
+
modules,
|
|
30
|
+
adapters: [
|
|
31
|
+
new TenantAdapter({
|
|
32
|
+
strategy: 'header',
|
|
33
|
+
headerName: 'X-Tenant-ID',
|
|
34
|
+
}),
|
|
35
|
+
],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Access tenant in any service
|
|
39
|
+
@Service()
|
|
40
|
+
class DataService {
|
|
41
|
+
@Inject(TENANT_CONTEXT) private tenant!: TenantInfo
|
|
42
|
+
|
|
43
|
+
async getData() {
|
|
44
|
+
return this.repo.findByTenant(this.tenant.id)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Documentation
|
|
50
|
+
|
|
51
|
+
[Full documentation](https://forinda.github.io/kick-js/)
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -1,108 +1 @@
|
|
|
1
|
-
var
|
|
2
|
-
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
-
|
|
4
|
-
// src/tenant.adapter.ts
|
|
5
|
-
import { Logger, Scope } from "@forinda/kickjs-core";
|
|
6
|
-
|
|
7
|
-
// src/types.ts
|
|
8
|
-
var TENANT_CONTEXT = /* @__PURE__ */ Symbol("TenantContext");
|
|
9
|
-
|
|
10
|
-
// src/tenant.adapter.ts
|
|
11
|
-
var log = Logger.for("MultiTenant");
|
|
12
|
-
var TenantAdapter = class {
|
|
13
|
-
static {
|
|
14
|
-
__name(this, "TenantAdapter");
|
|
15
|
-
}
|
|
16
|
-
name = "TenantAdapter";
|
|
17
|
-
options;
|
|
18
|
-
constructor(options = {}) {
|
|
19
|
-
this.options = {
|
|
20
|
-
strategy: options.strategy ?? "header",
|
|
21
|
-
required: options.required ?? true,
|
|
22
|
-
headerName: options.headerName ?? "x-tenant-id",
|
|
23
|
-
queryParam: options.queryParam ?? "tenantId",
|
|
24
|
-
...options
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
middleware() {
|
|
28
|
-
return [
|
|
29
|
-
{
|
|
30
|
-
handler: /* @__PURE__ */ __name(async (req, res, next) => {
|
|
31
|
-
if (this.options.excludeRoutes?.some((r) => req.path.startsWith(r))) {
|
|
32
|
-
return next();
|
|
33
|
-
}
|
|
34
|
-
const tenant = await this.resolveTenant(req);
|
|
35
|
-
if (!tenant) {
|
|
36
|
-
if (this.options.required) {
|
|
37
|
-
res.status(403).json({
|
|
38
|
-
message: "Tenant not found. Provide a valid tenant identifier."
|
|
39
|
-
});
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
return next();
|
|
43
|
-
}
|
|
44
|
-
;
|
|
45
|
-
req.tenant = tenant;
|
|
46
|
-
if (this.options.onTenantResolved) {
|
|
47
|
-
await this.options.onTenantResolved(tenant, req);
|
|
48
|
-
}
|
|
49
|
-
next();
|
|
50
|
-
}, "handler"),
|
|
51
|
-
phase: "beforeGlobal"
|
|
52
|
-
}
|
|
53
|
-
];
|
|
54
|
-
}
|
|
55
|
-
beforeStart(_app, container) {
|
|
56
|
-
container.registerFactory(TENANT_CONTEXT, () => ({
|
|
57
|
-
id: "default",
|
|
58
|
-
name: "Default Tenant"
|
|
59
|
-
}), Scope.SINGLETON);
|
|
60
|
-
log.info(`Tenant resolution: ${typeof this.options.strategy === "function" ? "custom" : this.options.strategy}`);
|
|
61
|
-
}
|
|
62
|
-
async resolveTenant(req) {
|
|
63
|
-
const strategy = this.options.strategy;
|
|
64
|
-
if (typeof strategy === "function") {
|
|
65
|
-
return strategy(req);
|
|
66
|
-
}
|
|
67
|
-
switch (strategy) {
|
|
68
|
-
case "header": {
|
|
69
|
-
const tenantId = req.get(this.options.headerName);
|
|
70
|
-
return tenantId ? {
|
|
71
|
-
id: tenantId
|
|
72
|
-
} : null;
|
|
73
|
-
}
|
|
74
|
-
case "subdomain": {
|
|
75
|
-
const host = req.hostname;
|
|
76
|
-
const parts = host.split(".");
|
|
77
|
-
if (parts.length >= 3) {
|
|
78
|
-
return {
|
|
79
|
-
id: parts[0]
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
case "path": {
|
|
85
|
-
const segments = req.path.split("/").filter(Boolean);
|
|
86
|
-
if (segments.length > 0) {
|
|
87
|
-
return {
|
|
88
|
-
id: segments[0]
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
case "query": {
|
|
94
|
-
const tenantId = req.query[this.options.queryParam];
|
|
95
|
-
return tenantId ? {
|
|
96
|
-
id: tenantId
|
|
97
|
-
} : null;
|
|
98
|
-
}
|
|
99
|
-
default:
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
export {
|
|
105
|
-
TENANT_CONTEXT,
|
|
106
|
-
TenantAdapter
|
|
107
|
-
};
|
|
108
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
var l=Object.defineProperty;var a=(i,t)=>l(i,"name",{value:t,configurable:!0});import{Logger as d,Scope as h}from"@forinda/kickjs-core";var o=Symbol("TenantContext");var p=d.for("MultiTenant"),s=class{static{a(this,"TenantAdapter")}name="TenantAdapter";options;constructor(t={}){this.options={strategy:t.strategy??"header",required:t.required??!0,headerName:t.headerName??"x-tenant-id",queryParam:t.queryParam??"tenantId",...t}}middleware(){return[{handler:a(async(t,n,e)=>{if(this.options.excludeRoutes?.some(u=>t.path.startsWith(u)))return e();let r=await this.resolveTenant(t);if(!r){if(this.options.required){n.status(403).json({message:"Tenant not found. Provide a valid tenant identifier."});return}return e()}t.tenant=r,this.options.onTenantResolved&&await this.options.onTenantResolved(r,t),e()},"handler"),phase:"beforeGlobal"}]}beforeStart(t,n){n.registerFactory(o,()=>({id:"default",name:"Default Tenant"}),h.SINGLETON),p.info(`Tenant resolution: ${typeof this.options.strategy=="function"?"custom":this.options.strategy}`)}async resolveTenant(t){let n=this.options.strategy;if(typeof n=="function")return n(t);switch(n){case"header":{let e=t.get(this.options.headerName);return e?{id:e}:null}case"subdomain":{let r=t.hostname.split(".");return r.length>=3?{id:r[0]}:null}case"path":{let e=t.path.split("/").filter(Boolean);return e.length>0?{id:e[0]}:null}case"query":{let e=t.query[this.options.queryParam];return e?{id:e}:null}default:return null}}};export{o as TENANT_CONTEXT,s as TenantAdapter};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forinda/kickjs-multi-tenant",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.13",
|
|
4
4
|
"description": "Multi-tenancy helpers for KickJS — tenant resolution, scoped DI, and database routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"kickjs",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"reflect-metadata": "^0.2.2",
|
|
30
|
-
"@forinda/kickjs-core": "1.2.
|
|
31
|
-
"@forinda/kickjs-http": "1.2.
|
|
30
|
+
"@forinda/kickjs-core": "1.2.13",
|
|
31
|
+
"@forinda/kickjs-http": "1.2.13"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "^24.5.2",
|
package/dist/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tenant.adapter.ts","../src/types.ts"],"sourcesContent":["import {\n Logger,\n type AppAdapter,\n type AdapterMiddleware,\n type Container,\n Scope,\n} from '@forinda/kickjs-core'\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(_app: any, container: Container): 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","/** 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"],"mappings":";;;;AAAA,SACEA,QAIAC,aACK;;;ACLA,IAAMC,iBAAiBC,uBAAO,eAAA;;;ADcrC,IAAMC,MAAMC,OAAOC,IAAI,aAAA;AA+BhB,IAAMC,gBAAN,MAAMA;EA9Cb,OA8CaA;;;EACXC,OAAO;EACCC;EAKR,YAAYA,UAA8B,CAAC,GAAG;AAC5C,SAAKA,UAAU;MACbC,UAAUD,QAAQC,YAAY;MAC9BC,UAAUF,QAAQE,YAAY;MAC9BC,YAAYH,QAAQG,cAAc;MAClCC,YAAYJ,QAAQI,cAAc;MAClC,GAAGJ;IACL;EACF;EAEAK,aAAkC;AAChC,WAAO;MACL;QACEC,SAAS,8BAAOC,KAAcC,KAAeC,SAAAA;AAE3C,cAAI,KAAKT,QAAQU,eAAeC,KAAK,CAACC,MAAML,IAAIM,KAAKC,WAAWF,CAAAA,CAAAA,GAAK;AACnE,mBAAOH,KAAAA;UACT;AAEA,gBAAMM,SAAS,MAAM,KAAKC,cAAcT,GAAAA;AAExC,cAAI,CAACQ,QAAQ;AACX,gBAAI,KAAKf,QAAQE,UAAU;AACzBM,kBACGS,OAAO,GAAA,EACPC,KAAK;gBAAEC,SAAS;cAAuD,CAAA;AAC1E;YACF;AACA,mBAAOV,KAAAA;UACT;;AAGEF,cAAYQ,SAASA;AAGvB,cAAI,KAAKf,QAAQoB,kBAAkB;AACjC,kBAAM,KAAKpB,QAAQoB,iBAAiBL,QAAQR,GAAAA;UAC9C;AAEAE,eAAAA;QACF,GA3BS;QA4BTY,OAAO;MACT;;EAEJ;EAEAC,YAAYC,MAAWC,WAA4B;AAGjDA,cAAUC,gBACRC,gBACA,OAAO;MAAEC,IAAI;MAAW5B,MAAM;IAAiB,IAC/C6B,MAAMC,SAAS;AAEjBlC,QAAImC,KACF,sBAAsB,OAAO,KAAK9B,QAAQC,aAAa,aAAa,WAAW,KAAKD,QAAQC,QAAQ,EAAE;EAE1G;EAEA,MAAce,cAAcT,KAA0C;AACpE,UAAMN,WAAW,KAAKD,QAAQC;AAE9B,QAAI,OAAOA,aAAa,YAAY;AAClC,aAAOA,SAASM,GAAAA;IAClB;AAEA,YAAQN,UAAAA;MACN,KAAK,UAAU;AACb,cAAM8B,WAAWxB,IAAIyB,IAAI,KAAKhC,QAAQG,UAAU;AAChD,eAAO4B,WAAW;UAAEJ,IAAII;QAAS,IAAI;MACvC;MACA,KAAK,aAAa;AAChB,cAAME,OAAO1B,IAAI2B;AACjB,cAAMC,QAAQF,KAAKG,MAAM,GAAA;AACzB,YAAID,MAAME,UAAU,GAAG;AACrB,iBAAO;YAAEV,IAAIQ,MAAM,CAAA;UAAG;QACxB;AACA,eAAO;MACT;MACA,KAAK,QAAQ;AACX,cAAMG,WAAW/B,IAAIM,KAAKuB,MAAM,GAAA,EAAKG,OAAOC,OAAAA;AAC5C,YAAIF,SAASD,SAAS,GAAG;AACvB,iBAAO;YAAEV,IAAIW,SAAS,CAAA;UAAG;QAC3B;AACA,eAAO;MACT;MACA,KAAK,SAAS;AACZ,cAAMP,WAAWxB,IAAIkC,MAAM,KAAKzC,QAAQI,UAAU;AAClD,eAAO2B,WAAW;UAAEJ,IAAII;QAAS,IAAI;MACvC;MACA;AACE,eAAO;IACX;EACF;AACF;","names":["Logger","Scope","TENANT_CONTEXT","Symbol","log","Logger","for","TenantAdapter","name","options","strategy","required","headerName","queryParam","middleware","handler","req","res","next","excludeRoutes","some","r","path","startsWith","tenant","resolveTenant","status","json","message","onTenantResolved","phase","beforeStart","_app","container","registerFactory","TENANT_CONTEXT","id","Scope","SINGLETON","info","tenantId","get","host","hostname","parts","split","length","segments","filter","Boolean","query"]}
|