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>
This commit is contained in:
304
10 - Services/tenant.md
Normal file
304
10 - Services/tenant.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# 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 <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:
|
||||
```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 <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:
|
||||
|
||||
```ts
|
||||
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]].
|
||||
Reference in New Issue
Block a user