> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vertz.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-Tenancy

> Tenant switching, listing, last-accessed tracking, and auto-resolve for multi-tenant apps

Vertz supports multi-tenant applications where a single user belongs to multiple tenants (organizations, workspaces, teams). The `tenant` config in `createAuth()` enables:

* **Tenant-scoped JWTs** — each session is scoped to one tenant at a time
* **Tenant switching** — users switch between tenants without re-authenticating
* **Tenant listing** — fetch all tenants a user belongs to
* **Auto-resolve** — automatically select the right tenant on login (last-accessed or custom logic)
* **Last-accessed tracking** — the server remembers which tenant the user last switched to

## Quick start

```ts theme={null}
import { createAuth, createServer } from '@vertz/server';

const auth = createAuth({
  session: { strategy: 'jwt', ttl: '60s' },
  tenant: {
    // Required — verify the user belongs to the target tenant
    verifyMembership: async (userId, tenantId) => {
      const member = await db.query(
        'SELECT 1 FROM tenant_members WHERE user_id = ? AND tenant_id = ?',
        [userId, tenantId],
      );
      return member.length > 0;
    },

    // Optional — list all tenants for the user
    listTenants: async (userId) => {
      return db.query(
        'SELECT t.id, t.name, t.logo FROM tenants t JOIN tenant_members tm ON t.id = tm.tenant_id WHERE tm.user_id = ?',
        [userId],
      );
    },

    // Optional — custom logic to pick the default tenant
    resolveDefault: async (userId, tenants) => {
      // Example: prefer the tenant marked as primary
      const primary = tenants.find((t) => t.isPrimary);
      return primary?.id;
    },
  },
});

const app = createServer({
  entities: [
    /* ... */
  ],
  db,
  auth,
});
```

This generates:

```
GET  /api/auth/tenants        → List tenants + resolve default
POST /api/auth/switch-tenant  → Switch tenant (re-issues JWT)
```

## Configuration

### `tenant.verifyMembership` (required)

Called before every tenant switch. Return `false` to deny — the endpoint returns `403`.

```ts theme={null}
verifyMembership: async (userId: string, tenantId: string) => boolean;
```

### `tenant.listTenants` (optional)

Enables the `GET /api/auth/tenants` endpoint. Without this, the endpoint returns `404`.

```ts theme={null}
listTenants: async (userId: string) => TenantInfo[]
```

`TenantInfo` requires `id` and `name`. You can add any extra fields (logo, plan, role) — they pass through to the client:

```ts theme={null}
interface TenantInfo {
  id: string;
  name: string;
  [key: string]: unknown; // logo, plan, memberCount, etc.
}
```

### `tenant.resolveDefault` (optional)

Determines which tenant to auto-select when a session has no `tenantId` (e.g., right after login). Called during `GET /api/auth/tenants`.

```ts theme={null}
resolveDefault: async (userId: string, tenants: TenantInfo[]) => string | undefined;
```

**Default behavior** (when `resolveDefault` is not provided):

1. Use `lastTenantId` (the last tenant the user switched to)
2. Fall back to the first tenant in the list
3. Return `undefined` if the user has no tenants

## How it works

### Tenant-scoped JWTs

When a user switches tenants, the server issues a new JWT with `tenantId` in the payload. All subsequent requests carry this scoped JWT. Entity access rules can use `rules.where({ tenantId: rules.user.tenantId })` to filter data by the current tenant.

The `tenantId` is preserved across token refreshes — switching tenant once persists until the user explicitly switches again.

### Last-accessed tracking

Every successful tenant switch updates the user's `lastTenantId` in the user store. This is used by the default `resolveDefault` strategy and is returned in the `GET /api/auth/tenants` response.

## Endpoints

### `GET /api/auth/tenants`

Returns the user's tenants, current session tenant, last-accessed tenant, and the resolved default.

**Response:**

```json theme={null}
{
  "tenants": [
    { "id": "org-1", "name": "Acme Corp", "logo": "/logos/acme.png" },
    { "id": "org-2", "name": "Startup Inc" }
  ],
  "currentTenantId": "org-1",
  "lastTenantId": "org-1",
  "resolvedDefaultId": "org-1"
}
```

| Field               | Type                  | Description                        |
| ------------------- | --------------------- | ---------------------------------- |
| `tenants`           | `TenantInfo[]`        | All tenants the user belongs to    |
| `currentTenantId`   | `string \| undefined` | Tenant in the current JWT (if any) |
| `lastTenantId`      | `string \| undefined` | Last tenant the user switched to   |
| `resolvedDefaultId` | `string \| undefined` | Recommended tenant to auto-select  |

**Errors:**

| Status | When                         |
| ------ | ---------------------------- |
| `401`  | No valid session             |
| `404`  | `listTenants` not configured |

### `POST /api/auth/switch-tenant`

Switches the session to a different tenant. Issues a new JWT scoped to the target tenant.

**Request:**

```json theme={null}
{ "tenantId": "org-2" }
```

**Response:**

```json theme={null}
{
  "tenantId": "org-2",
  "user": { "id": "user-1", "email": "alice@example.com" },
  "expiresAt": 1711036800000
}
```

New session cookies (`vertz.sid`, `vertz.ref`) are set automatically.

**Errors:**

| Status | When                                      |
| ------ | ----------------------------------------- |
| `401`  | No valid session                          |
| `403`  | User is not a member of the target tenant |
| `404`  | Tenant config not enabled                 |

## Entity tenant scoping

Entities with a `tenantId` field are automatically scoped by the framework. The access system adds `rules.where({ tenantId: rules.user.tenantId })` to all operations, so queries only return rows belonging to the current tenant.

```ts theme={null}
const projects = entity('projects', {
  model: projectsModel,
  access: {
    list: rules.authenticated(),
    // tenantId filtering is automatic — no need to add it manually
  },
});
```

To opt out for cross-tenant entities:

```ts theme={null}
entity('system-template', {
  tenantScoped: false,
  // ...
});
```

## Multi-level tenancy

Real-world SaaS apps often have **multiple levels of tenancy** — an account contains projects, or an agency manages organizations that contain brands. Vertz supports this natively: multiple `.tenant()` tables form a hierarchy, with billing, access, and data isolation at different levels.

### Define the hierarchy

Mark multiple tables as `.tenant()`. The framework infers the hierarchy from FK relationships:

```ts theme={null}
import { d } from '@vertz/db';

const accounts = d
  .table('accounts', {
    id: d.id(),
    name: d.text(),
  })
  .tenant();

const projects = d
  .table('projects', {
    id: d.id(),
    name: d.text(),
    accountId: d.text(), // FK → accounts.id
  })
  .tenant();
```

The framework detects that `projects.accountId` references `accounts.id` and infers: `account → project` (root → leaf). Up to 4 levels are supported.

### Per-level billing

Plans get a `level` field that targets a specific entity in the hierarchy:

```ts theme={null}
const access = defineAccess({
  entities: {
    account: { roles: ['owner', 'admin'] },
    project: {
      roles: ['admin', 'editor'],
      inherits: { 'account:owner': 'admin', 'account:admin': 'admin' },
    },
  },
  entitlements: {
    'project:ai-generate': { roles: ['admin', 'editor'] },
  },
  plans: {
    enterprise: {
      group: 'account-plans',
      level: 'account', // Assigned to accounts
      features: ['project:ai-generate'],
      limits: {
        'ai-credits': { max: 10_000, gates: 'project:ai-generate', per: 'month' },
      },
    },
    pro: {
      group: 'project-plans',
      level: 'project', // Assigned to projects
      features: ['project:ai-generate'],
      limits: {
        'ai-credits': { max: 500, gates: 'project:ai-generate', per: 'month' },
      },
    },
  },
  defaultPlans: {
    account: 'enterprise',
    project: 'pro',
  },
});
```

Plans at different levels use different `group` names (mutual exclusivity is per-level). Use `defaultPlans` (keyed by entity name) instead of `defaultPlan` for multi-level.

### Feature resolution modes

When the same entitlement is gated at multiple levels, the framework supports two resolution modes:

| Mode                | Behavior                                 | Use case                                             |
| ------------------- | ---------------------------------------- | ---------------------------------------------------- |
| `inherit` (default) | Parent plan grants features to children  | Enterprise account unlocks features for all projects |
| `local`             | Only the deepest level's plan is checked | Project must have its own plan to use a feature      |

```ts theme={null}
entitlements: {
  'project:ai-generate': {
    roles: ['admin', 'editor'],
    featureResolution: 'inherit',  // default — account plan cascades down
  },
  'project:custom-domain': {
    roles: ['admin'],
    featureResolution: 'local',    // project must have its own plan
  },
},
```

### Cascaded wallet consumption

When consuming a limit at a child level, the wallet is incremented at **every ancestor level** that has a plan with the same limit. This enforces ceilings at all levels:

```
Account: enterprise (10,000 ai-credits/month)
  └── Project A: pro (500 ai-credits/month)
  └── Project B: pro (500 ai-credits/month)
```

When Project A consumes 1 credit:

* Project A wallet: +1 (of 500)
* Account wallet: +1 (of 10,000)

If the account reaches 10,000 total across all projects, further consumption is denied even if individual projects still have room in their 500 limit.

`unconsume()` mirrors this — decrementing all ancestor levels.

### Level-aware tenant filtering

Entities scoped to a non-leaf level are filtered correctly. If `billing_invoices` is scoped to the account level and the user is at the project level, the framework walks the ancestor chain to find the account ID:

```ts theme={null}
// User scoped to project_abc (child of account_xyz)
// Listing invoices → filters by accountId = account_xyz
```

### Flag resolution

When the same flag exists at multiple levels, **deepest wins** (most specific overrides parent):

```
Account flag: beta_ai = true
Project flag: beta_ai = false
→ Resolved at project level: false
```

### Backward compatibility

Single `.tenant()` apps work unchanged — no `level`, no `defaultPlans`, no `ancestorResolver`. The multi-level features are entirely opt-in.

### Session payload

Multi-level sessions include `tenantLevel` alongside `tenantId`:

```ts theme={null}
// JWT payload includes:
{
  tenantId: 'proj-123',
  tenantLevel: 'project',  // Which level the tenantId belongs to
}
```

The `switch-tenant` endpoint resolves `tenantLevel` from the closure table automatically.

## Next steps

<CardGroup cols={2}>
  <Card title="Client-Side Tenants" icon="building" href="/guides/ui/multi-tenancy">
    TenantProvider, useTenant(), and TenantSwitcher component.
  </Card>

  <Card title="Authentication" icon="lock" href="/guides/server/auth">
    JWT sessions, RBAC, plans, and full auth configuration.
  </Card>

  <Card title="Access Control" icon="shield" href="/guides/ui/access-control">
    Use entitlements and access rules in your UI.
  </Card>
</CardGroup>
