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​

Billing relationships​

Billing parentage is separate from the @happyvertical/smrt-users tenant hierarchy. Reassigning a reseller or changing who pays does not reparent the tenant for permissions and does not move its records. A tenant with no billing relationship is self-billed. Each child has at most one relationship; its billingOwnerMode is self or reseller, and the owner is derived from that single row. Tenant UUIDs are normalized to lowercase before storage and lookup. A reseller can therefore manage a child that pays for itself.

import {
BillingRelationshipService,
withSystemContext,
} from '@happyvertical/smrt-tenancy';

const billing = await BillingRelationshipService.create({
db,
// Resolve IDs against the application's tenant directory (for example,
// smrt-users TenantCollection). The tenancy package does not own Tenant.
tenantExists: async (id) => Boolean(await tenants.get({ id })),
});

await withSystemContext(() =>
billing.setRelationship({
childTenantId: childId,
resellerTenantId: resellerId,
billingOwnerMode: 'reseller',
}),
);

const ownerId = await withSystemContext(() =>
billing.resolveBillingOwner(childId),
);
const billedChildren = await withSystemContext(() =>
billing.listChildrenBilledTo(ownerId),
);

setRelationship() changes parentage and the default owner together in a transaction. Setting billingOwnerMode: 'self' keeps reseller parentage while making the child pay for itself. clearRelationship() removes parentage and returns the child to self-billing. Cycles and self-parenting are rejected. Relationship changes are serialized on supported SQL adapters; PostgreSQL uses an advisory transaction lock. Local SQLite, DuckDB, and PostgreSQL are supported; remote LibSQL is rejected because its writers cannot share the in-process graph lock. The new _smrt_billing_relationships table must be applied through the normal smrt db:migrate deployment flow.

Generated API, MCP, and CLI mutation surfaces for these rows are disabled. BillingRelationship is a root export only because s-m-r-t consumer registration imports every published manifest model from the package root; saving it directly bypasses the service's tenant, cycle, and authorization checks. BillingRelationshipCollection is not exported. Callers use the service. Without an authorize callback, mutations require explicit system context. Reads are limited to the child, its reseller, or system context; a host may provide authorize for additional permission-checked access. Supply a tenant resolver that does not depend on the caller's current tenant scope when the operation intentionally crosses tenants, and enforce actor permissions in the host callback before allowing relationship changes.

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; skip tenant enforcement only for classes with allowSuperAdminBypass: true
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.

Authorized hierarchy read scopes​

Hosts use the owning TenantCollection.withTenantCatalogReadScope() or withTenantHierarchyContext() APIs in @happyvertical/smrt-users, with active enableTenancy() enforcement and an affirmative server-side authorizer. Catalog scopes allow self, ancestors and global rows; subtree scopes allow only the approved root and descendants. See the users package's hierarchical-tenants docs.

runTenantHierarchyReadScope() and getTenantHierarchyReadScope() are trusted plumbing for that owning package, not host authorization entry points. The runner checks enforcement is enabled but does not authorize the actor or derive the hierarchy. Do not construct a scope from request input.

Inside a scope, only allowlisted tenant-scoped list-family reads widen; caller filters still narrow them. Point get() reads retain the active tenant predicate, including under nested super-admin bypass. Tenant-scoped saves, deletes and raw queries are rejected, including explicit raw-query opt-ins. Actor switches and nested scopes fail closed. The callback does not alter actor/session authority, and PostgreSQL RLS remains an additional restriction. These collection hooks do not sandbox direct database access or models without tenancy. Outside the scope, normal opted-in super-admin bypass behavior is unchanged.

A consumer can strengthen an already tenant-scoped package model at startup with ObjectRegistry.registerOverride(qualifiedName, { tenancy: { mode: 'required' } }). This policy survives manifest/decorator re-registration and takes precedence over optional direct tenant registrations, including when a descendant inherits that registration. It preserves the declared tenant field and filter/population settings; it cannot introduce tenancy or change schema. Continue to enable the tenancy interceptor. Explicit system/super-admin context keeps its existing bypass semantics. See smrt-core's consumer runtime restrictions contract.