@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 (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],});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.
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.
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 |