# Ревью кода: `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 .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 отмечена выполненной; 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` никогда не подтверждается `304` —
     `embed.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](../../../../docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md));
- работа сервиса с настоящими внешними собеседниками: живой прогон доступен и
  покрывает подъём, маршруты, панель, журнал, метрики и остановку, но за
  настоящие 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. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
   нет.** Проход независимой реализации снят по стоимости, а не по замеру;
   «не знаю, чего не знаю» здесь не достаёт никто.

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