@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.
@@ -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
+ }