Skip to main content

@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

ExportDescription
TenantScoped(options?)Class decorator. Modes: 'required' (default) or 'optional'
tenantId(options?)Property decorator for the tenant ID field

Context Runners

ExportDescription
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

ExportDescription
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
TenantContextAsyncLocalStorage instance (advanced use)

Errors

TenantContextError (missing required context), TenantIsolationError (tenant mismatch).

Interceptor

ExportDescription
enableTenancy()Register tenant interceptor globally
disableTenancy()Remove tenant interceptor
isTenancyEnabled()Check if tenancy is active
createTenantInterceptor(options?)Create interceptor manually

Framework Adapters

ExportDescription
createSvelteKitHandle(options)SvelteKit hooks.server.ts handler
createExpressMiddleware(options)Express middleware
createCliContext(options)CLI context with run(), runWithTenant(), runAsSystem()

Registry (Advanced)

ExportDescription
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

ExportDescription
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.