Compare commits

...
23 Commits
Author SHA1 Message Date
av eacaf76d5f tasks: заведён остаток работы о гейте, контексте и токене
- четыре новые записи: проверить шаг migrations так же, как шаг сверки версий Go;
  свести шесть расхождений между документами канона; запретить обращаться к Bot
  API мимо клиента бота; разведка о шагах гейта, теряющих предмет
- context-cancel-in-pipeline приведена к правде: дописан перечень сделанного
  попутно, критерий с оракулом «тест на трёх прерываниях подряд» разбит надвое —
  проверена была только его узкая половина
2026-08-13 10:55:38 +03:00
av f4d8c7ed50 docs: мутация, не собравшаяся, — не мутация
- порядок заведения правила пополнен строкой: правка, снявшая последнее
  употребление импорта, роняет сборку, а не проверку, и её вывод легко принять
  за сработавшую мутацию. Случай был в этом же сеансе — проба приёма по HTTP
2026-08-13 10:29:17 +03:00
av bb9a67929c docs: правила о контексте и о токене записаны, две находки — в журнал
- в go-linters.md заведён раздел «Отмена и внешний собеседник», перечень
  механизированного пополнен девятью правилами и двумя шагами гейта, названы
  остатки: contextcheck не видит сигнатуру без контекста вовсе, шаг migrations
  судит только шаги, бывшие в базе диффа, rowserrcheck и sqlclosecheck
  профилактические — предмета в коде нет
- в logging.md и security.md чистка отказа Telegram описана по факту: точка одна
  и лежит на границе клиента, закрыты все пять путей вместе с логгером самой
  библиотеки. Прежнее «*Расхождение:* вычистки нет... она не логируется» было
  неверным дважды
- в журнал дефектов записаны две находки: отказ скачивания уносил токен бота
  (проскочил, жил с самого начала) и остановка сервиса хоронила конвертируемую
  запись в failed (поймано ревью до коммита)
- вопрос темы operations про отмену переформулирован: спрашивать надо не
  «доходит ли контекст», а «что шаг делает с задачей, деньгами и ответом
  отправителю»; вопрос про таймаут оставлен с оговоркой, что проброс контекста
  на него не отвечает
- в памятке: словарь кодов новых шагов, требование компилятора C у детектора
  гонок и оговорка, что «CGO не нужен» относится к сборке, а не к гейту
2026-08-13 10:28:31 +03:00
av bc5c35790e Гейт ищет гонки, переписанные шаги схемы и девять новых классов дефектов
- включены noctx, contextcheck и bodyclose (отмена доходит до внешнего вызова,
  контекст приезжает сверху, тело ответа закрывается), nilerr, rowserrcheck и
  sqlclosecheck (отказ не теряется молча), testifylint и nolintlint (форма
  утверждения и форма подавления), плюс errcheck check-type-assertions:
  непроверенное приведение типа паникует, и check-blank его не видит
- заведён шаг tests: go test -race, потому что «результат пишет только держатель
  захвата» — утверждение об одновременности. Без компилятора C шаг гоняет тесты
  без детектора и краснеет кодом 3 после них: гонки не повод отнимать у гейта
  сами тесты
- заведён шаг migrations: у файла шага схемы допустим один статус — A. Баз диффа
  две, BASE и HEAD: первая отвечает на «уже уехал» настолько, насколько свежа
  origin/master, вторая ловит правку закоммиченного шага независимо от неё.
  Каталог берётся из docs/.docs.json, пустой каталог роняет шаг
- единственное подавление — noctx на httptest.NewRequest в проверках: за
  фикстурой запроса внешнего собеседника нет. Граница проверена мутацией —
  http.Get и exec.Command из проверки правилу по-прежнему подсудны
2026-08-13 10:28:12 +03:00
av f494dcb83e Отмена доходит до внешнего собеседника, а токен не покидает единой точки
- контекст проложен от воркера и обоих входов до внешних вызовов: ffmpeg и
  ffprobe заводятся через exec.CommandContext, SpeechKit и Object Storage
  принимают ctx вместо context.Background, скачивание записи идёт запросом с
  контекстом. Прежде остановка сервиса не доходила до чужой работы вовсе
- прерванный шаг приговора не выносит: убитый по контексту ffmpeg отдаёт
  «signal: killed», от настоящего отказа неотличимо ни типом, ни errors.Is, и
  различает их только ctx.Err(). Задача остаётся на повтор, попытку не тратит и
  отправителю о несуществующем сбое не сообщает; воркер не считает остановку
  отказом, а задача не забирается вовсе, если нас уже остановили
- клиента Bot API заводит единая точка internal/adapter/telegram: токен стоит в
  пути каждого обращения, а http.Client кладёт адрес в *url.Error целиком.
  Чистка на месте употребления закрывала один вызов из пяти — теперь свой Do
  чистит отказ, подменённый логгер вычищает токен из строк самой библиотеки, а
  транспорт бота токена не получает вовсе
- принятие операции распознавания защищено от отмены своим пределом: SpeechKit
  мог её принять и начать считать деньги, а потерянный идентификатор заставил
  бы повтор оплатить ту же запись второй раз
- приём по HTTP доводит запись до задачи независимо от отправителя: на
  контексте запроса один обрыв соединения терял полностью загруженную запись
- ответ Telegram с не-2xx кодом больше не становится записью: прежде тело
  отказа доезжало до хранилища и умирало на ffprobe, уводя диагностику
2026-08-13 10:27:54 +03:00
av d8d6bcc193 docs: линтеры и механизация переехали записью конвенций go-linters.md
- документ docs/autotests.md снят: перечень правил, подавлений и лестница
  механизации — это конвенция о том, чем машина читает код, и место ей среди
  прочих записей
- в шапке названо, чего в записи нет: как писать тесты. Свойства, которые обязан
  проверять тест, остаются в review.md, «Типовые узлы»
- ссылки переставлены в памятке, индексе конвенций, четырёх записях, review.md,
  .golangci.yml, lefthook.yml и пакете сканеров
2026-08-13 09:11:49 +03:00
av 8bcd2c0059 tasks: закрыта задача gate-extra-linters
- shellcheck, hadolint и мутационный тест скрипта сверки версий заведены, набор
  предкоммитных проверок назван в памятке
2026-08-13 09:02:31 +03:00
av 37ccda3677 docs: autotests.md стал переносимым документом об автопроверках
- лестница механизации из пяти ступеней, два круга проверок, перечень правил
  таблицами по родам, перечень подавлений с причинами, порядок заведения
  правила; названы остатки правил и отклонённые подъёмы
- утверждения «Механизировано» в четырёх записях конвенций и единые точки в
  architecture.md приведены к сегодняшнему состоянию; изъятие «транспорт знает
  адаптер хранилища» названо строкой
- в журнал дефектов записаны две находки: узнавание конца потока по тексту и
  правило гейта, обходимое одной лишней строкой
2026-08-13 09:02:19 +03:00
av e1dfe662ea Гейт видит скрипты и Dockerfile, а pre-commit — затронутые файлы
- шаги shell (shellcheck) и dockerfile (hadolint) заведены; два правила hadolint
  подавлены поимённо с причиной — DL3007 до задачи pin-runtime-image-base и
  DL3018 по существу
- lefthook гоняет на затронутых файлах gofmt, golangci-lint по их каталогам,
  shellcheck, hadolint и gitleaks — около секунды; полный набор в pre-commit не
  переносится намеренно
- заведён мутационный тест скрипта сверки версий: 20 сценариев спеки toolchain
  плюс требование сообщения называть все четыре места, независимость исхода от
  установленного go и запрет звать go, docker и сеть
2026-08-13 09:02:05 +03:00
av 0353517ec4 Линтеры и сканеры взяли на себя то, что было прозой конвенций
- включены sloglint, misspell, depguard; forbidigo получил запреты на чтение
  времени, окружения и вывод в stdout — каждый по свойству, а не по одному
  имени: запрет на os.Getenv без соседей обходится os.LookupEnv
- заведён internal/archrules — тесты-сканеры: направление зависимостей между
  ядром, транспортами и адаптерами, узнавание ошибки по тексту в шести формах,
  и согласованность колонок очереди во всех четырёх местах плюс шаг схемы
- каждое правило проверено мутацией, каждое исключение объявлено с причиной
2026-08-13 09:01:52 +03:00
av 1d243ad2f6 Время читается единой точкой, а конец потока узнаётся не по тексту
- заведён internal/clock: Now даёт метку в UTC, Start — начало измерения
  длительности с монотонными часами; девять мест рабочего кода и запрос захвата
  переведены на него, долг «время time.Now() по месту» закрыт
- клиент SpeechKit узнавал конец потока сравнением err.Error() == "EOF": отказ
  с тем же текстом вернул бы усечённую расшифровку как готовую, теперь
  errors.Is(err, io.EOF)
- починены находки новых линтеров: две опечатки, два slog.DiscardHandler,
  четыре неэкранированные подстановки в docker/entrypoint.sh
2026-08-13 09:01:36 +03:00
av 6c7f006e75 docs: инструменты гейта переехали в свой дом — docs/autotests.md
- перечень «Механизировано» и то, что осталось прозой, снято из конвенций: они
  про то, как писать код, а не про инструменты, которые его читают
- новый документ — дом темы ревью autotests, с границами: семантика гейта
  остаётся в CLAUDE.md, журнал дефектов и вопросы по темам — в review.md
- вопрос ревью о суждении по готовому ответу сужен до того, что машина не
  проверяет: до ответа мимо recorder
2026-08-13 07:35:58 +03:00
av 8bffd30955 tasks: закрыты четыре задачи о гейте, заведён урожай ревью
- сняты fix-migrations-path-in-docs-config, gate-step-exit-codes,
  response-assertions-judge-result, gate-dependency-vulnerabilities
- заведена rollback-restores-wrong-session-duration: откат шага входа ставит
  14 суток, называя их умолчанием библиотеки в пять
2026-08-12 22:06:11 +03:00
av 3925c637f3 Гейт ловит достижимые уязвимости в зависимостях
- шаг vulns гоняет govulncheck последним: ему одному нужна сеть, и он самый
  долгий; отсутствие инструмента даёт код окружения, а не пропуск
- закрыты обе достижимые находки — grpc до 1.82.1, aws-sdk-go-v2/service/s3 до
  1.97.3 с eventstream 1.7.8; прогон на реальных ключах Yandex не делался
- место шага названо в семантике гейта: он судит достижимость из кода, и
  недостижимая GO-2026-5932 в golang.org/x/crypto/openpgp его не роняет
2026-08-12 22:04:53 +03:00
av 35bde75b1f Гейт роняет проверку, судящую ответ по живой карте заголовков
- forbidigo с analyze-types запрещает в файлах проверок обращение к
  httptest.ResponseRecorder.Header и .HeaderMap: правило судит по типу
  приёмника, поэтому ловит и цепочку, и переменную, и индекс, и обход
- класс стоил трёх зелёных гейтов при неработающем коде; правило записано
  строкой в перечне механизированного, прозой не дублируется
- поправлены два утверждения «механизировано: ничего», разошедшиеся с
  включённым линтером и со сверкой шага схемы
2026-08-12 22:04:35 +03:00
av 870b6bc829 Обёртки шагов гейта отдают код окружения, а не код дрейфа
- недостающий скрипт у всех четырёх обёрток в Taskfile.yml даёт 3, как обещает
  словарь: прежний 1 значил «дрейф» и отправлял искать разъехавшееся там, где
  просто неполно дерево
- в словарь кодов CLAUDE.md добавлены сами обёртки и оговорка про 201: этим
  кодом task отдаёт наружу любой отказ шага, а код шага печатает строкой
2026-08-12 22:04:06 +03:00
av 4a052ec99b Шаги схемы вынесены в свой каталог, и сверка их снова видит
- шаги PocketBase переехали из файла в пакет
  internal/adapter/repo/pocketbase/migrations, файл на шаг с именем
  зарегистрированного шага; туда же имена коллекций, срок сессии — в provider.go
- ключ migrations в docs/.docs.json наведён на этот каталог: прежнее значение
  указывало на несуществующий migrations/, и шаг гейта проходил зелёным при
  всякой правке схемы
- app.go подключает пакет шагов явным пустым импортом: пропавшая ссылка на
  константы унесла бы регистрацию, и хранилище поднялось бы без коллекций
2026-08-12 22:03:48 +03:00
av b46be019fc docs: канон приведён к сегодняшнему состоянию после сверки
- периметр: passport.md и security.md больше не утверждают, что HTTP API
  открыт без аутентификации, а review.md не числит эту находку типовой
  ложноположительной — приём, опрос и файл закрыты сессией с 2026-08-12;
- logging.md писал, что расширение попадает в журнал полем пути: описано
  изъятие инварианта приватности — собственное поле, имени и пути нет;
- узел ревью переименован в repo/pocketbase, поведение конвейера из обзора
  уехало ссылкой в спеку pipeline, Purpose спеки storage написан вместо
  заглушки, в ADR о переезде дописано уточнение о действующей раскладке.
2026-08-12 21:13:06 +03:00
av 8739b18a9f tasks: очередь расставлена от базы к деталям
- порядок беклога и роадмапа назначен слоями: проверки, которым можно
  верить → долги входа → владелец записи и контракт API → конвейер под
  тестами → приложение и возможности поверх; у каждого движения записана
  причина;
- заведены восемь задач под пункты «Завершения», которых не закрывала ни
  одна запись, — цель any-audio-source была без задач вовсе;
- у четырёх задач сняты критерии, требовавшие того, что делает задача ниже
  по очереди; исправлены ссылки на несуществующий repo/sqlite и на
  отменённую разведку об очереди.
2026-08-12 20:48:53 +03:00
av ddc34b3182 tasks: закрыта задача oidc-login, заведён урожай ревью 2026-08-12 18:10:59 +03:00
av c44f0e7582 HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой:
  собственную регистрацию, вход по паролю и одноразовый код — без этого
  закрытие приёма обходилось двумя запросами
- продление сессии выключено, срок семь суток: иначе отзыв доступа у
  провайдера до сервиса не доходит никогда
- файл записи отдаётся вошедшему по токену файла — пересмотр
  ADR-2026-08-12-file-link-open-but-not-logged
2026-08-12 17:44:22 +03:00
av d676df8a27 tasks: закрыта задача go-1-26-upgrade, заведён урожай ревью
- четыре задачи из находок ревью и заметки владельца: проверки shellcheck и
  hadolint с тестом скрипта сверки версий, закрепление рантайм-базы образа,
  коды выхода шагов гейта, настройка конвейера ревью
- INBOX разобран целиком и очищен
2026-08-12 14:26:07 +03:00
av 09228f23d8 Go обновлён до 1.26, а расхождение версий теперь роняет гейт
- шаг go-version в task gate сверяет объявленную версию в go.mod, Dockerfile,
  CLAUDE.md и README.md; судит по репозиторию, go не зовёт, docker и сети не
  требует
- заведена capability toolchain: до сих пор спеки нормировали только поведение
  сервиса, теперь и инструмент сборки. Причина и цена — в двух ADR
- закрыт дефект 2026-08-12: образ на golang:1.24-alpine разошёлся с go.mod и
  перестал собираться, а восемь шагов гейта и шесть проходов ревью были зелёными
2026-08-12 10:57:50 +03:00
170 changed files with 9961 additions and 724 deletions
+177
View File
@@ -1,15 +1,152 @@
# Линтеры проекта. Перечень правил и их дома — docs/conventions/go-linters.md,
# «Механизировано»; здесь только настройка и «почему именно так».
#
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
# staticcheck, unused. Сверх него включено то, что механизирует конвенции: то,
# что проверяет правило, прозой в конвенциях не остаётся.
version: "2"
linters:
default: standard
enable:
# docs/conventions/errors.md: сравнение ошибок через errors.Is и errors.As.
- errorlint
# docs/conventions/errors.md: ошибки — только stdlib.
- depguard
# docs/conventions/logging.md: форма вызова slog.
- sloglint
# Опечатка в комментарии и в тексте ошибки читается как термин проекта.
- misspell
# Запреты по месту: чем судят ответ в проверках, чем читают время, откуда
# берут конфигурацию, куда пишут вывод. Подробности у каждого правила ниже.
- forbidigo
# Отмена доходит до внешнего вызова: запрос и внешний процесс заводятся с
# контекстом. Инвариант «принятая запись не теряется молча» держится
# остановкой на середине, а не только записью в лог: `ffmpeg`, заведённый
# без контекста, переживает остановку воркера и дожёвывает чужую запись.
- noctx
# Контекст приезжает сверху, а не заводится по месту. `context.Background()`
# внутри адаптера обрывает цепочку отмены ровно на границе с платным
# внешним сервисом — там, где отмена и нужна.
- contextcheck
# Тело ответа закрывается. `errcheck` его не видит: `(io.ReadCloser).Close`
# объявлен в `exclude-functions` ниже, и незакрытое тело от невыясненного
# `Close` этим списком не отличается.
- bodyclose
# `return nil` после проверенной ошибки — это молчаливая потеря отказа,
# прямо запрещённая инвариантом об очереди (CLAUDE.md, major).
- nilerr
# Отказ выборки не теряется: неспрошенный `rows.Err()` превращает оборванное
# чтение в пустой результат.
- rowserrcheck
# `Rows` и `Stmt` закрываются: незакрытая выборка держит соединение.
- sqlclosecheck
# Форма утверждений в проверках: перепутанные местами «ожидалось/получено»,
# `assert` там, где после провала продолжать нельзя, `require` из горутины.
- testifylint
# Подавление — это решение: строчное `//nolint` обязано называть линтер и
# причину, а протухшее подавление обязано краснеть. Тот же порядок, что у
# подавлений в этом файле, но применённый к комментариям в коде.
- nolintlint
settings:
forbidigo:
# `analyze-types` включает суждение по типу приёмника, а не по печатному
# тексту вызова. Правилу о заголовках это необходимо (см. ниже), прочим
# правилам не мешает: имена пакетов в шаблонах те же.
analyze-types: true
forbid:
# Вывод идёт в журнал: строка в stdout мимо slog не имеет ни уровня, ни
# полей, и в разборе постфактум её не найти. Встроенные `print`/`println`
# названы тем же правилом: запрет на одно имя обходится соседним.
- pattern: '^fmt\.Print.*$'
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
- pattern: '^print(ln)?$'
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
# Конфигурация приезжает из TOML. Перечислены все способы прочитать
# окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv`
# одной правкой. Окружение читает только godotenv в main.go — он кладёт
# .env в окружение процесса, а не в настройки.
#
# Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и
# `os.Stdout.WriteString` — первый аргумент по имени функции не судится.
# Этот остаток назван прозой в docs/conventions/logging.md.
- pattern: '^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$'
msg: 'конфигурация только из TOML (docs/conventions/config.md)'
# Единая точка чтения времени — internal/clock: метка времени в UTC
# (`clock.Now`), измерение длительности с монотонными часами
# (`clock.Start`). Прежде время брали по месту, и хранилище сравнивало
# строками времена из разных зон.
- pattern: '^time\.Now$'
msg: 'время читают clock.Now (метка) и clock.Start (длительность) — docs/conventions/database.md'
# Проверка ответа судит по **готовому ответу**, а не по изменяемому
# состоянию обработчика. `httptest` устроен зеркально настоящему серверу:
# `Header()` отдаёт живую карту, доступную и после записи ответа, а
# снимок, который получит клиент, лежит отдельно и читается через
# `Result()`. Проверка, читающая живую карту, зелена при неработающем
# коде — класс всплывал трижды (docs/review.md, записи 2026-08-10,
# 2026-08-11 и 2026-08-12) и трижды стоил зелёного гейта.
#
# Правило судит по типу приёмника, и в этом весь смысл: запрет на
# цепочку `w.Header().Get` обходится одной лишней строкой —
# `h := w.Header()`, — а также чтением по индексу карты и обходом
# `range`. По типу под правило попадают все эти формы разом. Текстом его
# записать нельзя ещё и потому, что `.Header` носят и запрос
# (`req.Header.Set` в проверках законен), и снимок ответа
# (`w.Result().Header` — как раз то, к чему правило ведёт).
#
# Приёмник назван поимённо: подставной сервер в проверках отдаёт
# заголовок через `w.Header().Set`, но у него приёмник —
# `http.ResponseWriter`, и под правило он не попадает.
- pattern: '^httptest\.ResponseRecorder\.Header$'
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
# `HeaderMap` — тот же живой снимок прежним именем поля. Правило второе,
# потому что об устарелости поля говорит `staticcheck` (SA1019), а о том,
# почему по нему не судят ответ, — только это сообщение.
- pattern: '^httptest\.ResponseRecorder\.HeaderMap$'
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
sloglint:
# Стиль вызова один — пары «ключ-значение». `kv-only` запрещает атрибуты
# (`slog.String` и прочие) **целиком**, а не только смешение с парами:
# смешение и так запрещено умолчанием `no-mixed-args`. Решение осознанное —
# один стиль на весь код, — и записано строкой в
# docs/conventions/logging.md, «Сообщение».
no-mixed-args: true
kv-only: true
# `msg` — константа: сообщение с подставленным значением не сгруппировать
# отбором, а данные для этого и кладут в поля.
static-msg: true
# `key-naming-case` не включаем: словарь полей намеренно смешанный —
# доменные поля `snake_case`, системные домены с точкой (`http.method`,
# `ext.service`). См. docs/conventions/logging.md, «Поля: словарь имён».
depguard:
rules:
main:
deny:
- pkg: github.com/pkg/errors
desc: 'ошибки — только stdlib errors и fmt.Errorf (docs/conventions/errors.md)'
- pkg: github.com/cockroachdb/errors
desc: 'стек-трейс избыточен, контекст несёт цепочка %w (docs/conventions/errors.md)'
nolintlint:
# Подавление без причины снимают при первом же неудобстве: снимающий не
# знает, что оно ловило. Те же два требования, что у подавлений в этом
# файле, — имя линтера и причина строкой.
require-explanation: true
require-specific: true
# Подавление, которому нечего подавлять, — след починенного места, и
# краснеть оно обязано: иначе перечень подавлений врёт.
allow-unused: false
errcheck:
# Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не
# теряется молча» принимается реализацией, которая его теряет. Отказ,
# который решено не проверять, теперь объявляют ниже поимённо — заметно.
check-blank: true
# Непроверенное приведение типа паникует, а не отдаёт ошибку, поэтому
# `check-blank` его не ловит: `v := x.(T)` вовсе не про присваивание в `_`.
check-type-assertions: true
exclude-functions:
# Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки
- (io.Closer).Close
@@ -20,6 +157,46 @@ linters:
# Метод сам логирует ошибку отправки, вызывающему она не нужна
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
exclusions:
rules:
# Правило о заголовках живёт только в файлах проверок: в рабочем коде
# `Header()` и есть способ отдать заголовок.
- linters:
- forbidigo
path-except: '_test\.go$'
text: 'живой карте заголовков'
# Единая точка чтения времени сама читает время — иначе ей нечем.
- linters:
- forbidigo
path: 'internal/clock/'
text: 'time.Now'
# Проверка читает окружение **своего прогона** — `PATH`, чтобы убрать из
# него каталог с `go`, и `os.Environ()`, чтобы передать окружение дочернему
# процессу. Настройками приложения это не является. Исключение объявлено по
# тексту сообщения, а не по имени функции: правило называет четыре имени, и
# исключение обязано покрывать те же четыре.
- linters:
- forbidigo
path: '_test\.go$'
text: 'конфигурация только из TOML'
# Проверки строят время фикстур, а не метку домена: `time.Now` в них не
# обходит единую точку, а задаёт вход. Запрет здесь стоил бы обязательного
# обряда на каждый срок захвата в фикстуре и не поймал бы ничего.
- linters:
- forbidigo
path: '_test\.go$'
text: 'time.Now'
# `httptest.NewRequest` строит фикстуру для обработчика в том же процессе:
# внешнего собеседника за ней нет, и отменять у неё нечего — правило здесь
# говорит не о том, что мы имели в виду. Изъятие названо по имени этой
# функции, а не выключением `noctx` на проверках целиком: настоящий внешний
# вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему
# подсуден.
- linters:
- noctx
path: '_test\.go$'
text: 'httptest\.NewRequest'
formatters:
enable:
- gofmt
+59 -11
View File
@@ -24,7 +24,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
## Стек
Go 1.25 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
@@ -33,10 +33,15 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
Что нарушать нельзя.
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и в колонку
`error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во
всех местах выкладки. **critical**
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object
Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
вручную во всех местах выкладки. **critical**
*Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции
пользователей хранилища — туда его кладёт приведение настроек при каждом
подъёме, потому что применённый шаг схемы не переписывается и не пережил бы
ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные
места запрета это не отменяет.
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
@@ -82,7 +87,7 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
```bash
go build ./... # CGO не нужен
go test ./...
go test ./... # в гейте идёт с -race, и там нужен компилятор C
go vet ./...
gofmt -l .
golangci-lint run
@@ -100,17 +105,60 @@ task gate # весь набор проверок разом
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
`origin/master`; переопределяется `task gate BASE=<rev>`.
- **Какое правило чем проверяется** — конвенция
[docs/conventions/go-linters.md](docs/conventions/go-linters.md). Здесь
семантика гейта, там перечень правил, подавлений и место настройки каждого;
перечень здесь не повторяется.
- **Где логи шагов:** вывод команды, отдельного файла нет.
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
`docs.py check`, `tasks.py check` и `openspec.py check` словарь кодов общий:
0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта,
каталог не найден), 4 внутренний сбой.
`docs.py check`, `tasks.py check`, `openspec.py check` и
`scripts/check-go-version.sh` словарь кодов общий: 0 сошлось, 1 дрейф,
2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не
найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно:
четвёртый шаг с собственной семантикой сделал бы это утверждение неверным.
Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая
`tests`, `migrations`, `shell`, `dockerfile` и `vulns`: недостающий инструмент
— отказ окружения, код 3. У `tests` это отсутствие CGO или компилятора C, без
которых не работает детектор гонок — тесты он в этом случае всё равно гоняет,
без `-race`, и краснеет уже после них. У `migrations` код 3 — неразрешимая
база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 —
переписанный шаг схемы. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`,
`golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой».
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
любой отказ шага выходит с 201, а код шага печатает строкой
(«exit status 3»), поэтому словарь читается по коду скрипта.
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
неотформатированный файл, находка `golangci-lint`, дрейф раскладки документов,
дрейф каталога задач, форма `openspec/config.yaml`. Машина проверяет всё
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
схемы,
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
версий Go, дрейф раскладки документов,
дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода
уязвимость в зависимостях (`govulncheck`), находка `shellcheck` в скриптах
оболочки и `hadolint` в `Dockerfile`. Машина проверяет всё
перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с
именем недостающего плагина, а не пропускается молча.
- **Что ловит pre-commit, а что только гейт.** `lefthook.yml` гоняет на
**затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в
коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`,
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
сверка версий Go, три сверки документов и `govulncheck`: они смотрят всё
дерево либо требуют сети, а pre-commit обязан быть быстрым.
- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он
достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем,
шаг не роняет. Такая сегодня одна — `GO-2026-5932` в
`golang.org/x/crypto/openpgp`, исправления у неё нет вовсе.
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
- **сборка образа** — дорога, и отказ от неё сознательный. Дешёвая замена
стоит шагом сверки версий: он сравнивает строки и ловит расхождение, из-за
которого образ перестаёт собираться, но собираемости не проверяет. Собрать
образ по-прежнему может только человек — `task image`, и на подъёме версии
это обязательно;
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и два его
правила подавлены поимённо — `DL3007` до задачи `pin-runtime-image-base` и
`DL3018` по существу (alpine не держит старые версии пакетов, закрепление
ломает сборку через недели). Причины стоят строками в `Taskfile.yml`;
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
коммита. Полную историю никто не проверяет;
- согласованность документов между собой и с кодом — её судят агенты, зовёт
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM docker.io/library/golang:1.25-alpine AS build-env
FROM docker.io/library/golang:1.26-alpine AS build-env
# Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
# и CGO больше не требуется.
+4 -3
View File
@@ -13,6 +13,7 @@
## Технологии
- **Язык**: Go 1.26, CGO не нужен
- **Веб-фреймворк**: gin-gonic/gin
- **Telegram**: go-telegram-bot-api
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
@@ -113,9 +114,9 @@ transcriber/
## Разработка
Схему двигают шаги миграций PocketBase на Go —
`internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме
хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не
переписывается: изменение — только новым файлом шага.
`internal/adapter/repo/pocketbase/migrations`, файл на шаг. Непринятые шаги
накатываются при подъёме хранилища, прежде чем стартуют воркеры и сервер.
Применённый шаг не переписывается: изменение — только новым файлом шага.
Проверки перед коммитом — одной командой:
+157 -4
View File
@@ -9,6 +9,12 @@ vars:
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит
# дрейф своего каталога, и выпадение одного не подменяется другим.
#
# Недостающий скрипт — отказ окружения у всех обёрток ниже, и код у него 3 по
# общему словарю (CLAUDE.md, раздел «Гейт»). Прежний код 1 значил «дрейф» и
# отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам
# `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается
# по коду скрипта, а не по коду `task`.
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.py"}}'
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-tasks/skills/tasks/scripts/tasks.py"}}'
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-code/skills/openspec/scripts/openspec.py"}}'
@@ -27,11 +33,139 @@ tasks:
echo "gofmt: файлы выше не отформатированы"
exit 1
fi
- go test ./...
- task: tests
- golangci-lint run
- task: shell
- task: dockerfile
- task: go-version
- task: migrations
- task: docs
- task: tasks
- task: openspec
# Последним: единственный шаг, которому нужна сеть, и самый долгий.
- task: vulns
tests:
desc: 'Тесты с детектором гонок'
cmds:
# Гонки ищет детектор, а не чтение кода: у сервиса три воркера ходят в одну
# очередь, и «результат пишет только держатель захвата» — утверждение о
# одновременном доступе. Детектору нужен CGO и компилятор C; сборка
# приложения по-прежнему обходится без них (CLAUDE.md, «Стек»), поэтому их
# отсутствие — отказ окружения, код 3, а не отказ проверки.
# Окружение проверяется **после** обычного прогона, а не вместо него:
# отсутствие компилятора отнимает у гейта поиск гонок, но не должно
# отнимать сами тесты. Порядок проверок — сперва компилятор: без него
# совет «включи CGO_ENABLED=1» бесполезен.
- |
if ! command -v gcc >/dev/null 2>&1 && ! command -v clang >/dev/null 2>&1; then
go test ./... || exit 1
echo "тесты прошли, но гонки не искали: детектору нужен компилятор C"
echo "ни gcc, ни clang не найдены в PATH; поставь: apt install gcc"
exit 3
fi
if [ "$(go env CGO_ENABLED)" != "1" ]; then
go test ./... || exit 1
echo "тесты прошли, но гонки не искали: детектору нужен CGO"
echo "CGO_ENABLED=$(go env CGO_ENABLED); включи: CGO_ENABLED=1 task gate"
exit 3
fi
go test -race ./...
migrations:
desc: 'Применённый шаг схемы не переписывается'
cmds:
# Инвариант CLAUDE.md (critical): хранилище считает применённое по имени
# файла шага, поэтому изменить уехавший шаг нельзя — только добавить новый.
# Компилятор этого не держит, и до этого шага не держало ничто.
#
# Судится каталог шагов против базы диффа: у файла шага допустим один
# статус — `A`. Правка (`M`), удаление (`D`) и переименование (`R`) красят.
# `migrations.go` под правило не подпадает: строка `Register` у нового шага
# прибавляется именно там, и запрет на него запретил бы заведение шага.
- |
if ! git rev-parse --verify --quiet "{{.BASE}}" >/dev/null 2>&1; then
echo "база диффа не найдена: {{.BASE}}"
echo "задай свою: task migrations BASE=<rev>"
exit 3
fi
# Каталог шагов берётся из docs/.docs.json — там он уже записан ключом
# `migrations` для сверки документов. Свой литерал завёл бы факту второй
# дом: каталог переехал бы, а один из двух стражей молча позеленел.
dir=$(python3 -c 'import json,sys; print(json.load(open("docs/.docs.json"))["migrations"])' 2>/dev/null) || dir=""
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
echo "каталог шагов схемы не найден: ключ migrations в docs/.docs.json → '$dir'"
exit 3
fi
# Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от
# одного переименования — тот же приём, что у правил `internal/archrules`.
if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then
echo "в $dir нет ни одного файла шага: правило потеряло предмет"
echo "поправь шаблон имени в этом шаге либо ключ migrations в docs/.docs.json"
exit 3
fi
# Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал»
# ровно настолько, насколько свеж `origin/master`: отставшая ссылка
# читает весь каталог как добавленный, и правило молчит. `HEAD` ловит
# правку закоммиченного шага в рабочем дереве независимо от ссылки.
for base in {{.BASE}} HEAD; do
touched=$(git diff --name-status "$base" -- "$dir" \
| grep -E '[0-9]{12}_[^/]*\.go$' \
| grep -vE '^A[[:space:]]' || true)
if [ -n "$touched" ]; then
echo "база $base:"
echo "$touched"
echo "применённый шаг схемы переписан: изменение схемы — только новым файлом шага"
echo "(CLAUDE.md, «Инварианты», critical: хранилище считает применённое по имени файла)"
exit 1
fi
done
shell:
desc: 'shellcheck на скрипты оболочки'
cmds:
# Скриптов два и оба свои: шаг сверки версий и `docker/entrypoint.sh`.
# Второй в образ копируется, но не исполняется — `ENTRYPOINT` в
# `Dockerfile` закомментирован, — и проверяется он именно поэтому: код,
# который никто не гоняет, портится незаметно. Ни один из двух не виден ни
# `go vet`, ни `golangci-lint`.
- |
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck не найден в PATH"
echo "поставь: apt install shellcheck (или https://github.com/koalaman/shellcheck)"
exit 3
fi
shellcheck scripts/check-go-version.sh docker/entrypoint.sh
dockerfile:
desc: 'hadolint на Dockerfile'
cmds:
# DL3007 (`alpine:latest` у рантайм-слоя) подавлен: это открытая задача
# `pin-runtime-image-base`, и до её решения шаг краснел бы на известном.
# DL3018 (закрепить версии пакетов `apk`) подавлен по существу: alpine не
# держит старые версии в репозитории, и закрепление ломает сборку через
# недели — то есть лечение хуже болезни.
- |
if ! command -v hadolint >/dev/null 2>&1; then
echo "hadolint не найден в PATH"
echo "поставь: https://github.com/hadolint/hadolint/releases"
exit 3
fi
hadolint --ignore DL3007 --ignore DL3018 Dockerfile
go-version:
desc: 'Одна версия Go в go.mod, Dockerfile, CLAUDE.md и README.md'
cmds:
# Скрипт лежит в самом репозитории, а не в плагине: его отсутствие значит
# сломанное дерево, а не непоставленный плагин, и переопределять путь
# нечем и незачем. Код отсутствия — 3, как у прочих обёрток.
- |
py=scripts/check-go-version.sh
if [ ! -f "$py" ]; then
echo "$py не найден: дерево репозитория неполно"
exit 3
fi
sh "$py"
docs:
desc: 'Раскладка docs/ против канона'
@@ -42,7 +176,7 @@ tasks:
if [ ! -f "$py" ]; then
echo "docs.py не найден: $py"
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>"
exit 1
exit 3
fi
python3 "$py" check --base {{.BASE}}
@@ -54,7 +188,7 @@ tasks:
if [ ! -f "$py" ]; then
echo "tasks.py не найден: $py"
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>"
exit 1
exit 3
fi
python3 "$py" check --dir tasks
@@ -66,10 +200,29 @@ tasks:
if [ ! -f "$py" ]; then
echo "openspec.py не найден: $py"
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>"
exit 1
exit 3
fi
python3 "$py" check --dir .
vulns:
desc: 'Достижимые из кода уязвимости в зависимостях'
cmds:
# `govulncheck` — внешний инструмент, а не плагин и не файл репозитория:
# ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Его
# отсутствие — отказ окружения, код 3, как у прочих обёрток.
#
# Свой код 3 у самого инструмента значит «уязвимость найдена» и с кодом
# обёртки совпадает; различает их сообщение — обёртка называет недостающий
# инструмент. Шагу нужна сеть: база уязвимостей живёт на vuln.go.dev, и без
# сети шаг краснеет, а не пропускается молча.
- |
if ! command -v govulncheck >/dev/null 2>&1; then
echo "govulncheck не найден в PATH"
echo "поставь: go install golang.org/x/vuln/cmd/govulncheck@latest"
exit 3
fi
govulncheck ./...
# Контракт роли app_image (pet-project-server): собрать полный образ и затегать
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
image:
+26
View File
@@ -33,6 +33,32 @@ object_storage_region = "ru-central1"
# Endpoint Object Storage
object_storage_endpoint = "https://storage.yandexcloud.net/"
# Вход через внешнего провайдера OIDC (Authelia).
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
# API открытым наружу.
[auth]
# Адрес, куда сервис уводит человека на вход
auth_url = "https://auth.example.com/api/oidc/authorization"
# Адрес, где код обменивается на токен
token_url = "https://auth.example.com/api/oidc/token"
# Адрес, откуда берутся сведения о вошедшем
user_info_url = "https://auth.example.com/api/oidc/userinfo"
# Идентификатор клиента, заведённого у провайдера
client_id = "transcriber"
# Секрет клиента; приходит из выкладки, в git не коммитится
client_secret = ""
# Адрес возврата; тот же, что записан клиенту у провайдера
redirect_url = "https://transcriber.example.com/auth/callback"
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
# запуска по http://localhost, где браузер такую куку не сохранит
secure_cookie = true
# Telegram Bot Configuration
[telegram]
# Токен Telegram бота (получить у @BotFather в Telegram)
+4 -4
View File
@@ -12,21 +12,21 @@ if [ "${USER}" != "transcriber" ]; then
fi
if [ -z "${USER_GID}" ]; then
USER_GID="$(id -g ${USER})"
USER_GID="$(id -g "${USER}")"
fi
if [ -z "${USER_UID}" ]; then
USER_UID="$(id -u ${USER})"
USER_UID="$(id -u "${USER}")"
fi
# Change GID for USER?
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g ${USER})" ]; then
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g "${USER}")" ]; then
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group
sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd
fi
# Change UID for USER?
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u ${USER})" ]; then
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u "${USER}")" ]; then
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd
fi
+1 -1
View File
@@ -1,4 +1,4 @@
{
"canon": 14,
"migrations": "migrations"
"migrations": "internal/adapter/repo/pocketbase/migrations"
}
@@ -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.
Что делать с сессией и где она живёт, решает уже не наш код.
@@ -0,0 +1,47 @@
# Кого пускать в сервис, решает правило провайдера, а не сервис
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Кого пускать, решает провайдер, а не сервис»
## Решение
Сервис пускает всякого, кого пропустил провайдер, и **своей проверки допуска не
делает**. Кто допущен, определяет правило Authelia на этого клиента — настройка
выкладки, лежащая вне репозитория.
## Почему
Authelia — общий провайдер контура, а не выделенный под этот сервис: учётная
запись в ней есть у всякого, кому её завели ради любого другого сервиса на том же
сервере. Ревью дизайна назвало следствие прямо: механизм, приглашающий «второго
человека», приглашает всех, кто уже есть у провайдера.
Очевидный ответ — проверять принадлежность к названной в конфиге группе своим
кодом. Владелец от него отказался: это завело бы **второе место**, где решается
допуск, и решать его пришлось бы в двух местах согласованно.
Цена отказа названа в источнике и повторена в модели угроз:
> Правило живёт вне репозитория, в настройках выкладки, и сервис на него
> полагается так же, как полагается на обратный прокси в части панели
> администратора. Настроенный слишком широко клиент открывает сервис всем, у кого
> есть учётная запись в общей Authelia, — и проверить это по коду нельзя.
Запись заводится как **намеренный отказ от очевидного подхода**: проверку группы
предложат снова, и без записанной причины она выглядит бесплатной.
## Последствия
- `+` допуск решается в одном месте, а не в двух; изменение круга допущенных не
требует ни правки кода, ни выкладки.
- `+` сервис не читает из ответа провайдера ничего сверх нужного для заведения
записи — ни групп, ни ролей.
- `` защита сервиса стала свойством настройки, лежащей в другом репозитории, и
ревью её проверить не может: ни один проход не увидит, что клиент настроен
слишком широко.
- `` ошибка в настройке клиента не имеет наблюдаемого признака внутри сервиса:
посторонний, которого пропустила Authelia, выглядит как законный пользователь.
- `` разграничения по владельцу нет, поэтому цена ошибки в настройке — все
записи и все расшифровки разом, а не одна учётная запись. Сузит это
`record-ownership`.
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
- **Статус:** заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md)
## Решение
@@ -0,0 +1,51 @@
# Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
## Решение
Обработчик возврата от провайдера зовёт **собственный адрес хранилища**
`auth-with-oauth2` внутри процесса, через его же роутер, а не по сети и не
разбирая ответ провайдера своими руками.
## Почему
Решение [ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
отдало разбор ответа провайдера хранилищу: только тогда учётные записи заводятся
сами и видны в панели. Это решение не пересматривается — пересматривается способ
до него дотянуться.
Проверка исходников библиотеки версии 0.39.10 показала, что обмен наружу не
экспортирован: он живёт неэкспортированной функцией за собственным маршрутом.
Остались три формы, и владелец выбрал первую:
> (а) внутрипроцессный вызов собственного маршрута `auth-with-oauth2`: решение
> 2026-08-11 соблюдено дословно, цена — петля «наш обработчик → наш роутер → наш
> обработчик», разбор JSON-ответа и потеря типизированной ошибки; (б) сборка
> обмена из экспортированных кусков с сохранением записи и связи через `app.Save`:
> прямой код без петли, цена — пересмотр решения 2026-08-11 отдельным ADR; (в)
> отложить вход до появления фронтенда.
Запись заводится как **намеренный отказ от очевидного подхода**: собрать обмен
своими руками выглядит проще и дешевле, и предложение вернётся, если причина не
записана.
## Последствия
- `+` разбор ответа провайдера, заведение учётной записи и связь её с внешним
провайдером остаются за хранилищем — решение 2026-08-11 соблюдено дословно, а
не «по духу».
- `+` наш код не знает ни одного поля ответа провайдера: обновление библиотеки
под смену формата ответа доезжает само.
- `` петля через собственный роутер: обработчик зовёт сервис, частью которого
сам является. Это новый для проекта вид узла, и его придётся объяснять на
каждом следующем изменении.
- `` ответ разбирается текстом, типизированная ошибка теряется: причина отказа
обмена доступна только кодом состояния.
- `` роутер хранилища пришлось собирать **один раз** и держать полем: его
сборка вешает обработчики на само приложение и без идентификатора, поэтому
повторная не заменяет прежние. Ревью кода нашло это построенным путём —
анонимный запрос копил обработчики без предела, а каждое сохранение задачи
конвейером проходило по всем накопленным.
@@ -0,0 +1,56 @@
# Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 3
## Решение
Поле файла в хранилище **помечается защищённым**, а правило просмотра коллекции
файлов пускает всякого узнанного. Ссылка `/api/files/<коллекция>/<запись>/<имя>`
перестаёт быть правом пройти по ней: нужен короткий токен файла, который берут,
предъявив сессию.
Запись заменяет [ADR-2026-08-12-file-link-open-but-not-logged](ADR-2026-08-12-file-link-open-but-not-logged.md).
## Почему
Прежнее решение было обусловленным и само назвало условие своего пересмотра:
> Решение действует до разграничения доступа: задачи `oidc-login` и
> `record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
> записью.
Условие наступило. Прежний довод — «право прочитать задачу даёт знание её
идентификатора, и файл встаёт вровень с `GET /api/status/:id`» — держался на том,
что опрос готовности открыт анонимно. Этот change закрывает опрос за вход, и
файл, оставшийся открытым, стал бы единственным анонимным путём к содержимому
записи — самому чувствительному, что есть у проекта.
Вторая половина прежнего решения остаётся в силе: имя файла в журнал по-прежнему
не пишется. Защищённое поле сужает право пройти, но не отменяет запрета —
строка журнала со ссылкой собирала бы половину ключа.
Пометки самой по себе оказалось мало, и это выяснило ревью кода прогоном:
защищённый файл судится **и** токеном, **и** правилом просмотра коллекции, а
незаданное правило означает «только владелец панели». Файл не получал ни аноним,
ни вошедший — сценарий спеки не исполнялся вовсе. Правило назначено тем же шагом
схемы.
## Последствия
- `+` содержимое записи перестало быть доступным по одному знанию ссылки; после
закрытия API это был последний анонимный путь к нему.
- `+` условие, названное прежней записью, отработало как задумано: решение
пересмотрено записью, а не молча.
- `` ссылка усложнилась для потребителя: браузер с одной кукой файла не
получает, нужен порядок «сессия → токен файла → ссылка». Будущее приложение
обязано этот шаг делать, и задача про прослушивание записи начинается с него.
- `` разграничения по владельцу нет: токен файла берёт всякий вошедший, и по
ссылке он получит **любую** запись, а не только свою. Сужение приносит
`record-ownership`; до неё круг сузился с «кто угодно из интернета» до «кто
угодно из вошедших», и это меньше, чем кажется.
- `` отзыва у выданного токена нет, как не было у ссылки; смягчает только его
короткий срок.
@@ -0,0 +1,55 @@
# Сессия живёт семь суток и не продлевает саму себя
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 6
## Решение
Срок жизни сессии — **семь суток**, назначается при каждом подъёме сервиса.
Продление сессии **выключено**: адрес, которым хранилище меняет предъявленное
значение на новое, закрыт слоем приложения.
## Почему
Умолчание хранилища — пять суток и продлеваемая сессия. Второе делает первое
бессмысленным, и это выяснило ревью кода замером: предъявитель одного живого
значения продлевает себе доступ бессрочно, никуда не входя.
Значение имеет то, на чём держится вся остановка перерасхода. Паспорт опирается
на **отзыв доступа в Authelia** как на способ остановить того, кто тратит слишком
много. Но сервис после входа к провайдеру не обращается: подпись сессии считается
от значений в базе, и отзыв у провайдера до сервиса доходит **только** истечением
срока. При живом продлении не доходит никогда — человек, которому закрыли доступ,
сохраняет его навсегда.
Отвергнуто и названо ценой:
> сверяться с провайдером по расписанию — новая связь с Authelia и обработка её
> недоступности, работа шире задачи; принять как есть — тогда паспорт теряет
> способ остановить того, кто тратит слишком много.
Число семь суток выбрано владельцем как компромисс: реже входить против дольше
ждать, пока отзыв доедет.
Срок назначается **при подъёме, а не шагом схемы**, и это отдельное решение с
причиной: применённый шаг не переписывается, поэтому число, положенное туда,
разошлось бы со сроком жизни куки при первой же правке — браузер получил бы
новый срок, а хранилище продолжило выдавать прежний.
## Последствия
- `+` отзыв доступа у провайдера доходит до сервиса гарантированно, максимум за
семь суток; без этого он не доходил вовсе.
- `+` срок жизни сессии стал числом, которое кто-то выбрал, и правится он в одном
месте вместе со сроком куки.
- `` человек перевходит раз в неделю, и это заметно: своей страницы у сервиса
нет, так что вход начинается с перехода по адресу входа руками.
- `` семь суток — всё ещё окно, в которое отозванный доступ работает. Немедленно
закрыть чужую сессию можно только руками в панели, обновив ключ токенов записи;
своего адреса у этого нет.
- `` закрытие продления сделано слоем приложения, а не настройкой коллекции:
библиотека выдаёт сессию продлеваемой всегда, и отключить это в ней нечем.
Слой придётся помнить при всякой правке маршрутов.
@@ -0,0 +1,62 @@
# ADR-2026-08-12. Спекой нормируется и инструмент сборки, а не только поведение сервиса
- **Дата:** 2026-08-12
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решение 2
## Решение
Заведена capability `toolchain` — четвёртая, и первая, которая описывает **не
поведение сервиса** для его потребителей, а поведение инструмента, которым сервис
собирают. Потребитель у неё другой: тот, кто собирает.
Требование о согласованности объявленной версии Go живёт нормой в
[openspec/specs/toolchain/spec.md](../../openspec/specs/toolchain/spec.md), а не
прозой в памятке.
## Почему
Три существующие capability — `intake`, `pipeline`, `storage` — все про то, что
сервис делает для своих потребителей, а преамбула `architecture.md` прямо
говорила «поведение системы здесь не описывается — нормативно оно живёт в
`openspec/specs/`». Согласованность версий сборки под это определение не
подходит, и натяжение признано прямо в источнике:
> Признаём натяжение: три существующие capability описывают поведение сервиса для
> его потребителей, а `toolchain` описывает поведение инструмента разработки.
> Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml`
> говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно
> как домен он здесь и назван.
Очевидные пути отвергнуты оба:
> **Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и
> захват задачи — поведение работающего сервиса. Версия сборщика с ним не
> меняется вместе.
>
> **Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое
> требование — «расхождение роняет набор проверок», — и требование без дома
> проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже
> один раз жило в трёх документах и во всех трёх было неверным.
Последнее и есть довод, перевесивший чистоту определения: дефект 2026-08-12
случился именно потому, что утверждение о версии сборки жило только прозой, в
трёх местах сразу, и никто не отвечал за его истинность.
## Последствия
- `+` у правила о версиях есть нормативный дом со сценариями, и по нему видно, что
проверено, а что оставлено человеку. Три требования, двадцать два сценария.
- `+` следующая задача про инструмент сборки знает, куда дописывать, и не заводит
вторую спеку о том же.
- `` определение capability в проекте стало шире, чем «поведение сервиса», и
граница теперь проходит по слову «домен». Следующее пограничное решение будет
ссылаться на этот прецедент — в том числе тогда, когда ссылаться не стоило бы.
- `` асимметрия: четыре однородных шага гейта живут в двух разных домах. У трёх
плагинных (`docs.py`, `tasks.py`, `openspec.py`) нормативного дома нет вовсе,
только строка в памятке; у четвёртого есть спека. Либо дома появятся у
остальных, либо асимметрия останется навсегда.
- `` имя `toolchain` выбрано в том числе из-за настройки среды разработчика:
первая редакция звалась `build`, и глобальный запрет чтения каталогов с таким
именем сделал спеку нечитаемой для проходов ревью. Имя, выбранное под
ограничение инструмента, а не под предмет, — слабое основание, и при следующем
пересмотре его стоит перепроверить.
@@ -0,0 +1,60 @@
# ADR-2026-08-12. Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента
- **Дата:** 2026-08-12
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решения 1 и 6
## Решение
Шаг сверки версий добывает числа чтением файлов и **не зовёт `go` ни в каком
виде** — ни `go mod edit -json`, ни `go list -m`, ни `go env`. Директива
`toolchain` в `go.mod` при этом запрещена: её наличие роняет шаг.
Дословно из источника:
> Способ это не самый удобный: разбор директивы через `go mod edit -json` короче
> и надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой
> `GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN`
> `go` вправе полезть в сеть за нужной версией — то есть требование «без сети»
> перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а
> не от коммита.
## Почему
Причина не в аккуратности, а в том, что **ровно этой подменой и держался дефект,
ради которого шаг заведён**. 2026-08-12 требование модуля уехало на 1.25,
сборочный образ остался на 1.24, образ перестал собираться — и восемь шагов гейта
с шестью проходами ревью показали зелёное, потому что `go build ./...` шёл на
хостовом Go. Проверка судила по тому, что стоит на машине, вместо того что
записано в коммите. Шаг, зовущий `go`, воспроизвёл бы ту же подмену внутри себя:
зелёный там, где стоит нужная версия, и другой ответ на другой машине.
Отсюда же запрет `toolchain`. Директива — штатный механизм Go и очевидное
решение задачи расхождения: она заставила бы Go скачать нужную версию самому, и
сверять стало бы нечего. Отвергнута намеренно:
> Директива `toolchain` заставила бы Go скачивать нужный тулчейн сам, и
> расхождение с образом перестало бы ломать сборку. Но она же превращает сборку
> образа в сетевую операцию, а сборочный слой качает тулчейн при каждой сборке.
> Дороже и менее предсказуемо, чем строка сравнения.
Вдобавок она вводит **пятое место**, называющее версию, — то, которого закрытый
перечень из четырёх мест не знает: при `toolchain go1.27.0` четыре объявленных
числа сойдутся, а собирать будет пятое.
## Последствия
- `+` исход шага есть функция коммита. Проверено прогоном: с `PATH`, где нет
`go`, шаг даёт тот же код выхода и тот же вывод.
- `+` требование «без сети» выполняется по построению, а не обещанием: под
`strace` шаг не делает ни одного сетевого вызова.
- `+` пятое место закрыто: `toolchain` в `go.mod` роняет шаг с названной
причиной.
- `` разбор держится на регулярных выражениях `sed`/`awk` вместо готового
разбора, который дал бы сам `go`. Это дороже в сопровождении и хрупче: правка
образца ломает смежный случай беззвучно.
- `` запрет `toolchain` придётся снять или пересмотреть, если зависимость
однажды потребует версию выше той, что стоит у нас. Тогда эта запись
пересматривается, а не обходится.
- `` проверять сам скрипт нечем: `shellcheck` в гейт не заведён, тестов у него
нет. Из девятнадцати сценариев нормы машина гоняет один — тот, где всё
сошлось. Остаток объявлен и уехал отдельной задачей.
+7 -1
View File
@@ -32,7 +32,13 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | |
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
+56 -23
View File
@@ -8,11 +8,16 @@
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
этого ещё не решено — в разделе «Открытые вопросы».
Заведены три capability:
Заведены пять capability. Четыре первые нормируют **поведение сервиса** для его
потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а
инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает.
- [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 и
@@ -20,6 +25,15 @@
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
2026-08-12;
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
видит всё, что видел прежде аноним;
- [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой
его версии собирается сервис: одно число версии Go во всех местах, где она
названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade`
2026-08-12.
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
@@ -29,17 +43,26 @@
- **Один процесс.** Бот, 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`.
`main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и
они же держат обратные направления: транспорты не знают друг о друге, адаптер
не знает ни ядра, ни транспортов.
*Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http`
импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть
роутер этого хранилища, а не наш сервер поверх него. Правила на это
направление нет намеренно.
## Компоненты
@@ -57,7 +80,8 @@
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
@@ -69,8 +93,11 @@
## Внешние границы и форматы
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не
отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
@@ -94,8 +121,9 @@
| --- | --- | --- | --- | --- |
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
@@ -118,13 +146,14 @@
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Разбор конфигурации | `internal/config.LoadConfig` |
| Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает
сам.
генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в
код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло
2026-08-13: его читает `internal/clock`, и запрет держит линтер.
## Деплой
@@ -138,11 +167,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 с роутером пятой версии и
+18 -28
View File
@@ -6,7 +6,8 @@
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
перечень «Механизировано» ниже. Причина: файл на несколько сотен строк
перечень «Механизировано» записи [go-linters.md](go-linters.md) — дома правил,
которыми машина читает код. Причина: файл на несколько сотен строк
размазывает внимание по тривиальному — и модель, и человек добросовестно
проверят именование и не дойдут до формы решения.
@@ -17,12 +18,13 @@ severity — в [CLAUDE.md](../../CLAUDE.md).
Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом
шаге и дублируется воркером.
UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg`
предложение с заглавной буквы вместо константной категории.
Из этого перечня одно уже закрыто: доменные ошибки проверялись приведением типа
до 2026-08-11, задача `errors-as-instead-of-typecast`. Приведение типа на этом
месте больше не долг, а регрессия.
Из этого перечня закрыты два. Доменные ошибки проверялись приведением типа до
2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по
месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило
держит линтер. Оба места больше не долг, а регрессия.
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
@@ -46,27 +48,15 @@ htmx, а здесь решено делать SPA — и перенесённы
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
над `fetch`, показ ошибок и состояний списка.
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: лестница
механизации, два круга (pre-commit и гейт), перечень правил и подавлений,
порядок заведения нового правила. Про инструменты, а не про то, как писать
тесты.
## Механизировано
## Что из этого проверяет машина
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
промптах ревью не пересказывается.
| Правило | Где механизировано |
| --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `send` |
| Форматирование исходников | `.golangci.yml``gofmt` |
| Подозрительные конструкции языка | `.golangci.yml``govet`, `staticcheck`, `ineffassign`, `unused` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
| Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` |
Не названное здесь место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
**Из перечисленного в записях правилом выражено одно** — сравнение ошибок через
`errors.Is` и `errors.As` (`errorlint`, строка таблицы выше). Прозой остаётся всё
прочее: ни константный `msg` лога (`sloglint`), ни запрет `fmt.Print*` и
`os.Getenv` (`forbidigo`), ни запрет сторонних пакетов ошибок (`depguard`), ни
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
оставшееся прозой, проверяет человек на каждом ревью заново.
Перечень правил, доведённых до проверки, и место настройки каждого — в записи
[go-linters.md](go-linters.md). Там же сказано, что из перечисленного в прочих
записях осталось прозой и потому проверяется человеком на каждом ревью заново, и
там же названы остатки правил — то, что правило не ловит. Числа механизированного
здесь нет намеренно: оно протухает при каждом новом правиле.
+22 -6
View File
@@ -8,9 +8,10 @@
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
проверки пустых ключей внутри адаптеров.
**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера
не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас
участвует.
**Механизировано:** запрет `os.Getenv` `forbidigo` в `.golangci.yml`
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
приезжают из TOML»; `godotenv` в `main.go` по-прежнему загружает `.env`, но кладёт
его в окружение процесса, а не в настройки приложения.
## Принципы
@@ -60,6 +61,11 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
говорит, какой формы значение здесь ждут. Пустым оставлен только
`client_secret` — он и есть секрет.
## Поля по дискриминатору `type`
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
@@ -87,7 +93,7 @@ Ansible из `pet-project-server`). Приложение просто читае
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`.
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`).
- В `config.dist.toml` секретные поля — пустые строки.
@@ -115,10 +121,20 @@ TOML. Пустой токен бота ловится в `NewTelegramController`
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
места проверки нет.
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с
молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом
было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение
`client_secret` в журнал попасть не должно.
## Структура в коде
- Весь разбор и проверка — в `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()`, файл их перекрывает. Новое поле
требует правки обоих мест.
+12 -8
View File
@@ -2,14 +2,17 @@
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не
следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по
месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации
и разбора нет. Правила действуют на новый код; переписывание существующего —
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует
этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время
единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило
держит линтер. Правила действуют на новый код; переписывание существующего —
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты
в transcriber нет.
**Механизировано:** сверка изменённого шага схемы с
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
(`forbidigo` плюс `internal/clock`) и согласованность колонок очереди
(тест-сканер `internal/archrules`). Прочие пункты — прозой; адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Первичные ключи — ULID, не автоинкремент
@@ -55,8 +58,9 @@
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени.
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`):
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему
- Миграции — шаги PocketBase на Go
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
поля заводятся кодом. При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением.
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
+9 -5
View File
@@ -9,9 +9,10 @@
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
решают сами.
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних
пакетов ошибок в проекте и так нет.
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint`,
сторонние пакеты ошибок `depguard`, узнавание ошибки по тексту сообщения —
тест-сканер `internal/archrules`. Перечень и адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Базовая идиома: stdlib
@@ -135,8 +136,11 @@ transcriber — **приложение, а не библиотека**: внеш
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой
границы **нет**: паника в шаге конвейера роняет процесс целиком.
ронял процесс. В transcriber его вешает роутер хранилища сам
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**:
паника в шаге конвейера роняет процесс целиком.
## Несколько ошибок
+267
View File
@@ -0,0 +1,267 @@
# Линтеры и механизированные проверки
Конвенция о том, **чем машина читает наш код**: какие свойства доведены до
правила, чем каждое проверяется, когда оно запускается и что осталось человеку.
Свойство, ставшее правилом, из прозы соседних записей удаляется и появляется
здесь строкой — эта запись его принимает.
**Чего здесь нет: как писать тесты.** Запись говорит об инструментах и правилах —
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
обязан проверять, и там же записано требование, чтобы проверка была **способна
упасть**. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
исходники.
Пока язык у проекта один, и запись названа по нему. Появится второй — у него
будет своя запись, а лестница и два круга останутся общими.
Устройство ниже **переносимо**: разделы «Лестница механизации», «Два круга» и
«Как заводят новое правило» — не особенность transcriber и переносятся в другой
Go-проект как есть. Своё здесь — перечень правил и подавлений.
## Границы: где что живёт
Чтобы факт не жил в двух местах:
- **семантика гейта** — команда целиком, база диффа, словарь кодов выхода, что
красит безусловно, чего в гейте намеренно нет и кто тогда обязан это гонять —
в [CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь это не повторяется: у гейта
один дом, и он у памятки, потому что её читают прежде работы;
- **как писать код** — соседние записи этой конвенции ([README.md](README.md) —
индекс). Свойство, ставшее правилом, оттуда удаляется и попадает в перечень
ниже; обратный перенос запрещён — правило, оставшееся ещё и прозой, проверяют
дважды;
- **настройка конвейера ревью, вопросы по темам и журнал дефектов** —
[../review.md](../review.md). Перечень ниже говорит этим вопросам, чего
спрашивать уже не нужно;
- **поведение сервиса** — нормативные спеки `openspec/specs/`. У шага сверки
версий Go поведение нормировано отдельно, спекой
[toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка
проекта, у которой есть своя capability, и потому единственная, чьи сценарии
проверяются построчно (`scripts/check_go_version_test.go`). Второй самодельный
шаг — `migrations` — нормы не имеет: он проверен мутацией на трёх исходах
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
него нет, и дрейф его собственного шаблона имени никто не поймает. Это
объявленный долг, а не умолчание.
## Лестница механизации
Свойство поднимается по ступеням, и ступень выбирают не по вкусу, а по тому,
чем свойство выражается. Верхняя ступень дешевле нижней в эксплуатации и дороже
в заведении, поэтому прыгать через ступень без нужды не надо.
1. **Проза конвенции.** Свойство названо словами, проверяет человек на каждом
ревью заново. Это ступень по умолчанию и худшая из всех: она стоит внимания
каждого прогона и молча перестаёт работать, когда внимание кончилось.
2. **Настройка готового линтера.** Свойство совпало с чужим правилом —
включается строкой в `.golangci.yml`. Дешевле всего; ограничение в том, что
правило чужое и говорит о том, о чём его написали.
3. **Запрет по имени** (`forbidigo`, `depguard`). Свойство выражается через «эту
функцию/пакет тут звать нельзя». Дешёво и точно, но требует **единой точки**,
куда запрещённое переносят: запрет без дома оставляет код без способа сделать
нужное.
4. **Тест-сканер исходников** (`internal/archrules`). Свойство — о структуре, а
не о вызове: направление зависимостей, согласованность двух перечней,
отсутствие идиомы. Пишется руками на `go/parser` или регулярном выражении,
зато читается как тест и ломается заметно.
5. **Свой шаг проверки** (`scripts/`, шаги `Taskfile.yml`). Свойство выходит за
пределы кода на Go: версия инструмента, форма `Dockerfile`, раскладка
документов. Дороже всех — у шага появляется своя норма и свои тесты.
Ступень, выбранная неверно, видна сразу. Запрет по имени, обходимый одной
лишней строкой, — это ступень 4, наряженная третьей: так было с правилом о
заголовках ответа, которое сначала запретило текст `\.Header\(\)\.Get`, а
обходилось присваиванием в переменную. Правило переписано на суждение **по типу
приёмника** (`analyze-types`), и это уже настоящая третья ступень.
## Два круга: pre-commit и гейт
Проверки идут двумя кругами, и круг выбирается по цене прогона.
| | pre-commit (`lefthook.yml`) | гейт (`task gate`) |
| --- | --- | --- |
| Когда | на каждый коммит | перед тем как считать задачу сделанной |
| На чём | на **затронутых файлах** | на всём дереве |
| Сколько идёт | около секунды | десятки секунд |
| Что делает с находкой | `gofmt` правит и добавляет в коммит, прочее роняет коммит | роняет прогон |
Перечень работ pre-commit и то, что остаётся только гейту, — в
[CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь важен принцип: **pre-commit не
подменяет гейт**. Он ловит дешёвое и местное, а сборка, тесты целиком, сверки
документов и запрос к базе уязвимостей идут в гейте — иначе коммит стоил бы
минуту, и хук отключили бы через день.
Полный набор проверок в pre-commit не переносится сознательно; обратное решение
— «гонять всё на каждый коммит» — известно и отклонено по этой же причине.
## Механизировано
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
промптах ревью не пересказывается.
### Ошибки и отказы
| Правило | Где механизировано |
| --- | --- |
| Сравнение ошибок через `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` и `send` |
| Непроверенное приведение типа (`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`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml``depguard` |
### Структура и границы
| Правило | Где механизировано |
| --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
### Отмена и внешний собеседник
| Правило | Где механизировано |
| --- | --- |
| Запрос и внешний процесс заводятся с контекстом (`exec.CommandContext`, `http.NewRequestWithContext`, `QueryContext`) | `.golangci.yml``noctx`. Единая точка не нужна: контекст приезжает доводом, а контракты `internal/contract` несут его первым |
| Контекст приезжает сверху, а не заводится по месту (`context.Background()` в середине цепочки) | `.golangci.yml``contextcheck` |
| Тело ответа HTTP закрывается | `.golangci.yml``bodyclose`. Отдельно от `errcheck`: там `(io.ReadCloser).Close` объявлен исключением, и незакрытое тело от невыясненного `Close` неотличимо |
### Время, вывод, конфигурация
| Правило | Где механизировано |
| --- | --- |
| Время читают `clock.Now` (метка, UTC) и `clock.Start` (длительность, монотонные часы) — не `time.Now` по месту | `.golangci.yml``forbidigo`; единая точка — `internal/clock` |
| Вывод идёт через `slog`, а не `fmt.Print*` и не встроенными `print`/`println` | `.golangci.yml``forbidigo`. Не ловит `fmt.Fprintln(os.Stdout, …)` — первый аргумент по имени функции не судится; остаток прозой в [logging.md](logging.md) |
| Конфигурация приезжает из TOML, а не из окружения | `.golangci.yml``forbidigo`: `os.Getenv`, `os.LookupEnv`, `os.Environ`, `os.ExpandEnv` — все четыре, иначе запрет обходится соседним именем |
| Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml``sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) |
### Проверки о самих проверках
| Правило | Где механизировано |
| --- | --- |
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
| Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | `scripts/check_go_version_test.go` — 20 сценариев спеки `toolchain` плюс два свойства самого шага: исход не зависит от установленного `go`, и шаг не зовёт ни `go`, ни `docker`, ни сеть |
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Без компилятора C шаг гоняет тесты без детектора и краснеет кодом 3: гонки — не повод отнимать у гейта сами тесты |
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml``nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
### Форма кода и файлов вне Go
| Правило | Где механизировано |
| --- | --- |
| Форматирование исходников | `.golangci.yml``gofmt`; на pre-commit правится на месте |
| Подозрительные конструкции языка | `.golangci.yml``govet`, `staticcheck`, `ineffassign`, `unused` |
| Опечатка в комментарии и в тексте ошибки | `.golangci.yml``misspell` |
| Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit |
| Форма `Dockerfile` | `Taskfile.yml` → шаг `dockerfile` (`hadolint`), он же на pre-commit |
| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) |
### Хранилище, документы, секреты, зависимости
| Правило | Где механизировано |
| --- | --- |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` в `docs/.docs.json`, чтобы у факта не было второго дома; пустой каталог роняет шаг — правило, потерявшее предмет, молчать не должно. `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` в `docs/.docs.json` |
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
| Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) |
Не названное здесь место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
## Подавления: что и почему
Подавление — это решение, а не настройка, поэтому каждое названо поимённо и с
причиной. Причина живёт строкой рядом с подавлением (в `.golangci.yml` или
`Taskfile.yml`), а здесь — их перечень, чтобы видеть все разом.
| Подавлено | Где | Почему |
| --- | --- | --- |
| `errcheck` на `defer Close`, `os.Remove` и `send` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
| `noctx` на `httptest.NewRequest` в `*_test.go` | `.golangci.yml`, `exclusions` | Фикстура запроса к обработчику в том же процессе: внешнего собеседника за ней нет, отменять нечего. Изъятие названо по имени этой функции, а не выключением `noctx` на проверках: настоящий внешний вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему подсуден, и это проверено мутацией |
| `SC1007` в `scripts/check-go-version.sh` | директива в скрипте | Ложное срабатывание на идиому `CDPATH= cd`, которая защищает `cd` от чужого `CDPATH` |
| `DL3007` (`alpine:latest`) | `Taskfile.yml`, шаг `dockerfile` | Открытая задача `pin-runtime-image-base`; до её решения шаг краснел бы на известном |
| `DL3018` (закрепить версии `apk`) | там же | Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели |
## Что остаётся прозой
**Из перечисленного в записях конвенций правилом выражено не всё.** Прозой
остаётся то, чему нет ни готового правила, ни детерминированного оракула:
уровень лога по адресату, единая логирующая точка на доменной границе, словарь
имён полей, канонический вид идентификатора, естественные ключи у деталей.
Свойство, оставшееся прозой, проверяет человек на каждом ревью заново — это и
есть первая ступень лестницы, и подъём с неё всегда выигрыш.
Названы поимённо и **остатки правил** — то, что правило не ловит и потому
осталось человеку:
- вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`:
`forbidigo` судит по имени вызванной функции, а не по её первому аргументу;
- проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`;
- **отсутствие** контекста у сигнатуры: `contextcheck` ловит обрыв цепочки —
`context.Background()` там, где контекст был доводом, — но метод, у которого
довода нет вовсе, правилу не виден. Первый проброс контекста в новый адаптер
остаётся человеку;
- шаг `migrations` судит только те шаги схемы, которые **есть в базе диффа**: у
добавленного после неё файла статус `A`, и правка такого файла законна — он
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
молчит на всём каталоге, и на подозрении база задаётся руками
(`task migrations BASE=<rev>`);
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
есть роутер этого хранилища. Изъятие названо в
[../architecture.md](../architecture.md), «Принципы», и правила на это направление
нет.
Отдельно названы **правила, чей подъём отклонён**:
- `key-naming-case` у `sloglint` — словарь полей намеренно смешанный: доменные
поля `snake_case`, системные домены с точкой (`http.method`, `ext.service`);
- `msg-style: lowercased` у `sloglint` — это ровно конвенция «`msg` — короткая
константа в нижнем регистре», но код называет сообщения предложениями с
заглавной, и это объявленное *Расхождение*. Цена подъёма — переписать больше
ста вызовов, и она не заплачена;
- закрепление версий пакетов `apk` (`DL3018`) — см. подавления выше.
**Кандидат, ждущий решения:** `clock.Now` и `clock.Start` отдают один тип, поэтому
`time.Since(clock.Now())` компилируется и молча меряет длительность настенными
часами — ровно то, против чего пакет и написан. Держал бы это компилятор, будь у
`Start` свой тип с методом `Elapsed()`. Сегодня таких мест нет.
## Как заводят новое правило
Порядок один и тот же, и последние два шага пропускать нельзя.
1. **Найти дом.** Ступень лестницы выбирается по тому, чем свойство
выражается, а не по тому, что проще включить.
2. **Написать причину рядом.** Правило без причины снимают при первом же
неудобстве: тот, кто снимает, не знает, что оно ловило.
3. **Починить находки, а не подавить.** Подавление годится, когда правило
говорит не о том, что мы имели в виду; тогда оно попадает в перечень выше с
причиной. Подавление «пока некогда» — это отложенная работа, и её место в
каталоге задач, а не в конфиге.
4. **Проверить мутацией.** Внести ровно то нарушение, против которого правило
написано, и убедиться, что проверка краснеет и называет место. Правило,
принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны
они в [../review.md](../review.md) (журнал 2026-08-11 про недостижимую норму,
2026-08-13 про обходимый текстовый запрет).
**Мутация обязана собираться.** Правка, снявшая последнее употребление
импорта, роняет сборку, а не проверку: вывод при этом похож на отказ, и
мутацию легко засчитать сработавшей. Прежде чем верить красному, убедись, что
красное — от проверки.
**Мутация ставится по одному нарушению на строку.** `golangci-lint` печатает
с одной строки исходника **одну** находку (умолчание `uniq-by-line`), и
мутация, задевшая сразу два правила, покажет только первое: так молчали
`sqlclosecheck` и `rowserrcheck` на пробе, где та же строка уже краснела от
`noctx`. Проверять правило пробой, где оно единственное нарушенное.
5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её
заменило.
+30 -10
View File
@@ -11,8 +11,13 @@ OpenSpec.
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
ошибку выше, где её логируют снова.
**Механизировано:** ничего. Ни `sloglint`, ни `forbidigo` в `.golangci.yml` не
включены, поэтому правилами не выражено ни одно из перечисленного ниже.
**Механизировано:** форма вызова — `sloglint`: только пары
«ключ-значение», `msg` константой, **атрибуты (`slog.String` и прочие) не
употребляются вовсе**. Запрет `fmt.Print*` и встроенных `print`/`println`
`forbidigo`; вывод в stdout через `fmt.Fprintln(os.Stdout, …)` правилом не
ловится и остаётся прозой этой записи. Прозой остаются также уровень по адресату,
единая логирующая точка и словарь имён полей: оракула у них нет. Адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Принципы
@@ -236,16 +241,31 @@ Object Storage, скачивание файла из Telegram и опрос оп
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
строки лога.
Разговор с Telegram этому правилу следует, и точка чистки одна на все вызовы —
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из пяти:
*Расхождение:* расширение берётся из имени отправителя дословно
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
`sendMessage`, скачивание записи и `getMe` из конструктора;
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
замена точная — токен известен.
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
отказа и строке журнала.
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот
хвост не выходит: там расширение приводится к перечню известных форматов. Остаток
описан в [../security.md](../security.md).
расширением. В журнал оно идёт **собственным полем** строки приёма — это
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
[../security.md](../security.md).
## Куда пишем и уровень
+25 -8
View File
@@ -8,11 +8,17 @@
CGO сборке не нужен.
Схему двигают **шаги миграций PocketBase** на Go, каталог
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг
регистрируется при загрузке пакета, а накатывается при подъёме хранилища
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не
переписывается — изменение только новым шагом: применённое хранилище считает по
имени файла.
`internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя
шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый
шаг не переписывается — изменение только новым шагом: применённое хранилище
считает по имени шага.
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
пути** (`docs/.docs.json`, ключ `migrations`), а префикс наводится только на
каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
репозитория берёт их оттуда.
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
@@ -96,9 +102,17 @@ capability, и третий смысл развёл бы одно слово п
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
каталог и бакет растут неограниченно.
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
не помечено защищённым: право прочитать запись даёт знание её идентификатора,
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в
хранилище **в журнал не пишется** — оно последняя часть ссылки.
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её
сужает: создание записи разрешено только контексту обмена OIDC
(`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены.
Без этого сужения закрытие API обходится двумя запросами — завести себе
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
@@ -134,6 +148,9 @@ capability, и третий смысл развёл бы одно слово п
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
| Срок жизни сессии | 7 суток | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки в 5 суток никем не выбрано |
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
+17 -9
View File
@@ -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.
+71
View File
@@ -99,6 +99,77 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
на `/_/` кодом `200`.
## Вход через OIDC: что выяснилось при реализации
Дописано 2026-08-12 задачей `oidc-login`. Провенанс общий: чтение исходников
`pocketbase@v0.39.10` из кеша модулей плюс прогоны против настоящего хранилища на
временном каталоге, все — в ходе ревью того change. Живой Authelia в прогонах не
было ни разу: провайдера подменял свой `httptest`-сервер.
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
`CreateRule = ""` (создание доступно анониму) и `PasswordAuth.Enabled = true`
(`migrations/1640988000_init.go`, `core/collection_model_auth_options.go`).
Прогон подтвердил: `POST /api/collections/users/records``200`, следом
`auth-with-password``200` с токеном. То есть закрытие API за вход обходится
двумя запросами, пока эта поверхность не закрыта своим шагом схемы.
**Правило создания нельзя закрывать полностью.** `CreateRule = nil` означает «только
суперпользователь», а запись при первом входе заводит **внутренний** запрос
самого обмена, идущий без таких прав (`apis/record_crud.go`: проверка
`!hasSuperuserAuth && collection.CreateRule == nil`). Прогон: с `nil` вход
кончался `401`, учётных записей `0`. Работает правило
`@request.context = "oauth2"` — контекст ставит сам обмен
(`core.RequestInfoContextOAuth2`), а посторонний запрос приходит с контекстом по
умолчанию. Открывать правило пустой строкой при этом нельзя: публичный обмен
принимает поля создаваемой записи от вызывающего.
**Обмен кода наружу не экспортирован.** Пакет `apis` отдаёт ошибки, middleware,
`NewRouter`, `Serve` и обёртки; сам обмен — неэкспортированная функция за
маршрутом `POST /api/collections/{c}/auth-with-oauth2`, принимающая `provider`,
`code`, `codeVerifier`, `redirectURL`. Собственный `/api/oauth2-redirect` служит
другому — он ищет клиента realtime-подписки по параметру `state`, то есть
обслуживает всплывающее окно JS-клиента, а не серверный вход.
**`apis.NewRouter` не идемпотентна: собирать её нужно один раз и держать, а не
создавать заново при каждом вызове.**
Она зовёт `bindRealtimeEvents` и `bindUIExtensions`, а те вешают девять
обработчиков **на приложение** и без поля `Id`; `hook.Bind` такому генерирует
новый идентификатор и **добавляет**. Замер: пять вызовов подряд подняли
`OnModelAfterUpdateSuccess` с 4 до 14, а 3000 вызовов — время сотни сохранений
записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. Освобождения нет, только
перезапуск.
**Связывание учётной записи идёт по `sub`, а не найдя — по почте.** Обмен ищет
запись в `_externalAuths` по `providerId`, и лишь затем `FindAuthRecordByEmail`
(`apis/record_auth_with_oauth2.go`). Отсюда цена открытой регистрации: запись,
заведённая посторонним на чужой адрес почты, достаётся первому же настоящему
входу с этим адресом.
**Защищённое поле файла судится двумя вещами сразу** — коротким токеном файла из
строки запроса **и** правилом просмотра коллекции (`apis/file.go`). Незаданное
правило означает «только суперпользователь», поэтому одной пометки `Protected`
мало: прогон показал `404` анониму, вошедшему кукой, вошедшему заголовком и
вошедшему с законно полученным токеном файла — пока правило не назначено.
**Сессия по умолчанию продлеваема бессрочно.** Токен несёт поле
`refreshable=true`, и `POST /api/collections/{c}/auth-refresh` меняет его на
новый с новым сроком. Прогон: три продления подряд, каждое `200`, `exp` растёт.
Настройки «выдавать непродлеваемую сессию» у коллекции нет — закрывается только
слоем приложения поверх маршрута.
**Подпись сессии считается от секрета коллекции и ключа записи**, обе величины в
базе (`core/record_query.go`, `FindAuthRecordByToken`). Отсюда два следствия:
сессия переживает перезапуск сервиса сама, а смена ключа записи
(`Record.RefreshTokenKey()`) обесценивает все её выданные сессии разом.
**Куки библиотека не читает вовсе** — сессию берёт только заголовком
`Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
**Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом
роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию
`MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять
суток; проект умолчание не переопределяет.
## Что отвергнуто и почему
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
+263 -15
View File
@@ -8,6 +8,11 @@
Разделы ниже заполнены наперёд по коду и правятся по итогам прогонов: «Типовые
ложноположительные» первым прогоном уже пользовались.
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
узлах».
### Типовые узлы
Рода узлов проекта и проверяемые свойства к каждому.
@@ -38,12 +43,17 @@
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
успех молча.
**Репозиторий SQLite** (`adapter/repo/sqlite`):
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
подпакетом `migrations`):
- список колонок совпадает во всех четырёх запросах файла;
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`;
- захват задачи не выдаёт одну строку двум вызывающим;
- ошибка драйвера транслируется в доменную у источника.
- список колонок совпадает во всех четырёх местах — `applyToRecord`,
`recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата;
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`):
@@ -95,20 +105,29 @@
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост.
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой
[security.md](security.md). Находкой считается только новая поверхность,
выставленная наружу, а не повторение этого факта.
- **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не
новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а
разграничения по владельцу нет сознательно — [security.md](security.md),
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
поверхность, выставленная наружу, либо путь к содержимому записи **без**
сессии, а не повторение этого факта.
### Вопросы по темам
Форма: `<тема>: <вопрос> (<провенанс>)`.
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и
`transcribe.go`, 2026-08-10).
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
внешнего собеседника и это держат правила `noctx` и `contextcheck`
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
собеседник»), а исход прерванного шага нормой по-прежнему не описан
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
`transcribe.go`, 2026-08-13; прежний провенанс 2026-08-10 устарел вместе с
дефектом «остановка хоронила запись»).
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`,
2026-08-10).
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
`s3.go`, `speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10).
@@ -132,6 +151,23 @@
(CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
тестов два файла, и оба мимо конвейера.
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
2026-08-12 роняет гейт правилом линтера
([conventions/go-linters.md](conventions/go-linters.md), «Механизировано»), и
спрашивать о нём не нужно.
- `security`: не открылась ли снова поверхность, которую приносит хранилище, —
собственная регистрация, вход по паролю, одноразовый код, восстановление
доступа, продление сессии. Всё это приходит включённым и закрывается нами
(задача `oidc-login` 2026-08-12).
- `security`: не появился ли второй способ получить сессию к тому же человеку —
заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
- `operations`: доходит ли отзыв доступа у провайдера до сервиса и за какой срок —
после входа сервис к провайдеру не обращается, и канал здесь один
(ADR-2026-08-12-session-without-refresh).
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
приложения, — сборка роутера хранилища оказалась именно такой.
### Триггеры метки
@@ -179,7 +215,14 @@ API и имя не откатываются обратной правкой по
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы.
отдан внешней программе, и она вне нашей границы;
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
выкладки вне репозитория, и по коду её не проверить
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
- `security`: поведение браузера с куками — применение `SameSite`, приём
`Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки
этого рода остаются гипотезами.
**Перестали проверять сознательно:**
@@ -187,7 +230,13 @@ API и имя не откатываются обратной правкой по
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
длительность от подставного источника. Своего теста у
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md).
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер
Telegram: он проверяет токен обращением к Telegram, а боевым токеном
запускаться запрещено. Значит поведенческая верификация живым прогоном
недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера
хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока
нет.
## Журнал дефектов
@@ -197,6 +246,199 @@ API и имя не откатываются обратной правкой по
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
выдумывать его задним числом нельзя.
## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
- **Где:** `internal/service/transcribe.go`, шаг конвертации — дефект завела та
же правка, что проложила контекст до `ffmpeg`
- **Симптом:** на прод не уехал, поймали до коммита. Выглядел бы так: обычная
выкладка посреди конвертации переводит здоровую запись в терминальное
`failed`, отправителю уходит «сбой конвертации файла», а вернуть задачу может
только владелец правкой в панели. Окно — часы: конвертация шестичасовой записи
идёт дольше часа по построению
- **Причина:** контекст дошёл до внешнего процесса, а различать его отмену шаг
не научили. Убитый по контексту `ffmpeg` отдаёт `signal: killed` — от
настоящего отказа (`exit status N`) эта ошибка неотличима ни типом, ни
`errors.Is`: различает только `ctx.Err()`. Шаг звал `failJob` на любой отказ
`Convert`. Хуже: `failJob` возвращает `nil`, поэтому воркер считал прогон
успешным, и метрика владельца — та, которой он замечает отказы, — не
шевелилась
- **Чем воспроизведён:** проверкой `TestShutdownDuringConversionKeepsJobRetryable`
с подставным конвертером, ведущим себя как убитый процесс: отдаёт отказ, не
несущий `context.Canceled`. Мутация снята — без развилки проверка краснеет
- **Почему не поймали раньше:** правка выглядела механической, «линтер потребовал
контекст». Цена оказалась в семантике очереди, а не в сигнатурах: отмена стала
значить разное на соседних шагах одного конвейера. Ни один линтер такого не
видит — это заметили три прохода ревью независимо, и все три построили путь
- **Что меняем:** прерванный шаг приговора не выносит — задача остаётся на
повтор, попытку не тратит (счётчик, выросший при захвате, возвращают назад) и
отправителю о несуществующем сбое не сообщает. Воркер не считает остановку
отказом и не пишет о ней владельцу. Задача не забирается вовсе, если нас уже
остановили. Остаток объявлен: норма отмены в спеке `pipeline` не описана, и
открытая задача `context-cancel-in-pipeline` этим закрыта не целиком
## 2026-08-13 — отказ скачивания уносил токен бота в журнал [проскочил]
- **Где:** `internal/controller/tg/tg.go`, скачивание записи по ссылке
`file.Link(c.bot.Token)`
- **Симптом:** не наблюдался, потому что журнал за этим местом никто не читал
построчно. Первый же сбой сети на скачивании писал в журнал
`Failed to download audio file` вместе с полным адресом запроса, а в адресе
Telegram держит токен бота (`…/bot<TOKEN>/…`). Инвариант «секрет не покидает
конфиг» помечен critical и необратим: утёкший токен отзывают руками
- **Причина:** `http.Get` возвращает `*url.Error`, и тот встраивает адрес
целиком. Отказ уходил в `fmt.Errorf("failed to download file: %w", err)`, а
оттуда — в `logger.Error` соседней строкой
- **Чем воспроизведён:** чтением цепочки от `http.Get` до вызова `logger.Error`
в трёх обработчиках; на живом боте не проверялся — боевым токеном запускаться
запрещено
- **Почему не поймали раньше:** правило было записано прозой и ровно про этот
случай — [conventions/logging.md](conventions/logging.md), «Ошибка
HTTP-транспорта несёт URL». Хуже: там же стояло объявленное *Расхождение* с
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
машина прозу не проверяет
- **Что меняем:** чистка перенесена с места употребления на **границу клиента**
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
больше не получает: клиента ему отдают готовым. Расхождение в конвенции
закрыто, оценка в [security.md](security.md) исправлена
- **Чем закрыт от возврата:** проверками `internal/adapter/telegram/bot_test.go`
— четыре пути (`getFile`, `sendMessage`, конструктор, логгер библиотеки)
судятся по тексту отказа и строке журнала. Мутация снята: со снятой чисткой
три из них краснеют, печатая токен. Правило остаётся прозой (линтер не отличит
ссылку с секретом от ссылки без него), но у прозы теперь есть оракул
- **Как нашли:** первый путь — попутно, при разборе находок `noctx`: тот
потребовал переписать `http.Get` на запрос с контекстом, и цепочку пришлось
прочитать целиком. Остальные четыре — конвейером ревью в тот же день; правка,
закрывшая один путь, объявила класс закрытым в двух документах, и это едва не
осталось так
## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]
- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока
результата распознавания
- **Симптом:** сегодня не наблюдался — путь рабочий, пока библиотека отдаёт конец
потока значением `io.EOF`. Отказ с текстом «EOF» был бы принят за конец потока,
и расшифровка вернулась бы усечённой: пользователь получил бы половину записи
как готовый результат
- **Причина:** конец потока узнавался сравнением `err.Error() == "EOF"`. Текст
сообщения — не признак: его носит и чужая ошибка, а сменит его библиотека —
условие перестанет срабатывать вовсе, и оба исхода молчаливы
- **Чем воспроизведён:** не воспроизводился на живом сервисе — прогон на реальных
ключах запрещён. Найден тестом-сканером `internal/archrules` при его заведении
- **Почему не поймали раньше:** `errorlint` видит `err == ErrX` и приведение типа,
но матчинг по тексту не видит; прозой это правило записано не было, и ревью его
не спрашивало
- **Что меняем:** узнавание переведено на `errors.Is(err, io.EOF)`; класс закрыт
тестом-сканером (docs/conventions/go-linters.md, «Ошибки и отказы»)
## 2026-08-13 — правило гейта обходилось одной лишней строкой [пойман ревью]
- **Где:** `.golangci.yml`, правило `forbidigo` о суждении по живой карте
заголовков — заведено в тот же день задачей `response-assertions-judge-result`
- **Симптом:** правило ловило только прямую цепочку `w.Header().Get`. Присваивание
в переменную (`h := w.Header()`), чтение по индексу карты, обход `range` и поле
`HeaderMap` проходили гейт зелёными — то есть класс, стоивший трёх зелёных
гейтов, возвращался четвёртый раз, и уже без человеческой страховки: документы
успели снять его с прохода ревью
- **Причина:** `forbidigo` по умолчанию судит по печатному тексту вызова, а не по
типу значения. Правило, записанное текстом, отсекает одну форму записи, а не
свойство
- **Чем воспроизведён:** прогоном линтера на файле проверок с шестью формами
чтения живой карты: помечена была одна
- **Почему не поймали раньше:** правило проверили ровно тем нарушением, против
которого писали. Мутация была, но одна — нужна была по одной на каждую форму
- **Что меняем:** правило судит по типу приёмника (`analyze-types`,
`httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
проверено мутацией по каждой. Отсюда же строка в
docs/conventions/go-linters.md, «Лестница механизации»: запрет по имени, обходимый лишней строкой, — это ступень
тест-сканера, наряженная запретом
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
- **Где:** шаг схемы `202608120001` задачи `oidc-login`, правило создания записи
в коллекции пользователей
- **Симптом:** `users.CreateRule = nil` закрывало создание записи для всех, кроме
владельца панели. Запись при первом входе заводит внутренний запрос самого
обмена, идущий без таких прав, — значит после выкладки вход не сработал бы ни
у кого, включая владельца, а приём и опрос уже были закрыты. Сервис остался бы
доступен только через Telegram, и чинилось бы это руками в панели
- **Причина:** закрывали ровно то, ради чего задача затевалась, — самостоятельную
регистрацию, которую хранилище приносит открытой. Глухое `nil` выглядит самым
надёжным её закрытием и отвергает заодно единственный законный путь заведения
записи. Различить их можно: обмен помечает свой запрос контекстом `oauth2`
- **Чем воспроизведён:** тестом против настоящего хранилища с подставным
провайдером: возврат от провайдера отвечал `401`, обращений к токен-эндпоинту
`1`, учётных записей после входа `0`. Причина изолирована тем же прогоном —
с открытым правилом возврат давал `302` и запись появлялась
- **Почему не поймали раньше:** все проверки задачи заводили учётную запись
прямым сохранением, мимо входа, и потому шли по коду, который в бою не
исполняется. Гейт был зелёным. Поймали два прохода независимо — разбор кода по
исходникам библиотеки и враждебный проход падающим тестом
- **Что меняем:** правило сузили до контекста обмена
(`@request.context = "oauth2"`), а в набор проверок добавили вход целиком через
подставного провайдера — от увода до куки сессии. Проверка, заводящая запись
мимо входа, больше не считается покрытием входа
## 2026-08-12 — проверка не могла упасть: читала живую карту заголовков вместо ответа [пойман ревью]
- **Где:** `internal/controller/http/auth_test.go`, проверка уборки носителя
состояния входа; сам дефект — в `auth.go`, уборка стояла в `defer`
- **Симптом:** носитель состояния и проверочного кода не убирался ни на успешном
возврате, ни на отказном, и жил свои десять минут. Одноразовость возврата
держалась ровно на этой уборке, то есть тоже не работала. Проверка при этом
была зелёной и утверждала обратное
- **Причина:** двойная. В коде — `defer` исполняется после того, как ответ уже
начали писать, а заголовки к этому моменту зафиксированы снимком, и позднейшая
правка их карты до браузера не доезжает. В проверке — `httptest` устроен
зеркально: `Header()` отдаёт живую карту, а снимок лежит отдельно и читается
через `Result()`. Проверка смотрела в живую карту и видела то, чего клиент не
получит
- **Чем воспроизведён:** отдельной программой вне проекта: на настоящем сервере
ответ приходил с пустым `Set-Cookie`, а тот же обработчик под `httptest`
показывал куку в `Header()` и не показывал в `Result()`
- **Почему не поймали раньше:** оракул был ложным по построению, и никакая
регрессия его не разбудила бы. Гейт зелёный. Поймали два прохода — сверка
требований и разбор кода, — оба воспроизведением, а не чтением
- **Что меняем:** уборка перенесена до записи ответа; все проверки этого файла
судят по `Result()`. Класс всплывает **третий раз** (2026-08-10 «тесты
http-обработчика ни разу не были зелёными», 2026-08-11 «проверка приёма не
могла упасть»), поэтому он же ушёл в конвенции правилом: проверка ответа
судит по готовому ответу, а не по изменяемому состоянию обработчика.
Механизировано 2026-08-12 задачей `response-assertions-judge-result`
`forbidigo` в `.golangci.yml` роняет гейт на чтении живой карты заголовков в
файле проверок. Правило судит по **типу приёмника**, а не по тексту вызова, и
потому ловит любую форму чтения живой карты — цепочкой, через переменную, по
индексу, обходом, полем `HeaderMap`. Текстовый запрет ловил только прямую
цепочку и обходился одной лишней строкой — это назвал прогон ревью этой же
задачи. Проходу ревью остаётся проверка, идущая мимо recorder, через свой
`http.ResponseWriter`
## 2026-08-12 — каждый анонимный запрос навсегда замедлял запись в хранилище [пойман ревью]
- **Где:** `internal/controller/http/auth.go`, обмен кода собирал роутер
хранилища на каждый вызов
- **Симптом:** сборка роутера вешает девять обработчиков на само приложение и
без идентификатора, поэтому повторная не заменяет прежние, а добавляет.
Обработчики исполняются на каждой записи в хранилище, а конвейер пишет задачу на
каждом шаге. Освобождения нет — только перезапуск. Раскачивалось анонимно:
атакующий ставит себе куку состояния сам, и сверка сравнивает две его же
величины, а обмен исполняется раньше обращения к провайдеру
- **Причина:** функция сборки выглядит чистой — она возвращает роутер, и по имени
не видно, что она правит приложение. Решение звать собственный адрес хранилища
внутри процесса сделало эту сборку частью горячего пути
- **Чем воспроизведён:** замером на настоящем приложении: пять вызовов подряд
подняли число обработчиков одного события с 4 до 14; 3000 анонимных возвратов
довели сотню сохранений записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. При
недоступном провайдере утечка сохранялась
- **Почему не поймали раньше:** ни один шаг гейта не смотрит на побочные эффекты
вызова библиотеки, а замер требует прогона. Поймали три прохода — архитектурный
зондом, враждебный падающим тестом, сверка требований чтением
- **Что меняем:** роутер собирается один раз и живёт полем обработчика; в набор
проверок добавлена та, что считает длину очереди обработчиков после двадцати
входов
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0`
@@ -219,6 +461,12 @@ API и имя не откатываются обратной правкой по
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
ревью.
**Закрыто** задачей `go-1-26-upgrade` 2026-08-12: шаг `go-version` в `task gate`
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
согласованно, и парная сверка не увидела бы документ, разошедшийся с
согласованным кодом. Норма — capability `toolchain`.
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
+82 -28
View File
@@ -2,14 +2,20 @@
## Периметр
**Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и
аутентификации не делает ни прокси, ни само приложение.** Находки строятся против
этого — сегодняшнего — периметра.
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
обратный прокси, а приём записи, опрос готовности и файл записи требуют входа
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты
без входа только проба здоровья и метрики. Находки строятся против этого —
сегодняшнего — периметра.
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный
вход для программ по личным токенам, два уровня доступа — пользователь видит
свои записи, владелец сервиса ещё и страницу расхода. Он **не** развёрнут;
описанное ниже разграничение доступа относится только к Telegram.
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
страницу расхода. **Разграничения по владельцу нет:** всякий вошедший видит все
записи и все расшифровки, как видел их прежде аноним. Его заводит задача
`record-ownership`.
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
записью приложения он не связан.
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
@@ -28,9 +34,18 @@
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).
Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio`
доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу
запросов и не ограничен по размеру файла.
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
Отсюда главное следствие, из которого читается всё остальное: **`POST
/api/audio` требует входа, а число запросов и размер файла по-прежнему ничем не
ограничены**. Вошедший не ограничен ни в том, ни в другом, и тратит наши деньги
на распознавание столько, сколько захочет.
## Недоверенный вход
@@ -38,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` | Отправитель по любому из каналов |
@@ -94,9 +109,12 @@ Telegram отправителю.
каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет.
- **Ссылка на файл**`/api/files/<коллекция>/<запись>/<имя>`. Поле файла не
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется**
- **Ссылка на файл**`/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
остаётся: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
@@ -124,18 +142,44 @@ Telegram отправителю.
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
меняется владельцем в любой момент: список привязан к изменяемому значению.
- **HTTP API** ничего. Ни ключа, ни сессии, ни ограничения по адресу.
- **Метрики и здоровье**`GET /metrics` и `GET /health` открыты вместе с
остальным.
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
кукой `transcriber_session`, живёт семь суток, обесценивается выходом.
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
бессрочно, и семисуточный срок — единственное, чем отзыв доступа у провайдера
доходит до сервиса, — не значил бы ничего.
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
та же проверка, но она названа здесь отдельно, потому что это второй способ
предъявить ту же сессию.
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
здесь «сессия → токен файла → ссылка».
- **Кто допущен****решает Authelia, а не сервис.** Своей проверки группы
приложение не делает: кого пускать, определяет правило провайдера на этого
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
его не проверить. Клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
2026-08-12.
- **Заведение учётной записи** — только входом у провайдера. Собственное
создание записи, вход по паролю, одноразовый код и восстановление доступа
выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
без этого закрытия вход обходился бы двумя запросами.
- **Метрики и здоровье**`GET /metrics` и `GET /health` открыты без сессии:
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
эти адреса не несут.
Владения записью в модели данных нет: у задачи нет пользователя. Пока API
анонимен, знание UUID задачи и есть право её читать.
Владения записью в модели данных по-прежнему нет: у задачи нет пользователя.
Знание UUID задачи и есть право её читать — теперь для всякого вошедшего, а не
для всякого встречного.
Целевой периметр заводит четыре механизма вместо одного белого списка:
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
них уже стоит:
| Механизм | Что даёт | Чья задача |
| --- | --- | --- |
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты | `oidc-login` |
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты**сделано 2026-08-12** | `oidc-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
@@ -223,8 +267,16 @@ Telegram отправителю.
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
типа файла не идёт.
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL
нигде не логируется.
Токен бота стоит в пути **каждого** обращения к Bot API (`bot<TOKEN>/getFile`,
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
скачивания и отправки пишутся в журнал. Теперь адрес снимается на границе
клиента — `internal/adapter/telegram`, `NewBot`: свой `Do` чистит отказ, а
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
## Что вне модели
@@ -239,10 +291,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`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
+16 -16
View File
@@ -1,14 +1,15 @@
module git.vakhrushev.me/av/transcriber
go 1.25.0
go 1.26.0
require (
github.com/BurntSushi/toml v1.5.0
github.com/aws/aws-sdk-go-v2 v1.37.2
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.30.3
github.com/aws/aws-sdk-go-v2/credentials v1.18.3
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/smithy-go v1.27.7
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
@@ -17,25 +18,24 @@ require (
github.com/prometheus/client_golang v1.23.0
github.com/stretchr/testify v1.10.0
github.com/yandex-cloud/go-genproto v0.17.0
google.golang.org/grpc v1.74.2
google.golang.org/grpc v1.82.1
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
github.com/aws/smithy-go v1.27.7 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -70,9 +70,9 @@ require (
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/protobuf v1.36.7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
+42 -42
View File
@@ -5,10 +5,10 @@ github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.30.3 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc=
github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI=
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 h1:ptfyXmv+ooxzFwyuBth0yqABcjVIkjDL0iTYZBSbum8=
@@ -17,32 +17,30 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 h1:nRniHAvjFJGUCl04F3WaAj7
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2/go.mod h1:eJDFKAMHHUvv4a0Zfa7bQb//wFNUXGrbFpYRCHe2kD0=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 h1:Nb2pUE30lySKPGdkiIJ1SZgHsjiebOiRNI7R9NA1WtM=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3/go.mod h1:BO5EKulvhBF1NXwui8lfnuDPBQQU5807yvWASZ/5n6k=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 h1:sBpc8Ph6CpfZsEdkz/8bfg8WhKlWMCms5iWj6W/AW2U=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2/go.mod h1:Z2lDojZB+92Wo6EKiZZmJid9pPrDJW2NNIXSlaEfVlU=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 h1:blV3dY6WbxIVOFggfYIo2E1Q2lZoy5imS7nKgu5m6Tc=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2/go.mod h1:cBWNeLBjHJRSmXAxdS7mwiMUEgx6zup4wQ9J+/PcsRQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 h1:oxmDEO14NBZJbK/M8y3brhMFEIGN4j8a6Aq8eY0sqlo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2/go.mod h1:4hH+8QCrk1uRWDPsVfsNDUup3taAjO8Dnx63au7smAU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 h1:0hBNFAPwecERLzkhhBY+lQKUMpXSKVv4Sxovikrioms=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2/go.mod h1:Vcnh4KyR4imrrjGN7A2kP2v9y6EPudqoPKXtnmBliPU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 h1:utPhv4ECQzJIUbtx7vMN4A8uZxlQ5tSt1H1toPI41h8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0/go.mod h1:1/eZYtTWazDgVl96LmGdGktHFi7prAcGCrJ9JGvBITU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 h1:j7/jTOjWeJDolPwZ/J4yZ7dUsxsWZEsxNwH5O7F8eEA=
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0/go.mod h1:M0xdEPQtgpNT7kdAX4/vOAPkFj60hSQRb7TvW9B0iug=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2g1RDHlvri3NxHA08MGI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E=
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M=
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -145,18 +143,18 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@@ -185,15 +183,17 @@ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+5 -3
View File
@@ -1,6 +1,7 @@
package ffmpeg
import (
"context"
"fmt"
"os"
"os/exec"
@@ -15,7 +16,7 @@ func NewFfmpegConverter() *FfmpegConverter {
return &FfmpegConverter{}
}
func (c *FfmpegConverter) Convert(src, dest string) error {
func (c *FfmpegConverter) Convert(ctx context.Context, src, dest string) error {
// Проверяем существование исходного файла
if _, err := os.Stat(src); os.IsNotExist(err) {
return fmt.Errorf("input file does not exist: %s", src)
@@ -26,8 +27,9 @@ func (c *FfmpegConverter) Convert(src, dest string) error {
return fmt.Errorf("ffmpeg not found in PATH: %w", err)
}
// Создаем команду ffmpeg для конвертации в OGG
cmd := exec.Command(ffmpegExecutable,
// Команда заводится с контекстом: отменённый контекст убивает процесс, а не
// оставляет его дожёвывать чужую запись после остановки воркера.
cmd := exec.CommandContext(ctx, ffmpegExecutable,
"-i", src, // входной файл
"-c:a", "libvorbis", // кодек Vorbis для OGG
"-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество)
+5 -3
View File
@@ -1,6 +1,7 @@
package ffmpeg
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -26,7 +27,7 @@ func NewFfmpegMetaViewer() *FfmpegMetaViewer {
return &FfmpegMetaViewer{}
}
func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
func (m *FfmpegMetaViewer) GetInfo(ctx context.Context, src string) (*contract.AudioInfo, error) {
// Проверяем существование исходного файла
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil, fmt.Errorf("input file does not exist: %s", src)
@@ -37,8 +38,9 @@ func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
}
// Создаем команду ffprobe для получения метаданных
cmd := exec.Command(ffprobeExecutable,
// Команда заводится с контекстом: отправитель, закрывший соединение, не
// оставляет за собой чтение метаданных чужого файла.
cmd := exec.CommandContext(ctx, ffprobeExecutable,
"-v", "quiet", // тихий режим (без лишнего вывода)
"-print_format", "json", // вывод в формате JSON
"-show_format", // показать информацию о формате
+4 -3
View File
@@ -1,6 +1,7 @@
package recognizer
import (
"context"
"io"
"git.vakhrushev.me/av/transcriber/internal/entity"
@@ -9,14 +10,14 @@ import (
type MemoryAudioRecognizer struct{}
func (r *MemoryAudioRecognizer) Recognize(file io.Reader, fileName string) (operationID string, err error) {
func (r *MemoryAudioRecognizer) Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error) {
return uuid.NewString(), nil
}
func (r *MemoryAudioRecognizer) GetRecognitionText(operationID string) (string, error) {
func (r *MemoryAudioRecognizer) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
return "Foo bar, Baz.", nil
}
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
return entity.NewCompletedResult(), nil
}
@@ -1,8 +1,10 @@
package yandex
import (
"context"
"fmt"
"io"
"time"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
@@ -54,16 +56,31 @@ func (s *YandexAudioRecognizerService) Close() error {
return s.sttService.Close()
}
func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string) (string, error) {
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
// прежде, чем ответ дойдёт, и защита ничего не даст.
const startRecognitionTimeout = 10 * time.Second
err := s.s3Sevice.uploadFile(file, fileName)
func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) {
// Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен —
// объект ложится под тем же ключом.
err := s.s3Sevice.uploadFile(ctx, file, fileName)
if err != nil {
return "", err
}
uri := s.s3Sevice.fileUrl(fileName)
opId, err := s.sttService.recognizeFileFromS3(uri)
// А вот принятие операции от отмены защищено. Окно короткое и дорогое:
// SpeechKit может операцию принять и начать считать деньги, а ответ до нас
// не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же
// запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала
// вечно.
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
defer cancel()
opId, err := s.sttService.recognizeFileFromS3(startCtx, uri)
if err != nil {
return "", err
}
@@ -71,12 +88,19 @@ func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string
return opId, nil
}
func (s *YandexAudioRecognizerService) GetRecognitionText(operationID string) (string, error) {
return s.sttService.getRecognitionText(operationID)
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
// родителя и собственный предел по времени. Употребляется там, где обрыв стоит
// дороже ожидания: у платной операции, чей результат нельзя переспросить.
func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
}
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
operation, err := s.sttService.checkOperationStatus(operationID)
func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
return s.sttService.getRecognitionText(ctx, operationID)
}
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
if err != nil {
return nil, err
}
@@ -0,0 +1,38 @@
package yandex
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Принятие операции распознавания защищено от отмены: остановка сервиса не
// должна обрывать вызов, который уже мог начать стоить денег и чей результат
// нельзя переспросить. Проверяется само средство защиты — проводка к нему
// оракула не имеет: клиент SpeechKit подставить нечем, а прогон на реальных
// ключах запрещён (CLAUDE.md, «Запреты»).
func TestProtectedContextSurvivesParentCancel(t *testing.T) {
parent, cancel := context.WithCancel(t.Context())
protected, release := protectFromCancel(parent, time.Minute)
defer release()
cancel()
require.Error(t, parent.Err(), "родитель отменён — иначе проверка судит не то")
assert.NoError(t, protected.Err(), "защищённый вызов пережил отмену родителя")
}
// Защита не бессрочна: у вызова свой предел, иначе остановка ждала бы вечно.
func TestProtectedContextKeepsItsOwnDeadline(t *testing.T) {
protected, release := protectFromCancel(t.Context(), time.Minute)
defer release()
deadline, ok := protected.Deadline()
require.True(t, ok, "у защищённого вызова обязан быть свой предел")
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, 5*time.Second)
}
+2 -2
View File
@@ -67,8 +67,8 @@ func newYandexS3Service(cfg s3Config) (*yandexS3Service, error) {
}, nil
}
func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
_, err := s.uploader.Upload(context.Background(), &s3.PutObjectInput{
func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileName string) error {
_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(fileName),
Body: file,
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"strings"
"google.golang.org/grpc"
@@ -93,9 +94,7 @@ func (s *speechKitService) Close() error {
}
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
ctx := context.Background()
func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string) (string, error) {
// Добавляем авторизацию и folder_id в контекст
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -136,9 +135,7 @@ func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
}
// GetRecognitionResult получает результат распознавания по ID операции
func (s *speechKitService) getRecognitionText(operationID string) (string, error) {
ctx := context.Background()
func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) {
// Добавляем авторизацию и folder_id в контекст
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -157,7 +154,10 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
for {
resp, err := stream.Recv()
if err != nil {
if err.Error() == "EOF" {
// Конец потока библиотека отдаёт ровно `io.EOF`. Прежде он узнавался
// сравнением текста сообщения: так же выглядел бы и настоящий отказ
// с текстом «EOF», и распознавание молча вернуло бы половину текста.
if errors.Is(err, io.EOF) {
break
}
return "", fmt.Errorf("failed to receive recognition response: %w", err)
@@ -176,9 +176,7 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
}
// checkOperationStatus проверяет статус операции распознавания
func (s *speechKitService) checkOperationStatus(operationID string) (*operation.Operation, error) {
ctx := context.Background()
func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID string) (*operation.Operation, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
+8 -6
View File
@@ -10,13 +10,15 @@ import (
pb "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть
// адреса ссылки на него, поэтому меняются только новым шагом схемы.
const (
FilesCollection = "files"
JobsCollection = "transcribe_jobs"
// Шаги схемы регистрируются загрузкой своего пакета, а накатывает их
// `RunAllMigrations` ниже. Импорт здесь пустой и явный, хотя соседние файлы
// пакета и так берут оттуда имена коллекций: день, когда имена перестанут
// читаться отсюда, унёс бы вместе с последней ссылкой и регистрацию — список
// шагов остался бы пустым, `RunAllMigrations` вернул бы `nil`, и приложение
// поднялось бы здоровым, но без коллекций. Отказ вылез бы не на старте, а на
// первом приёме записи.
_ "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
@@ -12,6 +12,8 @@ import (
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
@@ -82,7 +84,7 @@ func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkF
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
record, err := repo.app.FindRecordById(FilesCollection, fileID)
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
@@ -115,7 +117,7 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
// приватности этого не допускает. Свой суффикс хранилище допишет само.
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
collection, err := findCollection(repo.app, FilesCollection)
collection, err := findCollection(repo.app, migrations.FilesCollection)
if err != nil {
return nil, err
}
@@ -142,7 +144,7 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e
}
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
collection, err := findCollection(repo.app, FilesCollection)
collection, err := findCollection(repo.app, migrations.FilesCollection)
if err != nil {
return nil, err
}
@@ -160,7 +162,7 @@ func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
record, err := repo.app.FindRecordById(FilesCollection, id)
record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
@@ -168,7 +170,7 @@ func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
}
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
record, err := repo.app.FindRecordById(FilesCollection, fileID)
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
@@ -1,22 +1,11 @@
package pocketbase
package migrations
import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Схема заводится версионированными шагами, и применённый шаг не переписывается
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
// применённое по имени файла шага.
//
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
// `apis.Serve` прежде, чем поднять сервер.
func init() {
migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
}
func up202608110001(app core.App) error {
files := core.NewBaseCollection(FilesCollection)
files.Fields.Add(
@@ -118,5 +107,3 @@ func down202608110001(app core.App) error {
}
return nil
}
func ptr[T any](v T) *T { return &v }
@@ -0,0 +1,124 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// defaultAuthTokenDuration — умолчание библиотеки, к которому возвращает откат.
const defaultAuthTokenDuration = 1209600
// up202608120001 закрывает поверхность, которую хранилище приносит своим
// системным шагом, и защищает файл записи.
//
// Коллекция пользователей заводится библиотекой с открытым созданием записи и
// включённым входом по паролю. Без этого шага закрытие API обходится двумя
// запросами: завести себе учётную запись, войти паролем, предъявить полученное
// заголовком. Отдельная цена открытого создания — захват учётной записи: обмен
// кода ищет запись сперва по неизменяемому признаку провайдера, а не найдя —
// по адресу почты, и запись, заведённая посторонним на чужой адрес, достаётся
// первому же настоящему входу с этим адресом.
func up202608120001(app core.App) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
// Завести учётную запись можно только входом у провайдера.
//
// Правило именно такое, а не `nil`: запись при первом входе заводит
// внутренний запрос самого обмена, и он идёт без прав суперпользователя —
// глухое `nil` отвергло бы его наравне с посторонним, и войти не смог бы
// никто. Контекст `oauth2` ставит обмен (`core.RequestInfoContextOAuth2`),
// а посторонний запрос приходит с контекстом по умолчанию.
//
// Открывать правило пустой строкой нельзя: публичный обмен принимает поля
// создаваемой записи от вызывающего, и всякий владелец учётной записи у
// провайдера задал бы их сам.
users.CreateRule = ptr(`@request.context = "oauth2"`)
users.PasswordAuth.Enabled = false
users.OTP.Enabled = false
// Провайдер включается здесь с пустыми значениями: адреса, идентификатор
// клиента и секрет приходят из конфига при каждом подъёме. Положенный сюда
// секрет не пережил бы ротации — применённый шаг не переписывается.
users.OAuth2.Enabled = true
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to close users collection surface: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
// Ссылка на файл перестаёт быть правом пройти по ней: до этого шага знание
// ссылки и было доступом, а отзыва у неё нет. Конвейер этим не затронут —
// он читает файл из файловой системы хранилища, а не по ссылке.
//
// Комментарий прежнего шага утверждает обратное — «защищённым поле не
// помечено намеренно». Прежний шаг не переписывается, поэтому решение
// отменяется здесь: право прочитать запись больше не даёт знание её
// идентификатора.
field, ok := files.Fields.GetByName("file").(*core.FileField)
if !ok {
return errors.New("files collection has no file field")
}
field.Protected = true
// Одной пометки мало: защищённый файл судится ещё и правилом просмотра
// коллекции, а незаданное правило означает «только владелец панели» — файл
// не получил бы и вошедший. Правило пускает всякого узнанного: владельца у
// записи ещё нет, и сужать выборку эта задача не должна.
files.ViewRule = ptr(`@request.auth.id != ""`)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to protect record file: %w", err)
}
return nil
}
// down202608120001 возвращает умолчания библиотеки — те, что стояли до шага.
//
// Открытое создание записи сюда не возвращается намеренно: это ровно то, что
// шаг и закрывал, и откат, восстанавливающий анонимную регистрацию, оставил бы
// сервис хуже, чем он был до задачи. Срок жизни сессии возвращается
// умолчанием, а не нулём: нулевую длительность валидация коллекции отвергает, и
// прежний откат падал на ней, не дойдя до снятия защиты с файла.
func down202608120001(app core.App) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
users.CreateRule = nil
users.PasswordAuth.Enabled = true
users.OTP.Enabled = true
users.OAuth2.Enabled = false
users.OAuth2.Providers = nil
users.AuthToken.Duration = defaultAuthTokenDuration
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to restore users collection: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
if field, ok := files.Fields.GetByName("file").(*core.FileField); ok {
field.Protected = false
}
files.ViewRule = nil
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to unprotect record file: %w", err)
}
return nil
}
@@ -0,0 +1,35 @@
// Package migrations — шаги схемы хранилища и имена коллекций, которые они
// заводят.
//
// Схема заводится версионированными шагами, и применённый шаг не переписывается
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
// применённое по **имени шага**, а не по пути файла, поэтому имена в
// `Register` ниже не переносятся и не переименовываются, даже если файл переехал.
//
// Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина
// внешняя: сверка документов ловит изменённый шаг схемы при нетронутом
// `docs/database.md` по префиксу пути (`docs/.docs.json`, ключ `migrations`), а
// префикс наводится только на каталог. Пока шаги лежали файлом, наводить его
// было не на что, и проверка молчала на всякой правке схемы.
package migrations
import (
pbmigrations "github.com/pocketbase/pocketbase/migrations"
)
// Имена коллекций живут здесь, рядом с шагом, который их заводит. Они же — часть
// пути к файлу в раскладке хранилища и часть адреса ссылки на него, поэтому
// меняются только новым шагом схемы.
const (
FilesCollection = "files"
JobsCollection = "transcribe_jobs"
)
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
// `apis.Serve` прежде, чем поднять сервер.
func init() {
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
}
func ptr[T any](v T) *T { return &v }
+3 -1
View File
@@ -2,6 +2,8 @@ package pocketbase
import (
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
@@ -20,7 +22,7 @@ import (
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
// переход хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) {
app.OnRecordUpdateRequest(JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
app.OnRecordUpdateRequest(migrations.JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
original := e.Record.Original()
if original == nil || original.GetString("state") == e.Record.GetString("state") {
return e.Next()
@@ -0,0 +1,72 @@
package pocketbase
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// ProviderName — имя провайдера у коллекции пользователей. Библиотека знает его
// как обобщённый OIDC и по нему же ищет настройку при обмене кода.
const ProviderName = "oidc"
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
// применяется, потому что оно никем не выбрано.
//
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
// провайдера, и потому живёт здесь, а не в каталоге шагов. Причина та же:
// применённый шаг не переписывается, и число, положенное туда, разошлось бы со
// сроком жизни куки при первой же правке — браузер получил бы новый срок, а
// хранилище продолжило выдавать прежний.
const SessionDuration = 7 * 24 * 60 * 60
// ProviderSettings — то, что приезжает из конфига и приводится к настройкам
// коллекции.
type ProviderSettings struct {
AuthURL string
TokenURL string
UserInfoURL string
ClientID string
ClientSecret string
}
// ApplyProviderSettings приводит настройки провайдера у коллекции пользователей
// к значениям конфига.
//
// Делается это при каждом подъёме, а не однажды шагом схемы, и причина в
// инварианте: применённый шаг не переписывается. Секрет, положенный шагом, не
// пережил бы ротации — смена значения в конфиге до хранилища не доехала бы
// вовсе, и вход сломался бы после смены ключа, а починить это можно было бы
// только руками в панели.
//
// Секрет здесь не логируется и в текст ошибки не попадает: сообщение называет
// имя коллекции, а не значения.
func ApplyProviderSettings(app core.App, settings ProviderSettings) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
// Срок жизни сессии живёт здесь, а не в шаге схемы: применённый шаг не
// переписывается, и правка числа не доехала бы до хранилища, разойдясь со
// сроком жизни куки.
users.AuthToken.Duration = SessionDuration
users.OAuth2.Enabled = true
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
Name: ProviderName,
ClientId: settings.ClientID,
ClientSecret: settings.ClientSecret,
AuthURL: settings.AuthURL,
TokenURL: settings.TokenURL,
UserInfoURL: settings.UserInfoURL,
DisplayName: "Authelia",
}}
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to apply provider settings to users collection: %w", err)
}
return nil
}
@@ -12,6 +12,10 @@ import (
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
type TranscriptJobRepository struct {
@@ -23,7 +27,7 @@ func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
}
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
collection, err := findCollection(repo.app, JobsCollection)
collection, err := findCollection(repo.app, migrations.JobsCollection)
if err != nil {
return err
}
@@ -50,7 +54,7 @@ func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
// LostAcquisitionError и результата не пишет.
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
err := repo.app.RunInTransaction(func(txApp core.App) error {
record, err := txApp.FindRecordById(JobsCollection, job.Id)
record, err := txApp.FindRecordById(migrations.JobsCollection, job.Id)
if err != nil {
return fmt.Errorf("failed to find transcribe job: %w", err)
}
@@ -77,7 +81,7 @@ func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder stri
}
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
record, err := repo.app.FindRecordById(JobsCollection, id)
record, err := repo.app.FindRecordById(migrations.JobsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
@@ -105,16 +109,22 @@ const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
// разделителем, обратил бы условие срока в постоянную истину или постоянную
// ложь — молча.
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
now := types.NowDateTime()
// Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка
// хранилища читает часы сама, и запрет линтера её не видит — новая метка в
// этом запросе обошла бы единую точку молча.
now, err := types.ParseDateTime(clock.Now())
if err != nil {
return nil, fmt.Errorf("failed to parse current time: %w", err)
}
query := repo.app.DB().NewQuery(`
UPDATE {{` + JobsCollection + `}}
UPDATE {{` + migrations.JobsCollection + `}}
SET acquisition_id = {:acquisition_id},
acquire_time = {:now},
attempts = attempts + 1,
updated = {:now}
WHERE id = (
SELECT id FROM {{` + JobsCollection + `}}
SELECT id FROM {{` + migrations.JobsCollection + `}}
WHERE state = {:state}
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
@@ -140,7 +150,7 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string,
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
}
return nil, fmt.Errorf("failed to aquire job with state %s: %w", state, err)
return nil, fmt.Errorf("failed to acquire job with state %s: %w", state, err)
}
return row.toJob(), nil
@@ -16,6 +16,8 @@ import (
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
@@ -136,7 +138,7 @@ func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) {
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
// собственные времена хранилища.
record, err := app.FindRecordById(JobsCollection, job.Id)
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
require.NoError(t, app.Save(record))
@@ -232,7 +234,7 @@ func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
require.NoError(t, err)
// Задача досталась другому, пока шаг работал.
record, err := app.FindRecordById(JobsCollection, mine.Id)
record, err := app.FindRecordById(migrations.JobsCollection, mine.Id)
require.NoError(t, err)
record.Set("acquisition_id", "someone-else")
require.NoError(t, app.Save(record))
@@ -279,7 +281,7 @@ func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, acquired.AcquisitionID)
record, err := app.FindRecordById(JobsCollection, job.Id)
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("attempts", 5)
record.Set("state", entity.StateDead)
@@ -360,7 +362,7 @@ func patchRecord(t *testing.T, app core.App, recordID, body string) {
req := httptest.NewRequest(
http.MethodPatch,
"/api/collections/"+JobsCollection+"/records/"+recordID,
"/api/collections/"+migrations.JobsCollection+"/records/"+recordID,
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
+101
View File
@@ -0,0 +1,101 @@
package telegram
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// ErrEmptyToken — токен бота не задан. Отдельным значением, потому что подъём
// без Telegram — законный исход: сервис продолжает работать с HTTP API.
var ErrEmptyToken = errors.New("telegram bot token is empty")
// NewBot заводит клиента Bot API — и это **единая точка**, через которую с
// библиотекой разговаривают оба пакета: адаптер отправки и транспорт бота.
//
// Точка нужна ради инварианта «секрет не покидает конфиг». Токен живёт в пути
// каждого обращения к Bot API (`https://api.telegram.org/bot<TOKEN>/getFile`),
// а `http.Client` кладёт адрес запроса в `*url.Error` целиком. Библиотека
// отдаёт этот отказ вызывающему как есть, поэтому чистка на месте употребления
// закрывает ровно один вызов из пяти: остаются `getFile`, `sendMessage`,
// `getMe` из конструктора и длинный опрос. Здесь закрыты все.
func NewBot(token string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
return newBot(token, tgbotapi.APIEndpoint, logger)
}
// newBot принимает адрес отдельно — иначе проверка утечки токена ходила бы за
// подтверждением в живой Telegram, а боевым токеном запускаться запрещено.
func newBot(token, endpoint string, logger *slog.Logger) (*tgbotapi.BotAPI, error) {
if token == "" {
return nil, ErrEmptyToken
}
// Длинный опрос живёт внутри библиотеки и печатает свой отказ пакетным
// логгером в stderr (`GetUpdatesChan`), минуя и наш `slog`, и чистку выше.
// Это самый частый путь: опрос идёт непрерывно, а скачивание — только когда
// кто-то прислал запись. Логгер пакетный, поэтому и подменяется один раз.
if err := tgbotapi.SetLogger(&redactingLogger{token: token, logger: logger}); err != nil {
return nil, fmt.Errorf("failed to set telegram logger: %w", err)
}
return tgbotapi.NewBotAPIWithClient(token, endpoint, &safeClient{inner: &http.Client{}})
}
// safeClient — клиент, чей отказ не несёт адреса. Библиотека объявляет
// зависимость интерфейсом `HTTPClient` и возвращает наш отказ вызывающему
// нетронутым, поэтому чистка отсюда доходит до каждого вызова Bot API.
type safeClient struct {
inner *http.Client
}
func (c *safeClient) Do(req *http.Request) (*http.Response, error) {
resp, err := c.inner.Do(req)
if err != nil {
return nil, WithoutURL(err)
}
return resp, nil
}
// WithoutURL снимает с отказа адрес запроса, сохраняя причину. Стандартный
// клиент кладёт в `*url.Error` полный URL, а в ссылке Telegram стоит токен
// бота: без этой чистки первый же сбой сети печатает секрет в журнал.
// Причина остаётся и узнаётся `errors.Is` по-прежнему.
func WithoutURL(err error) error {
var urlErr *url.Error
if errors.As(err, &urlErr) {
return urlErr.Err
}
return err
}
// redactingLogger отдаёт сообщения библиотеки нашему журналу, вычеркнув токен.
// Здесь чистится текст, а не ошибка: библиотека печатает уже отформатированную
// строку, и разбирать в ней `*url.Error` нечего. Замена точная — токен известен.
type redactingLogger struct {
token string
logger *slog.Logger
}
const redactedToken = "«токен»"
func (l *redactingLogger) Println(v ...any) {
l.write(strings.TrimSuffix(fmt.Sprintln(v...), "\n"))
}
func (l *redactingLogger) Printf(format string, v ...any) {
l.write(fmt.Sprintf(format, v...))
}
// write пишет на WARN: это сбой фонового цикла со штатным повтором, а не
// событие, требующее разбора (docs/conventions/logging.md, «Уровень —
// это адресат»). Сообщение нейтрально: тем же логгером библиотека печатает и
// отладку, если её включить, а разделить их она не даёт.
func (l *redactingLogger) write(message string) {
l.logger.Warn("Telegram library log",
"message", strings.ReplaceAll(message, l.token, redactedToken))
}
+140
View File
@@ -0,0 +1,140 @@
package telegram
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Токен виден в пути каждого обращения к Bot API, а `http.Client` кладёт путь в
// `*url.Error` целиком. Проверки ниже судят по тексту: секрет не должен
// встречаться ни в отказе, ни в строке журнала. Утечка необратима — утёкший
// токен отзывают руками (CLAUDE.md, «Инварианты», critical).
const probeToken = "7654321:AAHsecretBOTtokenVALUE"
// getMeResponse — ответ, которым подставной Telegram пускает конструктор
// дальше: `NewBotAPIWithClient` ходит за `getMe` прежде, чем отдать клиента.
const getMeResponse = `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"probe","username":"probe_bot"}}`
func newProbeBot(t *testing.T, handler http.HandlerFunc) (*tgbotapi.BotAPI, *httptest.Server) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.NoError(t, err)
return bot, server
}
// Отказ транспорта на любом вызове Bot API не несёт токена: чистка стоит на
// границе клиента, а не у места употребления, поэтому закрыты все вызовы разом.
func TestBotAPIFailureDoesNotCarryToken(t *testing.T) {
bot, server := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
})
// Собеседник исчез — так выглядит обрыв сети, DNS-сбой и недоступность
// api.telegram.org.
server.Close()
t.Run("getFile", func(t *testing.T) {
_, err := bot.GetFile(tgbotapi.FileConfig{FileID: "any"})
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
})
t.Run("sendMessage", func(t *testing.T) {
_, err := bot.Send(tgbotapi.NewMessage(1, "текст"))
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err)
})
}
// Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`,
// и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду.
func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
server.Close()
_, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ конструктора: %v", err)
}
// Длинный опрос печатает свои отказы пакетным логгером самой библиотеки, минуя
// наш `slog`. Логгер подменён — значит, и эта строка идёт через вычистку.
func TestLibraryLoggerRedactsToken(t *testing.T) {
journal := &bytes.Buffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
redacting := &redactingLogger{token: probeToken, logger: logger}
redacting.Println(errors.New(`Post "https://api.telegram.org/bot` + probeToken + `/getUpdates": dial tcp: refused`))
redacting.Printf("Failed to get updates from %s", "https://api.telegram.org/bot"+probeToken+"/getUpdates")
written := journal.String()
assert.NotContains(t, written, probeToken, "токен уехал в журнал: %s", written)
assert.Equal(t, 2, strings.Count(written, redactedToken), "вместо токена стоит пометка")
assert.Contains(t, written, "dial tcp", "причина отказа осталась")
}
// Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу.
func TestEmptyTokenIsRecognizedByValue(t *testing.T) {
_, err := NewBot("", slog.New(slog.DiscardHandler))
require.ErrorIs(t, err, ErrEmptyToken)
}
// WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает
// работать, иначе чистка стоила бы узнаваемости отказа.
func TestWithoutURLKeepsCause(t *testing.T) {
cause := errors.New("dial tcp: connection refused")
wrapped := &url.Error{Op: "Post", URL: "https://api.telegram.org/bot" + probeToken + "/getMe", Err: cause}
cleaned := WithoutURL(wrapped)
assert.NotContains(t, cleaned.Error(), probeToken)
require.ErrorIs(t, cleaned, cause)
assert.Equal(t, cause, WithoutURL(cause), "отказ без адреса не трогают")
}
// Стык, которого не сторожил никто: подмена пакетного логгера держится одной
// строкой в `NewBot`, а снятие этой строки не роняло ни одной проверки. Оракул
// косвенный по необходимости — библиотека не отдаёт установленный логгер
// обратно, — поэтому он смотрит на исход: её собственная строка обязана
// оказаться в нашем журнале.
func TestLibraryLoggerIsActuallyInstalled(t *testing.T) {
journal := &bytes.Buffer{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if _, err := w.Write([]byte(getMeResponse)); err != nil {
t.Errorf("подставной Telegram не смог ответить: %v", err)
}
}))
t.Cleanup(server.Close)
bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.NewTextHandler(journal, nil)))
require.NoError(t, err)
// Отладку библиотека печатает тем же логгером, что и отказы: включаем её,
// чтобы строка появилась без обрыва сети.
bot.Debug = true
_, err = bot.GetMe()
require.NoError(t, err)
assert.Contains(t, journal.String(), "Telegram library log",
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
}
+3 -1
View File
@@ -16,7 +16,9 @@ type TelegramMessageSender struct {
}
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
bot, err := tgbotapi.NewBotAPI(botToken)
// Клиент заводится единой точкой: её отказ не несёт токена, а отказ
// конструктора несёт — `NewBotAPI` зовёт `getMe`.
bot, err := NewBot(botToken, logger)
if err != nil {
return nil, err
}
+503
View File
@@ -0,0 +1,503 @@
// Package archrules — тесты-сканеры исходников для правил, которых не выражает
// ни линтер, ни компилятор: направление зависимостей между пакетами и
// согласованность перечня колонок очереди.
//
// Каждое правило здесь — бывшая строка прозы: у него есть детерминированный
// оракул, поэтому ему место в наборе проверок, а не в промпте ревью. Перечень
// механизированного — docs/conventions/go-linters.md.
//
// Пакет тестовый целиком: рабочего кода в нём нет и быть не должно.
package archrules
import (
"bytes"
"go/parser"
"go/printer"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
const (
modulePath = "git.vakhrushev.me/av/transcriber"
// repoRoot — корень репозитория относительно каталога пакета.
repoRoot = "../.."
)
// Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а
// ffmpeg, Yandex, Telegram и хранилище подставляются в `main.go`
// (docs/architecture.md, «Принципы»).
const core = "internal/service"
// Транспорты — входы в ядро. Общее у двух транспортов живёт в ядре, а не в
// одном из них: иначе второй начинает зависеть от первого и тащит его целиком.
var transports = map[string]bool{
"internal/controller/http": true,
"internal/controller/tg": true,
"internal/controller/worker": true,
}
const adapterPrefix = "internal/adapter/"
// Пакеты, названные константами выше, обязаны существовать. Иначе правила ниже
// стали бы вечно зелёными от одного `git mv`: обход по отсутствующему ключу
// карты идёт ноль раз и молчит.
func TestПакетыПравилСуществуют(t *testing.T) {
dirs := packageDirs(t)
for pkg := range transports {
if !dirs[pkg] {
t.Errorf(
"транспорт %s не найден в дереве: правило о транспортах потеряло "+
"предмет — переименуй его в этом файле",
pkg,
)
}
}
if !dirs[core] {
t.Errorf(
"ядро %s не найдено в дереве: правила о ядре потеряли предмет — "+
"переименуй его в этом файле",
core,
)
}
var adapters int
for dir := range dirs {
if strings.HasPrefix(dir, adapterPrefix) {
adapters++
}
}
if adapters == 0 {
t.Errorf("под %s не найдено ни одного пакета: правило об адаптерах потеряло предмет", adapterPrefix)
}
}
func TestЯдроНеЗнаетОбАдаптерах(t *testing.T) {
for _, imp := range internalImports(t)[core] {
if strings.HasPrefix(imp, adapterPrefix) {
t.Errorf(
"%s импортирует адаптер %s: ядро зависит от интерфейсов "+
"internal/contract, а реализацию подставляет main.go",
core, imp,
)
}
}
}
func TestЯдроНеЗнаетОТранспортах(t *testing.T) {
for _, imp := range internalImports(t)[core] {
if transports[imp] {
t.Errorf(
"%s импортирует транспорт %s: зависимость направлена не туда, "+
"ядро не знает, кто его позвал",
core, imp,
)
}
}
}
func TestТранспортыНеЗнаютДругОДруге(t *testing.T) {
for pkg, imports := range internalImports(t) {
if !transports[pkg] {
continue
}
for _, imp := range imports {
if transports[imp] && imp != pkg {
t.Errorf(
"%s импортирует транспорт %s: общее у двух входов живёт в ядре",
pkg, imp,
)
}
}
}
}
func TestАдаптерыНеЗнаютНиЯдра_НиТранспортов(t *testing.T) {
for pkg, imports := range internalImports(t) {
if !strings.HasPrefix(pkg, adapterPrefix) {
continue
}
for _, imp := range imports {
if imp == core || transports[imp] {
t.Errorf(
"адаптер %s импортирует %s: адаптер реализует интерфейс "+
"internal/contract и о вызывающем не знает",
pkg, imp,
)
}
}
}
}
// Ошибку узнают `errors.Is` и `errors.As`. Сравнение текста сообщения ловит
// заодно и чужую ошибку с тем же текстом, а при смене текста в библиотеке
// перестаёт ловить вообще — молча. `errorlint` видит `err == ErrX` и приведение
// типа, но матчинг по тексту не видит: его ловит это правило.
//
// Прецедент: клиент SpeechKit узнавал конец потока сравнением `err.Error() ==
// "EOF"` — правилом это закрыто 2026-08-13.
//
// Форм записи одного и того же условия много, и правило перечисляет их все:
// равенство и **неравенство**, обратный порядок операндов, `switch` по тексту и
// поиск подстроки любым способом. Отрицание — самая частая форма, и текстовый
// запрет, ловящий только `==`, обходился бы ею молча.
//
// Ищутся все вхождения, а не первое: два места в одном файле иначе починили бы
// по одному за прогон.
func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
patterns := []*regexp.Regexp{
regexp.MustCompile(`\.Error\(\)\s*(==|!=)`),
regexp.MustCompile(`(==|!=)\s*[\w.]+\.Error\(\)`),
regexp.MustCompile(`switch\s+[\w.]+\.Error\(\)`),
regexp.MustCompile(`strings\.\w+\([^)]*\.Error\(\)`),
regexp.MustCompile(`regexp\.\w+\([^)]*\.Error\(\)`),
regexp.MustCompile(`\.MatchString\([^)]*\.Error\(\)`),
}
for _, path := range goFiles(t) {
// Комментарии сняты разбором: объяснение, приводящее запрещённую форму
// в пример, — не код, и краснеть на нём правило не должно.
body := []byte(sourceWithoutComments(t, path))
rel, err := filepath.Rel(repoRoot, path)
if err != nil {
t.Fatalf("отношу путь %s: %v", path, err)
}
for _, re := range patterns {
for _, loc := range re.FindAllIndex(body, -1) {
t.Errorf(
"%s:%d — ошибку узнают errors.Is и errors.As, а не по тексту сообщения: %q",
rel, lineOf(body, loc[0]), strings.TrimSpace(string(body[loc[0]:loc[1]])),
)
}
}
}
}
// Колонки очереди правятся в четырёх местах пакета хранилища плюс шаг схемы, и
// компилятор видит два из них (инвариант CLAUDE.md, «Инварианты», major).
// Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
// нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения —
// поле теряется только у задачи, попавшей к воркеру.
//
// Правила ниже закрывают все четыре места плюс шаг схемы: перечень запроса,
// структуру захвата, запись коллекции (`applyToRecord`/`recordToJob`) и перенос
// поля в задачу (`toJob`). Литерал колонки ищется **в телах** нужных функций, а
// не в файле: файл держит и структуру с тегами `db:"…"`, и по ней условие
// выполнялось бы само собой.
const (
repoPkg = "internal/adapter/repo/pocketbase"
acquireFile = repoPkg + "/transcript_job_repo.go"
mappingFile = repoPkg + "/job_mapping.go"
migrationsPath = repoPkg + "/migrations"
)
// Колонки, которые заводит и заполняет само хранилище: перечня запроса они
// касаются, а нашего кода — нет.
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
func TestПереченьЗахватаСовпадаетСоСтруктурой(t *testing.T) {
query := acquireColumnNames(t)
row := rowColumnNames(t)
for _, col := range query {
if !row[col] {
t.Errorf(
"колонка %q есть в acquireColumns, но не в acquiredRow: из захвата "+
"она приедет нулевой, и первый Save затрёт сохранённое значение",
col,
)
}
delete(row, col)
}
for col := range row {
t.Errorf(
"колонка %q есть в acquiredRow, но не в acquireColumns: запрос её не "+
"читает, и поле остаётся нулевым",
col,
)
}
}
func TestКолонкиЗахватаЗаведеныШагомСхемы(t *testing.T) {
declared := schemaFieldNames(t)
for _, col := range acquireColumnNames(t) {
if col == "id" {
continue // ключ заводит само хранилище, шаг схемы его не объявляет
}
if !declared[col] {
t.Errorf(
"колонка %q читается захватом, но ни один шаг схемы её не заводит: "+
"запрос отвалится на живой базе",
col,
)
}
}
}
// Четвёртое место — путь через запись коллекции: `applyToRecord` пишет колонку,
// `recordToJob` читает. Ищется литерал **в телах этих функций**, а не в файле:
// в файле лежит и структура захвата со своими тегами `db:"…"`, и по ней условие
// выполнялось бы само собой — правило было бы зелёным всегда.
func TestКолонкиЗахватаЧитаютсяИЧерезЗапись(t *testing.T) {
write := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
funcBody(t, mappingFile, "func applyToRecord(")
read := funcBody(t, mappingFile, "func recordToJob(")
for _, col := range acquireColumnNames(t) {
if storageOwned[col] {
continue // эти колонки заводит и заполняет само хранилище
}
if !strings.Contains(write, `"`+col+`"`) {
t.Errorf(
"колонку %q читает захват, но её не пишет ни applyOwnedByPipeline, "+
"ни applyToRecord: путь через запись коллекции её потеряет",
col,
)
}
if !strings.Contains(read, `"`+col+`"`) {
t.Errorf(
"колонку %q читает захват, но recordToJob её не читает: задача, "+
"прочитанная не захватом, приедет без этого поля",
col,
)
}
}
}
// Пятое условие того же инварианта: колонка, доехавшая до структуры захвата,
// обязана попасть в задачу. `toJob` обращается к **полям**, а не к литералам,
// поэтому сверяются имена полей, а не имена колонок: поле, забытое здесь,
// приезжает из захвата прочитанным и теряется на последнем шаге.
func TestПоляСтруктурыЗахватаДоезжаютДоЗадачи(t *testing.T) {
body := funcBody(t, mappingFile, "func (r *acquiredRow) toJob()")
for _, field := range rowFieldNames(t) {
if !strings.Contains(body, "r."+field) {
t.Errorf(
"поле %s структуры захвата не читается в toJob: колонка приедет из "+
"запроса, но в задачу не попадёт",
field,
)
}
}
}
// --- Чтение исходников ------------------------------------------------------
// acquireColumnNames достаёт имена колонок из константы `acquireColumns`. Она
// склеена из строковых литералов, поэтому берётся текстом, а не разбором типов:
// значение константы известно на месте.
func acquireColumnNames(t *testing.T) []string {
t.Helper()
body := readFile(t, acquireFile)
const marker = "const acquireColumns = "
start := strings.Index(body, marker)
if start < 0 {
t.Fatalf("в %s нет константы acquireColumns: правило потеряло предмет", acquireFile)
}
tail := body[start+len(marker):]
end := strings.Index(tail, "`\n")
if end < 0 {
t.Fatalf("не нашёл конец константы acquireColumns в %s", acquireFile)
}
var cols []string
for _, chunk := range strings.Split(strings.NewReplacer("`", "", "+", "", "\n", "", "\t", "").Replace(tail[:end]), ",") {
if col := strings.TrimSpace(chunk); col != "" {
cols = append(cols, col)
}
}
if len(cols) == 0 {
t.Fatalf("перечень acquireColumns прочитан пустым: правило потеряло предмет")
}
return cols
}
// rowColumnNames достаёт колонки из тегов `db:"…"` структуры `acquiredRow`.
func rowColumnNames(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
for _, m := range regexp.MustCompile("`db:\"([^\"]+)\"`").FindAllStringSubmatch(rowStruct(t), -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("у acquiredRow не прочитан ни один тег db: правило потеряло предмет")
}
return out
}
// rowFieldNames достаёт имена полей структуры `acquiredRow` — те, к которым
// обращается `toJob`.
func rowFieldNames(t *testing.T) []string {
t.Helper()
var out []string
for _, m := range regexp.MustCompile(`(?m)^\t([A-Z]\w*)\s`).FindAllStringSubmatch(rowStruct(t), -1) {
out = append(out, m[1])
}
if len(out) == 0 {
t.Fatalf("у acquiredRow не прочитано ни одно поле: правило потеряло предмет")
}
return out
}
// rowStruct — текст объявления структуры `acquiredRow`.
func rowStruct(t *testing.T) string {
t.Helper()
body := readFile(t, mappingFile)
start := strings.Index(body, "type acquiredRow struct {")
if start < 0 {
t.Fatalf("в %s нет структуры acquiredRow: правило потеряло предмет", mappingFile)
}
end := strings.Index(body[start:], "\n}")
if end < 0 {
t.Fatalf("не нашёл конец структуры acquiredRow в %s", mappingFile)
}
return body[start : start+end]
}
// funcBody — текст тела функции от её заголовка до закрывающей скобки в первой
// позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило,
// потерявшее предмет, обязано краснеть, а не зеленеть.
func funcBody(t *testing.T, file, header string) string {
t.Helper()
body := readFile(t, file)
start := strings.Index(body, header)
if start < 0 {
t.Fatalf("в %s нет %s: правило потеряло предмет", file, header)
}
end := strings.Index(body[start:], "\n}")
if end < 0 {
t.Fatalf("не нашёл конец %s в %s", header, file)
}
return body[start : start+end]
}
// schemaFieldNames собирает имена полей, заведённых шагами схемы: `Name: "…"` в
// любом файле каталога шагов. Перечень объединённый — колонку заводит тот шаг,
// который её добавил, а переписывать применённый шаг нельзя.
func schemaFieldNames(t *testing.T) map[string]bool {
t.Helper()
dir := filepath.Join(repoRoot, migrationsPath)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("читаю каталог шагов схемы: %v", err)
}
re := regexp.MustCompile(`Name:\s*"([^"]+)"`)
out := map[string]bool{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
continue
}
body, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatalf("читаю %s: %v", e.Name(), err)
}
for _, m := range re.FindAllStringSubmatch(string(body), -1) {
out[m[1]] = true
}
}
if len(out) == 0 {
t.Fatalf("шаги схемы не объявили ни одного поля: правило потеряло предмет")
}
return out
}
func readFile(t *testing.T, rel string) string {
t.Helper()
body, err := os.ReadFile(filepath.Join(repoRoot, rel))
if err != nil {
t.Fatalf("читаю %s: %v", rel, err)
}
return string(body)
}
// internalImports возвращает карту «пакет репозитория → его внутренние импорты».
// Файлы проверок не читаются: подставной адаптер в тесте ядра законен, а вот в
// рабочем коде — нет.
func internalImports(t *testing.T) map[string][]string {
t.Helper()
out := map[string][]string{}
fset := token.NewFileSet()
for _, path := range goFiles(t) {
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("разбираю %s: %v", path, err)
}
rel, err := filepath.Rel(repoRoot, filepath.Dir(path))
if err != nil {
t.Fatalf("отношу путь %s: %v", path, err)
}
for _, imp := range f.Imports {
if after, ok := strings.CutPrefix(strings.Trim(imp.Path.Value, `"`), modulePath+"/"); ok {
out[rel] = append(out[rel], after)
}
}
}
if len(out) == 0 {
t.Fatal("не найдено ни одного файла с внутренними импортами: правило потеряло предмет")
}
return out
}
// sourceWithoutComments — исходник без комментариев: файл разбирается без них и
// печатается заново. Снимать комментарии текстом нельзя — строковый литерал со
// знаками `//` внутри перестал бы читаться.
func sourceWithoutComments(t *testing.T, path string) string {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("разбираю %s: %v", path, err)
}
var buf bytes.Buffer
if err := printer.Fprint(&buf, fset, f); err != nil {
t.Fatalf("печатаю %s: %v", path, err)
}
return buf.String()
}
// packageDirs — каталоги репозитория с рабочим кодом на Go, путями от корня
// модуля. Каталог без импортов внутрь модуля тоже считается: правила о предмете
// говорят, а не о его зависимостях.
func packageDirs(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
for _, path := range goFiles(t) {
rel, err := filepath.Rel(repoRoot, filepath.Dir(path))
if err != nil {
t.Fatalf("отношу путь %s: %v", path, err)
}
out[rel] = true
}
return out
}
// goFiles — все нерабочие каталоги отброшены, файлы проверок тоже: правила
// говорят о рабочем коде.
func goFiles(t *testing.T) []string {
t.Helper()
var files []string
err := filepath.WalkDir(repoRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case ".git", "data", "docker", "node_modules":
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
files = append(files, path)
}
return nil
})
if err != nil {
t.Fatalf("обхожу репозиторий: %v", err)
}
return files
}
func lineOf(body []byte, offset int) int {
return 1 + strings.Count(string(body[:offset]), "\n")
}
+32
View File
@@ -0,0 +1,32 @@
// Package clock — единая точка чтения времени.
//
// Прежде время брали `time.Now()` по месту вызова, и конвенция
// [docs/conventions/database.md] числила это долгом: метка времени в локальной
// зоне, а хранилище сравнивает времена **строками** побайтово. Долг закрыт
// заведением этого пакета; правило держит `forbidigo` в `.golangci.yml` —
// `time.Now` вне этого пакета запрещён.
//
// Метка времени и измерение длительности читаются по-разному, и потому здесь две
// функции, а не одна.
package clock
import "time"
// Now — метка времени: UTC, как её пишет и сравнивает хранилище.
//
// Приведение к UTC снимает монотонные часы, и для метки это верно: её кладут в
// колонку и сравнивают с чужими значениями, а не с собственным прошлым
// показанием.
func Now() time.Time {
return time.Now().UTC()
}
// Start — начало измерения длительности: время **с монотонными часами**.
//
// Зоны у него нет намеренно: значение не выходит наружу и годится только на
// вход `time.Since`. Монотонные часы здесь и нужны — иначе перевод стрелок или
// поправка ntp посреди конвертации дала бы отрицательную или скачущую
// длительность в журнале и в метрике.
func Start() time.Time {
return time.Now()
}
+70
View File
@@ -2,7 +2,10 @@ package config
import (
"fmt"
"net/url"
"os"
"sort"
"strings"
"github.com/BurntSushi/toml"
)
@@ -12,6 +15,7 @@ type Config struct {
Storage StorageConfig `toml:"storage"`
Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"`
Auth AuthConfig `toml:"auth"`
}
type ServerConfig struct {
@@ -42,6 +46,69 @@ type TelegramConfig struct {
UpdateTimeout int `toml:"update_timeout"`
}
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
// секрет, положенный однажды шагом, не пережил бы ротации.
type AuthConfig struct {
AuthURL string `toml:"auth_url"`
TokenURL string `toml:"token_url"`
UserInfoURL string `toml:"user_info_url"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
RedirectURL string `toml:"redirect_url"`
// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
// выключается только для локального запуска по `http://localhost`, где
// браузер такую куку не сохранит.
SecureCookie bool `toml:"secure_cookie"`
}
// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
// открытым наружу, а узнать об этом было бы неоткуда.
//
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
// молча падает целиком, вместе с ботом и воркерами.
func (c AuthConfig) Validate() error {
values := map[string]string{
"auth_url": c.AuthURL,
"token_url": c.TokenURL,
"user_info_url": c.UserInfoURL,
"client_id": c.ClientID,
"client_secret": c.ClientSecret,
"redirect_url": c.RedirectURL,
}
missing := make([]string, 0, len(values))
for name, value := range values {
if value == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
// Названы имена ключей, а не значения: значение `client_secret` в
// сообщение об ошибке попасть не должно, оно уедет в журнал.
return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
}
malformed := make([]string, 0, 4)
for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
parsed, err := url.Parse(values[name])
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
malformed = append(malformed, name)
}
}
if len(malformed) > 0 {
return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
}
return nil
}
// DefaultConfig returns a Config with default values
func defaultConfig() *Config {
return &Config{
@@ -66,6 +133,9 @@ func defaultConfig() *Config {
BotToken: "",
UpdateTimeout: 10,
},
Auth: AuthConfig{
SecureCookie: true,
},
}
}
+97
View File
@@ -0,0 +1,97 @@
package config
import (
"strings"
"testing"
)
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
// молча выключенным входом, то есть открытым наружу. До этих проверок она не
// исполнялась ни разу.
func validAuthConfig() AuthConfig {
return AuthConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "secret-value",
RedirectURL: "https://transcriber.example.com/auth/callback",
}
}
func TestAuthConfigValidateAcceptsFilled(t *testing.T) {
if err := validAuthConfig().Validate(); err != nil {
t.Fatalf("заполненный конфиг отвергнут: %v", err)
}
}
func TestAuthConfigValidateNamesEveryMissingKey(t *testing.T) {
cases := map[string]func(*AuthConfig){
"auth_url": func(c *AuthConfig) { c.AuthURL = "" },
"token_url": func(c *AuthConfig) { c.TokenURL = "" },
"user_info_url": func(c *AuthConfig) { c.UserInfoURL = "" },
"client_id": func(c *AuthConfig) { c.ClientID = "" },
"client_secret": func(c *AuthConfig) { c.ClientSecret = "" },
"redirect_url": func(c *AuthConfig) { c.RedirectURL = "" },
}
for key, clear := range cases {
t.Run(key, func(t *testing.T) {
cfg := validAuthConfig()
clear(&cfg)
err := cfg.Validate()
if err == nil {
t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key)
}
if !strings.Contains(err.Error(), key) {
t.Fatalf("имя ключа %s не названо: %v", key, err)
}
})
}
}
// TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал,
// и значения секрета в нём быть не должно — только имя ключа.
func TestAuthConfigValidateHidesSecretValue(t *testing.T) {
cfg := validAuthConfig()
cfg.ClientSecret = "super-secret-value"
cfg.AuthURL = ""
err := cfg.Validate()
if err == nil {
t.Fatal("отказа нет")
}
if strings.Contains(err.Error(), "super-secret-value") {
t.Fatalf("значение секрета попало в текст отказа: %v", err)
}
}
// TestAuthConfigValidateRejectsMalformedURL: непустая строка, не похожая на
// адрес, отвергается здесь, а не позже — хранилище отказало бы уже из хука
// подъёма, до регистрации пробы здоровья, и сервис упал бы молча целиком.
func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
cases := map[string]string{
"без схемы": "auth.example.com/api/oidc/authorization",
"пробел спереди": " https://auth.example.com/authorize",
"чужая схема": "ftp://auth.example.com/authorize",
"пустой хост": "https:///authorize",
"не адрес вовсе": "todo: заполнить",
}
for name, value := range cases {
t.Run(name, func(t *testing.T) {
cfg := validAuthConfig()
cfg.AuthURL = value
err := cfg.Validate()
if err == nil {
t.Fatalf("негодный адрес %q пропущен", value)
}
if !strings.Contains(err.Error(), "auth_url") {
t.Fatalf("имя ключа не названо: %v", err)
}
})
}
}
+11 -5
View File
@@ -1,6 +1,7 @@
package contract
import (
"context"
"io"
"git.vakhrushev.me/av/transcriber/internal/entity"
@@ -10,18 +11,23 @@ type AudioInfo struct {
Seconds int // Длина аудиофайла в секундах
}
// Контекст первым доводом несут все интерфейсы, за которыми стоит внешний
// собеседник — процесс `ffmpeg`, S3, SpeechKit. Он здесь не украшение: остановка
// сервиса обязана доходить до чужой работы, а не оставлять её сиротой. Без него
// конвертация шестичасовой записи переживает остановку воркера, а запрос к
// платному распознаванию висит до собственного таймаута библиотеки.
type AudioMetaViewer interface {
GetInfo(src string) (*AudioInfo, error)
GetInfo(ctx context.Context, src string) (*AudioInfo, error)
}
type AudioFileConverter interface {
Convert(src, dest string) error
Convert(ctx context.Context, src, dest string) error
}
type AudioRecognizer interface {
Recognize(file io.Reader, fileName string) (operationID string, err error)
GetRecognitionText(operationID string) (string, error)
CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error)
Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error)
GetRecognitionText(ctx context.Context, operationID string) (string, error)
CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error)
}
type TelegramMessageSender interface {
+364
View File
@@ -0,0 +1,364 @@
package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/security"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
)
const (
// SessionCookieName — имя куки сессии. Имя нормативно: его смена молча
// выкидывает всех вошедших.
SessionCookieName = "transcriber_session"
// stateCookieName — носитель состояния и проверочного кода PKCE. Живёт
// один вход и убирается на возврате, каким бы тот ни был.
stateCookieName = "transcriber_login"
// stateCookieMaxAge — потолок времени на вход у провайдера. Дольше носитель
// не нужен, а вечный носитель надолго фиксирует состояние.
stateCookieMaxAge = 10 * 60
// exchangeTimeout — потолок обмена кода у провайдера. Без него молчащий
// провайдер держит обработчик возврата открытым неограниченно долго, и «медленный»
// становится неотличим от «отказал».
exchangeTimeout = 15 * time.Second
)
// AuthHandler ведёт вход, возврат от провайдера и выход.
//
// Разбор ответа провайдера остаётся за хранилищем — решение от 2026-08-11.
// Обмен кода библиотека наружу не отдаёт: он живёт за её собственным адресом,
// поэтому обработчик возврата зовёт этот адрес внутри процесса, через её же
// роутер. Цена петли принята решением владельца от 2026-08-12: взамен учётные
// записи заводит хранилище и они видны в панели.
type AuthHandler struct {
app core.App
logger *slog.Logger
authURL string
redirectURL string
clientID string
secureCookie bool
// storageMux — роутер хранилища, через который идёт обмен кода. Собирается
// один раз: сборка вешает обработчики на само приложение и без
// идентификатора, поэтому повторная не заменяет прежние, а добавляет к ним.
// Собранный на каждый вход, он копил бы их без предела — и копил бы по
// запросу анонима, потому что обмен исполняется раньше обращения к
// провайдеру.
storageMux http.Handler
storageMuxOnce sync.Once
storageMuxErr error
}
type AuthHandlerConfig struct {
AuthURL string
RedirectURL string
ClientID string
SecureCookie bool
}
func NewAuthHandler(app core.App, cfg AuthHandlerConfig, logger *slog.Logger) *AuthHandler {
if logger == nil {
logger = slog.Default()
}
return &AuthHandler{
app: app,
logger: logger,
authURL: cfg.AuthURL,
redirectURL: cfg.RedirectURL,
clientID: cfg.ClientID,
secureCookie: cfg.SecureCookie,
}
}
// Register вешает адреса входа вне пространства `/api`: оно поделено с
// собственными адресами хранилища.
func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
// Продление сессии закрывается на всём роутере: адрес приносит хранилище
// своим, и перехватить его можно только слоем.
r.Bind(BlockSessionRefresh())
r.GET("/auth/login", h.Login)
r.GET("/auth/callback", h.Callback)
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
// чужой ссылке.
//
// Слой предъявления нужен и здесь: без него выход не знает, чью сессию
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
// значение годным. Требования сессии при этом нет: выход без неё убирает
// куку и молчит.
r.POST("/auth/logout", h.Logout).Bind(SessionFromCookie())
}
// Login уводит человека к провайдеру, запомнив состояние и проверочный код
// PKCE у браузера.
func (h *AuthHandler) Login(e *core.RequestEvent) error {
state := security.RandomString(32)
verifier := security.RandomString(43)
e.SetCookie(&http.Cookie{
Name: stateCookieName,
Value: state + ":" + verifier,
Path: "/",
MaxAge: stateCookieMaxAge,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
query := url.Values{}
query.Set("response_type", "code")
query.Set("client_id", h.clientID)
query.Set("redirect_uri", h.redirectURL)
query.Set("scope", "openid profile email")
query.Set("state", state)
query.Set("code_challenge", security.S256Challenge(verifier))
query.Set("code_challenge_method", "S256")
separator := "?"
if strings.Contains(h.authURL, "?") {
separator = "&"
}
return e.Redirect(http.StatusFound, h.authURL+separator+query.Encode())
}
// Callback принимает возврат от провайдера, сверяет состояние и меняет код на
// сессию средствами хранилища.
func (h *AuthHandler) Callback(e *core.RequestEvent) error {
// Носитель убирается всегда — и на успехе, и на отказе, — и убирается
// **до** записи ответа. Отложенная уборка не работает вовсе: заголовки
// фиксируются в момент, когда ответ начинают писать, и позднейшая правка их
// карты до браузера не доезжает. Состояние одноразовое ровно этим: пока
// носитель жив, переигранный возврат проходит сверку.
h.clearStateCookie(e)
query := e.Request.URL.Query()
// Всё, что ниже до обмена, — негодный ввод от пришедшего, а не поломка
// сервиса: владельцу разбирать нечего, и уровень здесь отладочный. Иначе
// обычный отказ человека у провайдера стал бы неотличим от «провайдер лежит».
if providerError := query.Get("error"); providerError != "" {
h.logger.Debug("Login rejected by provider",
"reason", knownProviderError(providerError), "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
state, verifier, err := h.readStateCookie(e)
if err != nil {
h.logger.Debug("Login state is missing or malformed",
"error", err, "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
if query.Get("state") != state {
h.logger.Debug("Login state mismatch", "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
code := query.Get("code")
if code == "" {
h.logger.Debug("Provider returned no code", "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
token, err := h.exchange(e.Request.Context(), code, verifier)
if err != nil {
// Отказ обмена — уже про сервис и его связь с провайдером, поэтому
// уровень выше. Код провайдера в журнал не идёт: он и есть предъявитель
// входа.
h.logger.Error("Failed to exchange provider code",
"error", err, "capability", "access", "transport", "http")
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
}
h.setSessionCookie(e, token)
return e.Redirect(http.StatusFound, "/")
}
// Logout обесценивает выданные учётной записи сессии и убирает куку.
//
// Порядок обязателен: сперва обесценивание, потом уборка. При обратном порядке
// выход, разошедшийся с одновременным входом, оставил бы годную сессию, а
// человек был бы уверен, что вышел.
func (h *AuthHandler) Logout(e *core.RequestEvent) error {
if e.Auth != nil {
// Ключ токенов обновляется у свежей записи: между чтением и записью
// могла пройти чужая правка, и полное сохранение устаревшей записи
// затёрло бы её.
record, err := h.app.FindRecordById(e.Auth.Collection().Id, e.Auth.Id)
if err != nil {
h.logger.Error("Failed to load account for logout", "error", err, "transport", "http")
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
}
record.RefreshTokenKey()
if err := h.app.Save(record); err != nil {
h.logger.Error("Failed to revoke sessions", "error", err, "transport", "http")
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
}
}
h.clearSessionCookie(e)
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
// exchange зовёт собственный адрес хранилища внутри процесса. По сети запрос не
// идёт: роутер поднимается тот же, что обслуживает внешние запросы.
func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, exchangeTimeout)
defer cancel()
body, err := json.Marshal(map[string]string{
"provider": pbrepo.ProviderName,
"code": code,
"codeVerifier": verifier,
"redirectURL": h.redirectURL,
})
if err != nil {
return "", fmt.Errorf("failed to build exchange request: %w", err)
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
"/api/collections/users/auth-with-oauth2",
strings.NewReader(string(body)),
)
if err != nil {
return "", fmt.Errorf("failed to build exchange request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
handler, err := h.storageHandler()
if err != nil {
return "", err
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
// Тело ответа наружу не выносится: в нём приезжает описание отказа
// провайдера, а оно принадлежит журналу, а не человеку.
return "", fmt.Errorf("storage rejected the exchange with code %d", recorder.Code)
}
var response struct {
Token string `json:"token"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
return "", fmt.Errorf("failed to read exchange response: %w", err)
}
if response.Token == "" {
return "", errors.New("exchange response carries no session")
}
return response.Token, nil
}
// knownProviderError приводит причину отказа к перечню известных.
//
// Значение приходит строкой запроса и целиком задаётся тем, кто её шлёт: без
// приведения аноним пишет в журнал что угодно и сколько угодно — предел один,
// размер заголовков. Журнал же единственное место, где наблюдаются инварианты о
// молчаливой потере задачи, и вытеснять его чужим текстом нельзя.
//
// Приём тот же, каким расширение записи приводится к перечню форматов.
func knownProviderError(value string) string {
switch value {
case "access_denied", "invalid_request", "invalid_scope", "server_error",
"temporarily_unavailable", "unauthorized_client", "unsupported_response_type",
"interaction_required", "login_required", "consent_required":
return value
default:
return "other"
}
}
// storageHandler собирает роутер хранилища один раз и отдаёт его всем
// последующим обменам.
func (h *AuthHandler) storageHandler() (http.Handler, error) {
h.storageMuxOnce.Do(func() {
router, err := apis.NewRouter(h.app)
if err != nil {
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
return
}
mux, err := router.BuildMux()
if err != nil {
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
return
}
h.storageMux = mux
})
return h.storageMux, h.storageMuxErr
}
func (h *AuthHandler) readStateCookie(e *core.RequestEvent) (state, verifier string, err error) {
cookie, err := e.Request.Cookie(stateCookieName)
if err != nil {
return "", "", fmt.Errorf("login state cookie is missing: %w", err)
}
state, verifier, found := strings.Cut(cookie.Value, ":")
if !found || state == "" || verifier == "" {
return "", "", errors.New("login state cookie is malformed")
}
return state, verifier, nil
}
func (h *AuthHandler) setSessionCookie(e *core.RequestEvent, token string) {
e.SetCookie(&http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
MaxAge: pbrepo.SessionDuration,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
}
func (h *AuthHandler) clearSessionCookie(e *core.RequestEvent) {
e.SetCookie(&http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
}
func (h *AuthHandler) clearStateCookie(e *core.RequestEvent) {
e.SetCookie(&http.Cookie{
Name: stateCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteLaxMode,
})
}
+539
View File
@@ -0,0 +1,539 @@
package http
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
// предъявляется сессия, что её прекращает и какие адреса остаются открытыми.
// TestApiRequiresSession — первый критерий приёмки. Запрос без сессии получает
// отказ и ничего не заводит, а проба здоровья и метрики остаются открытыми.
func TestApiRequiresSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
t.Run("приём записи без сессии", func(t *testing.T) {
req := createMultipartRequest(t, "test.mp3", []byte("audio"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.NotContains(t, w.Body.String(), "job_id")
// Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в
// хранилище.
files, err := env.app.FindAllRecords(migrations.FilesCollection)
require.NoError(t, err)
assert.Empty(t, files)
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
require.NoError(t, err)
assert.Empty(t, jobs)
})
t.Run("опрос готовности без сессии", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/status/anything", nil)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.NotContains(t, w.Body.String(), "transcription_text")
assert.NotContains(t, w.Body.String(), "created_at")
})
}
// TestUnknownJobIsIndistinguishableWithoutSession: по кодам ответа без сессии не
// перебирается список заведённых задач.
func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, created.Code)
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
require.NoError(t, err)
require.Len(t, jobs, 1)
existing := httptest.NewRecorder()
env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/api/status/"+jobs[0].Id, nil))
missing := httptest.NewRecorder()
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil))
assert.Equal(t, http.StatusUnauthorized, existing.Code)
assert.Equal(t, missing.Code, existing.Code)
}
// TestOpenEndpointsStayOpen — вторая сторона границы: проба здоровья и метрики
// сессии не требуют. Маршруты вешает `main`, поэтому здесь собирается такой же
// роутер с теми же двумя адресами.
func TestOpenEndpointsStayOpen(t *testing.T) {
app := newTestStorage(t)
r, err := apis.NewRouter(app)
require.NoError(t, err)
r.GET("/health", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
r.GET("/metrics", func(e *core.RequestEvent) error {
return e.String(http.StatusOK, "# metrics")
})
mux, err := r.BuildMux()
require.NoError(t, err)
for _, path := range []string{"/health", "/metrics"} {
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
assert.Equal(t, http.StatusOK, w.Code, "адрес %s обязан отвечать без сессии", path)
}
}
// TestSessionSurvivesRestart — второй критерий приёмки. Подпись сессии считается
// от секрета коллекции и ключа записи, оба лежат в базе, поэтому выкладка
// вошедших не выкидывает.
func TestSessionSurvivesRestart(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
before := httptest.NewRecorder()
env.serve(before, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, before.Code)
// Сервер пересоздаётся на том же хранилище — то же, что перезапуск процесса
// поверх прежнего каталога данных.
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
env.handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// Прежняя кука прошла проверку: до обработчика дошло, и он ответил про
// ненайденную задачу, а не про отсутствующую сессию.
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestLogoutClosesAccess — третий критерий приёмки. Выход обесценивает выданные
// сессии, а не только убирает куку.
func TestLogoutClosesAccess(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
env.handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
logoutResponse := httptest.NewRecorder()
mux.ServeHTTP(logoutResponse, logout)
require.Equal(t, http.StatusOK, logoutResponse.Code)
// Куку выход убирает.
assert.Contains(t, logoutResponse.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
// И прежнее значение больше не открывает доступ — этого уборка куки сама по
// себе не даёт: унесённое значение работало бы до истечения срока.
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder()
mux.ServeHTTP(afterResponse, after)
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
}
// TestLogoutWhenAccountIsGone: учётной записи, которой предъявлена сессия, уже
// нет — выход отвечает отказом и не делает вид, что закрыл доступ.
func TestLogoutWhenAccountIsGone(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
// Сессия выдана, а запись удалена — так выглядит гонка выхода с удалением
// учётной записи в панели.
require.NoError(t, env.app.Delete(env.account))
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, logout)
// Записи нет — проверка сессии её не находит, и до обесценивания дело не
// доходит: выход отвечает успехом, убрав куку. Доступа при этом всё равно
// не осталось, потому что не осталось учётной записи.
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder()
env.mux.ServeHTTP(afterResponse, after)
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
}
// TestLogoutWithoutSession: выход без сессии убирает куку и молчит.
func TestLogoutWithoutSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/auth/logout", nil))
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
}
// TestLoginRedirectsToProvider: вход уводит к провайдеру и запоминает состояние
// у браузера.
func TestLoginRedirectsToProvider(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
require.Equal(t, http.StatusFound, w.Code)
location := w.Result().Header.Get("Location")
assert.Contains(t, location, "https://auth.example.com/api/oidc/authorization")
assert.Contains(t, location, "code_challenge_method=S256")
assert.Contains(t, location, "client_id=transcriber")
// Носитель состояния несёт те же признаки защиты, что и кука сессии.
stateCookie := w.Result().Header.Get("Set-Cookie")
assert.Contains(t, stateCookie, stateCookieName)
assert.Contains(t, stateCookie, "HttpOnly")
assert.Contains(t, stateCookie, "Secure")
assert.Contains(t, stateCookie, "SameSite=Lax")
}
// TestCallbackRejectsForeignState — возврат с невыданным состоянием сессии не
// открывает и учётной записи не заводит.
func TestCallbackRejectsForeignState(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
AuthURL: "https://auth.example.com/api/oidc/authorization",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
authHandler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
accountsBefore, err := env.app.FindAllRecords("users")
require.NoError(t, err)
cases := []struct {
name string
cookie *http.Cookie
query string
}{
{
name: "состояния не выдавали вовсе",
cookie: nil,
query: "?code=whatever&state=foreign",
},
{
name: "состояние не совпало с выданным",
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
query: "?code=whatever&state=foreign",
},
{
name: "провайдер вернул отказ",
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
query: "?error=access_denied&state=issued",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/callback"+tc.query, nil)
if tc.cookie != nil {
req.AddCookie(tc.cookie)
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
accountsAfter, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Len(t, accountsAfter, len(accountsBefore))
// Носитель убирается и на отказном возврате: иначе состояние
// осталось бы годным для новой попытки.
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), stateCookieName+"=;")
})
}
}
// TestHeaderBeatsCookie: предъявленный заголовок побеждает куку.
func TestHeaderBeatsCookie(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
req.Header.Set("Authorization", env.session)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
// Прошёл заголовок: иначе негодная кука дала бы отказ.
assert.Equal(t, http.StatusNotFound, w.Code)
}
// TestSelfServiceAccountsAreClosed — то, ради чего задача вообще имеет смысл.
// Пока создание записи и вход по паролю открыты, закрытие приёма обходится
// двумя запросами.
func TestSelfServiceAccountsAreClosed(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
t.Run("завести учётную запись самому нельзя", func(t *testing.T) {
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusOK, w.Code)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
})
t.Run("вход паролем недоступен", func(t *testing.T) {
body := strings.NewReader(`{"identity":"person@example.com","password":"whatever"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/auth-with-password", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
})
}
// TestAccessGrantingValuesAreNotLogged — четвёртый критерий приёмки, расширенный
// ревью дизайна: не печатается ничто, что даёт доступ.
//
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
// другим ключом, поиск по ключу не разбудил бы.
func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, created.Code)
journal := env.journal.String()
require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")
assert.NotContains(t, journal, env.session,
"значение сессии в журнале: строка стала бы ключом к чужому доступу")
assert.NotContains(t, journal, env.account.Email(),
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
}
// TestProviderSecretIsNotLogged: секрет клиента не появляется в журнале при
// приведении настроек провайдера к конфигу.
func TestProviderSecretIsNotLogged(t *testing.T) {
app := newTestStorage(t)
const secret = "super-secret-client-value"
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: secret,
}))
// Настройка доехала до хранилища — иначе проверка отсутствия секрета в
// журнале прошла бы на невыполненной работе.
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
require.True(t, found)
assert.Equal(t, secret, provider.ClientSecret)
}
// TestProviderSecretRotationReachesStorage: смена секрета в конфиге доезжает до
// хранилища. Положенный однажды шагом схемы, он бы не доехал — применённый шаг
// не переписывается.
func TestProviderSecretRotationReachesStorage(t *testing.T) {
app := newTestStorage(t)
settings := pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "first-secret",
}
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
settings.ClientSecret = "rotated-secret"
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
require.True(t, found)
assert.Equal(t, "rotated-secret", provider.ClientSecret)
}
// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней.
func TestRecordFileIsProtected(t *testing.T) {
app := newTestStorage(t)
files, err := app.FindCollectionByNameOrId(migrations.FilesCollection)
require.NoError(t, err)
field, ok := files.Fields.GetByName("file").(*core.FileField)
require.True(t, ok)
assert.True(t, field.Protected,
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
}
// TestRecordFileNeedsSession: ссылка на файл записи без сессии отказывает, а
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
// ссылке.
func TestRecordFileNeedsSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
require.Equal(t, http.StatusCreated, created.Code)
files, err := env.app.FindAllRecords(migrations.FilesCollection)
require.NoError(t, err)
require.Len(t, files, 1)
names := files[0].GetStringSlice("file")
require.Len(t, names, 1)
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
anonymous := httptest.NewRecorder()
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
// Отказ приходит кодом «не найдено»: защищённый файл не раскрывает даже
// своего существования. До пометки поля защищённым эта же ссылка отдавала
// содержимое кому угодно — знание ссылки и было доступом.
assert.Equal(t, http.StatusNotFound, anonymous.Code,
"ссылка отдала файл без сессии: знание ссылки снова стало доступом")
assert.NotContains(t, anonymous.Body.String(), "audio content")
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
fileRepo := pbrepo.NewFileRepository(env.app)
reader, err := fileRepo.Open(files[0].Id)
require.NoError(t, err)
defer func() {
assert.NoError(t, reader.Close())
}()
content := make([]byte, len("audio content"))
_, err = reader.Read(content)
require.NoError(t, err)
assert.Equal(t, "audio content", string(content))
}
// TestSessionLifetimeIsAssigned: срок жизни сессии назначен нами, а не достался
// умолчанием библиотеки в пять суток.
//
// Назначается он приведением настроек при подъёме, а не шагом схемы: применённый
// шаг не переписывается, и число, положенное туда, разошлось бы со сроком жизни
// куки при первой же правке.
func TestSessionLifetimeIsAssigned(t *testing.T) {
app := newTestStorage(t)
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
require.NotEqual(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration,
"шаг схемы назначил срок сам — тогда правка числа до хранилища не доедет")
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: "https://auth.example.com/api/oidc/authorization",
TokenURL: "https://auth.example.com/api/oidc/token",
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
ClientID: "transcriber",
ClientSecret: "local-test-secret",
}))
users, err = app.FindCollectionByNameOrId("users")
require.NoError(t, err)
assert.Equal(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration)
}
+353
View File
@@ -0,0 +1,353 @@
package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// Проверки этого файла проходят вход целиком — от увода к провайдеру до куки
// сессии. Без них сердцевина изменения не исполнялась ни разу: прочие проверки
// заводят учётную запись прямым сохранением и останавливаются раньше обмена.
// fakeProvider — подставной провайдер OIDC. Отдаёт токен и сведения о человеке,
// считая обращения: по счётчику видно, дошло ли до сети вообще.
type fakeProvider struct {
server *httptest.Server
tokenHits int
failToken bool
subject string
emailValue string
}
func newFakeProvider(t *testing.T) *fakeProvider {
t.Helper()
provider := &fakeProvider{subject: "person-sub-1", emailValue: "person@example.com"}
write := func(w http.ResponseWriter, body string) {
if _, err := w.Write([]byte(body)); err != nil {
t.Errorf("подставной провайдер не ответил: %v", err)
}
}
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
provider.tokenHits++
if provider.failToken {
w.WriteHeader(http.StatusBadRequest)
write(w, `{"error":"invalid_grant"}`)
return
}
w.Header().Set("Content-Type", "application/json")
write(w, `{"access_token":"provider-access-token","token_type":"bearer","expires_in":3600}`)
})
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
write(w, `{"sub":"`+provider.subject+`","email":"`+provider.emailValue+`","name":"Person","email_verified":true}`)
})
provider.server = httptest.NewServer(mux)
t.Cleanup(provider.server.Close)
return provider
}
// loginEnv — окружение проверки входа: хранилище с настроенным подставным
// провайдером и собранный роутер со всеми слоями.
type loginEnv struct {
app core.App
mux http.Handler
handler *AuthHandler
provider *fakeProvider
}
func setupLoginEnv(t *testing.T) *loginEnv {
t.Helper()
app := newTestStorage(t)
provider := newFakeProvider(t)
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
AuthURL: provider.server.URL + "/authorize",
TokenURL: provider.server.URL + "/token",
UserInfoURL: provider.server.URL + "/userinfo",
ClientID: "transcriber",
ClientSecret: "local-test-secret",
}))
handler := NewAuthHandler(app, AuthHandlerConfig{
AuthURL: provider.server.URL + "/authorize",
RedirectURL: "https://transcriber.example.com/auth/callback",
ClientID: "transcriber",
SecureCookie: true,
}, nil)
r, err := apis.NewRouter(app)
require.NoError(t, err)
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
return &loginEnv{app: app, mux: mux, handler: handler, provider: provider}
}
// startLogin проходит первый шаг входа и отдаёт носитель состояния вместе с
// выданным состоянием — тем, что сервис ждёт обратно.
func (e *loginEnv) startLogin(t *testing.T) (cookie *http.Cookie, state string) {
t.Helper()
w := httptest.NewRecorder()
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
require.Equal(t, http.StatusFound, w.Code)
for _, c := range w.Result().Cookies() {
if c.Name == stateCookieName {
cookie = c
}
}
require.NotNil(t, cookie, "носитель состояния не поставлен")
location, err := url.Parse(w.Result().Header.Get("Location"))
require.NoError(t, err)
state = location.Query().Get("state")
require.NotEmpty(t, state)
return cookie, state
}
// TestLoginCreatesAccountAndSession — вход целиком: человека заводят по слову
// провайдера, и он получает сессию.
//
// Без этой проверки закрытое создание записи в коллекции пользователей выглядит
// работающим: прочие проверки заводят запись мимо входа.
func TestLoginCreatesAccountAndSession(t *testing.T) {
env := setupLoginEnv(t)
before, err := env.app.FindAllRecords("users")
require.NoError(t, err)
require.Empty(t, before, "учётных записей быть не должно: шаг схемы их не заводит")
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusFound, w.Code, "вход не прошёл: тело %s", w.Body.String())
assert.Equal(t, 1, env.provider.tokenHits, "обмен до провайдера не дошёл")
after, err := env.app.FindAllRecords("users")
require.NoError(t, err)
require.Len(t, after, 1, "учётная запись не заведена — войти не может никто")
var session *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == SessionCookieName {
session = c
}
}
require.NotNil(t, session, "кука сессии не поставлена")
// Признаки куки нормативны: их потеря делает сессию доступной скриптам либо
// уносит её по незашифрованному соединению.
assert.True(t, session.HttpOnly)
assert.True(t, session.Secure)
assert.Equal(t, http.SameSiteLaxMode, session.SameSite)
assert.Equal(t, pbrepo.SessionDuration, session.MaxAge)
assert.NotEmpty(t, session.Value)
// Носитель состояния убран — и убран так, что это видно готовому ответу, а
// не только живой карте заголовков.
var cleared bool
for _, c := range w.Result().Cookies() {
if c.Name == stateCookieName && c.MaxAge < 0 {
cleared = true
}
}
assert.True(t, cleared, "носитель состояния пережил возврат")
// Выданная сессия открывает доступ к закрытым адресам.
check := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
check.AddCookie(session)
checkResponse := httptest.NewRecorder()
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
NewTranscribeHandler(pbrepo.NewTranscriptJobRepository(env.app), nil, nil).Register(r)
checkMux, err := r.BuildMux()
require.NoError(t, err)
checkMux.ServeHTTP(checkResponse, check)
assert.Equal(t, http.StatusNotFound, checkResponse.Code,
"сессия не открыла доступ: получен %d", checkResponse.Code)
}
// TestSelfServiceRegistrationStaysClosed: правило создания пускает обмен и не
// пускает постороннего.
func TestSelfServiceRegistrationStaysClosed(t *testing.T) {
env := setupLoginEnv(t)
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
"посторонний завёл себе учётную запись: %s", w.Body.String())
accounts, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Empty(t, accounts)
}
// TestCallbackDoesNotLeakHooks: обмен не копит обработчики приложения.
//
// Сборка роутера хранилища вешает обработчики на само приложение и без
// идентификатора, поэтому повторная не заменяет прежние. Собранный на каждый
// вход, роутер копил бы их без предела — и копил бы по запросу анонима, потому
// что обмен исполняется раньше обращения к провайдеру.
func TestCallbackDoesNotLeakHooks(t *testing.T) {
env := setupLoginEnv(t)
count := func() int {
hook := reflect.ValueOf(env.app.OnModelAfterCreateSuccess()).Elem().FieldByName("handlers")
return hook.Len()
}
// Первый вход собирает роутер — с него и считаем.
cookie, state := env.startLogin(t)
first := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
first.AddCookie(cookie)
env.mux.ServeHTTP(httptest.NewRecorder(), first)
before := count()
for range 20 {
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
req.AddCookie(cookie)
env.mux.ServeHTTP(httptest.NewRecorder(), req)
}
assert.Equal(t, before, count(),
"обработчики копятся: 20 входов добавили %d", count()-before)
}
// TestSessionRefreshIsClosed: сессия не продлевает саму себя.
//
// При живом продлении срок её жизни ничего не значит, а вместе с ним перестаёт
// работать единственный канал, которым отзыв доступа у провайдера доходит до
// сервиса.
func TestSessionRefreshIsClosed(t *testing.T) {
env := setupLoginEnv(t)
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusFound, w.Code)
var session *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == SessionCookieName {
session = c
}
}
require.NotNil(t, session)
refresh := httptest.NewRequest(http.MethodPost, RefreshPath, nil)
refresh.Header.Set("Authorization", session.Value)
refreshResponse := httptest.NewRecorder()
env.mux.ServeHTTP(refreshResponse, refresh)
assert.Equal(t, http.StatusNotFound, refreshResponse.Code,
"сессия продлилась: %s", refreshResponse.Body.String())
// И нового значения в ответе нет — продлевать нечем.
assert.NotContains(t, refreshResponse.Body.String(), `"token"`)
}
// TestCallbackRejectsProviderFailure: отказ обмена не открывает сессию.
func TestCallbackRejectsProviderFailure(t *testing.T) {
env := setupLoginEnv(t)
env.provider.failToken = true
cookie, state := env.startLogin(t)
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
for _, c := range w.Result().Cookies() {
assert.NotEqual(t, SessionCookieName, c.Name, "сессия открыта на отказе обмена")
}
accounts, err := env.app.FindAllRecords("users")
require.NoError(t, err)
assert.Empty(t, accounts)
}
// TestRecordFileNeedsSessionAndToken: файл записи отдаётся вошедшему и не
// отдаётся анониму.
func TestRecordFileNeedsSessionAndToken(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
require.Equal(t, http.StatusCreated, created.Code)
files, err := env.app.FindAllRecords(migrations.FilesCollection)
require.NoError(t, err)
require.Len(t, files, 1)
names := files[0].GetStringSlice("file")
require.Len(t, names, 1)
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
// Аноним не проходит.
anonymous := httptest.NewRecorder()
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
assert.GreaterOrEqual(t, anonymous.Code, http.StatusBadRequest)
assert.NotContains(t, anonymous.Body.String(), "audio content")
// Вошедший берёт короткоживущий токен файла и проходит по ссылке с ним:
// защищённый файл судится этим токеном, а не сессионной кукой.
tokenRequest := httptest.NewRequest(http.MethodPost, "/api/files/token", nil)
tokenRequest.Header.Set("Authorization", env.session)
tokenResponse := httptest.NewRecorder()
env.mux.ServeHTTP(tokenResponse, tokenRequest)
require.Equal(t, http.StatusOK, tokenResponse.Code, "токен файла не выдан: %s", tokenResponse.Body.String())
var payload struct {
Token string `json:"token"`
}
require.NoError(t, json.Unmarshal(tokenResponse.Body.Bytes(), &payload))
require.NotEmpty(t, payload.Token)
withToken := httptest.NewRecorder()
env.mux.ServeHTTP(withToken, httptest.NewRequest(http.MethodGet, link+"?token="+payload.Token, nil))
assert.Equal(t, http.StatusOK, withToken.Code,
"вошедший не получил файл: %d", withToken.Code)
assert.Contains(t, withToken.Body.String(), "audio content")
}
+72
View File
@@ -0,0 +1,72 @@
package http
import (
"net/http"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
)
// RefreshPath — адрес хранилища, которым сессия продлевает саму себя.
const RefreshPath = "/api/collections/users/auth-refresh"
// BlockSessionRefresh закрывает продление сессии.
//
// Хранилище выдаёт сессию продлеваемой: предъявитель значения меняет его на
// новое, с новым сроком, и делает это сколько угодно раз, никуда не входя. При
// живом продлении срок жизни сессии перестаёт что-либо значить, а вместе с ним
// перестаёт работать единственный канал, которым отзыв доступа у провайдера
// доходит до сервиса, — сервис после входа к провайдеру не обращается.
//
// Решение владельца от 2026-08-12: продление выключено, цена — вход раз в
// семь суток.
func BlockSessionRefresh() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberBlockSessionRefresh",
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 2,
Func: func(e *core.RequestEvent) error {
if e.Request.URL.Path == RefreshPath {
return e.JSON(http.StatusNotFound, map[string]string{
"error": "Продление сессии выключено",
})
}
return e.Next()
},
}
}
// SessionFromCookie перекладывает значение куки сессии в заголовок, которым
// хранилище читает предъявленную сессию.
//
// Куки хранилище не читает вовсе — только заголовок `Authorization`. Браузер же
// сам заголовка не шлёт, а своей страницы со скриптом у сервиса нет, поэтому
// сессия предъявляется кукой, а способ проверки остаётся один.
//
// Предъявленный заголовок побеждает: иначе браузер с сессионной кукой получал
// бы на собственных адресах хранилища не то, что предъявил.
//
// Слой стоит раньше проверки токена: тот идёт с приоритетом
// DefaultLoadAuthTokenMiddlewarePriority и к этому моменту заголовок должен
// быть на месте.
func SessionFromCookie() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberSessionFromCookie",
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority - 1,
Func: func(e *core.RequestEvent) error {
if e.Request.Header.Get("Authorization") != "" {
return e.Next()
}
cookie, err := e.Request.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
return e.Next()
}
e.Request.Header.Set("Authorization", cookie.Value)
return e.Next()
},
}
}
+17 -1
View File
@@ -1,6 +1,7 @@
package http
import (
"context"
"log/slog"
"net/http"
"time"
@@ -44,6 +45,14 @@ type GetTranscribeJobResponse struct {
// сохранены — публичный контракт API объявлен необратимым.
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
api := r.Group("/api")
// Оба адреса уходят за аутентификацию. Слой предъявления стоит перед
// проверкой и действует только здесь: собственная поверхность хранилища под
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
api.Bind(SessionFromCookie())
api.Bind(apis.RequireAuth())
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
// поэтому предел тела равен потолку самой записи.
@@ -63,7 +72,14 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
}
}()
job, err := h.trsService.CreateJobFromApi(file, header.Filename)
// Запись доехала целиком, поэтому задача заводится независимо от того,
// дождётся ли отправитель ответа: на контексте запроса приём терял бы
// полностью загруженную запись от одного обрыва соединения, а забрать
// результат он может и позже — по `GET /status/{id}`. Значения контекста
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
ctx := context.WithoutCancel(e.Request.Context())
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename)
if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
+104 -20
View File
@@ -2,6 +2,7 @@ package http
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -22,6 +23,7 @@ import (
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service"
@@ -38,7 +40,12 @@ type stubMetaViewer struct {
err error
}
func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
func (m *stubMetaViewer) GetInfo(ctx context.Context, _ string) (*contract.AudioInfo, error) {
// Настоящий `ffprobe` заведён с контекстом и по отмене умирает; стаб,
// который контекст игнорирует, сделал бы проверку приёма неспособной упасть.
if err := ctx.Err(); err != nil {
return nil, err
}
if m.err != nil {
return nil, m.err
}
@@ -49,7 +56,7 @@ func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
// этого файла её не зовёт.
type stubConverter struct{}
func (c *stubConverter) Convert(string, string) error { return nil }
func (c *stubConverter) Convert(context.Context, string, string) error { return nil }
// TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
type TestTgSender struct{}
@@ -70,6 +77,50 @@ type testEnv struct {
handler *TranscribeHandler
app core.App
journal *journalBuffer
// session — значение сессии вошедшего. Приём и опрос закрыты за
// аутентификацией, и проверка, судящая их по существу, обязана предъявить
// сессию ровно так же, как это делает браузер.
session string
// account — учётная запись, которой выдана сессия. Нужна проверкам выхода.
account *core.Record
}
// serve шлёт запрос от имени вошедшего: сессия предъявляется кукой — тем же
// способом, каким её предъявляет браузер. Заголовок проверки не ставят: куку в
// него перекладывает слой предъявления, и подмена его здесь означала бы проверку
// не той цепочки.
//
// Проверки, судящие отказ без сессии, зовут `mux` напрямую.
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
e.mux.ServeHTTP(w, req)
}
// newTestAccount заводит учётную запись и выдаёт ей сессию.
//
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
// запросом больше нельзя — создание закрыто шагом схемы, и в этом весь смысл
// изменения. Прямое сохранение идёт мимо правил доступа так же, как идёт вход,
// когда учётную запись заводит само хранилище.
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
t.Helper()
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
record := core.NewRecord(users)
record.Set("email", "person@example.com")
record.Set("verified", true)
// Случайный пароль ставит и само хранилище, когда заводит запись по входу у
// провайдера: запись auth-коллекции без пароля не сохраняется, а войти по
// нему всё равно нельзя — парольный вход выключен шагом схемы.
record.SetRandomPassword()
require.NoError(t, app.Save(record))
token, err := record.NewAuthToken()
require.NoError(t, err)
return record, token
}
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
@@ -147,7 +198,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
mux, err := r.BuildMux()
require.NoError(t, err)
return &testEnv{mux: mux, handler: handler, app: app, journal: journal}
account, session := newTestAccount(t, app)
return &testEnv{
mux: mux,
handler: handler,
app: app,
journal: journal,
session: session,
account: account,
}
}
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
@@ -179,7 +239,7 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
func storedFileNames(t *testing.T, env *testEnv) []string {
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
records, err := env.app.FindAllRecords(migrations.FilesCollection)
require.NoError(t, err)
var names []string
@@ -191,14 +251,14 @@ func storedFileNames(t *testing.T, env *testEnv) []string {
// countFiles считает записи о файлах.
func countFiles(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
records, err := env.app.FindAllRecords(migrations.FilesCollection)
require.NoError(t, err)
return len(records)
}
// countJobs считает заведённые задачи расшифровки.
func countJobs(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(pbrepo.JobsCollection)
records, err := env.app.FindAllRecords(migrations.JobsCollection)
require.NoError(t, err)
return len(records)
}
@@ -241,7 +301,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
req := createMultipartRequest(t, "sample.m4a", content)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
@@ -304,7 +364,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, tc.req(t))
env.serve(w, tc.req(t))
require.Equal(t, http.StatusBadRequest, w.Code)
@@ -327,7 +387,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
req := createMultipartRequest(t, "empty.m4a", nil)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
@@ -374,7 +434,7 @@ func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
req := createMultipartRequest(t, tc.fileName, []byte("запись"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
@@ -400,7 +460,7 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
@@ -419,7 +479,7 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
@@ -459,7 +519,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
@@ -482,7 +542,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
@@ -507,7 +567,7 @@ func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) {
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
@@ -528,7 +588,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
req := createMultipartRequest(t, "sample.mp3", content)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
@@ -593,7 +653,7 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
@@ -622,7 +682,7 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code)
@@ -642,7 +702,7 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code)
@@ -664,7 +724,7 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
env.serve(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
@@ -673,3 +733,27 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
assert.Equal(t, "Job not found", response["error"])
}
// Отправитель, у которого соединение оборвалось после полной загрузки, задачу
// всё равно получает: запись доехала целиком, а результат он заберёт позже по
// `GET /status/{id}`. Приём на контексте запроса терял бы такую запись молча —
// решение владельца от 2026-08-13.
func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := createMultipartRequest(t, "sample.m4a", []byte("аудио"))
// Так выглядит ушедший отправитель: контекст запроса отменяется сервером,
// когда соединение закрылось.
ctx, cancel := context.WithCancel(req.Context())
cancel()
req = req.WithContext(ctx)
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
require.NoError(t, err)
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
}
+111
View File
@@ -0,0 +1,111 @@
package tg
import (
"io"
"log/slog"
"net/http"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// probeClient подменяет клиента бота и запоминает, кого спрашивали. Через него
// проверяется стык: скачивание обязано идти клиентом бота, а не общим
// `http.DefaultClient` — чистку отказа от адреса с токеном несёт именно клиент
// (`internal/adapter/telegram`). Подмена на общий клиент правил гейта не
// нарушает, поэтому сторожить стык может только проверка.
type probeClient struct {
seen []string
download func(w *probeResponse)
}
type probeResponse struct {
status int
body string
}
func (c *probeClient) Do(req *http.Request) (*http.Response, error) {
c.seen = append(c.seen, req.URL.Path)
switch {
case strings.Contains(req.URL.Path, "/getMe"):
return jsonResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"probe_bot"}}`), nil
case strings.Contains(req.URL.Path, "/getFile"):
return jsonResponse(`{"ok":true,"result":{"file_id":"x","file_path":"voice/file_1.ogg"}}`), nil
}
answer := &probeResponse{status: http.StatusOK, body: "аудио"}
if c.download != nil {
c.download(answer)
}
return &http.Response{
StatusCode: answer.status,
Body: io.NopCloser(strings.NewReader(answer.body)),
Header: make(http.Header),
}, nil
}
func jsonResponse(body string) *http.Response {
header := make(http.Header)
header.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}
}
func newProbeController(t *testing.T, client *probeClient) *TelegramController {
t.Helper()
// Клиент подставной, поэтому адрес значения не имеет — важно лишь, что
// библиотека соберёт из него разбираемый URL.
bot, err := tgbotapi.NewBotAPIWithClient(
"7654321:AAHsecretBOTtokenVALUE",
"http://telegram.probe/bot%s/%s",
client,
)
require.NoError(t, err)
return &TelegramController{
bot: bot,
logger: slog.New(slog.DiscardHandler),
}
}
// Скачивание идёт клиентом бота: иначе отказ пойдёт мимо чистки и унесёт токен.
func TestDownloadGoesThroughBotClient(t *testing.T) {
client := &probeClient{}
controller := newProbeController(t, client)
body, name, err := controller.downloadAudioFile(t.Context(), "file-id")
require.NoError(t, err)
t.Cleanup(func() {
if err := body.Close(); err != nil {
t.Errorf("не удалось закрыть тело: %v", err)
}
})
assert.Equal(t, "voice/file_1.ogg", name)
require.Len(t, client.seen, 3, "клиент бота видел все обращения: getMe, getFile и скачивание")
assert.Contains(t, client.seen[2], "voice/file_1.ogg", "скачивание ушло мимо клиента бота")
}
// Отказ выдачи файла — это не запись: тело такого ответа не должно доехать до
// хранилища и умереть на `ffprobe`, уведя диагностику к чужой причине.
func TestDownloadRejectsNonOKStatus(t *testing.T) {
client := &probeClient{download: func(w *probeResponse) {
w.status = http.StatusUnauthorized
w.body = `{"ok":false,"error_code":401,"description":"Unauthorized"}`
}}
controller := newProbeController(t, client)
body, _, err := controller.downloadAudioFile(t.Context(), "file-id")
require.Error(t, err)
assert.Nil(t, body, "тело отказа наружу не отдают")
assert.Contains(t, err.Error(), "401", "код ответа назван — по нему видно, что отказал Telegram")
}
+55 -25
View File
@@ -1,6 +1,8 @@
package tg
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
@@ -8,6 +10,12 @@ import (
"slices"
"strings"
// Транспорт знает адаптер Telegram ровно ради единой точки чистки отказа:
// второй экземпляр той же функции здесь был бы вторым способом делать одно
// и то же, а секрет в журнале — необратим. Направление «транспорт не знает
// адаптера» правилом не держится и уже нарушено HTTP-поверхностью
// (docs/conventions/go-linters.md, «Что остаётся прозой»).
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/service"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@@ -25,25 +33,22 @@ type TelegramController struct {
}
type TelegramConfig struct {
BotToken string
UpdateTimeout int
UserWhiteList []string
}
// NewTelegramController принимает готового клиента, а не токен: клиента заводит
// единая точка `internal/adapter/telegram`, и только её отказ не несёт секрета.
// Токен сюда не приезжает вовсе — значит, и утечь отсюда ему неоткуда.
func NewTelegramController(
config TelegramConfig,
bot *tgbotapi.BotAPI,
transcribeService *service.TranscribeService,
jobRepo contract.TranscriptJobRepository,
logger *slog.Logger,
) (*TelegramController, error) {
botToken := config.BotToken
if botToken == "" {
return nil, &EmptyBotTokenError{}
}
bot, err := tgbotapi.NewBotAPI(botToken)
if err != nil {
return nil, err
if bot == nil {
return nil, errors.New("telegram bot is not created")
}
controller := &TelegramController{
@@ -58,7 +63,11 @@ func NewTelegramController(
return controller, nil
}
func (c *TelegramController) Start() {
// Start принимает контекст жизни процесса и отдаёт его каждому обработчику:
// скачивание записи и разбор её метаданных — работа с внешним собеседником, и
// остановка сервиса обязана до неё доходить. Приём обновлений контекстом не
// правится: его прекращает Stop.
func (c *TelegramController) Start(ctx context.Context) {
c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName)
u := tgbotapi.NewUpdate(0)
@@ -94,11 +103,11 @@ func (c *TelegramController) Start() {
// Handle audio messages and files
if update.Message.Audio != nil {
c.handleAudioMessage(update.Message)
c.handleAudioMessage(ctx, update.Message)
} else if update.Message.Voice != nil {
c.handleVoiceMessage(update.Message)
c.handleVoiceMessage(ctx, update.Message)
} else if update.Message.Document != nil {
c.handleDocumentMessage(update.Message)
c.handleDocumentMessage(ctx, update.Message)
}
}
}
@@ -148,7 +157,7 @@ func (c *TelegramController) handleHelpCommand(message *tgbotapi.Message) {
c.send(msg)
}
func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
func (c *TelegramController) handleAudioMessage(ctx context.Context, message *tgbotapi.Message) {
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
progressMsg.ReplyToMessageID = message.MessageID
@@ -159,7 +168,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(message.Audio.FileID)
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Audio.FileID)
if err != nil {
c.logger.Error("Failed to download audio file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
@@ -169,7 +178,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
@@ -183,7 +192,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
c.send(successMsg)
}
func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
func (c *TelegramController) handleVoiceMessage(ctx context.Context, message *tgbotapi.Message) {
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...")
progressMsg.ReplyToMessageID = message.MessageID
@@ -194,7 +203,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(message.Voice.FileID)
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Voice.FileID)
if err != nil {
c.logger.Error("Failed to download voice file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.")
@@ -204,7 +213,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
@@ -218,7 +227,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
c.send(successMsg)
}
func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
func (c *TelegramController) handleDocumentMessage(ctx context.Context, message *tgbotapi.Message) {
// Проверяем, является ли документ аудиофайлом
if !c.isAudioDocument(message.Document) {
return
@@ -234,7 +243,7 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(message.Document.FileID)
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Document.FileID)
if err != nil {
c.logger.Error("Failed to download document file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
@@ -244,7 +253,7 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
@@ -258,20 +267,41 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
c.send(successMsg)
}
func (c *TelegramController) downloadAudioFile(fileID string) (io.ReadCloser, string, error) {
func (c *TelegramController) downloadAudioFile(ctx context.Context, fileID string) (io.ReadCloser, string, error) {
// Получаем информацию о файле
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
if err != nil {
return nil, "", fmt.Errorf("failed to get file info: %w", err)
}
// Скачиваем файл
// Скачиваем файл. Запрос заводится с контекстом: скачивание шестичасовой
// записи иначе продолжается и после остановки сервиса, а ссылка на файл
// несёт токен бота — держать её живой дольше нужного незачем.
//
// Клиент берётся у бота, а не `http.DefaultClient`: у бота он свой, и его
// отказ уже не несёт адреса (`internal/adapter/telegram`, единая точка).
fileURL := file.Link(c.bot.Token)
resp, err := http.Get(fileURL)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, "", fmt.Errorf("failed to build download request: %w", telegram.WithoutURL(err))
}
resp, err := c.bot.Client.Do(request)
if err != nil {
return nil, "", fmt.Errorf("failed to download file: %w", err)
}
// Отказ выдачи файла — это не запись. Без проверки телом «записи» станет
// JSON вида `{"ok":false,…}`: он доедет до хранилища, ляжет рабочей копией
// и умрёт на `ffprobe`, а отправитель получит жалобу на свой файл вместо
// правды о протухшей ссылке.
if resp.StatusCode != http.StatusOK {
if err := resp.Body.Close(); err != nil {
c.logger.Error("Failed to close download response", "error", err)
}
return nil, "", fmt.Errorf("failed to download file: unexpected status %d", resp.StatusCode)
}
// Получаем имя файла из URL
fileName := file.FilePath
if fileName == "" {
+31 -12
View File
@@ -17,21 +17,31 @@ type Worker interface {
Name() string
}
// pollInterval — пауза между прогонами шага. Полем, а не константой по месту:
// проверке нужен второй прогон, чтобы остановить воркер **после** того, как он
// рассудил об исходе первого. Отменять контекст изнутри шага она не может —
// отменённый контекст теперь и значит «нас остановили».
const pollInterval = time.Second
type CallbackWorker struct {
name string
f func() error
logger *slog.Logger
name string
// Шаг принимает контекст воркера: остановка обязана доходить до чужой
// работы, которую шаг завёл, а не только прерывать цикл между шагами.
f func(ctx context.Context) error
logger *slog.Logger
interval time.Duration
}
func NewCallbackWorker(name string, f func() error, logger *slog.Logger) *CallbackWorker {
func NewCallbackWorker(name string, f func(ctx context.Context) error, logger *slog.Logger) *CallbackWorker {
if logger == nil {
logger = slog.Default()
}
return &CallbackWorker{
name: name,
f: f,
logger: logger,
name: name,
f: f,
logger: logger,
interval: pollInterval,
}
}
@@ -48,25 +58,34 @@ func (w *CallbackWorker) Start(ctx context.Context) {
w.logger.Info("Worker received shutdown signal", "worker", w.Name())
return
default:
err := w.f()
err := w.f(ctx)
// Признак узнаётся по смыслу, а не по точной форме значения:
// приведение типа видело только вершину цепочки и сломалось бы от
// первой же обёртки `%w`, которая в проекте — умолчание.
var noop *contract.NoopJobError
isNoop := errors.As(err, &noop)
if !isNoop {
// Остановка — не отказ шага: контекст отменили мы сами. Считать её
// в метрику и писать владельцу «Worker error» значит красить каждую
// выкладку как поломку — по тому же доводу, по которому не считается
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый процесс
// отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
stopped := err != nil && !isNoop && ctx.Err() != nil
if !isNoop && !stopped {
metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc()
}
if err != nil && !isNoop {
if err != nil && !isNoop && !stopped {
w.logger.Error("Worker error", "worker", w.Name(), "error", err)
}
if stopped {
w.logger.Info("Worker step interrupted by shutdown", "worker", w.Name())
}
// Ждем 1 секунду перед следующей итерацией
// Ждем перед следующей итерацией
select {
case <-ctx.Done():
w.logger.Info("Worker received shutdown signal during sleep", "worker", w.Name())
return
case <-time.After(1 * time.Second):
case <-time.After(w.interval):
// Продолжаем работу
}
}
+70 -14
View File
@@ -40,10 +40,13 @@ func (b *journalBuffer) String() string {
}
// runOnce прогоняет воркер ровно один раз и возвращает журнал этого прогона.
// Цикл воркера бесконечен и спит секунду между прогонами, поэтому контекст
// отменяется сразу после первого вызова работы: ждать второго прогона нечего, а
// секунда сна на проверку — цена ни за что.
func runOnce(t *testing.T, name string, work func() error) string {
//
// Воркер останавливает **второй** прогон, а не первый: отменённый контекст
// теперь и значит «нас остановили», и отмена изнутри первого шага сделала бы
// его исход неотличимым от остановки — проверка судила бы не то, что заявляет.
// Пауза между прогонами на время проверки укорочена до миллисекунды: ждать
// секунду ради второго вызова незачем.
func runOnce(t *testing.T, name string, work func(ctx context.Context) error) string {
t.Helper()
journal := &journalBuffer{}
@@ -54,15 +57,21 @@ func runOnce(t *testing.T, name string, work func() error) string {
var once sync.Once
done := make(chan struct{})
calls := 0
w := NewCallbackWorker(name, func() error {
err := work()
once.Do(func() {
cancel()
close(done)
})
return err
w := NewCallbackWorker(name, func(ctx context.Context) error {
calls++
if calls > 1 {
// Первый прогон уже рассужен: журнал написан, счётчик сдвинут.
once.Do(func() {
cancel()
close(done)
})
return &contract.NoopJobError{State: "stopping"}
}
return work(ctx)
}, logger)
w.interval = time.Millisecond
finished := make(chan struct{})
go func() {
@@ -147,7 +156,7 @@ func TestWrappedNoopIsNotAFailure(t *testing.T) {
before := jobCount(t, name, "false")
beforeErr := jobCount(t, name, "true")
journal := runOnce(t, name, func() error {
journal := runOnce(t, name, func(context.Context) error {
return fmt.Errorf("find and acquire job: %w", &contract.NoopJobError{State: "created"})
})
@@ -171,7 +180,7 @@ func TestFailureIsLoggedAndCounted(t *testing.T) {
before := jobCount(t, name, "true")
journal := runOnce(t, name, func() error {
journal := runOnce(t, name, func(context.Context) error {
return errors.New("database is gone")
})
@@ -191,7 +200,7 @@ func TestSuccessIsCounted(t *testing.T) {
before := jobCount(t, name, "false")
journal := runOnce(t, name, func() error {
journal := runOnce(t, name, func(context.Context) error {
return nil
})
@@ -202,3 +211,50 @@ func TestSuccessIsCounted(t *testing.T) {
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
}
}
// Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой
// развилки каждая выкладка красит журнал владельца отказами и накручивает
// счётчик сбоев, которых не было, — тот же довод, по которому не считается
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
// процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
func TestShutdownIsNotAFailure(t *testing.T) {
const name = "stopped_worker"
beforeErr := jobCount(t, name, "true")
beforeOk := jobCount(t, name, "false")
journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := NewCallbackWorker(name, func(context.Context) error {
// Так выглядит шаг, которого застала остановка.
cancel()
return errors.New("ffmpeg conversion failed: signal: killed")
}, logger)
w.interval = time.Millisecond
finished := make(chan struct{})
go func() {
w.Start(ctx)
close(finished)
}()
select {
case <-finished:
case <-time.After(5 * time.Second):
t.Fatal("воркер не остановился по отмене контекста")
}
if got := journal.String(); strings.Contains(got, "Worker error") {
t.Errorf("остановка записана отказом: журнал %q", got)
}
if got := jobCount(t, name, "true"); got != beforeErr {
t.Errorf("остановка засчитана отказом: было %v, стало %v", beforeErr, got)
}
if got := jobCount(t, name, "false"); got != beforeOk {
t.Errorf("остановка засчитана успешным прогоном: было %v, стало %v", beforeOk, got)
}
}
+5 -3
View File
@@ -2,6 +2,8 @@ package entity
import (
"time"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
type TranscribeJob struct {
@@ -52,13 +54,13 @@ func (j *TranscribeJob) MoveToState(state string) {
// именно отказавшие: иначе задача, прошедшая конвейер целиком, накопила бы
// их поштучно и умерла бы здоровой.
j.Attempts = 0
j.UpdatedAt = time.Now()
j.UpdatedAt = clock.Now()
}
func (j *TranscribeJob) MoveToStateAndDelay(state string, delay *time.Time) {
j.MoveToState(state)
j.DelayTime = delay
j.UpdatedAt = time.Now()
j.UpdatedAt = clock.Now()
}
func (j *TranscribeJob) Done(transcriptionText string) {
@@ -78,7 +80,7 @@ func (j *TranscribeJob) RetryAfter(delay time.Time) {
j.AcquisitionID = nil
j.AcquireTime = nil
j.DelayTime = &delay
j.UpdatedAt = time.Now()
j.UpdatedAt = clock.Now()
}
// Die переводит задачу, исчерпавшую попытки, в состояние «мертва». Число
+1 -2
View File
@@ -3,7 +3,6 @@ package service
import (
"errors"
"fmt"
"io"
"log/slog"
"testing"
"time"
@@ -36,7 +35,7 @@ func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.Transcr
}
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
logger := slog.New(slog.DiscardHandler)
return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
}
+21 -19
View File
@@ -1,6 +1,7 @@
package service
import (
"context"
"errors"
"io"
"log/slog"
@@ -17,6 +18,7 @@ import (
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
@@ -28,19 +30,19 @@ import (
// failingConverter отказывает на каждой попытке.
type failingConverter struct{}
func (c *failingConverter) Convert(string, string) error {
func (c *failingConverter) Convert(context.Context, string, string) error {
return errors.New("конвертация не удалась")
}
type okMetaViewer struct{}
func (m *okMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
func (m *okMetaViewer) GetInfo(context.Context, string) (*contract.AudioInfo, error) {
return &contract.AudioInfo{Seconds: 1}, nil
}
type failingMetaViewer struct{}
func (m *failingMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInfo, error) {
return nil, errors.New("запись не читается")
}
@@ -89,7 +91,7 @@ func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter
converter,
&recognizer.MemoryAudioRecognizer{},
sender,
slog.New(slog.NewTextHandler(io.Discard, nil)),
slog.New(slog.DiscardHandler),
)
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender}
@@ -100,7 +102,7 @@ func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
t.Helper()
chatId := int64(100)
job, err := env.service.CreateJobFromTelegram(strings.NewReader("запись"), "voice.ogg", chatId, 1)
job, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", chatId, 1)
require.NoError(t, err)
return job
}
@@ -110,7 +112,7 @@ func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
t.Helper()
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
require.NoError(t, err)
record.Set("delay_time", "")
require.NoError(t, env.app.Save(record))
@@ -121,7 +123,7 @@ func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) {
t.Helper()
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
require.NoError(t, env.app.Save(record))
@@ -148,7 +150,7 @@ func TestJobDiesAfterAttemptLimit(t *testing.T) {
rotAcquisition(t, env, job.Id)
// Следующий захват видит перебор и хоронит задачу.
err := env.service.FindAndRunConversionJob()
err := env.service.FindAndRunConversionJob(t.Context())
var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
@@ -177,9 +179,9 @@ func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err)
}
require.Error(t, env.service.FindAndRunConversionJob())
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("state", entity.StateCreated)
require.NoError(t, env.app.Save(record))
@@ -202,13 +204,13 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
empty, err := env.fileRepo.CreateRemote("object-key", 1)
require.NoError(t, err)
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("file", empty.Id)
require.NoError(t, env.app.Save(record))
// Первый отказ.
require.Error(t, env.service.FindAndRunConversionJob())
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -219,7 +221,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
// Второй отказ — с той же задачи, пауза снята вручную.
clearDelay(t, env, job.Id)
require.Error(t, env.service.FindAndRunConversionJob())
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
after, err = env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -246,7 +248,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3")
require.Error(t, err, "отказ источника метаданных роняет приём")
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
@@ -262,7 +264,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3")
require.NoError(t, err)
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
@@ -279,7 +281,7 @@ func TestJobNeverPointsToMissingFile(t *testing.T) {
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
// исходную запись, а не на несозданный результат.
require.NoError(t, env.service.FindAndRunConversionJob())
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -297,7 +299,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
content := strings.Repeat("запись ", 1000)
job, err := env.service.CreateJobFromApi(strings.NewReader(content), "sample.mp3")
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3")
require.NoError(t, err)
require.NotNil(t, job.FileID)
@@ -320,7 +322,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
func TestLocalizeGivesReadableCopy(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job, err := env.service.CreateJobFromApi(strings.NewReader("содержимое"), "sample.mp3")
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3")
require.NoError(t, err)
require.NotNil(t, job.FileID)
@@ -354,7 +356,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
// Конвертация отказывает — задача уходит в `failed`, копии убраны.
require.NoError(t, env.service.FindAndRunConversionJob())
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
+14 -13
View File
@@ -1,6 +1,7 @@
package service
import (
"context"
"errors"
"io"
"strings"
@@ -10,7 +11,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
@@ -32,7 +33,7 @@ type scriptedRecognizer struct {
lastObjectKey string
}
func (r *scriptedRecognizer) Recognize(file io.Reader, fileName string) (string, error) {
func (r *scriptedRecognizer) Recognize(_ context.Context, file io.Reader, fileName string) (string, error) {
r.recognizeCalls++
r.lastObjectKey = fileName
if r.recognizeErr != nil {
@@ -45,11 +46,11 @@ func (r *scriptedRecognizer) Recognize(file io.Reader, fileName string) (string,
return "operation-id", nil
}
func (r *scriptedRecognizer) GetRecognitionText(string) (string, error) {
func (r *scriptedRecognizer) GetRecognitionText(context.Context, string) (string, error) {
return r.text, nil
}
func (r *scriptedRecognizer) CheckRecognitionStatus(string) (*entity.RecognitionResult, error) {
func (r *scriptedRecognizer) CheckRecognitionStatus(context.Context, string) (*entity.RecognitionResult, error) {
return r.result, nil
}
@@ -92,7 +93,7 @@ func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) {
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeJob())
require.NoError(t, svc.FindAndRunTranscribeJob(t.Context()))
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
@@ -119,7 +120,7 @@ func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
svc := withRecognizer(env, rec)
require.Error(t, svc.FindAndRunTranscribeJob())
require.Error(t, svc.FindAndRunTranscribeJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -134,7 +135,7 @@ func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognize
t.Helper()
job := convertedJob(t, env)
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob())
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob(t.Context()))
clearDelay(t, env, job.Id)
return job
@@ -150,7 +151,7 @@ func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
svc := withRecognizer(env, rec)
for i := 0; i < 3; i++ {
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -174,7 +175,7 @@ func TestCheckJobFailsJobAndTellsSender(t *testing.T) {
rec.result = entity.NewFailedResult("операция отклонена")
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -196,7 +197,7 @@ func TestCheckJobCompletesAndAnswersOnce(t *testing.T) {
rec.text = "расшифровка записи"
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -224,7 +225,7 @@ func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) {
rec.text = ""
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -249,13 +250,13 @@ func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) {
acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour))
require.NoError(t, err)
record, err := env.app.FindRecordById(pocketbase.JobsCollection, job.Id)
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("acquisition_id", "someone-else")
require.NoError(t, env.app.Save(record))
svc := withRecognizer(env, rec)
err = svc.checkTranscribeJob(acquired, "mine")
err = svc.checkTranscribeJob(t.Context(), acquired, "mine")
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost)
+83
View File
@@ -0,0 +1,83 @@
package service
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// killedConverter ведёт себя как настоящий `ffmpeg`, убитый по контексту:
// дожидается отмены и отдаёт отказ, который **не** несёт `context.Canceled`.
// Это не упрощение, а суть проверки: `exec` отдаёт `*exec.ExitError` с текстом
// «signal: killed», и отличить остановку от негодной записи по самой ошибке
// нельзя — только по контексту шага.
type killedConverter struct {
cancel func()
}
func (c *killedConverter) Convert(ctx context.Context, _, _ string) error {
c.cancel()
<-ctx.Done()
return errors.New("ffmpeg conversion failed: signal: killed")
}
// Остановка сервиса посреди конвертации не выносит записи приговора: задача
// остаётся пригодной к повтору, попытку не тратит и отправителю о сбое,
// которого не было, не сообщает. Прежде любой отказ `Convert` уводил задачу в
// терминальное `failed`, откуда её возвращает только владелец правкой в панели.
func TestShutdownDuringConversionKeepsJobRetryable(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
converter := &killedConverter{cancel: cancel}
env := newPipelineEnv(t, &okMetaViewer{}, converter)
job := newTelegramJob(t, env)
err := env.service.FindAndRunConversionJob(ctx)
require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена")
assert.Nil(t, after.AcquisitionID, "захват снят: задачу возьмёт следующий прогон")
assert.Equal(t, 0, after.Attempts, "остановка попытки не тратит")
assert.Nil(t, after.ErrorText, "приговора не выносили")
assert.Empty(t, env.sender.messages, "отправителю о несуществующем сбое не сообщают")
}
// Задача, которую шаг не успел взять, потому что нас уже остановили, остаётся
// нетронутой: захват не случился, попытка не потрачена.
func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env)
ctx, cancel := context.WithCancel(t.Context())
cancel()
err := env.service.FindAndRunConversionJob(ctx)
// Исход «шаг не сделал ничего» — это `NoopJobError`: воркер не пишет о нём
// владельцу и не считает его в метрику.
require.Error(t, err)
var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop)
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State)
assert.Equal(t, 0, after.Attempts, "захвата не было — попытке взяться неоткуда")
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
assert.Empty(t, record.GetString("acquisition_id"))
}
+79 -27
View File
@@ -1,6 +1,7 @@
package service
import (
"context"
"errors"
"fmt"
"io"
@@ -14,6 +15,8 @@ import (
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/metrics"
"github.com/google/uuid"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
const (
@@ -73,7 +76,7 @@ func NewTranscribeService(
}
}
func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) {
func (s *TranscribeService) CreateJobFromTelegram(ctx context.Context, file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) {
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceTelegram,
@@ -81,19 +84,19 @@ func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName strin
TgReplyMessageId: &replyMsgId,
}
return s.createTranscribeJob(job, file, fileName)
return s.createTranscribeJob(ctx, job, file, fileName)
}
func (s *TranscribeService) CreateJobFromApi(file io.Reader, fileName string) (*entity.TranscribeJob, error) {
func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceApi,
}
return s.createTranscribeJob(job, file, fileName)
return s.createTranscribeJob(ctx, job, file, fileName)
}
func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
func (s *TranscribeService) createTranscribeJob(ctx context.Context, job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
// Определяем расширение файла
ext := filepath.Ext(fileName)
if ext == "" {
@@ -120,7 +123,7 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
// строка журнала вместе с идентификатором записи собрала бы её целиком.
s.logger.Info("Creating transcribe job", "file_ext", ext)
info, err := s.metaviewer.GetInfo(work.Path())
info, err := s.metaviewer.GetInfo(ctx, work.Path())
if err != nil {
s.logger.Error("Failed to get file info", "error", err, "file_ext", ext)
return nil, err
@@ -158,28 +161,41 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
return job, nil
}
func (s *TranscribeService) FindAndRunConversionJob() error {
return s.runStep(entity.StateCreated, conversionAcquireTimeout, s.convertJob)
func (s *TranscribeService) FindAndRunConversionJob(ctx context.Context) error {
return s.runStep(ctx, entity.StateCreated, conversionAcquireTimeout, s.convertJob)
}
func (s *TranscribeService) FindAndRunTranscribeJob() error {
return s.runStep(entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob)
func (s *TranscribeService) FindAndRunTranscribeJob(ctx context.Context) error {
return s.runStep(ctx, entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob)
}
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
return s.runStep(entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob)
func (s *TranscribeService) FindAndRunTranscribeCheckJob(ctx context.Context) error {
return s.runStep(ctx, entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob)
}
// runStep забирает задачу и отдаёт её шагу. Отказ шага не оставляет задачу
// захваченной до конца срока: захват снимается, и задача ждёт нарастающую паузу
// — иначе повтор наступал бы через восемь часов, а не через секунду.
func (s *TranscribeService) runStep(state string, expiration time.Duration, step func(job *entity.TranscribeJob, holder string) error) error {
//
// Контекст доходит до шага, а через него — до внешнего собеседника: остановка
// сервиса убивает `ffmpeg` и обрывает запрос к распознаванию. Прерванный шаг
// приговора не выносит: задача остаётся пригодной к повтору, попытки не тратит
// и отправителю о несуществующем сбое не сообщает — исход остановки отличается
// от исхода отказа на каждом шаге.
func (s *TranscribeService) runStep(ctx context.Context, state string, expiration time.Duration, step func(ctx context.Context, job *entity.TranscribeJob, holder string) error) error {
// Нас уже остановили — задачу не забираем: захват стоил бы ей попытки, а
// работы всё равно не будет. Исход «шаг не сделал ничего» — это `NoopJobError`
// по смыслу, и он же не поднимает уровень и не считается в метрику.
if ctx.Err() != nil {
return &contract.NoopJobError{State: state}
}
job, holder, err := s.findJob(state, expiration)
if err != nil {
return err
}
if err := step(job, holder); err != nil {
if err := step(ctx, job, holder); err != nil {
s.scheduleRetry(job, holder, err)
return err
}
@@ -187,7 +203,7 @@ func (s *TranscribeService) runStep(state string, expiration time.Duration, step
return nil
}
func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string) error {
func (s *TranscribeService) convertJob(ctx context.Context, job *entity.TranscribeJob, holder string) error {
s.logger.Info("Starting conversion job", "job_id", job.Id)
if job.FileID == nil {
@@ -224,14 +240,28 @@ func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string)
s.logger.Info("Converting file", "job_id", job.Id, "src_format", srcExt)
// Измеряем время конвертации
startTime := time.Now()
err = s.converter.Convert(src.Path(), dest.Path())
startTime := clock.Start()
err = s.converter.Convert(ctx, src.Path(), dest.Path())
conversionDuration := time.Since(startTime)
// Записываем метрику времени конвертации
metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds())
if err != nil {
// Остановка сервиса — не приговор записи. Убитый по контексту `ffmpeg`
// отдаёт `signal: killed`, и от настоящего отказа конвертации
// (`exit status N`) эта ошибка неотличима ни типом, ни `errors.Is`:
// различает их только контекст шага. Без этой развилки каждый деплой
// хоронил бы конвертируемую запись в `failed` — состояние терминальное,
// и вернуть её оттуда может только владелец правкой в панели, — да ещё
// и сообщал бы отправителю о сбое, которого не было.
if ctxErr := ctx.Err(); ctxErr != nil {
s.logger.Info("File conversion interrupted by shutdown",
"job_id", job.Id,
"duration", conversionDuration)
return fmt.Errorf("conversion interrupted: %w", ctxErr)
}
s.logger.Error("File conversion failed",
"error", err,
"job_id", job.Id,
@@ -274,7 +304,7 @@ func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string)
return nil
}
func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder string) error {
func (s *TranscribeService) transcribeJob(ctx context.Context, job *entity.TranscribeJob, holder string) error {
s.logger.Info("Starting transcribe job", "job_id", job.Id)
if job.FileID == nil {
@@ -302,8 +332,12 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
s.logger.Info("Starting recognition", "job_id", job.Id, "file_id", *job.FileID)
// Запускаем асинхронное распознавание
operationID, err := s.recognizer.Recognize(content, fileRecord.FileName)
operationID, err := s.recognizer.Recognize(ctx, content, fileRecord.FileName)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
s.logger.Info("Recognition interrupted by shutdown", "job_id", job.Id)
return fmt.Errorf("recognition interrupted: %w", ctxErr)
}
s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
return err
}
@@ -321,7 +355,7 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
// Обновляем задачу с ID операции распознавания
job.FileID = &destFileRecord.Id
job.RecognitionOpID = &operationID
delayTime := time.Now().Add(firstCheckDelay)
delayTime := clock.Now().Add(firstCheckDelay)
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
if err := s.jobRepo.Save(job, holder); err != nil {
@@ -333,18 +367,22 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
return nil
}
func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder string) error {
func (s *TranscribeService) checkTranscribeJob(ctx context.Context, job *entity.TranscribeJob, holder string) error {
if job.RecognitionOpID == nil {
s.logger.Error("Recognition operation ID not found", "job_id", job.Id)
return fmt.Errorf("recogniton opId not found for job: %s", job.Id)
return fmt.Errorf("recognition opId not found for job: %s", job.Id)
}
opId := *job.RecognitionOpID
// Проверяем статус операции
s.logger.Info("Checking operation status", "job_id", job.Id, "operation_id", opId)
recResult, err := s.recognizer.CheckRecognitionStatus(opId)
recResult, err := s.recognizer.CheckRecognitionStatus(ctx, opId)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
s.logger.Info("Status check interrupted by shutdown", "job_id", job.Id)
return fmt.Errorf("status check interrupted: %w", ctxErr)
}
s.logger.Error("Failed to check recognition status", "error", err, "operation_id", opId)
return err
}
@@ -354,7 +392,7 @@ func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder
// здесь своя, числом, а число попыток обнуляется переходом: ожидание
// чужой операции попытку не тратит.
s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId)
delayTime := time.Now().Add(nextCheckDelay)
delayTime := clock.Now().Add(nextCheckDelay)
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
if err := s.jobRepo.Save(job, holder); err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
@@ -373,8 +411,12 @@ func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder
}
// Операция завершена, получаем результат
transcriptionText, err := s.recognizer.GetRecognitionText(opId)
transcriptionText, err := s.recognizer.GetRecognitionText(ctx, opId)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
s.logger.Info("Text fetch interrupted by shutdown", "job_id", job.Id)
return fmt.Errorf("text fetch interrupted: %w", ctxErr)
}
s.logger.Error("Failed to get recognition text", "error", err, "operation_id", opId)
return err
}
@@ -397,7 +439,7 @@ func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder
// переводят в «мертва» и сообщают об этом отправителю.
func (s *TranscribeService) findJob(state string, expiration time.Duration) (*entity.TranscribeJob, string, error) {
acquisitionId := uuid.NewString()
rottingTime := time.Now().Add(-1 * expiration)
rottingTime := clock.Now().Add(-1 * expiration)
job, err := s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime)
if err != nil {
@@ -448,7 +490,17 @@ func (s *TranscribeService) scheduleRetry(job *entity.TranscribeJob, holder stri
return
}
job.RetryAfter(time.Now().Add(retryDelay(job.Attempts)))
// Остановка попытки не тратит: задача не виновата в том, что нас
// перезапустили. Счётчик растёт при захвате, поэтому здесь его возвращают
// назад — иначе пять выкладок подряд уводят живую запись в «мертва» с
// приговором «попытки исчерпаны».
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) {
if job.Attempts > 0 {
job.Attempts--
}
}
job.RetryAfter(clock.Now().Add(retryDelay(job.Attempts)))
if err := s.jobRepo.Save(job, holder); err != nil {
var lostOnSave *contract.LostAcquisitionError
+30
View File
@@ -1,11 +1,41 @@
# Refer for explanation to following link:
# https://lefthook.dev/configuration/
#
# Предкоммитные проверки — дешёвая часть гейта на **затронутых файлах**. Полный
# набор здесь не гоняется намеренно: он идёт минуты, а pre-commit обязан быть
# быстрым. Что ловит pre-commit и что остаётся только гейту — CLAUDE.md,
# раздел «Гейт»; перечень правил и их дома — docs/conventions/go-linters.md.
templates:
av-hooks-dir: "/home/av/projects/private/git-hooks"
pre-commit:
jobs:
# Форматирование правится на месте и добавляется в коммит: спорить тут не о
# чем, а гейт на неотформатированном файле краснеет.
- name: "gofmt"
glob: "*.go"
run: "gofmt -w {staged_files}"
stage_fixed: true
# Линтеры гоняются по пакетам затронутых файлов, а не по всему дереву:
# golangci-lint принимает файлы только из одного каталога, поэтому на вход
# идут каталоги.
- name: "golangci-lint"
glob: "*.go"
run: |
dirs=$(printf '%s\n' {staged_files} | xargs -r -n1 dirname | sort -u)
golangci-lint run $dirs
- name: "shellcheck"
glob: "*.sh"
run: "shellcheck {staged_files}"
# Подавления те же, что у шага гейта, и по тем же причинам — Taskfile.yml,
# задача `dockerfile`.
- name: "hadolint"
glob: "Dockerfile"
run: "hadolint --ignore DL3007 --ignore DL3018 {staged_files}"
- name: "gitleaks"
run: "gitleaks git --staged"
+53 -5
View File
@@ -19,6 +19,7 @@ import (
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/config"
"git.vakhrushev.me/av/transcriber/internal/contract"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
@@ -27,6 +28,8 @@ import (
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/prometheus/client_golang/prometheus/promhttp"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
func main() {
@@ -50,6 +53,13 @@ func main() {
logger.Info("Configuration loaded successfully", "config_path", *configPath)
}
// Незаполненный вход роняет старт: подняться с молча выключенным входом
// значит остаться открытым наружу, и узнать об этом было бы неоткуда.
if err := cfg.Auth.Validate(); err != nil {
logger.Error("Unable to start with incomplete login settings", "error", err)
os.Exit(1)
}
// Загружаем переменные окружения из .env файла
if err := godotenv.Load(); err != nil {
logger.Warn("Warning: .env file not found, using system environment variables")
@@ -127,13 +137,13 @@ func main() {
var wg sync.WaitGroup
tgConfig := tgcontroller.TelegramConfig{
BotToken: cfg.Telegram.BotToken,
UpdateTimeout: cfg.Telegram.UpdateTimeout,
UserWhiteList: cfg.Server.UsersWhiteList,
}
// Создаем Telegram бот
tgController, err := tgcontroller.NewTelegramController(tgConfig, transcribeService, jobRepo, logger)
// Клиента бота заводит единая точка: её отказ не несёт токена, тогда как
// отказ `NewBotAPI` несёт — он ходит за `getMe`.
tgController, err := newTelegramController(cfg.Telegram.BotToken, tgConfig, transcribeService, jobRepo, logger)
if err != nil {
logger.Error("Failed to create Telegram controller", "error", err)
// Не останавливаем приложение, если Telegram бот не создан
@@ -143,7 +153,7 @@ func main() {
go func() {
defer wg.Done()
logger.Info("Starting Telegram bot")
tgController.Start()
tgController.Start(ctx)
logger.Info("Telegram bot stopped gracefully")
}()
}
@@ -172,6 +182,12 @@ func main() {
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
// и второму серверу на нём взяться неоткуда.
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
AuthURL: cfg.Auth.AuthURL,
RedirectURL: cfg.Auth.RedirectURL,
ClientID: cfg.Auth.ClientID,
SecureCookie: cfg.Auth.SecureCookie,
}, logger)
// Сервер приезжает каналом, а не общей переменной: хук исполняется в
// горутине сервера, а читает его горутина остановки, и связи «произошло
@@ -188,7 +204,7 @@ func main() {
// `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
// журнале контейнера не видно. Поля — те, что просит конвенция.
se.Router.BindFunc(func(e *core.RequestEvent) error {
start := time.Now()
start := clock.Start()
err := e.Next()
level := slog.LevelInfo
@@ -207,6 +223,20 @@ func main() {
return err
})
// Настройки провайдера приводятся к конфигу при каждом подъёме:
// применённый шаг схемы не переписывается, и секрет, положенный
// однажды шагом, не пережил бы ротации.
if err := pbrepo.ApplyProviderSettings(storage, pbrepo.ProviderSettings{
AuthURL: cfg.Auth.AuthURL,
TokenURL: cfg.Auth.TokenURL,
UserInfoURL: cfg.Auth.UserInfoURL,
ClientID: cfg.Auth.ClientID,
ClientSecret: cfg.Auth.ClientSecret,
}); err != nil {
return fmt.Errorf("failed to apply provider settings: %w", err)
}
authHandler.Register(se.Router)
transcribeHandler.Register(se.Router)
se.Router.GET("/health", func(e *core.RequestEvent) error {
@@ -298,3 +328,21 @@ func main() {
logger.Info("Transcriber service stopped")
}
// newTelegramController собирает бота и транспорт вокруг него. Токен доходит
// до единой точки `internal/adapter/telegram` и дальше не идёт: транспорт его
// не видит вовсе, а отказ, который увидит журнал, адреса с токеном не несёт.
func newTelegramController(
botToken string,
cfg tgcontroller.TelegramConfig,
transcribeService *service.TranscribeService,
jobRepo contract.TranscriptJobRepository,
logger *slog.Logger,
) (*tgcontroller.TelegramController, error) {
bot, err := telegram.NewBot(botToken, logger)
if err != nil {
return nil, err
}
return tgcontroller.NewTelegramController(cfg, bot, transcribeService, jobRepo, logger)
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-12
@@ -0,0 +1,230 @@
## Context
Версия Go названа в проекте четырежды, и сегодня четыре места расходятся:
`go.mod` требует `go 1.25.0`, `Dockerfile` собирает на `golang:1.25-alpine`,
`CLAUDE.md` обещает «Go 1.25», а `README.md` не называет версию вовсе. На машине
разработки стоит `go1.26.5`.
Число 1.25 никем не назначалось: `go mod tidy` поднял требование модуля,
следуя за PocketBase, а образ подтянули следом. Ровно этот же механизм 2026-08-12
породил дефект — требование модуля уехало на 1.25, `Dockerfile` остался на
`golang:1.24-alpine` с `GOTOOLCHAIN=local`, и образ перестал собираться. Восемь
шагов набора проверок и шесть проходов ревью показали зелёное: `go build ./...`
идёт на хостовом Go, а образ не собирает ни один шаг. Случай записан в
`docs/review.md` за 2026-08-12 и там же назван способ починки — сравнение строк
вместо сборки образа.
Ограничения, в которых работаем: набор проверок обязан оставаться дешёвым и
работать без сети и без docker; выкладку это изменение не запускает; сборку
образа в набор проверок не заводим — отказ записан.
## Goals / Non-Goals
**Goals:**
- одно число версии Go во всех четырёх местах;
- шаг набора проверок, который краснеет на расхождении и называет оба числа;
- шаг стоит доли секунды и не зависит ни от docker, ни от сети.
**Non-Goals:**
- сборка образа шагом набора проверок — дорого, отказ записан в
`docs/review.md`;
- проверка того, что объявленная версия вообще существует в реестре образов, —
это требует сети;
- сверка версий прочих инструментов (`golangci-lint`, `task`, `ffmpeg`) — их
расхождение так не ломает, и заводить перечень впрок незачем;
- `shellcheck` шагом набора проверок. Замер: shell-скриптов в гейте один, три
соседних шага — Python в плагинах, линтер к ним неприменим; цена шага — одна
строка плюс подавление ложного `SC1007` на идиому `CDPATH= cd`. Отказ всё равно
осознанный, но цену называем настоящую, а не «их четыре»;
- автоматическая правка разошедшихся мест — шаг набора проверок судит, а не
чинит.
## Decisions
### Решение 1: версия 1.26, а число выбирает человек
Берём 1.26 — она стоит на машине разработки (`go1.26.5`), образ
`golang:1.26-alpine` в реестре есть, последний релиз тоже `go1.26.5`, а
`CGO_ENABLED=0 go build ./...` на ней уже проходит. Требование PocketBase v0.39.10
(`go 1.25.0`) она выполняет.
В `go.mod` пишем `go 1.26.0`, а не `1.26.5`: требование модуля — это нижняя
граница, и привязывать её к патчу значит без нужды отсекать сборку на более
раннем патче той же минорной версии.
**Число называет человек, и нормой это не записано намеренно.** `go mod tidy`
поднимает требование модуля сам, следуя за зависимостью, и подъём, никем не
назначенный, дал сегодняшнее расхождение. Но «выбрал человек» ненаблюдаемо:
директива, поднятая инструментом, и директива, назначенная решением, выглядят
одинаково, а норма, которую нечем уронить, расходится с кодом молча. Поэтому
здесь мотив, а в спеке — то, что проверяется: сборка и тесты на объявленном
числе до мерджа.
**Отвергнуто: остаться на 1.25 и завести только сверку.** Сверка — половина
задачи, и она бы прижилась; но тогда сегодняшнее число остаётся тем, которое
никто не назначал, и первый же `go mod tidy` следующей зависимости повторит
подъём вслепую. Задача собрана из двух половин именно поэтому.
**Отвергнуто: `toolchain` в `go.mod` вместо подъёма `go`.** Директива
`toolchain` заставила бы Go скачивать нужный тулчейн сам, и расхождение с
образом перестало бы ломать сборку. Но она же превращает сборку образа в
сетевую операцию, а сборочный слой качает тулчейн при каждой сборке. Дороже и
менее предсказуемо, чем строка сравнения.
### Решение 2: новая capability `toolchain`
Дельта-спека ложится в новую capability `toolchain` — «каким инструментом и какой
его версии собирается сервис, и что об этом проверяется до выкладки».
**Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и
захват задачи — поведение работающего сервиса. Версия сборщика с ним не меняется
вместе, а правило гранулярности в `openspec/config.yaml` именно про это: «дробить,
когда в одной спеке смешиваются разные заботы».
**Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое
требование — «расхождение роняет набор проверок», — и требование без дома
проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже
один раз жило в трёх документах и во всех трёх было неверным.
**Отвергнуто имя `build`.** Первая редакция называла capability `build`, и на
разметке выяснилось, что читать её нельзя: настройка среды разработчика
запрещает чтение любого каталога с этим именем. Спека, недоступная проходам
ревью, не проверяется ни одним из них, а после архивации осталась бы слепым
пятном насовсем. Имя `toolchain` точнее и по существу: предмет здесь —
инструмент сборки и его версия, а не сборка как процесс.
Признаём натяжение: три существующие capability описывают поведение сервиса для
его потребителей, а `toolchain` описывает поведение инструмента разработки.
Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml`
говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно
как домен он здесь и назван. Если capability так и останется с одним
требованием, дешевле будет переименовать её, чем расщепить
(`RENAMED Requirements`).
### Решение 3: шаг сверяет все четыре места, а не два
Минимум по критерию приёмки — `go.mod` против `Dockerfile`. Берём шире: плюс
`CLAUDE.md` и `README.md`.
Причина прямо из дефекта 2026-08-12: **три места из четырёх говорили одно и то
же, и неверными были именно они.** Пару `go.mod``Dockerfile` парная сверка
тогда поймала бы — та пара как раз разошлась. Чего она не ловит, так это
документа, разошедшегося с **согласованным** кодом: сойдись тогда `go.mod` с
образом на 1.24, и памятка с README продолжали бы врать молча, а гейт оставался
бы зелёным. Сегодня проект ровно в этом состоянии наполовину: `README.md` не
называет версию вовсе, а `CLAUDE.md` проверяется только тем, что кто-то её
прочтёт.
Цена: строку о версии придётся держать в форме, которую находит машина. Это же и
польза — документ, чья строка перестала находиться, краснеет вместо того, чтобы
молча протухнуть.
**Отвергнуто: сверять только `go.mod` и `Dockerfile`.** Дешевле на три строки
скрипта и не ловит половину прошлого дефекта.
**Отвергнуто: сверять ещё и `config.dist.toml`, `docker/entrypoint.sh` и
Ansible-роль в `pet-project-server`.** Версии Go там нет; чужой репозиторий этому
набору проверок недоступен.
### Решение 4: отдельный скрипт в репозитории, а не строка в `Taskfile.yml`
Шаг живёт файлом `scripts/check-go-version.sh`, а `Taskfile.yml` его зовёт.
Сверка четырёх мест — это четыре разных способа достать число (директива
модуля, тег образа, проза памятки, проза README), сравнение и внятное сообщение
со всеми четырьмя. В `Taskfile.yml` это легло бы двадцатью строками shell внутри
YAML, где их не читает ни редактор, ни `shellcheck`, а кавычки экранируются
дважды. Соседние шаги (`docs`, `tasks`, `openspec`) уже зовут скрипты, и эта
форма для набора проверок родная.
Скрипт лежит в репозитории, а не в плагине: он про этот проект, а не про метод
работы.
Оговорка о выигрыше: `shellcheck` шагом набора проверок этим изменением **не**
заводится, и обоснование выше стоит на том, что файл хотя бы **можно** проверить
и прочитать глазами, а не на том, что его кто-то проверяет машиной. Заводить
линтер оболочки — отдельная работа, и она уезжает урожаем.
**Отвергнуто: строка shell прямо в `Taskfile.yml`.** Дешевле на один файл,
дороже при первом же изменении: правка регулярного выражения в YAML-скаляре
ошибается молча.
**Отвергнуто: написать проверку на Go отдельной командой.** Тогда она попадает в
`go build ./...` и `go vet ./...`, а вместе с ней — разбор `Dockerfile` в коде
сервиса. Проверка о проекте не должна ехать в бинарник сервиса.
### Решение 5: форма строки, которую ищет машина
| Место | Что ищем | Правило множественности |
| --- | --- | --- |
| `go.mod` | единственная строка, начинающаяся с `go ` — первые два числа | директива `toolchain` запрещена: она пятое место |
| `Dockerfile` | тег `golang:<мажор>.<минор>[.<патч>][-<база>]`, берём первые два числа | все вхождения `FROM golang:` обязаны давать одно число |
| `CLAUDE.md` | образец `Go <мажор>.<минор>` в разделе `## Стек` | ровно одно вхождение в разделе; вне раздела число не читается |
| `README.md` | образец `Go <мажор>.<минор>` в разделе `## Технологии` | ровно одно вхождение в разделе; вне раздела число не читается |
Патч и база образа из тега отбрасываются: спека объявила их свободными, и
образец обязан это допускать — иначе `golang:1.26.5-alpine` уронил бы набор
проверок на дереве, которое та же спека называет верным.
Правило множественности заведено не впрок: реализация, молча берущая первое
совпадение, судила бы по обновлённой строке и не видела протухшей соседней.
**Рамка у него — раздел, а не файл, и это правка по находке ревью.** Первая
редакция считала вхождения по всему файлу, и на памятке это давало
гарантированный ложный красный: она по устройству ведёт историю закрытых долгов
(«Два прежних долга закрыты и здесь названы»), и первая же правдивая строка о
прошлой версии уронила бы шаг — сообщением, которое толкает чинить не шаг, а
исторический документ. Тем же ловился бы любой пример команды в README. Решение
человека на чекпоинте: считать по разделу стека, за его пределами число не
читать.
Граница раздела при этом определена явно — следующий заголовок того же или более
высокого уровня, и заголовок внутри блока кода за заголовок не считается.
Неопределённая граница давала бы **ложное зелёное**: пример в чужом разделе
открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда
его читать не велено.
Число не нашлось — это отказ с названным местом, а не «нечего сравнивать»:
пропавшая строка иначе выглядела бы как совпадение.
### Решение 6: шаг судит по репозиторию, а не по машине
Число берётся чтением файлов. `go` шаг не зовёт вовсе — ни `go mod edit -json`,
ни `go list -m`, ни `go env`.
Способ это не самый удобный: разбор директивы через `go mod edit -json` короче и
надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой
`GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN` `go`
вправе полезть в сеть за нужной версией — то есть требование «без сети»
перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а не
от коммита, — ровно та подмена, которая держала дефект 2026-08-12 невидимым:
`go build ./...` шёл на хостовом Go и потому был зелёным, пока образ не
собирался.
Отсюда же и словарь кодов выхода: 0 сошлось, 1 расхождение, 2 ошибка
употребления, 3 окружение. Свой словарь заводить нельзя — раздел «Гейт» в
`CLAUDE.md` объявляет его общим для проверочных шагов, и четвёртый шаг с
собственной семантикой сделал бы это утверждение неверным.
Оболочка — POSIX `sh`, без GNU-only флагов (`grep -P`, `sed -E` с
расширениями, `mapfile`). Пути шаг строит от корня репозитория, а не от текущего
каталога: иначе его исход зависел бы от того, откуда он запущен.
## Risks / Trade-offs
- **Скрипт ищет число прозой документа, и переписанная строка сломает шаг**
сообщение отказа называет место, где число не нашлось, поэтому чинится
однозначно и сразу. Ложное зелёное здесь невозможно по построению: не нашлось
— отказ.
- **Четыре места вместо двух — четыре места, которые надо править при подъёме
версии** → это цена решения 3, и она осознанная: молчаливо врущий документ
дороже одной лишней правки.
- **1.26 может оказаться несовместимой с зависимостью, которую мы ещё не
трогали** → проверяется до мерджа: `task image` собирает образ на объявленной
версии, а `go test ./...` идёт на хостовом go1.26.5. Обе проверки в критериях
приёмки.
- **Шаг проверяет согласованность чисел, но не то, что образ собирается**
осознанный остаток. Собранный образ по-прежнему видит только тот, кто позвал
`task image` руками; сверка ловит класс расхождений, а не все отказы сборки.
@@ -0,0 +1,51 @@
## Why
Сервис собирается тремя разными числами версии Go сразу: модуль требует одно,
сборочный образ берёт другое, памятка обещает третье, а README не называет
никакого. Расхождение этих чисел никто не проверяет, и один раз оно уже уехало в
работу: 2026-08-12 образ не собирался вовсе, а полный набор проверок и шесть
проходов ревью показали зелёное. Поймали случайно, руками. Пока сравнения нет,
то же самое повторится на следующем подъёме версии — а замечено будет в момент
выкладки, когда чинить дороже всего.
## What Changes
- Версия Go, на которой собирается сервис, поднимается до 1.26 и называется
**одним и тем же числом** в четырёх местах: требование модуля, сборочный
образ, памятка разработчику и README. Сегодня README не называет его вовсе —
строка заводится.
- В набор проверок добавляется шаг, который сравнивает объявленные числа между
собой и краснеет, называя все четыре места и число каждого. Шаг сравнивает
строки: он не собирает образ, не ходит в сеть, не требует docker и не
спрашивает, что за Go установлен на машине, — судит он по тому, что лежит в
репозитории.
- Собирать образ проверкой мы по-прежнему **не** будем — это дорого, и отказ
осознанный. Сравнение строк ловит тот же класс расхождений за доли секунды.
- Разница в третьем числе версии между модулем и образом остаётся законной:
сравниваются только первые два.
## Capabilities
### New Capabilities
- `toolchain`: каким инструментом и какой его версии собирается сервис, и что об
этом проверяется до выкладки. Первое требование capability — согласованность
объявленной версии инструмента сборки и её проверка набором проверок.
### Modified Capabilities
Нет. Поведение сервиса для его потребителей не меняется: запись принимается,
расшифровывается и хранится ровно как прежде.
## Impact
- требование версии в `go.mod`;
- сборочный слой `Dockerfile`;
- строка о версии в `CLAUDE.md` и в `README.md`;
- набор шагов `task gate` в `Taskfile.yml` и описание семантики набора проверок
в `CLAUDE.md`;
- запись журнала дефектов `docs/review.md` за 2026-08-12 получает починку,
которую обещала.
Выкладка этим изменением не запускается. Внешних зависимостей изменение не
трогает: PocketBase требует не ниже 1.25, и 1.26 это требование выполняет.
@@ -0,0 +1,620 @@
# Ревью кода: go-1-26-upgrade — триаж
База диффа: `HEAD` (коммит `aa20b22`), изменение целиком в рабочем дереве. Дата
прогона: 2026-08-12.
## Сводка
**Размер, сложность, метка.** Размер — среднее: `tasks.md` 20 шагов в 4 разделах,
8 файлов; `internal/` не тронут ни строкой. Сложность — знакомое: все узлы
названы поимённо до работы, шагов формы «разобраться/выяснить» нет. **Метка
`medium`** (максимум по осям), **режим — по графу**. Триггеры `docs/review.md`
проверены все три группы, ни один пункт не совпал.
**Состояние гейта: ЗЕЛЁНЫЙ.** Подтверждено собственным прогоном триажа, а не
только отчётом прохода: `task gate BASE=HEAD` → exit 0. Все девять шагов зелёные,
включая новый `go-version`. Одно унаследованное замечание `tasks.py`
(`any-audio-source`: цель без задач и без тега `decomposed`) шаг не роняет и к
диффу отношения не имеет.
### План разметки задачи с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/specs/` + дельта `specs/toolchain/spec.md` | разбор | specs | **закрыта**, 4 находки (S1–S4) + 2 блока наблюдений |
| autotests | `CLAUDE.md`, раздел «Гейт» | — | autotests | **закрыта**, 3 находки (A1–A3) + отчёт гейта |
| conventions | `docs/conventions/` | разбор | code | **закрыта**, 3 находки (C1–C3) + 3 наблюдения ниже порога |
| architecture | `docs/architecture.md` + источник `docs/passport.md` | разбор | basics | **закрыта**, 1 находка (B1) + 4 пункта «дешевле переделать» |
| security | `docs/security.md` | разбор | basics | **закрыта**, находок нет: тема неприменима к диффу целиком |
| operations | `docs/architecture.md` §Эксплуатация + источник `docs/database.md` | разбор | basics | **закрыта**, 1 находка (B3); 4 вопроса из 6 неприменимы |
**Тем без отчёта нет.** Все шесть тем ядра вернули отчёты; своих тем сверх ядра у
проекта нет. Ни одна тема не осталась непроверенной по причине «проход не
запускался».
**Находок на входе:** 13 нумерованных (A1A3, S1S4, C1C3, B1B3) плюс 15
ненумерованных содержательных пунктов (3 «поведение вне спеки», 5 «границы
спеки», 3 наблюдения ниже порога, 4 «дешевле переделать до мерджа») = **28
позиций**. **Осталось в основных секциях: 6** — 2 блокирующие и 4 «стоит
исправить сейчас». Слито по причине 4 пары, понижено до гипотез 3, уехало в
promote 5, выброшено 6 (все названы поимённо).
### Сигнал о заниженной метке
**Сигнал подан двумя проходами независимо — `code` и `basics`.** Оба назвали одно
и то же: изменение вводит новое понятие (capability `toolchain` — первая, что
описывает не поведение сервиса, а инструмент разработки), новый каталог верхнего
уровня `scripts/` и новый шаг гейта; `design.md` сам записывает нерешённое
натяжение в размещении capability. Оба сказали, что метка `large` дала бы
отдельный проход `review-architecture`, и оба отказались решать за конвейер.
**Провенанс один, приоритет два.** Согласие двух проходов — это одна модель,
высказавшаяся дважды: `confidence` оно не повышает, приоритет повышает. Одна
строка с двумя провенансами, а не два пункта.
**Суждение триажа — факт для человека, не команда конвейеру:**
1. **По записанному правилу разметка верна.** `docs/review.md`, «Триггеры метки»:
в группе «Крупное здесь» ближайший пункт — «каркас приложения: сборка
фронтенда, раздача статики и шаг гейта разом» — требует трёх вещей сразу,
здесь только шаг гейта. В «Незнакомое здесь» не совпал ни один из шести: форма
решения (сравнение строк `sed`/`awk`, без `go` и без docker) была названа до
работы. Отрицательный тест пройден: миграции, формата файла, контракта API и
имени ключа конфига изменение не трогает. `review-scope` не ошибся против
правила, которое у него было.
2. **Ось, на которую указали проходы, в правиле отсутствует.** Ни один триггер не
говорит о заведении новой capability и о новом каталоге верхнего уровня. А
именно эта ось дала **обе блокирующие находки прогона** — обе про канон, а не
про код. Сигнал верен по существу: правило разметки не видит того, что в этом
изменении оказалось самым дорогим.
3. **Перезапуска это не требует, метку задним числом не пересматривают.** Тема
`architecture` дома не лишилась и без отчёта не осталась — её закрыл `basics`
на глубине «разбор». Честный остаток: тему смотрел проход широкого профиля, а
не специализированный, и вопрос «правильно ли выбрано имя и дом capability»
остался без независимого разбора (H-1).
4. **Что с этим делать — не здесь.** Кандидат в правило вынесен в promote (P-5).
---
## 1. Блокирует мердж (2 из 3)
### B-1. Требование «ровно одно вхождение» станет нормой в форме, которая про `Dockerfile` уже неверна, а на `CLAUDE.md` уронит гейт на правдивой строке
- Файл: `openspec/changes/go-1-26-upgrade/specs/toolchain/spec.md:16-18` против
`scripts/check-go-version.sh:127-131` и `:147-153`; правильные слова уже лежат в
`openspec/changes/go-1-26-upgrade/design.md:158-163`
- Severity: **major** | Confidence: **high**
- Действие: **развилка**
- Найдено проходами: `specs` (S1 и пункт «Границы спеки»), `basics` (пункт «Рамка
правила „ровно одно вхождение“»). **Слито триажем по причине:** причина одна —
требование написано пофайлово единым правилом, а четыре места устроены
по-разному. Правится одним абзацем.
- **Оракул — три прогона на копии дерева** (копии в scratchpad; рабочее дерево не
тронуто, `git status --porcelain` до и после совпадает):
1. второй сборочный слой `FROM docker.io/library/golang:1.26-alpine AS second`
**exit 0**. Норма гласит: «Каждое место MUST называть версию ровно один раз.
Второе вхождение числа в том же месте MUST считаться отказом», и перечень
мест включает `Dockerfile`. Код нормы не исполняет и исполнять не должен:
`collect Dockerfile "$(read_dockerfile)" 0` передаёт `strict=0` намеренно.
Контроль: тот же второй слой с `golang:1.25-alpine` → exit 1, «Dockerfile
называет несколько разных версий» — то есть совпадение слоёв проверяется,
единственность нет;
2. в `CLAUDE.md` дописана правдивая строка `- прежде собирались на Go 1.25; долг
закрыт задачей go-1-26-upgrade` → **exit 1**, «CLAUDE.md называет несколько
разных версий»;
3. в `README.md` дописан блок кода с `# нужен Go 1.26`**exit 1**, «README.md
называет версию больше одного раза».
- Последствие. **Со стороны `Dockerfile`** — молчаливое расхождение нормы и кода:
после архивации нормой станет спека, а не `design.md`. Многослойная сборка с
двумя `FROM golang:` — законная форма. Ревьюер следующего изменения увидит
`strict=0`, прочтёт MUST и «починит» скрипт, уронив гейт на рабочем
`Dockerfile`; обратный исход не лучше — норма останется ложью, на которую
сошлются. **Со стороны документов** — гарантированный ложный красный:
`CLAUDE.md` по устройству ведёт историю (раздел «Гейт» прямо говорит «Два
прежних долга закрыты и здесь названы»), и первая же правдивая запись о прошлой
версии роняет шаг. По правилу проекта «Что считается сломанным — новый красный
шаг гейта… чинится прежде любой другой работы» это остановит работу, а
сообщение «называет несколько разных версий» толкает чинить не скрипт, а
исторический документ, то есть подделывать запись. `README.md` ловится тем же на
любом блоке кода с командой установки.
- Почему до мерджа: спека замерзает архивацией, после неё правка MUST — отдельное
изменение. Сегодня это один абзац.
- **Вопрос человеку:**
- **Вариант А (дешёвый, ожидаемый).** Развести правило по местам прямо в
требовании, дословно как уже написано в `design.md:158-163`: единственности
требовать от `go.mod`, `CLAUDE.md` и `README.md`, а от `Dockerfile`
совпадения всех вхождений `FROM golang:`. Плюс сузить рамку для документов:
правило считает не файл целиком, а помеченную строку стека (или раздел
«Стек»/«Технологии»). Цена: абзац спеки + `read_doc` начинает читать раздел, а
не файл — несколько строк скрипта. Сценарий «Одно место называет два разных
числа» остаётся верным и правки не требует.
- **Вариант Б (дешевле сейчас, дороже потом).** Развести только `Dockerfile`
(правка чисто текстовая, кода не трогает), а цену «файл целиком» для
документов принять осознанно и записать остатком в спеке: «`CLAUDE.md` не
ведёт истории версий Go; запись о прошлой версии живёт в `docs/review.md`».
Тогда красный на истории — не сюрприз, а объявленный запрет.
### B-2. Канонический перечень capability назовёт три из четырёх ровно в момент архивации, и промолчит именно о новой
- Файл: `docs/architecture.md:11-26`; `openspec/changes/go-1-26-upgrade/tasks.md`,
раздел «3. Документы» (в нём `docs/architecture.md` нет)
- Severity: **minor** | Confidence: **high**
- Действие: **инлайн**
- Найдено проходами: `specs` (S3), `basics` (B1). **Дубль по причине, слит;**
предложение взято более широкое, от `basics`.
- Оракул — поимённые положения, все перепроверены триажем:
- `openspec/config.yaml:25-26` дословно: «Состояние спек и правило „первая
задача, трогающая поведение, заводит спеку своей capability“ —
docs/architecture.md, преамбула». Дом назначен, и он один;
- `docs/architecture.md:11` дословно: «Заведены три capability:»; `proposal.md`
заводит четвёртую;
- прецедент: прошлое изменение правило этот список **тем же коммитом**, что и
реализацию — `git show 01cc31d -- docs/architecture.md` даёт `-Заведены две
capability, и каждая описана частично:` / `+Заведены три capability:`;
- `tasks.md`, раздел 3, содержит ровно два пункта — `CLAUDE.md` и
`docs/review.md`; обзора архитектуры в нём нет;
- машинного оракула нет и быть не может: `CLAUDE.md`, «Гейт» — «согласованность
документов между собой и с кодом — её судят агенты, зовёт их скилл
`av-dev-docs:healthcheck`, и звать его надо руками».
- Последствие. После архивации в `openspec/specs/` появится четвёртая capability,
о которой единственный назначенный обзор молчит. Следующий, кто возьмётся за
версию инструмента сборки, пойдёт по указанному дому, четвёртой строки не найдёт
и либо заведёт вторую спеку на ту же тему, либо припишет требование в `pipeline`
— ровно то, от чего `design.md:79-82` отказался. Отказ молчаливый: гейт этого
класса не ловит по устройству.
- Предложение (инлайн, три правки):
1. четвёртая строка перечня в `docs/architecture.md` — про `toolchain`, со
ссылкой на спеку и задачу-источник;
2. оговорка к преамбуле: сегодня она читается «поведение системы здесь не
описывается — нормативно оно живёт в `openspec/specs/`», а `toolchain`
описывает **не** поведение сервиса; без оговорки преамбула становится
неверной в момент архивации;
3. пункт в `tasks.md`, раздел «3. Документы», чтобы правка не потерялась.
**Третий слот блокирующих не занят** — кандидатов нет: всё прочее либо не
замерзает мерджем, либо не имеет оракула.
---
## 2. Стоит исправить сейчас (4 из 4)
### N-1. Единственный новый страж проекта не покрыт ничем: следующая правка его регулярных выражений перестанет ловить случай молча
- Файл: `scripts/check-go-version.sh` (весь); `tasks.md:96-111`
- Severity: **major** | Confidence: **high**
- Действие: **развилка** (новая работа, за границей объявленного scope)
- Найдено проходами: `autotests` (A1), `specs` (S4). **Дубль по причине, слит.**
- Оракул (перепроверено триажем):
- `find . -iname '*check-go-version*'` → ровно один файл, сам скрипт; тестов нет;
- `grep -rln 'check-go-version' --include='*_test.go' --include='*.bats'
--include='*test*.sh' .` → пусто;
- `task gate` прогоняет скрипт ровно на согласованном дереве, то есть проверяет
один сценарий дельты из четырнадцати — «Версии совпадают»;
- раздел «4. Проверка» в `tasks.md` перечисляет 11 сценариев, все `[x]`, но ни
один не зафиксирован ничем, кроме прозы: это разовый ручной прогон, а не
оракул;
- положение проекта, которое здесь нарушено, записано: `docs/review.md»,
«Типовые узлы» → «Любой узел»: «изменённое место покрыто хоть одним
**проходящим** тестом».
- Последствие. POSIX-sh с разбором четырёх разных форм через `sed`/`awk` — класс
кода, где правка одного образца ломает смежный случай беззвучно. `go vet`,
`golangci-lint` и `gofmt` shell не видят; `shellcheck` в гейт сознательно не
введён. Правка третьего аргумента `collect` или образца `read_doc` снимает
проверку молча, и заметят это на следующем подъёме версии — примерно через год,
и ровно тем способом, каким был найден дефект 2026-08-12: образ перестал
собираться, и этого не увидел никто. Класс — «молчание»: страж перестаёт
стеречь, не сообщая об этом.
- **Вопрос человеку:**
- **А. Сейчас, в этом изменении.** Тест-скрипт рядом
(`scripts/check-go-version.test.sh`) и отдельный шаг гейта: десяток
мутационных прогонов на временной копии дерева — по одному на сценарий дельты.
Цена ~100 строк shell плюс шаг Taskfile. Плюс: страж проверен ровно тем
способом, каким `docs/review.md` велит проверять оракулы.
- **Б. Задачей урожая, вместе с `shellcheck` (P-1).** Один шаг гейта, гоняющий и
линтер оболочки, и мутационные прогоны. Плюс: не раздувает изменение, scope
остаётся заявленным. Минус: между мерджем и задачей страж не проверен ничем, и
правки в этот промежуток пройдут вслепую.
- **В. Принять остаток осознанно** и записать строкой в `docs/review.md`,
«Недоступно проверке» → «Перестали проверять сознательно», с ценой. Плюс:
честно и бесплатно. Минус: следующий промах этого класса будет уже вторым.
### N-2. Новое машинное правило не попало в единственный индекс механизированного, и следующий проход конвенций будет сверять версии руками
- Файл: `docs/conventions/README.md:50-65`
- Severity: **minor** | Confidence: **medium**
- Действие: **инлайн**
- Найдено проходом: `code` (C3)
- Оракул — поимённое положение конвенций: `docs/conventions/README.md:52-53`
(«Проверяется командами из CLAUDE.md; прозой не дублируется») и `:64-65` («Не
названное здесь место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное»). Строка про `docs.py check` в той же
таблице — прямой прецедент внесения шагов гейта.
- Последствие. Дифф заводит новое машинное правило и не вносит его в индекс,
объявленный исчерпывающим. Следующий проход по конвенциям и следующий человек
будут считать согласованность версий непроверенной и сверять её руками. Заодно
это единственное место в `docs/conventions/`, откуда новый скрипт вообще был бы
виден: сегодня из дома конвенций он не виден никак.
- Предложение: строка в таблицу — `| Одно число версии Go в go.mod, Dockerfile,
CLAUDE.md и README.md | Taskfile.yml → шаг go-version
(scripts/check-go-version.sh) |`.
### N-3. Сломанное окружение шаг объявит расхождением версий и назовёт невиновный файл
- Файл: `scripts/check-go-version.sh:105-119`, `:147-153`
- Severity: **minor** | Confidence: **high** (проход давал `medium`; поднято
прогоном)
- Действие: **инлайн**
- Найдено проходом: `code` (C1)
- **Оракул — два прогона на копии дерева:**
1. `chmod a-r README.md && sh scripts/check-go-version.sh`
```
sed: can't read .../README.md: Permission denied
check-go-version: README.md не называет версию Go
Объявленная версия Go по местам:
go.mod 1.26
Dockerfile 1.26
CLAUDE.md 1.26
README.md версия не названа
exit=1
```
2. прогон с `PATH`, где нет `awk`**exit 127**, код вне словаря вовсе.
Для сравнения, штатные пути проверены и корректны: нет файла места → 3, лишний
аргумент → 2, директива `toolchain` → 1, согласованное дерево → 0.
- Последствие. Значения добываются подстановкой команд в **аргументе** (`collect
README.md "$(read_doc "$readmemd")" 1`), а POSIX теряет код возврата подстановки,
стоящей в аргументе простой команды. Любой отказ чтения — файл есть, но
нечитаем; урезанный `PATH`; сломанный апплет busybox — даёт пустой вход,
`collect` видит `total -eq 0` и печатает утверждение **о содержимом файла** там,
где сломалось окружение. По словарю, который этот же дифф и расширил, 1 значит
«дрейф», 3 — «окружение», 4 — «внутренний сбой»; кода 4 скрипт не возвращает ни
на одном пути.
- **Честная оценка веса.** Ущерб невелик: `sed` печатает свою причину строкой
выше, так что человек у терминала подсказку видит, а `task gate` различает
только ноль и не-ноль. Вероятность низкая — CI у проекта нет, гейт гоняет
владелец на своей машине. Находка остаётся потому, что шаг гейта — источник, на
который смотрят как на истину, и ложное утверждение о конкретном файле из такого
источника дороже своей вероятности.
- Предложение: добывать значения через промежуточную переменную с проверкой кода,
а не в аргументе — четыре места, по одному на источник.
### N-4. Цена отказа от `shellcheck` названа в памятке вчетверо, и вопрос от этого отложится снова
- Файл: `CLAUDE.md:122-123`; то же в `design.md:37-38`
- Severity: **minor** | Confidence: **high**
- Действие: **инлайн**
- Найдено проходом: `code` (C2); сюда же ушла находка `autotests` A3 как замер
цены.
- Оракул — замер, снятый триажем на этом прогоне:
- `find . -path ./.git -prune -o -name '*.sh' -print` → ровно два файла:
`./scripts/check-go-version.sh` и `./docker/entrypoint.sh`; в гейте из них
один. Остальные три шага гейта — `docs.py`, `tasks.py`, `openspec.py` — Python
и лежат в плагинах вне репозитория;
- `shellcheck scripts/check-go-version.sh`**одно** замечание, SC1007 на
`root=$(CDPATH= cd -- ... )`, и оно ложное: `CDPATH= ` — идиома очистки
переменной перед `cd`. Значит заведение линтера стоит одной строки шага плюс
одной директивы подавления.
- Последствие. Строка «`shellcheck` для скриптов гейта — их четыре, и заводить им
линтер надо разом» смешивает «скриптов в гейте четыре» с «`shellcheck` применим
к четырём». Применим он к одному. Следующий прочтёт в памятке цену «надо разом,
четыре штуки» и отложит вопрос снова — при том что нелинтуемым остаётся ровно
тот файл, который проход `code` был вынужден разбирать глазами построчно, а
триаж — прогонять руками.
- Предложение: переписать пункт по факту — «shell-скрипт в гейте один; три
соседних шага — Python в плагинах. Линтер не заведён; цена ему одна строка шага
и одно подавление SC1007». Ту же правку в `design.md`.
---
## 3. Гипотезы без доказательства
### H-1. Имя и дом capability `toolchain` замерзают мерджем (понижено: оракула нет)
Из прохода `basics`, «Дешевле переделать до мерджа». После архивации спека уезжает
в `openspec/specs/` насовсем; `design.md` сам признаёт, что при одном требовании
переименовать дешевле, чем расщепить. Вопрос дома шире: четыре однородных шага
набора проверок живут в двух разных домах — у трёх плагинных дома нет вовсе, у
четвёртого есть нормативная спека, и эта асимметрия становится постоянной.
**Почему понижено.** Оракула нет и построить его нечем: это суждение о
правильности имени, а не о поведении. Плюс два смягчающих факта: стадия ревью
дизайна (`specs` + rubric) прошла до кода и её замечания отработаны — вопрос уже
был на столе; `CLAUDE.md`, «Необратимое», имени capability не перечисляет.
Severity снята.
**Остаток честный:** независимого архитектурного разбора у этого вопроса не было —
на метке `medium` отдельный проход не запускается, тему закрывал `basics` широким
профилем. Это и есть содержание сигнала о заниженной метке.
### H-2. Пересборка того же коммита через месяц даст другой `ffmpeg` (понижено: замера нет, строка не из этого диффа)
Из прохода `basics` (B3). `Dockerfile:26` — рантайм-слой `alpine:latest`, а
`Taskfile.yml:93` собирает с `--pull`: два образа из одного коммита с разницей в
неделю несут разные `ffmpeg`. Регрессия конвертации после такой пересборки
выглядит как задачи в `failed` при пустом диффе репозитория, и откат на прежний
коммит её не чинит. Класс тот же, ради которого написан весь новый шаг:
объявленное и собранное расходятся, и сверять некому.
**Почему понижено.** Замера нет — ни одного числа о том, как часто и насколько
меняется `ffmpeg` в `alpine:latest`; снять на этом прогоне нечем. Строка внесена
не этим изменением, только активирована им. `Confidence: medium`.
### H-3. «Два дома у семантики шага» — проверено и не подтвердилось
Из прохода `basics`. Утверждение: семантика шага описана и в `CLAUDE.md` «Гейт», и
в спеке `toolchain`, а `openspec/config.yaml` предупреждает, что «второй дом факта
расходится с первым молча».
**Проверено триажем:** спека `spec.md:93-96` не пересказывает словарь, а
**ссылается** на него — «раздел „Гейт“ в `CLAUDE.md` объявляет словарь общим». Это
уже правильная форма: один дом факта, вторая точка — ссылка. Последствия не
построено, находкой не выводится.
---
## 4. Promote candidates
- **P-1. `shellcheck` шагом набора проверок.** Цена замерена на этом прогоне:
файлов `.sh` два, в гейте один, единственное сегодняшнее замечание — ложный
SC1007 на идиому `CDPATH= cd`. Шаг стоит одной строки плюс одной директивы
подавления.
- **P-2. Род узла «скрипт набора проверок» в `docs/review.md`, «Типовые узлы».**
Сегодня перечень родов покрывает только рантайм. Скрипт гейта — новый род с
собственными проверяемыми свойствами: отличает «расхождение» от «сломанного
окружения», исход есть функция коммита, покрыт мутационным прогоном. Без этого
рода свойство «изменённое место покрыто хоть одним проходящим тестом» к shell не
приложено ничем, и находка N-1 в следующий раз опять будет добываться с нуля.
- **P-3. Обёртки `Taskfile` отдают 1, когда скрипта нет, а словарь велит 3.** Из
S2/B2, слитых по причине. **Но так делают все четыре обёртки**`docs`,
`tasks`, `openspec` и новая `go-version`: новый шаг лишь повторил существующий
рисунок, и дефектом **этого** диффа это не является. Сам скрипт при отсутствующем
месте выходит корректно — 3 (проверено). Правило, а не правка: привести все
четыре обёртки к 3 разом либо убрать «или файл не найден» из описания кода 3 и
объявить `exit 1` нормой для «скрипта шага нет».
- **P-4. Норму «версия внешнего инструмента объявлена числом и сверяется»
распространить на рантайм-базу образа.** Из H-2. Non-Goal этого изменения
записан; кандидат в отдельную задачу.
- **P-5. Триггер метки: изменение, заводящее новую capability или новый каталог
верхнего уровня.** Из сигнала о заниженной метке. Сегодня «Триггеры метки» видят
только объём и незнакомость формы решения; ось «изменение трогает канон» в них
отсутствует, а на этом прогоне именно она дала обе блокирующие находки.
---
## 5. Границы покрытия
Секция не сокращается. Без неё формулировка «критичных проблем не обнаружено»
запрещена — и здесь она не употребляется.
### План: темы, дома, глубины
Все шесть тем ядра размечены, у всех есть дом, все закрыты — таблица в сводке.
**Тем без дома нет. Тем без отчёта нет. Своих тем сверх ядра проект не
объявляет.** Глубина «разбор» у пяти тем, у `autotests` глубина не назначалась.
### Какие проходы запускались
На метке `medium`, в режиме «по графу», запускались четыре: `autotests`, `specs`,
`code`, `basics`. Стадия ревью дизайна (`specs` + `rubric`) прошла раньше, до
кода, и её замечания отработаны — на этом прогоне она не повторялась.
### Какие проходы не запускались и почему
- **`review-architecture`** — не запускается на метке `medium` по устройству
графа; тема `architecture` отдана `basics`. Именно об этом сигнал двух проходов.
- **Проход независимой реализации** — снят из конвейера по стоимости.
- **Проход про идиоматичность языка** — упразднён.
### Что каждый запущенный проход не мог проверить в принципе
Оговорка о происхождении: **сырые выводы, поданные триажу, несут границы прозой, а
не блоком `Coverage of this pass` из контракта.** Перечень ниже восстановлен по
тому, что проходы написали, а не по их charter'ам, — и может быть неполон. Это
отдельная строка деградации.
- `autotests` — не судит содержание кода; видит зелёное/красное и наличие тестов.
Прогон гейта не собирает образ (намеренно) и не считает покрытие изменённых строк.
- `specs` — судит соответствие кода дельта-спеке и обратно; не судит качество кода
вне нормы и не проверяет, нужна ли норма вообще.
- `code` — темы `conventions` плюс технический разбор; Go-кода дифф не содержит,
поэтому `logging.md`, `errors.md`, `config.md` неприменимы поимённо, и разбор
свёлся к shell, который ни один линтер проекта не видит.
- `basics` — темы `security`, `operations`, `architecture` широким профилем; на
этой метке заменяет специализированные проходы, а не дополняет их.
- **Триаж не находит ничего нового по определению**: работает с чужими выводами и
своими прогонами-оракулами. Пропуск любого прохода — его пропуск тоже.
### Неприменимые темы и вопросы — названы, а не пропущены
- **`security`: тема неприменима к диффу целиком.** `internal/` не тронут ни
строкой. Три вопроса темы из `docs/review.md` адресованы
`internal/service/transcribe.go` и `internal/metrics` — они не менялись, вопросы
остаются открытыми и после этого прогона. Периметр сборки дом объявляет вне
модели: `docs/security.md:233-235`.
- **`operations`: применимы 2 вопроса из 6.** Отказ соседа, повтор и
одновременность, остановка на середине, наблюдаемость, рост объёма —
неприменимы: рантайм не меняется. Три вопроса темы из `docs/review.md` к диффу
неприменимы и остаются открытыми.
- **`autotests`: вопрос темы из `docs/review.md:133-134`** адресован
job-конвейеру в `internal/`, которого дифф не трогает.
- **`conventions`: вопрос темы** («новая колонка правится во всех четырёх местах»)
**колонок изменение не трогает вовсе**; `internal/adapter/repo/pocketbase` не
изменён ни строкой.
### Что осталось целиком на человеке
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах;
- `operations`: реальный профиль нагрузки;
- `security`: стойкость `ffmpeg` к вредоносному входу.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe` — решение и цена в
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
**Своё, для этого изменения:** **собираемость образа на объявленной версии**
требование «Объявленное число — то, на котором проект собирается» прямо оставляет
проверку человеку и запрещает вводить её в набор проверок. Косвенные свидетельства
положительные (`go version``go1.26.5`; гейт зелёный; образ `transcriber:dev` в
наличии), но **самой сборки триаж не запускал** — она объявлена сделанной пунктом
`tasks.md` 4.2.
### Каких документов проекта не хватило
- **`docs/review.md`, «Типовые ложноположительные» — есть и непуст (4 пункта), но
все четыре про рантайм `internal/`.** Дифф его не трогает, поэтому **проектных
ложноположительных для него не существует вовсе**: отсев вкусовщины шёл по общим
критериям устава, без проектного входа.
- **`CLAUDE.md`, «Инварианты» — раздел есть, 9 пунктов, и ни один не применим к
диффу.** Следствие названо прямо: **ни одна находка этого прогона не поднята до
`critical` по основанию «нарушен инвариант проекта» — сослаться не на что.**
Ранжирование велось по обратимости, выведенной из механики openspec, и это
**предположение триажа**, а не записанное правило проекта.
- **`CLAUDE.md`, «Необратимое» — имени capability, состава `openspec/specs/` и
раскладки `scripts/` в нём нет.** Поэтому «спека замерзает мерджем» — вывод из
механики openspec, а не проектная норма.
- **`CLAUDE.md`, «Ориентир по размеру порции: не замерялся» — дословно.** Значит
суждение «объём right-size» опирается на оценку триажа, а не на проектное число.
### Сработавшие потолки
- `basics`**сообщил сам**: 3 находки при потолке 4, за срезом ничего.
- `code`**сообщил частично**: «Потолок конвенций 1/4 — срез не сработал». Про
потолок технической половины не сказал ничего.
- `specs`**не сообщил потолок вовсе.** 4 находки. **Это находка о прогоне:**
проход обязан сообщать потолок сам.
- `autotests`**не сообщил потолок вовсе.** 3 находки. То же.
- **Триаж:** 2 из 3 блокирующих, 4 из 4 «стоит исправить сейчас». Секция «сейчас»
заполнена под завязку; ничего не выброшено молча.
### Выброшено — поимённо
1. **A2**: вопрос темы про шаг конвейера — к диффу не относится.
2. **A3**: `shellcheck` SC1007 — ложное срабатывание, действующего последствия нет;
содержание уехало замером в N-4 и P-1.
3. **Порядок шагов в `gate`**: самый дешёвый шаг стоит пятым. Вкусовщина по всем
трём условиям: поведения не меняет, стоимости следующего изменения не меняет,
записанной конвенции о порядке шагов в проекте нет.
4. **`proposal.md`: «Внешних зависимостей изменение не трогает»** против переезда
`smithy-go` из `// indirect` в прямые требования. Версия `v1.27.7` не менялась,
пакет импортируется в `internal/adapter/recognizer/yandex/s3.go:15` — штатный
результат заказанного `go mod tidy`. Прозаическая неточность без последствий.
5. **`CLAUDE.md:114-115`** «краснеет с именем недостающего плагина» — новый шаг
плагином не является. Последствия не построено; чинится вместе с P-3.
6. **`docs/review.md:194-198`**: преамбула журнала не сходится с верхней записью.
Расхождение приехало предыдущим коммитом, вне диффа.
### Четыре строки, которые не принесёт ни один проход
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон его
не открывает. Для этого изменения это особенно весомо: `design.md` ссылается на
решения, но ни один проход не открывал `docs/adr/` и не проверял, не
противоречит ли новая норма уже принятому.
2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже
процессный. Всякое число в этом отчёте снято на этом прогоне и сопровождается
командой замера.
3. **Поимённая сверка с руководствами по стилю языка не задавалась ни одним
проходом.** Для этого изменения дыра шире обычного: основной артефакт —
POSIX-shell, у которого в проекте нет ни конвенции, ни линтера, ни руководства.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Вопрос «а можно ли было решить это принципиально иначе — например, одним
`go.mod` как источником истины и генерацией остальных трёх мест» никто не
задавал.
### Среда прогона
- **Рабочее дерево не тронуто.** Все оракулы добыты на копиях в scratchpad. `git
status --porcelain` до и после прогона совпадает символ в символ.
- **Запреты `CLAUDE.md` соблюдены:** боевой каталог данных не трогался, боевой
токен не запускался, в Yandex Cloud не ходили, выкладка не запускалась,
`testdata` не заводилась, временное — только в scratchpad.
- **Отказов доступа не было.** `deny: Read(./build)` на путях этого изменения не
сработал (capability названа `toolchain` именно поэтому).
- **Ограничение среды:** подагентам запрещено писать файлы отчётов. Отчёт возвращён
триажем текстом и записан сюда оркестратором.
---
# Дополнение: перепроверка после отработки B-1
Записано оркестратором после того, как находки триажа были отработаны. Отчёт без
этого раздела сообщал бы о составе прогона неверно.
## Что было сделано по находкам
| Находка | Действие | Исход |
|---|---|---|
| B-1 | развилка → человеку | Выбран вариант А: правило множественности разведено по местам, рамка в документах сужена до раздела стека. Изменило требование и код |
| B-2 | инлайн | Четвёртая capability и оговорка о её природе — в преамбуле `docs/architecture.md`. Ссылка на спеку поставлена шагом синка: до архивации файла нет и `docs.py check` краснеет битой ссылкой |
| N-1 | развилка → человеку | Выбран вариант Б: задачей урожая, вместе с `shellcheck`. Между мерджем и той задачей страж не проверен ничем — названо остатком |
| N-2 | инлайн | Строка в таблице «Механизировано» `docs/conventions/README.md` |
| N-3 | инлайн | Проверка читаемости места: код 3 и сообщение о нечитаемости вместо «версия не названа» с кодом 1 |
| N-4 | инлайн | Цена отказа от `shellcheck` названа по замеру в `CLAUDE.md` и `design.md`; ложный `SC1007` подавлен в скрипте |
## Второй прогон: только проход `specs`
**Полный прогон ревью кода не повторялся.** Правка по B-1 изменила дельта-спеку и
код, поэтому перепрогнан **целенаправленно один проход**`specs`, владеющий
темой `requirements`, чей дом и изменился. `autotests` заменён собственным
прогоном гейта оркестратором; `code` и `basics` не перезапускались.
**Чем это ограничено, прямо:** технический разбор новых функций `section`,
`has_section` и `collect_doc` независимым проходом **не выполнялся** — их читал
только `specs` в своей оптике (соответствие норме) и оркестратор. Проход `code`
видел прежнюю редакцию скрипта. Триаж второй раз не запускался, поэтому находки
ниже не проходили дедупликации и добычи оракула независимым агентом — оракулы у
них свои, прогонами.
**Разметка не повторялась,** хотя дельта-спека менялась. Причина названа: правка
сузила формулировку одного требования внутри уже размеченной capability, не
меняя ни набора capability, ни периметра узлов, ни списка тем — план тем остался
бы тем же. Это осознанное отступление от правила «дельта-спеки изменились —
повтори разметку», а не пропуск.
## Находки перепроверки — три, все minor, все отработаны
- **S1 закрыта по существу**, а не переформулировкой. Проверено обеими сторонами:
норма больше не требует единственности от сборочного образа, и код ровно это и
делает; правдивая историческая строка о прошлой версии в памятке даёт зелёное,
а второе число внутри раздела стека — красное.
- **F-1.** Таблица `design.md` продолжала велеть «ровно одно вхождение на файл» —
правило, обратное принятой норме, — и переживала бы мердж как единственное
описание того, как машина ищет число. Абзац-мотивировка вдобавок стал
фактически неверен. Переписаны оба.
- **F-2, дороже прочих.** Норма называла начало раздела и молчала о конце.
Раздел закрывался только заголовком того же уровня, а строка, похожая на
заголовок, внутри блока кода читалась как настоящий заголовок. Худший исход —
**ложное зелёное**: если строку версии из раздела убрать, а ниже по файлу
появится заголовок первого уровня и любое «Go 1.26», шаг добрал бы число из
чужого места и промолчал. Граница определена требованием и исполнена кодом;
контрольный прогон подтверждает, что ложное зелёное исчезло — шаг теперь
честно говорит «версия не названа».
- **F-3.** Отличие «сломанного окружения» от «пропавшей строки» держалось только
на коде: ни один сценарий его не требовал, автотеста нет, гейт гоняет скрипт
ровно на согласованном дереве. Записано требованием и сценарием.
## Оракулы перепроверки
Регрессионная батарея — **21 прогон**, все совпали с ожиданием: 4 мутации по
минору и 4 удаления строки версии (по одной на место), директива `toolchain`,
патч в теге, смена базы образа, два слоя одной версии, два слоя разных версий,
дубль внутри раздела, история вне раздела, пропавший раздел, `PATH` без `go`,
запуск из подкаталога, коды выхода 0/1/2/3. Отдельно проверено, что при
нечитаемом файле строка «не называет версию» не печатается ни разу.
Сверх того: `openspec validate --strict` — valid; `task gate` — exit 0;
`task image` пересобран на `golang:1.26-alpine` — exit 0; `shellcheck` на скрипте
чист.
## Что осталось открытым после отработки
- **N-1: страж не покрыт ничем.** Решением человека уехало задачей урожая. Из
девятнадцати сценариев дельты машина гоняет один — тот, где всё сошлось;
остальные восемнадцать подтверждены разовыми прогонами. До закрытия той задачи
правки скрипта идут вслепую.
- **H-2: рантайм-база образа берётся «последней доступной».** Два образа из
одного коммита с разницей в неделю несут разные `ffmpeg`. Вне границ задачи,
уезжает урожаем.
- **Сигнал о заниженной метке** остаётся фактом для человека: ось «изменение
трогает канон» в правиле выбора метки отсутствует, а на этом прогоне именно она
дала обе блокирующие находки. Кандидат в правило — P-5.
@@ -0,0 +1,228 @@
## ADDED Requirements
### Requirement: Версия инструмента сборки объявлена одним числом
Проект SHALL объявлять версию Go, на которой собирается сервис, одинаково во
всех местах, где она названа. Мест ровно четыре, и перечень закрыт: требование
модуля в `go.mod`, сборочный образ в `Dockerfile`, строка стека в `CLAUDE.md`,
строка стека в `README.md`.
Сравниваются мажор и минор. Третье число у сборочного образа MUST оставаться
свободным, как и база образа: образ обновляется своим темпом, и требовать от
него совпадения по патчу значило бы краснеть на каждом его обновлении. Тег
читается по форме `golang:<мажор>.<минор>[.<патч>][-<база>]`, и берутся из него
первые два числа.
Правило множественности у мест разное, потому что места устроены по-разному.
**Документы** — `CLAUDE.md` и `README.md` — MUST называть версию ровно один раз,
и считается это **не по файлу, а по разделу стека**: `## Стек` в памятке,
`## Технологии` в README. Второе вхождение числа **в этом разделе** MUST
считаться отказом: обновят одно, второе протухнет молча. За пределами раздела
число не читается вовсе — иначе памятка, которая по устройству ведёт историю
закрытых долгов, роняла бы проверку на первой же правдивой строке о прошлой
версии, а сообщение толкало бы чинить не проверку, а исторический документ.
**Сборочный образ** единственности не требует: каждый слой — настоящий вход
сборки, и многослойная сборка законна. От всех вхождений `FROM golang:` MUST
требоваться совпадение мажора и минора, а не единственность.
**Требование модуля** называется директивой `go` и по устройству файла
единственно.
Граница раздела MUST быть определена, а не подразумеваться: раздел кончается
следующим заголовком того же или более высокого уровня, заголовок третьего уровня
и ниже остаётся внутри раздела, а строка, похожая на заголовок, но лежащая внутри
блока кода, заголовком MUST не считаться. Без этого пример в чужом разделе
открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда
норма его читать не велит.
`go.mod` MUST не содержать директиву `toolchain`. Она называет версию **пятым**
местом, которого перечень не знает: при `toolchain go1.27.0` четыре объявленных
числа сойдутся, а собирать будет пятое — то есть вернётся тот самый класс
расхождения, ради которого требование и заведено.
#### Scenario: Все четыре места названы одинаково
- **GIVEN** дерево проекта, где `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md`
называют версию Go
- **WHEN** их читают подряд
- **THEN** мажор и минор совпадают во всех четырёх
#### Scenario: Патч сборочного образа отличается законно
- **GIVEN** `go.mod` требует `1.26.0`, а образ собирается на `golang:1.26.5-alpine`
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: База сборочного образа сменилась
- **GIVEN** образ переехал с `golang:1.26-alpine` на `golang:1.26-bookworm`
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: Раздел стека называет версию дважды
- **GIVEN** раздел стека в `CLAUDE.md` называет версию два раза
- **WHEN** версии сравнивают
- **THEN** это расхождение, даже если оба числа одинаковы
#### Scenario: Число за пределами раздела стека не читается
- **GIVEN** `CLAUDE.md` вне раздела стека упоминает прошлую версию Go — например
записью о закрытом долге
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: Сборочный образ собран в два слоя
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с одним мажором и минором
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: Слои сборочного образа разошлись между собой
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с разными минорами
- **WHEN** версии сравнивают
- **THEN** это расхождение
#### Scenario: Заголовок раздела встретился внутри блока кода
- **GIVEN** документ в чужом разделе показывает пример, внутри которого есть
строка, совпадающая с заголовком раздела стека, а ниже названо другое число
- **WHEN** версии сравнивают
- **THEN** число из примера не читается, и расхождением это не считается
#### Scenario: Раздел стека закрыт заголовком верхнего уровня
- **GIVEN** после раздела стека идёт заголовок первого уровня, а ниже названа
прошлая версия
- **WHEN** версии сравнивают
- **THEN** это число не читается, и расхождением не считается
#### Scenario: Раздела стека нет вовсе
- **GIVEN** в документе нет раздела, где называется версия
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет недостающий раздел
#### Scenario: Модуль объявляет версию пятым местом
- **GIVEN** `go.mod` содержит директиву `toolchain`
- **WHEN** версии сравнивают
- **THEN** это расхождение
### Requirement: Объявленное число — то, на котором проект собирается
Объявленная версия SHALL быть той, на которой сервис действительно собирается и
проходит тесты. Согласованность четырёх строк между собой этого не доказывает:
четыре одинаковых числа несуществующей версии требованию о согласованности
удовлетворяют, а собрать на них нельзя.
Проверка эта MUST оставаться за человеком и MUST не входить в набор проверок:
она требует сборки образа, а сборка образа набором проверок не делается
намеренно — дорого. Подъём версии MUST не уезжать в основную ветку, пока сборка
образа и тесты на объявленном числе не прогнаны.
#### Scenario: Версию подняли
- **GIVEN** объявленную версию Go подняли во всех четырёх местах
- **WHEN** изменение готовят к мерджу
- **THEN** до мерджа на этой версии прогнаны сборка образа и тесты
### Requirement: Расхождение версий роняет набор проверок
Набор проверок `task gate` SHALL включать шаг, который сравнивает объявленные
версии между собой и MUST завершаться отказом, когда они разошлись. Сообщение
отказа MUST называть **все четыре места и прочитанное в каждом число** — не одну
разошедшуюся пару: в дефекте 2026-08-12 три места из четырёх говорили одно и то
же и неверными были именно они, а по сообщению о паре человек чинит не то место.
Шаг MUST судить по содержимому файлов репозитория и MUST не спрашивать
установленный инструмент — ни `go version`, ни `go env`, ни `GOTOOLCHAIN`. Исход
его MUST быть функцией коммита, а не машины: шаг, чей ответ зависит от того, что
стоит на хосте, воспроизводит ровно ту подмену, которая держала дефект
2026-08-12 невидимым — там `go build ./...` шёл на хостовом Go, а объявленное
число не проверял никто.
Шаг MUST работать сравнением строк — без сборки образа, без docker и без сети —
и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только
читать: файлов он не правит и разошедшихся мест не чинит.
Коды выхода MUST следовать словарю прочих проверочных шагов проекта: 0 сошлось,
1 расхождение, 2 ошибка употребления, 3 окружение. Своего словаря шаг MUST не
заводить: раздел «Гейт» в `CLAUDE.md` объявляет словарь общим, и четвёртый шаг с
собственной семантикой сделал бы это утверждение неверным.
Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места.
«Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы
как совпадение.
Отказ чтения места MUST не выглядеть как отсутствие числа. Место, которое
существует, но не читается, — это отказ окружения, и сообщение MUST говорить о
нечитаемости, а не о ненайденной версии: иначе шаг отправляет чинить документ, в
котором строка на месте, а сломаны права.
#### Scenario: Разошёлся сборочный образ
- **GIVEN** `Dockerfile` называет версию, отличную от прочих трёх мест
- **WHEN** запускают `task gate`
- **THEN** шаг сверки завершается отказом
- **AND** сообщение называет все четыре места и число каждого
- **AND** весь набор проверок краснеет
#### Scenario: Разошлось требование модуля
- **GIVEN** `go.mod` называет версию, отличную от прочих трёх мест
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет `go.mod` среди разошедшихся
#### Scenario: Разошлась памятка
- **GIVEN** `CLAUDE.md` называет версию, отличную от прочих трёх мест
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет `CLAUDE.md` среди разошедшихся
#### Scenario: Разошёлся README
- **GIVEN** `README.md` называет версию, отличную от прочих трёх мест
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет `README.md` среди разошедшихся
#### Scenario: Версии совпадают
- **GIVEN** все четыре места называют одно число
- **WHEN** запускают `task gate`
- **THEN** шаг сверки проходит с кодом 0
- **AND** остальные шаги набора идут как прежде
#### Scenario: Инструмента сборки нет на машине
- **GIVEN** в `PATH` нет `go` вовсе
- **WHEN** запускают шаг сверки
- **THEN** исход и сообщение те же, что и при установленном `go`
#### Scenario: Ни docker, ни сети нет
- **GIVEN** docker недоступен и сети нет
- **WHEN** запускают шаг сверки
- **THEN** он отрабатывает и даёт тот же исход, что и при доступном docker
#### Scenario: Шаг запущен не из корня проекта
- **GIVEN** шаг запускают из подкаталога дерева
- **WHEN** он ищет свои четыре места
- **THEN** исход тот же, что и при запуске из корня
#### Scenario: Место существует, но не читается
- **GIVEN** файл одного из мест на диске есть, но прав на чтение нет
- **WHEN** запускают шаг сверки
- **THEN** он завершается кодом окружения и говорит о нечитаемости места
- **AND** сообщения «версия не названа» не печатает
#### Scenario: Версия не названа там, где должна быть
- **GIVEN** одно из четырёх мест перестало называть версию Go
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет место, где число не нашлось
@@ -0,0 +1,173 @@
## Критерии приёмки
### От постановки
Перенесены дословно из записи задачи `tasks/items/go-1-26-upgrade.md`: закрытие
задачи удалит файл, а критерии обязаны его пережить.
- Модуль, образ и документы называют одну версию Go. Оракул — `grep` по четырём
местам: `go.mod`, `Dockerfile`, `CLAUDE.md`, `README.md`; все дают одно число.
- Сборка на объявленной версии проходит. Оракул — `task image` и
`CGO_ENABLED=0 go build ./...` на чистом дереве.
- Расхождение версий роняет гейт. Оракул — прогон `task gate` на дереве, где
версия в `Dockerfile` понижена на минор: шаг краснеет и называет оба числа.
- Совпадение гейт не роняет, а сам шаг не требует docker и работает без сети.
Оракул — `task gate` на неизменённом дереве и прогон с
`DOCKER_HOST=/dev/null`.
- Гейт зелёный целиком. Оракул — `task gate`.
### От рубрики ревью дизайна
Проход `review-rubric`, стадия ревью дизайна. Рубрика на род узла «проверочный
шаг набора проверок, читающий разнородные источники».
- **Мутационный оракул на каждый источник.** Четыре прогона: по очереди понизить
минор в `go.mod`, `Dockerfile`, `CLAUDE.md`, `README.md`. В каждом шаг краснеет
и называет именно изменённый источник. Оракул — четыре прогона, четыре красных,
четыре разных сообщения.
- **Пустая выборка — отказ, а не согласие.** Четыре прогона: по очереди убрать
строку версии из каждого источника. Каждый даёт отказ с именем этого источника.
- **Исход — функция коммита, а не машины.** Оракул — прогон с `PATH`, из которого
убран `go`: тот же код выхода и тот же вывод, что и при установленном `go`.
- **Второе вхождение числа не проходит молча.** Оракул — дописать в `CLAUDE.md`
второе «Go 1.25» и прогнать шаг: он краснеет, а не судит по первому совпадению.
- **Патч и база образа свободны.** Оракул — два прогона: `golang:1.26.5-alpine` и
`golang:1.26-bookworm` в `Dockerfile`, оба зелёные.
- **Сообщение масштабируется на четыре места.** Оракул — прогон на дереве, где
один источник разошёлся с тремя: вывод содержит четыре пары «место: число».
- **Словарь кодов выхода объявлен.** Оракул — четыре прогона: сошлось, расхождение,
лишний аргумент, файл источника убран — дают 0, 1, 2 и 3.
- **Исход не зависит от рабочего каталога.** Оракул — прогон из корня и из
подкаталога дают один код выхода.
- **Шаг только читает.** Оракул — контрольная сумма дерева до и после прогона
совпадает, два прогона подряд дают одинаковый вывод.
- **Шаг не ходит в сеть.** Оракул — прогон под `strace -f -e
trace=socket,connect,sendto,recvfrom`: ни одного сетевого вызова.
- **Граница «чего шаг не проверяет» записана.** Оракул — раздел «Гейт» в
`CLAUDE.md` содержит строку о том, что сборка образа в набор проверок
по-прежнему не входит.
- **Директива `toolchain` не проходит незамеченной.** Оракул — дописать
`toolchain go1.27.0` в `go.mod` и прогнать шаг: он краснеет.
## 1. Шаг сверки версий
- [x] 1.1 Написать `scripts/check-go-version.sh` на POSIX `sh`: достать мажор и
минор из директивы `go` в `go.mod`, из тега `FROM golang:` в `Dockerfile`, из
строки стека в `CLAUDE.md` и из строки стека в `README.md`
- [x] 1.2 Пути строить от корня репозитория, а не от текущего каталога
- [x] 1.3 Не звать `go` ни в каком виде: ни `go mod edit`, ни `go list`, ни
`go env`. Исход обязан быть функцией содержимого файлов
- [x] 1.4 Тег образа разбирать по форме `golang:<мажор>.<минор>[.<патч>][-<база>]`
— патч и база отбрасываются
- [x] 1.5 Запретить директиву `toolchain` в `go.mod` — отказ с названием причины
- [x] 1.6 Второе вхождение числа в одном источнике — отказ; для `Dockerfile` все
`FROM golang:` обязаны давать одно число
- [x] 1.7 Отказ при расхождении: сообщение печатает все четыре места и число
каждого, а не одну пару
- [x] 1.8 Отказ при ненайденном числе: место названо поимённо, «нечего
сравнивать» исходом не считается
- [x] 1.9 Коды выхода по словарю проекта: 0 сошлось, 1 расхождение, 2 ошибка
употребления, 3 окружение
- [x] 1.10 Не использовать GNU-only флаги (`grep -P`, `sed -E` с расширениями,
`mapfile`); сделать скрипт исполняемым
- [x] 1.11 Завести шаг `go-version` в `Taskfile.yml` по образцу соседних шагов,
включая внятный отказ при отсутствии скрипта
- [x] 1.12 Включить шаг в `gate`
## 2. Подъём версии до 1.26
- [x] 2.1 `go.mod`: директива `go 1.26.0`
- [x] 2.2 `Dockerfile`: сборочный слой на `golang:1.26-alpine`
- [x] 2.3 `CLAUDE.md`, раздел «Стек»: «Go 1.26» — ровно одно вхождение числа на
файл, включая абзац из шага 3.1
- [x] 2.4 `README.md`, раздел «Технологии»: завести строку версии Go в форме,
которую находит скрипт
- [x] 2.5 `go mod tidy` и проверка, что `go.sum` не разъехался и директива
`toolchain` не появилась
## 3. Документы
- [x] 3.1 `CLAUDE.md`, раздел «Гейт»: новый шаг в перечне того, что красит
безусловно; строка о том, что сборка образа в гейт по-прежнему не входит;
словарь кодов выхода распространён на четвёртый проверочный шаг
- [x] 3.2 `docs/review.md`: запись за 2026-08-12 получает строку о том, чем
дефект закрыт
- [x] 3.3 `docs/architecture.md`, преамбула: четвёртая capability в перечне и
оговорка о том, что она нормирует не поведение сервиса (находка ревью B-2).
Ссылка на спеку ставится шагом синка, после архивации: до неё файла нет и
`docs.py check` краснеет битой ссылкой
- [x] 3.4 `docs/conventions/README.md`, таблица «Механизировано»: строка про
новое машинное правило (находка ревью N-2)
## 5. Отработка находок ревью кода
Отчёт триажа — `openspec/changes/go-1-26-upgrade/review/report.md`.
- [x] 5.1 N-3: нечитаемое место даёт код 3 с честным сообщением, а не «версия не
названа» с кодом 1. Проверка читаемости стоит рядом с проверкой существования:
подстановка команд в аргументе теряет код возврата, а в `read_doc` статус
конвейера берётся от последней команды
- [x] 5.2 N-4: цена отказа от `shellcheck` названа по замеру — один shell-скрипт,
а не четыре. Правка в `CLAUDE.md` и в `design.md`
- [x] 5.3 Подавление ложного `SC1007` в скрипте, чтобы заведение линтера потом
стоило ровно одной строки шага. `shellcheck` на скрипте чист
## 6. B-1: правило множественности разведено по местам
Находка B-1 меняла требование, поэтому прошла через чекпоинт заново. Решение
человека: развести правило по местам и сузить рамку до раздела.
- [x] 6.1 Требование: единственность — от раздела стека в документах, от
сборочного образа — совпадение всех вхождений `FROM golang:`, требование модуля
единственно по устройству файла
- [x] 6.2 Пять новых сценариев: дубль в разделе, число вне раздела, два слоя с
одной версией, два слоя с разными, пропавший раздел
- [x] 6.3 Скрипт читает раздел (`## Стек` в памятке, `## Технологии` в README), а
не файл целиком
- [x] 6.4 Пропавший раздел — свой исход с именем раздела, а не «версия не названа»
- [x] 6.5 Прогоны: история о прошлой версии вне раздела — зелено; пример команды в
README вне раздела — зелено; дубль внутри раздела — красно; два слоя одной
версии — зелено; два слоя разных — красно; раздела нет — красно с его именем
- [x] 6.6 Регрессия прежней батареи: 8 мутаций по местам, `toolchain`, патч и база
тега, `PATH` без `go`, подкаталог, коды 0/1/2/3 — все как прежде
- [x] 6.7 `openspec validate --strict` и `task gate` зелёные
## 7. Отработка перепроверки спек после правки B-1
Целевой перепрогон прохода `specs`: правка коснулась ровно его темы. Прежняя
находка S1 подтверждена закрытой по существу; три новые, все minor.
- [x] 7.1 F-2, ложное зелёное: раздел кончался только заголовком того же уровня, а
заголовок внутри блока кода читался как настоящий — число доставалось из-за
границы раздела. Граница определена в требовании и исполнена в коде: следующий
заголовок того же или более высокого уровня, блок кода заголовков не даёт,
хвостовые пробелы в заголовке не значат ничего
- [x] 7.2 F-2: два сценария — заголовок раздела внутри блока кода, раздел закрыт
заголовком верхнего уровня
- [x] 7.3 F-3: отличие «сломанного окружения» от «пропавшей строки» записано
требованием и сценарием, а не только кодом
- [x] 7.4 F-1: таблица `design.md` велела правило, обратное принятой норме
(«одно вхождение на файл»), и абзац-мотивировка стал фактически неверен —
переписаны оба
- [x] 7.5 Регрессия 21 прогоном: 8 мутаций по местам, `toolchain`, патч и база
тега, два слоя одной и разных версий, дубль в разделе, история вне раздела,
пропавший раздел, `PATH` без `go`, подкаталог, коды 0/1/2/3 — все совпали с
ожиданием
- [x] 7.6 `shellcheck` на скрипте чист
## 4. Проверка
- [x] 4.1 `CGO_ENABLED=0 go build ./...` и `go test ./...` на go1.26.5
- [x] 4.2 `task image` собирает образ на `golang:1.26-alpine`
- [x] 4.3 Четыре мутации по минору — по одной на источник; в каждой шаг краснеет
и называет изменённый источник; дерево возвращается как было
- [x] 4.4 Четыре мутации удалением строки версии — по одной на источник; в каждой
отказ называет место
- [x] 4.5 Прогон с `PATH` без `go`, прогон с `DOCKER_HOST=/dev/null`, прогон под
`strace` без сетевых вызовов — исход тот же
- [x] 4.6 Прогон из подкаталога — тот же код выхода
- [x] 4.7 Прогоны на `golang:1.26.5-alpine` и `golang:1.26-bookworm` — зелёные
- [x] 4.8 Прогон с дописанным `toolchain go1.27.0` — красный
- [x] 4.9 Четыре прогона на коды выхода: 0, 1, 2, 3
- [x] 4.10 Контрольная сумма дерева до и после прогона совпадает
- [x] 4.11 `task gate` целиком зелёный
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-12
@@ -0,0 +1,273 @@
## Context
Сегодня HTTP API открыт наружу без проверки — так записано первой строкой модели
угроз. Приглашение второго человека упирается в это: у записей нет владельца, а
подобранный идентификатор задачи отдаёт чужую расшифровку.
Решение от 2026-08-11 (`ADR-2026-08-11-pocketbase-storage-with-admin-panel`)
назвало способ: **ответ провайдера разбирает хранилище, а не наш код**. У
коллекции пользователей включается провайдер `oidc` с адресами Authelia, учётные
записи заводятся сами, и панель их видит. Проверено на версии 0.39.10 —
`docs/research/pocketbase.md`, раздел «Пользователи — только те, кого туда
положат».
**Способ остаётся верным, но его механика уже проверена по исходникам
библиотеки, и три ожидания постановки она не подтверждает.** Проверено чтением
`pocketbase@v0.39.10`:
1. **Куки библиотека не читает вовсе.** Сессию она берёт единственным способом —
заголовком `Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
Критерий приёмки задачи написан про куку.
2. **Эндпоинта выхода библиотека не приносит.** Список её адресов
аутентификации — `auth-methods`, `auth-refresh`, `auth-with-password`,
`auth-with-oauth2`, `request-otp`, `auth-with-otp`, восстановление пароля,
подтверждение почты и смена почты (`apis/record_auth.go`). Выхода среди них
нет.
3. **Браузерный вход по редиректу библиотека своим не приносит.** Она приносит
обмен уже полученного кода: `POST /api/collections/{c}/auth-with-oauth2`
требует `provider`, `code`, `codeVerifier` и `redirectURL`. Её собственный
`/api/oauth2-redirect` служит другому: он ищет клиента realtime-подписки по
параметру `state` и отдаёт код туда (`apis/record_auth_with_oauth2_redirect.go`)
— это механика её JS-клиента с всплывающим окном, а не серверный вход.
Отсюда объём: инициировать вход, принять возврат и завести куку — наш код.
Разбор ответа провайдера, заведение учётной записи и связь с внешним провайдером
остаются за хранилищем, как и решено. Решение 2026-08-11 не пересматривается.
## Goals / Non-Goals
**Goals:**
- запрос к приёму записи и к опросу готовности без сессии получает отказ и
ничего не заводит;
- вход идёт у Authelia по OIDC, учётные записи заводятся сами;
- выход закрывает доступ немедленно, а не по истечении срока;
- сессия переживает выкладку;
- проба здоровья и метрики остаются открытыми.
**Non-Goals:**
- владелец у записи и сужение выборки по нему — задача `record-ownership`;
- вход для программ по личным токенам — отдельная цель роадмапа. Внешняя
программа, ходившая в API анонимно, этим изменением ломается намеренно, и
замены ей здесь не появляется;
- белый список Telegram — живёт до `telegram-account-link`;
- панель администратора — в неё провайдер не пускает, наружу её закрывает
обратный прокси;
- своя страница входа со скриптом: у сервиса нет фронтенда, и заводить его ради
входа незачем.
## Decisions
### Сессия предъявляется кукой, а заголовок остаётся внутренним
**Выбрано:** наш обработчик возврата ставит куку `HttpOnly`, `Secure`,
`SameSite=Lax` со значением, выданным хранилищем. Промежуточный слой перед
проверкой перекладывает значение куки в заголовок `Authorization`, если заголовка
нет. Дальше работает штатная проверка библиотеки.
Человек увидит обычный вход: перешёл, авторизовался у Authelia, вернулся —
работает. Ни строки скрипта на его стороне.
Отвергнуто:
- **заголовок `Authorization` как единственный способ.** Это механика библиотеки
и путь наименьшего кода, но браузер такой заголовок сам не шлёт: понадобился
бы свой фронтенд, который держит значение и подставляет его. Фронтенда у
сервиса нет, а заводить его ради входа — работа шире задачи. Критерий приёмки
задачи вдобавок написан про куку;
- **своя таблица сессий.** Даёт полный контроль над выходом и сроком, но заводит
второй способ делать то, что хранилище уже делает, — и второй дом для факта
«кто вошёл». Отвергнуто по концептуальной целостности.
Заголовок при этом остаётся рабочим: его требуют собственные адреса
аутентификации хранилища, и глушить их значит ломать библиотеку изнутри. Это
осознанно оставленная вторая дверь, и она названа в спеке.
### Форма решения выбрана из трёх, а не из одной
Прежде трёх решений ниже — выбор самой формы. Рассматривались три.
**A — свой тонкий слой входа поверх хранилища.** Выбрана. Наш код ведёт флоу и
ставит куку, обмен кода и заведение учётной записи остаются за хранилищем.
Цена: сверка состояния и установка куки — наша ответственность, то есть ошибки
в чувствительном месте наши.
**B — вход целиком на обратном прокси.** Authelia стоит перед сервисом и не
пускает неузнанные запросы, приложение доверяет заголовку от прокси. Нашего кода
почти ноль. Отвергнуто по трём причинам сразу: при прямом обращении к порту
заголовок подделывает кто угодно в той же сети, а сервис не имеет способа
отличить прокси от постороннего; учётные записи в панели не появляются вовсе, а
решение 2026-08-11 требует обратного; вход для программ по личным токенам из
этой формы не вырастает — его пришлось бы делать заново и мимо.
**C — фронтенд и штатный клиент хранилища.** Своя страница, всплывающее окно,
подписка, значение сессии в хранилище браузера. Всё штатно для библиотеки.
Отвергнуто: у сервиса нет фронтенда, и заводить его ради входа — работа шире
задачи; значение сессии становится доступно скриптам страницы, то есть XSS
уносит сессию целиком, тогда как кука с запретом чтения скриптом этого не даёт.
### Вход и возврат ведёт наш код, разбор ответа — хранилище
**Выбрано:** три своих адреса — начало входа, возврат от провайдера, выход.
Начало входа заводит `state` и PKCE-verifier, кладёт их во временную куку и
уводит человека на `authURL` провайдера. Возврат сверяет `state`, а код отдаёт
хранилищу вызовом его же обмена — тем, что стоит за `auth-with-oauth2`.
**Обмен кода библиотека наружу не отдаёт** — он живёт неэкспортированной
функцией за собственным адресом хранилища. Решением владельца от 2026-08-12
обработчик возврата зовёт **этот адрес внутри процесса**, через роутер
хранилища, а не по сети.
Цена названа и принята: получается петля «наш обработчик → наш роутер → наш
обработчик», ответ разбирается текстом, а типизированная ошибка теряется.
Взамен решение 2026-08-11 соблюдается дословно — разбор ответа провайдера
остаётся за хранилищем, и учётные записи видны в панели.
Отвергнуто:
- **собрать обмен своими руками** из кусков, которые библиотека всё же отдаёт.
Прямой код без петли, но разбор ответа провайдера переезжает к нам — это
пересмотр решения 2026-08-11 отдельным ADR, и владелец его не выбрал;
- **всплывающее окно и realtime-подписка**, как делает JS-клиент библиотеки.
Работает без нашего кода вовсе, но требует того самого фронтенда и держит
открытым realtime-соединение ради одного входа.
### Кого пускать, решает провайдер, а не сервис
Решением владельца от 2026-08-12 сервис своей проверки допуска **не делает**:
кто допущен, определяет правило Authelia на этого клиента. Всякий, кого
провайдер пропустил, получает учётную запись и доступ.
Цена принята и обязана быть записанной: правило живёт вне репозитория, в
настройках выкладки, и сервис на него полагается так же, как полагается на
обратный прокси в части панели администратора. Настроенный слишком широко
клиент открывает сервис всем, у кого есть учётная запись в общей Authelia, — и
проверить это по коду нельзя. Строка об этом идёт в `docs/security.md`, раздел
«Что разграничивает доступ».
Отвергнуто: **проверка группы своим кодом** — защита стояла бы в сервисе и не
зависела от настройки контура, но владелец выбрал не заводить второе место, где
решается допуск.
### Выход обесценивает выданные сессии, а не только убирает куку
**Выбрано:** выход обновляет ключ токенов учётной записи
(`Record.RefreshTokenKey()` плюс сохранение) и убирает куку. Подпись сессии
считается от этого ключа, поэтому все прежние значения перестают проходить
разом.
Отвергнуто:
- **только уборка куки.** Унесённое значение продолжало бы открывать доступ до
истечения срока — то есть выход не закрывал бы доступ, а делал вид;
- **чёрный список выданных значений.** Даёт точечный выход одной сессии, но
требует своей таблицы и её чистки; при одном человеке и одном браузере это
цена без покупателя.
Цена выбранного названа прямо: выход закрывает **все** сессии учётной записи, а
не только текущую. При сегодняшнем числе пользователей это незаметно, и
переделка, когда станет заметно, — чёрный список из отвергнутого варианта.
### Сессия переживает перезапуск сама
Проверено по исходникам: подпись считается от секрета коллекции
(`Collection().AuthToken.Secret`) и ключа записи, оба лежат в базе
(`core/record_query.go`, `FindAuthRecordByToken`). Значит требование выполняется
устройством хранилища, и нашей работы здесь нет — есть проверка тестом.
### Настройки провайдера приводятся к конфигу при каждом запуске
**Выбрано:** шаг схемы включает провайдера с пустыми значениями, а адреса,
идентификатор клиента и секрет проставляются при подъёме сервиса из конфига.
Причина в инварианте: **применённый шаг схемы не переписывается**. Проставь
секрет однажды шагом — и ротация секрета в конфиге до хранилища не доедет вовсе,
вход сломается после смены ключа, а починить это можно будет только руками в
панели.
Отвергнуто:
- **секрет в шаге схемы.** Разбито инвариантом выше;
- **настройка руками в панели.** Работает, но не воспроизводится: поднятый с
нуля сервис оказывается без входа, и знание живёт в голове владельца.
### Что изменило ревью кода
Три решения приняты владельцем 2026-08-12 уже после того, как код был написан:
ревью нашло, что заявленное поведение не работает.
**Продление сессии выключено.** Хранилище выдаёт сессию продлеваемой, и
предъявитель менял своё значение на новое бессрочно, никуда не входя. При живом
продлении семисуточный срок не значил ничего — а он объявлен единственным
каналом, которым отзыв доступа у провайдера доходит до сервиса. Цена: вход раз в
семь суток. Отвергнуто: сверяться с провайдером по расписанию (новая связь с
Authelia и обработка её недоступности — работа шире задачи) и принять как есть
(тогда паспорт теряет способ остановить того, кто тратит слишком много).
**Файл записи открыт вошедшим.** Пометка поля защищённым сама по себе закрыла
файл вообще для всех, кроме владельца панели: защищённый файл судится ещё и
правилом просмотра коллекции, а незаданное правило означает «только
суперпользователь». Назначено правило для всякого узнанного. Отвергнуто:
оставить файл только панели — тогда задача про прослушивание записи начинается с
того же вопроса.
**Форма адреса провайдера проверяется на старте.** Непустая, но негодная строка
проходила проверку конфига и отвергалась хранилищем позже — из хука подъёма, до
регистрации пробы здоровья. Сервис падал целиком, вместе с ботом и воркерами, а
у владельца не было даже кода состояния. Отвергнуто: поднимать пробу здоровья
раньше настройки провайдера — это завело бы состояние «сервис жив, вход сломан»,
которого спека не описывает.
## Risks / Trade-offs
- **Секрет клиента появляется в новом месте — в базе.** → Инвариант проекта
запрещает секрету попадать в git, в лог, в ответ и в `error_text`; база в этом
перечне не значится, и запрета не нарушает. Но место новое, и модель угроз
обязана его назвать: чтение файла базы теперь равносильно чтению секрета
клиента. Пишется в `docs/security.md` этой же задачей.
- **Ломается внешняя программа, ходившая в API анонимно.** → Ломка намеренная и
объявлена в предложении: это и есть предмет задачи. Замены для программ
(личные токены) в этом изменении нет — она отдельной целью.
- **Вторая дверь: заголовок `Authorization` остаётся принимаемым.** → Он
предъявляет ту же сессию и той же проверке, поэтому обхода не даёт. Но это
второй способ войти, и в спеке он назван, чтобы не был обнаружен ревью как
находка.
- **Выход закрывает все сессии учётной записи.** → Названо решением выше, цена
принята.
- **PKCE-verifier и `state` живут во временной куке.** → Кука ставится на время
входа, `HttpOnly` и `SameSite=Lax`, и убирается на возврате. Хранить их в
памяти процесса нельзя: выкладка посреди входа роняла бы вход.
- **Признак `Secure` закрывает локальный запуск.** → Браузер не сохранит такую
куку по `http://localhost`, и вход перестанет работать у того, кто поднимает
сервис командой из раздела команд. Признак берётся из конфига с умолчанием
«включено», и расхождение образца называется строкой в
`docs/conventions/config.md`.
- **Коллекция пользователей остаётся умолчательной `users`.** → Своя коллекция
означала бы задание правил и способов входа с нуля вместо подчистки
умолчаний, а переезд позже — перевязку связей с провайдером и обесценивание
всех выданных сессий. Цена умолчательной: её заводит системный шаг библиотеки
с открытым созданием записи, и закрывать это приходится нам.
- **Проверить вход целиком без живой Authelia нельзя.** → Тесты закрывают
сверку `state`, отказ без сессии, выход и сохранность сессии; живой вход у
провайдера остаётся ручной проверкой владельца на выкладке. Это граница
покрытия, и она называется в докладе.
## Migration Plan
Шаг схемы включает провайдера у коллекции пользователей и накатывается при
подъёме, как и прежние шаги. Данных он не трогает: ни одной записи не
переписывается, учётные записи заводятся сами при первом входе.
Откат — прежний образ: шаг схемы обратим своим `down`, а до первого входа в
коллекции пользователей пусто.
Порядок выкладки: сперва завести клиента в Authelia и получить секрет, потом
положить его в конфиг на сервере, потом выкладывать. Обратный порядок поднимает
сервис с провайдером без секрета — вход не работает, а API уже закрыт.
## Open Questions
- Адрес возврата должен совпадать с тем, что записан клиенту в Authelia. Значение
выбирается при заведении клиента и попадает в конфиг; здесь оно не
фиксируется.
@@ -0,0 +1,54 @@
## Why
HTTP API открыт наружу без всякой проверки: кто угодно из интернета заводит
задачи расшифровки за наши деньги и читает чужие расшифровки, подобрав
идентификатор задачи. Сегодняшний периметр так и записан в модели угроз —
аутентификации не делает ни обратный прокси, ни само приложение.
Второго человека пригласить в сервис сейчас нельзя: это значит открыть ему всё,
что в сервисе уже лежит.
## What Changes
- Сервис узнаёт, кто к нему пришёл. Учётные записи заводит и проверяет внешний
провайдер — Authelia по OIDC; своей регистрации и своих паролей не заводим.
- **BREAKING** Приём записи и опрос готовности задачи требуют входа: запрос без
сессии получает отказ и не заводит задачу, а текста расшифровки не отдаёт.
Внешняя программа, ходившая в API без всякого входа, перестаёт работать.
- Появляются вход и выход: вход уводит человека к провайдеру и возвращает
обратно уже узнанным, выход закрывает доступ немедленно.
- Проба здоровья и метрики остаются открытыми и сессии не требуют: ни у пробы,
ни у сборщика метрик её нет. Наружу их закрывает правило обратного прокси —
это работа выкладки.
- Разграничения записей по владельцу здесь **нет**: после входа человек видит
ровно столько же, сколько видно сейчас.
## Capabilities
### New Capabilities
- `access`: кто пришёл в сервис и пускают ли его дальше — вход через внешнего
провайдера, чем предъявляется сессия, что её прекращает и какие адреса
остаются открытыми.
### Modified Capabilities
- `intake`: приём записи и опрос готовности задачи перестают быть доступны
анонимно — оба требуют узнанного отправителя.
- `storage`: ссылка на файл записи перестаёт быть правом пройти по ней — файл
отдаётся только узнанному отправителю.
## Impact
- Коллекция пользователей хранилища: включённый провайдер `oidc` с адресами
Authelia, идентификатором клиента и секретом; связь учётной записи с внешним
провайдером хранилище ведёт своей служебной коллекцией.
- `POST /api/audio` и `GET /api/status/{id}` — публичный контракт HTTP API
объявлен проектом необратимым, и здесь он меняется: у обоих появляется отказ
без входа.
- Новые адреса входа, возврата от провайдера и выхода.
- Секция конфигурации под провайдера: адрес, идентификатор клиента, секрет.
Секрет попадает в настройки коллекции хранилища — это новое место, где он
живёт, и его надо назвать в модели угроз.
- `config.dist.toml` и разбор конфига.
- `docs/security.md`: первая строка периметра перестаёт быть верной.
- Панель администратора не трогается: в неё провайдер не пускает, и закрывает её
обратный прокси.
@@ -0,0 +1,313 @@
# Ревью кода: oidc-login — триаж
База диффа: `origin/master`, изменение целиком в рабочем дереве. Дата прогона: 2026-08-12.
---
## Что сделано по итогам (дописано оркестратором после отработки)
Все семь пунктов закрыты; три развилки решены владельцем 2026-08-12.
| Пункт | Исход |
|---|---|
| 1. Войти не может никто | Исправлено: `CreateRule = @request.context = "oauth2"` правкой неуехавшего шага. Оракул — `TestLoginCreatesAccountAndSession`: вход целиком через подставного провайдера, учётная запись заводится, сессия выдаётся |
| 2. Утечка обработчиков | Исправлено: роутер хранилища собирается один раз через `sync.Once`. Оракул — `TestCallbackDoesNotLeakHooks`: 20 возвратов не меняют длины очереди |
| 3. Файл не отдаётся вошедшему | **Решение владельца: открыть вошедшим.** Назначено `files.ViewRule = @request.auth.id != ""`, спека дополнена порядком «сессия → токен файла → ссылка». Оракул — `TestRecordFileNeedsSessionAndToken` |
| 4. Носитель состояния не убирался | Исправлено: уборка перенесена до записи ответа; все проверки файла судят по `w.Result()`, а не по живой карте заголовков |
| 5. Опечатка в `[auth]` роняет процесс | **Решение владельца: проверять форму адреса на старте.** `Validate` разбирает адреса и требует схему и хост. Оракул — `TestAuthConfigValidateRejectsMalformedURL` |
| 6. Отзыв доступа не доходит | **Решение владельца: выключить продление.** Заведён слой `BlockSessionRefresh`; спека и модель угроз дополнены. Оракул — `TestSessionRefreshIsClosed` |
| 7. Сердцевина не покрыта | Исправлено: заведён `login_test.go` (вход целиком, отказ обмена, утечка, продление, файл) и `internal/config/config_test.go`; закрыты ветки выхода |
Сверх семи, из срезанного потолком, исправлено там же, где окно закрывается мерджем:
- **G** — срок жизни сессии перенесён из шага схемы в приведение настроек при подъёме;
- **L** — откат шага больше не падает на валидации и не возвращает открытую регистрацию;
- **O**`docs/database.md` приведён к коду, коллекция `users` описана, три числа внесены в таблицу;
- **Q** — уровни журнала разведены по адресату, добавлено поле `capability`;
- **R**`auth.client_secret` внесён в перечень секретов и в инвариант `CLAUDE.md` с изъятием про базу;
- **N** — причина отказа провайдера приводится к перечню известных кодов;
- язык ответов пользователю переведён на русский.
**Не сделано намеренно, ушло в урожай:** `P` (настройка `docs/.docs.json` указывает на несуществующий каталог миграций — дефект гейта, не этого изменения), `M` (код провайдера в журнале запросов хранилища), `S` (начало входа собрано руками), `T`/`U` (рантбук выкладки), `V` (проверка конфига не в норме), гипотезы без пути и остаток пункта 4 (серверный учёт употреблённых состояний).
---
## Сводка
**Размер, сложность, метка.** Размер — крупное: 24 подзадачи в 6 группах, 5 слоёв кода, 8 узлов в «Затрагивает», 2 capability (одна ADDED целиком). Сложность — незнакомое: `docs/review.md`, «Триггеры метки», «Незнакомое здесь» называет вход через OIDC дословно первой строкой. **Метка `large`** (максимум по осям), **режим — по графу**, опиниативные проходы открыты.
**Состояние гейта: ЗЕЛЁНЫЙ.** Проверено собственным прогоном триажа, а не только отчётом прохода: `task gate BASE=origin/master` → exit 0, все девять шагов. Унаследованное замечание `tasks.py` (`any-audio-source`) шаг не роняет и к диффу отношения не имеет.
**Зелёный гейт здесь — часть находки, а не свидетельство.** Два теста в `internal/controller/http/auth_test.go` утверждают проверенным то, что не работает (пункты 3 и 4), и оба зелёные. Два пункта `tasks.md` — 5.5 и 5.8 — отмечены `[x]` за проверки, которых в файле нет.
### План разметки задачи с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/changes/oidc-login/specs/{access,intake,storage}/spec.md` | разбор | specs | **закрыта**, 5 находок (C, D, E, M, V) + 3 блока наблюдений |
| autotests | `CLAUDE.md`, «Гейт» | — | autotests | **закрыта**, 3 находки (H, I, J) + отчёт гейта и `govulncheck` |
| conventions | `docs/conventions/{config,database,errors,logging}.md` | разбор | code | **закрыта**, 7 находок (A, L, O, P, Q, R + доля в B) |
| architecture | `docs/architecture.md`; источник `docs/passport.md` | доказательство | architecture | **закрыта**, 3 находки (B, G, S) + 5 пунктов «дешевле переделать» |
| security | `docs/security.md` | доказательство | adversary | **закрыта**, 6 находок (A, B, E, F, M, N) + 5 свойств без пути |
| operations | `docs/architecture.md` «Эксплуатация»; источник `docs/database.md` | доказательство | ops | **закрыта**, 3 находки (K, T, U) |
**Тем без отчёта нет.** Все шесть тем ядра вернули отчёты. `review-basics` не запускался — по решению `review-scope`: своих тем сверх ядра у проекта нет. Тем, унесённых непроверенными, нет.
**Сигнал о заниженной метке: не поступил, и провенанс у этого один.** `review-code` подал строку прямо: «метка `large` соответствует изменению, понижения не вижу». `review-basics` не запускался, поэтому второго независимого корректора метки у прогона не было — согласия двух проходов нет, есть отсутствие возражения от одного.
**Находок на входе:** 22 нумерованных (A–V) плюс 23 ненумерованных содержательных пункта (8 «поведение вне спеки», 5 «границы спеки», 5 «свойства без построенного пути», 5 «дешевле переделать») = **45 позиций**. **Осталось в основных секциях: 7** — 3 блокирующие и 4 «стоит исправить сейчас». Слито по причине 4 группы, понижено до гипотез 5, уехало в promote 6, выброшено как вкусовщина 3, срезано потолком с поимённым перечислением 13.
---
## Блокирует мердж
### 1. После выкладки войти не может никто, включая владельца — первый вход не заводит учётной записи
- Файл: `internal/adapter/repo/pocketbase/migrations.go:149`
- Severity: `critical` · Confidence: `high`
- Найдено проходами: code, adversary (2 прохода, оракула два разных; согласие приоритет повышает, `confidence` — нет)
- **Оракул — мой собственный, добыт на этом прогоне.** Тест против настоящего PocketBase с подставным провайдером OIDC (`httptest`, token + userinfo), запущен через `go test -overlay=…` без записи в дерево проекта:
```
users.CreateRule = <nil>
ИСХОД: код=401 обращений к токен-эндпоинту=1 учётных записей=0
тело={"error":"Login failed"}
ERROR Failed to exchange provider code error="storage rejected the exchange with code 403"
```
Причина изолирована тем же прогоном: с `CreateRule = @request.context = "oauth2"` результат `код=302 учётных записей=1 Location="/"`, при этом анонимный `POST /api/collections/users/records` по-прежнему получает `400`. Подтверждение по исходникам библиотеки: `apis/record_crud.go:230-232` (`!hasSuperuserAuth && collection.CreateRule == nil` → Forbidden); внутренний запрос обмена идёт без авторизации.
- Последствие: шаг схемы закрывает создание записи для всех, кроме суперпользователя, а запись при первом входе заводит именно внутренний запрос обмена. Ни одной записи `users` шаг схемы не создаёт, приём и опрос закрыты сессией. После выкладки HTTP-вход не работает ни у кого; жив только Telegram. Лечится руками в панели — ровно то, от чего задача уходила.
- Предложение: `users.CreateRule = ptr("@request.context = \"oauth2\"")` **правкой самого шага `up202608120001`**: он ещё не уезжал на сервер, и окно закрывается мерджем — после выкладки то же изменение потребует нового шага. **Не** чинить открытием `CreateRule = ""`: публичный `auth-with-oauth2` принимает `createData`, и всякий владелец учётной записи Authelia задаст поля новой записи сам.
- Действие: **инлайн**
### 2. Всякий анонимный запрос на возврат навсегда добавляет обработчики приложению и дёргает Authelia нашим секретом
- Файл: `internal/controller/http/auth.go:224-234`
- Severity: `critical` · Confidence: `high`
- Найдено проходами: architecture, adversary (`critical`), specs и code (`minor`) — 4 прохода, оракула два
- **Оракул — мой собственный, тот же прогон:**
```
OnModelAfterCreateSuccess: до=5 после 50 возвратов=55
OnModelAfterUpdateSuccess: до=6 после=106
обращений к провайдеру за 50 анонимных возвратов: 50
провайдер недоступен: обработчиков до=5 после 10 возвратов=15
```
Последняя строка важна: утечка происходит **раньше** сетевого обращения и работает при мёртвом провайдере. Путь анонимный: атакующий сам ставит себе куку `transcriber_login=S:V` и зовёт `/auth/callback?state=S&code=x` — сверка сравнивает две его же величины. Причина: `apis.NewRouter` (`apis/base.go:47,53`) зовёт `bindRealtimeEvents` и `bindUIExtensions`, которые вешают обработчики **на приложение** без поля `Id`; `hook.Bind` (`tools/hook/hook.go:64`) дописывает, а не заменяет.
- Последствие: рост линейный и не освобождается до перезапуска; каждое сохранение задачи конвейером проходит по всем накопленным замыканиям (замер adversary: 3000 хитов → 100 сохранений 3.86ms → 59.8ms, куча +5013 КиБ). Побочно каждый анонимный запрос гонит обмен к Authelia нашими `client_id`/`client_secret`.
- Предложение: строить роутер один раз (при подъёме либо `sync.Once`), держать `http.Handler` полем `AuthHandler`. Решение владельца о петле внутри процесса не пересматривается.
- Остаток, который правка не закрывает и который я не заказываю: анонимный запрос по-прежнему вызывает исходящее обращение к провайдеру. Ограничение числа запросов в scope изменения не входит; названо, чтобы не потерялось.
- Действие: **инлайн**
### 3. Файл записи не отдаётся ни одному вошедшему — сценарий дельты не исполняется, а `docs/security.md` уже утверждает обратное
- Файл: `internal/adapter/repo/pocketbase/migrations.go:168-179`; тест `internal/controller/http/auth_test.go:405-431`; `docs/security.md:112-114`; `openspec/changes/oidc-login/tasks.md:68`
- Severity: `major` · Confidence: `high`
- Найдено проходами: specs, code, adversary
- **Оракул — мой собственный, тот же прогон:**
```
files.ViewRule = <nil>
вошедший кукой → 404
вошедший заголовком → 404
файловый токен получен: код=200 непусто=true
вошедший файловым токеном → 404
аноним → 404
```
Причина: `Protected=true` включает проверку по файловому токену **и** по `ViewRule` коллекции (`apis/file.go:109-134`); `ViewRule` у `files` не назначался ни одним шагом → `nil` → доступ только суперпользователю (`core/record_query.go:606-608`).
- Последствие: сценарий дельты `storage` «GIVEN забирающий предъявил сессию THEN приходит тот же файл» не исполняется. Пункт `tasks.md` 5.8 («без сессии отдаёт отказ, **а с сессией — тот же файл**») отмечен сделанным, а тест проверяет только отказ анониму. `docs/security.md:113` уже переписан утверждением «пройти по ссылке теперь можно только с сессией»: сегодня это ложно. Заведённая задача про прослушивание записи упрётся сюда и, вероятнее всего, «починит» снятием `Protected`, вернув «знание ссылки = доступ».
- Действие: **развилка**
**Вопрос владельцу.** Файл записи защищён так, что его не получает никто, кроме владельца панели. Что делаем:
1. назначить `files.ViewRule` для вошедших и описать в спеке шаг «сессия → файловый токен → ссылка» (цена: правка шага схемы + новый абзац нормы + тест; окно на правку неуехавшего шага закрывается мерджем);
2. переписать требование как «файл виден только владельцу в панели», снять сценарий из дельты `storage` и поправить `docs/security.md` (цена: правка нормы, задача про прослушивание записи начинается с этого же вопроса);
3. оставить как есть и записать расхождение (цена: норма и код разошлись сознательно, следующий проход найдёт то же самое).
---
## Стоит исправить сейчас
### 4. Носитель состояния входа живёт 10 минут вместо одного входа, и стерегущий его тест зелёный ложно
- Файл: `internal/controller/http/auth.go:131` (defer), `293-303`; тест `internal/controller/http/auth_test.go:272`
- Severity: `major` · Confidence: `high`
- Найдено проходами: specs, code (сведено с находкой «одноразовость состояния не реализована ничем» — причина одна: единственным механизмом одноразовости была уборка куки)
- **Оракул — мой собственный, тот же прогон, настоящий сервер против recorder'а:**
```
НАСТОЯЩИЙ СЕРВЕР: код=401 Set-Cookie=[]
RECORDER rec.Header()=[transcriber_login=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax]
RECORDER rec.Result().Header=[]
УСПЕХ: код=302 Location="/" Set-Cookie=[transcriber_session=…; Max-Age=604800; HttpOnly; Secure; SameSite=Lax]
```
На успешной ветке уборки состояния тоже нет — в ответе только кука сессии. Причина: `e.SetCookie` правит карту заголовков, а `defer` исполняется **после** `e.JSON`/`e.Redirect`, которые уже позвали `WriteHeader`. `net/http` при `WriteHeader` фиксирует снимок; `httptest.ResponseRecorder` наоборот — `Header()` отдаёт живую карту, а снимок лежит в `snapHeader`, который читает `Result()`.
- Последствие: спека требует «MUST убираться на возврате — и на успешном, и на отказном»; носитель не убирается ни там, ни там и остаётся годным 10 минут. Повторно поданный URL возврата проходит сверку и уходит в обмен; отказ наступает только потому, что код у провайдера одноразовый — гарантия перенесена на внешнюю систему, чего спека не допускает. Тест утверждает обратное и проходит, потому что читает `w.Header()`.
- Предложение: убирать куку **до** записи ответа (как уже сделано в `Logout`); всем тестам этого файла судить по `w.Result()`.
- Остаток: `tasks.md` 5.5 обещает ещё и «повторный возврат с уже употреблённым состоянием», то есть учёт употреблённых состояний. Уборка куки закрывает переигрывание тем же браузером, но не учёт как таковой. Нужен ли учёт — вопрос владельцу, отдельно от этой правки; в код его сейчас не заказываю.
- Действие: **инлайн**
### 5. Опечатка в `[auth]` кладёт весь сервис детерминированно, и `/health` в этот момент ещё не зарегистрирован
- Файл: `main.go:220-234`; `internal/config/config.go:69-90`
- Severity: `major` · Confidence: `high`
- Найдено проходом: ops
- Оракул: эксперимент прохода — `ApplyProviderSettings` с непустым, но негодным URL → `oauth2: (providers: (0: (authURL: must be a valid URL; tokenURL: …).).)`. Проверено мной по коду: `AuthConfig.Validate` (`config.go:69-90`) сверяет **только непустоту** шести ключей; `ApplyProviderSettings` зовётся внутри хука `OnServe` **выше** регистрации `/health` и `/metrics` (`main.go:227-234` против `main.go:239+`), а ошибка хука прерывает `apis.Serve` до открытия порта (`apis/serve.go:216-269`). Далее общий shutdown останавливает бот и все три воркера.
- Последствие: пробел от шаблона или отсутствующая схема в адресе роняет сервис целиком при каждом перезапуске, и у владельца нет даже кода состояния — только текст в журнале контейнера. Это первая выкладка этой секции конфига, шесть новых ключей.
- Действие: **развилка**
**Вопрос владельцу.** Негодный адрес провайдера сегодня валит процесс молча. Что делаем:
1. проверять форму URL в `Validate()` — отказ переезжает на старт, называет ключ поимённо и виден в журнале сразу (цена: три строки, поведение «не поднимаемся с кривым входом» сохраняется);
2. регистрировать `/health` и `/metrics` **до** `ApplyProviderSettings` — сервис поднимается и честно отвечает о своём состоянии (цена: появляется состояние «сервис жив, вход сломан», которого спека не описывает);
3. и то и другое.
### 6. Отзыв доступа в Authelia не доходит до сервиса никогда: предъявитель продлевает сессию сам
- Файл: `internal/adapter/repo/pocketbase/migrations.go:126-130` (комментарий); `openspec/changes/oidc-login/specs/access/spec.md:185-188`; `docs/security.md:145`
- Severity: `major` · Confidence: `high`
- Найдено проходом: adversary
- **Оракул — мой собственный, тот же прогон:**
```
auth-refresh #1 → код=200 новый токен непуст=true
auth-refresh #2 → код=200 новый токен непуст=true
auth-refresh #3 → код=200 новый токен непуст=true
продлённое значение на /api/status → 404 (401 значило бы, что не работает)
```
Замер adversary добавляет растущий `exp` (13:32:59 → 13:33:00 → 13:33:01 → 13:33:03) и claim `refreshable=true`.
- Последствие: спека дельты `access` объявляет срок сессии «единственным, что доносит до сервиса отзыв доступа у провайдера», и на этом утверждении стоит ссылка паспорта на отзыв в Authelia как на способ остановить перерасход. Канала нет: предъявитель одного живого значения продлевает себе доступ бессрочно, никуда не входя. Аноним так не может — нужен живой токен.
- Действие: **развилка**
**Вопрос владельцу.** Что делаем с продлением сессии:
1. выключить продление (закрыть `auth-refresh` для `users`) — отзыв начинает доходить за семь суток, как обещает норма (цена: человек перевходит раз в неделю);
2. сверяться с провайдером по расписанию (цена: новая связь с Authelia, обработка её недоступности, вне текущего scope);
3. принять как есть и **сейчас же** убрать из `specs/access/spec.md`, `docs/security.md` и комментария шага схемы утверждение про канал отзыва (цена: паспорт теряет способ остановить перерасход, и это надо записать явно).
При любом варианте утверждение о канале отзыва сегодня ложно — правка нормы обязательна во всех трёх.
### 7. Сердцевина входа не исполнялась ни одним тестом: обмен кода, выдача куки сессии и проверка конфига
- Файл: `internal/controller/http/auth.go:199-252, 269-279`; `internal/config/config.go:69-90`; `internal/controller/http/auth.go:174-195`
- Severity: `major` · Confidence: `high`
- Найдено проходами: autotests, specs (сведены три находки: покрытие `exchange` и `setSessionCookie`, непокрытая `AuthConfig.Validate`, непокрытые ветки отказа `Logout` — причина одна: проверки останавливаются раньше сердцевины)
- **Оракул — мой собственный прогон покрытия:**
```
auth.go:199 exchange 0.0%
auth.go:269 setSessionCookie 0.0%
auth.go:128 Callback 54.5%
auth.go:174 Logout 63.6%
config.go:69 Validate 0.0% (у пакета internal/config нет файла тестов вовсе)
```
- Последствие: единственный код, который меняет код провайдера на сессию, и код, который выдаёт сессию браузеру, не проверены ни на успех, ни на отказ. Спека объявляет имя `transcriber_session` нормативным именно потому, что «тест, ставящий и читающий одно и то же имя, этого не замечает» — потеря `HttpOnly`/`Secure`/`SameSite`, смена имени или срока пройдут гейт зелёными. Класс не новый: `docs/review.md`, журнал, записи 2026-08-10 («тесты http-обработчика ни разу не были зелёными») и 2026-08-11 («проверка приёма не могла упасть») — тот же род, третье появление.
- Предложение: поднять подставного провайдера `httptest` и пройти `Callback` до `302` + куки, судя по `w.Result().Cookies()`, сверяя литерал имени, флаги и `MaxAge`; завести файл тестов `internal/config`; закрыть обе ветки отказа `Logout`.
- Действие: **инлайн**
---
## Гипотезы без доказательства
- **Чужая страница гасит сессию.** `POST /auth/logout` сессии не требует и на кросс-сайтовом запросе отвечает `200` с `Set-Cookie Max-Age=-1`. Браузера в прогоне нет, применение `SameSite=Lax` к POST не проверялось. Confidence `low``minor` (adversary).
- **Две учётные записи провайдера с одной почтой сливаются в одну нашу.** Обмен ищет по `sub`, не найдя — по почте. Требует, чтобы Authelia выдала двум субъектам один адрес; живого провайдера нет. Confidence `medium`, без оракула → выше `major` не поднимается и в основные секции не идёт (adversary).
- **Претензия `picture` тянет до 32 МиБ на вход.** `MappedFields.AvatarURL` заставляет библиотеку скачать URL из ответа провайдера. Confidence `low``minor` (adversary).
- **Анонимный `POST /api/collections/users/request-verification` заставляет сервис слать почту.** Почта не настроена, потолка запросов нет. Confidence `medium``minor` (adversary).
- **Вошедший читает, правит и удаляет свою запись `users`** (системные правила `id = @request.auth.id` оставлены), тогда как `docs/security.md` утверждает, что правила коллекций пусты и отдают `403`. Своим прогоном не проверял — бюджет попытки израсходован на пункты 1–4, 6, 7 (adversary).
## Promote candidates
- **Проверка ответа судит по `w.Result()`, а не по `w.Header()`.** Ровно этим различием держался ложно зелёный тест пункта 4. Место — `docs/review.md`, «Типовые узлы», абзац про способность проверки упасть: род механизируем grep'ом и уже дал дефект.
- **`docs/.docs.json` объявляет механизированную сверку миграций, которой нет.** Ключ `"migrations": "migrations"`, а `check_migrations` фильтрует изменённые файлы по префиксу `migrations/`; такого каталога в репозитории нет (`git ls-files | grep -c "^migrations/"``0`), шаги схемы лежат в `internal/adapter/repo/pocketbase/`. Шаг гейта зелен при изменённом `migrations.go` и нетронутом `docs/database.md`. Правило есть, механизации нет: `"migrations": "internal/adapter/repo/pocketbase"` либо снять пометку «механизировано» в `docs/conventions/README.md`.
- **Откат бинаря не откатывает шаг схемы** — назвать в `CLAUDE.md`, «Необратимое», рядом с «применённой миграцией» (эксперимент ops: два прогона `New()` разных ревизий над одним каталогом, `files.file.Protected=true` сохраняется).
- **Новый секрет `auth.client_secret` не назван ни в перечне секретных полей `docs/conventions/config.md:94-96`, ни в инварианте `CLAUDE.md`.** Перечень поимённый и закрытый — это и делает его правилом.
- **Ввод пользователя в журнале приводится к закрытому перечню, а не пишется как есть** — обобщение приёма задачи `no-user-filename-in-log`. Повод: `providerError` из query уходит в `logger.Warn` целиком (падающий тест adversary: 204806 байт запроса → 204902 байта журнала).
- **Имя провайдера `oidc` — это значение в связи учётной записи с провайдером.** Смена имени после выкладки отвяжет всех заведённых людей. `CLAUDE.md`, «Необратимое», знает имя ключа конфига, но не знает имени провайдера.
## Границы покрытия
### План: темы, глубины, дома
| тема | дом | глубина | закрыта |
|---|---|---|---|
| requirements | дельта-спеки `access`, `intake`, `storage` | разбор | specs |
| autotests | `CLAUDE.md`, «Гейт» | — | autotests |
| conventions | `docs/conventions/{config,database,errors,logging}.md` | разбор | code |
| architecture | `docs/architecture.md`; источник `docs/passport.md` | доказательство | architecture |
| security | `docs/security.md` | доказательство | adversary |
| operations | `docs/architecture.md` «Эксплуатация»; источник `docs/database.md` | доказательство | ops |
Тем без дома нет. Тем без отчёта нет.
### Что запускалось и что нет
- Запущены на метке `large`, режим «по графу»: `specs`, `code`, `architecture`, `adversary`, `ops`, `autotests`.
- `basics` не запускался: решение `review-scope` — своих тем сверх ядра нет. Это решение, а не бюджет.
- Триаж запускал сам: `task gate BASE=origin/master` (exit 0), покрытие `internal/controller/http` и `internal/config`, шесть собственных тестов-оракулов через `go test -overlay=…` (в дерево проекта не писал), чтение исходников PocketBase v0.39.10 и `docs.py`.
### Чего запущенные проходы не могли проверить в принципе
- Живой вход у настоящей Authelia не воспроизводился ни одним проходом и мной: провайдера нет, поднять нечем. Всё, что известно о протоколе, получено против подставного провайдера и исходников библиотеки.
- Поведенческая верификация живым запуском сервиса не проводилась: адаптер Telegram роняет старт при негодном токене, а боевым токеном запускаться запрещено (`CLAUDE.md`, «Запреты»).
- Поведение браузера с куками — применение `SameSite`, приём `Set-Cookie` кросс-сайтом — не проверялось: браузера в прогоне нет.
- Панель `/_/` в тестовом роутере отсутствует (её вешает `apis.Serve`); закрывает её обратный прокси, то есть выкладка, а она вне модели.
- `govulncheck` дал две уязвимости (GO-2026-6061 grpc, GO-2026-5764 aws eventstream/s3), обе унаследованы от `origin/master`, в цепочке распознавания, не в этом коде.
- Замер утечки обработчиков сделан на 50 и 3000 итерациях в тесте; поведение под настоящим потоком не замерялось ничем.
### Что осталось целиком на человеке
Из `docs/review.md`, «Недоступно проверке». Списки не сливаются: при следующем промахе первый вопрос — «не тот ли это класс, который мы перестали проверять».
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах — SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки — проект работает на единицах записей в день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата отдан внешней программе, и она вне нашей границы.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe` — проверки приёма получают длительность от подставного источника; своего теста у `adapter/metaviewer/ffmpeg` нет (`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`).
**Сверх проектного перечня — общее:** история инцидентов, поведение под реальным потоком, поведение внешних систем в их версиях (здесь — конкретной Authelia владельца и её правила на этого клиента), завязка потребителей на текущее поведение и вопрос «а нужна ли эта функциональность вообще».
### Каких документов проекта не хватило
- `docs/review.md`, «Типовые ложноположительные»: раздел есть и непуст (4 пункта), но **ни один не относится к области этого изменения** — все четыре про конвейер, очередь и открытый HTTP. Отсев для темы входа и веб-поверхности шёл по общим критериям, проектных ложноположительных этой области я не знал.
- `docs/review.md`, «Вопросы по темам»: вопросов по теме входа нет — раздел писан до появления этой поверхности. Вопросы `security` про журнал и метки применялись, вопросы про конвейер неприменимы.
- `docs/conventions/web-ui.md` существует (104 строки), требование к языку пользовательского текста в нём есть; конвенции по форме HTTP-ответов входа (коды, тело отказа) в нём нет — поэтому «Login failed» судилось только по требованию русского языка, а форма ответа не судилась ничем.
- Прочих пробелов проходы не заявляли; `CLAUDE.md` с разделом инвариантов, `docs/security.md`, `docs/architecture.md`, `docs/database.md`, `docs/passport.md` и четыре конвенции были на месте и использовались.
### Потолки проходов
- `code`: 4 из 4 — срез сработал. За срезом остались два рода, названы самим проходом: (1) язык пользовательского текста на новой публичной поверхности («Login failed», «Logout failed» по-английски против `web-ui.md`); (2) продолжение известных «Расхождений» новым кодом — `msg` предложением, «failed to» в обёртках, третья точка трансляции доменной ошибки в ответ.
- `architecture`: 3 из 3 — потолок выбран полностью, за срезом ничего не заявлено.
- `specs`, `adversary`, `ops`, `autotests`: **свои потолки не сообщили.** Это находка о прогоне: сколько находок каждый показал против своего лимита и что осталось за срезом, установить нечем. Из четверых пришло 5, 6, 3 и 3 находки — то есть по крайней мере `adversary` шёл близко к типичному лимиту, и молчание о срезе здесь дороже всего.
### Срезано потолком триажа — названо поимённо
Тринадцать позиций с оракулами не попали в секции 1–2 и **к правке не заказаны**. Строки ниже — не задание; они здесь потому, что ничего не выбрасывается молча.
1. **G** (`major`, architecture): `SessionDuration` питает две точки разной обратимости — `AuthToken.Duration` в применяемом однажды шаге схемы и `MaxAge` куки, перечитываемый каждый подъём. Первая же правка константы уедет только в куку. Оракул — инвариант `CLAUDE.md` «Миграция, уехавшая на сервер, не переписывается» и собственный довод `design.md`. Правка (перенести `AuthToken.Duration` в `ApplyProviderSettings`) стоит трёх строк **сейчас** и требует нового шага схемы **после** выкладки: окно закрывается мерджем. Первый кандидат на восьмое место.
2. **M** (`minor`): код провайдера оседает в журнале запросов хранилища на пять суток. Проверено мной по исходникам: `activityLogger` пишет `event.Request.URL.RequestURI()` (со строкой запроса) полем `url` (`apis/middlewares.go:391,422`), ретеншен `MaxDays: 5` (`core/settings_model.go:158`), проект его не переопределяет. Спека требует «код MUST не попадать в журнал»; наш `slog` чист, и тест смотрит только в него.
3. **N** (`minor`): аноним пишет в журнал контейнера мегабайты (`?error=<1 МиБ>``Warn` целиком; падающий тест adversary: 204806 → 204902 байта). Журнал — единственное место наблюдения двух инвариантов о молчаливой потере задачи.
4. **L** (`minor`): `down202608120001` падает на валидации — проверено моим прогоном: `Save(users)` с `Duration=0``authToken: (duration: cannot be blank.)`. Путь «шаг обратим своим down» из `design.md` не работает, снятие `Protected` не выполняется вовсе, а сам `down` возвращает `CreateRule = ""` — открытую регистрацию, то самое, что чинит `up`.
5. **O** (`minor`): `docs/database.md:98-101` противоречит коду («поле файла не помечено защищённым»), коллекция `users` не описана, три новых числа (7 суток, 10 минут, 15 секунд) не попали в таблицу «Настройки с числовым значением». Оракул — `docs/conventions/database.md`, «Прочее».
6. **Q** (`minor`): уровни журнала не по адресату — отказ человека у Authelia даёт `WARN`, возврат по старой ссылке `ERROR`. Оракул — `docs/conventions/logging.md`, «Уровни», дословно. Плюс ни одна новая запись не несёт поля `capability`.
7. **S** (`minor`): начало входа собрано руками поверх того, что библиотека экспортирует (`InitProvider`, `BuildAuthURL`, `PKCE`) — протокол разрезан пополам, `auth_url` и `client_id` получают второго потребителя мимо настроек коллекции.
8. **U** (`minor`): пустая секция `[auth]` роняет сервис целиком, тогда как пустой токен бота лишь деградировал до «работает без Telegram». Асимметрия осознанная, но в рантбуке выкладки не названа.
9. **V** (`minor`): новое безусловное условие отказа старта по шести ключам живёт в `tasks.md` и конвенции, но не в норме.
10. **Поведение вне спеки** (8 пунктов от `specs`, ни один не заказан): `stateCookieMaxAge` 10 минут; редирект успешного входа на `/`; выход без сессии отвечает `200`; отказ загрузки записи при выходе оставляет куку; состав `scope`; склейка `authURL` через `?`/`&`; `ApplyProviderSettings` перетирает список провайдеров целиком (провайдер, заведённый владельцем в панели, исчезнет при подъёме); `down` не возвращает `OTP.Enabled`.
11. **Границы спеки** (5 пунктов): два входа одновременно в двух вкладках; поведение при отказе приведения настроек провайдера; остальная поверхность аутентификации хранилища (`confirm-password-reset`, `request-verification`, `confirm-verification`, `request-email-change` — перечень «что выключено» в спеке закрыт тремя пунктами, а поверхность шире); отзыв доступа внутри срока сессии; «владелец закрывает чужие сессии немедленно» существует только как ручная правка в панели.
12. **Имя куки состояния `transcriber_login` в спеке не нормировано**, в отличие от `transcriber_session`; **комментарий шага `up202608110001`** до сих пор утверждает «Защищённым поле не помечено намеренно» — после выкладки два шага противоречат друг другу в исходнике.
13. **Литерал `"users"` живёт в четырёх местах** при существующих константах имён коллекций (`FilesCollection`, `JobsCollection`).
**Выброшено как вкусовщина (3):** имена ключей `auth.*` как таковые (`CLAUDE.md` уже относит имя ключа конфига к необратимому — повторение записанного, а не находка); замечание про JSON-404 катч-олла на `/` (поведение хранилища, не этого кода, последствие не названо); предложение обобщить сборку адреса согласия сверх пункта S (работающий частный случай, последствия сверх S нет).
### Четыре строки, которых не принесёт ни один проход
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон его не открывает. Расхождение изменения с записанным решением ловит скилл `av-dev-docs:healthcheck`, а не ревью. В этом изменении решений владельца названо минимум три (архив бессрочный, петля обмена внутри процесса, семь суток сессии) — ни одно против ADR не сверено.
2. **Записанные наблюдения проекта не использовались.** `docs/research/` прогон не открывал. Всякое число в этом отчёте снято на этом прогоне и сопровождено командой или выводом; чисел из записанных наблюдений здесь нет.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.** Различение «идиоматично против распространено» не спрашивает никто с тех пор, как упразднён проход про идиоматичность; к новому коду (`auth.go`, `session.go`, `provider.go`) это относится целиком.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет.** Проход независимой реализации снят по стоимости, а не по замеру. «Не знаю, чего не знаю» про форму решения входа — а форма здесь нащупывалась по ходу, это и подняло метку до `large` — не достаёт никто.
Метка `large`, поэтому пятая строка (про `small`) не применяется — дома всех трёх тем `security`, `operations`, `architecture` открывались.
@@ -0,0 +1,298 @@
## ADDED Requirements
### Requirement: Вход через внешнего провайдера
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
Своей регистрации, своей формы пароля и своего восстановления доступа сервис
MUST не заводить: учётные записи держит провайдер, и это граница домена из
паспорта.
Вход начинается собственным адресом сервиса: он уводит человека к провайдеру.
Провайдер возвращает человека на адрес возврата, и сервис MUST обменять
принесённый код на учётную запись **средствами хранилища**, а не разбором ответа
провайдера своими руками — так решено 2026-08-11. Учётная запись, которой ещё
нет, заводится сама; связь её с внешним провайдером ведёт хранилище.
Возврат от провайдера MUST быть проверен на подмену: сервис сверяет пришедшее
состояние с тем, что сам выдал, и отвергает возврат, чьё состояние он не
выдавал. Без этой сверки вход принимает чужой код.
Обмен кода MUST быть ограничен во времени: у обращения к провайдеру есть
таймаут, и по его истечении вход кончается отказом. Молчащий провайдер иначе
держит обработчик возврата открытым до упора, а «провайдер медленный»
становится неотличим от «провайдер отказал».
Ни код, принесённый от провайдера, ни секрет клиента MUST не попадать в журнал.
Адреса нормативны: вход — `GET /auth/login`, возврат — `GET /auth/callback`,
выход — `POST /auth/logout`. Они лежат вне `/api/`, потому что это пространство
поделено с собственными адресами хранилища. Выход берёт `POST` намеренно: по
`GET` его срабатывание уносится переходом по чужой ссылке.
#### Scenario: Человек входит впервые
- **GIVEN** провайдер настроен и учётной записи в сервисе ещё нет
- **WHEN** человек проходит вход и возвращается с кодом провайдера
- **THEN** учётная запись заводится, а сессия открывается
- **AND** дальнейший запрос к API от этой сессии проходит
Состояние и проверочный код PKCE сервис SHALL хранить у браузера — тем же
носителем, что и сессию, и с теми же признаками защиты. Носитель MUST жить не
дольше одного входа, MUST убираться на возврате — и на успешном, и на отказном,
— а состояние MUST быть одноразовым: возврат, чьё состояние уже употреблено,
отвергается наравне с невыданным. Проверочный код PKCE обязателен: обмен кода
средствами хранилища его требует.
Носитель без защиты соединения отменял бы то, ради чего заведён: перехваченный
проверочный код обесценивает PKCE, а подставленное состояние — сверку подмены.
#### Scenario: Признаки носителя состояния
- **WHEN** сервис уводит человека к провайдеру
- **THEN** носитель состояния и проверочного кода несёт те же признаки защиты,
что и кука сессии
#### Scenario: Возврат нельзя переиграть
- **GIVEN** человек уже вернулся от провайдера и сессия открылась
- **WHEN** тот же возврат с тем же состоянием приходит второй раз
- **THEN** сессия не открывается, а ответ несёт отказ
#### Scenario: Возврат с чужим состоянием
- **WHEN** на адрес возврата приходит код с состоянием, которого сервис не
выдавал
- **THEN** сессия не открывается, а ответ несёт отказ
- **AND** учётная запись не заводится
#### Scenario: Провайдер отказал
- **WHEN** провайдер возвращает человека с ошибкой вместо кода
- **THEN** сессия не открывается, а ответ несёт отказ
### Requirement: Иных способов открыть сессию нет
Сервис SHALL оставить вход у провайдера единственным способом завести учётную
запись и получить сессию. Собственное создание записи в коллекции пользователей,
вход по паролю, вход по одноразовому коду и восстановление доступа MUST быть
выключены настройкой коллекции.
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
хранилища заводит коллекцию пользователей с открытым созданием записи и
включённым входом по паролю, и без этого требования закрытие приёма обходится
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
настоящему входу с этим адресом.
#### Scenario: Завести учётную запись самому нельзя
- **WHEN** анонимный запрос создаёт запись в коллекции пользователей
- **THEN** ответ несёт отказ, а записи не появляется
#### Scenario: Вход паролем недоступен
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
- **THEN** ответ несёт отказ, а сессия не открывается
#### Scenario: Восстановление доступа недоступно
- **WHEN** запрос просит восстановление пароля или одноразовый код
- **THEN** ответ несёт отказ
### Requirement: Сессия предъявляется кукой
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
(`SameSite=Lax` или строже).
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
Сервис MUST перекладывать значение куки в этот заголовок **только когда
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
кукой получал бы не то, что предъявил на собственных адресах хранилища.
Область действия слоя MUST быть ограничена адресами приложения — приёмом записи
и опросом готовности. Собственная поверхность хранилища под него не подпадает:
часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и
расширение слоя на всё сняло бы эту защиту молча.
#### Scenario: Кука открывает доступ
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой и без заголовка
- **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом
- **WHEN** сервис ставит куку сессии
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
#### Scenario: Предъявленный заголовок побеждает куку
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **THEN** проверку проходит значение заголовка, а не куки
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
ни код, принесённый от провайдера, ни секрет клиента, ни адрес почты
пользователя. Записанное значение сессии MUST читаться как ключ к чужому
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
собранные логи, откуда её не убрать.
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
#### Scenario: Значения сессии нет в журнале
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой
- **THEN** значение сессии не встречается ни в одной журнальной записи
#### Scenario: Адреса почты нет в журнале
- **WHEN** человек проходит вход и учётная запись заводится
- **THEN** адрес его почты не встречается ни в одной журнальной записи
### Requirement: Сессия переживает перезапуск сервиса
Сервис SHALL держать сессию годной после своего перезапуска: подпись сессии MUST
опираться на секрет, лежащий в хранилище, а не на значение, заведённое в памяти
при старте. Иначе всякая выкладка выкидывает всех вошедших молча.
#### Scenario: Прежняя кука годна после перезапуска
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** сервис поднимается заново на том же хранилище
- **THEN** запрос с прежней кукой проходит
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
Сервис SHALL назначать срок жизни сессии сам — **семь суток**, числом в настройке
коллекции и тем же числом в сроке жизни куки. Умолчание хранилища MUST не
применяться: оно даёт пять суток, и это число никем не выбрано.
Срок здесь — единственное, что доносит до сервиса **отзыв доступа у
провайдера**. Сессия выдана однажды, и к провайдеру сервис больше не ходит:
человек, которому Authelia закрыла доступ, работает до истечения своей сессии.
Паспорт опирается на отзыв в Authelia как на способ остановить того, кто
тратит слишком много, — значит срок сессии и есть цена этой остановки.
**Отсюда запрет на продление.** Хранилище выдаёт сессию продлеваемой:
предъявитель меняет своё значение на новое, с новым сроком, и делает это сколько
угодно раз, никуда не входя. Сервис SHALL закрыть продление — иначе срок жизни
сессии не значит ничего, а канал отзыва перестаёт существовать вовсе.
Владелец MUST иметь способ закрыть чужие сессии немедленно, не дожидаясь срока.
#### Scenario: Сессия не продлевает саму себя
- **GIVEN** человек вошёл и получил сессию
- **WHEN** этой же сессией он просит продлить её
- **THEN** ответ несёт отказ, а нового значения в нём нет
#### Scenario: Сессия истекает назначенным сроком
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** назначенный срок прошёл
- **THEN** запрос с этой кукой получает отказ
#### Scenario: Владелец закрывает чужую сессию
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** владелец обесценивает сессии этой учётной записи
- **THEN** запрос с прежней кукой получает отказ
### Requirement: Выход прекращает доступ
Сервис SHALL закрывать доступ по выходу немедленно: выход MUST обесценивать
выданные этой учётной записи сессии на стороне сервиса, а не только убирать куку
у браузера. Куку сервис при этом MUST убрать тоже.
Одной уборки куки мало: сессия предъявляется значением, и унесённое значение
продолжало бы открывать доступ до самого своего истечения.
Порядок обязателен: сперва обесценивание, потом уборка куки. При обратном
порядке выход, разошедшийся с одновременным входом, оставляет годную сессию, а
человек уверен, что вышел.
#### Scenario: После выхода прежняя кука не работает
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он выходит, а затем шлёт запрос к API с прежней кукой
- **THEN** запрос получает отказ
#### Scenario: Выход убирает куку
- **WHEN** человек выходит
- **THEN** ответ убирает куку сессии у браузера
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого пропустил провайдер, и своей проверки допуска
MUST не делать. Кто допущен, определяет правило провайдера на этого клиента —
настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
#### Scenario: Пропущенный провайдером получает доступ
- **WHEN** человек проходит вход у провайдера и возвращается с кодом
- **THEN** учётная запись заводится, а доступ открывается
- **AND** сервис не спрашивает у ответа провайдера ничего сверх того, что нужно
для заведения записи
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` без сессии. Ни у пробы
здоровья, ни у сборщика метрик сессии нет, и требование входа остановило бы
наблюдение за сервисом.
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
адреса не несут.
#### Scenario: Проба здоровья доступна анонимно
- **WHEN** запрос приходит на `GET /health` без сессии
- **THEN** ответ имеет код `200`
#### Scenario: Метрики доступны анонимно
- **WHEN** запрос приходит на `GET /metrics` без сессии
- **THEN** ответ имеет код `200`
### Requirement: Секрет провайдера живёт в конфиге
Сервис SHALL брать адреса провайдера, идентификатор клиента и секрет клиента из
конфига. Секрет MUST не попадать ни в журнал, ни в ответ, ни в git; настройки
провайдера в хранилище MUST приводиться к значениям конфига при каждом запуске,
а не заводиться однажды шагом схемы.
Причина второго требования в необратимости шага схемы: применённый шаг не
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
вовсе — вход сломался бы после ротации.
#### Scenario: Секрета нет в журнале
- **WHEN** сервис поднимается с настроенным провайдером
- **THEN** значение секрета не встречается ни в одной журнальной записи
#### Scenario: Смена секрета доезжает до хранилища
- **GIVEN** сервис уже поднимался с прежним секретом
- **WHEN** секрет в конфиге заменён и сервис поднят заново
- **THEN** настройки провайдера в хранилище несут новое значение
@@ -0,0 +1,104 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
сохранена и получить заведённую под неё задачу расшифровки в состоянии
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
полем `status`.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча. Появление отказа без
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
видно было анонимно.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни задачи не заводится
- **AND** тело ответа не несёт данных задачи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу
`GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
`job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
пока текста нет: пустая строка на месте отсутствующего текста читается как
«расшифровка пуста».
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
кодам ответа перебирается список заведённых задач.
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
#### Scenario: Задача найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние заведённой задачи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
#### Scenario: Сессии нет
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
состояние по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Задачи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
@@ -0,0 +1,77 @@
## MODIFIED Requirements
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
здесь нет — его заводит отдельная задача.
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
недосмотр.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **AND** забирающий предъявил сессию и взял по ней токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Без сессии файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают без сессии
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
#### Scenario: Конвейер читает файл без сессии
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из файловой системы хранилища и шаг проходит
@@ -0,0 +1,121 @@
## 1. Конфигурация
- [x] 1.1 Завести секцию конфига под провайдера: адрес авторизации, адрес обмена
кода, адрес сведений о пользователе, идентификатор клиента, секрет клиента,
адрес возврата
- [x] 1.2 Дописать те же ключи в `config.dist.toml` с пустыми значениями и
комментарием, откуда их брать
- [x] 1.3 Проверить, что незаполненный конфиг роняет старт с внятным
сообщением, а не поднимает сервис с молча выключенным входом
## 2. Провайдер в хранилище
- [x] 2.1 Завести шаг схемы, включающий провайдера `oidc` у коллекции
пользователей; файл шага именуется по правилу проекта и не переписывает
прежние
- [x] 2.2 Тем же шагом закрыть создание записи в коллекции пользователей и
выключить вход по паролю, одноразовый код и восстановление доступа: умолчание
библиотеки оставляет их открытыми
- [x] 2.3 Тем же шагом назначить срок жизни сессии числом вместо умолчания в
пять суток
- [x] 2.4 При подъёме сервиса приводить настройки провайдера к значениям
конфига: адреса, идентификатор клиента, секрет
- [x] 2.5 Убедиться, что секрет не попадает в журнал ни при подъёме, ни при
ошибке настройки
## 3. Вход, возврат, выход
- [x] 3.1 `GET /auth/login`: завести состояние и проверочный код PKCE, положить
во временную куку с теми же признаками, что у сессионной, увести на адрес
авторизации провайдера
- [x] 3.2 `GET /auth/callback`: сверить состояние с выданным, отвергнуть
несовпавшее и уже употреблённое, обменять код средствами хранилища с
таймаутом, поставить куку сессии, убрать временную
- [x] 3.3 Кука сессии зовётся `transcriber_session` и несёт `HttpOnly`,
`SameSite` и `Secure`; последний берётся из конфига с умолчанием «включено»
- [x] 3.4 `POST /auth/logout`: сперва обесценить ключ токенов учётной записи,
затем убрать куку сессии
- [x] 3.5 Промежуточный слой перекладывает значение куки в заголовок
`Authorization`, только когда заголовка нет, и только на адресах приложения
## 4. Закрытие API
- [x] 4.1 `POST /api/audio` и `GET /api/status/{id}` требуют узнанного
отправителя; отказ — код `401`
- [x] 4.2 Отказ по отсутствию сессии наступает раньше чтения тела запроса
- [x] 4.3 `GET /health` и `GET /metrics` остаются доступны без сессии
- [x] 4.4 Отказ без сессии одинаков для заведённой и неизвестной задачи
- [x] 4.5 Пометить поле файла защищённым тем же шагом схемы: ссылка на файл
перестаёт быть правом пройти по ней и требует сессии
- [x] 4.6 Убедиться, что конвейер по-прежнему читает файл из файловой системы, а
панель администратора его по-прежнему скачивает
## 5. Проверки
- [x] 5.1 Тест: оба эндпоинта API без куки отдают `401` и не заводят задачу;
`/health` и `/metrics` без куки отдают `200`
- [x] 5.2 Тест: запрос с прежней кукой проходит после пересоздания сервера
- [x] 5.3 Тест: после выхода запрос с прежней кукой получает отказ
- [x] 5.4 Тест: ни значение секрета, ни значение сессии, ни адрес почты не
встречаются в записанном выводе логгера
- [x] 5.5 Тест: возврат с невыданным состоянием не открывает сессию и не заводит
учётную запись; повторный возврат с уже употреблённым — тоже
- [x] 5.6 Тест: анонимное создание записи в коллекции пользователей и вход по
паролю получают отказ
- [x] 5.7 Тест: запрос с кукой и заголовком разом проходит по заголовку
- [x] 5.8 Тест: ссылка на файл записи без сессии отдаёт отказ, а с сессией —
тот же файл
- [x] 5.9 `task gate` зелёный целиком
## 6. Документация
- [x] 6.1 `docs/security.md`: первая строка периметра переписана под новый
периметр; названо новое место жизни секрета клиента — база; в разделе «Что
разграничивает доступ» записано, что допуск держит правило провайдера вне
репозитория, а сервис своей проверки не делает
- [x] 6.2 `docs/architecture.md`: capability `access` внесена в перечень
- [x] 6.3 `docs/conventions/config.md`: новые ключи конфига и расхождения
образца, если появились
## Критерии приёмки
Перенесены из записи задачи `oidc-login` дословно. Файл задачи закрытие удалит —
критерии обязаны его пережить.
- Запрос к `POST /api/audio` и `GET /api/status/:id` без сессии получает отказ, а
не заводит задачу и не отдаёт текст. Оракул — тест на обоих эндпоинтах без
куки: код ответа 401 либо 302 на вход, тело без данных задачи. Тот же тест
проверяет вторую сторону границы: `GET /health` и `GET /metrics` без куки
отвечают 200.
- Сессия переживает перезапуск приложения. Оракул — тест: запрос с прежней кукой
после пересоздания сервера проходит.
- Выход из сессии закрывает доступ. Оракул — тест: после выхода тот же запрос
получает отказ.
- Секрет провайдера не попадает ни в лог, ни в ответ. Оракул — тест на отсутствие
значения секрета в записанном выводе логгера.
- Первая строка `docs/security.md` описывает новый периметр. Оракул — `task
gate`, шаг `docs.py check`.
**Сужение против исходного критерия, объявленное ревью дизайна:** код отказа —
`401`, без допуска `302`. Оба адреса судят внешнюю программу, а не браузер, и
`302` для программы означает «получил 200 со страницей входа»; `curl -L` при нём
уходит постить тело на страницу входа провайдера. Дельта-спека `intake`
нормирует `401` двумя сценариями.
## Рубрика ревью дизайна
Порождена проходом `rubric` до чтения артефактов; сюда переносятся пункты,
ставшие приёмочными сверх критериев задачи.
- Отказ без сессии наступает раньше чтения тела и раньше обращения к хранилищу.
- Форма отказа одна и та же у существующего и несуществующего ресурса.
- Ни одно значение, дающее доступ, не печатается: код провайдера, секрет
клиента, значение сессии, адрес почты.
- Правило доступа читается как «всё требует сессии, кроме перечня», а перечень
открытого живёт в одном месте.
- Все прочие способы получить сессию к тому же субъекту выключены либо названы
поимённо с обоснованием, почему они не обход.
- Возврат от провайдера отвергается без состояния, с чужим, с истёкшим и с уже
употреблённым — до обмена кода.
- У обращения к провайдеру есть таймаут, и «медленный» отличается от «отказал».
- Исход входа и выхода не зависит от порядка параллельных операций.
+347
View File
@@ -0,0 +1,347 @@
# access Specification
## Purpose
Кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера
OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются
открытыми.
Разграничения записей по владельцу здесь **нет**: всякий вошедший видит ровно
то же, что видел прежде аноним. Его заводит отдельная задача, и до неё сессия
отвечает только на вопрос «узнан ли пришедший», а не «чьё он смотрит».
Вход из Telegram эта capability не нормирует: бот проверяет отправителя своим
белым списком, и с учётной записью приложения тот список не связан.
## Requirements
### Requirement: Вход через внешнего провайдера
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
Своей регистрации, своей формы пароля и своего восстановления доступа сервис
MUST не заводить: учётные записи держит провайдер, и это граница домена из
паспорта.
Вход начинается собственным адресом сервиса: он уводит человека к провайдеру.
Провайдер возвращает человека на адрес возврата, и сервис MUST обменять
принесённый код на учётную запись **средствами хранилища**, а не разбором ответа
провайдера своими руками — так решено 2026-08-11. Учётная запись, которой ещё
нет, заводится сама; связь её с внешним провайдером ведёт хранилище.
Возврат от провайдера MUST быть проверен на подмену: сервис сверяет пришедшее
состояние с тем, что сам выдал, и отвергает возврат, чьё состояние он не
выдавал. Без этой сверки вход принимает чужой код.
Состояние и проверочный код PKCE сервис SHALL хранить у браузера — тем же
носителем, что и сессию, и с теми же признаками защиты. Носитель MUST жить не
дольше одного входа, MUST убираться на возврате — и на успешном, и на отказном,
— а состояние MUST быть одноразовым: возврат, чьё состояние уже употреблено,
отвергается наравне с невыданным. Проверочный код PKCE обязателен: обмен кода
средствами хранилища его требует.
Носитель без защиты соединения отменял бы то, ради чего заведён: перехваченный
проверочный код обесценивает PKCE, а подставленное состояние — сверку подмены.
Уборка носителя MUST происходить до записи ответа. Отложенная не работает вовсе:
заголовки фиксируются в момент, когда ответ начинают писать, и позднейшая правка
до браузера не доезжает.
Обмен кода MUST быть ограничен во времени: у обращения к провайдеру есть
таймаут, и по его истечении вход кончается отказом. Молчащий провайдер иначе
держит обработчик возврата открытым неограниченно долго, а «провайдер медленный»
становится неотличим от «провайдер отказал».
Ни код, принесённый от провайдера, ни секрет клиента MUST не попадать в журнал.
Адреса нормативны: вход — `GET /auth/login`, возврат — `GET /auth/callback`,
выход — `POST /auth/logout`. Они лежат вне `/api/`, потому что это пространство
поделено с собственными адресами хранилища. Выход берёт `POST` намеренно: по
`GET` его срабатывание уносится переходом по чужой ссылке.
#### Scenario: Человек входит впервые
- **GIVEN** провайдер настроен и учётной записи в сервисе ещё нет
- **WHEN** человек проходит вход и возвращается с кодом провайдера
- **THEN** учётная запись заводится, а сессия открывается
- **AND** дальнейший запрос к API от этой сессии проходит
#### Scenario: Признаки носителя состояния
- **WHEN** сервис уводит человека к провайдеру
- **THEN** носитель состояния и проверочного кода несёт те же признаки защиты,
что и кука сессии
#### Scenario: Возврат нельзя переиграть
- **GIVEN** человек уже вернулся от провайдера и сессия открылась
- **WHEN** тот же возврат с тем же состоянием приходит второй раз
- **THEN** сессия не открывается, а ответ несёт отказ
#### Scenario: Возврат с чужим состоянием
- **WHEN** на адрес возврата приходит код с состоянием, которого сервис не
выдавал
- **THEN** сессия не открывается, а ответ несёт отказ
- **AND** учётная запись не заводится
#### Scenario: Провайдер отказал
- **WHEN** провайдер возвращает человека с ошибкой вместо кода
- **THEN** сессия не открывается, а ответ несёт отказ
### Requirement: Иных способов открыть сессию нет
Сервис SHALL оставить вход у провайдера единственным способом завести учётную
запись и получить сессию. Собственное создание записи в коллекции пользователей,
вход по паролю, вход по одноразовому коду и восстановление доступа MUST быть
выключены настройкой коллекции.
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
хранилища заводит коллекцию пользователей с открытым созданием записи и
включённым входом по паролю, и без этого требования закрытие приёма обходится
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
настоящему входу с этим адресом.
Закрытие MUST не отменять заведения записи самим входом: запись при первом входе
заводит внутренний запрос обмена, и правило, отвергающее его наравне с
посторонним, оставляет сервис без единого способа войти.
#### Scenario: Завести учётную запись самому нельзя
- **WHEN** анонимный запрос создаёт запись в коллекции пользователей
- **THEN** ответ несёт отказ, а записи не появляется
#### Scenario: Вход у провайдера запись заводит
- **GIVEN** учётной записи в сервисе ещё нет
- **WHEN** человек проходит вход у провайдера
- **THEN** учётная запись появляется
#### Scenario: Вход паролем недоступен
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
- **THEN** ответ несёт отказ, а сессия не открывается
#### Scenario: Восстановление доступа недоступно
- **WHEN** запрос просит восстановление пароля или одноразовый код
- **THEN** ответ несёт отказ
### Requirement: Сессия предъявляется кукой
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
(`SameSite=Lax` или строже).
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
Сервис MUST перекладывать значение куки в этот заголовок **только когда
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
кукой получал бы не то, что предъявил на собственных адресах хранилища.
Область действия слоя MUST быть ограничена адресами приложения — приёмом записи
и опросом готовности. Собственная поверхность хранилища под него не подпадает:
часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и
расширение слоя на всё сняло бы эту защиту молча.
#### Scenario: Кука открывает доступ
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой и без заголовка
- **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом
- **WHEN** сервис ставит куку сессии
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
#### Scenario: Предъявленный заголовок побеждает куку
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **THEN** проверку проходит значение заголовка, а не куки
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
ни код, принесённый от провайдера, ни секрет клиента, ни адрес почты
пользователя. Записанное значение сессии MUST читаться как ключ к чужому
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
собранные логи, откуда её не убрать.
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
Причина отказа, пришедшая от провайдера строкой запроса, MUST приводиться к
перечню известных: значение целиком задаёт тот, кто шлёт запрос, и без
приведения аноним пишет в журнал что угодно и сколько угодно.
#### Scenario: Значения сессии нет в журнале
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой
- **THEN** значение сессии не встречается ни в одной журнальной записи
#### Scenario: Адреса почты нет в журнале
- **WHEN** человек проходит вход и учётная запись заводится
- **THEN** адрес его почты не встречается ни в одной журнальной записи
### Requirement: Сессия переживает перезапуск сервиса
Сервис SHALL держать сессию годной после своего перезапуска: подпись сессии MUST
опираться на секрет, лежащий в хранилище, а не на значение, заведённое в памяти
при старте. Иначе всякая выкладка выкидывает всех вошедших молча.
#### Scenario: Прежняя кука годна после перезапуска
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** сервис поднимается заново на том же хранилище
- **THEN** запрос с прежней кукой проходит
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
Сервис SHALL назначать срок жизни сессии сам — **семь суток**, и тем же числом
задавать срок жизни куки. Умолчание хранилища MUST не применяться: оно даёт пять
суток, и это число никем не выбрано.
Назначаться срок MUST при каждом подъёме, а не шагом схемы: применённый шаг не
переписывается, и число, положенное туда, разошлось бы со сроком жизни куки при
первой же правке — браузер получил бы новый срок, а хранилище продолжило выдавать
прежний.
Срок здесь — единственное, что доносит до сервиса **отзыв доступа у
провайдера**. Сессия выдана однажды, и к провайдеру сервис больше не ходит:
человек, которому провайдер закрыл доступ, работает до истечения своей сессии.
Паспорт опирается на отзыв у провайдера как на способ остановить того, кто
тратит слишком много, — значит срок сессии и есть цена этой остановки.
**Отсюда запрет на продление.** Хранилище выдаёт сессию продлеваемой:
предъявитель меняет своё значение на новое, с новым сроком, и делает это сколько
угодно раз, никуда не входя. Сервис SHALL закрыть продление — иначе срок жизни
сессии не значит ничего, а канал отзыва перестаёт существовать вовсе.
Владелец MUST иметь способ закрыть чужие сессии немедленно, не дожидаясь срока.
#### Scenario: Сессия не продлевает саму себя
- **GIVEN** человек вошёл и получил сессию
- **WHEN** этой же сессией он просит продлить её
- **THEN** ответ несёт отказ, а нового значения в нём нет
#### Scenario: Сессия истекает назначенным сроком
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** назначенный срок прошёл
- **THEN** запрос с этой кукой получает отказ
#### Scenario: Владелец закрывает чужую сессию
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** владелец обесценивает сессии этой учётной записи
- **THEN** запрос с прежней кукой получает отказ
### Requirement: Выход прекращает доступ
Сервис SHALL закрывать доступ по выходу немедленно: выход MUST обесценивать
выданные этой учётной записи сессии на стороне сервиса, а не только убирать куку
у браузера. Куку сервис при этом MUST убрать тоже.
Одной уборки куки мало: сессия предъявляется значением, и унесённое значение
продолжало бы открывать доступ до самого своего истечения.
Порядок обязателен: сперва обесценивание, потом уборка куки. При обратном
порядке выход, разошедшийся с одновременным входом, оставляет годную сессию, а
человек уверен, что вышел.
#### Scenario: После выхода прежняя кука не работает
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он выходит, а затем шлёт запрос к API с прежней кукой
- **THEN** запрос получает отказ
#### Scenario: Выход убирает куку
- **WHEN** человек выходит
- **THEN** ответ убирает куку сессии у браузера
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого пропустил провайдер, и своей проверки допуска
MUST не делать. Кто допущен, определяет правило провайдера на этого клиента —
настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
#### Scenario: Пропущенный провайдером получает доступ
- **WHEN** человек проходит вход у провайдера и возвращается с кодом
- **THEN** учётная запись заводится, а доступ открывается
- **AND** сервис не спрашивает у ответа провайдера ничего сверх того, что нужно
для заведения записи
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` без сессии. Ни у пробы
здоровья, ни у сборщика метрик сессии нет, и требование входа остановило бы
наблюдение за сервисом.
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
адреса не несут.
#### Scenario: Проба здоровья доступна анонимно
- **WHEN** запрос приходит на `GET /health` без сессии
- **THEN** ответ имеет код `200`
#### Scenario: Метрики доступны анонимно
- **WHEN** запрос приходит на `GET /metrics` без сессии
- **THEN** ответ имеет код `200`
### Requirement: Секрет провайдера живёт в конфиге
Сервис SHALL брать адреса провайдера, идентификатор клиента и секрет клиента из
конфига. Секрет MUST не попадать ни в журнал, ни в ответ, ни в git; настройки
провайдера в хранилище MUST приводиться к значениям конфига при каждом запуске,
а не заводиться однажды шагом схемы.
Причина второго требования в необратимости шага схемы: применённый шаг не
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
вовсе — вход сломался бы после ротации.
Незаполненная или негодная настройка входа MUST ронять старт с перечнем ключей и
без их значений. Форма адресов проверяется там же: непустая, но негодная строка
иначе отвергается хранилищем позже — из хука подъёма, до регистрации пробы
здоровья, — и сервис падает целиком, не оставив владельцу даже кода состояния.
#### Scenario: Секрета нет в журнале
- **WHEN** сервис поднимается с настроенным провайдером
- **THEN** значение секрета не встречается ни в одной журнальной записи
#### Scenario: Смена секрета доезжает до хранилища
- **GIVEN** сервис уже поднимался с прежним секретом
- **WHEN** секрет в конфиге заменён и сервис поднят заново
- **THEN** настройки провайдера в хранилище несут новое значение
#### Scenario: Негодная настройка роняет старт
- **WHEN** сервис поднимается с пустым или негодным ключом секции входа
- **THEN** старт кончается отказом, а отказ называет имена ключей
- **AND** значений этих ключей в отказе нет
+53 -9
View File
@@ -14,12 +14,19 @@ Telegram делит с ним общий шаг заведения задачи,
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`.
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
сохранена и получить заведённую под неё задачу расшифровки в состоянии
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
полем `status`.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча.
и переименование поля ломает внешнюю программу молча. Появление отказа без
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
@@ -27,16 +34,28 @@ MUST нести идентификатор задачи полем `job_id` и
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
видно было анонимно.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни задачи не заводится
- **AND** тело ответа не несёт данных задачи
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится
@@ -44,6 +63,7 @@ MUST нести идентификатор задачи полем `job_id` и
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
@@ -179,7 +199,7 @@ MUST нести идентификатор задачи полем `job_id` и
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов
принадлежит перечню известных форматов
- **THEN** метка метрики принимает значение `other`
- **AND** имя файла в хранилище сохраняет пришедшее расширение
@@ -192,23 +212,47 @@ MUST нести идентификатор задачи полем `job_id` и
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу
`GET /api/status/:id`. Ответ MUST нести идентификатор полем `job_id`, состояние
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
`GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
`job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
пока текста нет: пустая строка на месте отсутствующего текста читается как
«расшифровка пуста».
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
кодам ответа перебирается список заведённых задач.
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
#### Scenario: Задача найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние заведённой задачи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
#### Scenario: Сессии нет
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
состояние по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Задачи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
+44 -3
View File
@@ -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: Сервис поднимается на чистом каталоге данных
@@ -91,7 +100,22 @@ MUST завести свою схему и принимать записи об
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи. Отданный файл MUST совпадать с принятым по длине.
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
здесь нет — его заводит отдельная задача.
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
недосмотр.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
@@ -101,6 +125,10 @@ MUST завести свою схему и принимать записи об
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
@@ -112,9 +140,22 @@ MUST завести свою схему и принимать записи об
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают
- **AND** забирающий предъявил сессию и взял по ней токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Без сессии файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают без сессии
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Конвейер читает файл без сессии
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из файловой системы хранилища и шаг проходит
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
+241
View File
@@ -0,0 +1,241 @@
# toolchain Specification
## Purpose
Каким инструментом и какой его версии собирается сервис, и что об этом
проверяется до выкладки. Заведена задачей `go-1-26-upgrade` 2026-08-12 по
дефекту, записанному в `docs/review.md` за то же число: сборочный образ разошёлся
с требованием модуля, образ перестал собираться, а восемь шагов гейта и шесть
проходов ревью показали зелёное.
Capability нормирует **не поведение сервиса** для его потребителей, а поведение
инструмента разработки; потребитель у неё другой — тот, кто собирает сервис. Это
осознанное исключение, и оно названо в преамбуле `docs/architecture.md`.
## Requirements
### Requirement: Версия инструмента сборки объявлена одним числом
Проект SHALL объявлять версию Go, на которой собирается сервис, одинаково во
всех местах, где она названа. Мест ровно четыре, и перечень закрыт: требование
модуля в `go.mod`, сборочный образ в `Dockerfile`, строка стека в `CLAUDE.md`,
строка стека в `README.md`.
Сравниваются мажор и минор. Третье число у сборочного образа MUST оставаться
свободным, как и база образа: образ обновляется своим темпом, и требовать от
него совпадения по патчу значило бы краснеть на каждом его обновлении. Тег
читается по форме `golang:<мажор>.<минор>[.<патч>][-<база>]`, и берутся из него
первые два числа.
Правило множественности у мест разное, потому что места устроены по-разному.
**Документы** — `CLAUDE.md` и `README.md` — MUST называть версию ровно один раз,
и считается это **не по файлу, а по разделу стека**: `## Стек` в памятке,
`## Технологии` в README. Второе вхождение числа **в этом разделе** MUST
считаться отказом: обновят одно, второе протухнет молча. За пределами раздела
число не читается вовсе — иначе памятка, которая по устройству ведёт историю
закрытых долгов, роняла бы проверку на первой же правдивой строке о прошлой
версии, а сообщение толкало бы чинить не проверку, а исторический документ.
**Сборочный образ** единственности не требует: каждый слой — настоящий вход
сборки, и многослойная сборка законна. От всех вхождений `FROM golang:` MUST
требоваться совпадение мажора и минора, а не единственность.
**Требование модуля** называется директивой `go` и по устройству файла
единственно.
Граница раздела MUST быть определена, а не подразумеваться: раздел кончается
следующим заголовком того же или более высокого уровня, заголовок третьего уровня
и ниже остаётся внутри раздела, а строка, похожая на заголовок, но лежащая внутри
блока кода, заголовком MUST не считаться. Без этого пример в чужом разделе
открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда
норма его читать не велит.
`go.mod` MUST не содержать директиву `toolchain`. Она называет версию **пятым**
местом, которого перечень не знает: при `toolchain go1.27.0` четыре объявленных
числа сойдутся, а собирать будет пятое — то есть вернётся тот самый класс
расхождения, ради которого требование и заведено.
#### Scenario: Все четыре места названы одинаково
- **GIVEN** дерево проекта, где `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md`
называют версию Go
- **WHEN** их читают подряд
- **THEN** мажор и минор совпадают во всех четырёх
#### Scenario: Патч сборочного образа отличается законно
- **GIVEN** `go.mod` требует `1.26.0`, а образ собирается на `golang:1.26.5-alpine`
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: База сборочного образа сменилась
- **GIVEN** образ переехал с `golang:1.26-alpine` на `golang:1.26-bookworm`
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: Раздел стека называет версию дважды
- **GIVEN** раздел стека в `CLAUDE.md` называет версию два раза
- **WHEN** версии сравнивают
- **THEN** это расхождение, даже если оба числа одинаковы
#### Scenario: Число за пределами раздела стека не читается
- **GIVEN** `CLAUDE.md` вне раздела стека упоминает прошлую версию Go — например
записью о закрытом долге
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: Сборочный образ собран в два слоя
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с одним мажором и минором
- **WHEN** версии сравнивают
- **THEN** расхождением это не считается
#### Scenario: Слои сборочного образа разошлись между собой
- **GIVEN** `Dockerfile` содержит два `FROM golang:` с разными минорами
- **WHEN** версии сравнивают
- **THEN** это расхождение
#### Scenario: Заголовок раздела встретился внутри блока кода
- **GIVEN** документ в чужом разделе показывает пример, внутри которого есть
строка, совпадающая с заголовком раздела стека, а ниже названо другое число
- **WHEN** версии сравнивают
- **THEN** число из примера не читается, и расхождением это не считается
#### Scenario: Раздел стека закрыт заголовком верхнего уровня
- **GIVEN** после раздела стека идёт заголовок первого уровня, а ниже названа
прошлая версия
- **WHEN** версии сравнивают
- **THEN** это число не читается, и расхождением не считается
#### Scenario: Раздела стека нет вовсе
- **GIVEN** в документе нет раздела, где называется версия
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет недостающий раздел
#### Scenario: Модуль объявляет версию пятым местом
- **GIVEN** `go.mod` содержит директиву `toolchain`
- **WHEN** версии сравнивают
- **THEN** это расхождение
### Requirement: Объявленное число — то, на котором проект собирается
Объявленная версия SHALL быть той, на которой сервис действительно собирается и
проходит тесты. Согласованность четырёх строк между собой этого не доказывает:
четыре одинаковых числа несуществующей версии требованию о согласованности
удовлетворяют, а собрать на них нельзя.
Проверка эта MUST оставаться за человеком и MUST не входить в набор проверок:
она требует сборки образа, а сборка образа набором проверок не делается
намеренно — дорого. Подъём версии MUST не уезжать в основную ветку, пока сборка
образа и тесты на объявленном числе не прогнаны.
#### Scenario: Версию подняли
- **GIVEN** объявленную версию Go подняли во всех четырёх местах
- **WHEN** изменение готовят к мерджу
- **THEN** до мерджа на этой версии прогнаны сборка образа и тесты
### Requirement: Расхождение версий роняет набор проверок
Набор проверок `task gate` SHALL включать шаг, который сравнивает объявленные
версии между собой и MUST завершаться отказом, когда они разошлись. Сообщение
отказа MUST называть **все четыре места и прочитанное в каждом число** — не одну
разошедшуюся пару: в дефекте 2026-08-12 три места из четырёх говорили одно и то
же и неверными были именно они, а по сообщению о паре человек чинит не то место.
Шаг MUST судить по содержимому файлов репозитория и MUST не спрашивать
установленный инструмент — ни `go version`, ни `go env`, ни `GOTOOLCHAIN`. Исход
его MUST быть функцией коммита, а не машины: шаг, чей ответ зависит от того, что
стоит на хосте, воспроизводит ровно ту подмену, которая держала дефект
2026-08-12 невидимым — там `go build ./...` шёл на хостовом Go, а объявленное
число не проверял никто.
Шаг MUST работать сравнением строк — без сборки образа, без docker и без сети —
и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только
читать: файлов он не правит и разошедшихся мест не чинит.
Коды выхода MUST следовать общему словарю проверочных шагов проекта; словарь
объявляет раздел «Гейт» в `CLAUDE.md`, и здесь он не повторяется. Своего словаря шаг
MUST не заводить: четвёртый шаг с собственной семантикой сделал бы это
утверждение неверным.
Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места.
«Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы
как совпадение.
Отказ чтения места MUST не выглядеть как отсутствие числа. Место, которое
существует, но не читается, — это отказ окружения, и сообщение MUST говорить о
нечитаемости, а не о ненайденной версии: иначе шаг отправляет чинить документ, в
котором строка на месте, а сломаны права.
#### Scenario: Разошёлся сборочный образ
- **GIVEN** `Dockerfile` называет версию, отличную от прочих трёх мест
- **WHEN** запускают `task gate`
- **THEN** шаг сверки завершается отказом
- **AND** сообщение называет все четыре места и число каждого
- **AND** весь набор проверок краснеет
#### Scenario: Разошлось требование модуля
- **GIVEN** `go.mod` называет версию, отличную от прочих трёх мест
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет `go.mod` среди разошедшихся
#### Scenario: Разошлась памятка
- **GIVEN** `CLAUDE.md` называет версию, отличную от прочих трёх мест
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет `CLAUDE.md` среди разошедшихся
#### Scenario: Разошёлся README
- **GIVEN** `README.md` называет версию, отличную от прочих трёх мест
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет `README.md` среди разошедшихся
#### Scenario: Версии совпадают
- **GIVEN** все четыре места называют одно число
- **WHEN** запускают `task gate`
- **THEN** шаг сверки проходит с кодом 0
- **AND** остальные шаги набора идут как прежде
#### Scenario: Инструмента сборки нет на машине
- **GIVEN** в `PATH` нет `go` вовсе
- **WHEN** запускают шаг сверки
- **THEN** исход и сообщение те же, что и при установленном `go`
#### Scenario: Ни docker, ни сети нет
- **GIVEN** docker недоступен и сети нет
- **WHEN** запускают шаг сверки
- **THEN** он отрабатывает и даёт тот же исход, что и при доступном docker
#### Scenario: Шаг запущен не из корня проекта
- **GIVEN** шаг запускают из подкаталога дерева
- **WHEN** он ищет свои четыре места
- **THEN** исход тот же, что и при запуске из корня
#### Scenario: Место существует, но не читается
- **GIVEN** файл одного из мест на диске есть, но прав на чтение нет
- **WHEN** запускают шаг сверки
- **THEN** он завершается кодом окружения и говорит о нечитаемости места
- **AND** сообщения «версия не названа» не печатает
#### Scenario: Версия не названа там, где должна быть
- **GIVEN** одно из четырёх мест перестало называть версию Go
- **WHEN** запускают шаг сверки
- **THEN** он завершается отказом и называет место, где число не нашлось
+243
View File
@@ -0,0 +1,243 @@
#!/bin/sh
# Сверяет объявленную версию Go во всех местах, где она названа: требование
# модуля, сборочный образ, памятка и README. Сравниваются мажор и минор; патч и
# база сборочного образа свободны.
#
# Шаг судит по содержимому репозитория и только по нему: команда `go` не
# зовётся вовсе. Иначе исход зависел бы от установленного тулчейна и от
# GOTOOLCHAIN, а при непустом GOTOOLCHAIN `go` вправе уйти в сеть за нужной
# версией — то есть шаг перестал бы быть функцией коммита. Ровно эта подмена
# держала дефект 2026-08-12 невидимым: `go build ./...` шёл на хостовом Go и был
# зелёным, пока образ не собирался.
#
# Коды выхода — общий словарь проверочных шагов проекта (CLAUDE.md, «Гейт»):
# 0 сошлось, 1 расхождение, 2 ошибка употребления, 3 окружение.
set -eu
me=check-go-version
if [ "$#" -ne 0 ]; then
cat >&2 <<'EOF'
Использование: check-go-version.sh
Сверяет объявленную версию Go в go.mod, Dockerfile, CLAUDE.md и README.md.
Аргументов не принимает. Коды выхода: 0 сошлось, 1 расхождение,
2 ошибка употребления, 3 окружение.
EOF
exit 2
fi
# Корень считается от самого скрипта, а не от текущего каталога: иначе исход
# зависел бы от того, откуда шаг запустили.
#
# `CDPATH= ` — очистка переменной перед `cd`, а не забытый пробел в присваивании:
# непустой CDPATH увёл бы `cd` в чужой каталог. shellcheck читает это как SC1007,
# и подавление стоит здесь, чтобы заведение линтера потом стоило ровно одной
# строки шага.
# shellcheck disable=SC1007
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd) || {
echo "$me: не удалось определить корень репозитория" >&2
exit 3
}
# Перечень мест закрыт и лежит здесь одним списком. Пятое место, о котором никто
# не знает, — это и есть тот дефект, против которого написан шаг, поэтому
# перечень не расползается по коду.
gomod=$root/go.mod
dockerfile=$root/Dockerfile
claudemd=$root/CLAUDE.md
readmemd=$root/README.md
# Нечитаемое место проверяется здесь, а не при разборе, и вот почему. Значения
# добываются подстановкой команд в аргументе, а она теряет код возврата; в
# `read_doc` вдобавок конвейер, чей статус берётся от последней команды. Отказ
# чтения дал бы пустой вход, и шаг сказал бы «README.md не называет версию» —
# то есть отправил бы чинить документ, в котором строка на месте, а сломаны
# права. Отказ окружения обязан звучать как отказ окружения.
for f in "$gomod" "$dockerfile" "$claudemd" "$readmemd"; do
if [ ! -f "$f" ]; then
echo "$me: нет файла ${f#"$root"/}" >&2
exit 3
fi
if [ ! -r "$f" ]; then
echo "$me: файл ${f#"$root"/} нечитаем" >&2
exit 3
fi
done
# Число непустых строк на входе.
count_lines() {
awk 'NF { n = n + 1 } END { print n + 0 }'
}
# Различные непустые значения на входе.
distinct() {
awk 'NF' | sort -u
}
# Директива `go` в go.mod: первые два числа.
read_gomod() {
sed -n 's/^go[[:space:]]\{1,\}\([0-9][0-9]*\.[0-9][0-9]*\).*$/\1/p' "$gomod"
}
# Все теги golang в Dockerfile: golang:<мажор>.<минор>[.<патч>][-<база>].
# Патч и база отбрасываются — спека объявила их свободными.
read_dockerfile() {
sed -n \
's/^[Ff][Rr][Oo][Mm][[:space:]].*golang:\([0-9][0-9]*\.[0-9][0-9]*\).*$/\1/p' \
"$dockerfile"
}
# Тело раздела документа. Раздел кончается следующим заголовком того же или более
# высокого уровня; заголовок внутри блока кода заголовком не считается, иначе
# пример в чужом разделе открыл бы «раздел стека» на пустом месте. Хвостовые
# пробелы в заголовке markdown не рендерит, поэтому и здесь они не значат ничего.
section() {
awk -v want="$2" '
/^```/ { fence = !fence; if (inside) print; next }
!fence && (/^# / || /^## /) {
line = $0
sub(/[[:space:]]+$/, "", line)
inside = (line == want)
next
}
inside { print }
' "$1"
}
# Есть ли в документе раздел с таким заголовком. Правила те же, что у section:
# иначе «раздел есть» и «раздел читается» разошлись бы на первом же примере.
has_section() {
awk -v want="$2" '
/^```/ { fence = !fence; next }
!fence && (/^# / || /^## /) {
line = $0
sub(/[[:space:]]+$/, "", line)
if (line == want) { found = 1 }
}
END { exit(found ? 0 : 1) }
' "$1"
}
# Все вхождения образца `Go <мажор>.<минор>` в разделе стека документа.
#
# Читается именно раздел, а не файл целиком. `CLAUDE.md` по устройству ведёт
# историю закрытых долгов, и правдивая строка о прошлой версии уронила бы шаг
# сообщением «называет версию больше одного раза» — то есть послала бы чинить
# исторический документ вместо проверки. Тем же ловился бы любой пример команды
# в README.
#
# Первый sed ставит каждое вхождение на свою строку: два числа в одной строке
# иначе слились бы в одно, и второе, протухшее, осталось бы невидимым.
read_doc() {
section "$1" "$2" | sed -e 's/Go [0-9][0-9]*\.[0-9][0-9]*/\
&\
/g' | sed -n 's/^Go \([0-9][0-9]*\.[0-9][0-9]*\)$/\1/p'
}
failed=0
report=''
collected=''
fail() {
echo "$me: $1" >&2
failed=1
}
add_report() {
line=$(printf ' %-11s %s' "$1" "$2")
report="$report$line
"
}
# Добывает число из места и кладёт его в `collected`; пусто — значит не добыто.
# Третий аргумент: 1 — место обязано называть версию ровно один раз.
#
# Функция не зовётся в подстановке команд намеренно: подоболочка потеряла бы и
# флаг отказа, и отчёт.
collect() {
place=$1
values=$2
strict=$3
collected=''
total=$(printf '%s\n' "$values" | count_lines)
uniq=$(printf '%s\n' "$values" | distinct)
uniq_total=$(printf '%s\n' "$uniq" | count_lines)
if [ "$total" -eq 0 ]; then
add_report "$place" 'версия не названа'
fail "$place не называет версию Go"
return 0
fi
if [ "$uniq_total" -gt 1 ]; then
add_report "$place" "$(printf '%s' "$uniq" | tr '\n' '/') — разные числа"
fail "$place называет несколько разных версий"
return 0
fi
if [ "$strict" -eq 1 ] && [ "$total" -gt 1 ]; then
add_report "$place" "$uniq — названа $total раз(а)"
fail "$place называет версию больше одного раза"
return 0
fi
collected=$uniq
add_report "$place" "$uniq"
}
# Директива toolchain — пятое место, которого перечень не знает: четыре числа
# сойдутся, а собирать будет пятое.
if grep '^toolchain[[:space:]]' "$gomod" >/dev/null 2>&1; then
fail 'go.mod содержит директиву toolchain — она называет версию пятым местом'
fi
# Раздел, в котором документ называет версию. За его пределами число не читается.
claude_section='## Стек'
readme_section='## Технологии'
# Пропавший раздел — отдельный исход: он говорит «искать негде», а не «версия не
# названа», и чинится другим движением.
collect_doc() {
place=$1
path=$2
heading=$3
collected=''
if ! has_section "$path" "$heading"; then
add_report "$place" "нет раздела «$heading»"
fail "в $place нет раздела «$heading», где называется версия"
return 0
fi
collect "$place" "$(read_doc "$path" "$heading")" 1
}
# Документы называют версию прозой, и второе вхождение в разделе стека — не
# дубликат, а второе утверждение: обновят одно, второе протухнет молча. У
# Dockerfile иначе: каждый сборочный слой — настоящий вход сборки, и от них
# требуется совпадение, а не единственность.
collect go.mod "$(read_gomod)" 1
v_gomod=$collected
collect Dockerfile "$(read_dockerfile)" 0
v_docker=$collected
collect_doc CLAUDE.md "$claudemd" "$claude_section"
v_claude=$collected
collect_doc README.md "$readmemd" "$readme_section"
v_readme=$collected
if [ "$failed" -eq 0 ]; then
found=$(printf '%s\n%s\n%s\n%s\n' \
"$v_gomod" "$v_docker" "$v_claude" "$v_readme" | distinct | count_lines)
if [ "$found" -ne 1 ]; then
fail 'объявленные версии Go разошлись'
fi
fi
if [ "$failed" -ne 0 ]; then
printf 'Объявленная версия Go по местам:\n%s' "$report" >&2
exit 1
fi
exit 0
+358
View File
@@ -0,0 +1,358 @@
// Package scripts — проверки скриптов репозитория. Рабочего кода на Go в нём
// нет: пакет существует ради того, чтобы `go test ./...` гонял и shell.
//
// Норма шага сверки версий — openspec/specs/toolchain/spec.md. Каждый её
// сценарий проверяется здесь мутацией: дерево-образец собирается во временном
// каталоге, портится ровно одним способом, и от скрипта требуется объявленный
// исход. Прежде сценарии подтверждались разовыми ручными прогонами — после
// первой правки образца они перестали бы выполняться молча.
package scripts
import (
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
// Дерево-образец: все четыре места называют одну версию.
//
// `CLAUDE.md` держит второе число **за** разделом стека намеренно: так выглядит
// правдивая строка о закрытом долге, и норма велит её не читать.
var fixture = map[string]string{
"go.mod": "module example\n\ngo 1.26.0\n",
"Dockerfile": "FROM docker.io/library/golang:1.26-alpine AS builder\n" +
"RUN true\n\n" +
"FROM docker.io/library/alpine:3.22\n",
"CLAUDE.md": "# CLAUDE.md\n\n" +
"## Стек\n\nGo 1.26, встроенная PocketBase.\n\n" +
"## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n",
"README.md": "# transcriber\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n",
}
// allPlaces — все четыре места и число каждого: этого требует норма от
// сообщения о расхождении. Числа два, потому что разошедшееся место называет
// своё.
var allPlaces = []string{"go.mod", "Dockerfile", "CLAUDE.md", "README.md", "1.26", "1.25"}
const (
exitOK = 0
exitDrift = 1
exitUsage = 2
exitEnviron = 3
)
func TestСверкаВерсийПоСценариямНормы(t *testing.T) {
cases := []struct {
name string
// mutate портит дерево-образец; nil — дерево не портится.
mutate func(t *testing.T, root string)
// args — аргументы скрипта.
args []string
// dir — рабочий каталог прогона относительно корня дерева.
dir string
want int
// says — что обязано прозвучать в сообщении.
says string
// saysAll — что обязано прозвучать всё разом. Норма требует от сообщения
// о расхождении **все четыре места и число каждого**: в дефекте
// 2026-08-12 три места из четырёх говорили одно и то же, и неверными
// были именно они — по сообщению о паре человек чинит не то место.
saysAll []string
// saysNot — чего в сообщении быть не должно.
saysNot string
}{
{name: "все четыре места названы одинаково", want: exitOK},
{
name: "патч сборочного образа отличается законно",
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26.5-alpine"),
want: exitOK,
},
{
name: "база сборочного образа сменилась",
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26-bookworm"),
want: exitOK,
},
{
name: "сборочный образ собран в два слоя",
mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.26-alpine AS tools"),
want: exitOK,
},
{
name: "слои сборочного образа разошлись между собой",
mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.25-alpine AS tools"),
want: exitDrift,
says: "Dockerfile",
},
{
name: "разошёлся сборочный образ",
mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.25-alpine"),
want: exitDrift,
says: "разошлись",
saysAll: allPlaces,
},
{
name: "разошлось требование модуля",
mutate: replace("go.mod", "go 1.26.0", "go 1.25.0"),
want: exitDrift,
says: "разошлись",
saysAll: allPlaces,
},
{
name: "разошлась памятка",
mutate: replace("CLAUDE.md", "Go 1.26, встроенная", "Go 1.25, встроенная"),
want: exitDrift,
says: "разошлись",
saysAll: allPlaces,
},
{
name: "разошёлся README",
mutate: replace("README.md", "Go 1.26 и ffmpeg", "Go 1.25 и ffmpeg"),
want: exitDrift,
says: "разошлись",
saysAll: allPlaces,
},
{
name: "раздел стека называет версию дважды",
mutate: replace("CLAUDE.md", "встроенная PocketBase.", "встроенная PocketBase, всё та же Go 1.26."),
want: exitDrift,
says: "больше одного раза",
},
{
name: "число за пределами раздела стека не читается",
mutate: replace("CLAUDE.md", "Go 1.24 — долг закрыт.", "Go 1.24 и Go 1.23 — долги закрыты."),
want: exitOK,
},
{
name: "заголовок раздела встретился внутри блока кода",
mutate: replace("README.md", "## Технологии\n\nGo 1.26 и ffmpeg.\n",
"## Пример\n\n```md\n## Технологии\n\nGo 1.19 из примера.\n```\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n"),
want: exitOK,
},
{
name: "раздел стека закрыт заголовком верхнего уровня",
mutate: replace("CLAUDE.md", "## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n",
"# Приложение\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n"),
want: exitOK,
},
{
name: "раздела стека нет вовсе",
mutate: replace("CLAUDE.md", "## Стек", "## Инструменты"),
want: exitDrift,
says: "нет раздела",
},
{
name: "версия не названа там, где должна быть",
mutate: replace("README.md", "Go 1.26 и ffmpeg.", "ffmpeg и всё остальное."),
want: exitDrift,
says: "не называет версию",
},
{
name: "модуль объявляет версию пятым местом",
mutate: replace("go.mod", "go 1.26.0", "go 1.26.0\n\ntoolchain go1.27.0"),
want: exitDrift,
says: "toolchain",
},
{
name: "места нет вовсе",
mutate: remove("README.md"),
want: exitEnviron,
says: "нет файла",
},
{
name: "место существует, но не читается",
mutate: unreadable("README.md"),
want: exitEnviron,
says: "нечитаем",
saysNot: "не называет версию",
},
{
name: "шаг запущен не из корня проекта",
dir: "scripts",
want: exitOK,
},
{
name: "шагу переданы аргументы",
args: []string{"--base", "origin/master"},
want: exitUsage,
says: "Использование",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
root := treeWithScript(t)
if c.mutate != nil {
c.mutate(t, root)
}
code, out := runScript(t, root, c.dir, c.args)
if code != c.want {
t.Errorf("код возврата %d, ожидался %d\nвывод:\n%s", code, c.want, out)
}
if c.says != "" && !strings.Contains(out, c.says) {
t.Errorf("в сообщении нет %q\nвывод:\n%s", c.says, out)
}
for _, want := range c.saysAll {
if !strings.Contains(out, want) {
t.Errorf("сообщение не называет %q\nвывод:\n%s", want, out)
}
}
if c.saysNot != "" && strings.Contains(out, c.saysNot) {
t.Errorf("в сообщении есть лишнее %q\nвывод:\n%s", c.saysNot, out)
}
})
}
}
// Норма требует, чтобы исход был функцией коммита, а не машины: скрипт не
// спрашивает установленный инструмент. Проверяется это прогоном без `go` в
// `PATH` — исход обязан не измениться.
func TestИсходНеЗависитОтУстановленногоGo(t *testing.T) {
root := treeWithScript(t)
withGo, outWith := runScript(t, root, "", nil)
if withGo != exitOK {
t.Fatalf("дерево-образец обязано сходиться, а код %d:\n%s", withGo, outWith)
}
goBin, err := exec.LookPath("go")
if err != nil {
t.Skip("go не найден в PATH — проверять нечего")
}
var kept []string
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
if dir != filepath.Dir(goBin) {
kept = append(kept, dir)
}
}
withoutGo, outWithout := runScript(t, root, "", nil, "PATH="+strings.Join(kept, string(os.PathListSeparator)))
if withoutGo != withGo {
t.Errorf("без go в PATH код %d, с ним %d\nвывод:\n%s", withoutGo, withGo, outWithout)
}
}
// Скрипт не зовёт ни `go`, ни `docker`, ни сеть — это читается из его текста, и
// правило держит именно текст: прогон без сети в наборе проверок недоступен.
func TestСкриптНеЗоветНиGoНиDocker(t *testing.T) {
body, err := os.ReadFile("check-go-version.sh")
if err != nil {
t.Fatalf("читаю скрипт: %v", err)
}
// Границы слова обязательны: имя `read_dockerfile` и переменная `dockerfile`
// законны — читается файл, а не зовётся демон.
code := withoutComments(string(body))
for _, forbidden := range []string{`go\s+version`, `go\s+env`, `GOTOOLCHAIN`, `\bdocker\b`, `\bcurl\b`, `\bwget\b`} {
if regexp.MustCompile(forbidden).FindString(code) != "" {
t.Errorf("скрипт зовёт %s вне комментария: исход перестаёт быть функцией коммита", forbidden)
}
}
}
// --- Помощники --------------------------------------------------------------
// treeWithScript собирает дерево-образец и кладёт в него сам скрипт: корень он
// считает от своего расположения, поэтому проверяется копия внутри дерева.
func treeWithScript(t *testing.T) string {
t.Helper()
root := t.TempDir()
for name, body := range fixture {
write(t, filepath.Join(root, name), body, 0o644)
}
script, err := os.ReadFile("check-go-version.sh")
if err != nil {
t.Fatalf("читаю скрипт: %v", err)
}
if err := os.Mkdir(filepath.Join(root, "scripts"), 0o755); err != nil {
t.Fatalf("завожу каталог scripts: %v", err)
}
write(t, filepath.Join(root, "scripts", "check-go-version.sh"), string(script), 0o755)
return root
}
// runScript гоняет скрипт и отдаёт код возврата с объединённым выводом.
// `env` — добавка к окружению прогона, `args` — аргументы скрипта.
func runScript(t *testing.T, root, dir string, args []string, env ...string) (int, string) {
t.Helper()
// Контекст проверки: зависший скрипт умирает вместе с ней, а не переживает
// прогон осиротевшим процессом.
cmd := exec.CommandContext(t.Context(), "sh", append([]string{filepath.Join(root, "scripts", "check-go-version.sh")}, args...)...)
cmd.Dir = filepath.Join(root, dir)
if len(env) > 0 {
cmd.Env = append(os.Environ(), env...)
}
out, err := cmd.CombinedOutput()
code := 0
if err != nil {
var exit *exec.ExitError
if !errors.As(err, &exit) {
t.Fatalf("прогон скрипта: %v", err)
}
code = exit.ExitCode()
}
return code, string(out)
}
func replace(file, old, new string) func(*testing.T, string) {
return func(t *testing.T, root string) {
t.Helper()
path := filepath.Join(root, file)
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("читаю %s: %v", file, err)
}
if !strings.Contains(string(body), old) {
t.Fatalf("в образце %s нет %q: мутация потеряла предмет", file, old)
}
write(t, path, strings.Replace(string(body), old, new, 1), 0o644)
}
}
func remove(file string) func(*testing.T, string) {
return func(t *testing.T, root string) {
t.Helper()
if err := os.Remove(filepath.Join(root, file)); err != nil {
t.Fatalf("убираю %s: %v", file, err)
}
}
}
func unreadable(file string) func(*testing.T, string) {
return func(t *testing.T, root string) {
t.Helper()
path := filepath.Join(root, file)
if err := os.Chmod(path, 0o000); err != nil {
t.Fatalf("снимаю права с %s: %v", file, err)
}
// Права возвращаются, иначе уборка временного каталога отказала бы.
t.Cleanup(func() {
if err := os.Chmod(path, 0o644); err != nil {
t.Errorf("возвращаю права %s: %v", file, err)
}
})
if body, err := os.ReadFile(path); err == nil {
t.Skipf("файл читается и без прав (%d байт) — прогон под root?", len(body))
}
}
}
func write(t *testing.T, path, body string, perm os.FileMode) {
t.Helper()
if err := os.WriteFile(path, []byte(body), perm); err != nil {
t.Fatalf("пишу %s: %v", path, err)
}
}
// withoutComments снимает строки-комментарии: слово в объяснении вызовом не
// является, а объяснения в этом скрипте длиннее самого кода.
func withoutComments(body string) string {
var kept []string
for line := range strings.SplitSeq(body, "\n") {
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
kept = append(kept, line)
}
}
return strings.Join(kept, "\n")
}
+57 -20
View File
@@ -11,6 +11,25 @@
Секция одна — полок домена у проекта нет, и делить очередь на две
значило бы держать два порядка вместо одного.
**Чем очередь упорядочена на этом этапе — от базы к деталям.**
Сначала то, на чём стоит остальное: проверки, которым можно верить,
владелец записи, единый контракт API, покрытый тестами конвейер, — и
только потом экраны и возможности поверх них. Порядок расставлен на
груминге 2026-08-12 и держится, пока сервис не собран целиком:
задача, взятая раньше своего основания, стоит дважды — сперва её
пишут, потом переписывают под появившееся основание.
Отсюда правило для **новых** записей. Заведённая по ходу работы —
интейком, урожаем ревью, разбором находок — задача встаёт в конец
очереди машинально, и это **не** её место, а отсутствие места.
Слой ей назначает человек на ближайшем груминге: ниже того, чего она
требует, и выше того, что требует её. Причина уезжает в запись
(`move --after <слаг> --reason`).
Разведки это касается вдвойне: её исход — новые задачи, и слой они
наследуют не от разведки, а от того, что трогают. Разведка о конвейере
может принести задачу основания, которой место в голове очереди.
Тип записи стоит первым полем меты и решает, что у неё может быть:
`feature` 🐞 `fix` 🧹 `chore` 🔬 `research`
@@ -20,45 +39,63 @@
## Очередь
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
- [✨ Пускать в приложение только после входа через OIDC](items/oidc-login.md) — HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору.
- [🧹 Ронять гейт на изменённой функции, которую не выполняет ни один тест](items/gate-changed-lines-coverage.md) — Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
- [🧹 Поднимать сервис локально без действующего токена бота](items/local-run-without-telegram-token.md) — Адаптер Telegram проверяет токен обращением к Telegram и роняет старт, а боевым токеном запускаться запрещено: проверить поведение живым прогоном не может ни одна задача.
- [🐞 Убрать код провайдера из журнала запросов хранилища](items/provider-code-out-of-storage-log.md) — Строка запроса с кодом входа целиком уезжает в таблицу _logs и лежит там пять суток, хотя спека access требует, чтобы код в журнал не попадал.
- [🐞 Вести учёт употреблённых состояний входа на сервере](items/server-side-login-state.md) — Одноразовость возврата держится на уборке куки, то есть на браузере: сервер не помнит, какие состояния уже потрачены.
- [🧹 Строить адрес входа из настроек коллекции, а не из конфига](items/login-url-from-collection-settings.md) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
- [🔬 Четыре недоказанные гипотезы о поверхности входа](items/login-surface-hypotheses.md) — Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было.
- [🧹 Назвать в необратимом, что откат кода не откатывает шаг схемы](items/rollback-does-not-undo-schema-step.md) — Откат бинаря оставляет применённый шаг схемы в силе, и на этом строятся решения о выкладке: сегодня об этом не сказано нигде.
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
- [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
- [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать.
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
- [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
- [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто.
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен.
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
- [Удалять запись со всеми уровнями текста по требованию владельца](items/delete-record.md) — Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
- [Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage.
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
- [✨ Узнавать уже загруженный файл по хеш-сумме](items/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
- [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
- [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей.
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
- [✨ Удалять запись со всеми уровнями текста по требованию владельца](items/delete-record.md) — Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
- [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса.
- [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
- [✨ Отдавать вычитанный текст рядом с сырым](items/literary-text-level.md) — Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
- [✨ Показывать заголовок в списке, отбирать список по темам и считать токены](items/insights-visible-in-list.md) — Заголовок, темы и пересказ считаются, но список по-прежнему показывает первые слова расшифровки и не отбирается ничем, а расход на модель не виден числом.
- [✨ Считать только те уровни текста, что включены у владельца записи](items/settings-applied-in-pipeline.md) — Дом настроек есть, а конвейер их не читает: выключенный уровень всё равно уходит платной модели, и настройка ничего не экономит.
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана.
- [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
- [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
- [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста.
- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
- [✨ Отдавать текст в сотни килобайт файлом, а не сотней сообщений](items/long-text-delivery.md) — Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд.
- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
- [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто.
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
- [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
- [🔬 Перечень форматов, которые конвейер принимает на самом деле](items/audio-format-coverage-measure.md) — Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
- [ Принимать видео и брать из него звуковую дорожку](items/video-audio-track-intake.md) — Запись семейного архива приходит видеофайлом, а приём смотрит на аудио: человеку приходится доставать дорожку самому.
- [🔬 Стоит ли брать OpenTelemetry вместо голого Prometheus](items/opentelemetry-fit.md) — Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами.
- [🧹 Обновить Go до 1.26 и сверять версию шагом гейта](items/go-1-26-upgrade.md) — Модуль объявляет go 1.25.0, образ собирается на golang:1.25-alpine, на машине разработки стоит 1.26.5, и расхождение этих чисел не ловит ни один шаг гейта: разъехавшийся Dockerfile прошёл весь конвейер зелёным.
- [🧹 Ловить уязвимости в зависимостях шагом гейта](items/gate-dependency-vulnerabilities.md) — govulncheck находит две достижимые уязвимости в клиентах Yandex, а ни гейт, ни список «чего в гейте нет» о нём не знают: узнать о третьей будет неоткуда.
- [🧹 Считать покрытие изменённых строк шагом гейта](items/gate-changed-lines-coverage.md) — Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Воркер читает ctx только между итерациями: остановка контейнера ждёт конца шага, а на занятом писателе один запрос к хранилищу держится до 9,5 секунды при мягком таймауте в 5.
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
- [✨ Собирать путь одной записи по конвейеру запросом](items/job-path-by-request.md) — Звенья пути связаны только идентификатором задачи в строках журнала: чтобы понять, где запись провела минуты, владелец читает логи контейнера глазами.
- [✨ Считать вызовы, отказы и длительность по каждому внешнему сервису](items/external-service-metrics.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет ни одной метрики: отказ внешнего сервиса виден только строкой в журнале контейнера.
- [✨ Показывать метрикой задачу, застрявшую в состоянии](items/stalled-pipeline-metric.md) — Вставший конвейер неотличим от простоя: возраст задачи в состоянии не считается, и очередь без движения выглядит как отсутствие работы.
- [✨ Оповещать владельца об отказе, не дожидаясь жалобы](items/owner-alerting.md) — Об отказе владелец узнаёт от пользователя: правил оповещения нет ни на одной метрике, а метрики читают глазами.
- [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа.
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
- [Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage.
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
- [Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
- [🧹 Закрепить версию рантайм-базы образа](items/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит.
- [🧹 Настроить конвейер ревью по итогам прогона go-1-26-upgrade](items/review-config-from-go-upgrade.md) — Прогон вскрыл две прорехи настройки: «Типовые узлы» знают только рантайм и не знают рода «проверочный шаг набора проверок», а «Триггеры метки» не видят оси «изменение трогает канон» — и именно она дала обе блокирующие находки.
- [🐞 Починить срок сессии, который ставит откат шага входа](items/rollback-restores-wrong-session-duration.md) — Константа defaultAuthTokenDuration в шаге 202608120001 названа умолчанием библиотеки, но 1209600 — это 14 суток, а умолчание PocketBase 432000, пять суток: откат объявляет возврат к умолчанию и ставит срок вдвое больше выбранных владельцем семи.
- [🧹 Проверить шаг гейта migrations так же, как шаг сверки версий Go](items/migrations-step-norm-and-tests.md) — Шаг охраняет critical-инвариант «применённый шаг схемы не переписывается», но своих проверок не имеет: дрейф шаблона имени, переезд каталога или потеря grep в конвейере оставят его вечно зелёным, и это не заметит ничто.
- [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
- [🔬 Шаги гейта, у которых правило может потерять предмет](items/gate-steps-subject-guard.md) — У шага migrations страж предмета есть, у шагов docs, tasks и openspec неизвестно: они зовут чужие скрипты из плагинов, и правило, потерявшее файлы, зеленело бы молча.
- [🧹 Свести шесть расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; четыре из них в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
- [🔬 Квота по общему размеру загруженного на пользователя](items/per-user-size-quota.md) — Паспорт и security.md запрещают отказы по квоте пользователю, а заметка владельца просит квоту по умолчанию 5 ГБ — открытое противоречие с границей домена, которое владелец решил не разбирать сейчас.
+4
View File
@@ -0,0 +1,4 @@
# Входящие
Сырые заметки до разбора. Разбирает владелец; разобранное уезжает задачами и
здесь не остаётся.
+3 -3
View File
@@ -25,15 +25,15 @@
- [🎯 Сервисом пользуются несколько человек, и записи одного не видны другому](items/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
- [🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую](items/upload-reliability.md) — Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги.
- [🎯 Человек убирает свою запись из архива вместе со всеми текстами](items/data-ownership.md) — Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
- [🎯 Пользователь настраивает, что сервис делает с его записями](items/user-settings.md) — Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем.
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
- [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться.
## Направления
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
- [🎯 Запись длиной до шести часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
- [🎯 Человек убирает свою запись из архива вместе со всеми текстами](items/data-ownership.md) — Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
## Сопровождение
+1 -1
View File
@@ -1,7 +1,7 @@
# ✨ Сделать страницу статистики для владельца
- **Тип:** feature
- **Категория:** Очередь
- **Категория:** Очередь — Страница показывает собранный учёт: без учёта показывать нечего.
- **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
- **Теги:** goal:usage-stats
+2 -1
View File
@@ -1,8 +1,9 @@
# 🎯 Принимается запись любого формата, включая дорожку из видео
- **Тип:** goal
- **Секция:** Направления
- **Секция:** Направления — Перечень форматов не замерен, и потолок длины у видео тот же, что у долгих записей: тянется следом за ними.
- **Зачем:** Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
- **Теги:** decomposed
Человек отдаёт файл, не думая о том, что внутри: аудио любого распространённого
контейнера или видео, из которого нужна только речь. Подготовка на стороне
+4 -3
View File
@@ -1,7 +1,7 @@
# ✨ Пускать скрипты в API по личным токенам
- **Тип:** feature
- **Категория:** Очередь
- **Категория:** Очередь — Второй способ представиться ставится на готовые владельца и контракт, иначе форма ошибки переписывается дважды.
- **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
- **Теги:** goal:multi-user
@@ -18,7 +18,6 @@
- таблица токенов: владелец, имя, отпечаток, время выпуска и последнего
обращения, и её миграция;
- эндпоинты выпуска, перечня и отзыва токена;
- экран настроек — место, где токен выпускают и отзывают;
- `docs/security.md` — второй способ представиться и хранение отпечатка;
- `README.md` — пример вызова API скриптом.
@@ -38,4 +37,6 @@
Учётные записи по-прежнему заводит Authelia — свою регистрацию не делаем.
Сроков жизни и областей действия у токена не заводим: он даёт права владельца
целиком. Берётся после `oidc-login`: до неё представляться некому.
целиком. Берётся после `oidc-login`: до неё представляться некому. Экрана выпуска здесь
нет — приложения ещё не существует, токен выпускается запросом к API; место
токена на экране настроек заводит `settings-screen`.
@@ -0,0 +1,32 @@
# 🔬 Перечень форматов, которые конвейер принимает на самом деле
- **Тип:** research
- **Категория:** Очередь — Форматы: сначала замер того, что конвейер берёт на самом деле.
- **Зачем:** Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
- **Теги:** goal:any-audio-source
Двигает пункты 1 и 4 «Завершения» цели: перечень принимаемых форматов замерен и
записан, а расхождение `ogg/vorbis` против заявленного SpeechKit `OGG_OPUS`
разобрано.
## Вопрос
Какие форматы доходят до текста целиком, какие ломаются на конвертации, какие —
на распознавании, и чем на самом деле кодирует конвертер: `vorbis` или `opus`.
## Куда ляжет ответ
- `docs/research/audio-formats.md` — таблица «формат на входе → исход», с
командой замера и версией ffmpeg, на которой он сделан;
- расхождение `ogg/vorbis` против `OGG_OPUS`: строка о том, устранено оно или
проверенно безвредно, и чем это подтверждено;
- форматы, которые принять нельзя, — задачей об отказе на приёме, с провенансом
этой разведки.
## Рамки
Замер идёт на своих файлах во временном каталоге и на подставном распознавателе
`internal/adapter/recognizer/memory.go`; прогон на реальных ключах Yandex
запрещён — там, где без настоящего SpeechKit не обойтись, ответ берётся из
задачи `speechkit-limits`, а не оплачивается заново. Видеофайлы здесь только
измеряются, приём их заводит `video-audio-track-intake`.

Some files were not shown because too many files have changed in this diff Show More