@node-tenant/tenant-monorepo
v1.0.0
Published
Enterprise Multi-Tenant Platform for Node.js
Readme
@node-tenant/tenant
Enterprise Multi-Tenant Platform for Node.js — framework-agnostic, ORM-agnostic, production-ready.
What is this?
@node-tenant/tenant is a complete multi-tenant ecosystem for Node.js — think Django-Tenants, but for the JavaScript world. It is not just tenant detection middleware. It is a full platform covering:
| Feature | Description |
|---|---|
| Tenant Lifecycle | Create, update, suspend, delete tenants |
| Organizations | Group tenants under parent orgs (HiringGo → India, USA, UAE) |
| Relationships | Tenant-to-tenant connections (partner, collaboration, etc.) |
| Resource Sharing | Share candidates, documents, etc. across tenants with permissions |
| Multiple DB Strategies | tenantId column, schema-per-tenant, database-per-tenant, or hybrid |
| Framework Adapters | Express, Next.js, NestJS, Fastify |
| ORM Adapters | Prisma, TypeORM, Sequelize, Mongoose |
| Storage Adapters | Pluggable storage for the public control layer |
| Plugin System | Extend with custom plugins and lifecycle hooks |
| Event System | Subscribe to tenant.created, shared, etc. |
| Permissions Engine | read / write / admin / owner per resource type |
| CLI | tenant create, migrate-all, share, and more |
| AI Plugin | Tenant-aware Claude AI integration (optional) |
Quick Start
Install
# Core is always required
pnpm add @node-tenant/tenant-core
# Pick your framework adapter
pnpm add @node-tenant/tenant-express # Express
pnpm add @node-tenant/tenant-next # Next.js
pnpm add @node-tenant/tenant-nestjs # NestJS
pnpm add @node-tenant/tenant-fastify # Fastify
# Pick your storage adapter (for the control layer)
pnpm add @node-tenant/tenant-storage-prisma # Prisma
pnpm add @node-tenant/tenant-storage-typeorm # TypeORM
pnpm add @node-tenant/tenant-storage-mongodb # MongoDB
# Pick your ORM adapter (for tenant query scoping)
pnpm add @node-tenant/tenant-prisma # Prisma tenant-scoped queries
pnpm add @node-tenant/tenant-typeorm # TypeORM
pnpm add @node-tenant/tenant-mongoose # MongooseExpress + Prisma (60 seconds)
import express from 'express';
import { PrismaClient } from '@prisma/client';
import { TenantManager } from '@node-tenant/tenant-core';
import { tenantMiddleware, subdomainResolver } from '@node-tenant/tenant-express';
import { createPrismaStorage } from '@node-tenant/tenant-storage-prisma';
import { useTenantPrisma } from '@node-tenant/tenant-prisma';
const prisma = new PrismaClient();
const storage = createPrismaStorage(prisma);
const manager = new TenantManager({ storage });
const app = express();
app.use(express.json());
// Resolve tenant from subdomain: acme.myapp.com → slug "acme"
app.use(tenantMiddleware({ manager, resolver: subdomainResolver(storage) }));
app.get('/users', async (_req, res) => {
// All queries are automatically scoped to the current tenant
const db = useTenantPrisma(prisma);
const users = await db.user.findMany(); // WHERE tenant_id = 'acme'
res.json(users);
});
app.listen(3000);Multi-Tenancy Strategies
1. tenantId — Shared Database
All tenants share one database. Every table has a tenant_id column.
users
id | tenant_id | name | emailuseTenantPrisma(prisma) automatically injects WHERE tenant_id = ? on every query.
Best for: small-to-medium SaaS apps, lowest infrastructure cost.
2. schema — Schema per Tenant (PostgreSQL / SQL Server)
public.tenants ← control layer
acme.users ← tenant A data
globex.users ← tenant B datauseTenantPrisma(prisma) issues SET search_path TO "acme" before each query.
Best for: strong data isolation, medium complexity.
3. database — Database per Tenant
tenant_control_db ← control layer
tenant_acme_db ← tenant A
tenant_globex_db ← tenant BBest for: enterprise / regulated industries requiring strict isolation.
Hybrid Mode
const db = useTenantPrisma(prisma, {
strategyOverrides: {
'enterprise-tenant-id': 'database',
'free-tier-tenant-id': 'tenantId',
}
});Architecture
┌─────────────────────────────────────────────────────────────┐
│ Public Control Layer │
│ (Organizations, Tenants, Domains, Relationships, Shares) │
│ StorageAdapter: Prisma / TypeORM / MongoDB / Sequelize │
└────────────────────────┬────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
TenantManager TenantContext PermissionEngine
(lifecycle) (AsyncLocal) (read/write/admin)
│
┌─────┴──────┐
│ │
Framework Database
Adapters Adapters
│ │
Express Prisma
Next.js TypeORM
NestJS Mongoose
Fastify Raw SQLTenant Context
The active tenant is stored in AsyncLocalStorage and is available anywhere in the request lifecycle — no prop drilling, no dependency injection required.
import { TenantContextManager } from '@node-tenant/tenant-core';
// Inside any handler / service / repository:
const ctx = TenantContextManager.current();
// { id: 'uuid', slug: 'acme', strategy: 'tenantId', config: {} }Or wrap arbitrary code in a tenant context:
await manager.runAsTenant('acme', async () => {
// All code here runs as tenant "acme"
const ctx = TenantContextManager.currentOrThrow();
});Organizations
Group multiple tenants under one organization:
import { OrganizationManager } from '@node-tenant/tenant-organizations';
const orgManager = new OrganizationManager(manager);
const { organization, tenant } = await orgManager.createWithTenant({
orgName: 'HiringGo',
orgSlug: 'hiringgo',
tenantName: 'HiringGo India',
tenantSlug: 'hiringgo-india',
strategy: 'tenantId',
});
// Add more tenants to the org
await manager.createTenant({
name: 'HiringGo USA',
slug: 'hiringgo-usa',
organizationId: organization.id,
strategy: 'tenantId',
status: 'active',
config: {},
});
// List all tenants in org
const tenants = await orgManager.getTenantsForOrg(organization.id);Tenant Relationships
Connect tenants for cross-tenant workflows:
import { TenantRelationshipManager } from '@node-tenant/tenant-relations';
const relManager = new TenantRelationshipManager(manager);
// Create a partnership
await relManager.createBidirectional(hiringGoId, goognoId, 'partner', {
notes: 'Joint recruitment pipeline',
});
// Check if relationship exists
const isPartner = await relManager.hasRelationship(hiringGoId, goognoId, 'partner');
// Get all related tenant IDs
const partners = await relManager.getRelatedTenantIds(hiringGoId, 'partner');Relationship types: organization | collaboration | partner | marketplace | support
Resource Sharing
Share resources across tenants without duplicating data:
// HiringGo shares a candidate with Goognu (read-only)
const share = await manager.shareResource({
resourceType: 'candidate',
resourceId: 'candidate-john-doe',
ownerTenantId: hiringGoId,
targetTenantId: goognoId,
permission: 'read',
});
// Check access
const canRead = await manager.canAccessResource({
tenantId: goognoId,
resourceType: 'candidate',
resourceId: 'candidate-john-doe',
required: 'read', // true
});
const canWrite = await manager.canAccessResource({
tenantId: goognoId,
resourceType: 'candidate',
resourceId: 'candidate-john-doe',
required: 'write', // false
});
// Revoke sharing
await manager.unshareResource(share.id);Permission hierarchy: owner > admin > write > read
Tenant Resolution
| Strategy | Example | Resolver |
|---|---|---|
| Subdomain | acme.myapp.com | subdomainResolver(storage) |
| Domain | acme.com | domainResolver(storage) |
| Path | /acme/dashboard | pathResolver(storage, 1) |
| Header | X-Tenant-ID: acme | headerResolver(storage) |
| Chain | First match wins | chainResolvers(...) |
import { chainResolvers, subdomainResolver, headerResolver } from '@node-tenant/tenant-core';
const resolver = chainResolvers(
subdomainResolver(storage), // try subdomain first
headerResolver(storage), // fall back to header
);Plugin System
const auditPlugin = {
name: 'audit-logger',
onTenantCreate: async (tenant) => {
await db.auditLog.create({ action: 'tenant_created', tenantId: tenant.id });
},
onTenantResolved: async (ctx) => {
console.log(`Request for tenant: ${ctx.slug}`);
},
};
manager.use(auditPlugin);Plugin hooks: onInstall | onTenantCreate | onTenantUpdate | onTenantDelete | onTenantResolved | onTenantSwitch
Events
manager.on('tenant.created', (tenant) => { /* ... */ });
manager.on('tenant.updated', (tenant) => { /* ... */ });
manager.on('tenant.deleted', ({ tenantId }) => { /* ... */ });
manager.on('tenant.resolved', (ctx) => { /* ... */ });
manager.on('tenant.shared', (share) => { /* ... */ });
manager.on('tenant.relationship.created', (rel) => { /* ... */ });
manager.on('organization.created', (org) => { /* ... */ });CLI
npx @node-tenant/tenant-cli tenant list
npx @node-tenant/tenant-cli tenant create --name "Acme Corp" --slug acme --strategy tenantId
npx @node-tenant/tenant-cli tenant update <id> --status suspended
npx @node-tenant/tenant-cli tenant delete <id> --confirm
npx @node-tenant/tenant-cli tenant migrate <id> --path ./migrations
npx @node-tenant/tenant-cli tenant migrate-all
npx @node-tenant/tenant-cli tenant seed <id>
npx @node-tenant/tenant-cli tenant relationships <tenantId>
npx @node-tenant/tenant-cli tenant share --resource-type candidate --resource-id cand-001 --owner <ownerTenantId> --target <targetTenantId> --permission readCreate tenant.config.ts in your project root:
import { TenantManager } from '@node-tenant/tenant-core';
import { createPrismaStorage } from '@node-tenant/tenant-storage-prisma';
import { PrismaClient } from '@prisma/client';
const manager = new TenantManager({ storage: createPrismaStorage(new PrismaClient()) });
export { manager };AI Plugin
Tenant-aware AI powered by Claude (optional):
pnpm add @node-tenant/tenant-ai @anthropic-ai/sdkimport { TenantAI } from '@node-tenant/tenant-ai';
const ai = new TenantAI({ manager, apiKey: process.env.ANTHROPIC_API_KEY });
// Register a tenant-scoped tool
ai.registerTool({
name: 'list_candidates',
description: 'List candidates for the current tenant',
inputSchema: { type: 'object', properties: { limit: { type: 'number' } } },
execute: async (input, ctx) => {
// ctx.id is the active tenant — data is always isolated
return db.candidate.findMany({ where: { tenantId: ctx.id }, take: input.limit });
},
});
// AI runs within current tenant context, never leaks cross-tenant data
await manager.runAsTenant('acme', async () => {
const answer = await ai.ask('How many candidates do we have?');
console.log(answer);
});NestJS
import { TenantModule, TenantMiddlewareService, CurrentTenant } from '@node-tenant/tenant-nestjs';
@Module({
imports: [
TenantModule.forRoot({ manager, resolver: subdomainResolver(storage), global: true }),
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddlewareService).forRoutes('*');
}
}
@Controller('me')
export class MeController {
@Get()
getMe(@CurrentTenant() tenant: TenantContext) {
return tenant;
}
}Next.js
// middleware.ts
import { withTenant, subdomainResolver } from '@node-tenant/tenant-next';
import { manager } from './lib/tenant';
export const middleware = withTenant(
{ manager, resolver: subdomainResolver(manager.storage) },
(_req, ctx) => {
const response = NextResponse.next();
response.headers.set('x-tenant-id', ctx.id);
return response;
},
);Packages
| Package | Description |
|---|---|
| @node-tenant/tenant-core | Core engine: types, context, events, manager, resolvers, permissions |
| @node-tenant/tenant-express | Express middleware |
| @node-tenant/tenant-next | Next.js adapter |
| @node-tenant/tenant-nestjs | NestJS module + decorators |
| @node-tenant/tenant-fastify | Fastify plugin |
| @node-tenant/tenant-storage-prisma | Prisma storage adapter + schema |
| @node-tenant/tenant-storage-typeorm | TypeORM storage adapter + entities |
| @node-tenant/tenant-storage-mongodb | MongoDB storage adapter |
| @node-tenant/tenant-storage-sequelize | Sequelize storage adapter |
| @node-tenant/tenant-prisma | Prisma ORM tenant-scoped query proxy |
| @node-tenant/tenant-typeorm | TypeORM ORM tenant-scoped repository |
| @node-tenant/tenant-mongoose | Mongoose connection switcher |
| @node-tenant/tenant-relations | Tenant relationship manager |
| @node-tenant/tenant-organizations | Organization manager |
| @node-tenant/tenant-ai | Claude AI plugin with tenant isolation |
| @node-tenant/tenant-cli | CLI tool |
Security
- Tenant isolation enforced via
AsyncLocalStorage— no accidental cross-tenant leaks - Permission hierarchy (
owner > admin > write > read) validated before resource access - Relationship validation — resource sharing requires an established relationship
- Audit logging — all mutations are recorded in
audit_logs - Status checks — suspended/inactive tenants are rejected at the middleware level
- Context verification —
currentOrThrow()fails fast if called outside a tenant context
Development
# Clone and install
git clone https://github.com/adpulseflow/tenant
cd tenant
pnpm install
# Build all packages
pnpm build
# Run tests
pnpm test
# Run a specific example
cd examples/express-prisma-postgres
cp .env.example .env # add your DATABASE_URL
pnpm devContributing
PRs welcome. See CONTRIBUTING.md.
License
MIT © AdPulseFlow
