feat(mera/b2c): публичный периметр на отдельном поддомене — этап 1 из 8 #2545

Open
lekss361 wants to merge 1 commit from feat/mera-b2c-perimeter into main
6 changed files with 295 additions and 0 deletions
Showing only changes of commit 2b86e94c5b - Show all commits

View file

@ -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

View file

@ -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).

View file

@ -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:-<no response>}', 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"

View file

@ -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
* никогда не видит, это внутренний Caddybackend 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 (
<div
className={`${manrope.variable} ${plexMono.variable}`}
style={{
minHeight: "100vh",
background: pageBg,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{children}
</div>
);
}

View file

@ -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 (
<main
style={{
fontFamily: tokens.font.sans,
color: tokens.ink,
textAlign: "center",
padding: "48px 24px",
maxWidth: 560,
}}
>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 13,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: tokens.muted,
marginBottom: 16,
}}
>
МЕРА
</div>
<h1
style={{
fontSize: 28,
fontWeight: 600,
marginBottom: 12,
color: tokens.ink2,
}}
>
Оценка квартиры на вторичном рынке
</h1>
<p style={{ fontSize: 16, color: tokens.body, lineHeight: 1.5 }}>
Сервис скоро откроется для всех. Мы готовим публичный запуск загляните
позже.
</p>
</main>
);
}

View file

@ -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) {