- 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>
15 KiB
title, tags, aliases
| title | tags | aliases | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant API |
|
|
Tenant API
Last updated: 2026-06-10 — current
feature/white-label-shopsscan. Related: Tenant, PRD - Seller-Owned White-Label Shops and Bots, Authentication API
Three route groups:
| Mount | File | Auth | Description |
|---|---|---|---|
/api/tenants |
backend/src/routes/tenantRoutes.ts |
Required (JWT) | Admin + tenant-owner management surface. |
/api/storefront |
backend/src/routes/storefrontRoutes.ts |
None (public) | Public storefront surface — tenant resolved from Host header. |
/api/telegram |
backend/src/routes/tenantWebhookRoutes.ts |
Telegram webhook secret header | Tenant-bot webhook receiver for claim activation. |
Authentication and authorization
All /api/tenants/* routes require Authorization: Bearer <jwt> via authenticateToken.
Three authorization tiers:
| Tier | How enforced | Who |
|---|---|---|
| Platform admin | authorizeRoles('admin') middleware |
Users with role = 'admin' |
| Tenant role | requireTenantRole(...roles) middleware |
Users with a matching row in tenant_user_roles for the target tenant |
| Self (tenant creation) | Inline check | Any authenticated user (creates for themselves) |
requireTenantRole looks up tenant_user_roles by (tenantId, req.user.id). It passes if the user's role is in the allowed list or if the user is a platform admin.
/api/storefront/* routes are fully public — no authenticateToken. Tenant identity comes from the Host header only, never from the request body.
Storefront routes — GET /api/storefront/...
GET /api/storefront/bootstrap
Resolves the tenant from the Host header and returns the bootstrap payload for the frontend TenantProvider.
Auth: None.
Middleware: tenantResolutionMiddleware → storefrontRateLimiter (120 req/min per tenant+IP).
Response 200:
{
"success": true,
"data": {
"tenantId": "uuid",
"slug": "myshop",
"shopId": "uuid",
"brand": {
"name": "My Shop",
"logoUrl": "https://cdn.example.com/logo.png",
"primaryColor": "#1F6FEB",
"supportEmail": "support@example.com"
},
"features": {
"escrowCheckout": true,
"directCheckout": false,
"externalPayments": false,
"telegramMiniApp": false
},
"paymentRails": ["amn_escrow"],
"localeDefaults": ["en", "fa"]
}
}
Response 404: Host does not match any active tenant or domain.
[!note] Amanat default The frontend
TenantProvidertreats a 404 as "no tenant for this host" and falls back to Amanat platform defaults — this is not an error condition for the frontend.
GET /api/storefront/t/:slug/bootstrap
Preview-only bootstrap by tenant slug. Allowed only when the request arrives from the platform base domain (amn.gg, localhost). Owner can preview a pending tenant this way.
Auth: None.
Restrictions: Returns 403 PREVIEW_FORBIDDEN if the Host is not the platform base domain.
GET /api/storefront/catalog (Phase 2 stub)
POST /api/storefront/checkout (Phase 2 stub)
GET /api/storefront/orders/:orderId (Phase 2 stub)
All return 501 NOT_IMPLEMENTED. Namespace reserved.
Tenant management routes — /api/tenants/...
POST /api/tenants — create tenant
Any authenticated user may create a tenant for themselves (ownerUserId = req.user.id). Only platform admins may supply a different ownerUserId.
Created tenants start with status = 'pending'. A platform admin must call POST /activate before the tenant becomes publicly accessible.
Auth: authenticateToken.
Request body:
{
"slug": "myshop",
"displayName": "My Shop",
"type": "hosted_seller",
"brand": { "name": "My Shop", "primaryColor": "#1F6FEB" },
"features": { "escrowCheckout": true },
"localeDefaults": ["en"]
}
| Field | Required | Description |
|---|---|---|
slug |
yes | URL-safe identifier [a-z0-9-]{3,40}. Lowercased automatically. |
displayName |
yes | Human-readable shop name. |
type |
no | One of hosted_seller, white_label, isolated, enterprise. Default hosted_seller. |
brand |
no | { name?, logoUrl?, primaryColor?, supportEmail? } |
features |
no | Feature flag overrides. |
localeDefaults |
no | Default ['en']. |
ownerUserId |
no | Admin-only override. |
Response 201: Created tenant record.
Response 409 TENANT_SLUG_TAKEN: Slug already in use.
Side effects on create:
- Auto-grants
ownerrole to the creating user (tenant_user_roles). - Seeds a default
tenant_payment_policiesrow withallowedRails: ['amn_escrow'].
GET /api/tenants — list tenants
Platform admins only.
Auth: authenticateToken + authorizeRoles('admin').
Query params:
| Param | Description |
|---|---|
status |
Filter by tenantStatus enum value. |
type |
Filter by tenantType enum value. |
page |
Page number (default 1). |
limit |
Page size (default 20). |
Response 200: { data: { tenants: Tenant[], total: number } }
GET /api/tenants/:tenantId — get tenant
Auth: authenticateToken + any tenant role.
Response 200: Full tenant record (no secrets).
Response 404 TENANT_NOT_FOUND
GET /api/tenants/:tenantId/bootstrap — authenticated bootstrap
Same payload shape as the storefront route, but requires authentication and a tenant role. Used by the merchant admin dashboard.
Auth: authenticateToken + any tenant role.
PATCH /api/tenants/:tenantId — update tenant
Auth: authenticateToken + tenant role owner.
Request body (all optional):
{
"displayName": "Updated Shop Name",
"brand": { "primaryColor": "#FF6B35" },
"features": { "telegramMiniApp": true },
"localeDefaults": ["en", "fa"]
}
Response 200: Updated tenant record.
POST /api/tenants/:tenantId/suspend — suspend tenant
Platform admins only. Sets status = 'suspended'.
Auth: authenticateToken + authorizeRoles('admin').
POST /api/tenants/:tenantId/activate — activate tenant
Platform admins only. Sets status = 'active'. A new tenant must be activated before it is publicly accessible.
Auth: authenticateToken + authorizeRoles('admin').
POST /api/tenants/:tenantId/domains — add custom domain
Auth: authenticateToken + tenant role owner.
Request body:
{
"hostname": "shop.example.com",
"mode": "cname"
}
| Field | Required | Description |
|---|---|---|
hostname |
yes | Full hostname to register. Must be globally unique. |
mode |
no | cname (default) or managed_ns. |
Response 201: Domain record including verificationToken (the merchant uses this for DNS proof).
Response 400: hostname missing.
Domain starts with status = 'pending'. The tenant admin can trigger verification manually, and the backend domain poller retries pending domains on an interval. DNS can point either directly at the configured server IP or by CNAME to the configured Caddy target.
POST /api/tenants/:tenantId/domains/:domainId/verify — verify DNS and provision route
Checks whether the hostname resolves to the multi-stack ingress. If DNS passes, the backend adds an idempotent Caddy Admin API route for the hostname and marks the domain active with tlsStatus = 'pending'.
Auth: authenticateToken + tenant role owner or developer.
Response 200:
{
"success": true,
"data": { "...": "updated domain" },
"meta": { "dnsVerified": true },
"statusCode": 200
}
If DNS is not ready yet, the response still succeeds with dnsVerified: false and the domain remains pending.
POST /api/tenants/:tenantId/domains/:domainId/tls-check — check TLS status
Probes HTTPS for an active domain and updates tlsStatus to issued, pending, or failed.
Auth: authenticateToken + tenant role owner or developer.
Response 400 DOMAIN_NOT_ACTIVE: Domain must be active before TLS can be checked.
DELETE /api/tenants/:tenantId/domains/:domainId — remove domain
Deprovisions the Caddy route and marks the domain suspended with tlsStatus = 'expired'.
Auth: authenticateToken + tenant role owner.
Response 200: { "data": { "removed": true } }
GET /api/tenants/:tenantId/domains — list domains
Auth: authenticateToken + any tenant role.
Response 200: Array of TenantDomain records.
POST /api/tenants/:tenantId/telegram/bot — register Telegram bot
Stores the encrypted bot token, derives telegramBotId from the token prefix, resolves the username via Telegram getMe when not supplied, registers the tenant webhook when APP_URL or FRONTEND_URL is configured, and attempts to set the bot chat menu button to the tenant Mini App URL.
Auth: authenticateToken + tenant role owner or developer.
Request body:
{
"botToken": "123456789:AAABB...",
"username": "MyShopBot",
"miniAppUrl": "https://myshop.amn.gg"
}
| Field | Required | Description |
|---|---|---|
botToken |
yes | BotFather token. Must use <numeric_id>:<secret> format. Stored AES-256-GCM encrypted — never returned in responses. |
username |
no | Bot @username without @. If omitted, backend calls Telegram getMe and stores the returned username when available. |
miniAppUrl |
no | Tenant storefront base URL. If omitted, backend derives https://<tenantSlug>.<TENANT_BASE_DOMAIN>. The menu button opens <miniAppUrl>/telegram/. |
Response 201: Public bot record (no encrypted token fields and no webhook secret). Pending bots include claimUrl.
[!warning] Token handling
botTokenin the request body is write-only. The API never returns it. Keep it out of logs.
GET /api/tenants/:tenantId/telegram/bot/:botId/claim-link — get bot claim URL
Returns the pending bot's Telegram deep link:
{ "success": true, "data": { "claimUrl": "https://t.me/MyShopBot?start=..." } }
Auth: authenticateToken + tenant role owner or developer.
DELETE /api/tenants/:tenantId/telegram/bot/:botId — remove bot
Physically deletes the bot row after verifying it belongs to the tenant.
Auth: authenticateToken + tenant role owner or developer.
Response 200: { "data": { "removed": true } }
POST /api/telegram/tenant-webhook/:botId — tenant Telegram webhook
Unauthenticated public endpoint mounted before global auth/rate-limit middleware. Telegram must send X-Telegram-Bot-Api-Secret-Token; the route compares it to the stored webhookSecret.
Current handled update:
| Update | Behavior |
|---|---|
/start <claimToken> on a pending bot |
Calls tenantBotService.claimAdmin(), stores adminTelegramUserId, flips bot status to active, and sends a confirmation message to the claimant. |
| Any other valid update | Acknowledged with 200 { ok: true } and ignored for now. |
GET /api/tenants/:tenantId/telegram/bots — list bots
Auth: authenticateToken + any tenant role.
Response 200: Array of public bot records. Secret fields are excluded. Each pending bot may include claimUrl.
| Public bot field | Description |
|---|---|
id |
Internal bot row id. |
tenantId |
Owning tenant id. |
telegramBotId |
Numeric Telegram bot id as text. |
username |
Bot username without @. |
status |
pending, active, suspended, or revoked. |
miniAppUrl |
Stored Mini App base URL, if supplied. |
claimUrl |
Derived Telegram deep link while pending; null after claim. |
adminTelegramUserId |
Telegram user id that claimed admin control, if any. |
[!warning] Bot token handling
encryptedToken,encryptedTokenIv,encryptedTokenTag, andwebhookSecretnever appear in route responses.
PUT /api/tenants/:tenantId/payment-policy — upsert payment policy
Idempotent — creates or replaces the single policy row for the tenant.
Auth: authenticateToken + tenant role owner or finance.
Request body:
{
"allowedRails": ["amn_escrow", "amn_direct"],
"defaultRail": "amn_escrow",
"buyerDisclosureMode": "strict",
"escrowRequiredAboveAmount": "500.000000000000000000",
"escrowRequiredForCategories": ["digital-goods"]
}
defaultRail must be a member of allowedRails — returns 400 VALIDATION_ERROR if not.
Response 200: Policy record.
GET /api/tenants/:tenantId/payment-policy — get payment policy
Auth: authenticateToken + any tenant role.
Response 200: Policy record or null if none exists yet.
POST /api/tenants/:tenantId/roles — grant role
Auth: authenticateToken + tenant role owner.
Request body:
{ "userId": "uuid", "role": "manager" }
Response 201: Role grant record.
DELETE /api/tenants/:tenantId/roles — revoke role
Auth: authenticateToken + tenant role owner.
Request body:
{ "userId": "uuid", "role": "manager" }
Response 200: { "data": { "removed": true } }
Error codes
| Code | HTTP | Meaning |
|---|---|---|
TENANT_SLUG_TAKEN |
409 | Slug already registered. |
TENANT_SLUG_INVALID |
400 | Slug does not match [a-z0-9-]{3,40}. |
TENANT_NOT_FOUND |
404 | No tenant with that id / slug / host. |
PREVIEW_FORBIDDEN |
403 | Slug preview requested from a non-platform host. |
DOMAIN_NOT_FOUND |
404 | Domain id does not exist or is not owned by the tenant. |
DOMAIN_NOT_ACTIVE |
400 | TLS check requested before domain status is active. |
VALIDATION_ERROR |
400 | Missing required field or invalid value (e.g. defaultRail ∉ allowedRails). |
RATE_LIMIT_EXCEEDED |
429 | Storefront rate limiter: 120 req/min per tenant+IP. |
Tenant resolution middleware
tenantResolutionMiddleware (backend/src/shared/middleware/tenantResolution.ts) runs on every storefront route. It attaches req.tenant and req.tenantDomain (when a custom domain matched).
Resolution order:
- Preview — Host is platform base (
amn.gg,localhost) andreq.params.slugorreq.query.tis present →resolveTenantBySlug(slug, { previewOnly: true }). - Subdomain — Host ends with
.amn.gg(single label only, e.g.seller.amn.gg) →resolveTenantByHost→ slug lookup. - Custom domain — Any other host →
resolveTenantByHost→findByHostname.
On no match, req.tenant is undefined and the route handler returns 404.
[!important] Security invariants
- Never reads
X-Tenant-IDor any client-supplied header.- Only resolves preview by slug when the
Hostis the platform base.- Fail-open: resolution errors call
next()without crashing the request.
Related: Tenant, Authentication API, PRD - Seller-Owned White-Label Shops and Bots.