Decision 2026-05-26: pilot аккаунты пилот-программы получают доступ
ТОЛЬКО к разделу Trade-In (оценка вторички). Landing, Analytics,
Site Finder, Concept — admin-only.
Изменения:
- `auth/roles.yaml` — `pilot.paths` сужен с 7 путей до 2:
`/trade-in/**` + `/trade-in/api/v1/**`. Deny список оставлен
как есть для defense-in-depth.
- `backend/tests/test_rbac.py` + tradein mirror — переписан
`test_is_path_allowed_pilot_excludes_admin` → `_pilot_only_tradein`:
pilot allowed только /trade-in, denied на /, /analytics, /site-finder,
/concept, /api/v1/parcels. Тест `test_get_user_scope_pilot` ассертит
что /trade-in/** в allowed, / и /analytics/** нет.
- `frontend/src/lib/__tests__/isPathAllowed.test.ts` — те же parity
обновления (pilot allowed list сужен, denied list расширен landing
+ non-tradein путями).
- `caddy/users.caddy.snippet` — синхронизирован с VPS-актуальной
версией: regen'd user1..user10 hashes + admintest/pilottest entries
(locally added на VPS 2026-05-26 для QA RBAC). Без этого `deploy.yml`
делал бы `git reset --hard` и стирал бы локальные правки.
- `auth/roles.yaml` users: блок — также добавлены `admintest: admin`
и `pilottest: pilot` (mirror VPS local edit).
Эффект для UX:
- pilot открывает `/` → frontend RouteGuard → fullscreen «Доступа нет»
(т.к. `/` не в allowed_paths).
- pilot TopNav на main frontend — только пункт «Trade-In» виден
(остальные `/analytics`, `/site-finder`, `/concept`, `/admin`
отфильтрованы `isPathAllowed`).
- pilot открывает `/trade-in` → работает как раньше.
- admin (admintest, kopylov-если бы был admin) — без изменений.
Backend middleware path-enforcement не делает — security guarantee
по-прежнему на admin-only endpoints (`/api/v1/admin/*` блочится для
пилота). Path-filter на UI-уровне через `allowed_paths` /
`deny_paths` в /me response.
Pilot login flow: после basic_auth → main frontend → useMe → /me →
scope (allowed=/trade-in/**). RouteGuard на `/` → NoAccessScreen.
Pilot должен bookmark'ить `https://gendsgn.ru/trade-in/` напрямую.
Follow-up: можно добавить auto-redirect в RouteGuard — если pilot
landing-path → redirect на первый allowed (`/trade-in`). Отдельный PR.
Tests: 20/20 pass на обоих backend стэках, ruff clean.
Replaces tradein.cad_buildings snapshot with live postgres_fdw foreign table reading gendesign.v_tradein_cad_buildings. Fixes /trade-in/api/v1/geocode/reverse 500 (Nominatim ban) and address_not_geocoded for cadastre addresses (e.g. Хохрякова 81).
Security (deep-review fixes):
- 100_tradein_fdw_role.sql: passwordless CREATE ROLE; password set by deploy.yml ALTER ROLE bootstrap reading GENDESIGN_FDW_PASSWORD from backend/.env.runtime (via psql :'pw' var → format %L — injection-safe).
- core/fdw.py: regex whitelist [A-Za-z0-9_-]{32,256} on password, ValueError without echoing value, try/rollback on commit.
- 060_postgres_fdw_extension.sql: connect_timeout='3' on FOREIGN SERVER + ALTER ADD/SET fallback.
- geocoder.py: _cadastral_forward_sync / _cadastral_reverse_sync wrapped in asyncio.to_thread.
- 100_*.sql: REVOKE ALL ON ALL TABLES/SEQUENCES/FUNCTIONS IN SCHEMA public; only GRANT SELECT on v_tradein_cad_buildings.
- pg_user_mappings query handles PUBLIC mapping (usename IS NULL).
Tests: 3 SQL-injection guards on ensure_fdw_user_mapping + rewritten cadastral suite.
PR #335 использовал `with db.begin_nested():` SAVEPOINT для per-row UPDATE,
но outer transaction никогда не commit'ился. После цикла Celery task
закрывал session (`db.close()` в finally) → autobegin'd outer tx
rollback → все UPDATE'ы теряются.
Симптом: task завершается с processed=3 succeeded=3, в Glitchtip нет ошибок,
но в БД ни одного catalog_scraped_at != NULL.
Fix: добавлен db.commit() после цикла + rollback на ошибку commit'а.
Добавляет endpoint для приёма заявок на пилот (lead-gen).
INSERT в pilot_requests, response {id, created_at, status}.
Telegram-уведомление — TODO (creds не настроены, #307 SF-B3).
Migration: data/sql/118_pilot_requests.sql
PR #322 used placeholder URLs:
- /api/object/{id}/documents -> 404 (Playwright showed: 5 real endpoints:
document/rpd / developer/report / project/documentation /
documentation/other / document/permits). Replaced with 5 parallel
fetches + merge into single upsert_documents call.
- /api/object/{id}/checks -> 404. 6 checkboxes likely inline in bulk
kn/object payload; endpoint /checks does not exist. Commented out
_fetch_obj_checks_safe with TODO for separate investigation PR.
- BrowserSession Semaphore(3) -> Semaphore(6) — double concurrency
for intra-object gather.
GlitchTip: kn_scrape_failures run #19 showed 40 failures each for
both placeholder endpoints across first 30 objects of 1532.
Add _extract_obj_class_from_ai() with regex patterns for Комфорт/Бизнес/Премиум/Элит/Стандарт.
_norm_object() uses it as fallback; objClass from API still takes priority if ever returned.
Add backfill SQL 105 to retroactively populate existing rows from raw JSON payloads.
Coverage: ~1806/4548 rows will be filled; remainder NULL is expected (Нежилое/no AI desc).
Replace unweighted AVG(deals_total_avg_area_m2) and AVG(deals_total_avg_price_thousand_rub_per_m2)
with SUM(x * count) / NULLIF(SUM(count), 0) pattern in _INLINE_VELOCITY_SQL.
Months with zero deals no longer dilute the weighted mean 2-4x.
P0 follow-up to PR #290 (mv fix, issue #21).
When Objective mapping coverage falls below 50% of competitors in the radius,
fall back to rosreestr_deals JOIN on the parcel's cadastral quarter. Audit shows
237/237 EKB quarters (100%) have rosreestr data for the last 12 months, compared
to <20% Objective coverage before bulk mapping.
- velocity.py: add _compute_rosreestr_fallback(), _OBJECTIVE_COVERAGE_MIN_RATIO
constant, velocity_source field on VelocityResult (objective/rosreestr_fallback/none)
- parcels.py: extract cad_quarter from cad_num, pass to compute_velocity
- site-finder.ts: add velocity_source field to Velocity interface
- VelocityBlock.tsx: badge "Источник: квартальные сделки" when rosreestr_fallback
Epic #271 item #17