приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное вшивается через go:embed и раздаётся корневым маршрутом: разметка на неизвестном пути вне корней сервиса, отказ контракта внутри корня - перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов, а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь - шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка входят в гейт, а в Dockerfile появилась ступень приложения
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-15
|
||||
@@ -0,0 +1,257 @@
|
||||
# Дизайн: каркас приложения
|
||||
|
||||
## Контекст
|
||||
|
||||
Фреймворк и способ раздачи выбраны разведкой `spa-framework-choice` и записаны
|
||||
решением [ADR-2026-08-11-spa-on-vue](../../../docs/adr/ADR-2026-08-11-spa-on-vue.md):
|
||||
Vue 3 с роутером пятой версии, сборка Vite, статика внутри бинарника, шаг сборки
|
||||
в наборе проверок и в образе. Отвергнутые кандидаты — Svelte и React — названы
|
||||
там же с ценой отказа и здесь не пересматриваются. Правила кода приложения лежат
|
||||
в [conventions/web-ui.md](../../../docs/conventions/web-ui.md).
|
||||
|
||||
Эта задача решает не «на чём писать», а «чем сервис отдаёт написанное и что он
|
||||
отвечает на пути, которого не знает».
|
||||
|
||||
## Что человек увидит иначе
|
||||
|
||||
Сегодня браузер, открывший адрес сервиса, получает отказ: сервис отвечает только
|
||||
на своих адресах, и ни один из них страницы не отдаёт. После изменения тот же
|
||||
адрес открывает приложение, а обновление страницы посреди приложения возвращает
|
||||
тот же экран, а не ошибку. Вошедший видит своё имя, не вошедший — предложение
|
||||
войти.
|
||||
|
||||
## Решения
|
||||
|
||||
### Один корневой маршрут вместо готовой раздачи библиотеки
|
||||
|
||||
У библиотеки есть готовый обработчик статики с подстановкой `index.html` на
|
||||
непопавший путь. Он **не берётся**, и причин две.
|
||||
|
||||
Первая — устройство роутера. Сборка маршрутов сама вешает на путь `/` отказ
|
||||
«ничего не совпало», если такого маршрута ещё нет. Образец `/` в стандартном
|
||||
мультиплексоре покрывает все пути, и второй всепокрывающий образец рядом с ним —
|
||||
`/{path...}` — либо конфликтует с ним, либо разрешается правилом, которое мы не
|
||||
задаём. Поэтому маршрут ставится ровно на `/`: тем самым он занимает место
|
||||
библиотечного отказа, а не спорит с ним.
|
||||
|
||||
Вторая — подстановка `index.html` слепа. Она не различает «человек обновил
|
||||
страницу приложения» и «программа ошиблась адресом под корнем хранилища»: оба
|
||||
случая непопавшие, и оба получили бы разметку с кодом `200`. Перечень корней
|
||||
знает только сервис, и проверять его обязан он.
|
||||
|
||||
Отвергнуто: `apis.Static(fsys, true)` на `/{path...}` — по обеим причинам выше.
|
||||
|
||||
### Корни сервиса перечислены одним местом
|
||||
|
||||
Правило неизвестного пути обязано перечислять **все** корни (`archive`, «Адреса
|
||||
приложения живут своим пространством»), а корней сегодня четыре плюс два
|
||||
отдельных адреса наблюдения. Перечень заводится единой точкой в пакете
|
||||
транспорта: разложенный по обработчикам, он теряет корень молча, и потерянный
|
||||
корень означает разметку вместо отказа — то есть ровно ту поломку, от которой
|
||||
правило и поставлено.
|
||||
|
||||
Что делает обработчик по порядку:
|
||||
|
||||
1. путь принадлежит корню сервиса или совпадает с адресом наблюдения — отказ
|
||||
`404` формой библиотеки, то есть **тем же, чем он отвечает сегодня**. Своей
|
||||
формы отказа здесь не заводится: под корнем приложения она уже есть и её даёт
|
||||
собственный перехват `/app/`, а под чужими корнями наша форма была бы второй;
|
||||
2. метод не `GET` и не `HEAD` — отказ `405`;
|
||||
3. путь ведёт под каталог ресурсов, а файла там нет — отказ `404`;
|
||||
4. путь совпал с файлом собранного приложения — файл;
|
||||
5. прочее — разметка приложения.
|
||||
|
||||
**Принадлежность корню — два условия, и оба обязательны:** точное совпадение либо
|
||||
префикс вместе с косой чертой. По одному префиксу корню `/app` достался бы
|
||||
посторонний `/apple`; по одному префиксу с косой чертой голый `/api` не достался
|
||||
бы никому и уехал бы разметкой. Проверено прогоном мультиплексора на раскладке
|
||||
этого дизайна.
|
||||
|
||||
**Шаг 3 закрывает случай, ради которого писано правило кэширования.** Разметка
|
||||
прежней сборки называет ресурсы прежней сборки; после выкладки их имена другие, и
|
||||
без этого шага браузер получил бы на несуществующий ресурс `200` с разметкой,
|
||||
отверг бы её по типу содержимого и показал пустой экран — а в кодах ответов
|
||||
сервиса не осталось бы ничего.
|
||||
|
||||
**Перечень не описывает регистрацию, а порождает её.** Наши маршруты — проба
|
||||
здоровья, метрики, группа приложения и группа входа — вешаются из перечня;
|
||||
корни библиотеки (`/api`, `/_`) остаются её литералами и держатся своим тестом.
|
||||
Иначе перечень был бы вторым описанием таблицы маршрутов, которую сегодня ведут
|
||||
три места, и разошлись бы они молча: корень, заведённый регистрацией и забытый в
|
||||
перечне, отдаёт разметку с кодом `200` там, где программа ждёт отказ контракта, а
|
||||
компилятор не видит ничего. Заодно уходит второе перечисление адресов
|
||||
наблюдения — сегодня уровень журнала называет пробу здоровья и метрики отдельно.
|
||||
|
||||
Цена названа: задача трогает сборку маршрутов сверх первоначальных границ.
|
||||
Принято решением владельца на чекпоинте 2026-08-15; отвергнуты правило-сканер
|
||||
(ловит только литералы и добавляет шаг проверок) и перечень описанием
|
||||
(расхождение обнаружил бы клиент).
|
||||
|
||||
**В журнал этот маршрут пишет исход, а не путь.** Путь здесь целиком задаёт
|
||||
спрашивающий, и до изменения он ловился отказом маршрутизатора, а теперь
|
||||
становится успешным ответом: дословная строка сделала бы журнал местом, куда
|
||||
аноним пишет свой текст. Пишется исход — разметка, ресурс, отказ — и длина пути.
|
||||
|
||||
### Приложение живёт каталогом `web/`, а вшивается своим пакетом Go
|
||||
|
||||
Исходники, зависимости и настройка сборки — `web/`; вшиваемый каталог —
|
||||
`web/embed`; собранное сборщик кладёт в `web/embed/dist`. Вшивает его файл
|
||||
`web/embed.go` пакетом `web`. Транспорт получает готовую файловую систему
|
||||
параметром, а не тянет пакет сам: так тест подставляет свою сборку, не собирая
|
||||
приложение.
|
||||
|
||||
Отвергнуто: держать исходники приложения под `internal/`. Каталог означает
|
||||
«внутреннее для Go», а приложение к сборке Go отношения не имеет; сборщик
|
||||
фронтенда пришлось бы учить писать результат в чужое дерево.
|
||||
|
||||
### Собранного приложения в git нет, а сборка Go без него не ломается
|
||||
|
||||
Собранное не коммитится: это производное, и его версия в git разошлась бы с
|
||||
исходниками молча. Но вшивание требует, чтобы каталог существовал в момент сборки
|
||||
Go, иначе `go build ./...` отказывает у всякого, кто приложение не собирал.
|
||||
|
||||
Отсюда раскладка в два этажа. В git лежит **пустой каталог с меткой** —
|
||||
`web/embed/.gitkeep`, — а сборщик пишет в `web/embed/dist`, то есть **этажом
|
||||
ниже метки**. Так очистка выходного каталога, которую сборщик делает перед каждой
|
||||
сборкой, метки не касается: положенная рядом с собранным, она уехала бы первым же
|
||||
прогоном, а следом — удалённой в ближайший коммит, и `go build ./...` отказал бы
|
||||
у всякого, кто клонировал заново.
|
||||
|
||||
Обработчик, не нашедший разметки, отвечает `503` и честной страницей «приложение
|
||||
не собрано» — плюс строка в журнале при подъёме. Молча отдавать `404` он не
|
||||
вправе: инвариант о неслучившемся молчании относится и к этому случаю.
|
||||
|
||||
**Что за сборка вшита, видно снаружи.** При подъёме в журнал уходит отпечаток
|
||||
вшитой разметки. Иначе бинарник молча несёт вчерашнюю сборку — вне набора
|
||||
проверок порядок шагов ничем не задан, и `go run .` вшивает то, что лежит с
|
||||
прошлого раза, — а отличить «не та сборка» от «та» нечем: приложение открывается
|
||||
и ведёт себя как прежняя версия.
|
||||
|
||||
Отвергнуто: **коммитить собранное** — производное в git, расходится с
|
||||
исходниками. Отвергнуто: **ронять старт** без собранного приложения — сломало бы
|
||||
`go test ./...` и `go run .` у того, кто правит только Go, а команды эти
|
||||
объявлены в `CLAUDE.md` работающими.
|
||||
|
||||
### Заголовки кэширования назначаются, а не достаются умолчанию
|
||||
|
||||
Отдача файла в библиотеке заголовка кэширования не ставит вовсе, а вшитый файл не
|
||||
несёт и времени правки: браузер остаётся с собственной догадкой о том, сколько
|
||||
хранить копию. Догадка эта разная у разных браузеров, и проверить её со стороны
|
||||
сервиса нечем.
|
||||
|
||||
Поэтому: **признаком служит каталог, а не вид файла.** Ресурс из каталога,
|
||||
который наполняет сборщик, отдаётся с долгим сроком и пометкой «неизменяемо» —
|
||||
имена там строит сборщик и несёт в них отпечаток содержимого, поэтому устареть
|
||||
такой ответ не может. Всё прочее, включая разметку, — с требованием спрашивать
|
||||
заново.
|
||||
|
||||
Признак назван каталогом потому, что вид файла его не даёт: в сборке лежат и
|
||||
файлы с постоянными именами — иконка, манифест, а после `installable-pwa` ещё и
|
||||
service worker, у которого отпечатка не бывает по устройству. Такой файл,
|
||||
однажды отданный как «неизменяемый», не отзывается со стороны сервиса ничем:
|
||||
запросов он больше не увидит, а у установленного на телефон приложения залипший
|
||||
service worker означает, что новая сборка не доходит вовсе.
|
||||
|
||||
**Отпечаток в именах здесь не нормируется.** Его делает сборщик, сервис имён не
|
||||
выбирает и их нарушения не заметит — значит это правило конвенции приложения, а
|
||||
не спеки: спеки нормируют поведение сервиса, а инструмент, которым его собирают,
|
||||
не нормируют вовсе (`architecture.md`, преамбула).
|
||||
|
||||
Долгий срок — год. Число живёт настройкой в [database.md](../../../docs/database.md),
|
||||
«Настройки с числовым значением», как и прочие числа проекта.
|
||||
|
||||
### Node приходит отдельной ступенью образа
|
||||
|
||||
Ступеней в образе становится три: сборка приложения, сборка бинарника, рабочий
|
||||
слой. Приложение собирается **до** бинарника, потому что вшивание требует
|
||||
готового каталога. Рабочего слоя это не касается: Node в нём не остаётся.
|
||||
|
||||
Цена названа решением ADR и здесь только подтверждается: во что слой обходится
|
||||
образу по времени и по весу, не замерялось — замер делает эта задача.
|
||||
|
||||
### Node не ставится на машину, а зовётся контейнером
|
||||
|
||||
Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**,
|
||||
а не вызывает их из `PATH`. Требованием к машине разработчика становится docker,
|
||||
которым и так собирается образ, — второго устанавливаемого окружения сверх
|
||||
`ffmpeg` не появляется вовсе.
|
||||
|
||||
Так снимается расхождение, которое иначе завелось бы молча: версия Node на машине
|
||||
разработчика и версия в образе — два разных числа, и собранное ими приложение
|
||||
различается ровно тогда, когда различаются они. Обёртка берёт **тот же** образ,
|
||||
которым собирает ступень `Dockerfile`.
|
||||
|
||||
Отсюда три частности, каждая из которых иначе кусается на первом же прогоне:
|
||||
|
||||
- **имя образа берётся из `Dockerfile`**, а не объявляется вторым числом в
|
||||
`Taskfile.yml`: второй дом версии разошёлся бы с первым, и сверять их было бы
|
||||
нечем — своего шага сверки у него, в отличие от версий Go, нет;
|
||||
- **контейнер ходит под тем же пользователем, что и вызвавший**, иначе каталог
|
||||
зависимостей и собранное лягут от `root`, и убрать их с машины обычными
|
||||
средствами не выйдет;
|
||||
- **кэш установщика уводится наружу контейнера**, в каталог под `/tmp`: под
|
||||
чужим пользователем домашнего каталога у контейнера нет, и установка иначе
|
||||
отказывает, а кэш внутри контейнера не пережил бы прогон — каждый набор
|
||||
проверок тянул бы зависимости заново, включая задачи, приложения не
|
||||
касающиеся. В git каталог не попадает по построению: он вне дерева проекта.
|
||||
|
||||
**Зависимости ставятся из файла замка командой, которая его не правит.** Иначе
|
||||
шаг набора проверок правит отслеживаемый файл: обновление зависимости приезжает в
|
||||
коммит без чьего-либо решения, а собранное в наборе проверок перестаёт совпадать
|
||||
с собранным в образе. Обе половины молчаливы — набор проверок зелёный в обоих
|
||||
случаях. Ту же команду зовёт и ступень `Dockerfile`.
|
||||
|
||||
**Шаг ходит в сеть, и это второй такой шаг в наборе проверок.** Установщик
|
||||
обращается к реестру пакетов, а docker — к реестру образов; без сети шаг
|
||||
краснеет. Отказ сети и реестра — **отказ окружения, код 3**, отдельно от красной
|
||||
сборки, у которой код 1. Утверждение `CLAUDE.md` «шагу `vulns` нужна сеть, и он
|
||||
один такой» перестаёт быть верным и правится этой же задачей: иначе следующий
|
||||
читатель раздела «Гейт» получит неверную посылку.
|
||||
|
||||
Решение правит **последствие** [ADR-2026-08-11-spa-on-vue](../../../docs/adr/ADR-2026-08-11-spa-on-vue.md):
|
||||
там записано, что Node и его установщик становятся нужны на машине
|
||||
разработчика. Нужен docker; сам ADR при этом не пересматривается — выбор
|
||||
фреймворка и наличие шага сборки остаются прежними.
|
||||
|
||||
Отвергнуто: **ставить Node на машину** — вводит второе устанавливаемое окружение
|
||||
и разъезжается с версией в образе. Отвергнуто: **дать выбор — контейнер или
|
||||
локальный Node** — это второй способ делать одно и то же, и собранное ими
|
||||
различалось бы в зависимости от того, у кого что стоит.
|
||||
|
||||
### Код приложения проверяется машиной, а не глазами
|
||||
|
||||
Проверок у приложения две, и обе входят в набор проверок наравне со сборкой:
|
||||
**Biome** — форматирование и статический анализ, **юнит-тесты Vue** — поведение
|
||||
экрана, включая показ вошедшего и три ветки ответа на вопрос «кто вошёл».
|
||||
Гоняются они тем же контейнером, что и сборка, — второго окружения не заводится.
|
||||
|
||||
Без них требование «приложение показывает вошедшего» — единственное, ради чего
|
||||
задача заводится, — не проверял бы ни один шаг: набор проверок судил бы только
|
||||
раздачу из Go и оставался зелёным на сломанном приложении. Ручная проверка эту
|
||||
дыру не закрывает: следующая задача её не повторит.
|
||||
|
||||
Оговорка на Biome одна и проверяется первым же прогоном: если он не покрывает
|
||||
разметку однофайловых компонентов, инструмент меняется. Решением владельца на
|
||||
чекпоинте 2026-08-15 взят он, а замена — отдельное решение, не подстановка по
|
||||
ходу.
|
||||
|
||||
### Шаг сборки приложения — первый в наборе проверок
|
||||
|
||||
Он стоит **перед** сборкой Go по той же причине: вшивается то, что собрано.
|
||||
Недостающий docker — отказ окружения, код 3 по общему словарю (`CLAUDE.md`,
|
||||
«Гейт»), наравне с недостающим компилятором C у шага тестов; красная сборка
|
||||
приложения — код 1, отказ проверки наравне с `go build`.
|
||||
|
||||
## Границы
|
||||
|
||||
Экранов, кроме показа вошедшего, не делается: их заводят `upload-and-status-screen`
|
||||
и `records-list-screen`. Установка на телефон, service worker и версионирование
|
||||
статики — `installable-pwa`. Готовый набор компонентов не берётся: он не выбран,
|
||||
а способен удвоить собранный файл.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **Набор компонентов и стили** — не решено, дом вопроса
|
||||
[conventions/web-ui.md](../../../docs/conventions/web-ui.md), «Что не решено».
|
||||
- **Во что слой Node обходится образу** — замеряется этой задачей, число уезжает
|
||||
в решение ADR последствием.
|
||||
@@ -0,0 +1,56 @@
|
||||
## Why
|
||||
|
||||
Сервис отдаёт данные, но человеку их показать нечем: экранов нет вовсе, а
|
||||
браузер, открывший адрес сервиса, получает поверхность хранилища вместо
|
||||
приложения. Два основных сценария паспорта — «семейный архив» и «возвращение к
|
||||
записи» — не работают ни один, и упираются они в одно: приложения не существует.
|
||||
|
||||
Каркас сам по себе экранов не приносит. Он приносит то, на чём они стоят:
|
||||
собранное приложение, которое сервис отдаёт браузером, открывается по любому
|
||||
своему адресу и показывает вошедшего, спросив об этом сервис.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Сервис начинает отдавать браузеру **приложение**: разметку и её ресурсы. Они
|
||||
лежат внутри самого бинарника, отдельного каталога рядом с ним не требуется, и
|
||||
внешнего веб-сервера под них не заводится.
|
||||
- Неизвестный адрес **вне корней сервиса** отдаёт разметку приложения, а не
|
||||
отказ. Так обновление страницы посреди приложения открывает тот же экран, а не
|
||||
ошибку. Корни перечислены поимённо, и адрес **внутри** корня в приложение не
|
||||
проваливается никогда: отказ контракта остаётся отказом контракта.
|
||||
- Разметка и её ресурсы отдаются **без сессии**: приложение само спрашивает
|
||||
сервис, кто вошёл, и уводит ко входу, когда не вошёл никто. Требование сессии
|
||||
на самой разметке отдавало бы отказ вместо экрана входа.
|
||||
- Открытое приложение показывает **вошедшего** — то, что сервис ответил о нём, —
|
||||
и это единственное, что оно сегодня показывает.
|
||||
- В сборку приходит **шаг сборки приложения**: он входит в набор проверок и в
|
||||
сборку образа. Спеками это не нормируется — у сборки другой потребитель, тот,
|
||||
кто собирает.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `webapp`: приложение в браузере — чем сервис его отдаёт, каким адресом оно
|
||||
открывается, что делает обновление страницы посреди приложения и что человек
|
||||
видит, открыв его.
|
||||
|
||||
### Modified Capabilities
|
||||
- `access`: разметка приложения и её ресурсы пополняют перечень адресов,
|
||||
открытых без сессии; сегодня в нём только проба здоровья и метрики.
|
||||
|
||||
## Impact
|
||||
|
||||
- новый каталог приложения: исходники, зависимости, настройка сборки;
|
||||
- `internal/controller/http` — маршрут, отдающий разметку на неизвестном пути вне
|
||||
корней, и раздача ресурсов приложения из бинарника;
|
||||
- `Taskfile.yml` — шаг сборки приложения, его место в наборе проверок и в сборке
|
||||
образа;
|
||||
- `Dockerfile` — приложение собирается внутри образа, чтобы выкладка не зависела
|
||||
от машины разработчика;
|
||||
- `.gitignore` — каталог зависимостей и собранное приложение;
|
||||
- новая внешняя зависимость сборки: Node и его установщик пакетов. На машину
|
||||
разработчика они не ставятся — шаг сборки зовёт их контейнером, поэтому
|
||||
требованием к машине становится docker, которым и так собирается образ;
|
||||
- `docs/conventions/web-ui.md` и `docs/architecture.md` — правила кода
|
||||
приложения перестают быть выведенными из выбора, а компонент и зависимость
|
||||
сборки появляются в описании устройства.
|
||||
@@ -0,0 +1,395 @@
|
||||
# Ревью кода: `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. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
|
||||
нет.** Проход независимой реализации снят по стоимости, а не по замеру;
|
||||
«не знаю, чего не знаю» здесь не достаёт никто.
|
||||
|
||||
Формулировка «критичных проблем не обнаружено» к этому отчёту неприменима: три
|
||||
находки блокируют мердж, и ещё шесть проверенных срезаны потолком и названы
|
||||
поимённо выше.
|
||||
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Приложение отдаётся без сессии
|
||||
|
||||
Сервис SHALL отдавать разметку приложения и её ресурсы без сессии. Перечень
|
||||
адресов, открытых анонимно, пополняется ими: прежде в нём стояли только проба
|
||||
здоровья и метрики.
|
||||
|
||||
Причина в самом входе: человек, ещё не вошедший, дошёл бы до входа только через
|
||||
приложение, а закрытая сессией разметка отдала бы ему отказ вместо экрана. Цена
|
||||
открытости названа здесь же и невелика — ни разметка, ни ресурсы содержимого
|
||||
записей не несут: они одинаковы для всех и собираются до всякого запроса.
|
||||
|
||||
Открытость MUST не касаться данных: всякий адрес под корнем приложения
|
||||
по-прежнему требует сессии, и приложение, открытое анонимно, не получает ни
|
||||
одной записи.
|
||||
|
||||
#### Scenario: Разметка доступна анонимно
|
||||
|
||||
- **WHEN** запрос приходит на корень сервиса без сессии
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Ресурс приложения доступен анонимно
|
||||
|
||||
- **WHEN** запрос приходит на ресурс приложения без сессии
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
#### Scenario: Данные анонимно не отдаются
|
||||
|
||||
- **GIVEN** приложение открыто без сессии
|
||||
- **WHEN** оно спрашивает список записей
|
||||
- **THEN** ответ имеет код `401`
|
||||
@@ -0,0 +1,271 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Приложение отдаётся самим бинарником
|
||||
|
||||
Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника.
|
||||
Каталога с собранными файлами рядом с бинарником MUST не требоваться, и внешнего
|
||||
веб-сервера под раздачу MUST не заводиться.
|
||||
|
||||
Причина в выкладке: сервис едет на сервер одним образом, и второй разворачиваемый
|
||||
артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с собранным.
|
||||
Бинарник, которому нужен каталог рядом, отдаёт пустую страницу молча — каталог
|
||||
либо забыли положить, либо положили не тот, и различить это снаружи нечем.
|
||||
|
||||
#### Scenario: Приложение открывается у бинарника без каталога рядом
|
||||
|
||||
- **GIVEN** бинарник запущен в каталоге, где нет ничего, кроме его настроек
|
||||
- **WHEN** браузер спрашивает корень сервиса
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Ресурс приложения отдаётся оттуда же
|
||||
|
||||
- **GIVEN** разметка приложения названа своим ресурсом
|
||||
- **WHEN** браузер спрашивает этот ресурс
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — содержимое ресурса
|
||||
|
||||
### Requirement: Сервис, поднятый без собранного приложения, говорит об этом
|
||||
|
||||
Сервис SHALL отвечать кодом `503` на всяком пути, где он отдал бы разметку, если
|
||||
собранного приложения в нём нет, и MUST писать об этом строку в журнал при
|
||||
подъёме. Отвечать `404` и молчать он MUST не вправе: «приложения нет» и «такого
|
||||
адреса нет» — разные состояния, и первое чинится сборкой, а не поиском опечатки в
|
||||
адресе.
|
||||
|
||||
Состояние это возможно только у собранного мимо набора проверок: и набор
|
||||
проверок, и сборка образа собирают приложение раньше бинарника. Проба здоровья
|
||||
при этом остаётся зелёной: она отвечает за то, работает ли сервис, а сервис в
|
||||
этом состоянии принимает записи и расшифровывает их — не работает только показ.
|
||||
|
||||
#### Scenario: Пустая сборка отвечает отказом, а не отсутствием адреса
|
||||
|
||||
- **GIVEN** бинарник собран без собранного приложения
|
||||
- **WHEN** браузер спрашивает корень сервиса
|
||||
- **THEN** ответ имеет код `503`
|
||||
|
||||
#### Scenario: Отсутствие сборки видно в журнале
|
||||
|
||||
- **GIVEN** бинарник собран без собранного приложения
|
||||
- **WHEN** сервис поднимается
|
||||
- **THEN** в журнале есть строка о том, что приложение не собрано
|
||||
|
||||
#### Scenario: Проба здоровья остаётся зелёной
|
||||
|
||||
- **GIVEN** бинарник собран без собранного приложения
|
||||
- **WHEN** запрос приходит на пробу здоровья
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
### Requirement: Неизвестный путь вне корней открывает приложение
|
||||
|
||||
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
|
||||
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
|
||||
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/auth` у входа,
|
||||
`/_` у панели, — отдельными адресами стоят `/health` и `/metrics`.
|
||||
|
||||
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
|
||||
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
|
||||
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
|
||||
`/api` не достался бы никому и уехал бы разметкой.
|
||||
|
||||
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
|
||||
контракта остаётся отказом контракта и уходит той формой, которой этот корень
|
||||
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
|
||||
получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и
|
||||
приняла бы её за ответ.
|
||||
|
||||
Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не
|
||||
подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка
|
||||
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
|
||||
него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
|
||||
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
|
||||
|
||||
Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST
|
||||
отвечать `405`.
|
||||
|
||||
#### Scenario: Обновление страницы посреди приложения открывает тот же экран
|
||||
|
||||
- **GIVEN** приложение открыто на своём маршруте
|
||||
- **WHEN** браузер спрашивает этот путь заново
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Голый корень разметкой не подменяется
|
||||
|
||||
- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без
|
||||
косой черты
|
||||
- **THEN** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
|
||||
|
||||
- **WHEN** запрос приходит на путь, который начинается именем корня, но не
|
||||
отделён от него косой чертой
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
|
||||
|
||||
- **GIVEN** человек вошёл и предъявил сессию
|
||||
- **WHEN** он спрашивает неизвестный путь под корнем приложения
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем приложения без сессии отвечает как все прочие его адреса
|
||||
|
||||
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом
|
||||
|
||||
- **WHEN** запрос приходит на неизвестный путь под корнем хранилища
|
||||
- **THEN** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
|
||||
|
||||
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Наблюдение приложением не подменяется
|
||||
|
||||
- **WHEN** запрос приходит на пробу здоровья
|
||||
- **THEN** отвечает проба здоровья, а не приложение
|
||||
|
||||
#### Scenario: Чужой метод отвечает отказом
|
||||
|
||||
- **WHEN** на неизвестный путь вне корней приходит запрос методом, которым
|
||||
страницу не открывают
|
||||
- **THEN** ответ имеет код `405`
|
||||
|
||||
#### Scenario: Проверка доступности разметку получает
|
||||
|
||||
- **WHEN** разметку спрашивают методом `HEAD`
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
### Requirement: Обновлённое приложение доходит до браузера
|
||||
|
||||
Сервис SHALL отдавать **ресурс из каталога, который наполняет сборщик**, с долгим
|
||||
сроком хранения и пометкой «неизменяемо», а всё прочее, включая разметку, — с
|
||||
требованием спрашивать заново. Признак — каталог, а не вид файла: имена в нём
|
||||
строит сборщик и несёт в них отпечаток содержимого, поэтому изменившийся ресурс
|
||||
приезжает под новым именем и прежний ответ устареть не может.
|
||||
|
||||
Долгий срок MUST не доставаться файлу вне этого каталога. Разметка, иконка,
|
||||
манифест и всякий файл с постоянным именем меняются под тем же именем, и
|
||||
отозвать у браузера выданное «неизменяемо» нечем: выложенное обновление не дойдёт
|
||||
до того, кто уже открывал приложение, пока он не почистит хранилище браузера
|
||||
руками. Заметить это со стороны сервиса нечем — запросов он больше не увидит.
|
||||
|
||||
Отпечаток в именах — свойство сборки, а не сервиса, и нормой его держит конвенция
|
||||
приложения, а не эта спека: сервис имён не выбирает и их нарушения не заметит.
|
||||
Здесь названо только то, что делает с ними сам сервис.
|
||||
|
||||
#### Scenario: Ресурс сборщика отдаётся с долгим сроком
|
||||
|
||||
- **WHEN** браузер спрашивает ресурс из каталога, который наполняет сборщик
|
||||
- **THEN** ответ несёт долгий срок хранения и пометку «неизменяемо»
|
||||
|
||||
#### Scenario: Разметка спрашивается заново
|
||||
|
||||
- **WHEN** браузер спрашивает разметку приложения
|
||||
- **THEN** ответ несёт требование спрашивать её заново
|
||||
- **AND** пометки «неизменяемо» в ответе нет
|
||||
|
||||
#### Scenario: Файл с постоянным именем долгого срока не получает
|
||||
|
||||
- **WHEN** браузер спрашивает файл сборки, лежащий вне каталога ресурсов
|
||||
- **THEN** ответ несёт требование спрашивать его заново
|
||||
|
||||
### Requirement: Путь, отданный приложению, в журнал не идёт
|
||||
|
||||
Сервис SHALL записывать о таком запросе **исход из закрытого перечня** —
|
||||
разметка, ресурс, отказ — и длину пути, а самого пути MUST не записывать ни в
|
||||
один свой журнал. То же относится к меткам метрик.
|
||||
|
||||
Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней
|
||||
ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений
|
||||
распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда
|
||||
аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже
|
||||
есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к
|
||||
перечню известных.
|
||||
|
||||
Журналов при этом **два**: свой и журнал хранилища, куда библиотека кладёт путь
|
||||
целиком вместе с адресом отправителя. Требование относится к обоим.
|
||||
|
||||
#### Scenario: Путь не доезжает до журнала
|
||||
|
||||
- **WHEN** приходит запрос на путь вне корней сервиса
|
||||
- **THEN** записи о нём не несут этого пути
|
||||
- **AND** несут исход и длину пути
|
||||
|
||||
#### Scenario: Длинный путь журнал не наполняет
|
||||
|
||||
- **WHEN** приходит запрос на путь длиной в тысячу знаков
|
||||
- **THEN** записи о нём не растут вместе с длиной пути
|
||||
|
||||
### Requirement: Сервис объявляет, какая сборка приложения в нём вшита
|
||||
|
||||
Сервис SHALL писать при подъёме отпечаток вшитой сборки. Он же MUST уходить
|
||||
меткой ответа с разметкой: сама разметка отдаётся с требованием спрашивать её
|
||||
заново, и без метки браузер получает полное тело вместо подтверждения — вшитый
|
||||
файл не несёт времени правки вовсе.
|
||||
|
||||
Без отпечатка «не та сборка» неотличима от «той»: вне набора проверок порядок
|
||||
шагов ничем не задан, и бинарник собирается с тем, что лежало в каталоге с
|
||||
прошлого раза. Приложение при этом открывается и ведёт себя как прежняя версия,
|
||||
а искать причину человек идёт в код сервиса.
|
||||
|
||||
#### Scenario: Отпечаток виден при подъёме
|
||||
|
||||
- **WHEN** сервис поднимается с собранным приложением
|
||||
- **THEN** в журнале есть отпечаток вшитой сборки
|
||||
|
||||
#### Scenario: Разметка несёт метку ответа
|
||||
|
||||
- **WHEN** браузер спрашивает разметку приложения
|
||||
- **THEN** ответ несёт метку, по которой её можно спросить заново
|
||||
|
||||
### Requirement: Открытое приложение показывает вошедшего
|
||||
|
||||
Приложение SHALL спрашивать сервис, кто вошёл, и показывать его имя. Отказ
|
||||
`401` MUST уводить ко входу: человек, ещё не вошедший, получает его на всяком
|
||||
адресе данных, и это его штатное состояние, а не поломка.
|
||||
|
||||
Всякий **иной** отказ и сорванный запрос MUST ко входу не уводить, а показываться
|
||||
строкой о неудаче. Трактовка «любой отказ значит не вошёл» замкнула бы круг:
|
||||
приложение ушло бы ко входу, вход вернул бы человека в приложение, и на отказе
|
||||
сервиса или ограничителя частоты круг пошёл бы заново.
|
||||
|
||||
Имя вошедшего берётся ответом сервиса, а не кукой сессии: кука недоступна
|
||||
скриптам страницы, и другого способа узнать вошедшего у приложения нет. Имени в
|
||||
ответе может не быть вовсе — тогда приложение MUST показать, что вход выполнен,
|
||||
и MUST не подставлять вместо имени адрес почты: его в ответе нет по норме
|
||||
`access`.
|
||||
|
||||
#### Scenario: Вошедший виден
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он открывает приложение
|
||||
- **THEN** приложение показывает его имя
|
||||
|
||||
#### Scenario: Не вошедшему предлагается вход
|
||||
|
||||
- **GIVEN** сессии у человека нет
|
||||
- **WHEN** он открывает приложение
|
||||
- **THEN** приложение ведёт его ко входу
|
||||
|
||||
#### Scenario: Отказ сервиса ко входу не уводит
|
||||
|
||||
- **GIVEN** сервис отвечает на вопрос о вошедшем отказом, который не является
|
||||
отсутствием сессии
|
||||
- **WHEN** человек открывает приложение
|
||||
- **THEN** приложение показывает строку о неудаче
|
||||
- **AND** ко входу оно не уводит
|
||||
|
||||
#### Scenario: Учётная запись без имени
|
||||
|
||||
- **GIVEN** человек вошёл, а имени у его учётной записи нет
|
||||
- **WHEN** он открывает приложение
|
||||
- **THEN** приложение показывает, что вход выполнен
|
||||
- **AND** адреса почты на экране нет
|
||||
@@ -0,0 +1,147 @@
|
||||
## 1. Каталог приложения и его сборка
|
||||
|
||||
- [x] 1.1 Завести `web/` с `package.json`, файлом замка, настройкой Vite и
|
||||
`index.html`; выходной каталог сборщика — `web/embed/dist`
|
||||
- [x] 1.2 Команда сборки приложения гоняет проверку типов и сборку одним вызовом
|
||||
(`vue-tsc` входит в команду сборки, а не стоит отдельным шагом)
|
||||
- [x] 1.3 В git лежит `web/embed/.gitkeep` — этажом выше выходного каталога, чтобы
|
||||
очистка перед сборкой его не уносила; собранное и каталог зависимостей внесены
|
||||
в `.gitignore`
|
||||
- [x] 1.4 Написан экран-заглушка: показывает имя вошедшего, на `401` ведёт ко
|
||||
входу, на прочий отказ показывает строку о неудаче; одна таблица маршрутов
|
||||
через `createRouter`, адреса обычные (`createWebHistory`)
|
||||
- [x] 1.5 Обращение к API идёт через одну свою обёртку над `fetch`; она же
|
||||
единственное место, где читается код ответа
|
||||
- [x] 1.6 Заведён Biome: форматирование и статический анализ кода приложения.
|
||||
Не покрывает разметку однофайловых компонентов — сказать строкой и остановиться,
|
||||
замена инструмента решается человеком
|
||||
- [x] 1.7 Заведены юнит-тесты Vue: показ вошедшего, увод ко входу на `401`,
|
||||
строка о неудаче на прочем отказе, учётная запись без имени
|
||||
|
||||
## 2. Раздача приложения из бинарника
|
||||
|
||||
- [x] 2.1 `web/embed.go` вшивает `web/embed` целиком (`all:`), включая метку
|
||||
пустого каталога
|
||||
- [x] 2.2 Перечень корней сервиса заведён единой точкой в пакете транспорта:
|
||||
`/api`, `/app`, `/auth`, `/_` и отдельные адреса `/health`, `/metrics`
|
||||
- [x] 2.2.1 Наши маршруты вешаются **из перечня**: проба здоровья, метрики,
|
||||
группа приложения, группа входа. Корни библиотеки остаются её литералами и
|
||||
держатся своим тестом; второе перечисление адресов наблюдения в уровне журнала
|
||||
снято
|
||||
- [x] 2.3 Принадлежность корню — точное совпадение либо префикс вместе с косой
|
||||
чертой; оба условия проверяются
|
||||
- [x] 2.4 Обработчик приложения зарегистрирован маршрутом `/` (тем самым занимает
|
||||
место библиотечного отказа) и принимает файловую систему параметром
|
||||
- [x] 2.5 Порядок обработчика: корень сервиса или адрес наблюдения — отказ формой
|
||||
библиотеки; метод не `GET` и не `HEAD` — `405`; несовпавший путь под каталогом
|
||||
ресурсов — `404`; совпавший файл — файл; прочее — разметка
|
||||
- [x] 2.6 Заголовки кэширования назначены по каталогу: ресурсы сборщика — год и
|
||||
«неизменяемо», всё прочее, включая разметку, — спрашивать заново
|
||||
- [x] 2.7 Отсутствие собранного приложения даёт `503` с честной страницей и
|
||||
строку в журнале при подъёме, а не `404` молча; проба здоровья остаётся зелёной
|
||||
- [x] 2.8 При подъёме в журнал уходит отпечаток вшитой разметки
|
||||
- [x] 2.9 Строка журнала корневого маршрута несёт исход и длину пути, но не сам
|
||||
путь
|
||||
- [x] 2.10 Разметка и ресурсы отдаются без сессии; адреса данных под корнем
|
||||
приложения сессии по-прежнему требуют
|
||||
|
||||
## 3. Сборка и выкладка
|
||||
|
||||
- [x] 3.1 Шаг сборки приложения заведён в `Taskfile.yml` и стоит **первым** в
|
||||
наборе проверок, до сборки Go; рядом — шаги Biome и юнит-тестов приложения
|
||||
- [x] 3.1.1 Проверки приложения гоняются тем же контейнером, что и сборка;
|
||||
второго окружения не заводится
|
||||
- [x] 3.2 Шаг зовёт установщик и сборщик контейнером, а не из `PATH`: имя образа
|
||||
берётся из `Dockerfile`, контейнер ходит под вызвавшим пользователем, кэш
|
||||
установщика уведён в каталог под `/tmp`
|
||||
- [x] 3.3 Зависимости ставятся из файла замка командой, которая его не правит; ту
|
||||
же команду зовёт ступень `Dockerfile`
|
||||
- [x] 3.4 Недостающий docker и отказ сети или реестра — отказ окружения, код 3;
|
||||
красная сборка приложения — код 1
|
||||
- [x] 3.5 В `Dockerfile` добавлена ступень сборки приложения перед сборкой
|
||||
бинарника; в рабочий слой Node не попадает
|
||||
- [x] 3.6 Вес выяснен, время замера снято с задачи решением владельца
|
||||
2026-08-15: финальный образ от ступени приложения не растёт вовсе (Node в него
|
||||
не копируется), вшитое добавляет к бинарнику 86 072 байта. Время сборки
|
||||
остаётся неизвестным сознательно
|
||||
- [x] 3.7 Последствие ADR-2026-08-11-spa-on-vue поправлено: требованием к машине
|
||||
разработчика становится docker, а не Node
|
||||
|
||||
## 4. Проверки
|
||||
|
||||
- [x] 4.1 Тест: неизвестный путь вне корней отдаёт разметку с кодом `200`
|
||||
- [x] 4.2 Тест: неизвестный путь под каждым корнем сервиса разметки не отдаёт
|
||||
- [x] 4.3 Тест: голый корень без косой черты разметки не отдаёт, а посторонний
|
||||
путь, начинающийся именем корня без косой черты, — отдаёт
|
||||
- [x] 4.4 Тест: неизвестный путь под корнем приложения отвечает `404` с сессией и
|
||||
`401` без неё
|
||||
- [x] 4.5 Тест: несуществующий файл под каталогом ресурсов отвечает `404`
|
||||
- [x] 4.6 Тест: проба здоровья и метрики приложением не подменяются
|
||||
- [x] 4.7 Тест: чужой метод отвечает `405`, а `HEAD` на разметку — `200`
|
||||
- [x] 4.8 Тест: разметка и ресурс отдаются без сессии, а список записей — нет
|
||||
- [x] 4.9 Тест: у разметки стоит требование спрашивать заново, у ресурса из
|
||||
каталога сборщика — год и «неизменяемо»
|
||||
- [x] 4.10 Тест: пустая сборка даёт `503`, а не `404`, и проба здоровья при этом
|
||||
зелёная
|
||||
- [x] 4.11 Юнит-тесты приложения краснеют на сломанном экране: намеренная поломка
|
||||
показа вошедшего роняет набор проверок
|
||||
- [x] 4.12 `task gate` зелёный целиком
|
||||
|
||||
## 5. Документы
|
||||
|
||||
- [x] 5.1 `docs/conventions/web-ui.md` — снята оговорка «кода приложения ещё
|
||||
нет»; дописаны правила: отпечаток в именах ресурсов, несовпавший ресурс отвечает
|
||||
`404`, зависимости ставятся из файла замка
|
||||
- [x] 5.2 `docs/architecture.md` — компонент приложения, внешняя зависимость
|
||||
сборки, новая capability и перечень корней в единых точках
|
||||
- [x] 5.3 `docs/database.md` — срок хранения ресурсов в настройках с числовым
|
||||
значением
|
||||
- [x] 5.4 `CLAUDE.md` — команды и требования к машине разработчика, состав гейта
|
||||
вместе с проверками приложения, второй сетезависимый шаг названный поимённо
|
||||
- [x] 5.5 `docs/review.md` — заведён род узла «раздача собранной статики и шаг
|
||||
сборки приложения» в типовых узлах
|
||||
- [x] 5.6 `docs/conventions/go-linters.md` либо соседняя запись — чем проверяется
|
||||
код приложения: Biome и юнит-тесты, что каждый ловит и где настроен
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
Дословно из записи задачи `spa-skeleton`:
|
||||
|
||||
- Статика собирается одной командой и вшивается в бинарник: запущенный бинарник
|
||||
отдаёт приложение без каталога рядом. Оракул — сборка, запуск бинарника из
|
||||
пустого каталога, запрос корня возвращает разметку приложения.
|
||||
- Образ собирается на чистой машине без предустановленного окружения фронтенда.
|
||||
Оракул — `task image` без локально установленных зависимостей фронтенда.
|
||||
- Приложение показывает, кто вошёл, и обращается к API живого сервера. Оракул —
|
||||
тест либо ручная проверка на запущенном сервере с сессией.
|
||||
- Шаг сборки статики входит в `task gate` и краснеет при ошибке сборки. Оракул —
|
||||
намеренно сломанный исходник роняет `task gate`.
|
||||
- Обновление страницы на любом маршруте приложения открывает тот же экран, а
|
||||
адрес внутри корня сервиса в приложение не проваливается. Оракул — тест на три
|
||||
запроса: неизвестный путь вне корней отдаёт разметку, неизвестный путь внутри
|
||||
`/api/` и внутри `/app/` отдаёт ошибку контракта.
|
||||
|
||||
Рубрика ревью дизайна, тем же списком:
|
||||
|
||||
- Перечень корней живёт одной точкой, и добавление корня не требует правки в двух
|
||||
местах.
|
||||
- Запрос несуществующего файла под каталогом ресурсов отвечает `404`, а не
|
||||
разметкой с `200`.
|
||||
- После прогона сборки приложения `git status` чист по `web/`.
|
||||
- После `task gate` `git status` чист по файлу замка зависимостей.
|
||||
- Отсутствие сборки громкое: код ответа, страница и строка журнала; «сборки нет»
|
||||
отличается от «файла нет».
|
||||
- Долгий неотзываемый срок ставится только файлу из каталога ресурсов сборщика.
|
||||
- Разметка и ресурсы отдаются анонимно, данные под корнем приложения — нет; по
|
||||
анонимному ответу нельзя узнать, вошёл ли кто-то.
|
||||
- В бинарник попадает только собранное: ни карт исходников, ни каталога
|
||||
зависимостей, ни файлов вне выходного каталога.
|
||||
- Отпечаток вшитой сборки виден строкой журнала при подъёме.
|
||||
- Шаг сборки следует словарю кодов: недостающий инструмент и отказ сети — 3,
|
||||
красная сборка — 1; молчаливого пропуска нет.
|
||||
- Ступень сборки не протекает в рабочий слой образа, а версия сборочного
|
||||
окружения объявлена одним местом.
|
||||
- Файловая система строится один раз, а не на каждый запрос; поведение на `HEAD`
|
||||
определено.
|
||||
- Запрос на путь длиной в килобайт не добавляет значения ни метке метрики, ни
|
||||
строке журнала.
|
||||
@@ -446,3 +446,34 @@ MUST отвечать отказом `401`, когда сессии нет. Св
|
||||
- **WHEN** приложение спрашивает, кто вошёл
|
||||
- **THEN** адреса почты в ответе нет
|
||||
|
||||
### Requirement: Приложение отдаётся без сессии
|
||||
|
||||
Сервис SHALL отдавать разметку приложения и её ресурсы без сессии. Перечень
|
||||
адресов, открытых анонимно, пополняется ими: прежде в нём стояли только проба
|
||||
здоровья и метрики.
|
||||
|
||||
Причина в самом входе: человек, ещё не вошедший, дошёл бы до входа только через
|
||||
приложение, а закрытая сессией разметка отдала бы ему отказ вместо экрана. Цена
|
||||
открытости названа здесь же и невелика — ни разметка, ни ресурсы содержимого
|
||||
записей не несут: они одинаковы для всех и собираются до всякого запроса.
|
||||
|
||||
Открытость MUST не касаться данных: всякий адрес под корнем приложения
|
||||
по-прежнему требует сессии, и приложение, открытое анонимно, не получает ни
|
||||
одной записи.
|
||||
|
||||
#### Scenario: Разметка доступна анонимно
|
||||
|
||||
- **WHEN** запрос приходит на корень сервиса без сессии
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Ресурс приложения доступен анонимно
|
||||
|
||||
- **WHEN** запрос приходит на ресурс приложения без сессии
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
#### Scenario: Данные анонимно не отдаются
|
||||
|
||||
- **GIVEN** приложение открыто без сессии
|
||||
- **WHEN** оно спрашивает список записей
|
||||
- **THEN** ответ имеет код `401`
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# webapp Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Приложение в браузере: чем сервис его отдаёт, каким адресом оно открывается, что
|
||||
делает обновление страницы посреди него и что человек видит, открыв его.
|
||||
|
||||
Спека отвечает за **сервис**, а не за сборщик: правило неизвестного пути, срок
|
||||
хранения ответов, поведение при несобранном приложении и то, что уходит в журнал.
|
||||
Отпечаток в именах ресурсов — свойство сборки, и его дом — конвенция приложения.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Приложение отдаётся самим бинарником
|
||||
|
||||
Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника.
|
||||
Каталога с собранными файлами рядом с бинарником MUST не требоваться, и внешнего
|
||||
веб-сервера под раздачу MUST не заводиться.
|
||||
|
||||
Причина в выкладке: сервис едет на сервер одним образом, и второй разворачиваемый
|
||||
артефакт рядом с ним завёл бы вторую точку, где выкладка расходится с собранным.
|
||||
Бинарник, которому нужен каталог рядом, отдаёт пустую страницу молча — каталог
|
||||
либо забыли положить, либо положили не тот, и различить это снаружи нечем.
|
||||
|
||||
#### Scenario: Приложение открывается у бинарника без каталога рядом
|
||||
|
||||
- **GIVEN** бинарник запущен в каталоге, где нет ничего, кроме его настроек
|
||||
- **WHEN** браузер спрашивает корень сервиса
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Ресурс приложения отдаётся оттуда же
|
||||
|
||||
- **GIVEN** разметка приложения названа своим ресурсом
|
||||
- **WHEN** браузер спрашивает этот ресурс
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — содержимое ресурса
|
||||
|
||||
### Requirement: Сервис, поднятый без собранного приложения, говорит об этом
|
||||
|
||||
Сервис SHALL отвечать кодом `503` на всяком пути, где он отдал бы разметку, если
|
||||
собранного приложения в нём нет, и MUST писать об этом строку в журнал при
|
||||
подъёме. Отвечать `404` и молчать он MUST не вправе: «приложения нет» и «такого
|
||||
адреса нет» — разные состояния, и первое чинится сборкой, а не поиском опечатки в
|
||||
адресе.
|
||||
|
||||
Состояние это возможно только у собранного мимо набора проверок: и набор
|
||||
проверок, и сборка образа собирают приложение раньше бинарника. Проба здоровья
|
||||
при этом остаётся зелёной: она отвечает за то, работает ли сервис, а сервис в
|
||||
этом состоянии принимает записи и расшифровывает их — не работает только показ.
|
||||
|
||||
#### Scenario: Пустая сборка отвечает отказом, а не отсутствием адреса
|
||||
|
||||
- **GIVEN** бинарник собран без собранного приложения
|
||||
- **WHEN** браузер спрашивает корень сервиса
|
||||
- **THEN** ответ имеет код `503`
|
||||
|
||||
#### Scenario: Отсутствие сборки видно в журнале
|
||||
|
||||
- **GIVEN** бинарник собран без собранного приложения
|
||||
- **WHEN** сервис поднимается
|
||||
- **THEN** в журнале есть строка о том, что приложение не собрано
|
||||
|
||||
#### Scenario: Проба здоровья остаётся зелёной
|
||||
|
||||
- **GIVEN** бинарник собран без собранного приложения
|
||||
- **WHEN** запрос приходит на пробу здоровья
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
### Requirement: Неизвестный путь вне корней открывает приложение
|
||||
|
||||
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
|
||||
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
|
||||
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/auth` у входа,
|
||||
`/_` у панели, — отдельными адресами стоят `/health` и `/metrics`.
|
||||
|
||||
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
|
||||
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
|
||||
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
|
||||
`/api` не достался бы никому и уехал бы разметкой.
|
||||
|
||||
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
|
||||
контракта остаётся отказом контракта и уходит той формой, которой этот корень
|
||||
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
|
||||
получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и
|
||||
приняла бы её за ответ.
|
||||
|
||||
Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не
|
||||
подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка
|
||||
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
|
||||
него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
|
||||
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
|
||||
|
||||
Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST
|
||||
отвечать `405`.
|
||||
|
||||
#### Scenario: Обновление страницы посреди приложения открывает тот же экран
|
||||
|
||||
- **GIVEN** приложение открыто на своём маршруте
|
||||
- **WHEN** браузер спрашивает этот путь заново
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Голый корень разметкой не подменяется
|
||||
|
||||
- **WHEN** запрос приходит на путь, совпадающий с корнем сервиса точно и без
|
||||
косой черты
|
||||
- **THEN** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
|
||||
|
||||
- **WHEN** запрос приходит на путь, который начинается именем корня, но не
|
||||
отделён от него косой чертой
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
|
||||
|
||||
- **GIVEN** человек вошёл и предъявил сессию
|
||||
- **WHEN** он спрашивает неизвестный путь под корнем приложения
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем приложения без сессии отвечает как все прочие его адреса
|
||||
|
||||
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом
|
||||
|
||||
- **WHEN** запрос приходит на неизвестный путь под корнем хранилища
|
||||
- **THEN** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
|
||||
|
||||
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Наблюдение приложением не подменяется
|
||||
|
||||
- **WHEN** запрос приходит на пробу здоровья
|
||||
- **THEN** отвечает проба здоровья, а не приложение
|
||||
|
||||
#### Scenario: Чужой метод отвечает отказом
|
||||
|
||||
- **WHEN** на неизвестный путь вне корней приходит запрос методом, которым
|
||||
страницу не открывают
|
||||
- **THEN** ответ имеет код `405`
|
||||
|
||||
#### Scenario: Проверка доступности разметку получает
|
||||
|
||||
- **WHEN** разметку спрашивают методом `HEAD`
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
### Requirement: Обновлённое приложение доходит до браузера
|
||||
|
||||
Сервис SHALL отдавать **ресурс из каталога, который наполняет сборщик**, с долгим
|
||||
сроком хранения и пометкой «неизменяемо», а всё прочее, включая разметку, — с
|
||||
требованием спрашивать заново. Признак — каталог, а не вид файла: имена в нём
|
||||
строит сборщик и несёт в них отпечаток содержимого, поэтому изменившийся ресурс
|
||||
приезжает под новым именем и прежний ответ устареть не может.
|
||||
|
||||
Долгий срок MUST не доставаться файлу вне этого каталога. Разметка, иконка,
|
||||
манифест и всякий файл с постоянным именем меняются под тем же именем, и
|
||||
отозвать у браузера выданное «неизменяемо» нечем: выложенное обновление не дойдёт
|
||||
до того, кто уже открывал приложение, пока он не почистит хранилище браузера
|
||||
руками. Заметить это со стороны сервиса нечем — запросов он больше не увидит.
|
||||
|
||||
Отпечаток в именах — свойство сборки, а не сервиса, и нормой его держит конвенция
|
||||
приложения, а не эта спека: сервис имён не выбирает и их нарушения не заметит.
|
||||
Здесь названо только то, что делает с ними сам сервис.
|
||||
|
||||
#### Scenario: Ресурс сборщика отдаётся с долгим сроком
|
||||
|
||||
- **WHEN** браузер спрашивает ресурс из каталога, который наполняет сборщик
|
||||
- **THEN** ответ несёт долгий срок хранения и пометку «неизменяемо»
|
||||
|
||||
#### Scenario: Разметка спрашивается заново
|
||||
|
||||
- **WHEN** браузер спрашивает разметку приложения
|
||||
- **THEN** ответ несёт требование спрашивать её заново
|
||||
- **AND** пометки «неизменяемо» в ответе нет
|
||||
|
||||
#### Scenario: Файл с постоянным именем долгого срока не получает
|
||||
|
||||
- **WHEN** браузер спрашивает файл сборки, лежащий вне каталога ресурсов
|
||||
- **THEN** ответ несёт требование спрашивать его заново
|
||||
|
||||
### Requirement: Путь, отданный приложению, в журнал не идёт
|
||||
|
||||
Сервис SHALL записывать о таком запросе **исход из закрытого перечня** —
|
||||
разметка, ресурс, отказ — и длину пути, а самого пути MUST не записывать ни в
|
||||
один свой журнал. То же относится к меткам метрик.
|
||||
|
||||
Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней
|
||||
ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений
|
||||
распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда
|
||||
аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже
|
||||
есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к
|
||||
перечню известных.
|
||||
|
||||
Журналов при этом **два**: свой и журнал хранилища, куда библиотека кладёт путь
|
||||
целиком вместе с адресом отправителя. Требование относится к обоим.
|
||||
|
||||
#### Scenario: Путь не доезжает до журнала
|
||||
|
||||
- **WHEN** приходит запрос на путь вне корней сервиса
|
||||
- **THEN** записи о нём не несут этого пути
|
||||
- **AND** несут исход и длину пути
|
||||
|
||||
#### Scenario: Длинный путь журнал не наполняет
|
||||
|
||||
- **WHEN** приходит запрос на путь длиной в тысячу знаков
|
||||
- **THEN** записи о нём не растут вместе с длиной пути
|
||||
|
||||
### Requirement: Сервис объявляет, какая сборка приложения в нём вшита
|
||||
|
||||
Сервис SHALL писать при подъёме отпечаток вшитой сборки. Он же MUST уходить
|
||||
меткой ответа с разметкой: сама разметка отдаётся с требованием спрашивать её
|
||||
заново, и без метки браузер получает полное тело вместо подтверждения — вшитый
|
||||
файл не несёт времени правки вовсе.
|
||||
|
||||
Без отпечатка «не та сборка» неотличима от «той»: вне набора проверок порядок
|
||||
шагов ничем не задан, и бинарник собирается с тем, что лежало в каталоге с
|
||||
прошлого раза. Приложение при этом открывается и ведёт себя как прежняя версия,
|
||||
а искать причину человек идёт в код сервиса.
|
||||
|
||||
#### Scenario: Отпечаток виден при подъёме
|
||||
|
||||
- **WHEN** сервис поднимается с собранным приложением
|
||||
- **THEN** в журнале есть отпечаток вшитой сборки
|
||||
|
||||
#### Scenario: Разметка несёт метку ответа
|
||||
|
||||
- **WHEN** браузер спрашивает разметку приложения
|
||||
- **THEN** ответ несёт метку, по которой её можно спросить заново
|
||||
|
||||
### Requirement: Открытое приложение показывает вошедшего
|
||||
|
||||
Приложение SHALL спрашивать сервис, кто вошёл, и показывать его имя. Отказ
|
||||
`401` MUST уводить ко входу: человек, ещё не вошедший, получает его на всяком
|
||||
адресе данных, и это его штатное состояние, а не поломка.
|
||||
|
||||
Всякий **иной** отказ и сорванный запрос MUST ко входу не уводить, а показываться
|
||||
строкой о неудаче. Трактовка «любой отказ значит не вошёл» замкнула бы круг:
|
||||
приложение ушло бы ко входу, вход вернул бы человека в приложение, и на отказе
|
||||
сервиса или ограничителя частоты круг пошёл бы заново.
|
||||
|
||||
Имя вошедшего берётся ответом сервиса, а не кукой сессии: кука недоступна
|
||||
скриптам страницы, и другого способа узнать вошедшего у приложения нет. Имени в
|
||||
ответе может не быть вовсе — тогда приложение MUST показать, что вход выполнен,
|
||||
и MUST не подставлять вместо имени адрес почты: его в ответе нет по норме
|
||||
`access`.
|
||||
|
||||
#### Scenario: Вошедший виден
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он открывает приложение
|
||||
- **THEN** приложение показывает его имя
|
||||
|
||||
#### Scenario: Не вошедшему предлагается вход
|
||||
|
||||
- **GIVEN** сессии у человека нет
|
||||
- **WHEN** он открывает приложение
|
||||
- **THEN** приложение ведёт его ко входу
|
||||
|
||||
#### Scenario: Отказ сервиса ко входу не уводит
|
||||
|
||||
- **GIVEN** сервис отвечает на вопрос о вошедшем отказом, который не является
|
||||
отсутствием сессии
|
||||
- **WHEN** человек открывает приложение
|
||||
- **THEN** приложение показывает строку о неудаче
|
||||
- **AND** ко входу оно не уводит
|
||||
|
||||
#### Scenario: Учётная запись без имени
|
||||
|
||||
- **GIVEN** человек вошёл, а имени у его учётной записи нет
|
||||
- **WHEN** он открывает приложение
|
||||
- **THEN** приложение показывает, что вход выполнен
|
||||
- **AND** адреса почты на экране нет
|
||||
Reference in New Issue
Block a user