# API Design REST поверх HTTP/JSON, авторизация — `Authorization: Bearer ` (кроме публичных эндпоинтов). Ошибки — `application/problem+json`. Пагинация — `?page=&pageSize=`, ответ `PagedList` (`items`, `total`, `page`, `pageSize`) — используется не везде, см. таблицы ниже. Все даты — ISO-8601 UTC. Тела запросов/ответов — camelCase JSON; енумы сериализуются строками (`"Active"`, не `0`). Базовый префикс: `/api` (без версионирования). Схема генерируется нативным `Microsoft.AspNetCore.OpenApi` (`/openapi/v1.json`) и Scalar UI (`/scalar`) — каждый эндпоинт аннотирован `.Produces()`, так что схема полностью описывает и тела запросов, и тела ответов. Ниже — полный контракт, сверенный построчно с кодом (`backend/src/PnvPanel.Api/Endpoints/*.cs`). ## Auth | Метод | Путь | Роль | Тело запроса | Тело ответа | | ----- | --------------------------- | ------ | ------------------------------------------ | -------------------------------------- | | POST | `/api/auth/register` | — | `{ userName, password }` | `{ id, userName }` | | POST | `/api/auth/login` | — | `{ userName, password }` | `{ accessToken, expiresAt, user }` + refresh в httpOnly cookie | | POST | `/api/auth/refresh` | — | — (refresh из cookie) | то же, что login; ротация cookie | | POST | `/api/auth/logout` | user | — | `204 No Content` | | POST | `/api/auth/change-password` | user | `{ currentPassword, newPassword }` | `204 No Content` | | POST | `/api/auth/change-username` | user | `{ newUserName }` | `204 No Content` | | GET | `/api/auth/me` | user | — | `{ id, userName, role, isActivated, telegramLinked }` | | DELETE| `/api/auth/me` | user | — | `204 No Content` | `user`/ответ `/me` — **`role` строкой** (одна роль, не массив). Группа `/api/auth/*` под общим rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 запросов/мин). > **Вход по username.** Email в системе не используется. Забыт пароль: при привязанном > Telegram — вход без пароля через бота и смена пароля в настройках; иначе — сброс админом (см. Admin). ## Auth — Telegram (привязка и passwordless-вход) Группа `/api/auth/telegram/*`, тот же rate-limit, что и `/api/auth/*`. | Метод | Путь | Роль | Тело ответа | | ----- | --------------------------------------------- | ---- | ------------------------------------------------------ | | POST | `/api/auth/telegram/link-token` | user | `{ deepLink, expiresAt }` | | POST | `/api/auth/telegram/unlink` | user | `204 No Content` | | POST | `/api/auth/telegram/login-request` | — | `{ requestId, deepLink, expiresAt }` | | GET | `/api/auth/telegram/login-request/{id}` | — | см. ниже | `deepLink` — `null`, если `Telegram:BotToken` не настроен или Bot API недоступен (username бота панель получает сама через `getMe`, см. [telegram-bot.md](telegram-bot.md#конфигурация)), иначе `https://t.me/?start=link_` / `?start=login_`. **QR backend не рендерит** — фронт строит QR из `deepLink` сам (`qrcode.react`). `GET …/login-request/{id}` (поллинг) → варианты ответа: ```json // ожидание / отклонено / истекло — accessToken/expiresAt/user всегда null, кроме Approved { "status": "Pending", "accessToken": null, "expiresAt": null, "user": null } // подтверждено — выпуск токенов (refresh уходит в httpOnly cookie), запрос помечается Consumed { "status": "Approved", "accessToken": "…", "expiresAt": "…", "user": { "id": "…", "userName": "…", "role": "user", "isActivated": true, "telegramLinked": true } } ``` `status` — одно из `Pending`/`Approved`/`Rejected`/`Expired`/`Consumed`. > Апдейты Telegram (`/start`, кнопки, `/configs`) обрабатывает in-process бот (long polling), а не > HTTP-эндпоинты. Команды бота — в [telegram-bot.md](telegram-bot.md). ## Configs (пользователь) Группа `/api` (не вложена дальше), `RequireAuthorization()`. | Метод | Путь | Тело запроса | Тело ответа | | ------ | --------------------------------- | ------------------------------------ | --------------------------------------- | | GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` | | GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }` — **без пагинации**, весь список сразу | | POST | `/api/configs` | `{ inboundId, label? }` | `VpnConfigDto` (`200 OK`, не 201) | | PATCH | `/api/configs/{id}` | `{ label? }` | `VpnConfigDto` | | POST | `/api/configs/{id}/rotate` | — | `VpnConfigDto` (новый `id` тот же, новый `SubscriptionToken`) | | DELETE | `/api/configs/{id}` | — | `204 No Content` | | GET | `/api/configs/{id}/link` | — | `{ connectionString, subscriptionUrl }` | | GET | `/api/subscription` | — | `{ subscriptionUrl }` | **Нет отдельного `GET /api/configs/{id}`** — детали конфига берутся из списка `GET /api/configs`. `VpnConfigDto`: `{ id, label, protocol, location, usedUpBytes, usedDownBytes, expiresAt, status, createdAt }`. `expiresAt` всегда `null` (лимиты по сроку не реализованы — см. [domain-model.md](domain-model.md)). Ссылка подключения **не приходит вместе с созданием** — фронт запрашивает `GET .../link` отдельно, по кнопке на карточке конфига; QR строится на фронте из `connectionString`. Все `/api/configs/*`, `/api/news`, `/api/apps` без активации → `403` (`Auth.NotActivated`, единая проверка `RequireActivationBehavior`); создание сверх квоты роли → `409` (`Configs.QuotaExceeded`). ## Apps — каталог приложений | Метод | Путь | Роль | Тело запроса | Тело ответа | | ------ | -------------------------- | ----- | --------------------------------------------------------------------------------- | ------------- | | GET | `/api/apps` | user | — | `Record` | | GET | `/api/admin/apps` | admin | — | `AdminAppDto[]` (вкл. выключенные) | | POST | `/api/admin/apps` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isRecommended }` | `AdminAppDto` | | PUT | `/api/admin/apps/{id}` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled, isRecommended }` | `AdminAppDto` | | DELETE | `/api/admin/apps/{id}` | admin | — | `204 No Content` | `IsRecommended` — рекомендованные приложения идут первыми внутри своей группы ОС на `/api/apps` (сортировка `IsRecommended desc, SortOrder asc`, применяется после фильтра `IsEnabled`), помечаются значком-звездой на странице инструкций и в списке приложений в админке. `GET /api/apps` → пример (только `isEnabled == true`, ОС без приложений в ответе отсутствует): ```json { "Android": [ { "id": "…", "name": "v2rayNG", "downloadUrl": "https://…", "description": null, "iconUrl": null } ], "IOS": [ { "id": "…", "name": "Hiddify", "downloadUrl": "https://…", "description": null, "iconUrl": null } ] } ``` Значение `OsPlatform` в C#/JSON — `IOS` (не `iOS`). ## Instructions — страница инструкций Вводный markdown-текст (singleton) над вкладками + дополнительные вкладки, обе части редактируются из админки. Встроенная вкладка «Приложения» (каталог `ClientApp`) в этот API не входит — фронт всегда рисует её первой, сама её достаёт через `GET /api/apps`. | Метод | Путь | Роль | Тело запроса | Тело ответа | | ------ | ------------------------------------- | ----- | ----------------------------------- | ------------- | | GET | `/api/instructions/intro` | user | — | `InstructionIntroDto` | | GET | `/api/instructions/tabs` | user | — | `InstructionTabDto[]` (сортировка `SortOrder asc`) | | PUT | `/api/admin/instructions/intro` | admin | `{ body }` | `InstructionIntroDto` | | POST | `/api/admin/instructions/tabs` | admin | `{ title, body, sortOrder }` | `InstructionTabDto` | | PUT | `/api/admin/instructions/tabs/{id}` | admin | `{ title, body, sortOrder }` | `InstructionTabDto` | | DELETE | `/api/admin/instructions/tabs/{id}` | admin | — | `204 No Content` | `PUT /api/admin/instructions/intro` — get-or-create (строка одна на всю систему; если её ещё нет, создаётся, иначе обновляется на месте). `GET /api/instructions/intro` никогда не 404-ит — если строка ещё не создана, отдаёт `{ id: "00000000-0000-0000-0000-000000000000", body: "", updatedAt: }`, чтобы публичная страница не падала. Вкладки — обычный CRUD без статуса черновик/опубликовано, как у `NewsPostDto`. ## News — лента новостей | Метод | Путь | Роль | Тело запроса | Тело ответа | | ------ | ------------------------ | ----- | ------------------------ | ------------- | | GET | `/api/news` | user | query: `page, pageSize` | `PagedList` | | GET | `/api/admin/news` | admin | query: `page, pageSize` | `PagedList` | | POST | `/api/admin/news` | admin | `{ title, body }` | `NewsPostDto` | | PUT | `/api/admin/news/{id}` | admin | `{ title, body }` | `NewsPostDto` | | DELETE | `/api/admin/news/{id}` | admin | — | `204 No Content` | Нет черновиков/отложенной публикации — `POST` сразу видна всем аутентифицированным пользователям и триггерит SignalR-событие `newsPublished` (см. ниже). `NewsPostDto`: `{ id, title, body, createdAt, updatedAt }`. ## Activation (пользователь) | Метод | Путь | Роль | Тело запроса | Тело ответа | | ----- | --------------------------- | ---- | ----------------- | -------------------------------------------------------- | | GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` | | POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` | ## Support (пользователь) Группа `/api/support`, `RequireAuthorization()` + `IRequiresActivation` (кроме `GET /attachments/{id}`, который тоже требует активации, но не привязан к типу тикета). Создание баг-репорта и добавление комментария — `multipart/form-data` (вложения), остальное — JSON. | Метод | Путь | Тело запроса | Тело ответа | | ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- | | GET | `/api/support/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке | | GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList` (только свои) | | GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) | | POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` | | POST | `/api/support/tickets/role-requests` | `{ existingRoleId? \| (newRoleName, newRoleMaxConfigs, newRoleMaxIpLimit), justification }` | `TicketDetailDto` | | POST | `/api/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` | | POST | `/api/support/tickets/{id}/reopen` | — | `204 No Content` (только владелец, только из `Resolved`) | | GET | `/api/support/attachments/{id}` | — | бинарный поток с `Content-Type` вложения | `TicketSummaryDto`: `{ id, userId, userName, type, status, createdAt, lastActivityAt }` — один DTO для своего и админского списков. `TicketDetailDto` добавляет `requestedRoleId, requestedRoleName, proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, comments: TicketCommentDto[]`. `TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }`. Ровно одна из двух заявок на роль: либо `existingRoleId` (роль `admin` запрещена — `403 Support.CannotRequestAdminRole`), либо все три поля новой роли. Заявка при существующем открытом запросе на роль → `409 Support.RoleRequestAlreadyPending`. `POST …/comments` на `Closed`-тикете → `409 Support.TicketClosed`. Вложения отдаются не статикой — `` не может передать `Authorization`-заголовок, фронт качает их как `Blob` через `fetch` и рендерит `Object URL`. ## Admin — Support Группа `/api/admin/support`, `RequireAuthorization(RoleNames.Admin)` (активация не проверяется — сеяный админ активирован всегда). | Метод | Путь | Тело запроса | Тело ответа | | ----- | ------------------------------------------------ | ----------------------------------- | ------------- | | GET | `/api/admin/support/tickets` | query: `type?, status?, page, pageSize` | `PagedList` (все пользователи) | | GET | `/api/admin/support/tickets/{id}` | — | `TicketDetailDto` | | POST | `/api/admin/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` | | POST | `/api/admin/support/tickets/{id}/resolve` | — | `204 No Content` (любой тип, только из `Open`) | | POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (любой тип, финал) | | POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль) | | POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) | Обработать **собственный** тикет админу можно (в т.ч. одобрить свою же заявку на роль) — resolve/close/ reject/approve владением тикета не ограничены. Единственное реальное ограничение — `approve` вернёт `409 Roles.CannotRemoveLastAdmin` через `ChangeUserRoleAsync`, если заявка (своя или чужая) снимает `admin` с последнего администратора в системе. `approve`/`reject` — единственный способ решить заявку на роль (нельзя одобрить через `resolve`). При одобрении: если заявка на существующую роль — сразу `ChangeUserRoleCommand`-эквивалент; если на новую — сперва создаётся `AppRole` (`IRoleService.CreateRoleAsync`), затем назначается. То же самое администратор может сделать **из Telegram, не заходя на сайт** — инлайн-кнопки на уведомлении о заявке (см. [telegram-bot.md](telegram-bot.md)); для баг-репортов в Telegram только кнопка-ссылка на `/admin/support?ticket={id}` — переписка и вложения только на сайте (отдельного роута на конкретный тикет нет, `?ticket=` открывает диалог поверх списка). ## Admin — Maintenance Группа `/api/admin/maintenance`, `RequireAuthorization(RoleNames.Admin)`. Вкладка «Обслуживание» — разовые операции подчистки, задумана расширяемой (следующие кандидаты: очистка старых новостей и т.п.). | Метод | Путь | Тело запроса | Тело ответа | | ------ | ----------------------------------------- | -------------- | ------------- | | DELETE | `/api/admin/maintenance/tickets/closed` | — | `{ deletedCount }` — удаляет все тикеты в статусе `Closed` вместе с комментариями и вложениями (файлы стираются с диска через `IFileStorage.DeleteAsync`) | | DELETE | `/api/admin/maintenance/audit-logs` | query: `olderThanDays` (1–3650) | `{ deletedCount }` — удаляет записи `AuditLog` старше `olderThanDays` дней | | DELETE | `/api/admin/maintenance/apps/disabled` | — | `{ deletedCount }` — удаляет все `ClientApp` с `IsEnabled = false` | | DELETE | `/api/admin/maintenance/factory-reset` | — | `204 No Content` — полный сброс панели к состоянию свежего деплоя | Тикет/комментарий/вложение — плоские сущности без FK-каскада (см. `SupportTicket`), поэтому хендлер удаляет вручную в порядке вложения → комментарии → тикеты. Очистка аудита пишет собственную запись `AuditLogsCleanedUp` уже **после** выборки старых записей — её `CreatedAt` позже порога, поэтому она не удаляет сама себя. Полного удаления всего журнала нет осознанно — `AuditLog` в проекте append-only, доступна только очистка по возрасту. **`factory-reset`** — самая деструктивная операция панели, на фронте спрятана под спойлер («Опасная зона») и требует ввести фразу-подтверждение в диалоге (не просто `confirm()`). Удаляет: всех пользователей кроме текущего админа, все `VpnConfig`/`TrafficSample` (конфиги сначала best-effort отзываются на нодах через `IXuiPanelGateway.RemoveClientAsync` — недоступная нода не блокирует сброс), все `Node`/`Inbound`, тикеты с перепиской/вложениями (+файлы), `NewsPost`, `InstructionIntro`/ `InstructionTab`, весь `AuditLog`, все кастомные роли (`AppRole.IsSystem == false`) и `ClientApp` — каталог приложений и вводный текст инструкций затем пересеиваются дефолтными значениями через `IClientAppCatalogSeeder`/`IInstructionIntroSeeder` (те же сервисы, что использует `DbInitializer` при первом старте); вкладки инструкций дефолтами не пересеиваются, как и новости. Не атомарно целиком (несколько `SaveChangesAsync` внутри хендлера, как и в `DeleteUserCommandHandler`) — при сбое посередине возможно частичное состояние, компенсации нет, это осознанный компромисс для редкой ручной админской операции. Финальная запись `FactoryReset` в аудит добавляется уже после очистки самого журнала. ## Admin — Activation, Roles | Метод | Путь | Роль | Тело запроса | Тело ответа | | ------ | ----------------------------------------------- | ----- | ----------------------- | ------------- | | GET | `/api/admin/activation-requests` | admin | query: `statusFilter?, page=1, pageSize=20` | `PagedList` | | POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` | | POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` | | GET | `/api/admin/roles` | admin | — | `RoleDto[]` | | POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit }` | `RoleDto` | | PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` | | DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) | | PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) | | GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц**, одна на весь сервис — не per-роль) | | PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear? }` | `PricingSettingsDto` (`400`, если итог более длинного тарифа дешевле итога более короткого) | Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через approve/reject над `ActivationRequest`. ## Admin — Nodes | Метод | Путь | Роль | Тело запроса | Тело ответа | | ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- | | GET | `/api/admin/nodes` | admin | — | `NodeDto[]` | | POST | `/api/admin/nodes` | admin | `{ name, baseAddress, username, password, location? }` | `NodeDto` | | PUT | `/api/admin/nodes/{id}` | admin | `{ name, location?, isEnabled, username?, password? }` | `NodeDto` | | DELETE | `/api/admin/nodes/{id}` | admin | — | `204 No Content` | | POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` | | POST | `/api/admin/nodes/{id}/probe` | admin | — | `{ isReachable, errorMessage, status }` | `DELETE /api/admin/nodes/{id}` удаляет её инбаунды каскадно **без проверки существующих конфигов** на них — известный пробел (см. [tech-stack.md](tech-stack.md)), а не осознанная защита. `username`/`password` в `PUT` — оба опциональны; креденшлы меняются, только если заданы **оба**. ## Admin — Inbounds | Метод | Путь | Роль | Тело запроса | Тело ответа | | ----- | -------------------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- | | GET | `/api/admin/inbounds` | admin | query: `nodeId?` | `InboundDto[]` | | PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds? }` | `InboundDto` | ## Admin — Users & Stats | Метод | Путь | Роль | Тело запроса | Тело ответа | | ------ | ---------------------------------------- | ----- | --------------------------- | ------------- | | GET | `/api/admin/users` | admin | query: `page, pageSize, search?` | `PagedList` | | PATCH | `/api/admin/users/{id}/block` | admin | — | `204 No Content` | | PATCH | `/api/admin/users/{id}/unblock` | admin | — | `204 No Content` | | POST | `/api/admin/users/{id}/reset-password` | admin | `{ newPassword }` | `204 No Content` | | DELETE | `/api/admin/users/{id}` | admin | — | `204 No Content` (отзывает все конфиги пользователя в 3x-ui, затем удаляет учётку; себя удалить нельзя) | | GET | `/api/admin/users/{id}/configs` | admin | — | `VpnConfigDto[]` | | GET | `/api/admin/configs` | admin | query: `page, pageSize, search?, status?` | `PagedList` | | DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) | | GET | `/api/admin/stats` | admin | — | `StatsDto` | | GET | `/api/admin/audit` | admin | query: `page, pageSize` | `PagedList` | `AdminVpnConfigDto` — глобальный список конфигов для админа (не скоупится одним пользователем, в отличие от `VpnConfigDto`): `{ id, userId, userName, label, clientEmail, protocol, location, nodeName, usedUpBytes, usedDownBytes, expiresAt, status, createdAt }`. `search` матчится по `clientEmail`/`label`. **Блокировка/разблокировка — два отдельных эндпоинта без тела**, не один переключатель `isBlocked`. `StatsDto`: `{ totalUsers, activatedUsers, pendingActivationRequests, totalNodes, onlineNodes, totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — считается на лету при запросе, не кэшируется. ## Public — Subscription | Метод | Путь | Роль | Ответ | | ----- | ----------------- | ---- | ---------------------------------------------------------------- | | GET | `/sub/{token}` | — | `text/plain`, base64 от списка connection strings, `\n`-разделены | Вне `/api` (публичный эндпоинт для VPN-клиентов), под тем же rate-limit'ом, что и `/api/auth/*`. Токен — либо `AppUser.SubscriptionToken` (**все активные конфиги юзера**), либо `VpnConfig.SubscriptionToken` (**один конфиг**); пробуются по очереди, первый успешный — в ответе. Неизвестный/погашенный токен → `404`. Заголовки ответа: `Subscription-Userinfo: upload=; download=; total=; expire=` и `Profile-Update-Interval: 12` — их читают клиенты (v2rayN/Nekoray и т.п.), чтобы показать остаток. ## SignalR — Hub `/hubs/panel` Авторизация — тем же JWT. Группы: `user:{userId}` (личные события), `admins` (админам). ### Server → Client | Событие | Payload | Кому | | ---------------------- | ------------------------------------------------------------- | ------------ | | `configTrafficUpdated` | `{ configId, usedUpBytes, usedDownBytes }` | владельцу | | `configStatusChanged` | `{ configId, status }` | владельцу | | `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` | | `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` | | `userActivated` | `{ userId }` | владельцу | | `newsPublished` | `{ id, title, createdAt }` | все (broadcast) | | `ticketCreated` | `{ ticketId, userId, userName, type }` | `admins` | | `ticketUpdated` | `{ ticketId }` | владельцу | ### Client → Server Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по `UserId` из JWT (плюс `admins`, если роль админская). ## Коды ошибок | Код | Когда | | --- | -------------------------------------------------------------------- | | 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) | | 401 | Нет/просрочен/невалиден access-токен | | 403 | Нет прав по роли, либо `Auth.NotActivated` | | 404 | Ресурс не найден | | 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed` | | 422 | Прочие управляемые ошибки, не подошедшие под коды выше | | 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) | | 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) | `502`/недоступность 3x-ui наружу не пробрасывается — ошибка гейтвея становится `Result.Failure` и маппится в один из кодов выше (обычно 422), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.