@happyvertical/smrt-tenancy
Multi-tenancy for s-m-r-t with AsyncLocalStorage context propagation, automatic query filtering, and tenant ID population.
Installation
pnpm install @happyvertical/smrt-tenancy
Usage
import { enableTenancy, TenantScoped, tenantId, withTenant } from '@happyvertical/smrt-tenancy';
import { smrt, SmrtObject } from '@happyvertical/smrt-core';
// 1. Enable tenancy globally (once at app startup)
enableTenancy();
// 2. Mark classes as tenant-scoped
@smrt()
@TenantScoped({ mode: 'optional' })
class Document extends SmrtObject {
@tenantId({ nullable: true })
tenantId: string | null = null;
title: string = '';
}
// 3. Wrap operations in tenant context
await withTenant({ tenantId: 'tenant-123' }, async () => {
const docs = await collection.list({ where: { status: 'active' } });
// Executes: WHERE tenant_id = 'tenant-123' AND status = 'active'
});
API
Decorators
| Export | Description |
|---|---|
TenantScoped(options?) | Class decorator. Modes: 'required' (default) or 'optional' |
tenantId(options?) | Property decorator for the tenant ID field |
Context Runners
| Export | Description |
|---|---|
withTenant(ctx, fn) | Run code scoped to a tenant |
withTenantSync(ctx, fn) | Synchronous variant |
withSystemContext(fn) | Bypass all tenant checks (admin/migrations) |
withSuperAdminBypass(fn) | Keep tenant context but disable auto-filtering |
enterTenantContext(ctx) | Enter context without callback (for middleware) |
Context Accessors
| Export | Description |
|---|---|
getCurrentTenant() | Get current tenant context (may be undefined) |
getTenantId() | Get tenant ID string (may be undefined) |
requireTenant() | Get tenant context or throw |
requireTenantId() | Get tenant ID or throw |
hasTenantContext() | Check if in tenant context |
isSystemContext() | Check if in system context |
isSuperAdminBypass() | Check if super admin bypass is active |
TenantContext | AsyncLocalStorage instance (advanced use) |
Errors
TenantContextError (missing required context), TenantIsolationError (tenant mismatch).
Interceptor
| Export | Description |
|---|---|
enableTenancy() | Register tenant interceptor globally |
disableTenancy() | Remove tenant interceptor |
isTenancyEnabled() | Check if tenancy is active |
createTenantInterceptor(options?) | Create interceptor manually |
Framework Adapters
| Export | Description |
|---|---|
createSvelteKitHandle(options) | SvelteKit hooks.server.ts handler |
createExpressMiddleware(options) | Express middleware |
createCliContext(options) | CLI context with run(), runWithTenant(), runAsSystem() |
Registry (Advanced)
| Export | Description |
|---|---|
isTenantScopedClass(name) | Check if a class is tenant-scoped |
getTenantScopedConfig(name) | Get tenant config for a class |
getAllTenantScopedClasses() | List all registered tenant-scoped classes |
registerTenantScopedClass() | Register a class programmatically |
unregisterTenantScopedClass() | Remove a class from registry |
clearTenantScopedRegistry() | Clear all registrations |
Testing
| Export | Description |
|---|---|
setupTestTenancy(options?) | Enable tenancy for tests |
resetTenancy() | Clean up tenancy state between tests |
createTestTenantContext(ctx, fn) | Run test code in tenant context |
testTenantIsolation(tenantIds, fn) | Verify isolation between tenants |
assertTenantContextRequired(fn) | Assert operation requires context |
assertTenantIsolationViolation(fn) | Assert operation violates isolation |
Dependencies
@happyvertical/smrt-core-- SmrtObject, SmrtCollection, GlobalInterceptors@happyvertical/sql-- database operations@happyvertical/utils-- utility functions
Optional peers: svelte, @happyvertical/smrt-users, @happyvertical/smrt-svelte
License
MIT
Authorized tenant/global list reads
withTenantGlobalRead(tenantId, callback) permits list-family reads of the named
tenant plus global rows after validating the caller's tenant. It preserves the
original actor, permissions, and system status for business interceptors; it does
not turn ordinary callers into system callers. The built-in tenancy beforeList
hook ANDs this scope into every existing predicate branch, and rechecks identity
if nested code changes tenants. Point reads, raw queries, and writes retain their
normal guards. The capability is async-local, restores after exceptions, and does
not affect concurrent requests. Real system/super-admin callers retain their
existing bypass behavior. Use bounded collection reads inside the callback.