← All @molecule/* packages · App templates

@molecule/api-multi-tenancy

Core interface · multi-tenancy · API (Node) · v1.0.1 · Apache-2.0

Multi-tenant data isolation core interface for molecule.dev — tenant lifecycle, context switching, and middleware integration

npm install @molecule/api-multi-tenancy

npm · Source on GitHub

How it works

@molecule/api-multi-tenancy is the multi-tenancy core interface on the API (Node) side: the API your app calls, with no vendor inside.

Choose the implementation by bonding one of its 1 provider: @molecule/api-multi-tenancy-schema.

import { setProvider, createTenant, getTenantMiddleware } from '@molecule/api-multi-tenancy'
import { createProvider } from '@molecule/api-multi-tenancy-schema'

// SECURE wiring: authorize the (attacker-controlled) tenant header against the
// authenticated principal. `req.user` is populated by your auth middleware earlier
// in the chain; the middleware 403s if the header tenant isn't in this list.
setProvider(
  createProvider({
    resolveAuthorizedTenantIds: (req) => {
      const user = req.user as { tenantIds?: string[] } | undefined
      return user?.tenantIds ?? []
    },
  }),
)

// Create a new tenant
const tenant = await createTenant({ name: 'Acme Corp' })

// Mount tenant resolution AFTER auth so the membership check has req.user.
app.use(authMiddleware, getTenantMiddleware())

Providers (1): @molecule/api-multi-tenancy-schema

Works with: @molecule/api-bond, @molecule/api-i18n

Reference

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Multi-tenancy core interface for molecule.dev.

Provides the TenancyProvider interface — tenant lifecycle (create/delete/ list), request-scoped active-tenant context, and header-resolution middleware. Bond a concrete provider (e.g. @molecule/api-multi-tenancy-schema) at startup via setProvider().

Quick Start

import { setProvider, createTenant, getTenantMiddleware } from '@molecule/api-multi-tenancy'
import { createProvider } from '@molecule/api-multi-tenancy-schema'

// SECURE wiring: authorize the (attacker-controlled) tenant header against the
// authenticated principal. `req.user` is populated by your auth middleware earlier
// in the chain; the middleware 403s if the header tenant isn't in this list.
setProvider(
  createProvider({
    resolveAuthorizedTenantIds: (req) => {
      const user = req.user as { tenantIds?: string[] } | undefined
      return user?.tenantIds ?? []
    },
  }),
)

// Create a new tenant
const tenant = await createTenant({ name: 'Acme Corp' })

// Mount tenant resolution AFTER auth so the membership check has req.user.
app.use(authMiddleware, getTenantMiddleware())

Type

core

Installation

npm install @molecule/api-multi-tenancy @molecule/api-bond @molecule/api-i18n

API

Interfaces

CreateTenant

Payload for creating a new tenant.

interface CreateTenant {
  /** Human-readable tenant name. */
  name: string

  /** Optional metadata to associate with the tenant. */
  metadata?: Record<string, unknown>
}

TenancyConfig

Configuration options for a tenancy provider.

interface TenancyConfig {
  /** Default tenant ID to use when none is set in context. */
  defaultTenantId?: string

  /** Whether to enforce tenant isolation strictly (throw on missing tenant). */
  strictMode?: boolean
}

TenancyProvider

Multi-tenancy provider interface.

All tenancy providers must implement this interface to provide tenant lifecycle management, context switching, and middleware integration for multi-tenant data isolation.

interface TenancyProvider {
  /**
   * Sets the current tenant context. Subsequent operations will
   * be scoped to this tenant until changed.
   *
   * @param tenantId - The tenant identifier to activate.
   */
  setTenant(tenantId: string): void

  /**
   * Retrieves the current tenant identifier, or `null` if no
   * tenant context is active.
   *
   * @returns The current tenant ID or `null`.
   */
  getTenant(): string | null

  /**
   * Creates a new tenant in the system.
   *
   * @param tenant - The tenant creation payload.
   * @returns The created tenant.
   */
  createTenant(tenant: CreateTenant): Promise<Tenant>

  /**
   * Deletes a tenant and all associated data.
   *
   * @param tenantId - The identifier of the tenant to delete.
   */
  deleteTenant(tenantId: string): Promise<void>

  /**
   * Lists all tenants in the system.
   *
   * @returns Array of all tenants.
   */
  listTenants(): Promise<Tenant[]>

  /**
   * Creates an Express-compatible middleware that extracts the
   * tenant identifier from the incoming request (e.g. from a header)
   * and sets the tenant context for the request lifecycle.
   *
   * @returns An Express request handler.
   */
  getTenantMiddleware(): TenancyRequestHandler
}

TenancyRequest

Express-compatible request object (minimal shape).

interface TenancyRequest {
  /** Request headers. */
  headers: Record<string, string | string[] | undefined>
  [key: string]: unknown
}

TenancyResponse

Express-compatible response object (minimal shape).

interface TenancyResponse {
  /** Sets the HTTP status code. */
  status(code: number): TenancyResponse

  /** Sends a JSON response. */
  json(body: unknown): TenancyResponse
  [key: string]: unknown
}

Tenant

Represents a tenant in the system.

interface Tenant {
  /** Unique tenant identifier. */
  id: string

  /** Human-readable tenant name. */
  name: string

  /** Current status of the tenant. */
  status: TenantStatus

  /** Optional metadata associated with the tenant. */
  metadata?: Record<string, unknown>

  /** When the tenant was created. */
  createdAt: Date

  /** When the tenant was last updated. */
  updatedAt: Date
}

Types

TenancyNextFunction

Express-compatible next function.

type TenancyNextFunction = (err?: unknown) => void

TenancyRequestHandler

Express-compatible request handler for tenant middleware.

type TenancyRequestHandler = (
  req: TenancyRequest,
  res: TenancyResponse,
  next: TenancyNextFunction,
) => void | Promise<void>

TenantStatus

Status of a tenant.

type TenantStatus = 'active' | 'suspended' | 'pending' | 'deleted'

Functions

createTenant(tenant)

Creates a new tenant using the bonded provider.

function createTenant(tenant: CreateTenant): Promise<Tenant>
  • tenant — The tenant creation payload.

Returns: The created tenant.

deleteTenant(tenantId)

Deletes a tenant using the bonded provider.

function deleteTenant(tenantId: string): Promise<void>
  • tenantId — The identifier of the tenant to delete.

Returns: Resolves when the tenant is removed.

getProvider()

Retrieves the bonded multi-tenancy provider, throwing if none is configured.

function getProvider(): TenancyProvider

Returns: The bonded tenancy provider.

getTenant()

Retrieves the current tenant identifier using the bonded provider.

function getTenant(): string | null

Returns: The current tenant ID or null.

getTenantMiddleware()

Creates tenant-resolving middleware using the bonded provider.

function getTenantMiddleware(): TenancyRequestHandler

Returns: An Express request handler that sets the tenant context.

hasProvider()

Checks whether a multi-tenancy provider is currently bonded.

function hasProvider(): boolean

Returns: true if a tenancy provider is bonded.

listTenants()

Lists all tenants using the bonded provider.

function listTenants(): Promise<Tenant[]>

Returns: Array of all tenants.

setProvider(provider)

Registers a multi-tenancy provider as the active singleton. Called by bond packages during application startup.

function setProvider(provider: TenancyProvider): void
  • provider — The tenancy provider implementation to bond.

setTenant(tenantId)

Sets the current tenant context using the bonded provider.

function setTenant(tenantId: string): void
  • tenantId — The tenant identifier to activate.

Available Providers

ProviderPackage
Multi Tenancy@molecule/api-multi-tenancy-schema

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-bond ^1.0.1
  • @molecule/api-i18n ^1.0.1

Runtime Dependencies

  • @molecule/api-bond

  • @molecule/api-i18n

  • Data isolation is the application's responsibility, not the provider's. A provider supplies the tenant CONTEXT (which tenant the current request belongs to) plus secure header resolution; the app must scope its own queries by the active tenant — filter every read/write by a tenant_id column derived from getTenant(). A provider MAY additionally enforce isolation at the data layer, but the shipped @molecule/api-multi-tenancy-schema bond does NOT — it is a context tracker, so the isolation checkboxes below are only satisfied once YOUR queries are tenant-scoped. Tenant resolution is security-critical — the tenant header is attacker-controlled. Any caller can send x-tenant-id: <victim-tenant>, so a header-named tenant must never be honored on trust. Configure resolveAuthorizedTenantIds so the middleware rejects (403) any header tenant the authenticated principal doesn't belong to, and mount getTenantMiddleware() AFTER auth so the resolver can read the authenticated principal.

  • Providers are secure by default — an unconfigured provider fails CLOSED. With no resolveAuthorizedTenantIds configured, the schema bond REFUSES (403) every header-named tenant rather than activating it. So "403 on every tenant request" means the authorizer isn't wired — not that the package is broken. Only when membership is already enforced upstream may you opt into the raw-header path (allowUnauthorizedTenantHeader: true on the schema bond), and then the middleware MUST sit strictly behind that gate. A server-configured defaultTenantId (used only when no header is present) is trusted config and bypasses these checks.

  • Tenant context is request-scoped, never a module global. getTenant() reflects the currently executing request, and the schema provider's setTenant() THROWS outside a request scope (this prevents cross-request tenant bleed). For background jobs, seeds, and scripts, establish a scope explicitly (e.g. the schema bond's runWithTenant(tenantId, fn)) instead of calling setTenant() at top level.

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual tenant screens/flows, and check every box off one by one. These boxes describe the tenant isolation your APP must achieve using the tenant context this package provides (the package gives you the active-tenant context + secure header handling; you scope the data). A box you can't check is a bug in your query-scoping or a missing authorizer wiring, to fix — never a skip:

  • Cross-tenant invisibility: create records while signed in as tenant A, then sign in as tenant B — none of A's data is visible or reachable anywhere B can look (lists, detail pages, search results, and exports/downloads are ALL scoped to the current tenant). Reverse the roles (B's data, viewed as A) and confirm neither tenant ever sees the other's.
  • No IDOR across the tenant boundary: while signed in as tenant B, take a real record id that belongs to tenant A (guess/increment one, or copy it from A's session) and hit its detail/edit/delete/API route directly. The server REFUSES with 403/404 and never returns A's data — the id existing is not enough; tenant membership is re-checked server-side on every access.
  • Tenant context is derived SERVER-SIDE from the authenticated session/subdomain, never trusted from the client. Sending a spoofed tenant header (default x-tenant-id) or tenant body/query param for a tenant the caller doesn't belong to does NOT switch tenants — the request is rejected (403), never silently honored (this is what resolveAuthorizedTenantIds enforces). The same call with no header resolves the caller's own tenant, not a global or leaked one.
  • Membership is enforced both ways: a user can act only on the tenant(s) they belong to; attempting to join, read, or write a tenant they aren't a member of is refused, and revoking a user's membership immediately cuts off their access to that tenant's data.
  • Per-tenant config/branding/limits apply to the correct tenant only — tenant A's settings (name, metadata, theme, quotas) render for A and never leak into B; changing A's config leaves B's untouched.
  • Shared/global resources (if any) are clearly separated from tenant-scoped ones: platform-wide data is intentionally visible across tenants, and nothing tenant-scoped is accidentally exposed as global.