@nebutra/tenant 0.1.0 → 0.1.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/dist/context.d.ts +87 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +125 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/isolation.d.ts +157 -0
- package/dist/isolation.d.ts.map +1 -0
- package/dist/isolation.js +319 -0
- package/dist/middleware.d.ts +106 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +247 -0
- package/dist/react.d.ts +211 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +252 -0
- package/dist/resolvers/from-auth-session.d.ts +48 -0
- package/dist/resolvers/from-auth-session.d.ts.map +1 -0
- package/dist/resolvers/from-auth-session.js +41 -0
- package/dist/resolvers.d.ts +110 -0
- package/dist/resolvers.d.ts.map +1 -0
- package/dist/resolvers.js +253 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +78 -0
- package/package.json +3 -3
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { logger } from "@nebutra/logger";
|
|
2
|
+
function isRequest(req) {
|
|
3
|
+
return typeof Request !== "undefined" && req instanceof Request;
|
|
4
|
+
}
|
|
5
|
+
function getHeader(headers, headerName) {
|
|
6
|
+
if (!headers)
|
|
7
|
+
return null;
|
|
8
|
+
if (headers instanceof Headers) {
|
|
9
|
+
return headers.get(headerName);
|
|
10
|
+
}
|
|
11
|
+
const normalizedHeaderName = headerName.toLowerCase();
|
|
12
|
+
const entry = Object.entries(headers).find(([name]) => name.toLowerCase() === normalizedHeaderName);
|
|
13
|
+
const value = entry?.[1];
|
|
14
|
+
if (typeof value === "string")
|
|
15
|
+
return value;
|
|
16
|
+
if (Array.isArray(value))
|
|
17
|
+
return value[0] ?? null;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function getUrlString(req) {
|
|
21
|
+
const url = req.url;
|
|
22
|
+
if (!url)
|
|
23
|
+
return null;
|
|
24
|
+
return url instanceof URL ? url.toString() : url;
|
|
25
|
+
}
|
|
26
|
+
function getJwtToken(req) {
|
|
27
|
+
const explicitToken = isRequest(req) ? null : req.token;
|
|
28
|
+
if (explicitToken)
|
|
29
|
+
return explicitToken;
|
|
30
|
+
const authorization = getHeader(req.headers, "authorization");
|
|
31
|
+
const match = authorization?.match(/^Bearer\s+(.+)$/i);
|
|
32
|
+
return match?.[1]?.trim() || null;
|
|
33
|
+
}
|
|
34
|
+
function decodeJwtPayload(token) {
|
|
35
|
+
const parts = token.split(".");
|
|
36
|
+
if (parts.length !== 3 || !parts[1])
|
|
37
|
+
return null;
|
|
38
|
+
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Extract tenant ID from an HTTP header.
|
|
42
|
+
*
|
|
43
|
+
* Default strategy for API gateways. Header name is configurable.
|
|
44
|
+
*
|
|
45
|
+
* @param headerName The header name to look for (default: "x-tenant-id")
|
|
46
|
+
* @returns A resolver function
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* const resolver = fromHeader("x-tenant-id");
|
|
51
|
+
* const tenantId = await resolver({ headers: { "x-tenant-id": "org_123" } });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function fromHeader(headerName = "x-tenant-id") {
|
|
55
|
+
return (req) => {
|
|
56
|
+
const value = getHeader(req.headers, headerName);
|
|
57
|
+
if (typeof value === "string") {
|
|
58
|
+
logger.debug("Tenant resolved from header", { headerName, tenantId: value });
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Extract tenant ID from a subdomain using a regex pattern.
|
|
66
|
+
*
|
|
67
|
+
* Useful for SaaS products with tenant subdomains (e.g., `acme.app.nebutra.com`).
|
|
68
|
+
*
|
|
69
|
+
* @param pattern A regex pattern with a capture group for tenant ID
|
|
70
|
+
* @returns A resolver function
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* // Extract "acme" from "acme.app.nebutra.com"
|
|
75
|
+
* const resolver = fromSubdomain("^([a-z0-9-]+)\\.app\\.nebutra\\.com$");
|
|
76
|
+
* const tenantId = await resolver({ url: "https://acme.app.nebutra.com/api" });
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function fromSubdomain(pattern) {
|
|
80
|
+
const regex = new RegExp(pattern, "i");
|
|
81
|
+
return (req) => {
|
|
82
|
+
const urlString = getUrlString(req);
|
|
83
|
+
if (!urlString)
|
|
84
|
+
return null;
|
|
85
|
+
try {
|
|
86
|
+
const url = new URL(urlString);
|
|
87
|
+
const hostname = url.hostname;
|
|
88
|
+
const match = hostname.match(regex);
|
|
89
|
+
if (match?.[1]) {
|
|
90
|
+
logger.debug("Tenant resolved from subdomain", { hostname, tenantId: match[1] });
|
|
91
|
+
return match[1];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (_err) {
|
|
95
|
+
logger.warn("Failed to parse URL for subdomain extraction", { url: urlString });
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Extract tenant ID from a URL path prefix.
|
|
102
|
+
*
|
|
103
|
+
* Useful for multi-tenant apps with path-based routing (e.g., `/org/acme/...`).
|
|
104
|
+
*
|
|
105
|
+
* @param prefix The path prefix pattern (e.g., "/org/:tenantId" or "/org/([^/]+)")
|
|
106
|
+
* @returns A resolver function
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```ts
|
|
110
|
+
* // Extract tenant ID from "/org/acme/..."
|
|
111
|
+
* const resolver = fromPath("/org/:tenantId");
|
|
112
|
+
* const tenantId = await resolver({ url: "https://app.nebutra.com/org/acme/dashboard" });
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export function fromPath(prefix) {
|
|
116
|
+
// Convert Next.js-style `:tenantId` to regex capture group
|
|
117
|
+
const pattern = prefix.replace(/:tenantId/g, "([^/?]+)");
|
|
118
|
+
const regex = new RegExp(`^${pattern}`);
|
|
119
|
+
return (req) => {
|
|
120
|
+
const urlString = getUrlString(req);
|
|
121
|
+
if (!urlString)
|
|
122
|
+
return null;
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(urlString);
|
|
125
|
+
const pathname = url.pathname;
|
|
126
|
+
const match = pathname.match(regex);
|
|
127
|
+
if (match?.[1]) {
|
|
128
|
+
logger.debug("Tenant resolved from path", { pathname, tenantId: match[1] });
|
|
129
|
+
return match[1];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (_err) {
|
|
133
|
+
logger.warn("Failed to parse URL for path extraction", { url: urlString });
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Extract tenant ID from a JWT token claim.
|
|
140
|
+
*
|
|
141
|
+
* Useful for authentication services that embed tenant info in the token.
|
|
142
|
+
*
|
|
143
|
+
* @param claimName The JWT claim name (e.g., "tenant_id", "org_id")
|
|
144
|
+
* @returns A resolver function
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* // Extract from decoded JWT { sub: "user_123", tenant_id: "org_456" }
|
|
149
|
+
* const resolver = fromJwtClaim("tenant_id");
|
|
150
|
+
* const tenantId = await resolver({ token: "eyJhbGc..." });
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export function fromJwtClaim(claimName) {
|
|
154
|
+
return (req) => {
|
|
155
|
+
const token = getJwtToken(req);
|
|
156
|
+
if (!token)
|
|
157
|
+
return null;
|
|
158
|
+
try {
|
|
159
|
+
// Decode JWT without verification (signature should be verified upstream)
|
|
160
|
+
const payload = decodeJwtPayload(token);
|
|
161
|
+
if (!payload)
|
|
162
|
+
return null;
|
|
163
|
+
const value = payload[claimName];
|
|
164
|
+
if (typeof value === "string") {
|
|
165
|
+
logger.debug("Tenant resolved from JWT claim", { claimName, tenantId: value });
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (_err) {
|
|
170
|
+
logger.warn("Failed to extract JWT claim", { claimName });
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Resolve tenant ID from an API key by looking it up in a function (e.g., database).
|
|
177
|
+
*
|
|
178
|
+
* Useful for service-to-service authentication or API clients.
|
|
179
|
+
*
|
|
180
|
+
* @param lookupFn Async function that takes an API key and returns the tenant ID
|
|
181
|
+
* @returns A resolver function
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* // Look up API key in database
|
|
186
|
+
* const resolver = fromApiKey(async (apiKey) => {
|
|
187
|
+
* const key = await db.apiKey.findUnique({ where: { key: apiKey } });
|
|
188
|
+
* return key?.tenantId ?? null;
|
|
189
|
+
* });
|
|
190
|
+
* const tenantId = await resolver({ apiKey: "sk_123" });
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
export function fromApiKey(lookupFn) {
|
|
194
|
+
return async (req) => {
|
|
195
|
+
const apiKey = isRequest(req) ? null : req.apiKey;
|
|
196
|
+
if (!apiKey)
|
|
197
|
+
return null;
|
|
198
|
+
try {
|
|
199
|
+
const tenantId = await lookupFn(apiKey);
|
|
200
|
+
if (tenantId) {
|
|
201
|
+
logger.debug("Tenant resolved from API key");
|
|
202
|
+
return tenantId;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
logger.warn("Failed to lookup API key", { error: err });
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Compose multiple tenant resolvers with fallback behavior.
|
|
213
|
+
*
|
|
214
|
+
* Tries each resolver in order; returns the first successful match.
|
|
215
|
+
* If all resolvers return null, returns null.
|
|
216
|
+
*
|
|
217
|
+
* @param resolvers Array of resolver functions
|
|
218
|
+
* @returns A composite resolver function
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```ts
|
|
222
|
+
* // Try header first, then subdomain, then path
|
|
223
|
+
* const resolver = compose(
|
|
224
|
+
* fromHeader("x-tenant-id"),
|
|
225
|
+
* fromSubdomain("^([a-z0-9-]+)\\.app\\.nebutra\\.com$"),
|
|
226
|
+
* fromPath("/org/:tenantId")
|
|
227
|
+
* );
|
|
228
|
+
*
|
|
229
|
+
* const tenantId = await resolver({
|
|
230
|
+
* headers: { "x-tenant-id": "org_123" },
|
|
231
|
+
* url: "https://acme.app.nebutra.com/org/xyz/dashboard"
|
|
232
|
+
* });
|
|
233
|
+
* // Returns "org_123" (first match)
|
|
234
|
+
* ```
|
|
235
|
+
*/
|
|
236
|
+
export function compose(...resolvers) {
|
|
237
|
+
return async (req) => {
|
|
238
|
+
for (const resolver of resolvers) {
|
|
239
|
+
try {
|
|
240
|
+
const result = await Promise.resolve(resolver(req));
|
|
241
|
+
if (result) {
|
|
242
|
+
logger.debug("Tenant resolved via composed resolver", { tenantId: result });
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
logger.warn("Resolver in chain failed", { error: err });
|
|
248
|
+
// Continue to next resolver
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
253
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Isolation strategy determines how data is partitioned across tenants.
|
|
4
|
+
*
|
|
5
|
+
* - `shared_schema`: Single PostgreSQL schema with Row-Level Security (RLS) on `current_user` or `tenant_id`
|
|
6
|
+
* - `schema_per_tenant`: Separate PostgreSQL schema per tenant (e.g., `org_123_public`)
|
|
7
|
+
* - `database_per_tenant`: Separate PostgreSQL database per tenant (requires connection pooling)
|
|
8
|
+
*/
|
|
9
|
+
export type IsolationStrategy = "shared_schema" | "schema_per_tenant" | "database_per_tenant";
|
|
10
|
+
/**
|
|
11
|
+
* Plan tier determines feature access and rate limits.
|
|
12
|
+
*/
|
|
13
|
+
export type PlanTier = "free" | "pro" | "enterprise";
|
|
14
|
+
/**
|
|
15
|
+
* Runtime tenant context — available via AsyncLocalStorage in request handlers.
|
|
16
|
+
*/
|
|
17
|
+
export declare const TenantContextSchema: z.ZodObject<{
|
|
18
|
+
id: z.ZodString;
|
|
19
|
+
slug: z.ZodOptional<z.ZodString>;
|
|
20
|
+
plan: z.ZodOptional<z.ZodEnum<{
|
|
21
|
+
free: "free";
|
|
22
|
+
pro: "pro";
|
|
23
|
+
enterprise: "enterprise";
|
|
24
|
+
}>>;
|
|
25
|
+
features: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
26
|
+
limits: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
|
|
27
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
28
|
+
}, z.core.$strip>;
|
|
29
|
+
export type TenantContext = z.infer<typeof TenantContextSchema>;
|
|
30
|
+
/**
|
|
31
|
+
* Persistent tenant information — typically loaded from database.
|
|
32
|
+
*/
|
|
33
|
+
export declare const TenantInfoSchema: z.ZodObject<{
|
|
34
|
+
id: z.ZodString;
|
|
35
|
+
slug: z.ZodString;
|
|
36
|
+
name: z.ZodString;
|
|
37
|
+
plan: z.ZodEnum<{
|
|
38
|
+
free: "free";
|
|
39
|
+
pro: "pro";
|
|
40
|
+
enterprise: "enterprise";
|
|
41
|
+
}>;
|
|
42
|
+
createdAt: z.ZodUnion<[z.ZodDate, z.ZodString]>;
|
|
43
|
+
settings: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
44
|
+
parentTenantId: z.ZodOptional<z.ZodString>;
|
|
45
|
+
}, z.core.$strip>;
|
|
46
|
+
export type TenantInfo = z.infer<typeof TenantInfoSchema>;
|
|
47
|
+
export interface TenantResolverObjectInput {
|
|
48
|
+
headers?: Headers | Record<string, string | string[] | undefined>;
|
|
49
|
+
url?: string | URL;
|
|
50
|
+
token?: string;
|
|
51
|
+
apiKey?: string;
|
|
52
|
+
}
|
|
53
|
+
export type TenantResolverInput = Request | TenantResolverObjectInput;
|
|
54
|
+
/**
|
|
55
|
+
* Callback to resolve a tenant from various sources (header, subdomain, path, JWT, API key).
|
|
56
|
+
*
|
|
57
|
+
* Returns the resolved tenant ID or null if not found.
|
|
58
|
+
*/
|
|
59
|
+
export type TenantResolver = (req: TenantResolverInput) => Promise<string | null> | string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Configuration for tenant extraction and isolation.
|
|
62
|
+
*/
|
|
63
|
+
export declare const TenantConfigSchema: z.ZodObject<{
|
|
64
|
+
strategy: z.ZodDefault<z.ZodEnum<{
|
|
65
|
+
shared_schema: "shared_schema";
|
|
66
|
+
schema_per_tenant: "schema_per_tenant";
|
|
67
|
+
database_per_tenant: "database_per_tenant";
|
|
68
|
+
}>>;
|
|
69
|
+
headerName: z.ZodDefault<z.ZodString>;
|
|
70
|
+
subdomainPattern: z.ZodOptional<z.ZodString>;
|
|
71
|
+
pathPrefix: z.ZodOptional<z.ZodString>;
|
|
72
|
+
jwtClaimName: z.ZodOptional<z.ZodString>;
|
|
73
|
+
requireTenant: z.ZodDefault<z.ZodBoolean>;
|
|
74
|
+
}, z.core.$strip>;
|
|
75
|
+
export type TenantConfig = z.infer<typeof TenantConfigSchema> & {
|
|
76
|
+
/** Custom tenant resolver function (optional, not in zod schema) */
|
|
77
|
+
resolver?: TenantResolver;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Error thrown when tenant context is required but not found.
|
|
81
|
+
*/
|
|
82
|
+
export declare class TenantRequiredError extends Error {
|
|
83
|
+
name: string;
|
|
84
|
+
statusCode: number;
|
|
85
|
+
constructor(message?: string);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Error thrown when tenant isolation fails (RLS, schema, database).
|
|
89
|
+
*/
|
|
90
|
+
export declare class TenantIsolationError extends Error {
|
|
91
|
+
strategy?: IsolationStrategy | undefined;
|
|
92
|
+
name: string;
|
|
93
|
+
statusCode: number;
|
|
94
|
+
constructor(message?: string, strategy?: IsolationStrategy | undefined);
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAMxB;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,eAAe,GAAG,mBAAmB,GAAG,qBAAqB,CAAC;AAE9F;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,KAAK,GAAG,YAAY,CAAC;AAErD;;GAEG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;iBAkB9B,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE;;GAEG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;iBAqB3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,MAAM,WAAW,yBAAyB;IACxC,OAAO,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;IAClE,GAAG,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,yBAAyB,CAAC;AAEtE;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,GAAG,EAAE,mBAAmB,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,MAAM,GAAG,IAAI,CAAC;AAElG;;GAEG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;iBAoB7B,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,GAAG;IAC9D,oEAAoE;IACpE,QAAQ,CAAC,EAAE,cAAc,CAAC;CAC3B,CAAC;AAEF;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,IAAI,SAAyB;IAC7B,UAAU,SAAO;gBAEL,OAAO,GAAE,MAAqC;CAG3D;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAMpC,QAAQ,CAAC,EAAE,iBAAiB;IALrC,IAAI,SAA0B;IAC9B,UAAU,SAAO;gBAGf,OAAO,GAAE,MAA2C,EAC7C,QAAQ,CAAC,EAAE,iBAAiB,YAAA;CAItC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Runtime tenant context — available via AsyncLocalStorage in request handlers.
|
|
4
|
+
*/
|
|
5
|
+
export const TenantContextSchema = z.object({
|
|
6
|
+
/** Unique tenant identifier (UUID, slug, or org ID) */
|
|
7
|
+
id: z.string().min(1),
|
|
8
|
+
/** Optional tenant slug for URL-friendly routing (e.g., "acme-corp") */
|
|
9
|
+
slug: z.string().min(1).optional(),
|
|
10
|
+
/** Subscription tier — determines feature access and limits */
|
|
11
|
+
plan: z.enum(["free", "pro", "enterprise"]).optional(),
|
|
12
|
+
/** Feature flags enabled for this tenant */
|
|
13
|
+
features: z.array(z.string()).optional(),
|
|
14
|
+
/** Rate limits and quota settings */
|
|
15
|
+
limits: z.record(z.string(), z.number()).optional(),
|
|
16
|
+
/** Custom metadata (arbitrary JSON) */
|
|
17
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Persistent tenant information — typically loaded from database.
|
|
21
|
+
*/
|
|
22
|
+
export const TenantInfoSchema = z.object({
|
|
23
|
+
/** Unique tenant ID */
|
|
24
|
+
id: z.string().min(1),
|
|
25
|
+
/** URL-friendly slug */
|
|
26
|
+
slug: z.string().min(1),
|
|
27
|
+
/** Display name */
|
|
28
|
+
name: z.string().min(1),
|
|
29
|
+
/** Subscription plan */
|
|
30
|
+
plan: z.enum(["free", "pro", "enterprise"]),
|
|
31
|
+
/** When the tenant was created */
|
|
32
|
+
createdAt: z.date().or(z.string().datetime()),
|
|
33
|
+
/** Tenant-specific settings (arbitrary JSON) */
|
|
34
|
+
settings: z.record(z.string(), z.unknown()).default({}),
|
|
35
|
+
/** For hierarchical organizations — parent org ID */
|
|
36
|
+
parentTenantId: z.string().optional(),
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* Configuration for tenant extraction and isolation.
|
|
40
|
+
*/
|
|
41
|
+
export const TenantConfigSchema = z.object({
|
|
42
|
+
/** Data isolation strategy */
|
|
43
|
+
strategy: z
|
|
44
|
+
.enum(["shared_schema", "schema_per_tenant", "database_per_tenant"])
|
|
45
|
+
.default("shared_schema"),
|
|
46
|
+
/** HTTP header name for tenant ID (API gateway default) */
|
|
47
|
+
headerName: z.string().default("x-tenant-id"),
|
|
48
|
+
/** Subdomain pattern for tenant extraction (e.g., "([a-z0-9-]+)\\.app\\.nebutra\\.com") */
|
|
49
|
+
subdomainPattern: z.string().optional(),
|
|
50
|
+
/** URL path prefix for tenant extraction (e.g., "/org/:tenantId") */
|
|
51
|
+
pathPrefix: z.string().optional(),
|
|
52
|
+
/** JWT claim name containing tenant ID */
|
|
53
|
+
jwtClaimName: z.string().optional(),
|
|
54
|
+
/** Whether to throw error if tenant is required but not resolved */
|
|
55
|
+
requireTenant: z.boolean().default(true),
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* Error thrown when tenant context is required but not found.
|
|
59
|
+
*/
|
|
60
|
+
export class TenantRequiredError extends Error {
|
|
61
|
+
name = "TenantRequiredError";
|
|
62
|
+
statusCode = 400;
|
|
63
|
+
constructor(message = "Tenant context is required") {
|
|
64
|
+
super(message);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Error thrown when tenant isolation fails (RLS, schema, database).
|
|
69
|
+
*/
|
|
70
|
+
export class TenantIsolationError extends Error {
|
|
71
|
+
strategy;
|
|
72
|
+
name = "TenantIsolationError";
|
|
73
|
+
statusCode = 500;
|
|
74
|
+
constructor(message = "Failed to apply tenant isolation", strategy) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.strategy = strategy;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebutra/tenant",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"nebutra": {
|
|
8
8
|
"status": "foundation",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"zod": "^4.3.6",
|
|
47
|
-
"@nebutra/logger": "0.1.
|
|
47
|
+
"@nebutra/logger": "0.1.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"vitest": "^4.1.4",
|