Files
transcriber/openspec/specs/webapp/spec.md
T
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00

276 lines
20 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 отдавать разметку приложения на всяком пути, который не принадлежит
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни
перечислены поимённо — `/api` у хранилища, `/app` у приложения, `/_` у панели, —
отдельными адресами стоят `/health` и `/metrics`.
Корня `/auth` в перечне больше нет: собственного входа у сервиса не осталось, и
адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают тем
же, чем отвечает всякий путь вне корней, — разметкой приложения. Резервировать имя
за отказом сервис не берётся: имя, за которым ничего не стоит, ничем не отличается
от любого другого свободного имени, а второй перечень «когда-то занятых корней»
разошёлся бы с первым молча.
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/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: Прежний адрес входа открывает приложение
- **WHEN** запрос приходит на путь под прежним корнем входа
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
- **GIVEN** запрос идёт с заголовком, поставленным прокси
- **WHEN** он спрашивает неизвестный путь под корнем приложения
- **THEN** ответ имеет код `404`
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без
заголовка
- **THEN** ответ имеет код `401`
- **AND** тело ответа — не разметка приложения
#### Scenario: Неизвестный путь под корнем хранилища отвечает отказом
- **WHEN** запрос приходит на неизвестный путь под корнем хранилища
- **THEN** тело ответа — не разметка приложения
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
- **THEN** ответ имеет код `404`
- **AND** тело ответа — не разметка приложения
### 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 показываться строкой о неудаче.
Разделять их приложение обязано: «сервис вас не узнал» и «сервис не отвечает» —
разные состояния, и человек по ним делает разное. Прежде отказ `401` уводил ко
входу; уводить стало некуда, и различие сохраняется ради текста, а не ради
перехода.
Имя пришедшего берётся ответом сервиса, а не заголовком запроса: заголовок ставит
прокси, и приложение его не видит вовсе. Имени в ответе может не быть — тогда
приложение MUST показать, что человек узнан, и MUST не подставлять вместо имени
адрес почты: его в ответе нет по норме `access`.
#### Scenario: Узнанный виден
- **GIVEN** запрос приложения идёт с заголовком, поставленным прокси
- **WHEN** он открывает приложение
- **THEN** приложение показывает его имя
#### Scenario: Неузнанному показывают, что его не узнали
- **GIVEN** сервис отвечает на вопрос о пришедшем кодом `401`
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о том, что его не узнали
- **AND** никуда его не уводит
#### Scenario: Отказ сервиса от неузнавания отличается
- **GIVEN** сервис отвечает на вопрос о пришедшем отказом, который не является
неузнаванием
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о неудаче
- **AND** эта строка не та, которой оно сообщает о неузнавании