--- title: Tenant tags: [data-model, postgres, drizzle, white-label, multi-tenant] aliases: [TenantRecord, Merchant Tenant, White-Label Shop] --- # Tenant > **Last updated:** 2026-06-10 — current `feature/white-label-shops` scan. Six Drizzle/PostgreSQL tables that form the multi-tenant layer of the Amanat marketplace operating system. Introduced by [[PRD - Seller-Owned White-Label Shops and Bots]]. > [!note] Source > All six tables live in a single file: `backend/src/db/schema/tenant.ts` > Repositories: `backend/src/db/repositories/drizzle/DrizzleTenant*.ts` > Services: `backend/src/services/tenant/` --- ## Table overview | Table | Purpose | Isolation key | | --- | --- | --- | | `tenants` | Top-level tenant entity (one per merchant) | `id` (PK) | | `tenant_domains` | Custom / managed hostnames | `tenant_id` FK, unique on `hostname` | | `tenant_bots` | Telegram bot token registrations (encrypted) | `tenant_id` FK, unique on `telegram_bot_id` | | `tenant_integrations` | Catalog / delivery / payment adapter configs | `tenant_id` FK | | `tenant_payment_policies` | Per-tenant payment rail configuration | `tenant_id` FK, 1:1 | | `tenant_user_roles` | User ↔ tenant role grants | composite unique `(tenant_id, user_id, role)` | --- ## `tenants` | Column | Type | Constraints | Default | Description | | --- | --- | --- | --- | --- | | `id` | `uuid` | PK | `gen_random_uuid()` | Tenant identifier. | | `owner_user_id` | `uuid` | NOT NULL, FK → `users.id` RESTRICT | — | Owner [[User]] (`pgId`). RESTRICT prevents silent orphan on user delete. | | `slug` | `text` | NOT NULL, UNIQUE | — | URL-safe label `[a-z0-9-]{3,40}` used for `seller.amn.gg` and `/t/:slug`. | | `type` | `tenantType` enum | NOT NULL | `hosted_seller` | Tenant tier. | | `status` | `tenantStatus` enum | NOT NULL | `pending` | Lifecycle state. | | `display_name` | `text` | NOT NULL | — | Human name for the shop. | | `billing_account_id` | `text` | nullable | — | External billing reference (no FK in Phase 0/1). | | `isolation_mode` | `tenantIsolationMode` enum | NOT NULL | `shared` | Data isolation level. | | `shop_settings_id` | `uuid` | nullable, FK → `shop_settings.id` SET NULL | — | Link to existing [[ShopSettings]] row. | | `brand` | `jsonb` | nullable | — | `{ name?, logoUrl?, primaryColor?, supportEmail? }` — drives bootstrap payload. | | `features` | `jsonb` | nullable | — | `{ escrowCheckout?, directCheckout?, externalPayments?, telegramMiniApp? }` — overrides policy-derived flags. | | `locale_defaults` | `text[]` | nullable | — | e.g. `['en', 'fa']`. | | `legacy_object_id` | `text` | nullable | — | Convention parity field; tenants are PG-native. | | `created_at` | `timestamptz` | NOT NULL | `now()` | — | | `updated_at` | `timestamptz` | NOT NULL | `now()` | — | ### Indexes | Name | Columns | Type | | --- | --- | --- | | `tenants_slug_uq` | `slug` | UNIQUE | | `tenants_owner_user_id_idx` | `owner_user_id` | B-tree | | `tenants_status_idx` | `status` | B-tree | ### Enums | Enum | Values | | --- | --- | | `tenantType` | `hosted_seller`, `white_label`, `isolated`, `enterprise` | | `tenantStatus` | `pending`, `active`, `suspended`, `closed` | | `tenantIsolationMode` | `shared`, `schema`, `database`, `stack` | --- ## `tenant_domains` | Column | Type | Constraints | Default | Description | | --- | --- | --- | --- | --- | | `id` | `uuid` | PK | `gen_random_uuid()` | — | | `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | Owning tenant. | | `hostname` | `text` | NOT NULL, UNIQUE | — | Full hostname e.g. `shop.example.com`. Globally unique — the resolution key. | | `mode` | `tenantDomainMode` enum | NOT NULL | `cname` | How DNS is managed. | | `status` | `tenantDomainStatus` enum | NOT NULL | `pending` | Domain lifecycle state. | | `verification_token` | `text` | NOT NULL | — | Random hex token for TXT/CNAME proof. | | `tls_status` | `tenantTlsStatus` enum | NOT NULL | `pending` | TLS certificate state. | | `last_checked_at` | `timestamptz` | nullable | — | Last validation probe. | | `created_at` | `timestamptz` | NOT NULL | `now()` | — | | `updated_at` | `timestamptz` | NOT NULL | `now()` | — | ### Enums | Enum | Values | | --- | --- | | `tenantDomainMode` | `managed_ns`, `cname` | | `tenantDomainStatus` | `pending`, `active`, `degraded`, `suspended`, `removed` | | `tenantTlsStatus` | `pending`, `issued`, `failed`, `expired` | > [!warning] Hostname uniqueness is the security boundary > A single hostname MUST map to at most one tenant. The unique index `tenant_domains_hostname_uq` enforces this. Code in `tenantResolutionMiddleware` relies on `findByHostname` returning at most one row. --- ## `tenant_bots` | Column | Type | Constraints | Default | Description | | --- | --- | --- | --- | --- | | `id` | `uuid` | PK | `gen_random_uuid()` | — | | `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | Owning tenant. | | `telegram_bot_id` | `text` | NOT NULL, UNIQUE | — | Numeric Telegram bot id stored as text (exceeds JS safe int). | | `username` | `text` | NOT NULL | — | Bot @username. | | `encrypted_token` | `text` | NOT NULL | — | AES-256-GCM ciphertext of the BotFather token. | | `encrypted_token_iv` | `text` | NOT NULL | — | GCM IV (base64). | | `encrypted_token_tag` | `text` | NOT NULL | — | GCM auth tag (base64). | | `webhook_secret` | `text` | NOT NULL | — | Per-bot random hex webhook path secret used by `/api/telegram/tenant-webhook/:botId`. | | `status` | `tenantBotStatus` enum | NOT NULL | `pending` | Bot lifecycle. | | `mini_app_url` | `text` | nullable | — | Telegram Mini App URL when configured. | | `claim_token` | `text` | nullable | — | One-time Telegram `/start ` deep-link token for the first admin claim. | | `admin_telegram_user_id` | `text` | nullable | — | Telegram user id that claimed the bot admin role. | | `last_webhook_at` | `timestamptz` | nullable | — | Last received webhook update. | | `created_at` | `timestamptz` | NOT NULL | `now()` | — | | `updated_at` | `timestamptz` | NOT NULL | `now()` | — | ### Enums | Enum | Values | | --- | --- | | `tenantBotStatus` | `pending`, `active`, `suspended`, `revoked` | > [!warning] Token fields > `encrypted_token`, `encrypted_token_iv`, and `encrypted_token_tag` are AES-256-GCM fields. The repository layer **never decrypts** them. Decryption belongs exclusively to `tenantBotService`. Never include these columns or `webhook_secret` in API responses. > [!note] Claim flow > New bots start as `pending` with a `claim_token`. The public service response exposes only a derived `claimUrl` while the bot is pending. When Telegram sends `/start ` to `/api/telegram/tenant-webhook/:botId` with the correct Telegram webhook secret header, `tenantBotService.claimAdmin()` stores `admin_telegram_user_id` and flips the bot to `active`. --- ## `tenant_integrations` | Column | Type | Constraints | Default | Description | | --- | --- | --- | --- | --- | | `id` | `uuid` | PK | `gen_random_uuid()` | — | | `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | — | | `kind` | `tenantIntegrationKind` enum | NOT NULL | — | Integration category. | | `provider` | `text` | NOT NULL | — | Free-form provider slug e.g. `shopify`, `http_json`. | | `status` | `tenantIntegrationStatus` enum | NOT NULL | `draft` | Integration lifecycle. | | `config` | `jsonb` | nullable | — | Non-secret config blob. | | `encrypted_config` | `text` | nullable | — | AES-GCM ciphertext for provider keys/secrets. | | `encrypted_config_iv` | `text` | nullable | — | GCM IV. | | `encrypted_config_tag` | `text` | nullable | — | GCM auth tag. | | `last_sync_at` | `timestamptz` | nullable | — | — | | `last_error` | `text` | nullable | — | Last sync error message. | | `created_at` | `timestamptz` | NOT NULL | `now()` | — | | `updated_at` | `timestamptz` | NOT NULL | `now()` | — | Unique index: `tenant_integrations_tenant_kind_provider_uq` on `(tenant_id, kind, provider)`. ### Enums | Enum | Values | | --- | --- | | `tenantIntegrationKind` | `catalog`, `delivery`, `payment`, `accounting`, `notification` | | `tenantIntegrationStatus` | `draft`, `active`, `error`, `disabled` | --- ## `tenant_payment_policies` 1:1 with `tenants` (enforced by unique index on `tenant_id`). Created automatically with `amn_escrow` defaults when a tenant is created. | Column | Type | Constraints | Default | Description | | --- | --- | --- | --- | --- | | `id` | `uuid` | PK | `gen_random_uuid()` | — | | `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE, UNIQUE | — | Owning tenant (1:1). | | `allowed_rails` | `tenantPaymentRail[]` | NOT NULL | `ARRAY['amn_escrow']` | PG enum array of permitted payment rails. | | `default_rail` | `tenantPaymentRail` | NOT NULL | `amn_escrow` | Rail used when buyer doesn't specify. CHECK: must be in `allowed_rails`. | | `escrow_required_above_amount` | `numeric(38,18)` | nullable | — | Orders above this amount force `amn_escrow`. Matches `payments.amount` precision. | | `escrow_required_for_categories` | `text[]` | nullable | — | Category slugs that always require escrow. | | `buyer_disclosure_mode` | `tenantBuyerDisclosureMode` | NOT NULL | `strict` | How prominently the non-escrow notice is shown to buyers. | | `created_at` | `timestamptz` | NOT NULL | `now()` | — | | `updated_at` | `timestamptz` | NOT NULL | `now()` | — | ### Enums | Enum | Values | | --- | --- | | `tenantPaymentRail` | `amn_escrow`, `amn_direct`, `external_provider`, `manual_invoice` | | `tenantBuyerDisclosureMode` | `plain`, `strict` | > [!note] CHECK constraint > `tenant_payment_policies_default_in_allowed_ck` enforces `default_rail = ANY(allowed_rails)` at the DB level. Route validation mirrors this at the application level. --- ## `tenant_user_roles` | Column | Type | Constraints | Default | Description | | --- | --- | --- | --- | --- | | `id` | `uuid` | PK | `gen_random_uuid()` | — | | `tenant_id` | `uuid` | NOT NULL, FK → `tenants.id` CASCADE | — | — | | `user_id` | `uuid` | NOT NULL, FK → `users.id` CASCADE | — | `users.id` (Postgres UUID, i.e. `pgId`). | | `role` | `tenantUserRole` enum | NOT NULL | — | Role within the tenant. | | `created_at` | `timestamptz` | NOT NULL | `now()` | — | | `updated_at` | `timestamptz` | NOT NULL | `now()` | — | Unique index: `tenant_user_roles_tenant_user_role_uq` on `(tenant_id, user_id, role)` — a user may hold each role at most once per tenant. ### Enum | Enum | Values | | --- | --- | | `tenantUserRole` | `owner`, `manager`, `finance`, `support`, `developer` | --- ## State transitions ### Tenant status ```mermaid stateDiagram-v2 [*] --> pending : createTenant() pending --> active : operator activateTenant() active --> suspended : operator suspendTenant() suspended --> active : operator activateTenant() active --> closed : operator (Phase 2+) pending --> closed : operator rejects ``` ### Domain status ```mermaid stateDiagram-v2 [*] --> pending : POST /domains pending --> active : DNS verified + Caddy route added active --> active : TLS pending/issued pending --> degraded : Caddy provisioning fails degraded --> active : probe recovers active --> suspended : DELETE /domains/:domainId suspended --> removed : future cleanup ``` --- ## Key relationships ```mermaid erDiagram users ||--o{ tenants : "owns (ownerUserId)" shop_settings ||--o| tenants : "linked (shopSettingsId)" tenants ||--o{ tenant_domains : "has" tenants ||--o{ tenant_bots : "has" tenants ||--o{ tenant_integrations : "has" tenants ||--|| tenant_payment_policies : "has (1:1)" tenants ||--o{ tenant_user_roles : "grants" users ||--o{ tenant_user_roles : "receives" ``` --- ## Common queries ```ts // Resolve tenant from HTTP Host header (via service) const result = await tenantService.resolveTenantByHost(req.hostname); // result?.tenant or null // Find active domain const domain = await getTenantDomainRepo().findByHostname('shop.example.com'); // domain.status must be 'active' before trusting // Build bootstrap payload for public storefront const payload = await tenantService.buildBootstrapPayload(tenant); // Check user roles in tenant const roles = await getTenantUserRoleRepo().findRolesForUserInTenant(tenantId, userId); // Upsert payment policy (idempotent) await getTenantPaymentPolicyRepo().upsertForTenant(tenantId, { allowedRails, defaultRail }); ``` --- ## Migration Tables are PG-native — no Mongo backfill path. Run: ```bash cd backend && npx drizzle-kit generate # review the generated SQL npx drizzle-kit migrate ``` Related: [[PRD - Seller-Owned White-Label Shops and Bots]], [[ShopSettings]], [[User]], [[Payment]].