Files
nick-doc/10 - Services/tenant.md
Siavash Sameni e52ffce48a docs: sync vault with codebase state (2026-06-12)
- Update backend, frontend, scanner, deployment, amanat-assist service docs
- Update System Overview, Scanner Architecture, Telegram Mini App flow
- Update 10 - Services/README.md
- Add Tenant data model, Tenant API reference, Tenant Storefront Flow
- Add Multi-Shop Branch Project Scan (2026-06-10)
- Add tenant.md service doc
- Append activity log entry
- Reflects archived/search/stats route fix and new E2E test suite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 11:42:18 +04:00

11 KiB

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).

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.

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 <claimToken>. 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:

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:

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

interface TenantContextValue {
  tenant: TenantBootstrapPayload | null;
  isLoading: boolean;
  isAmanatDefault: boolean;
  error: string | null;
  reload: () => Promise<void>;
}

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.