From 2b86e94c5b7ea11e4821685c8c99a4c38681c84f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 28 Jul 2026 15:01:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(mera):=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20B2C-=D0=BF=D0=B5=D1=80=D0=B8=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=20=D0=9C=D0=95=D0=A0=D0=90=20(=D0=AD=D0=A2=D0=90=D0=9F=201,=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отдельный поддомен 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). --- .forgejo/workflows/perimeter-smoke.yml | 38 ++++++++++ Caddyfile | 62 ++++++++++++++++ scripts/smoke-mera-perimeter.sh | 63 +++++++++++++++++ .../frontend/src/app/mera-public/layout.tsx | 70 +++++++++++++++++++ .../frontend/src/app/mera-public/page.tsx | 48 +++++++++++++ .../src/components/auth/RouteGuard.tsx | 14 ++++ 6 files changed, 295 insertions(+) create mode 100644 .forgejo/workflows/perimeter-smoke.yml create mode 100644 scripts/smoke-mera-perimeter.sh create mode 100644 tradein-mvp/frontend/src/app/mera-public/layout.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/page.tsx diff --git a/.forgejo/workflows/perimeter-smoke.yml b/.forgejo/workflows/perimeter-smoke.yml new file mode 100644 index 00000000..5f3d654e --- /dev/null +++ b/.forgejo/workflows/perimeter-smoke.yml @@ -0,0 +1,38 @@ +# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска). +# +# НЕ pre-merge гейт — эти 4 проверки требуют реального DNS + выпущенного TLS- +# сертификата для mera.gendsgn.ru, т.е. осмысленны ТОЛЬКО против прода после +# деплоя. Запускается вручную (workflow_dispatch) или раз в сутки (cron) — +# страхует от случайной регрессии периметра (например, будущий PR по ошибке +# открывает B2B-путь на публичном поддомене, или basic_auth gate на gendsgn.ru +# случайно снимают). +# +# ДО того как появится DNS A-record mera.gendsgn.ru → IP VPS, проверки 1 и 2 +# (см. scripts/smoke-mera-perimeter.sh) ожидаемо КРАСНЫЕ — это не регресс, +# просто поддомен ещё не резолвится. Проверки 3 и 4 не зависят от DNS нового +# поддомена и обязаны быть зелёными всегда. +name: perimeter-smoke-mera + +on: + workflow_dispatch: {} + schedule: + # Раз в сутки, 06:17 UTC — вне пиков, время произвольное. + - cron: '17 6 * * *' + +concurrency: + group: perimeter-smoke-mera + cancel-in-progress: false + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Run perimeter smoke checks + run: | + chmod +x scripts/smoke-mera-perimeter.sh + ./scripts/smoke-mera-perimeter.sh diff --git a/Caddyfile b/Caddyfile index 7e7ce21a..a2e2d816 100644 --- a/Caddyfile +++ b/Caddyfile @@ -135,6 +135,68 @@ www.gendsgn.ru { redir https://gendsgn.ru{uri} permanent } +# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала). +# +# Архитектурное решение: отдельный поддомен, а НЕ дырка в блоке gendsgn.ru +# выше. На gendsgn.ru модель "запрещено всё, кроме дырок ВЫШЕ auth-import" — +# порядко-зависимая и общая для B2B (trade-in v2, admin, scrapers, /api/*). +# Здесь, наоборот, allowlist-by-default: basic_auth НЕТ ВООБЩЕ (не импортируем +# caddy/users.caddy.snippet), потому что на этом site-блоке B2B-маршрутов +# физически не объявлено — их нечего "открывать". Явно перечислены РОВНО два +# handle (корень "/" + статика Next _next/*), всё остальное — финальный +# catch-all `handle { respond 404 }`. Регресс-тест на эту модель: +# scripts/smoke-mera-perimeter.sh (проверяет, что B2B-путь здесь = 404, а не +# 200/401 — т.е. не был случайно проброшен). +# +# Next.js basePath=/trade-in запечён в prod-образ tradein-frontend (тот же +# контейнер, что обслуживает и gendsgn.ru/trade-in/*, см. build-args в +# .forgejo/workflows/deploy-tradein.yml) — поэтому корень поддомена rewrite'ится +# на internal-путь /trade-in/mera-public (страница-заглушка, +# tradein-mvp/frontend/src/app/mera-public/). Пользователь префикс /trade-in +# никогда не видит — rewrite меняет путь ТОЛЬКО для Caddy→backend запроса, +# это не HTTP-редирект браузера. +# +# DNS: A-record mera.gendsgn.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт +# реальный трафик. Если записи ещё нет на момент деплоя этого блока: `caddy +# reload`/`up -d --force-recreate caddy` в deploy.yml НЕ падает (конфиг +# синтаксически валиден, ошибка сертификата асинхронна и per-hostname) — Caddy +# просто залогирует неудачную попытку ACME-выпуска для mera.gendsgn.ru (DNS не +# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит +# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же +# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются — +# автоматический HTTPS в Caddy изолирован per-hostname (тот же принцип, что +# уже описан для status.gendsgn.ru ниже). Повторные неудачные попытки ДО +# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed +# validations/hostname/hour) — не критично, просто подождать; `docker volume +# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval). +mera.gendsgn.ru { + encode zstd gzip + + log { + output file /var/log/caddy/mera.gendsgn.ru.log + } + + # Единственная публичная страница этого этапа — заглушка "скоро". + # rewrite добавляет basePath-префикс только для Caddy→backend хопа. + handle / { + rewrite * /trade-in/mera-public + reverse_proxy tradein-frontend:3000 + } + + # Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же + # basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS + # чанки), сами по себе не содержат ни B2B-данных, ни секретов. + handle /trade-in/_next/* { + reverse_proxy tradein-frontend:3000 + } + + # Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin, + # /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется. + handle { + respond 404 + } +} + # Obsidian Self-hosted LiveSync (CouchDB backend). # Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap # (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user). diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh new file mode 100644 index 00000000..121de21e --- /dev/null +++ b/scripts/smoke-mera-perimeter.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска). +# +# Проверяет 4 инварианта периметра (см. корневой Caddyfile): +# 1. mera.gendsgn.ru отдаёт 200 анонимно (публичная заглушка). +# 2. mera.gendsgn.ru/v2 (B2B-путь) отдаёт 404 — allowlist-by-default, +# НЕ был случайно проброшен на B2B-дерево tradein-frontend. +# 3. gendsgn.ru/trade-in/v2 по-прежнему отдаёт 401 анониму (basic_auth gate +# не тронут этим этапом). +# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (тот же gate). +# +# ВАЖНО: проверки 1 и 2 требуют, чтобы DNS A-record mera.gendsgn.ru → IP VPS +# уже существовал И деплой прошёл (сертификат Let's Encrypt выпущен). Пока +# записи нет — они ожидаемо падают (DNS resolution failure / TLS handshake +# failure), это НЕ регресс периметра gendsgn.ru. Проверки 3 и 4 не зависят от +# DNS нового поддомена и обязаны быть зелёными всегда. +# +# Запуск вручную: +# bash scripts/smoke-mera-perimeter.sh +# Запуск в CI: .forgejo/workflows/perimeter-smoke.yml (workflow_dispatch + daily cron). +set -uo pipefail + +BASE_MERA="${SMOKE_MERA_BASE:-https://mera.gendsgn.ru}" +BASE_MAIN="${SMOKE_MAIN_BASE:-https://gendsgn.ru}" + +fail=0 + +check() { + local desc="$1" url="$2" expected="$3" + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$url" 2>/dev/null) + if [ "$code" = "$expected" ]; then + echo "PASS: $desc ($url -> $code)" + else + echo "FAIL: $desc ($url -> got '${code:-}', expected $expected)" + fail=1 + fi +} + +echo "== МЕРА B2C perimeter smoke (ЭТАП 1) ==" + +# 1. Публичный поддомен отдаёт 200 анонимно. +check "mera.gendsgn.ru root — public 200" "$BASE_MERA/" 200 + +# 2. B2B-путь на публичном поддомене — 404 (allowlist-by-default), не 200/401. +check "mera.gendsgn.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404 + +# 3. gendsgn.ru/trade-in/v2 по-прежнему закрыт basic_auth анониму. +check "gendsgn.ru/trade-in/v2 — still 401 anonymous" "$BASE_MAIN/trade-in/v2" 401 + +# 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга +# в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается +# на уровне Caddy до бэкенда). +check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/users" 401 + +echo "========================================" +if [ "$fail" -eq 0 ]; then + echo "ALL CHECKS PASSED" +else + echo "SOME CHECKS FAILED — see FAIL lines above" +fi + +exit "$fail" diff --git a/tradein-mvp/frontend/src/app/mera-public/layout.tsx b/tradein-mvp/frontend/src/app/mera-public/layout.tsx new file mode 100644 index 00000000..c7665050 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/layout.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from "next"; +import { IBM_Plex_Mono, Manrope } from "next/font/google"; + +import { pageBg } from "@/components/trade-in/v2/tokens"; + +/** + * ЭТАП 1 плана B2C-запуска МЕРА: публичный периметр БЕЗ функционала. + * + * Обслуживается ОТДЕЛЬНЫМ поддоменом mera.gendsgn.ru (см. корневой Caddyfile, + * site-блок `mera.gendsgn.ru { ... }`) — не напрямую по basePath. Caddy на + * этом поддомене rewrite'ит запрос корня "/" в "/trade-in/mera-public" (тот + * же образ tradein-frontend, что обслуживает и gendsgn.ru/trade-in/*, у него + * запечён NEXT_PUBLIC_BASE_PATH=/trade-in) — пользователь префикс /trade-in + * никогда не видит, это внутренний Caddy→backend hop. + * + * Изоляция от B2B-дерева (намеренная, см. задачу ЭТАП 1): + * - НЕ импортирует SupportChatProvider (тот живёт только в app/v2/layout.tsx — + * поддержка нужна оценщикам с доступом, не анонимным посетителям заглушки). + * - RouteGuard (components/auth/RouteGuard.tsx) содержит явный безусловный + * bypass именно для пути "/mera-public" — страница рендерится сразу, без + * ожидания /api/v1/me и без экрана "нет доступа" (в отличие от /ui-preview, + * чей bypass живёт только под dev/CI-флагом ENABLE_PREVIEW, этот — always-on, + * т.к. страница публична по продуктовому решению, а не временный QA-артефакт). + * - page.tsx — server component без хуков (useMe/useQuota/useHistory не + * импортируются вообще); функционала на этом этапе нет. + * + * robots: noindex — прецедент app/ui-preview/layout.tsx. На ЭТАПЕ 1 страница + * ещё не должна попадать в поисковую индексацию. + */ +const manrope = Manrope({ + subsets: ["latin", "cyrillic"], + weight: ["400", "500", "600", "700"], + variable: "--font-manrope", + display: "swap", +}); + +const plexMono = IBM_Plex_Mono({ + subsets: ["latin", "cyrillic"], + weight: ["400", "500"], + variable: "--font-plex-mono", + display: "swap", +}); + +export const metadata: Metadata = { + title: "МЕРА — оценка вторичного жилья", + description: + "Онлайн-оценка стоимости квартиры на вторичном рынке. Сервис скоро откроется.", + robots: { index: false, follow: false }, +}; + +export default function MeraPublicLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/page.tsx b/tradein-mvp/frontend/src/app/mera-public/page.tsx new file mode 100644 index 00000000..12a736bd --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/page.tsx @@ -0,0 +1,48 @@ +import { tokens } from "@/components/trade-in/v2/tokens"; + +/** + * ЭТАП 1 B2C-плана — публичная заглушка, БЕЗ функционала (никаких форм, + * запросов к API, авторизации). Server component намеренно: нет "use client", + * нет хуков — useMe/useQuota/useHistory здесь не используются вообще. + * Изоляция от RouteGuard/B2B-дерева — см. комментарий в layout.tsx. + */ +export default function MeraPublicPage() { + return ( +
+
+ МЕРА +
+

+ Оценка квартиры на вторичном рынке +

+

+ Сервис скоро откроется для всех. Мы готовим публичный запуск — загляните + позже. +

+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx index c99f0cdb..ae467c22 100644 --- a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx +++ b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx @@ -22,6 +22,11 @@ import { useMe } from "@/lib/useMe"; const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; // #801: dev/CI-only preview-маршрут (a11y/lighthouse) рендерится оффлайн без RBAC. const ENABLE_PREVIEW = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1"; +// ЭТАП 1 B2C-плана МЕРА: публичный периметр (mera.gendsgn.ru → rewrite на +// /trade-in/mera-public, см. Caddyfile). В отличие от ENABLE_PREVIEW выше — +// ЭТОТ bypass ВСЕГДА включён (в т.ч. в проде): страница публична по +// продуктовому решению, а не временный QA-артефакт. +const PUBLIC_PATHS = ["/mera-public"]; interface RouteGuardProps { children: React.ReactNode; @@ -42,6 +47,15 @@ export function RouteGuard({ children }: RouteGuardProps) { return <>{children}; } + // ЭТАП 1 B2C: публичная заглушка МЕРА не ждёт /api/v1/me и не проверяет + // RBAC — рендерится сразу для анонимного посетителя. useMe() выше уже + // вызван (Rules of Hooks — нельзя условно), но его результат здесь + // игнорируется: фоновый запрос к /me (скорее всего 401 без сессии) ни на + // что не влияет, страница не зависит от него — как и /ui-preview выше. + if (PUBLIC_PATHS.some((p) => rawPath === p || rawPath.startsWith(`${p}/`))) { + return <>{children}; + } + if (isLoading) return null; if (error instanceof HTTPError && error.status === 401) {