- 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>
11 KiB
Tenant Service
Last updated: 2026-06-10 — current
feature/white-label-shopsscan.
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? })
- Encrypts
botTokenwith AES-256-GCM usingTENANT_SECRET_KEY. - Resolves the bot username via Telegram
getMewhenusernameis omitted. - Generates a random
webhookSecretandclaimToken. - Calls
tenantBotRepo.create(...)— storesencryptedToken,encryptedTokenIv,encryptedTokenTag,webhookSecret, andclaimToken. - If
APP_URLor the firstFRONTEND_URLvalue is configured, fire-and-forget registers a Telegram webhook at/api/telegram/tenant-webhook/:botId. - 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)
- Touches
lastCheckedAt. - Accepts either an A record pointing to
CADDY_SERVER_IPor a CNAME pointing toCADDY_CNAME_TARGET. - Adds an idempotent Caddy route via
caddyService.addRoute(hostname). - Updates the domain to
status = 'active',tlsStatus = 'pending'. - 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:
- Reads
req.params.tenantId. - Checks
tenant_user_rolesfor(tenantId, req.user.id, role ∈ roles). - Also passes if
req.user.role === 'admin'(platform admin bypasses tenant role checks). - Returns
403if 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:
- Requires
X-Telegram-Bot-Api-Secret-Token. - Fetches the bot row and compares the header to
webhookSecret. - Touches
lastWebhookAt. - Handles
/start <claimToken>for pending bots by callingtenantBotService.claimAdmin(). - 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/membersand deletes/tenants/:tenantId/members/:memberId, while the backend currently exposesPOST /tenants/:tenantId/rolesandDELETE /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.