Аудит 02.09: curl http://188.124.37.140/api/v1/me отвечал 401 с
Www-Authenticate: Basic realm=GenDesign Pilot. Браузер, открывший голый IP,
отправил бы пароль пилота plaintext-ом любому слушающему канал.
Замысел блока был верный — «закрыть обход через голый IP тем же auth-гейтом».
Исполнение дырявое: basic_auth на plain HTTP это и есть канал утечки пароля.
Редирект на канонический HTTPS строже гейта: по HTTP не отдаётся ни контент,
ни сам запрос пароля — обход закрыт тем, что отвечать нечему. /health
остаётся публичным (liveness).
Потребителей у IP:80 нет: все deploy-смоки ходят docker exec → localhost
внутри контейнеров (grep по .forgejo/workflows и ops/).
Конфиг провалидирован БОЕВЫМ бинарём: docker exec gendesign-caddy-1 caddy
validate → Valid configuration. Гейт snippet-mounts зелёный.
Деплой делает `git reset --hard origin/main`, поэтому любой site-блок,
вписанный руками в /opt/gendesign/Caddyfile, живёт до первого пуша в main.
16.08 так и вышло с garmin.gendsgn.ru: контейнер остался healthy, а хост
исчез из конфига — TLS-хендшейк стал падать `alert internal error`,
потому что сертификат для него Caddy больше не держит.
Класть такой блок в git нельзя: у него единственный рубеж аутентификации —
секрет в пути URL (апстрим-MCP аутентификации не имеет, а claude.ai
connector кастомные заголовки не шлёт).
Развязка: в репозитории только `import caddy/local/*.caddy` и сам каталог
(держится .gitignore'ом), файлы с секретами лежат на VPS untracked —
reset --hard их не трогает. Пустой glob для Caddy не ошибка,
`caddy validate` проходит, на машинах без локальных блоков import — no-op.
Проверено `caddy validate` 2.11.4 на VPS: и с пустым каталогом, и с
реальным блоком garmin — Valid configuration.
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.
## Caddy: открытый редирект и потерянные ссылки
Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.
Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).
Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).
`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.
## Гейты, которых не было
`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.
Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.
## Бюджет внешнего геокодера
Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.
Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.
## «Адрес нигде не сохраняется» — теперь правда целиком
Две утечки, обе воспроизведены:
1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
вызов, прод пишет stdout в persistent journald — адрес ложился на диск
рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
время публичного запроса (contextvar, переживает await и to_thread).
Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
Закрыто `scrub_public_address`.
Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.
## Фронт
- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
не наступала вовсе, если человек переставал печатать: ответ по старой строке
долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.
## Проверено
Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.
Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
Проверка на живом Caddy (локальный стенд: реальный site-блок + заглушка вместо
фронта) показала, что 301 не работал вовсе: длинные адреса отдавали пустой
200. Причина — разбор Caddyfile: первый аргумент директивы, начинающийся со
слэша, читается как inline path-matcher. То есть `redir / permanent` означает
«для пути / редиректить на permanent», а не «редиректить на /»; матчер не
совпадал, директива молча не срабатывала, наружу уходило пустое тело.
Пустая страница с кодом 200 хуже 404: выглядит рабочей и не ловится ни
смоуком по коду ответа, ни глазом.
Замер после правки (`redir * <куда>`):
/trade-in/mera-public 301 → /
/trade-in/mera-public/ 301 → /
/trade-in/mera-public/oferta 301 → /oferta
/trade-in/mera-public/v3 301 → /v3
/ 200 upstream /trade-in/mera-public
/estimate 200 upstream /trade-in/mera-public/estimate
/trade-in/api/public/mera/coverage 200 upstream /api/public/mera/coverage
/trade-in/api/v1/me 404
/trade-in/v2 404
Заодно захват сужен до `([^/].*)` — страховка от протокол-относительной цели
(`//evil.example` резолвится браузером как чужой хост). Тем же замером
проверено, что сегодня это недостижимо и без ограничения: Caddy нормализует
путь до матчинга, и `//evil.example` / `/%2Fevil.example` приезжают одним
слэшем. Ограничение оставлено как второй барьер, комментарий говорит ровно
это — а не то, что чинилась живая дыра.
## Экран проверки — /estimate
Проверка квартиры вынесена на собственный адрес: там у автокомплита есть
место под список подсказок, а у результата — место рядом с полями. Форма в
герое лэндинга осталась входной точкой и уводит сюда, донося набранное через
sessionStorage (НЕ через query — адрес в URL попал бы в access-лог Caddy рядом
с IP посетителя, а мы на той же странице обещаем ничего не хранить).
Показывает живую пробу покрытия: сколько похожих квартир продаётся рядом и
сколько в среднем висят их объявления. Ни одной рублёвой цифры — цену продаёт
платный шаг. Тексты вердикта вынесены чистой функцией (coverage-copy.ts) и
покрыты тестами: подпись под возрастом обязана говорить «объявление», а не
«продаётся» (выборка цензурирована), при неизвестном возрасте плитки нет
вообще, а пустая когорта объясняется как факт о рынке с подсказкой, что
поменять, — директива «никогда не блокировать вывод».
## Короткие адреса
Человек больше не видит /trade-in/mera-public/... — только /, /estimate,
/oferta, /refund, /privacy. Длинные адреса отдают 301 на короткие: у страницы
один канонический адрес, старые ссылки живы.
Цена решения: ссылки работают только на meraocenka.ru (короткие пути раздаёт
этот хост). Открывать лэндинг для проверки нужно там же, а не с gendsgn.ru.
Ссылки эмитятся обычным <a> (PublicLink) — next/link подставляет basePath, и
href="/estimate" уехал бы на несуществующий /trade-in/estimate.
## Три дефекта, найденных на живом сайте
1. Палитра v3 никуда не доезжала. b2c-tokens.ts не импортировал НИКТО, ни одна
--b2c-* переменная не объявлялась, каскад молча пропускал такие декларации —
лэндинг отдавал 200 бесцветным. Добавлен мост b2cVars, гейтом стал тест:
каждая использованная в CSS переменная обязана быть объявлена.
2. Голый /trade-in/mera-public падал в 404 — матчер был со слэшем и звёздочкой.
Ровно туда вела «Главная» в подвале.
3. «Для бизнеса» вела на «/» — то есть на сам лэндинг. Теперь абсолютный адрес
B2B-контура. Пункты «Проверьте себя» и «Продажа под ключ» вели на якоря,
которых нет нигде: приведены к виду «Статьи» — видны, но не кликабельны.
## Периметр и приватность
Подсказки переведены на POST: access-лог публичного домена пишет URI целиком,
то есть GET с ?q= сохранял бы адрес квартиры в файл. Тело в лог не попадает.
Метод запинен тестом — это часть обещания, а не стиль.
П. 5.4 политики ПДн переписан ВМЕСТЕ с кодом: прежний текст утверждал, что
адрес не покидает браузер, и это перестало быть правдой. Новый говорит точно —
передаётся, используется однократно, в базах не сохраняется. Последнее
проверено по коду: suggest() работает без кэша, проба — один SELECT, аудит
пишет строку только при наличии username. PUBLIC_ESTIMATE_ENABLED сузился до
платного шага (бесплатная проба не хранит ничего, платный расчёт хранит).
## Проверено
vitest 47 passed (9 файлов), tsc, next lint, next build, isolation guard
40 файлов, backend 75 passed, caddy validate = Valid configuration.
Мутации структурных гейтов:
убрать --b2c-accent-text из b2cVars → падает тест палитры
убрать /estimate из @meraPages → падает тест маршрутов
добавить импорт next/link → падает тест basePath
откат → 14 passed
Caddyfile добавлен в paths-filter фронтового лэйна: его читает тест маршрутов,
и без этой строки правка одного лишь Caddyfile не запускала бы ни один гейт.
Refs #2894, #2895
Первый шаг к отдельному B2C-интерфейсу оценки на meraocenka.ru: домен получает
собственную поверхность бэкенда вместо того, чтобы тянуть куски закрытого
контура.
## Отдельный префикс, а не проброс кусков /api/v1/*
На meraocenka.ru действует allowlist-by-default. Открыть там API можно было
двумя способами: перечислить нужные v1-пути поимённо — или завести префикс, под
которым по определению не лежит ничего закрытого. Выбран второй: при первом
одна опечатка в матчере (`/trade-in/api/*` вместо точного пути) открывает
наружу весь v1 — ~20 ручек, включая PDF расчётов, фотографии и админку. Цена
ошибки, а не удобство.
Добавить сюда приватную ручку теперь нужно СПЕЦИАЛЬНО — положив файл в
app/api/public/. Случайно нельзя.
## Ноль записей в БД
Обе ручки только читают: /coverage — один SELECT, /suggest — прокси
автокомплита. Это условие, при котором публичная форма работает ДО контура
согласия 152-ФЗ (#2895: сегодня адрес физлица попадает в trade_in_estimates
раньше согласия, а пути удаления в бэкенде нет). Платный расчёт, который писать
будет, открывается только вместе с ним.
## Делегирование, а не копии
Обе ручки вызывают те же функции, что обслуживают закрытый контур
(v1.geocode.suggest_addresses, v1.trade_in.coverage_probe). Разбор #2894
показал, чем кончается вторая копия когорты: проба отвечает «данные есть» там,
где платный расчёт видит ноль. Публичный ответ переиспользует
CoverageProbeResponse — на нём уже стоит гейт «ни одного price-подобного поля».
## Бюджеты
Общего 300/60с мало: /suggest через DaData-тир — платный внешний вызов, абуз
стоит денег. Свои per-IP окна: 40/мин на подсказки (человек с debounce'ом
тратит единицы на адрес), 15/мин на пробу.
## Проверено
11 тестов, из них структурные: набор ручек под /api/public проверяется на
РАВЕНСТВО (третья, добавленная без правки теста, роняет сборку) и сверяется с
rbac._PUBLIC_PATHS в обе стороны — чтобы не осталось открытого пути-призрака.
Рядом висит закрытый маршрут-двойник: без него «аноним получает 200» одинаково
зелёный и когда исключение точечное, и когда auth-гейт снят целиком.
Мутации:
убрать пути из rbac._PUBLIC_PATHS → 7 failed / 4 passed
снять бюджет с /coverage → 2 failed / 9 passed
откат → 11 passed
Плюс 72 passed на связке rbac + coverage + version, `caddy validate` = Valid
configuration, ruff чист.
Смоук периметра дополнен парой, которую нельзя разделять: публичные ручки
отвечают 200 анонимно И /trade-in/api/v1/* на этом домене по-прежнему 404.
Зелёная только первая проверка = API открыт целиком, а тест этого не заметил.
Refs #2894, #2895
Три независимые правки инфраструктурной гигиены прод-VPS, замеренные живьём
2026-08-15 (ssh gendesign, read-only).
1. ops/docker-prune.sh: новая секция снимает зависшие (running, но брошенные)
job-контейнеры Forgejo Actions раннера — три штуки Up 4-8 недель на проде,
docker container prune их не видит (фильтрует только status=exited).
Фильтр по имени — якорь ^FORGEJO-ACTIONS-TASK- (regex, не substring),
сервисные контейнеры (forgejo/forgejo-runner*/gendesign-*/tradein-*/couchdb)
под него не подпадают + explicit-skip как страховка. Возраст — из
docker inspect .State.StartedAt, порог JOB_CONTAINER_MAX_AGE_HOURS (default
24 — CI job столько никогда не идёт). DRY_RUN уже существующий флаг,
переиспользован. Идемпотентно на пустом списке (mapfile + `|| true`).
2. ops/journald-gendesign.conf.example: SystemMaxUse=500M для journald.
Замер: journalctl --disk-usage без прав группы systemd-journal/adm
недосчитывает (показал 174M) — реальный du -sh /var/log/journal = 2.5G,
~80% всех 3.1G /var/log. journald.conf на проде сейчас без лимита вообще.
Установка — руками на сервере (systemd-конфиг вне /opt/gendesign, deploy.yml
его не синкает): инструкция в шапке файла.
3. Caddyfile: убран мёртвый блок status.gendsgn.ru (указывал на
несуществующий uptime-kuma, дёргал ACME раз в 6 часов на staging-эндпоинте).
Заодно убрана dangling forward-ссылка "описан для status.gendsgn.ru ниже" в
комментарии у meraocenka.ru. grep подтвердил: других ссылок на этот хост в
Caddyfile/compose/скриптах не осталось (docker-compose.uptime.yml и README
упоминают — вне scope, стек сам по себе не трогается). caddy validate через
prod-контейнер (stdin, без записи файлов на диск) — конфиг валиден, как и
baseline до правки.
Юридический блокер публичного B2C-запуска (G6 в mera-b2c-paid-flow-decision.md:
`LEGAL_ENTITY == null` → нет реквизитов, нет продажи) и входное требование
модерации эквайера: оферты не было вообще, а privacy-страница в собственной
шапке писала, что она НЕ утверждённая политика по ст. 18.1 152-ФЗ.
Что сделано:
- content.ts: `LEGAL_ENTITY` заполнен (ООО «ПРОЕКТ ФЛЭТ», ИНН/КПП/ОГРН, адреса,
директор, банковские реквизиты), добавлены `SUPPORT_EMAIL`,
`SERVICE_PRICE_RUB`, пути и короткие публичные URL документов. Единый
источник — подвал, оферта, возврат и ПДн рендерят эти поля, а не повторяют
строки. ОГРН сверен с открытыми данными ЕГРЮЛ (в исходном сообщении владельца
был с опечаткой …028028, верный …028281).
- Новые страницы /mera-public/oferta и /mera-public/refund — редакции владельца
от 13.08.2026 дословно, плейсхолдер `[адрес электронной почты]` заменён на
support@meraocenka.ru.
- privacy/page.tsx переписана на утверждённую редакцию с оператором и
реквизитами приказа. Сохранены оба инварианта честности: раздел про страницу
ввода адреса условен по `PUBLIC_ESTIMATE_ENABLED` (п. 5.4 — пока публичный
расчёт выключен, адрес не покидает браузер), срок хранения оплаченного отчёта
рендерится из `PAID_REPORT_RETENTION_MONTHS`, а не числом в тексте
(test_paid_retention_text_consistency.py).
- Caddyfile: короткие адреса /oferta, /refund, /privacy → rewrite на поддерево
лэндинга. Именно они напечатаны внутри документов и уйдут в заявку эквайеру.
Пути перечислены поимённо — allowlist-by-default периметра не ослаблен.
- smoke-mera-perimeter.sh: три новые проверки на короткие адреса.
Публикация оферты НЕ включает приём оплаты: платёжного контура в коде нет,
`PUBLIC_ESTIMATE_ENABLED` по-прежнему false. Оферта публикуется раньше кнопки
намеренно — без неё эквайер не примет заявку.
Проверено локально: tsc, next lint, vitest (29), isolation guard, next build
(три страницы пререндерены), pytest test_paid_retention_text_consistency (3),
caddy validate + adapt (rewrite на месте), рендер всех трёх страниц через
next start — реквизиты, почта и цена на месте.
Открывает публичный site-блок meraocenka.ru с allowlist-by-default: разрешены только корень лэндинга, его поддерево и _next/static, всё остальное 404. B2B-контур gendsgn.ru не затронут.
DNS A-записи для всех трёх доменов на 46.173.16.127 подтверждены до мержа.
Две связанные вещи, обе — по решению владельца продукта.
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".
Поиск домов (доля квартир в продаже) вынесен в отдельный продукт, отвязан от
бренда «Мера» (Мера = оценка вторички). Только 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>