1. Матчер статики сужен с /trade-in/_next/* до /trade-in/_next/static/* — тот
же, что у @uipreview (Caddyfile:78), проверенный в проде. Широкий вариант
открывал анонимам /_next/image (оптимизатор картинок, CPU по запросу), который
лэндингу не нужен: next/image, <img> и url() в mera-public не встречаются
вообще, картинок нет, шрифты — next/font/google self-hosted в _next/static/media.
2. Во все три reverse_proxy блока добавлен header_up -X-Authenticated-User —
единообразие с остальным периметром (:87, :129, :169). Блок не под basic_auth,
т.е. анонимный клиент тем более может прислать свой заголовок. Сейчас инертно
(лэндинг статичен), но станет дырой на этапе 5, когда откроется публичный
/estimate.
3. Регресс-тест на сужение: /trade-in/_next/image должен отдавать 404.
Конфликты (add/add + content) разрешены в пользу main: лэндинг #2615 и рефакторинг RouteGuard (вынос GuardedRoute в отдельный чанк, хелпер isPublicPath) уже содержат всё, что добавляла заглушка этой ветки.
Плюс закрыт разрыв на стыке #2615 и этого PR: футер лэндинга ссылается на политику ПДн через next/link, Next с basePath эмитит её как /trade-in/mera-public/privacy — такого handle в блоке периметра не было, ссылка уходила в catch-all 404. Добавлен узкий handle ровно на поддерево лэндинга (НЕ /trade-in/*) + два регресс-теста в смоук на то, что matcher не шире задуманного.
Две связанные вещи, обе — по решению владельца продукта.
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 провалидирован.
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 блока (`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".
Отдельный поддомен mera.gendsgn.ru вместо дырки в auth-gate gendsgn.ru — на
новом site-блоке B2B-маршруты (v2, admin, scrapers, /trade-in/api/*) физически
не объявлены, basic_auth не импортируется вообще. Allowlist-by-default: только
корень "/" (rewrite на /trade-in/mera-public) + статика Next _next/*, всё
остальное — 404.
Публичная страница-заглушка (tradein-mvp/frontend/src/app/mera-public/) не
тянет RouteGuard/SupportChatProvider функционально — RouteGuard получил
безусловный bypass для этого пути (аналог существующего /ui-preview,
но always-on, не за dev-флагом); сама страница — server component без
useMe/useQuota/useHistory. robots: noindex на этом этапе.
CI-регресс на периметр: scripts/smoke-mera-perimeter.sh (4 проверки: публичный
поддомен 200, B2B-путь на нём 404, gendsgn.ru/trade-in/v2 и /api/v1/admin/*
по-прежнему 401 анониму) + .forgejo/workflows/perimeter-smoke.yml
(workflow_dispatch + daily cron — не pre-merge гейт, требует реального DNS).
Требуется DNS: A-record mera.gendsgn.ru → IP VPS. До появления записи ACME
для нового хоста будет ретраить с backoff, остальные site-блоки не страдают
(автоматический HTTPS в Caddy изолирован per-hostname).
Поиск домов (доля квартир в продаже) вынесен в отдельный продукт, отвязан от
бренда «Мера» (Мера = оценка вторички). Только UI + 1 строка Caddy, бэк не тронут.
- app/sale-share/layout.tsx: своя metadata (title «Поиск домов — gendsgn»).
- components/trade-in/SaleShareHeader.tsx: своя шапка — продукт «Поиск домов»,
без MeraMark и Mera-табов; UserMenu (auth) сохранён.
- app/sale-share/page.tsx: Topbar → SaleShareHeader; убраны «Мера» из
breadcrumb/footer. Функционал/API (/trade-in/api/...) не тронут.
- Caddyfile: gendsgn.ru/sale-share → redir /trade-in/sale-share (короткий адрес;
basePath=/trade-in → true vanity-URL требует отдельного app).
Public CI surface для a11y/lighthouse аудита. /ui-preview/estimate (#803) рендерит mock-фикстуру без бэкенда, но Caddy basic_auth отдавал 401. handle-блок ДО auth-import → публично.
handle_path /trade-in/api/* стрипал /trade-in/api целиком — FastAPI
получал /v1/trade-in/estimate и возвращал 404. FastAPI router на
/api/v1/trade-in/*, нужен strip только префикса basePath /trade-in.
handle /trade-in/* НЕ матчил /trade-in (без слеша) — request попадал в
универсальный handle и проксировался на основной gendesign frontend,
который для unknown path отдавал 200 OK с пустым body.
Заменил на named matcher `@tradein path /trade-in /trade-in/*` — ловит
оба случая. Также убрал redirect-handle /trade-in → /trade-in/ т.к.
trailingSlash=false у Next.js и сам редиректит обратно.
Block был добавлен вручную при Forgejo migration (2026-05-16) но не
закоммичен в repo. Первый Caddyfile-touching deploy (#205 GlitchTip)
сделал git reset --hard origin/main на VPS → блок исчез → TLS handshake
internal error 80 на git.gendsgn.ru (prod down ~16:30).
Routes git.gendsgn.ru → forgejo:3000 (контейнер из forgejo-migration/
docker-compose.yml, shared gendesign_default network, Forgejo default
HTTP port).
VPS уже починен hotfix-ом руками; этот PR делает fix permanent — переживёт
любой git reset --hard от deploy.yml.
Obsidian Self-hosted LiveSync (CouchDB) выведен в отдельный docker-compose
stack и GHA workflow. Любой стек теперь редеплоится независимо.
* docker-compose.obsidian.yml: только couchdb в shared external network
* docker-compose.prod.yml: убрана couchdb-секция; caddy подключён к
shared network для маршрута obsidian.gendsgn.ru → couchdb:5984
* .github/workflows/deploy-obsidian.yml: новый workflow с path-filters
(триггерится только при изменениях obsidian-related файлов)
* .github/workflows/deploy.yml: + path-filters (триггерится только при
main-related файлах) + создание shared network в bootstrap
* docs/obsidian-livesync.md: обновлены инструкции под split-архитектуру
После push:
1. ssh gendesign 'docker network create gendesign_shared' (один раз)
2. Прописать COUCHDB_USER/PASSWORD в backend/.env.runtime
3. Добавить DNS A-record obsidian.gendsgn.ru → IP VPS
4. git push → GHA задеплоит обе части независимо
Преимущества:
- Изменения main не разрывают LiveSync клиентов
- Изменения obsidian не рестартуют main
- Падение obsidian-стека не влияет на основное приложение
- backend/Dockerfile: split builder/runner, runtime libs only, non-root app user, curl for healthcheck, --frozen via new uv.lock
- frontend/Dockerfile: npm ci instead of npm install (deterministic), USER node
- docker-compose.prod.yml: working backend healthcheck (curl now in image), redis healthcheck, drop dead NEXT_PUBLIC_API_BASE_URL env (Next.js bakes NEXT_PUBLIC_* at build time, runtime override is no-op)
- docker-compose.yml: same redis healthcheck, BACKEND_URL for next.config rewrites, drop uv from CMD (not in new image)
- Caddyfile: handle (not handle_path) so /api prefix is preserved into FastAPI router
- next.config.ts: rewrite /api/* and /health to BACKEND_URL in dev (no Caddy locally)
- frontend/src/lib/api.ts: empty default = same-origin relative URLs
- Makefile: drop uv run from migrate target
- Add backend/uv.lock and frontend/package-lock.json for reproducible builds
Verified: docker build succeeds for both, backend container starts, /health responds, curl healthcheck works inside image, container runs as non-root.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>