Files
transcriber/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/webapp/spec.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

13 KiB

MODIFIED Requirements

Requirement: Неизвестный путь вне корней открывает приложение

Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корень у сервиса остался один/app у приложения; отдельными адресами стоят /health и /metrics.

Корней /api и /_ в перечне больше нет: встроенного хранилища с его собственным пространством и панели администратора у сервиса не осталось, и адресов под этими именами не существует. Прежние пути хранилища и панели поэтому отвечают тем же, чем отвечает всякий путь вне корней, — разметкой приложения. Резервировать имя за отказом сервис не берётся: имя, за которым ничего не стоит, ничем не отличается от любого другого свободного имени, а второй перечень «когда-то занятых корней» разошёлся бы с первым молча.

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

Корня /auth в перечне нет тоже: собственного входа у сервиса не осталось.

Путь принадлежит корню, когда совпадает с ним точно либо начинается им вместе с косой чертой. Оба условия обязательны: по одному лишь префиксу корню /app достался бы посторонний /apple, а по одному лишь префиксу с косой чертой голый /app не достался бы никому и уехал бы разметкой.

Чем отвечает голый /app, названо прямо: он принадлежит корню приложения, адресом приложения при этом не является и потому MUST отвечать как неизвестный путь под корнем приложения — узнанному 404 телом отказа приложения, неузнанному 401 тем же телом, каким отвечают прочие адреса под этим корнем. Разметки в ответе нет ни в одном из двух случаев. Без этой строки «не разметка приложения» читается как «что-нибудь ещё», и код ответа выбрала бы за нас первая же сборка.

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

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

Открывающими страницу считаются GET и HEAD, и только они; прочие методы MUST отвечать 405.

Scenario: Обновление страницы посреди приложения открывает тот же экран

  • GIVEN приложение открыто на своём маршруте
  • WHEN браузер спрашивает этот путь заново
  • THEN ответ имеет код 200
  • AND тело ответа — разметка приложения

Scenario: Голый корень разметкой не подменяется

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

Scenario: Голый корень неузнанному отвечает как прочие адреса под корнем

  • WHEN запрос приходит без заголовка на путь, совпадающий с корнем приложения точно и без косой черты
  • THEN ответ имеет код 401
  • AND тело ответа — не разметка приложения

Scenario: Посторонний путь, начинающийся именем корня, открывает приложение

  • WHEN запрос приходит на путь, который начинается именем корня, но не отделён от него косой чертой
  • THEN ответ имеет код 200
  • AND тело ответа — разметка приложения

Scenario: Прежний адрес входа открывает приложение

  • WHEN запрос приходит на путь под прежним корнем входа
  • THEN ответ имеет код 200
  • AND тело ответа — разметка приложения

Scenario: Прежний путь хранилища открывает приложение

  • WHEN запрос приходит на путь под прежним корнем хранилища
  • THEN ответ имеет код 200
  • AND тело ответа — разметка приложения

Scenario: Прежний адрес панели открывает приложение

  • WHEN запрос приходит на прежний адрес панели — и записанный знаком, и записанный кодом этого знака
  • THEN оба ответа имеют код 200
  • AND тело каждого — разметка приложения

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

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

Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса

  • WHEN запрос приходит на неизвестный путь под корнем приложения без заголовка
  • THEN ответ имеет код 401
  • AND тело ответа — не разметка приложения

Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой

  • WHEN браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
  • THEN ответ имеет код 404
  • AND тело ответа — не разметка приложения

Requirement: Путь, отданный приложению, в журнал не идёт

Сервис SHALL записывать о запросе маршрут из закрытого перечня и длину пути, а самого запрошенного пути MUST не записывать ни в один свой журнал. То же относится к меткам метрик. Перечень маршрутов закрыт и назван: точные адреса наблюдения и объявленные образцы адресов приложения; всё, что ни одному из них не отвечает, MUST обозначаться одним общим значением. Для запроса, отданного приложению, к строке добавляется исход из закрытого перечня — разметка, ресурс, отказ.

Правило MUST накрывать обе половины адресного пространства — и путь вне корней сервиса, и путь под корнем приложения. Принадлежность пути сервису тут ничего не меняет: /app/<произвольный текст> принадлежит сервису и отвечает отказом, но множеством значений под корнем распоряжается спрашивающий ровно так же, как и вне его.

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

Идентификатор записи от этого не пропадает: его пишет обработчик своим полем, и пишет он прочитанный идентификатор, а не тот, что стоял в запросе.

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

Scenario: Путь не доезжает до журнала

  • WHEN приходит запрос на путь вне корней сервиса
  • THEN записи о нём не несут этого пути
  • AND несут исход и длину пути

Scenario: Длинный путь журнал не наполняет

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

Scenario: Путь под корнем приложения журнал не пишет

  • GIVEN пришедший не узнан
  • WHEN он спрашивает под корнем приложения путь, не отвечающий ни одному объявленному образцу адреса
  • THEN записи о нём не несут этого пути
  • AND не растут вместе с его длиной

Scenario: Объявленный образец адреса приложения в журнале различим

  • WHEN приходит запрос на объявленный адрес приложения
  • THEN запись о нём несёт образец этого адреса, а не запрошенный путь

Scenario: Второго журнала у сервиса нет

  • GIVEN сервис поднялся
  • WHEN приходит запрос на путь вне корней сервиса
  • THEN запись о нём появляется только в журнале сервиса