вход переехал на доверенный заголовок Authelia вместо собственного OIDC

- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
av
2026-08-22 20:24:22 +03:00
parent e4441f3c49
commit 7f33c957e5
63 changed files with 5257 additions and 2305 deletions
@@ -0,0 +1,674 @@
## ADDED Requirements
### Requirement: Пришедшего называет доверенный источник
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
не остаётся.
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
барьер вовсе.
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
требованием учётной записи на адресах приложения.
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает
хранилище, а на новом имени ещё и пишет в него; выполненное раньше ограничителя,
оно работало бы на запросах, которые тот уже отверг, и поток отвергнутых
обращений заводил бы учётные записи, которые потом не убираются ничем.
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
Область — корень приложения и адрес, которым хранилище выдаёт короткий токен
файла; она MUST выводиться из объявленного адресного пространства сервиса, а не
перечисляться вторым списком. Собственная поверхность хранилища под узнавание
MUST не подпадать: правка учётной записи, её чтение и перечисление коллекции
пользователей остаются недостижимыми для узнанного, потому что ключ учётной
записи лежит там обычной колонкой, а правило правки у коллекции — библиотечное и
разрешает править свою запись. Расширение области на всё дало бы узнанному
переписать себе ключ на чужое имя и забрать чужой архив.
Сужение области закрывает и вторую вещь: узнавание MUST не срабатывать на пробе
здоровья, на метриках и на ресурсах приложения. Иначе запрос за каждой картинкой
стоил бы обращения к базе, а первый такой запрос с новым именем — записи в неё.
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
без этой нормы все неназванные собрались бы в одну учётную запись с общим
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
бы вход анониму. Значение сверх объявленного предела длины и значение с
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
считает колонка.
Отказ хранилища при узнавании MUST кончаться отказом сервиса, а не молчаливым
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
настройке журнала: обе строки означают поломку контура, а поломка, записанная
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
которого никто не может войти, не отличит своей поломки (перечень доверенных
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
нести значения заголовка.
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
MUST не выдавать — ни куки, ни токена сессии. Исключение одно и названо здесь же:
**короткий токен файла**, который хранилище выдаёт узнанному, чтобы тот прошёл по
ссылке на файл записи; его нормирует capability `storage`, а срок его жизни
назначается числом и живёт там, где проект держит числовые настройки. На этот
срок — и только на него — отзыв доступа до файловой ссылки не доходит.
В остальном смысл именно таков: отзыв доступа судит провайдер на каждом
обращении, а не однажды выданный срок.
Собственный токен хранилища, предъявленный запросом, MUST побеждать заголовок:
владелец панели предъявляет свой, и подмена его учётной записью пользователя
отобрала бы у него панель посреди работы.
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
адресом его почты.
#### Scenario: Заголовок с доверенного адреса узнаёт человека
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
- **THEN** запрос идёт от имени учётной записи с этим значением
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
- **GIVEN** адреса источника в перечне доверенных нет
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
- **THEN** ответ имеет код `401`
- **AND** учётной записи с этим значением не появляется
#### Scenario: Предъявленный токен побеждает заголовок
- **GIVEN** запрос несёт и заголовок `Remote-User`, и годный собственный токен
хранилища
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается предъявитель токена
#### Scenario: Протухший токен узнаванию не мешает
- **GIVEN** запрос несёт заголовок `Remote-User` и негодный либо протухший токен
хранилища
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается названный заголовком
#### Scenario: Пустой заголовок не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Два значения одного заголовка не узнают никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Значение сверх предела длины не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Поверхность хранилища узнаванию не подпадает
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** он правит запись коллекции пользователей собственным адресом
хранилища
- **THEN** правка не проходит
#### Scenario: Проба здоровья учётной записи не заводит
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
- **THEN** учётной записи не появляется
#### Scenario: Недоверенный источник виден в журнале
- **WHEN** запрос с заголовком приходит с недоверенного адреса
- **THEN** журнал несёт строку об этом исходе с адресом пира
- **AND** значения заголовка в ней нет
#### Scenario: Сервис не ставит браузеру куки
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения проходит с заголовком
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
#### Scenario: Значения заголовка нет в журнале
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
- **THEN** значение заголовка не встречается ни в одной журнальной записи
### Requirement: Учётная запись заводится первым обращением
Сервис SHALL заводить учётную запись при первом обращении с новым значением
`Remote-User` и MUST находить её по тому же значению при каждом следующем.
Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой
коллекции пользователей.
Имя и адрес почты MUST браться из заголовков того же запроса, и только при
заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по
пределу колонки и чистится от управляющих знаков, негодный адрес почты
отбрасывается. Негодное значение необязательного поля MUST не отменять
заведения записи — иначе человек с длинным именем у провайдера не завёлся бы
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное обращение MUST не переписывать: иначе
всякий запрос был бы записью в базу, а правка имени у провайдера меняла бы
карточку человека молча, посреди его работы.
Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом
он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение
с чужим адресом досталось бы чужой записи.
**Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.**
Ни запросом снаружи, ни рукой в панели: переписанный ключ отдаёт архив
следующему, кто придёт с этим именем, а вернуть его будет нечем — владелец записи
назначается один раз и не меняется. Правило доступа коллекции пользователей MUST
закрывать правку записи снаружи наглухо, и MUST это держать схема, а не область
действия узнавания: защита, стоящая на том, что до поверхности хранилища никто не
дотянется, однажды уже оказалась случайной.
Одновременные первые обращения одним значением MUST кончаться одной учётной
записью: уникальность держит схема, а не порядок обращений.
**Два отказа уникальности различаются, и исход у них разный.** Отказ по ключевой
колонке — это гонка двух первых обращений одним именем, и он MUST кончаться
повторным поиском и продолжением работы. Отказ по любой другой колонке — адрес
почты, пришедший от провайдера, уже занят другой учётной записью — MUST кончаться
заведением записи **без почты**: она необязательна. Без этого разреза второй
человек с общим почтовым ящиком не завёлся бы никогда, потому что повторный поиск
по имени снова ничего не находит.
Цена ключа называется целиком, обеими сторонами. Переименование пользователя у
провайдера заводит **новую** учётную запись, и записи прежней остаются у прежней;
слить их или убрать нечем — владелец записи не меняется, а учётная запись с
записями не удаляется по норме `storage`. **Логин же переиспользуем**: человек,
которому провайдер выдал логин ушедшего, при первом обращении попадает в
существующую запись и получает весь её архив. Не допускать переиспользования —
работа провайдера; сервису неизменяемого признака заголовок не приносит, и эта
цена принимается, а не обходится.
#### Scenario: Первое обращение заводит запись
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** приходит запрос с заголовком `Remote-User`
- **THEN** учётная запись появляется
- **AND** запрос идёт от её имени
#### Scenario: Повторное обращение попадает в ту же запись
- **GIVEN** учётная запись заведена первым обращением
- **WHEN** приходит второй запрос с тем же значением заголовка
- **THEN** новой учётной записи не появляется
- **AND** запрос идёт от имени прежней
#### Scenario: Разным значениям — разные записи
- **WHEN** приходят запросы с двумя разными значениями заголовка
- **THEN** заводятся две учётные записи
- **AND** записи одного не видны другому
#### Scenario: Имя не переписывается вторым обращением
- **GIVEN** учётная запись заведена с одним значением `Remote-Name`
- **WHEN** приходит запрос с тем же `Remote-User` и другим `Remote-Name`
- **THEN** имя учётной записи остаётся прежним
#### Scenario: Два одновременных первых обращения дают одну запись
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** два запроса с одним значением заголовка приходят одновременно
- **THEN** в коллекции пользователей появляется ровно одна запись
- **AND** оба запроса идут от её имени
#### Scenario: Занятая почта не мешает завести запись
- **GIVEN** учётная запись с этим адресом почты уже заведена
- **WHEN** приходит первое обращение с другим `Remote-User` и тем же
`Remote-Email`
- **THEN** заводится своя учётная запись
- **AND** адреса почты у неё нет
#### Scenario: Ключ учётной записи не правится и рукой в панели
- **GIVEN** учётная запись заведена
- **WHEN** её ключ меняют сохранением записи мимо адресов приложения
- **THEN** сохранение отвергается, а ключ остаётся прежним
#### Scenario: Негодное имя не отменяет заведения
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** приходит обращение с именем длиннее предела колонки
- **THEN** учётная запись заводится, а имя обрезано по пределу
#### Scenario: Негодная почта отбрасывается, а не отменяет заведение
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** приходит обращение с адресом почты, не похожим на адрес
- **THEN** учётная запись заводится без почты
#### Scenario: Отвергнутый ограничителем запрос учётной записи не заводит
- **GIVEN** бюджет ограничителя частоты выбран
- **WHEN** приходит обращение с новым значением заголовка
- **THEN** ответ несёт отказ ограничителя
- **AND** учётной записи не появляется
#### Scenario: Заведение учётной записи видно в журнале
- **WHEN** приходит первое обращение с новым значением заголовка
- **THEN** журнал несёт строку о заведении с идентификатором записи
- **AND** значения заголовка в ней нет
#### Scenario: Ключ учётной записи снаружи не правится
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** он правит ключ своей учётной записи запросом к хранилищу
- **THEN** правка не проходит, а ключ остаётся прежним
### Requirement: Доверенный источник объявлен настройкой
Сервис SHALL брать перечень доверенных адресов из конфига и MUST ронять старт,
когда перечень пуст либо его строки не читаются как адрес или подсеть. Пустой
перечень значит «не верить никому»: сервис поднялся бы никого не узнающим, а
узнать об этом было бы неоткуда.
Отказ старта MUST называть имя ключа. Ни адресов провайдера, ни идентификатора
клиента, ни секрета клиента в конфиге MUST не быть: менять код больше не на что,
и секрет уходит из конфига вместе с протоколом.
Перечень MUST называться строкой журнала при подъёме. Сервис, никого не узнающий
из-за неверного перечня, иначе неотличим от сервиса, до которого заголовок не
доходит вовсе, — а это разные поломки в разных местах.
#### Scenario: Пустой перечень роняет старт
- **WHEN** сервис поднимается с пустым перечнем доверенных адресов
- **THEN** старт кончается отказом
- **AND** отказ называет имя ключа
#### Scenario: Негодная строка перечня роняет старт
- **WHEN** сервис поднимается с перечнем, где строка не читается как адрес или
подсеть
- **THEN** старт кончается отказом
#### Scenario: Перечень виден в журнале подъёма
- **WHEN** сервис поднимается с заполненным перечнем
- **THEN** журнал подъёма называет доверенные адреса
## MODIFIED Requirements
### Requirement: Иных способов открыть сессию нет
Сервис SHALL оставить собственные адреса входа хранилища неработающими: ни один
из них MUST не давать доступа и MUST не менять учётной записи. Собственное
создание записи в коллекции пользователей, вход по паролю, вход по одноразовому
коду, обмен кода у внешнего провайдера и восстановление доступа MUST быть
выключены настройкой коллекции.
**Закрывается не только вход, но и правка.** Перечисление, чтение, создание,
правка и удаление записи коллекции пользователей MUST быть закрыты правилами
доступа — то есть оставлены пустыми, что у хранилища означает «только владелец
панели». Умолчание библиотеки открывает всё это владельцу самой записи, и до сих
пор оно ничему не мешало ровно потому, что до поверхности хранилища браузер с
кукой не дотягивался. С узнаванием по заголовку эта защита перестаёт быть
защитой, а ключ учётной записи лежит в коллекции обычной колонкой: правка своей
записи и есть захват чужого имени. Наш код читает и заводит запись мимо правил,
панель работает суперпользователем, своих экранов профиля сервис не заводит —
закрытие не стоит ничего.
Требование отдельно от «Пришедшего называет доверенный источник» намеренно: то
нормирует наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
хранилища заводит коллекцию пользователей с открытым созданием записи и
включённым входом по паролю, и без этого требования узнавание по заголовку
обходится двумя запросами: завести себе запись, войти по паролю, предъявить
полученное.
Отдельная цена у открытого создания записи — захват учётной записи: запись,
заведённая посторонним под чужим именем, досталась бы первому же настоящему
обращению с этим именем.
Закрытие MUST не отменять заведения записи самим сервисом: учётную запись при
первом обращении заводит наш код, а не запрос снаружи, и правило коллекции ему
не судья.
#### Scenario: Завести учётную запись самому нельзя
- **WHEN** запрос снаружи создаёт запись в коллекции пользователей
- **THEN** ответ несёт отказ, а записи не появляется
#### Scenario: Обращение с заголовком запись заводит
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** запрос с заголовком приходит с доверенного адреса
- **THEN** учётная запись появляется
#### Scenario: Вход паролем недоступен
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
- **THEN** ответ несёт отказ, а доступа не открывается
#### Scenario: Обмен кода у провайдера недоступен
- **WHEN** запрос идёт на обмен кода внешнего провайдера к коллекции
пользователей
- **THEN** ответ несёт отказ, а доступа не открывается
#### Scenario: Восстановление доступа недоступно
- **WHEN** запрос просит восстановление пароля или одноразовый код
- **THEN** ответ несёт отказ
#### Scenario: Правка учётной записи снаружи закрыта
- **GIVEN** человек узнан и его учётная запись заведена
- **WHEN** он правит свою запись в коллекции пользователей запросом к хранилищу
- **THEN** ответ несёт отказ, а запись остаётся прежней
#### Scenario: Перечисление учётных записей закрыто
- **GIVEN** человек узнан
- **WHEN** он перечисляет коллекцию пользователей запросом к хранилищу
- **THEN** ответ несёт отказ
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка,
которым назван пришедший, ни короткий токен файла, ни адрес почты пользователя.
Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком
задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её
не убрать. Требование того же рода, что и запрет писать имя файла в хранилище:
там строка журнала собирала бы ссылку на чужую запись, здесь — имя, которым
довольно назваться, чтобы стать этим человеком.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из
заголовка — тоже: это логин человека у провайдера.
Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом,
доступа сам по себе не даёт и без него путь запроса не прослеживается.
#### Scenario: Значения заголовка нет в журнале
- **WHEN** запрос с заголовком проходит через сервис
- **THEN** значение заголовка не встречается ни в одной журнальной записи
#### Scenario: Адреса почты нет в журнале
- **WHEN** приходит первое обращение и учётная запись заводится
- **THEN** адрес почты не встречается ни в одной журнальной записи
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки
допуска MUST не делать. Кто допущен, определяет правило провайдера на домен
сервиса — настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и правило, настроенное слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только
первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному
значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок
жизни этого значения. Теперь отзыв действует со следующего запроса.
Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на
том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси,
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
#### Scenario: Названный провайдером получает доступ
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
прокси
- **THEN** доступ открывается, а учётная запись заводится, если её не было
- **AND** сервис не спрашивает у заголовков ничего сверх имени, показного имени
и адреса почты
#### Scenario: Отзыв у провайдера действует со следующего запроса
- **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ
- **WHEN** приходит следующий его запрос
- **THEN** прокси заголовка не ставит, и запрос получает отказ
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` неузнанному. Ни у пробы
здоровья, ни у сборщика метрик учётной записи нет, и требование узнавания
остановило бы наблюдение за сервисом.
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
адреса не несут.
Заголовок, пришедший с недоверенного адреса, MUST не менять их ответа: узнавание
отказа не выдаёт, и посторонняя строка в запросе не вправе гасить наблюдение.
#### Scenario: Проба здоровья доступна неузнанному
- **WHEN** запрос приходит на `GET /health` без заголовка
- **THEN** ответ имеет код `200`
#### Scenario: Метрики доступны неузнанному
- **WHEN** запрос приходит на `GET /metrics` без заголовка
- **THEN** ответ имеет код `200`
#### Scenario: Чужой заголовок наблюдения не гасит
- **WHEN** запрос на `GET /health` приходит с недоверенного адреса с заголовком
`Remote-User`
- **THEN** ответ имеет код `200`
### Requirement: У записи есть владелец, и чужую ей не отдают
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
имени которой запись принята, — и MUST отдавать данные такой записи только её
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни
конвейером, ни рукой в панели: колонка владельца пустого значения не принимает, и
норму эту держит capability `storage`.
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
доступа, ролей и передачи записи другому сервис не знает.
Владелец MUST браться из узнанного предъявителя и ниоткуда больше. Владелец,
пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `archive`: там живут адреса чтения записи, и держатель нормы обязан
быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
больше нет: спрашивающий с пустым владельцем — это вызов, у которого нет учётной
записи, и отвечать ему надо отказом, а не выборкой. Держится оно отдельно от
схемы намеренно: схема запрещает **заводить** ничью запись, а это правило
запрещает **спрашивать** ничьим именем, и одно другое не заменяет.
#### Scenario: Своя запись доступна
- **GIVEN** человек узнан и принял запись
- **WHEN** он спрашивает карточку этой записи
- **THEN** ответ несёт данные записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним узнанным
- **WHEN** её карточку спрашивает другой узнанный
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
- **WHEN** запрос на приём записи несёт своё значение владельца
- **THEN** владельцем принятой записи становится узнанный предъявитель
#### Scenario: Ничью запись завести нечем
- **WHEN** запись пытаются завести с пустым владельцем
- **THEN** хранилище её не сохраняет
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены две записи: своя и чужая
- **WHEN** карточку каждой спрашивают с пустым владельцем
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
### Requirement: Приложение узнаёт вошедшего
Сервис SHALL отдавать приложению сведения о том, кто пришёл, — `GET /app/me` — и
MUST отвечать отказом `401`, когда пришедший не узнан. Своей страницы со
скриптом, которой сервер отрисовал бы имя пришедшего, у сервиса нет: приложение
собирает разметку само и пришедшего узнаёт ответом.
Заголовок ставит прокси, и прочитать его из браузера приложение не может вовсе —
этот адрес единственный способ узнать, кто пришёл.
Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями
`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и
принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает
ему выходить наружу наравне с журналом.
#### Scenario: Пришедший узнан
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** приложение спрашивает, кто пришёл
- **THEN** ответ несёт идентификатор его учётной записи
#### Scenario: Пришедший не узнан
- **WHEN** приложение спрашивает, кто пришёл, без заголовка
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт учётной записи
#### Scenario: Адреса почты в ответе нет
- **GIVEN** пришедший узнан, и у его учётной записи есть адрес почты
- **WHEN** приложение спрашивает, кто пришёл
- **THEN** адреса почты в ответе нет
### Requirement: Приложение отдаётся без сессии
Сервис SHALL отдавать разметку приложения и её ресурсы неузнанному. Перечень
адресов, открытых без узнавания, пополняется ими: прежде в нём стояли только
проба здоровья и метрики.
Причина внешняя: заголовок ставит прокси, и человек, которого прокси не назвал,
до приложения не доходит вовсе. Разметка при этом обязана отдаваться и ему —
иначе неудача узнавания выглядела бы поломкой сервиса, а не отказом входа. Цена
открытости названа здесь же и невелика: ни разметка, ни ресурсы содержимого
записей не несут — они одинаковы для всех и собираются до всякого запроса.
Открытость MUST не касаться данных: всякий адрес под корнем приложения
по-прежнему требует узнанного предъявителя, и приложение, открытое неузнанным,
не получает ни одной записи.
#### Scenario: Разметка доступна неузнанному
- **WHEN** запрос приходит на корень сервиса без заголовка
- **THEN** ответ имеет код `200`
- **AND** тело ответа — разметка приложения
#### Scenario: Ресурс приложения доступен неузнанному
- **WHEN** запрос приходит на ресурс приложения без заголовка
- **THEN** ответ имеет код `200`
#### Scenario: Данные неузнанному не отдаются
- **GIVEN** приложение открыто без заголовка
- **WHEN** оно спрашивает список записей
- **THEN** ответ имеет код `401`
## REMOVED Requirements
### Requirement: Вход через внешнего провайдера
**Reason**: Сервис больше не ведёт входа. Собственный адрес, уводящий к
провайдеру, адрес возврата, сверка состояния, проверочный код PKCE и обмен кода
средствами хранилища убраны целиком: кто пришёл, называет обратный прокси,
сходивший к провайдеру на **каждом** запросе.
**Migration**: Узнавание нормирует требование «Пришедшего называет доверенный
источник». Правило провайдера переезжает с клиента OIDC на домен сервиса и живёт
в настройках выкладки.
### Requirement: Сессия предъявляется кукой
**Reason**: Сессии у сервиса нет вовсе. Кука `transcriber_session`, кука
состояния входа `transcriber_login` и слой, перекладывающий значение куки в
заголовок хранилища, убраны: узнавание идёт заголовком на каждом запросе, и
значения, переживающего запрос, сервис не выдаёт.
**Migration**: Требование «Пришедшего называет доверенный источник» — там же
записан запрет выдавать браузеру что-либо, переживающее запрос.
### Requirement: Сессия переживает перезапуск сервиса
**Reason**: Переживать перезапуск нечему. Узнавание не опирается ни на значение,
выданное однажды, ни на секрет подписи: заголовок приходит с каждым запросом.
**Migration**: Не требуется — свойство выполняется по построению.
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
**Reason**: Срока нет, потому что нет самой сессии. Он существовал ровно затем,
чтобы отзыв доступа у провайдера когда-нибудь дошёл до сервиса; теперь провайдер
судит каждый запрос, и отзыв действует со следующего. Запрет продления и способ
закрыть чужие сессии немедленно теряют предмет вместе со сроком.
**Migration**: Отзыв доступа нормирует требование «Кого пускать, решает
провайдер», сценарий про следующий запрос.
### Requirement: Выход прекращает доступ
**Reason**: Выхода у сервиса нет: обесценивать нечего и куку убирать неоткуда.
Выходят у провайдера, и со следующего запроса прокси заголовка уже не поставит.
**Migration**: Требование «Кого пускать, решает провайдер».
### Requirement: Секрет провайдера живёт в конфиге
**Reason**: Секрета клиента у сервиса больше нет: обменивать код не на что.
Вместе с ним из конфига уходят адреса провайдера и идентификатор клиента, а из
настроек коллекции пользователей — приведение их к конфигу при подъёме. Отсюда же
снимается изъятие из инварианта «Секрет не покидает конфиг»: чтение файла базы
больше не равносильно чтению секрета клиента.
**Migration**: Настройку узнавания нормирует требование «Доверенный источник
объявлен настройкой»; проверка целостности настройки на старте сохраняется там.
@@ -0,0 +1,100 @@
## MODIFIED Requirements
### Requirement: Отказ называет причину, а не место
Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине**
отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
разнице кодов перебирается список заведённых записей;
- узнанный предъявитель без учётной записи пользователя — `403`;
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
отвечать чужая и ничья запись;
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
негодный размер страницы;
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
- состояние, в котором действие недоступно, — `409`: текста запрошенного вида у
записи ещё нет;
- отказ хранилища и всякая неназванная причина — `500`.
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места
нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую
базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как
«моя запись пропала», а второе не говорит ему ничего.
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы
различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» —
все три `400`, — а приложению надо решать, предлагать ли повтор и что показать
человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же
задача экрана переписала бы контракт, согласованный здесь один раз.
Имена полей и перечень кодов нормативны — их разбирает каждый экран, и
выбранные кодом они стали бы контрактом молча:
- поля тела: `error_code` и `message`;
- перечень `error_code`: `unauthorized`, `forbidden`, `not_found`,
`bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`.
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на
адресах приложения две, а самый частый отказ у человека на мобильной сети —
«запись больше потолка» — приходит телом библиотеки, без кода и без предела
числом.
Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа
заводится добавлением в него, а не строкой в обработчике: иначе ветвь по
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
журнале аварию там, где её нет.
Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали
устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка
остаётся в журнале владельца сервиса.
#### Scenario: Сбой хранилища виден как сбой
- **GIVEN** хранилище отвечает отказом драйвера на чтение записи
- **WHEN** владелец спрашивает свою запись
- **THEN** ответ имеет код `500`
- **AND** тела записи в ответе нет
#### Scenario: Негодная запись видна как негодная
- **GIVEN** источник метаданных не может прочитать присланную запись
- **WHEN** отправитель шлёт её приёмом
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
- **AND** причина отказа в тело ответа не попадает
#### Scenario: Отказ по пустому владельцу
- **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет
- **WHEN** он шлёт запись приёмом
- **THEN** ответ имеет код `403`
#### Scenario: Форма тела одна на всех ветвях отказа
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
отсутствию учётной записи и по сбою хранилища
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
полями
- **AND** код отказа принадлежит закрытому перечню
- **AND** ни одно из них не содержит сырого текста ошибки
#### Scenario: Неузнанному неизвестная запись неотличима от заведённой
- **GIVEN** заведена запись
- **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по
неизвестному идентификатору
- **THEN** оба ответа имеют код `401` и одно тело
#### Scenario: Запись сверх потолка размера
- **GIVEN** отправитель узнан
- **WHEN** он шлёт запись длиннее потолка размера
- **THEN** ответ имеет код `413`, а тело несёт предел числом
- **AND** ни файла, ни аудиозаписи не заводится
@@ -0,0 +1,111 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
`multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос от неузнанного MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`.
Приём стоит тем же адресом, что и список записей, и отличается от него только
методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло
содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя
запись он и создаёт.
Ответ MUST нести **список** заведённых записей и место под признак повторного
файла у каждой, даже когда файл в запросе один. Форма согласована один раз и
вперёд: приём, отдающий одну запись, пришлось бы переписывать вместе с приёмом
нескольких файлов и с распознаванием повтора по содержимому, а экран загрузки —
переделывать под вторую форму. Число файлов в запросе при этом остаётся прежним:
меняется форма ответа, не число файлов.
Элемент списка MUST нести те же поля, что и карточка записи, плюс признак
повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча.
Состав карточки нормирует capability `archive`.
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес
опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка
публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней
программы на прежнем контракте не существует — своего токена у неё не было.
Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не
перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и
перечисленный здесь второй раз он разошёлся бы с ним молча. Рубеж называет
достигнутое, а не предстоящее, и `created` в перечне отсутствует вовсе.
Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи,
и код с телом такого отказа нормирует capability `archive` наравне с прочими
ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание —
самый частый отказ у человека на мобильной сети — прежде не был нормирован
ничем и уходил телом ограничителя тела, мимо единой формы.
Отказ неузнанному наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельцем принятой записи приём SHALL назначать узнанного предъявителя. Обязательность
владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого
значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в
приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как
запись попадёт в память, а схема отвечала бы отказом сохранения после укладки
файла.
Предъявитель, узнанный без учётной записи пользователя, MUST получать
отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ
неузнанному. Владелец панели, предъявивший собственный токен хранилища, — именно
такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него
нет, и владельцем записи он стать не может.
Код здесь другой, чем у запроса от неузнанного, и это не оплошность: `401` значит
«предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет —
оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены.
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
пишется.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
место под признак повторного файла
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель
#### Scenario: Узнанный без учётной записи пользователя
- **GIVEN** предъявлен собственный токен владельца панели
- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Пришедший не узнан
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель узнан
- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель узнан
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
@@ -0,0 +1,143 @@
## MODIFIED Requirements
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, — и правилом просмотра коллекции.
Правило MUST пускать только владельца файла: незаданное означает «только владелец
панели», и тогда файла не получит и узнанный, а прежнее «всякий узнанный»
отдавало чужое аудио тому, кто знает идентификатор записи.
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
токена: отказ наступает там, и требовать его от выдачи значит требовать
механизма, которого нет.
Отсюда порядок для потребителя: узнавание → токен файла → ссылка с этим токеном.
Адрес выдачи токена лежит в пространстве хранилища, и узнавание по заголовку MUST
на нём работать — иначе файл записи недостижим для браузера вовсе. Одного
заголовка при этом мало: без токена ссылка файла не отдаёт, и это свойство
хранилища, а не недосмотр.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и узнавания, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **AND** забирающий узнан и взял токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Неузнанному файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают неузнанным
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Токен файла выдаётся узнанному по заголовку
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
- **WHEN** он просит у хранилища токен файла
- **THEN** токен выдаётся
#### Scenario: Конвейер читает файл без узнавания
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из файловой системы хранилища и шаг проходит
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
значение оставалось у файлов, заведённых конвейером для записи без владельца;
таких записей больше не заводится, и разное правило у записи и у её файла
читалось бы как недосмотр.
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
остановится признаком на первом же приведении.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
касаясь пути, по которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним узнанным
- **WHEN** другой узнанный идёт по ссылке на файл этой записи со своим токеном
- **THEN** содержимого он не получает
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
- **THEN** содержимое отдаётся
#### Scenario: Файл без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** файл записи пытаются сохранить с пустым владельцем
- **THEN** хранилище его не сохраняет
#### Scenario: Приведённая копия получает владельца записи
- **GIVEN** запись с владельцем дошла до приведения
- **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа
@@ -0,0 +1,126 @@
## MODIFIED Requirements
### 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 спрашивать сервис, кто пришёл, и показывать его имя. Отказ
`401` MUST показываться строкой о том, что сервис его не узнал, и MUST никуда не
уводить: своего входа у сервиса нет, а вести человека некуда — заголовок ставит
обратный прокси, и человек, которого прокси не назвал, до приложения дошёл бы
только мимо него.
Всякий **иной** отказ и сорванный запрос MUST показываться строкой о неудаче.
Разделять их приложение обязано: «сервис вас не узнал» и «сервис не отвечает» —
разные состояния, и человек по ним делает разное. Прежде отказ `401` уводил ко
входу; уводить стало некуда, и различие сохраняется ради текста, а не ради
перехода.
Имя пришедшего берётся ответом сервиса, а не заголовком запроса: заголовок ставит
прокси, и приложение его не видит вовсе. Имени в ответе может не быть — тогда
приложение MUST показать, что человек узнан, и MUST не подставлять вместо имени
адрес почты: его в ответе нет по норме `access`.
#### Scenario: Узнанный виден
- **GIVEN** запрос приложения идёт с заголовком, поставленным прокси
- **WHEN** он открывает приложение
- **THEN** приложение показывает его имя
#### Scenario: Неузнанному показывают, что его не узнали
- **GIVEN** сервис отвечает на вопрос о пришедшем кодом `401`
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о том, что его не узнали
- **AND** никуда его не уводит
#### Scenario: Отказ сервиса от неузнавания отличается
- **GIVEN** сервис отвечает на вопрос о пришедшем отказом, который не является
неузнаванием
- **WHEN** человек открывает приложение
- **THEN** приложение показывает строку о неудаче
- **AND** эта строка не та, которой оно сообщает о неузнавании