Два параллельных агента взяли ОДИН номер: 277_landing_showcase_runs.sql в ветке
витрины и 277_payments_live_checkout_uidx.sql здесь. Обе ветки по отдельности
зелёные, но на main второй файл встал бы конфликтом — ровно ловушка из шапки
tests/test_migration_numbering.py: номер сверяется с origin/main, а не с чужими
открытыми ветками.
Заняты сейчас: 275 (метрики), 276+277 (витрина), 278 (публичный токен) → этот 279.
Ссылки на номер обновлены в payments.py и test_payments_router.py, включая путь,
по которому тест читает предикат частичного UNIQUE.
Плюс CREATE UNIQUE INDEX на существующей таблице payments обёрнут в
SET LOCAL lock_timeout = '5s' — гейт #2752.
Идемпотентность checkout держалась на «SELECT, потом INSERT» — ровно на том,
что шапка модуля называет дефектом. Двойной клик по кнопке оплаты давал два
параллельных запроса, два INSERT, два Init и два холда на карте покупателя.
- миграция 277: частичный UNIQUE (estimate_id, product_code) по живым статусам
+ ON CONFLICT DO NOTHING в INSERT. Проигравший гонку не идёт в банк: отдаёт
ссылку соперника, если та уже готова, иначе 409;
- граница по времени для брошенных попыток: NEW/FORM_SHOWED старше 30 минут
переводятся в DEADLINE_EXPIRED. Без неё зависший платёж (нотификации по нему
может не прийти вовсе) навсегда отдавал покупателю одну и ту же протухшую
PaymentURL. Окно НЕ распространяется на AUTHORIZED и прочие карточные
статусы — там деньги уже в игре, разгребать их — работа реконсиляции;
- IDOR: checkout читал оценку без _assert_estimate_access. По чужому
estimate_id возвращался order_id чужого живого платежа, а order_id — право
доступа для /payments/status/<order_id>, отдающего capability-ссылку на
отчёт. Проверка ставится только для оценок с владельцем: у анонимной покупки
идентичности нет, правом там работает сам estimate_id.
Тесты двусторонние, фальсификация прогнана: снятие ON CONFLICT / границы по
времени / IDOR-гварда красит ровно один тест каждый раз, два из трёх — по
значению ответа.
Не хватало ровно проводки: сервисный слой Т-Банка (PR-C) и схема (PR-B, 233)
уже были, HTTP-ручек и статус-машины — нет, как и доставки купленного.
Всё за kill-switch PAYMENTS_ENABLED (дефолт false): при выключенном контуре
каждая ручка отвечает 503 и не трогает ни банк, ни платёжные таблицы, поэтому
merge на проде не меняет поведения.
Идемпотентность целиком отдана БД (UNIQUE миграции 233 + ON CONFLICT DO
NOTHING), а не паре «проверить-потом-вставить»: между проверкой и вставкой
проходит параллельный ретрай банка, и товар выдаётся дважды. Признаком
«выдача состоялась» служит payment_notifications.processed_at, а не сам факт
строки — иначе падение процесса между записью нотификации и выдачей оставило
бы клиента без отчёта при списанных деньгах.
Доставка — capability-ссылка /api/v1/trade-in/r/<token>: токен лежит в
payment_entitlements.subject (ref_id остаётся estimate_id, на нём держится
UNIQUE «выдали один раз»), режется из GlitchTip-событий и открыт в rbac
отдельным узким префиксом. Тело GET /estimate/{id} вынесено в load_estimate,
чтобы у второго права доступа был тот же загрузчик, а не третья копия
гейта читаемости.
278 делает ALTER TABLE trade_in_estimates ADD COLUMN + CREATE UNIQUE INDEX на
ЖИВОЙ таблице (1123 строки на проде). ALTER берёт ACCESS EXCLUSIVE: без
lock_timeout он встал бы в очередь за запросами приложения и утащил их за собой.
Обёрнуто в BEGIN + SET LOCAL lock_timeout = '5s' + COMMIT по образцу
272_houses_region_code.sql.
Весь анти-абузный контур публичной ручки (анонимная квота cookie+IP, семафор,
503 вместо 502, consent-гейт) держится на одном аргументе: в
app.api.v1.trade_in.estimate уходит x_authenticated_user=None. Проверял это
ноль тестов: estimate везде замокан AsyncMock, который принимает любую
сигнатуру, — подмена None на чтение заголовка запроса оставляла все 14 тестов
зелёными, а публичная форма начинала считать от чужого имени мимо квоты.
Новый тест шлёт запрос С заголовком X-Authenticated-User: admin и сверяет
фактические await_args.kwargs; заодно требует, чтобы аргументы ехали по имени
(позиционный вызов обесценивает сверку) и чтобы аргумент вообще присутствовал
(дефолт эстиматора — чужая гарантия, не наша). Проверено падением: подмена на
request.headers.get даёт «пришло: 'admin'».
Там же UPDATE токена: параметры сверялись только по хэшу, id строки — нет.
Теперь пинится result.estimate_id: токен обязан вешаться на только что
посчитанную оценку. Проверено подменой параметра — красный по значению.
test_read_filters_by_expiry_and_hash оставлен текстовым: живого Postgres с
миграцией 278 здесь нет, а поведенческий тест, ни разу не прогнанный, — это
ещё один зелёный по построению. Вместо этого в самом тесте написано, что он
проверяет (предикат есть в тексте SQL, в параметрах хэш) и чего НЕ проверяет
(сессия — MagicMock, запрос не исполняется, протухший токен не отсекается), и
чем его заменить, когда БД появится.
Публичный контур умел только подсказки и пробу покрытия: полный расчёт закрыт
RBAC, а результат анонима нельзя было прочитать повторно — _assert_estimate_access
отдаёт 404 на строку с created_by IS NULL всем, кроме админа, то есть расчёт жил
ровно в теле POST-ответа и не переживал перезагрузку страницы.
POST /api/public/mera/estimate делегирует в app.api.v1.trade_in.estimate (копии
логики нет — иначе публичная когорта разъедется с платной) и отдаёт наружу только
бесплатную часть: число аналогов и вердикт покрытия из той же coverage_probe.
Цены, прогнозы и списки аналогов остаются в БД для платного контура.
Согласие 152-ФЗ обязательно и строго True на уровне схемы, поэтому отказ
происходит до входа в хендлер — раньше, чем адрес физлица дошёл бы до БД.
POST /api/public/mera/estimate/read читает бесплатную часть по токену
(secrets.token_urlsafe(32), в БД только sha256, срок жизни 7 дней, миграция 278).
Токен едет телом: access-лог Caddy пишет URI целиком, и капабилити-ссылка в пути
легла бы в файл рядом с IP посетителя — тот же довод, по которому POST'ом сделан
/suggest. Постоянный путь заодно не требует префиксной ветки в rbac._PUBLIC_PATHS.
Всё закрыто флагом public_estimate_enabled (дефолт false → 404): включение
открывает запись ПДн и требует решения владельца вместе с правкой политики.
Обе миграции создавали индекс без транзакции и без SET LOCAL lock_timeout.
Гейт scripts/check-migration-lock-timeout.py это и поймал:
::error 276_landing_showcase_deals.sql:: блокирующий DDL без lock_timeout
(CREATE INDEX IF NOT EXISTS idx_landing_showcase_deals_computed_at ...)
Обе обёрнуты в BEGIN + SET LOCAL lock_timeout = '5s' + COMMIT по образцу
272_houses_region_code.sql. Локально гейт зелёный: «проверено новых миграций: 31».
Ревью MAJOR по честности, два пункта.
1. Убран MAX_ABS_ERR_PCT = 40 из build_row. Докстринг модуля сам запрещает
отбор по величине ошибки, но запрет был реализован только в _sort_key, а
фильтр — тот же отбор ступенькой раньше, и злее: строка не попадала даже в
кандидаты. Обоснование «отклонение >40% — почти всегда занижение ДКП ради
налога» не держится: _load_sample уже режет выборку санитарным диапазоном
₽/м² (для ЕКБ это глобальные PPM2_MIN=30k / PPM2_MAX=600k — город намеренно
не заведён в deal_city_price_bands), то есть грубые занижения вырезаны выше
по потоку и ПО СВОЙСТВУ САМОЙ СДЕЛКИ. Всё, что после этого дало большую
ошибку, — работа оценщика, и посетитель обязан её видеть. Честность про
заниженные ДКП перенесена в note каждой строки.
Заодно убраны MIN_FACT_PPM2=30k (дублировал уже применённый фильтр) и
MAX_FACT_PPM2=1.2M (недостижим при потолке выборки 600k): из трёх отбраковок
в проде срабатывала ровно одна — та, что льстила витрине, а два мёртвых
порога читались как работающие. Осталась только структурная отбраковка «нет
прогноза / квартала / площади».
2. Счётчики прогона выведены в ответ ручки. Итог пересчёта пишется в
landing_showcase_runs (миграция 277) и уезжает в ShowcaseResponse.stats
вместе с правилом отбраковки: показано 20 из N годных, рассмотрено M сделок.
Отдельная таблица, а не колонки в строках, — иначе в самом важном случае
(показывать нечего) счётчики исчезли бы вместе со строками. Ручка теперь
берёт и строки, и числа ИЗ ОДНОГО прогона: иначе пустой прогон показал бы
вчерашние строки под сегодняшними счётчиками.
Тесты двусторонние и проверены на сломанном коде: возврат любого порога по
ошибке → красный с величиной отклонения в сообщении; возврат любой границы
₽/м² → красная своя половина; stats=None при живом прогоне → красный.
Лента «МЕРА сказала X — продали за Y» жила на константах в marketing-v3.ts.
Здесь появляется её настоящий источник: сделки Росреестра по ЕКБ, прогнанные
через тот же спайн оценщика, что и боевой расчёт (backtest_estimator).
Отбор строк идёт по полноте данных и свежести квартала и НЕ смотрит на
величину ошибки: отбор по малой ошибке дал бы формально работающий код и
врущую витрину — показанные строки перестали бы быть выборкой из работы
оценщика. Свойство закреплено двусторонним тестом.
Витрина не показывает адреса (номер дома есть у 2.7% сделок) и не показывает
дня сделки (deal_date — первое число квартала). Каждая строка несёт note о
том, что замер не point-in-time. Заниженные ради налога ДКП отбрасываются по
|отклонению| > 40% и ₽/м² вне [30k; 1.2M], счётчик отброшенного — в лог.
Ревью: гейт охранял не то место. Подмена знаменателя красила три теста, но
дефект «84.8% вместо 48.1%» живёт в SQL — во включении однострочных записей
истории (у domklik одна запись = «цену не менял») в знаменатель. Ревьюер
вернул дефект условием n_rows >= 2 в CTE moved, и все 25 тестов остались
зелёными: текстовые пины держали только span_days и max_abs_pct.
Новый пин держит обе половины: однострочные попадают в moved веткой CASE со
значением 0, и нигде в запросе нет фильтра по числу записей истории (ни в
WHERE, ни HAVING). Живой прогон на подготовленных строках не заведён
намеренно: DATABASE_URL в тестовой джобе — заглушка, Postgres там нет, и тест
по образцу test_purge_expired_trade_in_data.py молча скипался бы, то есть не
гейтил бы ничего. Фальсифицировано руками — с n_rows >= 2 тест красный и
называет причину.
Второе: метрика, у которой пропал вход, больше не доживает в таблице со
старым computed_at (ручка отдавала её неотличимо от свежей). Строки вне
сегодняшнего набора удаляются в той же транзакции. На ПУСТОМ наборе чистка
не ходит: разом отвалившиеся все входы — признак поломки прогона, а не пяти
одновременных «данных больше нет». Оба поведения покрыты тестами, оба
проверены на сломанном коде.
proxy_rotate_attempts / proxy_rotate_attempt_timeout_s тюнили ретраи changeip-GET.
Сам changeip снят в #2616 шаг 2 (аккаунт mobileproxy закрыт, ссылки нет), и с тех
пор ручки живут пламбингом: Settings -> property адаптера -> поле протокола
ScraperConfig -> и всё. Ни одного потребителя, только четыре теста, которые
заполняют их при сборке конфига.
Комментарий над ними в contracts.py оправдывал их сохранение так:
«оставлены как budget-верхняя-граница для app.tasks.avito_detail_backfill wait_for»
— но wait_for там берёт СОСЕДНЕЕ поле, avito_proxy_rotate_settle_s
(avito_detail_backfill.py:249). То есть комментарий приписывал этим двум полям
работу третьего и тем самым прикрывал их мёртвость.
Соседние ручки проверены и ОСТАВЛЕНЫ, они действительно читаются:
* avito/cian/yandex_proxy_max_rotations — pipeline._max_rotations;
* avito_proxy_rotate_settle_s — asyncio.wait_for в avito_detail_backfill.
Заодно сжат комментарий в config.py: перечисление истории changeip заменено на
то, что нужно знать сейчас — кто читает оставшиеся две ручки и где живая ротация
(ASOCKS_API_TOKEN / proxy_rotation, #2611).
ruff clean, 4974 passed / 37 skipped.
foreign table tradein.gendesign_ekb_districts_geom падала с «permission denied for
view ekb_districts_geom». Замер: has_table_privilege('tradein_fdw_reader',
'public.ekb_districts_geom','SELECT') = false, при том что все четыре соседних
объекта того же FDW-сервера (rosreestr_deals, mv_quarter_price_index,
v_tradein_cad_buildings, v_tradein_osm_poi_ekb) грант имеют.
Грант не «потерялся» — его не выдавали никогда: 74_dedupe_unified_views.sql
меняет тип объекта (DROP TABLE + CREATE OR REPLACE VIEW), то есть создаёт его
заново, а строки GRANT рядом не было. Тот же класс, что #2583.
Приёмка на проде после деплоя: тот же has_table_privilege обязан вернуть true,
а SELECT count(*) FROM gendesign_ekb_districts_geom — 8 вместо ошибки.
Числа на публичном лэндинге лежали литералами во фронте
(mera-public/marketing-v3.ts) — то есть были выдуманы и не имели срока
годности. Теперь их считает ночная задача и отдаёт публичная ручка,
вместе с размером выборки и описанием того, что именно измерено.
Что считается: число расчётов и период работы, медиана аналогов на
расчёт, медианная ЭКСПОЗИЦИЯ активного объявления по ЕКБ (не срок
продажи — так и написано в note), доля снижавших цену и медианное
снижение за 30 дней, сделки Росреестра по ЕКБ за 12 месяцев.
Ценовые метрики берут ТОЛЬКО domklik: у avito/yandex триггер не пишет
стартовую цену, а yandex вдобавок сеет синтетическую пару со сдвигом в
сутки — на такой смеси «снизил» и «не снижал» неразличимы. Знаменатель
доли — все объявления, наблюдавшиеся от 14 дней, включая не менявшие
цену; считая только по менявшим, получили бы 85% вместо честных 48%.
Метрика без входных данных строку НЕ пишет: подставленный ноль читался
бы как измеренный ноль. Пустая таблица — валидные {} и 200, а не 500.
«Точность прогноза» и «срок продажи» здесь не считаются намеренно —
таких величин в данных нет.
Найдено сверкой двух независимых источников: подсчёт упоминаний по всем
python-файлам (включая тесты) дал имена, встречающиеся ровно один раз — в
собственном объявлении; каждое затем подтверждено serena find_referencing_symbols
(LSP, видит и косвенные ссылки) и проверено grep'ом по yml/sql/ts/md на случай
ссылки строкой.
Удалено:
* _qr_code_data_url (trade_in_pdf) — QR как SVG data URL, не вызывался
ниоткуда; вместе с ним ушёл осиротевший import segno. `io` ОСТАВЛЕН —
io.BytesIO используется дальше по файлу (строка 774);
* _local_name (gar_flats_loader) — снятие namespace с имени тега;
* AvitoParseError — класс никто не поднимает и не ловит;
* IMVCityMismatchError — то же.
Что НЕ тронуто, хотя фильтр их показал:
* ~200 обработчиков FastAPI и celery-задач — их поднимает декоратор, по имени
их действительно никто не зовёт;
* refresh_ddu_price_indicator — точка ручного обслуживания, задокументирована
в комментарии к матвью (data/sql/152_mv_ddu_price_indicator.sql: «Refresh:
... (не в beat)»);
* schemas/parcel.py::MarketPrice — половина контракта, фронт использует
(frontend/src/types/site-finder.ts: market_price?: MarketPrice);
* JobSetting — SQLAlchemy-модель, живёт через metadata Base.
segno остался в backend/pyproject.toml и больше нигде не используется — снятие
зависимости требует пересборки лока, поэтому отдельным PR.
ruff clean, 4974 passed / 37 skipped.
Последняя часть макета Макса «B2C модуль для МЕРА»: /articles, /articles/kak-ocenit-kvartiru,
/docs. Проводка ссылок доведена до конца — «Статьи» и «Проверьте себя» в шапке и подвале
перестали быть заглушками, EstimateHeader переименован в InnerHeader (он теперь стоит на
трёх страницах, а не только на проверке).
ЧИСЛА СТАТЬИ — НЕ ИЗ МАКЕТА. Макет утверждал «по нашим данным, средний разрыв между
первой ценой в объявлении и ценой сделки — 4,1%, а у провисевших дольше трёх месяцев
доходит до 9%». Прод-замер (poincare, 29.08) показал, что величины такого рода у нас нет
вовсе: связки листинга со сделкой в данных не существует (cadastral_number пуст у всех
108 623 сделок и 111 693 объявлений). Заменено на то, что измеряется парно и честно:
по 6202 объявлениям Домклик в ЕКБ (срез 18.07.2026) цену снижали 44%, а среди провисевших
дольше трёх месяцев — 62% в медиане на 4,9%. Границы замера названы в самом тексте:
это движение цены В ОБЪЯВЛЕНИИ, а не скидка на сделке, и объявлений моложе трёх недель
в выборке нет (прогон обогащения их не захватил), поэтому по всему живому рынку доля ниже.
Заодно сняты остальные непроверяемые утверждения: «расхождение с кадастровой до 40%»
(кадастровой стоимости ПОМЕЩЕНИЙ нет ни в одной базе — проверить нечем), поправки §3
помечены как рыночная практика, а не наш замер, пороги фильтра §2 приведены к тем, что
реально стоят в оценщике (радиус 1 км, площадь ±15%, минимум 5 аналогов + каскад
послаблений). Плейсхолдеров marketing-v3 в статье не осталось.
Периметр закрыт во всех трёх местах: @meraPages / @meraShortSlash / @meraLongPages в
apps.caddy, PUBLIC_ROUTES и PUBLIC_SHORT_PATHS в гварде. @meraShortSlash до сих пор не
проверялся ничем — добавлен гейт в public-perimeter.test.ts (фальсифицирован: снятие
/docs из альтернации красит тест).
Гейт #2904 теперь видит и статью (articles-content.ts в V3_SOURCES) — фальсифицировано
отдельно: с одним этим файлом в списке снятие noindex красит гейт.
Честные отличия от макета — в докстрингах страниц: масок реквизитов и макетной редакции
оферты нет (источник правды — /oferta, /privacy, /refund от 13.08), регламент ответа
поддержки «9:00-19:00» снят (никто его не устанавливал), фильтр рубрик и обещание
«новые статьи каждую неделю» не рендерятся.
Проверено: tsc, eslint, vitest 69/69, изоляция mera-public, скриншоты трёх страниц
на 1180/375/320px без горизонтального переполнения.
CI Trade-In / backend-tests упал на tests/tasks/test_domclick_detail_backfill.py:165 —
там тот же хрупкий assert_called_once_with, что уже был поправлен в
tests/test_3118_domclick_warm_context.py: он фиксирует ТОЧНУЮ сигнатуру вызова
BrowserFetcher и ломается на любом новом kwarg.
Лечение то же самое: assert_called_once() + точечная проверка source/endpoint/
reuse_context. Полная проводка пула покрыта отдельным
tests/test_3197_domclick_proxy_pool_wiring.py.
Причина пропуска: локально прогонялась выборка из трёх файлов, а не весь набор.
Теперь прогнан весь: 4974 passed, 37 skipped, 0 failed.
BrowserFetcher(source="domclick", reuse_context=True) конструировался без
proxy_provider/use_pool/environment -- тела POST /fetch не несли "proxy",
сайдкар брал свой env-прокси, и прогон шёл мимо пула целиком: ни выбора узла
по affinity, ни scrape_proxy_source_bans, ни ротации при блоке. Тот же дефект
уже чинили на avito_detail_backfill/house_imv_backfill (#2698) -- этот call
site оставался последним непочиненным. environment обязателен: без него
отказ «пул пуст» на этом пути мёртв (#2616 шаг 1). reuse_context=True
сохранён без изменений.
Заодно поправлен устаревший комментарий над конструктором: ссылался на
scrape_proxies.provider_affinity='domclick' и миграцию 173 -- на проде
такого больше нет (миграция 253 сняла резервацию узла, #2800), все четыре
включённых узла (id 1/9/10/11) имеют provider_affinity='any'.
test_3118_domclick_warm_context.py обновлён под новую сигнатуру вызова
(assert_called_once_with -> точечная проверка нужных kwargs).
Редизайн результата /estimate по макету отчёта: шапка объекта (адрес + чипы
параметров + «Изменить данные»), две большие бесплатные карточки, тизер
платной части с shimmer-строками, честные состояния «мало данных» и «город
не покрыт» в стилистике «ЧЕСТНЫЙ ОТВЕТ».
Сознательные расхождения с макетом (задокументированы в шапке компонента):
- «✓ расчёт готов и ждёт вас» не воспроизводится — платного расчёта для
анонима не существует (анонимный /estimate закрыт, платёжного контура
нет, #2896); тизер честно называется «что будет в полном отчёте», вместо
кнопки оплаты — прежняя формулировка «приём оплаты подключается»;
- бейджи МИР/СБП/VISA — за тем же TODO платёжного контура, что и в FooterV3;
- email-подписка «сообщим когда добавим» — нет хранения и согласия (#2895),
вместо неё живой канал поддержки;
- «Расширить радиус до 3 км» — /coverage радиус не параметризует;
- подписи бесплатных плиток — запиненные честные из coverage-copy.
Возврат 150 ₽ — строкой из макета со ссылкой на /refund. Старый
CoverageResult удалён (один рендер результата, не два). Фикстурное превью
всех трёх тонов — /mera-public/ui-preview/report.
Проверено: tsc чисто, lint чисто, vitest 27 passed, скрин трёх состояний.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Зеркало #3216 для Site Finder. Отдельным PR, чтобы CI двух приложений не
блокировали друг друга.
Мажор ради GHSA-5xrq-8626-4rwp (CRITICAL, vitest ≤2.x). Сам CRITICAL к нам
неприменим — он про `--ui`, а `@vitest/ui` не установлен и скриптов с `--ui`
нет — но держать заведомо непатченный рантайм тестов ради этого рассуждения
не стоит.
vitest тянет vite сам, и у мажора другой диапазон (2.1.9 → vite ^5.0.0;
3.2.7 → ^5||^6||^7), поэтому одним мажором:
vitest 2.1.9 → 3.2.7 GHSA-5xrq-8626-4rwp
vite 5.4.21 → 7.3.6 GHSA-4w7w-66w2-5vf9, GHSA-fx2h-pf6j-xcff, GHSA-v6wh-96g9-6wx3
esbuild 0.21.5 → 0.28.2 GHSA-67mh-4wv8-2f99
OSV по локу: 4 → 1. Остаток — postcss 8.4.31, вендоренный ВНУТРИ
next/node_modules; нашим локом не управляется.
@vitejs/plugin-react 4.3.4 → 5.2.0 — вынужденно: у 4.x peer на vite ^4||^5||^6,
семёрка туда не попадает. В 6.x не идём — там peer уже ^8.0.0.
vitest.config.ts не тронут: в нём нет ничего из переименованного/убранного
мажором (ни environmentMatchGlobs, ни deps.inline, ни workspace).
Приёмка (локально, node 26):
npm run test 278 passed, 13 failed
npm run type-check ok
13 падений — те же самые, что на vitest 2 (те же 4 файла: api, useParcelAnalyzeQuery,
AnalysisPageContent.weights, WeightProfilePanel.identity; TypeError на
src/lib/sessionId.ts:8, localStorage undefined). Мажор их не добавил и не убрал.
Причина локальная — node 26 против node 20/24 в CI. Гейт — CI.
ДомКлик закрыт QRATOR с proof-of-work: первый запрос отдаёт 401 и заглушку с
задачей, браузер её решает, дёргает /__qrator/validate и получает пропуск в куках
(qrator_jsid2 + qrator_jsr). Пропуск живёт в cookie jar, то есть в browser
context'е — а прод брал каждую карточку в новом контексте, выбрасывая его.
Повторная валидация с того же IP получала 403 и страницу bot-mitigation.
Замер (прод, прод-прокси, по 6 карточек):
общий контекст, одна вкладка 6/6, validate не вызывался ни разу
общий контекст, вкладка на карточку 6/6, validate не вызывался ни разу
новый контекст на карточку (= прод) 1/6, validate = [304, 403] на каждой
боевой путь /fetch с reuse_context 6/6 при 0 перезапусков и 1 контексте
Что правится:
* снят код-дефолт domclick=1 из #3205: перезапуск процесса гарантированно
уничтожает контекст, то есть лечил симптом, который сам же и создавал.
Ручка per-provider и domclick как отдельный провайдер остаются;
* сброс контекста в бэкфилле был на КАЖДЫЙ блок — стал один раз за прогон.
Это и объясняет провал #3193: сброс выбрасывал пропуск, следующий фетч
блокировался гарантированно, что снова вызывало сброс. Приёмка тогда дала
ровно 1 успех из 10;
* снята неверная формулировка «отказ, а не челлендж» из #3204/#3205 —
26 624 байта это РЕЗУЛЬТАТ проваленного PoW, а не статика вместо него.
Ошибка вышла из метода: HTML читали на 4.5-й секунде и не смотрели в сеть.
Замер 6/6, которым обосновывали #3205, был испорчен: в логах сайдкара после
каждой страницы стоит «recycle threshold (1) достигнут, перезапуск браузера».
Тесты: два кодировали domclick=1 — переписаны через подставной словарь, чтобы
уровень «код-дефолт поставщика» продолжал проверяться, а не исчез вместе с
записью. Тест сброса требует ровно одну попытку за прогон.
159 passed (сайдкар), 4972 passed / 37 skipped (backend).
Мажор ради GHSA-5xrq-8626-4rwp (CRITICAL, vitest ≤2.x). Сам CRITICAL к нам
неприменим — он про `--ui`, а `@vitest/ui` не установлен и скриптов с `--ui`
нет — но держать заведомо непатченный рантайм тестов ради этого рассуждения
не стоит: следующий, кто добавит `--ui`, про оговорку не узнает.
Побочно закрылось больше, чем планировалось. vitest тянет vite сам, и диапазон
у мажора другой: 2.1.9 → vite ^5.0.0 (резолв 5.4.21 → esbuild 0.21.5),
3.2.7 → vite ^5||^6||^7 (резолв 7.3.6 → esbuild 0.28.2). Так что одним мажором:
vitest 2.1.9 → 3.2.7 GHSA-5xrq-8626-4rwp
vite 5.4.21 → 7.3.6 GHSA-4w7w-66w2-5vf9, GHSA-fx2h-pf6j-xcff, GHSA-v6wh-96g9-6wx3
esbuild 0.21.5 → 0.28.2 GHSA-67mh-4wv8-2f99
OSV по локу: 4 → 1. Остаток — postcss 8.4.31, вендоренный ВНУТРИ
next/node_modules; нашим локом не управляется вообще.
@vitejs/plugin-react 4.3.4 → 5.2.0 — вынужденно и ровно поэтому: у 4.x peer
на vite ^4||^5||^6, семёрка в него не попадает. У 5.2.0 — ^4||^5||^6||^7.
В шестёрку не идём: там peer уже ^8.0.0.
vitest.config.ts править не пришлось: в нём нет ничего из того, что мажор
переименовал или убрал (ни environmentMatchGlobs, ни deps.inline, ни workspace) —
только environment/globals/setupFiles/include, которые в 3.x как были.
Приёмка (локально, node 26):
npm run test 66 passed, 2 failed
npm run type-check ok
Те же самые 2 падения (LoginPage, ветки 429/401) дают и vitest 2 на этом же
локе, и vitest 2 на локе ДО апдейта зависимостей — проверено прогоном в обеих
конфигурациях. Это локальный node 26 против node 20/24 в CI, к мажору
отношения не имеет. Гейт — CI.
Макет «B2C модуль для МЕРА» (Макс, 29.08) — пять секций, которых превью
/mera-public/v3 не имело (его шапка их и перечисляла как отсутствующие):
- DealsTickerV3 — бегущая строка «ПРОГНОЗ → ФАКТ»; данные — ТЕ ЖЕ строки,
что таблица сверок (PROOF_ROWS_PLACEHOLDER): один будущий контур — один
набор реквизита, второй не заводится;
- GuessGameV3 — игра «Угадай цену», три раунда со слайдером, сверка
«ваш ответ / МЕРА / факт», итог с медианной ошибкой игрока; раунды —
GAME_ROUNDS_PLACEHOLDER под гейтом #2904;
- TwoPathsV3 — «сами / сделаем за вас»; заявка пути 2 ведёт в Telegram
поддержки (формы и договора «под ключ» не существует);
- ObjectionsV3 — FAQ «перед оплатой» на нативных details; ответы из
выверенной честной копии (content.ts/oferta/refund), макетный ответ про
«отметки продавцов» заменён тем, что в продукте есть (Росреестр + снятие
с публикации);
- ArticlesV3 — тизер «Разборы на данных»; карточки БЕЗ ссылок до появления
раздела статей (следующий шард).
Решение «модалка → /estimate» (StickyCtaV3) сохранено для всех CTA макета.
Гейт noindex↔плейсхолдеры дополнен новыми файлами и GAME_ROUNDS_PLACEHOLDER.
Проверено: tsc чисто, next lint чисто, vitest 27 passed, живой прогон игры
на dev (раунд → сверка → следующая), полностраничный скрин 1:1 с макетом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Node 20 вышел из поддержки 30.04.2026: security-патчи для него больше не
выпускаются, а образ node:20-alpine продолжает собираться и молча уносить это
в прод. Node 24 — текущая Active LTS.
Меняется ровно major рантайма, больше ничего:
frontend/Dockerfile node:20-alpine → node:24-alpine (deps/builder/runner)
tradein-mvp/frontend/Dockerfile то же, три стадии
.forgejo/workflows/ci.yml node-version "20" → "24" (два джоба)
.forgejo/workflows/ci-tradein.yml то же (один джоб)
Версия в CI намеренно держится равной major'у из Dockerfile — так было и
раньше, комментарии рядом обновлены вместе с числом, чтобы не разошлись.
Ни `engines`, ни `.nvmrc` в проекте нет — других мест, где закреплён major,
не осталось (проверено grep'ом по Dockerfile/yml/md).
Совместимость: next 15.5.24 поддерживает node 20/22/24; sharp 0.35.4 — node
^18.17 || ^20.3 || >=22, prebuild linuxmusl-x64 есть.
Приёмка — этот самый CI: джобы фронтов теперь выполняются на node 24, так что
зелёный прогон PR и есть доказательство. Локально проверить нечем — на машине
node 26, это не тот major.
Ничего не апгрейдит. Убирает три способа собрать образ не тем, что в репозитории.
1. tradein-mvp/backend/uv.lock — удалён (файл был untracked, в .gitignore
с рождения). Мера — uv-workspace, сборка берёт КОРНЕВОЙ tradein-mvp/uv.lock
(`context: ./tradein-mvp`, `COPY pyproject.toml uv.lock ./` + `uv sync
--frozen`). Лок, созданный `uv lock` из backend/, не читает никто, и он тихо
разошёлся с рабочим: на 2026-08-29 в нём pillow 12.2.0, starlette 1.0.1,
python-multipart 0.0.29, pydantic-settings 2.14.1, weasyprint 68.1 — пять
пакетов с открытыми advisory, которых в реальной сборке Меры нет вообще.
Именно он подмешал пять фантомных строк в OSV-скан этой серии.
Строка .gitignore остаётся (второй лок не нужен), но теперь с объяснением
почему — иначе следующий читатель снимет ignore и закоммитит призрак.
2. backend/Dockerfile — `COPY pyproject.toml uv.lock* ./` + `if [ -f uv.lock ];
then uv sync --frozen ...; else uv sync ...; fi` → без глоба и без фолбэка.
Глоб + фолбэк означали: пропал лок — сборка не падает, а молча переключается
на резолв «свежайшее из диапазонов pyproject». Образ собрался бы с версиями,
которых никто не видел ни в одном PR. Теперь пропажа лока роняет COPY.
3. tradein-mvp/browser/Dockerfile — `pip install "camoufox[geoip]" aiohttp`
без единого пина. У сайдкара нет лока вообще, так что любая пересборка (в том
числе на несвязанном коммите) тянула свежайший camoufox, а с ним другой
playwright — под который НЕ написан sed-патч coreBundle.js в том же файле.
Запинено по факту прод-контейнера tradein-browser: camoufox 0.5.5,
playwright 1.60.0, aiohttp 3.14.3. playwright явно, хотя и транзитивный
(camoufox 0.5.5 → playwright<1.61): патч завязан на конкретную сборку драйвера.
Там же переписан комментарий «Апгрейд playwright невозможен — camoufox 0.4.11
pinned»: неверны обе половины. camoufox не был запинен ни на что, а 0.4.11
в проде не стоит с неизвестно каких пор — контейнер сейчас несёт 0.5.5 и
playwright 1.60.0.
Версии сняты с живого прод-контейнера, наличие на PyPI проверено.
`npm update --package-lock-only` в tradein-mvp/frontend: package.json не тронут,
все диапазоны прежние — обновился только резолв внутри них.
OSV по локу: 6 уязвимых версий → 4. Закрыто:
sharp 0.34.5 → 0.35.4 GHSA-f88m-g3jw-g9cj
nanoid 3.3.17 → 3.3.18 GHSA-2v37-7h3g-55p8
Заодно (без CVE, в тех же диапазонах): next/@next/* 15.5.23 → 15.5.24,
js-yaml 4.3.1 → 4.3.2, @tanstack/react-query 5.101.4 → 5.102.8,
@typescript-eslint/* 8.66.0 → 8.68.0, rollup 4.62.4 → 4.63.1,
@testing-library/react 16.3.2 → 16.3.3, ws 8.21.2 → 8.21.3.
Остаток (4) диапазонами не чинится, вынесен в отдельную задачу:
postcss 8.4.31 — вендорится ВНУТРИ next/node_modules, нашим локом не управляется;
vitest 2.1.9 → vite 5.4.21 → esbuild 0.21.5 — dev-цепочка, лечится мажором
vitest 2 → 3.2.6+. GHSA-5xrq-8626-4rwp (CRITICAL) при этом неприменим:
@vitest/ui не установлен, скриптов с --ui нет.
Приёмка (локально, node 26):
npm ci --legacy-peer-deps — 566 пакетов, ok
npm run build — ok, весь роут-набор собрался
npm run test — 66 passed, 2 failed (LoginPage 429/401)
Два падения НЕ от апдейта: те же 2 теста падают ровно так же на СТАРОМ локе
(проверено откатом лока + npm ci + прогоном того же файла). Локальный node 26
против node 20 в CI; гейт — CI.
Хойстинг в диффе выглядит как даунгрейд, но им не является: picomatch 2.3.2
переехал из micromatch/node_modules/ наверх, а 4.0.5 → 4.0.7 ушёл под
tinyglobby/ вместе с fdir 6.5.0. Реального понижения версий нет.
NB: npm 12 локально требует --allow-remote=all даже при --package-lock-only
(ничего не ставится, только резолв). В CI npm 10 — там ограничения нет.
Лок не пересобирали с 3 июля (у «Меры» — 7 августа), и весь разрыв по уязвимостям
между двумя фронтендами объясняется этим. Прогон OSV: было 19 уязвимых пакет-версий,
стало 4. package.json не тронут — все фиксы влезли в существующие каретки.
Главное — Next: у 15.5.15 висело 21 advisory, 10 из них HIGH (обходы middleware/
proxy, SSRF в Server Actions и rewrites, набор DoS). Максимальный fixed_in среди них
— 15.5.21, то есть ВСЕ закрываются внутри ветки 15.5.x. Переход на 16 для
безопасности не требуется, это отдельная миграция.
next / eslint-config-next 15.5.15 -> 15.5.24
sharp 0.34.5 -> 0.35.4 унаследованные дыры libvips
postcss 8.5.10 -> 8.5.26 чтение произвольного .map по
sourceMappingURL
nanoid 3.3.11 -> 3.3.18
js-yaml 4.1.1 -> 4.3.2 квадратичный CPU на merge-key
form-data 4.0.5 -> 4.0.6 CRLF-инъекция
brace-expansion ... -> 1.1.18 три DoS
@babel/core 7.29.0 -> 7.29.7
@opentelemetry/core 2.7.1 -> 2.10.0
echarts 6.0.0 -> 6.1.0
@sentry/nextjs 10.53.1 -> 10.72.0
tailwindcss + postcss-плагин 4.2.4 -> 4.3.3
react / react-dom 19.2.5 -> 19.2.8
Дерево схлопнулось с 1010 до 749 пакетов — это следствие двухмесячной давности
лока, а не косметика, поэтому приёмка шла через полный npm ci, а не по диффу.
Остаются 4 и не чинятся здесь: postcss 8.4.31 Next вендорит ВНУТРИ своего
node_modules, а vitest 2.1.9 -> vite -> esbuild требуют мажора vitest — отдельная
задача.
Проверено локально: npm ci с нуля и npm run build проходят, сборка Next 15.5.24 +
tailwind 4.3.3 + sentry 10.72 отдаёт все маршруты. npm run test локально даёт 13
падений в 4 файлах (localStorage undefined при определённом window) — это jsdom 25
под здешним node 26; версии тестового стека в диффе НЕ менялись (jsdom 25.0.1,
vitest 2.1.9, vite 5.4.21 те же), CI гоняет node 20 и он тут гейт.
NB для тех, кто повторит локально: npm 12 по умолчанию режет remote-загрузки
(allow-remote=none) и спотыкается об опциональную @tailwindcss/oxide-wasm32-wasi.
Обходится флагом --allow-remote=all на время команды; в CI npm 10, там этого нет.
GLITCHTIP_ENABLE_MCP по умолчанию False (settings.py:291), поэтому
https://errors.gendsgn.ru/mcp сегодня отдаёт 404. Флаг читается в
asgi.py:181 (обёртка MCPDjangoDispatcher) и api/api.py:189 — нужен
только web-контейнеру, worker HTTP не отдаёт.
Заменяет неофициальный mcp-glitchtip (coffebar 0.1.2, 2 инструмента)
на вендорский с 17, включая detect_n_plus_one. Аутентификация —
статический APIToken в Authorization: Bearer, полный OAuth не нужен:
фолбэк в apps/oauth/provider.py:281-291 проверен чтением образа 6.1.6
(апстрим-issue #473 описывает более раннее состояние кода).
Blast radius: пересоздание только glitchtip-web, единицы секунд без
приёма событий. Схема БД и миграции не затрагиваются.
Откат: убрать строку и передеплоить.
Прогон всех локов через OSV batch API (2026-08-29) показал: уязвимые версии есть
только в backend/uv.lock. Корневой лок «Меры» (tradein-mvp/uv.lock, из которого и
собирается её образ) чист полностью — ноль находок.
Было 8 пакетов / 53 advisory, стало 0. Проверено повторным прогоном OSV по
получившемуся локу.
pillow 12.2.0 -> 12.3.0 26 advisory, среди них heap OOB write в
Image.paste/crop и ImageFilter.RankFilter;
Image.open у нас на внешнем входе (загрузка фото)
starlette 1.0.0 -> 1.6.0 10 advisory, в т.ч. обход лимитов request.form()
cryptography 47.0.0 -> 50.0.1 7 advisory; транзитив pdfminer-six
urllib3 2.6.3 -> 2.7.0 4 advisory; обход защиты от decompression-bomb
weasyprint 68.1 -> 69.0 CSS injection via presentational hints
idna 3.13 -> 3.19 2 advisory
mako 1.3.11 -> 1.4.1 path traversal в TemplateLookup (Windows-only,
у нас Linux — берём попутно)
pydantic-settings 2.14.0 -> 2.15.0 1 advisory
Апгрейд ТОЧЕЧНЫЙ (--upgrade-package на 8 имён), не общий --upgrade: спеки в
pyproject все вида ">=" без верхней границы, и общий апгрейд затянул бы мажоры
вроде redis 7->8 и numpy заодно. Диффом подтверждено: из 138 пакетов изменились
ровно эти 8, прямые спеки в pyproject.toml не тронуты.
Проверка совместимости локально: fastapi 0.136.1 поднимается на starlette 1.6.0,
TestClient отвечает. weasyprint импортировать на Windows нельзя (нет GTK), его
гоняет CI на Linux.
NB: fastapi.testclient на starlette 1.6 предупреждает, что связка с httpx
устарела в пользу httpx2 — это ворнинг, не отказ; отдельная задача.