Files
transcriber/openspec/specs/webapp/spec.md
T
av 663021f712 приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное
  вшивается через go:embed и раздаётся корневым маршрутом: разметка на
  неизвестном пути вне корней сервиса, отказ контракта внутри корня
- перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов,
  а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь
- шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка
  входят в гейт, а в Dockerfile появилась ступень приложения
2026-08-15 18:51:05 +03:00

20 KiB
Raw Blame History

webapp Specification

Purpose

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

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

Requirements

Requirement: Приложение отдаётся самим бинарником

Сервис SHALL отдавать разметку приложения и её ресурсы из самого бинарника. Каталога с собранными файлами рядом с бинарником MUST не требоваться, и внешнего веб-сервера под раздачу MUST не заводиться.

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

Scenario: Приложение открывается у бинарника без каталога рядом

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

Scenario: Ресурс приложения отдаётся оттуда же

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

Requirement: Сервис, поднятый без собранного приложения, говорит об этом

Сервис SHALL отвечать кодом 503 на всяком пути, где он отдал бы разметку, если собранного приложения в нём нет, и MUST писать об этом строку в журнал при подъёме. Отвечать 404 и молчать он MUST не вправе: «приложения нет» и «такого адреса нет» — разные состояния, и первое чинится сборкой, а не поиском опечатки в адресе.

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

Scenario: Пустая сборка отвечает отказом, а не отсутствием адреса

  • GIVEN бинарник собран без собранного приложения
  • WHEN браузер спрашивает корень сервиса
  • THEN ответ имеет код 503

Scenario: Отсутствие сборки видно в журнале

  • GIVEN бинарник собран без собранного приложения
  • WHEN сервис поднимается
  • THEN в журнале есть строка о том, что приложение не собрано

Scenario: Проба здоровья остаётся зелёной

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

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

Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корни перечислены поимённо — /api у хранилища, /app у приложения, /auth у входа, /_ у панели, — отдельными адресами стоят /health и /metrics.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Scenario: Наблюдение приложением не подменяется

  • WHEN запрос приходит на пробу здоровья
  • THEN отвечает проба здоровья, а не приложение

Scenario: Чужой метод отвечает отказом

  • WHEN на неизвестный путь вне корней приходит запрос методом, которым страницу не открывают
  • THEN ответ имеет код 405

Scenario: Проверка доступности разметку получает

  • WHEN разметку спрашивают методом HEAD
  • THEN ответ имеет код 200

Requirement: Обновлённое приложение доходит до браузера

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

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

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

Scenario: Ресурс сборщика отдаётся с долгим сроком

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

Scenario: Разметка спрашивается заново

  • WHEN браузер спрашивает разметку приложения
  • THEN ответ несёт требование спрашивать её заново
  • AND пометки «неизменяемо» в ответе нет

Scenario: Файл с постоянным именем долгого срока не получает

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

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

Сервис SHALL записывать о таком запросе исход из закрытого перечня — разметка, ресурс, отказ — и длину пути, а самого пути MUST не записывать ни в один свой журнал. То же относится к меткам метрик.

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

Журналов при этом два: свой и журнал хранилища, куда библиотека кладёт путь целиком вместе с адресом отправителя. Требование относится к обоим.

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

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

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

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

Requirement: Сервис объявляет, какая сборка приложения в нём вшита

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

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

Scenario: Отпечаток виден при подъёме

  • WHEN сервис поднимается с собранным приложением
  • THEN в журнале есть отпечаток вшитой сборки

Scenario: Разметка несёт метку ответа

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

Requirement: Открытое приложение показывает вошедшего

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

Всякий иной отказ и сорванный запрос MUST ко входу не уводить, а показываться строкой о неудаче. Трактовка «любой отказ значит не вошёл» замкнула бы круг: приложение ушло бы ко входу, вход вернул бы человека в приложение, и на отказе сервиса или ограничителя частоты круг пошёл бы заново.

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

Scenario: Вошедший виден

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

Scenario: Не вошедшему предлагается вход

  • GIVEN сессии у человека нет
  • WHEN он открывает приложение
  • THEN приложение ведёт его ко входу

Scenario: Отказ сервиса ко входу не уводит

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

Scenario: Учётная запись без имени

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