@nebutra/tenant
v0.1.2
Published
> **Status: Foundation** — Type definitions, factory pattern, and provider stubs are complete. Provider implementations require external service credentials to activate. See inline TODOs for integration points.
Downloads
374
Readme
Status: Foundation — Type definitions, factory pattern, and provider stubs are complete. Provider implementations require external service credentials to activate. See inline TODOs for integration points.
@nebutra/tenant
Multi-tenancy context and isolation for the Nebutra platform.
Provides:
- Request-scoped tenant context via AsyncLocalStorage (zero-copy, type-safe)
- Tenant resolution strategies (header, subdomain, path, JWT, API key)
- Middleware integration (Hono + Next.js)
- Database isolation (RLS, schema-per-tenant, database-per-tenant)
- Deterministic RLS SQL generation for migration-reviewed shared-schema policies
- React hooks for client-side tenant access
Installation
pnpm add @nebutra/tenantQuick Start
1. Set Tenant Context in Middleware
Hono (API Gateway)
import { Hono } from "hono";
import { tenantMiddleware } from "@nebutra/tenant/middleware";
import { getCurrentTenant } from "@nebutra/tenant";
const app = new Hono();
// Resolve tenant from x-tenant-id header
app.use(
tenantMiddleware({
headerName: "x-tenant-id",
requireTenant: true,
})
);
app.get("/api/data", async (c) => {
const tenant = getCurrentTenant();
return c.json({ tenantId: tenant.id });
});
export default app;Next.js API Route
// pages/api/data.ts
import { withTenant } from "@nebutra/tenant/middleware";
import { getCurrentTenant } from "@nebutra/tenant";
export default withTenant(async (req, res) => {
const tenant = getCurrentTenant();
res.json({ tenantId: tenant.id });
});Next.js Server Action
// lib/actions.ts
"use server";
import { withServerAction } from "@nebutra/tenant/middleware";
import { getCurrentTenant } from "@nebutra/tenant";
import { getSession } from "@/lib/auth";
export const fetchUserData = withServerAction(
async (userId: string) => {
const tenant = getCurrentTenant();
const user = await db.user.findUnique({
where: { id: userId, tenantId: tenant.id },
});
return user;
},
async () => {
const session = await getSession();
return session?.tenantId || null;
}
);2. Access Tenant Context
In Server Code
import { getCurrentTenant, getTenantOrNull } from "@nebutra/tenant";
// Throws error if tenant not found
const tenant = getCurrentTenant();
console.log(tenant.id);
// Returns null if tenant not found
const tenantOpt = getTenantOrNull();
if (tenantOpt) {
console.log(tenantOpt.id);
}In Client Components
"use client";
import { useTenant, useTenantId } from "@nebutra/tenant/react";
import { TenantProvider } from "@nebutra/tenant/react";
// In layout or root component
export default function RootLayout({ tenant, children }) {
return (
<TenantProvider value={tenant}>
{children}
</TenantProvider>
);
}
// In child components
function Dashboard() {
const tenantId = useTenantId();
return <div>Tenant: {tenantId}</div>;
}3. Database Isolation (Shared Schema + RLS)
import { PrismaClient } from "@prisma/client";
import { withRls } from "@nebutra/tenant/isolation";
import { getCurrentTenant } from "@nebutra/tenant";
const prisma = new PrismaClient();
// In a request handler
const tenant = getCurrentTenant();
const client = withRls(prisma, tenant.id);
// All queries now enforce RLS policies
const users = await client.user.findMany();Generate migration SQL for the backing PostgreSQL policies:
import { generateRlsPolicySql } from "@nebutra/tenant/isolation";
const sql = generateRlsPolicySql({
tables: ["users", "audit_logs"],
});
// Review and apply through your migration tool.Tenant Resolution Strategies
Header (Default)
import { tenantMiddleware } from "@nebutra/tenant/middleware";
app.use(
tenantMiddleware({
headerName: "x-tenant-id", // or "x-org-id", etc
})
);Subdomain
import { tenantMiddleware } from "@nebutra/tenant/middleware";
app.use(
tenantMiddleware({
subdomainPattern: "^([a-z0-9-]+)\\.app\\.nebutra\\.com$",
})
);
// Resolves tenant ID from "acme.app.nebutra.com" → "acme"URL Path
import { tenantMiddleware } from "@nebutra/tenant/middleware";
app.use(
tenantMiddleware({
pathPrefix: "/org/:tenantId",
})
);
// Resolves tenant ID from "/org/acme/dashboard" → "acme"JWT Claim
import { tenantMiddleware } from "@nebutra/tenant/middleware";
app.use(
tenantMiddleware({
jwtClaimName: "tenant_id", // or "org_id", etc
})
);API Key (Custom)
import { fromApiKey, compose } from "@nebutra/tenant";
import { tenantMiddleware } from "@nebutra/tenant/middleware";
const keyResolver = fromApiKey(async (apiKey) => {
const key = await db.apiKey.findUnique({
where: { key: apiKey },
});
return key?.tenantId ?? null;
});
app.use(
tenantMiddleware({
resolver: keyResolver,
})
);Composite (Fallback Chain)
import { compose, fromHeader, fromSubdomain, fromPath } from "@nebutra/tenant";
import { tenantMiddleware } from "@nebutra/tenant/middleware";
const resolver = compose(
fromHeader("x-tenant-id"),
fromSubdomain("^([a-z0-9-]+)\\.app\\.nebutra\\.com$"),
fromPath("/org/:tenantId")
);
app.use(
tenantMiddleware({
resolver,
})
);
// Tries header first, then subdomain, then pathIsolation Strategies
Shared Schema + RLS (Default)
Single PostgreSQL schema with Row-Level Security (RLS) policies enforced at the database level.
import { withRls } from "@nebutra/tenant/isolation";
const prisma = new PrismaClient();
const client = withRls(prisma, tenantId);
// All queries filtered by RLS policy:
// WHERE current_setting('app.current_tenant_id') = table.tenant_idSchema Per Tenant
Separate PostgreSQL schema per tenant (e.g., org_acme_public, org_customer_public).
import { getTenantSchema } from "@nebutra/tenant/isolation";
const schemaName = getTenantSchema("acme-corp");
// Returns: "org_acme_corp_public"
// Use with migration tool:
// pnpm prisma migrate deploy --schema org_acme_corp_publicDatabase Per Tenant
Separate PostgreSQL database per tenant, with connection pooling.
import { getTenantDatabaseUrl } from "@nebutra/tenant/isolation";
const tenantDbUrl = getTenantDatabaseUrl("acme-corp");
// Returns: "postgresql://user:pass@localhost/nebutra_acme_corp"
// Create new Prisma client for this database:
const tenantPrisma = new PrismaClient({
datasources: {
db: { url: tenantDbUrl },
},
});Types
TenantContext
Runtime tenant context passed through requests.
interface TenantContext {
id: string; // Unique tenant identifier
slug?: string; // URL-friendly slug
plan?: "free" | "pro" | "enterprise"; // Subscription tier
features?: string[]; // Feature flags
limits?: Record<string, number>; // Rate limits and quotas
metadata?: Record<string, unknown>; // Custom data
}TenantInfo
Persistent tenant information, usually loaded from database.
interface TenantInfo {
id: string;
slug: string;
name: string;
plan: "free" | "pro" | "enterprise";
createdAt: Date;
settings: Record<string, unknown>;
parentTenantId?: string; // For hierarchical orgs
}TenantConfig
Configuration for tenant extraction and isolation.
interface TenantConfig {
strategy?: "shared_schema" | "schema_per_tenant" | "database_per_tenant";
headerName?: string; // Default: "x-tenant-id"
subdomainPattern?: string; // Regex for subdomain extraction
pathPrefix?: string; // URL path prefix
jwtClaimName?: string; // JWT claim name
requireTenant?: boolean; // Throw error if not found (default: true)
resolver?: TenantResolver; // Custom resolver function
}API Reference
Context
runWithTenant(context, fn)— Execute function with tenant contextgetCurrentTenant()— Get current tenant (throws if missing)getTenantOrNull()— Get current tenant or nullrequireTenant(tenant, context?)— Assertion helpergetCurrentTenantId()— Get tenant ID (throws if missing)getTenantIdOrNull()— Get tenant ID or null
Resolvers
fromHeader(headerName?)— Resolve from HTTP headerfromSubdomain(pattern)— Resolve from subdomain regexfromPath(prefix)— Resolve from URL pathfromJwtClaim(claimName)— Resolve from JWT claimfromApiKey(lookupFn)— Resolve from API key lookupcompose(...resolvers)— Composite resolver with fallback
Middleware
tenantMiddleware(config)— Hono middlewarewithTenant(handler, config)— Next.js API route wrapperwithServerAction(handler, getTenantId)— Next.js Server Action wrapper
Isolation
withRls(prisma, tenantId)— Apply RLS extension to PrismagenerateRlsPolicySql(options)— Generate deterministic PostgreSQL RLS policy SQLgetTenantSchema(tenantId)— Get schema name for schema-per-tenantgetTenantDatabaseUrl(tenantId, baseUrl?)— Get DB URL for database-per-tenantTenantAwarePrismaClient— Wrapper class for tenant isolationcreateTenantPrismaProxy(prisma, tenantId, strategy)— Factory for isolation proxies
React
TenantProvider— Context provider componentuseTenant()— Hook to get current tenantuseTenantOrNull()— Hook to get current tenant or nulluseTenantId()— Hook to get tenant IDuseTenantIdOrNull()— Hook to get tenant ID or nulluseTenantPlan()— Hook to get tenant plan tieruseTenantFeature(feature)— Hook to check feature flaguseTenantLimit(limitName, defaultValue?)— Hook to get rate limitwithTenantGuard(Component, errorFallback?)— HOC requiring tenantTenantBoundary— Component requiring tenant context
Errors
TenantRequiredError
Thrown when tenant context is required but not found.
import { TenantRequiredError } from "@nebutra/tenant";
try {
const tenant = getCurrentTenant();
} catch (err) {
if (err instanceof TenantRequiredError) {
console.log(err.statusCode); // 400
}
}TenantIsolationError
Thrown when database isolation fails.
import { TenantIsolationError } from "@nebutra/tenant";
try {
const schema = getTenantSchema(invalidId);
} catch (err) {
if (err instanceof TenantIsolationError) {
console.log(err.strategy); // "schema_per_tenant"
}
}Examples
Multi-Tenant SaaS Dashboard
// middleware.ts
import { tenantMiddleware } from "@nebutra/tenant/middleware";
import { compose, fromHeader, fromSubdomain } from "@nebutra/tenant";
const resolver = compose(
fromHeader("x-tenant-id"),
fromSubdomain("^([a-z0-9-]+)\\.app\\.nebutra\\.com$")
);
export const middleware = tenantMiddleware({ resolver });
// pages/api/dashboard/stats.ts
import { getCurrentTenant } from "@nebutra/tenant";
import { withRls } from "@nebutra/tenant/isolation";
export default withTenant(async (req, res) => {
const tenant = getCurrentTenant();
const prisma = withRls(db, tenant.id);
const stats = await prisma.stat.aggregate({
where: { tenantId: tenant.id },
});
res.json(stats);
});
// components/Dashboard.tsx
"use client";
import { useTenantId } from "@nebutra/tenant/react";
export function Dashboard() {
const tenantId = useTenantId();
return <div>Dashboard for {tenantId}</div>;
}Feature-Gated Functionality
"use client";
import { useTenantFeature, useTenantPlan } from "@nebutra/tenant/react";
function AdvancedAnalytics() {
const hasAdvanced = useTenantFeature("advanced_analytics");
const plan = useTenantPlan();
if (!hasAdvanced && plan === "free") {
return <UpgradePrompt />;
}
return <AnalyticsPanel />;
}See Also
- @nebutra/logger — Structured logging
- @nebutra/queue — Message queue with tenant support
- Prisma RLS Documentation
