fix(admin): закрыть обещания панели, которые продукт не выполнял

Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.

Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».

Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.

Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.

Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.

Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.

Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.

docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
2026-09-01 07:27:15 +05:00
parent a1f0db22c2
commit c0a43ae915
86 changed files with 6237 additions and 1819 deletions
+153 -4
View File
@@ -46,6 +46,155 @@ Hardening-проход перед релизом `1.0.0`. Основная те
результат на достаточно большом входе, опаснее отсутствующей: отсутствующая
ничего не обещает.
Девятый проход — работа оператора в панели, по итогам приёмки `v1.0.0-rc1` на
живом Debian 13. Общая тема прохода: обещания интерфейса, которые продукт не
выполнял, хотя умел. Подпись под полем предлагала оставить секрет пустым, и
сервер действительно умел его сгенерировать — до этой генерации не доходило
управление. Контракт `currentColor` был объявлен в двух местах — и не
действовал, потому что цвет был вписан в сами ассеты. Ветка «сессия истекла,
войдите заново» существовала — и была недостижима сразу по двум причинам.
### Исправлено — панель оператора
- **Необязательный секрет пира был фактически обязателен.** Панель обещала
«оставьте пустым — сгенерируем автоматически» и отправляла `secret: ""`.
В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле
объявлено указателем и указатель не nil: помощник `hasValue` считает
указатель на пустую строку «значением». Правило `min=6` применялось к пустой
строке и отказывало, а генерация в `CreatePeer` оставалась недостижимой.
Ловушка закрыта механизмом, а не тегом на одном поле: между разбором тела и
проверкой правил появился шаг нормализации DTO (`dto.Normalizable`). Граница
проходит по каждому полю отдельно — у `remark` пустая строка означает
«убрать пометку», у `disabled` ноль означает «включён», и общее правило
«пусто → не задано» молча сломало бы оба.
Той же ловушкой ломался фильтр списка пиров: `el-input` с крестиком очистки
ставит пустую строку, axios сериализует её как `?name=`, и поиск отказывал в
один клик по крестику.
- **Генерация секрета названа явным шагом сервисного слоя.**
`service.GeneratePeerSecret` на базе `util.RandomString` (`crypto/rand` с
отбрасыванием смещённых байтов) используется и формой, и импортом: пир,
созданный панелью, и пир, импортированный без секрета, теперь неотличимы.
- **Любая ошибка любого поля превращалась в слово `invalid`.** Слой `vo` при
этом определял код ответа СРАВНЕНИЕМ текста сообщения с тремя литералами —
тот же антипаттерн, который запрещён панели, только на сервере. Ответ об
ошибке теперь несёт `errors: [{code, field, message, params}]`; панель
выбирает локализованную фразу по коду и подставляет причины под поля формы.
Границы числа и границы длины строки различаются кодом, хотя тег валидатора
у них один: оператору это разные фразы.
- **Истечение сессии не обрабатывалось.** Сервер отвечает HTTP 200 на любой
отказ, поэтому обработчик ошибок axios для отказов API не вызывался вовсе —
а ветка сессии жила именно там; её условие проверяло `code === "A0230"` и
поле `msg`, которых в этом API никогда не было. Вдобавок истёкший токен уезжал
с кодом системной ошибки. Теперь `ParseToken` возвращает объявленные значения
ошибок вместо свежих строк, middleware различает истечение и
недействительность через `errors.Is`, а панель показывает диалог и
возвращает на форму входа — один раз, даже когда истёкший токен уронил
несколько параллельных запросов страницы.
- **Обработчик транспортных ошибок падал сам.** Он читал `error.response.data`,
не проверив `error.response`, и при обрыве соединения подменял настоящую
причину `TypeError` внутри себя.
- **Сброс сессии больше не зовёт `localStorage.clear()`**, который заодно стирал
выбранный оператором язык панели.
- **`id` требовался и в пути, и в теле запроса.** `PeerUpdateDto` встраивал
`IdDto` с правилом `required`, хотя значение из тела всё равно затирается
значением из пути. Заодно убрана недостижимая запасная ветка `resolveID`,
читавшая идентификатор из тела: она вызывала разбор тела, которое обработчик
читает следом второй раз, а gin его не буферизует.
### Исправлено — отрисовка иконок
- **Контракт `currentColor` был объявлен и не действовал.** `fill: currentcolor`
стоял и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`, но восемь из
семнадцати ассетов несли литеральный `fill="#000000"` прямо на `<path>`, а
атрибут представления перебивает унаследованное CSS-свойство. Под это
попадали все семь иконок бокового меню на фоне `#181818`.
Литеральный цвет убран из ассетов; многоцветные объявлены явным списком;
преобразование в `<symbol>` и контракт ассета вынесены в чистый модуль
`SvgIcon/symbol.ts`, который можно выполнить вне Vite и DOM — и, значит,
проверить. Цвета в рантайме НЕ переписываются: молчаливая нормализация
скрывала бы ровно тот дефект, который контракт обязан делать видимым.
- **У `SvgIcon` убран проп цвета** и атрибут `fill` на `<use>`: он приглашал
чинить отрисовку точечно в обход общего контракта.
### Исправлено — правила имени пира
- **Два правила на одном поле противоречили друг другу.** Стояли
`min=1,max=32` и `validateStr`, требовавший 6-32 символа: имя из трёх
символов проходило одно правило и отказывалось на другом. Длина перенесена
внутрь одного правила.
- **Набор символов в слое контроллеров впускал `, - . / : ; <`.** Копия правила
несла неэкранированный дефис, из-за чего `+-=` образовывал ДИАПАЗОН; её
комментарий при этом утверждал, что набор тот же, что у импорта. Через панель
проходило имя `peer/name`, которое импорт того же пира отклонял, — при том что
имя уезжает во fragment клиентской ссылки и в автогенерируемый секрет.
Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими
дверями в таблицу пиров.
Набор символов ЛОГИНА администратора сознательно не сужен: он записан явно,
но повторяет прежнее фактическое множество. Имя администратора приходит из
`HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение правила
означало бы, что установка с логином вроде `admin.ops` перестаёт пускать
оператора в панель. Закреплено отдельным тестом, чтобы попытка «навести
порядок» роняла сборку, а не вход на живом сервере.
### Добавлено — атрибуция и контрактные тесты панели
- **Подпись «Разработано во Flamy»** внизу бокового меню, ссылкой фирменным
цветом. Адрес объявлен один раз в `apps/frontend/src/constants/branding.ts` и
принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API,
ни из таблицы `config`. Высота области прокрутки меню вычитает высоту
подписи, поэтому пункты меню не могут на неё наехать.
- **Контрактные тесты панели** (`tools/test/frontend-*.test.ts`) стали
обязательным шагом сборки наравне с тестами оркестратора и админки: контракт
спрайта иконок, совпадение наборов ключей `ru` и `en`, соответствие кодов
ошибок серверным константам, единственность адреса атрибуции.
Их исполняет уже закреплённый в `versions.env` Bun, а не vitest: jsdom не
вычисляет `currentColor` и визуальной корректности всё равно не доказал бы,
зато vitest привёл бы в граф `pnpm audit` — а его порог считается по всему
lock-файлу frontend — сотню транзитивных зависимостей ради нулевой
дополнительной гарантии.
### Изменено — документация
- **`docs/` разложена по слоям** вместо плоской кучи из четырнадцати файлов:
`architecture/`, `build/`, `runtime/`, `admin/`, `operations/`, `testing/`,
`acceptance/`. Двузначный префикс сохранён как стабильный идентификатор
документа — под ним на него ссылаются CHANGELOG, релизные гейты и сообщения
оркестратора.
- **`11-testing-and-acceptance.md` (117 КБ, 57 разделов) разбит на пять частей**
по слоям, на которых выполняются проверки.
- **Добавлен `docs/acceptance/`** — отчёты о фактических прогонах приёмки,
отдельно от описания самих проверок. Документ проверок переживает релизы;
отчёт о прогоне относится к одному артефакту и одному хосту и после
публикации не редактируется. Первый отчёт — build/host acceptance
`v1.0.0-rc1` на Debian 13 с перечнем найденных дефектов и их закрытия.
- **Добавлен `docs/admin/15-ui-contracts.md`** — контракты панели, которые не
проверяются ни типами, ни сборкой bundle.
- **Зафиксировано требование к памяти build-хоста:** `govulncheck` строит граф
достижимости по всему модулю вместе со stdlib, и на машине с ~1.9 GiB RAM без
swap он был убит OOM killer.
- **Обход документации в приёмке стал рекурсивным.** Плоский шаблон
`docs/*.md` после разнесения по каталогам совпадал бы ровно с одним файлом,
то есть проверка отчитывалась бы зелёным, не заглянув почти никуда.
### Исправлено — гейты сборки
- **Пайплайн в поиск с флагом `-q` под `pipefail` инвертирует смысл проверки.**
@@ -1069,7 +1218,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
фрагмент nftables, systemd-юниты, база админки и наследие `0.x`. Пути
установки и данных берутся из конфигурации, а не захардкожены.
- **`tools/legacy/purge-v0.sh`** и [docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) —
- **`tools/legacy/purge-v0.sh`** и [docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) —
явная очистка сервера от предыдущего поколения. По умолчанию скрипт
показывает план и ничего не делает; выполнение требует
`--apply --yes-i-know`. Из установщика он не вызывается никогда: это вернуло
@@ -1143,7 +1292,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
- **База админки — `hy2xs-admin.db`** вместо `h_ui.db`; reference-схема —
`apps/docs/sql/schema.sql` вместо `h_ui_db.sql`. Совместимость сохранять не
требуется: v1 ставится только с нуля. Историческое имя `h_ui.db` остаётся в
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md) — там это имя чужого
[docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md) — там это имя чужого
артефакта, который очистка должна найти.
- **Индикатор загрузки и legacy-цвета переведены на брендовый токен.**
@@ -1210,7 +1359,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
существует. Номера оставшихся миграций сохранены: перенумерация заставила бы
их примениться повторно.
В `docs/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они
В `docs/operations/14-legacy-cleanup.md` имена предыдущего поколения остаются — там они
обозначают реальные объекты, которые нужно удалить с сервера. Из остальных
v1-доков этот словарь убран.
@@ -1326,7 +1475,7 @@ Hardening-проход перед релизом `1.0.0`. Основная те
1. Выпишите с работающего сервера список пиров и их секреты.
2. Очистите сервер: `tools/legacy/purge-v0.sh` или ручная процедура из
[docs/14-legacy-cleanup.md](docs/14-legacy-cleanup.md).
[docs/operations/14-legacy-cleanup.md](docs/operations/14-legacy-cleanup.md).
3. Разверните `1.0.0` на чистом Debian 13 из release-пакета.
4. Заведите пиров заново и раздайте новые клиентские ссылки.