Files
HY2XS_flamy/docs/admin/15-ui-contracts.md
T
Crimson ab788725cf fix(env): контракт был шире домена, который принимает systemd
Разбор предыдущего прохода со сверкой по исходникам systemd v257.13 — той самой
линии, что стоит на Debian 13. Тема та же и слоем глубже: контракт, объявленный
шире, чем его принимает чужая сторона. Прошлый проход сделал транспорт lossless
для значений, которые systemd принимает, но не спросил, какие значения он
принимает вообще.

1. Домен значений файла окружения

Перед тем как принять пару, systemd прогоняет ключ и значение через
utf8_is_valid (src/basic/env-file.c, check_utf8ness_and_warn), и отказ там
возвращает -EINVAL — то есть НЕзагруженный EnvironmentFile= и юнит, который не
стартует, а не предупреждение. unichar_is_valid (src/basic/utf8.c) отвергает
суррогаты, U+FDD0..U+FDEF и все code points вида *FFFE/*FFFF, а сам
utf8_is_valid — встроенный NUL и невалидный UTF-8.

Пароль "abcde" + U+FDD0 — шесть символов, восемь байт, ни одного управляющего —
проходил панель, оркестратор, DTO и хеширование, записывался в hy2xs.env, и
после этого админка не поднималась. Тот же класс дефекта, ради уничтожения
которого контракт и существует, только слоем ниже.

Введён IsEnvTransportableText (Go) / isEnvTransportable (TS), повторяющий
множество systemd точно — не шире и не уже. Отдельно отвергаются одиночные
суррогаты: строка JavaScript вправе их содержать, а TextEncoder молча заменяет
непарный суррогат на U+FFFD, то есть без проверки в файл уехал бы ДРУГОЙ
секрет, а не отказ.

Заодно разделены домен транспорта и политика продукта. Проверка отвергала C0 и
DEL с формулировкой «формат управляющих символов не несёт» — неправда: внутри
двойных кавычек перевод строки накапливается как обычный байт и переживает
round-trip. Именно эта подмена и позволила проверке не знать про noncharacters.
Политика HY2XS теперь запрещает категорию Cc целиком (была шире кода ровно на
C1) плюс U+FEFF — последний отдельным решением продукта, а не форматом:
0xFEFF & 0xFFFE это 0xFEFE, и systemd такое значение принимает.

2. Рецепт восстановления выполнял env-файл как код

В docs/operations/12, раздел «Забыт пароль администратора», стояло
`set -a; . /etc/hy2xs/hy2xs.env; set +a`. Строка стала опасной ровно тогда,
когда файл научился нести произвольные значения. Для systemd
HY2XS_ADMIN_INITIAL_PASSWORD="$(...)" — буквальное значение: подстановок в
EnvironmentFile= нет вовсе. Но `.` обрабатывает файл bash, а bash внутри
двойных кавычек выполняет подстановку команд — от root, прямо в рецепте
восстановления доступа. Соседний раздел той же страницы при этом уже правильно
запрещал source/eval для bootstrap-admin.secret: документ запрещал действие и
тут же его предлагал.

Рецепт читает нужные значения как ДАННЫЕ. Поставлен гейт приёмки, запрещающий
возврат source/./eval над этими файлами в командах документации и в скриптах;
гейт смотрит только внутрь ```-блоков, чтобы объяснение, называющее убранную
конструкцию по имени, его не роняло.

3. Отказ приходил после мутаций хоста

Проверка транспорта жила только внутри renderRuntimeEnv, то есть срабатывала на
шаге «write runtime env» — уже после bootstrap оркестратора, установки пакетов
и раскладки файловой системы, — а read-only preflight-install говорил PASS: он
зовёт parseRuntimeEnv и ничего не рендерит. Детерминированно известная ошибка
конфигурации роняла операцию, оставив за собой изменённый хост, что прямо
противоречит контракту PHASE 0.

validateRuntimeEnvTransport вызывается теперь из parseRuntimeEnv и проходит по
ВСЕМ парам runtimeEnvEntries: ограничение принадлежит формату, а не полю
пароля, и HY2XS_ADMIN_CON_PASS сломал бы загрузку юнита так же.

4. Точность порта автомата и его описания

- в состоянии DOUBLE_QUOTE_VALUE_ESCAPE systemd пишет `c != '\n'`, а не
  проверку на любой перевод строки (в VALUE_ESCAPE — наоборот,
  strchr(NEWLINE, c)). Порт съедал и \<LF>, и \<CR>;
- комментарий обещал одно намеренное расхождение с systemd, а их два: кроме
  строки без `=`, HY2XS отказывает и на незакрытой кавычке в конце файла.
  Оба fail-closed и теперь названы оба.

Тесты: граничная таблица во всех слоях дополнена значениями вне домена
(U+FDD0, U+FDEF, U+FFFE, U+FFFF, U+1FFFF, U+10FFFF, невалидный UTF-8),
соседями диапазонов (U+FDCF, U+FDF0, U+FFFD, U+10FFFD), C1 и U+FEFF, одиночным
суррогатом. Добавлены TestEnvTransportDomainMatchesSystemd (домен не шире и не
уже) и TestProductPolicyIsWiderThanTransportDomain (домен и политика
различимы), а также проверки fail-closed порядка: parseRuntimeEnv отвергает
непригодную конфигурацию, проверяются все значения файла, запись и проверка
ходят по одному списку пар.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:38:57 +05:00

446 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Контракты панели
Свойства HY2XS admin, которые не проверяются ни типами, ни сборкой bundle и
потому ломались молча. Каждое из них закреплено тестом
(`tools/test/frontend-*.test.ts`) и гейтом приёмки.
Общее описание панели — [04-admin-panel.md](04-admin-panel.md).
---
## 1. Отрисовка иконок
**Правило.** Монохромная UI-иконка получает цвет ровно одним способом —
наследованием `currentColor` от компонента и темы. Ассет не содержит
литеральных цветов; цвет объявляется в CSS один раз, на `.svg-icon`.
**Запрещено:**
* литеральный `fill` / `stroke` / `stop-color` в монохромном ассете;
* цвет в инлайновом `style` внутри ассета;
* непустой `<style>` внутри ассета — его селекторы глобальны и красят чужие
иконки;
* растровое `<image>` — оно не подчиняется `currentColor` никогда;
* CSS-фильтр на `.svg-icon`;
* селектор по имени конкретной иконки (`[icon-class="…"]`);
* передача цвета параметром компонента.
**Многоцветные ассеты** объявляются явным списком `MULTICOLOR_ICONS` в
`SvgIcon/symbol.ts`. Их палитра — часть ассета, и проверка цвета к ним не
применяется. «Многоцветность» обязана быть решением, а не следствием того, что
иконку скачали с готовыми значениями `fill`.
**Система координат.** У каждого ассета обязан быть `viewBox` либо пара
`width`/`height`, из которой он синтезируется. Без неё `<use>` рисует иконку в
натуральную величину и обрезает её по размеру родительского `<svg>`.
**Почему цвета не переписываются в рантайме.** Источник истины — файл. Молчаливая
нормализация при сборке спрайта скрывала бы ровно тот дефект, который контракт
обязан делать видимым: добавленная с чёрным `fill` иконка выглядела бы
правильно и оставалась бы сломанной в исходниках.
**Что машина не докажет.** Фактический цвет на экране. Визуальная проверка
светлой и тёмной темы, состояний hover/active и свёрнутого меню остаётся ручной
и фиксируется в отчёте приёмки.
---
## 2. Структурированные ошибки
**Правило.** Панель не разбирает текст ответа. Отказ несёт код, а отказ по полю
— ещё и имя поля.
Форма ответа:
```json
{
"code": 50001,
"type": "no",
"message": "проверка данных не пройдена",
"errors": [
{ "code": "min_length", "field": "secret", "message": "…", "params": { "min": "6" } }
],
"data": null
}
```
* `code` — числовой код ответа (`model/constant/code.go`);
* `errors[].code` — причина (`model/constant/error.go`, `ErrCode*`);
* `errors[].field` — имя поля из JSON-тега; пусто для отказов уровня операции;
* `errors[].params` — числа правила, чтобы панель не заводила их вторую копию;
* `message` — человекочитаемый ответ для клиента без UI и запасной вариант для
кода, которого панель ещё не знает.
Границы числа и границы длины строки различаются кодом (`min` против
`min_length`), хотя тег валидатора у них один: оператору это разные фразы.
**Запрещено:**
* выводить код ответа сравнением текста сообщения;
* отдавать обобщённое `invalid` вместо описания полей;
* разбирать сообщение сервера на стороне панели.
**Локализация** строится по ключу `error.code.<код>` с параметрами правила.
Наборы ключей `ru` и `en` обязаны совпадать: забытый ключ не ломает ни типы, ни
сборку — vue-i18n молча отдаёт сам ключ, и оператор видит `error.code.min_length`
вместо фразы.
**Состояние сессии** сообщается кодами `unauthorized`, `session_expired`,
`token_invalid`, `account_disabled` при `code = 50401`. Панель по ним
показывает диалог и возвращает на форму входа.
**Вход администратора.** Неверные учётные данные всегда дают один код
`invalid_credentials`: «такого администратора нет» и «пароль не тот» обязаны
быть неразличимы снаружи. Отказ хранилища при этом остаётся системной ошибкой —
выдавать «неверный логин или пароль» при недоступной базе значит отправить
оператора искать несуществующую опечатку.
---
## 3. Необязательные поля и генерация секретов
**Правило.** Поле, объявленное необязательным, обязано принимать три
неразличимых состояния: отсутствует, пустая строка, одни пробелы.
Это НЕ следует автоматически из тега `omitempty`. В `go-playground/validator`
он не пропускает правило, если поле объявлено указателем и указатель не nil —
`hasValue` считает указатель на пустую строку «значением». Поэтому DTO,
у которых есть необязательные строковые поля, реализуют `dto.Normalizable`, и
слой контроллеров вызывает `Normalize()` между разбором тела и проверкой
правил.
**Граница проходит по каждому полю отдельно.** Общее правило «пусто → не
задано» молча ломает смысл:
| Поле | Пустое значение означает |
| --- | --- |
| `secret` при создании | сгенерировать |
| `secret` при изменении | не менять |
| `name` при изменении | не менять |
| `remark` | убрать пометку |
| `disabled = 0` | включён |
| `quotaBytes = 0` | нулевая квота |
**Генерация секрета принадлежит серверу.** Панель, подставляющая значение в
пустое поле, выполняла бы обещание «сгенерируем автоматически» ровно для одной
двери из четырёх: остаются прямой вызов API, импорт и будущие клиенты.
Единственная реализация — `service.GeneratePeerSecret`, на базе
`util.RandomString` (`crypto/rand` с отбрасыванием смещённых байтов). Тот же
вызов используется импортом.
**Имя пира** проверяется одним правилом `service.IsValidPeerName` на весь
продукт: в таблицу пиров ведут две двери, и они не имеют права требовать
разного.
---
## 4. Таблицы журнала
**Правило.** Колонка журнала объявляет свою ширину: служебные — через `width`,
содержательная — через `min-width`.
Без этого Element Plus делит доступную ширину между колонками практически
поровну. У журнала колонок три, поэтому уровень и время получали по трети
строки, а сообщение — единственное содержимое журнала — тоже треть.
**Сообщение переносится, а не обрезается.** У Hysteria в `msg` приезжает
диагностический JSON; строка, обрезанная многоточием, не отвечает ни на один
вопрос, ради которого страницу открыли.
**Обе страницы журнала построены на одном компоненте**
(`components/LogViewer`). Они были побайтово одинаковы и несли одни и те же три
дефекта в двух экземплярах — ширины, обработку отказа выгрузки и форму ответа.
Собственная `el-table-column` на странице журнала запрещена гейтом приёмки.
---
## 5. Выгрузка файлов
**Правило.** Сборка ссылки на скачивание существует в панели в единственном
экземпляре — `utils/download.ts`. Единственность проверяется контрактным
тестом по вхождению `createObjectURL`.
Копий было четыре, и все успели разойтись. Две из них ставили сетевой запрос
ПЕРЕД `try`:
```ts
const response = await exportApi(...); // отказ сюда не попадает
try { ... } catch (e) { /* empty */ }
```
то есть отказ самого запроса не ловился вовсе, а всё внутри глушилось молча:
оператор не получал ни файла, ни причины. Третья падала на `split(...)` при
отсутствующем `Content-Disposition` — и это исключение тоже глушилось.
**Отказ выгрузки показывает сама страница.** Бинарный ответ не проходит через
общий разбор конверта: у `Blob` нет полей `code` и `errors`, поэтому
перехватчик по нему фразы не даст.
---
## 6. Меню действий над строкой
**Правило.** Пункты `el-dropdown` объявляют `command`; обработчик — один, на
`el-dropdown`.
`@click` на каждом пункте не запрещён самим Element Plus, но `command` является
штатным контрактом именно для меню действий, и при нём невозможно добавить
пункт, забыв его подключить. Обе половины проверяются контрактным тестом:
наличие `@command` и отсутствие `@click` на пунктах.
**Частичный результат операции отличается от отказа кодом.** Отзыв доступа к
VPN состоит из двух половин — записи в базе и разрыва активной сессии, — и
первая может примениться без второй. Панель обязана распознать
`peer_disconnect_failed` по коду, показать его предупреждением, а не ошибкой, и
ОБНОВИТЬ строку: состояние в базе уже изменилось. Показ его как обычной ошибки
подтолкнул бы оператора к выводу, прямо противоположному истине.
---
## 7. Подтверждения
**Правило.** Отмена подтверждения — это ответ оператора, а не ошибка.
`ElMessageBox` отклоняет промис при нажатии «Отмена». `await
ElMessageBox.confirm(...)` без разбора отказа оставляет необработанное
отклонение промиса на каждую отмену. Единственный прямой вызов на странице
пиров живёт внутри `confirmAction`, переводящей отмену в обычное `false`; это
закреплено тестом.
---
## 8. Панель не выдумывает состояние
**Правило.** Отсутствие данных показывается как отсутствие данных.
Нарушений было три, и все три давали оператору ответ, противоположный истине.
**Страница конфигурации** строила форму merge'ем ответа сервера поверх полного
объекта значений по умолчанию, поэтому отсутствующая секция `trafficStats`
показывалась как `listen: :9999`, а явное `speedTest: false` считалось
ненастроенным и прятало свою вкладку. Экран, существующий ради диагностики
дрейфа, этот дрейф скрывал. Теперь ответ отличает «не задано» (`null`) от
значения, а секции вне production-профиля перечисляются отдельным списком
расхождений.
**Список пиров** получал `onlineUsers, _ := Hysteria2Online()` и при любом сбое
control plane показывал всех пиров офлайн. Теперь страница несёт
`onlineState: ok | unavailable` — один признак на ответ, а не флаг в каждой
строке, — и при `unavailable` показывает «онлайн неизвестен», а число устройств
как `?`.
**Дашборд** выводил доступность Traffic Stats API из ответа `systemctl` и умел
утверждать «служба остановлена» и «API доступен» одновременно. Теперь это два
независимых факта, а у состояния службы три значения: `active`, `inactive`,
`unknown`.
**Запрещено:**
* подставлять значение по умолчанию вместо отсутствующего в ответе;
* показывать `0`, `false` или «офлайн» там, где данные не получены;
* выводить один факт из другого, если их можно спросить по отдельности.
**Секреты на читающем экране.** Read-only страница не имеет права быть щедрее
санитизированной выгрузки того же документа. Вместо значения показывается
диагностический факт: «задан» / «не задан» для паролей и секретов, имена
параметров без значений для `acme.dns.config`, адрес с вырезанным
`access_token` для auth-URL.
**Редакторы без сохранения запрещены.** Конфигом владеет оркестратор, маршрутов
записи в API нет, поэтому поля ввода на странице конфигурации обещают действие,
которого не существует. Проверяется контрактным тестом: в шаблоне нет ни
`el-input`, ни `el-switch`, ни `el-select`, ни `v-model`.
---
## 9. Атрибуция
Адрес атрибуции объявлен один раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению. Он не является операторской настройкой: ни `hy2xs.env`,
ни config API, ни таблица `config`, ни настройки панели его не содержат и не
могут переопределить.
---
## 10. Форма входа
**Правило.** Панель не имеет права быть строже сервера. Значение, которое
сервер принял бы, форма обязана отправить.
### Где живёт контракт
Требования к логину и паролю администратора объявлены **один раз**, в
`apps/credential/admin.go`:
| Что | Значение | Владелец |
| --- | --- | --- |
| Длина логина | 6-32 символа | `AdminUsernameMinLength` / `AdminUsernameMaxLength` |
| Набор символов логина | `a-z A-Z 0-9 !@#$%^&*()_+,-./:;<=` | `AdminUsernameCharset` |
| Длина пароля | 6-64 символа Unicode | `AdminPasswordMinLength` / `AdminPasswordMaxLength` |
| Размер пароля | не более 72 байт в UTF-8 | `AdminPasswordMaxBytes` |
| Домен пароля | значение, загружаемое systemd из `EnvironmentFile=` | `IsEnvTransportableText` |
| Набор символов пароля | не ограничен, кроме `Cc` и `U+FEFF` | `hasForbiddenRune` |
| Пробелы по краям пароля | часть значения, не снимаются | — |
Контракт живёт в отдельном **leaf-пакете**, а не в `service`, и это не
вкусовщина. Его зовут `util.HashPassword` и слой данных при создании первой
учётной записи, а `service` импортирует `util` — обратный импорт был бы
циклическим. Пока контракт лежал в `service`, `HashPassword` завёл собственную
проверку `len(strings.TrimSpace(password)) < 6`, и она разошлась с остальным
продуктом.
Остальные стороны продукта только повторяют этот контракт, и каждая копия
сверяется с оригиналом тестом, читающим Go-исходник:
* панель — `apps/frontend/src/constants/credentials.ts`
(`tools/test/frontend-contract.test.ts`);
* оркестратор — `orchestrator/src/config/profile.ts`
(`orchestrator/test/admin-credentials.test.ts`);
* правила валидатора — `credentialStr` и `adminPassword` в
`apps/controller/validator.go`, длина живёт ВНУТРИ них.
### Почему у пароля нет набора символов
Пароль назначает оператор — установкой через `HY2XS_ADMIN_INITIAL_PASSWORD` или
формой смены. Сервер его набор не проверяет нигде: значение сравнивается с
bcrypt-хешем. Ограничение набора на форме не защищает ничего и умеет только
отвергнуть пароль, который сервер принял бы.
Исключения два, и они **разного происхождения**. Их важно не путать: одно
описывает чужое ограничение, другое — наше решение.
**Домен systemd — не наше правило.** Первый пароль администратора уезжает в
`/etc/hy2xs/hy2xs.env`, который systemd читает как `EnvironmentFile=`. Перед тем
как принять пару, systemd прогоняет ключ и значение через `utf8_is_valid`
(`src/basic/env-file.c`, `check_utf8ness_and_warn`), и отказ там возвращает
`-EINVAL`: это **незагруженный файл окружения**, то есть юнит, который не
стартует, а не предупреждение. `unichar_is_valid` (`src/basic/utf8.c`)
отвергает:
```text
U+D800..U+DFFF суррогаты
U+FDD0..U+FDEF noncharacters
(cp & 0xFFFE) == 0xFFFE U+FFFE, U+FFFF, U+1FFFE, … U+10FFFF
```
плюс встроенный NUL и любую невалидную последовательность UTF-8.
Пока контракт этого не знал, пароль `abcde` + `U+FDD0` — шесть символов, восемь
байт, ни одного управляющего — проходил панель, оркестратор, DTO и хеширование,
записывался в `hy2xs.env`, и после этого админка не поднималась. Тот же класс
дефекта, ради уничтожения которого контракт и существует, только слоем ниже.
На стороне панели и оркестратора отдельно отвергаются **одиночные суррогаты**:
строка JavaScript вправе их содержать, а `TextEncoder` молча заменит непарный
суррогат на `U+FFFD`. Без этой проверки не было бы отказа — было бы тихое
изменение пароля по дороге в файл.
**Политика HY2XS — наше решение.** Сверх домена systemd запрещены управляющие
символы Unicode целиком (категория `Cc`: `U+0000..U+001F`, `U+007F`,
`U+0080..U+009F`) и `U+FEFF`. Формат их несёт — `0xFEFF & 0xFFFE` это `0xFEFE`,
и systemd такое значение принимает, — но ни один из них невозможно ни увидеть в
поле ввода, ни повторить при следующем входе: они умеют ровно одно — запереть
оператора снаружи.
### Почему границ у пароля две
Их две потому, что они в **разных единицах**, и вывести одну из другой нельзя.
Граница в символах — та, которую видит оператор. Она считается в code points, а
не в байтах и не в единицах UTF-16: `go-playground/validator` считает `min`/`max`
на строке через `utf8.RuneCountInString`, и «пароль из 64 символов» обязано
означать одно и то же для латиницы и для кириллицы.
Граница в байтах — та, которую ставит bcrypt. `golang.org/x/crypto/bcrypt`
отвечает `ErrPasswordTooLong` на пароль длиннее **72 байт**
(`GenerateFromPassword`, `bcrypt.go:96`). У 64 символов длина от 64 до 256 байт:
```text
64 x "a" = 64 байта -> принимается
36 x "я" = 72 байта -> принимается (граница)
37 x "я" = 74 байта -> отвергается
18 x "😀" = 72 байта -> принимается (граница)
19 x "😀" = 76 байт -> отвергается
64 x "я" = 128 байт -> отвергается
```
Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72
байт» — верно только для ASCII, — а сопровождающий текст утверждал, что bcrypt
«молча отбрасывает остаток». Так вела себя редакция пакета до v0.28;
действующая отвечает ошибкой. Следствие: пароль из 64 кириллических букв
проходил панель, оркестратор и DTO, а отказ приходил из хеширования — системной
ошибкой на штатной смене пароля, а после установки — отсутствием администратора
вовсе.
### Панель считает длину так же, как сервер
Встроенных `min`/`max` Element Plus у пароля **нет**. Правила формы Element Plus
делегирует библиотеке `async-validator`, а та сравнивает `min`/`max` строки с
`String.prototype.length`, то есть считает единицы UTF-16:
```text
"😀😀😀" Go: 3 руны -> сервер отказывает (минимум 6)
JS: length === 6 -> форма считала минимум достигнутым
```
Панель отправляла бы заведомо отвергаемый пароль и не могла бы объяснить отказ.
Поэтому у обеих форм одно общее правило `adminPasswordFormRule`, и оно считает
code points итератором строки, а байты — через `TextEncoder`.
### Границы обеих форм обязаны совпадать
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
требование. Расхождение здесь запирает оператора снаружи после операции,
которую панель ему же и предложила: пароль длиннее предела формы входа
назначается успешно и после этого не вводится.
### Границы обеих форм обязаны совпадать
Форма входа и форма смены пароля предъявляют к паролю **одно и то же**
требование. Расхождение здесь запирает оператора снаружи после операции,
которую панель ему же и предложила: пароль длиннее предела формы входа
назначается успешно и после этого не вводится.
### Индикация ошибки принадлежит видимому полю
Element Plus рисует состояние отказа на `el-input__wrapper` правилом
```text
.el-form-item.is-error .el-form-item__content .el-input__wrapper
```
то есть селектором из четырёх классов. На форме входа видимое поле — это
`el-form-item`: внутрь одного поля кладутся иконка, ввод и переключатель
видимости пароля, а `el-input` занимает лишь среднюю часть. Поэтому штатная
индикация ложится вокруг одного лишь ввода и ни одной стороной не совпадает с
границей поля.
**Правило.** Там, где рамка поля нарисована на `el-form-item`, состояние отказа
рисуется на нём же, а штатная тень враппера гасится селектором, который
повторяет чужой и добавляет атрибут scoped-стиля — то есть выигрывает
специфичностью, а не `!important`. Сообщению об отказе оставляется место под
полем: `el-form-item__error` позиционируется абсолютно от `top: 100%` и живёт
вне рамки.
**Что машина не докажет.** Совпадение рамки с границей поля на экране. Проверка
остаётся ручной и фиксируется в отчёте приёмки; тест закрепляет только наличие
правил, которые её обеспечивают.
### Требование называется, а не нарушается
Фразы `credentials.usernameFormat` и `credentials.passwordFormat` перечисляют
границы и набор символов. Набор логина приходит из `HY2XS_ADMIN_USER`, и
посмотреть его в панели больше негде — сообщение «Неверный формат логина» не
давало оператору ни одного способа узнать, что от него хотят.
Серверная причина `credential_format` несёт те же значения в `params`
(`min`, `max`, `charset`), и фраза панели обязана их использовать: правило одно
и проверяет и длину, и набор, поэтому описывать его только через символы —
значит описывать отказ по длине неверно.
У пароля причина отдельная — `admin_password_format` с `params`
(`min`, `max`, `maxBytes`), — и фраза обязана называть **обе** границы. Пароль
из 40 эмодзи укладывается в 64 символа и не укладывается в 72 байта: сообщение
«не длиннее 64 символов» отправило бы оператора сокращать пароль, отвергнутый
не за это.