@happyvertical/smrt-tenancy
Multi-tenancy via AsyncLocalStorage context propagation with automatic query filtering, tenant ID population, and framework adapters (SvelteKit, Express, CLI).
Overview
The @happyvertical/smrt-tenancy package provides automatic tenant
isolation for SaaS applications built on SMRT-core via AsyncLocalStorage context propagation.
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/CLI)
│ └─ Resolve tenant ID from source
│ └─ enterTenantContext()
│ └─ AsyncLocalStorage.run(context)
│ └─ SMRT Operation (list/get/save/delete/query)
│ └─ TenantInterceptor (priority 100)
│ ├─ beforeList: inject tenantId into WHERE
│ ├─ beforeGet: convert to {id, tenantId}
│ ├─ beforeSave: auto-populate + validate
│ ├─ beforeDelete: validate ownership
│ └─ beforeQuery: enforce raw SQL policy
Critical distinction: withSystemContext() sets a SYSTEM_CONTEXT_MARKER
sentinel, different from "no context" (undefined). The interceptor can distinguish intentional
bypass from missing context.
Installation
npm install @happyvertical/smrt-tenancySetup (3 Steps)
1. Enable Tenancy Globally
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 (Two Patterns)
// Pattern 1: @TenantScoped decorator
import { smrt, SmrtObject } from '@happyvertical/smrt-core';
import { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';
@smrt()
@TenantScoped({ mode: 'optional' })
class Document extends SmrtObject {
@tenantId({ nullable: true })
tenantId: string | null = null;
title: string = '';
}
// Pattern 2: Via @smrt() decorator (tenancy package reads this too)
@smrt({ tenantScoped: { mode: 'optional' } })
class Document extends SmrtObject {
tenantId: string | null = null;
title: string = '';
}3. Setup Framework Adapter
// SvelteKit (hooks.server.ts)
import { createSvelteKitHandle } from '@happyvertical/smrt-tenancy';
export const handle = createSvelteKitHandle({
resolveTenantId: async (event) => {
const host = event.request.headers.get('host');
return host?.split('.')[0]; // tenant.example.com
}
});
// Express — uses enterTenantContext() (not withTenant,
// because middleware returns before handlers run)
import { createExpressMiddleware } from '@happyvertical/smrt-tenancy';
app.use(createExpressMiddleware({
resolveTenantId: (req) => req.headers['x-tenant-id'] as string
}));
// CLI — run(), runWithTenant(), runAsSystem(), runAsSuperAdmin()
import { createCliContext } from '@happyvertical/smrt-tenancy';
const cli = createCliContext({ resolveTenantId: () => argv.tenant });
await cli.runWithTenant('tenant-123', async () => { /* ... */ });Quick Start
1. Define Tenant-Scoped Model
@smrt()
@TenantScoped()
class Project extends SmrtObject {
tenantId = tenantId();
name: string = '';
owner: string = '';
}2. Automatic Filtering
// 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
// 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
// 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:
import {
getTenantId, requireTenantId, getCurrentTenant,
hasTenantContext, isSystemContext,
withTenant, withSystemContext, withSuperAdminBypass
} from '@happyvertical/smrt-tenancy';
// Context accessors
const id = getTenantId(); // string | undefined
const id2 = requireTenantId(); // throws TenantContextError if undefined
const ctx = getCurrentTenant(); // TenantContextData | undefined
const inCtx = hasTenantContext(); // boolean
const isSys = isSystemContext(); // boolean
// Context runners
await withTenant({ tenantId: 'tenant-123' }, async () => {
const projects = await projectCollection.list({});
});
await withSystemContext(async () => {
// Bypasses all tenant checks (admin/migrations)
});2. Isolation Modes
Required Mode (default)
@TenantScoped({ mode: 'required' })
class SecretData extends SmrtObject {
tenantId = tenantId();
}
// This throws TenantContextError
await secretDataCollection.list({}); // No context!Optional Mode
@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:
@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:
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
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)
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
// 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
// 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
enableTenancy({
rawQueryPolicy: 'throw' // Prevent leaks
});3. Validate at Middleware Entry
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
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
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
// 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
@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
// 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 callbacks (setTimeout, etc.)
Solution: Use TenantContext.bind(fn) to bind context, or capture tenantId in job metadata
// 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 |
hasTenantContext() | boolean | Check if in tenant context |
isSystemContext() | boolean | Check if in system context |
isSuperAdminBypass() | boolean | Check if super admin bypass is active |
withTenant(ctx, fn) | Promise<T> | Run fn in tenant context |
withTenantSync(ctx, fn) | T | Synchronous variant |
withSuperAdminBypass(fn) | Promise<T> | Keep context but disable auto-filtering |
withSystemContext(fn) | Promise<T> | Bypass all tenant checks (admin/migrations) |
enterTenantContext(ctx) | void | Enter context without callback (for middleware) |
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 testsresetTenancy()- Clean up tenancy state between testscreateTestTenantContext(ctx, fn)- Run test code in tenant contexttestTenantIsolation(tenantIds, fn)- Verify isolation between tenantsassertTenantContextRequired(fn)- Assert operation requires contextassertTenantIsolationViolation(fn)- Assert operation violates isolation
UI Components
The smrt-tenancy module provides the backend framework for multi-tenancy.
While it doesn't have dedicated UI components, tenant-related UI components are
available through the smrt-users module and smrt-svelte integration.
Available Components
Usage Example
import { TenantCard, TenantSwitcher } from '@happyvertical/smrt-users/components';
<!-- Display tenant information -->
<TenantCard tenant={currentTenant} />
<!-- Switch between tenants -->
<TenantSwitcher
tenants={availableTenants}
currentTenant={currentTenant}
on:switch={(e) => switchTenant(e.detail.tenantId)}
/>Note: These components are part of the smrt-users integration and require the smrt-users module to be installed and configured.