Files
houseplan-card/docs/specs/423-v170-polish.md
T
2026-09-02 21:03:05 +03:00

29 KiB
Raw Blame History

ТЗ #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 и без расширения передаваемых приватных данных:

  1. перечислять все активные стабильные семейства House Plan Repair;
  2. не использовать capability-token в имени файла;
  3. отклонять очевидно лишний preview до дорогой сборки;
  4. согласовывать форму по версии support API, а не версии релиза;
  5. вынести form-only локализации из initial View graph;
  6. подключить 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 None filename не создаётся;
  • browser и relay получают одинаковое имя.

3. Двухфазная quota-проверка

Один helper проверяет вместимость preview-map для (owner, draft_id):

  1. prune expired records по одному captured monotonic now;
  2. при подсчёте временно исключить старую запись того же owner/draft, потому что успешный refresh заменит её;
  3. проверить per-user и total limits;
  4. при отказе отправить 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 поле response config/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.

План отрицательной проверки

  1. Вернуть агрегацию по broken_plan_ — тест с новым translation_key краснеет.
  2. Вернуть filename от token — smoke/transport test находят token prefix.
  3. Перенести preflight после executor либо убрать post-await check — отдельные quota tests краснеют на build call или превышении лимита.
  4. Вернуть сравнение integration_version === CARD_VERSION — mixed-release smoke краснеет; удалить/reset support_api adoption — old-backend case краснеет.
  5. Вернуть form strings в core dictionary — bundle ownership test находит marker в initial graph и initial gzip перестаёт уменьшаться.
  6. Удалить 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.