Замер на проде 2026-08-22, разбивка одного расчёта по логам:
10.659 старт
10.827 дом найден 0.17 с
11.565 аналоги, 49 кандидатов 0.74 с
11.579 ДКП-коридор 0.01 с
17.576 yandex_valuation ← 6.0 с
19.159 cian_valuation ← 1.5 с
19.252 готово итого 8.6 с
Семь с половиной секунд из восьми с половиной — ожидание чужих HTTP. Наша база
и сам расчёт укладываются в секунду. По уже виденному адресу (кэш 24 ч) — 0.4-0.8 с,
по новому — 8-12.4 с. Геокодинг ни при чём: с готовыми координатами те же 7.5-10.8.
Публикация в РБК 30.08 приведёт аудиторию на НОВЫЕ адреса, то есть мимо кэша.
Масштабирование контейнеров тут не помогает: время уходит на ожидание чужого
ответа, а не на наши вычисления.
Что сделано: у обоих источников появился режим «только кэш» (fetch_on_miss).
При включённом ESTIMATE_EXTERNAL_SOURCES_BACKGROUND запрос делает лишь чтение
кэша (локальный запрос на миллисекунды), а свежая загрузка уходит в фон и
наполняет кэш к следующему обращению по тому же адресу.
Почему это безопасно: деградация источника в None — НЕ новое состояние ответа.
Ровно так же ведёт себя таймаут estimate_*_valuation_timeout_s, и этот путь
работает в проде сегодня. Контракт API не меняется.
Фоновая задача берёт СВОЮ сессию: сессия запроса закрывается вместе с ответом,
а обе функции источников делают внутри себя db.commit() — переиспользование
чужой сессии зафиксировало бы её незавершённую работу. По той же причине
отвергнут наивный asyncio.gather двух источников на одной сессии.
Очередь догрузки ограничена восемью задачами. Без потолка всплеск по новым
адресам — ровно тот случай, ради которого режим и сделан — породил бы сотни
параллельных задач с сессиями и HTTP-клиентами при max_connections 100 и
mem_limit 768m у backend, то есть отказ вместо ускорения.
Дефолт в коде False: поведение других окружений не меняется. На проде режим
включён через docker-compose.prod.yml у сервиса backend.
Отдельно НЕ сделано, хотя предлагалось: снижение таймаутов до 4 с. Замер
показал, что свежий запрос к Яндексу занимает 6 с — таймаут 4 обрывал бы его
почти всегда, кэш бы не наполнялся, и источник оказался бы тихо отключён.
Таймаут здесь страховка от патологии, а не регулятор задержки.
Тесты: 7 штук на режим «только кэш», собственную сессию, гашение ошибок,
удержание ссылки на задачу и потолок очереди. Фальсифицированы — на неизменённом
коде падают 5 из 7 (проходит только сторож неизменности дефолта). Смежные
тесты оценщика (29 штук: бюджет ЦИАН, клиентские координаты, аудит) зелёные.
Замер на проде 2026-08-21: Авито за QRATOR отдаёт JavaScript proof-of-work
челлендж (startPow → кука pow_solved → self-reload через window.location).
curl_cffi не исполняет JS и пройти его не может в принципе.
Что это давало в цифрах (counters прогонов avito_detail_backfill):
run 4508: enriched 3 / attempted 46
run 4394: enriched 2 / attempted 47
run 4348: enriched 4 / attempted 53
~6% успеха. Браузерный путь на том же проде — ~74% (17/23 в инлайн-обогащении
свипа, 2/3 в точечной проверке после #3048). То есть задание работало почти
вхолостую, а поля дома, которые чинил #3048, до карточек просто не доходили.
Исходное обоснование AVITO_DETAIL_BACKFILL_USE_CURL=true (browser открывает
десятки коннектов и превышает cap прокси-аккаунта auv) больше не действует:
браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect
SCRAPER_PROXY_URL, что и curl-путь.
Переключение сделано в docker-compose.prod.yml (environment перекрывает
env_file), а не правкой рантайм-env на хосте — чтобы изменение осталось в
репозитории и пережило deploy без дрейфа конфигурации. Дефолт в config.py
оставлен True: другие окружения не трогаем вслепую, но устаревшее обоснование
там помечено.
В SQL-формуле relevance_score кандидат без year_built получает штраф 0 —
столько же, сколько точное попадание в год, и лучше, чем кандидат с
известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие
данных выигрывает у знания и возвышает источник с худшей полнотой.
Флаг estimate_unknown_attr_penalty_enabled (default OFF): кандидат с NULL
получает МЕДИАННЫЙ по пулу штраф того же признака среди тех, у кого он
известен — не наказание и не награда; считается по тем же термам, что
SQL (abs(Δyear)/12.0, 1.5 за несовпадение типа). Лежит в Python-слое
после SQL, рядом с kitchen/ceiling (#2012), по тому же контракту:
включать — только по бэктесту.
Без чего флаг был бы мёртв (и был в первой редакции): _ANALOG_SELECT_COLS
не выбирал year_built/house_type — SQL считал по ним CASE, но в словарь
кандидата колонки не попадали, Python-слой видел None у ВСЕХ и не штрафовал
никого по построению. Probe-лог в прод-оверлее: pool=30 null_year=30.
Добавлены в _ANALOG_SELECT_COLS, во внешние SELECT тиров H/W и во
внутренний base Tier W (он строится явным списком). Контроль: флаг OFF с
колонками и без — метрики бэктеста идентичны до сотых. На этот инвариант
стоит тест по исходнику запросов.
Живой A/B (бэктест в прод-контейнере, оверлей /tmp/ab, 300 сделок ЕКБ
Q2 2026, одна и та же выборка в обоих прогонах — проверено по deal_id):
состав топ-50: сменилось 96 слотов из 5 915 (1.6 %), 27 сделок из 300
источники: avito 55.7→55.3 %, cian 24.5→24.8 %, yandex 12.7→12.6 %
цена: MAPE 16.70→16.70, bias −4.52→−4.52, coverage 84.46→84.46 —
идентично до сотых по всем срезам (сегменты, комнатность).
Нижний Тагил (300 сделок, пулы 21/p90 40): 0 из 5 666 слотов сменилось.
Почему эффект в разы меньше замера задачи (×0.20 avito): тот замер шёл
по SQL тира H без стратификации. В боевом пути 54.9 % слотов топ-50 —
гарантированная квота MIN_ANALOGS_PER_SOURCE=5, раздаётся ДО сортировки
остатка; и у avito в топ-50 NULL-год лишь у 25.7 % (задача мерила 56 %
по всем активным объявлениям). Обе величины измерены по фикстурам A/B.
Что это значит: артефакт в формуле есть, флаг его корректно лечит, но на
итоговую цену он не влияет измеримо. Включать по умолчанию оснований
нет — и это и есть ответ, ради которого флаг заводился вместо правки.
pytest tradein-mvp/backend: test_2936 7 passed; гейт фикстуры и
roundtrip 8 passed; -k "estimat or analog" 735 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ревью четырьмя независимыми линзами (периметр, семантика 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 → падает тест маршрутов.
Мониторинг GlitchTip сейчас нем (alerts_projectalert/alerts_alertrecipient
пусты, EMAIL_URL=consolemail:// печатает письма в stdout, аудит на проде
2026-08-15). GlitchTip умеет получателя типа webhook, но шлёт свой Slack-
совместимый JSON без каких-либо заголовков — Telegram Bot API его не
понимает, нужен адаптер.
- app/api/v1/glitchtip.py: POST /api/v1/trade-in/ops/glitchtip-webhook —
принимает issue- и uptime-алерты (структурно одинаковый payload у
GlitchTip 6.1.6, см. docstring), форматирует короткое сообщение
(проект/заголовок/ссылка/время получения) и шлёт через существующий
TelegramClient в отдельную тему алертов. Обрезка под лимит Telegram
(4096 симв.), неизвестная форма payload пересылается как есть с
пометкой вместо 500.
- Auth: GlitchTip не может слать кастомные заголовки (aiohttp.post без
headers=) — переиспользуем TRADEIN_INTERNAL_AUTH_SECRET (#2213) как
query-параметр `secret`, constant-time compare. В отличие от rbac.py
пустой секрет здесь fail-CLOSED (503), это единственный auth-рубеж пути.
- config.py: TELEGRAM_ALERTS_CHAT_ID / TELEGRAM_ALERTS_TOPIC_ID — намеренно
отдельные от TELEGRAM_SUPPORT_*, чтобы алерты не лились в топик клиентов.
- rbac.py: путь добавлен в _PUBLIC_PATHS (фиксированный, без секрета в
самом пути — секрет только в query).
- docker-compose.prod.yml: glitchtip-worker (реально шлёт вебхуки, не
glitchtip-web) переведён на networks: [default, shared] — без этого
tradein-backend не резолвится с его стороны (общей сети не было вообще).
Первый шаг к отдельному 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
Слияние main принесло собственные Sentry-скрубберы (redact_telegram_bot_token +
stabilize_retry_error_fingerprint) в app/main.py и app/scheduler_main.py — конфликт
разрешён композицией, а не выбором стороны: обработчик перед отправкой в GlitchTip
теперь прогоняет событие через всю цепочку в указанном порядке:
scrub_payment_request_body → scrub_pii_event → redact_telegram_bot_token →
stabilize_retry_error_fingerprint (main.py), и без redact_telegram_bot_token в
scheduler_main.py (тот процесс не держит TelegramClient) — оба канала,
before_send и before_send_transaction, используют один и тот же обработчик.
tests/test_sentry_scrub.py: тесты обеих сторон объединены без потерь — PR-D2
платёжный composed-тест (body-wipe + PII-scrub + token-redaction) и весь блок
RetryError fingerprint-стабилизации из main сосуществуют в одном файле.
PR-D2 платёжного контура МЕРЫ — закрывает утечки до открытия публичных путей
(PR-D3/D4), сам ничего не открывает: _PUBLIC_PATHS (rbac.py), Caddyfile,
roles.yaml, auth_session.py не тронуты.
- sentry_scrub.py: новая scrub_payment_request_body — вырезает
event.request.data целиком для /api/v1/trade-in/payments/* (sentry_sdk 2.64
кладёт полное тело запроса в request.data, send_default_pii=False это НЕ
гейтит — тот флаг управляет только куками). Плюс расширен _PII_KEYS:
customer_email/customer_phone/pan/expdate/cardid/rebillid/token/terminalkey.
- main.py, scheduler_main.py, tgbot_main.py (все 3 точки инициализации
sentry_sdk.init в проекте) — тот же обработчик проведён в ОБА канала,
before_send и before_send_transaction. Мотивирующий инцидент: на соседнем
продукте вчера закрыли только error-канал, transaction остался без
обработчика вообще.
- ratelimit.py: точный путь notify — свой щедрый SlidingWindowLimiter
(3000/60с per-IP, идиома support.py) вместо общего лимитера, но НЕ полное
отключение — backstop против шторма запросов остаётся, подпись проверяется
уже после разбора тела (PR-D3). Только notify, не checkout (тот с сессией).
- request_audit.py: notify — в audit skip-набор (defense-in-depth: middleware
внешний относительно rbac_guard и читает сырой X-Authenticated-User —
спуфнутый заголовок иначе писал бы фальшивые события с атрибуцией admin).
- smoke-mera-perimeter.sh: негативные проверки-канарейки — notify/checkout
сейчас закрыты 404 (meraocenka.ru, Caddy не проксирует) и 401
(gendsgn.ru, rbac ещё не открыл) с обеих сторон периметра.
Тесты: scrub на произвольной глубине + payment-path body-wipe, AST-разбор
(не substring — комментарии в этих же файлах сами упоминают
before_send_transaction) на проводку обоих каналов во всех точках
инициализации, 400 запросов notify без единого 429 + контроль что общий
лимитер по-прежнему активен на других путях, notify вне user_events даже со
спуфнутым X-Authenticated-User: admin.
main уехал вперёд за сутки: 234 занял 234_scrape_runs_ban_kind_unknown.sql
(0de22f4b), максимум на main сейчас 239 (235-237 — дыры). max+1=240 безопаснее
дыр; ни один открытый PR номер 235-240 не занимает (сверено по forgejo/main и
всем открытым веткам).
Переименован файл + обновлены все 7 упоминаний "migration 234"
(_manifest_applied.txt, config.py, schemas/trade_in.py,
purge_expired_trade_in_data.py, test_estimate_idor.py, content.ts,
types/trade-in.ts) — правки текстовые, ни один тест не читает миграцию по
имени файла.
Deep-review MEDIUM: предполётная проверка purge_expired_trade_in_data считала
по базовому предикату без retain_until — здоровая оплаченная строка (retain_until
проставлен, платёж есть) через сутки после продажи тоже попадала под счётчик,
и джоба аварийно останавливалась на первой же честной продаже навсегда
(вместе с ней — и 180-дневное удаление лидов, вызываемое из той же функции
после этой проверки).
- _PREFLIGHT_PAID_CANDIDATES_SQL: добавлен терм `retain_until IS NULL` —
теперь считает только реальную аномалию (retain_until не проставлен, а
платёж есть), а не штатное состояние. Докстринги функции/модуля поправлены
под фактическое поведение.
- Тест на неверный инвариант (`"retain_until" not in sql`) заменён на
позитивный (`"retain_until IS NULL" in sql`) + добавлены live-DB тесты на
оба случая из ревью (здоровая оплаченная строка не поднимает тревогу,
джоба не блокируется).
- privacy/page.tsx: константа "12 месяцев" вынесена в content.ts
(PAID_REPORT_RETENTION_MONTHS) вместо литерала + расходящегося комментария;
добавлен сверяющий тест (test_paid_retention_text_consistency.py) по
образцу _CONSENT_TEXT_SNAPSHOT. Смягчена формулировка про автоматическое
удаление — задача на проде выключена и ни разу не запускалась, текст
теперь описывает установленный порядок, а не наблюдаемый факт.
- Все 10 висячих ссылок на untracked `mera-pr-d-spec.md` (7 файлов) заменены
на краткое изложение сути в комментарии + ссылку на PR #2754.
Мина: purge_expired_trade_in_data (сейчас enabled=false) удаляет строки
WHERE expires_at < NOW() AND created_by IS NULL — это ровно популяция
будущих платящих физлиц (владелец продаёт отчёт за 150 руб., отчёт должен
жить год на нашей стороне, а не 24ч). Первый прогон после запуска продаж
безвозвратно снёс бы оплаченное.
Делается ДО платёжного кода, которого в этом PR нет:
- migration 234: колонка trade_in_estimates.retain_until (NULL = неоплачено,
бэкенд-бита-в-бит не меняется) + частичный индекс под purge-предикат.
- config.py: trade_in_paid_retention_days=365 (ENV) — единственный источник
"12 месяцев" для будущей оферты/экрана/SQL продления.
- Единый гейт чтения ESTIMATE_READABLE_SQL + estimate_readable() — раньше
SQL-фильтр (404) и Python-проверка (410) в trade_in.py уже разошлись по
тексту ответа; текст "estimate expired (24h TTL)" убран (стал бы ложью при
годовом хранении).
- purge_expired_trade_in_data: retain_until IS NULL (не < NOW() — оплаченное
не удаляем в принципе) + NOT EXISTS(payments) как независимая страховка +
pre-flight, который считает оплаченных кандидатов и падает в mark_failed
ДО первого батча при ненулевом результате.
- PDF: "Ссылка доступна до …" только при retain_until IS NOT NULL;
"ДЕЙСТВИТЕЛЕН ДО" (expires_at, актуальность расчёта) не тронут.
- Фронт: retain_until прокинут в mapper (validUntil остаётся на expires_at).
- privacy-страница: убрано устаревшее "механизма удаления нет" (неправда
после #2547), добавлен срок 12 месяцев для оплаченных отчётов.
Ни строчки платёжного кода. expires_at, trade_in_estimate_retention_hours,
_DELETE_EXPIRED_LEADS_SQL не тронуты.
Deep review APPROVE (deep-code-reviewer, 2026-08-06).
HIGH закрыт: purge trade_in_estimates ограничен `created_by IS NULL` — 129 B2C-строк
под удаление, 911 пилотских защищены (сверено на проде: 1040 просрочено всего).
MEDIUM закрыт: телефон в erase_person_data сравнивается по каноническому РФ-виду
с обеих сторон (8→7 при 11 цифрах, без усечения до последних 10).
Проверено: миграции 229/231 прогнаны на прод-схеме в BEGIN…ROLLBACK, тело дважды —
идемпотентны; CHECK consent отбивает false; NN свободны на main и в открытых PR;
consent-гейт недостижим для B2B (session-cookie инжектит X-Authenticated-User);
адрес не попадает в БД раньше согласия ни одним путём.
Гейт: CI Trade-In / backend-tests success 3m9s на 4ee4d4b8.
Миграция 233_payments.sql (payments / payment_notifications / payment_entitlements), поля TBANK_* и PAYMENTS_ENABLED, fail-fast в lifespan. Бизнес-логики нет, контур выключен по умолчанию.
По итогам deep review: UNIQUE NULLS NOT DISTINCT на обоих дедуп-ключах, payment_notifications.processed_at, payments.pd_erased_at, payments_lead_idx, CHECK на длину order_id, статусы сверены с официальной openapi.yaml (Confirm-2, v1.24).
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
192/193 -> 229/230: main занял 192_tradein_users_auth.sql и
193_tradein_users_seed.sql за время простоя PR. 228 зарезервирован
открытым PR #2732 (228_payments.sql) - следующие реально свободные
229/230, порядок consent_proof -> retention сохранён.
Правки ссылок на старые имена/префиксы: docstring-заголовки самих
SQL-файлов, перекрёстная ссылка 229 -> 230 в комментарии-докстринге,
комментарии migration 192/193 в lead.py / config.py / schemas/trade_in.py
/ purge_expired_trade_in_data.py, переменные и имена тестов в
test_estimate_consent_gate.py / test_purge_expired_trade_in_data.py.
(Оставлены нетронутыми ссылки на migration 192/193 в auth_session.py и
test_team_api.py - это про другие, уже существующие на main миграции
192_tradein_users_auth.sql / 193_tradein_users_seed.sql, не про эту
пару.)
Лимит на логине ключевался парой (username, IP), поэтому распределённый
перебор одного имени с тысячи адресов получал по 5 попыток с каждого
источника и не упирался ни во что. После снятия Caddy basic_auth с
/trade-in (#2558) POST /auth/login — единственная ручка, доступная из
интернета без кредов, так что дыра открыта прямо сейчас.
Поверх существующего per-IP лимита добавлен глобальный счётчик неудач
на ИМЯ, без IP в ключе. Превышение порога не блокирует учётку, а растит
задержку ответа (удвоение от 1с до потолка): блокировка по имени была бы
вектором отказа в обслуживании против конкретного человека — не зная
пароля, злоумышленник гарантированно выключал бы чужой вход.
Задержка применяется по ПРИСЛАННОМУ имени, без проверки его в реестре, и
из одного места — общего хвоста всех отказов по кредам. Иначе «быстрый
401» для несуществующего имени стал бы оракулом существования учётки, то
есть ровно той user-enumeration, от которой уже защищают одинаковый
generic-ответ и безусловный bcrypt.
Чтобы включить IDENTITY_STORE=auth, до этого требовалось положить в
runtime-окружение полный AUTH_DATABASE_URL с паролем внутри — при том что
пароль уже лежит там же отдельной переменной AUTH_DB_PASSWORD (её читает
deploy-пайплайн для ALTER ROLE). Один секрет в двух местах разъезжается:
сменили пароль роли, DSN остался старым — вход ложится молча и целиком.
Теперь явный AUTH_DATABASE_URL по-прежнему выигрывает (обратная совместимость
и аварийный обход, скажем sslmode); если он пуст, а AUTH_DB_PASSWORD задан,
DSN собирается из частей. Части переопределяемы через AUTH_DB_HOST, _PORT,
_NAME, _USER.
Дефолт хоста — gendesign-postgres, не postgres. Внутри стека «Меры» имя
postgres резолвится в ЕЁ СОБСТВЕННЫЙ контейнер (tradein-postgres), и такой
дефолт не упал бы «неизвестным хостом», а молча увёл бы аутентификацию в живую
БД tradein, где нет ни роли auth_app, ни таблиц реестра. Нужный сервер виден по
алиасу gendesign-postgres в сети gendesign_shared, к которой tradein-backend
подписан.
Пароль и имя пользователя экранируются quote(safe=""). Имя БД и хост —
намеренно нет: SQLAlchemy раскодирует обратно только userinfo, а path отдаёт
как есть, поэтому quote("c/d") уехало бы в сервер литеральным c%2Fd. Найдено
прогоном, закреплено тестом.
Закрыта реальная утечка на пути ЯВНОГО AUTH_DATABASE_URL: на «почти URL»
SQLAlchemy доходит до int(port) и падает ValueError с символом ПАРОЛЯ в тексте
(он съезжает на позицию порта). Без обрыва цепочки обломок печатался бы в
traceback, то есть в логи и GlitchTip. Теперь ValueError и ArgumentError
перевыбрасываются своим сообщением from None; тест рендерит traceback целиком и
проверяет, что пароля там нет.
Пустое значение AUTH_DB_PORT больше не роняет импорт. Порт типизирован int и
валидируется до всякой нашей логики, а settings создаётся на уровне модуля —
пустая строка уводила контейнер в restart-loop В ЛЮБОМ режиме, включая
дефолтный tradein, где к БД auth нет ни одного обращения.
Прод не меняется: при IDENTITY_STORE=tradein (дефолт) ничего из этого не
читается и соединение с auth не открывается.
Тесты: +16 профильных, 118 passed на связке auth-сьютов. Проверено
исполнением: пустой порт даёт 5432; пароль со спецсимволами экранируется и в
открытом виде в DSN не встречается.
Дефолт не меняет ничего: IDENTITY_STORE="tradein" — это сегодняшний прод,
tradein_users/tradein_sessions, соединение с БД auth не открывается вообще.
Переключение делается одной переменной окружения ПОСЛЕ того, как на проде
появится пароль auth_app и будут скопированы данные. Так сделано намеренно:
мерж, который зависит от невыполненного ручного шага, — это мерж, который
ломает прод в момент невнимательности.
Ядро. app/services/identity_store.py — единственное место, знающее, в какой БД
и в каких таблицах живёт реестр. Имена таблиц берутся из фиксированного словаря
по значению флага, не конкатенацией с вводом. app/core/auth_db.py — ЛЕНИВЫЙ
engine БД auth (core/db.py создаёт свой на импорте; такое же для auth роняло бы
старт без DSN).
Одно понятие состояния доступа вместо двух. В tradein_users состояние — булев
is_active, в auth.users — access_state из трёх значений. Конверсия живёт в одной
функции to_access_state(): True→active, False→disabled, а неизвестная строка,
NULL или чужой тип → disabled с WARNING. Fail-closed выбран сознательно: если
следующая миграция добавит четвёртое состояние, оно по умолчанию НЕ будет
пускать. Проверка доступа — свойство can_sign_in, а не сравнение со строкой.
Логин в режиме auth. Пароль проверяется ВСЕГДА и ДО ветвления по состоянию —
иначе появляется timing-oracle и перечисление логинов. Верный пароль +
trial_expired → 403 с машиночитаемым code="access_expired", сессия НЕ создаётся.
Верный пароль + disabled → тот же generic 401, что и при неверном пароле.
Резолв уже выданной сессии пропускает только active — блокировка обрывает
сессию немедленно, а не по истечении sliding-refresh.
Старт падает явно, если IDENTITY_STORE=auth, а DSN не задан. Без этого ошибка
конфигурации не похожа на аварию: продуктовая БД жива, приложение работает, а
rbac_guard ловит исключение резолва вместе с любым другим сбоем и падает в
legacy trusted-header ветку — то есть сутками раздаёт права из roles.yaml мимо
реестра, включая аккаунты с disabled.
Форма входа понимает новый код ответа. Ветвление по detail.code, а не по тексту:
текст бэк вправе менять, код — нет.
Гранты соблюдены, а не обойдены: auth_app не имеет UPDATE на role/manager_id и
не имеет DELETE на users (миграция 004, column-level).
Тесты: 2996 passed (+59). Единственный красный — test_search_cache_hit —
предсуществующий: проверен контрольным полным прогоном на чистом main
(2937 passed, тот же красный).
После cutover'а на свою авторизацию (#2558) единственным каналом в поддержку
остался чат ЗА логином, а самая частая причина писать в поддержку — как раз
«не могу войти». 2026-07-31 это выстрелило: «Практика» весь день билась в форму
входа (5 неудачных попыток с трёх разных IP, ни одной успешной) и сообщить об
этом из продукта не могла ничем — на /login не было ни чата, ни контакта.
Backend — 4 ручки /api/v1/trade-in/support/anon/* (public в rbac_guard):
- Идентичность анонима — opaque-токен в httpOnly+Secure куке; тред живёт в тех
же web_support_threads под ключом `anon:<token>`. Двоеточие делает коллизию с
реальным логином структурно невозможной (CHECK миграции 193 разрешает только
`^[A-Za-z0-9._-]{3,64}$`) — аноним не может попасть в чужой тред.
- Изоляция та же, что у авторизованной ветки: thread_id снаружи не принимается
ни в каком виде, тред резолвится ИСКЛЮЧИТЕЛЬНО из куки.
- Форма куки валидируется — мусор из браузера не становится ключом треда.
- В Telegram-топик уходит не токен (это bearer треда), а `anon-<6 hex sha256>`;
зеркало помечено «[С САЙТА · БЕЗ ВХОДА]» — оператору важно, что аккаунта нет.
- Анти-абуз: два бюджета — per-token (12/мин) и per-IP (10/10мин). Второй ловит
обход ротацией куки, без него публичная ручка записи в общий топик беззащитна.
- Кука и запись в БД — только после успешного sendMessage (порядок операций H1),
неудачная отправка не закрепляет за посетителем пустой тред.
Frontend:
- `SupportScope = "auth" | "anon"` в useSupportChat: scope выбирает базовый путь
и входит в ключ кэша (иначе после логина в панели висела бы переписка анонима).
Дефолт "auth" — существующие места монтирования не меняются.
- `AnonSupportWidget` монтируется на /login и в NoAccessScreen — обе точки тупики,
из которых пользователю больше некуда идти. На /login добавлена подсказка.
Ответы оператора маршрутизируются без изменений в bridge.py: реплай резолвится
по topic_message_id → thread_id, кто автор треда — там неважно.
Тесты: 13 новых на анонимную ветку + 2 на границу public/authed в rbac_guard.
62 passed (test_support + test_rbac).
CRITICAL: _propagate_authenticated_user делала skip-if-present вместо
перезаписи — клиент-контролируемый X-Authenticated-User (Caddy шлёт его
на КАЖДЫЙ прод-запрос) выигрывал у резолвленной сессии для всего
downstream-трафика, читающего заголовок напрямую (_assert_estimate_access*,
account_quota, /trade-in/history, support.py) — в обоих auth_mode
(dual и db_only). Теперь заголовок безусловно перезаписывается сессионным
username (ASGI header-имена всегда lowercase bytes).
Medium: .encode("latin-1") без errors="replace" крашил бы 500-кой каждый
запрос кириллического username. Login timing-oracle — verify_password
короткозамыкалась на unknown-username/NULL-hash (~1мс vs ~100-300мс bcrypt)
→ теперь всегда сверяется против dummy-хеша при отсутствующем юзере/хеше.
Login rate-limit key length-prefixed — username с ':' (или IPv6 IP) больше
не может схлопнуть чужой бюджет.
Новые тесты подтверждают регрессию: прогнаны на старом коде (до фикса)
через временный откат rbac.py — все три (spoof dual-mode, spoof db_only,
кириллица) падали с 'victim' == 'alice' / UnicodeEncodeError; после
фикса — зелёные. test_rbac.py/test_internal_auth_secret.py без изменений.
Foundation для эпика #2549: session-cookie auth поверх legacy Caddy
trusted-header. app.services.auth_session — CRUD для tradein_sessions
(create/get/revoke) + get_user_by_username для password-логина; opaque
secrets.token_urlsafe токены, sliding last_seen_at/expires_at refresh
(не чаще раза в 5 минут).
POST /api/v1/auth/login проверяет password_hash (bcrypt) через
app.core.password, ставит httponly+secure cookie, пишет
login_success/login_failed в user_events; per-username+IP rate-limit
(SlidingWindowLimiter) отдельно от общего RateLimitMiddleware. POST
/logout ревокает сессию и чистит cookie. Оба пути exempt из rbac_guard's
auth-required gate (иначе логин сам себя не пропустил бы).
rbac_guard теперь dual-mode: session-cookie резолвится первым (DB-роль
employee/manager/admin -> paths как у pilot/+team/admin), fallback на
legacy X-Authenticated-User + roles.yaml БЕЗ ИЗМЕНЕНИЙ когда auth_mode
== "dual"; auth_mode == "db_only" отключает legacy header полностью.
Резолвленный сессией username инжектится в ASGI scope headers (до
call_next) — RequestAuditMiddleware и downstream route-хендлеры видят
его прозрачно; RateLimitMiddleware (внешний относительно rbac_guard)
для session-запросов лимитирует по IP, не по username — документированный
trade-off, не регрессия.
GET /me — session-first: валидная cookie отдаёт scope из tradein_users
без похода в roles.yaml; без cookie — прежний legacy путь. session_secret
остаётся опциональным (opaque-токены не требуют подписи) — пустое
значение только logger.warning на старте, не startup-fail.
Полный набор тестов (tests/test_rbac.py, test_internal_auth_secret.py,
test_account_quota.py) проходит без правок — regression-safe.
Три дефекта, каждый блокировал легальный публичный запуск.
1. Адрес физлица сохранялся в базу ДО любого согласия: согласие фиксировалось
только на форме заявки, то есть ПОСЛЕ записи адреса. Для пилота с договором
терпимо, для человека с улицы — нет. Проверка согласия поставлена первой
строкой расчёта, до геокодирования и до обоих мест записи адреса.
Хранение — колонками на самой оценке, 1:1 с уже работающим прецедентом для
заявок (миграция 182): IP клиента, версия политики, дословный снимок текста.
Отдельная таблица событий не заводилась: согласие даётся ровно на создание
этой строки, и когда строка удаляется по сроку, исчезновение доказательства
вместе с данными логично.
Enforcement НЕ выводится из пустого created_by — первая версия так и делала
и сломала 92 несвязанных теста оценщика, которые зовут расчёт без имени
пользователя, проверяя ценовую логику. Вместо этого явный флаг, который
выставляет единственный боевой вызывающий. B2B-поток не тронут: поле
согласия опционально, иначе сломались бы пилоты, чей фронт его не шлёт.
2. Срок жизни оценки применялся только как фильтр при чтении — физического
удаления не было ни в одной фоновой задаче, данные жили вечно вопреки
декларированному сроку. Заведена задача удаления пачками с ограничением на
прогон и коммитом после каждой пачки, идемпотентная. В расписании она
ВЫКЛЮЧЕНА: это первая автоматическая задача, удаляющая персональные данные,
и первый прогон должен быть под наблюдением.
3. Пути «удалите мои данные» не было. Добавлен сервис удаления и админская
ручка. Ключи: имя пользователя, идентификатор оценки, телефон, чат в
телеграме.
Честно зафиксировано в коде: аноним без ссылки на оценку, без оставленного
телефона и без обращения в поддержку неидентифицируем — удалить его данные
без дополнительной идентификации нельзя. Отдельно: удаление чистит только
копию в базе, зеркало переписки в телеграм-топике не удаляется ничем в
кодовой базе, нужен ручной шаг.
4. Соответствие текста согласия на фронте и снимка на бэке держалось на
комментарии. Теперь есть тест, который ловит расхождение.
Сроки хранения вынесены в настройки. Значение для заявок предложено инженерно
(типичный отраслевой диапазон), юридически обоснованный срок — за юристом, и
это записано в коде.
Тесты: 2775 passed.
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage
в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот
доставляет ответ клиенту. Полный лог переписки в Postgres.
Отдельный контейнер на long-polling, а не webhook в tradein-backend:
не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и
маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API.
Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage.
Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален
в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате,
поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора
не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор.
Безопасность (найдено ревью, воспроизведено эмпирически):
- токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет;
утекал в GlitchTip через locals стек-фреймов (include_local_variables
по умолчанию True) и через span data HttpxIntegration. Закрыто
include_local_variables=False + regex-редактор в before_send (обе формы:
/bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub.
- httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в
docker logs каждые 30с. Приглушён до WARNING.
Надёжность:
- kill-switch при пустом токене — idle-блокировка, не exit(0): при
restart: unless-stopped выход с любым кодом даёт рестарт-луп.
unless-stopped выбран сознательно — только он гарантирует автозапуск
после ребута VPS.
- stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше,
чем докрутится long-poll (30с) и отработает drain (100с).
- сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset:
иначе сессия в failed-transaction не давала сохранить offset, апдейт
переигрывался и зеркалился в топик по кругу.
152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента
одним DELETE. Ретенция — follow-up.
Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS,
без них воркер штатно висит в idle. Порядок — в DEPLOY.md.
Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked,
throttle-окно шапки, redaction токена во всех формах event).
Топология подтверждена перед удалением (docker-compose.prod.yml): tradein-backend
(uvicorn app.main:app) — SCHEDULER_ENABLE=false; tradein-scraper (python -m
app.scheduler_main) — SCHEDULER_ENABLE=true + USE_KIT_SCHEDULER=true. Kit-путь
(_run_kit_scheduler → scraper_kit.orchestration.scheduler + product_handlers)
самодостаточен: не импортирует ничего из app.services.scheduler.scheduler_loop
или app.services.scrape_pipeline. Все НЕ-sweep джобы, которые kit-scheduler
диспетчерит через build_product_handlers, идут напрямую в app.tasks.*/
app.services.* (либо lazy-импортят import_rosreestr_dkp/_execute_cian_backfill
из scheduler.py) — мимо удаляемой legacy-машинерии.
app/services/scheduler.py: 2098 → 418 строк. Удалено: scheduler_loop,
get_due_schedules, reap_zombies, _claim_run, _defer_next_run_at, _spawn_tracked/
_drain_inflight/_inflight_tasks, все 27 trigger_*_run-функций, импорт
app.services.scrape_pipeline, константы SCHEDULER_TICK_SEC/ZOMBIE_THRESHOLD_HOURS
(достижимы были только через удалённый scheduler_loop-путь). Оставлено (живые
импортёры вне удалённого): compute_next_run_at + has_running_run (admin.py),
import_rosreestr_dkp + _execute_cian_backfill (lazy-импорты в
product_handlers.py — job-тела kit-handler'ов).
main.py: убран `from app.services.scheduler import scheduler_loop` + lifespan-блок
запуска (`if settings.scheduler_enable: asyncio.create_task(scheduler_loop())`);
прод-backend всегда шёл с SCHEDULER_ENABLE=false, так что это был мёртвый код.
scheduler_main.py: убрана ship-dark развилка #2192 (USE_KIT_SCHEDULER=false →
legacy scheduler_loop fallback) — _run_kit_scheduler() теперь безусловный путь.
Поле settings.use_kit_scheduler оставлено в конфиге (Settings extra="ignore"
защищает от startup-краха на leftover env var), но на ветвление не влияет.
app.services.scrape_pipeline: 0 runtime-импортёров в app/+scripts/+packages/
после этого PR (только тесты, которые Part E удалит вместе с самим файлом) —
подтверждено grep. scrape_pipeline.py не тронут (Part E).
Тесты: удалены test_house_imv_backfill_scheduler.py (100% legacy-триггер,
backfill_house_imv сервис покрыт в test_house_imv_backfill_browser_flag.py /
test_backfill_wave2.py) и test_kit_registry_completeness.py (parity-инвариант
против удалённого dispatch, дублирует test_scraper_kit_scheduler_parity.py).
Точечно вырезаны "Scheduler wiring" секции (trigger_fn_exists/dispatch_branch_
wired/runs_in_executor) из ~10 файлов, тестирующих сами task-функции — сами
task-тесты (SQL-shape, миграции, fake-db поведение) оставлены нетронутыми.
test_scheduler.py: 825 → ~90 строк (остались только compute_next_run_at-тесты).
test_scraper_kit_scheduler_parity.py: убрана golden-parity секция против
удалённого scheduler_loop (SOURCE_TO_OLD_TRIGGER/_drive_old_one_tick/
test_routing_parity_per_source), остальное (claim/reap_zombies/dispatch/
registry-shape тесты kit-модуля) сохранено — источник этих инвариантов не
app.services.scheduler, а сам scraper_kit.orchestration.scheduler.
test_scheduler_main.py: 2 теста, патчившие app.services.scheduler.scheduler_loop,
переведены на монкипатч sm._run_kit_scheduler (единственный путь после этого PR).
test_sweep_imv_phase.py:171-371 (6 прямых импортов run_avito_city_sweep из
scrape_pipeline) намеренно НЕ тронуты — Part E.
Verify: полный pytest 3179 passed / 6 skipped / 1 known-unrelated fail
(test_search_cache_hit, #2208, не связан с этим PR); ruff 0.7.4 чист на всех
изменённых файлах; `python -c "import app.main; import app.scheduler_main"` OK.