приём и чтение записей сведены к одному контракту приложения

- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
This commit is contained in:
av
2026-08-15 13:51:23 +03:00
parent 79ff12548f
commit 3a2da3004b
55 changed files with 5506 additions and 466 deletions
+56 -11
View File
@@ -149,15 +149,19 @@ MUST не заводить: учётные записи держит прова
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
кукой получал бы не то, что предъявил на собственных адресах хранилища.
Область действия слоя MUST быть ограничена адресами приложения — приёмом записи
и опросом готовности. Собственная поверхность хранилища под него не подпадает:
часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и
расширение слоя на всё сняло бы эту защиту молча.
Область действия слоя MUST быть ограничена **адресами приложения** — теми, что
живут под его собственным корнем. Собственная поверхность хранилища под него не
подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не
шлёт, и расширение слоя на всё сняло бы эту защиту молча.
Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым
адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия,
предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы.
#### Scenario: Кука открывает доступ
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой и без заголовка
- **WHEN** он шлёт запрос к адресу приложения с этой кукой и без заголовка
- **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом
@@ -170,6 +174,12 @@ MUST не заводить: учётные записи держит прова
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **THEN** проверку проходит значение заголовка, а не куки
#### Scenario: Слой не расширяется на поверхность хранилища
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к собственному адресу хранилища с одной лишь кукой
- **THEN** значение куки в заголовок не перекладывается
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
@@ -362,10 +372,11 @@ MUST не делать. Кто допущен, определяет правил
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
capability `archive`: там живут адреса чтения записи, и держатель нормы обязан
быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
@@ -377,13 +388,13 @@ capability `intake`: там живёт адрес опроса, и держат
#### Scenario: Своя запись доступна
- **GIVEN** человек вошёл и принял запись
- **WHEN** он спрашивает состояние этой записи своей сессией
- **THEN** ответ несёт состояние записи
- **WHEN** он спрашивает карточку этой записи своей сессией
- **THEN** ответ несёт данные записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший
- **WHEN** её карточку спрашивает другой вошедший
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
@@ -399,5 +410,39 @@ capability `intake`: там живёт адрес опроса, и держат
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены две записи: своя и чужая
- **WHEN** состояние каждой спрашивают с пустым владельцем
- **WHEN** карточку каждой спрашивают с пустым владельцем
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
### Requirement: Приложение узнаёт вошедшего
Сервис SHALL отдавать приложению сведения о том, кто вошёл, — `GET /app/me` — и
MUST отвечать отказом `401`, когда сессии нет. Своей страницы со скриптом,
которой сервер отрисовал бы имя вошедшего, у сервиса нет: приложение собирает
разметку само и вошедшего узнаёт ответом.
Кука сессии недоступна скриптам страницы, и прочитать из неё имя приложение не
может вовсе — этот адрес единственный способ его узнать.
Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями
`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и
принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает
ему выходить наружу наравне с журналом.
#### Scenario: Вошедший узнан
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** приложение спрашивает, кто вошёл
- **THEN** ответ несёт идентификатор его учётной записи
#### Scenario: Сессии нет
- **WHEN** приложение спрашивает, кто вошёл, без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт учётной записи
#### Scenario: Адреса почты в ответе нет
- **GIVEN** человек вошёл, и у его учётной записи есть адрес почты
- **WHEN** приложение спрашивает, кто вошёл
- **THEN** адреса почты в ответе нет