@venturekit-pro/tenancy API
Functions
Section titled “Functions”Context
Section titled “Context”| Function | Signature | Description |
|---|---|---|
createTenantContext | (options) => TenantContext | Create a tenant context |
getCurrentTenant | (ctx: RequestContext) => TenantContext | null | Get current tenant from request context |
resolveTenant | (event, strategy) => TenantContext | Resolve tenant from request |
Middleware
Section titled “Middleware”| Function | Signature | Description |
|---|---|---|
createTenantMiddleware | (options: { strategy: string }) => Middleware | Runtime-agnostic tenant resolution (AsyncLocalStorage TenantContext; not wired to @venturekit/runtime’s ctx.tenant) |
createRuntimeTenantMiddleware | (RuntimeTenantMiddlewareOptions) => Middleware<RequestContext> | Resolve the tenant onto ctx.tenant for @venturekit/runtime handlers (see below) |
createTenantUserScopesMiddleware | (TenantUserScopesMiddlewareOptions) => Middleware<RequestContext> | Grant per-tenant role scopes onto ctx.user.scopes (see below) |
createQuotaMiddleware | (options?) => Middleware | Create quota enforcement middleware |
checkQuotas | (tenantId: string, quotas: Record<string, QuotaDef>) => Promise<void> | Check quotas programmatically |
Runtime tenant resolution
Section titled “Runtime tenant resolution”createTenantMiddleware predates @venturekit/runtime and stores the tenant in an AsyncLocalStorage TenantContext — it does not populate RequestContext.tenant. For handler() stacks use createRuntimeTenantMiddleware, which calls a resolver with the live RequestContext and stashes the result on ctx.tenant (404 TenantNotFoundError on miss, unless optional: true).
function createRuntimeTenantMiddleware(options: RuntimeTenantMiddlewareOptions): Middleware<RequestContext>
interface RuntimeTenantMiddlewareOptions { resolver: (ctx: RequestContext) => Promise<TenantContext | null> | TenantContext | null optional?: boolean // default false — when true, a miss passes through with ctx.tenant = null}hostPrefixDomainResolver is a ready-made resolver for the common “every API hostname is api.<tenant-domain>” convention: it strips a prefix (default api.) from the Host header and looks the remainder up in your store. Under vk dev — and only there: the switch is VENTURE_LOCAL === 'true', which vk dev sets and no deployed stage carries, never a stage name — it also honors an X-Tenant-Slug header / ?tenant= query override.
function hostPrefixDomainResolver<T extends TenantContext>( options: HostPrefixDomainResolverOptions<T>,): RuntimeTenantResolver
interface HostPrefixDomainResolverOptions<T extends TenantContext> { prefix?: string // default 'api.' lookup: (domain: string, ctx: RequestContext) => Promise<T | null> | T | null devOverrides?: false | { headerName?: string // default 'X-Tenant-Slug' queryName?: string // default 'tenant' lookupBySlug: (slug: string, ctx: RequestContext) => Promise<T | null> | T | null }}import { handler } from '@venturekit/runtime';import { createRuntimeTenantMiddleware, hostPrefixDomainResolver } from '@venturekit-pro/tenancy';
const tenancy = createRuntimeTenantMiddleware({ resolver: hostPrefixDomainResolver({ prefix: 'api.', lookup: (domain) => loadCommunityByDomain(domain), }),});
export const main = handler(async (_b, ctx) => ({ tenant: ctx.tenant!.slug }), { middleware: [tenancy],});Per-tenant roles and scopes
Section titled “Per-tenant roles and scopes”In a multi-tenant app a user holds a role per tenant (a membership, a staff assignment, a seat). createTenantUserScopesMiddleware grants that role’s scopes onto ctx.user.scopes for the current tenant, from two sources in order:
- Claims fast path — the packed
custom:tenantRolesattribute (see PackedtenantRolesclaim below) carries the caller’s role in every tenant; when the current tenant is in the pack its role maps throughscopesByRolewith zero store round-trips. - Store fallback — when the tenant isn’t in the pack (a fresh approval the token hasn’t caught up with, first sign-in, an overflowed pack), the
tenantUserresolver loads the row andisActivegates the grant.
Fail-closed: an unknown role, a garbled claim, or a row rejected by isActive simply grants nothing — the middleware never throws. Place it after auth and after tenant resolution.
function createTenantUserScopesMiddleware<M extends { role: string }>( options: TenantUserScopesMiddlewareOptions<M>,): Middleware<RequestContext>
interface TenantUserScopesMiddlewareOptions<M extends { role: string }> { tenantUser: (ctx: RequestContext) => Promise<M | null> | M | null // store fallback + lazy row loader scopesByRole: Record<string, readonly string[]> | ((role: string) => Promise<readonly string[]> | readonly string[]) rolesClaim?: string | false // default 'custom:tenantRoles'; false = always hit the store isActive?: (tenantUser: M) => boolean // gate before granting (default: always active) userScopes?: (ctx: RequestContext) => Promise<readonly string[]> | readonly string[] // tenant-independent extras}The tenant-user row is resolved lazily — the claims path grants scopes without touching the store, so handlers that need the row pull it through getTenantUser / requireTenantUser (one query on first access, cached on the context):
| Function | Signature | Description |
|---|---|---|
getTenantUser(ctx) | <M>(ctx: RequestContext) => Promise<M | null> | The caller’s tenant-user row in the current tenant, or null. |
requireTenantUser(ctx) | <M>(ctx: RequestContext) => Promise<M> | Like getTenantUser but throws ForbiddenError (403) when no row resolves. |
const SCOPES_BY_ROLE = { member: ['member.verified'], moderator: ['member.verified', 'moderation.reports.read'], admin: ['member.verified', 'moderation.reports.read', 'admin.members.read'],};
const tenantUserScopes = () => createTenantUserScopesMiddleware({ tenantUser: (ctx) => loadMembership(ctx.tenant!.id, ctx.user!.id), scopesByRole: SCOPES_BY_ROLE, // or createRoleScopesResolver(...).lookup isActive: (m) => m.status === 'approved' && !m.suspendedUntil,});
export const main = handler(async (_b, ctx) => { const me = await requireTenantUser(ctx); // lazy row, cached return listMembers(ctx.tenant!.id, me.id);}, { scopes: ['admin.members.read'], middleware: [tenancy, tenantUserScopes()] });Packed tenantRoles claim
Section titled “Packed tenantRoles claim”The token-side half of the fast path. A single Cognito custom attribute carries all of a user’s tenant roles:
custom:tenantRoles = "<tenantId>:<role>|<tenantId>:<role>|…"Declare a tenantRoles custom attribute on the auth intent (customAttributes: ['tenantRoles', …]); Cognito exposes it as custom:tenantRoles. On every tenant-user mutation (sign-in upsert, approve, role change, suspend, leave) the app re-derives the FULL pack from its store and writes it (e.g. adminUpdateUserAttributes) — packing active users only, always recomputing (never string-editing the previous value).
| Export | Signature | Description |
|---|---|---|
packTenantRoles(entries, maxLength?) | (readonly TenantRoleEntry[], number?) => string | Encode entries (first-entry-wins per tenant), dropping trailing entries past maxLength. Returns '' for none — write it anyway to overwrite. Throws on empty/separator-bearing ids or roles. |
unpackTenantRoles(raw) | (unknown) => ReadonlyMap<string, string> | Decode into tenantId → role; fail-closed on any malformed input. |
TENANT_ROLES_CLAIM | 'custom:tenantRoles' | Default claim name. |
TENANT_ROLES_MAX_LENGTH | 2048 | Cognito’s custom-attribute cap (~40 UUID-keyed entries). |
Staleness trade-off: attributes are baked into tokens at issue time, so a change lands on the caller’s next token refresh (≤ the ID-token TTL). GRANTS still take effect immediately via the claim-miss fallback; REVOCATIONS (suspend, demote) keep honoring the old role until refresh — apps that can’t tolerate that window set rolesClaim: false.
Row-level security scope
Section titled “Row-level security scope”createTenantRlsMiddleware() bridges the tenant this package resolves (ctx.tenant) to the tenant @venturekit/data publishes to Postgres (runWithTenant), so RLS policies enforce isolation for every query the request makes. Ordering is load-bearing:
middleware: [ tenancy, // resolves ctx.tenant createTenantRlsMiddleware(), // ← opens the RLS scope tenantUserScopes, // queries the membership row inside the scope]See Tenant context in @venturekit/data for the policy shape and DB_TENANT_ROLE.
Cross-domain handoff authorization
Section titled “Cross-domain handoff authorization”The handoff mechanism lives in @venturekit/auth/server (createSessionHandoffRoutes); the question it delegates — may this user get a session on that host? — is tenancy:
| Export | Description |
|---|---|
createTenantHandoffAuthorize({ resolveTenantByHost, isActiveTenantUser }) | Returns the authorize({ userSub, targetHost }) predicate: normalise the host, resolve it to a tenant, check the user is an active tenant user there. Unknown host → false (fail-closed) |
normalizeHandoffHost(host) | Lower-cased, trimmed host as the resolver sees it |
Tenant CRUD (vk_tenants)
Section titled “Tenant CRUD (vk_tenants)”Column-agnostic CRUD over the vk_tenants table shipped by this package’s migration (auto-applied by vk migrate). Reads use SELECT *, so columns you ALTER onto the table (branding jsonb, …) ride along; writes take a camelCase field bag and infer the binding per value (status → enum cast, objects/arrays → jsonb). All helpers take a Querier (@venturekit/data’s query).
| Function | Description |
|---|---|
listTenants(q) | All tenants, newest first |
getTenantById(q, id) / getTenantBySlug(q, slug) / getTenantByPrimaryDomain(q, domain) | Look-ups → TenantRecord | null |
createTenant(q, fields) | Insert (slug, displayName, primaryDomain?, metadata?, status?, plus any app column) |
updateTenant(q, id, fields) | Partial update |
setTenantStatus(q, id, status) | TenantStatus = 'pending' | 'active' | 'suspended' | 'archived' | 'deleted' |
isTenantUniqueViolation(err) | Postgres 23505 guard for slug / domain collisions |
TenantRecord: id, slug, displayName, status, primaryDomain, metadata, createdAt, updatedAt (+ your columns).
Tenant lifecycle
Section titled “Tenant lifecycle”Reversible operations mutate only vk_tenants.status; hard delete walks every tenant-scoped table. Each operation calls the optional audit sink with a typed LifecycleAuditEvent — wire it to @venturekit-pro/audit’s record().
| Function | Description |
|---|---|
suspendTenant(q, { tenantId, reason?, actorUserId?, audit? }) | status = 'suspended' — your middleware should refuse requests for suspended tenants |
archiveTenant(q, args) | status = 'archived' — data intact, hidden from active UIs |
restoreTenant(q, args) | Back to 'active' |
hardDeleteTenant(q, { …, schema?, skipTables? }) | Irreversible. Runs the cascade, then drops the tenant row; returns CascadeResult { plan, counts, durationMs }. Run as a privileged role |
planCascade(q, { schema?, skipTables? }) | Introspect information_schema / pg_catalog for every table with a tenant_id column and order them leaves-first by foreign keys → CascadePlanEntry[] (tableName, depth, dependsOn). A self-referential FK cycle is surfaced as an error — add the table to skipTables |
executeCascade(q, { tenantId, schema?, skipTables? }) | Delete one tenant’s rows in plan order |
Per-user erasure & export
Section titled “Per-user erasure & export”The same introspecting walker keyed on the column that marks a row as a user’s (user_id by default) — the two per-user obligations of GDPR art. 17 / art. 20. Rows keyed by another column (author_id, created_by) need one call per column; the column name is validated as a plain identifier before it is interpolated.
| Function | Description |
|---|---|
planUserErasure(q, { column?, schema?, skipTables? }) | Every table carrying the column, FK children first → CascadePlanEntry[] |
executeUserErasure(q, { userId, column?, schema?, skipTables? }) | Irreversible. Delete the user’s rows in plan order → CascadeResult. Wrap in withTransaction() for all-or-nothing; re-running is idempotent. The Cognito account is separate (adminDeleteUser from @venturekit/auth/server) |
exportUserData(q, { userId, column?, schema?, skipTables? }) | UserDataExport { userId, column, tables: { '<schema>.<table>': rows[] }, skipped, exportedAt } — SELECT * per table, for you to redact and hand over. Read-only |
skipTables names the tables to leave alone (an append-only ledger you must keep) — for erasure those are the ones to anonymise instead.
Tenant-related types are exported from ./types/index.js, including tenant configuration, resolution strategies, quota definitions, and isolation settings. CRUD and lifecycle types: TenantRecord, TenantStatus, TenantWriteFields, LifecycleAuditEvent, LifecycleAuditSink, HardDeleteArgs, CascadeOptions, CascadePlanEntry, CascadeResult, UserDataOptions, UserDataExport.
The middleware surfaces above also export: RuntimeTenantResolver, RuntimeTenantMiddlewareOptions, HostPrefixDomainResolverOptions, TenantUserWithRole, TenantUserResolver, TenantUserScopesMiddlewareOptions, RoleScopesLookup, and TenantRoleEntry.
Classes
Section titled “Classes”TenantContext
Section titled “TenantContext”Manages tenant context for the current request.
Error Classes
Section titled “Error Classes”| Class | Status | Description |
|---|---|---|
TenantNotFoundError | 404 | Tenant could not be resolved |
TenantSuspendedError | 403 | Tenant is suspended |
TenantInactiveError | 403 | Tenant is inactive |
QuotaExceededError | 429 | Tenant quota exceeded |