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 —
основной массив, вероятно, из других источников координат (не только этот таск).
Чистка — отдельный шаг.
Tier A (_fetch_anchor_comps, "тот же дом") матчил по normalized street+house
(_normalize_building_key намеренно дропает город) БЕЗ единого гео-предиката —
единственный запрос к listings в файле без ST_DWithin/города/house_id_fk/LIMIT.
"Серов, ул. Ленина, 5" получал якорь по ЕКБ-объявлениям (~40 191 из ~40 200
активных листингов — ЕКБ) с самым доверенным тиром 'A', завышая цену в 4-5х;
хуже — anchor_tier='A' блокировал честный #oblast-D deals-headline-fallback
(гейт `anchor_tier is None`), так что область не могла получить даже
резервную ДКП-оценку.
Фикс мирроит уже одобренный geo-bound для Tier S (f9ae6f0c, #oblast-D):
ST_DWithin(geom::geography, subject_point, ANCHOR_TIER_A_RADIUS_M) от
lat/lon субъекта, radius = DEFAULT_RADIUS_M (1000м, тот же константа что и
Tier S) — строковый match уже устанавливает "та же улица+дом", радиус нужен
только чтобы отсечь РЕАЛЬНО кросс-городские коллизии (ЕКБ/Серов — сотни км),
не для внутридомовой точности. Tier A целиком гейтится на lat/lon субъекта
(как Tier C) — без них геопредикат невозможен; в проде geo всегда есть
(_empty_estimate возвращается раньше при неудачном geocode).
Объявления без geom: `AND geom IS NOT NULL` + ST_DWithin (NULL → exclude,
не blind-include) — >99.97% листингов имеют координаты (см. f9ae6f0c),
исключение погрешности не создаёт.
_normalize_building_key НЕ тронут (город по-прежнему не входит в ключ) —
добавление городского токена сломало бы матчинг источников без города в
адресе (Avito anonymous) и внесло бы новый normalization-риск; кросс-
городская коллизия закрыта на SQL-уровне надёжнее.
Поправлен ложный комментарий _band_haircut (:1900-1908): "same-building
anchor pool для oblast не формируется" был неверен даже ДО фикса (Tier A
не имел гео-фильтра вовсе — либо матчил ЕКБ, либо честно матчил местные
листинги, если они были).
Тест-страж test_estimator_deals_headline_fallback_oblast_d.py использовал
голый db=MagicMock() для _fetch_anchor_comps — db.execute(...).mappings().all()
на unconfigured MagicMock тривиально возвращает [] (MagicMock __iter__ default),
так что anchor_tier никогда не мог стать 'A' и регрессия было бы невидима.
Пропатчено явно (документирует допущение) + добавлен новый тест, который
НЕ мокает _fetch_anchor_comps — гоняет реальную SQL-логику через фейковый
db.execute, симулирующий настоящую ST_DWithin-фильтрацию по haversine-
дистанции. На pre-fix коде (git stash) даёт headline=186 400 ₽/м² вместо
честных 30 000 (deal corridor) — воспроизводит репортнутый баг buквально.
EKB control-тест подтверждает: несколько объявлений в одном доме ЕКБ
по-прежнему формируют Tier A anchor (n=4, ~145k ₽/м²) — не деградировало.
test_tier_a_primary_1774.py: позитивные Tier A тесты теперь передают
lat/lon субъекта (гейт требует их) — тесты на python-side novostroyki-
гейтинг (ожидающие tier=None) оставлены с lat=None (конечный результат
не меняется: Tier A целиком пропускается без lat/lon, как и Tier C раньше).
Full suite: 2842 passed, 9 skipped, 1 pre-existing failure (test_search_cache_hit,
не связан). Ruff чист.
_PRODUCT_SOURCES в test_scraper_kit_scheduler_parity.py — ручной canonical-список
(файл сам предписывает обновлять его при изменении product_handlers.py, #2397 Part C
уже чинил ровно эту протухшесть). Новый source из PR #2579 забыт — без него
test_real_build_product_handlers_covers_all_scheduled_sources не покрывает
регистрацию Handler'а: пропажа entry в product_handlers.py осталась бы незамечена,
а kit-scheduler логировал бы "unknown source, skip" каждый тик без выполнения задачи.
Проверено: тест падает при временном откате Handler-registration (AssertionError
misses source=deal_city_price_bands_refresh), проходит после восстановления.
Миграция 178 засеяла deal_city_price_bands разово (N>=30 сделок), без scheduler'а
на refresh. Город без строки падал на глобальный DEAL_MIN_PPM2=50000 (ЕКБ-калибровка)
— для дешёвых городов области это не anti-outlier guard, а cut-off легитимного рынка
(Североуральск median ~21.7k). Замер по прод-данным: 289 из 369 не-ЕКБ городов
(1265 сделок) не имели строки и падали на ЕКБ-порог.
- 194_deal_city_price_bands_tiers.sql — трёхуровневая схема (tier колонка):
full (N>=30, own p1/p99, unchanged) / rough (N 10-29, own p1 floor + фикс.
ceiling 800000) / region_fallback (N 1-9, pooled областной p1=15263 вместо
ЕКБ-порога). Екатеринбург по-прежнему не в таблице — estimator fallback
byte-identical.
- 195_scrape_schedules_seed_deal_city_price_bands_refresh.sql — scrape_schedules
row, окно 07:00-08:00 UTC (после rosreestr_dkp_import + asking_to_sold_ratio_refresh).
- app/tasks/deal_city_price_bands_refresh.py — периодический re-derive (kit-scheduler,
byte-identical 194 derivation), без DELETE (множество городов монотонно растёт).
- app/services/product_handlers.py — регистрация Handler для нового source.
Валидация: scratch-БД (syntax_check) в прод-контейнере, synthetic данные на
границах тиров (N=9/10/29/30) + Екатеринбург/non-rosreestr/NULL exclusion, оба
файла применены дважды (идемпотентность подтверждена), scratch-БД удалена.
После 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).
После cutover'а на DB-auth (#2558) аккаунты `kopylov` и `praktika` живут с
`role='manager'`, а team-API жёстко фильтровал `role='employee'` — сбросить
менеджеру пароль или заблокировать его было НЕЧЕМ, кроме ручного psql на проде.
Всплыло 2026-07-31: «Практика» весь день билась в логин (5 failed, 0 успешных),
а восстановить доступ через UI админ не мог.
Что меняется:
- `_fetch_employee_row` берёт actor: admin → `role IN ('employee','manager')`,
manager → по-прежнему только `role='employee'` + свои по `manager_id`.
- `GET /employees` без фильтра отдаёт admin'у и менеджеров (`?manager_id=` —
без изменений, только сотрудники этого менеджера).
- `EmployeeOut.role` — новое поле, UI показывает бейдж «менеджер» и
склоняет тексты («Заблокировать менеджера ...» вместо «сотрудника»).
Инвариант self-lockout сохранён и усилен тестом: строки `role='admin'`
недостижимы через этот роутер ни для кого, включая самого админа, поэтому
ни block, ни смена пароля с `revoke_user_sessions` не могут вырубить
действующего админа. Раздача роли admin остаётся вне API.
Тесты: 6 новых (список с менеджерами, сброс пароля менеджеру + отзыв сессий,
блокировка, manager не достаёт до чужого менеджера, admin не достаёт до
admin-строки), 3 существующих обновлены под новое ожидание списка.
Deep-review PR #2569 (echo-стенд на caddy:2, caddy adapt): комментарий у
/trade-in/api* и @tradein утверждал, что старая Set-форма
(header_up X-Authenticated-User {http.auth.user.id}) пропустила бы клиентский
заголовок насквозь — измерено, что это неверно. Caddy подставляет
неразрешённый плейсхолдер как литеральную строку (ReplaceKnown), т.е. старая
форма была бы fail-closed-but-сломанной (get_role("{http.auth.user.id}") ->
403 для всех), а не bypass. Решение (-Field вместо Set) остаётся правильным
по другой причине: это единственная форма с документированной delete-
семантикой, не зависящая от трактовки пустого/нерезолвленного плейсхолдера.
Также: @uipreview (единственный блок в trade-in-периметре без scrub) теперь
тоже явно удаляет X-Authenticated-User - единообразие периметра, хоть
сейчас и инертно (статичная страница без секрета).
И: комментарий у @saleshare уточнён - снятие Caddy basic_auth с
/trade-in/sale-share не делает страницу публичной, она по-прежнему за
RouteGuard + session-required API (редирект на /login без валидной сессии).
Провалидировано: caddy validate --config Caddyfile --adapter caddyfile
(caddy:2, локальный docker) -> "Valid configuration".
`/trade-in/` редиректит на `/trade-in/v2`, а v2-навигация (TopNav.tsx)
не знала про team-дашборд вообще — пункт «Команда» был только в legacy
Topbar.tsx (NAV_ITEMS), который на v2-страницах не рендерится. Дашборд
существовал, но был недостижим кликом.
- app/v2/page.tsx: showTeamNavItem — тот же двойной гейт, что и
Topbar.NAV_ITEMS "team" (isPathAllowed(/api/v1/team) + роль
admin/manager), передаётся в TopNav.
- v2/TopNav.tsx: пункт «Команда» в user-меню рядом с «Выйти» (не таб
SectionOverlay — /team отдельный роут, а не секция текущей страницы).
Второй дефект: team-API требует session-cookie, легаси Caddy-роль
(/me 200) через него не проходит → GET /api/v1/team/employees отдаёт
401, UI показывал невнятную красную плашку. app/team/page.tsx теперь
ловит 401 отдельно от 403 и уводит на /login?next=/team (тот же
redirect-паттерн, что RouteGuard.tsx для 401 от /me; в dev — сообщение
с кнопкой «Войти» вместо авто-редиректа, тот же NODE_ENV-гейт что и в
RouteGuard).
Оба trade-in блока (`handle /trade-in/api/*`, `@tradein`) + связанный
`@saleshare` редирект перенесены ВЫШЕ `import caddy/users.caddy.snippet`
в route{} — Caddy матчит handle-блоки сверху вниз, поэтому они теперь
short-circuit'ят до basic_auth-гейта (тот же механизм, что уже применён
к /health, /preview/*, @uipreview). Site Finder (/, /api/*, /analytics)
остаётся под basic_auth без изменений.
Trade-in уже имеет собственную авторизацию (форма входа + opaque
session-cookie, #2552) поверх RBAC внутри tradein-backend — Caddy
basic_auth для него стал избыточным легаси-слоем.
Security: после переноса плейсхолдер {http.auth.user.id} для этих
блоков никогда не резолвится (basic_auth больше не выполняется на
пути к ним). Вместо `header_up X-Authenticated-User {http.auth.user.id}`
(Set-операция с пустым значением — недокументированно надёжна как
удаление заголовка) используется явное `header_up -X-Authenticated-User`
(документированная delete-семантика `-<field>`). Это исключает проход
клиентского X-Authenticated-User насквозь к tradein-backend, который
в auth_mode="dual" всё ещё принимает legacy trusted-header путь —
без явного удаления внешний запрос с подделанным заголовком мог бы
выдать себя за другого пользователя. X-Internal-Auth-Secret не тронут
(#2213, всегда перезаписывается из env).
Провалидировано: `caddy validate --config Caddyfile --adapter caddyfile`
(caddy:2 образ, локальный docker) → "Valid configuration".
EmployeeTable: убран ранний return при пустом списке — на offset>0 (ровно
50/100/150 сотрудников) пейджер и кнопка «Назад» теперь остаются доступны;
текст различает «сотрудников вообще нет» (offset=0) и «страница за концом
списка» (offset>0).
team/page.tsx: useEmployees получает enabled=isAllowedRole, вычисленный ДО
вызова хука — прямой заход employee/analyst/pilot на /team больше не шлёт
обречённый GET до отрисовки role-gate.
Deep-review seed'а (#2564) нашёл смежный дефект в уже смерженном коде (#2563):
Python `$` матчит перед trailing newline (re.match(r'...\$', 'admin\n') -> True),
а Postgres `~` в CHECK tradein_users_username_ascii_ck (миграция 193) - False.
username="admin\n" проходил Pydantic-валидацию и падал уже в БД -> 500 вместо
честного 422. `\Z` - конец строки без поблажки на trailing newline, совпадает
с семантикой Postgres `~`.
Grep по app/schemas/ (pattern=/regex=/re.compile/re.match/re.fullmatch) -
других regex-валидаторов с `$` в схемах trade-in нет, team.py - единственный
файл с regex-based полем.
Deep-review #2564: manager_id синкался из EXCLUDED безусловно — повторный прогон сида
тихо обнулял связь сотрудник->менеджер, назначенную через team-API (#2563), сотрудник
выпадал из _LIST_EMPLOYEES_BY_MANAGER_SQL. Защищён COALESCE, как остальные UI-managed
поля.
is_active убран из ON CONFLICT DO UPDATE SET вовсе (не COALESCE — колонка NOT NULL
DEFAULT true делала бы COALESCE-ветку недостижимой, мёртвый код вводил в заблуждение
симметрией с реально работающими COALESCE-полями). Open/close доступа — решение
владельца продукта через UI (#2556), не повторный прогон seed-файла.
Единая страница /team для ролей admin/manager (backend сам скоупит список
по org-изоляции) — таблица сотрудников с пагинацией, создание сотрудника
с ручным паролем, изменение месячной квоты + сброс пароля одним PATCH,
drawer с историей оценок. Nav-пункт «Команда» в Topbar виден только
admin/manager (доп. roleGate поверх isPathAllowed — legacy analyst-роль
иначе тоже прошла бы path-фильтр).
Deep-review PR #2563 follow-up (после merge+deploy):
1. ORDER BY created_at DESC, id DESC в _LIST_EMPLOYEES_BY_MANAGER_SQL /
_LIST_EMPLOYEES_ALL_SQL. created_at DEFAULT now() — время транзакции, bulk-seed
(#2557) вставляет много юзеров одной транзакцией -> идентичный timestamp у N+
строк -> без тай-брейкера порядок между LIMIT/OFFSET страницами на PostgreSQL
для строк-близнецов не гарантирован (сотрудники пропадали/дублировались бы
при листании). id (BIGINT IDENTITY, монотонный) — детерминированный tie-break.
2. _batch_quota_status: unlimited теперь честно совпадает с
account_quota.is_unlimited — override.unlimited=true честится ТОЛЬКО для
username, присутствующего в roles.yaml (KeyError -> unlimited=False всегда,
override даже не читается). Раньше батч всегда читал override независимо от
roles.yaml -> список мог показать "unlimited" для квоты, которую реальный
enforcement (check_and_raise/increment, тот же is_unlimited) не признаёт.
Сегодня недостижимо (unlimited есть только у kopylov/praktika, оба в
roles.yaml), но станет достижимым при расширении ролевки.
Migration 193: переносит org-карту (admin/kopylov/praktika/user1-10), утверждённую
владельцем продукта, из auth/roles.yaml в tradein_users (Foundation — миграция 192).
password_hash=NULL для всех — пароли админ проставит вручную через team-UI (#2556).
ASCII-CHECK на username (deep-review #2561): rbac кодирует session-username через
encode("latin-1","replace"), кириллические логины одинаковой длины схлопываются
в общий downstream-identity (IDOR) — constraint запрещает это fail-closed.
ON CONFLICT DO UPDATE защищает password_hash/is_active/display_name/org_name/email
через COALESCE — повторный прогон (recovery / staging без _schema_migrations
tracking) не затирает то, что менеджер поменял через UI.
Deep-review PR #2563:
- HIGH: PATCH new_password не ревокал старые сессии сотрудника — sliding-refresh
продлевал скомпрометированную/чужую сессию бесконечно. Теперь revoke_user_sessions
вызывается при is_active=False ИЛИ new_password (self-lockout невозможен —
_fetch_employee_row фильтрует role='employee').
- Medium1: Origin/Referer-check (защита CSRF) на POST/PATCH team-роутах против
settings.cors_origins; оба заголовка отсутствуют (curl-смоук внутри контейнера) —
пропускаем, не ломаем прод-смоук.
- Medium2: батч-квота для GET /employees (_batch_quota_status) — было 2N+3 SQL-запросов
на N сотрудников, стало 5 плоско (замерено: N=10 -> 5 запросов). Добавлена пагинация
limit(default 50, max 200)/offset.
- Заодно: статические WHERE-ветки вместо f-string-сборки в list_employees;
monthly_limit теперь сбрасывает account_quota_overrides.unlimited=false и не
затирает существующий note.
PR #2562 review, 3 однострочника:
1. sanitizeNext обходился: WHATWG URL-парсер (router.push) вырезает ASCII
tab/CR/LF из ВСЕЙ строки перед парсингом, так что "/\t//evil" проходил
regex (позиция 1 — таб, не "/"/"\\"), а после навигации резолвился в
protocol-relative "//evil" → чужой origin. Теперь сначала strip
[\t\r\n], потом валидация — regex видит ту же строку, что увидит парсер.
2. next=/login (или /login?...) кидал юзера обратно на форму входа
(RouteGuard не гейтит /login) — dead-end. Фолбэк на "/".
3. RouteGuard брал next= только из usePathname(), без query — сессия,
истёкшая на deep-link (/v2?id=<uuid>), теряла отчёт после релогина.
Добавлен window.location.search в next (effect всегда client-side).
Session-only identity (current_team_actor, admin|manager) поверх tradein_users/
tradein_sessions (#2552 foundation). Org-изоляция manager <-> employee через
manager_id: чужой/несуществующий employee_id -> 404 (не 403 — не палим
существование), POST с чужим manager_id в теле от manager игнорируется
(принудительно свой id). Квота — upsert в account_quota_overrides (существующий
паттерн, без правки account_quota.py). История оценок — user_events LEFT JOIN
trade_in_estimates. Team-события (employee_created/blocked/unblocked/
password_reset/quota_changed) без пароля в payload.
POST /api/v1/auth/login/logout уже в main (DB-backed session, httponly
cookie tradein_session). Фронт: /login-форма (username+password, ошибки
401/429 по-русски, next= redirect с open-redirect guard), RouteGuard
редиректит на /login при 401 вместо NoAccessScreen variant=session
(prod-only, dev-режим без Caddy не трогаем), useLogout хук чистит
/me-кэш и уходит на /login. Role расширена admin|manager|employee (новые)
+ pilot|analyst|expired (legacy dual-mode resolver на бэке).
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.