feat(mera/b2c): публичный периметр МЕРЫ — свой API, отдельный экран оценки, короткие адреса #2913

Merged
lekss361 merged 6 commits from feat/mera-public-api into main 2026-08-16 07:25:24 +00:00
Owner

meraocenka.ru получает собственную поверхность: анонимный API, отдельный экран проверки квартиры и короткие адреса без /trade-in/mera-public.

Два коммита: бэкенд-периметр, затем экран и адреса. Разделять на два PR не стал — текст политики ПДн, смоук периметра и фронт меняются одним решением и по отдельности были бы неправдой друг про друга.


1. Публичный API — отдельный префикс, а не куски /api/v1/*

На публичном домене действует allowlist-by-default. Открыть там API можно было двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под которым по определению не лежит ничего закрытого.

Выбран второй. Разница не в удобстве, а в цене ошибки: при первом одна опечатка в матчере (/trade-in/api/* вместо точного пути) открывает наружу весь v1 — около двадцати ручек, включая PDF расчётов, фотографии объектов, историю и админку. Теперь добавить публичную ручку нужно специально, положив файл в app/api/public/.

Обе ручки делегируют в те же функции, что обслуживают закрытый контур (v1.geocode.suggest_addresses, v1.trade_in.coverage_probe). Разбор #2894 показал, чем кончается вторая копия когорты: проба честно отвечает «данные есть» там, где платный расчёт увидит ноль.

Бюджеты. Общего лимита (300/60с на IP) мало: /suggest через DaData-тир — платный внешний вызов, абуз стоит денег. Своё per-IP окно у каждой ручки: 40/мин на подсказки, 15/мин на пробу.

/suggest — POST, хотя ручка читающая. На публичном домене включён access-лог Caddy, и он пишет URI целиком: GET ...?q=Малышева+51 сохранил бы адрес квартиры в файл рядом с IP посетителя. На той же странице мы обещаем обратное. Тело в лог не попадает. Метод запинен тестом — это часть обещания, а не стиль.

2. Экран проверки — /estimate

Вынесен отдельным адресом: автокомплиту нужно место под список подсказок, результату — место рядом с полями, а в карточке первого экрана нет ни того, ни другого. Форма в герое осталась входной точкой и уводит сюда, донося набранное через sessionStorageне через query-параметры, по той же причине, что и POST выше.

Показывает живую пробу покрытия. Ни одной рублёвой цифры — цену продаёт платный шаг. Тексты вердикта вынесены чистой функцией и покрыты тестами:

  • подпись под возрастом обязана говорить «объявление», а не «продаётся»: выборка цензурирована, эти квартиры ещё продаются, и реальная экспозиция проданных всегда меньше;
  • при неизвестном возрасте плитки нет вообще — ноль вместо «неизвестно» был бы худшей из ошибок;
  • пустая когорта объясняется как факт о рынке, с подсказкой, что поменять (директива «никогда не блокировать вывод»).

3. Короткие адреса

Человек больше не видит /trade-in/mera-public/... — только /, /estimate, /oferta, /refund, /privacy. Длинные отдают 301 на короткие: у страницы один канонический адрес, а разосланные ссылки и закладки живы.

Ссылки эмитятся обычным <a> (компонент PublicLink): next/link подставляет basePath, и href="/estimate" уехал бы на несуществующий /trade-in/estimate.

Чем платим: переходы работают только на meraocenka.ru. Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru — публичный сервис живёт на своём домене, и делать его адреса некрасивыми ради QA-открытия с соседнего хоста не та цена.

4. Три дефекта, которые нашлись на живом сайте

Палитра v3 никуда не доезжала. b2c-tokens.ts завёл палитру макета, CSS начал ссылаться на var(--b2c-*) — а моста между ними не появилось: файл не импортировал никто, ни одна переменная нигде не объявлялась. Каскад молча пропускает такие декларации, поэтому страница отдавала 200 бесцветной — карточки без фона, кнопки без заливки. Ни сборка, ни линт, ни тайпчек этого не видят.

Голый /trade-in/mera-public падал в 404 — матчер был /trade-in/mera-public/*, со слэшем и звёздочкой. Ровно на этот адрес вела «Главная» в подвале.

«Для бизнеса» вела на / — то есть на сам лэндинг. Теперь абсолютный адрес B2B-контура. «Проверьте себя» и «Продажа под ключ» вели на якоря, которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны.

5. Политика ПДн переписана вместе с кодом

Прежний п. 5.4 утверждал, что введённый адрес не покидает браузер. С открытием бесплатной проверки это перестало быть правдой, поэтому текст заменён на точный: передаётся, используется однократно, в базах не сохраняется.

Последнее проверено по коду, а не предположено: suggest() работает без кэша (докстринг services/geocoder.py), проба покрытия — один SELECT, RequestAudit пишет строку только при наличии username, которого у анонима нет; адрес в любом случае едет телом и в аудит не попадает.

PUBLIC_ESTIMATE_ENABLED сузился до платного шага — там он и должен стоять: бесплатная проба не хранит ничего, платный расчёт хранит.

Проверено

vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard 40 файлов, backend 75 passed, caddy validateValid configuration.

Мутации структурных гейтов:

убрать пути из rbac._PUBLIC_PATHS      → 7 failed / 4 passed
снять бюджет с /coverage               → 2 failed / 9 passed
убрать --b2c-accent-text из b2cVars    → падает тест палитры
убрать /estimate из @meraPages         → падает тест маршрутов
добавить импорт next/link              → падает тест basePath
откат                                  → всё зелёное

Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки отвечают 200 анонимно и /trade-in/api/v1/* на этом домене по-прежнему 404. Зелёная только первая = API открыт целиком, а тест этого не заметил.

Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов, и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт.

Refs #2894, #2895

`meraocenka.ru` получает собственную поверхность: анонимный API, отдельный экран проверки квартиры и короткие адреса без `/trade-in/mera-public`. Два коммита: бэкенд-периметр, затем экран и адреса. Разделять на два PR не стал — текст политики ПДн, смоук периметра и фронт меняются одним решением и по отдельности были бы неправдой друг про друга. --- ## 1. Публичный API — отдельный префикс, а не куски `/api/v1/*` На публичном домене действует allowlist-by-default. Открыть там API можно было двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под которым по определению не лежит ничего закрытого. Выбран второй. Разница не в удобстве, а в цене ошибки: при первом одна опечатка в матчере (`/trade-in/api/*` вместо точного пути) открывает наружу **весь** v1 — около двадцати ручек, включая PDF расчётов, фотографии объектов, историю и админку. Теперь добавить публичную ручку нужно специально, положив файл в `app/api/public/`. Обе ручки делегируют в те же функции, что обслуживают закрытый контур (`v1.geocode.suggest_addresses`, `v1.trade_in.coverage_probe`). Разбор #2894 показал, чем кончается вторая копия когорты: проба честно отвечает «данные есть» там, где платный расчёт увидит ноль. **Бюджеты.** Общего лимита (300/60с на IP) мало: `/suggest` через DaData-тир — платный внешний вызов, абуз стоит денег. Своё per-IP окно у каждой ручки: 40/мин на подсказки, 15/мин на пробу. **`/suggest` — POST, хотя ручка читающая.** На публичном домене включён access-лог Caddy, и он пишет URI целиком: `GET ...?q=Малышева+51` сохранил бы адрес квартиры в файл рядом с IP посетителя. На той же странице мы обещаем обратное. Тело в лог не попадает. Метод запинен тестом — это часть обещания, а не стиль. ## 2. Экран проверки — `/estimate` Вынесен отдельным адресом: автокомплиту нужно место под список подсказок, результату — место рядом с полями, а в карточке первого экрана нет ни того, ни другого. Форма в герое осталась входной точкой и уводит сюда, донося набранное через `sessionStorage` — **не** через query-параметры, по той же причине, что и POST выше. Показывает живую пробу покрытия. Ни одной рублёвой цифры — цену продаёт платный шаг. Тексты вердикта вынесены чистой функцией и покрыты тестами: - подпись под возрастом обязана говорить «объявление», а не «продаётся»: выборка цензурирована, эти квартиры ещё продаются, и реальная экспозиция проданных всегда меньше; - при неизвестном возрасте плитки нет вообще — ноль вместо «неизвестно» был бы худшей из ошибок; - пустая когорта объясняется как факт о рынке, с подсказкой, что поменять (директива «никогда не блокировать вывод»). ## 3. Короткие адреса Человек больше не видит `/trade-in/mera-public/...` — только `/`, `/estimate`, `/oferta`, `/refund`, `/privacy`. Длинные отдают 301 на короткие: у страницы один канонический адрес, а разосланные ссылки и закладки живы. Ссылки эмитятся обычным `<a>` (компонент `PublicLink`): `next/link` подставляет `basePath`, и `href="/estimate"` уехал бы на несуществующий `/trade-in/estimate`. **Чем платим:** переходы работают только на `meraocenka.ru`. Открывать лэндинг для проверки нужно там же, а не с `gendsgn.ru` — публичный сервис живёт на своём домене, и делать его адреса некрасивыми ради QA-открытия с соседнего хоста не та цена. ## 4. Три дефекта, которые нашлись на живом сайте **Палитра v3 никуда не доезжала.** `b2c-tokens.ts` завёл палитру макета, CSS начал ссылаться на `var(--b2c-*)` — а моста между ними не появилось: файл не импортировал никто, ни одна переменная нигде не объявлялась. Каскад молча пропускает такие декларации, поэтому страница отдавала 200 бесцветной — карточки без фона, кнопки без заливки. Ни сборка, ни линт, ни тайпчек этого не видят. **Голый `/trade-in/mera-public` падал в 404** — матчер был `/trade-in/mera-public/*`, со слэшем и звёздочкой. Ровно на этот адрес вела «Главная» в подвале. **«Для бизнеса» вела на `/`** — то есть на сам лэндинг. Теперь абсолютный адрес B2B-контура. «Проверьте себя» и «Продажа под ключ» вели на якоря, которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны. ## 5. Политика ПДн переписана вместе с кодом Прежний п. 5.4 утверждал, что введённый адрес не покидает браузер. С открытием бесплатной проверки это перестало быть правдой, поэтому текст заменён на точный: передаётся, используется однократно, в базах не сохраняется. Последнее проверено по коду, а не предположено: `suggest()` работает без кэша (докстринг `services/geocoder.py`), проба покрытия — один SELECT, `RequestAudit` пишет строку только при наличии username, которого у анонима нет; адрес в любом случае едет телом и в аудит не попадает. `PUBLIC_ESTIMATE_ENABLED` сузился до платного шага — там он и должен стоять: бесплатная проба не хранит ничего, платный расчёт хранит. ## Проверено `vitest` 47 passed (9 файлов), `tsc`, `next lint`, `next build`, isolation guard 40 файлов, backend 75 passed, `caddy validate` → `Valid configuration`. Мутации структурных гейтов: ``` убрать пути из rbac._PUBLIC_PATHS → 7 failed / 4 passed снять бюджет с /coverage → 2 failed / 9 passed убрать --b2c-accent-text из b2cVars → падает тест палитры убрать /estimate из @meraPages → падает тест маршрутов добавить импорт next/link → падает тест basePath откат → всё зелёное ``` Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки отвечают 200 анонимно **и** `/trade-in/api/v1/*` на этом домене по-прежнему 404. Зелёная только первая = API открыт целиком, а тест этого не заметил. `Caddyfile` добавлен в paths-filter фронтового лэйна: его читает тест маршрутов, и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт. Refs #2894, #2895
lekss361 added 1 commit 2026-08-15 19:33:35 +00:00
feat(mera/b2c): публичный API МЕРЫ — /api/public/mera/{suggest,coverage}
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m49s
d046eaada2
Первый шаг к отдельному B2C-интерфейсу оценки на meraocenka.ru: домен получает
собственную поверхность бэкенда вместо того, чтобы тянуть куски закрытого
контура.

## Отдельный префикс, а не проброс кусков /api/v1/*

На meraocenka.ru действует allowlist-by-default. Открыть там API можно было
двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под
которым по определению не лежит ничего закрытого. Выбран второй: при первом
одна опечатка в матчере (`/trade-in/api/*` вместо точного пути) открывает
наружу весь v1 — ~20 ручек, включая PDF расчётов, фотографии и админку. Цена
ошибки, а не удобство.

Добавить сюда приватную ручку теперь нужно СПЕЦИАЛЬНО — положив файл в
app/api/public/. Случайно нельзя.

## Ноль записей в БД

Обе ручки только читают: /coverage — один SELECT, /suggest — прокси
автокомплита. Это условие, при котором публичная форма работает ДО контура
согласия 152-ФЗ (#2895: сегодня адрес физлица попадает в trade_in_estimates
раньше согласия, а пути удаления в бэкенде нет). Платный расчёт, который писать
будет, открывается только вместе с ним.

## Делегирование, а не копии

Обе ручки вызывают те же функции, что обслуживают закрытый контур
(v1.geocode.suggest_addresses, v1.trade_in.coverage_probe). Разбор #2894
показал, чем кончается вторая копия когорты: проба отвечает «данные есть» там,
где платный расчёт видит ноль. Публичный ответ переиспользует
CoverageProbeResponse — на нём уже стоит гейт «ни одного price-подобного поля».

## Бюджеты

Общего 300/60с мало: /suggest через DaData-тир — платный внешний вызов, абуз
стоит денег. Свои per-IP окна: 40/мин на подсказки (человек с debounce'ом
тратит единицы на адрес), 15/мин на пробу.

## Проверено

11 тестов, из них структурные: набор ручек под /api/public проверяется на
РАВЕНСТВО (третья, добавленная без правки теста, роняет сборку) и сверяется с
rbac._PUBLIC_PATHS в обе стороны — чтобы не осталось открытого пути-призрака.
Рядом висит закрытый маршрут-двойник: без него «аноним получает 200» одинаково
зелёный и когда исключение точечное, и когда auth-гейт снят целиком.

Мутации:
  убрать пути из rbac._PUBLIC_PATHS → 7 failed / 4 passed
  снять бюджет с /coverage         → 2 failed / 9 passed
  откат                            → 11 passed

Плюс 72 passed на связке rbac + coverage + version, `caddy validate` = Valid
configuration, ruff чист.

Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки
отвечают 200 анонимно И /trade-in/api/v1/* на этом домене по-прежнему 404.
Зелёная только первая проверка = API открыт целиком, а тест этого не заметил.

Refs #2894, #2895
bot-backend added 1 commit 2026-08-16 06:27:19 +00:00
feat(mera/b2c): отдельный экран оценки на meraocenka.ru и короткие адреса
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 44s
CI Trade-In / frontend-checks (pull_request) Successful in 1m2s
CI Trade-In / backend-tests (pull_request) Successful in 4m41s
7424c283d5
## Экран проверки — /estimate

Проверка квартиры вынесена на собственный адрес: там у автокомплита есть
место под список подсказок, а у результата — место рядом с полями. Форма в
герое лэндинга осталась входной точкой и уводит сюда, донося набранное через
sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом
с IP посетителя, а мы на той же странице обещаем ничего не хранить).

Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и
сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт
платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и
покрыты тестами: подпись под возрастом обязана говорить «объявление», а не
«продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет
вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что
поменять, — директива «никогда не блокировать вывод».

## Короткие адреса

Человек больше не видит /trade-in/mera-public/... — только /, /estimate,
/oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы
один канонический адрес, старые ссылки живы.

Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт
этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru.

Ссылки эмитятся обычным <a> (PublicLink) — next/link подставляет basePath, и
href="/estimate" уехал бы на несуществующий /trade-in/estimate.

## Три дефекта, найденных на живом сайте

1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна
   --b2c-* переменная не объявлялась, каскад молча пропускал такие декларации —
   лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест:
   каждая использованная в CSS переменная обязана быть объявлена.
2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой.
   Ровно туда вела «Главная» в подвале.
3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес
   B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря,
   которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны.

## Периметр и приватность

Подсказки переведены на POST: access-лог публичного домена пишет URI целиком,
то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает.
Метод запинен тестом — это часть обещания, а не стиль.

П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что
адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно —
передаётся, используется однократно, в базах не сохраняется. Последнее
проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит
пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до
платного шага (бесплатная проба не хранит ничего, платный расчёт хранит).

## Проверено

vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard
40 файлов, backend 75 passed, caddy validate = Valid configuration.

Мутации структурных гейтов:
  убрать --b2c-accent-text из b2cVars   → падает тест палитры
  убрать /estimate из @meraPages        → падает тест маршрутов
  добавить импорт next/link             → падает тест basePath
  откат                                 → 14 passed

Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов,
и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт.

Refs #2894, #2895
lekss361 changed title from feat(mera/b2c): публичный API МЕРЫ — /api/public/mera/{suggest,coverage} to feat(mera/b2c): публичный периметр МЕРЫ — свой API, отдельный экран оценки, короткие адреса 2026-08-16 06:28:07 +00:00
bot-backend added 1 commit 2026-08-16 06:28:50 +00:00
Merge remote-tracking branch 'forgejo/main' into feat/mera-public-api
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Successful in 46s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m45s
b1fb7bb055
# Conflicts:
#	tradein-mvp/backend/app/core/rbac.py
bot-backend added 1 commit 2026-08-16 06:47:00 +00:00
fix(mera/b2c): длинные адреса реально редиректят — redir без матчера не срабатывал
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 44s
CI Trade-In / frontend-checks (pull_request) Successful in 59s
CI Trade-In / backend-tests (pull_request) Successful in 4m38s
208173d816
Проверка на живом Caddy (локальный стенд: реальный site-блок + заглушка вместо
фронта) показала, что 301 не работал вовсе: длинные адреса отдавали пустой
200. Причина — разбор Caddyfile: первый аргумент директивы, начинающийся со
слэша, читается как inline path-matcher. То есть `redir / permanent` означает
«для пути / редиректить на permanent», а не «редиректить на /»; матчер не
совпадал, директива молча не срабатывала, наружу уходило пустое тело.

Пустая страница с кодом 200 хуже 404: выглядит рабочей и не ловится ни
смоуком по коду ответа, ни глазом.

Замер после правки (`redir * <куда>`):

  /trade-in/mera-public                 301 → /
  /trade-in/mera-public/                301 → /
  /trade-in/mera-public/oferta          301 → /oferta
  /trade-in/mera-public/v3              301 → /v3
  /                                     200   upstream /trade-in/mera-public
  /estimate                             200   upstream /trade-in/mera-public/estimate
  /trade-in/api/public/mera/coverage    200   upstream /api/public/mera/coverage
  /trade-in/api/v1/me                   404
  /trade-in/v2                          404

Заодно захват сужен до `([^/].*)` — страховка от протокол-относительной цели
(`//evil.example` резолвится браузером как чужой хост). Тем же замером
проверено, что сегодня это недостижимо и без ограничения: Caddy нормализует
путь до матчинга, и `//evil.example` / `/%2Fevil.example` приезжают одним
слэшем. Ограничение оставлено как второй барьер, комментарий говорит ровно
это — а не то, что чинилась живая дыра.
bot-backend added 1 commit 2026-08-16 07:01:57 +00:00
fix(mera/b2c): семь дефектов публичного периметра, найденных состязательным ревью
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 46s
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 4m42s
56194c606a
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.

## Caddy: открытый редирект и потерянные ссылки

Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.

Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).

Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).

`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.

## Гейты, которых не было

`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.

Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.

## Бюджет внешнего геокодера

Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.

Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.

## «Адрес нигде не сохраняется» — теперь правда целиком

Две утечки, обе воспроизведены:

1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
   вызов, прод пишет stdout в persistent journald — адрес ложился на диск
   рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
   время публичного запроса (contextvar, переживает await и to_thread).
   Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
   публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
   не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
   Закрыто `scrub_public_address`.

Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.

## Фронт

- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
  не наступала вовсе, если человек переставал печатать: ответ по старой строке
  долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
  промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
  option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
  rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
  вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
  оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
  уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.

## Проверено

Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.

Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
bot-backend added 1 commit 2026-08-16 07:07:45 +00:00
fix(ci): гейт Caddyfile не мог прочитать конфиг — -v $PWD из job-контейнера
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Successful in 52s
CI Trade-In / frontend-checks (pull_request) Successful in 1m32s
CI / frontend-tests (pull_request) Successful in 1m36s
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI Trade-In / backend-tests (pull_request) Successful in 5m10s
CI / backend-tests (pull_request) Successful in 16m46s
eb7ac3d326
Первая версия шага смонтировала `$PWD` внутрь caddy-контейнера и упала на
`open /etc/caddy/Caddyfile: no such file or directory`.

Причина: job сам исполняется внутри контейнера, а `docker run` создаёт
КОНТЕЙНЕР-БРАТ на том же демоне. Путь в `-v` резолвится на ХОСТЕ, тогда как
`$PWD` — путь внутри job-контейнера, которого на хосте нет. Классическая
ловушка docker-in-docker, и она не зависит от содержимого конфига — смонтируй
так что угодно, монтирования просто не произойдёт.

Заменено на `docker create -w /work` + `docker cp` + `docker start -a`: копия
не зависит от того, как смонтирован workspace. Копируется и каталог `caddy/` —
Caddyfile делает `import caddy/users.caddy.snippet`, без него validate падает
на импорте.

Проверено локально обе стороны: валидный конфиг → `Valid configuration`,
exit 0; конфиг с незакрытой скобкой → `unexpected EOF`, exit 1. Без второй
проверки гейт мог бы оказаться вечно-зелёным.
lekss361 merged commit 51a2d7db84 into main 2026-08-16 07:25:24 +00:00
lekss361 deleted branch feat/mera-public-api 2026-08-16 07:25:24 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2913
No description provided.