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:
Siavash Sameni
2026-06-12 11:42:18 +04:00
parent 18073afb52
commit e52ffce48a
18 changed files with 2619 additions and 1102 deletions

304
10 - Services/tenant.md Normal file
View 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]].