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" version: "2"
linters: linters:
default: standard default: standard
enable: enable:
# docs/conventions/errors.md: сравнение ошибок через errors.Is и errors.As.
- errorlint - 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: 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: errcheck:
# Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не # Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не
# теряется молча» принимается реализацией, которая его теряет. Отказ, # теряется молча» принимается реализацией, которая его теряет. Отказ,
# который решено не проверять, теперь объявляют ниже поимённо — заметно. # который решено не проверять, теперь объявляют ниже поимённо — заметно.
check-blank: true check-blank: true
# Непроверенное приведение типа паникует, а не отдаёт ошибку, поэтому
# `check-blank` его не ловит: `v := x.(T)` вовсе не про присваивание в `_`.
check-type-assertions: true
exclude-functions: exclude-functions:
# Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки # Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки
- (io.Closer).Close - (io.Closer).Close
@@ -20,6 +157,46 @@ linters:
# Метод сам логирует ошибку отправки, вызывающему она не нужна # Метод сам логирует ошибку отправки, вызывающему она не нужна
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send - (*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: formatters:
enable: enable:
- gofmt - 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 панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка — Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
@@ -33,10 +33,15 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
Что нарушать нельзя. Что нарушать нельзя.
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object - **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и в колонку Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
`error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
всех местах выкладки. **critical** вручную во всех местах выкладки. **critical**
*Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции
пользователей хранилища — туда его кладёт приведение настроек при каждом
подъёме, потому что применённый шаг схемы не переписывается и не пережил бы
ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные
места запрета это не отменяет.
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла - **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера. идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
@@ -82,7 +87,7 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
```bash ```bash
go build ./... # CGO не нужен go build ./... # CGO не нужен
go test ./... go test ./... # в гейте идёт с -race, и там нужен компилятор C
go vet ./... go vet ./...
gofmt -l . gofmt -l .
golangci-lint run golangci-lint run
@@ -100,17 +105,60 @@ task gate # весь набор проверок разом
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию - **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
`origin/master`; переопределяется `task gate BASE=<rev>`. `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` словарь кодов общий: `docs.py check`, `tasks.py check`, `openspec.py check` и
0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта, `scripts/check-go-version.sh` словарь кодов общий: 0 сошлось, 1 дрейф,
каталог не найден), 4 внутренний сбой. 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`, - **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
неотформатированный файл, находка `golangci-lint`, дрейф раскладки документов, гонка, найденная детектором (`go test -race`), переписанный применённый шаг
дрейф каталога задач, форма `openspec/config.yaml`. Машина проверяет всё схемы,
неотформатированный файл, находка `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` и смотрит только индекс - `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
коммита. Полную историю никто не проверяет; коммита. Полную историю никто не проверяет;
- согласованность документов между собой и с кодом — её судят агенты, зовёт - согласованность документов между собой и с кодом — её судят агенты, зовёт
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage # 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, # Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
# и CGO больше не требуется. # и CGO больше не требуется.
+4 -3
View File
@@ -13,6 +13,7 @@
## Технологии ## Технологии
- **Язык**: Go 1.26, CGO не нужен
- **Веб-фреймворк**: gin-gonic/gin - **Веб-фреймворк**: gin-gonic/gin
- **Telegram**: go-telegram-bot-api - **Telegram**: go-telegram-bot-api
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3) - **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
@@ -113,9 +114,9 @@ transcriber/
## Разработка ## Разработка
Схему двигают шаги миграций PocketBase на Go — Схему двигают шаги миграций PocketBase на Go —
`internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме `internal/adapter/repo/pocketbase/migrations`, файл на шаг. Непринятые шаги
хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не накатываются при подъёме хранилища, прежде чем стартуют воркеры и сервер.
переписывается: изменение — только новым файлом шага. Применённый шаг не переписывается: изменение — только новым файлом шага.
Проверки перед коммитом — одной командой: Проверки перед коммитом — одной командой:
+157 -4
View File
@@ -9,6 +9,12 @@ vars:
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы # Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит # переустановка плагина не меняла 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"}}' 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"}}' 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"}}' 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: файлы выше не отформатированы" echo "gofmt: файлы выше не отформатированы"
exit 1 exit 1
fi fi
- go test ./... - task: tests
- golangci-lint run - golangci-lint run
- task: shell
- task: dockerfile
- task: go-version
- task: migrations
- task: docs - task: docs
- task: tasks - task: tasks
- task: openspec - 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: docs:
desc: 'Раскладка docs/ против канона' desc: 'Раскладка docs/ против канона'
@@ -42,7 +176,7 @@ tasks:
if [ ! -f "$py" ]; then if [ ! -f "$py" ]; then
echo "docs.py не найден: $py" echo "docs.py не найден: $py"
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>" echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>"
exit 1 exit 3
fi fi
python3 "$py" check --base {{.BASE}} python3 "$py" check --base {{.BASE}}
@@ -54,7 +188,7 @@ tasks:
if [ ! -f "$py" ]; then if [ ! -f "$py" ]; then
echo "tasks.py не найден: $py" echo "tasks.py не найден: $py"
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>" echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>"
exit 1 exit 3
fi fi
python3 "$py" check --dir tasks python3 "$py" check --dir tasks
@@ -66,10 +200,29 @@ tasks:
if [ ! -f "$py" ]; then if [ ! -f "$py" ]; then
echo "openspec.py не найден: $py" echo "openspec.py не найден: $py"
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>" echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>"
exit 1 exit 3
fi fi
python3 "$py" check --dir . 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): собрать полный образ и затегать # Контракт роли app_image (pet-project-server): собрать полный образ и затегать
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server. # его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
image: image:
+26
View File
@@ -33,6 +33,32 @@ object_storage_region = "ru-central1"
# Endpoint Object Storage # Endpoint Object Storage
object_storage_endpoint = "https://storage.yandexcloud.net/" 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 Bot Configuration
[telegram] [telegram]
# Токен Telegram бота (получить у @BotFather в Telegram) # Токен Telegram бота (получить у @BotFather в Telegram)
+4 -4
View File
@@ -12,21 +12,21 @@ if [ "${USER}" != "transcriber" ]; then
fi fi
if [ -z "${USER_GID}" ]; then if [ -z "${USER_GID}" ]; then
USER_GID="$(id -g ${USER})" USER_GID="$(id -g "${USER}")"
fi fi
if [ -z "${USER_UID}" ]; then if [ -z "${USER_UID}" ]; then
USER_UID="$(id -u ${USER})" USER_UID="$(id -u "${USER}")"
fi fi
# Change GID for USER? # 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]*/${USER}:\1:${USER_GID}/" /etc/group
sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd
fi fi
# Change UID for USER? # 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 sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd
fi fi
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"canon": 14, "canon": 14,
"migrations": "migrations" "migrations": "internal/adapter/repo/pocketbase/migrations"
} }
@@ -67,6 +67,10 @@ PocketBase заменяет SQLite с goqu и goose и берёт на себя
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с `pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
переносятся по прежнему решению задачи `pocketbase-storage`. переносятся по прежнему решению задачи `pocketbase-storage`.
*Уточнено 2026-08-12:* каталог задаётся ключом `[storage] data_dir` со
значением `data`. Суффикс из десяти знаков дописывает конструктор имени,
которого сервис не зовёт, — имя задаёт он сам. Действующая раскладка —
[../database.md](../database.md), «Представление данных».
- `` вход перестаёт быть нашим: задача `oidc-login` переписывается с - `` вход перестаёт быть нашим: задача `oidc-login` переписывается с
собственной обработки ответа провайдера на настройку провайдера в PocketBase. собственной обработки ответа провайдера на настройку провайдера в 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 - **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md), - **Источник:** [../../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-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-domain-marker-boundary-by-norm.md) | |
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.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); что из [passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
этого ещё не решено — в разделе «Открытые вопросы». этого ещё не решено — в разделе «Открытые вопросы».
Заведены три capability: Заведены пять capability. Четыре первые нормируют **поведение сервиса** для его
потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а
инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает.
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его - [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: приём и
нормируют проверки, написанные задачей `http-handler-tests-never-green` опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала,
2026-08-11; метка метрики несёт только известное расширение. Задачи
`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) — пустой прогон воркера, захват - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
@@ -20,6 +25,15 @@
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` и её файл, как файл отдаётся и что видит владелец: задача `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. 2026-08-12.
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
@@ -29,17 +43,26 @@
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и - **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно. делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер - **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка захвата и порядок выборки нормирует
— единицы записей в день (оценка владельца, не замер). Готовую библиотеку [pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
очереди тоже не заводим — решено 2026-08-11, Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение [ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
кандидатов в [research/job-queue.md](research/job-queue.md). кандидатов в [research/job-queue.md](research/job-queue.md).
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине, - **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда
достаётся снова по истечении срока захвата и проходит шаг заново. задача возвращается в работу, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
в работу»; здесь это принцип письма шага, а не описание поведения.
- **Ядро зависит от интерфейсов.** `internal/service` знает только - **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в `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 | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам | | Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом | | Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода | | Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано --> <!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
@@ -69,8 +93,11 @@
## Внешние границы и форматы ## Внешние границы и форматы
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения. - **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
отдаёт файлы больше 20 МиБ — это потолок приёма из бота. самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с - **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением. `UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель - **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
@@ -94,8 +121,9 @@
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | | Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — |
@@ -118,13 +146,14 @@
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния | | Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю | | Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Разбор конфигурации | `internal/config.LoadConfig` | | Разбор конфигурации | `internal/config.LoadConfig` |
| Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
| Метрики | `internal/metrics`, префикс имени `transcriber_` | | Метрики | `internal/metrics`, префикс имени `transcriber_` |
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` | | Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()` генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в
по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло
сам. 2026-08-13: его читает `internal/clock`, и запрет держит линтер.
## Деплой ## Деплой
@@ -138,11 +167,15 @@
## Открытые вопросы ## Открытые вопросы
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера - **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер
обрабатывает PocketBase, а не наш код Authelia, ответ провайдера обрабатывает PocketBase, а не наш код
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия ([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия
и как связываются пользователь Telegram и пользователь веба. Панель живёт кукой `transcriber_session` и сама себя не продлевает. Норма —
администратора при этом Authelia не закрывает: у неё свой пароль [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, - **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и устанавливаемое на телефон, а фреймворком взят 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, тот же автор, те же Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
задачи. Код transcriber написан раньше и **части правил не следует**: ключи — задачи. Код 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 работает на Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком. htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
@@ -46,27 +48,15 @@ htmx, а здесь решено делать SPA — и перенесённы
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике, - [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
над `fetch`, показ ошибок и состояний списка. над `fetch`, показ ошибок и состояний списка.
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: лестница
механизации, два круга (pre-commit и гейт), перечень правил и подавлений,
порядок заведения нового правила. Про инструменты, а не про то, как писать
тесты.
## Механизировано ## Что из этого проверяет машина
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в Перечень правил, доведённых до проверки, и место настройки каждого — в записи
промптах ревью не пересказывается. [go-linters.md](go-linters.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`), ни
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
оставшееся прозой, проверяет человек на каждом ревью заново.
+22 -6
View File
@@ -8,9 +8,10 @@
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
проверки пустых ключей внутри адаптеров. проверки пустых ключей внутри адаптеров.
**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера **Механизировано:** запрет `os.Getenv` `forbidigo` в `.golangci.yml`
не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас ([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
участвует. приезжают из TOML»; `godotenv` в `main.go` по-прежнему загружает `.env`, но кладёт
его в окружение процесса, а не в настройки приложения.
## Принципы ## Принципы
@@ -60,6 +61,11 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля *Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем. `users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
говорит, какой формы значение здесь ждут. Пустым оставлен только
`client_secret` — он и есть секрет.
## Поля по дискриминатору `type` ## Поля по дискриминатору `type`
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
@@ -87,7 +93,7 @@ Ansible из `pet-project-server`). Приложение просто читае
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`, - Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`, `yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`. `yandex.object_storage_secret_access_key`, `auth.client_secret`.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`, - Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`). владелец — пользователь процесса (`1000:1000`).
- В `config.dist.toml` секретные поля — пустые строки. - В `config.dist.toml` секретные поля — пустые строки.
@@ -115,10 +121,20 @@ TOML. Пустой токен бота ловится в `NewTelegramController`
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
места проверки нет. места проверки нет.
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с
молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом
было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение
`client_secret` в журнал попасть не должно.
## Структура в коде ## Структура в коде
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`. - Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
- Одна корневая структура `Config` с под-структурами по секциям (`Server`, - Одна корневая структура `Config` с под-структурами по секциям. Перечень секций
`Database`, `Storage`, `Yandex`, `Telegram`). и полей здесь не повторяем: источник истины по составу — `config.dist.toml`,
действующие числа — [../database.md](../database.md), «Настройки с числовым
значением». Каталог данных задаётся одним ключом `[storage] data_dir`
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле - Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
требует правки обоих мест. требует правки обоих мест.
+12 -8
View File
@@ -2,14 +2,17 @@
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md). Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не **Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует
следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время
месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации единой точкой читается с 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, не автоинкремент ## Первичные ключи — ULID, не автоинкремент
@@ -55,8 +58,9 @@
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`). лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени. вставка падает громко. Измерение длительности — не метка времени.
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`): - Миграции — шаги PocketBase на Go
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему (`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
поля заводятся кодом. При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением. [../database.md](../database.md) тем же изменением.
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким - Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
+9 -5
View File
@@ -9,9 +9,10 @@
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
решают сами. решают сами.
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в **Механизировано:** приведение типа и `err == ErrX` ловит `errorlint`,
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних сторонние пакеты ошибок `depguard`, узнавание ошибки по тексту сообщения —
пакетов ошибок в проекте и так нет. тест-сканер `internal/archrules`. Перечень и адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Базовая идиома: stdlib ## Базовая идиома: stdlib
@@ -135,8 +136,11 @@ transcriber — **приложение, а не библиотека**: внеш
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) — - Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`. это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не - `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` на причину сохраняется. Общее правило: **секрет не кладём проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта. в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным Разговор с Telegram этому правилу следует, и точка чистки одна на все вызовы —
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня `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`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот расширением. В журнал оно идёт **собственным полем** строки приёма — это
хвост не выходит: там расширение приводится к перечню известных форматов. Остаток объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
описан в [../security.md](../security.md). «Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
[../security.md](../security.md).
## Куда пишем и уровень ## Куда пишем и уровень
+25 -8
View File
@@ -8,11 +8,17 @@
CGO сборке не нужен. CGO сборке не нужен.
Схему двигают **шаги миграций PocketBase** на Go, каталог Схему двигают **шаги миграций PocketBase** на Go, каталог
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг `internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя
регистрируется при загрузке пакета, а накатывается при подъёме хранилища шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый
переписывается — изменение только новым шагом: применённое хранилище считает по шаг не переписывается — изменение только новым шагом: применённое хранилище
имени файла. считает по имени шага.
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
пути** (`docs/.docs.json`, ключ `migrations`), а префикс наводится только на
каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
репозитория берёт их оттуда.
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита. **Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
@@ -96,9 +102,17 @@ capability, и третий смысл развёл бы одно слово п
файлы, ни объекты в Object Storage не удаляются после завершения задачи: файлы, ни объекты в Object Storage не удаляются после завершения задачи:
каталог и бакет растут неограниченно. каталог и бакет растут неограниченно.
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла - **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
не помечено защищённым: право прочитать запись даёт знание её идентификатора, помечено защищённым шагом `202608120001`, а правило просмотра коллекции
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
хранилище **в журнал не пишется** — оно последняя часть ссылки. файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её
сужает: создание записи разрешено только контексту обмена OIDC
(`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены.
Без этого сужения закрытие API обходится двумя запросами — завести себе
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции. - **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением, `app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
@@ -134,6 +148,9 @@ capability, и третий смысл развёл бы одно слово п
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | | Потолок размера одной записи | 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 | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня | | Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Работает сегодня, но токенов нет и доступ не разграничен | | Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только кука, снятая из браузера. Токен приносит `api-tokens` |
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11 **Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
@@ -68,8 +68,11 @@
файл. Своей записи и работы без сети не делаем — граница цели файл. Своей записи и работы без сети не делаем — граница цели
[web-access](../tasks/items/web-access.md). [web-access](../tasks/items/web-access.md).
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради - **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
речи в них. Складом произвольных файлов, папками и общим доступом к чужим речи в них. Складом произвольных файлов и папками сервис не становится. Общего
записям сервис не становится. доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у
записи в модели данных не существует, и всякий вошедший видит все записи
([security.md](security.md), «Периметр»). Это состояние, а не решение;
закрывает его `record-ownership`.
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и - **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем: показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
пользователя, потратившего слишком много, останавливает разговор или отзыв пользователя, потратившего слишком много, останавливает разговор или отзыв
@@ -95,8 +98,10 @@
отличает их по MIME-типу и расширению. Работает сегодня. отличает их по MIME-типу и расширению. Работает сегодня.
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном, 5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
увидит `done` и текст. Работает сегодня, но без токена и без разграничения увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
доступа. анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
его заводит `api-tokens`, — как нет и разграничения записей между
пользователями.
6. **Отказ на середине.** Конвертация или распознавание не удались — задача 6. **Отказ на середине.** Конвертация или распознавание не удались — задача
переходит в `failed`, а пользователь получает сообщение о том, что именно не переходит в `failed`, а пользователь получает сообщение о том, что именно не
вышло, и предложение повторить. вышло, и предложение повторить.
@@ -109,7 +114,10 @@
которой пользуемся: она и задаёт потолок по длине записи и формату. которой пользуемся: она и задаёт потолок по длине записи и формату.
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис - **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
перестанет устраивать по цене или по качеству русской речи. перестанет устраивать по цене или по качеству русской речи.
- **PocketBase** — хранилище взамен сегодняшнего SQLite, решено 2026-08-11 **PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Учётные задача `pocketbase-storage` 2026-08-12
записи оно хранит и получает от Authelia своим провайдером OIDC, но источником ([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
их не становится: заводит и проверяет людей по-прежнему Authelia. держит хранилище, файлы и панель владельца. Схема и
раскладка — [database.md](database.md). Учётные записи она хранит и получает от
Authelia своим провайдером OIDC, но источником их не становится: заводит и
проверяет людей по-прежнему Authelia.
+71
View File
@@ -99,6 +99,77 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
на `/_/` кодом `200`. на `/_/` кодом `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`):
- список колонок совпадает во всех четырёх запросах файла; - список колонок совпадает во всех четырёх местах — `applyToRecord`,
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`; `recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
- захват задачи не выдаёт одну строку двум вызывающим; [CLAUDE.md](../CLAUDE.md), «Инварианты»);
- ошибка драйвера транслируется в доменную у источника. - захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата;
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`, **Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`): `adapter/metaviewer/ffmpeg`):
@@ -95,20 +105,29 @@
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в - **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет. [database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост. Новой находкой это не считается, пока не измерен рост.
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой - **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не
[security.md](security.md). Находкой считается только новая поверхность, новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а
выставленная наружу, а не повторение этого факта. разграничения по владельцу нет сознательно — [security.md](security.md),
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
поверхность, выставленная наружу, либо путь к содержимому записи **без**
сессии, а не повторение этого факта.
### Вопросы по темам ### Вопросы по темам
Форма: `<тема>: <вопрос> (<провенанс>)`. Форма: `<тема>: <вопрос> (<провенанс>)`.
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры - `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и внешнего собеседника и это держат правила `noctx` и `contextcheck`
`transcribe.go`, 2026-08-10). ([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 — ни у - `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`, одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
2026-08-10). контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
`s3.go`, `speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и - `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`, возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10). 2026-08-10).
@@ -132,6 +151,23 @@
(CLAUDE.md, «Инварианты»). (CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня - `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`: реальный профиль нагрузки. Проект работает на единицах записей в - `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами; день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата - `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 — правда, звали так, что он всегда отказывал, — а теперь получают 2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
длительность от подставного источника. Своего теста у длительность от подставного источника. Своего теста у
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в `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 — образ не собирался, и этого не увидел никто [проскочил] ## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0` **Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0`
@@ -219,6 +461,12 @@ API и имя не откатываются обратной правкой по
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем директивой `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 — норма требовала от сервиса недостижимого [пойман ревью] ## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац - **Где:** дельта-спека `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` | Любой из интернета | | Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
| Идентификатор задачи | `GET /api/status/:id` | Любой из интернета | | Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка | | Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель | | Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов | | Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
@@ -94,9 +109,12 @@ Telegram отправителю.
каталогов, но это единственное, что стоит между входом и именем файла. каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет. расширением. Бакет один на все записи, префикса по пользователю нет.
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не - **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется** можно только с коротким токеном файла, который выдаётся по сессии, и запрос
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
остаётся: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку — иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем. целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное, - **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
@@ -124,18 +142,44 @@ Telegram отправителю.
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
меняется владельцем в любой момент: список привязан к изменяемому значению. меняется владельцем в любой момент: список привязан к изменяемому значению.
- **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу. - **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты вместе с кукой `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` | | Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` | | Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | | Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
@@ -223,8 +267,16 @@ Telegram отправителю.
приходит путь, выданный самим 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), шестичасовая запись весит единицы гигабайт — оценка, а не (паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а
замер: `research/` пуст, потолок длины стоит открытым вопросом не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать — 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
нет и не будет: решено считать расход и показывать его владельцу, а не
отказывать (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
Authelia. Рост каталога данных при этом ничем не наблюдается — Authelia. Рост каталога данных при этом ничем не наблюдается —
открытый вопрос `architecture.md`. открытый вопрос `architecture.md`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель - **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
+16 -16
View File
@@ -1,14 +1,15 @@
module git.vakhrushev.me/av/transcriber module git.vakhrushev.me/av/transcriber
go 1.25.0 go 1.26.0
require ( require (
github.com/BurntSushi/toml v1.5.0 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/config v1.30.3
github.com/aws/aws-sdk-go-v2/credentials v1.18.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/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/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
@@ -17,25 +18,24 @@ require (
github.com/prometheus/client_golang v1.23.0 github.com/prometheus/client_golang v1.23.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/yandex-cloud/go-genproto v0.17.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 ( require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 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/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/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // 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/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/internal/v4a v1.4.22 // 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/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 // 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.2 // 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.2 // 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/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/ssooidc v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.36.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/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // 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/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.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/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/protobuf v1.36.7 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.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-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 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 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.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= 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.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg= 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.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg= 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 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc=
github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI= 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= 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/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 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/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.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
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/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
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.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
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/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 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/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.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2/go.mod h1:Z2lDojZB+92Wo6EKiZZmJid9pPrDJW2NNIXSlaEfVlU= 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.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= 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.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= 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.8.2 h1:blV3dY6WbxIVOFggfYIo2E1Q2lZoy5imS7nKgu5m6Tc= 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.8.2/go.mod h1:cBWNeLBjHJRSmXAxdS7mwiMUEgx6zup4wQ9J+/PcsRQ= 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.2 h1:oxmDEO14NBZJbK/M8y3brhMFEIGN4j8a6Aq8eY0sqlo= 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.2/go.mod h1:4hH+8QCrk1uRWDPsVfsNDUup3taAjO8Dnx63au7smAU= 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.2 h1:0hBNFAPwecERLzkhhBY+lQKUMpXSKVv4Sxovikrioms= 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.2/go.mod h1:Vcnh4KyR4imrrjGN7A2kP2v9y6EPudqoPKXtnmBliPU= 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.86.0 h1:utPhv4ECQzJIUbtx7vMN4A8uZxlQ5tSt1H1toPI41h8= 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.86.0/go.mod h1:1/eZYtTWazDgVl96LmGdGktHFi7prAcGCrJ9JGvBITU= 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 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/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 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/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 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M=
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58= 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 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 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/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 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= 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.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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= 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.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 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 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/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-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= 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-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+5 -3
View File
@@ -1,6 +1,7 @@
package ffmpeg package ffmpeg
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -15,7 +16,7 @@ func NewFfmpegConverter() *FfmpegConverter {
return &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) { if _, err := os.Stat(src); os.IsNotExist(err) {
return fmt.Errorf("input file does not exist: %s", src) 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) return fmt.Errorf("ffmpeg not found in PATH: %w", err)
} }
// Создаем команду ffmpeg для конвертации в OGG // Команда заводится с контекстом: отменённый контекст убивает процесс, а не
cmd := exec.Command(ffmpegExecutable, // оставляет его дожёвывать чужую запись после остановки воркера.
cmd := exec.CommandContext(ctx, ffmpegExecutable,
"-i", src, // входной файл "-i", src, // входной файл
"-c:a", "libvorbis", // кодек Vorbis для OGG "-c:a", "libvorbis", // кодек Vorbis для OGG
"-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество) "-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество)
+5 -3
View File
@@ -1,6 +1,7 @@
package ffmpeg package ffmpeg
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -26,7 +27,7 @@ func NewFfmpegMetaViewer() *FfmpegMetaViewer {
return &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) { if _, err := os.Stat(src); os.IsNotExist(err) {
return nil, fmt.Errorf("input file does not exist: %s", src) 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) return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
} }
// Создаем команду ffprobe для получения метаданных // Команда заводится с контекстом: отправитель, закрывший соединение, не
cmd := exec.Command(ffprobeExecutable, // оставляет за собой чтение метаданных чужого файла.
cmd := exec.CommandContext(ctx, ffprobeExecutable,
"-v", "quiet", // тихий режим (без лишнего вывода) "-v", "quiet", // тихий режим (без лишнего вывода)
"-print_format", "json", // вывод в формате JSON "-print_format", "json", // вывод в формате JSON
"-show_format", // показать информацию о формате "-show_format", // показать информацию о формате
+4 -3
View File
@@ -1,6 +1,7 @@
package recognizer package recognizer
import ( import (
"context"
"io" "io"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
@@ -9,14 +10,14 @@ import (
type MemoryAudioRecognizer struct{} 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 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 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 return entity.NewCompletedResult(), nil
} }
@@ -1,8 +1,10 @@
package yandex package yandex
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"time"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
@@ -54,16 +56,31 @@ func (s *YandexAudioRecognizerService) Close() error {
return s.sttService.Close() 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 { if err != nil {
return "", err return "", err
} }
uri := s.s3Sevice.fileUrl(fileName) 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 { if err != nil {
return "", err return "", err
} }
@@ -71,12 +88,19 @@ func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string
return opId, nil return opId, nil
} }
func (s *YandexAudioRecognizerService) GetRecognitionText(operationID string) (string, error) { // protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
return s.sttService.getRecognitionText(operationID) // родителя и собственный предел по времени. Употребляется там, где обрыв стоит
// дороже ожидания: у платной операции, чей результат нельзя переспросить.
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) { func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
operation, err := s.sttService.checkOperationStatus(operationID) 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 { if err != nil {
return nil, err 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 }, nil
} }
func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error { func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileName string) error {
_, err := s.uploader.Upload(context.Background(), &s3.PutObjectInput{ _, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucketName), Bucket: aws.String(s.bucketName),
Key: aws.String(fileName), Key: aws.String(fileName),
Body: file, Body: file,
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"strings" "strings"
"google.golang.org/grpc" "google.golang.org/grpc"
@@ -93,9 +94,7 @@ func (s *speechKitService) Close() error {
} }
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3 // recognizeFileFromS3 запускает асинхронное распознавание файла из S3
func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) { func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string) (string, error) {
ctx := context.Background()
// Добавляем авторизацию и folder_id в контекст // Добавляем авторизацию и folder_id в контекст
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -136,9 +135,7 @@ func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
} }
// GetRecognitionResult получает результат распознавания по ID операции // GetRecognitionResult получает результат распознавания по ID операции
func (s *speechKitService) getRecognitionText(operationID string) (string, error) { func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) {
ctx := context.Background()
// Добавляем авторизацию и folder_id в контекст // Добавляем авторизацию и folder_id в контекст
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -157,7 +154,10 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
for { for {
resp, err := stream.Recv() resp, err := stream.Recv()
if err != nil { if err != nil {
if err.Error() == "EOF" { // Конец потока библиотека отдаёт ровно `io.EOF`. Прежде он узнавался
// сравнением текста сообщения: так же выглядел бы и настоящий отказ
// с текстом «EOF», и распознавание молча вернуло бы половину текста.
if errors.Is(err, io.EOF) {
break break
} }
return "", fmt.Errorf("failed to receive recognition response: %w", err) return "", fmt.Errorf("failed to receive recognition response: %w", err)
@@ -176,9 +176,7 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
} }
// checkOperationStatus проверяет статус операции распознавания // checkOperationStatus проверяет статус операции распознавания
func (s *speechKitService) checkOperationStatus(operationID string) (*operation.Operation, error) { func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID string) (*operation.Operation, error) {
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
+8 -6
View File
@@ -10,13 +10,15 @@ import (
pb "github.com/pocketbase/pocketbase" pb "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
)
// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть // Шаги схемы регистрируются загрузкой своего пакета, а накатывает их
// адреса ссылки на него, поэтому меняются только новым шагом схемы. // `RunAllMigrations` ниже. Импорт здесь пустой и явный, хотя соседние файлы
const ( // пакета и так берут оттуда имена коллекций: день, когда имена перестанут
FilesCollection = "files" // читаться отсюда, унёс бы вместе с последней ссылкой и регистрацию — список
JobsCollection = "transcribe_jobs" // шагов остался бы пустым, `RunAllMigrations` вернул бы `nil`, и приложение
// поднялось бы здоровым, но без коллекций. Отказ вылез бы не на старте, а на
// первом приёме записи.
_ "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
) )
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в // New создаёт приложение хранилища на заданном каталоге данных и приводит его в
@@ -12,6 +12,8 @@ import (
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
) )
// workFile — рабочая копия файла на диске. Живёт во временном каталоге // 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) { 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 { if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err) 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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err) 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) { 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 { if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err) return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
} }
@@ -1,22 +1,11 @@
package pocketbase package migrations
import ( import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
// Схема заводится версионированными шагами, и применённый шаг не переписывается
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
// применённое по имени файла шага.
//
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
// `apis.Serve` прежде, чем поднять сервер.
func init() {
migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
}
func up202608110001(app core.App) error { func up202608110001(app core.App) error {
files := core.NewBaseCollection(FilesCollection) files := core.NewBaseCollection(FilesCollection)
files.Fields.Add( files.Fields.Add(
@@ -118,5 +107,3 @@ func down202608110001(app core.App) error {
} }
return nil 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 ( import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
) )
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что // BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
@@ -20,7 +22,7 @@ import (
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое // стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
// переход хранит намеренно — приходило бы владельцу нулём. // переход хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) { 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() original := e.Record.Original()
if original == nil || original.GetString("state") == e.Record.GetString("state") { if original == nil || original.GetString("state") == e.Record.GetString("state") {
return e.Next() 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/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "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 { type TranscriptJobRepository struct {
@@ -23,7 +27,7 @@ func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
} }
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error { func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
collection, err := findCollection(repo.app, JobsCollection) collection, err := findCollection(repo.app, migrations.JobsCollection)
if err != nil { if err != nil {
return err return err
} }
@@ -50,7 +54,7 @@ func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
// LostAcquisitionError и результата не пишет. // LostAcquisitionError и результата не пишет.
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error { func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
err := repo.app.RunInTransaction(func(txApp core.App) 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 { if err != nil {
return fmt.Errorf("failed to find transcribe job: %w", err) 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) { 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 { if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err) 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) { 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(` query := repo.app.DB().NewQuery(`
UPDATE {{` + JobsCollection + `}} UPDATE {{` + migrations.JobsCollection + `}}
SET acquisition_id = {:acquisition_id}, SET acquisition_id = {:acquisition_id},
acquire_time = {:now}, acquire_time = {:now},
attempts = attempts + 1, attempts = attempts + 1,
updated = {:now} updated = {:now}
WHERE id = ( WHERE id = (
SELECT id FROM {{` + JobsCollection + `}} SELECT id FROM {{` + migrations.JobsCollection + `}}
WHERE state = {:state} WHERE state = {:state}
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now}) AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting}) 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"} 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 return row.toJob(), nil
@@ -16,6 +16,8 @@ import (
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
) )
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же // 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) require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour)) record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
require.NoError(t, app.Save(record)) require.NoError(t, app.Save(record))
@@ -232,7 +234,7 @@ func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Задача досталась другому, пока шаг работал. // Задача досталась другому, пока шаг работал.
record, err := app.FindRecordById(JobsCollection, mine.Id) record, err := app.FindRecordById(migrations.JobsCollection, mine.Id)
require.NoError(t, err) require.NoError(t, err)
record.Set("acquisition_id", "someone-else") record.Set("acquisition_id", "someone-else")
require.NoError(t, app.Save(record)) require.NoError(t, app.Save(record))
@@ -279,7 +281,7 @@ func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, acquired.AcquisitionID) require.NotNil(t, acquired.AcquisitionID)
record, err := app.FindRecordById(JobsCollection, job.Id) record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err) require.NoError(t, err)
record.Set("attempts", 5) record.Set("attempts", 5)
record.Set("state", entity.StateDead) record.Set("state", entity.StateDead)
@@ -360,7 +362,7 @@ func patchRecord(t *testing.T, app core.App, recordID, body string) {
req := httptest.NewRequest( req := httptest.NewRequest(
http.MethodPatch, http.MethodPatch,
"/api/collections/"+JobsCollection+"/records/"+recordID, "/api/collections/"+migrations.JobsCollection+"/records/"+recordID,
strings.NewReader(body), strings.NewReader(body),
) )
req.Header.Set("Content-Type", "application/json") 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) { func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
bot, err := tgbotapi.NewBotAPI(botToken) // Клиент заводится единой точкой: её отказ не несёт токена, а отказ
// конструктора несёт — `NewBotAPI` зовёт `getMe`.
bot, err := NewBot(botToken, logger)
if err != nil { if err != nil {
return nil, err 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 ( import (
"fmt" "fmt"
"net/url"
"os" "os"
"sort"
"strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
@@ -12,6 +15,7 @@ type Config struct {
Storage StorageConfig `toml:"storage"` Storage StorageConfig `toml:"storage"`
Yandex YandexConfig `toml:"yandex"` Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"` Telegram TelegramConfig `toml:"telegram"`
Auth AuthConfig `toml:"auth"`
} }
type ServerConfig struct { type ServerConfig struct {
@@ -42,6 +46,69 @@ type TelegramConfig struct {
UpdateTimeout int `toml:"update_timeout"` 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 // DefaultConfig returns a Config with default values
func defaultConfig() *Config { func defaultConfig() *Config {
return &Config{ return &Config{
@@ -66,6 +133,9 @@ func defaultConfig() *Config {
BotToken: "", BotToken: "",
UpdateTimeout: 10, 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 package contract
import ( import (
"context"
"io" "io"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
@@ -10,18 +11,23 @@ type AudioInfo struct {
Seconds int // Длина аудиофайла в секундах Seconds int // Длина аудиофайла в секундах
} }
// Контекст первым доводом несут все интерфейсы, за которыми стоит внешний
// собеседник — процесс `ffmpeg`, S3, SpeechKit. Он здесь не украшение: остановка
// сервиса обязана доходить до чужой работы, а не оставлять её сиротой. Без него
// конвертация шестичасовой записи переживает остановку воркера, а запрос к
// платному распознаванию висит до собственного таймаута библиотеки.
type AudioMetaViewer interface { type AudioMetaViewer interface {
GetInfo(src string) (*AudioInfo, error) GetInfo(ctx context.Context, src string) (*AudioInfo, error)
} }
type AudioFileConverter interface { type AudioFileConverter interface {
Convert(src, dest string) error Convert(ctx context.Context, src, dest string) error
} }
type AudioRecognizer interface { type AudioRecognizer interface {
Recognize(file io.Reader, fileName string) (operationID string, err error) Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error)
GetRecognitionText(operationID string) (string, error) GetRecognitionText(ctx context.Context, operationID string) (string, error)
CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error)
} }
type TelegramMessageSender interface { 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 package http
import ( import (
"context"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
@@ -44,6 +45,14 @@ type GetTranscribeJobResponse struct {
// сохранены — публичный контракт API объявлен необратимым. // сохранены — публичный контракт API объявлен необратимым.
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) { func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
api := r.Group("/api") api := r.Group("/api")
// Оба адреса уходят за аутентификацию. Слой предъявления стоит перед
// проверкой и действует только здесь: собственная поверхность хранилища под
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
api.Bind(SessionFromCookie())
api.Bind(apis.RequireAuth())
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись // Умолчание роутера хранилища — 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 { if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей // Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ. // границей и уже написал о нём. Транспорт переводит ошибку в ответ.
+104 -20
View File
@@ -2,6 +2,7 @@ package http
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -22,6 +23,7 @@ import (
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" 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/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
@@ -38,7 +40,12 @@ type stubMetaViewer struct {
err error 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 { if m.err != nil {
return nil, m.err return nil, m.err
} }
@@ -49,7 +56,7 @@ func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
// этого файла её не зовёт. // этого файла её не зовёт.
type stubConverter struct{} 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 не отвечает, но сервису отправитель нужен. // TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
type TestTgSender struct{} type TestTgSender struct{}
@@ -70,6 +77,50 @@ type testEnv struct {
handler *TranscribeHandler handler *TranscribeHandler
app core.App app core.App
journal *journalBuffer 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 — перехваченный журнал одной проверки. Свой на случай: общий на // journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
@@ -147,7 +198,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
mux, err := r.BuildMux() mux, err := r.BuildMux()
require.NoError(t, err) 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 собирает запрос из имени и содержимого. Файла на диске // createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
@@ -179,7 +239,7 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище. // storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
func storedFileNames(t *testing.T, env *testEnv) []string { 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) require.NoError(t, err)
var names []string var names []string
@@ -191,14 +251,14 @@ func storedFileNames(t *testing.T, env *testEnv) []string {
// countFiles считает записи о файлах. // countFiles считает записи о файлах.
func countFiles(t *testing.T, env *testEnv) int { 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) require.NoError(t, err)
return len(records) return len(records)
} }
// countJobs считает заведённые задачи расшифровки. // countJobs считает заведённые задачи расшифровки.
func countJobs(t *testing.T, env *testEnv) int { 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) require.NoError(t, err)
return len(records) return len(records)
} }
@@ -241,7 +301,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
req := createMultipartRequest(t, "sample.m4a", content) req := createMultipartRequest(t, "sample.m4a", content)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -304,7 +364,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, tc.req(t)) env.serve(w, tc.req(t))
require.Equal(t, http.StatusBadRequest, w.Code) require.Equal(t, http.StatusBadRequest, w.Code)
@@ -327,7 +387,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
req := createMultipartRequest(t, "empty.m4a", nil) req := createMultipartRequest(t, "empty.m4a", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -374,7 +434,7 @@ func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
req := createMultipartRequest(t, tc.fileName, []byte("запись")) req := createMultipartRequest(t, tc.fileName, []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -400,7 +460,7 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись")) req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -419,7 +479,7 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе")) req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code) require.Equal(t, http.StatusInternalServerError, w.Code)
@@ -459,7 +519,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись")) req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -482,7 +542,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе")) req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code) require.Equal(t, http.StatusInternalServerError, w.Code)
@@ -507,7 +567,7 @@ func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) {
req := createMultipartRequest(t, "sample.mp3", []byte("запись")) req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -528,7 +588,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
req := createMultipartRequest(t, "sample.mp3", content) req := createMultipartRequest(t, "sample.mp3", content)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -593,7 +653,7 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись")) req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code) 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) req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code) 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) req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code) 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) req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req) env.serve(w, req)
require.Equal(t, http.StatusNotFound, w.Code) 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"]) 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 package tg
import ( import (
"context"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@@ -8,6 +10,12 @@ import (
"slices" "slices"
"strings" "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/contract"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@@ -25,25 +33,22 @@ type TelegramController struct {
} }
type TelegramConfig struct { type TelegramConfig struct {
BotToken string
UpdateTimeout int UpdateTimeout int
UserWhiteList []string UserWhiteList []string
} }
// NewTelegramController принимает готового клиента, а не токен: клиента заводит
// единая точка `internal/adapter/telegram`, и только её отказ не несёт секрета.
// Токен сюда не приезжает вовсе — значит, и утечь отсюда ему неоткуда.
func NewTelegramController( func NewTelegramController(
config TelegramConfig, config TelegramConfig,
bot *tgbotapi.BotAPI,
transcribeService *service.TranscribeService, transcribeService *service.TranscribeService,
jobRepo contract.TranscriptJobRepository, jobRepo contract.TranscriptJobRepository,
logger *slog.Logger, logger *slog.Logger,
) (*TelegramController, error) { ) (*TelegramController, error) {
botToken := config.BotToken if bot == nil {
if botToken == "" { return nil, errors.New("telegram bot is not created")
return nil, &EmptyBotTokenError{}
}
bot, err := tgbotapi.NewBotAPI(botToken)
if err != nil {
return nil, err
} }
controller := &TelegramController{ controller := &TelegramController{
@@ -58,7 +63,11 @@ func NewTelegramController(
return controller, nil 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) c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName)
u := tgbotapi.NewUpdate(0) u := tgbotapi.NewUpdate(0)
@@ -94,11 +103,11 @@ func (c *TelegramController) Start() {
// Handle audio messages and files // Handle audio messages and files
if update.Message.Audio != nil { if update.Message.Audio != nil {
c.handleAudioMessage(update.Message) c.handleAudioMessage(ctx, update.Message)
} else if update.Message.Voice != nil { } else if update.Message.Voice != nil {
c.handleVoiceMessage(update.Message) c.handleVoiceMessage(ctx, update.Message)
} else if update.Message.Document != nil { } 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) 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 := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
progressMsg.ReplyToMessageID = message.MessageID 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 { if err != nil {
c.logger.Error("Failed to download audio file", "error", err) c.logger.Error("Failed to download audio file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.") errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
@@ -169,7 +178,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
defer fileReader.Close() 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 { if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err) c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.") errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
@@ -183,7 +192,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
c.send(successMsg) 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 := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...")
progressMsg.ReplyToMessageID = message.MessageID 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 { if err != nil {
c.logger.Error("Failed to download voice file", "error", err) c.logger.Error("Failed to download voice file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.") errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.")
@@ -204,7 +213,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
defer fileReader.Close() 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 { if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err) c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.") errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
@@ -218,7 +227,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
c.send(successMsg) 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) { if !c.isAudioDocument(message.Document) {
return 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 { if err != nil {
c.logger.Error("Failed to download document file", "error", err) c.logger.Error("Failed to download document file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.") errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
@@ -244,7 +253,7 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
defer fileReader.Close() 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 { if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err) c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.") errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
@@ -258,20 +267,41 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
c.send(successMsg) 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}) file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to get file info: %w", err) return nil, "", fmt.Errorf("failed to get file info: %w", err)
} }
// Скачиваем файл // Скачиваем файл. Запрос заводится с контекстом: скачивание шестичасовой
// записи иначе продолжается и после остановки сервиса, а ссылка на файл
// несёт токен бота — держать её живой дольше нужного незачем.
//
// Клиент берётся у бота, а не `http.DefaultClient`: у бота он свой, и его
// отказ уже не несёт адреса (`internal/adapter/telegram`, единая точка).
fileURL := file.Link(c.bot.Token) 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 { if err != nil {
return nil, "", fmt.Errorf("failed to download file: %w", err) 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 // Получаем имя файла из URL
fileName := file.FilePath fileName := file.FilePath
if fileName == "" { if fileName == "" {
+26 -7
View File
@@ -17,13 +17,22 @@ type Worker interface {
Name() string Name() string
} }
// pollInterval — пауза между прогонами шага. Полем, а не константой по месту:
// проверке нужен второй прогон, чтобы остановить воркер **после** того, как он
// рассудил об исходе первого. Отменять контекст изнутри шага она не может —
// отменённый контекст теперь и значит «нас остановили».
const pollInterval = time.Second
type CallbackWorker struct { type CallbackWorker struct {
name string name string
f func() error // Шаг принимает контекст воркера: остановка обязана доходить до чужой
// работы, которую шаг завёл, а не только прерывать цикл между шагами.
f func(ctx context.Context) error
logger *slog.Logger 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 { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
@@ -32,6 +41,7 @@ func NewCallbackWorker(name string, f func() error, logger *slog.Logger) *Callba
name: name, name: name,
f: f, f: f,
logger: logger, 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()) w.logger.Info("Worker received shutdown signal", "worker", w.Name())
return return
default: default:
err := w.f() err := w.f(ctx)
// Признак узнаётся по смыслу, а не по точной форме значения: // Признак узнаётся по смыслу, а не по точной форме значения:
// приведение типа видело только вершину цепочки и сломалось бы от // приведение типа видело только вершину цепочки и сломалось бы от
// первой же обёртки `%w`, которая в проекте — умолчание. // первой же обёртки `%w`, которая в проекте — умолчание.
var noop *contract.NoopJobError var noop *contract.NoopJobError
isNoop := errors.As(err, &noop) 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() 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) 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 { select {
case <-ctx.Done(): case <-ctx.Done():
w.logger.Info("Worker received shutdown signal during sleep", "worker", w.Name()) w.logger.Info("Worker received shutdown signal during sleep", "worker", w.Name())
return return
case <-time.After(1 * time.Second): case <-time.After(w.interval):
// Продолжаем работу // Продолжаем работу
} }
} }
+66 -10
View File
@@ -40,10 +40,13 @@ func (b *journalBuffer) String() string {
} }
// runOnce прогоняет воркер ровно один раз и возвращает журнал этого прогона. // 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() t.Helper()
journal := &journalBuffer{} journal := &journalBuffer{}
@@ -54,15 +57,21 @@ func runOnce(t *testing.T, name string, work func() error) string {
var once sync.Once var once sync.Once
done := make(chan struct{}) done := make(chan struct{})
calls := 0
w := NewCallbackWorker(name, func() error { w := NewCallbackWorker(name, func(ctx context.Context) error {
err := work() calls++
if calls > 1 {
// Первый прогон уже рассужен: журнал написан, счётчик сдвинут.
once.Do(func() { once.Do(func() {
cancel() cancel()
close(done) close(done)
}) })
return err return &contract.NoopJobError{State: "stopping"}
}
return work(ctx)
}, logger) }, logger)
w.interval = time.Millisecond
finished := make(chan struct{}) finished := make(chan struct{})
go func() { go func() {
@@ -147,7 +156,7 @@ func TestWrappedNoopIsNotAFailure(t *testing.T) {
before := jobCount(t, name, "false") before := jobCount(t, name, "false")
beforeErr := jobCount(t, name, "true") 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"}) 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") 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") return errors.New("database is gone")
}) })
@@ -191,7 +200,7 @@ func TestSuccessIsCounted(t *testing.T) {
before := jobCount(t, name, "false") before := jobCount(t, name, "false")
journal := runOnce(t, name, func() error { journal := runOnce(t, name, func(context.Context) error {
return nil return nil
}) })
@@ -202,3 +211,50 @@ func TestSuccessIsCounted(t *testing.T) {
t.Errorf("успешный прогон записан отказом: журнал %q", journal) 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 ( import (
"time" "time"
"git.vakhrushev.me/av/transcriber/internal/clock"
) )
type TranscribeJob struct { type TranscribeJob struct {
@@ -52,13 +54,13 @@ func (j *TranscribeJob) MoveToState(state string) {
// именно отказавшие: иначе задача, прошедшая конвейер целиком, накопила бы // именно отказавшие: иначе задача, прошедшая конвейер целиком, накопила бы
// их поштучно и умерла бы здоровой. // их поштучно и умерла бы здоровой.
j.Attempts = 0 j.Attempts = 0
j.UpdatedAt = time.Now() j.UpdatedAt = clock.Now()
} }
func (j *TranscribeJob) MoveToStateAndDelay(state string, delay *time.Time) { func (j *TranscribeJob) MoveToStateAndDelay(state string, delay *time.Time) {
j.MoveToState(state) j.MoveToState(state)
j.DelayTime = delay j.DelayTime = delay
j.UpdatedAt = time.Now() j.UpdatedAt = clock.Now()
} }
func (j *TranscribeJob) Done(transcriptionText string) { func (j *TranscribeJob) Done(transcriptionText string) {
@@ -78,7 +80,7 @@ func (j *TranscribeJob) RetryAfter(delay time.Time) {
j.AcquisitionID = nil j.AcquisitionID = nil
j.AcquireTime = nil j.AcquireTime = nil
j.DelayTime = &delay j.DelayTime = &delay
j.UpdatedAt = time.Now() j.UpdatedAt = clock.Now()
} }
// Die переводит задачу, исчерпавшую попытки, в состояние «мертва». Число // Die переводит задачу, исчерпавшую попытки, в состояние «мертва». Число
+1 -2
View File
@@ -3,7 +3,6 @@ package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"testing" "testing"
"time" "time"
@@ -36,7 +35,7 @@ func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.Transcr
} }
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService { 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) return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
} }
+21 -19
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"context"
"errors" "errors"
"io" "io"
"log/slog" "log/slog"
@@ -17,6 +18,7 @@ import (
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" 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/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
@@ -28,19 +30,19 @@ import (
// failingConverter отказывает на каждой попытке. // failingConverter отказывает на каждой попытке.
type failingConverter struct{} type failingConverter struct{}
func (c *failingConverter) Convert(string, string) error { func (c *failingConverter) Convert(context.Context, string, string) error {
return errors.New("конвертация не удалась") return errors.New("конвертация не удалась")
} }
type okMetaViewer struct{} 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 return &contract.AudioInfo{Seconds: 1}, nil
} }
type failingMetaViewer struct{} 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("запись не читается") return nil, errors.New("запись не читается")
} }
@@ -89,7 +91,7 @@ func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter
converter, converter,
&recognizer.MemoryAudioRecognizer{}, &recognizer.MemoryAudioRecognizer{},
sender, sender,
slog.New(slog.NewTextHandler(io.Discard, nil)), slog.New(slog.DiscardHandler),
) )
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender} 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() t.Helper()
chatId := int64(100) 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) require.NoError(t, err)
return job return job
} }
@@ -110,7 +112,7 @@ func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) { func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
t.Helper() t.Helper()
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID) record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
require.NoError(t, err) require.NoError(t, err)
record.Set("delay_time", "") record.Set("delay_time", "")
require.NoError(t, env.app.Save(record)) 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) { func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) {
t.Helper() t.Helper()
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID) record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
require.NoError(t, err) require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour)) record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
require.NoError(t, env.app.Save(record)) require.NoError(t, env.app.Save(record))
@@ -148,7 +150,7 @@ func TestJobDiesAfterAttemptLimit(t *testing.T) {
rotAcquisition(t, env, job.Id) rotAcquisition(t, env, job.Id)
// Следующий захват видит перебор и хоронит задачу. // Следующий захват видит перебор и хоронит задачу.
err := env.service.FindAndRunConversionJob() err := env.service.FindAndRunConversionJob(t.Context())
var noop *contract.NoopJobError var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся") 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)) _, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err) 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) require.NoError(t, err)
record.Set("state", entity.StateCreated) record.Set("state", entity.StateCreated)
require.NoError(t, env.app.Save(record)) require.NoError(t, env.app.Save(record))
@@ -202,13 +204,13 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
empty, err := env.fileRepo.CreateRemote("object-key", 1) empty, err := env.fileRepo.CreateRemote("object-key", 1)
require.NoError(t, err) 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) require.NoError(t, err)
record.Set("file", empty.Id) record.Set("file", empty.Id)
require.NoError(t, env.app.Save(record)) 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) after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err) require.NoError(t, err)
@@ -219,7 +221,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
// Второй отказ — с той же задачи, пауза снята вручную. // Второй отказ — с той же задачи, пауза снята вручную.
clearDelay(t, env, job.Id) 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) after, err = env.jobRepo.GetByID(job.Id)
require.NoError(t, err) require.NoError(t, err)
@@ -246,7 +248,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{}) 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, "отказ источника метаданных роняет приём") require.Error(t, err, "отказ источника метаданных роняет приём")
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
@@ -262,7 +264,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) 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) require.NoError(t, err)
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
@@ -279,7 +281,7 @@ func TestJobNeverPointsToMissingFile(t *testing.T) {
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на // Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
// исходную запись, а не на несозданный результат. // исходную запись, а не на несозданный результат.
require.NoError(t, env.service.FindAndRunConversionJob()) require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id) after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err) require.NoError(t, err)
@@ -297,7 +299,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
content := strings.Repeat("запись ", 1000) 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.NoError(t, err)
require.NotNil(t, job.FileID) require.NotNil(t, job.FileID)
@@ -320,7 +322,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
func TestLocalizeGivesReadableCopy(t *testing.T) { func TestLocalizeGivesReadableCopy(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) 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.NoError(t, err)
require.NotNil(t, job.FileID) require.NotNil(t, job.FileID)
@@ -354,7 +356,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
require.Empty(t, leftovers, "приём убрал свою рабочую копию") require.Empty(t, leftovers, "приём убрал свою рабочую копию")
// Конвертация отказывает — задача уходит в `failed`, копии убраны. // Конвертация отказывает — задача уходит в `failed`, копии убраны.
require.NoError(t, env.service.FindAndRunConversionJob()) require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*")) leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err) require.NoError(t, err)
+14 -13
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"context"
"errors" "errors"
"io" "io"
"strings" "strings"
@@ -10,7 +11,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
@@ -32,7 +33,7 @@ type scriptedRecognizer struct {
lastObjectKey string 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.recognizeCalls++
r.lastObjectKey = fileName r.lastObjectKey = fileName
if r.recognizeErr != nil { if r.recognizeErr != nil {
@@ -45,11 +46,11 @@ func (r *scriptedRecognizer) Recognize(file io.Reader, fileName string) (string,
return "operation-id", nil return "operation-id", nil
} }
func (r *scriptedRecognizer) GetRecognitionText(string) (string, error) { func (r *scriptedRecognizer) GetRecognitionText(context.Context, string) (string, error) {
return r.text, nil 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 return r.result, nil
} }
@@ -92,7 +93,7 @@ func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) {
rec := &scriptedRecognizer{result: entity.NewInProgressResult()} rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
svc := withRecognizer(env, rec) svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeJob()) require.NoError(t, svc.FindAndRunTranscribeJob(t.Context()))
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю") assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван") assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
@@ -119,7 +120,7 @@ func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")} rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
svc := withRecognizer(env, rec) svc := withRecognizer(env, rec)
require.Error(t, svc.FindAndRunTranscribeJob()) require.Error(t, svc.FindAndRunTranscribeJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id) after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err) require.NoError(t, err)
@@ -134,7 +135,7 @@ func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognize
t.Helper() t.Helper()
job := convertedJob(t, env) 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) clearDelay(t, env, job.Id)
return job return job
@@ -150,7 +151,7 @@ func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
svc := withRecognizer(env, rec) svc := withRecognizer(env, rec)
for i := 0; i < 3; i++ { 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) after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err) require.NoError(t, err)
@@ -174,7 +175,7 @@ func TestCheckJobFailsJobAndTellsSender(t *testing.T) {
rec.result = entity.NewFailedResult("операция отклонена") rec.result = entity.NewFailedResult("операция отклонена")
svc := withRecognizer(env, rec) svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob()) require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id) after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err) require.NoError(t, err)
@@ -196,7 +197,7 @@ func TestCheckJobCompletesAndAnswersOnce(t *testing.T) {
rec.text = "расшифровка записи" rec.text = "расшифровка записи"
svc := withRecognizer(env, rec) svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob()) require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id) after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err) require.NoError(t, err)
@@ -224,7 +225,7 @@ func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) {
rec.text = "" rec.text = ""
svc := withRecognizer(env, rec) svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob()) require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id) after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err) 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)) acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour))
require.NoError(t, err) 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) require.NoError(t, err)
record.Set("acquisition_id", "someone-else") record.Set("acquisition_id", "someone-else")
require.NoError(t, env.app.Save(record)) require.NoError(t, env.app.Save(record))
svc := withRecognizer(env, rec) svc := withRecognizer(env, rec)
err = svc.checkTranscribeJob(acquired, "mine") err = svc.checkTranscribeJob(t.Context(), acquired, "mine")
var lost *contract.LostAcquisitionError var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost) 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 package service
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -14,6 +15,8 @@ import (
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/metrics"
"github.com/google/uuid" "github.com/google/uuid"
"git.vakhrushev.me/av/transcriber/internal/clock"
) )
const ( 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{ job := &entity.TranscribeJob{
State: entity.StateCreated, State: entity.StateCreated,
Source: entity.SourceTelegram, Source: entity.SourceTelegram,
@@ -81,19 +84,19 @@ func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName strin
TgReplyMessageId: &replyMsgId, 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{ job := &entity.TranscribeJob{
State: entity.StateCreated, State: entity.StateCreated,
Source: entity.SourceApi, 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) ext := filepath.Ext(fileName)
if ext == "" { if ext == "" {
@@ -120,7 +123,7 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
// строка журнала вместе с идентификатором записи собрала бы её целиком. // строка журнала вместе с идентификатором записи собрала бы её целиком.
s.logger.Info("Creating transcribe job", "file_ext", ext) 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 { if err != nil {
s.logger.Error("Failed to get file info", "error", err, "file_ext", ext) s.logger.Error("Failed to get file info", "error", err, "file_ext", ext)
return nil, err return nil, err
@@ -158,28 +161,41 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
return job, nil return job, nil
} }
func (s *TranscribeService) FindAndRunConversionJob() error { func (s *TranscribeService) FindAndRunConversionJob(ctx context.Context) error {
return s.runStep(entity.StateCreated, conversionAcquireTimeout, s.convertJob) return s.runStep(ctx, entity.StateCreated, conversionAcquireTimeout, s.convertJob)
} }
func (s *TranscribeService) FindAndRunTranscribeJob() error { func (s *TranscribeService) FindAndRunTranscribeJob(ctx context.Context) error {
return s.runStep(entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob) return s.runStep(ctx, entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob)
} }
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error { func (s *TranscribeService) FindAndRunTranscribeCheckJob(ctx context.Context) error {
return s.runStep(entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob) return s.runStep(ctx, entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob)
} }
// runStep забирает задачу и отдаёт её шагу. Отказ шага не оставляет задачу // 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) job, holder, err := s.findJob(state, expiration)
if err != nil { if err != nil {
return err return err
} }
if err := step(job, holder); err != nil { if err := step(ctx, job, holder); err != nil {
s.scheduleRetry(job, holder, err) s.scheduleRetry(job, holder, err)
return err return err
} }
@@ -187,7 +203,7 @@ func (s *TranscribeService) runStep(state string, expiration time.Duration, step
return nil 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) s.logger.Info("Starting conversion job", "job_id", job.Id)
if job.FileID == nil { 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) s.logger.Info("Converting file", "job_id", job.Id, "src_format", srcExt)
// Измеряем время конвертации // Измеряем время конвертации
startTime := time.Now() startTime := clock.Start()
err = s.converter.Convert(src.Path(), dest.Path()) err = s.converter.Convert(ctx, src.Path(), dest.Path())
conversionDuration := time.Since(startTime) conversionDuration := time.Since(startTime)
// Записываем метрику времени конвертации // Записываем метрику времени конвертации
metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds()) metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds())
if err != nil { 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", s.logger.Error("File conversion failed",
"error", err, "error", err,
"job_id", job.Id, "job_id", job.Id,
@@ -274,7 +304,7 @@ func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string)
return nil 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) s.logger.Info("Starting transcribe job", "job_id", job.Id)
if job.FileID == nil { 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) 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 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) s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
return err return err
} }
@@ -321,7 +355,7 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
// Обновляем задачу с ID операции распознавания // Обновляем задачу с ID операции распознавания
job.FileID = &destFileRecord.Id job.FileID = &destFileRecord.Id
job.RecognitionOpID = &operationID job.RecognitionOpID = &operationID
delayTime := time.Now().Add(firstCheckDelay) delayTime := clock.Now().Add(firstCheckDelay)
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime) job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
if err := s.jobRepo.Save(job, holder); err != nil { if err := s.jobRepo.Save(job, holder); err != nil {
@@ -333,18 +367,22 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
return nil 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 { if job.RecognitionOpID == nil {
s.logger.Error("Recognition operation ID not found", "job_id", job.Id) 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 opId := *job.RecognitionOpID
// Проверяем статус операции // Проверяем статус операции
s.logger.Info("Checking operation status", "job_id", job.Id, "operation_id", opId) 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 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) s.logger.Error("Failed to check recognition status", "error", err, "operation_id", opId)
return err 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) 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) job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
if err := s.jobRepo.Save(job, holder); err != nil { if err := s.jobRepo.Save(job, holder); err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) 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 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) s.logger.Error("Failed to get recognition text", "error", err, "operation_id", opId)
return err 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) { func (s *TranscribeService) findJob(state string, expiration time.Duration) (*entity.TranscribeJob, string, error) {
acquisitionId := uuid.NewString() acquisitionId := uuid.NewString()
rottingTime := time.Now().Add(-1 * expiration) rottingTime := clock.Now().Add(-1 * expiration)
job, err := s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime) job, err := s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime)
if err != nil { if err != nil {
@@ -448,7 +490,17 @@ func (s *TranscribeService) scheduleRetry(job *entity.TranscribeJob, holder stri
return 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 { if err := s.jobRepo.Save(job, holder); err != nil {
var lostOnSave *contract.LostAcquisitionError var lostOnSave *contract.LostAcquisitionError
+30
View File
@@ -1,11 +1,41 @@
# Refer for explanation to following link: # Refer for explanation to following link:
# https://lefthook.dev/configuration/ # https://lefthook.dev/configuration/
#
# Предкоммитные проверки — дешёвая часть гейта на **затронутых файлах**. Полный
# набор здесь не гоняется намеренно: он идёт минуты, а pre-commit обязан быть
# быстрым. Что ловит pre-commit и что остаётся только гейту — CLAUDE.md,
# раздел «Гейт»; перечень правил и их дома — docs/conventions/go-linters.md.
templates: templates:
av-hooks-dir: "/home/av/projects/private/git-hooks" av-hooks-dir: "/home/av/projects/private/git-hooks"
pre-commit: pre-commit:
jobs: 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" - name: "gitleaks"
run: "gitleaks git --staged" run: "gitleaks git --staged"
+53 -5
View File
@@ -19,6 +19,7 @@ import (
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram" "git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/config" "git.vakhrushev.me/av/transcriber/internal/config"
"git.vakhrushev.me/av/transcriber/internal/contract"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg" tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
"git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/controller/worker"
@@ -27,6 +28,8 @@ import (
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"git.vakhrushev.me/av/transcriber/internal/clock"
) )
func main() { func main() {
@@ -50,6 +53,13 @@ func main() {
logger.Info("Configuration loaded successfully", "config_path", *configPath) 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 файла // Загружаем переменные окружения из .env файла
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
logger.Warn("Warning: .env file not found, using system environment variables") logger.Warn("Warning: .env file not found, using system environment variables")
@@ -127,13 +137,13 @@ func main() {
var wg sync.WaitGroup var wg sync.WaitGroup
tgConfig := tgcontroller.TelegramConfig{ tgConfig := tgcontroller.TelegramConfig{
BotToken: cfg.Telegram.BotToken,
UpdateTimeout: cfg.Telegram.UpdateTimeout, UpdateTimeout: cfg.Telegram.UpdateTimeout,
UserWhiteList: cfg.Server.UsersWhiteList, 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 { if err != nil {
logger.Error("Failed to create Telegram controller", "error", err) logger.Error("Failed to create Telegram controller", "error", err)
// Не останавливаем приложение, если Telegram бот не создан // Не останавливаем приложение, если Telegram бот не создан
@@ -143,7 +153,7 @@ func main() {
go func() { go func() {
defer wg.Done() defer wg.Done()
logger.Info("Starting Telegram bot") logger.Info("Starting Telegram bot")
tgController.Start() tgController.Start(ctx)
logger.Info("Telegram bot stopped gracefully") logger.Info("Telegram bot stopped gracefully")
}() }()
} }
@@ -172,6 +182,12 @@ func main() {
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
// и второму серверу на нём взяться неоткуда. // и второму серверу на нём взяться неоткуда.
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger) 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`, а хранилище пишет запросы в свою таблицу, которой в // `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
// журнале контейнера не видно. Поля — те, что просит конвенция. // журнале контейнера не видно. Поля — те, что просит конвенция.
se.Router.BindFunc(func(e *core.RequestEvent) error { se.Router.BindFunc(func(e *core.RequestEvent) error {
start := time.Now() start := clock.Start()
err := e.Next() err := e.Next()
level := slog.LevelInfo level := slog.LevelInfo
@@ -207,6 +223,20 @@ func main() {
return err 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) transcribeHandler.Register(se.Router)
se.Router.GET("/health", func(e *core.RequestEvent) error { se.Router.GET("/health", func(e *core.RequestEvent) error {
@@ -298,3 +328,21 @@ func main() {
logger.Info("Transcriber service stopped") 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 ### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`. ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
сохранена и получить заведённую под неё задачу расшифровки в состоянии
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
полем `status`.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым, Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча. и переименование поля ломает внешнюю программу молча. Появление отказа без
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных. пригодность содержимого узнаёт у источника метаданных.
@@ -27,16 +34,28 @@ MUST нести идентификатор задачи полем `job_id` и
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`. хранилище, и нормирует её capability `storage`.
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
видно было анонимно.
#### Scenario: Запись принята #### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` - **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status` - **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created` со значением `created`
- **AND** содержимое записи целиком лежит в хранилище одним файлом - **AND** содержимое записи целиком лежит в хранилище одним файлом
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни задачи не заводится
- **AND** тело ответа не несёт данных задачи
#### Scenario: Поля с записью нет #### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio` - **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи - **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится - **AND** ни файла, ни задачи не заводится
@@ -44,6 +63,7 @@ MUST нести идентификатор задачи полем `job_id` и
#### Scenario: Размеру записи приём не судья #### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт запись нулевой длины - **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет - **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
@@ -179,7 +199,7 @@ MUST нести идентификатор задачи полем `job_id` и
- **GIVEN** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не - **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов принадлежит перечню известных форматов
- **THEN** метка метрики принимает значение `other` - **THEN** метка метрики принимает значение `other`
- **AND** имя файла в хранилище сохраняет пришедшее расширение - **AND** имя файла в хранилище сохраняет пришедшее расширение
@@ -192,23 +212,47 @@ MUST нести идентификатор задачи полем `job_id` и
### Requirement: Опрос готовности задачи ### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу Сервис SHALL отдавать состояние задачи расшифровки по запросу
`GET /api/status/:id`. Ответ MUST нести идентификатор полем `job_id`, состояние `GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
полем `status` и время заведения полем `created_at`, а текст расшифровки полем получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет: текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
пустая строка на месте отсутствующего текста читается как «расшифровка пуста». `job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
пока текста нет: пустая строка на месте отсутствующего текста читается как
«расшифровка пуста».
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
кодам ответа перебирается список заведённых задач.
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
#### Scenario: Задача найдена #### Scenario: Задача найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние заведённой задачи - **WHEN** программа спрашивает состояние заведённой задачи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at` - **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
#### Scenario: Сессии нет
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
состояние по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Расшифровки ещё нет #### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста - **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе - **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Задачи с таким идентификатором нет #### Scenario: Задачи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает состояние по неизвестному идентификатору - **WHEN** программа спрашивает состояние по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче - **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
+44 -3
View File
@@ -1,7 +1,16 @@
# storage Specification # storage Specification
## Purpose ## Purpose
TBD - created by archiving change pocketbase-storage. Update Purpose after archive.
Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение
схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность
хранилища и панель владельца.
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`.
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
2026-08-11, а удаление приносит задача `delete-record`.
## Requirements ## Requirements
### Requirement: Сервис поднимается на чистом каталоге данных ### Requirement: Сервис поднимается на чистом каталоге данных
@@ -91,7 +100,22 @@ MUST завести свою схему и принимать записи об
### Requirement: Файл отдаётся ссылкой ### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи. Отданный файл MUST совпадать с принятым по длине. записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
здесь нет — его заводит отдельная задача.
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
недосмотр.
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
хранилища, а не по ссылке.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом. Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
@@ -101,6 +125,10 @@ MUST завести свою схему и принимать записи об
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно. бессрочно.
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
половину ключа.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
@@ -112,9 +140,22 @@ MUST завести свою схему и принимать записи об
#### Scenario: Файл забирают по ссылке #### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище - **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают - **AND** забирающий предъявил сессию и взял по ней токен файла
- **WHEN** ссылку на файл запрашивают с этим токеном
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого - **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Без сессии файл не отдаётся
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают без сессии
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Конвейер читает файл без сессии
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из файловой системы хранилища и шаг проходит
#### Scenario: Ссылка ведёт в никуда #### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет - **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` `feature` 🐞 `fix` 🧹 `chore` 🔬 `research`
@@ -20,45 +39,63 @@
## Очередь ## Очередь
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками. - [🧹 Ронять гейт на изменённой функции, которую не выполняет ни один тест](items/gate-changed-lines-coverage.md) — Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата. - [🧹 Поднимать сервис локально без действующего токена бота](items/local-run-without-telegram-token.md) — Адаптер Telegram проверяет токен обращением к Telegram и роняет старт, а боевым токеном запускаться запрещено: проверить поведение живым прогоном не может ни одна задача.
- [✨ Пускать в приложение только после входа через OIDC](items/oidc-login.md) — HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору. - [🐞 Убрать код провайдера из журнала запросов хранилища](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 задачи и есть право её читать. - [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать.
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем. - [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
- [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем. - [✨ Пускать скрипты в 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/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит. - [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-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/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
- [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки. - [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
- [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей. - [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей.
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет. - [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
- [✨ Удалять запись со всеми уровнями текста по требованию владельца](items/delete-record.md) — Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
- [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса. - [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса.
- [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет. - [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
- [✨ Отдавать вычитанный текст рядом с сырым](items/literary-text-level.md) — Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет. - [✨ Отдавать вычитанный текст рядом с сырым](items/literary-text-level.md) — Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
- [✨ Показывать заголовок в списке, отбирать список по темам и считать токены](items/insights-visible-in-list.md) — Заголовок, темы и пересказ считаются, но список по-прежнему показывает первые слова расшифровки и не отбирается ничем, а расход на модель не виден числом.
- [✨ Считать только те уровни текста, что включены у владельца записи](items/settings-applied-in-pipeline.md) — Дом настроек есть, а конвейер их не читает: выключенный уровень всё равно уходит платной модели, и настройка ничего не экономит.
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана. - [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана.
- [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет. - [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается. - [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
- [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно. - [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
- [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста. - [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста.
- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз. - [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
- [✨ Отдавать текст в сотни килобайт файлом, а не сотней сообщений](items/long-text-delivery.md) — Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд. - [✨ Отдавать текст в сотни килобайт файлом, а не сотней сообщений](items/long-text-delivery.md) — Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд.
- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново. - [🔬 Перечень форматов, которые конвейер принимает на самом деле](items/audio-format-coverage-measure.md) — Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
- [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто. - [ Принимать видео и брать из него звуковую дорожку](items/video-audio-track-intake.md) — Запись семейного архива приходит видеофайлом, а приём смотрит на аудио: человеку приходится доставать дорожку самому.
- [🧹 Покрыть тестами шаги конвейера и захват задачи](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 объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
- [🔬 Стоит ли брать OpenTelemetry вместо голого Prometheus](items/opentelemetry-fit.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/job-path-by-request.md) — Звенья пути связаны только идентификатором задачи в строках журнала: чтобы понять, где запись провела минуты, владелец читает логи контейнера глазами.
- [🧹 Ловить уязвимости в зависимостях шагом гейта](items/gate-dependency-vulnerabilities.md) — govulncheck находит две достижимые уязвимости в клиентах Yandex, а ни гейт, ни список «чего в гейте нет» о нём не знают: узнать о третьей будет неоткуда. - [✨ Считать вызовы, отказы и длительность по каждому внешнему сервису](items/external-service-metrics.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет ни одной метрики: отказ внешнего сервиса виден только строкой в журнале контейнера.
- [🧹 Считать покрытие изменённых строк шагом гейта](items/gate-changed-lines-coverage.md) — Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками. - [✨ Показывать метрикой задачу, застрявшую в состоянии](items/stalled-pipeline-metric.md) — Вставший конвейер неотличим от простоя: возраст задачи в состоянии не считается, и очередь без движения выглядит как отсутствие работы.
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Воркер читает ctx только между итерациями: остановка контейнера ждёт конца шага, а на занятом писателе один запрос к хранилищу держится до 9,5 секунды при мягком таймауте в 5. - [✨ Оповещать владельца об отказе, не дожидаясь жалобы](items/owner-alerting.md) — Об отказе владелец узнаёт от пользователя: правил оповещения нет ни на одной метрике, а метрики читают глазами.
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
- [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа. - [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа.
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись. - [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
- [Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage. - [Сделать страницу статистики для владельца](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 ГБ — открытое противоречие с границей домена, которое владелец решил не разбирать сейчас. - [🔬 Квота по общему размеру загруженного на пользователя](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/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем. - [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
- [🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую](items/upload-reliability.md) — Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги. - [🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую](items/upload-reliability.md) — Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги.
- [🎯 Человек убирает свою запись из архива вместе со всеми текстами](items/data-ownership.md) — Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
- [🎯 Пользователь настраивает, что сервис делает с его записями](items/user-settings.md) — Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем. - [🎯 Пользователь настраивает, что сервис делает с его записями](items/user-settings.md) — Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем.
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
- [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться. - [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться.
## Направления ## Направления
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
- [🎯 Запись длиной до шести часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново. - [🎯 Запись длиной до шести часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем. - [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
- [🎯 Человек убирает свою запись из архива вместе со всеми текстами](items/data-ownership.md) — Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
## Сопровождение ## Сопровождение
+1 -1
View File
@@ -1,7 +1,7 @@
# ✨ Сделать страницу статистики для владельца # ✨ Сделать страницу статистики для владельца
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь - **Категория:** Очередь — Страница показывает собранный учёт: без учёта показывать нечего.
- **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет. - **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
- **Теги:** goal:usage-stats - **Теги:** goal:usage-stats
+2 -1
View File
@@ -1,8 +1,9 @@
# 🎯 Принимается запись любого формата, включая дорожку из видео # 🎯 Принимается запись любого формата, включая дорожку из видео
- **Тип:** goal - **Тип:** goal
- **Секция:** Направления - **Секция:** Направления — Перечень форматов не замерен, и потолок длины у видео тот же, что у долгих записей: тянется следом за ними.
- **Зачем:** Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил. - **Зачем:** Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
- **Теги:** decomposed
Человек отдаёт файл, не думая о том, что внутри: аудио любого распространённого Человек отдаёт файл, не думая о том, что внутри: аудио любого распространённого
контейнера или видео, из которого нужна только речь. Подготовка на стороне контейнера или видео, из которого нужна только речь. Подготовка на стороне
+4 -3
View File
@@ -1,7 +1,7 @@
# ✨ Пускать скрипты в API по личным токенам # ✨ Пускать скрипты в API по личным токенам
- **Тип:** feature - **Тип:** feature
- **Категория:** Очередь - **Категория:** Очередь — Второй способ представиться ставится на готовые владельца и контракт, иначе форма ошибки переписывается дважды.
- **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем. - **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
- **Теги:** goal:multi-user - **Теги:** goal:multi-user
@@ -18,7 +18,6 @@
- таблица токенов: владелец, имя, отпечаток, время выпуска и последнего - таблица токенов: владелец, имя, отпечаток, время выпуска и последнего
обращения, и её миграция; обращения, и её миграция;
- эндпоинты выпуска, перечня и отзыва токена; - эндпоинты выпуска, перечня и отзыва токена;
- экран настроек — место, где токен выпускают и отзывают;
- `docs/security.md` — второй способ представиться и хранение отпечатка; - `docs/security.md` — второй способ представиться и хранение отпечатка;
- `README.md` — пример вызова API скриптом. - `README.md` — пример вызова API скриптом.
@@ -38,4 +37,6 @@
Учётные записи по-прежнему заводит Authelia — свою регистрацию не делаем. Учётные записи по-прежнему заводит 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