@nebutra/tenant 0.1.0 → 0.1.1
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 +2 -2
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { logger } from "@nebutra/logger";
|
|
2
|
+
import { TenantIsolationError } from "./types";
|
|
3
|
+
const DEFAULT_TENANT_COLUMN = "tenant_id";
|
|
4
|
+
const DEFAULT_POLICY_PREFIX = "tenant_isolation";
|
|
5
|
+
const DEFAULT_TENANT_EXPRESSION = "current_setting('app.current_tenant_id', true)";
|
|
6
|
+
function assertNonEmptyIdentifier(value, label) {
|
|
7
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) {
|
|
8
|
+
throw new TenantIsolationError(`Invalid ${label} for RLS policy generation`, "shared_schema");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function quoteIdentifier(identifier) {
|
|
12
|
+
assertNonEmptyIdentifier(identifier, "identifier");
|
|
13
|
+
return `"${identifier.replaceAll('"', '""')}"`;
|
|
14
|
+
}
|
|
15
|
+
function tableReference(table, schema) {
|
|
16
|
+
assertNonEmptyIdentifier(table, "table name");
|
|
17
|
+
if (!schema) {
|
|
18
|
+
return quoteIdentifier(table);
|
|
19
|
+
}
|
|
20
|
+
assertNonEmptyIdentifier(schema, "schema name");
|
|
21
|
+
return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
|
|
22
|
+
}
|
|
23
|
+
function policyPredicate(tenantColumn, tenantExpression) {
|
|
24
|
+
assertNonEmptyIdentifier(tenantColumn, "tenant column");
|
|
25
|
+
if (tenantExpression.trim().length === 0 || tenantExpression.includes("\0")) {
|
|
26
|
+
throw new TenantIsolationError("Invalid tenant expression for RLS policy generation", "shared_schema");
|
|
27
|
+
}
|
|
28
|
+
return `${quoteIdentifier(tenantColumn)} = ${tenantExpression}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Generate deterministic PostgreSQL Row-Level Security DDL for shared-schema tenancy.
|
|
32
|
+
*
|
|
33
|
+
* The generated SQL is intentionally migration-tool friendly: stable table sorting,
|
|
34
|
+
* explicit policy replacement, and no database connection side effects.
|
|
35
|
+
*/
|
|
36
|
+
export function generateRlsPolicySql(options) {
|
|
37
|
+
const { command = "ALL", forceRls = true, policyPrefix = DEFAULT_POLICY_PREFIX, schema, tables, tenantColumn = DEFAULT_TENANT_COLUMN, tenantExpression = DEFAULT_TENANT_EXPRESSION, } = options;
|
|
38
|
+
if (!Array.isArray(tables) || tables.length === 0) {
|
|
39
|
+
throw new TenantIsolationError("At least one table is required for RLS policy generation", "shared_schema");
|
|
40
|
+
}
|
|
41
|
+
assertNonEmptyIdentifier(policyPrefix, "policy prefix");
|
|
42
|
+
const normalizedCommand = command.toUpperCase();
|
|
43
|
+
if (!["ALL", "SELECT", "INSERT", "UPDATE", "DELETE"].includes(normalizedCommand)) {
|
|
44
|
+
throw new TenantIsolationError(`Unsupported RLS policy command: ${command}`, "shared_schema");
|
|
45
|
+
}
|
|
46
|
+
const predicate = policyPredicate(tenantColumn, tenantExpression);
|
|
47
|
+
const sortedTables = [...new Set(tables)].sort((a, b) => a.localeCompare(b));
|
|
48
|
+
return sortedTables
|
|
49
|
+
.map((table) => {
|
|
50
|
+
const target = tableReference(table, schema);
|
|
51
|
+
const policyName = quoteIdentifier(`${policyPrefix}_${table}`);
|
|
52
|
+
const statements = [`ALTER TABLE ${target} ENABLE ROW LEVEL SECURITY;`];
|
|
53
|
+
if (forceRls) {
|
|
54
|
+
statements.push(`ALTER TABLE ${target} FORCE ROW LEVEL SECURITY;`);
|
|
55
|
+
}
|
|
56
|
+
statements.push(`DROP POLICY IF EXISTS ${policyName} ON ${target};`);
|
|
57
|
+
const createPolicyLines = [`CREATE POLICY ${policyName} ON ${target}`];
|
|
58
|
+
if (normalizedCommand !== "ALL") {
|
|
59
|
+
createPolicyLines[0] += ` FOR ${normalizedCommand}`;
|
|
60
|
+
}
|
|
61
|
+
if (normalizedCommand !== "INSERT") {
|
|
62
|
+
createPolicyLines.push(` USING (${predicate})`);
|
|
63
|
+
}
|
|
64
|
+
if (["ALL", "INSERT", "UPDATE"].includes(normalizedCommand)) {
|
|
65
|
+
const prefix = createPolicyLines.length > 1 ? " WITH CHECK" : " WITH CHECK";
|
|
66
|
+
createPolicyLines.push(`${prefix} (${predicate})`);
|
|
67
|
+
}
|
|
68
|
+
statements.push(`${createPolicyLines.join("\n")};`);
|
|
69
|
+
return statements.join("\n");
|
|
70
|
+
})
|
|
71
|
+
.join("\n\n");
|
|
72
|
+
}
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// Database Isolation Helpers
|
|
75
|
+
// =============================================================================
|
|
76
|
+
/**
|
|
77
|
+
* Apply Prisma client extension that sets RLS (Row-Level Security) context.
|
|
78
|
+
*
|
|
79
|
+
* Works with PostgreSQL RLS policies that check `app.current_tenant_id`.
|
|
80
|
+
* This is the standard pattern for shared-schema multi-tenancy.
|
|
81
|
+
*
|
|
82
|
+
* The Prisma client middleware intercepts all queries and sets the
|
|
83
|
+
* application-level variable before executing.
|
|
84
|
+
*
|
|
85
|
+
* @param prisma The Prisma client to extend
|
|
86
|
+
* @param tenantId The tenant ID to set in RLS context
|
|
87
|
+
* @returns The extended Prisma client
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* import { PrismaClient } from "@prisma/client";
|
|
92
|
+
* import { withRls } from "@nebutra/tenant/isolation";
|
|
93
|
+
* import { getCurrentTenant } from "@nebutra/tenant";
|
|
94
|
+
*
|
|
95
|
+
* const prisma = new PrismaClient();
|
|
96
|
+
*
|
|
97
|
+
* // In a request handler:
|
|
98
|
+
* const tenant = getCurrentTenant();
|
|
99
|
+
* const client = withRls(prisma, tenant.id);
|
|
100
|
+
*
|
|
101
|
+
* // All queries now include RLS enforcement:
|
|
102
|
+
* const users = await client.user.findMany();
|
|
103
|
+
* // SQL: SELECT * FROM users WHERE current_setting('app.current_tenant_id') = user.tenant_id
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export function withRls(prisma, tenantId) {
|
|
107
|
+
try {
|
|
108
|
+
// Check if Prisma client supports $extends (v5+)
|
|
109
|
+
if (typeof prisma.$extends === "function") {
|
|
110
|
+
// Create a Prisma client extension that sets RLS context on each query
|
|
111
|
+
const extended = prisma.$extends({
|
|
112
|
+
query: {
|
|
113
|
+
$allOperations: {
|
|
114
|
+
async $before() {
|
|
115
|
+
// Execute SET command to set application variable
|
|
116
|
+
if (typeof prisma.$executeRaw === "function") {
|
|
117
|
+
await prisma.$executeRaw `SELECT set_config('app.current_tenant_id', ${tenantId}, false)`;
|
|
118
|
+
}
|
|
119
|
+
logger.debug("RLS context set", { tenantId });
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
return extended;
|
|
125
|
+
}
|
|
126
|
+
logger.debug("withRls: Prisma client does not support $extends, returning original client", {
|
|
127
|
+
tenantId,
|
|
128
|
+
});
|
|
129
|
+
return prisma;
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
logger.error("Failed to apply RLS extension", err, { tenantId });
|
|
133
|
+
throw new TenantIsolationError(`Failed to apply RLS isolation for tenant ${tenantId}`, "shared_schema");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Get the PostgreSQL schema name for schema-per-tenant strategy.
|
|
138
|
+
*
|
|
139
|
+
* Converts a tenant ID to a safe PostgreSQL schema name.
|
|
140
|
+
* Schema names must start with a letter and contain only alphanumerics and underscores.
|
|
141
|
+
*
|
|
142
|
+
* @param tenantId The tenant ID
|
|
143
|
+
* @returns The schema name (e.g., "org_acme_corp_public")
|
|
144
|
+
* @throws TenantIsolationError if tenant ID is invalid
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* const schemaName = getTenantSchema("acme-corp");
|
|
149
|
+
* // Returns: "org_acme_corp_public"
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export function getTenantSchema(tenantId) {
|
|
153
|
+
if (!tenantId || typeof tenantId !== "string") {
|
|
154
|
+
throw new TenantIsolationError("Invalid tenant ID for schema generation", "schema_per_tenant");
|
|
155
|
+
}
|
|
156
|
+
// Convert tenant ID to safe schema name
|
|
157
|
+
// Replace hyphens and special chars with underscores
|
|
158
|
+
const safe = tenantId
|
|
159
|
+
.toLowerCase()
|
|
160
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
161
|
+
.replace(/^[0-9]+/, "org_");
|
|
162
|
+
// Ensure doesn't conflict with reserved schemas
|
|
163
|
+
const reserved = ["public", "pg_", "information_schema", "pg_catalog"];
|
|
164
|
+
if (reserved.some((r) => safe.startsWith(r))) {
|
|
165
|
+
return `org_${safe}`;
|
|
166
|
+
}
|
|
167
|
+
return `${safe}_public`;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Get the PostgreSQL connection string for database-per-tenant strategy.
|
|
171
|
+
*
|
|
172
|
+
* Constructs a connection URL with the tenant-specific database name.
|
|
173
|
+
* Assumes the base connection URL is available and tenant databases follow
|
|
174
|
+
* a naming pattern (e.g., `nebutra_acme_corp`).
|
|
175
|
+
*
|
|
176
|
+
* @param tenantId The tenant ID
|
|
177
|
+
* @param baseUrl Optional base connection URL (defaults to process.env.DATABASE_URL)
|
|
178
|
+
* @returns The tenant-specific database URL
|
|
179
|
+
* @throws TenantIsolationError if base URL is invalid
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```ts
|
|
183
|
+
* const dbUrl = getTenantDatabaseUrl("acme-corp");
|
|
184
|
+
* // Returns: "postgresql://user:pass@localhost/nebutra_acme_corp"
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export function getTenantDatabaseUrl(tenantId, baseUrl) {
|
|
188
|
+
const url = baseUrl || process.env.DATABASE_URL;
|
|
189
|
+
if (!url) {
|
|
190
|
+
throw new TenantIsolationError("No base database URL available for tenant database resolution", "database_per_tenant");
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const parsedUrl = new URL(url);
|
|
194
|
+
// Convert tenant ID to safe database name
|
|
195
|
+
const dbName = `nebutra_${tenantId.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
|
|
196
|
+
// Replace pathname (database name)
|
|
197
|
+
parsedUrl.pathname = `/${dbName}`;
|
|
198
|
+
logger.debug("Tenant database URL constructed", { tenantId, dbName });
|
|
199
|
+
return parsedUrl.toString();
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
logger.error("Failed to construct tenant database URL", err, { tenantId });
|
|
203
|
+
throw new TenantIsolationError(`Failed to construct database URL for tenant ${tenantId}`, "database_per_tenant");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Wrapper for a Prisma client that automatically applies tenant filtering.
|
|
208
|
+
*
|
|
209
|
+
* This is useful for schema-per-tenant or database-per-tenant strategies
|
|
210
|
+
* where you want to ensure tenant isolation at the client level.
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* ```ts
|
|
214
|
+
* import { TenantAwarePrismaClient } from "@nebutra/tenant/isolation";
|
|
215
|
+
* import { getCurrentTenant } from "@nebutra/tenant";
|
|
216
|
+
*
|
|
217
|
+
* export function getTenantPrisma() {
|
|
218
|
+
* const tenant = getCurrentTenant();
|
|
219
|
+
* return new TenantAwarePrismaClient(prisma, tenant.id);
|
|
220
|
+
* }
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
export class TenantAwarePrismaClient {
|
|
224
|
+
prisma;
|
|
225
|
+
tenantId;
|
|
226
|
+
constructor(prisma, tenantId) {
|
|
227
|
+
this.prisma = prisma;
|
|
228
|
+
this.tenantId = tenantId;
|
|
229
|
+
logger.debug("TenantAwarePrismaClient initialized", { tenantId });
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Get the underlying Prisma client with RLS context applied.
|
|
233
|
+
*/
|
|
234
|
+
get client() {
|
|
235
|
+
return withRls(this.prisma, this.tenantId);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Get the schema name for this tenant (schema-per-tenant strategy).
|
|
239
|
+
*/
|
|
240
|
+
getSchema() {
|
|
241
|
+
return getTenantSchema(this.tenantId);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Get the database URL for this tenant (database-per-tenant strategy).
|
|
245
|
+
*/
|
|
246
|
+
getDatabaseUrl(baseUrl) {
|
|
247
|
+
return getTenantDatabaseUrl(this.tenantId, baseUrl);
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Execute a raw SQL query with tenant context.
|
|
251
|
+
*/
|
|
252
|
+
async executeRaw(query) {
|
|
253
|
+
try {
|
|
254
|
+
logger.debug("Executing raw query with tenant context", {
|
|
255
|
+
tenantId: this.tenantId,
|
|
256
|
+
queryLength: query.length,
|
|
257
|
+
});
|
|
258
|
+
const client = this.client;
|
|
259
|
+
if (!client.$executeRaw) {
|
|
260
|
+
throw new TenantIsolationError("Prisma client does not support $executeRaw", "shared_schema");
|
|
261
|
+
}
|
|
262
|
+
return await client.$executeRaw `${query}`;
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
logger.error("Failed to execute raw query", err, { tenantId: this.tenantId });
|
|
266
|
+
throw new TenantIsolationError(`Failed to execute query for tenant ${this.tenantId}`, "shared_schema");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Execute a raw query and return results.
|
|
271
|
+
*/
|
|
272
|
+
async queryRaw(query) {
|
|
273
|
+
try {
|
|
274
|
+
logger.debug("Executing query with tenant context", {
|
|
275
|
+
tenantId: this.tenantId,
|
|
276
|
+
queryLength: query.length,
|
|
277
|
+
});
|
|
278
|
+
const client = this.client;
|
|
279
|
+
if (!client.$queryRaw) {
|
|
280
|
+
throw new TenantIsolationError("Prisma client does not support $queryRaw", "shared_schema");
|
|
281
|
+
}
|
|
282
|
+
return await client.$queryRaw `${query}`;
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
logger.error("Failed to execute query", err, { tenantId: this.tenantId });
|
|
286
|
+
throw new TenantIsolationError(`Failed to query tenant data for ${this.tenantId}`, "shared_schema");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Create a tenant-aware Prisma proxy that applies isolation based on strategy.
|
|
292
|
+
*
|
|
293
|
+
* @param prisma The base Prisma client
|
|
294
|
+
* @param tenantId The tenant ID
|
|
295
|
+
* @param strategy The isolation strategy (default: "shared_schema")
|
|
296
|
+
* @returns A proxy or wrapper for the Prisma client
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* ```ts
|
|
300
|
+
* const prisma = createTenantPrismaProxy(client, "acme-corp", "shared_schema");
|
|
301
|
+
* const users = await prisma.user.findMany(); // Filtered by RLS
|
|
302
|
+
* ```
|
|
303
|
+
*/
|
|
304
|
+
export function createTenantPrismaProxy(prisma, tenantId, strategy = "shared_schema") {
|
|
305
|
+
logger.debug("Creating tenant Prisma proxy", { tenantId, strategy });
|
|
306
|
+
switch (strategy) {
|
|
307
|
+
case "shared_schema":
|
|
308
|
+
// Apply RLS extension
|
|
309
|
+
return withRls(prisma, tenantId);
|
|
310
|
+
case "schema_per_tenant":
|
|
311
|
+
// Wrap with schema awareness
|
|
312
|
+
return new TenantAwarePrismaClient(prisma, tenantId).client;
|
|
313
|
+
case "database_per_tenant":
|
|
314
|
+
// Wrap with database URL awareness
|
|
315
|
+
return new TenantAwarePrismaClient(prisma, tenantId).client;
|
|
316
|
+
default:
|
|
317
|
+
throw new TenantIsolationError(`Unknown isolation strategy: ${strategy}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { TenantConfig } from "./types";
|
|
2
|
+
/** Minimal Hono-like context — matches Hono's `Context` without importing it. */
|
|
3
|
+
interface HonoLikeContext {
|
|
4
|
+
req: {
|
|
5
|
+
raw: {
|
|
6
|
+
headers: Headers;
|
|
7
|
+
};
|
|
8
|
+
url: string;
|
|
9
|
+
header: (name: string) => string | undefined;
|
|
10
|
+
};
|
|
11
|
+
json: (body: unknown, status: number) => Response;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Create a Hono middleware that extracts tenant from request and sets context.
|
|
15
|
+
*
|
|
16
|
+
* - Resolves tenant ID using configured strategy (header, subdomain, path, JWT, API key)
|
|
17
|
+
* - Wraps handler execution with AsyncLocalStorage context
|
|
18
|
+
* - Returns 400 if tenant is required but not resolved
|
|
19
|
+
*
|
|
20
|
+
* @param config Tenant configuration
|
|
21
|
+
* @returns A Hono middleware function
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { Hono } from "hono";
|
|
26
|
+
* import { tenantMiddleware } from "@nebutra/tenant/middleware";
|
|
27
|
+
*
|
|
28
|
+
* const app = new Hono();
|
|
29
|
+
*
|
|
30
|
+
* app.use(
|
|
31
|
+
* tenantMiddleware({
|
|
32
|
+
* headerName: "x-tenant-id",
|
|
33
|
+
* requireTenant: true,
|
|
34
|
+
* })
|
|
35
|
+
* );
|
|
36
|
+
*
|
|
37
|
+
* app.get("/api/data", (c) => {
|
|
38
|
+
* const tenant = getCurrentTenant();
|
|
39
|
+
* return c.json({ tenantId: tenant.id });
|
|
40
|
+
* });
|
|
41
|
+
*
|
|
42
|
+
* export default app;
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare function tenantMiddleware(config?: Partial<TenantConfig>): (c: HonoLikeContext, next: () => Promise<Response>) => Promise<Response>;
|
|
46
|
+
/**
|
|
47
|
+
* Wrap a Next.js API route handler to extract and set tenant context.
|
|
48
|
+
*
|
|
49
|
+
* Uses the same resolver logic as Hono middleware but for Next.js.
|
|
50
|
+
*
|
|
51
|
+
* @param handler The API route handler
|
|
52
|
+
* @param config Tenant configuration
|
|
53
|
+
* @returns Wrapped handler that sets tenant context
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* // pages/api/data.ts
|
|
58
|
+
* import { withTenant } from "@nebutra/tenant/middleware";
|
|
59
|
+
* import { getCurrentTenant } from "@nebutra/tenant";
|
|
60
|
+
* import type { NextApiRequest, NextApiResponse } from "next";
|
|
61
|
+
*
|
|
62
|
+
* export default withTenant(
|
|
63
|
+
* async (req: NextApiRequest, res: NextApiResponse) => {
|
|
64
|
+
* const tenant = getCurrentTenant();
|
|
65
|
+
* res.json({ tenantId: tenant.id });
|
|
66
|
+
* },
|
|
67
|
+
* { headerName: "x-tenant-id" }
|
|
68
|
+
* );
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export declare function withTenant<T extends any[], R>(handler: (...args: T) => Promise<R> | R, config?: Partial<TenantConfig>): (...args: T) => Promise<R>;
|
|
72
|
+
/**
|
|
73
|
+
* Wrap a Next.js Server Action to extract and set tenant context.
|
|
74
|
+
*
|
|
75
|
+
* Server Actions don't have direct access to request headers, so tenant ID
|
|
76
|
+
* should be passed explicitly or resolved from authentication context.
|
|
77
|
+
*
|
|
78
|
+
* @param handler The server action handler
|
|
79
|
+
* @param getTenantId Function to extract tenant ID (e.g., from auth context)
|
|
80
|
+
* @returns Wrapped handler that sets tenant context
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* // lib/actions.ts
|
|
85
|
+
* "use server";
|
|
86
|
+
* import { withServerAction } from "@nebutra/tenant/middleware";
|
|
87
|
+
* import { getSession } from "@/lib/auth";
|
|
88
|
+
*
|
|
89
|
+
* export const getUserData = withServerAction(
|
|
90
|
+
* async (userId: string) => {
|
|
91
|
+
* const tenant = getCurrentTenant();
|
|
92
|
+
* const user = await db.user.findUnique({
|
|
93
|
+
* where: { id: userId, tenantId: tenant.id },
|
|
94
|
+
* });
|
|
95
|
+
* return user;
|
|
96
|
+
* },
|
|
97
|
+
* async () => {
|
|
98
|
+
* const session = await getSession();
|
|
99
|
+
* return session?.tenantId || null;
|
|
100
|
+
* }
|
|
101
|
+
* );
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export declare function withServerAction<T extends any[], R>(handler: (...args: T) => Promise<R> | R, getTenantId: () => Promise<string | null> | string | null): (...args: T) => Promise<R>;
|
|
105
|
+
export {};
|
|
106
|
+
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,SAAS,CAAC;AAO5D,iFAAiF;AACjF,UAAU,eAAe;IACvB,GAAG,EAAE;QACH,GAAG,EAAE;YAAE,OAAO,EAAE,OAAO,CAAA;SAAE,CAAC;QAC1B,GAAG,EAAE,MAAM,CAAC;QACZ,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;KAC9C,CAAC;IACF,IAAI,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,KAAK,QAAQ,CAAC;CACnD;AAYD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,GAAE,OAAO,CAAC,YAAY,CAAM,IAuBnD,GAAG,eAAe,EAAE,MAAM,MAAM,OAAO,CAAC,QAAQ,CAAC,uBAyDhE;AAMD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS,GAAG,EAAE,EAAE,CAAC,EAC3C,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EACvC,MAAM,GAAE,OAAO,CAAC,YAAY,CAAM,IAQpB,GAAG,MAAM,CAAC,KAAG,OAAO,CAAC,CAAC,CAAC,CAsDtC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,GAAG,EAAE,EAAE,CAAC,EACjD,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EACvC,WAAW,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,MAAM,GAAG,IAAI,IAE3C,GAAG,MAAM,CAAC,KAAG,OAAO,CAAC,CAAC,CAAC,CAiBtC"}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { logger } from "@nebutra/logger";
|
|
2
|
+
import { runWithTenant } from "./context";
|
|
3
|
+
import { fromHeader, fromJwtClaim, fromPath, fromSubdomain } from "./resolvers";
|
|
4
|
+
import { TenantRequiredError } from "./types";
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Hono Middleware for Multi-Tenant Context
|
|
7
|
+
// =============================================================================
|
|
8
|
+
/**
|
|
9
|
+
* Create a Hono middleware that extracts tenant from request and sets context.
|
|
10
|
+
*
|
|
11
|
+
* - Resolves tenant ID using configured strategy (header, subdomain, path, JWT, API key)
|
|
12
|
+
* - Wraps handler execution with AsyncLocalStorage context
|
|
13
|
+
* - Returns 400 if tenant is required but not resolved
|
|
14
|
+
*
|
|
15
|
+
* @param config Tenant configuration
|
|
16
|
+
* @returns A Hono middleware function
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { Hono } from "hono";
|
|
21
|
+
* import { tenantMiddleware } from "@nebutra/tenant/middleware";
|
|
22
|
+
*
|
|
23
|
+
* const app = new Hono();
|
|
24
|
+
*
|
|
25
|
+
* app.use(
|
|
26
|
+
* tenantMiddleware({
|
|
27
|
+
* headerName: "x-tenant-id",
|
|
28
|
+
* requireTenant: true,
|
|
29
|
+
* })
|
|
30
|
+
* );
|
|
31
|
+
*
|
|
32
|
+
* app.get("/api/data", (c) => {
|
|
33
|
+
* const tenant = getCurrentTenant();
|
|
34
|
+
* return c.json({ tenantId: tenant.id });
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* export default app;
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function tenantMiddleware(config = {}) {
|
|
41
|
+
const mergedConfig = {
|
|
42
|
+
headerName: "x-tenant-id",
|
|
43
|
+
requireTenant: true,
|
|
44
|
+
...config,
|
|
45
|
+
};
|
|
46
|
+
// Determine which resolver to use
|
|
47
|
+
let resolver;
|
|
48
|
+
if (mergedConfig.resolver) {
|
|
49
|
+
resolver = mergedConfig.resolver;
|
|
50
|
+
}
|
|
51
|
+
else if (mergedConfig.subdomainPattern) {
|
|
52
|
+
resolver = fromSubdomain(mergedConfig.subdomainPattern);
|
|
53
|
+
}
|
|
54
|
+
else if (mergedConfig.pathPrefix) {
|
|
55
|
+
resolver = fromPath(mergedConfig.pathPrefix);
|
|
56
|
+
}
|
|
57
|
+
else if (mergedConfig.jwtClaimName) {
|
|
58
|
+
resolver = fromJwtClaim(mergedConfig.jwtClaimName);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Default: header-based resolution
|
|
62
|
+
resolver = fromHeader(mergedConfig.headerName);
|
|
63
|
+
}
|
|
64
|
+
return async (c, next) => {
|
|
65
|
+
try {
|
|
66
|
+
// Extract tenant ID from request
|
|
67
|
+
const honoHeaders = {};
|
|
68
|
+
const rawHeaders = c.req.raw.headers;
|
|
69
|
+
rawHeaders.forEach((value, key) => {
|
|
70
|
+
honoHeaders[key.toLowerCase()] = value;
|
|
71
|
+
});
|
|
72
|
+
const resolverInput = {
|
|
73
|
+
headers: honoHeaders,
|
|
74
|
+
url: c.req.url,
|
|
75
|
+
};
|
|
76
|
+
const authHeader = c.req.header("Authorization");
|
|
77
|
+
if (authHeader)
|
|
78
|
+
resolverInput.token = authHeader.replace(/^Bearer\s+/i, "");
|
|
79
|
+
const apiKeyHeader = c.req.header("X-API-Key");
|
|
80
|
+
if (apiKeyHeader)
|
|
81
|
+
resolverInput.apiKey = apiKeyHeader;
|
|
82
|
+
const tenantId = await Promise.resolve(resolver(resolverInput));
|
|
83
|
+
if (!tenantId) {
|
|
84
|
+
if (mergedConfig.requireTenant) {
|
|
85
|
+
logger.warn("Tenant context required but not resolved", {
|
|
86
|
+
url: c.req.url,
|
|
87
|
+
});
|
|
88
|
+
return c.json({
|
|
89
|
+
error: "Tenant context required",
|
|
90
|
+
message: "No valid tenant ID found in request",
|
|
91
|
+
}, 400);
|
|
92
|
+
}
|
|
93
|
+
// Tenant is optional, proceed without context
|
|
94
|
+
logger.debug("Tenant context not resolved (optional)");
|
|
95
|
+
return next();
|
|
96
|
+
}
|
|
97
|
+
logger.debug("Tenant resolved in middleware", { tenantId });
|
|
98
|
+
// Execute handler within tenant context
|
|
99
|
+
return await runWithTenant({ id: tenantId }, async () => {
|
|
100
|
+
return next();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
logger.error("Tenant middleware error", err);
|
|
105
|
+
return c.json({
|
|
106
|
+
error: "Internal server error",
|
|
107
|
+
message: "Failed to process tenant context",
|
|
108
|
+
}, 500);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// =============================================================================
|
|
113
|
+
// Next.js API Route / Server Action Wrapper
|
|
114
|
+
// =============================================================================
|
|
115
|
+
/**
|
|
116
|
+
* Wrap a Next.js API route handler to extract and set tenant context.
|
|
117
|
+
*
|
|
118
|
+
* Uses the same resolver logic as Hono middleware but for Next.js.
|
|
119
|
+
*
|
|
120
|
+
* @param handler The API route handler
|
|
121
|
+
* @param config Tenant configuration
|
|
122
|
+
* @returns Wrapped handler that sets tenant context
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```ts
|
|
126
|
+
* // pages/api/data.ts
|
|
127
|
+
* import { withTenant } from "@nebutra/tenant/middleware";
|
|
128
|
+
* import { getCurrentTenant } from "@nebutra/tenant";
|
|
129
|
+
* import type { NextApiRequest, NextApiResponse } from "next";
|
|
130
|
+
*
|
|
131
|
+
* export default withTenant(
|
|
132
|
+
* async (req: NextApiRequest, res: NextApiResponse) => {
|
|
133
|
+
* const tenant = getCurrentTenant();
|
|
134
|
+
* res.json({ tenantId: tenant.id });
|
|
135
|
+
* },
|
|
136
|
+
* { headerName: "x-tenant-id" }
|
|
137
|
+
* );
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export function withTenant(handler, config = {}) {
|
|
141
|
+
const mergedConfig = {
|
|
142
|
+
headerName: "x-tenant-id",
|
|
143
|
+
requireTenant: true,
|
|
144
|
+
...config,
|
|
145
|
+
};
|
|
146
|
+
return async (...args) => {
|
|
147
|
+
// Extract req from args (Next.js convention: req is first arg)
|
|
148
|
+
const req = args[0];
|
|
149
|
+
if (!req || !req.headers) {
|
|
150
|
+
logger.warn("withTenant: No request object found");
|
|
151
|
+
if (mergedConfig.requireTenant) {
|
|
152
|
+
throw new TenantRequiredError("No request context available");
|
|
153
|
+
}
|
|
154
|
+
return handler(...args);
|
|
155
|
+
}
|
|
156
|
+
// Determine resolver
|
|
157
|
+
let resolver;
|
|
158
|
+
if (mergedConfig.resolver) {
|
|
159
|
+
resolver = mergedConfig.resolver;
|
|
160
|
+
}
|
|
161
|
+
else if (mergedConfig.subdomainPattern) {
|
|
162
|
+
resolver = fromSubdomain(mergedConfig.subdomainPattern);
|
|
163
|
+
}
|
|
164
|
+
else if (mergedConfig.pathPrefix) {
|
|
165
|
+
resolver = fromPath(mergedConfig.pathPrefix);
|
|
166
|
+
}
|
|
167
|
+
else if (mergedConfig.jwtClaimName) {
|
|
168
|
+
resolver = fromJwtClaim(mergedConfig.jwtClaimName);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
resolver = fromHeader(mergedConfig.headerName);
|
|
172
|
+
}
|
|
173
|
+
// Resolve tenant ID
|
|
174
|
+
const withTenantInput = {
|
|
175
|
+
headers: req.headers,
|
|
176
|
+
};
|
|
177
|
+
if (req.url)
|
|
178
|
+
withTenantInput.url = `${req.headers.host || ""}${req.url}`;
|
|
179
|
+
const authorization = req.headers.authorization;
|
|
180
|
+
if (authorization)
|
|
181
|
+
withTenantInput.token = authorization.replace(/^Bearer\s+/i, "");
|
|
182
|
+
const apiKey = req.headers["x-api-key"];
|
|
183
|
+
if (apiKey)
|
|
184
|
+
withTenantInput.apiKey = apiKey;
|
|
185
|
+
const tenantId = await Promise.resolve(resolver(withTenantInput));
|
|
186
|
+
if (!tenantId) {
|
|
187
|
+
if (mergedConfig.requireTenant) {
|
|
188
|
+
logger.warn("withTenant: Tenant required but not resolved");
|
|
189
|
+
throw new TenantRequiredError("No tenant context found in request");
|
|
190
|
+
}
|
|
191
|
+
logger.debug("withTenant: Tenant context not resolved (optional)");
|
|
192
|
+
return handler(...args);
|
|
193
|
+
}
|
|
194
|
+
logger.debug("withTenant: Tenant resolved", { tenantId });
|
|
195
|
+
// Execute handler within tenant context
|
|
196
|
+
return runWithTenant({ id: tenantId }, () => handler(...args));
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Wrap a Next.js Server Action to extract and set tenant context.
|
|
201
|
+
*
|
|
202
|
+
* Server Actions don't have direct access to request headers, so tenant ID
|
|
203
|
+
* should be passed explicitly or resolved from authentication context.
|
|
204
|
+
*
|
|
205
|
+
* @param handler The server action handler
|
|
206
|
+
* @param getTenantId Function to extract tenant ID (e.g., from auth context)
|
|
207
|
+
* @returns Wrapped handler that sets tenant context
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```ts
|
|
211
|
+
* // lib/actions.ts
|
|
212
|
+
* "use server";
|
|
213
|
+
* import { withServerAction } from "@nebutra/tenant/middleware";
|
|
214
|
+
* import { getSession } from "@/lib/auth";
|
|
215
|
+
*
|
|
216
|
+
* export const getUserData = withServerAction(
|
|
217
|
+
* async (userId: string) => {
|
|
218
|
+
* const tenant = getCurrentTenant();
|
|
219
|
+
* const user = await db.user.findUnique({
|
|
220
|
+
* where: { id: userId, tenantId: tenant.id },
|
|
221
|
+
* });
|
|
222
|
+
* return user;
|
|
223
|
+
* },
|
|
224
|
+
* async () => {
|
|
225
|
+
* const session = await getSession();
|
|
226
|
+
* return session?.tenantId || null;
|
|
227
|
+
* }
|
|
228
|
+
* );
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
export function withServerAction(handler, getTenantId) {
|
|
232
|
+
return async (...args) => {
|
|
233
|
+
try {
|
|
234
|
+
const tenantId = await Promise.resolve(getTenantId());
|
|
235
|
+
if (!tenantId) {
|
|
236
|
+
logger.warn("withServerAction: Tenant ID not available");
|
|
237
|
+
throw new TenantRequiredError("No tenant context available in server action");
|
|
238
|
+
}
|
|
239
|
+
logger.debug("withServerAction: Tenant resolved", { tenantId });
|
|
240
|
+
return runWithTenant({ id: tenantId }, () => handler(...args));
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
logger.error("withServerAction: Error setting tenant context", err);
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|