# SPEC-REVIEW-43-r4 - Issue: #43 «Диалог помощи и обратной связи с обезличенным support report» - Этап: spec (PROCESS.md §2.4) - Заход: r4 · блокирующих циклов израсходовано 2 из 4 (до этого раунда) - Материал: `docs/specs/043-private-support-report.md` на HEAD `cd8d01ac` (ветка `issue/43-help-feedback`), плюс код `scripts/support-relay/**` как доказательство исполнимости заявленных в §9 контрактов — в объёме, до которого дотягивается дельта этого раунда. - Предыдущий раунд: r3, вердикт **жёлтый**, SHA `c43bf847` (High 0, Medium 1); документ `docs/reviews/SPEC-REVIEW-43-r3.md`, опубликован коммитом `55b51a02`. SHA r3 в самом документе назван явно (раздел «Материал»); в сжатом комментарии-вердикте в issue он не повторён (там только «HEAD»), но это не «находка №1 инструкции» — первоисточник (файл ревью) SHA не потерял. SHA пересчитан и подтверждён по времени коммитов независимо от текста документа: `git log --date=iso-strict` даёт `c43bf847` = `2026-09-02 01:03:57+03:00`, что на 27 с опережает комментарий-вердикт `r3` (`22:21:03Z` = `01:21:03+03:00`, следующий коммит `55b51a02` = сам документ ревью, коммит `22:21:09Z`). ## Дельта r3→r4 `git diff c43bf847..HEAD` (без учёта `docs/reviews/SPEC-REVIEW-43-r3.md`, который сам является артефактом прошлого раунда, а не правкой автора): - `docs/specs/043-private-support-report.md` — 10 строк добавлено, 0 удалено: новый абзац в §9.2 (источник rate-limit), новая строка **AC12a** в §13, новая строка обязательной мутации в §14.4; - `scripts/support-relay/README.md` — 27 строк добавлено: минимальная конфигурация автоматизации Home Assistant «House Plan: приёмщик обратной связи → личка» (YAML) плюс пояснение, почему `parse_mode: plain_text` не косметика. Дельта строго докс-онли и локальна: адресует ровно два пункта прошлого вердикта (Medium §9.2 и снятый-с-запиской Low про недокументированную автоматизацию), кода не трогает, новую подсистему не задевает, объём несравним с исходной задачей. Полный разбор не требуется — разбор по дельте плюс всё, до чего эта дельта дотягивается (см. находку ниже: она лежит внутри того же абзаца §9.2, который дельта редактирует). ## Закрытие раунда r3 | Находка r3 | Чем закрыта | Где видно | |---|---|---| | **Medium** — §9.2 не фиксирует источник rate-limit-адреса (только код/README, требование не переживёт рефакторинг `hp_relay/app.py`) | Частично: добавлен абзац в §9.2 «источник — последний элемент `X-Forwarded-For`, потому что прокси перезаписывает заголовок целиком», плюс **AC12a** в §13 и строка мутации в §14.4 | `docs/specs/043-private-support-report.md:446-452` (абзац §9.2), `:539` (AC12a), `:590-591` (§14.4). Текст дословно соответствует тому, что просил r3, **кроме** условности через `trusted_proxy` — см. новую находку ниже, это тот же абзац, не новый пробел, а недозакрытый прежний | | **Low** (снят с запиской, не блокировал) — минимальная конфигурация вебхук-автоматизации не задокументирована в репозитории | Закрыт: полный YAML автоматизации (webhook-триггер, `local_only: false`, условие по `source`, `telegram_bot.send_message` с `parse_mode: plain_text`) добавлен в README | `scripts/support-relay/README.md:154-180`. Поля `source`/`text` в шаблоне совпадают буквально с тем, что шлёт код: `scripts/support-relay/hp_relay/delivery.py:180-184` (`HaWebhookDelivery.send`) формирует JSON именно с ключами `source`/`report_id`/`text` | Low закрыт полностью и без оговорок. Medium закрыт **не полностью** — см. находку. ## Унаследовано из r3 (и через r3 — из r2/r1) без повторной проверки Со ссылкой на `docs/reviews/SPEC-REVIEW-43-r3.md` (SHA `c43bf847`) и транзитивно на r2 (`docs/reviews/SPEC-REVIEW-43-r2.md`, SHA `e25aa302`): - §1–§8 — сценарий, UX-контракт, support package v1, backend API preview/ discard/submit — дельта их не касается; - §9.1 — два канала доставки (`telegram`/`ha_webhook`), запись до попытки доставки, privacy-текст §9.3 (кроме уже переоценённого абзаца §9.2) — не менялись этой дельтой, r3 проверил их построчно против кода (`hp_relay/delivery.py`, `app.py:107-116`, `config.py:66-68`); - §10, §11 (включая каноническую фразу `Touch editor: supported`), §12; - §13 AC1–AC11, AC13–AC17 (кроме новой AC12a); - §15–§20, включая классификацию `scripts/support-relay/**` как class B (закрыта в r1→r2, переподтверждена в r3 живым прогоном `process-gate.mjs`); - полный аудит `hp_relay/{multipart,validate}.py` — вне дельты r2→r3 и вне дельты r3→r4, предмет будущего код-ревью по AC5–AC12. ## Находки ### Medium (в скоупе #43, чинится в этой же задаче, вопрос технический — не владельцу) **§9.2 закрывает находку r3 частично: описывает доверие к `X-Forwarded-For` как безусловный архитектурный факт, а оно на самом деле включается флагом конфигурации `HP_RELAY_TRUSTED_PROXY`, который в спеке, AC и списке обязательных мутаций не упомянут вовсе.** Читаю код (`scripts/support-relay/hp_relay/app.py:147-158`): ```python def _source(self) -> str: if service.cfg.trusted_proxy: forwarded = self.headers.get("X-Forwarded-For", "") if forwarded: return forwarded.split(",")[-1].strip() return self.client_address[0] ``` `trusted_proxy` берётся из `HP_RELAY_TRUSTED_PROXY` (`config.py:82`, default `"1"`) — то есть чтение последнего элемента `X-Forwarded-For` действует только при этом флаге; иначе источником становится адрес TCP-соединения, то есть (при реальной топологии, где Caddy проксирует на `127.0.0.1`) **один и тот же адрес для всех клиентов**, что не «безопаснее», а means-один общий частотный бакет на весь публичный эндпоинт: одна активная попытка исчерпывает лимит для всех источников разом, а не для конкретного клиента, — деградация той же анти-abuse истории (§16, риск 3), только в обратную сторону. Новый абзац §9.2 (добавленный этой дельтой) не называет этот флаг вообще: > «The relay reads the *last* element of `X-Forwarded-For`, because the first > element is whatever the caller sent, and the reverse proxy in front of it is > configured to overwrite the header outright rather than append to it.» Это верно только пока `trusted_proxy=true`; сам разговор о существовании такого выключателя, о том, что оба продовых хоста (`support.houseplan.tech`, `support-staging.houseplan.tech`) обязаны держать его включённым, и о поведении при выключенном — в спеке отсутствует. **Это ровно тот класс дефекта, который сама находка r3 была призвана устранить**: в коде — правильно и обдуманно (для флага есть отдельное имя, дефолт и комментарий в `env.example:18-19`), а в ТЗ — не зафиксировано, значит не переживёт следующий рефакторинг `hp_relay/app.py` (случайное удаление `if service.cfg.trusted_proxy:` при сохранении `.split(",")[-1]` тихо расширит доверие к заголовку на любую топологию, включая прямую экспозицию порта без прокси). Ни `AC12a`, ни новая строка §14.4 эту ветку не покрывают: обе называют только подмену индекса элемента (`[0]` вместо `[-1]`), а не факт, что chтение заголовка вообще управляется флагом. Проверено также, что тестового покрытия ветки `trusted_proxy=False` нет: `grep -n "trusted_proxy" scripts/support-relay/tests/test_relay.py` — 0 совпадений. Ветка «адрес TCP-соединения» сейчас не проверяется ни одним тестом. **Чем закрыть, не открывая вопрос владельцу** (технический пункт по §7.1, решает автор ТЗ, ревьюер вправе оспорить): 1. в §9.2 назвать флаг явно: доверие к `X-Forwarded-For` действует только при `HP_RELAY_TRUSTED_PROXY=1` (`trusted_proxy` в конфиге), и это обязательное состояние для `prod`/`staging` — при выключенном или отсутствующем прокси источником становится адрес TCP-соединения, и это осознанно более консервативный, а не эквивалентный режим (одна корзина на весь трафик, а не «без лимита»); 2. AC12a — добавить условие: гарантия действует только при `trusted_proxy`, включённом на обоих продовых хостах; это часть DoR/release, а не просто поведение по умолчанию; 3. §14.4 — добавить мутацию: снятие проверки `if service.cfg.trusted_proxy` (доверие XFF независимо от флага) должно ронять новый тест, которого сейчас нет — то есть пункт 3 требует и код (тест), не только текст ТЗ; зафиксировать это явно, а не оставлять как молчаливый пробел. Серьёзность — Medium: не открывает утечку данных и не позволяет обойти лимит сильнее, чем позволяет топология (пока `trusted_proxy` включён по умолчанию и прокси настроен верно — как сейчас и есть), но ровно то самое несоответствие кода и ТЗ, которое r3 уже один раз квалифицировал как Medium в этом же абзаце. Не блокирует по High, но без исправления это жёлтый вердикт. ## Что проверено и признано корректным - **Low r3 закрыт полностью**: YAML-шаблон автоматизации в README дословно совпадает с полями, которые шлёт `HaWebhookDelivery.send` (`source`/`report_id`/`text`), включая объяснение, почему `parse_mode: plain_text` обязателен (иначе текст пользователя разбирается как разметка). - **Caddy-конфигурация** (`scripts/support-relay/deploy/Caddyfile.fragment`) подтверждена чтением: `header_up X-Forwarded-For {remote_host}` стоит на **обоих** сайтах (`support.houseplan.tech`, `support-staging.houseplan.tech`) без исключений — то есть при нынешнем деплое реальная топология соответствует тому, что описывает новый абзац §9.2 (с оговоркой из находки выше: соответствие держится на конфиге, а не на архитектурной гарантии). - **AC12a и новая строка §14.4** внутренне непротиворечивы и совпадают с уже существующим (написанным до r3) тестом `test_client_cannot_pick_its_own_rate_bucket` (`scripts/support-relay/tests/test_relay.py:452-467`) — читал тест, он действительно шлёт три поддельных `X-Forwarded-For` и проверяет один общий ключ корзины в `spool/rate/*.json`. - **Классификация `scripts/support-relay/**` class B** не затронута дельтой, живой прогон `node scripts/process-gate.mjs` на HEAD `cd8d01ac`: «гейт пройден, предупреждений 0» (офлайн, диапазон `origin/dev..HEAD`, 10 коммитов). - Коммит `cd8d01ac` несёт `Issue: #43` и `User-Visible: no` (docs-only, корректно — правки в спеку и README не меняют видимое поведение продукта). ## Чего не проверял и почему - `npm test` / `npm run build` (сверка трёх копий бандла) — не прогонял сам: Validate на этом же SHA `cd8d01ac` завершился success (ссылка дана в задании), покрывает `tsc`/`test`/`build`; дельта раунда и так не трогает `src/**`, предмета для этих гейтов у неё нет. - `node scripts/check-docs.mjs` — не требовался: дельта не трогает `src/**`. - `npm run invariants` / инварианты модели — не требовались: дельта не трогает геометрию, `layout`, `marker.space`, `open_spans`. - `python -m pytest tests_backend -q` — не требовался: дельта не трогает `custom_components/**`. - `python3 -m unittest discover -s scripts/support-relay/tests` — **не перегонял сам в этом раунде**: код relay этой дельтой не менялся (только документация), а r3 уже прогнал полный набор (35/35 зелёных) и лично проверил мутациями оба относящихся к этому раунду теста (`test_client_cannot_pick_its_own_rate_bucket`, `test_webhook_sends_text_and_keeps_the_package_on_the_node`) на способность падать — наследую этот результат из r3 (см. таблицу наследования). Находка выше — про то, чего в этом наборе тестов нет (`trusted_proxy=False`), а не про то, что существующие тесты красные. - Браузерные смоки, `npm run golden:verify` — не требовались: `src/**` для фичи #43 ещё не реализован (§1–§8 остаются на этапе ТЗ). - Полный построчный аудит `hp_relay/{multipart,validate}.py` — вне дельты r3→r4, предмет будущего код-ревью по AC5–AC12. ## Гейты, которые прогнал сам | Гейт | Результат | |---|---| | `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» (офлайн, `origin/dev..HEAD`, 10 коммитов) | | Чтение `hp_relay/app.py`, `config.py`, `env.example`, `test_relay.py`, `delivery.py`, `Caddyfile.fragment` | вручную, построчно — источник находки и подтверждения закрытий | | `npx tsc --noEmit` / `npm test` / `npm run build` | не прогонял — Validate зелёный на этом же SHA `cd8d01ac` (см. задание раунда), дельта докс-онли | ## Вывод Low r3 закрыт полностью. Medium r3 закрыт частично — тот же абзац §9.2 получил формулировку, которая верна только при включённом (по умолчанию, но не зафиксированном как обязательное условие) флаге `trusted_proxy`, и это условие не отражено ни в AC12a, ни в §14.4, ни где-либо ещё в ТЗ. High находок нет. Вердикт — жёлтый, не блокирующий продвижение по High, но возвращающий ТЗ на правку одного абзаца и одной AC-строки.