Files
transcriber/openspec/changes/archive/2026-08-15-spa-skeleton/review/code-review.md
T
av 663021f712 приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное
  вшивается через go:embed и раздаётся корневым маршрутом: разметка на
  неизвестном пути вне корней сервиса, отказ контракта внутри корня
- перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов,
  а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь
- шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка
  входят в гейт, а в Dockerfile появилась ступень приложения
2026-08-15 18:51:05 +03:00

34 KiB
Raw Blame History

Ревью кода: 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 — дефект старше этой задачи, но именно она заводит перечень корней, куда лечение просится. Варианты:

    1. чинить здесь: слой канонизации пути на перечне ServiceMounts, отказ 400 только на закодированной форме корня, тест на /%5f/, /%6detrics и на законный закодированный путь файла (цена — день, растёт scope задачи);
    2. чинить отдельной задачей, а в этой задаче только записать дыру в docs/security.md и завести задачу в беклоге (цена — час, дыра живёт до выкладки);
    3. закрыть на стороне выкладки — правило прокси на регулярном выражении по сырому пути (цена — вне репозитория, проверить прогоном нечем).

Образ собирается из дерева разработчика, а не из объявленных входов

  • Файл: Dockerfile:12-16,37; отсутствующий .dockerignore; .gitignore:57-61
  • Severity: major
  • Confidence: high
  • Оракул: ls -la .dockerignoreNo 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-120apis.Static первой строкой ставит requestEventKeySkipSuccessActivityLog; apis/middlewares.go:349-445activityLogger() навешен на все маршруты и при err == nil без этого признака пишет url (RequestURI, до 3000 знаков), referer, userAgent, а при Logs.LogIPuserIP и 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 statusdocs/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 отмечена выполненной; capability webapp не объявлена в перечне docs/architecture.md, задача 5.2 не отмечена; сценарий «Отсутствие сборки видно в журнале» оракула не имеет — буфер журнала в TestWebappNotBuilt заведён, но не читается.
  • Последствие: решение без дома снимается следующей задачей бесплатно и молча. Ближайший пример уже виден: вернуть путь анонима в журнал можно, не нарушив ни одного записанного требования, — и это ровно то свойство, которое блокирующая находка выше показала уже нарушенным. Capability без объявления даёт второе описание раздачи у следующего автора.
  • Предложение: дописать в дельту webapp требования «путь в журнал не идёт» и «отпечаток вшитой сборки в журнале подъёма»; строку capability webapp в 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.
  • Потолок триажа сработал. Срезано шесть проверенных находок, поимённо:
    1. Taskfile.yml, шаг front: рассинхронизованный package-lock.json роняет гейт кодом 3 после удавшегося npm ping, то есть при заведомо живой сети, — словарь CLAUDE.md («Гейт») велит здесь код 1, дрейф. Правка однострочная (code, п. 11).
    2. main.go:212-219, webapp.go:159-166: два новых поля журнала заведены мимо словаря docs/conventions/logging.md, домен webapp.* не объявлен (code, п. 12).
    3. webapp.go:24 против auth.go:96-105: корень /auth объявлен константой AuthRoot и оставлен литералом в трёх регистрациях. Правка AuthRoot даст разметку на /auth/callback — возврат от провайдера сломается без единой ошибки (code, п. 13).
    4. webapp.go:280-301: разметка с no-cache никогда не подтверждается 304embed.FS отдаёт нулевой ModTime, ETag не выставляется, каждая ревалидация тянет полное тело. Готовый buildFingerprint под ETag уже посчитан (ops, п. 22).
    5. webapp.outcome=failure доезжает с http.status_code=0 — предмет уже заведён задачей response-code-in-journal (adversary, п. 20).
    6. Задача 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, «Типовые ложноположительные», прочитан и применён: четыре записи, ни одна к находкам этого прогона не подошла. Отсев вкусовщины шёл по общим критериям и по этому разделу.

Четыре строки, которых не принесёт ни один проход

  1. Решения проекта не сверялись. docs/adr.* — процессный документ, прогон его не открывает. Расхождение изменения с записанным решением ловит av-dev:doc-healthcheck, а не ревью. У этой задачи ADR есть (ADR-2026-08-11-spa-on-vue.md, тронут), и сверен он не был.
  2. Записанные наблюдения проекта не использовались. docs/research.* не открывался. Все числа в отчёте сняты на этом прогоне: размер .git (du), умолчания журнала PocketBase (исходники модуля), 86 072 байта прироста бинарника (замер прохода ops), таблица ответов мультиплексора (go run).
  3. Поимённая сверка с руководствами по стилю Go, TypeScript и Vue не задавалась ни одним проходом. Различение «идиоматично против распространено» не спрашивает никто — прохода про идиоматичность в конвейере нет. Для этой задачи это ощутимее обычного: web/ — новая для проекта экосистема, и её первый код никем на идиоматичность не смотрен.
  4. Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет. Проход независимой реализации снят по стоимости, а не по замеру; «не знаю, чего не знаю» здесь не достаёт никто.

Формулировка «критичных проблем не обнаружено» к этому отчёту неприменима: три находки блокируют мердж, и ещё шесть проверенных срезаны потолком и названы поимённо выше.