- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и несуществующая дают один ответ; правило просмотра файлов сужено им же - приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается - удаление учётной записи с записями отвергается стражем, и вешает его сама сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
394 lines
29 KiB
Markdown
394 lines
29 KiB
Markdown
# storage Specification
|
||
|
||
## Purpose
|
||
|
||
Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение
|
||
схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность
|
||
хранилища и панель владельца.
|
||
|
||
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`.
|
||
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
|
||
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
|
||
2026-08-11, а удаление приносит задача `delete-record`.
|
||
## Requirements
|
||
### Requirement: Сервис поднимается на чистом каталоге данных
|
||
|
||
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
|
||
MUST завести свою схему и принимать записи обоими входами без единого ручного
|
||
шага до первого запуска.
|
||
|
||
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
|
||
читаться и не считаться источником: сервис начинает с чистого листа, и это
|
||
решение задачи, а не следствие отказа.
|
||
|
||
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
|
||
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
|
||
каталоге разошёлся бы с первым молча.
|
||
|
||
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
|
||
вместе, и второго пути к ним не заводится.
|
||
|
||
#### Scenario: Первый запуск на пустом каталоге
|
||
|
||
- **GIVEN** каталог данных пуст
|
||
- **WHEN** сервис запускается
|
||
- **THEN** он заводит своё хранилище и продолжает работу
|
||
- **AND** принятая следом запись доходит до состояния `done`
|
||
|
||
#### Scenario: Повторный запуск на заведённом каталоге
|
||
|
||
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
|
||
- **WHEN** он запускается снова
|
||
- **THEN** он не заводит схему второй раз и не теряет прежние записи
|
||
|
||
### Requirement: Файл записи живёт в хранилище
|
||
|
||
Сервис SHALL держать файл записи в хранилище, а не отдельным каталогом рядом с
|
||
ним. Файл MUST попадать туда вместе с записью, которой принадлежит, и MUST
|
||
адресоваться этой записью, а не путём на диске.
|
||
|
||
Раскладку файлов на диске выбирает хранилище. Собственного плоского каталога
|
||
записей у сервиса MUST не оставаться: файл, лежащий мимо хранилища, не попадёт
|
||
ни в панель владельца, ни в резервную копию, а ради этих двух вещей перевод и
|
||
делается.
|
||
|
||
Содержимое записи MUST не читаться в память целиком ни при укладке в хранилище,
|
||
ни при чтении из него: расчётный потолок записи — шесть часов, и такая запись в
|
||
память не помещается.
|
||
|
||
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
|
||
потолка**, и задан он MUST быть везде, где иначе действует чужое умолчание: и у
|
||
поля файла в хранилище, и у тела запроса приёма. Умолчания здесь не «без
|
||
предела», а величины на два-три порядка меньше нужного, и оставленные как есть
|
||
они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись
|
||
исчерпывает попытки на шаге конвертации.
|
||
|
||
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
|
||
|
||
Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных
|
||
отдают его внешней программе, — MUST получать рабочую копию **одним общим
|
||
способом**, и у этого способа MUST быть единственный способ её убрать. Уборку
|
||
зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по
|
||
месту шагам MUST не приходиться: иначе обязанность прибрать переписывается
|
||
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
|
||
оставшаяся во временном каталоге, и узнать о ней неоткуда.
|
||
|
||
#### Scenario: Принятая запись легла в хранилище
|
||
|
||
- **WHEN** запись принята любым входом
|
||
- **THEN** её файл лежит в хранилище и связан со своей записью
|
||
- **AND** отдельного каталога записей рядом с хранилищем не появляется
|
||
|
||
#### Scenario: Запись длиннее чужого умолчания принимается
|
||
|
||
- **WHEN** в хранилище кладут запись длиннее умолчания, действующего у поля файла
|
||
- **THEN** она ложится в хранилище, а не отвергается
|
||
|
||
#### Scenario: Шаг конвейера берёт файл по записи
|
||
|
||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||
- **WHEN** шаг конвейера берётся за эту запись
|
||
- **THEN** он получает файл по самой записи, а не по пути на диске
|
||
|
||
#### Scenario: Рабочая копия убрана после отказа шага
|
||
|
||
- **GIVEN** шагу выдана рабочая копия файла
|
||
- **WHEN** шаг завершается отказом
|
||
- **THEN** рабочей копии во временном каталоге не остаётся
|
||
|
||
### Requirement: Файл отдаётся ссылкой
|
||
|
||
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
|
||
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
|
||
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
|
||
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
|
||
|
||
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
|
||
коллекции. Правило MUST пускать только владельца файла: незаданное означает
|
||
«только владелец панели», и тогда файла не получит и вошедший, а прежнее «всякий
|
||
узнанный» отдавало чужое аудио тому, кто знает идентификатор записи.
|
||
|
||
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
|
||
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
|
||
токена: отказ наступает там, и требовать его от выдачи значит требовать
|
||
механизма, которого нет.
|
||
|
||
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
|
||
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
|
||
недосмотр.
|
||
|
||
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
|
||
хранилища, а не по ссылке.
|
||
|
||
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
|
||
|
||
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
|
||
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
|
||
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
|
||
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
|
||
бессрочно.
|
||
|
||
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
|
||
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
|
||
половину ключа.
|
||
|
||
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
||
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
|
||
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
|
||
другое кончается в журнале и собирает ссылку не хуже успешного пути.
|
||
|
||
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
|
||
`intake`.
|
||
|
||
#### Scenario: Файл забирают по ссылке
|
||
|
||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||
- **AND** забирающий предъявил сессию и взял по ней токен файла
|
||
- **WHEN** ссылку на файл запрашивают с этим токеном
|
||
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||
|
||
#### Scenario: Без сессии файл не отдаётся
|
||
|
||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||
- **WHEN** ссылку на файл запрашивают без сессии
|
||
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||
|
||
#### Scenario: Конвейер читает файл без сессии
|
||
|
||
- **GIVEN** запись принята и ждёт расшифровки
|
||
- **WHEN** шаг конвейера берётся за неё
|
||
- **THEN** файл читается из файловой системы хранилища и шаг проходит
|
||
|
||
#### Scenario: Ссылка ведёт в никуда
|
||
|
||
- **WHEN** запрашивают ссылку на запись, которой нет
|
||
- **THEN** приходит отказ, а не пустой ответ
|
||
|
||
#### Scenario: По журналу ссылку не собрать
|
||
|
||
- **GIVEN** запись принята и прошла конвейер
|
||
- **WHEN** читают журнал сервиса целиком
|
||
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
|
||
|
||
#### Scenario: Отказ чтения файла не называет его ключ
|
||
|
||
- **GIVEN** файл записи не читается из хранилища
|
||
- **WHEN** шаг конвейера берётся за эту запись и отказывает
|
||
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
|
||
|
||
### Requirement: Наружу хранилище отдаёт только то, что заказано
|
||
|
||
Сервис SHALL держать закрытыми собственные разделы хранилища, которые тот
|
||
публикует тем же портом. Запрос без прав владельца MUST получать отказ на
|
||
перечисление и чтение записей коллекций, на служебные разделы хранилища —
|
||
журналы запросов, резервные копии, настройки, расписание — и на правку чего бы
|
||
то ни было.
|
||
|
||
Требование заводится потому, что порт опубликован в интернет, а вместе с
|
||
переводом наружу выходит поверхность, которой у сервиса не было. Что API сервиса
|
||
сегодня открыт всякому — известно и записано моделью угроз; новая поверхность под
|
||
это знание не подпадает и закрывается здесь.
|
||
|
||
Правило доступа, оставленное пустым, значит «только владелец панели». Именно
|
||
пустым оно MUST и оставаться: непустое правило, поставленное будущей правкой
|
||
схемы, открыло бы перечисление всех записей анонимному запросу и не нарушило бы
|
||
при этом ни одного другого требования.
|
||
|
||
#### Scenario: Аноним перечисляет записи
|
||
|
||
- **WHEN** запрос без прав владельца просит список записей коллекции задач
|
||
- **THEN** приходит отказ
|
||
|
||
#### Scenario: Аноним читает служебный раздел
|
||
|
||
- **WHEN** запрос без прав владельца просит журнал запросов или список резервных
|
||
копий хранилища
|
||
- **THEN** приходит отказ
|
||
|
||
### Requirement: Владелец видит записи в панели
|
||
|
||
Сервис SHALL давать владельцу панель, где задача видна строкой, отбирается по
|
||
своему идентификатору и правится, а её файл слушается и скачивается.
|
||
|
||
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
|
||
второго процесса.
|
||
|
||
Панель — вход в задачу наравне с конвейером, а не окно просмотра, и правка
|
||
состояния задачи в ней MUST подчиняться тем же правилам перехода, что и правка
|
||
из кода: служебные поля прошлого состояния — признак захвата, время захвата,
|
||
пауза, число попыток — MUST очищаться. Иначе владелец, вернувший мёртвую задачу в
|
||
работу, получит задачу, которая не выдаётся захвату до конца прежнего срока и
|
||
умирает от первого же отказа, — и не узнает об этом.
|
||
|
||
Задача, заведённая в панели руками, MUST не уносить сервис: поля, без которых
|
||
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
|
||
перечень состояний — закрытым.
|
||
|
||
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
|
||
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
|
||
сервис — это записано моделью угроз проекта.
|
||
|
||
#### Scenario: Принятая запись видна владельцу
|
||
|
||
- **GIVEN** запись принята и её задача заведена
|
||
- **WHEN** владелец отбирает задачи по идентификатору принятой
|
||
- **THEN** он видит её строкой со своим состоянием
|
||
- **AND** файл этой записи скачивается из той же строки
|
||
|
||
#### Scenario: Мёртвую задачу вернули в работу правкой в панели
|
||
|
||
- **GIVEN** задача в состоянии «мертва» с исчерпанными попытками и признаком
|
||
прежнего захвата
|
||
- **WHEN** владелец меняет её состояние на рабочее
|
||
- **THEN** признак захвата, время захвата, пауза и число попыток очищены
|
||
- **AND** ближайший захват выдаёт задачу
|
||
|
||
### Requirement: Пароль владельца от панели не лежит в конфигурации
|
||
|
||
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
|
||
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
|
||
отпечаток.
|
||
|
||
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
|
||
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
|
||
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
|
||
файлы разом — это самое чувствительное, что есть у сервиса.
|
||
|
||
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
|
||
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
|
||
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
|
||
панель всякому читателю логов навсегда.
|
||
|
||
Пока владелец пароля не задал, сервис MUST работать обоими входами: панель без
|
||
владельца не мешает принимать записи.
|
||
|
||
#### Scenario: Владелец пароля ещё не задал
|
||
|
||
- **GIVEN** каталог данных пуст и владелец панели не заведён
|
||
- **WHEN** сервис запускается
|
||
- **THEN** он принимает записи обоими входами
|
||
- **AND** ни один ключ конфигурации не несёт пароля от панели
|
||
|
||
#### Scenario: Владелец заведён, приглашение больше не печатается
|
||
|
||
- **GIVEN** владелец панели заведён
|
||
- **WHEN** сервис запускается снова
|
||
- **THEN** приглашения завести владельца в журнале нет
|
||
|
||
### Requirement: Владелец задачи лежит связью с учётной записью
|
||
|
||
Хранилище SHALL держать владельца задачи расшифровки отдельной колонкой — связью
|
||
с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
||
не назван, не достаётся никому по недосмотру схемы.
|
||
|
||
Колонка MUST допускать пустое значение, и это решение с названной ценой: записи,
|
||
принятые ботом, владельца не имеют, потому что связи чата Telegram с учётной
|
||
записью сервис не ведёт. Обязательность для приёма по HTTP держит сама
|
||
capability `intake`, а не схема.
|
||
|
||
Колонка приезжает **новым шагом схемы**: применённый шаг не переписывается.
|
||
Записей, заведённых до этого шага, сервис не переносит — проект заводится с
|
||
чистого листа.
|
||
|
||
#### Scenario: Колонка появляется на пустой базе
|
||
|
||
- **WHEN** сервис поднимается на чистом каталоге данных
|
||
- **THEN** у таблицы задач есть колонка владельца
|
||
- **AND** умолчания у неё нет
|
||
|
||
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||
|
||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||
записью, тем же шагом схемы, — и правило просмотра файлов MUST пускать к файлу
|
||
только его владельца. Прежнее правило пускало всякого узнанного, и знание
|
||
идентификатора файловой записи равнялось праву скачать чужое аудио.
|
||
|
||
Без этого требования разграничение закрывает метаданные задачи и оставляет
|
||
открытым содержимое — то самое, что оно и заведено прятать. Хуже самой дыры была
|
||
бы отметка о закрытии: паспорт и модель угроз называют исполнителем этой работы
|
||
именно эту задачу, и слово «закрыто» скрыло бы открытый путь.
|
||
|
||
Владелец файла MUST назначаться там же, где владелец задачи, — при приёме, из
|
||
предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером
|
||
для записи без владельца.
|
||
|
||
Ссылка на файл в задаче переставляется каждым шагом конвейера, поэтому владелец
|
||
файла MUST лежать своей колонкой, а не выводиться через задачу: исходная копия
|
||
после конвертации не связана с задачей ничем.
|
||
|
||
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
||
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
||
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
|
||
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
|
||
касаясь пути, по которому аудио и уходит.
|
||
|
||
#### Scenario: Чужой файл не отдаётся
|
||
|
||
- **GIVEN** запись принята одним вошедшим
|
||
- **WHEN** другой вошедший идёт по ссылке на файл этой записи со своим токеном
|
||
- **THEN** содержимого он не получает
|
||
|
||
#### Scenario: Свой файл отдаётся
|
||
|
||
- **GIVEN** человек принял запись
|
||
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
|
||
- **THEN** содержимое отдаётся
|
||
|
||
#### Scenario: Файл записи из Telegram не отдаётся по API
|
||
|
||
- **GIVEN** запись принята ботом, и владельца у неё нет
|
||
- **WHEN** вошедший человек идёт по ссылке на её файл со своим токеном
|
||
- **THEN** содержимого он не получает
|
||
|
||
### Requirement: Учётная запись с записями не удаляется
|
||
|
||
Хранилище SHALL отвергать удаление учётной записи, у которой остались задачи
|
||
расшифровки **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
|
||
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
|
||
другую подменяет сообщением про обязательную связь — подсказкой, по которой
|
||
владелец панели пойдёт удалять записи руками.
|
||
|
||
Считаются обе коллекции с владельцем. Файл переживает свою задачу: шаг конвейера
|
||
заводит его до сохранения задачи, и потерянный захват оставляет файл с владельцем
|
||
и без ссылки.
|
||
|
||
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
|
||
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
|
||
запуска, а окружение проверок его не ставило вовсе.
|
||
|
||
Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему
|
||
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
|
||
поверхность.
|
||
|
||
Требование заведено вместо прежнего «удаление не уносит задачи следом»: оно
|
||
выглядело выполненным, а на деле хранилище при выключенном каскаде **снимает
|
||
ссылку** — задачи остаются, но становятся ничьими, а ничья задача не достаётся
|
||
по API никому. Архив человека исчезал бы молча и восстановлению не подлежал:
|
||
прежнего владельца не остаётся нигде.
|
||
|
||
Цена требования названа прямо: владелец панели упирается в отказ, а способа
|
||
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
|
||
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
|
||
тупик, а не недосмотр.
|
||
|
||
#### Scenario: Удаление учётной записи с записями отвергается
|
||
|
||
- **GIVEN** у учётной записи есть задачи расшифровки
|
||
- **WHEN** её удаляют
|
||
- **THEN** удаление не проходит, а отказ называет причину
|
||
- **AND** задачи и их владелец остаются прежними
|
||
|
||
#### Scenario: Учётная запись с одними файлами тоже не удаляется
|
||
|
||
- **GIVEN** у учётной записи остались файлы, но задач нет
|
||
- **WHEN** её удаляют
|
||
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
|
||
|
||
#### Scenario: Учётная запись без записей удаляется
|
||
|
||
- **GIVEN** у учётной записи нет ни задач, ни файлов
|
||
- **WHEN** её удаляют
|
||
- **THEN** удаление проходит
|
||
|