Files
nick-doc/03 - API Reference/Tenant API.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

460 lines
15 KiB
Markdown

---
title: Tenant API
tags: [api, tenant, white-label, storefront, reference]
aliases: [White-Label API, Storefront API, Merchant API]
---
# Tenant API
> **Last updated:** 2026-06-10 — current `feature/white-label-shops` scan.
> 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:**
```json
{
"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 `TenantProvider` treats 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:**
```json
{
"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:
1. Auto-grants `owner` role to the creating user (`tenant_user_roles`).
2. Seeds a default `tenant_payment_policies` row with `allowedRails: ['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):
```json
{
"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:**
```json
{
"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:**
```json
{
"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:**
```json
{
"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
> `botToken` in 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:
```json
{ "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`, and `webhookSecret` never 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:**
```json
{
"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:**
```json
{ "userId": "uuid", "role": "manager" }
```
**Response 201:** Role grant record.
---
### `DELETE /api/tenants/:tenantId/roles` — revoke role
**Auth:** `authenticateToken` + tenant role `owner`.
**Request body:**
```json
{ "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:
1. **Preview** — Host is platform base (`amn.gg`, `localhost`) **and** `req.params.slug` or `req.query.t` is present → `resolveTenantBySlug(slug, { previewOnly: true })`.
2. **Subdomain** — Host ends with `.amn.gg` (single label only, e.g. `seller.amn.gg`) → `resolveTenantByHost` → slug lookup.
3. **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-ID` or any client-supplied header.
> - Only resolves preview by slug when the `Host` is 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]].