Skip to content

@venturekit-pro/tenancy

Terminal window
npm install @venturekit-pro/tenancy
import { TenantContext, createTenantContext, getCurrentTenant, resolveTenant } from '@venturekit-pro/tenancy';
const tenant = getCurrentTenant(ctx);
// { id: 'acme', slug: 'acme', metadata: { ... } }
import { createTenantMiddleware, createQuotaMiddleware } from '@venturekit-pro/tenancy';
import type { IsolationConfig } from '@venturekit-pro/tenancy';
// `strategy` selects the **data** isolation model (shared tables vs schema vs
// database). `resolution` selects how the tenant is identified per request.
const isolation: IsolationConfig = { strategy: 'shared', resolution: 'subdomain' };
// `createTenantMiddleware()` requires `config` (resolution + isolation rules)
// and `lookupTenant` (your tenant store). `createQuotaMiddleware()` requires
// `quotaKey` and `checkUsage` (returns current usage for the quota).
middleware: [
createTenantMiddleware({
config: isolation,
lookupTenant: async (id) => loadTenant(id),
}),
createQuotaMiddleware({
quotaKey: 'apiRequests',
checkUsage: async (tenantId) => getMonthlyUsage(tenantId),
}),
]
import { TenantNotFoundError, TenantSuspendedError, TenantInactiveError, QuotaExceededError } from '@venturekit-pro/tenancy';
import { checkQuotas } from '@venturekit-pro/tenancy';
// checkQuotas(tenantId, quotas, checkUsage)
// quotas: TenantQuotas — a map of quotaKey → numeric limit
// checkUsage: (tenantId, quotaKey) => Promise<number> — current usage
await checkQuotas(
tenantId,
{ apiRequests: 10_000, storage: 5_000_000_000 },
async (id, quotaKey) => loadCurrentUsage(id, quotaKey),
);
StrategySource
subdomainacme.app.example.com
custom_domainapp.acme.com (domain lookup via domainLookup)
path/t/acme/api/tasks
headerX-Tenant-ID: acme (configurable via headerName)
query?tenant=acme (configurable via queryParam)
jwttenant_id claim (configurable via jwtClaim)
custompluggable async resolver via customResolver

For handler() stacks prefer createRuntimeTenantMiddleware({ resolver }) — it populates ctx.tenant — with hostPrefixDomainResolver() for the api.<tenant-domain> convention. See the API reference.

import { createTenantUserScopesMiddleware, packTenantRoles } from '@venturekit-pro/tenancy';
import { createRoleScopesResolver } from '@venturekit/auth';
// Grants the user's role-for-this-tenant scopes onto ctx.user.scopes — claims first
// (packed custom:tenantRoles), then the tenant-user store.
const tenantUserScopes = createTenantUserScopesMiddleware({
tenantUser: (ctx) => loadMembership(ctx.tenant!.id, ctx.user!.id), // store fallback
scopesByRole: createRoleScopesResolver({ querier: query }).lookup, // or a static Record<role, scopes[]>
});
// Write path: keep the packed claim current on every membership change
const packed = packTenantRoles(memberships.map((m) => ({ tenantId: m.tenantId, role: m.role })));
await adminUpdateUserAttributes({ username, attributes: { 'custom:tenantRoles': packed } });

Declare tenancy: true on the auth intent so the pool carries custom:tenantId + custom:tenantRoles.

  • createTenantRlsMiddleware() — opens @venturekit/data’s runWithTenant() scope for the resolved tenant so Postgres RLS filters every query. Place it right after the tenancy middleware.
  • createTenantHandoffAuthorize({ resolveTenantByHost, isActiveTenantUser }) — the membership gate for @venturekit/auth/server’s cross-domain session handoff (white-label custom domains).
import { createTenant, getTenantBySlug, suspendTenant, restoreTenant, hardDeleteTenant } from '@venturekit-pro/tenancy';
import { query } from '@venturekit/data';
const tenant = await createTenant(query, { slug: 'acme', displayName: 'Acme', primaryDomain: 'acme.io' });
await suspendTenant(query, { tenantId: tenant.id, reason: 'unpaid', audit: (e) => record(query, toAuditEvent(e)) });
await restoreTenant(query, { tenantId: tenant.id });
await hardDeleteTenant(adminQuery, { tenantId: tenant.id }); // introspects every tenant_id table, deletes leaves-first

The vk_tenants table ships with the package’s migration (0000_vk_tenancy_tenants.sql), auto-applied by vk migrate; extra columns you ALTER on ride along through the same helpers.

The same introspecting walker, keyed on the column that marks a row as a user’s (user_id by default):

import { exportUserData, executeUserErasure, planUserErasure } from '@venturekit-pro/tenancy';
import { withTransaction } from '@venturekit/data';
const bundle = await exportUserData(query, { userId, skipTables: ['audit_log'] }); // art. 20 — { tables: { 'public.posts': rows[] }, … }
const plan = await planUserErasure(query); // preview: tables in deletion order
await withTransaction((tx) => executeUserErasure(tx.query, { userId, skipTables: ['audit_log'] })); // art. 17, all-or-nothing
await adminDeleteUser(userId); // the Cognito account is separate

skipTables names the tables to leave alone — an append-only ledger you must keep, which you anonymise instead. Rows keyed by another column (author_id) take one more call with column.

  • @venturekit/core — required
  • @venturekit/runtime — runtime middleware
  • @venturekit/data — optional; supplies the Querier for CRUD / lifecycle and the RLS scope