@happyvertical/smrt-tenancy
Multi-tenancy via AsyncLocalStorage context propagation. Auto-filters every SmrtCollection query by tenant, auto-populates tenantId on save, and ships adapters for SvelteKit, Express, and CLI.
Overview
@happyvertical/smrt-tenancy provides automatic tenant isolation for SaaS
applications built on smrt-core. It uses Node's AsyncLocalStorage to
propagate a tenant context through async operations, and a GlobalInterceptors hook (priority 100, runs first) to inject tenantId into queries, auto-populate it on
save, and validate isolation on every read/write/delete.
Key Features
- Automatic query filtering:
WHERE tenant_id = '<current>'added to everylist()/get() - AsyncLocalStorage context: context flows through async/await without manual plumbing
- Decorator-based:
@TenantScoped()-- or@smrt({ tenantScoped: ... })-- marks a class for isolation - Framework adapters: SvelteKit, Express, CLI
- Super-admin bypass: keep context but disable auto-filtering
- Raw SQL policy:
throw/warn/allowto prevent accidental leaks directoryClassesdispatch:afterSaveandafterDeleteemitdirectory.<class>.created|updated|deletedsignals through the DispatchBus for configured classes
Installation
pnpm add @happyvertical/smrt-tenancyQuick Start
1. Mark Classes as Tenant-Scoped
Two equivalent patterns; the tenancy package reads both:
// Pattern 1: @TenantScoped() decorator (preferred)
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 reads this too
@smrt({ tenantScoped: { mode: 'optional' } })
class Document extends SmrtObject {
tenantId: string | null = null;
title: string = '';
}Modes:
'required'(default) -- throwsTenantContextErrorwhen there's no active context'optional'-- passes through if no context (used by most domain models so system-level scripts still work)
2. Wrap Operations in Context
import {
withTenant,
withSystemContext,
withSuperAdminBypass,
getTenantId,
} from '@happyvertical/smrt-tenancy';
// Normal request -- everything inside is auto-scoped
await withTenant({ tenantId: 'tenant-123' }, async () => {
const docs = await documentCollection.list({});
// Actual SQL: WHERE tenant_id = 'tenant-123'
});
// Bypass tenant checks (admin tools, migrations)
await withSystemContext(async () => {
/* all tenant checks disabled */
});
// Keep context but disable auto-filtering (super-admin reading cross-tenant data)
await withSuperAdminBypass(async () => {
const allLogs = await auditLogCollection.list({});
});3. Wire up a 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() (middleware returns before handlers run)
import { createExpressMiddleware } from '@happyvertical/smrt-tenancy';
app.use(createExpressMiddleware({
resolveTenantId: (req) => req.headers['x-tenant-id'] as string,
}));
// CLI -- exposes run(), runWithTenant(), runAsSystem(), runAsSuperAdmin()
import { createCliContext } from '@happyvertical/smrt-tenancy';
const cli = createCliContext({ resolveTenantId: () => argv.tenant });
await cli.runWithTenant('tenant-123', async () => { /* ... */ });Interceptor System
Tenancy hooks GlobalInterceptors (priority 100, runs first). The interceptor sees every
collection / save operation:
| Hook | Behavior |
|---|---|
beforeList | Injects tenantId into the WHERE clause; validates that any explicit filter matches
the context |
beforeGet | Same -- rewrites an ID lookup to { id, tenantId } |
beforeSave | Auto-populates tenantId if empty and autoPopulate: true;
validates if already set |
beforeDelete | Validates that instance.tenantId matches the current context |
beforeQuery | Enforces the raw-SQL policy on tenant-scoped classes (throw / warn / allow) |
afterSave | Emits directory.<class>.created / directory.<class>.updated via dispatchBus for any class
in directoryClasses |
afterDelete | Emits directory.<class>.deleted via dispatchBus for directoryClasses |
Mismatches throw TenantIsolationError. Missing required context throws TenantContextError.
Context Accessors
import {
getTenantId, requireTenantId, getCurrentTenant,
hasTenantContext, isSystemContext, isSuperAdminBypass,
} from '@happyvertical/smrt-tenancy';
const id = getTenantId(); // string | undefined
const must = requireTenantId(); // throws TenantContextError if undefined
const ctx = getCurrentTenant(); // TenantContextData | undefined
const inCtx = hasTenantContext(); // boolean
const sys = isSystemContext(); // boolean
const bypass = isSuperAdminBypass(); // booleanRaw SQL Policy
Prevents accidental cross-tenant leaks via hand-written queries:
enableTenancy({ rawQueryPolicy: 'throw' }); // default
// This throws TenantIsolationError
await projectCollection.query({
sql: 'SELECT * FROM projects',
params: [],
});
// Explicit opt-out for a specific call
await projectCollection.query({
sql: 'SELECT * FROM projects WHERE status = ?',
params: ['active'],
allowRawOnTenantScoped: true,
});The Documented-Exception Pattern
Per docs/content/standards.md §7, tenant-aware models should apply @TenantScoped({ mode: 'optional' }). Packages that deviate must document
why in their own CLAUDE.md under a "Known exceptions to monorepo standards" heading.
The canonical example lives in packages/secrets/CLAUDE.md:
Secretuses the inlinetenantScoped: trueform becauseSecretService.store()performs manual scoping via the(slug, context)upsert key. Switching to@TenantScopedwithout rethinking the upsert key would surface false-positive name collisions across tenants.TenantKeyis deliberately not tenant-scoped at all: key-rotation tooling, AMK rewrap jobs, and super-admin audits must query across tenants.
A second exception lives inside this package: serializeInstance() in src/interceptor.ts calls instance.toJSON() directly. Section 7 of standards.md normally
forbids this in favor of transformJSON(), but the interceptor has to serialize
arbitrary instances -- workspace stubs and plain-object test doubles whose classes may not
extend SmrtObject and therefore have no transformJSON() hook. The call is
duck-typed and falls back to manual key iteration when toJSON is absent. See the inline
comment at the call site for the full rationale.
Testing
import {
setupTestTenancy, resetTenancy,
createTestTenantContext,
testTenantIsolation,
assertTenantContextRequired,
} from '@happyvertical/smrt-tenancy/testing';
describe('Project isolation', () => {
beforeEach(() => { setupTestTenancy({ enableInterceptors: true }); });
afterEach(() => { resetTenancy(); });
it('isolates projects by tenant', async () => {
const projectA = await createTestTenantContext(
{ tenantId: 'tenant-a' },
async () => projectCollection.create({ name: 'Project A' }),
);
await createTestTenantContext(
{ tenantId: 'tenant-b' },
async () => {
const found = await projectCollection.get(projectA.id);
expect(found).toBeNull(); // isolated!
},
);
});
it('requires context for required-mode classes', async () => {
await assertTenantContextRequired(() => projectCollection.list({}));
});
});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 the full context |
hasTenantContext() | boolean | Check if in a tenant context |
isSystemContext() | boolean | Check for the SYSTEM_CONTEXT_MARKER sentinel |
isSuperAdminBypass() | boolean | Check if super-admin bypass is active |
withTenant(ctx, fn) | Promise<T> | Run fn in a tenant context |
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 a 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 a tenant contexttestTenantIsolation(tenantIds, fn)-- verify isolation between tenantsassertTenantContextRequired(fn)-- assert operation requires contextassertTenantIsolationViolation(fn)-- assert operation violates isolation
Gotchas
- Context lost in callbacks:
setTimeout(() => getTenantId(), 100)returnsundefined. Fix:TenantContext.bind(fn), or capture the tenantId on the outside and re-enter context withwithTenant(). - Nested contexts override: an inner
withTenant()overrides the outer one and restores on exit. - Auto-populate only if empty: if
tenantIdis already set, the interceptor validates it -- it does not overwrite. - Isolation checked at query time:
list({ where: { tenantId: 'other' } })throws immediately if the value doesn't match the current context.
Used By
Effectively every @happyvertical/smrt-* domain package consumes smrt-tenancy:
- smrt-content, smrt-commerce, smrt-assets, smrt-events -- standard
@TenantScoped({ mode: 'optional' })consumers - smrt-secrets -- documented exception (inline
tenantScoped: trueform) - smrt-users -- hierarchical tenants, super-admin bypass