# @venturekit-pro/tenancy

> **Warning:** This package is in active development and not production-ready. APIs may change without notice.

Multi-tenant utilities for [VentureKit](https://venturekit.dev) — tenant resolution, context management, data isolation, and quota enforcement.

## Installation

```bash
npm install @venturekit-pro/tenancy@dev
```

## Overview

`@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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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 first
```

A 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:

```typescript
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.

```typescript
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](https://venturekit.dev/api-reference/tenancy) for full documentation.

## License

Apache-2.0 — see [LICENSE](../../LICENSE) for details.
