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

v0.29.34Multi-TenancyESM

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 every list() / 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/allow to prevent accidental leaks
  • directoryClasses dispatch: afterSave and afterDelete emit directory.<class>.created|updated|deleted signals through the DispatchBus for configured classes

Installation

bash
pnpm add @happyvertical/smrt-tenancy

Quick Start

1. Mark Classes as Tenant-Scoped

Two equivalent patterns; the tenancy package reads both:

typescript
// 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) -- throws TenantContextError when 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

typescript
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

typescript
// 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:

HookBehavior
beforeListInjects tenantId into the WHERE clause; validates that any explicit filter matches the context
beforeGetSame -- rewrites an ID lookup to { id, tenantId }
beforeSaveAuto-populates tenantId if empty and autoPopulate: true; validates if already set
beforeDeleteValidates that instance.tenantId matches the current context
beforeQueryEnforces the raw-SQL policy on tenant-scoped classes (throw / warn / allow)
afterSaveEmits directory.<class>.created / directory.<class>.updated via dispatchBus for any class in directoryClasses
afterDeleteEmits directory.<class>.deleted via dispatchBus for directoryClasses

Mismatches throw TenantIsolationError. Missing required context throws TenantContextError.

Context Accessors

typescript
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(); // boolean

Raw SQL Policy

Prevents accidental cross-tenant leaks via hand-written queries:

typescript
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:

  • Secret uses the inline tenantScoped: true form because SecretService.store() performs manual scoping via the (slug, context) upsert key. Switching to @TenantScoped without rethinking the upsert key would surface false-positive name collisions across tenants.
  • TenantKey is 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

typescript
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

FunctionReturnsDescription
getTenantId()string | undefinedGet current tenant ID
requireTenantId()stringGet tenant ID; throw if undefined
getCurrentTenant()TenantContextData | undefinedGet the full context
hasTenantContext()booleanCheck if in a tenant context
isSystemContext()booleanCheck for the SYSTEM_CONTEXT_MARKER sentinel
isSuperAdminBypass()booleanCheck 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)voidEnter context without a callback (for middleware)

Decorator Options

OptionTypeDefaultDescription
mode'required' | 'optional''required'Context requirement
fieldstring'tenantId'Tenant ID field name
autoFilterbooleantrueAuto-add tenant to queries
autoPopulatebooleantrueAuto-set tenant on save
allowSuperAdminBypassbooleanfalseEnable bypass for this class

Framework Adapters

  • createSvelteKitHandle(options) -- SvelteKit middleware
  • createExpressMiddleware(options) -- Express middleware
  • createCliContext(options) -- CLI context manager

Testing Utilities

  • setupTestTenancy(options?) -- enable tenancy for tests
  • resetTenancy() -- clean up tenancy state between tests
  • createTestTenantContext(ctx, fn) -- run test code in a tenant context
  • testTenantIsolation(tenantIds, fn) -- verify isolation between tenants
  • assertTenantContextRequired(fn) -- assert operation requires context
  • assertTenantIsolationViolation(fn) -- assert operation violates isolation

Gotchas

  • Context lost in callbacks: setTimeout(() => getTenantId(), 100) returns undefined. Fix: TenantContext.bind(fn), or capture the tenantId on the outside and re-enter context with withTenant().
  • Nested contexts override: an inner withTenant() overrides the outer one and restores on exit.
  • Auto-populate only if empty: if tenantId is 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: