Skip to content

@venturekit-pro/tenancy API

FunctionSignatureDescription
createTenantContext(options) => TenantContextCreate a tenant context
getCurrentTenant(ctx: RequestContext) => TenantContext | nullGet current tenant from request context
resolveTenant(event, strategy) => TenantContextResolve tenant from request
FunctionSignatureDescription
createTenantMiddleware(options: { strategy: string }) => MiddlewareRuntime-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?) => MiddlewareCreate quota enforcement middleware
checkQuotas(tenantId: string, quotas: Record<string, QuotaDef>) => Promise<void>Check quotas programmatically

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 (STAGE === 'dev') 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],
});

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:

  1. Claims fast path — the packed custom:tenantRoles attribute (see Packed tenantRoles claim below) carries the caller’s role in every tenant; when the current tenant is in the pack its role maps through scopesByRole with zero store round-trips.
  2. 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 tenantUser resolver loads the row and isActive gates 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):

FunctionSignatureDescription
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()] });

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).

ExportSignatureDescription
packTenantRoles(entries, maxLength?)(readonly TenantRoleEntry[], number?) => stringEncode 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_LENGTH2048Cognito’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.

Tenant-related types are exported from ./types/index.js, including tenant configuration, resolution strategies, quota definitions, and isolation settings.

The middleware surfaces above also export: RuntimeTenantResolver, RuntimeTenantMiddlewareOptions, HostPrefixDomainResolverOptions, TenantUserWithRole, TenantUserResolver, TenantUserScopesMiddlewareOptions, RoleScopesLookup, and TenantRoleEntry.

Manages tenant context for the current request.

ClassStatusDescription
TenantNotFoundError404Tenant could not be resolved
TenantSuspendedError403Tenant is suspended
TenantInactiveError403Tenant is inactive
QuotaExceededError429Tenant quota exceeded