> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Веб-панель клиента

> Всё, что клиент может сделать из браузера: вход, подписки, оформление заказа, магазин, поддержка, рефералы и собственные данные.

Веб-панель клиента — это браузерная половина того продукта, который бот Telegram
несёт начиная с v0.2. Это не второй продукт: она читает те же записи клиентов, те
же права на услуги и то же состояние Remnawave, и клиент, вошедший в браузере
через Telegram, попадает ровно в тот аккаунт, которым он пользовался в боте.

Её обслуживает то же приложение Next.js, что и панель оператора, по пути
`/account`, а за ней стоит третий префикс API — `/v1/account`. Три префикса
никогда не делят промежуточные обработчики: `/v1/panel` принимает сессию
оператора, `/v1/admin` — bearer-токен, `/v1/account` — сессию клиента, потому что
клиентская сессия ни при каких условиях не должна дотянуться до операторского
маршрута, а самый дешёвый способ это гарантировать — никогда не делить между ними
маршрутизатор.

## Что она охватывает

> **Вход**
>
> Telegram, необязательная ссылка от бота и любое количество провайдеров OIDC.

> **Дашборд**
>
> Статус, дата окончания, остаток дней, трафик, устройства и активный тариф.

> **[Подключение](https://omniflow-vpn.mintlify.site/ru/customer/connection.md)**
>
> Инструкции по платформам, deep-ссылки приложений, QR-код и сама ссылка.

> **Безопасность аккаунта**
>
> Активные сессии, собственная история аккаунта и привязанные способы входа.

> **Тарифы и оформление заказа**
>
> Сравнение, пробный период, покупка, продление, повышение и понижение тарифа,
> промокоды и разбивка по кошельку.

> **Кошелёк**
>
> Баланс по каждой валюте, книга проводок и пополнение через любого включённого
> провайдера.

> **Цифровые товары**
>
> Telegram Premium и Stars, с шагом проверки получателя и прогрессом доставки.

> **Поддержка и новости**
>
> Обращения, вложения, объявления и настройки уведомлений.

> **Рефералы и лояльность**
>
> Код, ссылка, число приглашённых и подтверждённых, вознаграждения и прогресс по
> уровням.

> **Ваши собственные данные**
>
> Выгрузка персональных данных и запрос на удаление аккаунта.

Каждый из этих пунктов — та же запись, которую читает и пишет бот. Оформление
заказа, начатое в чате, — это то же оформление, которое показывает браузер;
ответ поддержки, прочитанный в вебе, перестаёт быть непрочитанным в Telegram. Это
один продукт с двумя поверхностями.

## Как устроена компоновка панели

Панель сделана сначала для телефона и становится приложением на десктопе, а не
десктопной раскладкой, схлопывающейся на телефоне. Это не лозунг: клиентский
продукт открывают с телефона, часто прямо изнутри Telegram.

На узком экране это одна колонка с пятью вкладками в плавающей панели, выбранными
по частоте потребности, а не по делению API — магазин тарифов и магазин цифровых
товаров делят одну вкладку, потому что со стороны клиента это одно и то же дело.

От `md` и шире плавающая панель уступает место боковой. Она несёт те же пять
разделов плюс те, что телефон держит уровнем ниже, под вкладкой аккаунта:
заказы, устройства, приглашения, объявления и безопасность — потому что
ограничение, оправдывавшее их сокрытие, состоит в том, что пять — это всё, что
вмещает панель под большой палец, а у колонки с запасом места такого ограничения
нет. Там же лежит идентификатор аккаунта, который спрашивает поддержка, и кнопка
выхода. Тот, кто открывает именно *веб*-кабинет, а не бота, по определению не за
телефоном.

| Вкладка | Маршрут | Что содержит |
| - | - | - |
| Подписки | `/account` | Дашборд: по одной карточке на подписку |
| Магазин | `/account/store` | Тарифы, баннер возобновления оформления заказа, история заказов, магазин товаров |
| Кошелёк | `/account/wallet` | Балансы, пополнение, книга проводок |
| Поддержка | `/account/support` | Обращения, новости, настройки уведомлений |
| Аккаунт | `/account/profile` | Тема, язык и хаб |

Всё, что не входит в эти пять, — это маршрут-деталь, и вместо вкладки он получает
элемент управления **Назад**. Пять вкладок не могут одновременно нести устройства, сессии,
приглашения, настройки уведомлений и персональные данные, поэтому всё это живёт в
хабе на `/account/profile`:

| Пункт хаба | Маршрут |
| - | - |
| Устройства | `/account/devices` |
| Сессии | `/account/security` |
| Приглашения | `/account/referrals` |
| Настройки уведомлений | `/account/preferences` |
| Персональные данные | `/account/privacy` |

Новости доступны из вкладки поддержки, а у каждой истории свой экран:
`/account/orders` для покупок тарифов и `/account/shop/orders` для цифровых
товаров.

Вся панель отдаёт `robots: noindex, nofollow`, а каждый ответ `/v1/account` несёт
`Cache-Control: no-store`, `Referrer-Policy: no-referrer`,
`Cross-Origin-Opener-Policy: same-origin`, `X-Content-Type-Options: nosniff` и
политику безопасности контента `default-src 'none'` — потому что клиентский ответ
может содержать ссылку на подписку, а это учётные данные.

## Как её включить

Панель включена по умолчанию и требует `APP_DATA_ENCRYPTION_KEY`, которым
запечатываются клиентские секреты OIDC, короткоживущая cookie процесса входа,
сохранённые контактные адреса и учётные данные шлюзов товаров.

| Переменная | По умолчанию | Назначение |
| - | - | - |
| `APP_CUSTOMER_PANEL_ENABLED` | `true` | Монтирует `/v1/account`. Установка, работающая только через Telegram, может её выключить. |
| `APP_CUSTOMER_COOKIE_SECURE` | `true` | Помечает cookie сессии флагом `Secure`. Выключайте только для локального стенда на чистом HTTP; API пишет предупреждение при старте, когда флаг выключен. |
| `APP_CUSTOMER_MAGIC_LINK_ENABLED` | `false` | Предлагает вход по ссылке, доставляемой ботом. Требует `APP_PUBLIC_URL`, иначе API откажется стартовать. |
| `APP_SUPPORT_ATTACHMENT_DIR` | `/var/lib/omniflow/support-attachments` | Где хранится файл, загруженный через веб-панель. Смонтируйте туда том. |

Оформление заказа читает `APP_TERMS_URL`, `APP_DEFAULT_CURRENCY`,
`APP_RECOVERY_WINDOW_HOURS`, `APP_TRIAL_MINIMUM_ACCOUNT_AGE_HOURS` и
`APP_MULTI_SUBSCRIPTION_ENABLED` — те же переменные, что читает бот, так что один
`.env` настраивает оба процесса одинаково. Пробный период, в котором чат
отказывает, а браузер выдаёт, был бы средством защиты от злоупотреблений с дырой.
Полный список — в [Конфигурации](https://omniflow-vpn.mintlify.site/ru/getting-started/configuration.md).

Сервис, который не подключён, оставляет свои маршруты не смонтированными, а не
отвечает 503 на каждый, поэтому установка без настроенной коммерции показывает
панель, в которой просто нет оформления заказа, а не такую, где каждый элемент
управления покупкой ломается при нажатии. По той же причине не смонтированная
поверхность отвечает `404`, а не `503`: `503` сказал бы «это существует и
сломано», что для установки, которая просто не продаёт цифровые товары, — ложь, и
вдобавок сообщил бы анонимному вызывающему, что настроил оператор.

> [!NOTE]
>
> Обращение, открытое в Telegram, хранит только ссылку на файл Telegram, потому
> что байты остаются у Telegram. У загрузки из браузера такого хранителя нет,
> поэтому её держит установка — вот почему панели нужен каталог с правом записи, а
> боту нет.

> [!WARNING]
>
> Оставленный выключенным `APP_CUSTOMER_COOKIE_SECURE` в продакшене означает, что
> cookie сессии можно перехватить из любого запроса по чистому HTTP к тому же
> хосту.

## Вход

`/account/sign-in` отрисовывается по данным `GET /v1/account/auth/methods`, а не
по знаниям, зашитым на этапе сборки, поэтому установка без токена бота не
показывает кнопку Telegram, которая всё равно не сработает, а установка с тремя
провайдерами OIDC показывает три. Признак Telegram выводится из того, разрешается
ли имя пользователя бота, потому что виджет загружается по имени пользователя:
токен без разрешимого имени пользователя не может его отрисовать. Если нет ни
Telegram, ни одного провайдера OIDC, экран сообщает: «Ни один способ входа ещё не
настроен. Обратитесь в поддержку».

Поля пароля нет, и никакая настройка его не добавляет. Это решение, а не
упущение, — с названной ценой и названным условием пересмотра:
[0007 — Клиенты не входят по паролю](https://omniflow-vpn.mintlify.site/ru/architecture/decision-0007-no-customer-password.md).

### Telegram

Основной путь. Виджет входа Telegram подписывает свои поля по HMAC-SHA256 с
токеном бота, поэтому payload, прошедший проверку, — доказательство того, что его
произвёл Telegram именно для этого бота. Omniflow дополнительно ограничивает
`auth_date` одной минутой: сам по себе хэш не истекает никогда, и без этой границы
payload, снятый из URL или со скриншота, аутентифицировал бы бесконечно.

`POST /v1/account/auth/telegram` принимает документ ровно в том виде, в каком его
отдаёт колбэк `data-onauth` виджета — `id` и `auth_date` приходят числами JSON,
остальные поля строками, — и проверяет подпись по цифрам как есть. Страница
отправляет объект без изменений; клиент, который сначала его переформатирует,
ничего не выигрывает и рискует сломать подпись.

Виджет загружается по имени пользователя бота, которое Omniflow разрешает через
Bot API при первом использовании. Успешно разрешённое имя хранится на время жизни
процесса; неудачный запрос запоминается на тридцать секунд и затем повторяется,
поэтому Bot API, недоступный в момент прихода первого клиента, не прячет кнопку до
следующего перезапуска. Telegram отрисовывает виджет только на домене, привязанном
к боту, поэтому задайте этот домен командой `/setdomain` в BotFather, прежде чем
включать вход; пока имя пользователя разрешить не удаётся, кнопка просто не
отрисовывается.

Открытая как Telegram Mini App, панель входит вообще без кнопки: окружающий клиент
передаёт странице подписанный `initData`, который проверяется тем же способом при
другом выводе ключа. Попытка делается один раз; если в ней отказано, экран так и
говорит и предлагает обычные способы, а не повторяет тот же `initData`, пока
клиент не закроет приложение. Субъект Telegram, у которого нет строки
идентичности, провижинит нового клиента — в отличие от правила OIDC ниже, потому
что субъект Telegram не является адресом, который может заявить кто-то другой, так
что здесь нет аккаунта, который можно было бы захватить.

Проверенный аккаунт Telegram сопоставляется с клиентом под той же advisory-блокировкой,
которую бот берёт на `/start`, и в том же порядке, что у бота, поэтому первое нажатие
в чате и первый вход в браузере не могут наперегонки создать два аккаунта:

1. Активная строка идентичности называет клиента.
2. Иначе — отображение Remnawave: установка, импортированная из раннего релиза, несёт
   Telegram ID в `remnawave_users` ещё до появления строки идентичности, и клиент,
   ни разу не нажимавший `/start`, усыновляется, а не получает второй пустой аккаунт.
   Идентичность привязывается, отображение дозаполняется — ровно так, как это делает
   бот.
3. Иначе — идентичность, которую клиент **отвязал** на экране безопасности, называет
   клиента, которому принадлежала, и реактивируется. В прежних релизах такой аккаунт
   навсегда становился недостижимым из Telegram.

   > [!WARNING]
   >
   > Поэтому отвязка Telegram в разделе **Способы входа** не закрывает доступ
   > аккаунту Telegram. Следующий вход через тот же аккаунт Telegram — виджет или
   > Mini App — молча привязывает его заново и сразу авторизует. Отвязка убирает
   > способ из списка, но не отзывает притязание аккаунта Telegram на клиента,
   > потому что именно по нему бот знает этого клиента. Чтобы аккаунт Telegram
   > перестал попадать в запись клиента, оператор приостанавливает клиента.
4. Иначе создаётся новый клиент — со строкой `bot_preferences` и языком, взятым из
   `language_code` Mini App, если он есть, иначе из `Accept-Language` браузера, иначе
   английским, — записанным и в `users.locale`, и в `bot_preferences.locale`, чтобы бот
   приветствовал клиента, зарегистрировавшегося по-русски, по-русски.

Когда шаги 2 и 3 расходятся — отображение называет одного клиента, а отозванная
идентичность другого, — ни один не выбирается молча. Перенос идентичности переместил
бы аккаунт Telegram между заказами и кошельками двух людей, поэтому вход отклоняется с
`identity_taken`, а экран предлагает обратиться в поддержку.

Любой другой неоперационный сбой сводится к фиксированному набору ответов, потому что
установка не должна становиться оракулом того, какие аккаунты Telegram ей
известны:

| Условие | Статус | Код проблемы |
| - | - | - |
| Способ входа через Telegram не настроен | 404 | `method_unavailable` |
| Аккаунт приостановлен или закрыт | 403 | `account_unavailable` |
| Payload не прошёл проверку либо в регистрации отказано | 401 | `sign_in_rejected` |
| На аккаунт Telegram претендуют две записи клиента | 409 | `identity_taken` |
| Всё остальное | 500 | `sign_in_unavailable` |

### Ссылка для входа

Включаемый оператором запасной вариант для установок, где виджет использовать
нельзя. Клиент отправляет боту `/login` и получает одноразовую ссылку, действующую
десять минут и сгорающую при использовании, с ограничением в пять ссылок в час на
аккаунт. Это ограничение защищает клиента, а не сервер: без него повторные запросы
ссылки — способ завалить чужой чат Telegram приглашениями войти.

Ссылка запрашивается у бота, а не через веб-форму, и это сделано намеренно. Форме
пришлось бы принимать какой-то идентификатор и отвечать, знает ли его Omniflow, а
любая версия такого ответа сообщает постороннему, является ли кто-то здесь
клиентом. Старт внутри чата, в котором клиент уже аутентифицирован, снимает этот
вопрос и гарантирует, что ссылка попадёт только к тому, кто уже контролирует
аккаунт.

Погашение проходит в два шага. Ссылка приводит на
`GET /v1/account/auth/link?token=…`, который **никого не авторизует**: он проверяет
токен, не расходуя его, и перенаправляет на `/account/sign-in/confirm#token=…`. Эта
страница просит клиента продолжить и отправляет `POST /v1/account/auth/link` с
`{"token": "…"}`, который в одной транзакции расходует токен и открывает сессию и
отвечает как любой другой вход.

Разделение нужно потому, что GET может инициировать любая страница. Если бы сессию
открывал GET, кто-то мог бы запросить у бота ссылку на **свой** аккаунт, подложить
её в браузер жертвы и оставить жертву авторизованной в аккаунте, которым управляет
атакующий, — и всё, что жертва затем введёт, достанется ему (login CSRF). POST, как
и любой открывающий сессию POST под `/v1/account/auth`, требует
`Content-Type: application/json` и отклоняет запрос, который браузер помечает как
межсайтовый (`Sec-Fetch-Site`, отличный от `same-origin`, или чужой `Origin`), с
`403 cross_origin_request`; отправка формы получает `415 unsupported_media_type`.
Токен попадает на страницу подтверждения во фрагменте URL, который браузер никогда
не отправляет на сервер и не кладёт в `Referer`.

Неизвестный, израсходованный и истёкший — намеренно одна и та же ошибка: сообщение
о том, какой из случаев сработал, подтвердило бы, что токен когда-то существовал.
GET перенаправляет сбои на `/account/sign-in?error=…` с `link_invalid` или
`method_unavailable`; POST отвечает `401 link_invalid`, `404 method_unavailable` или
обычными проблемами входа (`account_unavailable`, `sign_in_unavailable`).

> [!NOTE]
>
> Ссылка, которую доставляет бот, указывает на
> `<APP_PUBLIC_URL>/v1/account/auth/link?token=…` — обработчик, который проверяет
> токен и передаёт его странице подтверждения. В прежних выпусках она указывала на `/account/sign-in/link`,
> маршрута по этому пути веб-приложение не обслуживало, и ссылка отвечала 404 уже
> после того, как одноразовый токен был доставлен. Теперь веб-приложение
> обслуживает и `/account/sign-in/link`, перенаправляя на API, поэтому ссылка,
> лежащая в истории чата с прежнего выпуска, работает обещанные десять минут.

> [!NOTE]
>
> Ссылка десять минут является bearer-учётными данными. Бот говорит об этом в
> сообщении и отправляет его с запретом на пересылку.

### OIDC

Одновременно может быть включено любое количество провайдеров, каждый — обычный
документ discovery с выбранными оператором подписью, иконкой и порядком.
Используется поток Authorization Code с PKCE, антифорджери-параметром `state` и
`nonce`, который сравнивается за постоянное время со значением, выпущенным
потоком. Nonce не дублирует PKCE: PKCE привязывает код авторизации к этому потоку,
а nonce привязывает к нему полученный ID-токен.

Состояние потока живёт в короткоживущей запечатанной cookie
(`__Host-omniflow_account_oidc`, `SameSite=Lax`), а не в базе данных, поэтому
брошенный вход не оставляет ничего, что нужно чистить. Cookie очищается *до*
обмена токена, поэтому повторно проигранный callback не может быть обработан
дважды.

Пресеты для Google, Yandex и Discord предзаполняют форму и создают обычные записи;
ничто дальше по цепочке не ветвится по тому, от какого провайдера пришла запись.
Клиентские провайдеры по умолчанию имеют `allow_auto_provision = true`, в отличие
от провайдеров панели оператора, потому что клиентская панель, отказывающая
каждому, кто ещё не пользовался ботом, — это не способ входа, а второй замок.

> [!WARNING]
>
> Yandex не всегда возвращает `email_verified`. Поэтому его пресет оставляет
> требование подтверждённого адреса выключенным — если включить его для этого
> провайдера, будет отказано в каждом входе через него.

Соответствие внешней идентичности клиенту регулируют два правила:

* **Субъект, которого никто не привязывал, никогда не присваивает существующий
  аккаунт по адресу электронной почты.** Сопоставление по адресу позволило бы
  любому, кто способен заставить провайдера заявить чужой адрес, захватить чужой
  аккаунт. Непривязанный субъект либо провижинит нового клиента, если провайдер
  это разрешает, либо получает отказ.
* **Привязка происходит изнутри сессии.** Поток привязки начинается на
  `GET /v1/account/sign-in-methods/oidc/{slug}/start`, и целевой аккаунт
  запечатывается в cookie потока, а не читается из сессии на callback, поэтому
  поток привязки невозможно завершить против другого аккаунта, чем тот, который
  его начал. Если субъект уже принадлежит другому клиенту, попытка отклоняется как
  `identity_taken`, и никогда не выполняется слияние: объединение двух клиентов
  перенесло бы заказы, кошелёк и подписку одного человека в аккаунт другого без
  пути назад, и отказ сообщает, что идентичность занята, но никогда — на чьём она
  аккаунте.

> [!NOTE]
>
> В поставляемом виде ни один экран веб-панели не запускает этот поток привязки.
> `/account/security` перечисляет привязанные способы и предлагает только отвязку,
> поэтому добавление второго провайдера к существующему аккаунту — возможность
> уровня API, а не кнопка.

Поскольку ключом является субъект, а адрес — лишь утверждение, смена клиентом
своей почты на стороне провайдера не является событием и не требует пути
восстановления. Отключение или удаление клиентского провайдера OIDC отзывает
каждую выпущенную им сессию (`revoked_reason = 'provider_disabled'`), потому что
провайдер, который выключен, пока открытые им двери остаются открытыми, не
выключен.

## Сессии

Сессии живут в cookie, с флагами `HttpOnly` и `SameSite=Lax`. Cookie называется
`__Host-omniflow_account`, когда несёт `Secure`: префикс `__Host-` привязывает её
к этому конкретному источнику, поэтому браузер откажется принять её с атрибутом
`Domain`, а соседний поддомен не сможет её перезаписать.

Префикс требует `Secure`, и cookie с префиксом `__Host-` без него браузер
отбрасывает, а не принимает как более слабую, — поэтому при
`APP_CUSTOMER_COOKIE_SECURE=false`, то есть на локальном стеке по чистому HTTP,
такое имя вовсе не давало бы войти. Имя следует за атрибутом:
`omniflow_account` без `Secure` и `__Host-omniflow_account` с ним. В любом случае
оно отличается от cookie панели оператора, чтобы обе панели можно было держать
открытыми в одном браузере и ни одна не вытесняла другую. Хранится только
SHA-256 токена, поэтому чтение базы данных никогда не даёт пригодную cookie.

`Lax`, а не `Strict`, выбран намеренно: и ссылка для входа, и callback OIDC
приходят как межсайтовые переходы и иначе приводили бы к экрану без сессии.
Небезопасные методы защищает CSRF-токен, а не политика cookie.

| Свойство | Панель клиента | Панель оператора |
| - | - | - |
| Тайм-аут бездействия | 14 дней | 30 минут |
| Абсолютный срок жизни | 60 дней | 12 часов |
| Ротация токена | каждые 24 часа | каждые 15 минут |
| Окно повторной аутентификации | 15 минут | — |

Клиентские значения намеренно щедрее. Повторный вход стоит оператору нескольких
секунд рабочего дня; клиенту, который смотрит, сколько дней осталось, такое трение
не оправдано, а панель, выкидывающая его за время обеда, просто возвращает его в
бота. Работу по безопасности вместо этого делают абсолютный горизонт и ротация
вместе с повторной аутентификацией на тех немногих действиях, где она оправдана.
Сроки жизни ни одной из панелей не настраиваются — это вкомпилированные значения
по умолчанию.

Ротация рассчитана на то, как браузер на самом деле загружает страницу: несколько
запросов одновременно, и все несут ту cookie, которую вкладка держала в момент
открытия. Когда такая пачка попадает на границу ротации, токен меняет ровно один
запрос — замена выполняется как compare-and-set по дайджесту, с которым он
пришёл, так что второй запрос не может её перезаписать, — а вытесненный токен ещё
**одну минуту** продолжает разрешаться через пересылающую запись в Valkey, которая
называет сессию и несёт замену, запечатанную ключом данных установки. Запрос,
пришедший со старой cookie внутри этой минуты, обслуживается как обычно и уходит с
новой cookie; только по истечении минуты старый токен становится мёртвым, и только
у мёртвого токена cookie стирается. Если в момент плановой ротации Valkey
недоступен, ротация пропускается и повторяется на одном из следующих запросов, а не
выполняется без пересылающей записи: сессия, сменившая токен на день позже, —
меньшая беда, чем клиент, которого разлогинило без видимой причины.

**Повторная аутентификация** применяется к четырём действиям, которыми украденная,
но ещё живая cookie иначе могла бы заблокировать настоящего владельца или сделать
что-то необратимое:

| Действие | Маршрут |
| - | - |
| Удалить способ входа | `DELETE /v1/account/sign-in-methods/{identityID}` |
| Сменить ссылку на подписку | `POST /v1/account/subscriptions/{id}/rotate-link` |
| Отключить все устройства | `DELETE /v1/account/subscriptions/{id}/devices` |
| Запросить удаление аккаунта | `POST /v1/account/privacy/deletion` |

Сессии старше пятнадцати минут отказывают с **401**
`reauthentication_required` — 401, а не 403, потому что средство исправления
доступно самому клиенту, и панель должна сказать ему об этом, а не показывать
глухой отказ. Каждый из этих экранов сообщает об этом до нажатия кнопки, а не
после, а отказ отправляет клиента на `/account/sign-in?next=<path>`; каждый путь
входа — виджет, Mini App, провайдер OIDC — возвращает его на этот путь, как только
свежая сессия создана. Тот же `next` передаётся и тогда, когда оболочка
обнаруживает, что сессии нет, поэтому клиент, у которого сессия закончилась на
странице заказа, возвращается к этому заказу. Принимается только путь на том же
сайте под `/account`; всё остальное ведёт на дашборд.

Повторный вход из браузера, который ещё держит живую сессию того же клиента,
**заменяет** эту сессию, а не добавляет вторую: прежняя строка отзывается с
причиной `reauthenticated`. Клиент, которого попросили войти заново, просил более
свежую сессию, а не два устройства на экране безопасности. Cookie другого клиента
не трогается — новая cookie просто занимает её место в браузере, — потому что
иначе один человек мог бы разлогинить другого, зная лишь адрес этого экрана.

Каждый небезопасный запрос несёт CSRF-токен двойной отправки, выведенный из
секрета сессии, поэтому токен, выпущенный для одной сессии, нельзя проиграть в
другой, — а панели клиента и оператора выводят свои токены под разными метками,
поэтому ни одна не может выпустить учётные данные для другой. Текущий токен
публикуется в заголовке ответа `X-CSRF-Token` каждого аутентифицированного ответа
и подхватывается общим транспортом, поэтому токен, повёрнутый посреди сессии, уже
актуален к моменту отправки следующей мутации. Несовпадение — это `403
csrf_failed`.

Отсутствующая cookie отвечает `401 unauthenticated` ещё до проверки сервиса,
потому что ответ 503 в этом месте сообщил бы анонимному вызывающему что-то о
конфигурации установки без всякой пользы. И мёртвая сессия, и приостановленный
аккаунт по пути наружу очищают cookie, поэтому браузер, держащий непригодную
сессию, перестаёт предъявлять её в каждом последующем запросе.

> [!NOTE]
>
> Отдельной кнопки «выйти» нет. Клиент завершает сессию на `/account/security` —
> **Завершить** на конкретной строке или **Завершить все сессии**, что намеренно
> включает и текущую.

## Экраны подписки

Дашборд отвечает на тот вопрос, с которым клиенты его открывают: работает ли моя
подписка и сколько осталось. Всё остальное на странице достижимо оттуда, а не
конкурирует с этим.

Каждая карточка несёт цветную точку фазы, предложение о фазе, название тарифа,
остаток дней как главную цифру, использование устройств и индикатор трафика.
Словарь фаз — клиентский: `active`, `expiring_soon`, `grace`, `limited`,
`provisioning`, `none`, `disabled`, `expired`, `failed`, — и именно его
отрисовывают обе поверхности, а не внутренний статус подписки. Остаток дней
округляется **вверх**, потому что подписка, у которой осталось восемь часов, с
точки зрения клиента имеет ещё один день, а не ноль.

Строка подписки создаётся вместе с заказом, который её открывает, и переживает
его неоплату. Такая строка несёт `pendingOrderId`, пока право не выдано, и
карточка показывает её как **Ожидает оплаты** с переходом к заказу, а не как «не
активна» с кнопкой **Подключить**, в которой экран подключения откажет.
«Подключить» предлагается только выданной подписке в пригодной фазе; истёкшая
или отключённая предлагает **Продлить**, а никогда не выданная — **Посмотреть
тарифы**. То же решение управляет главной кнопкой на `/account/subscriptions/{id}`,
где перевыпуск ссылки показывается только после того, как что-то выдано.

Переключатель подписок появляется, когда установка разрешает одновременные
подписки, а также всякий раз, когда у клиента фактически больше одной, даже если
настройка выключена: клиент с двумя подписками при настройке на одну — это
состояние миграции, и скрытие переключателя оставило бы одну из них без доступа.

Трафик показывается полосой и, над ней, предложением — текстовый эквивалент виден
всем, а не спрятан для вспомогательных технологий, и та же фраза служит полосе
доступным именем. Процент вычисляется на сервере и ограничивается диапазоном,
поэтому эти два представления не могут разойтись. «Безлимит» передаётся явно, а не
выводится из нулевого лимита, и при безлимитном объёме полоса не рисуется вовсе.

Когда Remnawave недоступен, страница всё равно отрисовывает то, что знает сам
Omniflow, — тариф, период, дату окончания — и говорит, какие цифры неактуальны,
вместо того чтобы выдавать остановившееся значение трафика за живое. Уведомление
об обслуживании, если оператор его задал, отрисовывается поверх всего на языке
клиента, с ожидаемым временем возврата, когда оно настроено; операторская причина
обслуживания не показывается.

На `/account/subscriptions/{id}` клиент переименовывает подписку — до 40 символов,
без управляющих символов, с отказом вместо молчаливого усечения, потому что
подпись показывается ему обратно дословно — и находит элемент управления сменой
ссылки.

### Подключение устройства

Экран подключения существует, чтобы перенести одну строку на другое устройство. Он
предлагает deep-ссылку для каждого документированного клиентского приложения,
QR-код, отрисованный в браузере, и саму ссылку с кнопкой копирования. Список
приложений берётся из той же таблицы, которую отрисовывает бот, поэтому клиент,
читающий одну рекомендацию в чате и другую в браузере, невозможен по построению.

Подписка, которая ещё выдаётся, отвечает `409 not_provisioned`, и экран считает
это этапом, а не сбоем: он сообщает, что подписка настраивается, и перечитывает
её каждые пять секунд, пока ссылка не появится, так что клиент, пришедший прямо
после оплаты, видит, как экран превращается в ссылку, а не получает совет
вернуться позже. Никакой другой отказ не опрашивается — сбой не проходит по
таймеру.

### Смена ссылки доступа

Смена ссылки делает старую недействительной на всех устройствах, и каждое
устройство должно импортировать новую, прежде чем снова подключится. Трафик и дата
окончания не затрагиваются. Это средство исправления, когда ссылку передали или
она утекла, и оно закрыто как подтверждением вводом фразы, так и требованием
недавнего входа.

Одно подтверждение — одна смена. Экран не отправляет второй запрос, пока первый
в пути, поэтому двойное нажатие не может обесценить ссылку, которую клиенту
только что выдали; а когда запрос ушёл, но ответ так и не пришёл, экран
сообщает, что ссылка, возможно, уже заменена, и указывает на экран подключения,
а не просит третьего подтверждения, которое сменило бы её ещё раз.

## Устройства и приватность

Панель никогда не возвращает аппаратный идентификатор или IP-адрес подключённого
устройства. Устройство именуется непрозрачным дескриптором — усечённым дайджестом,
который сервер разрешает обратно, хэшируя собственный текущий список, — поэтому
удаление никогда не требует, чтобы вызывающий держал идентификатор, над которым
выполняется действие. «Последняя активность» — это дата, а не точный момент:
достаточно, чтобы узнать свой ноутбук, и недостаточно, чтобы построить журнал
перемещений.

Ссылки на подписку — это учётные данные, поэтому экран подключения запрашивает их
по требованию, а не дашборд носит их с собой, и QR-код генерируется в браузере, а
не запрашивается как изображение, что иначе поместило бы ссылку в строку запроса,
лог прокси и кэш браузера.

[Подключение устройства](https://omniflow-vpn.mintlify.site/ru/customer/connection.md) покрывает всё это полностью:
таблицу клиентских приложений по платформам, deep-ссылки, QR, ручной запасной
вариант, список устройств, удаление и смену ссылки.

## История аккаунта

Экран безопасности перечисляет живые сессии (до 50), привязанные способы входа и
историю аккаунта, доступную клиенту для чтения: входы, выходы, смены ссылок,
удаления устройств и изменения способов входа, по 50 за раз.

Каждая строка сессии показывает способ входа, записанный для неё IP-адрес и
относительное время последней активности, а также либо пометку «Текущая», либо
кнопку **Завершить**. **Завершить все сессии** намеренно отправляет
`keepCurrent: false` — элемент управления существует на случай подозрения на
компрометацию, и щадить сессию, из которой его нажали, значило бы оставить
атакующего внутри, если это он её держит, — после чего возвращает браузер на экран
входа.

Эта история — отдельная запись от журнала аудита оператора. Журнал аудита — это
операторский артефакт, который ищут под правами доступа оператора, и решение по
каждой строке о том, какие её поля может видеть клиент, поместило бы решение о
раскрытии в путь чтения. Клиентский журнал написан для показа: его словарь закрыт,
и в нём нет колонки ни для суммы, ни для ссылки, ни для идентификатора другой
стороны.

Удаление способа входа отклоняется, когда он последний
(`409 last_sign_in_method`), и панель отключает элемент управления, а не даёт
запросу провалиться, потому что причину стоит показать до попытки. Клиент,
удаливший свой единственный способ входа, заблокировал себе доступ к аккаунту с
оплаченной подпиской, и ничто в панели не сможет впустить его обратно.

## Покупка и оплата

Экран магазина сравнивает тарифы по тем условиям, которые решают исход покупки:
период, объём трафика, лимит устройств, цена и валюта. Он ничего не сортирует,
ничего не фильтрует и ничего не скрывает — тариф, который клиент не может начать,
всё равно показывается с причиной от сервера под ним, потому что клиент, которому
рассказали о тарифе и который не может его найти, решит, что панель сломана, а не
что он не подходит по условиям.

Право на покупку и предлагаемые операции приходят из API уже решёнными. Словарь
операций на проводе — `purchase`, `extension`, `upgrade` и `downgrade`; `renewal`
и `trial` — принимаемые входные псевдонимы, которые нормализуются в эти четыре.

Правило умещается в одно предложение, и бот применяет то же самое: тариф, который
клиент держит, продлевается; тариф с ценой не ниже держимого — повышение; тариф с
ценой ниже держимого — понижение; а когда ничего не держится, остаётся только
купить. «Держится» означает тариф за текущим правом подписки, которое не
вытеснено и не истекло: истёкшее право не держится, поэтому клиенту с истёкшей
подпиской предлагается `purchase` на каждом тарифе, включая тот, что у него был, а
не «повышение» с тарифа, за который он больше не платит. Равная цена — это
повышение, а не отказ. `upgrade` и `downgrade` появляются только тогда, когда
политика версии тарифа не равна `forbid`; при нескольких живых подписках тариф
оценивается относительно каждой и может быть и тем и другим, а какую именно он
меняет, решает выбор цели. `purchase` предлагается дополнительно, когда политика
разрешает ещё одну подписку. Тариф, который не предлагает ничего — клиент что-то
держит, а этот тариф запрещает переход на себя, — несёт `subscription_limit_reached`.

`purchase` никогда не нацеливается на подписку, у которой ещё есть время. Покупка
планируется с текущего момента и вытесняет то, что есть, поэтому, нацеленная на
живую подписку, она выбросила бы оставшееся время; страница тарифа и оформление
перечисляют рядом с «новой подпиской» только пустые слоты — неоплаченную или
истёкшую подписку, — а `POST /checkout` отклоняет остальное с
`422 operation_forbidden`. При настройке на одну подписку покупка поверх живой
отклоняется так же; каталог вместо этого предлагает там `extension`.

> [!NOTE]
>
> Ответ каталога о праве на покупку носит рекомендательный характер. Ограничение
> тарифа на одновременность и число подписок перепроверяются внутри создания заказа
> под блокировкой, которую это чтение намеренно не берёт, а пробный период
> переоценивается ещё раз в момент подтверждения. Скрыть невозможное действие в
> каталоге — любезность; отказать в нём при создании — гарантия.

Тариф без цены в расчётной валюте вовсе не попадает в каталог, потому что купить
его здесь нельзя, а запись, с которой нельзя ничего сделать, — это лишь вопрос, на
который клиент не может ответить.

Оформление заказа — это возобновляемое намерение купить, и у клиента оно самое
большее одно. Оно общее с ботом: открытие оформления в браузере в той же
транзакции удаляет то, что осталось открытым в чате, а частичный уникальный индекс
в базе данных обеспечивает «по одному за раз» — этим занимается база, а не код той
или иной поверхности. Две недоделанные покупки на одного человека — состояние,
которое ни одна поверхность не смогла бы объяснить. Оформление истекает через
**один час** после открытия, а чтение истёкшего сначала удаляет его, поэтому
клиент начинает заново со свежей, корректно рассчитанной котировки, а не
подтверждает цену, от которой каталог с тех пор ушёл.

**1. Выберите тариф и цель**

Когда одновременные подписки включены, каждое продление, повышение и понижение
тарифа называет подписку, которую оно меняет, — без предвыбранного значения по
умолчанию, потому что повышение, применённое не к той подписке, не видно до
следующего продления. Установка с одной подпиской не отрисовывает выбор вовсе.

**2. Настройте его**

Отряды (squads) и дополнения, где тариф их предлагает. Границы выбора отрядов
указаны как подсказка и обеспечиваются сервером: вторая реализация правила
подсчёта на React — это ровно то, что рано или поздно пропустило бы набор, в
котором заказ затем откажет.

Тариф, требующий выбора, открывается **без цены**: оформление несёт
`squadSelection: {required: true, reason}` и `quoteAvailable: false`,
конфигуратор отрисовывается первым, разбивка предлагает выбрать, а кнопка
подтверждения остаётся выключенной, пока сервер не сообщит, что выбор
разрешился. Каждое нажатие отправляет весь набор, поэтому неполный набор
сохраняется и сообщается как `squad_selection_too_few`; набор, который тариф не
примет никогда — сервер, которого он не предлагает, больше, чем он разрешает,
любой выбор на тарифе с автоматическим назначением, — отклоняется с `422`
**до** сохранения, так что оформление никогда не остаётся в состоянии,
которое клиент не может исправить с экрана.

**3. Примените промокод**

На отклонённый код отвечают причиной — неизвестен, не подходит, исчерпан или
некорректен, — и котировка пересчитывается без него, поэтому экран объясняет
исход, а не падает. Страница тарифа перечисляет, какие кампании идут, но
никогда — коды, которые их погашают: код — это предъявительская ценность,
которую оператор вручает конкретной аудитории.

**4. Прочитайте разбивку**

Промежуточный итог, дополнения, скидка, применённая сумма кошелька и внешняя
сумма, которую ещё предстоит оплатить. Каждая цифра вычисляется сервером,
поэтому число, которое клиент одобряет, и число, которое с него списывают, не
могут быть получены двумя разными способами.

**5. Оплатите**

Предлагаются только провайдеры, которые включены *и* рассчитываются в выбранной
валюте, и каждый показывает ту цену, которую он бы списал: один и тот же тариф
у разного провайдера может стоить разное число, и прятать это до страницы
самого провайдера было бы неприятным сюрпризом.

Подтверждение оформления создаёт заказ и ничего не списывает. Запуск платежа у
провайдера — отдельный шаг, поэтому клиент, закрывший вкладку между ними, находит
ждущий заказ, а не платёж, за который никто не может отчитаться.

Подтверждение также применяет операторское
[требование обязательных каналов](https://omniflow-vpn.mintlify.site/ru/operations/required-channels.md) для клиента
с идентичностью Telegram и отклоняет с `422 channel_required`, неся каналы, на
которые нужно подписаться, — тот же список, что бот показывает перед
**Оплатить**, — когда клиент записан как отсутствующий в одном из них. Веб читает
подписку, записанную воркером, а не спрашивает Telegram, поэтому только что
подписавшийся клиент ждёт следующего прохода или нажимает «Я подписался» в боте;
неизвестный или устаревший ответ никогда не блокирует.

Заказ, который ещё ожидает оплаты и что-то должен, несёт `paymentChoices` — способы,
принимающие его валюту, каждый с суммой, которую осталось внести, — и
`preferredProvider`, способ, который записало оформление, поэтому экран заказа
предлагает собственный выбор, а не зависит от `?provider=`, с которым
перенаправило оформление. Переход из списка заказов, перезагрузка без строки
запроса, вторая вкладка — всё это по-прежнему ведёт к оплате.
`POST /orders/{id}/payment` по умолчанию берёт провайдера платёжного намерения,
затем записанного; способ, не принимающий валюту заказа, он отклоняет с
`422 provider_currency_unsupported`, а заказ, переставший ожидать оплату — или чей
час истёк, независимо от того, прошла ли уже очистка по истечению, — с
`409 order_not_payable`, так что кнопка, нажатая на мгновение позже, читается как
конфликт, а не как сбой. Telegram Stars предлагается только клиенту с
идентичностью Telegram, потому что расчёт идёт через инвойс в чате.

Экран оформления всегда предлагает **Отменить покупку**, в том числе когда само
оформление не удалось прочитать. У клиента не больше одного оформления, и оно
блокирует любую другую покупку, поэтому выход, существующий только на счастливом
пути, оставил бы кого-то с покупкой, которую нельзя ни завершить, ни бросить.

### Когда платёж проходит не гладко

Экран заказа складывает три факта — состояние заказа, статус платёжного намерения
и статус выдачи — в одну фазу именно для того, чтобы ни одной поверхности не
приходилось соединять их самой. Случай, который делает это настояние оправданным,
— опоздавший вебхук, приходящий уже после завершения выдачи доступа. Трёхэтапный
индикатор прогресса (оплачено, выдача доступа, готово) рисуется только по фазе, а
страница опрашивает сервер каждые шесть секунд, пока фаза ещё может измениться, и
не опрашивает вовсе, когда уже не может.

Передача к оплате бывает одного из четырёх видов, у каждого своя формулировка,
потому что сведение их в одно сообщение «завершите оплату» оставило бы троих из
четырёх клиентов ждать того, что никогда не появится:

| Передача | Что делает клиент |
| - | - |
| `hosted` | Открывает страницу оплаты самого провайдера в новой вкладке |
| `telegram_invoice` | Открывает бота по `https://t.me/<bot>?start=pay_<orderID>`; бот присылает счёт по этому заказу, и клиент оплачивает его в чате |
| `manual` | Ждёт, пока оператор подтвердит перевод, что может занять часы |
| `none` | Ничего, когда заказ ничего не должен: кошелёк всё покрыл. На заказе, который ещё должен, это означает, что ссылку на бота построить не удалось, и страница предлагает завершить оплату из списка заказов в боте |

Telegram Stars оплачиваются только внутри чата Telegram, поэтому этот способ
предлагается — в оформлении, на экране заказа и при пополнении кошелька — только
клиенту с идентичностью Telegram, а платёж Stars, запущенный кем-либо ещё,
отклоняется с `provider_unavailable`. Ссылка строится из имени бота, которое
разрешается тем же запросом, что и на экране входа; `/start pay_<orderID>` — это
сигнал боту прислать счёт по этому заказу. «Платить больше нечего» показывается
только когда `externalMinor` равен нулю.

Элемент управления **обновить** перечитывает данные у провайдера, а не просто
заново запрашивает собственную запись Omniflow, поэтому клиент, вернувшийся до
прихода вебхука, всё равно видит расчётное состояние; это делается по мере
возможности, и адаптер, который нельзя опросить, оставляет заказ ровно в том
состоянии, в котором его в итоге найдёт вебхук.

Прогресс выдачи доступа читается из операции выдачи, созданной заказом, с
показом числа попыток, когда оно превышает единицу: повторяющаяся операция снаружи
выглядит так же, как застрявшая, а эта разница — единственное, что хочет знать
ждущий клиент. Показываемая ошибка — собственная ограниченная классификация
воркера, и никогда не сообщение провайдера, потому что такие сообщения могут
нести идентификаторы.

**Отмена** появляется, только пока заказ ещё в состоянии `draft` или `pending`;
всё, что позже, — это `409 order_not_cancellable`, потому что возвраты — действие
оператора, а не клиента. Отмена идемпотентна и заодно удаляет сессию оформления,
указывающую на заказ, поэтому клиент не остаётся с оформлением, которое указывает
на что-то отменённое.

> [!NOTE]
>
> Подтверждение оформления переиспользует собственный ключ идемпотентности
> оформления, поэтому двойное нажатие или повторённый запрос разрешаются в уже
> созданный заказ. Запуск платежа ключуется по заказу *и* выбранному адаптеру —
> смена провайдера является новым платежом, а двойное нажатие той же кнопки нет.
> Оба маршрута всё равно требуют заголовок `Idempotency-Key` длиной 8–128 символов,
> чтобы клиент, который повторяет запрос, обозначил это намеренно, но дедупликацию
> делает не этот заголовок.

`/account/orders` перечисляет покупки тарифов, начиная с новых, с курсорной
пагинацией по той паре, по которой упорядочен список, поэтому платёж, прошедший
между двумя страницами, не может ни удвоить строку, ни скрыть одну из виду.
**Пополнений кошелька в этом списке нет** — пополнение перемещает деньги в
кошелёк, а не покупает что-либо, и учитывается в собственной книге проводок
кошелька. Клиент, ищущий пополнение в истории заказов, найдёт его на
`/account/wallet`.

## Кошелёк

Три цифры несутся отдельно, потому что они отвечают на разные вопросы, а
единственный «баланс» ни на один не ответил бы честно: **всего** — это то, что
держит книга проводок, **зарезервировано** — то, что уже заявили неоплаченные
заказы, а **доступно** — то, что новый заказ действительно может потратить.
Клиенту, который видит только общую сумму, а затем наблюдает применение меньшей на
оформлении заказа, дважды назвали неверное число.

Балансы ведутся по каждой валюте и изолированы: баланс в одной валюте никогда не
оплачивает заказ в другой. Каждая валюта, которую держит клиент, получает свою
карточку, но форма пополнения предлагает только расчётную валюту. Книга проводок
работает только на добавление, поэтому исправление появляется как компенсирующая
проводка, а не как отредактированная строка, и каждая строка называет тип своего
движения — кредит, дебет, платёж, возврат, реферальное вознаграждение, коррекция
или истечение — вместе с примечанием оператора, если коррекция его несла.

Пополнение проходит через тот же конвейер заказа, вебхука и сверки, что и покупка
тарифа, с применением заданных оператором предустановленных сумм, минимума,
максимума и скользящего окна. Предустановленные суммы фильтруются доменом по тому,
сколько этот клиент уже зачислил за окно, поэтому предложенная сумма — это та,
которую примут. Отказы приходят как `topup_disabled`, `topup_below_minimum`,
`topup_above_maximum`, `topup_window_exceeded` или `topup_invalid_amount`.

Ключ идемпотентности вызывающего **становится ключом заказа** — здесь, в отличие
от всех остальных мест: пополнение вводит в кошелёк реальные деньги, поэтому
повторённый запрос зачисляет то же самое пополнение, а не второе. Если заказ
создан, но провайдер отказал в намерении, заказ всё равно возвращается — `201` с
`paymentProblem` вместо `payment`, — потому что он существует и принадлежит
клиенту, а провайдер, отказавший в намерении, — это состояние, допускающее повтор,
а не причина скрывать то, что было создано.

`GET /v1/account/wallet` несёт `pendingTopUps`: заказы на пополнение, которые ещё
ждут оплаты, каждый со своей передачей платежа. Экран кошелька отрисовывает
передачу из этого списка, а не из ответа, который её начал, поэтому закрытая
страница провайдера, перезагрузка и вторая вкладка показывают один и тот же путь
обратно, а пополнение, оплата которого так и не началась, ведёт на экран заказа,
где предлагается каждый способ, принимающий его. Пополнений нет в
`/account/orders`, так что этот список — единственное место, где они видны.

## Цифровые товары

Магазин продаёт Telegram Premium и Stars ровно по тем правилам, которым следует
бот. Каталог не приводит цен, потому что котировка — это обещание со сроком
годности, а экран, полный обещаний, начал бы истекать ещё во время чтения; товар,
который его шлюз сейчас продать не может, показывается как недоступный, а не
скрывается, чтобы клиент, добавивший его в закладки, узнал причину.

**Цена — это котировка со сроком.** Она выпускается при открытии товара, а не при
выводе каталога. Панель показывает обратный отсчёт, а покупка отправляет
показанную котировку обратно. Сервер перекотирует перед списанием и отказывает с
отдельной причиной, когда окно прошло (`quote_expired`, перекотируется молча) или
когда число изменилось (`price_changed`, показывается рядом со старым и требует
второго осознанного нажатия), поэтому цена на экране всегда является ценой
списания.

**Получатель подтверждается отдельно.** Идентификатор нормализуется сервером —
вставленная ссылка, ведущая `@`, завершающий слеш отбрасываются, — показывается в
точно том виде, в каком он будет передан шлюзу, и в покупке отказывают, если
подтверждённая строка не байт в байт совпадает с отправленной. Редактирование поля
отменяет согласие. Доставка необратима, как только шлюз отправил товар, и опечатку
в имени пользователя вернуть нельзя. По той же причине количество ограничено
десятью.

> [!NOTE]
>
> Переключатель «это для меня» помечает заказ для собственной истории клиента и
> ничего не решает о доставке. Доставка адресуется исключительно по проверенному
> идентификатору — Omniflow не хранит имя пользователя Telegram для
> веб-клиента.

**Неоднозначная доставка ставится на паузу.** Шлюз, стоящий перед Fragment, не
соблюдает ключ идемпотентности, поэтому потерянный ответ действительно
неоднозначен: повтор может доставить и списать дважды, а возврат может вернуть
деньги за пришедший товар. Такая доставка уходит в очередь на разбор оператору, и
панель об этом сообщает, предлагая поддержку, а не кнопку повтора, — и она окрашена
как предупреждение, а не как сбой, потому что никто пока не может сказать, что
покупка провалилась, и красный цвет сообщил бы клиенту неправду о его деньгах.
Передача в поддержку несёт ссылку вида `goods-order:<orderId>`, которая называет
заказ в форме, пригодной для обращения и не повторяющей получателя. Доставка,
провалившаяся окончательно, возвращается в кошелёк, и панель сообщает о возврате
только после того, как зачисление существует.

Неоплаченный заказ магазина оплачивается через обычный экран заказа, а не через
второе оформление: второе оформление — это способ развести два оформления в
разногласии о том, что включено. Когда деньги должны, а ни один настроенный
провайдер не рассчитывается в этой валюте, панель предлагает поддержку вместо
мёртвой кнопки.

## Поддержка и новости

Обращения — это те же обращения операторского стола и те же обращения бота: одна
переписка на запись, читаемая с той поверхности, которую клиент открыл.

Состояние прочтения живёт на сообщении, а не на поверхности, поэтому ответ,
прочитанный в Telegram, не остаётся жирным в вебе, а клиент, ответивший в браузере,
попадает в операторскую очередь ровно так же, как ответивший в чате. Ответ на
*решённое* обращение открывает его заново, потому что клиент, который пишет снова,
не получил ответа на свой вопрос, — то же правило применяет бот, поэтому две
поверхности не могут оставить обращение в разных состояниях.

Внутренние заметки операторов никогда не возвращаются. Они живут в собственной
таблице, и ни один клиентский запрос её не касается, что является более сильной
гарантией, чем их отфильтровывание.

Ограничения публикуются в `GET /v1/account/support/limits` и загружаются до того,
как форма создания станет пригодной, поэтому панель считает до того же числа, на
котором отказывает API, а не гадает:

| Ограничение | По умолчанию |
| - | - |
| Открытых переписок на клиента | 5 |
| Длина темы | 120 символов |
| Длина сообщения | 4000 символов |
| Размер вложения | 5 МиБ (установка может задать меньше, но никогда больше 10 МиБ) |
| Типы содержимого вложений | `image/png`, `image/jpeg`, `image/webp`, `application/pdf`, `text/plain` |

Длины считаются в символах, а не в байтах, потому что иначе теме на кириллице
отказали бы на половине той длины, которая разрешена английской. Достижение квоты
открытых переписок отключает элемент управления «новая переписка» и приводит к
отказу в создании с `409 too_many_open_tickets`. Переписка возвращает не более 200
своих последних сообщений.

Вложения работают по списку разрешённых типов, и байты никто не анализирует:
анализ содержимого существует, чтобы угадать неизвестный тип, а догадка — ровно то,
чего список разрешённых принимать не должен. Файл, приложенный в Telegram, остаётся
у Telegram и называется, а не предлагается к скачиванию, потому что предложить
скачивание, которое не может работать, хуже, чем сказать, где лежит файл.

### Новости

`/account/news` перечисляет опубликованные объявления с бейджем категории —
объявление, инцидент, обслуживание или новость, — отметкой непрочитанного и явной
кнопкой **Отметить прочитанным**. Прочтение — это действие, которое совершает
клиент, а не вывод из позиции прокрутки, и запись о прочтении общая с ботом,
поэтому пост, отмеченный прочитанным здесь, перестаёт быть непрочитанным там.

Панель намеренно не отправляет с запросом локаль. Сервер сам определяет, на каком
языке клиент читает объявления, — запрос, затем предпочтение в боте, затем
собственная локаль аккаунта, затем английский, — потому что навязывание локали этой
страницы заставило бы одного и того же клиента читать одно и то же объявление на
двух языках в зависимости от того, какую поверхность он открыл; когда
определённая локаль отличается от локали страницы, об этом сообщает уведомление.
Пост без перевода на определённую локаль **отсутствует**, а не показывается на
другом языке: входящие, которые молча переключают язык посреди списка, читать
труднее, чем более короткие.

Пост, отнесённый к маркетинговым, несёт дополнительный бейдж. Клиент дал согласие
его получать и вправе видеть, что из этого — служебное уведомление, а что —
предложение, не выводя это из формулировок.

### Настройки и согласия

`/account/preferences` содержит четыре переключателя уведомлений (окончание,
трафик, продление, новости — все включены по умолчанию), язык, на котором пишут
бот и рассыльщик, окно тишины, согласие на уведомления браузера, маркетинговое
согласие с датированным свидетельством и список контактных каналов только для
чтения. Каждый элемент управления пишет одно поле: маршрут — настоящий PATCH, где
опущенное поле остаётся нетронутым, потому что отправка всего документа целиком
позволила бы этой странице перезаписать выбор, сделанный клиентом в Telegram между
загрузкой и переключением.

Элементов выбора языка два, и они намеренно пишут в разные места. Язык **профиля**
(`PATCH /v1/account/me`) — это язык аккаунта: он записывается в `users.locale` и в
той же транзакции в `bot_preferences.locale`, потому что бот читает второе раньше
первого, и настройка профиля, трогавшая только первое, обещала сменить язык бота, но
не меняла. Язык **уведомлений** на этом экране пишет только `bot_preferences.locale`
и может быть `auto`: тогда бот следует языку клиента Telegram для сообщений, а
кабинет сохраняет язык аккаунта. Выбор языка профиля после этого заменяет `auto`
выбранным языком, так что две поверхности никогда не расходятся в решении, которое
клиент принял один раз.

Часы тишины задаются целыми часами в часовом поясе самого клиента и сбрасываются
отправкой окна, у которого начало равно концу: опустить поле означало бы «не
трогать окно», что противоположно только что высказанной просьбе клиента.

Пояс, в котором они считаются, — это `users.timezone`, и его задаёт тот же
экран: поисковый выбор рядом с окном тишины, перечисляющий известные браузеру
зоны IANA с текущим смещением каждой и с зоной, которую сообщает устройство,
наверху. Он пишет через `PATCH /v1/account/me` вместе с текущим языком
аккаунта, потому что маршрут принимает оба поля, и смена пояса не должна
сбрасывать язык. Каждый аккаунт начинает с `UTC`, поэтому элемент управления
стоит рядом с окном, которым управляет, а не на экране профиля.

Согласие работает только на добавление: изменение записывает новое решение вместе
с поверхностью и версией политики, которые его приняли, поэтому у вопроса «когда и
где этот человек дал согласие» всегда есть ответ, и строка пишется при каждой
отписке, даже если клиент уже был отписан, — «он и так уже был отписан» не является
причиной, чтобы его отписка не оставила следа. Повторное согласие снимает только то
подавление, которое вызвал сам клиент; подавление, записанное из-за отбоя доставки
или жалобы, — это чужой вывод о доставляемости, и галочка его не отменяет.

Отписка останавливает маркетинговые сообщения и новости, снимает маркетинговый
флаг с каждого живого контактного канала и записывает подавление. Она не
останавливает транзакционные сообщения о платеже, окончании срока или ответе
поддержки — они несут факты, которые клиенту нужны независимо от того, хочет ли он
слышать бизнес, и отписка, заглушившая бы их, превратила бы маркетинговую
настройку в способ потерять доступ без предупреждения.

> [!NOTE]
>
> Маркетинговое согласие может быть записано с двух экранов — переключателя в
> настройках и формы контактного канала на `/account/privacy` — и каждый ставит
> собственный источник и версию политики. Поэтому клиент, читающий историю своих
> согласий, может увидеть два разных происхождения одного и того же решения.

Уведомления браузера предлагаются только по явному согласию, никогда не
запрашиваются при загрузке страницы и работают только на переднем плане: панель
поднимает уведомление о новом ответе оператора или объявлении, которое она замечает,
пока вкладка находится в фоне. Нет ни web push, ни service worker, ни сохранённой
подписки, потому что у Omniflow нет серверного push, чтобы её доставить, — а
подписка, которую сервер никогда не использует, это запрос разрешения, потраченный
впустую. Когда вкладка закрыта, до клиента доходит Telegram. Согласие хранится в
собственном хранилище браузера, а не на аккаунте, потому что флаг уровня аккаунта
заявлял бы настройку, которую одно устройство соблюсти не может; оно не следует за
клиентом в другой браузер и исчезает вместе с данными сайта.

## Рефералы и лояльность

Экран реферальной программы показывает код, условия программы и правило
подтверждения, число приглашённых, подтверждённых, ожидающих и отклонённых,
остаток слотов относительно установленного оператором предела, а также историю
вознаграждений с состоянием каждого. Код создаётся при первом чтении экрана и
выпускается тем же способом, каким его выпускает бот, поэтому атрибуцию может
выполнить любая из поверхностей.

История никогда не несёт идентификатор приглашённого клиента: пригласивший вправе
знать, что приглашение принесло выплату, но не получать список того, кто его
друзья, после того как друг стал клиентом чужого сервиса.

Отменённое вознаграждение исключается из заработанной суммы и из подсчёта по
пределу. Оператор отменяет его, когда реферал оказывается злоупотреблением, и
занятый им слот возвращается вместе с ним. Когда публичный URL не настроен, панель
показывает код и сообщает, что ссылку построить нельзя, вместо того чтобы
отрисовать ссылку в никуда.

Ссылка для распространения, которую строит панель, — это
`<APP_PUBLIC_URL>/account/sign-in?ref=<code>`. Экран входа держит код на время входа
— в session storage, так что он переживает круг через OIDC и умирает вместе с вкладкой,
— а панель отправляет его в `POST /v1/account/referrals/attribution` при первом
аутентифицированном рендере. Это тот же момент, в который атрибутирует
`/start ref_<code>` в боте, и правило то же, что у бота: первая запись побеждает,
самореферал невозможен по построению, пара неизменяема. Сверху наложены две защиты,
которые подразумевают условия самой программы: ничего не пишется, пока программа
выключена, и ничего не пишется для клиента, который уже за что-то платил, — критерий
квалификации это первый оплаченный заказ, и такая строка никогда не смогла бы честно
квалифицироваться. Маршрут всегда отвечает `200` с исходом (`recorded`,
`already_attributed`, `program_disabled`, `unknown_code`, `self_referral`, `not_new`),
а не проблемой, потому что ссылка, которая не засчиталась, — не то, на что приглашённый
может повлиять, и панель этого не показывает.

Раздел лояльности показывает текущий уровень, прогресс к следующему и правила,
которые им управляют. Ничто на этих экранах не начисляет вознаграждение и не
повышает уровень: воркеры вознаграждений и оценки уже записали эти строки, а
выполнение этого при загрузке страницы поставило бы баланс клиента в зависимость от
того, какой экран он открыл, — и клиент внутри льготного периода был бы понижен в
момент открытия страницы. Программа, которая не включена, или программа без
уровней, сообщается как «не включена», а не как пустая лестница, потому что в
списке из нуля уровней они выглядят одинаково, а показывать экран стоит только для
одного из них.

## Персональные данные

### Выгрузка

Клиент может скачать собственные записи: профиль, идентичности, контакты,
подписки, права на услуги, заказы, платежи, книгу проводок кошелька, переписки
поддержки, реферальный статус и статус лояльности, историю согласий и события
жизненного цикла.

Выгрузка формируется синхронно, а не ставится в очередь. Выгрузке из очереди
потребовались бы канал доставки, срок хранения и ссылка, аутентифицирующая сама
себя, — три дополнительных решения о раскрытии ради ответа на вопрос, который
помещается в один ответ. Это POST, хотя он ничего не меняет, потому что GET был бы
закэширован, предзагружен, записан в строку доступа прокси и достижим переходом, а
это самый чувствительный документ, который производит API. Документ никогда не
логируется и никогда не пишется на диск, и панель его не отрисовывает — экран,
который его нарисовал бы, оставил бы его в позиции прокрутки, на скриншоте и в
кэше «назад-вперёд».

Документ объявляет, что он не включил и почему: ни идентификатора другого клиента,
ни платёжных учётных данных, ни секрета провайдера, ни ссылки на подписку, ни
идентификатора устройства или сети. Каждый раздел ограничен 1000 строками, и любой
раздел, упёршийся в это ограничение, **называется** в документе, потому что
клиенту, которому сказали, что его данные полны, когда это не так, сообщили
неправду ровно о том единственном, ради чего документ существует.

### Удаление

Запрос на удаление записывает запрос. Он не удаляет.

Выполняет его тот процесс хранения данных, которым уже управляет оператор, поэтому
необратимое действие никогда не происходит на основании одной браузерной сессии,
как бы недавно эта сессия ни аутентифицировалась. Запрос требует недавнего входа,
явного подтверждения и указанной причины длиной не более 400 символов —
произвольным текстом, а не выбором из меню, потому что закрытый список вложил бы
категории оператора в уста клиента. Запрос можно отозвать, пока он не исполнен, и
его отмена не требует повторной аутентификации: отмена всегда лишь предотвращает
необратимое.

При запросе происходят три вещи, в таком порядке, чтобы «записан» значило
больше, чем строка в журнале. Пишется событие жизненного цикла с клиентом в роли
действующего лица. В той же транзакции ставится в очередь уведомление оператору в
потоке `security` — `customer_deletion_requested` с идентификатором аккаунта и
никогда с причиной клиента: это его собственные слова, и им место в записи,
которую открывает оператор, а не в групповом чате. Затем завершаются все *другие*
сеансы клиента: запрос — это клиент, говорящий, что с аккаунтом покончено, а
сеанс, оставленный открытым на другом устройстве, иначе продолжал бы им
пользоваться или отозвал бы запрос без него. Сеанс, из которого сделан запрос,
сохраняется, потому что отмена делается из него. Воркера, исполняющего запрос,
нет; его исполняет оператор из записи клиента в панели, куда и ведёт уведомление.

Второй запрос при открытом первом отклоняется с `409 deletion_pending`. Событие
жизненного цикла никогда не удаляется при отмене — «это запрашивали, а потом
отозвали» и «этого никогда не запрашивали» суть разные факты, — но отозванный
запрос теряет свою причину, потому что слова клиента были написаны для решения,
которое больше не стоит.

### Контактные каналы

Адреса, по которым эта установка может связаться с клиентом, перечислены и
управляются на `/account/privacy`. Добавление записывает намерение и ничего более:
панель не может доказать, что адрес принадлежит тому, кто его вводит, поэтому
флаги — это разрешение, которое читает конвейер доставки, а не утверждение, что
адрес подтверждён. Канал, который не может получать ни транзакционные, ни
маркетинговые сообщения, отклоняется, потому что это адрес, хранимый без причины.

Контактный адрес, уже зарегистрированный где-то ещё, отклоняется словами не более
чем «здесь недоступно», а средством исправления является обращение в поддержку.
Сообщение о том, что им владеет другой аккаунт, превратило бы панель в оракул того,
является ли данный адрес клиентским. Удаление канала отзывает его, а не удаляет,
поэтому адрес нельзя молча перенести между аккаунтами, и история согласий
продолжает на что-то указывать.

Адреса запечатываются ключом данных установки и сопоставляются по отпечатку с
ключом, поэтому украденную базу данных нельзя проверить на известный адрес
хэшированием кандидатов. Без `APP_DATA_ENCRYPTION_KEY` весь раздел сообщается как
непредлагаемый, а не как ошибка.

## Ограничения частоты запросов и контракт транспорта

Ограничение частоты запросов действует только на двух клиентских маршрутах, оба —
на клиента и оба **закрываются при отказе**: недоступный лимитер отказывает,
поэтому сбой не может молча снять защиту:

| Маршрут | Ограничение |
| - | - |
| `POST /v1/account/contacts` | 10 в час |
| `POST /v1/account/privacy/export` | 3 в час |

Больше ничто под `/v1/account` не ограничивается по частоте на уровне HTTP,
включая маршруты входа.

Для тех, кто интегрируется с тем же API:

* **Проблемы** соответствуют RFC 9457 — `application/problem+json` с
  `{type, title, status, detail, request_id}`, где контрактом является код в конце
  `type`. Строка `detail` — это английская проза, написанная для операторов;
  панель никогда не показывает её клиенту, а переводит код.
* **CSRF**: прочитайте `X-CSRF-Token` из любого аутентифицированного ответа и
  отправляйте его при каждом методе, кроме GET, HEAD и OPTIONS.
* **Курсоры**: истории заказов, кошелька и магазина листаются по
  `?cursor=<timestamp>&cursorId=<uuid>` и отвечают `400 invalid_cursor`, когда
  курсор некорректен; истории поддержки, новостей и рефералов принимают один
  непрозрачный `?cursor=` и вместо ошибки молча возвращают первую страницу.
* **`Idempotency-Key`** имеет длину 8–128 символов. Он **обязателен** на
  `POST /checkout/confirm`, `POST /orders/{id}/payment`, `POST /wallet/top-up` и
  `POST /shop/purchase` и **необязателен** на двух маршрутах записи в поддержку,
  потому что клиенту, чей браузер повторно отправил форму, не следует показывать
  ошибку валидации о заголовке, о котором он никогда не слышал.
* **Тела JSON** декодируются с отклонением неизвестных полей и должны содержать
  ровно одно значение; всё прочее — `400 invalid_request`.
* Каждое «не найдено» намеренно неотличимо от «не ваше».

Собственный транспорт панели написан вручную, а не сгенерирован, потому что
сгенерированные хуки SWR выпускают голые вызовы `fetch`, не соблюдающие никакой
мутатор: сгенерированный хук не отправил бы ни cookie сессии, ни CSRF-токен,
поэтому каждая мутация через него отклонялась бы, а каждое чтение было бы
анонимным.

## Смежные страницы

> **[Подключение устройства](https://omniflow-vpn.mintlify.site/ru/customer/connection.md)**
>
> Клиентские приложения, deep-ссылки, QR, список устройств и смена ссылки.

> **[Бот Telegram](https://omniflow-vpn.mintlify.site/ru/customer/telegram-bot.md)**
>
> Тот же продукт в чате и поверхность, которая достаёт до закрытой вкладки.

> **[Уведомления](https://omniflow-vpn.mintlify.site/ru/customer/notifications.md)**
>
> Классы уведомлений, согласия, часы тишины и лента новостей.

> **[Приватность и ваши данные](https://omniflow-vpn.mintlify.site/ru/customer/privacy.md)**
>
> Содержимое выгрузки, изъятия, запросы на удаление и состояние хранения.

> **[Заказы и платежи](https://omniflow-vpn.mintlify.site/ru/commerce/orders-payments.md)**
>
> Машина состояний заказа, стоящая за экранами выше.

> **[Клиентский OIDC](https://omniflow-vpn.mintlify.site/ru/integrations/oidc.md)**
>
> Настройка провайдеров входа, которые предлагает эта панель.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.