> ## Documentation Index
> Fetch the complete documentation index at: https://veriqa.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Настройка и кастомизация

> Каналы, дизайн страницы входа, локализация и точки расширения Veriqa.

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

## Каналы

Каждый канал подключается в `AddChannelAdapters` и активируется секцией `Veriqa:Channels:{Канал}` с
`"Enabled": true`. Доступны **MAX, Telegram, WhatsApp и Email**. Каждый из них поставляется
отдельным пакетом: базовый набор — метапакетом `Veriqa.Core.BaseChannels` (Telegram, WhatsApp,
Email), MAX отдельно (`Veriqa.Core.ChannelAdapter.Max`), а если нужен один канал — только его
пакет. Подробнее — [настройка каналов](/docs/ru/guides/channels) и
[поддерживаемые каналы](/docs/ru/guides/supported-channels).

```csharp Program.cs theme={null}
authServer.AddChannelAdapters(adapters =>
{
    adapters.AddTelegram();
    adapters.AddWhatsApp();
    adapters.AddMax();
    adapters.AddEmail();
});
```

<Note>
  Email работает в двух режимах: **Pull** (magic link — пользователь переходит по ссылке из
  письма) и **Push** (письмо в один тап — пользователь отправляет готовое письмо). Push по умолчанию
  выключен и включается флагом `Veriqa:Channels:Email:PushEnabled`. Эндпоинты Email-канала мапит
  `MapVeriqaAuthServer` автоматически при включённом канале — см.
  [быстрый старт, шаг 3](/docs/ru/quickstart/dotnet#3-соберите-middleware-pipeline).
</Note>

<Note>
  Telegram получает обновления в режиме `Webhook` (по умолчанию) или `Polling` — задаётся
  `Veriqa:Channels:Telegram:UpdateMode`. Для `Webhook` укажите `WebhookBaseUrl` и `WebhookSecretToken`.
</Note>

Клиент может ограничить вход конкретным каналом через `acr_values=channel:{тип}` — см.
[быстрый старт](/docs/ru/quickstart/dotnet#9-выбор-канала-через-acr-values).

## Email: доставка и приём

Email включается двумя независимыми режимами под секцией `Veriqa:Channels:Email`:

* **Pull** (magic link) — Veriqa отправляет письмо со ссылкой, пользователь переходит по ней.
  Включён по умолчанию (`PullEnabled`).
* **Push** (письмо в один тап) — пользователь отправляет готовое письмо на входящий адрес, Veriqa
  обрабатывает входящее. Выключен по умолчанию (`PushEnabled`) — включается вместе с настройкой
  приёма и политикой верификации отправителя. Флаг читается при старте: после его изменения
  сервис нужно перезапустить.

```json appsettings.json theme={null}
{
  "Veriqa": {
    "Channels": {
      "Email": {
        "Enabled": true,
        "InboundVerification": "shared_secret",
        "PublicBaseUrl": "https://your-domain.com",
        "PreferredMode": "Push",
        "PullEnabled": true,
        "PushEnabled": false,
        "Outbound": {
          "Smtp": {
            "Host": "smtp.your-provider.com",
            "Port": 587,
            "UseSsl": false,
            "Username": "…",
            "Password": "…"
          }
        },
        "Inbound": {
          "Provider": "Webhook",
          "InboundAddress": "login@your-domain.com",
          "UsePlusAddressing": true,
          "VerificationPolicy": "DmarcAlignedPass",
          "WebhookSecretToken": "…"
        }
      }
    }
  }
}
```

* `PublicBaseUrl` обязателен при включённом канале — из него строятся magic links и QR.
* `Outbound:Smtp` — SMTP для Pull-режима (`Port` 587 = STARTTLS; `UseSsl: true` = implicit TLS,
  порт 465). При заданных учётных данных STARTTLS обязателен, если не указано
  `RequireStartTls: false` (по умолчанию `true`). Пароль храните в secret-store, не в `appsettings.json`.
* `Inbound` — приём для Push-режима: `InboundAddress`, `UsePlusAddressing` (вкладывает
  correlation-токен в адрес вида `login+{token}@…`), `WebhookSecretToken` для валидации вебхука
  провайдера.
* `VerificationPolicy` — верификация отправителя: `DmarcAlignedPass` (по умолчанию),
  `AllowListOnly` (по списку `AllowedDomains`). Значение `None` в продакшне запрещено.

## Дизайн-пресеты и тема

Внешний вид страницы задаётся пресетом `Veriqa:AuthPageDesign:Preset`:

| Пресет | Описание |
| - | - |
| `Default` | Стандартная светлая тема (по умолчанию). |
| `Dark` | Тёмная тема. |
| `Minimal` | Без декоративных элементов: строка бренда не выводится, у контейнера нет рамки. Состав страницы пресет не меняет — видимость QR задаёт [`ShowQrCode`](#видимость-qr-кода). |
| `Branded` | Основной цвет клиента (см. ниже). |

Цветовая схема управляется отдельно — `Veriqa:AuthPageDesign:Theme`:

| Тема | Описание |
| - | - |
| `Auto` | Следует системной настройке ОС (`prefers-color-scheme`). По умолчанию. |
| `Light` | Светлая тема. |
| `Dark` | Тёмная тема. |

Явный `Theme` (`Light` / `Dark`) приоритетнее цветовой схемы пресета. Тема проставляет на `<html>`
атрибут `data-theme`, пресет — `data-preset`. Анимации уважают `prefers-reduced-motion`.

```json appsettings.json theme={null}
{
  "Veriqa": {
    "AuthPageDesign": {
      "Preset": "Default",
      "Theme": "Auto"
    }
  }
}
```

## Видимость QR-кода

Показывать ли QR на странице входа, задаёт ключ `ShowQrCode` секции `Veriqa:AuthPageDesign:QrCode`
— в ней же живёт разрешение кода
([справочник конфигурации](/docs/ru/reference/configuration#veriqaauthpagedesignqrcode)):

```json appsettings.json theme={null}
{
  "Veriqa": {
    "AuthPageDesign": {
      "QrCode": {
        "ShowQrCode": "DesktopOnly"
      }
    }
  }
}
```

| Значение | Поведение |
| - | - |
| `Always` | QR показывается на любом устройстве, несколько каналов раскладываются вкладками. |
| `DesktopOnly` | QR скрыт на телефоне, где сканировать собственный экран нечем: там каналы раскладываются кнопками. Планшет и десктоп получают вкладки и QR. По умолчанию. |
| `Never` | QR не показывается никогда — только кнопки каналов, на любом устройстве. |

Два свойства `DesktopOnly`, которых не видно по названию:

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

Пресет на видимость QR не влияет: `Preset` и `ShowQrCode` — независимые настройки.

<Warning>
  **После обновления.** Раньше по умолчанию было `Always`. Установка, не задающая `ShowQrCode`,
  теперь показывает на телефоне кнопки каналов вместо QR и вкладок; планшет и десктоп не меняются.
  Чтобы сохранить прежнюю страницу, задайте `"ShowQrCode": "Always"`.
</Warning>

### Кнопки каналов

На телефоне при `DesktopOnly` и на любом устройстве при `Never` полосы вкладок нет: каналы идут
списком кнопок, по одной на канал, в порядке набора каналов.

* Кнопка мессенджера «Открыть в …» ведёт прямо в него.
* Email с кнопкой почтового клиента сохраняет эту кнопку и ссылку «Войти по ссылке на почту» под ней.
* Email с формой адреса получает свою кнопку, которая раскрывает форму на месте. Страница, где этот
  канал единственный, показывает форму сразу — выбирать не из чего.
* [Подсказка канала](/docs/ru/reference/configuration#veriqalocalization-и-veriqachanneldisplay) стоит под
  кнопкой своего канала.

При `Never` эту раскладку рендерит сам сервер, поэтому скрипт ей не нужен. При `DesktopOnly`
страница рендерится с вкладками и переходит на кнопки, распознав телефон.

### Текст страницы следует за фактом показа QR

Строки, называющие код (инструкция, ссылка «вернуться к QR-коду»), уезжают в разметку **парой
редакций** — «с QR» и «без QR». Переключаются они тем же условием, что и сам блок кода: классами
`.veriqa-if-qr` / `.veriqa-if-no-qr` по атрибуту `data-qr-visibility` корневого элемента. Так
страница не обещает QR, которого не показывает.

Для интегратора это ограничение на свой CSS (`CustomCssPath`) и скрипт (`CustomJsPath`): правило,
перебивающее `display` этих классов, рассогласует текст с картинкой — например оставит «отсканируйте
код» на странице без кода. Скрывая или показывая QR своими средствами, переключайте обе редакции
текста теми же условиями.

<Note>
  QR генерируется сервером всегда, даже когда страница его не показывает: PNG уезжает в разметку
  data-URI и остаётся скрытым. При `Never` это только увеличивает вес ответа.
</Note>

## Брендирование

Логотип и имя бренда клиента заполняют строку бренда вверху карточки входа **в любом пресете, кроме
`Minimal`**; основной цвет применяется **только** в пресете `Branded`:

```json appsettings.json theme={null}
{
  "Veriqa": {
    "AuthPageDesign": {
      "Preset": "Branded",
      "PrimaryColor": "#FF5500",
      "LogoUrl": "https://your-domain.com/logo.png",
      "BrandName": "Acme",
      "PrimaryColorByTheme": { "dark": "#FF8A3D" },
      "LogoUrlByTheme": { "dark": "https://your-domain.com/logo-dark.png" }
    }
  }
}
```

* `PrimaryColor` — строго формат `#RRGGBB`; невалидное значение откатывается на нейтральный
  дефолт. Красит только общие элементы (основную кнопку, фокус-рамки, спиннер).
* `LogoUrl` и `BrandName` вместе заменяют собственный знак продукта. Если ни один уровень не задал ни
  того, ни другого, строка бренда показывает красный ромб и `Veriqa`. Заданное **любое одно** значение
  заменяет этот знак целиком: одно имя — без ромба, один логотип — без `Veriqa`. Поэтому незаданный
  логотип не означает «без строки бренда» — страница без неё задаётся пресетом `Minimal`. Страницы,
  кроме окна входа (веб-подтверждение, истёкший вход, страницы писем), строки бренда не выводят вовсе.
* `BrandName` — имя рядом с логотипом, вместо собственной подписи продукта. Логотип и имя
  резолвятся **независимо**: задайте любое одно, и страница покажет то, что есть, — только имя,
  только логотип или оба. В отличие от логотипа имя **по теме не режется** (картинка не может
  перекраситься под тёмную страницу, а текст берёт цвет страницы), поэтому `BrandNameByTheme` не
  существует. Пустое значение не задаёт ничего: имя тогда берётся с уровня ниже. Значение — обычный
  текст (выводится экранированным) и ключ локализации (см. [Локализацию](#локализация)).
* `PrimaryColorByTheme` и `LogoUrlByTheme` задают значение для конкретной темы (`light`, `dark`).
  Тема без своего значения берёт соседний общий ключ (`PrimaryColor`, `LogoUrl`). При
  `Theme: Auto` тему выбирает браузер, и на страницу уезжают оба значения — светлое и тёмное.
* Фирменные цвета каналов (Telegram, WhatsApp, MAX) **не перекрашиваются** ни в одном пресете.

<Warning>
  **После обновления.** Логотип или имя бренда, заданные при пресете, отличном от `Branded`, раньше
  игнорировались, а теперь выводятся. Страница `Branded` без того и другого раньше обходилась без строки
  бренда, а теперь показывает собственный знак продукта. В `Minimal` строки бренда нет в разметке, и
  своя таблица стилей или скрипт, искавшие её, ничего не найдут.
</Warning>

## Блок в подвале страницы входа

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

```json appsettings.json theme={null}
{
  "Veriqa": {
    "AuthPageDesign": {
      "FooterHtml": "<a href=\"https://your-domain.com/privacy\" target=\"_blank\">Privacy</a> · <a href=\"/terms\" target=\"_blank\" rel=\"noopener\">Terms</a>"
    }
  }
}
```

Блок задаётся этой глобальной секцией и записью клиента (`Veriqa:OpenIddict:Clients`), где
приложение задаёт свой блок вместо глобального. Запись `ui_config` его **не** задаёт: запись
выбирается параметром запроса, а запрос может выбирать оформление страницы, но не разметку на ней.

<Note>
  **Уровень `Tenant`.** Настройка стоит и на уровне тенанта, но читатель этого уровня из коробки не
  зарегистрирован — его пишет интегратор. Без него резолюция идёт к записи клиента и к этой секции.
</Note>

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

### Что может содержать разметка

Значение проверяется на строгий формат и при выходе за него отвергается **целиком** — ничего не
вырезается и не чинится:

* **текст** — любые символы, кроме `<` и `>`. Голый `&` допустим, голый `>` — нет, пишите `&gt;`;
* **теги** `a`, `span`, `div`, `p`, `strong`, `em` и пустой `br` (`<br>`, `<br/>`, `<br />` —
  закрывающего `</br>` нет). Имена тегов и атрибутов регистронезависимы;
* **атрибуты** `href`, `target`, `rel` и `class`, каждый не более раза в теге, значение — в двойных
  кавычках и без `"`, `<`, `>`. Всё прочее — `style`, `id`, любой обработчик `on*` — отвергается,
  как и значения в одинарных кавычках или без кавычек;
* **никаких классов самой страницы**: `class` отвергается, если одно из его имён — `veriqa` или
  начинается с `veriqa-`, в любом регистре букв и после декодирования символьных ссылок. Имя, лишь
  начинающееся с этого слова, — `veriqable` — допустимо, как и пустой `class`;
* **у каждой ссылки есть `href` и `target="_blank"`.** Адрес проверяется в том виде, в каком его
  читает браузер, — после декодирования символьных ссылок — и должен быть `https://…`, `http://…`
  либо корневым путём `/path`. Протокол-относительный `//host`, адрес `javascript:` или `mailto:`,
  обратный слеш и управляющий символ внутри адреса отвергаются;
* **теги сбалансированы**: закрывающий закрывает последний незакрытый, к концу незакрытых нет;
* комментарии, `<!DOCTYPE` и любой тег вне списка отвергаются.

`target="_blank"` здесь не вопрос вкуса. Уход со страницы в той же вкладке обрывает идущий на ней
вход, а возврат заново запрашивает `GET /connect/authorize` и создаёт новую транзакцию — ссылка,
открывающаяся на месте, стоит пользователю начатого входа.

<Warning>
  **Блок выводится как есть — он не очищается.** Проверка выше решает, пустить ли значение на
  страницу, но не чистит его. За то, что блок говорит, куда ведут его ссылки и что они сделают с
  тем, кто по ним перешёл, отвечаете вы. Своего `rel` Veriqa тоже не добавляет: атрибуты ваших
  ссылок задаёте вы.
</Warning>

<Note>
  **Почему классы `veriqa-` отвергаются.** Они принадлежат разметке страницы: её скрипт достаёт свои
  элементы по классу (`.veriqa-tab`, `.veriqa-panel`, `.veriqa-tooltip-host`, `.veriqa-tooltip`), и
  блок с таким классом доставал бы до поведения страницы, на которой стоит, — `class="veriqa-panel"`
  внутри блока погасили бы вкладки каналов. Называйте свои классы своим префиксом и оформляйте их из
  своей таблицы стилей (`CustomCssPath`).
</Note>

<Warning>
  **Обновление, когда такой класс уже задан.** Прежние версии пропускали класс `veriqa-` в этом
  блоке; теперь значение недопустимо. Заданное в глобальной секции, оно не даёт хосту стартовать;
  заданное в записи клиента — выбрасывает эту запись (см. таблицу ниже). Переименуйте класс до
  обновления.
</Warning>

### Что делает недопустимое значение

| Где задано значение | Что происходит |
| - | - |
| Эта глобальная секция | Хост **не стартует**, и сообщение называет настройку и секцию, в которой её править. Конфигурация, перечитанная уже запущенным хостом, вместо этого сохраняет последнее успешно прочитанное значение |
| Запись клиента | Запись выбрасывается из эффективной конфигурации **целиком** — этот клиент резолвит с уровня ниже все свои настройки, а не только блок. В логе названы клиент и настройка |
| Перевод в locale-файле | Страница рисуется **без** блока, а в лог уходит предупреждение с именем настройки и языком. Для остальных языков значение остаётся в силе |

### Перевод блока

Значение — такой же Natural Key, как любой другой текст страницы (см.
[Локализацию](#локализация)): страница ищет его в locale-файлах хоста, а значение, которого там нет,
выводится как есть. Перевод проверяется на тот же формат, что и значение, — перевод вне формата
оставляет страницу без блока.

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

```json appsettings.json theme={null}
{
  "Veriqa": {
    "AuthPageDesign": {
      "FooterHtml": "<a href=\"/privacy\" target=\"_blank\">Privacy</a> | authpage"
    }
  }
}
```

```json ru.json theme={null}
{
  "<a href=\"/privacy\" target=\"_blank\">Privacy</a> | authpage": "<a href=\"/privacy\" target=\"_blank\">Конфиденциальность</a>"
}
```

Учтите, что `|` **зарезервирован** под эту метку: всё после **последнего** `|` отрезается от
текста, который увидит пользователь. Разметка, которая сама должна содержать `|`, этой идиоме не
подходит — используйте сущность `&#124;` или другой разделитель.

## Инструкция после скана

В [hop-режиме](/docs/ru/reference/configuration#veriqahopmode) страница узнаёт, что пользователь продолжил
на телефоне, ещё до прихода подтверждения: QR-код отсканирован, страница открылась на телефоне,
мессенджер запущен. Каждый такой шаг называет строка статуса. На первом из них страница убирает выбор
канала — вкладки, QR-коды и кнопки каналов вместе с описывающей их инструкцией под заголовком — и
показывает на их месте инструкцию: по умолчанию «Продолжите на телефоне и подтвердите вход в
открывшемся приложении.» Предмет подтверждения, строка статуса и обратный отсчёт остаются.
Перезагрузка страницы возвращает выбор канала; когда вход истекает, уходит и инструкция, а
показывается ссылка перезапуска.

`HopInstructionHtml` заменяет текст ядра вашей разметкой:

```json appsettings.json theme={null}
{
  "Veriqa": {
    "AuthPageDesign": {
      "HopInstructionHtml": "<p>Check your phone: <strong>confirm in the messenger</strong> that has just opened.</p>"
    }
  }
}
```

Настройка следует [блоку в подвале](#блок-в-подвале-страницы-входа) во всех правилах, кроме одного:
те же уровни (эта глобальная секция, запись клиента, тенант — не запись `ui_config`), пустое значение
ничего не задаёт, тот же допустимый формат и тот же исход недопустимого значения, значение — Natural
Key. Исключение — перевод вне формата: страница тогда показывает собственный текст ядра, а не пустоту,
потому что блок стоит на месте выбора канала и пустой оставил бы пользователя без слова о том, что
делать дальше. В лог уходит предупреждение с именем настройки и языком.

Собственного стиля у настройки нет. Оформляйте блок своей таблицей стилей (`CustomCssPath`) через его
класс `veriqa-hop-instruction`; правила самой страницы задают только цвет текста, размер и отступы.

## CSS-переменные

Страницы стилизуются через CSS-переменные с префиксом `--veriqa-*`. Их можно переопределить
своим CSS. Ниже — основные имена; полный набор объявлен в таблице стилей пакета и включает,
помимо перечисленных, шкалу отступов `--veriqa-spacing-*` и цвета служебных плашек:

```css theme={null}
:root {
  /* Primary — нейтральный канон по теме (светлая: #111827, тёмная: #f9fafb);
     клиентский цвет применяется только в пресете Branded */
  --veriqa-primary: #111827;
  --veriqa-on-primary: #ffffff;
  /* Поверхности и фон (светлая тема) */
  --veriqa-bg: #ffffff;
  --veriqa-surface: #f9f9fa;
  --veriqa-surface-variant: #e2e2e3;
  --veriqa-surface-container: #eeeeef;
  /* Текст */
  --veriqa-text: #1a1c1d;
  --veriqa-muted: #4c4546;
  --veriqa-soft: #767071;
  /* Семантические цвета */
  --veriqa-success: #16a34a;
  --veriqa-error: #dc2626;
  --veriqa-warning: #eab308;
  /* Скругления, типографика, анимации */
  --veriqa-radius: 8px;
  --veriqa-radius-lg: 12px;
  --veriqa-font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
  --veriqa-font-mono: "JetBrains Mono", Consolas, monospace;
  --veriqa-transition-fast: 160ms ease;
}
```

## Внешний CSS

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

Стили подключаются ко **всем** страницам, которые генерирует Veriqa: к окну входа, к странице
подтверждения входа, к странице истёкшей ссылки и к страницам входа по email. Ваш CSS красит их
одинаково — отдельной настройки для служебных страниц нет.

```json appsettings.json theme={null}
{
  "Veriqa": {
    "AuthPageDesign": {
      "CustomCssPath": "/css/veriqa-custom.css",
      "CssSriHash": "sha384-..."
    }
  }
}
```

Задавайте `CssSriHash` (Subresource Integrity), если стили подключаются с внешнего адреса —
внешний ресурс на странице входа по умолчанию под запретом.

## Конфигурации UI по клиенту (ui\_config)

Помимо глобального оформления, можно задать **именованные конфигурации UI** и привязать их к
конкретному OIDC-клиенту. Каталог записей — словарь `Records` секции `Veriqa:UiConfigurations`
(«код записи → оформление»), то есть полный путь записи — `Veriqa:UiConfigurations:Records:<код>`;
запись переопределяет глобальное оформление для своего кода.

```json appsettings.json theme={null}
{
  "Veriqa": {
    "UiConfigurations": {
      "Records": {
        "brandA": {
          "Preset": "Branded",
          "PrimaryColor": "#FF5500",
          "LogoUrl": "https://your-domain.com/logo.png",
          "BrandName": "Acme",
          "Title": "Вход в кабинет",
          "TtlSeconds": 300
        }
      }
    },
    "OpenIddict": {
      "Clients": [
        {
          "ClientId": "my-app",
          "DefaultUiConfig": "brandA",
          "AllowedUiConfigs": [ "brandA" ]
        }
      ]
    }
  }
}
```

* `DefaultUiConfig` — код записи, применяемой по умолчанию для клиента.
* `AllowedUiConfigs` — коды, которые клиенту разрешено запрашивать. Код из этого списка валиден
  и без записи в каталоге — тогда берётся глобальное оформление.

## Локализация

Страница входа использует подход **Natural Keys**: ключ локализации — английский текст,
базовый язык — `en` (перевод для него не нужен). Реестр поддерживаемых языков строится из
locale-файлов, которые поставляет **ваш хост**: JSON-словари `{язык}.json` в `wwwroot/locales`
(путь настраивается через `Veriqa:Channels:Localization:LocalesPath`) плюс базовый `en`. Из коробки
страница отображается на английском; чтобы добавить язык, положите его `{язык}.json` в каталог
локалей — изменения кода не требуются. Русский и китайский переводить не нужно вовсе: они уже есть в
поставке — см. [Готовые переводы](#готовые-переводы) ниже. Сам каталог необязателен: хост без него
пишет на старте `Locale files directory not found — channel messages are sent in the base language.`,
и это предупреждение ожидаемо.

### Готовые переводы

Весь каталог — 106 ключей, всё, что пользователь читает при аутентификации, — публикуется отдельным
пакетом без сборок: `Veriqa.Core.ChannelLocales`. В нём лежат `en.json`, `ru.json`, `zh.json` и
`pseudo.json` (это локаль для проверки вёрстки, а не язык для продакшена: её можно получить явным
`Accept-Language`, но как `DefaultLanguage` она отклоняется).

```xml PackageReference theme={null}
<PackageReference Include="Veriqa.Core.ChannelLocales" Version="0.6.0" />
```

<Warning>
  **Сам по себе пакет язык не включает.** Его targets кладут файлы рядом с вашей входной сборкой, в
  каталог `veriqa-channel-locales/`, и **намеренно не** в `wwwroot/locales`: каталог ядра не должен
  затирать ваш — их сравнивают, а не сливают. Пока не сделан один из двух шагов ниже, хост
  по-прежнему отвечает на базовом языке.
</Warning>

Использовать можно двумя способами:

* **скопировать** нужные файлы из `veriqa-channel-locales/` в свой каталог локалей. Дальше это ваши
  файлы, и любую формулировку в них можно править;
* **указать на него**: задать в `Veriqa:Channels:Localization:LocalesPath` тот каталог, который
  разложил пакет. Относительный путь отсчитывается от **content root** хоста, поэтому указывайте
  путь, верный для вашего способа запуска, — или абсолютный. Править там нечего: следующая версия
  пакета заменит эти файлы целиком.

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

### Какой язык получит пользователь

У страницы входа и у сообщений в канале **разные** источники языка, и различаться они могут вполне
законно.

**Страница входа** идёт от запроса: сначала OIDC-параметр `ui_locales` — явный выбор, сделанный на
стороне доверяющей стороны, — затем `Accept-Language` браузера. Значение, не совпавшее ни с одним
языком реестра, уходит в язык по умолчанию:

```json appsettings.json theme={null}
{
  "Veriqa": {
    "Localization": {
      "DefaultLanguage": "en"
    }
  }
}
```

`DefaultLanguage` валидируется при старте: значение должно входить в реестр (locale-файлы
хоста + `en`), иначе приложение не стартует. Например, `"DefaultLanguage": "ru"` требует,
чтобы хост поставлял `wwwroot/locales/ru.json`.

Язык, на котором отрисована страница, едет **на транзакции**, и к нему откатывается каждая следующая
страница и каждое сообщение этого входа — в том числе страница автопоста `form_post`, которой
доставляется ответ авторизации: она отображается на нём, а не определяет язык по заголовку браузера
заново. `Accept-Language` с языком по умолчанию остаётся фолбэком для ответов, у которых транзакции
не было, — например, для ошибок, применяемых к `redirect_uri`. У подтверждения, созданного
[сервер-сервер](/docs/ru/reference/confirmation-api), страницы нет вовсе — там едет поле `locale` запроса.

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

| Канал | Сообщает язык получателя |
| - | - |
| Telegram | да — язык, который Telegram указывает для пользователя |
| MAX | да — `user_locale` из обновления |
| WhatsApp | нет — берётся локаль транзакции |
| Email | нет — см. ниже |

**Email — третий случай**, потому что его тексты читают не в мессенджере. Письмо входа уходит до
того, как кто-либо ответил, поэтому профиля, у которого можно спросить язык, ещё нет: письмо
формулируется на языке запроса, по которому его отправляли (`Accept-Language` того браузера), с
откатом на локаль транзакции. А страница подтверждения, которую открывает magic link, поступает
наоборот: она предпочитает локаль транзакции заголовку `Accept-Language` браузера почтового клиента —
чтобы совпасть с окном входа, в котором пользователь начал.

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

### Тексты каналов и писем — те же locale-файлы

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

Основные ключи:

| Ключ (он же английский текст) | Где виден |
| - | - |
| `Confirm ✅` · `Decline ❌` | кнопки под запросом подтверждения (Telegram, WhatsApp, MAX) |
| `Sign-in confirmed ✅` · `Sign-in declined ❌` | статус после ответа пользователя **при входе** в тех же каналах |
| `Sign-in confirmed` · `Sign-in declined` | то же для сторонних каналов при входе — платформа может не отображать эмодзи |
| `Something went wrong ⚠️ Go back to the sign-in page and start again.` · `Something went wrong. Go back to the sign-in page and start again.` | ошибка обработки ответа — текст канала, от типа транзакции не зависит (вариант с эмодзи — в тех же каналах, без — в сторонних) |
| `Too many attempts. Wait a minute and start the sign-in again.` | ответ, когда исчерпан лимит попыток пользователя — он советует подождать, а не начать заново: новая попытка на исчерпанном лимите будет отклонена |
| `Sign-in time expired ⌛ Go back to the sign-in page and start again.` · `Sign-in time expired. Go back to the sign-in page and start again.` · `Sign-in time expired. Start the sign-in again from the application.` | статус **при входе**, когда время на подтверждение вышло — в тех же каналах, в сторонних каналах и на confirm-странице Email соответственно |
| `Sign-in time expired ⌛` | тот же статус на страницах, которые отдаёт сам Veriqa |
| `Confirmed ✅` · `Declined ❌` · `Time expired ⌛` | квитанция подтверждения действия (не входа); текст нейтрален и одинаков на всех поверхностях |
| `Confirm sign-in` | запрос подтверждения без контекста инициатора |
| `Confirm sign-in to "{app}"` · `… — request from {browser} on {os}` · `… on {os}, {region}` | запрос с контекстом инициатора; набор `{слотов}` в переводе менять нельзя — при расхождении используется английский текст |
| `Sign in to {app}` | тема письма Email-канала |
| `Approve sign-in` · `Or scan the QR code to finish signing in on another device:` · `Direct link:` · `Do not forward this mail: the link grants sign-in access to your account.` | видимые строки письма: подпись кнопки, подписи QR-блока, подвал |

<Note>
  Это **продуктовые дефолты инсталляции**: locale-файлы — ассет хоста, один комплект на процесс.
  Текст «как у конкретного тенанта» задаётся не здесь.
</Note>

<Warning>
  **Предложения письма, в которых стоит подстановка, живут не здесь.** Заголовок, вступление, строка
  срока действия и строка с деталями инициатора несут `{app}`, `{valid_until}`, `{browser}` — они
  часть **текста варианта** шаблона `sign-in-mail`, а не ключи локализации: разметка письма
  языконезависима и переводится не копиями, а слотами. Перевести эти предложения — значит задать свою
  лестницу вариантов письма (см. [Своё тело письма](/docs/ru/guides/channels#своё-тело-письма)); правка
  locale-файла на них не действует.
</Warning>

### Разные переводы одной фразы: контекстный суффикс

Ключ — это английский текст, поэтому одна фраза даёт один перевод на язык. Если одну и ту же
английскую фразу нужно перевести **по-разному** в разных местах (например, для одного канала —
иначе, чем для остальных), к ключу добавляется контекстная метка через `|`:

```json ru.json theme={null}
{
  "Sign-in confirmed ✅": "Вход подтверждён ✅",
  "Sign-in confirmed ✅ | telegram": "Вход в Telegram подтверждён ✅"
}
```

Что нужно знать:

* **метка — служебная, пользователь её не видит.** Если перевода нет, показывается часть ключа
  **до последнего** `|` — то есть чистый английский текст, а не `… | telegram`;
* **не записывайте ключ сам в себя** (`"X | telegram": "X | telegram"`) — это единственный способ
  показать метку пользователю;
* **`|` зарезервирован** под метку: обычный текст интерфейса не должен его содержать;
* **заводите контекст только при реальном расхождении.** По умолчанию одна фраза — один ключ без
  суффикса; плодить контексты там, где перевод совпадает, не нужно.

### Тексты, заданные настройкой, — тоже ключи локализации

Тексты, которые задаёт запись `ui_config`, проходят **ту же** резолюцию, что и встроенные:
заданное значение и есть Natural Key. Отдельного «нелокализуемого» текста в продукте нет.

| Настройка записи `ui_config` | Что задаёт |
| - | - |
| `Title` | заголовок окна входа |
| `Instruction` | инструкция на окне входа |
| `BrandName` | имя бренда в строке бренда окна входа, в любом пресете, кроме `Minimal` |

<Note>
  **Текст подтверждения задаётся не полем записи, а шаблоном сообщения.** Формулировка запроса
  подтверждения — это **сообщение**, и задаётся она лестницей его шаблона:
  `Veriqa:MessageTemplates:confirmation-prompt:Templates` (устройство ключа и его уровни — в
  [Справочнике конфигурации](/docs/ru/reference/configuration#veriqamessagetemplates)). Правило этой секции
  на неё распространяется полностью: заданный текст — такой же Natural Key, локализацию он не
  обходит. Если формулировка нужна своя у конкретного приложения, задайте её в записи клиента
  (уровень `Application`), а не глобально.
</Note>

Что это значит на практике:

* **значение без записи в locale-файлах выводится как есть.** Ключ — это и есть текст, поэтому
  промах ничего не ломает: пользователь видит ровно ту строку, которую вы задали, просто
  непереведённую. Для базового языка запись не нужна вообще;
* **чтобы текст перевёлся, заведите его ключ в своих locale-файлах** — тех же
  `wwwroot/locales/{язык}.json`, что и остальные тексты. Каталог локалей — ассет хоста, а хост
  это вы: ключи ваших записей `ui_config` заводятся там наравне с продуктовыми.

**Как задать значение, которое заведомо переводится.** Отдельного маркера для этого нет —
используется штатный контекстный суффикс (см. выше): значением настройки задаётся строка
`<текст> | <метка>`, а перевод кладётся в locale-файлы.

```json appsettings.json theme={null}
{
  "Veriqa": {
    "UiConfigurations": {
      "Records": {
        "brandA": {
          "Title": "Sign in to the portal | brandA",
          "Instruction": "Choose a channel | brandA"
        }
      }
    }
  }
}
```

```json ru.json theme={null}
{
  "Sign in to the portal | brandA": "Вход в портал",
  "Choose a channel | brandA": "Выберите канал"
}
```

Что нужно знать:

* **метка — служебная**: всё после **последнего** `|` пользователь не видит. Значение
  `Privacy | Terms` выведется как `Privacy` — настройка, текст которой сам должен содержать `|`,
  этой идиоме не подходит;
* **промах не даёт отказа**: без записи в locale-файле пользователь увидит
  `Sign in to the portal` — правильный текст, просто непереведённый. Ни метки, ни пустой строки,
  ни ошибки;
* **базовый текст выбираете вы**: значение `Политика | authpage` без перевода на английский
  покажет англоязычному пользователю русский. Поведение предсказуемое, но базовым текстом
  разумно делать формулировку на базовом языке (`en`);
* **метка нужна не всегда.** Если ваш текст и так уникален, переводите его без суффикса — ключом
  будет само значение. Метка страхует от совпадения с продуктовым ключом и разводит одну и ту же
  фразу по разным записям `ui_config`.

<Warning>
  Заданное значение ищется в locale-файлах. Если оно **случайно совпало** с ключом продуктового
  текста — например, `Title` задан ровно как встроенный заголовок окна входа, — на неанглийских языках
  подставится продуктовый перевод, а не ваша строка. Если такой
  текст должен оставаться вашим, добавьте к значению контекстный суффикс: ключ перестанет совпадать
  с продуктовым, а пользователь увидит ту же строку до последнего `|`.
</Warning>

## Точки расширения

Ключевые сервисы заменяются своей реализацией — регистрируйте их в блоке `AddVeriqaAuthServer`.

### Рендерер страницы входа

Реализуйте `IAuthPageRenderer` для полного контроля над HTML страницы аутентификации:

```csharp theme={null}
public sealed class MyAuthPageRenderer : IAuthPageRenderer
{
    public string RenderAuthPage(AuthPageRenderContext context) => "<html>...</html>";
}

// Регистрация
authServer.UseAuthPageRenderer<MyAuthPageRenderer>();
```

`AuthPageRenderContext` приходит с **уже вычисленными** значениями: `Design` — эффективный дизайн
страницы (глобальная секция, переопределённая уровнями владения по полям), `Title` и
`Instruction` — тексты, если их задал какой-то уровень (иначе `null`, и текст ваш),
`QrVisibility` — эффективная видимость QR. Свой рендерер ничего не складывает сам и потому не
может потерять переопределение.

`Title` и `Instruction` приходят **ключами локализации**, а не готовым текстом (см.
«Тексты, заданные настройкой, — тоже ключи локализации»). Встроенный рендерер прогоняет их через
locale-файлы; свой рендерер делает то же вызовом
`IConfirmationPromptLocalizer.ResolveOrBaseText(значение, язык)` — иначе заданный уровнем текст
перестанет переводиться, а значение с контекстным суффиксом покажет метку.

### Claims-маппер

Реализуйте `IClaimsMapper` для своего маппинга resolved identity в OIDC claims:

```csharp theme={null}
public sealed class MyClaimsMapper(IMyDirectory directory) : IClaimsMapper
{
    public async Task<Result<IReadOnlyList<Claim>>> MapToClaimsAsync(
        ResolvedIdentitySnapshot resolvedIdentity,
        CompletionSnapshot completion,
        ClaimsMappingContext context,
        CancellationToken cancellationToken = default)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, resolvedIdentity.Subject)
        };

        // Дополнительные claims канала лежат в словаре resolvedIdentity.Claims
        if (resolvedIdentity.Claims?.TryGetValue("name", out var name) is true)
        {
            claims.Add(new(ClaimTypes.Name, name));
        }

        // Маппинг идёт на потоке запроса, поэтому обогащение claims из своей БД или каталога —
        // обычный await, без sync-over-async
        var profile = await directory.LoadAsync(resolvedIdentity.Subject, cancellationToken);
        claims.Add(new(ClaimTypes.Role, profile.Role));

        return Result<IReadOnlyList<Claim>>.Success(claims.AsReadOnly());
    }
}

// Регистрация
authServer.UseClaimsMapper<MyClaimsMapper>();
```

`UseClaimsMapper<T>()` регистрирует маппер как **scoped** — по экземпляру на запрос авторизации.
Поэтому scoped-зависимость (`DbContext`, unit of work, любой пер-реквестный сервис) берётся прямо
в конструктор, как `IMyDirectory` выше: `IServiceScopeFactory` не нужен. Встроенный маппер остаётся
singleton — он без состояния и без зависимостей.

`ClaimsMappingContext` говорит, для кого строятся claims:

| Член | Значение |
| - | - |
| `ClientId` | `client_id` вызывающего RP; `null`, если запрос его не несёт |
| `TenantId` | тенант запроса; `null`, если уровень тенанта не задан |
| `Scopes` | scopes, запрошенные RP |

Атрибуция — это то, что нужно pairwise-субъекту (OIDC Core 1.0 §8.1): один и тот же пользователь
получает разный `sub` для разных RP. Встроенный маппер её не использует — выдаваемый им `sub`
глобален по всем RP.

### Обработчик событий транзакции

Реализуйте `ITransactionEventHandler`, чтобы реагировать на события транзакций (например,
подключать канал уведомлений при входе):

```csharp theme={null}
authServer.AddTransactionEventHandler<MyTransactionEventHandler>();
```

Обработчик добавляется **рядом** со встроенными, а не вместо них: порт потребляется как
`IEnumerable<ITransactionEventHandler>`, поэтому каждое событие получают все зарегистрированные
обработчики. Встроенные продолжают работать — окно входа по-прежнему получает событие завершения,
журнал аудита — свои записи.

### Идемпотентность создания транзакции

`CreateTransactionRequest` принимает пару `IdempotencyKey` + `IdempotencyScope`: повтор запроса с
той же парой возвращает уже созданную транзакцию, а не заводит вторую (при расхождении параметров
создания — отказ `idempotency_key_conflict`).

Из встроенных входов пару заполняет `POST /api/transaction/confirmation` — создание транзакции
подтверждения по client credentials: ключ берётся из поля `idempotency_key` тела запроса, а область —
из аутентифицированного `client_id`, поэтому одинаковый ключ двух приложений означает два разных
запроса. Повтор с тем же ключом отвечает той же транзакцией и заново собранной точкой входа.
Браузерный authorization endpoint пару не заполняет: там повторный запрос означает новый вход, а не
повтор прежнего. Если вы управляете движком напрямую, пару заполняете вы.

## Дальше

<CardGroup cols={2}>
  <Card title="Хранилища" icon="database" href="/docs/ru/guides/storage">
    Стор транзакций и база OpenIddict: InMemory / EF Core / Redis, PostgreSQL.
  </Card>

  <Card title="Контекст инициатора" icon="location-crosshairs" href="/docs/ru/concepts/initiator-context">
    Что показывается в подтверждении: кто и откуда инициировал вход.
  </Card>

  <Card title="Hardening к продакшну" icon="shield-check" href="/docs/ru/guides/hardening">
    Чек-лист перед выпуском в продакшн.
  </Card>

  <Card title="Быстрый старт .NET" icon="rocket" href="/docs/ru/quickstart/dotnet">
    Базовая интеграция за несколько минут.
  </Card>
</CardGroup>


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