chore(mera-public): isolation guard + favicon + Caddy handle (#2631) #2644
5 changed files with 176 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
10
Caddyfile
10
Caddyfile
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
158
tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs
Normal file
158
tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs
Normal 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} файлов проверено, закрытый контур не найден.`,
|
||||
);
|
||||
BIN
tradein-mvp/frontend/src/app/favicon.ico
Normal file
BIN
tradein-mvp/frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Loading…
Add table
Reference in a new issue