diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 4a0bc79c..94b9d2ff 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -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 diff --git a/Caddyfile b/Caddyfile index 59402dbf..1e6a0e25 100644 --- a/Caddyfile +++ b/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 { diff --git a/tradein-mvp/frontend/package.json b/tradein-mvp/frontend/package.json index b3579017..f71653ba 100644 --- a/tradein-mvp/frontend/package.json +++ b/tradein-mvp/frontend/package.json @@ -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", diff --git a/tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs b/tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs new file mode 100644 index 00000000..f0612f25 --- /dev/null +++ b/tradein-mvp/frontend/scripts/check-mera-public-isolation.mjs @@ -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} файлов проверено, закрытый контур не найден.`, +); diff --git a/tradein-mvp/frontend/src/app/favicon.ico b/tradein-mvp/frontend/src/app/favicon.ico new file mode 100644 index 00000000..b026b8c2 Binary files /dev/null and b/tradein-mvp/frontend/src/app/favicon.ico differ