feat(mera/b2c): публичный периметр МЕРЫ — свой API, отдельный экран оценки, короткие адреса #2913
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2913
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/mera-public-api"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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сузился до платного шага — там он и должен стоять: бесплатная проба не хранит ничего, платный расчёт хранит.Проверено
vitest47 passed (9 файлов),tsc,next lint,next build, isolation guard 40 файлов, backend 75 passed,caddy validate→Valid configuration.Мутации структурных гейтов:
Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки отвечают 200 анонимно и
/trade-in/api/v1/*на этом домене по-прежнему 404. Зелёная только первая = API открыт целиком, а тест этого не заметил.Caddyfileдобавлен в paths-filter фронтового лэйна: его читает тест маршрутов, и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт.Refs #2894, #2895
feat(mera/b2c): публичный API МЕРЫ — /api/public/mera/{suggest,coverage}to feat(mera/b2c): публичный периметр МЕРЫ — свой API, отдельный экран оценки, короткие адресаredirбез матчера не срабатывалРевью четырьмя независимыми линзами (периметр, семантика 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 → падает тест маршрутов.-v $PWDиз job-контейнера