# 1.0.0-rc1 — дефекты приёмки и их закрытие Относится к прогону [2026-09-01, `v1.0.0-rc1`](2026-09-01-v1.0.0-rc1-host-acceptance.md). Ни один из перечисленных дефектов не является P0 safety blocker и не дискредитирует пройденную server acceptance. Все они заметно ухудшают работу оператора и закрыты до финального `v1.0.0`. Раздел «Найдено сверх отчёта» описывает дефекты того же класса, обнаруженные при разборе корневых причин: искали причину одного отказа — нашли механизм, порождавший несколько. ## Сводка | ID | Дефект | Приоритет | Статус | | --- | --- | --- | --- | | UX-01 | Некорректный цвет SVG-иконок | P1 | закрыт | | UX-02 | Необязательный секрет пира фактически обязателен | P1 | закрыт | | UX-03 | Сообщение `Invalid` неинформативно | P1 | закрыт | | UX-04 | Плейсхолдеры слишком персонализированы | P2 | закрыт | | UX-05 | Нет атрибуции Flamy в боковом меню | P1 | закрыт | | EX-01 | Фильтр списка пиров ломается после очистки | P1 | закрыт | | EX-02 | Правила имени пира противоречили друг другу | P1 | закрыт | | EX-03 | Набор символов имени пира допускал `/ : ; . ,` | P1 | закрыт | | EX-04 | Истечение сессии не обрабатывалось | P1 | закрыт | | EX-05 | `id` требовался и в пути, и в теле запроса | P2 | закрыт | | EX-06 | Обработчик транспортных ошибок падал сам | P2 | закрыт | --- ## UX-01 — некорректный цвет SVG-иконок **Наблюдалось:** иконки логина и бокового меню отображались почти чёрными и не соответствовали теме. **Корневая причина.** Контракт `currentColor` в панели УЖЕ существовал — `fill: currentcolor` объявлен и в `SvgIcon/index.vue`, и в `styles/sidebar.scss`. Он не действовал, потому что восемь из семнадцати ассетов несли литеральный атрибут `fill="#000000"` прямо на ``, а атрибут представления перебивает унаследованное CSS-свойство. Под это попали ВСЕ семь иконок бокового меню (`report`, `users`, `hysteria`, `setting`, `error`, `log-system`, `log-hysteria`) на фоне `--menuBg: #181818`, а также `user` на форме входа. Соседняя `password` литерального цвета не несёт и рисовалась белой — отсюда и ощущение, что иконки не соответствуют друг другу. Ни одна существующая проверка этого не видела: гейт приёмки проверял у ассетов только наличие системы координат. **Как закрыто.** 1. Литеральный цвет убран из монохромных ассетов: они несут `fill="currentColor"`. 2. Многоцветные ассеты (`download`, `upload`) объявлены явным списком `MULTICOLOR_ICONS` и под проверку цвета не попадают — их палитра является частью ассета. 3. Преобразование файла в `` и контракт ассета вынесены в чистый модуль `SvgIcon/symbol.ts`: без Vite и DOM, поэтому проверяются тестом и гейтом, а не только глазами на живой странице. 4. У `SvgIcon` убран проп `color` и атрибут `fill` на `` — он приглашал чинить цвет точечно в обход общего контракта. 5. Цвета в рантайме НЕ переписываются: источник истины — файл. Молчаливая нормализация скрывала бы ровно тот дефект, который контракт обязан делать видимым. **Чем закреплено:** `tools/test/frontend-sprite.test.ts` (контракт всех ассетов, обе ветки нормализации, наличие обеих половин контракта — ассета и CSS, запрет CSS-фильтров и селекторов по имени иконки) и соответствующие гейты приёмки в `tools/build/lib/acceptance.sh`. **Что проверяется вручную** (машина этого не докажет): фактический цвет на светлой и тёмной теме, в состояниях hover и active, в свёрнутом меню. --- ## UX-02 — необязательный секрет фактически обязателен **Наблюдалось:** подпись под полем обещает «оставьте пустым — сгенерируем автоматически», пустое поле блокирует создание пира и выдаёт `Invalid`. **Корневая причина.** Не отсутствие автогенерации: `service.CreatePeer` умел генерировать секрет и делал это. Запрос до неё не доходил. В `go-playground/validator` тег `omitempty` НЕ пропускает правило, если поле объявлено указателем и указатель не nil. Помощник `hasValue` (`baked_in.go`): ```go if fl.(*validate).fldIsPointer && getValue(field) != nil { return true } ``` Для `*string`, указывающего на пустую строку, это возвращает «значение есть». Панель отправляет `secret: ""`, правило `min=6` применяется к пустой строке и отказывает. **Как закрыто.** Не тегом на одном поле, а механизмом: между разбором тела и проверкой правил добавлен шаг нормализации DTO (`dto.Normalizable`). Он приводит «поле отсутствует», «пустая строка» и «одни пробелы» к одному состоянию для тех полей, где отсутствие значения законно. Граница проходит по каждому полю ОТДЕЛЬНО и это существенно: у `remark` пустая строка означает «убрать пометку», у `disabled` ноль означает «включён», у `quotaBytes` ноль — нулевую квоту. Общее правило «пусто → не задано» молча сломало бы все три. Генерация названа явным шагом сервисного слоя — `service.GeneratePeerSecret` на базе `util.RandomString` (`crypto/rand` с отбрасыванием смещённых байтов). Тот же вызов используется импортом: пир, созданный формой, и пир, импортированный без секрета, теперь неотличимы. **Чем закреплено:** матрица «отсутствует / пусто / пробелы / перевод строки → генерируется», границы `5 → отказ, 6 → приём, 128 → приём, 129 → отказ`, неповторяемость сгенерированных секретов и — главное — проверка того, что сгенерированный секрет НЕМЕДЛЕННО аутентифицирует пира через `service.Hysteria2Auth`. То, что секрет записан, ничего не значит, пока по нему не проходит доступ. --- ## UX-03 — сообщение `Invalid` неинформативно **Корневая причина.** `validateField` схлопывал любую ошибку любого поля в `constant.InvalidError = "invalid"`, а `vo.Fail` определял HTTP-семантику СРАВНЕНИЕМ текста сообщения с тремя известными литералами — тот же антипаттерн, который запрещён панели, только на сервере. **Как закрыто.** * Ответ об ошибке несёт `errors: [{code, field, message, params}]`. * Отказ разбора тела (`body_invalid`) отделён от нарушения правила. * Коды правил различают границы числа и границы длины строки (`min` / `min_length`): оператору это разные фразы. * Доменные отказы получили коды: `peer_name_taken`, `peer_name_reserved`, `peer_bootstrap_identity_locked`, `invalid_credentials`. * `vo` больше не выводит код из текста — код передаётся аргументом. * Панель выбирает локализованную фразу ПО КОДУ и подставляет причины под соответствующие поля формы; текст сервера остаётся ответом для клиента без UI и запасным вариантом для неизвестного кода. * Числа правил приходят в `params`, поэтому второй копии границ в панели нет. Отдельно: отказ входа кодируется как `invalid_credentials`, но НЕ уточняется — «такого администратора нет» и «пароль не тот» остаются неразличимы снаружи, иначе форма входа становится способом проверять существование имён. Отказ базы при этом остаётся системной ошибкой: выдавать «неверный логин или пароль» при недоступной SQLite значит отправить оператора искать несуществующую опечатку. --- ## UX-04 — плейсхолдеры слишком персонализированы Заменено на нейтральный компактный baseline: | Поле | Было | Стало | | --- | --- | --- | | Имя | `например, ivan-laptop` | `client-01` | | Комментарий | `например, Ноутбук Ивана, отдел продаж` | `ноутбук` | Префикс «например,» убран: плейсхолдер и так является примером. Подсказки под полями остались подробными; подсказка имени теперь называет фактические границы (6-32 символа). --- ## UX-05 — атрибуция Flamy в боковом меню Внизу бокового меню добавлена подпись «Разработано во **Flamy**», где `Flamy` — ссылка на `https://flamy.studio` фирменным цветом, с `target="_blank"` и `rel="noopener noreferrer"`. Адрес объявлен ОДИН раз в `apps/frontend/src/constants/branding.ts` и принадлежит приложению: он не читается ни из `hy2xs.env`, ни из config API, ни из таблицы `config`, ни из настроек панели. Оператор HY2XS не должен иметь возможности переназначить, куда ведёт подпись разработчика. Вёрстка: высота области прокрутки меню вычитает `$sidebarFooterHeight`, поэтому пункты меню не могут наехать на подпись даже при длинном списке — им физически некуда. В свёрнутом меню (54 px) остаётся только имя-ссылка; на узком экране меню уходит в off-canvas на полную ширину. **Чем закреплено:** `tools/test/frontend-contract.test.ts` — единственность адреса в исходниках панели, отсутствие его в операторских поверхностях, наличие футера в меню, учёт его высоты, атрибуты безопасности внешней ссылки. Плюс гейты приёмки. --- # Найдено сверх отчёта ## EX-01 — фильтр списка пиров ломался после очистки `el-input` с крестиком очистки ставит пустую строку, axios сериализует её как `?name=`, и та же ловушка `omitempty` на указателе (см. UX-02) отказывала поиску пиров с `invalid`. Список пиров ломался в один клик по крестику. Закрыто тем же механизмом нормализации; закреплено регрессионным тестом. ## EX-02 — правила имени пира противоречили друг другу На поле стояли `min=1,max=32` И `validateStr`, требовавший 6-32 символа. Имя из трёх символов проходило одно правило и отказывалось на другом, а оператор видел `invalid` рядом с подсказкой «короткий идентификатор пира». Длина перенесена внутрь одного правила `peerName`. Действующая граница — 6-32, то есть та, которая уже была задокументирована и закреплена тестами импорта. ## EX-03 — набор символов имени пира допускал `/ : ; . ,` Слой контроллеров нёс собственную копию правила: ```text ^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$ ``` с комментарием «тот же набор символов, что и у импорта». Набор был другим: дефис внутри класса не экранирован, поэтому `+-=` образует ДИАПАЗОН и впускает `, - . / 0-9 : ; < =`. Через панель проходило имя `peer/name`, которое импорт того же пира отклонял, — при том что имя пира уезжает во fragment клиентской ссылки и в автогенерируемый секрет. Правило объявлено один раз (`service.IsValidPeerName`) и используется обеими дверями в таблицу пиров. Набор символов ЛОГИНА администратора при этом сознательно НЕ сужен: он записан явно, но повторяет прежнее фактическое множество. Имя администратора приходит из `HY2XS_ADMIN_USER`, оркестратор его не ограничивает, и сужение правила означало бы, что установка с логином вроде `admin.ops` перестаёт пускать оператора в панель. Это закреплено отдельным тестом, чтобы попытка «навести порядок» роняла сборку, а не вход на живом сервере. ## EX-04 — истечение сессии не обрабатывалось Ветка «сессия истекла, войдите заново» была недостижима в двух местах сразу. Сервер отвечает HTTP 200 на любой отказ, поэтому обработчик ошибок axios для отказов API не вызывался вовсе — а ветка сессии жила именно там. Условие в ней проверяло `code === "A0230"` и поле `msg`, которых в этом API никогда не было. Ключ локализации `common.sessionExpired` существовал и был мёртвым. Вдобавок истёкший токен уезжал с кодом системной ошибки: `vo` не узнавал `token expired` среди трёх известных строк. Закрыто: `ParseToken` возвращает объявленные значения ошибок вместо свежих строк, middleware различает истечение и недействительность через `errors.Is`, ответ несёт код `session_expired`, панель показывает диалог и возвращает на форму входа. Диалог показывается один раз, даже когда истёкший токен уронил несколько параллельных запросов страницы. Побочно: сброс сессии больше не зовёт `localStorage.clear()`, который заодно стирал выбранный оператором язык панели. ## EX-05 — `id` требовался и в пути, и в теле `PeerUpdateDto` встраивал `IdDto` с правилом `required`, поэтому тело запроса обязано было повторять идентификатор из адреса. Панель его повторяла, поэтому расхождение не проявлялось; любой другой клиент, сделавший `PATCH /peers/7` без `"id": 7` в теле, получал отказ — при том что значение из тела всё равно затирается значением из пути. Заодно убрана недостижимая запасная ветка `resolveID`, читавшая идентификатор из тела: она вызывала разбор тела, которое обработчик читает следом второй раз, а gin его не буферизует. То есть запасной путь не сработал бы ровно тогда, когда понадобился бы. ## EX-06 — обработчик транспортных ошибок падал сам Обработчик читал `error.response.data`, не проверив `error.response`. При обрыве соединения или таймауте он падал с `TypeError` и подменял настоящую причину отказом внутри себя. Теперь сетевой отказ отличается от отказа сервера и сообщается отдельной фразой. --- ## Разбор кода после этих исправлений Проверка внесённых здесь исправлений по дереву на коммите `c0a43ae9` нашла дефекты, которых хостовой прогон `rc1` не показывал, — включая два отдельных пути, по которым молча снималось ограничение устройств, и отключение пира, не разрывавшее его активную сессию. Они перечислены в [2026-09-01-v1.0.0-rc2-preflight-findings.md](2026-09-01-v1.0.0-rc2-preflight-findings.md) и закрыты до сборки `rc2`. ## Что осталось сделать до финального v1.0.0 1. пересобрать `rc2` и повторить build/security acceptance; 2. установить `rc2` на тестовый хост; 3. проверить визуально: цвет иконок в обеих темах, hover/active, свёрнутое меню, подпись Flamy на узком экране; 4. проверить создание пира с пустым секретом и работоспособность его share URI на живом сервере; 5. выполнить reconfigure/fault matrix D1-D1h на `rc2`; 6. выполнить reboot acceptance; 7. отдельно проверить Salamander fallback; 8. после готовности HY2XS Desktop — внешний Gecko E2E; 9. удалить или пересоздать тестовый пир, чья URI была выведена за пределы admin UI во время прогона `rc1`.