chore(mera-public): isolation guard + favicon + Caddy handle (#2631)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
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 1m15s
CI Trade-In / backend-tests (pull_request) Successful in 2m51s

Аудит заявленной утечки /api/v1/me: в текущем исходнике не воспроизводится —
next build + standalone-сервер + разбор app-build-manifest показали, что
chunk-split #2545 (GuardedRoute за next/dynamic) чист, ни один чанк
публичного роута не содержит /api/v1/me. Live-проверка meraocenka.ru
подтверждает: во всех 12 чанках лэндинга строки нет. Репорт issue почти
наверняка ловил stale-образ (rapid-merge trap, .claude/rules/tradein.md).

Чтобы инвариант не отрегрессил молча — source-level guard
(scripts/check-mera-public-isolation.mjs, BFS по статическим import'ам от
корневого layout + mera-public; dynamic import не следуем — семантика
webpack-чанков; проверен инъекцией нарушения) + шаг в CI frontend-checks.

favicon.ico (мульти-размер, брендовые токены) + Caddy handle /favicon.ico
в периметре meraocenka.ru (allowlist-404 резал его до Next).

Refs #2631
This commit is contained in:
bot-backend 2026-08-05 11:09:30 +05:00
parent 7d13e93792
commit deff85d8b6
5 changed files with 176 additions and 1 deletions

View file

@ -140,3 +140,9 @@ jobs:
- name: Lint (next lint)
# Blocking: любая ESLint-ошибка → job RED.
run: npm run lint
- name: Mera-public isolation guard (#2631)
# Blocking: статический import-graph публичного лэндинга не должен
# достигать закрытого контура (useMe/lib/api/sessionId/isPathAllowed/
# GuardedRoute вне next/dynamic). Инвариант этапа 1 #2545.
run: npm run check:mera-public-isolation

View file

@ -282,6 +282,16 @@ meraocenka.ru {
}
}
# #2631: favicon — единственный корневой статик, который браузер запрашивает
# сам; без явного handle падал в allowlist-404. app/favicon.ico отдаёт Next
# по корневому пути через basePath /trade-in.
handle /favicon.ico {
rewrite * /trade-in/favicon.ico
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
}
# Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin,
# /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется.
handle {

View file

@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.50.0",

View file

@ -0,0 +1,158 @@
#!/usr/bin/env node
/**
* Guard for issue #2631 / #2545: публичный B2C-лэндинг `/mera-public` должен
* оставаться "пустым по зависимостям" ни один файл, который Next.js грузит
* СТАТИЧЕСКИ (initial JS для этого route-чанка), не должен транзитивно
* дотягиваться до закрытого контура (useMe / lib/api / lib/sessionId /
* isPathAllowed / RBAC). Никакого test runner (jest/vitest) в проекте нет
* (см. package.json) это source-level guard вместо test-suite.
*
* НЕ подключён в `.forgejo/workflows/ci-tradein.yml` этим PR (вне scope
* только `tradein-mvp/frontend/**`). Чтобы он реально гейтил PR, нужен один
* доп. `run: npm run check:mera-public-isolation` шаг в job `frontend-checks`
* рядом с `npm run lint` отдельным devops/frontend PR.
*
* Как это соотносится с реальным webpack-чанком: `RouteGuard.tsx` намеренно
* подключает `GuardedRoute` через `next/dynamic(() => import(...))` то есть
* точку разрыва графа модулей (см. шапки RouteGuard.tsx / GuardedRoute.tsx).
* Этот скрипт следует ТОЛЬКО статическим edges (`import ... from "..."` /
* `export ... from "..."` / side-effect `import "..."`) и НИКОГДА не идёт
* внутрь динамического `import(...)` ровно то же правило, по которому
* webpack решает, что попадёт в один чанк, а что уедет в отдельный.
*
* Запуск: `node scripts/check-mera-public-isolation.mjs` (npm run
* check:mera-public-isolation). Exit 1 + печать цепочки импортов при находке.
*/
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SRC = path.resolve(__dirname, "..", "src");
const RESOLVABLE_EXTS = [".ts", ".tsx", ".js", ".jsx"];
const STATIC_IMPORT_RE = /(?:^|\s)(?:import|export)\s[^;]*?\sfrom\s*["']([^"']+)["']|(?:^|\s)import\s*["']([^"']+)["']/g;
function collectFilesRecursively(dir) {
if (!existsSync(dir)) return [];
const out = [];
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
out.push(...collectFilesRecursively(full));
} else if (RESOLVABLE_EXTS.includes(path.extname(full))) {
out.push(full);
}
}
return out;
}
// Всё, что Next.js грузит для /mera-public НЕЗАВИСИМО от dynamic()-разрывов:
// корневой layout (Providers + RouteGuard) + всё поддерево лэндинга.
const ENTRY_FILES = [
path.join(SRC, "app/layout.tsx"),
path.join(SRC, "app/providers.tsx"),
path.join(SRC, "components/auth/RouteGuard.tsx"),
...collectFilesRecursively(path.join(SRC, "app/mera-public")),
];
// Закрытый контур — см. GuardedRoute.tsx шапку. GuardedRoute.tsx сам тоже
// forbidden: он обязан доезжать ТОЛЬКО через next/dynamic, никогда статически.
const FORBIDDEN = [
"lib/useMe.ts",
"lib/api.ts",
"lib/sessionId.ts",
"lib/isPathAllowed.ts",
"components/auth/GuardedRoute.tsx",
].map((p) => path.join(SRC, p));
function resolveSpecifier(spec, fromFile) {
let base;
if (spec.startsWith("@/")) {
base = path.join(SRC, spec.slice(2));
} else if (spec.startsWith(".")) {
base = path.resolve(path.dirname(fromFile), spec);
} else {
return null; // bare specifier — внешний пакет / next/*, не наш граф
}
if (existsSync(base) && statSync(base).isFile()) return base;
for (const ext of RESOLVABLE_EXTS) {
if (existsSync(base + ext)) return base + ext;
}
for (const ext of RESOLVABLE_EXTS) {
const indexed = path.join(base, "index" + ext);
if (existsSync(indexed)) return indexed;
}
return null; // .css / .module.css / картинки / не найдено — лист, не ошибка
}
function staticImportsOf(file) {
const src = readFileSync(file, "utf8");
const specs = [];
let m;
STATIC_IMPORT_RE.lastIndex = 0;
while ((m = STATIC_IMPORT_RE.exec(src)) !== null) {
specs.push(m[1] ?? m[2]);
}
return specs;
}
// BFS от всех entry-файлов по СТАТИЧЕСКИМ рёбрам, с parent-map для отчёта.
const parent = new Map();
const visited = new Set();
const queue = [];
for (const entry of ENTRY_FILES) {
if (!visited.has(entry)) {
visited.add(entry);
queue.push(entry);
}
}
let violation = null;
while (queue.length > 0 && !violation) {
const file = queue.shift();
for (const forbidden of FORBIDDEN) {
if (file === forbidden) {
violation = file;
break;
}
}
if (violation) break;
for (const spec of staticImportsOf(file)) {
const resolved = resolveSpecifier(spec, file);
if (!resolved || visited.has(resolved)) continue;
visited.add(resolved);
parent.set(resolved, file);
queue.push(resolved);
}
}
function chainTo(file) {
const chain = [file];
let cur = file;
while (parent.has(cur)) {
cur = parent.get(cur);
chain.unshift(cur);
}
return chain.map((f) => path.relative(SRC, f)).join("\n -> ");
}
if (violation) {
console.error(
"FAIL: /mera-public публичный бандл статически дотягивается до закрытого контура.\n" +
"Цепочка импортов:\n " +
chainTo(violation) +
"\n\nЕсли модуль реально нужен закрытому RBAC-коду — подключай его через " +
"next/dynamic(() => import(...)), как GuardedRoute в RouteGuard.tsx, а не " +
"статическим import.",
);
process.exit(1);
}
console.log(
`OK: mera-public isolation — ${ENTRY_FILES.length} файлов проверено, закрытый контур не найден.`,
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB