хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+8 -5
View File
@@ -5,8 +5,8 @@
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: комментариями снабжена половина полей; единого места проверки на старте
нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в точке входа, а пустые
ключи `[yandex]` ловит конструктор распознавателя.
нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.
**Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml`
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
@@ -137,9 +137,12 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `cmd/transcriber` сразу после загрузки и роняет
процесс с именем незаполненного ключа. Причина в цене умолчания: поднявшись с
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
которой не управляют. Причина в цене умолчания: поднявшись с
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
+29 -32
View File
@@ -2,11 +2,11 @@
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует
этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время
единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило
держит линтер. Правила действуют на новый код; переписывание существующего —
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует этому
целиком: ключи — ULID в нижнем регистре, выдаёт их единая точка `internal/ident`
(с 2026-08-22, задача `storage-without-pocketbase`), время читает единая точка
`internal/clock` (с 2026-08-13), и правило времени держит линтер. Расхождений у
записи не осталось.
**Механизировано:** сверка изменённого шага схемы с
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
@@ -17,17 +17,17 @@
## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи.
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
хронологией не является: порядок берут по колонке времени с ключом.
**приложением** в момент создания записи. Выдача монотонна внутри одной
миллисекунды: колонка времени несёт секунды, и порядок записей одной секунды
задаёт ключ. Порядок ленты берут парой «время заведения и ключ» — одного
времени мало.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все
записи сущности в логах.
- **Точка генерации и разбора одна**: создание — при вставке записи в
репозитории, разбор — на входных границах. Самодельных генераторов по месту
вызова не заводим.
- **Точка генерации и разбора одна**`internal/ident`: `New` выдаёт, `Parse`
разбирает пришедшее снаружи. Самодельных генераторов по месту вызова не
заводим.
## Канонический вид — lowercase
@@ -47,31 +47,28 @@
## Прочее
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код.
*Расхождение:* перечни, по которым панель владельца правит запись руками,
закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить
значение, которого сервис не знает. Закрыты рубеж записи, причина её
остановки, вид текста, источник и исход события журнала. Цена названа: новое
значение любого из них потребует нового шага схемы, а применённый шаг не
переписывается. Прочие перечни остаются обычным `TEXT`.
- Enum-поля (`state`, `halt_reason`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код. Прежде часть перечней закрывала схема — правку руками вела
панель владельца, и она вправе была завести значение, которого сервис не
знает. Панели нет с 2026-08-22, правка идёт только нашим кодом, и закрытый
перечень в схеме остался бы ценой — новое значение стоило бы нового шага — без
покупателя.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени.
*Расхождение:* вид времени задаёт хранилище — `2006-01-02 15:04:05.000Z`,
пробел вместо `T` и доли секунды ([../database.md](../database.md), «Время»).
Правило RFC 3339 действует на то, что пишем мы сами мимо хранилища; вид
хранилища не меняем — сравнение строк в сыром запросе побайтово, и
разошедшийся вид молча обращает условие срока захвата в константу.
- Миграции — шаги PocketBase на Go
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
поля заводятся кодом. При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением.
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
Умолчаний вида `CURRENT_TIMESTAMP` в схеме нет ни у одной колонки, и вид один
на все — включая те, что пишет только сам сервис: своего типа времени у SQLite
нет, а колонка, заполненная то одним видом, то другим, молча обращает условие
срока захвата в константу.
- Миграции — шаги `pressly/goose/v3` на Go
(`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия — число в
начале имени): таблицы, их колонки и индексы заводятся кодом. При изменении
структуры обновляем схему [../database.md](../database.md) тем же изменением.
- Время в запросе кладётся и сравнивается тем же видом, каким оно лежит в
колонке. Сравнение строк побайтово, и разошедшийся вид обращает условие в
постоянную истину или ложь — молча.
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
сравнение по неуникальному значению делает порядок обработки
невоспроизводимым.
+23 -16
View File
@@ -100,15 +100,19 @@ transcriber — **приложение, а не библиотека**: внеш
| Доменная ошибка | Статус | `error_code` | Сообщение |
| --- | --- | --- | --- |
| сессии нет | 401 | `unauthorized` | «требуется вход» |
| предъявитель узнан, учётной записи пользователя нет | 403 | `forbidden` | «у вашей сессии нет учётной записи» |
| пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
| файл не приложен, формат не распознан, негодное значение параметра | 400 | `bad_request` | «некорректный ввод» |
| файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
| текста запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
| текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
| прочее | 500 | `internal` | «внутренняя ошибка» |
Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
случаем: им был владелец панели, предъявивший собственный токен хранилища.
Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
учётную запись заводит само.
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
@@ -124,10 +128,13 @@ transcriber — **приложение, а не библиотека**: внеш
`json-api-for-spa` 2026-08-15.
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
частоты, неизвестный путь под корнем приложения — и до этой точки не доходит
вовсе. Их приводит к той же форме слой `OneErrorForm`, стоящий снаружи всех
прочих. Без него формы отказа было бы две, и отказ у человека на мобильной сети
приходил бы телом библиотеки.
частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
неоткуда.
### Разовый ответ и сохранённая диагностика
@@ -135,7 +142,7 @@ transcriber — **приложение, а не библиотека**: внеш
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
- **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
и полезен. Но:
- **секреты запрещены** — токены, ключи, пароли, заголовок
@@ -146,8 +153,8 @@ transcriber — **приложение, а не библиотека**: внеш
числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
машинного текста — эту часть правила держит спека `intake`.
Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
машинного текста — эту часть правила держит спека `archive`.
## panic
@@ -156,11 +163,11 @@ transcriber — **приложение, а не библиотека**: внеш
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber его вешает роутер хранилища сам
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
шаге конвейера роняет процесс целиком.
ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
роняет процесс целиком.
## Несколько ошибок
+9 -9
View File
@@ -78,10 +78,11 @@
| --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `os.Remove` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove`, отложенные `(*sql.Rows).Close` и `(*sql.Tx).Rollback` и запись тела ответа (`json.Encoder.Encode`, `http.ResponseWriter.Write`) |
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
| Отказ выборки из базы не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. Предмет у правил появился 2026-08-22: выборки идут своим `database/sql`, и обе ветви ловятся на живом коде |
| Обращение к базе идёт с контекстом (`ExecContext`, `QueryContext`, `BeginTx`) | `.golangci.yml``noctx`. Контекст у репозиториев свой — почему, названо в [../database.md](../database.md), «Представление данных» |
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml``depguard` |
### Структура и границы
@@ -90,8 +91,9 @@
| --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Транспорты не знают адаптеров | `internal/archrules``TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
| Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
### Отмена и внешний собеседник
@@ -138,7 +140,7 @@
| Правило | Где механизировано |
| --- | --- |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни база: применённое считается своей таблицей учёта. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
@@ -188,11 +190,9 @@
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
молчит на всём каталоге, и на подозрении база задаётся руками
(`task migrations BASE=<rev>`);
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
есть роутер этого хранилища. Изъятие названо в
[../architecture.md](../architecture.md), «Принципы», и правила на это направление
нет.
- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
[../architecture.md](../architecture.md), «Слои и модель домена».
Отдельно названы **правила, чей подъём отклонён**:
+9 -10
View File
@@ -105,12 +105,12 @@ stdlib-логом в поток ошибок. Это выбор, а не дол
| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms`, `http.path_length`. **Запрошенного пути в строке нет ни под каким корнем**: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В `http.route` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт), `http.path_length`. Самого пути в строке нет: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. Вместо пути в `http.route` стоит `<приложение>` |
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` |
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
@@ -205,7 +205,7 @@ Object Storage и опрос операции не логируются ника
## HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `transport`.
`http.status_code`, `duration_ms`, `http.path_length`, `transport`.
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
потребитель молча оставит одно из значений. Правило проверяется чтением,
@@ -214,13 +214,12 @@ Object Storage и опрос операции не логируются ника
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся.
Расхождения здесь больше нет: слой журналирования запросов свой,
`cmd/transcriber`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
Расхождения здесь больше нет: слой журналирования запросов свой
`internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
то есть при боевом `INFO` не пишутся вовсе.
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
по которому разбирают отказы, эта таблица не попадает.
**Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
## Безопасность: что не логируем
@@ -257,7 +256,7 @@ Object Storage и опрос операции не логируются ника
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением. В журнал оно идёт **собственным полем** строки приёма — это
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
[../security.md](../security.md).
+10 -9
View File
@@ -74,15 +74,16 @@
[webapp](../../openspec/specs/webapp/spec.md).
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
`404`; путь внутри корня в приложение не проваливается никогда. Корней
сегодня три — `/api/` у хранилища, `/app/` у приложения, `/_/` у панели, —
плюс `/health` и `/metrics` отдельными адресами. Корень `/auth/` снят
2026-08-22 вместе с собственным входом, и пути под ним стали обычными путями
вне корней. Приложение
уехало из общего `/api/` решением владельца 2026-08-15: пространство
принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с
нашим. Перечень корней сервису не описывают, а из него **порождают**
регистрацию маршрутов: описанный порознь, он разошёлся бы с ними молча.
`404`; путь внутри корня в приложение не проваливается никогда. Корень
сегодня **один**`/app/` у приложения, — плюс `/health` и `/metrics`
отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый
ушёл с собственным входом, два других — со встроенным хранилищем и его
панелью, и пути под ними стали обычными путями вне корней. Приложение уехало
из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило:
соседа, ради которого выбирался, больше нет, а формы запросов и ответов от
смены хранилища не изменились ни одним полем. Перечень корней сервису не
описывают, а из него **порождают** регистрацию маршрутов: описанный порознь,
он разошёлся бы с ними молча.
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
которому не нашлось файла, отвечает `404`. Правило — вторая половина
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и