Дефолт AUTH_MODE=legacy — сегодняшнее поведение байт-в-байт: соединение с БД
auth не открывается, кука не читается, отсутствие настроек не роняет старт.
Popup Caddy стоит и снимается последним PR эпика — инвариант «гейт уходит
последним» не нарушен.
У Site Finder не было авторизации вообще: rbac_guard доверял заголовку
X-Authenticated-User от Caddy. Теперь он умеет резолвить сессионную куку
общего реестра. ВЫДАВАТЬ сессии «Птица» не будет — логин один, у «Меры», а
кука host-only на gendsgn.ru с path=/ и так долетает до обоих продуктов.
Меньше кода и меньше мест, где можно ошибиться.
Режим трёхзначный, а не булев: legacy | dual | db_only. Это прямое следствие
ревью. При булевом флаге фолбэк «сессия не нашлась → верим заголовку» после
снятия popup превращался бы в полный обход аутентификации, и ничто в коде не
заставило бы про него вспомнить. В db_only легаси-ветка недостижима: 401.
Резолв уехал в threadpool. Три независимых ревьюера нашли одно и то же:
sync-запрос к БД в async-guard блокирует event loop на каждом non-public
запросе — ровно инцидент #1202, который в этом же файле уже лечили. Кука
разбирается на loop'е, в поток уезжает только строка токена; запрос без куки
не платит ни за поток, ни за коннект.
Срок годности сессии считают часы БД, а не приложения. Раньше проверка шла в
Python, а sliding-refresh переписывал строку через now() базы — при
расхождении часов истёкшая сессия не просто проходила, а продлевалась заново,
то есть воскресала навсегда. Теперь `expires_at > now()` в самом SELECT;
питоновская проверка оставлена вторым поясом.
Fail-fast на старте проверяет не синтаксис DSN, а живое соединение: SELECT 1.
Иначе неверный пароль или хост выглядели бы как «ни у кого нет сессии» —
сутками, потому что ошибку ловил бы except в guard'е.
Ещё из ревью: connect_timeout и statement_timeout по 3с (недоступный хост
вешал коннект на минуты); throttling логов сбоя реестра (иначе шторм в
GlitchTip на каждый запрос); тела SQL закреплены ассертами формы — мутация
любого фрагмента теперь красит тесты, до этого не красила ничего.
Дефолт хоста БД — postgres, и это зеркально «Мере». У неё gendesign-postgres,
потому что внутри её стека `postgres` — чужой контейнер; здесь стек главный, и
`postgres` из корневого compose и есть нужный сервер. Алиас gendesign-postgres
дефолтом был бы багом: контейнер beat состоит только в сети default и это имя
из него не разрезолвится.
Сверка реестра с ролевой картой сделана на живом проде: все 13 юзеров
auth.users присутствуют в auth/roles.yaml, ни один не получит 403 на всё.
Четыре QA-фикстуры (admintest, pilottest, analysttest, expiredtest) есть в
yaml, но не в реестре — после снятия popup войти ими через браузер будет
нельзя, только внутрисетевым заголовком.
Осознанный долг, вписан ⚠️-блоком перед guard'ом: paths/deny из roles.yaml
бэкендом не применяются (их энфорсит фронтовый RouteGuard), guard проверяет
только известность username и admin-пути. Это предсуществующее поведение;
менять его здесь значило бы изменить и легаси-ветку, то есть нарушить
«дефолт = сегодня».
Тесты: 4594 passed, 0 failed. Главный — подделка: валидная кука плюс
присланный клиентом X-Authenticated-User другого пользователя, выигрывает
кука, и роут, читающий заголовок напрямую, видит владельца куки. У «Птицы»
таких прямых читателей одиннадцать, поэтому перезапись ASGI-scope обязана быть
полной, а не skip-if-present (CRITICAL #2552).
ask_side/ask_global получают предикат (city IS NULL OR city ILIKE :asking_city), симметрично deal-стороне. Областные объявления (развёртки с 12 июля) занижали ask-медиану и завышали коэффициент выкупа на 2.5-4.9%. NULL-толерантность намеренная: listings.city пока заполнена не у всех источников.
Refs #2583
Чтобы включить 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 не встречается.
Root cause: event-diff CTE джойнил "today" (снимок за CURRENT_DATE) с "prior"
(DISTINCT ON по всей listing_source_snapshots, ~2.6-2.8M строк) обычным JOIN.
Планировщик оценивал today в 1 строку (свежевставленные в той же транзакции
строки ANALYZE ещё не видел) → Nested Loop без Materialize пересчитывал
DISTINCT ON по всей таблице заново на каждую из ~80-140k реальных строк today
(EXPLAIN на проде: cost≈300k на этом шаге) — прогон не укладывался ни в 6h
zombie-порог, ни в сутки, каждую ночь минимум с 19 июля.
Переписано на JOIN LATERAL (per-row indexed point-lookup через idx_lss_source_date,
cost упал до ~4.4/строку). Плюс budget_sec → SET LOCAL statement_timeout как
defense-in-depth (по образцу geocode_missing_listings) — задача теперь честно
падает в mark_failed вместо того чтобы висеть сутками, если план когда-нибудь
разрегрессирует снова.
Зомби-детектор (reap_zombies) не тронут — он только помечает scrape_runs.status,
не убивает backend (нет pid/application_name в схеме run'а); pg_terminate_backend
для этого — отдельный follow-up, не в этом PR.
DELETE 4 мёртвых mobileproxy-узлов (id 2/3/4/5) из scrape_proxies. Подписка закрыта, узлы мертвы с 4-9 июля; у трёх в rotate_url лежал чужой API-ключ открытым текстом (источник блокера PR #2611). scrape_proxy_rotations пуста, единственный FK не мешает. Условие по домену (url LIKE mobileproxy.space), не по id.
Только фронт. Caddyfile, DNS и бэкенд не тронуты: периметр и домен
meraocenka.ru делаются отдельным PR, чтобы горячий Caddyfile не менялся
параллельно с эпиком «единый вход».
Заменяет заглушку из feat/mera-b2c-perimeter (48 строк «скоро откроется»)
на полноценную страницу: первый экран, как это работает, что человек
получает, откуда данные, вопросы-ответы, подвал и страница обработки ПДн.
## Починен живой баг, из-за которого лэндинг уводил бы людей на форму входа
RouteGuard регистрировал useEffect с router.push('/login') ДО early-return
для публичных путей. Хуки выполняются всегда, поэтому на проде аноним на
/mera-public получал фоновый GET /api/v1/me → 401 → редирект на логин, и
«безусловный bypass» до этого просто не доходил.
Guard разделён: RouteGuard теперь только смотрит pathname, а весь закрытый
контур (useMe, RBAC, экраны отказа) вынесен в GuardedRoute и подключён через
next/dynamic — это точка разрыва графа импортов, а не только логическая
развилка. Проверено на живой странице: запросов к API — НОЛЬ.
## Обещания приведены в соответствие с продуктом
Ревью по честности нашло 1 critical и 4 high — всё это обещания, которых
продукт не выполняет. Убрано или переписано:
- «Отчёт в PDF» — ручка owner-scoped, анониму отдаёт 401 by design;
- «список объектов, на которых построен расчёт» — план прямо запрещает
показывать анониму сырые объявления конкурентов;
- шесть городов подавались как равнозначные, хотя сбор вне Екатеринбурга
выключен (миграция 179, enabled=false) и данных единицы. Теперь честно:
полное покрытие — Екатеринбург, по области данных меньше;
- «в расчёте два типа данных» умалчивало третий — чужие оценочные модели,
которые реально двигают итоговую цифру (estimator.py, IMV/Yandex blend);
- страница ПДн обещала удаление данных, механизма которого нет.
Тексты и правовые формулировки вынесены в content.ts одним местом, рядом с
ссылками на код, который их подтверждает. Финальная редакция privacy —
за юристом, это помечено в файле.
## Форма адреса — честная заглушка, и это вынужденно
Живого автокомплита быть не может: _PUBLIC_PATHS «Меры» (rbac.py) открывает
анониму только health/docs/login/logout и анонимный чат. /geocode/suggest и
/trade-in/estimate отдают анониму 401. Открывать их до анти-абуза (этап 2
плана B2C) прямо запрещено планом. Форма честно говорит, что произойдёт,
и не изображает работу, которой нет. Переключается флагом
PUBLIC_ESTIMATE_ENABLED, рядом с ним — гейт из трёх условий.
## Проверено живьём, не по отчёту
- 360px и 390px: горизонтального переполнения нет (единственный элемент за
экраном — skip-link, так и задумано);
- один h1, иерархия H1→H2→H3 без пропусков, landmark-разметка;
- внешних ресурсов ноль — ни CDN, ни шрифтов, ни картинок с чужих доменов;
- ноль запросов к /api/** со страницы;
- tsc --noEmit чист.
NB для ревьюера: .claude/rules/ui-*.md по frontmatter paths: матчат
frontend/**, то есть корневой фронт Site Finder, а не tradein-mvp/frontend.
Здесь применяется дизайн-система v2/tokens.ts. Tailwind в этом фронте нет.
scrape_proxies.rotate_url колонка неоднородна: прод несёт и mobileproxy
changeip-ссылки (id 3/4/5), и ASocks-ссылки (id 1/9/10/11). Без явной проверки
хоста Authorization: Bearer <ASOCKS_API_TOKEN> ушёл бы на чужой провайдер —
security review PR #2611. Добавлен ALLOWED_ROTATE_HOST-пиннинг (https-only,
хост == api.asocks.com) ДО HTTP-вызова; несовпадение — отказ, не безголовый
запрос без Authorization (смысл ручной ротации — конкретный провайдер).
Заодно: класс исключения (не секрет) в note сетевой ошибки — отличить
ConnectError от ReadTimeout; расширено leak-покрытие на текст log/Sentry
сообщений (не только reason/note).
Миграция 200 (номер 199 занят параллельным PR #2611, не смержен в main).
UPDATE listings SET region_code=NULL WHERE source='avito' и slug города в
source_url не входит в наши шесть (ekaterinburg/nizhniy_tagil/
kamensk-uralskiy/pervouralsk/verhnyaya_pyshma/serov). Строки — наследие
массового заброса 18 июня до появления гео-фильтра карточек (f0264237,
20 июня), канал закрыт, все 16930 строк is_active=false.
NULL вместо настоящего региона: колонку не читает ни одна живая выборка,
восстанавливать регион по тексту не будем. Idempotent (region_code IS NOT
NULL guard). Только UPDATE, без DDL.
Ревью PR #2609: domclick — ровно один узел (прод scrape_proxies.id=1),
намеренно вырезанный из общего пула через provider_affinity='domclick'
(см. 173_scrape_proxies_add_domclick_affinity.sql) — QRATOR банит всё,
кроме этого одного чистого residential-адреса. Fallback-запрос из
предыдущего коммита мог законно забрать его под avito/cian/yandex,
оставив domclick (сейчас исправно собирает: 6501 активных объявлений,
368/сутки) без прокси вообще — чинили бы один источник ценой полной
поломки другого.
- acquire(): fallback-SELECT дополнен условием "affinity='any' ИЛИ есть
ДРУГОЙ enabled-узел той же affinity" через коррелированный EXISTS-
подзапрос (WHERE + FOR UPDATE SKIP LOCKED + ORDER BY last_ok_at NULLS
LAST, id — сохранены). Кандидат с единственным enabled-узлом своей
выделенной affinity в fallback не участвует.
- Тесты: единственный domclick-узел → acquire('avito') возвращает None;
второй enabled domclick-узел появляется — fallback снова срабатывает.
- Починен мок FakeSession (tests/services/test_proxy_pool.py):
ветка "mark_health ok" раньше ставила enabled=True безусловно по
совпадению общей подстроки "SET consecutive_fails = 0" (одинаковой в
старом и новом SQL) — test_mark_health_ok_revives_disabled_proxy
проходил бы и против кода без реанимации. Теперь ставит enabled=True
только если в тексте SQL реально есть "enabled". Та же проблема была
и в fallback-ветке (protects_last_node переопределял логику в Python
независимо от SQL) — исправлено аналогично: применяется, только если
в SQL реально есть EXISTS-подзапрос.
Прод-замер: disabled-узлы никогда не перепроверялись (WHERE enabled в
run_proxy_healthcheck) — auto-disable по DISABLE_THRESHOLD необратим,
транзиентный сбой = вечный приговор (id 11 сгорел за ночь, будучи
физически исправным). acquire() при пустой выборке по provider_affinity
падал в None, морив источник голодом при живых свободных узлах чужой
affinity.
- run_proxy_healthcheck: disabled-узлы проверяются реже (DISABLED_RECHECK_MINUTES=60
либо last_check_at IS NULL); успешная проба реанимирует узел
(enabled=true через mark_health) и инкрементит новый счётчик revived.
- mark_health(ok=True) теперь безусловно ставит enabled=true (реанимация).
- acquire: вторым заходом при пустой выборке своей affinity берёт любой
свободный здоровый узел любой affinity (WARNING-лог), приоритет своих
сохранён.
- _probe_proxy классифицирует неуспех (timeout/connect_error/http_error/other)
в fail_kind — прокидывается в mark_health только для логирования; полноценное
разделение порогов транзиент/бан отложено (см. docstring mark_health).
Дефолт не меняет ничего: 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, тот же красный).
Ночная очередь geocode_missing_listings была на 98.5% забита is_active=false
объявлениями чужих регионов (Новосибирск/Казань/Челябинск/Тюмень/Ижевск...) без
улицы и дома. ORDER BY listings_count DESC ставил такой мусор в начало очереди
(у 'Новосибирская обл.,Новосибирск' — 214 listings, у реального адреса — 1-2),
поэтому Nominatim-бюджет (1 req/sec) съедался мусором и до активных адресов
дело не доходило: 8 ночных прогонов подряд saved=0.
Добавлен AND is_active в SELECT. UPDATE (lat/lon и оба tried_at) намеренно
оставлены без этого фильтра — координаты и backoff-метка принадлежат паре
(address, city) как тексту, не конкретному listing; is_active=false дубликат
той же пары и так навсегда исключён из будущих SELECT, а unfiltered UPDATE
проставляет ему ответ бесплатно (Nominatim-вызов уже оплачен активным
листингом) на случай реактивации.
Refs #2604
Схема под решения владельца от 2026-07-31 по эпику «единый вход».
Python-кода нет, поведение прода не меняется — в БД auth пока никто не ходит.
Развилка А закрыта в пользу ПОЛНОГО переезда: tradein_users (БД tradein) в
итоге удаляется, auth.users становится единственным реестром людей. Значит
role и manager_id переезжают сюда — это отменяет решение 001:15-19 («ролей
здесь нет — сознательно»), что зафиксировано в шапке файла и переписанным
COMMENT ON TABLE, а не оставлено расходиться молча.
Развилка Б закрыта в пользу трёх состояний: is_active заменён на access_state
(active / trial_expired / disabled). Булев флаг схлопывал «пускаем, но
объясняем» и «не пускаем вовсе» в одно значение — trial-экран исчезал бы
без падения тестов. Семантика зафиксирована в COMMENT: trial_expired при
ВЕРНОМ пароле даёт 403 с отдельным кодом и НЕ выдаёт сессию, disabled —
generic 401; неверный пароль в любом состоянии остаётся generic 401, то есть
защита от перечисления логинов сохраняется. user2 («Брусника») → trial_expired.
Колонки role/manager_id зеркалят м.192 побуквенно (CHECK ролей, иерархический
CHECK, partial index, self-FK ON DELETE SET NULL), чтобы код «Меры» переехал
на auth.users без правок. Добавлен users_manager_not_self_ck — на уровне БД
самоназначение менеджером иначе проходит, а второй потребитель (Птица)
валидации «Меры» не имеет.
Гранты. INSERT выдан — без него переезд не состоится (создание сотрудника из
«Команды»). DELETE НЕ выдан: потребителя нет (в team.py только POST и PATCH),
а 002:22-33 отклоняла ровно такие гранты-на-будущее; появится хендлер —
появится строка GRANT в той же миграции. Табличный UPDATE из 002:80 сужен до
column-level: иначе auth_app молча получил бы право писать role и
access_state, и ошибка в PATCH-эндпоинте превращалась бы в тихое повышение до
админа или тихое снятие блокировки. role и manager_id в список не включены —
их сегодня не пишет никто.
Гранта на users_id_seq нет намеренно: для GENERATED ALWAYS AS IDENTITY
PostgreSQL использует NextValueExpr → nextval_internal(check_permissions
:= false), ACL последовательности не проверяется. Утверждение 002:26-27
(«идентичность требует nextval») фактически неверно; проверено обратным
экспериментом — REVOKE, затем INSERT.
Проверено исполнением на postgres:16, не по комментариям:
- чистая сборка 001→002→003→004 — 13 строк, роли admin/manager×2/employee×10,
user2 = trial_expired, is_active отсутствует, все 6 констрейнтов на месте;
- повторный прогон 004 ×2 идемпотентен;
- ручные прод-правки (user2 → active, user3 → manager) переживают повтор —
backfill не затирает решения владельца;
- периметр auth_app: INSERT users ✓, UPDATE access_state ✓, INSERT sessions ✓;
UPDATE role ✗, UPDATE manager_id ✗, DELETE ✗, CREATE TABLE ✗;
- CHECK'и ловят: admin с manager_id, self-manager, access_state вне списка,
role вне списка, INSERT без role.
Тест: 6 passed. Добавлена проверка запрета CREATE INDEX CONCURRENTLY —
в связке с обязательной обёрткой BEGIN/COMMIT это комбинация, невыполнимая
на проде (25001), а отдельной проверки на неё не было.
Замыкает петлю "город объявления -> геокодирование" (issue #2594, шаг 2/3).
listings.city (миграция 196) заполняется скрапером из контекста развёртки, но
три caller-места геокодера решали город по ТЕКСТУ адреса и игнорировали
колонку - голый адрес без города в тексте ("ул. Победы, 30", тагильский)
уходил в Екатеринбург.
- app/tasks/geocode_missing.py: группировка по (address, city) вместо
address, city_hint в geocode(), UPDATE/tried_at-пометка по паре через
city IS NOT DISTINCT FROM :city (обычный `=` не поймал бы NULL-город и
не даёт нужной симметрии между группами).
- app/tasks/backfill_listings_coords_geoportal.py: гейт по колонке city
ПЕРЕД матчем против EKB-only ekb_geoportal_buildings, ПЕРЕД текстовым
гейтом _names_non_ekb_city (сохранён как fallback для city IS NULL).
Это окно идёт раньше geocode_missing_listings, поэтому раньше успевало
испортить координаты первым.
- app/api/v1/admin.py: per-ID endpoint /geocode-missing читает city из
SELECT (listings.city / deals.city) и передаёт как city_hint.
geocoder.py не тронут (запрещено ТЗ).
Тесты: falsification-прогон (stash impl, тесты остаются) - 7 failed / 36
passed на старом коде, все 7 - новые тесты на новое поведение; после
stash pop - 43 passed / 0 failed. Полный pytest tradein-mvp/backend:
2970 passed, 1 failed (pre-existing tests/test_search_api.py::test_search_cache_hit,
несвязан), 9 skipped.
Скрапер знает город в момент сбора (city_slug из CITY_LOCATIONS/CITY_ANCHORS,
scraper_kit.orchestration.pipeline), но раньше нигде его не записывал. Провайдеры
(avito/cian) часто отдают адрес БЕЗ города в тексте ("ул. Победы, 30" вместо
"Нижний Тагил, ул. Победы, 30" — cian даже явно вырезает location-часть перед
записью, providers/cian/serp.py _format_address skip_types={"location",...}).
Без города такой адрес при геокодинге считался "город не назван" и коллизировал
с одноимённой екатеринбургской улицей (Ленина/Победы/Тенистая — сотни совпадений
в ЕКБ-реестрах) → объявление получало координаты Екатеринбурга.
Fix: отдельная колонка listings.city (196_listings_city.sql), проставляется из
sweep-контекста через save_listings(..., city=...) — НЕ парсингом/дописыванием
в address. Раздельная колонка не портит исходный текст адреса: downstream
text-парсеры (geocoder._parse_street_house/_names_non_ekb_city, estimator
house-matching) продолжают работать на исходном сыром тексте неизменёнными —
дописывание города в address ломало бы bare-form адреса без street-маркера
("Дружинина, 33" без "ул.") в этих же парсерах.
Симметрия: EKB-варианты city-sweep функций (city_slug=None) тоже получают
city="Екатеринбург" — resolve_city_name(None) даёт тот же ЕКБ-дефолт, что и
get_city_location/get_city_anchors. Проставлено во всех продовых write-путях:
run_avito_city_sweep/run_yandex_city_sweep/run_cian_city_sweep (city_slug-aware),
run_avito_newbuilding_sweep/run_cian_full_load/run_yandex_full_load/
run_avito_full_load (подтверждённо EKB-only по докстрингам), run_domclick_city_sweep
(EKB city_id, oblast B2 ещё не wired — честный None для неизвестного city_id).
Scope: только write-path для НОВЫХ листингов. Бэкфилл накопленных строк и
консультация city в geocode_missing_listings/backfill_coords_from_geoportal
(gate там пока text-only, _names_non_ekb_city) — geocoder.py намеренно не
тронут (#2582/#2580) — отдельные follow-up задачи.
PR-1 эпика: вся авторизация переезжает на одну нейтральную форму входа, браузерный
popup (Caddy basic_auth) убирается. Этот PR — ТОЛЬКО фундамент, прод работает как
сейчас: в БД gendesign ничего не меняется, новая БД создаётся и наполняется
логинами без паролей, читать её пока некому.
Почему отдельная БД, а не таблица в существующей: хранилище доступов не должно
принадлежать продукту, из которого аккаунты выносятся. Сервер — существующий
gendesign-postgres (новый контейнер не заводим); проверено, что оба бэкенда
сидят в сети gendesign_shared и TCP-достают до него.
Состав:
- data/sql/auth/001-003 — схема (users, sessions), роль приложения, сид 13 логинов.
password_hash = NULL у ВСЕХ: plaintext и bcrypt-хеши в git запрещены, пароли
проставляются отдельно на проде (конвенция репы, прецедент tradein м.193).
- ops/db-bootstrap/create_auth_db.sql — CREATE DATABASE через \gexec. Не миграцией:
CREATE DATABASE запрещён в транзакции, а миграции обязаны быть транзакционными.
- ops/db-bootstrap/set_auth_app_password.sql — пароль роли из env, зеркало
set_tradein_fdw_password.sql (GUC + \o /dev/null + %L, строго через stdin —
:'pw' не интерполируется внутри $$...$$, на этом падал деплой 2026-05-24).
Схема лежит в ПОДКАТАЛОГЕ data/sql/auth/ намеренно: основной цикл деплоя использует
`ls -1 data/sql/*.sql`, который в подкаталоги не рекурсирует → эти файлы физически
не могут примениться в БД gendesign. Защита не на дисциплине, а на глобе. Триггер
`data/sql/**` подкаталог при этом покрывает.
Права: владелец БД — суперюзер, а не auth_app (иначе гранты были бы декорацией).
users — только SELECT+UPDATE (INSERT не выдан: создания аккаунтов в этом PR нет, а
снять грант, на который уже опирается прод-код, сложнее чем выдать). REVOKE ALL ON
DATABASE FROM PUBLIC продублирован в bootstrap и в миграции намеренно: bootstrap
гоняется каждый деплой (переприменяемость), миграция — однократно (самодостаточность).
Проверено ИСПОЛНЕНИЕМ на postgis/postgis:16-3.4 (тот же образ, что на проде):
двойной прогон всех файлов идемпотентен; COALESCE-защита сида не затирает вручную
проставленные пароль/имя (проверено живьём); ASCII-CHECK отклоняет кириллицу;
auth_app коннектится, посторонняя роль → permission denied; битая миграция даёт
exit 1 и НЕ пишется в _schema_migrations, т.е. деплой прервётся до подъёма кода;
пароль с кавычками и бэкслешем не ломает %L и не печатается в stdout.
Тест backend/tests/sql/test_auth_sql_migrations.py: имена, транзакционность, наличие
wiring в deploy.yml и детектор паролей/хешей, покрывающий И data/sql/auth, И
ops/db-bootstrap — единственное место в репе с ALTER ROLE ... PASSWORD.
Детектор проверен на живучесть: подложенный bcrypt-хеш роняет тест.
Открытые развилки зафиксированы комментариями в коде, решаются в PR-2/3:
судьба tradein_users/tradein_sessions (два одинаковых по схеме хранилища) и
expired != disabled (trial-экран не выражается булевым is_active).
NB: переменную AUTH_DB_PASSWORD нужно завести вручную в runtime-env бэкенда на VPS.
Пока пусто — шаг ALTER ROLE пропускается с warning'ом, деплой не падает.
N1 не собирается с 16 июня, в scrape_schedules его нет. Миграция 165 удалила
источник на 90% (allowlist/scheduler/settings) — оставались точечные литералы:
- SourcesMap.tsx: цвет для мёртвого source в легенде карты (fallback серый).
- admin.py geocode-missing: N1-ветка address-плейсхолдер фильтра + стале
докстринги, упоминавшие N1 как активный источник listings.
- test_estimator_source_quota.py: докстринг регрессии с упоминанием N1
среди вытесняемых источников.
Данные (382 listings source='n1', is_active=false) не трогаются — все
поверхности уже провайдер-агностичны с safe fallback для неизвестных id
(source-registry.ts, trade_in_pdf.py _SOURCE_LOGO_COLORS.get, SourcesMap.tsx
colorForSource). Денормализованные счётчики (TOTAL_SOURCES/mappers.ts,
_TOTAL_SOURCES/trade_in_pdf.py, LIVE_SOURCE_COUNT/source-registry.ts) уже
производные от актуальных ростеров без n1 — индексация не затронута.
Две связанные вещи, обе — по решению владельца продукта.
1. auth/** в paths-фильтры обоих CI (ci.yml, ci-tradein.yml).
auth/roles.yaml — общий RBAC-конфиг двух стеков, но лежит в корне репы и не
попадал НИ В ОДИН фильтр: правка ролей/пользователей не запускала ни backend-,
ни tradein-сьют. Так 2026-07-30 в main уехал красный test_get_role_known_users
(user2 переведён в expired, тест ждал pilot) — обнаружен только вручную и
починен в PR #2587. Теперь правка roles.yaml гоняет оба гейта.
2. «Поиск домов» (/trade-in/sale-share) — ТЕСТОВЫЙ продукт, доступ только у
админа. Раньше он был закрыт от клиентских ролей (employee/manager/pilot), но
оставался открыт внутренней роли analyst. «Только у админа» включает и
внутренние роли → analyst добавлен в deny по sale-share.
Асимметрия с «Кэшем» намеренная и запиннена тестом: Кэш — не продукт, а
диагностика кэшей/скраперов, т.е. ровно тот инструмент, ради которого роль
analyst заведена; ему он оставлен.
Замеры после правки (реальный is_path_allowed поверх roles.yaml):
роль | Поиск домов | Кэш | ядро продукта
admin | True | True | True
analyst | False | True | True
pilot | False | False| True
Тест test_yaml_roles_deliberately_outside_client_deny переписан: пиннит ОБЕ
стороны асимметрии, а не только «analyst видит всё». Набор внутренних путей
разрезан на _SALE_SHARE_PATHS / _CACHE_TOOL_PATHS с assert'ом, что разрез
покрывает исходный набор целиком — иначе новый путь добавят и забудут отнести
к продукту, оставив analyst непроверенным.
Заодно поправлены устаревшие комментарии «Доступ: pilot + admin» в Caddyfile
(vanity-редирект gendsgn.ru/sale-share) и в докстринге самой страницы.
Тесты: 77 passed (tradein rbac/auth_session/auth_api) + 24 passed (site-finder).
tsc --noEmit + next build — зелёные. YAML обоих workflow провалидирован.
H1 (deep-review PR #2589): `с\.` в _LOCALITY_MARKER_RE был мертворождён —
хвостовой \b не может сработать между "." и пробелом (оба не-словесные),
формат «с. <село>» (104/77к прод-адресов) не ловился НИ РАЗУ, зато ложно
срабатывал на инициалах «им с.п.королева». Живой инцидент воспроизведён:
"с. Новоалексеевское, ул. 8 Марта,37" снапалось в ЕКБ (совпадение до 5-го
знака, село в 35 км западнее). Заменил на lookahead-форму для однобуквенных
маркеров (с/д/дер/пгт/ст): "\.\s*(?=[а-яё])" — различает "д. 5" (дом-номер,
не матчит) от "д. Кашино" (деревня, матчит), что заодно позволило безопасно
включить "д."/"дер." (раньше исключённые из-за коллизии с house-маркером).
M2: гейт по city_hint оставался list-based (_names_non_ekb_city(city_hint)) —
для СТРУКТУРНОГО хинта (не свободного текста) fail-closed форма должна быть
обратной: "не Екатеринбург → закрыто", а не "есть в списке 37 → закрыто".
city_hint принимает произвольную строку без валидации (API напрямую, минуя
6-городный UI-selector) — city_hint="Реж" держал гейт открытым, geoportal
отдавал точный ЕКБ-дом, city_ambiguous=False делало это уверенно-неверным.
M3: добавлены изолирующие тесты — предыдущая параметризация "незнакомый
топоним" всегда несла ещё и "обл."/"республика"/"край" одновременно с
проверяемым сигналом, поэтому мёртвый "с\." прошёл незамеченным. Плюс честно
зафиксирована известная дыра (гейт по city_hint) — "реж, ленина 5" без
маркера остаётся в ЕКБ-дефолте (архитектурное решение отдельно, не список
городов).
docstring `_names_unrecognized_locality`/`_ekb_local_tiers_allowed` приведён
в соответствие с фактическим поведением regex.
Живая проверка прода после #2584: триггер городского дропдауна
(pp-dd-trigger-dashed, ParamsPanel.tsx) — фиксированные 176x22px,
font-size 11px. Дефолтный лейбл "Определить автоматически" (~146px
в Manrope 400, замерено opentype.js против реального шрифта прода)
не влезал в однострочный бюджет ~143px, переносился на вторую
строку и обрезался высотой триггера.
Заменил UNCONFIRMED_CITY_LABEL на "Автоопределение" (~96px, большой
запас) — сохраняет смысл, перекликается с "Авто" у РАДИУС АНАЛИЗА,
но не двусмысленно рядом с названиями городов. Самое длинное
название города в CITY_LABELS, "Каменск-Уральский" (~108px),
укладывается в тот же бюджет без переноса — второго фикса не
требует.
Разрешён конфликт в geocoder.py между #2580 (city_hint threading в
локальный гейт + dual-query Tier 4 Nominatim) и #2582 (fail-closed
структурный маркер топонима вместо списка городов): `_ekb_local_tiers_allowed`
теперь принимает `city_hint` и закрывает гейт по OR трёх независимых
сигналов — известный не-ЕКБ город в тексте (#11), known-city в city_hint
(#2580 C1), незнакомый топоним-маркер в тексте (#2582). Добавлены тесты на
все 4 достижимые ветки (hint=известный город / маркер в тексте / ничего /
hint=Екатеринбург); полный набор тестов (свои + пришедшие из #2580) зелёный
одновременно.
Deep-review R3: плашка city_ambiguous всегда подставляла {city} — внутреннее
состояние с дефолтом "Екатеринбург" (initCityLabel), а не то, что реально
определил бэкенд (в ответе только булев target_city_ambiguous, угаданного
города там нет). Ровно в целевом сценарии фикса — нетронутая форма, «Ленина
1», cityConfirmed=false — текст утверждал «если это не Екатеринбург»
независимо от реального результата (там мог быть Нижний Тагил) — та же
нечестность, которую предыдущий коммит убирал из запроса, только в тексте.
Текст плашки теперь ветвится по cityConfirmed:
- cityConfirmed=true (город реально был подтверждён и отправлен) — прежний
текст с конкретным {city} уместен, не меняю.
- cityConfirmed=false (это и есть путь, где cityAmbiguous обычно и
срабатывает после предыдущего коммита) — нейтральная формулировка без
упоминания конкретного города: «Если это неверно, выберите город выше и
повторите оценку.»
tsc --noEmit / next lint / next build — чисто (те же 2 pre-existing warning в
несвязанных файлах).
backfill_coords_from_geoportal брал все listings с lat IS NULL, парсил street+house
и матчил напрямую против EKB-only ekb_geoportal_buildings, минуя geocoder.geocode()
и его городской гейт (_names_non_ekb_city). Улица+дом могут буквально совпасть между
Екатеринбургом и другим городом области ("проспект Ленина 1" есть и в ЕКБ, и в Нижнем
Тагиле) — такие адреса получали екатеринбургские координаты и портили радиусные
выборки аналогов на этой улице в ЕКБ, а сами исчезали из выборки своего города.
Фикс: _names_non_ekb_city(address) перед вызовом _geoportal_house_match — тот же гейт,
что уже используется в geocode(). Прямой вызов geoportal-матчера (а не полноценный
geocode()) сохранён намеренно — pure local-DB операция без внешнего HTTP, полноценный
geocode() добавил бы Nominatim/Yandex вызов на каждый non-EKB адрес backlog'а (лишняя
нагрузка на ограниченный Nominatim, Yandex сейчас 403 — #2585).
geo_precision оставлен NULL для house-level матчей этого тира — по конвенции
089_listings_geo_precision.sql/geocode_missing.py NULL означает "не coarse", то же
значение что geo_precision=None для precise-адресов в geocode_missing_listings;
исключать из radius-аналогов нужно только 'city'-fallback.
Порядок окон (05:00 geoportal → 06:00 geocode_missing_listings) не менялся: гонка была
безвредна для корректно заматченных EKB-адресов, вредна только из-за отсутствия гейта —
теперь non-EKB адреса здесь не матчатся вообще и просто ждут oblast-aware провайдеров
в следующем окне.
Поправлен ложный комментарий в migration 171 ("не-ЕКБ адреса не матчатся — корректно").
Ущерб на проде (SELECT-only, без изменений): 2040 листингов с координатами внутри
EKB-bbox (56.65-56.95, 60.40-60.85) при адресе, называющем другой город области
(1941 после исключения мкр/р-н/жк-омонимов вроде ЖК "Заречный" внутри ЕКБ). Только
~31 из них совпадают по координатам с ekb_geoportal_buildings/gendesign_cad_buildings —
основной массив, вероятно, из других источников координат (не только этот таск).
Чистка — отдельный шаг.