@molecule/api-multi-tenancy
v1.0.2
Published
Multi-tenant data isolation core interface for molecule.dev — tenant lifecycle, context switching, and middleware integration
Downloads
436
Maintainers
Readme
@molecule/api-multi-tenancy
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.tsJSDoc, 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-i18nAPI
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) => voidTenancyRequestHandler
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(): TenancyProviderReturns: The bonded tenancy provider.
getTenant()
Retrieves the current tenant identifier using the bonded provider.
function getTenant(): string | nullReturns: The current tenant ID or null.
getTenantMiddleware()
Creates tenant-resolving middleware using the bonded provider.
function getTenantMiddleware(): TenancyRequestHandlerReturns: An Express request handler that sets the tenant context.
hasProvider()
Checks whether a multi-tenancy provider is currently bonded.
function hasProvider(): booleanReturns: 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): voidprovider— The tenancy provider implementation to bond.
setTenant(tenantId)
Sets the current tenant context using the bonded provider.
function setTenant(tenantId: string): voidtenantId— The tenant identifier to activate.
Available Providers
| Provider | Package |
| ------------- | ------------------------------------ |
| 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-i18nData 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_idcolumn derived fromgetTenant(). A provider MAY additionally enforce isolation at the data layer, but the shipped@molecule/api-multi-tenancy-schemabond 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 sendx-tenant-id: <victim-tenant>, so a header-named tenant must never be honored on trust. ConfigureresolveAuthorizedTenantIdsso the middleware rejects (403) any header tenant the authenticated principal doesn't belong to, and mountgetTenantMiddleware()AFTER auth so the resolver can read the authenticated principal.Providers are secure by default — an unconfigured provider fails CLOSED. With no
resolveAuthorizedTenantIdsconfigured, 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: trueon the schema bond), and then the middleware MUST sit strictly behind that gate. A server-configureddefaultTenantId(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'ssetTenant()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'srunWithTenant(tenantId, fn)) instead of callingsetTenant()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 whatresolveAuthorizedTenantIdsenforces). 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.
