@venturekit-pro/tenancy
v0.0.47
Published
Multi-tenant utilities for VentureKit
Readme
@venturekit-pro/tenancy
Warning: This package is in active development and not production-ready. APIs may change without notice.
Multi-tenant utilities for VentureKit — tenant resolution, context management, data isolation, and quota enforcement.
Installation
npm install @venturekit-pro/tenancy@devOverview
@venturekit-pro/tenancy provides:
- Tenant resolution — resolve tenants from subdomain, custom domain, path, header, or JWT
- Tenant context —
createTenantContext(),getCurrentTenant(),resolveTenant() - Tenant middleware —
createTenantMiddleware()for automatic tenant resolution per request - Quota enforcement —
createQuotaMiddleware(),checkQuotas()for usage limits - Error types —
TenantNotFoundError,TenantSuspendedError,TenantInactiveError,QuotaExceededError
Tenant Resolution
VentureKit supports multiple strategies for identifying the current tenant:
| Strategy | Example |
|----------|---------|
| Subdomain | acme.app.example.com → tenant acme |
| Custom domain | app.acme.com → lookup in domain table |
| Path prefix | /t/acme/api/tasks → tenant acme |
| Header | X-Tenant-ID: acme |
| JWT claim | tenant_id claim in access token |
Middleware
Add tenant resolution to your handlers:
import { createTenantMiddleware, createQuotaMiddleware } from '@venturekit-pro/tenancy';
import { handler } from '@venturekit/runtime';
export const main = handler(async (_body, ctx, logger) => {
logger.info('Current tenant', { tenantId: ctx.tenant?.id });
return { tenantId: ctx.tenant?.id };
}, {
scopes: ['api.read'],
middleware: [
createTenantMiddleware({ strategy: 'subdomain' }),
createQuotaMiddleware(),
],
});Quota Enforcement
Define per-tenant quotas and enforce them automatically:
import { checkQuotas } from '@venturekit-pro/tenancy';
// Throws QuotaExceededError if over limit
await checkQuotas(tenantId, {
apiRequests: { limit: 10000, period: 'month' },
storage: { limit: 5_000_000_000 }, // 5 GB
});Context
Access the current tenant anywhere in your handler:
import { getCurrentTenant } from '@venturekit-pro/tenancy';
const tenant = getCurrentTenant(ctx);
// { id: 'acme', slug: 'acme', metadata: { ... } }Hierarchy
Tenants form trees — a group and its schools, a franchise and its stores. The tree is
vk_tenants.parent_id plus vk_tenant_closure (every ancestor→descendant path with its
depth, migration 0000_vk_tenancy_tree), maintained in code and read in one statement:
import { createTenantTree, unpackTenantRoles } from '@venturekit-pro/tenancy';
import { query } from '@venturekit/data';
const tree = createTenantTree(); // or { tenants: 'platform.tenant_ref', closure: 'platform.tenant_closure' } on a projection
await tree.setParent(query, schoolId, groupId); // rewrites the subtree's closure; refuses a cycle
await tree.descendantsOf(query, groupId); // [{ tenantId, depth }], shallowest first
await tree.ancestorsOf(query, schoolId); // nearest firstA role held in a parent counts in its descendants — a group's owner is the owner of each
of its schools without a membership row in any. The rules are pure (inheritedTenantRole,
reachableTenants), and the tree feeds them:
const pack = unpackTenantRoles(ctx.user.claims['custom:tenantRoles']);
const isSystemRole = (role: string) => role in SYSTEM_ROLES; // custom roles are rows of the tenant that defined them
// a request naming another tenant: may this session act as it, and as what?
const held = await tree.roleIn(query, pack, requestedTenantId, { inherits: isSystemRole });
if (!held) throw new ForbiddenError('This session may not act as that tenant');
// a session description / tenant switcher: every tenant this user may act as
const sites = await tree.reach(query, pack, { inherits: isSystemRole });An explicit membership in a child always wins over an inherited one, and a nearer ancestor over a farther one. The store is read only when the memberships alone do not settle the question.
Erasure and export
The cascade walker that backs hardDeleteTenant also serves the two
per-user obligations (GDPR art. 17 / art. 20). Both key on the column that
marks a row as the user's — user_id by default — and introspect the schema,
so a new table is covered the day it is added.
import { exportUserData, executeUserErasure, planUserErasure } from '@venturekit-pro/tenancy';
// Portability: every row of the user, per table, ready to redact and hand over
const dump = await exportUserData(query, { userId, skipTables: ['audit_events'] });
// Erasure: dry-run first, then delete FK children before parents
const plan = await planUserErasure(query, { column: 'member_id' });
await withTransaction((tx) => executeUserErasure(tx.query, { userId, column: 'member_id', skipTables: ['audit_events'] }));
// …then adminDeleteUser() from @venturekit/auth/server for the Cognito account.Rows keyed by another column (author_id, created_by) need one call per
column; tables in skipTables (an append-only ledger you must keep) are the
ones to anonymise instead.
API Reference
See the API reference for full documentation.
License
Apache-2.0 — see LICENSE for details.
