29 KiB
ТЗ #423 — Полиш support pipeline и защитных инструментов v1.70.0
- Issue: https://github.com/Matysh/houseplan-card/issues/423
- Приоритет: P3,
tech-debt/polish - Маршрут: full; затронуты backend, frontend UX/i18n, transport, bundle graph и Playwright tooling, поэтому задача не проходит критерий одной поверхности лёгкого трека
- Связанные контракты: #43 (Help & feedback), #352/#367 (bundle ownership и бюджет), #404/#407/#421 (browser exception guard), #422 (docs screenshot gate)
Сценарий
Пользователь обновляет карточку и интеграцию House Plan через HACS. Браузер ещё может держать предыдущий bundle, либо одна из двух частей обновляется раньше другой. Пользователь открывает «Помощь и обратную связь», пишет сообщение и при желании прикладывает обезличенный пакет.
Одновременно разработчик должен иметь честные сведения о Repair families, дешёвый отказ при исчерпанном лимите preview, безопасное имя файла, контролируемый initial bundle и benchmark, который не пропускает browser exception.
Что человек увидит до и после
До: при любом несовпадении номера релиза форма полностью скрывается, даже если обе стороны поддерживают один и тот же support API. Скачанный JSON и multipart-вложение названы префиксом живого preview-token. Остальные дефекты почти не видны напрямую: новый тип Repair может отсутствовать в пакете, а запрос с исчерпанным лимитом сначала зря строит пакет до 8 МиБ.
После: форма доступна при совместимом support_api, независимо от равенства
номеров релиза. Старый backend без capability по-прежнему fail-closed показывает
предложение обновиться. И ручное скачивание, и relay attachment получают одно
имя по короткому префиксу SHA-256, без части capability-token. Тексты, поля,
согласие на вложение и отправка внешне не меняются.
Цель
Закрыть шесть подтверждённых разрывов аудита v1.70.0 без изменения support package v1 и без расширения передаваемых приватных данных:
- перечислять все активные стабильные семейства House Plan Repair;
- не использовать capability-token в имени файла;
- отклонять очевидно лишний preview до дорогой сборки;
- согласовывать форму по версии support API, а не версии релиза;
- вынести form-only локализации из initial View graph;
- подключить backdrop decode benchmark к общему browser-error guard.
Проблема и подтверждённые причины
1. Repair families
_support_repairs() сейчас анализирует только issue_id.startswith("broken_plan_")
и жёстко выдаёт code: "broken_plan". Реестр уже хранит безопасный стабильный
translation_key; именно он описывает семейство, тогда как issue id содержит
идентичность конкретного экземпляра и может включать приватный placeholder.
2. Filename
ws_support_submit() передаёт preview-token в filename_token, а
support_transport.py пишет первые 32 символа в multipart filename. Frontend
использует первые 12 символов того же token при Download JSON. Полный token не
раскрывается, но имя без необходимости основано на bearer capability.
3. Preview quota
ws_support_preview() захватывает копии store и исполняет _build_snapshot() до
prune/count. Только после потенциальной валидации, псевдонимизации и сериализации
8 МиБ приходит support_rate_limited.
4. Compatibility
_buildSupportPreview(), _submitSupport() и _renderSupportDialog() требуют
_haIntegrationVersion === CARD_VERSION. Номер релиза является диагностикой,
но не версией протокола. Владелец выбрал явную capability support_api: 1.
5. Lazy support copy
43 английских support.* и соответствующие русские строки входят в общий
initial chunk через eager locale dictionaries. Только support.title нужен
карточке до загрузки editor runtime; вся остальная форма уже lazy.
6. Backdrop benchmark
demo/benchmark_backdrop_decode.mjs создаёт page напрямую и не вызывает ни
watchPage(), ни reportPageErrors(). Необработанный pageerror может попасть
в лог, но оставить exit code нулевым.
Скоуп
В скоупе:
- безопасная агрегация Repair issues по
translation_key; - одинаковый short-id из SHA-256 для browser download и multipart filename;
- quota preflight до store copy/build и повторная проверка после асинхронной сборки;
- top-level capability
support_apiв ответеhouseplan/config/get; - component-memory состояние capability и единый чистый predicate совместимости;
- lazy support dictionaries для RU/EN/DE/FR с прежними значениями строк;
- bundle ownership test и зафиксированное уменьшение initial View gzip;
- подключение backdrop benchmark к
watchPage()иreportPageErrors(); - unit/backend/smoke/contract tests, User Guide и changelog RU/EN.
Не-скоуп
- изменение endpoint relay, consent, retention, формы, package schema или набора приватных данных;
- изменение лимитов
3/3, TTL 10 минут или replacement semantics; - отправка списка raw Repair ids, placeholders, titles или exception text;
- новый negotiation endpoint или пробный submit ради определения capability;
- ленивый вынос всей RU/EN локали: переносится только семейство support form;
- изменение screenshot capture workflow: неверный комментарий и межпрогонный гейт уже принадлежат #422;
- превращение backdrop benchmark в обязательный CI performance gate.
UX
- About и Guide доступны всегда, как сейчас.
- Форма, checkbox и Send доступны, когда
support_api === 1. - Отсутствующее, нецелое, нулевое или неизвестное значение capability считается несовместимым. UI показывает локализованное сообщение об обновлении до совместимых версий, не технический номер API.
- Несовпадающие card/integration release versions сами по себе больше ничего не скрывают и не блокируют.
- Никаких новых действий, переключателей, toast, фокусов и состояний загрузки.
- Download JSON сохраняет те же bytes, но имя становится
houseplan-support-{sha256[0:12]}.json.
Контракт реализации
1. Safe Repair family
Для каждой активной записи реестра с domain === houseplan берётся
translation_key объекта issue. Значение включается только если это строка,
соответствующая ^[a-z][a-z0-9_]{0,63}$. Записи без безопасного ключа
пропускаются fail-closed.
Одинаковые ключи агрегируются, результат сортируется по code и имеет прежний
вид { "code": string, "count": positive integer }. Ни issue_id, ни
translation placeholders, ни display text в package не попадают. Два
broken_plan_<space> по-прежнему дают {code: "broken_plan", count: 2};
будущее семейство с другим id автоматически попадает под своим translation key.
2. Filename short-id
Short-id равен первым 12 lowercase hex символам уже рассчитанного SHA-256 exact attachment bytes. SHA уже входит в preview и request metadata; отдельный random id и новое поле WebSocket ответа не нужны. Благодаря случайному namespace псевдонимов новый preview имеет новые bytes/hash даже для того же плана.
- frontend Download использует
preview.sha256.slice(0, 12); - backend transport строит имя только из валидированного
attachment_sha256[:12]; - parameter
filename_tokenудаляется из transport API; - при
attachment is Nonefilename не создаётся; - browser и relay получают одинаковое имя.
3. Двухфазная quota-проверка
Один helper проверяет вместимость preview-map для (owner, draft_id):
- prune expired records по одному captured monotonic
now; - при подсчёте временно исключить старую запись того же owner/draft, потому что успешный refresh заменит её;
- проверить per-user и total limits;
- при отказе отправить
support_rate_limitedдо чтения store, deep copy иasync_add_executor_job(_build_snapshot).
Старый preview текущего draft на preflight не удаляется: неудачная новая сборка
не должна уничтожать пригодный снимок. После возврата executor выполняются новый
prune и тот же capacity check, потому что во время await другой запрос мог
занять слот. Только после успешной второй проверки старый token того же draft
удаляется и новый record записывается без следующего await.
4. Версия support API
Текущий backend добавляет в каждый полный и projected ответ
houseplan/config/get поле:
{ "support_api": 1 }
Поле — runtime capability, а не persisted config. Миграции нет. Карточка на
каждом успешном config/get нормализует значение: только safe integer 1
считается поддерживаемым; отсутствие/другая версия сбрасывает прошлое значение,
чтобы downgrade backend не оставил stale permission.
Чистый helper supportApiCompatible(value) используется всеми тремя путями:
render, preview build и submit. Release integration_version сохраняется для
диагностики и других функций, но больше не участвует в support form gate.
Добавочные изменения протокола остаются в API v1; breaking change получает новый
номер и старый frontend fail-closed.
5. Lazy support dictionaries
В основных src/i18n/{en,ru,de,fr}.json остаётся support.title, потому что
header action существует до загрузки editor runtime. Остальные support.*
переносятся без изменения текста и placeholders в отдельные словари, импортируемые
только графом houseplan-editor-runtime.
Lazy helper:
- типизирует собственный набор support keys;
- выбирает точный словарь RU/EN/DE/FR и синхронно fallback-ит в English;
- использует общий
substдля placeholders; - не мутирует глобальный LanguageRuntime и не создаёт дополнительной сетевой загрузки после загрузки editor runtime.
Общие i18n tests отдельно доказывают равенство ключей/placeholders основного и
support-наборов во всех четырёх языках. Dead-key scan рассматривает оба английских
словаря. Production build обязан показать, что form-only English/Russian marker
strings отсутствуют в initialViewFiles и присутствуют в lazyEditorFiles.
Baseline для сравнения берётся из чистого dev на начале задачи: 291046 B
gzip initial View. После реализации значение должно быть строго меньше baseline;
увеличение бюджета 300000 или простая рекалибровка запрещены.
6. Browser error guard benchmark
Benchmark оборачивает созданную страницу штатным watchPage(). После всех cases,
но до browser.close(), он вызывает await reportPageErrors(); при true
завершает сценарий ненулевым кодом без печати ложного success. Синхронные ошибки
отдельных слишком больших cases продолжают печататься как измерительный FAIL и
не превращаются автоматически в crash всего benchmark: предмет guard — только
необработанный page error.
Contract test охватывает все demo/benchmark_*.mjs, которые создают Playwright
page: такая страница должна пройти через watchPage, а скрипт — запросить
reportPageErrors или finish. Отрицательная проверка удаляет один из этих
вызовов и обязана краснеть. Для динамического доказательства сам benchmark
получает служебный --guard-probe: режим не запускает тяжёлую decode-матрицу,
создаёт контролируемый tail pageerror, проходит через тот же финальный verdict
и обязан завершиться кодом 1 с сообщением общего guard. Обычный запуск без флага
не меняет cases или формат строк измерений.
Модель данных, API и миграция
- Persisted config/layout, model version, import/export и support package v1 не меняются.
support_api— новое additive top-level поле responseconfig/get; старые клиенты его игнорируют, старые backends не присылают._haSupportApiживёт только в памяти экземпляра карточки, в cache/localStorage не записывается.- Preview record сохраняет прежние bytes/hash/versions. Нового filename id в record нет.
- Relay request JSON и validation schema не меняются; меняется только безопасное multipart filename.
i18n
Видимый текст support.update_required обновляется во всех RU/EN/DE/FR: вместо
требования одинакового номера релиза он просит обновить карточку и интеграцию до
совместимых версий. Остальные строки переносятся побайтово без правки смысла.
Основные ключи:
support.title— остаётся в core dictionary;support.update_required— lazy, новый смысл compatibility;- все остальные
support.*— lazy, значения без изменения.
Accessibility, touch и kiosk
Разметка dialog, порядок tab, label/aria-live, focus, 44×44 targets и responsive layout не меняются. Touch, desktop и keyboard используют один compatibility predicate. Kiosk по-прежнему скрывает Help action. Вынос словарей не допускает пустого текста или English flash: editor runtime и его словари загружаются одной lazy graph dependency до открытия dialog.
Security и privacy
- Capability token остаётся только в WebSocket payload/runtime-map и никогда не входит в filename.
- SHA short-id не добавляет новую информацию: полный SHA уже явно показан пользователю и отправляется relay для проверки bytes.
- Repair aggregation принимает только стабильный безопасный translation key; сырые ids/placeholders исключены тестом.
- Capability negotiation не вызывает relay и не ослабляет HA
may_write. - Quota preflight уменьшает доступную злоумышленнику дорогую работу; финальная проверка сохраняет ограничение при конкурентных запросах.
Производительность
- Исчерпанная preview quota отклоняется до deep copy/schema/projection/JSON.
- Обычный допустимый preview получает два линейных прохода по максимум трём records; стоимость ничтожна относительно snapshot build.
- Initial View gzip обязан уменьшиться относительно 291046 B. Lazy editor может вырасти на перенесённые строки; суммарные bytes допустимы, потому что платит только открывающий редактор/Help пользователь.
- Backdrop benchmark не становится CI gate и не меняет измеряемую матрицу; один финальный browser round-trip не входит в case timing.
Затрагиваемые файлы
Ожидаемый набор:
custom_components/houseplan/websocket_api.py;custom_components/houseplan/support_transport.py;tests_backend/test_ha_websocket.py;tests_backend/test_ha_support_transport.py;src/houseplan-card.ts,src/houseplan-editor-runtime.ts;src/support-feedback.tsлибо отдельный compatibility helper;src/i18n/{en,ru,de,fr}.jsonи новые lazy support dictionaries/helper;test/support-feedback.test.mjs,test/i18n.test.mjs,test/i18n-dead-keys.test.mjs,test/bundle-assets.test.mjs;demo/smoke_support_feedback.mjs;demo/benchmark_backdrop_decode.mjsи его contract test;docs/USER-GUIDE.md,docs/USER-GUIDE.ru.md;docs/CHANGELOG.md,docs/CHANGELOG.ru.md.
.github/workflows/docs-screenshots.yml не меняется в #423.
Критерии приёмки
- AC1. Два House Plan issues одного безопасного
translation_keyдают одну family/count; другое безопасное семейство попадает автоматически; foreign domain и unsafe/missing key не попадают, raw ids/placeholders отсутствуют. Доказательство: backend unit/HA test + code review. - AC2. Browser download и relay multipart используют exact filename
houseplan-support-{sha256[0:12]}.json; capability-token отсутствует в имени, bytes/hash не меняются. Доказательство: frontend smoke + transport test. - AC3. При исчерпанной quota без replaceable token ответ
support_rate_limitedприходит без store loads/executor build; refresh своего draft имеет право заменить token и не удаляет старый при build failure. Доказательство: HA backend tests. - AC4. Если слот занят конкурентно во время snapshot
await, финальная проверка не превышает лимит и не удаляет прежний token текущего draft. Доказательство: управляемый backend test. - AC5.
config/getвсегда возвращаетsupport_api: 1, включая projection; поле не хранится в config. Старый backend без поля остаётся совместим с frontend load, но support form fail-closed. Доказательство: backend + unit. - AC6. Support form/preview/submit работают при разных release versions и
support_api === 1; missing, invalid, 0 или 2 блокируют все три пути одним predicate. Доказательство: unit +smoke_support_feedback.mjs. - AC7. Все RU/EN/DE/FR support keys непусты, имеют одинаковый key/placeholder
set и отображают прежний текст, кроме согласованного compatibility message.
Core содержит только
support.title; form-only strings принадлежат lazy editor graph. Доказательство: i18n + bundle ownership tests. - AC8. Production build имеет initial View gzip строго меньше baseline 291046 B без изменения budget; Help dialog на четырёх языках остаётся полным. Доказательство: build manifest + smoke/code review.
- AC9. Backdrop benchmark регистрирует page через
watchPage, запрашивает финальный browser-error verdict и в--guard-probeвыходит ненулевым при injected pageerror без запуска decode-матрицы. Обычный режим сохраняет прежние cases/вывод. Доказательство: contract test + динамическая отрицательная probe. - AC10.
.github/workflows/docs-screenshots.ymlне меняется; #423 ссылается на #422 как владельца дублирующего пункта. Доказательство: diff/code review. - AC11. Typecheck, unit, build и затронутые backend tests зелёные; runtime package/config schemas и UI layout не меняются. Доказательство: локальный цикл и Linux CI.
- AC12. User Guide и оба changelog описывают capability-based compatibility и безопасное имя без технических обещаний о внутреннем SHA-префиксе сверх нужного пользователю. Доказательство: docs guard/code review.
План отрицательной проверки
- Вернуть агрегацию по
broken_plan_— тест с новымtranslation_keyкраснеет. - Вернуть filename от token — smoke/transport test находят token prefix.
- Перенести preflight после executor либо убрать post-await check — отдельные quota tests краснеют на build call или превышении лимита.
- Вернуть сравнение
integration_version === CARD_VERSION— mixed-release smoke краснеет; удалить/resetsupport_apiadoption — old-backend case краснеет. - Вернуть form strings в core dictionary — bundle ownership test находит marker в initial graph и initial gzip перестаёт уменьшаться.
- Удалить
watchPageили browser-error verdict из benchmark — contract/probe краснеет.
Риски
- Stale capability после downgrade. Missing/invalid field явно сбрасывает
состояние на incompatible при каждом
config/get. - API v2 ошибочно принимается старой карточкой. Predicate принимает ровно 1; additive изменения не повышают API, breaking change повышает.
- Quota race. Capacity проверяется повторно после каждого
await, запись replacement выполняется атомарным синхронным участком. - Неудачный refresh уничтожает старый preview. Старый token удаляется только после успешного build и финальной capacity-проверки.
- Repair key раскрывает данные. Строгая форма ключа и запрет fallback к issue id оставляют только проектный translation key.
- Lazy copy теряет локаль/fallback. Отдельная parity/placeholder matrix и smoke четырёх языков блокируют неполный перенос.
- Экономия бандла мнимая. AC фиксирует owner graph и числовой baseline, не только общий budget.
- Benchmark начинает считать ожидаемый per-case FAIL как exception. Guard реагирует только на pageerror; существующий catch/вывод cases сохраняется.
Откат
Откат implementation-коммита возвращает строгий release-version gate, прежние
filenames, eager support strings, позднюю quota-проверку и старый benchmark.
Persisted data и package schema откатывать не нужно. Backend additive
support_api безопасно удалить вместе с frontend predicate одним релизом;
старый frontend и так игнорирует поле.
Release-артефакты
- обязательны
docs/CHANGELOG.mdиdocs/CHANGELOG.ru.mdв том же User-Visible commit; - обязательна правка раздела Help & feedback в обоих User Guide;
- golden/docs screenshots не нужны: DOM/layout/text видимой совместимой формы не меняются, а compatibility message проверяется текстом/smoke;
- performance artifact — before/after
dist/houseplan-assets.jsonс baseline initial gzip 291046 B и доказательством ownership; - security evidence — transport test, где filename не содержит token;
- issue остаётся открытым в
S8-mergedдо пакетного закрытия при выпуске беты.
Принятые предположения
- Владелец подтвердил default: protocol capability важнее равенства product versions; первая и единственная поддерживаемая версия сейчас равна 1.
- SHA-256 — уже раскрытая внутри consented preview метаинформация и допустимый источник short-id; новое случайное поле не добавляется.
translation_key— каноническое имя Repair family. Repair без безопасного translation key считается внутренне некорректным и в support package не показывается.- Все form-only support strings переносятся для четырёх локалей одновременно; оставлять RU eager при выносе только EN было бы скрытой архитектурной асимметрией.
- Пункт issue про порядок комментария в docs workflow полностью принадлежит #422 и не расходует реализацию/ревью #423.