# Tenant Service > **Last updated:** 2026-06-10 — current `feature/white-label-shops` scan. Tenant lifecycle, custom-domain provisioning, encrypted Telegram bot management, tenant webhook handling, and request-time tenant resolution for the white-label multi-shop branch. --- ## Directory layout ``` backend/src/services/tenant/ ├── tenantService.ts # Core lifecycle + bootstrap payload ├── tenantBotService.ts # Encrypted bot token management ├── tenantAuthService.ts # Tenant role middleware factories ├── domainProvisioningService.ts # DNS verification + Caddy route lifecycle └── caddyService.ts # Caddy Admin API wrapper backend/src/shared/middleware/ └── tenantResolution.ts # Request-time Host → tenant resolver backend/src/routes/ ├── tenantRoutes.ts # Authenticated tenant admin API ├── storefrontRoutes.ts # Public storefront bootstrap/stubs └── tenantWebhookRoutes.ts # Telegram tenant bot webhook ``` --- ## tenantService.ts **Singleton export:** `tenantService` (also default export). ### Typed errors | Class | `code` | Thrown when | | --- | --- | --- | | `TenantSlugInvalidError` | `TENANT_SLUG_INVALID` | Slug does not match `/^[a-z0-9-]{3,40}$/` | | `TenantSlugTakenError` | `TENANT_SLUG_TAKEN` | Slug already registered | | `TenantNotFoundError` | `TENANT_NOT_FOUND` | `updateTenant` / `suspendTenant` / `activateTenant` on missing id | Route handlers map these to `400 / 409 / 404` via the shared `handleServiceError` helper in `tenantRoutes.ts`. ### Methods #### `createTenant(input)` Creates a tenant, auto-grants the `owner` role to the creating user, and seeds a default `amn_escrow` payment policy (all in sequence, not a transaction — acceptable for Phase 0/1). ```ts await tenantService.createTenant({ ownerUserId: 'uuid', slug: 'myshop', displayName: 'My Shop', type: 'hosted_seller', // optional, default 'hosted_seller' brand: { primaryColor: '#1F6FEB' }, // optional features: {}, // optional localeDefaults: ['en'], // optional }); ``` Slug is lowercased before the uniqueness check. #### `getTenantById(id)` / `getTenantBySlug(slug)` Direct DB lookups. Return `null` if not found. #### `resolveTenantByHost(host)` The main resolution path for HTTP requests. ``` (a) host ends with .amn.gg → strip suffix → findBySlug (must be 'active') (b) custom host → findByHostname (must be 'active') → findById ``` Returns `{ tenant, domain? }` or `null`. Never throws — callers can treat null as a 404. #### `resolveTenantBySlug(slug, { previewOnly })` Used only for `/t/:slug/bootstrap` preview paths. `previewOnly: true` allows `pending` tenants. `previewOnly: false` requires `status = 'active'`. #### `buildBootstrapPayload(tenant)` → `TenantBootstrapPayload` Assembles the public bootstrap object from the tenant row and its payment policy. Feature flags are derived from policy rails and overridden by `tenant.features` JSONB. ```ts interface TenantBootstrapPayload { tenantId: string; shopId?: string; slug: string; brand: { name: string; logoUrl?: string; primaryColor?: string; supportEmail?: string }; features: { escrowCheckout: boolean; directCheckout: boolean; externalPayments: boolean; telegramMiniApp: boolean }; paymentRails: TenantPaymentRail[]; localeDefaults: string[]; } ``` **Security:** never includes `ownerUserId`, `brand.supportEmail` is only included if set, no encrypted fields. #### `updateTenant(id, patch)` / `suspendTenant(id)` / `activateTenant(id)` / `listTenants(opts?)` Standard CRUD. All throw `TenantNotFoundError` on missing id. --- ## tenantBotService.ts **Singleton export:** `tenantBotService`. Manages Telegram bot token registration with AES-256-GCM encryption. The service encrypts on write; repositories only store ciphertext. The raw token is never logged or returned. **Required env var:** `TENANT_SECRET_KEY` — a 32-byte key provided as 64 hex chars or 44 base64 chars. Missing or wrongly sized keys fail fast before bot registration can proceed. ### Methods #### `registerBot(tenantId, { telegramBotId, username, botToken, miniAppUrl? })` 1. Encrypts `botToken` with AES-256-GCM using `TENANT_SECRET_KEY`. 2. Resolves the bot username via Telegram `getMe` when `username` is omitted. 3. Generates a random `webhookSecret` and `claimToken`. 4. Calls `tenantBotRepo.create(...)` — stores `encryptedToken`, `encryptedTokenIv`, `encryptedTokenTag`, `webhookSecret`, and `claimToken`. 5. If `APP_URL` or the first `FRONTEND_URL` value is configured, fire-and-forget registers a Telegram webhook at `/api/telegram/tenant-webhook/:botId`. 6. Returns the public bot record **without** encrypted fields or webhook secret. Pending bots include a derived `claimUrl`. #### `listBotsForTenant(tenantId)` Returns all bots for the tenant, with encrypted fields stripped from the response. #### `configureBotMenu(botId, shopUrl)` Decrypts the token internally and calls Telegram `setChatMenuButton` so the bot opens `shopUrl/telegram/`. Errors are logged but do not block bot registration. #### `claimAdmin(botId, claimToken, telegramUserId)` Called by `tenantWebhookRoutes` on `/start `. Verifies the pending bot claim token, stores `adminTelegramUserId`, flips the bot to `active`, and sends a confirmation message. #### `revokeBot(botId)` Sets `status = 'revoked'` on the bot row. > [!warning] Secret handling > `getDecryptedToken()` and token decryption are internal-only. Never call them from an HTTP route handler and never log plaintext BotFather tokens. --- ## domainProvisioningService.ts Owns the custom-domain lifecycle for `multi.amn.gg` and tenant-owned hostnames. ### Methods #### `verifyAndProvision(domainId)` 1. Touches `lastCheckedAt`. 2. Accepts either an A record pointing to `CADDY_SERVER_IP` or a CNAME pointing to `CADDY_CNAME_TARGET`. 3. Adds an idempotent Caddy route via `caddyService.addRoute(hostname)`. 4. Updates the domain to `status = 'active'`, `tlsStatus = 'pending'`. 5. Marks `status = 'degraded'`, `tlsStatus = 'failed'` when Caddy provisioning fails. Returns `'active'` or `'pending'`. #### `checkTlsStatus(domainId)` Performs an HTTPS probe through Caddy and updates `tlsStatus` to `issued`, `pending`, or `failed`. #### `deprovision(domainId)` Removes the Caddy route and marks the domain `suspended` with `tlsStatus = 'expired'`. #### `syncActiveDomains()` On backend startup, pings Caddy Admin API and re-adds all active domain routes. The database is the source of truth because Caddy API routes can be lost on Caddy restart. #### `startPoller()` Polls pending domains and active domains with pending TLS status every `DOMAIN_POLL_INTERVAL_MS` (default 60000 ms). --- ## caddyService.ts Thin Caddy Admin API wrapper. | Method | Purpose | | --- | --- | | `ping()` | Check Admin API reachability. | | `addRoute(hostname)` | Add a host route. `/api/*`, `/socket.io/*`, and `/uploads/*` proxy to backend; all other paths proxy to frontend. | | `removeRoute(hostname)` | Delete the route by Caddy `@id`. | | `hasRoute(hostname)` | Check if the route exists. | | `checkTls(hostname)` | Probe HTTPS and classify TLS as `issued`, `pending`, or `failed`. | --- ## tenantAuthService.ts Provides Express middleware factories for tenant-scoped authorization. #### `requireTenantRole(...roles: TenantUserRoleName[])` Returns an Express middleware that: 1. Reads `req.params.tenantId`. 2. Checks `tenant_user_roles` for `(tenantId, req.user.id, role ∈ roles)`. 3. Also passes if `req.user.role === 'admin'` (platform admin bypasses tenant role checks). 4. Returns `403` if no matching role found. Usage: ```ts router.get('/:tenantId/settings', authenticateToken, requireTenantRole('owner', 'manager'), handler ); ``` #### `requireTenantOwner` Shortcut for `requireTenantRole('owner')`. #### `requirePlatformAdmin` Returns `403` unless `req.user.role === 'admin'`. Thin wrapper around `authorizeRoles('admin')`. --- ## tenantResolutionMiddleware `backend/src/shared/middleware/tenantResolution.ts` Express middleware for the public storefront surface. Attaches `req.tenant: TenantRecord | undefined` and `req.tenantDomain: TenantDomainRecord | undefined`. Resolution order and security invariants are documented in [[Tenant API#Tenant resolution middleware]]. The Express `Request` type augmentation lives in this file: ```ts declare global { namespace Express { interface Request { tenant?: TenantRecord; tenantDomain?: TenantDomainRecord; } } } ``` --- ## tenantWebhookRoutes.ts Mounted at `/api/telegram` before the normal authenticated route groups. `POST /tenant-webhook/:botId`: 1. Requires `X-Telegram-Bot-Api-Secret-Token`. 2. Fetches the bot row and compares the header to `webhookSecret`. 3. Touches `lastWebhookAt`. 4. Handles `/start ` for pending bots by calling `tenantBotService.claimAdmin()`. 5. Acknowledges other updates with `200 { ok: true }`. --- ## Frontend — TenantContext `frontend/src/contexts/TenantContext.tsx` Fetches `/api/storefront/bootstrap` on mount. Exposes: ```ts interface TenantContextValue { tenant: TenantBootstrapPayload | null; isLoading: boolean; isAmanatDefault: boolean; error: string | null; reload: () => Promise; } ``` `useTenant()` is a guard hook — throws if called outside `TenantProvider`. On `404 TENANT_NOT_FOUND` or network error the provider falls back to `AMANAT_DEFAULTS` with `isAmanatDefault: true`. This means the rest of the frontend works unchanged on `amn.gg` — no tenant resolution required there. `frontend/src/hooks/use-tenant-theme.ts` derives `primaryColor`, `cssVars`, and `brandName` from `useTenant()`. `--tenant-primary` CSS variable defaults to `#1F6FEB` when no tenant color is set. Admin UI lives in `frontend/src/app/dashboard/admin/tenants` and `frontend/src/sections/admin/tenants`. It includes list/detail views, domain Check DNS / Check TLS actions, bot registration with activation links, payment policy editing, and member-role controls. > [!warning] Current frontend/backend mismatch > The Members tab posts to `/tenants/:tenantId/members` and deletes `/tenants/:tenantId/members/:memberId`, while the backend currently exposes `POST /tenants/:tenantId/roles` and `DELETE /tenants/:tenantId/roles`. Fix one side before relying on member management in production. --- ## Env vars | Variable | Required | Description | | --- | --- | --- | | `TENANT_BASE_DOMAIN` | no | Base domain for subdomain tenants. Default `amn.gg`. | | `TENANT_SECRET_KEY` | yes (when registering bots) | 32-byte AES key, provided as 64 hex chars or 44 base64 chars. | | `APP_URL` / `FRONTEND_URL` | yes for webhook auto-registration | Base URL used to register Telegram `setWebhook`. `APP_URL` wins; otherwise first comma-separated `FRONTEND_URL` value is used. | | `CADDY_ADMIN_URL` | no | Caddy Admin API URL. Default `http://infra-caddy:2019`. | | `CADDY_BACKEND_UPSTREAM` | no | Backend upstream for dynamic tenant routes. Default `escrow-multi-backend:5001`. | | `CADDY_FRONTEND_UPSTREAM` | no | Frontend upstream for dynamic tenant routes. Default `escrow-multi-frontend:8083`. | | `CADDY_SERVER_IP` | no | Public IP accepted by DNS verification. | | `CADDY_CNAME_TARGET` | no | CNAME target accepted by DNS verification. Default `multi.amn.gg`. | | `DOMAIN_POLL_INTERVAL_MS` | no | Pending-domain/TLS poll interval. Default `60000`. | Related: [[Tenant]], [[Tenant API]], [[PRD - Seller-Owned White-Label Shops and Bots]].