[!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.
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 <token> 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 <claimToken> 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).
[!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
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
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
// Resolve tenant from HTTP Host header (via service)
constresult=awaittenantService.resolveTenantByHost(req.hostname);// result?.tenant or null
// Find active domain
constdomain=awaitgetTenantDomainRepo().findByHostname('shop.example.com');// domain.status must be 'active' before trusting
// Build bootstrap payload for public storefront
constpayload=awaittenantService.buildBootstrapPayload(tenant);// Check user roles in tenant
constroles=awaitgetTenantUserRoleRepo().findRolesForUserInTenant(tenantId,userId);// Upsert payment policy (idempotent)
awaitgetTenantPaymentPolicyRepo().upsertForTenant(tenantId,{allowedRails,defaultRail});
Migration
Tables are PG-native — no Mongo backfill path. Run:
cd backend && npx drizzle-kit generate
# review the generated SQL
npx drizzle-kit migrate