@happyvertical/smrt-tenancy

Multi-tenancy via AsyncLocalStorage context propagation with automatic query filtering, tenant ID population, and framework adapters (SvelteKit, Express, CLI).

v0.20.44Multi-TenancyESM

Overview

The @happyvertical/smrt-tenancy package provides automatic tenant isolation for SaaS applications built on SMRT-core via AsyncLocalStorage context propagation.

Key Features

  • Automatic Query Filtering: WHERE tenantId = current added automatically
  • AsyncLocalStorage Context: Propagates tenant through async operations
  • Decorator-Based: @TenantScoped marks classes for isolation
  • Framework Adapters: SvelteKit, Express, CLI support
  • Super-Admin Bypass: Controlled cross-tenant access
  • Raw SQL Policy: Prevents accidental data leaks
  • Testing Utilities: Complete test suite helpers

Architecture

Request Flow
├─ Framework Adapter (SvelteKit/Express/CLI)
│  └─ Resolve tenant ID from source
│     └─ enterTenantContext()
│        └─ AsyncLocalStorage.run(context)
│           └─ SMRT Operation (list/get/save/delete/query)
│              └─ TenantInterceptor (priority 100)
│                 ├─ beforeList: inject tenantId into WHERE
│                 ├─ beforeGet: convert to {id, tenantId}
│                 ├─ beforeSave: auto-populate + validate
│                 ├─ beforeDelete: validate ownership
│                 └─ beforeQuery: enforce raw SQL policy
			

Critical distinction: withSystemContext() sets a SYSTEM_CONTEXT_MARKER sentinel, different from "no context" (undefined). The interceptor can distinguish intentional bypass from missing context.

Installation

bash
npm install @happyvertical/smrt-tenancy

Setup (3 Steps)

1. Enable Tenancy Globally

typescript
import { enableTenancy } from '@happyvertical/smrt-tenancy';

enableTenancy({
  rawQueryPolicy: 'throw',  // Prevent raw SQL
  onMissingContext: (className, operation) => {
    console.error('Missing context: ' + className + ' ' + operation);
  }
});

2. Mark Classes as Tenant-Scoped (Two Patterns)

typescript
// Pattern 1: @TenantScoped decorator
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 package reads this too)
@smrt({ tenantScoped: { mode: 'optional' } })
class Document extends SmrtObject {
  tenantId: string | null = null;
  title: string = '';
}

3. Setup 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() (not withTenant,
// because middleware returns before handlers run)
import { createExpressMiddleware } from '@happyvertical/smrt-tenancy';

app.use(createExpressMiddleware({
  resolveTenantId: (req) => req.headers['x-tenant-id'] as string
}));

// CLI — run(), runWithTenant(), runAsSystem(), runAsSuperAdmin()
import { createCliContext } from '@happyvertical/smrt-tenancy';

const cli = createCliContext({ resolveTenantId: () => argv.tenant });
await cli.runWithTenant('tenant-123', async () => { /* ... */ });

Quick Start

1. Define Tenant-Scoped Model

typescript
@smrt()
@TenantScoped()
class Project extends SmrtObject {
  tenantId = tenantId();
  name: string = '';
  owner: string = '';
}

2. Automatic Filtering

typescript
// In route handler (context established by middleware)
app.get('/api/projects', async (req, res) => {
  // Query automatically filtered by tenant
  const projects = await projectCollection.list({
    where: { status: 'active' }
  });
  // Actual SQL: WHERE tenantId = 'current' AND status = 'active'

  res.json(projects);
});

3. Automatic Population

typescript
// Create new project
app.post('/api/projects', async (req, res) => {
  const project = await projectCollection.create({
    name: req.body.name,
    // tenantId auto-populated from context
  });

  res.json(project);
});

4. Isolation Validation

typescript
// Delete validates tenant ownership
app.delete('/api/projects/:id', async (req, res) => {
  const project = await projectCollection.get(req.params.id);
  // Throws TenantIsolationError if wrong tenant
  await project.delete();

  res.json({ deleted: true });
});

Core Concepts

1. Tenant Context

AsyncLocalStorage-based context flows through async operations:

typescript
import {
  getTenantId, requireTenantId, getCurrentTenant,
  hasTenantContext, isSystemContext,
  withTenant, withSystemContext, withSuperAdminBypass
} from '@happyvertical/smrt-tenancy';

// Context accessors
const id = getTenantId();           // string | undefined
const id2 = requireTenantId();      // throws TenantContextError if undefined
const ctx = getCurrentTenant();     // TenantContextData | undefined
const inCtx = hasTenantContext();   // boolean
const isSys = isSystemContext();    // boolean

// Context runners
await withTenant({ tenantId: 'tenant-123' }, async () => {
  const projects = await projectCollection.list({});
});

await withSystemContext(async () => {
  // Bypasses all tenant checks (admin/migrations)
});

2. Isolation Modes

Required Mode (default)

typescript
@TenantScoped({ mode: 'required' })
class SecretData extends SmrtObject {
  tenantId = tenantId();
}

// This throws TenantContextError
await secretDataCollection.list({});  // No context!

Optional Mode

typescript
@TenantScoped({ mode: 'optional' })
class PublicConfig extends SmrtObject {
  tenantId = tenantId({ nullable: true });
}

// Both work
await configCollection.list({});  // All records
await withTenant({ tenantId: 't1' }, () =>
  configCollection.list({})
);  // Filtered

3. Super-Admin Bypass

Controlled cross-tenant access for privileged operations:

typescript
@TenantScoped({ allowSuperAdminBypass: true })
class AuditLog extends SmrtObject {
  tenantId = tenantId();
}

// Admin viewing all logs
await withSuperAdminBypass(async () => {
  const allLogs = await auditLogCollection.list({});
  // No tenant filtering
});

4. Raw SQL Policy

Prevents accidental data leaks via raw queries:

typescript
enableTenancy({
  rawQueryPolicy: 'throw'  // Default
});

// This throws TenantIsolationError
await projectCollection.query({
  sql: 'SELECT * FROM projects',
  params: []
});

// Explicitly allowed
await projectCollection.query({
  sql: 'SELECT * FROM projects WHERE status = ?',
  params: ['active'],
  allowRawOnTenantScoped: true  // Override
});

Testing

typescript
import {
  createTestTenantContext,
  setupTestTenancy,
  assertTenantContextRequired
} from '@happyvertical/smrt-tenancy/testing';

describe('Project isolation', () => {
  beforeAll(() => {
    setupTestTenancy({ enableInterceptors: true });
  });

  it('should isolate projects by tenant', async () => {
    // Create in tenant A
    const projectA = await createTestTenantContext(
      { tenantId: 'tenant-a' },
      async () => {
        return projectCollection.create({ name: 'Project A' });
      }
    );

    // Verify not visible in tenant B
    await createTestTenantContext(
      { tenantId: 'tenant-b' },
      async () => {
        const found = await projectCollection.get(projectA.id);
        expect(found).toBeNull(); // Isolated!
      }
    );
  });

  it('should require context', async () => {
    await assertTenantContextRequired(() =>
      projectCollection.list({})
    );
  });
});

Integration with Other Modules

smrt-users (RBAC)

typescript
const handle = createSvelteKitHandle({
  resolveTenantId: async (event) => {
    const auth = await getAuth(event);
    return auth?.tenantId;
  },

  resolvePermissions: async (event, tenantId, userId) => {
    const perms = await getPermissions(userId, tenantId);
    return new Set(perms);
  },

  isSuperAdmin: async (event, tenantId, userId) => {
    const user = await getUser(userId);
    return user?.roles?.includes('admin') ?? false;
  }
});

// Use in business logic
import { getCurrentTenant } from '@happyvertical/smrt-tenancy';

function requirePermission(permission: string): boolean {
  const context = getCurrentTenant();
  return context?.permissions?.has(permission) ?? false;
}

Message Queues

typescript
// Capture context when queueing
export async function scheduleJob(data: unknown) {
  const tenantId = requireTenantId();

  await queue.enqueue({
    type: 'process_data',
    data,
    metadata: { tenantId }  // Capture!
  });
}

// Restore context in worker
async function processJob(job: Job) {
  await withTenant(
    { tenantId: job.metadata.tenantId },
    async () => {
      await handleJob(job);
    }
  );
}

Best Practices

1. Always Use Auto-Filtering

typescript
// Good
@TenantScoped()
class Document extends SmrtObject {
  tenantId = tenantId();  // Auto-filters
}

// Risky
@TenantScoped({ autoFilter: false })
class Document extends SmrtObject {
  tenantId = tenantId();  // Manual filtering error-prone
}

2. Block Raw SQL by Default

typescript
enableTenancy({
  rawQueryPolicy: 'throw'  // Prevent leaks
});

3. Validate at Middleware Entry

typescript
createExpressMiddleware({
  resolveTenantId: async (req) => {
    const tenantId = req.headers['x-tenant-id'];

    // Verify tenant exists
    const tenant = await getTenant(tenantId as string);
    if (!tenant) {
      throw new Error('Invalid tenant');
    }

    return tenantId;
  }
});

4. Index tenantId Field

sql
CREATE INDEX idx_documents_tenant_id
ON documents(tenant_id);

CREATE INDEX idx_documents_tenant_status
ON documents(tenant_id, status);

5. Audit Cross-Tenant Access

typescript
enableTenancy({
  onIsolationViolation: (className, expected, actual) => {
    logger.warn('ISOLATION_VIOLATION', {
      className,
      expectedTenant: expected,
      attemptedTenant: actual,
      timestamp: new Date()
    });
  }
});

Troubleshooting

TenantContextError: No context available

Solution: Ensure middleware is registered before routes

typescript
// Verify middleware registered
app.use(createExpressMiddleware({...}));  // BEFORE routes

// Verify in handler
console.log(getTenantId());  // Should not be undefined

TenantIsolationError: Cross-tenant access

Solution: Enable bypass if needed for admins

typescript
@TenantScoped({ allowSuperAdminBypass: true })
class AuditLog extends SmrtObject {
  tenantId = tenantId();
}

// Verify bypass is active
isSuperAdmin: async (event) => {
  return user?.isAdmin ?? false;
}

Raw SQL query failed

Solution: Use list()/get() or explicit bypass

typescript
// Instead of raw SQL
await collection.query({ sql: 'SELECT...' });

// Use collection methods
await collection.list({ where: {...} });

// Or explicit bypass
await collection.query({
  sql: 'SELECT...',
  allowRawOnTenantScoped: true
});

Context lost in callbacks (setTimeout, etc.)

Solution: Use TenantContext.bind(fn) to bind context, or capture tenantId in job metadata

typescript
// When queueing
const job = {
  type: 'process',
  metadata: {
    tenantId: requireTenantId()  // Capture!
  }
};

// When processing
await withTenant(
  { tenantId: job.metadata.tenantId },
  async () => {
    // Context restored
  }
);

API Reference

Context Functions

FunctionReturnsDescription
getTenantId()string | undefinedGet current tenant ID
requireTenantId()stringGet tenant ID, throw if undefined
getCurrentTenant()TenantContextData | undefinedGet full context
hasTenantContext()booleanCheck if in tenant context
isSystemContext()booleanCheck if in system context
isSuperAdminBypass()booleanCheck if super admin bypass is active
withTenant(ctx, fn)Promise<T>Run fn in tenant context
withTenantSync(ctx, fn)TSynchronous variant
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 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 tenant context
  • testTenantIsolation(tenantIds, fn) - Verify isolation between tenants
  • assertTenantContextRequired(fn) - Assert operation requires context
  • assertTenantIsolationViolation(fn) - Assert operation violates isolation

UI Components

The smrt-tenancy module provides the backend framework for multi-tenancy. While it doesn't have dedicated UI components, tenant-related UI components are available through the smrt-users module and smrt-svelte integration.

Available Components

Usage Example

svelte
import { TenantCard, TenantSwitcher } from '@happyvertical/smrt-users/components';

<!-- Display tenant information -->
<TenantCard tenant={currentTenant} />

<!-- Switch between tenants -->
<TenantSwitcher
	tenants={availableTenants}
	currentTenant={currentTenant}
	on:switch={(e) => switchTenant(e.detail.tenantId)}
/>

View tenant components →

Note: These components are part of the smrt-users integration and require the smrt-users module to be installed and configured.