Files
transcriber/openspec/changes/archive/2026-08-15-app-json-contract/specs/access/spec.md
T
av 3a2da3004b приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
2026-08-15 13:51:23 +03:00

10 KiB

MODIFIED Requirements

Requirement: Сессия предъявляется кукой

Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и своей страницы со скриптом для этого не требуется. Кука сессии MUST быть недоступна скриптам страницы (HttpOnly), MUST не уходить по незашифрованному соединению (Secure) и MUST не отправляться при переходе с чужого сайта (SameSite=Lax или строже).

Имя куки нормативно — transcriber_session: смена имени молча выкидывает всех вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.

Хранилище читает предъявленную сессию заголовком Authorization, и этот способ остаётся рабочим: его требуют собственные адреса аутентификации хранилища. Сервис MUST перекладывать значение куки в этот заголовок только когда заголовка нет: предъявленный заголовок побеждает, иначе браузер с сессионной кукой получал бы не то, что предъявил на собственных адресах хранилища.

Область действия слоя MUST быть ограничена адресами приложения — теми, что живут под его собственным корнем. Собственная поверхность хранилища под него не подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и расширение слоя на всё сняло бы эту защиту молча.

Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия, предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы.

Scenario: Кука открывает доступ

  • GIVEN человек вошёл и получил куку сессии
  • WHEN он шлёт запрос к адресу приложения с этой кукой и без заголовка
  • THEN запрос проходит

Scenario: Кука защищена от чтения скриптом

  • WHEN сервис ставит куку сессии
  • THEN она несёт признаки HttpOnly, Secure и SameSite

Scenario: Предъявленный заголовок побеждает куку

  • WHEN запрос несёт и куку сессии, и заголовок Authorization
  • THEN проверку проходит значение заголовка, а не куки

Scenario: Слой не расширяется на поверхность хранилища

  • GIVEN человек вошёл и получил куку сессии
  • WHEN он шлёт запрос к собственному адресу хранилища с одной лишь кукой
  • THEN значение куки в заголовок не перекладывается

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 ответ на обе тот же, что и на неизвестный идентификатор

ADDED Requirements

Requirement: Приложение узнаёт вошедшего

Сервис SHALL отдавать приложению сведения о том, кто вошёл, — GET /app/me — и MUST отвечать отказом 401, когда сессии нет. Своей страницы со скриптом, которой сервер отрисовал бы имя вошедшего, у сервиса нет: приложение собирает разметку само и вошедшего узнаёт ответом.

Кука сессии недоступна скриптам страницы, и прочитать из неё имя приложение не может вовсе — этот адрес единственный способ его узнать.

Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями id и name. Адрес почты MUST в ответ не попадать: он приходит от провайдера и принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает ему выходить наружу наравне с журналом.

Scenario: Вошедший узнан

  • GIVEN человек вошёл и получил куку сессии
  • WHEN приложение спрашивает, кто вошёл
  • THEN ответ несёт идентификатор его учётной записи

Scenario: Сессии нет

  • WHEN приложение спрашивает, кто вошёл, без сессии
  • THEN ответ имеет код 401
  • AND тело ответа не несёт учётной записи

Scenario: Адреса почты в ответе нет

  • GIVEN человек вошёл, и у его учётной записи есть адрес почты
  • WHEN приложение спрашивает, кто вошёл
  • THEN адреса почты в ответе нет