@forinda/kickjs-multi-tenant 0.6.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Felix Orinda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,79 @@
1
+ import { AppAdapter, AdapterMiddleware, Container } from '@forinda/kickjs-core';
2
+
3
+ /** DI token for the current tenant context */
4
+ declare const TENANT_CONTEXT: unique symbol;
5
+ /** Tenant information resolved from the request */
6
+ interface TenantInfo {
7
+ /** Unique tenant identifier */
8
+ id: string;
9
+ /** Optional tenant name */
10
+ name?: string;
11
+ /** Optional tenant-specific config/metadata */
12
+ metadata?: Record<string, any>;
13
+ }
14
+ /** Strategy for resolving the tenant from a request */
15
+ type TenantResolutionStrategy = 'header' | 'subdomain' | 'path' | 'query' | ((req: any) => TenantInfo | null | Promise<TenantInfo | null>);
16
+ interface MultiTenantOptions {
17
+ /**
18
+ * How to resolve the tenant from the request.
19
+ * - 'header' — reads X-Tenant-ID header (default)
20
+ * - 'subdomain' — extracts from subdomain (tenant.example.com)
21
+ * - 'path' — extracts from first path segment (/tenant-id/...)
22
+ * - 'query' — reads ?tenantId= query param
23
+ * - function — custom resolver
24
+ */
25
+ strategy?: TenantResolutionStrategy;
26
+ /** Header name when strategy is 'header' (default: 'x-tenant-id') */
27
+ headerName?: string;
28
+ /** Query param name when strategy is 'query' (default: 'tenantId') */
29
+ queryParam?: string;
30
+ /**
31
+ * Called after tenant is resolved. Use for validation, loading tenant
32
+ * config from DB, or rejecting unknown tenants.
33
+ */
34
+ onTenantResolved?: (tenant: TenantInfo, req: any) => void | Promise<void>;
35
+ /** Return a 403 if no tenant can be resolved (default: true) */
36
+ required?: boolean;
37
+ /** Routes to skip tenant resolution (e.g., health checks) */
38
+ excludeRoutes?: string[];
39
+ }
40
+
41
+ /**
42
+ * Multi-tenancy adapter for KickJS.
43
+ *
44
+ * Resolves the tenant from each request and makes it available
45
+ * via DI (`@Inject(TENANT_CONTEXT)`) and `req.tenant`.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * import { TenantAdapter, TENANT_CONTEXT } from '@forinda/kickjs-multi-tenant'
50
+ *
51
+ * bootstrap({
52
+ * modules,
53
+ * adapters: [
54
+ * new TenantAdapter({
55
+ * strategy: 'header',
56
+ * onTenantResolved: async (tenant) => {
57
+ * // Load tenant config from DB, validate, etc.
58
+ * },
59
+ * }),
60
+ * ],
61
+ * })
62
+ *
63
+ * // In a service:
64
+ * @Service()
65
+ * class UserService {
66
+ * constructor(@Inject(TENANT_CONTEXT) private tenant: TenantInfo) {}
67
+ * }
68
+ * ```
69
+ */
70
+ declare class TenantAdapter implements AppAdapter {
71
+ name: string;
72
+ private options;
73
+ constructor(options?: MultiTenantOptions);
74
+ middleware(): AdapterMiddleware[];
75
+ beforeStart(_app: any, container: Container): void;
76
+ private resolveTenant;
77
+ }
78
+
79
+ export { type MultiTenantOptions, TENANT_CONTEXT, TenantAdapter, type TenantInfo, type TenantResolutionStrategy };
package/dist/index.js ADDED
@@ -0,0 +1,108 @@
1
+ var __defProp = Object.defineProperty;
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
@@ -0,0 +1 @@
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"]}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@forinda/kickjs-multi-tenant",
3
+ "version": "0.6.0",
4
+ "description": "Multi-tenancy helpers for KickJS — tenant resolution, scoped DI, and database routing",
5
+ "keywords": [
6
+ "kickjs",
7
+ "multi-tenant",
8
+ "multitenancy",
9
+ "saas",
10
+ "tenant",
11
+ "middleware",
12
+ "typescript",
13
+ "@forinda/kickjs-core",
14
+ "@forinda/kickjs-http"
15
+ ],
16
+ "type": "module",
17
+ "main": "dist/index.js",
18
+ "types": "dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "import": "./dist/index.js",
22
+ "types": "./dist/index.d.ts"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "dependencies": {
29
+ "reflect-metadata": "^0.2.2",
30
+ "@forinda/kickjs-core": "0.7.0",
31
+ "@forinda/kickjs-http": "0.7.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^24.5.2",
35
+ "tsup": "^8.5.0",
36
+ "typescript": "^5.9.2"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "license": "MIT",
42
+ "author": "Felix Orinda",
43
+ "engines": {
44
+ "node": ">=20.0"
45
+ },
46
+ "homepage": "https://forinda.github.io/kick-js/",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/forinda/kick-js.git",
50
+ "directory": "packages/multi-tenant"
51
+ },
52
+ "bugs": {
53
+ "url": "https://github.com/forinda/kick-js/issues"
54
+ },
55
+ "scripts": {
56
+ "build": "tsup",
57
+ "dev": "tsup --watch",
58
+ "typecheck": "tsc --noEmit",
59
+ "clean": "rm -rf dist .turbo"
60
+ }
61
+ }