From b46be019fc510012d3fb3d6f92c93d15de076c14 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Wed, 12 Aug 2026 21:13:06 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B2=D0=B5=D0=B4=D1=91=D0=BD=20=D0=BA=20=D1=81?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=D0=B4=D0=BD=D1=8F=D1=88=D0=BD=D0=B5=D0=BC?= =?UTF-8?q?=D1=83=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8E?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=81=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - периметр: passport.md и security.md больше не утверждают, что HTTP API открыт без аутентификации, а review.md не числит эту находку типовой ложноположительной — приём, опрос и файл закрыты сессией с 2026-08-12; - logging.md писал, что расширение попадает в журнал полем пути: описано изъятие инварианта приватности — собственное поле, имени и пути нет; - узел ревью переименован в repo/pocketbase, поведение конвейера из обзора уехало ссылкой в спеку pipeline, Purpose спеки storage написан вместо заглушки, в ADR о переезде дописано уточнение о действующей раскладке. --- ...-11-pocketbase-storage-with-admin-panel.md | 4 ++ docs/architecture.md | 38 ++++++++++++------- docs/conventions/config.md | 7 +++- docs/conventions/errors.md | 7 +++- docs/conventions/logging.md | 11 ++++-- docs/passport.md | 26 ++++++++----- docs/review.md | 23 +++++++---- docs/security.md | 14 ++++--- openspec/specs/intake/spec.md | 2 +- openspec/specs/storage/spec.md | 11 +++++- openspec/specs/toolchain/spec.md | 8 ++-- 11 files changed, 100 insertions(+), 51 deletions(-) diff --git a/docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md b/docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md index 50462d7..1674771 100644 --- a/docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md +++ b/docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md @@ -67,6 +67,10 @@ PocketBase заменяет SQLite с goqu и goose и берёт на себя `pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с файлом атрибутов. Момент перехода назначает человек; данные прежней базы не переносятся по прежнему решению задачи `pocketbase-storage`. + *Уточнено 2026-08-12:* каталог задаётся ключом `[storage] data_dir` со + значением `data`. Суффикс из десяти знаков дописывает конструктор имени, + которого сервис не зовёт, — имя задаёт он сам. Действующая раскладка — + [../database.md](../database.md), «Представление данных». - `−` вход перестаёт быть нашим: задача `oidc-login` переписывается с собственной обработки ответа провайдера на настройку провайдера в PocketBase. Что делать с сессией и где она живёт, решает уже не наш код. diff --git a/docs/architecture.md b/docs/architecture.md index 7422537..81cd0dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,9 +12,12 @@ потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает. -- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его - нормируют проверки, написанные задачей `http-handler-tests-never-green` - 2026-08-11; +- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: приём и + опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала, + метка метрики несёт только известное расширение. Задачи + `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, + `pocketbase-storage` и `oidc-login` 2026-08-12. Приём из Telegram здесь не + описан; - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и @@ -40,14 +43,17 @@ - **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и делят одну базу. Отдельного воркер-процесса нет намеренно. -- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер - забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка - — единицы записей в день (оценка владельца, не замер). Готовую библиотеку - очереди тоже не заводим — решено 2026-08-11, +- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость + захвата и порядок выборки нормирует + [pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим». + Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, + не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11, [ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение кандидатов в [research/job-queue.md](research/job-queue.md). -- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине, - достаётся снова по истечении срока захвата и проходит шаг заново. +- **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда + задача возвращается в работу, нормирует + [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается + в работу»; здесь это принцип письма шага, а не описание поведения. - **Ядро зависит от интерфейсов.** `internal/service` знает только `internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в `main.go`. @@ -149,11 +155,15 @@ ## Открытые вопросы -- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера - обрабатывает PocketBase, а не наш код - ([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия - и как связываются пользователь Telegram и пользователь веба. Панель - администратора при этом Authelia не закрывает: у неё свой пароль +- **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер — + Authelia, ответ провайдера обрабатывает PocketBase, а не наш код + ([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия + живёт кукой `transcriber_session` и сама себя не продлевает. Норма — + [access](../openspec/specs/access/spec.md), решения — + [ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md) + и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md). + **Не решено одно:** как связываются пользователь Telegram и пользователь веба. + Панель администратора при этом Authelia не закрывает: у неё свой пароль суперпользователя. - **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA, устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и diff --git a/docs/conventions/config.md b/docs/conventions/config.md index d7c9bb9..338d9dd 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -130,7 +130,10 @@ TOML. Пустой токен бота ловится в `NewTelegramController` ## Структура в коде - Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`. -- Одна корневая структура `Config` с под-структурами по секциям (`Server`, - `Database`, `Storage`, `Yandex`, `Telegram`). +- Одна корневая структура `Config` с под-структурами по секциям. Перечень секций + и полей здесь не повторяем: источник истины по составу — `config.dist.toml`, + действующие числа — [../database.md](../database.md), «Настройки с числовым + значением». Каталог данных задаётся одним ключом `[storage] data_dir` + ([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)). - Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле требует правки обоих мест. diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md index 1088b95..e821e4f 100644 --- a/docs/conventions/errors.md +++ b/docs/conventions/errors.md @@ -135,8 +135,11 @@ transcriber — **приложение, а не библиотека**: внеш - Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) — это значения `error`. - `recover` — на верхней границе обработчика, чтобы один паникующий запрос не - ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой - границы **нет**: паника в шаге конвейера роняет процесс целиком. + ронял процесс. В transcriber его вешает роутер хранилища сам + (`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId` + на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс + живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**: + паника в шаге конвейера роняет процесс целиком. ## Несколько ошибок diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index 060d9d4..59ec8e2 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -241,11 +241,14 @@ Object Storage, скачивание файла из Telegram и опрос оп она не логируется — то есть утечки нет, но защищает от неё только отсутствие строки лога. -*Расхождение:* расширение берётся из имени отправителя дословно +*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост -расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот -хвост не выходит: там расширение приводится к перечню известных форматов. Остаток -описан в [../security.md](../security.md). +расширением. В журнал оно идёт **собственным полем** строки приёма — это +объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md), +«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе +(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит: +там расширение приводится к перечню известных форматов. Остаток описан в +[../security.md](../security.md). ## Куда пишем и уровень diff --git a/docs/passport.md b/docs/passport.md index 59d01c2..058c858 100644 --- a/docs/passport.md +++ b/docs/passport.md @@ -22,7 +22,7 @@ | Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось | | Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи | | Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня | -| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Работает сегодня, но токенов нет и доступ не разграничен | +| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только кука, снятая из браузера. Токен приносит `api-tokens` | **Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11 основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на @@ -68,8 +68,11 @@ файл. Своей записи и работы без сети не делаем — граница цели [web-access](../tasks/items/web-access.md). - **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради - речи в них. Складом произвольных файлов, папками и общим доступом к чужим - записям сервис не становится. + речи в них. Складом произвольных файлов и папками сервис не становится. Общего + доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у + записи в модели данных не существует, и всякий вошедший видит все записи + ([security.md](security.md), «Периметр»). Это состояние, а не решение; + закрывает его `record-ownership`. - **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем: пользователя, потратившего слишком много, останавливает разговор или отзыв @@ -95,8 +98,10 @@ отличает их по MIME-типу и расширению. Работает сегодня. 5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном, получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не - увидит `done` и текст. Работает сегодня, но без токена и без разграничения - доступа. + увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC: + анонимный запрос обоими адресами отклоняется. Своего входа у программы нет — + его заводит `api-tokens`, — как нет и разграничения записей между + пользователями. 6. **Отказ на середине.** Конвертация или распознавание не удались — задача переходит в `failed`, а пользователь получает сообщение о том, что именно не вышло, и предложение повторить. @@ -109,7 +114,10 @@ которой пользуемся: она и задаёт потолок по длине записи и формату. - **Whisper и его серверные обёртки** — запасной путь, если внешний сервис перестанет устраивать по цене или по качеству русской речи. -- **PocketBase** — хранилище взамен сегодняшнего SQLite, решено 2026-08-11 - ([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Учётные - записи оно хранит и получает от Authelia своим провайдером OIDC, но источником - их не становится: заводит и проверяет людей по-прежнему Authelia. +**PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела +задача `pocketbase-storage` 2026-08-12 +([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она +держит хранилище, файлы и панель владельца. Схема и +раскладка — [database.md](database.md). Учётные записи она хранит и получает от +Authelia своим провайдером OIDC, но источником их не становится: заводит и +проверяет людей по-прежнему Authelia. diff --git a/docs/review.md b/docs/review.md index 417cdf8..bc7faf4 100644 --- a/docs/review.md +++ b/docs/review.md @@ -38,12 +38,16 @@ - вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в успех молча. -**Репозиторий SQLite** (`adapter/repo/sqlite`): +**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`): -- список колонок совпадает во всех четырёх запросах файла; -- `NULL` в колонке разбирается в указатель, а не роняет `Scan`; -- захват задачи не выдаёт одну строку двум вызывающим; -- ошибка драйвера транслируется в доменную у источника. +- список колонок совпадает во всех четырёх местах — `applyToRecord`, + `recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант + [CLAUDE.md](../CLAUDE.md), «Инварианты»); +- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только + держатель захвата; +- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет + свои `created`/`updated` ([database.md](database.md), «Представление данных»); +- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком. **Обёртка над внешним процессом** (`adapter/converter/ffmpeg`, `adapter/metaviewer/ffmpeg`): @@ -95,9 +99,12 @@ - **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в [database.md](database.md); срок хранения не задан сознательно, задачи на него нет. Новой находкой это не считается, пока не измерен рост. -- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой - [security.md](security.md). Находкой считается только новая поверхность, - выставленная наружу, а не повторение этого факта. +- **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не + новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а + разграничения по владельцу нет сознательно — [security.md](security.md), + «Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая + поверхность, выставленная наружу, либо путь к содержимому записи **без** + сессии, а не повторение этого факта. ### Вопросы по темам diff --git a/docs/security.md b/docs/security.md index 2c70c3e..d5326b2 100644 --- a/docs/security.md +++ b/docs/security.md @@ -53,8 +53,8 @@ | Вход | Канал | Кто может слать | | --- | --- | --- | -| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой из интернета | -| Идентификатор задачи | `GET /api/status/:id` | Любой из интернета | +| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела | +| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи | | Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка | | Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель | | Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов | @@ -283,10 +283,12 @@ Telegram отправителю. - **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся. - **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно - (паспорт, 2026-08-11), шестичасовая запись весит единицы гигабайт — оценка, а не - замер: `research/` пуст, потолок длины стоит открытым вопросом - `architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать - (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в + (паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а + не замер: распределения длин у сервиса нет, а самая длинная проверенная запись + — 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)). + Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот + нет и не будет: решено считать расход и показывать его владельцу, а не + отказывать (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в Authelia. Рост каталога данных при этом ничем не наблюдается — открытый вопрос `architecture.md`. - **Перерасход денег на внешних сервисах.** Распознавание и языковая модель diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index 23c9060..e88d200 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -199,7 +199,7 @@ Telegram делит с ним общий шаг заведения задачи, - **GIVEN** источник метаданных читает запись и отдаёт её длительность - **WHEN** программа шлёт запись с именем, чей хвост после последней точки не - принадлежит перечню known-форматов + принадлежит перечню известных форматов - **THEN** метка метрики принимает значение `other` - **AND** имя файла в хранилище сохраняет пришедшее расширение diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index 56e0af5..2c856cf 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -1,7 +1,16 @@ # storage Specification ## Purpose -TBD - created by archiving change pocketbase-storage. Update Purpose after archive. + +Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение +схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность +хранилища и панель владельца. + +Приём и опрос готовности нормирует `intake`, вход и сессию — `access`. +Сознательно не описаны: перенос прежних данных — его нет по решению задачи +`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом +2026-08-11, а удаление приносит задача `delete-record`. + ## Requirements ### Requirement: Сервис поднимается на чистом каталоге данных diff --git a/openspec/specs/toolchain/spec.md b/openspec/specs/toolchain/spec.md index 702963d..325b7e1 100644 --- a/openspec/specs/toolchain/spec.md +++ b/openspec/specs/toolchain/spec.md @@ -162,10 +162,10 @@ Capability нормирует **не поведение сервиса** для и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только читать: файлов он не правит и разошедшихся мест не чинит. -Коды выхода MUST следовать словарю прочих проверочных шагов проекта: 0 сошлось, -1 расхождение, 2 ошибка употребления, 3 окружение. Своего словаря шаг MUST не -заводить: раздел «Гейт» в `CLAUDE.md` объявляет словарь общим, и четвёртый шаг с -собственной семантикой сделал бы это утверждение неверным. +Коды выхода MUST следовать общему словарю проверочных шагов проекта; словарь +объявляет раздел «Гейт» в `CLAUDE.md`, и здесь он не повторяется. Своего словаря шаг +MUST не заводить: четвёртый шаг с собственной семантикой сделал бы это +утверждение неверным. Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места. «Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы