feat(mera): открыть публичный B2C-периметр МЕРА (ЭТАП 1, без функционала)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m17s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m17s
Отдельный поддомен 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).
This commit is contained in:
parent
5f3579b8c2
commit
2b86e94c5b
6 changed files with 295 additions and 0 deletions
38
.forgejo/workflows/perimeter-smoke.yml
Normal file
38
.forgejo/workflows/perimeter-smoke.yml
Normal 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
|
||||||
62
Caddyfile
62
Caddyfile
|
|
@ -135,6 +135,68 @@ www.gendsgn.ru {
|
||||||
redir https://gendsgn.ru{uri} permanent
|
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).
|
# Obsidian Self-hosted LiveSync (CouchDB backend).
|
||||||
# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap
|
# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap
|
||||||
# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user).
|
# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user).
|
||||||
|
|
|
||||||
63
scripts/smoke-mera-perimeter.sh
Normal file
63
scripts/smoke-mera-perimeter.sh
Normal 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"
|
||||||
70
tradein-mvp/frontend/src/app/mera-public/layout.tsx
Normal file
70
tradein-mvp/frontend/src/app/mera-public/layout.tsx
Normal 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
|
||||||
|
* никогда не видит, это внутренний 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 (
|
||||||
|
<div
|
||||||
|
className={`${manrope.variable} ${plexMono.variable}`}
|
||||||
|
style={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: pageBg,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
tradein-mvp/frontend/src/app/mera-public/page.tsx
Normal file
48
tradein-mvp/frontend/src/app/mera-public/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,11 @@ import { useMe } from "@/lib/useMe";
|
||||||
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||||
// #801: dev/CI-only preview-маршрут (a11y/lighthouse) рендерится оффлайн без RBAC.
|
// #801: dev/CI-only preview-маршрут (a11y/lighthouse) рендерится оффлайн без RBAC.
|
||||||
const ENABLE_PREVIEW = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1";
|
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 {
|
interface RouteGuardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -42,6 +47,15 @@ export function RouteGuard({ children }: RouteGuardProps) {
|
||||||
return <>{children}</>;
|
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 (isLoading) return null;
|
||||||
|
|
||||||
if (error instanceof HTTPError && error.status === 401) {
|
if (error instanceof HTTPError && error.status === 401) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue