- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное вшивается через go:embed и раздаётся корневым маршрутом: разметка на неизвестном пути вне корней сервиса, отказ контракта внутри корня - перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов, а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь - шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка входят в гейт, а в Dockerfile появилась ступень приложения
34 KiB
Ревью кода: spa-skeleton
Сводка
- Режим прогона: по графу, метка
large. - Обоснование метки, размер и сложность: до триажа не доехали — задание
передало только саму метку и режим. Строка деградации, а не оценка: сверить
метку с предметом мне нечем. Свой замер (не замер разметчика): изменение
рабочего дерева — 12 изменённых файлов, +270/−35 строк, плюс ~850 строк нового
кода в
internal/controller/http/webapp*.goиweb/. - Гейт: зелёный целиком (14 шагов), унаследованных отказов нет.
- Находок на входе: 26 (пункты 1–19 поимённо, 4 подпункта в 20-м, 21–23).
- Осталось в первых двух секциях: 7 (3 + 4). Понижено — 5, в promote — 4, срезано потолком — 6; срезанное поимённо названо в границах покрытия.
План с исходом по каждой теме
| Тема | Дом | Глубина | Кто закрывает | Исход |
|---|---|---|---|---|
| requirements | дельта-спеки specs/{webapp,access} |
разбор | specs | закрыта, 7 находок |
| autotests | CLAUDE.md, «Гейт» |
— | autotests | закрыта, 2 находки |
| conventions | docs/conventions/ |
разбор | code | закрыта, 4 находки |
| architecture | docs/architecture.md + passport.md |
доказательство | architecture | закрыта, 3 находки |
| security | docs/security.md |
доказательство | adversary | закрыта, 7 находок |
| operations | architecture.md «Эксплуатация» + database.md |
доказательство | ops | закрыта, 3 находки |
| темы проекта | своих нет | — | basics | дома у темы нет; проход не запускался, темы разобраны именными проходами |
Тем без отчёта нет — все шесть заявленных домов закрыты своими проходами.
Сигнал о заниженной метке: не поступал ни от одного прохода. review-code
его не подавал; review-basics не запускался и подать не мог. Это «возражений
нет» от одного из двух возможных источников, а не от обоих.
Ни один проход не объявил свой потолок и не приложил блок «Coverage of this
pass». Это находка о прогоне, а не оформление: «находок больше нет»
неотличимо от «больше не поместилось», и ровно об этом стоит запись
docs/review.md от 2026-08-13 («Ни один проход не сообщает свой потолок»).
Повторяется прогон в прогон, механизации нет.
Блокирует мердж
Панель владельца со всеми записями и файлами открывается анониму из интернета по адресу /%5f/
-
Файл:
internal/controller/http/webapp.go:17-28,100-124;docs/security.md:29-38 -
Severity: major
-
Confidence: high
-
Оракул (снят триажем на этом прогоне): проба стандартного мультиплексора,
/tmp/claude-1000/.../scratchpad/muxprobe,go run .:/_/ -> 200 HIT /_/ path="/_/" raw="" /%5f/ -> 200 HIT /_/ path="/_/" raw="/%5f/" /%6detrics -> 200 HIT /metrics path="/metrics" raw="/%6detrics" /%68ealth -> 200 HIT /health path="/health" raw="/%68ealth" /%61pi/collections -> 200 HIT /api/ path="/api/collections" /%61pp/me -> 200 HIT /app/ path="/app/me"Маршрутизатор сравнивает раскодированный путь, исходная форма остаётся в
URL.RawPath. Живой прогон враждебного прохода даёт то же:/_/и/%5f/отвечают байт в байт, весь клиент панели (633 КБ JS, 275 КБ CSS) грузится анониму. -
Последствие: правило Authelia на обратном прокси написано на литерал
/_/(docs/security.md, «Третий сдвиг»), поэтому закодированная форма проходит мимо него и попадает в панель суперпользователя — все записи, все файлы, все пользователи. Вход в само приложение при этом не обходится:/%61pp/meотвечает401, разграничение живёт в обработчике, а не в маршруте. -
Дефект унаследованный: так вело себя адресное пространство и до задачи, изменение его не вносило. В блокирующие он попал по ущербу, а не по авторству.
-
Половина пути не проверена: правило прокси лежит в
pet-project-server, в этом репозитории его нет. Проверена только сторона сервиса — отсюдаmajor, а неcritical. -
Предложение: слой, приводящий путь к канонической форме (отказ
400приRawPath != "", если раскодированный путь попадает в перечень корней), рядом с новым перечнемServiceMounts; плюс правкаdocs/security.md. Осторожно: глухая проверкаRawPath != ""сломает скачивание файлов записей с пробелами и не-латиницей в имени — они приходят закодированными законно. -
Найдено проходом: adversary; оракул перепроверен триажем.
-
Действие: развилка.
Панель
/_/доступна анониму по/%5f/мимо Authelia — дефект старше этой задачи, но именно она заводит перечень корней, куда лечение просится. Варианты:- чинить здесь: слой канонизации пути на перечне
ServiceMounts, отказ400только на закодированной форме корня, тест на/%5f/,/%6detricsи на законный закодированный путь файла (цена — день, растёт scope задачи); - чинить отдельной задачей, а в этой задаче только записать дыру в
docs/security.mdи завести задачу в беклоге (цена — час, дыра живёт до выкладки); - закрыть на стороне выкладки — правило прокси на регулярном выражении по сырому пути (цена — вне репозитория, проверить прогоном нечем).
- чинить здесь: слой канонизации пути на перечне
Образ собирается из дерева разработчика, а не из объявленных входов
- Файл:
Dockerfile:12-16,37; отсутствующий.dockerignore;.gitignore:57-61 - Severity: major
- Confidence: high
- Оракул:
ls -la .dockerignore→No such file or directory; вDockerfileступеньfront-buildделаетCOPY web/package.json web/package-lock.json ./,RUN npm ci, а следомCOPY web/ ./— то есть локальныйweb/node_modulesложится поверх результатаnpm ci; ступеньbuild-envделаетCOPY . ..Taskfile.yml:163монтирует$PWD/webв контейнер, поэтому каталог зависимостей в дереве разработчика заводится при каждомtask front, а.gitignoreпрячет его от git, но не от docker. - Последствие: артефакт, который едет на сервер, собирается из того, что лежит у
собравшего, а не из
package-lock.json, — и молча: сборка при этом зелёная. В промежуточный слойbuild-envприCOPY . .уезжают также.git(12 МБ), локальныйconfig.tomlи каталогdata/того, кто запускал сервис локально; в финальный образ они не попадают, но живут в слоях сборки. - Поправка к исходной находке: заявленное падение ступени из-за чужих
платформенных пакетов маловероятно — зависимости в дерево кладёт тот же
node:24-alpine, что и ступень образа. Замер «193 МБ» сегодня не воспроизводится:web/node_modulesпуст. Существо находки от этого не меняется — вход сборки не ограничен ничем. - Предложение:
.dockerignoreв корне сweb/node_modules/,web/embed/dist/,data/,.git/,config.toml,tmp/. - Найдено проходом: code/техника; оракул перепроверен триажем.
- Действие: инлайн.
Путь и адрес анонима уезжают в журнал хранилища, хотя записанное свойство прогона утверждает обратное
- Файл:
internal/controller/http/webapp.go:231-284;docs/review.md:75 - Severity: major
- Confidence: high
- Оракул (перепроверен триажем по исходникам библиотеки,
pocketbase@v0.39.10):apis/base.go:117-120—apis.Staticпервой строкой ставитrequestEventKeySkipSuccessActivityLog;apis/middlewares.go:349-445—activityLogger()навешен на все маршруты и приerr == nilбез этого признака пишетurl(RequestURI, до 3000 знаков),referer,userAgent, а приLogs.LogIP—userIPиremoteIP;core/settings_model.go:158-159— умолчанияMaxDays: 5,LogIP: true; в проекте настройки журнала не трогаются вовсе (grep 'Settings().Logs' → пусто). Наша раздача написана мимоapis.Staticи признак не ставит. - Последствие: каждый успешный ответ разметкой и ресурсом кладёт в таблицу
_logsвыбранный анонимом путь и его адрес, и лежит это пять суток. Строкаdocs/review.md, добавленная этой же задачей, — «путь, выбранный анонимом, не уходит ни строкой журнала, ни меткой метрики» — верна для журнала контейнера и неверна для журнала хранилища. Ложное записанное свойство хуже отсутствующего: следующий прогон возьмёт его оракулом. Частично унаследовано: отказы (err != nil) писались в_logsи раньше; новое — успешные ответы, то есть основной поток. - Предложение: привязать
apis.SkipSuccessActivityLog()к корневому маршруту вWebappHandler.Registerи поправить строку вdocs/review.md, назвав оба журнала. - Найдено проходом: architecture.
- Действие: инлайн.
Стоит исправить сейчас
docs/security.md описывает периметр уже кода: новая анонимная поверхность в нём не названа
- Файл:
docs/security.md(не тронут изменением);internal/controller/http/webapp.go:236-284 - Severity: major
- Confidence: high
- Оракул:
git status—docs/security.mdв изменениях рабочего дерева отсутствует, тогда как раздача заведена; раздел «Периметр» перечисляет входы без корневого маршрута, а таблица «Что разграничивает доступ» о раздаче не знает. - Последствие: анонимно открытым стал весь путь вне шести корней плюс всё содержимое сборки, и документ этого не говорит. Следующие прогоны читают периметр отсюда и не станут искать пути через раздачу вовсе — блиндаж, который накапливается.
- Предложение: дописать в «Периметр» корневой маршрут (что отдаётся анониму, что — нет) и строку в «Что разграничивает доступ» про раздачу.
- Найдено проходом: adversary.
- Действие: инлайн.
Расхождение раскладки сборки с ожиданиями кода не ловится ничем: гейт зелёный, приложение отвечает 503 либо теряет правила 404 и срока хранения
- Файл:
web/embed.go:22-44;web/vite.config.ts:8-13;internal/controller/http/webapp.go:36,262-275,291-301 - Severity: minor
- Confidence: high
- Оракул:
grep— ни один тест не зовётweb.Dist(), все проверки раздачи получаютfstest.MapFS;outDir: 'embed/dist'в сборщике иdistDir = "embed/dist"в Go связаны только совпадением строк;build.assetsDirвvite.config.tsне задан вовсе, а Go держитconst assetsDir = "assets"— сегодня они совпадают по умолчанию сборщика. - Последствие: одна причина, два исхода. Правка
outDirдаётbuilt == falseи503на каждом пути показа при зелёном гейте; смена умолчанияassetsDirсборщиком молча выключает и правило404под каталогом ресурсов, и годовой срок хранения — тесты остаются зелёными, потому что подставная сборка называет каталог сама. - Предложение:
web/embed_test.goна оба состоянияDist()(собрано / пусто), проверяющий именно вшитое дерево — наличиеindex.htmlи каталога ресурсов; плюсbuild.assetsDir: 'assets'явной строкой вvite.config.ts. - Найдено проходами: autotests (п. 1), specs (пп. 3, 4), architecture (п. 14) — одна причина в четырёх формулировках; оракула у согласия нет, приоритет подняло само совпадение.
- Действие: инлайн.
Каталог ресурсов сборщика сам по себе отвечает разметкой с кодом 200
- Файл:
internal/controller/http/webapp.go:262-277 - Severity: minor
- Confidence: high
- Оракул: чтение кода воспроизводит путь целиком —
fs.Stat("assets")даёт каталог, ветка ресурса пропускается по!info.IsDir(), аstrings.HasPrefix("assets", "assets/")— ложь, поэтому запрос уходит вserveIndex. Враждебный проход подтвердил падающим тестом и живым прогоном:/assetsи/assets/→200с разметкой. - Последствие: нарушено записанное свойство узла
(
docs/review.md:66-67, «несовпавший ресурс под каталогом сборщика отвечает404, а не разметкой с кодом200»). Ровно от этой ошибки — префикс без точного совпадения — в соседних строках защищаетсяMount.Covers, и здесь осталось одно условие из двух. - Предложение:
name == assetsDir || strings.HasPrefix(name, assetsDir+"/")в обеих ветках — и в отказе, и вsetCacheHeader. - Найдено проходом: adversary.
- Действие: инлайн.
Три решения раздачи не имеют дома: следующий автор снимет их, не нарушив ни одного требования
- Файл:
internal/controller/http/webapp.go:156-163,201-224;main.go:347-362;openspec/changes/spa-skeleton/specs/webapp/spec.md;docs/architecture.md;openspec/changes/spa-skeleton/tasks.md(задачи 2.8, 5.2) - Severity: minor
- Confidence: high
- Оракул: правило «путь в журнал не идёт» живёт в комментарии и тесте, но не в
дельта-спеке; у отпечатка вшитой сборки (
buildFingerprint) нет ни требования, ни теста, а задача 2.8 отмечена выполненной; capabilitywebappне объявлена в перечнеdocs/architecture.md, задача 5.2 не отмечена; сценарий «Отсутствие сборки видно в журнале» оракула не имеет — буфер журнала вTestWebappNotBuiltзаведён, но не читается. - Последствие: решение без дома снимается следующей задачей бесплатно и молча. Ближайший пример уже виден: вернуть путь анонима в журнал можно, не нарушив ни одного записанного требования, — и это ровно то свойство, которое блокирующая находка выше показала уже нарушенным. Capability без объявления даёт второе описание раздачи у следующего автора.
- Предложение: дописать в дельту
webappтребования «путь в журнал не идёт» и «отпечаток вшитой сборки в журнале подъёма»; строку capabilitywebappвdocs/architecture.md; дочитать буфер журнала вTestWebappNotBuilt; отметить задачи 2.8 и 5.2. - Найдено проходами: specs (пп. 5, 6, 7, 9).
- Действие: инлайн.
Гипотезы без доказательства
- Человек, ошибшийся адресом, видит пустой экран с кодом
200(web/src/router.ts:9-12). Поведение подтверждено чтением — в таблице один маршрут/, catch-all нет, — а дефектность не подтверждена: требования на этот счёт нет ни в дельте, ни вweb-ui.md. Решает владелец: маршрут «такого экрана нет» либо запись границей. Найдено: specs (п. 8). - Разметка уходит без
Content-Security-Policy, тогда как панель на том же порту его получает. Пути нет: ближайший вход в разметку — ответ языковой модели из ещё не сделаннойllm-insights-adapter. Понижено доminor, переведено в promote. Найдено: adversary (п. 20). - Состав вшитого никем не судится — что именно уехало в бинарник, не проверяет ни тест, ни шаг. Ущерб не построен, оракула нет. Найдено: adversary (п. 20).
- У корневого маршрута нет потолка частоты (ограничитель заведён только на
/app/). Замера нагрузки нет, ущерб не построен; проект работает на единицах записей в день (docs/review.md, «Недоступно проверке»). Найдено: ops (п. 23). POST /api/collections/users/request-verificationотвечает204анониму. Предмет старше изменения, пути к ущербу проход не построил. Найдено: adversary (п. 20).
Promote candidates
- Зависимости приложения не сканируются на уязвимости (
Taskfile.yml, шагfront;web/package.json).govulncheckсмотрит только Go, ручнойnpm auditсегодня чист. Это не находка о коде, а пробел в правиле:CLAUDE.md, «Чего в гейте намеренно нет», перечисляет исключения поимённо, а новая экосистема не попала ни в проверки, ни в этот перечень. Кандидат: шагnpm auditв гейт (словарь кодов общий) либо строка исключения с причиной. Решение владельца, не оркестратора. Найдено: autotests (п. 2). - Копия перечня корней в тесте расходится с перечнем молча
(
journal_route_test.go:15-24противwebapp.go:76-88). Тест стережёт инвариантcriticalпро имя файла и сверяется с рукописной копией: новый корень в копию не попадёт, тест останется зелёным. Кандидат: правило вinternal/archrules— тем же способом, каким проект уже стережёт перечень колонок и дескриптор рубежа. Найдено: architecture (п. 16). - Заголовки безопасности разметки — где объявляется
Content-Security-Policyи кто за него отвечает; сегодня дома у правила нет (docs/conventions/web-ui.mdо заголовках не говорит). - Поле
ExactвMountнесёт два смысла — «точный адрес» и «адрес наблюдения». Сегодня они совпадают, завтра разойдутся. Кандидат в конвенцию описания адресного пространства, а не правка этого кода. Найдено: architecture (п. 16).
Границы покрытия
План: темы, дома, глубины
Воспроизведён таблицей в сводке выше. Тема «темы проекта» дома не имеет —
своих тем у проекта не заведено, поэтому review-basics не запускался, и это
не пропуск, а отсутствие предмета.
Проходы
- Запускались на метке
large, режим «по графу»:specs,autotests,code,architecture,adversary,ops. Отчёт пришёл от каждого. - Не запускался:
review-basics— все темы разобраны именными проходами. - Что каждый проход не мог проверить в принципе — назвать нечем: ни один
проход не приложил блок «Coverage of this pass» и ни один не объявил свой
потолок. Сколько находок осталось за срезом у
specs(7 показанных) и уadversary(7 показанных) — неизвестно. Это находка о прогоне, повторяющая записьdocs/review.mdот 2026-08-13. - Потолок триажа сработал. Срезано шесть проверенных находок, поимённо:
Taskfile.yml, шагfront: рассинхронизованныйpackage-lock.jsonроняет гейт кодом 3 после удавшегосяnpm ping, то есть при заведомо живой сети, — словарьCLAUDE.md(«Гейт») велит здесь код 1, дрейф. Правка однострочная (code, п. 11).main.go:212-219,webapp.go:159-166: два новых поля журнала заведены мимо словаряdocs/conventions/logging.md, доменwebapp.*не объявлен (code, п. 12).webapp.go:24противauth.go:96-105: корень/authобъявлен константойAuthRootи оставлен литералом в трёх регистрациях. ПравкаAuthRootдаст разметку на/auth/callback— возврат от провайдера сломается без единой ошибки (code, п. 13).webapp.go:280-301: разметка сno-cacheникогда не подтверждается304—embed.FSотдаёт нулевойModTime,ETagне выставляется, каждая ревалидация тянет полное тело. ГотовыйbuildFingerprintподETagуже посчитан (ops, п. 22).webapp.outcome=failureдоезжает сhttp.status_code=0— предмет уже заведён задачейresponse-code-in-journal(adversary, п. 20).- Задача 3.6 (
tasks.md) закрыть на этом окружении нечем — см. ниже.
- Задача 3.6 остаётся человеку.
docker build --target front-buildвиснет наnpm ciи в обычной сети, и с--network=host: из контейнера нет исходящей сети вовсе при рабочем DNS. Замерено то, что измеримо: вшитое приложение добавляет к бинарнику 86 072 байта, Node в финальный слой не попадает структурно,task frontбез сети отвечает за 11,3 с кодом 3. Число размера образа снимается на машине с сетью (ops, п. 21).
Что осталось целиком на человеке
Не проверит ни один проход (docs/review.md, «Недоступно проверке»):
- поведение внешних сервисов под нагрузкой и на границах — SpeechKit и Object Storage поднять в тесте нечем;
- реальный профиль нагрузки: проект работает на единицах записей в день;
- стойкость
ffmpegк вредоносному входу; - поведение настоящей Authelia и её правило на нашего клиента — настройка выкладки вне репозитория. На этом прогоне это стоило половины оракула у первой блокирующей находки: сторона сервиса проверена, сторона прокси — нет;
- поведение браузера с куками (
SameSite, приёмSet-Cookieпри переходе с чужого сайта).
Перестали проверять сознательно (тот же раздел, отдельным списком):
- разбор вывода настоящего
ffprobe— своего теста уadapter/metaviewer/ffmpegнет (ADR-2026-08-11-stub-adapters-in-tests); - работа сервиса с настоящими внешними собеседниками: живой прогон доступен и покрывает подъём, маршруты, панель, журнал, метрики и остановку, но за настоящие SpeechKit и Object Storage не отвечает — ключи выдуманные, распознавание подменяется в коде; вход через живого провайдера OIDC тоже недоступен.
Общее, что не проверяет ни один прогон в принципе: история инцидентов; поведение под реальным потоком; поведение внешних систем в их версиях; завязка потребителей на текущее поведение; и вопрос «нужна ли эта функциональность вообще».
Каких документов и данных не хватило
- Разметка не передала размер, сложность и обоснование метки — в задании
триажу только метка и режим. Сверить
largeс предметом нечем; сигнала о заниженной метке при этом не подавал никто. - Правила обратного прокси нет в репозитории — оно живёт в
pet-project-server. Из-за этого путь к панели через/%5f/подтверждён только на стороне сервиса, и находка держитmajor, а неcritical. docs/conventions/web-ui.mdне говорит о заголовках безопасности — документ есть, предмета в нём нет; отсюдаContent-Security-Policyуехал в promote, а не в находки.openspec/changes/spa-skeleton/specs/webapp/spec.mdне покрывает три решения раздачи — дельта есть, требований на предмет в ней нет (пункт 4 второй секции).docs/review.md, «Типовые ложноположительные», прочитан и применён: четыре записи, ни одна к находкам этого прогона не подошла. Отсев вкусовщины шёл по общим критериям и по этому разделу.
Четыре строки, которых не принесёт ни один проход
- Решения проекта не сверялись.
docs/adr.*— процессный документ, прогон его не открывает. Расхождение изменения с записанным решением ловитav-dev:doc-healthcheck, а не ревью. У этой задачи ADR есть (ADR-2026-08-11-spa-on-vue.md, тронут), и сверен он не был. - Записанные наблюдения проекта не использовались.
docs/research.*не открывался. Все числа в отчёте сняты на этом прогоне: размер.git(du), умолчания журнала PocketBase (исходники модуля), 86 072 байта прироста бинарника (замер проходаops), таблица ответов мультиплексора (go run). - Поимённая сверка с руководствами по стилю Go, TypeScript и Vue не
задавалась ни одним проходом. Различение «идиоматично против
распространено» не спрашивает никто — прохода про идиоматичность в конвейере
нет. Для этой задачи это ощутимее обычного:
web/— новая для проекта экосистема, и её первый код никем на идиоматичность не смотрен. - Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет. Проход независимой реализации снят по стоимости, а не по замеру; «не знаю, чего не знаю» здесь не достаёт никто.
Формулировка «критичных проблем не обнаружено» к этому отчёту неприменима: три находки блокируют мердж, и ещё шесть проверенных срезаны потолком и названы поимённо выше.