mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
211 lines
18 KiB
Markdown
211 lines
18 KiB
Markdown
# 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-строки.
|