@happyvertical/smrt-tenancy
Production-ready multi-tenancy framework with automatic tenant isolation, AsyncLocalStorage context propagation, and framework adapters.
v0.19.0Multi-TenancyESM
Overview
The @happyvertical/smrt-tenancy package (NEW in v0.19.0) provides automatic tenant
isolation for SaaS applications built on SMRT-core.
Key Features
- Automatic Query Filtering: WHERE tenantId = current added automatically
- AsyncLocalStorage Context: Propagates tenant through async operations
- Decorator-Based: @TenantScoped marks classes for isolation
- Framework Adapters: SvelteKit, Express, CLI support
- Super-Admin Bypass: Controlled cross-tenant access
- Raw SQL Policy: Prevents accidental data leaks
- Testing Utilities: Complete test suite helpers
Architecture
Request Flow ├─ Framework Adapter (SvelteKit/Express) │ └─ Resolve tenant ID from source │ └─ enterTenantContext() │ └─ AsyncLocalStorage.run(context) │ └─ SMRT Operation (list/get/save) │ └─ TenantInterceptor │ ├─ Check: Class tenant-scoped? │ ├─ Check: Context available? │ ├─ Auto-filter: Add tenantId │ ├─ Auto-populate: Set tenantId │ └─ Validate: Isolation rules
Installation
bash
npm install @happyvertical/smrt-tenancySetup (3 Steps)
1. Enable Tenancy Globally
typescript
import { enableTenancy } from '@happyvertical/smrt-tenancy';
enableTenancy({
rawQueryPolicy: 'throw', // Prevent raw SQL
onMissingContext: (className, operation) => {
console.error('Missing context: ' + className + ' ' + operation);
}
});2. Mark Classes as Tenant-Scoped
typescript
import { smrt, SmrtObject } from '@happyvertical/smrt-core';
import { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';
@smrt()
@TenantScoped()
class Document extends SmrtObject {
tenantId = tenantId(); // Framework manages this
title: string = '';
content: string = '';
}3. Setup Framework Middleware
typescript
// SvelteKit (hooks.server.ts)
import { createSvelteKitHandle } from '@happyvertical/smrt-tenancy/adapters';
export const handle = createSvelteKitHandle({
resolveTenantId: async (event) => {
const host = event.request.headers.get('host');
return host?.split('.')[0]; // tenant.example.com
}
});
// Express
import { createExpressMiddleware } from '@happyvertical/smrt-tenancy/adapters';
app.use(createExpressMiddleware({
resolveTenantId: (req) => req.headers['x-tenant-id'] as string
}));Quick Start
1. Define Tenant-Scoped Model
typescript
@smrt()
@TenantScoped()
class Project extends SmrtObject {
tenantId = tenantId();
name: string = '';
owner: string = '';
}2. Automatic Filtering
typescript
// In route handler (context established by middleware)
app.get('/api/projects', async (req, res) => {
// Query automatically filtered by tenant
const projects = await projectCollection.list({
where: { status: 'active' }
});
// Actual SQL: WHERE tenantId = 'current' AND status = 'active'
res.json(projects);
});3. Automatic Population
typescript
// Create new project
app.post('/api/projects', async (req, res) => {
const project = await projectCollection.create({
name: req.body.name,
// tenantId auto-populated from context
});
res.json(project);
});4. Isolation Validation
typescript
// Delete validates tenant ownership
app.delete('/api/projects/:id', async (req, res) => {
const project = await projectCollection.get(req.params.id);
// Throws TenantIsolationError if wrong tenant
await project.delete();
res.json({ deleted: true });
});Core Concepts
1. Tenant Context
AsyncLocalStorage-based context flows through async operations:
typescript
import { getTenantId, requireTenantId, withTenant } from '@happyvertical/smrt-tenancy';
// Non-throwing getter
const tenantId = getTenantId(); // string | undefined
// Throwing getter (for required cases)
const tenantId = requireTenantId(); // throws if undefined
// Manual context setting
await withTenant({ tenantId: 'tenant-123' }, async () => {
const projects = await projectCollection.list({});
});2. Isolation Modes
Required Mode (default)
typescript
@TenantScoped({ mode: 'required' })
class SecretData extends SmrtObject {
tenantId = tenantId();
}
// This throws TenantContextError
await secretDataCollection.list({}); // No context!Optional Mode
typescript
@TenantScoped({ mode: 'optional' })
class PublicConfig extends SmrtObject {
tenantId = tenantId({ nullable: true });
}
// Both work
await configCollection.list({}); // All records
await withTenant({ tenantId: 't1' }, () =>
configCollection.list({})
); // Filtered3. Super-Admin Bypass
Controlled cross-tenant access for privileged operations:
typescript
@TenantScoped({ allowSuperAdminBypass: true })
class AuditLog extends SmrtObject {
tenantId = tenantId();
}
// Admin viewing all logs
await withSuperAdminBypass(async () => {
const allLogs = await auditLogCollection.list({});
// No tenant filtering
});4. Raw SQL Policy
Prevents accidental data leaks via raw queries:
typescript
enableTenancy({
rawQueryPolicy: 'throw' // Default
});
// This throws TenantIsolationError
await projectCollection.query({
sql: 'SELECT * FROM projects',
params: []
});
// Explicitly allowed
await projectCollection.query({
sql: 'SELECT * FROM projects WHERE status = ?',
params: ['active'],
allowRawOnTenantScoped: true // Override
});Testing
typescript
import {
createTestTenantContext,
setupTestTenancy,
assertTenantContextRequired
} from '@happyvertical/smrt-tenancy/testing';
describe('Project isolation', () => {
beforeAll(() => {
setupTestTenancy({ enableInterceptors: true });
});
it('should isolate projects by tenant', async () => {
// Create in tenant A
const projectA = await createTestTenantContext(
{ tenantId: 'tenant-a' },
async () => {
return projectCollection.create({ name: 'Project A' });
}
);
// Verify not visible in tenant B
await createTestTenantContext(
{ tenantId: 'tenant-b' },
async () => {
const found = await projectCollection.get(projectA.id);
expect(found).toBeNull(); // Isolated!
}
);
});
it('should require context', async () => {
await assertTenantContextRequired(() =>
projectCollection.list({})
);
});
});Integration with Other Modules
smrt-users (RBAC)
typescript
const handle = createSvelteKitHandle({
resolveTenantId: async (event) => {
const auth = await getAuth(event);
return auth?.tenantId;
},
resolvePermissions: async (event, tenantId, userId) => {
const perms = await getPermissions(userId, tenantId);
return new Set(perms);
},
isSuperAdmin: async (event, tenantId, userId) => {
const user = await getUser(userId);
return user?.roles?.includes('admin') ?? false;
}
});
// Use in business logic
import { getCurrentTenant } from '@happyvertical/smrt-tenancy';
function requirePermission(permission: string): boolean {
const context = getCurrentTenant();
return context?.permissions?.has(permission) ?? false;
}Message Queues
typescript
// Capture context when queueing
export async function scheduleJob(data: unknown) {
const tenantId = requireTenantId();
await queue.enqueue({
type: 'process_data',
data,
metadata: { tenantId } // Capture!
});
}
// Restore context in worker
async function processJob(job: Job) {
await withTenant(
{ tenantId: job.metadata.tenantId },
async () => {
await handleJob(job);
}
);
}Best Practices
1. Always Use Auto-Filtering
typescript
// Good
@TenantScoped()
class Document extends SmrtObject {
tenantId = tenantId(); // Auto-filters
}
// Risky
@TenantScoped({ autoFilter: false })
class Document extends SmrtObject {
tenantId = tenantId(); // Manual filtering error-prone
}2. Block Raw SQL by Default
typescript
enableTenancy({
rawQueryPolicy: 'throw' // Prevent leaks
});3. Validate at Middleware Entry
typescript
createExpressMiddleware({
resolveTenantId: async (req) => {
const tenantId = req.headers['x-tenant-id'];
// Verify tenant exists
const tenant = await getTenant(tenantId as string);
if (!tenant) {
throw new Error('Invalid tenant');
}
return tenantId;
}
});4. Index tenantId Field
sql
CREATE INDEX idx_documents_tenant_id
ON documents(tenant_id);
CREATE INDEX idx_documents_tenant_status
ON documents(tenant_id, status);5. Audit Cross-Tenant Access
typescript
enableTenancy({
onIsolationViolation: (className, expected, actual) => {
logger.warn('ISOLATION_VIOLATION', {
className,
expectedTenant: expected,
attemptedTenant: actual,
timestamp: new Date()
});
}
});Troubleshooting
TenantContextError: No context available
Solution: Ensure middleware is registered before routes
typescript
// Verify middleware registered
app.use(createExpressMiddleware({...})); // BEFORE routes
// Verify in handler
console.log(getTenantId()); // Should not be undefinedTenantIsolationError: Cross-tenant access
Solution: Enable bypass if needed for admins
typescript
@TenantScoped({ allowSuperAdminBypass: true })
class AuditLog extends SmrtObject {
tenantId = tenantId();
}
// Verify bypass is active
isSuperAdmin: async (event) => {
return user?.isAdmin ?? false;
}Raw SQL query failed
Solution: Use list()/get() or explicit bypass
typescript
// Instead of raw SQL
await collection.query({ sql: 'SELECT...' });
// Use collection methods
await collection.list({ where: {...} });
// Or explicit bypass
await collection.query({
sql: 'SELECT...',
allowRawOnTenantScoped: true
});Context lost in background jobs
Solution: Capture tenantId in job metadata
typescript
// When queueing
const job = {
type: 'process',
metadata: {
tenantId: requireTenantId() // Capture!
}
};
// When processing
await withTenant(
{ tenantId: job.metadata.tenantId },
async () => {
// Context restored
}
);API Reference
Context Functions
| Function | Returns | Description |
|---|---|---|
getTenantId() | string | undefined | Get current tenant ID |
requireTenantId() | string | Get tenant ID, throw if undefined |
getCurrentTenant() | TenantContextData | undefined | Get full context |
withTenant(ctx, fn) | Promise<T> | Run fn in tenant context |
withSuperAdminBypass(fn) | Promise<T> | Run fn with bypass enabled |
withSystemContext(fn) | Promise<T> | Run fn without tenant context |
Decorator Options
| Option | Type | Default | Description |
|---|---|---|---|
mode | 'required' | 'optional' | 'required' | Context requirement |
field | string | 'tenantId' | Tenant ID field name |
autoFilter | boolean | true | Auto-add tenant to queries |
autoPopulate | boolean | true | Auto-set tenant on save |
allowSuperAdminBypass | boolean | false | Enable bypass for this class |
Framework Adapters
createSvelteKitHandle(options)- SvelteKit middlewarecreateExpressMiddleware(options)- Express middlewarecreateCliContext(options)- CLI context manager
Testing Utilities
setupTestTenancy(options?)- Enable tenancy for testscreateTestTenantContext(ctx, fn)- Run fn in test contextassertTenantContextRequired(fn)- Assert error thrownresetTenancy()- Clear tenancy state