18 KiB
SPEC-REVIEW-43-r4
- Issue: #43 «Диалог помощи и обратной связи с обезличенным support report»
- Этап: spec (PROCESS.md §2.4)
- Заход: r4 · блокирующих циклов израсходовано 2 из 4 (до этого раунда)
- Материал:
docs/specs/043-private-support-report.mdна HEADcd8d01ac(ветка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):
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, решает автор ТЗ, ревьюер вправе оспорить):
- в §9.2 назвать флаг явно: доверие к
X-Forwarded-Forдействует только приHP_RELAY_TRUSTED_PROXY=1(trusted_proxyв конфиге), и это обязательное состояние дляprod/staging— при выключенном или отсутствующем прокси источником становится адрес TCP-соединения, и это осознанно более консервативный, а не эквивалентный режим (одна корзина на весь трафик, а не «без лимита»); - AC12a — добавить условие: гарантия действует только при
trusted_proxy, включённом на обоих продовых хостах; это часть DoR/release, а не просто поведение по умолчанию; - §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на HEADcd8d01ac: «гейт пройден, предупреждений 0» (офлайн, диапазонorigin/dev..HEAD, 10 коммитов). - Коммит
cd8d01acнесётIssue: #43иUser-Visible: no(docs-only, корректно — правки в спеку и README не меняют видимое поведение продукта).
Чего не проверял и почему
npm test/npm run build(сверка трёх копий бандла) — не прогонял сам: Validate на этом же SHAcd8d01acзавершился 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-строки.