- периметр: passport.md и security.md больше не утверждают, что HTTP API открыт без аутентификации, а review.md не числит эту находку типовой ложноположительной — приём, опрос и файл закрыты сессией с 2026-08-12; - logging.md писал, что расширение попадает в журнал полем пути: описано изъятие инварианта приватности — собственное поле, имени и пути нет; - узел ревью переименован в repo/pocketbase, поведение конвейера из обзора уехало ссылкой в спеку pipeline, Purpose спеки storage написан вместо заглушки, в ADR о переезде дописано уточнение о действующей раскладке.
20 KiB
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 приглашения завести владельца в журнале нет