Files
transcriber/openspec/specs/webapp/spec.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

341 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 отдавать разметку приложения на всяком пути, который не принадлежит
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корень у
сервиса остался **один**`/app` у приложения; отдельными адресами стоят
`/health` и `/metrics`.
Корней `/api` и `/_` в перечне больше нет: встроенного хранилища с его
собственным пространством и панели администратора у сервиса не осталось, и
адресов под этими именами не существует. Прежние пути хранилища и панели поэтому
отвечают тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
Резервировать имя за отказом сервис не берётся: имя, за которым ничего не стоит,
ничем не отличается от любого другого свободного имени, а второй перечень
«когда-то занятых корней» разошёлся бы с первым молча.
Этим же снимается дефект подменённого знака: путь панели, записанный кодом знака,
раскодируется в тот же путь и попадает в то же правило — правило одно, и особого
случая у него нет.
Корня `/auth` в перечне нет тоже: собственного входа у сервиса не осталось.
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
`/app` не достался бы никому и уехал бы разметкой.
Чем отвечает голый `/app`, названо прямо: он принадлежит корню приложения,
адресом приложения при этом не является и потому MUST отвечать как **неизвестный
путь под корнем приложения** — узнанному `404` телом отказа приложения,
неузнанному `401` тем же телом, каким отвечают прочие адреса под этим корнем.
Разметки в ответе нет ни в одном из двух случаев. Без этой строки «не разметка
приложения» читается как «что-нибудь ещё», и код ответа выбрала бы за нас первая
же сборка.
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
контракта остаётся отказом контракта и уходит той формой, которой этот корень
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и
приняла бы её за ответ.
Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не
подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST
отвечать `405`.
#### Scenario: Обновление страницы посреди приложения открывает тот же экран
- **GIVEN** приложение открыто на своём маршруте
- **WHEN** браузер спрашивает этот путь заново
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Голый корень разметкой не подменяется
- **GIVEN** запрос идёт с заголовком, поставленным прокси
- **WHEN** запрос приходит на путь, совпадающий с корнем приложения точно и без
косой черты
- **THEN** ответ имеет код `404`
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
#### Scenario: Голый корень неузнанному отвечает как прочие адреса под корнем
- **WHEN** запрос приходит без заголовка на путь, совпадающий с корнем приложения
точно и без косой черты
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
- **WHEN** запрос приходит на путь, который начинается именем корня, но не
отделён от него косой чертой
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний адрес входа открывает приложение
- **WHEN** запрос приходит на путь под прежним корнем входа
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний путь хранилища открывает приложение
- **WHEN** запрос приходит на путь под прежним корнем хранилища
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Прежний адрес панели открывает приложение
- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и
записанный кодом этого знака
- **THEN** оба ответа имеют код `200`
- **AND** тело каждого — разметка приложения
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
- **GIVEN** запрос идёт с заголовком, поставленным прокси
- **WHEN** он спрашивает неизвестный путь под корнем приложения
- **THEN** ответ имеет код `404`
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без
заголовка
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
- **THEN** ответ имеет код `404`
- **AND** тело ответа — не разметка приложения
### Requirement: Обновлённое приложение доходит до браузера
Сервис SHALL отдавать **ресурс из каталога, который наполняет сборщик**, с долгим
сроком хранения и пометкой «неизменяемо», а всё прочее, включая разметку, — с
требованием спрашивать заново. Признак — каталог, а не вид файла: имена в нём
строит сборщик и несёт в них отпечаток содержимого, поэтому изменившийся ресурс
приезжает под новым именем и прежний ответ устареть не может.
Долгий срок MUST не доставаться файлу вне этого каталога. Разметка, иконка,
манифест и всякий файл с постоянным именем меняются под тем же именем, и
отозвать у браузера выданное «неизменяемо» нечем: выложенное обновление не дойдёт
до того, кто уже открывал приложение, пока он не почистит хранилище браузера
руками. Заметить это со стороны сервиса нечем — запросов он больше не увидит.
Отпечаток в именах — свойство сборки, а не сервиса, и нормой его держит конвенция
приложения, а не эта спека: сервис имён не выбирает и их нарушения не заметит.
Здесь названо только то, что делает с ними сам сервис.
#### Scenario: Ресурс сборщика отдаётся с долгим сроком
- **WHEN** браузер спрашивает ресурс из каталога, который наполняет сборщик
- **THEN** ответ несёт долгий срок хранения и пометку «неизменяемо»
#### Scenario: Разметка спрашивается заново
- **WHEN** браузер спрашивает разметку приложения
- **THEN** ответ несёт требование спрашивать её заново
- **AND** пометки «неизменяемо» в ответе нет
#### Scenario: Файл с постоянным именем долгого срока не получает
- **WHEN** браузер спрашивает файл сборки, лежащий вне каталога ресурсов
- **THEN** ответ несёт требование спрашивать его заново
### Requirement: Путь, отданный приложению, в журнал не идёт
Сервис SHALL записывать о запросе **маршрут из закрытого перечня** и длину пути,
а самого запрошенного пути MUST не записывать ни в один свой журнал. То же
относится к меткам метрик. Перечень маршрутов закрыт и назван: точные адреса
наблюдения и объявленные образцы адресов приложения; всё, что ни одному из них
не отвечает, MUST обозначаться одним общим значением. Для запроса, отданного
приложению, к строке добавляется **исход из закрытого перечня** — разметка,
ресурс, отказ.
Правило MUST накрывать обе половины адресного пространства — и путь вне корней
сервиса, и путь под корнем приложения. Принадлежность пути сервису тут ничего не
меняет: `/app/<произвольный текст>` принадлежит сервису и отвечает отказом, но
множеством значений под корнем распоряжается спрашивающий ровно так же, как и
вне его.
Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней
ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений
распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда
аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже
есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к
перечню известных.
Идентификатор записи от этого не пропадает: его пишет обработчик своим полем, и
пишет он прочитанный идентификатор, а не тот, что стоял в запросе.
Журнал у сервиса теперь **один**: второй, куда чужая библиотека клала путь целиком
вместе с адресом отправителя, ушёл вместе с ней. Правило от этого не ослабло, а
перестало зависеть от настройки чужого журнала, которую мы не писали.
#### Scenario: Путь не доезжает до журнала
- **WHEN** приходит запрос на путь вне корней сервиса
- **THEN** записи о нём не несут этого пути
- **AND** несут исход и длину пути
#### Scenario: Длинный путь журнал не наполняет
- **WHEN** приходит запрос на путь длиной в тысячу знаков
- **THEN** записи о нём не растут вместе с длиной пути
#### Scenario: Путь под корнем приложения журнал не пишет
- **GIVEN** пришедший не узнан
- **WHEN** он спрашивает под корнем приложения путь, не отвечающий ни одному
объявленному образцу адреса
- **THEN** записи о нём не несут этого пути
- **AND** не растут вместе с его длиной
#### Scenario: Объявленный образец адреса приложения в журнале различим
- **WHEN** приходит запрос на объявленный адрес приложения
- **THEN** запись о нём несёт образец этого адреса, а не запрошенный путь
#### Scenario: Второго журнала у сервиса нет
- **GIVEN** сервис поднялся
- **WHEN** приходит запрос на путь вне корней сервиса
- **THEN** запись о нём появляется только в журнале сервиса
### Requirement: Сервис объявляет, какая сборка приложения в нём вшита
Сервис SHALL писать при подъёме отпечаток вшитой сборки. Он же MUST уходить
меткой ответа с разметкой: сама разметка отдаётся с требованием спрашивать её
заново, и без метки браузер получает полное тело вместо подтверждения — вшитый
файл не несёт времени правки вовсе.
Без отпечатка «не та сборка» неотличима от «той»: вне набора проверок порядок
шагов ничем не задан, и бинарник собирается с тем, что лежало в каталоге с
прошлого раза. Приложение при этом открывается и ведёт себя как прежняя версия,
а искать причину человек идёт в код сервиса.
#### Scenario: Отпечаток виден при подъёме
- **WHEN** сервис поднимается с собранным приложением
- **THEN** в журнале есть отпечаток вшитой сборки
#### Scenario: Разметка несёт метку ответа
- **WHEN** браузер спрашивает разметку приложения
- **THEN** ответ несёт метку, по которой её можно спросить заново
### Requirement: Открытое приложение показывает вошедшего
Приложение SHALL спрашивать сервис, кто пришёл, и показывать его имя. Отказ
`401` MUST показываться строкой о том, что сервис его не узнал, и MUST никуда не
уводить: своего входа у сервиса нет, а вести человека некуда — заголовок ставит
обратный прокси, и человек, которого прокси не назвал, до приложения дошёл бы
только мимо него.
Всякий **иной** отказ и сорванный запрос MUST показываться строкой о неудаче.
Разделять их приложение обязано: «сервис вас не узнал» и «сервис не отвечает» —
разные состояния, и человек по ним делает разное. Прежде отказ `401` уводил ко
входу; уводить стало некуда, и различие сохраняется ради текста, а не ради
перехода.
Имя пришедшего берётся ответом сервиса, а не заголовком запроса: заголовок ставит
прокси, и приложение его не видит вовсе. Имени в ответе может не быть — тогда
приложение MUST показать, что человек узнан, и MUST не подставлять вместо имени
адрес почты: его в ответе нет по норме `access`.
#### Scenario: Узнанный виден
- **GIVEN** запрос приложения идёт с заголовком, поставленным прокси
- **WHEN** он открывает приложение
- **THEN** приложение показывает его имя
#### Scenario: Неузнанному показывают, что его не узнали
- **GIVEN** сервис отвечает на вопрос о пришедшем кодом `401`
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о том, что его не узнали
- **AND** никуда его не уводит
#### Scenario: Отказ сервиса от неузнавания отличается
- **GIVEN** сервис отвечает на вопрос о пришедшем отказом, который не является
неузнаванием
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о неудаче
- **AND** эта строка не та, которой оно сообщает о неузнавании