Compare commits
23
Commits
aa20b229f9
...
eacaf76d5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eacaf76d5f
|
||
|
|
f4d8c7ed50
|
||
|
|
bb9a67929c
|
||
|
|
bc5c35790e
|
||
|
|
f494dcb83e
|
||
|
|
d8d6bcc193
|
||
|
|
8bcd2c0059
|
||
|
|
37ccda3677
|
||
|
|
e1dfe662ea
|
||
|
|
0353517ec4
|
||
|
|
1d243ad2f6
|
||
|
|
6c7f006e75
|
||
|
|
8bffd30955
|
||
|
|
3925c637f3
|
||
|
|
35bde75b1f
|
||
|
|
870b6bc829
|
||
|
|
4a052ec99b
|
||
|
|
b46be019fc
|
||
|
|
8739b18a9f
|
||
|
|
ddc34b3182
|
||
|
|
c44f0e7582
|
||
|
|
d676df8a27
|
||
|
|
09228f23d8
|
+177
@@ -1,15 +1,152 @@
|
||||
# Линтеры проекта. Перечень правил и их дома — docs/conventions/go-linters.md,
|
||||
# «Механизировано»; здесь только настройка и «почему именно так».
|
||||
#
|
||||
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
|
||||
# staticcheck, unused. Сверх него включено то, что механизирует конвенции: то,
|
||||
# что проверяет правило, прозой в конвенциях не остаётся.
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
default: standard
|
||||
enable:
|
||||
# docs/conventions/errors.md: сравнение ошибок через errors.Is и errors.As.
|
||||
- errorlint
|
||||
# docs/conventions/errors.md: ошибки — только stdlib.
|
||||
- depguard
|
||||
# docs/conventions/logging.md: форма вызова slog.
|
||||
- sloglint
|
||||
# Опечатка в комментарии и в тексте ошибки читается как термин проекта.
|
||||
- misspell
|
||||
# Запреты по месту: чем судят ответ в проверках, чем читают время, откуда
|
||||
# берут конфигурацию, куда пишут вывод. Подробности у каждого правила ниже.
|
||||
- forbidigo
|
||||
# Отмена доходит до внешнего вызова: запрос и внешний процесс заводятся с
|
||||
# контекстом. Инвариант «принятая запись не теряется молча» держится
|
||||
# остановкой на середине, а не только записью в лог: `ffmpeg`, заведённый
|
||||
# без контекста, переживает остановку воркера и дожёвывает чужую запись.
|
||||
- noctx
|
||||
# Контекст приезжает сверху, а не заводится по месту. `context.Background()`
|
||||
# внутри адаптера обрывает цепочку отмены ровно на границе с платным
|
||||
# внешним сервисом — там, где отмена и нужна.
|
||||
- contextcheck
|
||||
# Тело ответа закрывается. `errcheck` его не видит: `(io.ReadCloser).Close`
|
||||
# объявлен в `exclude-functions` ниже, и незакрытое тело от невыясненного
|
||||
# `Close` этим списком не отличается.
|
||||
- bodyclose
|
||||
# `return nil` после проверенной ошибки — это молчаливая потеря отказа,
|
||||
# прямо запрещённая инвариантом об очереди (CLAUDE.md, major).
|
||||
- nilerr
|
||||
# Отказ выборки не теряется: неспрошенный `rows.Err()` превращает оборванное
|
||||
# чтение в пустой результат.
|
||||
- rowserrcheck
|
||||
# `Rows` и `Stmt` закрываются: незакрытая выборка держит соединение.
|
||||
- sqlclosecheck
|
||||
# Форма утверждений в проверках: перепутанные местами «ожидалось/получено»,
|
||||
# `assert` там, где после провала продолжать нельзя, `require` из горутины.
|
||||
- testifylint
|
||||
# Подавление — это решение: строчное `//nolint` обязано называть линтер и
|
||||
# причину, а протухшее подавление обязано краснеть. Тот же порядок, что у
|
||||
# подавлений в этом файле, но применённый к комментариям в коде.
|
||||
- nolintlint
|
||||
settings:
|
||||
forbidigo:
|
||||
# `analyze-types` включает суждение по типу приёмника, а не по печатному
|
||||
# тексту вызова. Правилу о заголовках это необходимо (см. ниже), прочим
|
||||
# правилам не мешает: имена пакетов в шаблонах те же.
|
||||
analyze-types: true
|
||||
forbid:
|
||||
# Вывод идёт в журнал: строка в stdout мимо slog не имеет ни уровня, ни
|
||||
# полей, и в разборе постфактум её не найти. Встроенные `print`/`println`
|
||||
# названы тем же правилом: запрет на одно имя обходится соседним.
|
||||
- pattern: '^fmt\.Print.*$'
|
||||
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
|
||||
- pattern: '^print(ln)?$'
|
||||
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
|
||||
# Конфигурация приезжает из TOML. Перечислены все способы прочитать
|
||||
# окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv`
|
||||
# одной правкой. Окружение читает только godotenv в main.go — он кладёт
|
||||
# .env в окружение процесса, а не в настройки.
|
||||
#
|
||||
# Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и
|
||||
# `os.Stdout.WriteString` — первый аргумент по имени функции не судится.
|
||||
# Этот остаток назван прозой в docs/conventions/logging.md.
|
||||
- pattern: '^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$'
|
||||
msg: 'конфигурация только из TOML (docs/conventions/config.md)'
|
||||
# Единая точка чтения времени — internal/clock: метка времени в UTC
|
||||
# (`clock.Now`), измерение длительности с монотонными часами
|
||||
# (`clock.Start`). Прежде время брали по месту, и хранилище сравнивало
|
||||
# строками времена из разных зон.
|
||||
- pattern: '^time\.Now$'
|
||||
msg: 'время читают clock.Now (метка) и clock.Start (длительность) — docs/conventions/database.md'
|
||||
# Проверка ответа судит по **готовому ответу**, а не по изменяемому
|
||||
# состоянию обработчика. `httptest` устроен зеркально настоящему серверу:
|
||||
# `Header()` отдаёт живую карту, доступную и после записи ответа, а
|
||||
# снимок, который получит клиент, лежит отдельно и читается через
|
||||
# `Result()`. Проверка, читающая живую карту, зелена при неработающем
|
||||
# коде — класс всплывал трижды (docs/review.md, записи 2026-08-10,
|
||||
# 2026-08-11 и 2026-08-12) и трижды стоил зелёного гейта.
|
||||
#
|
||||
# Правило судит по типу приёмника, и в этом весь смысл: запрет на
|
||||
# цепочку `w.Header().Get` обходится одной лишней строкой —
|
||||
# `h := w.Header()`, — а также чтением по индексу карты и обходом
|
||||
# `range`. По типу под правило попадают все эти формы разом. Текстом его
|
||||
# записать нельзя ещё и потому, что `.Header` носят и запрос
|
||||
# (`req.Header.Set` в проверках законен), и снимок ответа
|
||||
# (`w.Result().Header` — как раз то, к чему правило ведёт).
|
||||
#
|
||||
# Приёмник назван поимённо: подставной сервер в проверках отдаёт
|
||||
# заголовок через `w.Header().Set`, но у него приёмник —
|
||||
# `http.ResponseWriter`, и под правило он не попадает.
|
||||
- pattern: '^httptest\.ResponseRecorder\.Header$'
|
||||
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
|
||||
# `HeaderMap` — тот же живой снимок прежним именем поля. Правило второе,
|
||||
# потому что об устарелости поля говорит `staticcheck` (SA1019), а о том,
|
||||
# почему по нему не судят ответ, — только это сообщение.
|
||||
- pattern: '^httptest\.ResponseRecorder\.HeaderMap$'
|
||||
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
|
||||
|
||||
sloglint:
|
||||
# Стиль вызова один — пары «ключ-значение». `kv-only` запрещает атрибуты
|
||||
# (`slog.String` и прочие) **целиком**, а не только смешение с парами:
|
||||
# смешение и так запрещено умолчанием `no-mixed-args`. Решение осознанное —
|
||||
# один стиль на весь код, — и записано строкой в
|
||||
# docs/conventions/logging.md, «Сообщение».
|
||||
no-mixed-args: true
|
||||
kv-only: true
|
||||
# `msg` — константа: сообщение с подставленным значением не сгруппировать
|
||||
# отбором, а данные для этого и кладут в поля.
|
||||
static-msg: true
|
||||
# `key-naming-case` не включаем: словарь полей намеренно смешанный —
|
||||
# доменные поля `snake_case`, системные домены с точкой (`http.method`,
|
||||
# `ext.service`). См. docs/conventions/logging.md, «Поля: словарь имён».
|
||||
|
||||
depguard:
|
||||
rules:
|
||||
main:
|
||||
deny:
|
||||
- pkg: github.com/pkg/errors
|
||||
desc: 'ошибки — только stdlib errors и fmt.Errorf (docs/conventions/errors.md)'
|
||||
- pkg: github.com/cockroachdb/errors
|
||||
desc: 'стек-трейс избыточен, контекст несёт цепочка %w (docs/conventions/errors.md)'
|
||||
|
||||
nolintlint:
|
||||
# Подавление без причины снимают при первом же неудобстве: снимающий не
|
||||
# знает, что оно ловило. Те же два требования, что у подавлений в этом
|
||||
# файле, — имя линтера и причина строкой.
|
||||
require-explanation: true
|
||||
require-specific: true
|
||||
# Подавление, которому нечего подавлять, — след починенного места, и
|
||||
# краснеть оно обязано: иначе перечень подавлений врёт.
|
||||
allow-unused: false
|
||||
|
||||
errcheck:
|
||||
# Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не
|
||||
# теряется молча» принимается реализацией, которая его теряет. Отказ,
|
||||
# который решено не проверять, теперь объявляют ниже поимённо — заметно.
|
||||
check-blank: true
|
||||
# Непроверенное приведение типа паникует, а не отдаёт ошибку, поэтому
|
||||
# `check-blank` его не ловит: `v := x.(T)` вовсе не про присваивание в `_`.
|
||||
check-type-assertions: true
|
||||
exclude-functions:
|
||||
# Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки
|
||||
- (io.Closer).Close
|
||||
@@ -20,6 +157,46 @@ linters:
|
||||
# Метод сам логирует ошибку отправки, вызывающему она не нужна
|
||||
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
# Правило о заголовках живёт только в файлах проверок: в рабочем коде
|
||||
# `Header()` и есть способ отдать заголовок.
|
||||
- linters:
|
||||
- forbidigo
|
||||
path-except: '_test\.go$'
|
||||
text: 'живой карте заголовков'
|
||||
# Единая точка чтения времени сама читает время — иначе ей нечем.
|
||||
- linters:
|
||||
- forbidigo
|
||||
path: 'internal/clock/'
|
||||
text: 'time.Now'
|
||||
# Проверка читает окружение **своего прогона** — `PATH`, чтобы убрать из
|
||||
# него каталог с `go`, и `os.Environ()`, чтобы передать окружение дочернему
|
||||
# процессу. Настройками приложения это не является. Исключение объявлено по
|
||||
# тексту сообщения, а не по имени функции: правило называет четыре имени, и
|
||||
# исключение обязано покрывать те же четыре.
|
||||
- linters:
|
||||
- forbidigo
|
||||
path: '_test\.go$'
|
||||
text: 'конфигурация только из TOML'
|
||||
# Проверки строят время фикстур, а не метку домена: `time.Now` в них не
|
||||
# обходит единую точку, а задаёт вход. Запрет здесь стоил бы обязательного
|
||||
# обряда на каждый срок захвата в фикстуре и не поймал бы ничего.
|
||||
- linters:
|
||||
- forbidigo
|
||||
path: '_test\.go$'
|
||||
text: 'time.Now'
|
||||
# `httptest.NewRequest` строит фикстуру для обработчика в том же процессе:
|
||||
# внешнего собеседника за ней нет, и отменять у неё нечего — правило здесь
|
||||
# говорит не о том, что мы имели в виду. Изъятие названо по имени этой
|
||||
# функции, а не выключением `noctx` на проверках целиком: настоящий внешний
|
||||
# вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему
|
||||
# подсуден.
|
||||
- linters:
|
||||
- noctx
|
||||
path: '_test\.go$'
|
||||
text: 'httptest\.NewRequest'
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
|
||||
@@ -24,7 +24,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
|
||||
|
||||
## Стек
|
||||
|
||||
Go 1.25 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и
|
||||
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
|
||||
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
|
||||
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
|
||||
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
||||
@@ -33,10 +33,15 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
||||
|
||||
Что нарушать нельзя.
|
||||
|
||||
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object
|
||||
Storage не попадают в git, в лог, в ответ пользователю и в колонку
|
||||
`error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во
|
||||
всех местах выкладки. **critical**
|
||||
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit, пара ключей Object
|
||||
Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
|
||||
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
|
||||
вручную во всех местах выкладки. **critical**
|
||||
*Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции
|
||||
пользователей хранилища — туда его кладёт приведение настроек при каждом
|
||||
подъёме, потому что применённый шаг схемы не переписывается и не пережил бы
|
||||
ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные
|
||||
места запрета это не отменяет.
|
||||
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
|
||||
пользователя и его сообщение в лог не пишутся — только длина и
|
||||
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
|
||||
@@ -82,7 +87,7 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
||||
|
||||
```bash
|
||||
go build ./... # CGO не нужен
|
||||
go test ./...
|
||||
go test ./... # в гейте идёт с -race, и там нужен компилятор C
|
||||
go vet ./...
|
||||
gofmt -l .
|
||||
golangci-lint run
|
||||
@@ -100,17 +105,60 @@ task gate # весь набор проверок разом
|
||||
|
||||
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
|
||||
`origin/master`; переопределяется `task gate BASE=<rev>`.
|
||||
- **Какое правило чем проверяется** — конвенция
|
||||
[docs/conventions/go-linters.md](docs/conventions/go-linters.md). Здесь
|
||||
семантика гейта, там перечень правил, подавлений и место настройки каждого;
|
||||
перечень здесь не повторяется.
|
||||
- **Где логи шагов:** вывод команды, отдельного файла нет.
|
||||
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
|
||||
`docs.py check`, `tasks.py check` и `openspec.py check` словарь кодов общий:
|
||||
0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта,
|
||||
каталог не найден), 4 внутренний сбой.
|
||||
`docs.py check`, `tasks.py check`, `openspec.py check` и
|
||||
`scripts/check-go-version.sh` словарь кодов общий: 0 сошлось, 1 дрейф,
|
||||
2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не
|
||||
найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно:
|
||||
четвёртый шаг с собственной семантикой сделал бы это утверждение неверным.
|
||||
Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая
|
||||
`tests`, `migrations`, `shell`, `dockerfile` и `vulns`: недостающий инструмент
|
||||
— отказ окружения, код 3. У `tests` это отсутствие CGO или компилятора C, без
|
||||
которых не работает детектор гонок — тесты он в этом случае всё равно гоняет,
|
||||
без `-race`, и краснеет уже после них. У `migrations` код 3 — неразрешимая
|
||||
база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 —
|
||||
переписанный шаг схемы. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`,
|
||||
`golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой».
|
||||
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
|
||||
любой отказ шага выходит с 201, а код шага печатает строкой
|
||||
(«exit status 3»), поэтому словарь читается по коду скрипта.
|
||||
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
|
||||
неотформатированный файл, находка `golangci-lint`, дрейф раскладки документов,
|
||||
дрейф каталога задач, форма `openspec/config.yaml`. Машина проверяет всё
|
||||
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
|
||||
схемы,
|
||||
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
|
||||
версий Go, дрейф раскладки документов,
|
||||
дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода
|
||||
уязвимость в зависимостях (`govulncheck`), находка `shellcheck` в скриптах
|
||||
оболочки и `hadolint` в `Dockerfile`. Машина проверяет всё
|
||||
перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с
|
||||
именем недостающего плагина, а не пропускается молча.
|
||||
- **Что ловит pre-commit, а что только гейт.** `lefthook.yml` гоняет на
|
||||
**затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в
|
||||
коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`,
|
||||
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
|
||||
сверка версий Go, три сверки документов и `govulncheck`: они смотрят всё
|
||||
дерево либо требуют сети, а pre-commit обязан быть быстрым.
|
||||
- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на
|
||||
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
|
||||
ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он
|
||||
достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем,
|
||||
шаг не роняет. Такая сегодня одна — `GO-2026-5932` в
|
||||
`golang.org/x/crypto/openpgp`, исправления у неё нет вовсе.
|
||||
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
|
||||
- **сборка образа** — дорога, и отказ от неё сознательный. Дешёвая замена
|
||||
стоит шагом сверки версий: он сравнивает строки и ловит расхождение, из-за
|
||||
которого образ перестаёт собираться, но собираемости не проверяет. Собрать
|
||||
образ по-прежнему может только человек — `task image`, и на подъёме версии
|
||||
это обязательно;
|
||||
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и два его
|
||||
правила подавлены поимённо — `DL3007` до задачи `pin-runtime-image-base` и
|
||||
`DL3018` по существу (alpine не держит старые версии пакетов, закрепление
|
||||
ломает сборку через недели). Причины стоят строками в `Taskfile.yml`;
|
||||
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
|
||||
коммита. Полную историю никто не проверяет;
|
||||
- согласованность документов между собой и с кодом — её судят агенты, зовёт
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM docker.io/library/golang:1.25-alpine AS build-env
|
||||
FROM docker.io/library/golang:1.26-alpine AS build-env
|
||||
|
||||
# Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
|
||||
# и CGO больше не требуется.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
## Технологии
|
||||
|
||||
- **Язык**: Go 1.26, CGO не нужен
|
||||
- **Веб-фреймворк**: gin-gonic/gin
|
||||
- **Telegram**: go-telegram-bot-api
|
||||
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
|
||||
@@ -113,9 +114,9 @@ transcriber/
|
||||
## Разработка
|
||||
|
||||
Схему двигают шаги миграций PocketBase на Go —
|
||||
`internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме
|
||||
хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не
|
||||
переписывается: изменение — только новым файлом шага.
|
||||
`internal/adapter/repo/pocketbase/migrations`, файл на шаг. Непринятые шаги
|
||||
накатываются при подъёме хранилища, прежде чем стартуют воркеры и сервер.
|
||||
Применённый шаг не переписывается: изменение — только новым файлом шага.
|
||||
|
||||
Проверки перед коммитом — одной командой:
|
||||
|
||||
|
||||
+157
-4
@@ -9,6 +9,12 @@ vars:
|
||||
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
|
||||
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит
|
||||
# дрейф своего каталога, и выпадение одного не подменяется другим.
|
||||
#
|
||||
# Недостающий скрипт — отказ окружения у всех обёрток ниже, и код у него 3 по
|
||||
# общему словарю (CLAUDE.md, раздел «Гейт»). Прежний код 1 значил «дрейф» и
|
||||
# отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам
|
||||
# `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается
|
||||
# по коду скрипта, а не по коду `task`.
|
||||
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.py"}}'
|
||||
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-tasks/skills/tasks/scripts/tasks.py"}}'
|
||||
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-code/skills/openspec/scripts/openspec.py"}}'
|
||||
@@ -27,11 +33,139 @@ tasks:
|
||||
echo "gofmt: файлы выше не отформатированы"
|
||||
exit 1
|
||||
fi
|
||||
- go test ./...
|
||||
- task: tests
|
||||
- golangci-lint run
|
||||
- task: shell
|
||||
- task: dockerfile
|
||||
- task: go-version
|
||||
- task: migrations
|
||||
- task: docs
|
||||
- task: tasks
|
||||
- task: openspec
|
||||
# Последним: единственный шаг, которому нужна сеть, и самый долгий.
|
||||
- task: vulns
|
||||
|
||||
tests:
|
||||
desc: 'Тесты с детектором гонок'
|
||||
cmds:
|
||||
# Гонки ищет детектор, а не чтение кода: у сервиса три воркера ходят в одну
|
||||
# очередь, и «результат пишет только держатель захвата» — утверждение о
|
||||
# одновременном доступе. Детектору нужен CGO и компилятор C; сборка
|
||||
# приложения по-прежнему обходится без них (CLAUDE.md, «Стек»), поэтому их
|
||||
# отсутствие — отказ окружения, код 3, а не отказ проверки.
|
||||
# Окружение проверяется **после** обычного прогона, а не вместо него:
|
||||
# отсутствие компилятора отнимает у гейта поиск гонок, но не должно
|
||||
# отнимать сами тесты. Порядок проверок — сперва компилятор: без него
|
||||
# совет «включи CGO_ENABLED=1» бесполезен.
|
||||
- |
|
||||
if ! command -v gcc >/dev/null 2>&1 && ! command -v clang >/dev/null 2>&1; then
|
||||
go test ./... || exit 1
|
||||
echo "тесты прошли, но гонки не искали: детектору нужен компилятор C"
|
||||
echo "ни gcc, ни clang не найдены в PATH; поставь: apt install gcc"
|
||||
exit 3
|
||||
fi
|
||||
if [ "$(go env CGO_ENABLED)" != "1" ]; then
|
||||
go test ./... || exit 1
|
||||
echo "тесты прошли, но гонки не искали: детектору нужен CGO"
|
||||
echo "CGO_ENABLED=$(go env CGO_ENABLED); включи: CGO_ENABLED=1 task gate"
|
||||
exit 3
|
||||
fi
|
||||
go test -race ./...
|
||||
|
||||
migrations:
|
||||
desc: 'Применённый шаг схемы не переписывается'
|
||||
cmds:
|
||||
# Инвариант CLAUDE.md (critical): хранилище считает применённое по имени
|
||||
# файла шага, поэтому изменить уехавший шаг нельзя — только добавить новый.
|
||||
# Компилятор этого не держит, и до этого шага не держало ничто.
|
||||
#
|
||||
# Судится каталог шагов против базы диффа: у файла шага допустим один
|
||||
# статус — `A`. Правка (`M`), удаление (`D`) и переименование (`R`) красят.
|
||||
# `migrations.go` под правило не подпадает: строка `Register` у нового шага
|
||||
# прибавляется именно там, и запрет на него запретил бы заведение шага.
|
||||
- |
|
||||
if ! git rev-parse --verify --quiet "{{.BASE}}" >/dev/null 2>&1; then
|
||||
echo "база диффа не найдена: {{.BASE}}"
|
||||
echo "задай свою: task migrations BASE=<rev>"
|
||||
exit 3
|
||||
fi
|
||||
# Каталог шагов берётся из docs/.docs.json — там он уже записан ключом
|
||||
# `migrations` для сверки документов. Свой литерал завёл бы факту второй
|
||||
# дом: каталог переехал бы, а один из двух стражей молча позеленел.
|
||||
dir=$(python3 -c 'import json,sys; print(json.load(open("docs/.docs.json"))["migrations"])' 2>/dev/null) || dir=""
|
||||
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
|
||||
echo "каталог шагов схемы не найден: ключ migrations в docs/.docs.json → '$dir'"
|
||||
exit 3
|
||||
fi
|
||||
# Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от
|
||||
# одного переименования — тот же приём, что у правил `internal/archrules`.
|
||||
if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then
|
||||
echo "в $dir нет ни одного файла шага: правило потеряло предмет"
|
||||
echo "поправь шаблон имени в этом шаге либо ключ migrations в docs/.docs.json"
|
||||
exit 3
|
||||
fi
|
||||
# Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал»
|
||||
# ровно настолько, насколько свеж `origin/master`: отставшая ссылка
|
||||
# читает весь каталог как добавленный, и правило молчит. `HEAD` ловит
|
||||
# правку закоммиченного шага в рабочем дереве независимо от ссылки.
|
||||
for base in {{.BASE}} HEAD; do
|
||||
touched=$(git diff --name-status "$base" -- "$dir" \
|
||||
| grep -E '[0-9]{12}_[^/]*\.go$' \
|
||||
| grep -vE '^A[[:space:]]' || true)
|
||||
if [ -n "$touched" ]; then
|
||||
echo "база $base:"
|
||||
echo "$touched"
|
||||
echo "применённый шаг схемы переписан: изменение схемы — только новым файлом шага"
|
||||
echo "(CLAUDE.md, «Инварианты», critical: хранилище считает применённое по имени файла)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
shell:
|
||||
desc: 'shellcheck на скрипты оболочки'
|
||||
cmds:
|
||||
# Скриптов два и оба свои: шаг сверки версий и `docker/entrypoint.sh`.
|
||||
# Второй в образ копируется, но не исполняется — `ENTRYPOINT` в
|
||||
# `Dockerfile` закомментирован, — и проверяется он именно поэтому: код,
|
||||
# который никто не гоняет, портится незаметно. Ни один из двух не виден ни
|
||||
# `go vet`, ни `golangci-lint`.
|
||||
- |
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "shellcheck не найден в PATH"
|
||||
echo "поставь: apt install shellcheck (или https://github.com/koalaman/shellcheck)"
|
||||
exit 3
|
||||
fi
|
||||
shellcheck scripts/check-go-version.sh docker/entrypoint.sh
|
||||
|
||||
dockerfile:
|
||||
desc: 'hadolint на Dockerfile'
|
||||
cmds:
|
||||
# DL3007 (`alpine:latest` у рантайм-слоя) подавлен: это открытая задача
|
||||
# `pin-runtime-image-base`, и до её решения шаг краснел бы на известном.
|
||||
# DL3018 (закрепить версии пакетов `apk`) подавлен по существу: alpine не
|
||||
# держит старые версии в репозитории, и закрепление ломает сборку через
|
||||
# недели — то есть лечение хуже болезни.
|
||||
- |
|
||||
if ! command -v hadolint >/dev/null 2>&1; then
|
||||
echo "hadolint не найден в PATH"
|
||||
echo "поставь: https://github.com/hadolint/hadolint/releases"
|
||||
exit 3
|
||||
fi
|
||||
hadolint --ignore DL3007 --ignore DL3018 Dockerfile
|
||||
|
||||
go-version:
|
||||
desc: 'Одна версия Go в go.mod, Dockerfile, CLAUDE.md и README.md'
|
||||
cmds:
|
||||
# Скрипт лежит в самом репозитории, а не в плагине: его отсутствие значит
|
||||
# сломанное дерево, а не непоставленный плагин, и переопределять путь
|
||||
# нечем и незачем. Код отсутствия — 3, как у прочих обёрток.
|
||||
- |
|
||||
py=scripts/check-go-version.sh
|
||||
if [ ! -f "$py" ]; then
|
||||
echo "$py не найден: дерево репозитория неполно"
|
||||
exit 3
|
||||
fi
|
||||
sh "$py"
|
||||
|
||||
docs:
|
||||
desc: 'Раскладка docs/ против канона'
|
||||
@@ -42,7 +176,7 @@ tasks:
|
||||
if [ ! -f "$py" ]; then
|
||||
echo "docs.py не найден: $py"
|
||||
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>"
|
||||
exit 1
|
||||
exit 3
|
||||
fi
|
||||
python3 "$py" check --base {{.BASE}}
|
||||
|
||||
@@ -54,7 +188,7 @@ tasks:
|
||||
if [ ! -f "$py" ]; then
|
||||
echo "tasks.py не найден: $py"
|
||||
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>"
|
||||
exit 1
|
||||
exit 3
|
||||
fi
|
||||
python3 "$py" check --dir tasks
|
||||
|
||||
@@ -66,10 +200,29 @@ tasks:
|
||||
if [ ! -f "$py" ]; then
|
||||
echo "openspec.py не найден: $py"
|
||||
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>"
|
||||
exit 1
|
||||
exit 3
|
||||
fi
|
||||
python3 "$py" check --dir .
|
||||
|
||||
vulns:
|
||||
desc: 'Достижимые из кода уязвимости в зависимостях'
|
||||
cmds:
|
||||
# `govulncheck` — внешний инструмент, а не плагин и не файл репозитория:
|
||||
# ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Его
|
||||
# отсутствие — отказ окружения, код 3, как у прочих обёрток.
|
||||
#
|
||||
# Свой код 3 у самого инструмента значит «уязвимость найдена» и с кодом
|
||||
# обёртки совпадает; различает их сообщение — обёртка называет недостающий
|
||||
# инструмент. Шагу нужна сеть: база уязвимостей живёт на vuln.go.dev, и без
|
||||
# сети шаг краснеет, а не пропускается молча.
|
||||
- |
|
||||
if ! command -v govulncheck >/dev/null 2>&1; then
|
||||
echo "govulncheck не найден в PATH"
|
||||
echo "поставь: go install golang.org/x/vuln/cmd/govulncheck@latest"
|
||||
exit 3
|
||||
fi
|
||||
govulncheck ./...
|
||||
|
||||
# Контракт роли app_image (pet-project-server): собрать полный образ и затегать
|
||||
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
|
||||
image:
|
||||
|
||||
@@ -33,6 +33,32 @@ object_storage_region = "ru-central1"
|
||||
# Endpoint Object Storage
|
||||
object_storage_endpoint = "https://storage.yandexcloud.net/"
|
||||
|
||||
# Вход через внешнего провайдера OIDC (Authelia).
|
||||
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
|
||||
# API открытым наружу.
|
||||
[auth]
|
||||
# Адрес, куда сервис уводит человека на вход
|
||||
auth_url = "https://auth.example.com/api/oidc/authorization"
|
||||
|
||||
# Адрес, где код обменивается на токен
|
||||
token_url = "https://auth.example.com/api/oidc/token"
|
||||
|
||||
# Адрес, откуда берутся сведения о вошедшем
|
||||
user_info_url = "https://auth.example.com/api/oidc/userinfo"
|
||||
|
||||
# Идентификатор клиента, заведённого у провайдера
|
||||
client_id = "transcriber"
|
||||
|
||||
# Секрет клиента; приходит из выкладки, в git не коммитится
|
||||
client_secret = ""
|
||||
|
||||
# Адрес возврата; тот же, что записан клиенту у провайдера
|
||||
redirect_url = "https://transcriber.example.com/auth/callback"
|
||||
|
||||
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
|
||||
# запуска по http://localhost, где браузер такую куку не сохранит
|
||||
secure_cookie = true
|
||||
|
||||
# Telegram Bot Configuration
|
||||
[telegram]
|
||||
# Токен Telegram бота (получить у @BotFather в Telegram)
|
||||
|
||||
@@ -12,21 +12,21 @@ if [ "${USER}" != "transcriber" ]; then
|
||||
fi
|
||||
|
||||
if [ -z "${USER_GID}" ]; then
|
||||
USER_GID="$(id -g ${USER})"
|
||||
USER_GID="$(id -g "${USER}")"
|
||||
fi
|
||||
|
||||
if [ -z "${USER_UID}" ]; then
|
||||
USER_UID="$(id -u ${USER})"
|
||||
USER_UID="$(id -u "${USER}")"
|
||||
fi
|
||||
|
||||
# Change GID for USER?
|
||||
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g ${USER})" ]; then
|
||||
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g "${USER}")" ]; then
|
||||
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group
|
||||
sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd
|
||||
fi
|
||||
|
||||
# Change UID for USER?
|
||||
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u ${USER})" ]; then
|
||||
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u "${USER}")" ]; then
|
||||
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd
|
||||
fi
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"canon": 14,
|
||||
"migrations": "migrations"
|
||||
"migrations": "internal/adapter/repo/pocketbase/migrations"
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ PocketBase заменяет SQLite с goqu и goose и берёт на себя
|
||||
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
|
||||
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
|
||||
переносятся по прежнему решению задачи `pocketbase-storage`.
|
||||
*Уточнено 2026-08-12:* каталог задаётся ключом `[storage] data_dir` со
|
||||
значением `data`. Суффикс из десяти знаков дописывает конструктор имени,
|
||||
которого сервис не зовёт, — имя задаёт он сам. Действующая раскладка —
|
||||
[../database.md](../database.md), «Представление данных».
|
||||
- `−` вход перестаёт быть нашим: задача `oidc-login` переписывается с
|
||||
собственной обработки ответа провайдера на настройку провайдера в PocketBase.
|
||||
Что делать с сессией и где она живёт, решает уже не наш код.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Кого пускать в сервис, решает правило провайдера, а не сервис
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Кого пускать, решает провайдер, а не сервис»
|
||||
|
||||
## Решение
|
||||
|
||||
Сервис пускает всякого, кого пропустил провайдер, и **своей проверки допуска не
|
||||
делает**. Кто допущен, определяет правило Authelia на этого клиента — настройка
|
||||
выкладки, лежащая вне репозитория.
|
||||
|
||||
## Почему
|
||||
|
||||
Authelia — общий провайдер контура, а не выделенный под этот сервис: учётная
|
||||
запись в ней есть у всякого, кому её завели ради любого другого сервиса на том же
|
||||
сервере. Ревью дизайна назвало следствие прямо: механизм, приглашающий «второго
|
||||
человека», приглашает всех, кто уже есть у провайдера.
|
||||
|
||||
Очевидный ответ — проверять принадлежность к названной в конфиге группе своим
|
||||
кодом. Владелец от него отказался: это завело бы **второе место**, где решается
|
||||
допуск, и решать его пришлось бы в двух местах согласованно.
|
||||
|
||||
Цена отказа названа в источнике и повторена в модели угроз:
|
||||
|
||||
> Правило живёт вне репозитория, в настройках выкладки, и сервис на него
|
||||
> полагается так же, как полагается на обратный прокси в части панели
|
||||
> администратора. Настроенный слишком широко клиент открывает сервис всем, у кого
|
||||
> есть учётная запись в общей Authelia, — и проверить это по коду нельзя.
|
||||
|
||||
Запись заводится как **намеренный отказ от очевидного подхода**: проверку группы
|
||||
предложат снова, и без записанной причины она выглядит бесплатной.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` допуск решается в одном месте, а не в двух; изменение круга допущенных не
|
||||
требует ни правки кода, ни выкладки.
|
||||
- `+` сервис не читает из ответа провайдера ничего сверх нужного для заведения
|
||||
записи — ни групп, ни ролей.
|
||||
- `−` защита сервиса стала свойством настройки, лежащей в другом репозитории, и
|
||||
ревью её проверить не может: ни один проход не увидит, что клиент настроен
|
||||
слишком широко.
|
||||
- `−` ошибка в настройке клиента не имеет наблюдаемого признака внутри сервиса:
|
||||
посторонний, которого пропустила Authelia, выглядит как законный пользователь.
|
||||
- `−` разграничения по владельцу нет, поэтому цена ошибки в настройке — все
|
||||
записи и все расшифровки разом, а не одна учётная запись. Сузит это
|
||||
`record-ownership`.
|
||||
@@ -3,6 +3,7 @@
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
|
||||
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
|
||||
- **Статус:** заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
|
||||
|
||||
## Решение
|
||||
|
||||
Обработчик возврата от провайдера зовёт **собственный адрес хранилища**
|
||||
`auth-with-oauth2` внутри процесса, через его же роутер, а не по сети и не
|
||||
разбирая ответ провайдера своими руками.
|
||||
|
||||
## Почему
|
||||
|
||||
Решение [ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
|
||||
отдало разбор ответа провайдера хранилищу: только тогда учётные записи заводятся
|
||||
сами и видны в панели. Это решение не пересматривается — пересматривается способ
|
||||
до него дотянуться.
|
||||
|
||||
Проверка исходников библиотеки версии 0.39.10 показала, что обмен наружу не
|
||||
экспортирован: он живёт неэкспортированной функцией за собственным маршрутом.
|
||||
Остались три формы, и владелец выбрал первую:
|
||||
|
||||
> (а) внутрипроцессный вызов собственного маршрута `auth-with-oauth2`: решение
|
||||
> 2026-08-11 соблюдено дословно, цена — петля «наш обработчик → наш роутер → наш
|
||||
> обработчик», разбор JSON-ответа и потеря типизированной ошибки; (б) сборка
|
||||
> обмена из экспортированных кусков с сохранением записи и связи через `app.Save`:
|
||||
> прямой код без петли, цена — пересмотр решения 2026-08-11 отдельным ADR; (в)
|
||||
> отложить вход до появления фронтенда.
|
||||
|
||||
Запись заводится как **намеренный отказ от очевидного подхода**: собрать обмен
|
||||
своими руками выглядит проще и дешевле, и предложение вернётся, если причина не
|
||||
записана.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` разбор ответа провайдера, заведение учётной записи и связь её с внешним
|
||||
провайдером остаются за хранилищем — решение 2026-08-11 соблюдено дословно, а
|
||||
не «по духу».
|
||||
- `+` наш код не знает ни одного поля ответа провайдера: обновление библиотеки
|
||||
под смену формата ответа доезжает само.
|
||||
- `−` петля через собственный роутер: обработчик зовёт сервис, частью которого
|
||||
сам является. Это новый для проекта вид узла, и его придётся объяснять на
|
||||
каждом следующем изменении.
|
||||
- `−` ответ разбирается текстом, типизированная ошибка теряется: причина отказа
|
||||
обмена доступна только кодом состояния.
|
||||
- `−` роутер хранилища пришлось собирать **один раз** и держать полем: его
|
||||
сборка вешает обработчики на само приложение и без идентификатора, поэтому
|
||||
повторная не заменяет прежние. Ревью кода нашло это построенным путём —
|
||||
анонимный запрос копил обработчики без предела, а каждое сохранение задачи
|
||||
конвейером проходило по всем накопленным.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||
пункт 3
|
||||
|
||||
## Решение
|
||||
|
||||
Поле файла в хранилище **помечается защищённым**, а правило просмотра коллекции
|
||||
файлов пускает всякого узнанного. Ссылка `/api/files/<коллекция>/<запись>/<имя>`
|
||||
перестаёт быть правом пройти по ней: нужен короткий токен файла, который берут,
|
||||
предъявив сессию.
|
||||
|
||||
Запись заменяет [ADR-2026-08-12-file-link-open-but-not-logged](ADR-2026-08-12-file-link-open-but-not-logged.md).
|
||||
|
||||
## Почему
|
||||
|
||||
Прежнее решение было обусловленным и само назвало условие своего пересмотра:
|
||||
|
||||
> Решение действует до разграничения доступа: задачи `oidc-login` и
|
||||
> `record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
|
||||
> записью.
|
||||
|
||||
Условие наступило. Прежний довод — «право прочитать задачу даёт знание её
|
||||
идентификатора, и файл встаёт вровень с `GET /api/status/:id`» — держался на том,
|
||||
что опрос готовности открыт анонимно. Этот change закрывает опрос за вход, и
|
||||
файл, оставшийся открытым, стал бы единственным анонимным путём к содержимому
|
||||
записи — самому чувствительному, что есть у проекта.
|
||||
|
||||
Вторая половина прежнего решения остаётся в силе: имя файла в журнал по-прежнему
|
||||
не пишется. Защищённое поле сужает право пройти, но не отменяет запрета —
|
||||
строка журнала со ссылкой собирала бы половину ключа.
|
||||
|
||||
Пометки самой по себе оказалось мало, и это выяснило ревью кода прогоном:
|
||||
защищённый файл судится **и** токеном, **и** правилом просмотра коллекции, а
|
||||
незаданное правило означает «только владелец панели». Файл не получал ни аноним,
|
||||
ни вошедший — сценарий спеки не исполнялся вовсе. Правило назначено тем же шагом
|
||||
схемы.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` содержимое записи перестало быть доступным по одному знанию ссылки; после
|
||||
закрытия API это был последний анонимный путь к нему.
|
||||
- `+` условие, названное прежней записью, отработало как задумано: решение
|
||||
пересмотрено записью, а не молча.
|
||||
- `−` ссылка усложнилась для потребителя: браузер с одной кукой файла не
|
||||
получает, нужен порядок «сессия → токен файла → ссылка». Будущее приложение
|
||||
обязано этот шаг делать, и задача про прослушивание записи начинается с него.
|
||||
- `−` разграничения по владельцу нет: токен файла берёт всякий вошедший, и по
|
||||
ссылке он получит **любую** запись, а не только свою. Сужение приносит
|
||||
`record-ownership`; до неё круг сузился с «кто угодно из интернета» до «кто
|
||||
угодно из вошедших», и это меньше, чем кажется.
|
||||
- `−` отзыва у выданного токена нет, как не было у ссылки; смягчает только его
|
||||
короткий срок.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Сессия живёт семь суток и не продлевает саму себя
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||
пункт 6
|
||||
|
||||
## Решение
|
||||
|
||||
Срок жизни сессии — **семь суток**, назначается при каждом подъёме сервиса.
|
||||
Продление сессии **выключено**: адрес, которым хранилище меняет предъявленное
|
||||
значение на новое, закрыт слоем приложения.
|
||||
|
||||
## Почему
|
||||
|
||||
Умолчание хранилища — пять суток и продлеваемая сессия. Второе делает первое
|
||||
бессмысленным, и это выяснило ревью кода замером: предъявитель одного живого
|
||||
значения продлевает себе доступ бессрочно, никуда не входя.
|
||||
|
||||
Значение имеет то, на чём держится вся остановка перерасхода. Паспорт опирается
|
||||
на **отзыв доступа в Authelia** как на способ остановить того, кто тратит слишком
|
||||
много. Но сервис после входа к провайдеру не обращается: подпись сессии считается
|
||||
от значений в базе, и отзыв у провайдера до сервиса доходит **только** истечением
|
||||
срока. При живом продлении не доходит никогда — человек, которому закрыли доступ,
|
||||
сохраняет его навсегда.
|
||||
|
||||
Отвергнуто и названо ценой:
|
||||
|
||||
> сверяться с провайдером по расписанию — новая связь с Authelia и обработка её
|
||||
> недоступности, работа шире задачи; принять как есть — тогда паспорт теряет
|
||||
> способ остановить того, кто тратит слишком много.
|
||||
|
||||
Число семь суток выбрано владельцем как компромисс: реже входить против дольше
|
||||
ждать, пока отзыв доедет.
|
||||
|
||||
Срок назначается **при подъёме, а не шагом схемы**, и это отдельное решение с
|
||||
причиной: применённый шаг не переписывается, поэтому число, положенное туда,
|
||||
разошлось бы со сроком жизни куки при первой же правке — браузер получил бы
|
||||
новый срок, а хранилище продолжило выдавать прежний.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` отзыв доступа у провайдера доходит до сервиса гарантированно, максимум за
|
||||
семь суток; без этого он не доходил вовсе.
|
||||
- `+` срок жизни сессии стал числом, которое кто-то выбрал, и правится он в одном
|
||||
месте вместе со сроком куки.
|
||||
- `−` человек перевходит раз в неделю, и это заметно: своей страницы у сервиса
|
||||
нет, так что вход начинается с перехода по адресу входа руками.
|
||||
- `−` семь суток — всё ещё окно, в которое отозванный доступ работает. Немедленно
|
||||
закрыть чужую сессию можно только руками в панели, обновив ключ токенов записи;
|
||||
своего адреса у этого нет.
|
||||
- `−` закрытие продления сделано слоем приложения, а не настройкой коллекции:
|
||||
библиотека выдаёт сессию продлеваемой всегда, и отключить это в ней нечем.
|
||||
Слой придётся помнить при всякой правке маршрутов.
|
||||
@@ -0,0 +1,62 @@
|
||||
# ADR-2026-08-12. Спекой нормируется и инструмент сборки, а не только поведение сервиса
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решение 2
|
||||
|
||||
## Решение
|
||||
|
||||
Заведена capability `toolchain` — четвёртая, и первая, которая описывает **не
|
||||
поведение сервиса** для его потребителей, а поведение инструмента, которым сервис
|
||||
собирают. Потребитель у неё другой: тот, кто собирает.
|
||||
|
||||
Требование о согласованности объявленной версии Go живёт нормой в
|
||||
[openspec/specs/toolchain/spec.md](../../openspec/specs/toolchain/spec.md), а не
|
||||
прозой в памятке.
|
||||
|
||||
## Почему
|
||||
|
||||
Три существующие capability — `intake`, `pipeline`, `storage` — все про то, что
|
||||
сервис делает для своих потребителей, а преамбула `architecture.md` прямо
|
||||
говорила «поведение системы здесь не описывается — нормативно оно живёт в
|
||||
`openspec/specs/`». Согласованность версий сборки под это определение не
|
||||
подходит, и натяжение признано прямо в источнике:
|
||||
|
||||
> Признаём натяжение: три существующие capability описывают поведение сервиса для
|
||||
> его потребителей, а `toolchain` описывает поведение инструмента разработки.
|
||||
> Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml`
|
||||
> говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно
|
||||
> как домен он здесь и назван.
|
||||
|
||||
Очевидные пути отвергнуты оба:
|
||||
|
||||
> **Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и
|
||||
> захват задачи — поведение работающего сервиса. Версия сборщика с ним не
|
||||
> меняется вместе.
|
||||
>
|
||||
> **Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое
|
||||
> требование — «расхождение роняет набор проверок», — и требование без дома
|
||||
> проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже
|
||||
> один раз жило в трёх документах и во всех трёх было неверным.
|
||||
|
||||
Последнее и есть довод, перевесивший чистоту определения: дефект 2026-08-12
|
||||
случился именно потому, что утверждение о версии сборки жило только прозой, в
|
||||
трёх местах сразу, и никто не отвечал за его истинность.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` у правила о версиях есть нормативный дом со сценариями, и по нему видно, что
|
||||
проверено, а что оставлено человеку. Три требования, двадцать два сценария.
|
||||
- `+` следующая задача про инструмент сборки знает, куда дописывать, и не заводит
|
||||
вторую спеку о том же.
|
||||
- `−` определение capability в проекте стало шире, чем «поведение сервиса», и
|
||||
граница теперь проходит по слову «домен». Следующее пограничное решение будет
|
||||
ссылаться на этот прецедент — в том числе тогда, когда ссылаться не стоило бы.
|
||||
- `−` асимметрия: четыре однородных шага гейта живут в двух разных домах. У трёх
|
||||
плагинных (`docs.py`, `tasks.py`, `openspec.py`) нормативного дома нет вовсе,
|
||||
только строка в памятке; у четвёртого есть спека. Либо дома появятся у
|
||||
остальных, либо асимметрия останется навсегда.
|
||||
- `−` имя `toolchain` выбрано в том числе из-за настройки среды разработчика:
|
||||
первая редакция звалась `build`, и глобальный запрет чтения каталогов с таким
|
||||
именем сделал спеку нечитаемой для проходов ревью. Имя, выбранное под
|
||||
ограничение инструмента, а не под предмет, — слабое основание, и при следующем
|
||||
пересмотре его стоит перепроверить.
|
||||
@@ -0,0 +1,60 @@
|
||||
# ADR-2026-08-12. Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента
|
||||
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решения 1 и 6
|
||||
|
||||
## Решение
|
||||
|
||||
Шаг сверки версий добывает числа чтением файлов и **не зовёт `go` ни в каком
|
||||
виде** — ни `go mod edit -json`, ни `go list -m`, ни `go env`. Директива
|
||||
`toolchain` в `go.mod` при этом запрещена: её наличие роняет шаг.
|
||||
|
||||
Дословно из источника:
|
||||
|
||||
> Способ это не самый удобный: разбор директивы через `go mod edit -json` короче
|
||||
> и надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой
|
||||
> `GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN`
|
||||
> `go` вправе полезть в сеть за нужной версией — то есть требование «без сети»
|
||||
> перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а
|
||||
> не от коммита.
|
||||
|
||||
## Почему
|
||||
|
||||
Причина не в аккуратности, а в том, что **ровно этой подменой и держался дефект,
|
||||
ради которого шаг заведён**. 2026-08-12 требование модуля уехало на 1.25,
|
||||
сборочный образ остался на 1.24, образ перестал собираться — и восемь шагов гейта
|
||||
с шестью проходами ревью показали зелёное, потому что `go build ./...` шёл на
|
||||
хостовом Go. Проверка судила по тому, что стоит на машине, вместо того что
|
||||
записано в коммите. Шаг, зовущий `go`, воспроизвёл бы ту же подмену внутри себя:
|
||||
зелёный там, где стоит нужная версия, и другой ответ на другой машине.
|
||||
|
||||
Отсюда же запрет `toolchain`. Директива — штатный механизм Go и очевидное
|
||||
решение задачи расхождения: она заставила бы Go скачать нужную версию самому, и
|
||||
сверять стало бы нечего. Отвергнута намеренно:
|
||||
|
||||
> Директива `toolchain` заставила бы Go скачивать нужный тулчейн сам, и
|
||||
> расхождение с образом перестало бы ломать сборку. Но она же превращает сборку
|
||||
> образа в сетевую операцию, а сборочный слой качает тулчейн при каждой сборке.
|
||||
> Дороже и менее предсказуемо, чем строка сравнения.
|
||||
|
||||
Вдобавок она вводит **пятое место**, называющее версию, — то, которого закрытый
|
||||
перечень из четырёх мест не знает: при `toolchain go1.27.0` четыре объявленных
|
||||
числа сойдутся, а собирать будет пятое.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` исход шага есть функция коммита. Проверено прогоном: с `PATH`, где нет
|
||||
`go`, шаг даёт тот же код выхода и тот же вывод.
|
||||
- `+` требование «без сети» выполняется по построению, а не обещанием: под
|
||||
`strace` шаг не делает ни одного сетевого вызова.
|
||||
- `+` пятое место закрыто: `toolchain` в `go.mod` роняет шаг с названной
|
||||
причиной.
|
||||
- `−` разбор держится на регулярных выражениях `sed`/`awk` вместо готового
|
||||
разбора, который дал бы сам `go`. Это дороже в сопровождении и хрупче: правка
|
||||
образца ломает смежный случай беззвучно.
|
||||
- `−` запрет `toolchain` придётся снять или пересмотреть, если зависимость
|
||||
однажды потребует версию выше той, что стоит у нас. Тогда эта запись
|
||||
пересматривается, а не обходится.
|
||||
- `−` проверять сам скрипт нечем: `shellcheck` в гейт не заведён, тестов у него
|
||||
нет. Из девятнадцати сценариев нормы машина гоняет один — тот, где всё
|
||||
сошлось. Остаток объявлен и уехал отдельной задачей.
|
||||
+7
-1
@@ -32,7 +32,13 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | |
|
||||
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
|
||||
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
|
||||
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
|
||||
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | |
|
||||
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
|
||||
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
|
||||
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
|
||||
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
|
||||
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
|
||||
|
||||
+56
-23
@@ -8,11 +8,16 @@
|
||||
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
|
||||
этого ещё не решено — в разделе «Открытые вопросы».
|
||||
|
||||
Заведены три capability:
|
||||
Заведены пять capability. Четыре первые нормируют **поведение сервиса** для его
|
||||
потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а
|
||||
инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает.
|
||||
|
||||
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его
|
||||
нормируют проверки, написанные задачей `http-handler-tests-never-green`
|
||||
2026-08-11;
|
||||
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: приём и
|
||||
опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала,
|
||||
метка метрики несёт только известное расширение. Задачи
|
||||
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
|
||||
`pocketbase-storage` и `oidc-login` 2026-08-12. Приём из Telegram здесь не
|
||||
описан;
|
||||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||||
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
|
||||
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
|
||||
@@ -20,6 +25,15 @@
|
||||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
||||
2026-08-12;
|
||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||
2026-08-12. Разграничения записей по владельцу здесь нет: всякий вошедший
|
||||
видит всё, что видел прежде аноним;
|
||||
- [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой
|
||||
его версии собирается сервис: одно число версии Go во всех местах, где она
|
||||
названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade`
|
||||
2026-08-12.
|
||||
|
||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
||||
@@ -29,17 +43,26 @@
|
||||
|
||||
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||||
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер
|
||||
забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка
|
||||
— единицы записей в день (оценка владельца, не замер). Готовую библиотеку
|
||||
очереди тоже не заводим — решено 2026-08-11,
|
||||
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
|
||||
захвата и порядок выборки нормирует
|
||||
[pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
|
||||
Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
|
||||
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
|
||||
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
|
||||
кандидатов в [research/job-queue.md](research/job-queue.md).
|
||||
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
|
||||
достаётся снова по истечении срока захвата и проходит шаг заново.
|
||||
- **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда
|
||||
задача возвращается в работу, нормирует
|
||||
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
|
||||
в работу»; здесь это принцип письма шага, а не описание поведения.
|
||||
- **Ядро зависит от интерфейсов.** `internal/service` знает только
|
||||
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
|
||||
`main.go`.
|
||||
`main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и
|
||||
они же держат обратные направления: транспорты не знают друг о друге, адаптер
|
||||
не знает ни ядра, ни транспортов.
|
||||
*Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http`
|
||||
импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть
|
||||
роутер этого хранилища, а не наш сервер поверх него. Правила на это
|
||||
направление нет намеренно.
|
||||
|
||||
## Компоненты
|
||||
|
||||
@@ -57,7 +80,8 @@
|
||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
|
||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
||||
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
|
||||
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
|
||||
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
||||
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
|
||||
|
||||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
|
||||
|
||||
@@ -69,8 +93,11 @@
|
||||
## Внешние границы и форматы
|
||||
|
||||
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
|
||||
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не
|
||||
отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
|
||||
Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
|
||||
самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
|
||||
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
|
||||
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
|
||||
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
|
||||
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
||||
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
|
||||
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
||||
@@ -94,8 +121,9 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
||||
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||
|
||||
@@ -118,13 +146,14 @@
|
||||
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
|
||||
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
|
||||
| Разбор конфигурации | `internal/config.LoadConfig` |
|
||||
| Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
|
||||
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
||||
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
|
||||
|
||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
||||
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
|
||||
по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает
|
||||
сам.
|
||||
генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в
|
||||
код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло
|
||||
2026-08-13: его читает `internal/clock`, и запрет держит линтер.
|
||||
|
||||
## Деплой
|
||||
|
||||
@@ -138,11 +167,15 @@
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера
|
||||
обрабатывает PocketBase, а не наш код
|
||||
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия
|
||||
и как связываются пользователь Telegram и пользователь веба. Панель
|
||||
администратора при этом Authelia не закрывает: у неё свой пароль
|
||||
- **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер —
|
||||
Authelia, ответ провайдера обрабатывает PocketBase, а не наш код
|
||||
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия
|
||||
живёт кукой `transcriber_session` и сама себя не продлевает. Норма —
|
||||
[access](../openspec/specs/access/spec.md), решения —
|
||||
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
|
||||
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
|
||||
**Не решено одно:** как связываются пользователь Telegram и пользователь веба.
|
||||
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
||||
суперпользователя.
|
||||
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
|
||||
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
|
||||
|
||||
+18
-28
@@ -6,7 +6,8 @@
|
||||
|
||||
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
|
||||
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
|
||||
перечень «Механизировано» ниже. Причина: файл на несколько сотен строк
|
||||
перечень «Механизировано» записи [go-linters.md](go-linters.md) — дома правил,
|
||||
которыми машина читает код. Причина: файл на несколько сотен строк
|
||||
размазывает внимание по тривиальному — и модель, и человек добросовестно
|
||||
проверят именование и не дойдут до формы решения.
|
||||
|
||||
@@ -17,12 +18,13 @@ severity — в [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
|
||||
задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
|
||||
UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом
|
||||
шаге и дублируется воркером.
|
||||
UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg` —
|
||||
предложение с заглавной буквы вместо константной категории.
|
||||
|
||||
Из этого перечня одно уже закрыто: доменные ошибки проверялись приведением типа
|
||||
до 2026-08-11, задача `errors-as-instead-of-typecast`. Приведение типа на этом
|
||||
месте больше не долг, а регрессия.
|
||||
Из этого перечня закрыты два. Доменные ошибки проверялись приведением типа до
|
||||
2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по
|
||||
месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило
|
||||
держит линтер. Оба места больше не долг, а регрессия.
|
||||
|
||||
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
|
||||
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
|
||||
@@ -46,27 +48,15 @@ htmx, а здесь решено делать SPA — и перенесённы
|
||||
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
|
||||
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
|
||||
над `fetch`, показ ошибок и состояний списка.
|
||||
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: лестница
|
||||
механизации, два круга (pre-commit и гейт), перечень правил и подавлений,
|
||||
порядок заведения нового правила. Про инструменты, а не про то, как писать
|
||||
тесты.
|
||||
|
||||
## Механизировано
|
||||
## Что из этого проверяет машина
|
||||
|
||||
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
|
||||
промптах ревью не пересказывается.
|
||||
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `send` |
|
||||
| Форматирование исходников | `.golangci.yml` → `gofmt` |
|
||||
| Подозрительные конструкции языка | `.golangci.yml` → `govet`, `staticcheck`, `ineffassign`, `unused` |
|
||||
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
||||
| Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` |
|
||||
|
||||
Не названное здесь место механизации означает, что проход по конвенциям будет
|
||||
добросовестно проверять уже проверенное.
|
||||
|
||||
**Из перечисленного в записях правилом выражено одно** — сравнение ошибок через
|
||||
`errors.Is` и `errors.As` (`errorlint`, строка таблицы выше). Прозой остаётся всё
|
||||
прочее: ни константный `msg` лога (`sloglint`), ни запрет `fmt.Print*` и
|
||||
`os.Getenv` (`forbidigo`), ни запрет сторонних пакетов ошибок (`depguard`), ни
|
||||
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
|
||||
оставшееся прозой, проверяет человек на каждом ревью заново.
|
||||
Перечень правил, доведённых до проверки, и место настройки каждого — в записи
|
||||
[go-linters.md](go-linters.md). Там же сказано, что из перечисленного в прочих
|
||||
записях осталось прозой и потому проверяется человеком на каждом ревью заново, и
|
||||
там же названы остатки правил — то, что правило не ловит. Числа механизированного
|
||||
здесь нет намеренно: оно протухает при каждом новом правиле.
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме
|
||||
проверки пустых ключей внутри адаптеров.
|
||||
|
||||
**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера
|
||||
не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас
|
||||
участвует.
|
||||
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
|
||||
приезжают из TOML»; `godotenv` в `main.go` по-прежнему загружает `.env`, но кладёт
|
||||
его в окружение процесса, а не в настройки приложения.
|
||||
|
||||
## Принципы
|
||||
|
||||
@@ -60,6 +61,11 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
|
||||
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля
|
||||
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем.
|
||||
|
||||
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
||||
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
|
||||
говорит, какой формы значение здесь ждут. Пустым оставлен только
|
||||
`client_secret` — он и есть секрет.
|
||||
|
||||
## Поля по дискриминатору `type`
|
||||
|
||||
Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из
|
||||
@@ -87,7 +93,7 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
|
||||
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`,
|
||||
`yandex.object_storage_access_key_id`,
|
||||
`yandex.object_storage_secret_access_key`.
|
||||
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
|
||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||
владелец — пользователь процесса (`1000:1000`).
|
||||
- В `config.dist.toml` секретные поля — пустые строки.
|
||||
@@ -115,10 +121,20 @@ TOML. Пустой токен бота ловится в `NewTelegramController`
|
||||
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
|
||||
места проверки нет.
|
||||
|
||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
|
||||
процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с
|
||||
молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом
|
||||
было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение
|
||||
`client_secret` в журнал попасть не должно.
|
||||
|
||||
## Структура в коде
|
||||
|
||||
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
|
||||
- Одна корневая структура `Config` с под-структурами по секциям (`Server`,
|
||||
`Database`, `Storage`, `Yandex`, `Telegram`).
|
||||
- Одна корневая структура `Config` с под-структурами по секциям. Перечень секций
|
||||
и полей здесь не повторяем: источник истины по составу — `config.dist.toml`,
|
||||
действующие числа — [../database.md](../database.md), «Настройки с числовым
|
||||
значением». Каталог данных задаётся одним ключом `[storage] data_dir`
|
||||
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
|
||||
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
|
||||
требует правки обоих мест.
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
|
||||
|
||||
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не
|
||||
следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по
|
||||
месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации
|
||||
и разбора нет. Правила действуют на новый код; переписывание существующего —
|
||||
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует
|
||||
этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время
|
||||
единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило
|
||||
держит линтер. Правила действуют на новый код; переписывание существующего —
|
||||
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
|
||||
|
||||
**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты
|
||||
в transcriber нет.
|
||||
**Механизировано:** сверка изменённого шага схемы с
|
||||
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
|
||||
(`forbidigo` плюс `internal/clock`) и согласованность колонок очереди
|
||||
(тест-сканер `internal/archrules`). Прочие пункты — прозой; адреса —
|
||||
[go-linters.md](go-linters.md), «Механизировано».
|
||||
|
||||
## Первичные ключи — ULID, не автоинкремент
|
||||
|
||||
@@ -55,8 +58,9 @@
|
||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
||||
вставка падает громко. Измерение длительности — не метка времени.
|
||||
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`):
|
||||
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему
|
||||
- Миграции — шаги PocketBase на Go
|
||||
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
|
||||
поля заводятся кодом. При изменении структуры обновляем схему
|
||||
[../database.md](../database.md) тем же изменением.
|
||||
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
|
||||
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
|
||||
решают сами.
|
||||
|
||||
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в
|
||||
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних
|
||||
пакетов ошибок в проекте и так нет.
|
||||
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint`,
|
||||
сторонние пакеты ошибок — `depguard`, узнавание ошибки по тексту сообщения —
|
||||
тест-сканер `internal/archrules`. Перечень и адреса —
|
||||
[go-linters.md](go-linters.md), «Механизировано».
|
||||
|
||||
## Базовая идиома: stdlib
|
||||
|
||||
@@ -135,8 +136,11 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
||||
это значения `error`.
|
||||
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
||||
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой
|
||||
границы **нет**: паника в шаге конвейера роняет процесс целиком.
|
||||
ронял процесс. В transcriber его вешает роутер хранилища сам
|
||||
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
|
||||
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
|
||||
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**:
|
||||
паника в шаге конвейера роняет процесс целиком.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
|
||||
@@ -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
@@ -11,8 +11,13 @@ OpenSpec.
|
||||
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
|
||||
ошибку выше, где её логируют снова.
|
||||
|
||||
**Механизировано:** ничего. Ни `sloglint`, ни `forbidigo` в `.golangci.yml` не
|
||||
включены, поэтому правилами не выражено ни одно из перечисленного ниже.
|
||||
**Механизировано:** форма вызова — `sloglint`: только пары
|
||||
«ключ-значение», `msg` константой, **атрибуты (`slog.String` и прочие) не
|
||||
употребляются вовсе**. Запрет `fmt.Print*` и встроенных `print`/`println` —
|
||||
`forbidigo`; вывод в stdout через `fmt.Fprintln(os.Stdout, …)` правилом не
|
||||
ловится и остаётся прозой этой записи. Прозой остаются также уровень по адресату,
|
||||
единая логирующая точка и словарь имён полей: оракула у них нет. Адреса —
|
||||
[go-linters.md](go-linters.md), «Механизировано».
|
||||
|
||||
## Принципы
|
||||
|
||||
@@ -236,16 +241,31 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
||||
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
||||
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
||||
|
||||
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным
|
||||
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня
|
||||
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
|
||||
строки лога.
|
||||
Разговор с Telegram этому правилу следует, и точка чистки одна на все вызовы —
|
||||
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
|
||||
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из пяти:
|
||||
|
||||
*Расхождение:* расширение берётся из имени отправителя дословно
|
||||
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
|
||||
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
|
||||
`sendMessage`, скачивание записи и `getMe` из конструктора;
|
||||
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
|
||||
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
|
||||
замена точная — токен известен.
|
||||
|
||||
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
|
||||
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
|
||||
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
|
||||
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
|
||||
отказа и строке журнала.
|
||||
|
||||
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
|
||||
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||
расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот
|
||||
хвост не выходит: там расширение приводится к перечню известных форматов. Остаток
|
||||
описан в [../security.md](../security.md).
|
||||
расширением. В журнал оно идёт **собственным полем** строки приёма — это
|
||||
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
|
||||
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
|
||||
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
|
||||
там расширение приводится к перечню известных форматов. Остаток описан в
|
||||
[../security.md](../security.md).
|
||||
|
||||
## Куда пишем и уровень
|
||||
|
||||
|
||||
+25
-8
@@ -8,11 +8,17 @@
|
||||
CGO сборке не нужен.
|
||||
|
||||
Схему двигают **шаги миграций PocketBase** на Go, каталог
|
||||
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг
|
||||
регистрируется при загрузке пакета, а накатывается при подъёме хранилища
|
||||
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не
|
||||
переписывается — изменение только новым шагом: применённое хранилище считает по
|
||||
имени файла.
|
||||
`internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя
|
||||
шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
|
||||
хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый
|
||||
шаг не переписывается — изменение только новым шагом: применённое хранилище
|
||||
считает по имени шага.
|
||||
|
||||
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
|
||||
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
|
||||
пути** (`docs/.docs.json`, ключ `migrations`), а префикс наводится только на
|
||||
каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
|
||||
репозитория берёт их оттуда.
|
||||
|
||||
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
||||
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
|
||||
@@ -96,9 +102,17 @@ capability, и третий смысл развёл бы одно слово п
|
||||
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
|
||||
каталог и бакет растут неограниченно.
|
||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
не помечено защищённым: право прочитать запись даёт знание её идентификатора,
|
||||
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в
|
||||
хранилище **в журнал не пишется** — оно последняя часть ссылки.
|
||||
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
|
||||
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
|
||||
файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт
|
||||
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
|
||||
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
|
||||
- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её
|
||||
сужает: создание записи разрешено только контексту обмена OIDC
|
||||
(`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены.
|
||||
Без этого сужения закрытие API обходится двумя запросами — завести себе
|
||||
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
|
||||
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
|
||||
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||
@@ -134,6 +148,9 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
|
||||
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
|
||||
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
|
||||
| Срок жизни сессии | 7 суток | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки в 5 суток никем не выбрано |
|
||||
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
|
||||
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||||
|
||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||||
|
||||
+17
-9
@@ -22,7 +22,7 @@
|
||||
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
||||
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
||||
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Работает сегодня, но токенов нет и доступ не разграничен |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только кука, снятая из браузера. Токен приносит `api-tokens` |
|
||||
|
||||
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
|
||||
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
|
||||
@@ -68,8 +68,11 @@
|
||||
файл. Своей записи и работы без сети не делаем — граница цели
|
||||
[web-access](../tasks/items/web-access.md).
|
||||
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
|
||||
речи в них. Складом произвольных файлов, папками и общим доступом к чужим
|
||||
записям сервис не становится.
|
||||
речи в них. Складом произвольных файлов и папками сервис не становится. Общего
|
||||
доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у
|
||||
записи в модели данных не существует, и всякий вошедший видит все записи
|
||||
([security.md](security.md), «Периметр»). Это состояние, а не решение;
|
||||
закрывает его `record-ownership`.
|
||||
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
|
||||
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
|
||||
пользователя, потратившего слишком много, останавливает разговор или отзыв
|
||||
@@ -95,8 +98,10 @@
|
||||
отличает их по MIME-типу и расширению. Работает сегодня.
|
||||
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
|
||||
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
||||
увидит `done` и текст. Работает сегодня, но без токена и без разграничения
|
||||
доступа.
|
||||
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
|
||||
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
|
||||
его заводит `api-tokens`, — как нет и разграничения записей между
|
||||
пользователями.
|
||||
6. **Отказ на середине.** Конвертация или распознавание не удались — задача
|
||||
переходит в `failed`, а пользователь получает сообщение о том, что именно не
|
||||
вышло, и предложение повторить.
|
||||
@@ -109,7 +114,10 @@
|
||||
которой пользуемся: она и задаёт потолок по длине записи и формату.
|
||||
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
|
||||
перестанет устраивать по цене или по качеству русской речи.
|
||||
- **PocketBase** — хранилище взамен сегодняшнего SQLite, решено 2026-08-11
|
||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Учётные
|
||||
записи оно хранит и получает от Authelia своим провайдером OIDC, но источником
|
||||
их не становится: заводит и проверяет людей по-прежнему Authelia.
|
||||
**PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела
|
||||
задача `pocketbase-storage` 2026-08-12
|
||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
|
||||
держит хранилище, файлы и панель владельца. Схема и
|
||||
раскладка — [database.md](database.md). Учётные записи она хранит и получает от
|
||||
Authelia своим провайдером OIDC, но источником их не становится: заводит и
|
||||
проверяет людей по-прежнему Authelia.
|
||||
|
||||
@@ -99,6 +99,77 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
||||
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
|
||||
на `/_/` кодом `200`.
|
||||
|
||||
## Вход через OIDC: что выяснилось при реализации
|
||||
|
||||
Дописано 2026-08-12 задачей `oidc-login`. Провенанс общий: чтение исходников
|
||||
`pocketbase@v0.39.10` из кеша модулей плюс прогоны против настоящего хранилища на
|
||||
временном каталоге, все — в ходе ревью того change. Живой Authelia в прогонах не
|
||||
было ни разу: провайдера подменял свой `httptest`-сервер.
|
||||
|
||||
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
|
||||
`CreateRule = ""` (создание доступно анониму) и `PasswordAuth.Enabled = true`
|
||||
(`migrations/1640988000_init.go`, `core/collection_model_auth_options.go`).
|
||||
Прогон подтвердил: `POST /api/collections/users/records` → `200`, следом
|
||||
`auth-with-password` → `200` с токеном. То есть закрытие API за вход обходится
|
||||
двумя запросами, пока эта поверхность не закрыта своим шагом схемы.
|
||||
|
||||
**Правило создания нельзя закрывать полностью.** `CreateRule = nil` означает «только
|
||||
суперпользователь», а запись при первом входе заводит **внутренний** запрос
|
||||
самого обмена, идущий без таких прав (`apis/record_crud.go`: проверка
|
||||
`!hasSuperuserAuth && collection.CreateRule == nil`). Прогон: с `nil` вход
|
||||
кончался `401`, учётных записей `0`. Работает правило
|
||||
`@request.context = "oauth2"` — контекст ставит сам обмен
|
||||
(`core.RequestInfoContextOAuth2`), а посторонний запрос приходит с контекстом по
|
||||
умолчанию. Открывать правило пустой строкой при этом нельзя: публичный обмен
|
||||
принимает поля создаваемой записи от вызывающего.
|
||||
|
||||
**Обмен кода наружу не экспортирован.** Пакет `apis` отдаёт ошибки, middleware,
|
||||
`NewRouter`, `Serve` и обёртки; сам обмен — неэкспортированная функция за
|
||||
маршрутом `POST /api/collections/{c}/auth-with-oauth2`, принимающая `provider`,
|
||||
`code`, `codeVerifier`, `redirectURL`. Собственный `/api/oauth2-redirect` служит
|
||||
другому — он ищет клиента realtime-подписки по параметру `state`, то есть
|
||||
обслуживает всплывающее окно JS-клиента, а не серверный вход.
|
||||
|
||||
**`apis.NewRouter` не идемпотентна: собирать её нужно один раз и держать, а не
|
||||
создавать заново при каждом вызове.**
|
||||
Она зовёт `bindRealtimeEvents` и `bindUIExtensions`, а те вешают девять
|
||||
обработчиков **на приложение** и без поля `Id`; `hook.Bind` такому генерирует
|
||||
новый идентификатор и **добавляет**. Замер: пять вызовов подряд подняли
|
||||
`OnModelAfterUpdateSuccess` с 4 до 14, а 3000 вызовов — время сотни сохранений
|
||||
записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. Освобождения нет, только
|
||||
перезапуск.
|
||||
|
||||
**Связывание учётной записи идёт по `sub`, а не найдя — по почте.** Обмен ищет
|
||||
запись в `_externalAuths` по `providerId`, и лишь затем `FindAuthRecordByEmail`
|
||||
(`apis/record_auth_with_oauth2.go`). Отсюда цена открытой регистрации: запись,
|
||||
заведённая посторонним на чужой адрес почты, достаётся первому же настоящему
|
||||
входу с этим адресом.
|
||||
|
||||
**Защищённое поле файла судится двумя вещами сразу** — коротким токеном файла из
|
||||
строки запроса **и** правилом просмотра коллекции (`apis/file.go`). Незаданное
|
||||
правило означает «только суперпользователь», поэтому одной пометки `Protected`
|
||||
мало: прогон показал `404` анониму, вошедшему кукой, вошедшему заголовком и
|
||||
вошедшему с законно полученным токеном файла — пока правило не назначено.
|
||||
|
||||
**Сессия по умолчанию продлеваема бессрочно.** Токен несёт поле
|
||||
`refreshable=true`, и `POST /api/collections/{c}/auth-refresh` меняет его на
|
||||
новый с новым сроком. Прогон: три продления подряд, каждое `200`, `exp` растёт.
|
||||
Настройки «выдавать непродлеваемую сессию» у коллекции нет — закрывается только
|
||||
слоем приложения поверх маршрута.
|
||||
|
||||
**Подпись сессии считается от секрета коллекции и ключа записи**, обе величины в
|
||||
базе (`core/record_query.go`, `FindAuthRecordByToken`). Отсюда два следствия:
|
||||
сессия переживает перезапуск сервиса сама, а смена ключа записи
|
||||
(`Record.RefreshTokenKey()`) обесценивает все её выданные сессии разом.
|
||||
|
||||
**Куки библиотека не читает вовсе** — сессию берёт только заголовком
|
||||
`Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
|
||||
|
||||
**Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом
|
||||
роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию
|
||||
`MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять
|
||||
суток; проект умолчание не переопределяет.
|
||||
|
||||
## Что отвергнуто и почему
|
||||
|
||||
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
|
||||
|
||||
+263
-15
@@ -8,6 +8,11 @@
|
||||
Разделы ниже заполнены наперёд по коду и правятся по итогам прогонов: «Типовые
|
||||
ложноположительные» первым прогоном уже пользовались.
|
||||
|
||||
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
|
||||
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
|
||||
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
|
||||
узлах».
|
||||
|
||||
### Типовые узлы
|
||||
|
||||
Рода узлов проекта и проверяемые свойства к каждому.
|
||||
@@ -38,12 +43,17 @@
|
||||
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
||||
успех молча.
|
||||
|
||||
**Репозиторий SQLite** (`adapter/repo/sqlite`):
|
||||
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
|
||||
подпакетом `migrations`):
|
||||
|
||||
- список колонок совпадает во всех четырёх запросах файла;
|
||||
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`;
|
||||
- захват задачи не выдаёт одну строку двум вызывающим;
|
||||
- ошибка драйвера транслируется в доменную у источника.
|
||||
- список колонок совпадает во всех четырёх местах — `applyToRecord`,
|
||||
`recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
|
||||
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
|
||||
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
|
||||
держатель захвата;
|
||||
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
|
||||
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
|
||||
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
|
||||
|
||||
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
||||
`adapter/metaviewer/ffmpeg`):
|
||||
@@ -95,20 +105,29 @@
|
||||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||||
Новой находкой это не считается, пока не измерен рост.
|
||||
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой
|
||||
[security.md](security.md). Находкой считается только новая поверхность,
|
||||
выставленная наружу, а не повторение этого факта.
|
||||
- **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не
|
||||
новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а
|
||||
разграничения по владельцу нет сознательно — [security.md](security.md),
|
||||
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
|
||||
поверхность, выставленная наружу, либо путь к содержимому записи **без**
|
||||
сессии, а не повторение этого факта.
|
||||
|
||||
### Вопросы по темам
|
||||
|
||||
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
||||
|
||||
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры
|
||||
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и
|
||||
`transcribe.go`, 2026-08-10).
|
||||
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||||
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||||
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
|
||||
собеседник»), а исход прерванного шага нормой по-прежнему не описан
|
||||
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
|
||||
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
|
||||
`transcribe.go`, 2026-08-13; прежний провенанс 2026-08-10 устарел вместе с
|
||||
дефектом «остановка хоронила запись»).
|
||||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
||||
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`,
|
||||
2026-08-10).
|
||||
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||||
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
|
||||
`s3.go`, `speechkit.go`, 2026-08-13).
|
||||
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
||||
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
||||
2026-08-10).
|
||||
@@ -132,6 +151,23 @@
|
||||
(CLAUDE.md, «Инварианты»).
|
||||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
|
||||
тестов два файла, и оба мимо конвейера.
|
||||
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
|
||||
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
|
||||
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
|
||||
2026-08-12 роняет гейт правилом линтера
|
||||
([conventions/go-linters.md](conventions/go-linters.md), «Механизировано»), и
|
||||
спрашивать о нём не нужно.
|
||||
- `security`: не открылась ли снова поверхность, которую приносит хранилище, —
|
||||
собственная регистрация, вход по паролю, одноразовый код, восстановление
|
||||
доступа, продление сессии. Всё это приходит включённым и закрывается нами
|
||||
(задача `oidc-login` 2026-08-12).
|
||||
- `security`: не появился ли второй способ получить сессию к тому же человеку —
|
||||
заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
|
||||
- `operations`: доходит ли отзыв доступа у провайдера до сервиса и за какой срок —
|
||||
после входа сервис к провайдеру не обращается, и канал здесь один
|
||||
(ADR-2026-08-12-session-without-refresh).
|
||||
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
|
||||
приложения, — сборка роутера хранилища оказалась именно такой.
|
||||
|
||||
### Триггеры метки
|
||||
|
||||
@@ -179,7 +215,14 @@ API и имя не откатываются обратной правкой по
|
||||
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
|
||||
день, и утверждения о росте остаются условиями, а не замерами;
|
||||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||
отдан внешней программе, и она вне нашей границы.
|
||||
отдан внешней программе, и она вне нашей границы;
|
||||
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
|
||||
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
|
||||
выкладки вне репозитория, и по коду её не проверить
|
||||
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
|
||||
- `security`: поведение браузера с куками — применение `SameSite`, приём
|
||||
`Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки
|
||||
этого рода остаются гипотезами.
|
||||
|
||||
**Перестали проверять сознательно:**
|
||||
|
||||
@@ -187,7 +230,13 @@ API и имя не откатываются обратной правкой по
|
||||
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
|
||||
длительность от подставного источника. Своего теста у
|
||||
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
||||
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md).
|
||||
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
|
||||
- **всё, что требует поднять сервис целиком.** Локальный запуск роняет адаптер
|
||||
Telegram: он проверяет токен обращением к Telegram, а боевым токеном
|
||||
запускаться запрещено. Значит поведенческая верификация живым прогоном
|
||||
недоступна ни одной задаче, и заменяют её проверки поверх настоящего роутера
|
||||
хранилища. Замечено 2026-08-12 задачей `oidc-login`; своей задачи на это пока
|
||||
нет.
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
@@ -197,6 +246,199 @@ API и имя не откатываются обратной правкой по
|
||||
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
||||
выдумывать его задним числом нельзя.
|
||||
|
||||
## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
|
||||
|
||||
- **Где:** `internal/service/transcribe.go`, шаг конвертации — дефект завела та
|
||||
же правка, что проложила контекст до `ffmpeg`
|
||||
- **Симптом:** на прод не уехал, поймали до коммита. Выглядел бы так: обычная
|
||||
выкладка посреди конвертации переводит здоровую запись в терминальное
|
||||
`failed`, отправителю уходит «сбой конвертации файла», а вернуть задачу может
|
||||
только владелец правкой в панели. Окно — часы: конвертация шестичасовой записи
|
||||
идёт дольше часа по построению
|
||||
- **Причина:** контекст дошёл до внешнего процесса, а различать его отмену шаг
|
||||
не научили. Убитый по контексту `ffmpeg` отдаёт `signal: killed` — от
|
||||
настоящего отказа (`exit status N`) эта ошибка неотличима ни типом, ни
|
||||
`errors.Is`: различает только `ctx.Err()`. Шаг звал `failJob` на любой отказ
|
||||
`Convert`. Хуже: `failJob` возвращает `nil`, поэтому воркер считал прогон
|
||||
успешным, и метрика владельца — та, которой он замечает отказы, — не
|
||||
шевелилась
|
||||
- **Чем воспроизведён:** проверкой `TestShutdownDuringConversionKeepsJobRetryable`
|
||||
с подставным конвертером, ведущим себя как убитый процесс: отдаёт отказ, не
|
||||
несущий `context.Canceled`. Мутация снята — без развилки проверка краснеет
|
||||
- **Почему не поймали раньше:** правка выглядела механической, «линтер потребовал
|
||||
контекст». Цена оказалась в семантике очереди, а не в сигнатурах: отмена стала
|
||||
значить разное на соседних шагах одного конвейера. Ни один линтер такого не
|
||||
видит — это заметили три прохода ревью независимо, и все три построили путь
|
||||
- **Что меняем:** прерванный шаг приговора не выносит — задача остаётся на
|
||||
повтор, попытку не тратит (счётчик, выросший при захвате, возвращают назад) и
|
||||
отправителю о несуществующем сбое не сообщает. Воркер не считает остановку
|
||||
отказом и не пишет о ней владельцу. Задача не забирается вовсе, если нас уже
|
||||
остановили. Остаток объявлен: норма отмены в спеке `pipeline` не описана, и
|
||||
открытая задача `context-cancel-in-pipeline` этим закрыта не целиком
|
||||
|
||||
## 2026-08-13 — отказ скачивания уносил токен бота в журнал [проскочил]
|
||||
|
||||
- **Где:** `internal/controller/tg/tg.go`, скачивание записи по ссылке
|
||||
`file.Link(c.bot.Token)`
|
||||
- **Симптом:** не наблюдался, потому что журнал за этим местом никто не читал
|
||||
построчно. Первый же сбой сети на скачивании писал в журнал
|
||||
`Failed to download audio file` вместе с полным адресом запроса, а в адресе
|
||||
Telegram держит токен бота (`…/bot<TOKEN>/…`). Инвариант «секрет не покидает
|
||||
конфиг» помечен critical и необратим: утёкший токен отзывают руками
|
||||
- **Причина:** `http.Get` возвращает `*url.Error`, и тот встраивает адрес
|
||||
целиком. Отказ уходил в `fmt.Errorf("failed to download file: %w", err)`, а
|
||||
оттуда — в `logger.Error` соседней строкой
|
||||
- **Чем воспроизведён:** чтением цепочки от `http.Get` до вызова `logger.Error`
|
||||
в трёх обработчиках; на живом боте не проверялся — боевым токеном запускаться
|
||||
запрещено
|
||||
- **Почему не поймали раньше:** правило было записано прозой и ровно про этот
|
||||
случай — [conventions/logging.md](conventions/logging.md), «Ошибка
|
||||
HTTP-транспорта несёт URL». Хуже: там же стояло объявленное *Расхождение* с
|
||||
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
|
||||
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
|
||||
машина прозу не проверяет
|
||||
- **Что меняем:** чистка перенесена с места употребления на **границу клиента** —
|
||||
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
|
||||
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
|
||||
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
|
||||
больше не получает: клиента ему отдают готовым. Расхождение в конвенции
|
||||
закрыто, оценка в [security.md](security.md) исправлена
|
||||
- **Чем закрыт от возврата:** проверками `internal/adapter/telegram/bot_test.go`
|
||||
— четыре пути (`getFile`, `sendMessage`, конструктор, логгер библиотеки)
|
||||
судятся по тексту отказа и строке журнала. Мутация снята: со снятой чисткой
|
||||
три из них краснеют, печатая токен. Правило остаётся прозой (линтер не отличит
|
||||
ссылку с секретом от ссылки без него), но у прозы теперь есть оракул
|
||||
- **Как нашли:** первый путь — попутно, при разборе находок `noctx`: тот
|
||||
потребовал переписать `http.Get` на запрос с контекстом, и цепочку пришлось
|
||||
прочитать целиком. Остальные четыре — конвейером ревью в тот же день; правка,
|
||||
закрывшая один путь, объявила класс закрытым в двух документах, и это едва не
|
||||
осталось так
|
||||
|
||||
## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]
|
||||
|
||||
- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока
|
||||
результата распознавания
|
||||
- **Симптом:** сегодня не наблюдался — путь рабочий, пока библиотека отдаёт конец
|
||||
потока значением `io.EOF`. Отказ с текстом «EOF» был бы принят за конец потока,
|
||||
и расшифровка вернулась бы усечённой: пользователь получил бы половину записи
|
||||
как готовый результат
|
||||
- **Причина:** конец потока узнавался сравнением `err.Error() == "EOF"`. Текст
|
||||
сообщения — не признак: его носит и чужая ошибка, а сменит его библиотека —
|
||||
условие перестанет срабатывать вовсе, и оба исхода молчаливы
|
||||
- **Чем воспроизведён:** не воспроизводился на живом сервисе — прогон на реальных
|
||||
ключах запрещён. Найден тестом-сканером `internal/archrules` при его заведении
|
||||
- **Почему не поймали раньше:** `errorlint` видит `err == ErrX` и приведение типа,
|
||||
но матчинг по тексту не видит; прозой это правило записано не было, и ревью его
|
||||
не спрашивало
|
||||
- **Что меняем:** узнавание переведено на `errors.Is(err, io.EOF)`; класс закрыт
|
||||
тестом-сканером (docs/conventions/go-linters.md, «Ошибки и отказы»)
|
||||
|
||||
## 2026-08-13 — правило гейта обходилось одной лишней строкой [пойман ревью]
|
||||
|
||||
- **Где:** `.golangci.yml`, правило `forbidigo` о суждении по живой карте
|
||||
заголовков — заведено в тот же день задачей `response-assertions-judge-result`
|
||||
- **Симптом:** правило ловило только прямую цепочку `w.Header().Get`. Присваивание
|
||||
в переменную (`h := w.Header()`), чтение по индексу карты, обход `range` и поле
|
||||
`HeaderMap` проходили гейт зелёными — то есть класс, стоивший трёх зелёных
|
||||
гейтов, возвращался четвёртый раз, и уже без человеческой страховки: документы
|
||||
успели снять его с прохода ревью
|
||||
- **Причина:** `forbidigo` по умолчанию судит по печатному тексту вызова, а не по
|
||||
типу значения. Правило, записанное текстом, отсекает одну форму записи, а не
|
||||
свойство
|
||||
- **Чем воспроизведён:** прогоном линтера на файле проверок с шестью формами
|
||||
чтения живой карты: помечена была одна
|
||||
- **Почему не поймали раньше:** правило проверили ровно тем нарушением, против
|
||||
которого писали. Мутация была, но одна — нужна была по одной на каждую форму
|
||||
- **Что меняем:** правило судит по типу приёмника (`analyze-types`,
|
||||
`httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
|
||||
проверено мутацией по каждой. Отсюда же строка в
|
||||
docs/conventions/go-linters.md, «Лестница механизации»: запрет по имени, обходимый лишней строкой, — это ступень
|
||||
тест-сканера, наряженная запретом
|
||||
|
||||
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
|
||||
|
||||
- **Где:** шаг схемы `202608120001` задачи `oidc-login`, правило создания записи
|
||||
в коллекции пользователей
|
||||
- **Симптом:** `users.CreateRule = nil` закрывало создание записи для всех, кроме
|
||||
владельца панели. Запись при первом входе заводит внутренний запрос самого
|
||||
обмена, идущий без таких прав, — значит после выкладки вход не сработал бы ни
|
||||
у кого, включая владельца, а приём и опрос уже были закрыты. Сервис остался бы
|
||||
доступен только через Telegram, и чинилось бы это руками в панели
|
||||
- **Причина:** закрывали ровно то, ради чего задача затевалась, — самостоятельную
|
||||
регистрацию, которую хранилище приносит открытой. Глухое `nil` выглядит самым
|
||||
надёжным её закрытием и отвергает заодно единственный законный путь заведения
|
||||
записи. Различить их можно: обмен помечает свой запрос контекстом `oauth2`
|
||||
- **Чем воспроизведён:** тестом против настоящего хранилища с подставным
|
||||
провайдером: возврат от провайдера отвечал `401`, обращений к токен-эндпоинту
|
||||
`1`, учётных записей после входа `0`. Причина изолирована тем же прогоном —
|
||||
с открытым правилом возврат давал `302` и запись появлялась
|
||||
- **Почему не поймали раньше:** все проверки задачи заводили учётную запись
|
||||
прямым сохранением, мимо входа, и потому шли по коду, который в бою не
|
||||
исполняется. Гейт был зелёным. Поймали два прохода независимо — разбор кода по
|
||||
исходникам библиотеки и враждебный проход падающим тестом
|
||||
- **Что меняем:** правило сузили до контекста обмена
|
||||
(`@request.context = "oauth2"`), а в набор проверок добавили вход целиком через
|
||||
подставного провайдера — от увода до куки сессии. Проверка, заводящая запись
|
||||
мимо входа, больше не считается покрытием входа
|
||||
|
||||
## 2026-08-12 — проверка не могла упасть: читала живую карту заголовков вместо ответа [пойман ревью]
|
||||
|
||||
- **Где:** `internal/controller/http/auth_test.go`, проверка уборки носителя
|
||||
состояния входа; сам дефект — в `auth.go`, уборка стояла в `defer`
|
||||
- **Симптом:** носитель состояния и проверочного кода не убирался ни на успешном
|
||||
возврате, ни на отказном, и жил свои десять минут. Одноразовость возврата
|
||||
держалась ровно на этой уборке, то есть тоже не работала. Проверка при этом
|
||||
была зелёной и утверждала обратное
|
||||
- **Причина:** двойная. В коде — `defer` исполняется после того, как ответ уже
|
||||
начали писать, а заголовки к этому моменту зафиксированы снимком, и позднейшая
|
||||
правка их карты до браузера не доезжает. В проверке — `httptest` устроен
|
||||
зеркально: `Header()` отдаёт живую карту, а снимок лежит отдельно и читается
|
||||
через `Result()`. Проверка смотрела в живую карту и видела то, чего клиент не
|
||||
получит
|
||||
- **Чем воспроизведён:** отдельной программой вне проекта: на настоящем сервере
|
||||
ответ приходил с пустым `Set-Cookie`, а тот же обработчик под `httptest`
|
||||
показывал куку в `Header()` и не показывал в `Result()`
|
||||
- **Почему не поймали раньше:** оракул был ложным по построению, и никакая
|
||||
регрессия его не разбудила бы. Гейт зелёный. Поймали два прохода — сверка
|
||||
требований и разбор кода, — оба воспроизведением, а не чтением
|
||||
- **Что меняем:** уборка перенесена до записи ответа; все проверки этого файла
|
||||
судят по `Result()`. Класс всплывает **третий раз** (2026-08-10 «тесты
|
||||
http-обработчика ни разу не были зелёными», 2026-08-11 «проверка приёма не
|
||||
могла упасть»), поэтому он же ушёл в конвенции правилом: проверка ответа
|
||||
судит по готовому ответу, а не по изменяемому состоянию обработчика.
|
||||
Механизировано 2026-08-12 задачей `response-assertions-judge-result` —
|
||||
`forbidigo` в `.golangci.yml` роняет гейт на чтении живой карты заголовков в
|
||||
файле проверок. Правило судит по **типу приёмника**, а не по тексту вызова, и
|
||||
потому ловит любую форму чтения живой карты — цепочкой, через переменную, по
|
||||
индексу, обходом, полем `HeaderMap`. Текстовый запрет ловил только прямую
|
||||
цепочку и обходился одной лишней строкой — это назвал прогон ревью этой же
|
||||
задачи. Проходу ревью остаётся проверка, идущая мимо recorder, через свой
|
||||
`http.ResponseWriter`
|
||||
|
||||
## 2026-08-12 — каждый анонимный запрос навсегда замедлял запись в хранилище [пойман ревью]
|
||||
|
||||
- **Где:** `internal/controller/http/auth.go`, обмен кода собирал роутер
|
||||
хранилища на каждый вызов
|
||||
- **Симптом:** сборка роутера вешает девять обработчиков на само приложение и
|
||||
без идентификатора, поэтому повторная не заменяет прежние, а добавляет.
|
||||
Обработчики исполняются на каждой записи в хранилище, а конвейер пишет задачу на
|
||||
каждом шаге. Освобождения нет — только перезапуск. Раскачивалось анонимно:
|
||||
атакующий ставит себе куку состояния сам, и сверка сравнивает две его же
|
||||
величины, а обмен исполняется раньше обращения к провайдеру
|
||||
- **Причина:** функция сборки выглядит чистой — она возвращает роутер, и по имени
|
||||
не видно, что она правит приложение. Решение звать собственный адрес хранилища
|
||||
внутри процесса сделало эту сборку частью горячего пути
|
||||
- **Чем воспроизведён:** замером на настоящем приложении: пять вызовов подряд
|
||||
подняли число обработчиков одного события с 4 до 14; 3000 анонимных возвратов
|
||||
довели сотню сохранений записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. При
|
||||
недоступном провайдере утечка сохранялась
|
||||
- **Почему не поймали раньше:** ни один шаг гейта не смотрит на побочные эффекты
|
||||
вызова библиотеки, а замер требует прогона. Поймали три прохода — архитектурный
|
||||
зондом, враждебный падающим тестом, сверка требований чтением
|
||||
- **Что меняем:** роутер собирается один раз и живёт полем обработчика; в набор
|
||||
проверок добавлена та, что считает длину очереди обработчиков после двадцати
|
||||
входов
|
||||
|
||||
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
|
||||
|
||||
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0` —
|
||||
@@ -219,6 +461,12 @@ API и имя не откатываются обратной правкой по
|
||||
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
|
||||
ревью.
|
||||
|
||||
**Закрыто** задачей `go-1-26-upgrade` 2026-08-12: шаг `go-version` в `task gate`
|
||||
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
|
||||
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
|
||||
согласованно, и парная сверка не увидела бы документ, разошедшийся с
|
||||
согласованным кодом. Норма — capability `toolchain`.
|
||||
|
||||
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
||||
|
||||
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
|
||||
|
||||
+82
-28
@@ -2,14 +2,20 @@
|
||||
|
||||
## Периметр
|
||||
|
||||
**Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и
|
||||
аутентификации не делает ни прокси, ни само приложение.** Находки строятся против
|
||||
этого — сегодняшнего — периметра.
|
||||
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
||||
обратный прокси, а приём записи, опрос готовности и файл записи требуют входа
|
||||
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты
|
||||
без входа только проба здоровья и метрики. Находки строятся против этого —
|
||||
сегодняшнего — периметра.
|
||||
|
||||
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный
|
||||
вход для программ по личным токенам, два уровня доступа — пользователь видит
|
||||
свои записи, владелец сервиса ещё и страницу расхода. Он **не** развёрнут;
|
||||
описанное ниже разграничение доступа относится только к Telegram.
|
||||
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
||||
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
||||
страницу расхода. **Разграничения по владельцу нет:** всякий вошедший видит все
|
||||
записи и все расшифровки, как видел их прежде аноним. Его заводит задача
|
||||
`record-ownership`.
|
||||
|
||||
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной
|
||||
записью приложения он не связан.
|
||||
|
||||
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
|
||||
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
|
||||
@@ -28,9 +34,18 @@
|
||||
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
|
||||
она вне модели («Что вне модели», строка про контур).
|
||||
|
||||
Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio`
|
||||
доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу
|
||||
запросов и не ограничен по размеру файла.
|
||||
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
|
||||
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
|
||||
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
|
||||
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
|
||||
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
|
||||
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
|
||||
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
|
||||
|
||||
Отсюда главное следствие, из которого читается всё остальное: **`POST
|
||||
/api/audio` требует входа, а число запросов и размер файла по-прежнему ничем не
|
||||
ограничены**. Вошедший не ограничен ни в том, ни в другом, и тратит наши деньги
|
||||
на распознавание столько, сколько захочет.
|
||||
|
||||
## Недоверенный вход
|
||||
|
||||
@@ -38,8 +53,8 @@
|
||||
|
||||
| Вход | Канал | Кто может слать |
|
||||
| --- | --- | --- |
|
||||
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой из интернета |
|
||||
| Идентификатор задачи | `GET /api/status/:id` | Любой из интернета |
|
||||
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
|
||||
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
|
||||
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
|
||||
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
|
||||
@@ -94,9 +109,12 @@ Telegram отправителю.
|
||||
каталогов, но это единственное, что стоит между входом и именем файла.
|
||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||
расширением. Бакет один на все записи, префикса по пользователю нет.
|
||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не
|
||||
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а
|
||||
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется**
|
||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
|
||||
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
|
||||
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
|
||||
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
|
||||
остаётся: **имя файла в хранилище в журнал не пишется**
|
||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
||||
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
||||
@@ -124,18 +142,44 @@ Telegram отправителю.
|
||||
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя
|
||||
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram
|
||||
меняется владельцем в любой момент: список привязан к изменяемому значению.
|
||||
- **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты вместе с
|
||||
остальным.
|
||||
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
||||
кукой `transcriber_session`, живёт семь суток, обесценивается выходом.
|
||||
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
|
||||
бессрочно, и семисуточный срок — единственное, чем отзыв доступа у провайдера
|
||||
доходит до сервиса, — не значил бы ничего.
|
||||
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
|
||||
та же проверка, но она названа здесь отдельно, потому что это второй способ
|
||||
предъявить ту же сессию.
|
||||
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
|
||||
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
|
||||
коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
|
||||
правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
|
||||
здесь «сессия → токен файла → ссылка».
|
||||
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||||
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||||
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||||
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||||
2026-08-12.
|
||||
- **Заведение учётной записи** — только входом у провайдера. Собственное
|
||||
создание записи, вход по паролю, одноразовый код и восстановление доступа
|
||||
выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
|
||||
без этого закрытия вход обходился бы двумя запросами.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты без сессии:
|
||||
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
|
||||
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||||
эти адреса не несут.
|
||||
|
||||
Владения записью в модели данных нет: у задачи нет пользователя. Пока API
|
||||
анонимен, знание UUID задачи и есть право её читать.
|
||||
Владения записью в модели данных по-прежнему нет: у задачи нет пользователя.
|
||||
Знание UUID задачи и есть право её читать — теперь для всякого вошедшего, а не
|
||||
для всякого встречного.
|
||||
|
||||
Целевой периметр заводит четыре механизма вместо одного белого списка:
|
||||
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
|
||||
них уже стоит:
|
||||
|
||||
| Механизм | Что даёт | Чья задача |
|
||||
| --- | --- | --- |
|
||||
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты | `oidc-login` |
|
||||
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
|
||||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` |
|
||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||
@@ -223,8 +267,16 @@ Telegram отправителю.
|
||||
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
|
||||
типа файла не идёт.
|
||||
|
||||
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL
|
||||
нигде не логируется.
|
||||
Токен бота стоит в пути **каждого** обращения к Bot API (`bot<TOKEN>/getFile`,
|
||||
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
|
||||
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
|
||||
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
|
||||
скачивания и отправки пишутся в журнал. Теперь адрес снимается на границе
|
||||
клиента — `internal/adapter/telegram`, `NewBot`: свой `Do` чистит отказ, а
|
||||
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
|
||||
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
|
||||
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
|
||||
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
|
||||
|
||||
## Что вне модели
|
||||
|
||||
@@ -239,10 +291,12 @@ Telegram отправителю.
|
||||
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
|
||||
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
|
||||
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
|
||||
(паспорт, 2026-08-11), шестичасовая запись весит единицы гигабайт — оценка, а не
|
||||
замер: `research/` пуст, потолок длины стоит открытым вопросом
|
||||
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать
|
||||
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
|
||||
(паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а
|
||||
не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
|
||||
— 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
|
||||
Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
|
||||
нет и не будет: решено считать расход и показывать его владельцу, а не
|
||||
отказывать (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
|
||||
Authelia. Рост каталога данных при этом ничем не наблюдается —
|
||||
открытый вопрос `architecture.md`.
|
||||
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
module git.vakhrushev.me/av/transcriber
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.37.2
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.30.3
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.3
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
|
||||
github.com/aws/smithy-go v1.27.7
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
@@ -17,25 +18,24 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/yandex-cloud/go-genproto v0.17.0
|
||||
google.golang.org/grpc v1.74.2
|
||||
google.golang.org/grpc v1.82.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
|
||||
github.com/aws/smithy-go v1.27.7 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
@@ -70,9 +70,9 @@ require (
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
|
||||
google.golang.org/protobuf v1.36.7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -5,10 +5,10 @@ github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
|
||||
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.30.3 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 h1:ptfyXmv+ooxzFwyuBth0yqABcjVIkjDL0iTYZBSbum8=
|
||||
@@ -17,32 +17,30 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 h1:nRniHAvjFJGUCl04F3WaAj7
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2/go.mod h1:eJDFKAMHHUvv4a0Zfa7bQb//wFNUXGrbFpYRCHe2kD0=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 h1:Nb2pUE30lySKPGdkiIJ1SZgHsjiebOiRNI7R9NA1WtM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3/go.mod h1:BO5EKulvhBF1NXwui8lfnuDPBQQU5807yvWASZ/5n6k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 h1:sBpc8Ph6CpfZsEdkz/8bfg8WhKlWMCms5iWj6W/AW2U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2/go.mod h1:Z2lDojZB+92Wo6EKiZZmJid9pPrDJW2NNIXSlaEfVlU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 h1:blV3dY6WbxIVOFggfYIo2E1Q2lZoy5imS7nKgu5m6Tc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2/go.mod h1:cBWNeLBjHJRSmXAxdS7mwiMUEgx6zup4wQ9J+/PcsRQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 h1:oxmDEO14NBZJbK/M8y3brhMFEIGN4j8a6Aq8eY0sqlo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2/go.mod h1:4hH+8QCrk1uRWDPsVfsNDUup3taAjO8Dnx63au7smAU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 h1:0hBNFAPwecERLzkhhBY+lQKUMpXSKVv4Sxovikrioms=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2/go.mod h1:Vcnh4KyR4imrrjGN7A2kP2v9y6EPudqoPKXtnmBliPU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 h1:utPhv4ECQzJIUbtx7vMN4A8uZxlQ5tSt1H1toPI41h8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0/go.mod h1:1/eZYtTWazDgVl96LmGdGktHFi7prAcGCrJ9JGvBITU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 h1:j7/jTOjWeJDolPwZ/J4yZ7dUsxsWZEsxNwH5O7F8eEA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0/go.mod h1:M0xdEPQtgpNT7kdAX4/vOAPkFj60hSQRb7TvW9B0iug=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2g1RDHlvri3NxHA08MGI=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
|
||||
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
|
||||
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
|
||||
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -145,18 +143,18 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
|
||||
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
@@ -185,15 +183,17 @@ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -15,7 +16,7 @@ func NewFfmpegConverter() *FfmpegConverter {
|
||||
return &FfmpegConverter{}
|
||||
}
|
||||
|
||||
func (c *FfmpegConverter) Convert(src, dest string) error {
|
||||
func (c *FfmpegConverter) Convert(ctx context.Context, src, dest string) error {
|
||||
// Проверяем существование исходного файла
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return fmt.Errorf("input file does not exist: %s", src)
|
||||
@@ -26,8 +27,9 @@ func (c *FfmpegConverter) Convert(src, dest string) error {
|
||||
return fmt.Errorf("ffmpeg not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
// Создаем команду ffmpeg для конвертации в OGG
|
||||
cmd := exec.Command(ffmpegExecutable,
|
||||
// Команда заводится с контекстом: отменённый контекст убивает процесс, а не
|
||||
// оставляет его дожёвывать чужую запись после остановки воркера.
|
||||
cmd := exec.CommandContext(ctx, ffmpegExecutable,
|
||||
"-i", src, // входной файл
|
||||
"-c:a", "libvorbis", // кодек Vorbis для OGG
|
||||
"-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -26,7 +27,7 @@ func NewFfmpegMetaViewer() *FfmpegMetaViewer {
|
||||
return &FfmpegMetaViewer{}
|
||||
}
|
||||
|
||||
func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
|
||||
func (m *FfmpegMetaViewer) GetInfo(ctx context.Context, src string) (*contract.AudioInfo, error) {
|
||||
// Проверяем существование исходного файла
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("input file does not exist: %s", src)
|
||||
@@ -37,8 +38,9 @@ func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
|
||||
return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
// Создаем команду ffprobe для получения метаданных
|
||||
cmd := exec.Command(ffprobeExecutable,
|
||||
// Команда заводится с контекстом: отправитель, закрывший соединение, не
|
||||
// оставляет за собой чтение метаданных чужого файла.
|
||||
cmd := exec.CommandContext(ctx, ffprobeExecutable,
|
||||
"-v", "quiet", // тихий режим (без лишнего вывода)
|
||||
"-print_format", "json", // вывод в формате JSON
|
||||
"-show_format", // показать информацию о формате
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package recognizer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
@@ -9,14 +10,14 @@ import (
|
||||
|
||||
type MemoryAudioRecognizer struct{}
|
||||
|
||||
func (r *MemoryAudioRecognizer) Recognize(file io.Reader, fileName string) (operationID string, err error) {
|
||||
func (r *MemoryAudioRecognizer) Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error) {
|
||||
return uuid.NewString(), nil
|
||||
}
|
||||
|
||||
func (r *MemoryAudioRecognizer) GetRecognitionText(operationID string) (string, error) {
|
||||
func (r *MemoryAudioRecognizer) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
|
||||
return "Foo bar, Baz.", nil
|
||||
}
|
||||
|
||||
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
|
||||
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
||||
return entity.NewCompletedResult(), nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package yandex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
@@ -54,16 +56,31 @@ func (s *YandexAudioRecognizerService) Close() error {
|
||||
return s.sttService.Close()
|
||||
}
|
||||
|
||||
func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string) (string, error) {
|
||||
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
|
||||
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
|
||||
// прежде, чем ответ дойдёт, и защита ничего не даст.
|
||||
const startRecognitionTimeout = 10 * time.Second
|
||||
|
||||
err := s.s3Sevice.uploadFile(file, fileName)
|
||||
func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) {
|
||||
|
||||
// Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен —
|
||||
// объект ложится под тем же ключом.
|
||||
err := s.s3Sevice.uploadFile(ctx, file, fileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
uri := s.s3Sevice.fileUrl(fileName)
|
||||
|
||||
opId, err := s.sttService.recognizeFileFromS3(uri)
|
||||
// А вот принятие операции от отмены защищено. Окно короткое и дорогое:
|
||||
// SpeechKit может операцию принять и начать считать деньги, а ответ до нас
|
||||
// не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же
|
||||
// запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала
|
||||
// вечно.
|
||||
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
|
||||
defer cancel()
|
||||
|
||||
opId, err := s.sttService.recognizeFileFromS3(startCtx, uri)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -71,12 +88,19 @@ func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string
|
||||
return opId, nil
|
||||
}
|
||||
|
||||
func (s *YandexAudioRecognizerService) GetRecognitionText(operationID string) (string, error) {
|
||||
return s.sttService.getRecognitionText(operationID)
|
||||
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
|
||||
// родителя и собственный предел по времени. Употребляется там, где обрыв стоит
|
||||
// дороже ожидания: у платной операции, чей результат нельзя переспросить.
|
||||
func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
|
||||
}
|
||||
|
||||
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
|
||||
operation, err := s.sttService.checkOperationStatus(operationID)
|
||||
func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) {
|
||||
return s.sttService.getRecognitionText(ctx, operationID)
|
||||
}
|
||||
|
||||
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
|
||||
operation, err := s.sttService.checkOperationStatus(ctx, operationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package yandex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Принятие операции распознавания защищено от отмены: остановка сервиса не
|
||||
// должна обрывать вызов, который уже мог начать стоить денег и чей результат
|
||||
// нельзя переспросить. Проверяется само средство защиты — проводка к нему
|
||||
// оракула не имеет: клиент SpeechKit подставить нечем, а прогон на реальных
|
||||
// ключах запрещён (CLAUDE.md, «Запреты»).
|
||||
func TestProtectedContextSurvivesParentCancel(t *testing.T) {
|
||||
parent, cancel := context.WithCancel(t.Context())
|
||||
|
||||
protected, release := protectFromCancel(parent, time.Minute)
|
||||
defer release()
|
||||
|
||||
cancel()
|
||||
|
||||
require.Error(t, parent.Err(), "родитель отменён — иначе проверка судит не то")
|
||||
assert.NoError(t, protected.Err(), "защищённый вызов пережил отмену родителя")
|
||||
}
|
||||
|
||||
// Защита не бессрочна: у вызова свой предел, иначе остановка ждала бы вечно.
|
||||
func TestProtectedContextKeepsItsOwnDeadline(t *testing.T) {
|
||||
protected, release := protectFromCancel(t.Context(), time.Minute)
|
||||
defer release()
|
||||
|
||||
deadline, ok := protected.Deadline()
|
||||
|
||||
require.True(t, ok, "у защищённого вызова обязан быть свой предел")
|
||||
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, 5*time.Second)
|
||||
}
|
||||
@@ -67,8 +67,8 @@ func newYandexS3Service(cfg s3Config) (*yandexS3Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
|
||||
_, err := s.uploader.Upload(context.Background(), &s3.PutObjectInput{
|
||||
func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileName string) error {
|
||||
_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucketName),
|
||||
Key: aws.String(fileName),
|
||||
Body: file,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
@@ -93,9 +94,7 @@ func (s *speechKitService) Close() error {
|
||||
}
|
||||
|
||||
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3
|
||||
func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string) (string, error) {
|
||||
// Добавляем авторизацию и folder_id в контекст
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||
@@ -136,9 +135,7 @@ func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
|
||||
}
|
||||
|
||||
// GetRecognitionResult получает результат распознавания по ID операции
|
||||
func (s *speechKitService) getRecognitionText(operationID string) (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) {
|
||||
// Добавляем авторизацию и folder_id в контекст
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||
@@ -157,7 +154,10 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
// Конец потока библиотека отдаёт ровно `io.EOF`. Прежде он узнавался
|
||||
// сравнением текста сообщения: так же выглядел бы и настоящий отказ
|
||||
// с текстом «EOF», и распознавание молча вернуло бы половину текста.
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return "", fmt.Errorf("failed to receive recognition response: %w", err)
|
||||
@@ -176,9 +176,7 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
|
||||
}
|
||||
|
||||
// checkOperationStatus проверяет статус операции распознавания
|
||||
func (s *speechKitService) checkOperationStatus(operationID string) (*operation.Operation, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID string) (*operation.Operation, error) {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
|
||||
|
||||
|
||||
@@ -10,13 +10,15 @@ import (
|
||||
|
||||
pb "github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть
|
||||
// адреса ссылки на него, поэтому меняются только новым шагом схемы.
|
||||
const (
|
||||
FilesCollection = "files"
|
||||
JobsCollection = "transcribe_jobs"
|
||||
// Шаги схемы регистрируются загрузкой своего пакета, а накатывает их
|
||||
// `RunAllMigrations` ниже. Импорт здесь пустой и явный, хотя соседние файлы
|
||||
// пакета и так берут оттуда имена коллекций: день, когда имена перестанут
|
||||
// читаться отсюда, унёс бы вместе с последней ссылкой и регистрацию — список
|
||||
// шагов остался бы пустым, `RunAllMigrations` вернул бы `nil`, и приложение
|
||||
// поднялось бы здоровым, но без коллекций. Отказ вылез бы не на старте, а на
|
||||
// первом приёме записи.
|
||||
_ "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
|
||||
@@ -82,7 +84,7 @@ func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkF
|
||||
}
|
||||
|
||||
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
||||
record, err := repo.app.FindRecordById(FilesCollection, fileID)
|
||||
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
||||
}
|
||||
@@ -115,7 +117,7 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
||||
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
|
||||
// приватности этого не допускает. Свой суффикс хранилище допишет само.
|
||||
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
|
||||
collection, err := findCollection(repo.app, FilesCollection)
|
||||
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -142,7 +144,7 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e
|
||||
}
|
||||
|
||||
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
|
||||
collection, err := findCollection(repo.app, FilesCollection)
|
||||
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -160,7 +162,7 @@ func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.
|
||||
}
|
||||
|
||||
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
||||
record, err := repo.app.FindRecordById(FilesCollection, id)
|
||||
record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
@@ -168,7 +170,7 @@ func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
||||
}
|
||||
|
||||
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
|
||||
record, err := repo.app.FindRecordById(FilesCollection, fileID)
|
||||
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
||||
}
|
||||
|
||||
+1
-14
@@ -1,22 +1,11 @@
|
||||
package pocketbase
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Схема заводится версионированными шагами, и применённый шаг не переписывается
|
||||
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
|
||||
// применённое по имени файла шага.
|
||||
//
|
||||
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
|
||||
// `apis.Serve` прежде, чем поднять сервер.
|
||||
func init() {
|
||||
migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
||||
}
|
||||
|
||||
func up202608110001(app core.App) error {
|
||||
files := core.NewBaseCollection(FilesCollection)
|
||||
files.Fields.Add(
|
||||
@@ -118,5 +107,3 @@ func down202608110001(app core.App) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -0,0 +1,124 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// defaultAuthTokenDuration — умолчание библиотеки, к которому возвращает откат.
|
||||
const defaultAuthTokenDuration = 1209600
|
||||
|
||||
// up202608120001 закрывает поверхность, которую хранилище приносит своим
|
||||
// системным шагом, и защищает файл записи.
|
||||
//
|
||||
// Коллекция пользователей заводится библиотекой с открытым созданием записи и
|
||||
// включённым входом по паролю. Без этого шага закрытие API обходится двумя
|
||||
// запросами: завести себе учётную запись, войти паролем, предъявить полученное
|
||||
// заголовком. Отдельная цена открытого создания — захват учётной записи: обмен
|
||||
// кода ищет запись сперва по неизменяемому признаку провайдера, а не найдя —
|
||||
// по адресу почты, и запись, заведённая посторонним на чужой адрес, достаётся
|
||||
// первому же настоящему входу с этим адресом.
|
||||
func up202608120001(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
// Завести учётную запись можно только входом у провайдера.
|
||||
//
|
||||
// Правило именно такое, а не `nil`: запись при первом входе заводит
|
||||
// внутренний запрос самого обмена, и он идёт без прав суперпользователя —
|
||||
// глухое `nil` отвергло бы его наравне с посторонним, и войти не смог бы
|
||||
// никто. Контекст `oauth2` ставит обмен (`core.RequestInfoContextOAuth2`),
|
||||
// а посторонний запрос приходит с контекстом по умолчанию.
|
||||
//
|
||||
// Открывать правило пустой строкой нельзя: публичный обмен принимает поля
|
||||
// создаваемой записи от вызывающего, и всякий владелец учётной записи у
|
||||
// провайдера задал бы их сам.
|
||||
users.CreateRule = ptr(`@request.context = "oauth2"`)
|
||||
users.PasswordAuth.Enabled = false
|
||||
users.OTP.Enabled = false
|
||||
|
||||
// Провайдер включается здесь с пустыми значениями: адреса, идентификатор
|
||||
// клиента и секрет приходят из конфига при каждом подъёме. Положенный сюда
|
||||
// секрет не пережил бы ротации — применённый шаг не переписывается.
|
||||
users.OAuth2.Enabled = true
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to close users collection surface: %w", err)
|
||||
}
|
||||
|
||||
files, err := app.FindCollectionByNameOrId(FilesCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find files collection: %w", err)
|
||||
}
|
||||
|
||||
// Ссылка на файл перестаёт быть правом пройти по ней: до этого шага знание
|
||||
// ссылки и было доступом, а отзыва у неё нет. Конвейер этим не затронут —
|
||||
// он читает файл из файловой системы хранилища, а не по ссылке.
|
||||
//
|
||||
// Комментарий прежнего шага утверждает обратное — «защищённым поле не
|
||||
// помечено намеренно». Прежний шаг не переписывается, поэтому решение
|
||||
// отменяется здесь: право прочитать запись больше не даёт знание её
|
||||
// идентификатора.
|
||||
field, ok := files.Fields.GetByName("file").(*core.FileField)
|
||||
if !ok {
|
||||
return errors.New("files collection has no file field")
|
||||
}
|
||||
field.Protected = true
|
||||
|
||||
// Одной пометки мало: защищённый файл судится ещё и правилом просмотра
|
||||
// коллекции, а незаданное правило означает «только владелец панели» — файл
|
||||
// не получил бы и вошедший. Правило пускает всякого узнанного: владельца у
|
||||
// записи ещё нет, и сужать выборку эта задача не должна.
|
||||
files.ViewRule = ptr(`@request.auth.id != ""`)
|
||||
|
||||
if err := app.Save(files); err != nil {
|
||||
return fmt.Errorf("failed to protect record file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// down202608120001 возвращает умолчания библиотеки — те, что стояли до шага.
|
||||
//
|
||||
// Открытое создание записи сюда не возвращается намеренно: это ровно то, что
|
||||
// шаг и закрывал, и откат, восстанавливающий анонимную регистрацию, оставил бы
|
||||
// сервис хуже, чем он был до задачи. Срок жизни сессии возвращается
|
||||
// умолчанием, а не нулём: нулевую длительность валидация коллекции отвергает, и
|
||||
// прежний откат падал на ней, не дойдя до снятия защиты с файла.
|
||||
func down202608120001(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
users.CreateRule = nil
|
||||
users.PasswordAuth.Enabled = true
|
||||
users.OTP.Enabled = true
|
||||
users.OAuth2.Enabled = false
|
||||
users.OAuth2.Providers = nil
|
||||
users.AuthToken.Duration = defaultAuthTokenDuration
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to restore users collection: %w", err)
|
||||
}
|
||||
|
||||
files, err := app.FindCollectionByNameOrId(FilesCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find files collection: %w", err)
|
||||
}
|
||||
|
||||
if field, ok := files.Fields.GetByName("file").(*core.FileField); ok {
|
||||
field.Protected = false
|
||||
}
|
||||
files.ViewRule = nil
|
||||
|
||||
if err := app.Save(files); err != nil {
|
||||
return fmt.Errorf("failed to unprotect record file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package migrations — шаги схемы хранилища и имена коллекций, которые они
|
||||
// заводят.
|
||||
//
|
||||
// Схема заводится версионированными шагами, и применённый шаг не переписывается
|
||||
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
|
||||
// применённое по **имени шага**, а не по пути файла, поэтому имена в
|
||||
// `Register` ниже не переносятся и не переименовываются, даже если файл переехал.
|
||||
//
|
||||
// Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина
|
||||
// внешняя: сверка документов ловит изменённый шаг схемы при нетронутом
|
||||
// `docs/database.md` по префиксу пути (`docs/.docs.json`, ключ `migrations`), а
|
||||
// префикс наводится только на каталог. Пока шаги лежали файлом, наводить его
|
||||
// было не на что, и проверка молчала на всякой правке схемы.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
pbmigrations "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// Имена коллекций живут здесь, рядом с шагом, который их заводит. Они же — часть
|
||||
// пути к файлу в раскладке хранилища и часть адреса ссылки на него, поэтому
|
||||
// меняются только новым шагом схемы.
|
||||
const (
|
||||
FilesCollection = "files"
|
||||
JobsCollection = "transcribe_jobs"
|
||||
)
|
||||
|
||||
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
|
||||
// `apis.Serve` прежде, чем поднять сервер.
|
||||
func init() {
|
||||
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
||||
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -2,6 +2,8 @@ package pocketbase
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
|
||||
@@ -20,7 +22,7 @@ import (
|
||||
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
|
||||
// переход хранит намеренно — приходило бы владельцу нулём.
|
||||
func BindPanelRules(app core.App) {
|
||||
app.OnRecordUpdateRequest(JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
app.OnRecordUpdateRequest(migrations.JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
original := e.Record.Original()
|
||||
if original == nil || original.GetString("state") == e.Record.GetString("state") {
|
||||
return e.Next()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// ProviderName — имя провайдера у коллекции пользователей. Библиотека знает его
|
||||
// как обобщённый OIDC и по нему же ищет настройку при обмене кода.
|
||||
const ProviderName = "oidc"
|
||||
|
||||
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
|
||||
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
|
||||
// применяется, потому что оно никем не выбрано.
|
||||
//
|
||||
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
|
||||
// провайдера, и потому живёт здесь, а не в каталоге шагов. Причина та же:
|
||||
// применённый шаг не переписывается, и число, положенное туда, разошлось бы со
|
||||
// сроком жизни куки при первой же правке — браузер получил бы новый срок, а
|
||||
// хранилище продолжило выдавать прежний.
|
||||
const SessionDuration = 7 * 24 * 60 * 60
|
||||
|
||||
// ProviderSettings — то, что приезжает из конфига и приводится к настройкам
|
||||
// коллекции.
|
||||
type ProviderSettings struct {
|
||||
AuthURL string
|
||||
TokenURL string
|
||||
UserInfoURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
// ApplyProviderSettings приводит настройки провайдера у коллекции пользователей
|
||||
// к значениям конфига.
|
||||
//
|
||||
// Делается это при каждом подъёме, а не однажды шагом схемы, и причина в
|
||||
// инварианте: применённый шаг не переписывается. Секрет, положенный шагом, не
|
||||
// пережил бы ротации — смена значения в конфиге до хранилища не доехала бы
|
||||
// вовсе, и вход сломался бы после смены ключа, а починить это можно было бы
|
||||
// только руками в панели.
|
||||
//
|
||||
// Секрет здесь не логируется и в текст ошибки не попадает: сообщение называет
|
||||
// имя коллекции, а не значения.
|
||||
func ApplyProviderSettings(app core.App, settings ProviderSettings) error {
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
// Срок жизни сессии живёт здесь, а не в шаге схемы: применённый шаг не
|
||||
// переписывается, и правка числа не доехала бы до хранилища, разойдясь со
|
||||
// сроком жизни куки.
|
||||
users.AuthToken.Duration = SessionDuration
|
||||
|
||||
users.OAuth2.Enabled = true
|
||||
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: ProviderName,
|
||||
ClientId: settings.ClientID,
|
||||
ClientSecret: settings.ClientSecret,
|
||||
AuthURL: settings.AuthURL,
|
||||
TokenURL: settings.TokenURL,
|
||||
UserInfoURL: settings.UserInfoURL,
|
||||
DisplayName: "Authelia",
|
||||
}}
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to apply provider settings to users collection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
)
|
||||
|
||||
type TranscriptJobRepository struct {
|
||||
@@ -23,7 +27,7 @@ func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
|
||||
}
|
||||
|
||||
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
||||
collection, err := findCollection(repo.app, JobsCollection)
|
||||
collection, err := findCollection(repo.app, migrations.JobsCollection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -50,7 +54,7 @@ func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
||||
// LostAcquisitionError и результата не пишет.
|
||||
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
|
||||
err := repo.app.RunInTransaction(func(txApp core.App) error {
|
||||
record, err := txApp.FindRecordById(JobsCollection, job.Id)
|
||||
record, err := txApp.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find transcribe job: %w", err)
|
||||
}
|
||||
@@ -77,7 +81,7 @@ func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder stri
|
||||
}
|
||||
|
||||
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
|
||||
record, err := repo.app.FindRecordById(JobsCollection, id)
|
||||
record, err := repo.app.FindRecordById(migrations.JobsCollection, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
|
||||
}
|
||||
@@ -105,16 +109,22 @@ const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
|
||||
// разделителем, обратил бы условие срока в постоянную истину или постоянную
|
||||
// ложь — молча.
|
||||
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
|
||||
now := types.NowDateTime()
|
||||
// Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка
|
||||
// хранилища читает часы сама, и запрет линтера её не видит — новая метка в
|
||||
// этом запросе обошла бы единую точку молча.
|
||||
now, err := types.ParseDateTime(clock.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse current time: %w", err)
|
||||
}
|
||||
|
||||
query := repo.app.DB().NewQuery(`
|
||||
UPDATE {{` + JobsCollection + `}}
|
||||
UPDATE {{` + migrations.JobsCollection + `}}
|
||||
SET acquisition_id = {:acquisition_id},
|
||||
acquire_time = {:now},
|
||||
attempts = attempts + 1,
|
||||
updated = {:now}
|
||||
WHERE id = (
|
||||
SELECT id FROM {{` + JobsCollection + `}}
|
||||
SELECT id FROM {{` + migrations.JobsCollection + `}}
|
||||
WHERE state = {:state}
|
||||
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
|
||||
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
|
||||
@@ -140,7 +150,7 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string,
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to aquire job with state %s: %w", state, err)
|
||||
return nil, fmt.Errorf("failed to acquire job with state %s: %w", state, err)
|
||||
}
|
||||
|
||||
return row.toJob(), nil
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
|
||||
@@ -136,7 +138,7 @@ func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) {
|
||||
|
||||
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
|
||||
// собственные времена хранилища.
|
||||
record, err := app.FindRecordById(JobsCollection, job.Id)
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
|
||||
require.NoError(t, app.Save(record))
|
||||
@@ -232,7 +234,7 @@ func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Задача досталась другому, пока шаг работал.
|
||||
record, err := app.FindRecordById(JobsCollection, mine.Id)
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, mine.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquisition_id", "someone-else")
|
||||
require.NoError(t, app.Save(record))
|
||||
@@ -279,7 +281,7 @@ func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, acquired.AcquisitionID)
|
||||
|
||||
record, err := app.FindRecordById(JobsCollection, job.Id)
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("attempts", 5)
|
||||
record.Set("state", entity.StateDead)
|
||||
@@ -360,7 +362,7 @@ func patchRecord(t *testing.T, app core.App, recordID, body string) {
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/collections/"+JobsCollection+"/records/"+recordID,
|
||||
"/api/collections/"+migrations.JobsCollection+"/records/"+recordID,
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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",
|
||||
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
|
||||
}
|
||||
@@ -16,7 +16,9 @@ type TelegramMessageSender struct {
|
||||
}
|
||||
|
||||
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
|
||||
bot, err := tgbotapi.NewBotAPI(botToken)
|
||||
// Клиент заводится единой точкой: её отказ не несёт токена, а отказ
|
||||
// конструктора несёт — `NewBotAPI` зовёт `getMe`.
|
||||
bot, err := NewBot(botToken, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
@@ -12,6 +15,7 @@ type Config struct {
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Yandex YandexConfig `toml:"yandex"`
|
||||
Telegram TelegramConfig `toml:"telegram"`
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -42,6 +46,69 @@ type TelegramConfig struct {
|
||||
UpdateTimeout int `toml:"update_timeout"`
|
||||
}
|
||||
|
||||
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
|
||||
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
|
||||
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
|
||||
// секрет, положенный однажды шагом, не пережил бы ротации.
|
||||
type AuthConfig struct {
|
||||
AuthURL string `toml:"auth_url"`
|
||||
TokenURL string `toml:"token_url"`
|
||||
UserInfoURL string `toml:"user_info_url"`
|
||||
ClientID string `toml:"client_id"`
|
||||
ClientSecret string `toml:"client_secret"`
|
||||
// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
|
||||
RedirectURL string `toml:"redirect_url"`
|
||||
// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
|
||||
// выключается только для локального запуска по `http://localhost`, где
|
||||
// браузер такую куку не сохранит.
|
||||
SecureCookie bool `toml:"secure_cookie"`
|
||||
}
|
||||
|
||||
// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
|
||||
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
|
||||
// открытым наружу, а узнать об этом было бы неоткуда.
|
||||
//
|
||||
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
|
||||
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
|
||||
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
|
||||
// молча падает целиком, вместе с ботом и воркерами.
|
||||
func (c AuthConfig) Validate() error {
|
||||
values := map[string]string{
|
||||
"auth_url": c.AuthURL,
|
||||
"token_url": c.TokenURL,
|
||||
"user_info_url": c.UserInfoURL,
|
||||
"client_id": c.ClientID,
|
||||
"client_secret": c.ClientSecret,
|
||||
"redirect_url": c.RedirectURL,
|
||||
}
|
||||
|
||||
missing := make([]string, 0, len(values))
|
||||
for name, value := range values {
|
||||
if value == "" {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
// Названы имена ключей, а не значения: значение `client_secret` в
|
||||
// сообщение об ошибке попасть не должно, оно уедет в журнал.
|
||||
return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
malformed := make([]string, 0, 4)
|
||||
for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
|
||||
parsed, err := url.Parse(values[name])
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
malformed = append(malformed, name)
|
||||
}
|
||||
}
|
||||
if len(malformed) > 0 {
|
||||
return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config with default values
|
||||
func defaultConfig() *Config {
|
||||
return &Config{
|
||||
@@ -66,6 +133,9 @@ func defaultConfig() *Config {
|
||||
BotToken: "",
|
||||
UpdateTimeout: 10,
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
SecureCookie: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
@@ -10,18 +11,23 @@ type AudioInfo struct {
|
||||
Seconds int // Длина аудиофайла в секундах
|
||||
}
|
||||
|
||||
// Контекст первым доводом несут все интерфейсы, за которыми стоит внешний
|
||||
// собеседник — процесс `ffmpeg`, S3, SpeechKit. Он здесь не украшение: остановка
|
||||
// сервиса обязана доходить до чужой работы, а не оставлять её сиротой. Без него
|
||||
// конвертация шестичасовой записи переживает остановку воркера, а запрос к
|
||||
// платному распознаванию висит до собственного таймаута библиотеки.
|
||||
type AudioMetaViewer interface {
|
||||
GetInfo(src string) (*AudioInfo, error)
|
||||
GetInfo(ctx context.Context, src string) (*AudioInfo, error)
|
||||
}
|
||||
|
||||
type AudioFileConverter interface {
|
||||
Convert(src, dest string) error
|
||||
Convert(ctx context.Context, src, dest string) error
|
||||
}
|
||||
|
||||
type AudioRecognizer interface {
|
||||
Recognize(file io.Reader, fileName string) (operationID string, err error)
|
||||
GetRecognitionText(operationID string) (string, error)
|
||||
CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error)
|
||||
Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error)
|
||||
GetRecognitionText(ctx context.Context, operationID string) (string, error)
|
||||
CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error)
|
||||
}
|
||||
|
||||
type TelegramMessageSender interface {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -44,6 +45,14 @@ type GetTranscribeJobResponse struct {
|
||||
// сохранены — публичный контракт API объявлен необратимым.
|
||||
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
api := r.Group("/api")
|
||||
|
||||
// Оба адреса уходят за аутентификацию. Слой предъявления стоит перед
|
||||
// проверкой и действует только здесь: собственная поверхность хранилища под
|
||||
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
|
||||
// не шлёт.
|
||||
api.Bind(SessionFromCookie())
|
||||
api.Bind(apis.RequireAuth())
|
||||
|
||||
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
||||
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
|
||||
// поэтому предел тела равен потолку самой записи.
|
||||
@@ -63,7 +72,14 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
||||
}
|
||||
}()
|
||||
|
||||
job, err := h.trsService.CreateJobFromApi(file, header.Filename)
|
||||
// Запись доехала целиком, поэтому задача заводится независимо от того,
|
||||
// дождётся ли отправитель ответа: на контексте запроса приём терял бы
|
||||
// полностью загруженную запись от одного обрыва соединения, а забрать
|
||||
// результат он может и позже — по `GET /status/{id}`. Значения контекста
|
||||
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
|
||||
ctx := context.WithoutCancel(e.Request.Context())
|
||||
|
||||
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename)
|
||||
if err != nil {
|
||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
||||
|
||||
@@ -2,6 +2,7 @@ package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
@@ -38,7 +40,12 @@ type stubMetaViewer struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
||||
func (m *stubMetaViewer) GetInfo(ctx context.Context, _ string) (*contract.AudioInfo, error) {
|
||||
// Настоящий `ffprobe` заведён с контекстом и по отмене умирает; стаб,
|
||||
// который контекст игнорирует, сделал бы проверку приёма неспособной упасть.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
@@ -49,7 +56,7 @@ func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
||||
// этого файла её не зовёт.
|
||||
type stubConverter struct{}
|
||||
|
||||
func (c *stubConverter) Convert(string, string) error { return nil }
|
||||
func (c *stubConverter) Convert(context.Context, string, string) error { return nil }
|
||||
|
||||
// TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
|
||||
type TestTgSender struct{}
|
||||
@@ -70,6 +77,50 @@ type testEnv struct {
|
||||
handler *TranscribeHandler
|
||||
app core.App
|
||||
journal *journalBuffer
|
||||
// session — значение сессии вошедшего. Приём и опрос закрыты за
|
||||
// аутентификацией, и проверка, судящая их по существу, обязана предъявить
|
||||
// сессию ровно так же, как это делает браузер.
|
||||
session string
|
||||
// account — учётная запись, которой выдана сессия. Нужна проверкам выхода.
|
||||
account *core.Record
|
||||
}
|
||||
|
||||
// serve шлёт запрос от имени вошедшего: сессия предъявляется кукой — тем же
|
||||
// способом, каким её предъявляет браузер. Заголовок проверки не ставят: куку в
|
||||
// него перекладывает слой предъявления, и подмена его здесь означала бы проверку
|
||||
// не той цепочки.
|
||||
//
|
||||
// Проверки, судящие отказ без сессии, зовут `mux` напрямую.
|
||||
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
|
||||
e.mux.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
// newTestAccount заводит учётную запись и выдаёт ей сессию.
|
||||
//
|
||||
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
|
||||
// запросом больше нельзя — создание закрыто шагом схемы, и в этом весь смысл
|
||||
// изменения. Прямое сохранение идёт мимо правил доступа так же, как идёт вход,
|
||||
// когда учётную запись заводит само хранилище.
|
||||
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||
t.Helper()
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set("email", "person@example.com")
|
||||
record.Set("verified", true)
|
||||
// Случайный пароль ставит и само хранилище, когда заводит запись по входу у
|
||||
// провайдера: запись auth-коллекции без пароля не сохраняется, а войти по
|
||||
// нему всё равно нельзя — парольный вход выключен шагом схемы.
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
token, err := record.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
return record, token
|
||||
}
|
||||
|
||||
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||
@@ -147,7 +198,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &testEnv{mux: mux, handler: handler, app: app, journal: journal}
|
||||
account, session := newTestAccount(t, app)
|
||||
|
||||
return &testEnv{
|
||||
mux: mux,
|
||||
handler: handler,
|
||||
app: app,
|
||||
journal: journal,
|
||||
session: session,
|
||||
account: account,
|
||||
}
|
||||
}
|
||||
|
||||
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
||||
@@ -179,7 +239,7 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
|
||||
|
||||
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
|
||||
func storedFileNames(t *testing.T, env *testEnv) []string {
|
||||
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
|
||||
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
var names []string
|
||||
@@ -191,14 +251,14 @@ func storedFileNames(t *testing.T, env *testEnv) []string {
|
||||
|
||||
// countFiles считает записи о файлах.
|
||||
func countFiles(t *testing.T, env *testEnv) int {
|
||||
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
|
||||
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
return len(records)
|
||||
}
|
||||
|
||||
// countJobs считает заведённые задачи расшифровки.
|
||||
func countJobs(t *testing.T, env *testEnv) int {
|
||||
records, err := env.app.FindAllRecords(pbrepo.JobsCollection)
|
||||
records, err := env.app.FindAllRecords(migrations.JobsCollection)
|
||||
require.NoError(t, err)
|
||||
return len(records)
|
||||
}
|
||||
@@ -241,7 +301,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||
req := createMultipartRequest(t, "sample.m4a", content)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -304,7 +364,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, tc.req(t))
|
||||
env.serve(w, tc.req(t))
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
@@ -327,7 +387,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
||||
req := createMultipartRequest(t, "empty.m4a", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -374,7 +434,7 @@ func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
||||
req := createMultipartRequest(t, tc.fileName, []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -400,7 +460,7 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
|
||||
req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -419,7 +479,7 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
||||
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
@@ -459,7 +519,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
|
||||
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -482,7 +542,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
|
||||
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
@@ -507,7 +567,7 @@ func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) {
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -528,7 +588,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
||||
req := createMultipartRequest(t, "sample.mp3", content)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -593,7 +653,7 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
|
||||
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -622,7 +682,7 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
@@ -642,7 +702,7 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
@@ -664,7 +724,7 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
@@ -673,3 +733,27 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "Job not found", response["error"])
|
||||
}
|
||||
|
||||
// Отправитель, у которого соединение оборвалось после полной загрузки, задачу
|
||||
// всё равно получает: запись доехала целиком, а результат он заберёт позже по
|
||||
// `GET /status/{id}`. Приём на контексте запроса терял бы такую запись молча —
|
||||
// решение владельца от 2026-08-13.
|
||||
func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := createMultipartRequest(t, "sample.m4a", []byte("аудио"))
|
||||
// Так выглядит ушедший отправитель: контекст запроса отменяется сервером,
|
||||
// когда соединение закрылось.
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
|
||||
|
||||
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package tg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -8,6 +10,12 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
// Транспорт знает адаптер Telegram ровно ради единой точки чистки отказа:
|
||||
// второй экземпляр той же функции здесь был бы вторым способом делать одно
|
||||
// и то же, а секрет в журнале — необратим. Направление «транспорт не знает
|
||||
// адаптера» правилом не держится и уже нарушено HTTP-поверхностью
|
||||
// (docs/conventions/go-linters.md, «Что остаётся прозой»).
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
@@ -25,25 +33,22 @@ type TelegramController struct {
|
||||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
BotToken string
|
||||
UpdateTimeout int
|
||||
UserWhiteList []string
|
||||
}
|
||||
|
||||
// NewTelegramController принимает готового клиента, а не токен: клиента заводит
|
||||
// единая точка `internal/adapter/telegram`, и только её отказ не несёт секрета.
|
||||
// Токен сюда не приезжает вовсе — значит, и утечь отсюда ему неоткуда.
|
||||
func NewTelegramController(
|
||||
config TelegramConfig,
|
||||
bot *tgbotapi.BotAPI,
|
||||
transcribeService *service.TranscribeService,
|
||||
jobRepo contract.TranscriptJobRepository,
|
||||
logger *slog.Logger,
|
||||
) (*TelegramController, error) {
|
||||
botToken := config.BotToken
|
||||
if botToken == "" {
|
||||
return nil, &EmptyBotTokenError{}
|
||||
}
|
||||
|
||||
bot, err := tgbotapi.NewBotAPI(botToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if bot == nil {
|
||||
return nil, errors.New("telegram bot is not created")
|
||||
}
|
||||
|
||||
controller := &TelegramController{
|
||||
@@ -58,7 +63,11 @@ func NewTelegramController(
|
||||
return controller, nil
|
||||
}
|
||||
|
||||
func (c *TelegramController) Start() {
|
||||
// Start принимает контекст жизни процесса и отдаёт его каждому обработчику:
|
||||
// скачивание записи и разбор её метаданных — работа с внешним собеседником, и
|
||||
// остановка сервиса обязана до неё доходить. Приём обновлений контекстом не
|
||||
// правится: его прекращает Stop.
|
||||
func (c *TelegramController) Start(ctx context.Context) {
|
||||
c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName)
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
@@ -94,11 +103,11 @@ func (c *TelegramController) Start() {
|
||||
|
||||
// Handle audio messages and files
|
||||
if update.Message.Audio != nil {
|
||||
c.handleAudioMessage(update.Message)
|
||||
c.handleAudioMessage(ctx, update.Message)
|
||||
} else if update.Message.Voice != nil {
|
||||
c.handleVoiceMessage(update.Message)
|
||||
c.handleVoiceMessage(ctx, update.Message)
|
||||
} else if update.Message.Document != nil {
|
||||
c.handleDocumentMessage(update.Message)
|
||||
c.handleDocumentMessage(ctx, update.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,7 +157,7 @@ func (c *TelegramController) handleHelpCommand(message *tgbotapi.Message) {
|
||||
c.send(msg)
|
||||
}
|
||||
|
||||
func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
|
||||
func (c *TelegramController) handleAudioMessage(ctx context.Context, message *tgbotapi.Message) {
|
||||
// Отправляем сообщение о начале обработки
|
||||
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
|
||||
progressMsg.ReplyToMessageID = message.MessageID
|
||||
@@ -159,7 +168,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
|
||||
}
|
||||
|
||||
// Скачиваем файл
|
||||
fileReader, fileName, err := c.downloadAudioFile(message.Audio.FileID)
|
||||
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Audio.FileID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to download audio file", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
|
||||
@@ -169,7 +178,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
|
||||
defer fileReader.Close()
|
||||
|
||||
// Обрабатываем файл
|
||||
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to create transcribe job", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||
@@ -183,7 +192,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
|
||||
c.send(successMsg)
|
||||
}
|
||||
|
||||
func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
|
||||
func (c *TelegramController) handleVoiceMessage(ctx context.Context, message *tgbotapi.Message) {
|
||||
// Отправляем сообщение о начале обработки
|
||||
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...")
|
||||
progressMsg.ReplyToMessageID = message.MessageID
|
||||
@@ -194,7 +203,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
|
||||
}
|
||||
|
||||
// Скачиваем файл
|
||||
fileReader, fileName, err := c.downloadAudioFile(message.Voice.FileID)
|
||||
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Voice.FileID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to download voice file", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.")
|
||||
@@ -204,7 +213,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
|
||||
defer fileReader.Close()
|
||||
|
||||
// Обрабатываем файл
|
||||
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to create transcribe job", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||
@@ -218,7 +227,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
|
||||
c.send(successMsg)
|
||||
}
|
||||
|
||||
func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
|
||||
func (c *TelegramController) handleDocumentMessage(ctx context.Context, message *tgbotapi.Message) {
|
||||
// Проверяем, является ли документ аудиофайлом
|
||||
if !c.isAudioDocument(message.Document) {
|
||||
return
|
||||
@@ -234,7 +243,7 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
|
||||
}
|
||||
|
||||
// Скачиваем файл
|
||||
fileReader, fileName, err := c.downloadAudioFile(message.Document.FileID)
|
||||
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Document.FileID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to download document file", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
|
||||
@@ -244,7 +253,7 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
|
||||
defer fileReader.Close()
|
||||
|
||||
// Обрабатываем файл
|
||||
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
|
||||
if err != nil {
|
||||
c.logger.Error("Failed to create transcribe job", "error", err)
|
||||
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
|
||||
@@ -258,20 +267,41 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
|
||||
c.send(successMsg)
|
||||
}
|
||||
|
||||
func (c *TelegramController) downloadAudioFile(fileID string) (io.ReadCloser, string, error) {
|
||||
func (c *TelegramController) downloadAudioFile(ctx context.Context, fileID string) (io.ReadCloser, string, error) {
|
||||
// Получаем информацию о файле
|
||||
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
// Скачиваем файл
|
||||
// Скачиваем файл. Запрос заводится с контекстом: скачивание шестичасовой
|
||||
// записи иначе продолжается и после остановки сервиса, а ссылка на файл
|
||||
// несёт токен бота — держать её живой дольше нужного незачем.
|
||||
//
|
||||
// Клиент берётся у бота, а не `http.DefaultClient`: у бота он свой, и его
|
||||
// отказ уже не несёт адреса (`internal/adapter/telegram`, единая точка).
|
||||
fileURL := file.Link(c.bot.Token)
|
||||
resp, err := http.Get(fileURL)
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to build download request: %w", telegram.WithoutURL(err))
|
||||
}
|
||||
|
||||
resp, err := c.bot.Client.Do(request)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
|
||||
// Отказ выдачи файла — это не запись. Без проверки телом «записи» станет
|
||||
// JSON вида `{"ok":false,…}`: он доедет до хранилища, ляжет рабочей копией
|
||||
// и умрёт на `ffprobe`, а отправитель получит жалобу на свой файл вместо
|
||||
// правды о протухшей ссылке.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
c.logger.Error("Failed to close download response", "error", err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("failed to download file: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Получаем имя файла из URL
|
||||
fileName := file.FilePath
|
||||
if fileName == "" {
|
||||
|
||||
@@ -17,21 +17,31 @@ type Worker interface {
|
||||
Name() string
|
||||
}
|
||||
|
||||
// pollInterval — пауза между прогонами шага. Полем, а не константой по месту:
|
||||
// проверке нужен второй прогон, чтобы остановить воркер **после** того, как он
|
||||
// рассудил об исходе первого. Отменять контекст изнутри шага она не может —
|
||||
// отменённый контекст теперь и значит «нас остановили».
|
||||
const pollInterval = time.Second
|
||||
|
||||
type CallbackWorker struct {
|
||||
name string
|
||||
f func() error
|
||||
logger *slog.Logger
|
||||
name string
|
||||
// Шаг принимает контекст воркера: остановка обязана доходить до чужой
|
||||
// работы, которую шаг завёл, а не только прерывать цикл между шагами.
|
||||
f func(ctx context.Context) error
|
||||
logger *slog.Logger
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func NewCallbackWorker(name string, f func() error, logger *slog.Logger) *CallbackWorker {
|
||||
func NewCallbackWorker(name string, f func(ctx context.Context) error, logger *slog.Logger) *CallbackWorker {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &CallbackWorker{
|
||||
name: name,
|
||||
f: f,
|
||||
logger: logger,
|
||||
name: name,
|
||||
f: f,
|
||||
logger: logger,
|
||||
interval: pollInterval,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,25 +58,34 @@ func (w *CallbackWorker) Start(ctx context.Context) {
|
||||
w.logger.Info("Worker received shutdown signal", "worker", w.Name())
|
||||
return
|
||||
default:
|
||||
err := w.f()
|
||||
err := w.f(ctx)
|
||||
// Признак узнаётся по смыслу, а не по точной форме значения:
|
||||
// приведение типа видело только вершину цепочки и сломалось бы от
|
||||
// первой же обёртки `%w`, которая в проекте — умолчание.
|
||||
var noop *contract.NoopJobError
|
||||
isNoop := errors.As(err, &noop)
|
||||
if !isNoop {
|
||||
// Остановка — не отказ шага: контекст отменили мы сами. Считать её
|
||||
// в метрику и писать владельцу «Worker error» значит красить каждую
|
||||
// выкладку как поломку — по тому же доводу, по которому не считается
|
||||
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый процесс
|
||||
// отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
|
||||
stopped := err != nil && !isNoop && ctx.Err() != nil
|
||||
if !isNoop && !stopped {
|
||||
metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc()
|
||||
}
|
||||
if err != nil && !isNoop {
|
||||
if err != nil && !isNoop && !stopped {
|
||||
w.logger.Error("Worker error", "worker", w.Name(), "error", err)
|
||||
}
|
||||
if stopped {
|
||||
w.logger.Info("Worker step interrupted by shutdown", "worker", w.Name())
|
||||
}
|
||||
|
||||
// Ждем 1 секунду перед следующей итерацией
|
||||
// Ждем перед следующей итерацией
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
w.logger.Info("Worker received shutdown signal during sleep", "worker", w.Name())
|
||||
return
|
||||
case <-time.After(1 * time.Second):
|
||||
case <-time.After(w.interval):
|
||||
// Продолжаем работу
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,10 +40,13 @@ func (b *journalBuffer) String() string {
|
||||
}
|
||||
|
||||
// runOnce прогоняет воркер ровно один раз и возвращает журнал этого прогона.
|
||||
// Цикл воркера бесконечен и спит секунду между прогонами, поэтому контекст
|
||||
// отменяется сразу после первого вызова работы: ждать второго прогона нечего, а
|
||||
// секунда сна на проверку — цена ни за что.
|
||||
func runOnce(t *testing.T, name string, work func() error) string {
|
||||
//
|
||||
// Воркер останавливает **второй** прогон, а не первый: отменённый контекст
|
||||
// теперь и значит «нас остановили», и отмена изнутри первого шага сделала бы
|
||||
// его исход неотличимым от остановки — проверка судила бы не то, что заявляет.
|
||||
// Пауза между прогонами на время проверки укорочена до миллисекунды: ждать
|
||||
// секунду ради второго вызова незачем.
|
||||
func runOnce(t *testing.T, name string, work func(ctx context.Context) error) string {
|
||||
t.Helper()
|
||||
|
||||
journal := &journalBuffer{}
|
||||
@@ -54,15 +57,21 @@ func runOnce(t *testing.T, name string, work func() error) string {
|
||||
|
||||
var once sync.Once
|
||||
done := make(chan struct{})
|
||||
calls := 0
|
||||
|
||||
w := NewCallbackWorker(name, func() error {
|
||||
err := work()
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
close(done)
|
||||
})
|
||||
return err
|
||||
w := NewCallbackWorker(name, func(ctx context.Context) error {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
// Первый прогон уже рассужен: журнал написан, счётчик сдвинут.
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
close(done)
|
||||
})
|
||||
return &contract.NoopJobError{State: "stopping"}
|
||||
}
|
||||
return work(ctx)
|
||||
}, logger)
|
||||
w.interval = time.Millisecond
|
||||
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
@@ -147,7 +156,7 @@ func TestWrappedNoopIsNotAFailure(t *testing.T) {
|
||||
before := jobCount(t, name, "false")
|
||||
beforeErr := jobCount(t, name, "true")
|
||||
|
||||
journal := runOnce(t, name, func() error {
|
||||
journal := runOnce(t, name, func(context.Context) error {
|
||||
return fmt.Errorf("find and acquire job: %w", &contract.NoopJobError{State: "created"})
|
||||
})
|
||||
|
||||
@@ -171,7 +180,7 @@ func TestFailureIsLoggedAndCounted(t *testing.T) {
|
||||
|
||||
before := jobCount(t, name, "true")
|
||||
|
||||
journal := runOnce(t, name, func() error {
|
||||
journal := runOnce(t, name, func(context.Context) error {
|
||||
return errors.New("database is gone")
|
||||
})
|
||||
|
||||
@@ -191,7 +200,7 @@ func TestSuccessIsCounted(t *testing.T) {
|
||||
|
||||
before := jobCount(t, name, "false")
|
||||
|
||||
journal := runOnce(t, name, func() error {
|
||||
journal := runOnce(t, name, func(context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -202,3 +211,50 @@ func TestSuccessIsCounted(t *testing.T) {
|
||||
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
|
||||
}
|
||||
}
|
||||
|
||||
// Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой
|
||||
// развилки каждая выкладка красит журнал владельца отказами и накручивает
|
||||
// счётчик сбоев, которых не было, — тот же довод, по которому не считается
|
||||
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
|
||||
// процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
|
||||
func TestShutdownIsNotAFailure(t *testing.T) {
|
||||
const name = "stopped_worker"
|
||||
|
||||
beforeErr := jobCount(t, name, "true")
|
||||
beforeOk := jobCount(t, name, "false")
|
||||
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
w := NewCallbackWorker(name, func(context.Context) error {
|
||||
// Так выглядит шаг, которого застала остановка.
|
||||
cancel()
|
||||
return errors.New("ffmpeg conversion failed: signal: killed")
|
||||
}, logger)
|
||||
w.interval = time.Millisecond
|
||||
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
w.Start(ctx)
|
||||
close(finished)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("воркер не остановился по отмене контекста")
|
||||
}
|
||||
|
||||
if got := journal.String(); strings.Contains(got, "Worker error") {
|
||||
t.Errorf("остановка записана отказом: журнал %q", got)
|
||||
}
|
||||
if got := jobCount(t, name, "true"); got != beforeErr {
|
||||
t.Errorf("остановка засчитана отказом: было %v, стало %v", beforeErr, got)
|
||||
}
|
||||
if got := jobCount(t, name, "false"); got != beforeOk {
|
||||
t.Errorf("остановка засчитана успешным прогоном: было %v, стало %v", beforeOk, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
)
|
||||
|
||||
type TranscribeJob struct {
|
||||
@@ -52,13 +54,13 @@ func (j *TranscribeJob) MoveToState(state string) {
|
||||
// именно отказавшие: иначе задача, прошедшая конвейер целиком, накопила бы
|
||||
// их поштучно и умерла бы здоровой.
|
||||
j.Attempts = 0
|
||||
j.UpdatedAt = time.Now()
|
||||
j.UpdatedAt = clock.Now()
|
||||
}
|
||||
|
||||
func (j *TranscribeJob) MoveToStateAndDelay(state string, delay *time.Time) {
|
||||
j.MoveToState(state)
|
||||
j.DelayTime = delay
|
||||
j.UpdatedAt = time.Now()
|
||||
j.UpdatedAt = clock.Now()
|
||||
}
|
||||
|
||||
func (j *TranscribeJob) Done(transcriptionText string) {
|
||||
@@ -78,7 +80,7 @@ func (j *TranscribeJob) RetryAfter(delay time.Time) {
|
||||
j.AcquisitionID = nil
|
||||
j.AcquireTime = nil
|
||||
j.DelayTime = &delay
|
||||
j.UpdatedAt = time.Now()
|
||||
j.UpdatedAt = clock.Now()
|
||||
}
|
||||
|
||||
// Die переводит задачу, исчерпавшую попытки, в состояние «мертва». Число
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -36,7 +35,7 @@ func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.Transcr
|
||||
}
|
||||
|
||||
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
@@ -28,19 +30,19 @@ import (
|
||||
// failingConverter отказывает на каждой попытке.
|
||||
type failingConverter struct{}
|
||||
|
||||
func (c *failingConverter) Convert(string, string) error {
|
||||
func (c *failingConverter) Convert(context.Context, string, string) error {
|
||||
return errors.New("конвертация не удалась")
|
||||
}
|
||||
|
||||
type okMetaViewer struct{}
|
||||
|
||||
func (m *okMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
||||
func (m *okMetaViewer) GetInfo(context.Context, string) (*contract.AudioInfo, error) {
|
||||
return &contract.AudioInfo{Seconds: 1}, nil
|
||||
}
|
||||
|
||||
type failingMetaViewer struct{}
|
||||
|
||||
func (m *failingMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
||||
func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInfo, error) {
|
||||
return nil, errors.New("запись не читается")
|
||||
}
|
||||
|
||||
@@ -89,7 +91,7 @@ func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter
|
||||
converter,
|
||||
&recognizer.MemoryAudioRecognizer{},
|
||||
sender,
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
slog.New(slog.DiscardHandler),
|
||||
)
|
||||
|
||||
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender}
|
||||
@@ -100,7 +102,7 @@ func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
|
||||
t.Helper()
|
||||
|
||||
chatId := int64(100)
|
||||
job, err := env.service.CreateJobFromTelegram(strings.NewReader("запись"), "voice.ogg", chatId, 1)
|
||||
job, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", chatId, 1)
|
||||
require.NoError(t, err)
|
||||
return job
|
||||
}
|
||||
@@ -110,7 +112,7 @@ func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
|
||||
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
|
||||
require.NoError(t, err)
|
||||
record.Set("delay_time", "")
|
||||
require.NoError(t, env.app.Save(record))
|
||||
@@ -121,7 +123,7 @@ func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
|
||||
func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
|
||||
require.NoError(t, env.app.Save(record))
|
||||
@@ -148,7 +150,7 @@ func TestJobDiesAfterAttemptLimit(t *testing.T) {
|
||||
rotAcquisition(t, env, job.Id)
|
||||
|
||||
// Следующий захват видит перебор и хоронит задачу.
|
||||
err := env.service.FindAndRunConversionJob()
|
||||
err := env.service.FindAndRunConversionJob(t.Context())
|
||||
|
||||
var noop *contract.NoopJobError
|
||||
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
|
||||
@@ -177,9 +179,9 @@ func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
|
||||
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Error(t, env.service.FindAndRunConversionJob())
|
||||
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("state", entity.StateCreated)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
@@ -202,13 +204,13 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||
empty, err := env.fileRepo.CreateRemote("object-key", 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("file", empty.Id)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
|
||||
// Первый отказ.
|
||||
require.Error(t, env.service.FindAndRunConversionJob())
|
||||
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
@@ -219,7 +221,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||
|
||||
// Второй отказ — с той же задачи, пауза снята вручную.
|
||||
clearDelay(t, env, job.Id)
|
||||
require.Error(t, env.service.FindAndRunConversionJob())
|
||||
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
after, err = env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
@@ -246,7 +248,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
|
||||
|
||||
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
|
||||
|
||||
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3")
|
||||
require.Error(t, err, "отказ источника метаданных роняет приём")
|
||||
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
@@ -262,7 +264,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
|
||||
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3")
|
||||
require.NoError(t, err)
|
||||
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
@@ -279,7 +281,7 @@ func TestJobNeverPointsToMissingFile(t *testing.T) {
|
||||
|
||||
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
|
||||
// исходную запись, а не на несозданный результат.
|
||||
require.NoError(t, env.service.FindAndRunConversionJob())
|
||||
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
@@ -297,7 +299,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
||||
|
||||
content := strings.Repeat("запись ", 1000)
|
||||
|
||||
job, err := env.service.CreateJobFromApi(strings.NewReader(content), "sample.mp3")
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
|
||||
@@ -320,7 +322,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
||||
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job, err := env.service.CreateJobFromApi(strings.NewReader("содержимое"), "sample.mp3")
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
|
||||
@@ -354,7 +356,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
|
||||
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
|
||||
|
||||
// Конвертация отказывает — задача уходит в `failed`, копии убраны.
|
||||
require.NoError(t, env.service.FindAndRunConversionJob())
|
||||
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
@@ -32,7 +33,7 @@ type scriptedRecognizer struct {
|
||||
lastObjectKey string
|
||||
}
|
||||
|
||||
func (r *scriptedRecognizer) Recognize(file io.Reader, fileName string) (string, error) {
|
||||
func (r *scriptedRecognizer) Recognize(_ context.Context, file io.Reader, fileName string) (string, error) {
|
||||
r.recognizeCalls++
|
||||
r.lastObjectKey = fileName
|
||||
if r.recognizeErr != nil {
|
||||
@@ -45,11 +46,11 @@ func (r *scriptedRecognizer) Recognize(file io.Reader, fileName string) (string,
|
||||
return "operation-id", nil
|
||||
}
|
||||
|
||||
func (r *scriptedRecognizer) GetRecognitionText(string) (string, error) {
|
||||
func (r *scriptedRecognizer) GetRecognitionText(context.Context, string) (string, error) {
|
||||
return r.text, nil
|
||||
}
|
||||
|
||||
func (r *scriptedRecognizer) CheckRecognitionStatus(string) (*entity.RecognitionResult, error) {
|
||||
func (r *scriptedRecognizer) CheckRecognitionStatus(context.Context, string) (*entity.RecognitionResult, error) {
|
||||
return r.result, nil
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) {
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeJob())
|
||||
require.NoError(t, svc.FindAndRunTranscribeJob(t.Context()))
|
||||
|
||||
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
|
||||
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
|
||||
@@ -119,7 +120,7 @@ func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
|
||||
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
require.Error(t, svc.FindAndRunTranscribeJob())
|
||||
require.Error(t, svc.FindAndRunTranscribeJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
@@ -134,7 +135,7 @@ func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognize
|
||||
t.Helper()
|
||||
|
||||
job := convertedJob(t, env)
|
||||
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob())
|
||||
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob(t.Context()))
|
||||
|
||||
clearDelay(t, env, job.Id)
|
||||
return job
|
||||
@@ -150,7 +151,7 @@ func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
@@ -174,7 +175,7 @@ func TestCheckJobFailsJobAndTellsSender(t *testing.T) {
|
||||
rec.result = entity.NewFailedResult("операция отклонена")
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
@@ -196,7 +197,7 @@ func TestCheckJobCompletesAndAnswersOnce(t *testing.T) {
|
||||
rec.text = "расшифровка записи"
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
@@ -224,7 +225,7 @@ func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) {
|
||||
rec.text = ""
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
@@ -249,13 +250,13 @@ func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) {
|
||||
acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := env.app.FindRecordById(pocketbase.JobsCollection, job.Id)
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquisition_id", "someone-else")
|
||||
require.NoError(t, env.app.Save(record))
|
||||
|
||||
svc := withRecognizer(env, rec)
|
||||
err = svc.checkTranscribeJob(acquired, "mine")
|
||||
err = svc.checkTranscribeJob(t.Context(), acquired, "mine")
|
||||
|
||||
var lost *contract.LostAcquisitionError
|
||||
require.ErrorAs(t, err, &lost)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -73,7 +76,7 @@ func NewTranscribeService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) {
|
||||
func (s *TranscribeService) CreateJobFromTelegram(ctx context.Context, file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) {
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceTelegram,
|
||||
@@ -81,19 +84,19 @@ func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName strin
|
||||
TgReplyMessageId: &replyMsgId,
|
||||
}
|
||||
|
||||
return s.createTranscribeJob(job, file, fileName)
|
||||
return s.createTranscribeJob(ctx, job, file, fileName)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) CreateJobFromApi(file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||
func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceApi,
|
||||
}
|
||||
|
||||
return s.createTranscribeJob(job, file, fileName)
|
||||
return s.createTranscribeJob(ctx, job, file, fileName)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||
func (s *TranscribeService) createTranscribeJob(ctx context.Context, job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||
// Определяем расширение файла
|
||||
ext := filepath.Ext(fileName)
|
||||
if ext == "" {
|
||||
@@ -120,7 +123,7 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
|
||||
// строка журнала вместе с идентификатором записи собрала бы её целиком.
|
||||
s.logger.Info("Creating transcribe job", "file_ext", ext)
|
||||
|
||||
info, err := s.metaviewer.GetInfo(work.Path())
|
||||
info, err := s.metaviewer.GetInfo(ctx, work.Path())
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get file info", "error", err, "file_ext", ext)
|
||||
return nil, err
|
||||
@@ -158,28 +161,41 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) FindAndRunConversionJob() error {
|
||||
return s.runStep(entity.StateCreated, conversionAcquireTimeout, s.convertJob)
|
||||
func (s *TranscribeService) FindAndRunConversionJob(ctx context.Context) error {
|
||||
return s.runStep(ctx, entity.StateCreated, conversionAcquireTimeout, s.convertJob)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
||||
return s.runStep(entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob)
|
||||
func (s *TranscribeService) FindAndRunTranscribeJob(ctx context.Context) error {
|
||||
return s.runStep(ctx, entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
||||
return s.runStep(entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob)
|
||||
func (s *TranscribeService) FindAndRunTranscribeCheckJob(ctx context.Context) error {
|
||||
return s.runStep(ctx, entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob)
|
||||
}
|
||||
|
||||
// runStep забирает задачу и отдаёт её шагу. Отказ шага не оставляет задачу
|
||||
// захваченной до конца срока: захват снимается, и задача ждёт нарастающую паузу
|
||||
// — иначе повтор наступал бы через восемь часов, а не через секунду.
|
||||
func (s *TranscribeService) runStep(state string, expiration time.Duration, step func(job *entity.TranscribeJob, holder string) error) error {
|
||||
//
|
||||
// Контекст доходит до шага, а через него — до внешнего собеседника: остановка
|
||||
// сервиса убивает `ffmpeg` и обрывает запрос к распознаванию. Прерванный шаг
|
||||
// приговора не выносит: задача остаётся пригодной к повтору, попытки не тратит
|
||||
// и отправителю о несуществующем сбое не сообщает — исход остановки отличается
|
||||
// от исхода отказа на каждом шаге.
|
||||
func (s *TranscribeService) runStep(ctx context.Context, state string, expiration time.Duration, step func(ctx context.Context, job *entity.TranscribeJob, holder string) error) error {
|
||||
// Нас уже остановили — задачу не забираем: захват стоил бы ей попытки, а
|
||||
// работы всё равно не будет. Исход «шаг не сделал ничего» — это `NoopJobError`
|
||||
// по смыслу, и он же не поднимает уровень и не считается в метрику.
|
||||
if ctx.Err() != nil {
|
||||
return &contract.NoopJobError{State: state}
|
||||
}
|
||||
|
||||
job, holder, err := s.findJob(state, expiration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := step(job, holder); err != nil {
|
||||
if err := step(ctx, job, holder); err != nil {
|
||||
s.scheduleRetry(job, holder, err)
|
||||
return err
|
||||
}
|
||||
@@ -187,7 +203,7 @@ func (s *TranscribeService) runStep(state string, expiration time.Duration, step
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string) error {
|
||||
func (s *TranscribeService) convertJob(ctx context.Context, job *entity.TranscribeJob, holder string) error {
|
||||
s.logger.Info("Starting conversion job", "job_id", job.Id)
|
||||
|
||||
if job.FileID == nil {
|
||||
@@ -224,14 +240,28 @@ func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string)
|
||||
s.logger.Info("Converting file", "job_id", job.Id, "src_format", srcExt)
|
||||
|
||||
// Измеряем время конвертации
|
||||
startTime := time.Now()
|
||||
err = s.converter.Convert(src.Path(), dest.Path())
|
||||
startTime := clock.Start()
|
||||
err = s.converter.Convert(ctx, src.Path(), dest.Path())
|
||||
conversionDuration := time.Since(startTime)
|
||||
|
||||
// Записываем метрику времени конвертации
|
||||
metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds())
|
||||
|
||||
if err != nil {
|
||||
// Остановка сервиса — не приговор записи. Убитый по контексту `ffmpeg`
|
||||
// отдаёт `signal: killed`, и от настоящего отказа конвертации
|
||||
// (`exit status N`) эта ошибка неотличима ни типом, ни `errors.Is`:
|
||||
// различает их только контекст шага. Без этой развилки каждый деплой
|
||||
// хоронил бы конвертируемую запись в `failed` — состояние терминальное,
|
||||
// и вернуть её оттуда может только владелец правкой в панели, — да ещё
|
||||
// и сообщал бы отправителю о сбое, которого не было.
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
s.logger.Info("File conversion interrupted by shutdown",
|
||||
"job_id", job.Id,
|
||||
"duration", conversionDuration)
|
||||
return fmt.Errorf("conversion interrupted: %w", ctxErr)
|
||||
}
|
||||
|
||||
s.logger.Error("File conversion failed",
|
||||
"error", err,
|
||||
"job_id", job.Id,
|
||||
@@ -274,7 +304,7 @@ func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder string) error {
|
||||
func (s *TranscribeService) transcribeJob(ctx context.Context, job *entity.TranscribeJob, holder string) error {
|
||||
s.logger.Info("Starting transcribe job", "job_id", job.Id)
|
||||
|
||||
if job.FileID == nil {
|
||||
@@ -302,8 +332,12 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
|
||||
s.logger.Info("Starting recognition", "job_id", job.Id, "file_id", *job.FileID)
|
||||
|
||||
// Запускаем асинхронное распознавание
|
||||
operationID, err := s.recognizer.Recognize(content, fileRecord.FileName)
|
||||
operationID, err := s.recognizer.Recognize(ctx, content, fileRecord.FileName)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
s.logger.Info("Recognition interrupted by shutdown", "job_id", job.Id)
|
||||
return fmt.Errorf("recognition interrupted: %w", ctxErr)
|
||||
}
|
||||
s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
}
|
||||
@@ -321,7 +355,7 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
|
||||
// Обновляем задачу с ID операции распознавания
|
||||
job.FileID = &destFileRecord.Id
|
||||
job.RecognitionOpID = &operationID
|
||||
delayTime := time.Now().Add(firstCheckDelay)
|
||||
delayTime := clock.Now().Add(firstCheckDelay)
|
||||
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
||||
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
@@ -333,18 +367,22 @@ func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder stri
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder string) error {
|
||||
func (s *TranscribeService) checkTranscribeJob(ctx context.Context, job *entity.TranscribeJob, holder string) error {
|
||||
if job.RecognitionOpID == nil {
|
||||
s.logger.Error("Recognition operation ID not found", "job_id", job.Id)
|
||||
return fmt.Errorf("recogniton opId not found for job: %s", job.Id)
|
||||
return fmt.Errorf("recognition opId not found for job: %s", job.Id)
|
||||
}
|
||||
|
||||
opId := *job.RecognitionOpID
|
||||
|
||||
// Проверяем статус операции
|
||||
s.logger.Info("Checking operation status", "job_id", job.Id, "operation_id", opId)
|
||||
recResult, err := s.recognizer.CheckRecognitionStatus(opId)
|
||||
recResult, err := s.recognizer.CheckRecognitionStatus(ctx, opId)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
s.logger.Info("Status check interrupted by shutdown", "job_id", job.Id)
|
||||
return fmt.Errorf("status check interrupted: %w", ctxErr)
|
||||
}
|
||||
s.logger.Error("Failed to check recognition status", "error", err, "operation_id", opId)
|
||||
return err
|
||||
}
|
||||
@@ -354,7 +392,7 @@ func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder
|
||||
// здесь своя, числом, а число попыток обнуляется переходом: ожидание
|
||||
// чужой операции попытку не тратит.
|
||||
s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId)
|
||||
delayTime := time.Now().Add(nextCheckDelay)
|
||||
delayTime := clock.Now().Add(nextCheckDelay)
|
||||
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||
@@ -373,8 +411,12 @@ func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder
|
||||
}
|
||||
|
||||
// Операция завершена, получаем результат
|
||||
transcriptionText, err := s.recognizer.GetRecognitionText(opId)
|
||||
transcriptionText, err := s.recognizer.GetRecognitionText(ctx, opId)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
s.logger.Info("Text fetch interrupted by shutdown", "job_id", job.Id)
|
||||
return fmt.Errorf("text fetch interrupted: %w", ctxErr)
|
||||
}
|
||||
s.logger.Error("Failed to get recognition text", "error", err, "operation_id", opId)
|
||||
return err
|
||||
}
|
||||
@@ -397,7 +439,7 @@ func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder
|
||||
// переводят в «мертва» и сообщают об этом отправителю.
|
||||
func (s *TranscribeService) findJob(state string, expiration time.Duration) (*entity.TranscribeJob, string, error) {
|
||||
acquisitionId := uuid.NewString()
|
||||
rottingTime := time.Now().Add(-1 * expiration)
|
||||
rottingTime := clock.Now().Add(-1 * expiration)
|
||||
|
||||
job, err := s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime)
|
||||
if err != nil {
|
||||
@@ -448,7 +490,17 @@ func (s *TranscribeService) scheduleRetry(job *entity.TranscribeJob, holder stri
|
||||
return
|
||||
}
|
||||
|
||||
job.RetryAfter(time.Now().Add(retryDelay(job.Attempts)))
|
||||
// Остановка попытки не тратит: задача не виновата в том, что нас
|
||||
// перезапустили. Счётчик растёт при захвате, поэтому здесь его возвращают
|
||||
// назад — иначе пять выкладок подряд уводят живую запись в «мертва» с
|
||||
// приговором «попытки исчерпаны».
|
||||
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) {
|
||||
if job.Attempts > 0 {
|
||||
job.Attempts--
|
||||
}
|
||||
}
|
||||
|
||||
job.RetryAfter(clock.Now().Add(retryDelay(job.Attempts)))
|
||||
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
var lostOnSave *contract.LostAcquisitionError
|
||||
|
||||
+31
-1
@@ -1,11 +1,41 @@
|
||||
# Refer for explanation to following link:
|
||||
# https://lefthook.dev/configuration/
|
||||
#
|
||||
# Предкоммитные проверки — дешёвая часть гейта на **затронутых файлах**. Полный
|
||||
# набор здесь не гоняется намеренно: он идёт минуты, а pre-commit обязан быть
|
||||
# быстрым. Что ловит pre-commit и что остаётся только гейту — CLAUDE.md,
|
||||
# раздел «Гейт»; перечень правил и их дома — docs/conventions/go-linters.md.
|
||||
|
||||
templates:
|
||||
av-hooks-dir: "/home/av/projects/private/git-hooks"
|
||||
|
||||
pre-commit:
|
||||
jobs:
|
||||
|
||||
# Форматирование правится на месте и добавляется в коммит: спорить тут не о
|
||||
# чем, а гейт на неотформатированном файле краснеет.
|
||||
- name: "gofmt"
|
||||
glob: "*.go"
|
||||
run: "gofmt -w {staged_files}"
|
||||
stage_fixed: true
|
||||
|
||||
# Линтеры гоняются по пакетам затронутых файлов, а не по всему дереву:
|
||||
# golangci-lint принимает файлы только из одного каталога, поэтому на вход
|
||||
# идут каталоги.
|
||||
- name: "golangci-lint"
|
||||
glob: "*.go"
|
||||
run: |
|
||||
dirs=$(printf '%s\n' {staged_files} | xargs -r -n1 dirname | sort -u)
|
||||
golangci-lint run $dirs
|
||||
|
||||
- name: "shellcheck"
|
||||
glob: "*.sh"
|
||||
run: "shellcheck {staged_files}"
|
||||
|
||||
# Подавления те же, что у шага гейта, и по тем же причинам — Taskfile.yml,
|
||||
# задача `dockerfile`.
|
||||
- name: "hadolint"
|
||||
glob: "Dockerfile"
|
||||
run: "hadolint --ignore DL3007 --ignore DL3018 {staged_files}"
|
||||
|
||||
- name: "gitleaks"
|
||||
run: "gitleaks git --staged"
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
||||
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
|
||||
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||
@@ -27,6 +28,8 @@ import (
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -50,6 +53,13 @@ func main() {
|
||||
logger.Info("Configuration loaded successfully", "config_path", *configPath)
|
||||
}
|
||||
|
||||
// Незаполненный вход роняет старт: подняться с молча выключенным входом
|
||||
// значит остаться открытым наружу, и узнать об этом было бы неоткуда.
|
||||
if err := cfg.Auth.Validate(); err != nil {
|
||||
logger.Error("Unable to start with incomplete login settings", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Загружаем переменные окружения из .env файла
|
||||
if err := godotenv.Load(); err != nil {
|
||||
logger.Warn("Warning: .env file not found, using system environment variables")
|
||||
@@ -127,13 +137,13 @@ func main() {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
tgConfig := tgcontroller.TelegramConfig{
|
||||
BotToken: cfg.Telegram.BotToken,
|
||||
UpdateTimeout: cfg.Telegram.UpdateTimeout,
|
||||
UserWhiteList: cfg.Server.UsersWhiteList,
|
||||
}
|
||||
|
||||
// Создаем Telegram бот
|
||||
tgController, err := tgcontroller.NewTelegramController(tgConfig, transcribeService, jobRepo, logger)
|
||||
// Клиента бота заводит единая точка: её отказ не несёт токена, тогда как
|
||||
// отказ `NewBotAPI` несёт — он ходит за `getMe`.
|
||||
tgController, err := newTelegramController(cfg.Telegram.BotToken, tgConfig, transcribeService, jobRepo, logger)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Telegram controller", "error", err)
|
||||
// Не останавливаем приложение, если Telegram бот не создан
|
||||
@@ -143,7 +153,7 @@ func main() {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
logger.Info("Starting Telegram bot")
|
||||
tgController.Start()
|
||||
tgController.Start(ctx)
|
||||
logger.Info("Telegram bot stopped gracefully")
|
||||
}()
|
||||
}
|
||||
@@ -172,6 +182,12 @@ func main() {
|
||||
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
||||
// и второму серверу на нём взяться неоткуда.
|
||||
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
|
||||
authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{
|
||||
AuthURL: cfg.Auth.AuthURL,
|
||||
RedirectURL: cfg.Auth.RedirectURL,
|
||||
ClientID: cfg.Auth.ClientID,
|
||||
SecureCookie: cfg.Auth.SecureCookie,
|
||||
}, logger)
|
||||
|
||||
// Сервер приезжает каналом, а не общей переменной: хук исполняется в
|
||||
// горутине сервера, а читает его горутина остановки, и связи «произошло
|
||||
@@ -188,7 +204,7 @@ func main() {
|
||||
// `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
|
||||
// журнале контейнера не видно. Поля — те, что просит конвенция.
|
||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||
start := time.Now()
|
||||
start := clock.Start()
|
||||
err := e.Next()
|
||||
|
||||
level := slog.LevelInfo
|
||||
@@ -207,6 +223,20 @@ func main() {
|
||||
return err
|
||||
})
|
||||
|
||||
// Настройки провайдера приводятся к конфигу при каждом подъёме:
|
||||
// применённый шаг схемы не переписывается, и секрет, положенный
|
||||
// однажды шагом, не пережил бы ротации.
|
||||
if err := pbrepo.ApplyProviderSettings(storage, pbrepo.ProviderSettings{
|
||||
AuthURL: cfg.Auth.AuthURL,
|
||||
TokenURL: cfg.Auth.TokenURL,
|
||||
UserInfoURL: cfg.Auth.UserInfoURL,
|
||||
ClientID: cfg.Auth.ClientID,
|
||||
ClientSecret: cfg.Auth.ClientSecret,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to apply provider settings: %w", err)
|
||||
}
|
||||
|
||||
authHandler.Register(se.Router)
|
||||
transcribeHandler.Register(se.Router)
|
||||
|
||||
se.Router.GET("/health", func(e *core.RequestEvent) error {
|
||||
@@ -298,3 +328,21 @@ func main() {
|
||||
|
||||
logger.Info("Transcriber service stopped")
|
||||
}
|
||||
|
||||
// newTelegramController собирает бота и транспорт вокруг него. Токен доходит
|
||||
// до единой точки `internal/adapter/telegram` и дальше не идёт: транспорт его
|
||||
// не видит вовсе, а отказ, который увидит журнал, адреса с токеном не несёт.
|
||||
func newTelegramController(
|
||||
botToken string,
|
||||
cfg tgcontroller.TelegramConfig,
|
||||
transcribeService *service.TranscribeService,
|
||||
jobRepo contract.TranscriptJobRepository,
|
||||
logger *slog.Logger,
|
||||
) (*tgcontroller.TelegramController, error) {
|
||||
bot, err := telegram.NewBot(botToken, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tgcontroller.NewTelegramController(cfg, bot, transcribeService, jobRepo, logger)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-12
|
||||
@@ -0,0 +1,230 @@
|
||||
## Context
|
||||
|
||||
Версия Go названа в проекте четырежды, и сегодня четыре места расходятся:
|
||||
`go.mod` требует `go 1.25.0`, `Dockerfile` собирает на `golang:1.25-alpine`,
|
||||
`CLAUDE.md` обещает «Go 1.25», а `README.md` не называет версию вовсе. На машине
|
||||
разработки стоит `go1.26.5`.
|
||||
|
||||
Число 1.25 никем не назначалось: `go mod tidy` поднял требование модуля,
|
||||
следуя за PocketBase, а образ подтянули следом. Ровно этот же механизм 2026-08-12
|
||||
породил дефект — требование модуля уехало на 1.25, `Dockerfile` остался на
|
||||
`golang:1.24-alpine` с `GOTOOLCHAIN=local`, и образ перестал собираться. Восемь
|
||||
шагов набора проверок и шесть проходов ревью показали зелёное: `go build ./...`
|
||||
идёт на хостовом Go, а образ не собирает ни один шаг. Случай записан в
|
||||
`docs/review.md` за 2026-08-12 и там же назван способ починки — сравнение строк
|
||||
вместо сборки образа.
|
||||
|
||||
Ограничения, в которых работаем: набор проверок обязан оставаться дешёвым и
|
||||
работать без сети и без docker; выкладку это изменение не запускает; сборку
|
||||
образа в набор проверок не заводим — отказ записан.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- одно число версии Go во всех четырёх местах;
|
||||
- шаг набора проверок, который краснеет на расхождении и называет оба числа;
|
||||
- шаг стоит доли секунды и не зависит ни от docker, ни от сети.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- сборка образа шагом набора проверок — дорого, отказ записан в
|
||||
`docs/review.md`;
|
||||
- проверка того, что объявленная версия вообще существует в реестре образов, —
|
||||
это требует сети;
|
||||
- сверка версий прочих инструментов (`golangci-lint`, `task`, `ffmpeg`) — их
|
||||
расхождение так не ломает, и заводить перечень впрок незачем;
|
||||
- `shellcheck` шагом набора проверок. Замер: shell-скриптов в гейте один, три
|
||||
соседних шага — Python в плагинах, линтер к ним неприменим; цена шага — одна
|
||||
строка плюс подавление ложного `SC1007` на идиому `CDPATH= cd`. Отказ всё равно
|
||||
осознанный, но цену называем настоящую, а не «их четыре»;
|
||||
- автоматическая правка разошедшихся мест — шаг набора проверок судит, а не
|
||||
чинит.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Решение 1: версия 1.26, а число выбирает человек
|
||||
|
||||
Берём 1.26 — она стоит на машине разработки (`go1.26.5`), образ
|
||||
`golang:1.26-alpine` в реестре есть, последний релиз тоже `go1.26.5`, а
|
||||
`CGO_ENABLED=0 go build ./...` на ней уже проходит. Требование PocketBase v0.39.10
|
||||
(`go 1.25.0`) она выполняет.
|
||||
|
||||
В `go.mod` пишем `go 1.26.0`, а не `1.26.5`: требование модуля — это нижняя
|
||||
граница, и привязывать её к патчу значит без нужды отсекать сборку на более
|
||||
раннем патче той же минорной версии.
|
||||
|
||||
**Число называет человек, и нормой это не записано намеренно.** `go mod tidy`
|
||||
поднимает требование модуля сам, следуя за зависимостью, и подъём, никем не
|
||||
назначенный, дал сегодняшнее расхождение. Но «выбрал человек» ненаблюдаемо:
|
||||
директива, поднятая инструментом, и директива, назначенная решением, выглядят
|
||||
одинаково, а норма, которую нечем уронить, расходится с кодом молча. Поэтому
|
||||
здесь мотив, а в спеке — то, что проверяется: сборка и тесты на объявленном
|
||||
числе до мерджа.
|
||||
|
||||
**Отвергнуто: остаться на 1.25 и завести только сверку.** Сверка — половина
|
||||
задачи, и она бы прижилась; но тогда сегодняшнее число остаётся тем, которое
|
||||
никто не назначал, и первый же `go mod tidy` следующей зависимости повторит
|
||||
подъём вслепую. Задача собрана из двух половин именно поэтому.
|
||||
|
||||
**Отвергнуто: `toolchain` в `go.mod` вместо подъёма `go`.** Директива
|
||||
`toolchain` заставила бы Go скачивать нужный тулчейн сам, и расхождение с
|
||||
образом перестало бы ломать сборку. Но она же превращает сборку образа в
|
||||
сетевую операцию, а сборочный слой качает тулчейн при каждой сборке. Дороже и
|
||||
менее предсказуемо, чем строка сравнения.
|
||||
|
||||
### Решение 2: новая capability `toolchain`
|
||||
|
||||
Дельта-спека ложится в новую capability `toolchain` — «каким инструментом и какой
|
||||
его версии собирается сервис, и что об этом проверяется до выкладки».
|
||||
|
||||
**Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и
|
||||
захват задачи — поведение работающего сервиса. Версия сборщика с ним не меняется
|
||||
вместе, а правило гранулярности в `openspec/config.yaml` именно про это: «дробить,
|
||||
когда в одной спеке смешиваются разные заботы».
|
||||
|
||||
**Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое
|
||||
требование — «расхождение роняет набор проверок», — и требование без дома
|
||||
проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже
|
||||
один раз жило в трёх документах и во всех трёх было неверным.
|
||||
|
||||
**Отвергнуто имя `build`.** Первая редакция называла capability `build`, и на
|
||||
разметке выяснилось, что читать её нельзя: настройка среды разработчика
|
||||
запрещает чтение любого каталога с этим именем. Спека, недоступная проходам
|
||||
ревью, не проверяется ни одним из них, а после архивации осталась бы слепым
|
||||
пятном насовсем. Имя `toolchain` точнее и по существу: предмет здесь —
|
||||
инструмент сборки и его версия, а не сборка как процесс.
|
||||
|
||||
Признаём натяжение: три существующие capability описывают поведение сервиса для
|
||||
его потребителей, а `toolchain` описывает поведение инструмента разработки.
|
||||
Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml`
|
||||
говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно
|
||||
как домен он здесь и назван. Если capability так и останется с одним
|
||||
требованием, дешевле будет переименовать её, чем расщепить
|
||||
(`RENAMED Requirements`).
|
||||
|
||||
### Решение 3: шаг сверяет все четыре места, а не два
|
||||
|
||||
Минимум по критерию приёмки — `go.mod` против `Dockerfile`. Берём шире: плюс
|
||||
`CLAUDE.md` и `README.md`.
|
||||
|
||||
Причина прямо из дефекта 2026-08-12: **три места из четырёх говорили одно и то
|
||||
же, и неверными были именно они.** Пару `go.mod`↔`Dockerfile` парная сверка
|
||||
тогда поймала бы — та пара как раз разошлась. Чего она не ловит, так это
|
||||
документа, разошедшегося с **согласованным** кодом: сойдись тогда `go.mod` с
|
||||
образом на 1.24, и памятка с README продолжали бы врать молча, а гейт оставался
|
||||
бы зелёным. Сегодня проект ровно в этом состоянии наполовину: `README.md` не
|
||||
называет версию вовсе, а `CLAUDE.md` проверяется только тем, что кто-то её
|
||||
прочтёт.
|
||||
|
||||
Цена: строку о версии придётся держать в форме, которую находит машина. Это же и
|
||||
польза — документ, чья строка перестала находиться, краснеет вместо того, чтобы
|
||||
молча протухнуть.
|
||||
|
||||
**Отвергнуто: сверять только `go.mod` и `Dockerfile`.** Дешевле на три строки
|
||||
скрипта и не ловит половину прошлого дефекта.
|
||||
|
||||
**Отвергнуто: сверять ещё и `config.dist.toml`, `docker/entrypoint.sh` и
|
||||
Ansible-роль в `pet-project-server`.** Версии Go там нет; чужой репозиторий этому
|
||||
набору проверок недоступен.
|
||||
|
||||
### Решение 4: отдельный скрипт в репозитории, а не строка в `Taskfile.yml`
|
||||
|
||||
Шаг живёт файлом `scripts/check-go-version.sh`, а `Taskfile.yml` его зовёт.
|
||||
|
||||
Сверка четырёх мест — это четыре разных способа достать число (директива
|
||||
модуля, тег образа, проза памятки, проза README), сравнение и внятное сообщение
|
||||
со всеми четырьмя. В `Taskfile.yml` это легло бы двадцатью строками shell внутри
|
||||
YAML, где их не читает ни редактор, ни `shellcheck`, а кавычки экранируются
|
||||
дважды. Соседние шаги (`docs`, `tasks`, `openspec`) уже зовут скрипты, и эта
|
||||
форма для набора проверок родная.
|
||||
|
||||
Скрипт лежит в репозитории, а не в плагине: он про этот проект, а не про метод
|
||||
работы.
|
||||
|
||||
Оговорка о выигрыше: `shellcheck` шагом набора проверок этим изменением **не**
|
||||
заводится, и обоснование выше стоит на том, что файл хотя бы **можно** проверить
|
||||
и прочитать глазами, а не на том, что его кто-то проверяет машиной. Заводить
|
||||
линтер оболочки — отдельная работа, и она уезжает урожаем.
|
||||
|
||||
**Отвергнуто: строка shell прямо в `Taskfile.yml`.** Дешевле на один файл,
|
||||
дороже при первом же изменении: правка регулярного выражения в YAML-скаляре
|
||||
ошибается молча.
|
||||
|
||||
**Отвергнуто: написать проверку на Go отдельной командой.** Тогда она попадает в
|
||||
`go build ./...` и `go vet ./...`, а вместе с ней — разбор `Dockerfile` в коде
|
||||
сервиса. Проверка о проекте не должна ехать в бинарник сервиса.
|
||||
|
||||
### Решение 5: форма строки, которую ищет машина
|
||||
|
||||
| Место | Что ищем | Правило множественности |
|
||||
| --- | --- | --- |
|
||||
| `go.mod` | единственная строка, начинающаяся с `go ` — первые два числа | директива `toolchain` запрещена: она пятое место |
|
||||
| `Dockerfile` | тег `golang:<мажор>.<минор>[.<патч>][-<база>]`, берём первые два числа | все вхождения `FROM golang:` обязаны давать одно число |
|
||||
| `CLAUDE.md` | образец `Go <мажор>.<минор>` в разделе `## Стек` | ровно одно вхождение в разделе; вне раздела число не читается |
|
||||
| `README.md` | образец `Go <мажор>.<минор>` в разделе `## Технологии` | ровно одно вхождение в разделе; вне раздела число не читается |
|
||||
|
||||
Патч и база образа из тега отбрасываются: спека объявила их свободными, и
|
||||
образец обязан это допускать — иначе `golang:1.26.5-alpine` уронил бы набор
|
||||
проверок на дереве, которое та же спека называет верным.
|
||||
|
||||
Правило множественности заведено не впрок: реализация, молча берущая первое
|
||||
совпадение, судила бы по обновлённой строке и не видела протухшей соседней.
|
||||
|
||||
**Рамка у него — раздел, а не файл, и это правка по находке ревью.** Первая
|
||||
редакция считала вхождения по всему файлу, и на памятке это давало
|
||||
гарантированный ложный красный: она по устройству ведёт историю закрытых долгов
|
||||
(«Два прежних долга закрыты и здесь названы»), и первая же правдивая строка о
|
||||
прошлой версии уронила бы шаг — сообщением, которое толкает чинить не шаг, а
|
||||
исторический документ. Тем же ловился бы любой пример команды в README. Решение
|
||||
человека на чекпоинте: считать по разделу стека, за его пределами число не
|
||||
читать.
|
||||
|
||||
Граница раздела при этом определена явно — следующий заголовок того же или более
|
||||
высокого уровня, и заголовок внутри блока кода за заголовок не считается.
|
||||
Неопределённая граница давала бы **ложное зелёное**: пример в чужом разделе
|
||||
открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда
|
||||
его читать не велено.
|
||||
|
||||
Число не нашлось — это отказ с названным местом, а не «нечего сравнивать»:
|
||||
пропавшая строка иначе выглядела бы как совпадение.
|
||||
|
||||
### Решение 6: шаг судит по репозиторию, а не по машине
|
||||
|
||||
Число берётся чтением файлов. `go` шаг не зовёт вовсе — ни `go mod edit -json`,
|
||||
ни `go list -m`, ни `go env`.
|
||||
|
||||
Способ это не самый удобный: разбор директивы через `go mod edit -json` короче и
|
||||
надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой
|
||||
`GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN` `go`
|
||||
вправе полезть в сеть за нужной версией — то есть требование «без сети»
|
||||
перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а не
|
||||
от коммита, — ровно та подмена, которая держала дефект 2026-08-12 невидимым:
|
||||
`go build ./...` шёл на хостовом Go и потому был зелёным, пока образ не
|
||||
собирался.
|
||||
|
||||
Отсюда же и словарь кодов выхода: 0 сошлось, 1 расхождение, 2 ошибка
|
||||
употребления, 3 окружение. Свой словарь заводить нельзя — раздел «Гейт» в
|
||||
`CLAUDE.md` объявляет его общим для проверочных шагов, и четвёртый шаг с
|
||||
собственной семантикой сделал бы это утверждение неверным.
|
||||
|
||||
Оболочка — POSIX `sh`, без GNU-only флагов (`grep -P`, `sed -E` с
|
||||
расширениями, `mapfile`). Пути шаг строит от корня репозитория, а не от текущего
|
||||
каталога: иначе его исход зависел бы от того, откуда он запущен.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Скрипт ищет число прозой документа, и переписанная строка сломает шаг** →
|
||||
сообщение отказа называет место, где число не нашлось, поэтому чинится
|
||||
однозначно и сразу. Ложное зелёное здесь невозможно по построению: не нашлось
|
||||
— отказ.
|
||||
- **Четыре места вместо двух — четыре места, которые надо править при подъёме
|
||||
версии** → это цена решения 3, и она осознанная: молчаливо врущий документ
|
||||
дороже одной лишней правки.
|
||||
- **1.26 может оказаться несовместимой с зависимостью, которую мы ещё не
|
||||
трогали** → проверяется до мерджа: `task image` собирает образ на объявленной
|
||||
версии, а `go test ./...` идёт на хостовом go1.26.5. Обе проверки в критериях
|
||||
приёмки.
|
||||
- **Шаг проверяет согласованность чисел, но не то, что образ собирается** →
|
||||
осознанный остаток. Собранный образ по-прежнему видит только тот, кто позвал
|
||||
`task image` руками; сверка ловит класс расхождений, а не все отказы сборки.
|
||||
@@ -0,0 +1,51 @@
|
||||
## Why
|
||||
|
||||
Сервис собирается тремя разными числами версии Go сразу: модуль требует одно,
|
||||
сборочный образ берёт другое, памятка обещает третье, а README не называет
|
||||
никакого. Расхождение этих чисел никто не проверяет, и один раз оно уже уехало в
|
||||
работу: 2026-08-12 образ не собирался вовсе, а полный набор проверок и шесть
|
||||
проходов ревью показали зелёное. Поймали случайно, руками. Пока сравнения нет,
|
||||
то же самое повторится на следующем подъёме версии — а замечено будет в момент
|
||||
выкладки, когда чинить дороже всего.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Версия Go, на которой собирается сервис, поднимается до 1.26 и называется
|
||||
**одним и тем же числом** в четырёх местах: требование модуля, сборочный
|
||||
образ, памятка разработчику и README. Сегодня README не называет его вовсе —
|
||||
строка заводится.
|
||||
- В набор проверок добавляется шаг, который сравнивает объявленные числа между
|
||||
собой и краснеет, называя все четыре места и число каждого. Шаг сравнивает
|
||||
строки: он не собирает образ, не ходит в сеть, не требует docker и не
|
||||
спрашивает, что за Go установлен на машине, — судит он по тому, что лежит в
|
||||
репозитории.
|
||||
- Собирать образ проверкой мы по-прежнему **не** будем — это дорого, и отказ
|
||||
осознанный. Сравнение строк ловит тот же класс расхождений за доли секунды.
|
||||
- Разница в третьем числе версии между модулем и образом остаётся законной:
|
||||
сравниваются только первые два.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `toolchain`: каким инструментом и какой его версии собирается сервис, и что об
|
||||
этом проверяется до выкладки. Первое требование capability — согласованность
|
||||
объявленной версии инструмента сборки и её проверка набором проверок.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
Нет. Поведение сервиса для его потребителей не меняется: запись принимается,
|
||||
расшифровывается и хранится ровно как прежде.
|
||||
|
||||
## Impact
|
||||
|
||||
- требование версии в `go.mod`;
|
||||
- сборочный слой `Dockerfile`;
|
||||
- строка о версии в `CLAUDE.md` и в `README.md`;
|
||||
- набор шагов `task gate` в `Taskfile.yml` и описание семантики набора проверок
|
||||
в `CLAUDE.md`;
|
||||
- запись журнала дефектов `docs/review.md` за 2026-08-12 получает починку,
|
||||
которую обещала.
|
||||
|
||||
Выкладка этим изменением не запускается. Внешних зависимостей изменение не
|
||||
трогает: PocketBase требует не ниже 1.25, и 1.26 это требование выполняет.
|
||||
@@ -0,0 +1,620 @@
|
||||
# Ревью кода: go-1-26-upgrade — триаж
|
||||
|
||||
База диффа: `HEAD` (коммит `aa20b22`), изменение целиком в рабочем дереве. Дата
|
||||
прогона: 2026-08-12.
|
||||
|
||||
## Сводка
|
||||
|
||||
**Размер, сложность, метка.** Размер — среднее: `tasks.md` 20 шагов в 4 разделах,
|
||||
8 файлов; `internal/` не тронут ни строкой. Сложность — знакомое: все узлы
|
||||
названы поимённо до работы, шагов формы «разобраться/выяснить» нет. **Метка
|
||||
`medium`** (максимум по осям), **режим — по графу**. Триггеры `docs/review.md`
|
||||
проверены все три группы, ни один пункт не совпал.
|
||||
|
||||
**Состояние гейта: ЗЕЛЁНЫЙ.** Подтверждено собственным прогоном триажа, а не
|
||||
только отчётом прохода: `task gate BASE=HEAD` → exit 0. Все девять шагов зелёные,
|
||||
включая новый `go-version`. Одно унаследованное замечание `tasks.py`
|
||||
(`any-audio-source`: цель без задач и без тега `decomposed`) шаг не роняет и к
|
||||
диффу отношения не имеет.
|
||||
|
||||
### План разметки задачи с исходом по каждой теме
|
||||
|
||||
| тема | дом | глубина | кто закрывает | исход |
|
||||
|---|---|---|---|---|
|
||||
| requirements | `openspec/specs/` + дельта `specs/toolchain/spec.md` | разбор | specs | **закрыта**, 4 находки (S1–S4) + 2 блока наблюдений |
|
||||
| autotests | `CLAUDE.md`, раздел «Гейт» | — | autotests | **закрыта**, 3 находки (A1–A3) + отчёт гейта |
|
||||
| conventions | `docs/conventions/` | разбор | code | **закрыта**, 3 находки (C1–C3) + 3 наблюдения ниже порога |
|
||||
| architecture | `docs/architecture.md` + источник `docs/passport.md` | разбор | basics | **закрыта**, 1 находка (B1) + 4 пункта «дешевле переделать» |
|
||||
| security | `docs/security.md` | разбор | basics | **закрыта**, находок нет: тема неприменима к диффу целиком |
|
||||
| operations | `docs/architecture.md` §Эксплуатация + источник `docs/database.md` | разбор | basics | **закрыта**, 1 находка (B3); 4 вопроса из 6 неприменимы |
|
||||
|
||||
**Тем без отчёта нет.** Все шесть тем ядра вернули отчёты; своих тем сверх ядра у
|
||||
проекта нет. Ни одна тема не осталась непроверенной по причине «проход не
|
||||
запускался».
|
||||
|
||||
**Находок на входе:** 13 нумерованных (A1–A3, S1–S4, C1–C3, B1–B3) плюс 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` до чтения артефактов; сюда переносятся пункты,
|
||||
ставшие приёмочными сверх критериев задачи.
|
||||
|
||||
- Отказ без сессии наступает раньше чтения тела и раньше обращения к хранилищу.
|
||||
- Форма отказа одна и та же у существующего и несуществующего ресурса.
|
||||
- Ни одно значение, дающее доступ, не печатается: код провайдера, секрет
|
||||
клиента, значение сессии, адрес почты.
|
||||
- Правило доступа читается как «всё требует сессии, кроме перечня», а перечень
|
||||
открытого живёт в одном месте.
|
||||
- Все прочие способы получить сессию к тому же субъекту выключены либо названы
|
||||
поимённо с обоснованием, почему они не обход.
|
||||
- Возврат от провайдера отвергается без состояния, с чужим, с истёкшим и с уже
|
||||
употреблённым — до обмена кода.
|
||||
- У обращения к провайдеру есть таймаут, и «медленный» отличается от «отказал».
|
||||
- Исход входа и выхода не зависит от порядка параллельных операций.
|
||||
@@ -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** значений этих ключей в отказе нет
|
||||
@@ -14,12 +14,19 @@ Telegram делит с ним общий шаг заведения задачи,
|
||||
### Requirement: Приём записи по HTTP
|
||||
|
||||
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||||
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена
|
||||
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ
|
||||
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`.
|
||||
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||
ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть
|
||||
сохранена и получить заведённую под неё задачу расшифровки в состоянии
|
||||
`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние
|
||||
полем `status`.
|
||||
|
||||
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
|
||||
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||
|
||||
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
|
||||
и переименование поля ломает внешнюю программу молча.
|
||||
и переименование поля ломает внешнюю программу молча. Появление отказа без
|
||||
сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу.
|
||||
|
||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||
пригодность содержимого узнаёт у источника метаданных.
|
||||
@@ -27,16 +34,28 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||
хранилище, и нормирует её capability `storage`.
|
||||
|
||||
Владельца у принятой записи приём не заводит: после входа видно ровно то же, что
|
||||
видно было анонимно.
|
||||
|
||||
#### Scenario: Запись принята
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||
со значением `created`
|
||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** ни файла, ни задачи не заводится
|
||||
- **AND** тело ответа не несёт данных задачи
|
||||
|
||||
#### Scenario: Поля с записью нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
||||
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||
- **AND** ни файла, ни задачи не заводится
|
||||
@@ -44,6 +63,7 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
#### Scenario: Размеру записи приём не судья
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель предъявил сессию
|
||||
- **WHEN** программа шлёт запись нулевой длины
|
||||
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||
|
||||
@@ -179,7 +199,7 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
|
||||
принадлежит перечню known-форматов
|
||||
принадлежит перечню известных форматов
|
||||
- **THEN** метка метрики принимает значение `other`
|
||||
- **AND** имя файла в хранилище сохраняет пришедшее расширение
|
||||
|
||||
@@ -192,23 +212,47 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
### Requirement: Опрос готовности задачи
|
||||
|
||||
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
||||
`GET /api/status/:id`. Ответ MUST нести идентификатор полем `job_id`, состояние
|
||||
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
|
||||
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
|
||||
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
|
||||
`GET /api/status/:id` **только узнанному отправителю**. Запрос без сессии MUST
|
||||
получать код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни
|
||||
текста расшифровки. Ответ узнанному отправителю MUST нести идентификатор полем
|
||||
`job_id`, состояние полем `status` и время заведения полем `created_at`, а текст
|
||||
расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе,
|
||||
пока текста нет: пустая строка на месте отсутствующего текста читается как
|
||||
«расшифровка пуста».
|
||||
|
||||
Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по
|
||||
кодам ответа перебирается список заведённых задач.
|
||||
|
||||
Выборку по владельцу опрос не сужает: узнанный отправитель видит любую задачу по
|
||||
её идентификатору ровно как прежде. Сужение придёт отдельной задачей.
|
||||
|
||||
#### Scenario: Задача найдена
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа спрашивает состояние заведённой задачи
|
||||
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
|
||||
- **WHEN** программа спрашивает состояние заведённой задачи без сессии
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки
|
||||
|
||||
#### Scenario: Без сессии неизвестная задача неотличима от заведённой
|
||||
|
||||
- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем
|
||||
состояние по неизвестному идентификатору
|
||||
- **THEN** оба ответа имеют код `401`
|
||||
|
||||
#### Scenario: Расшифровки ещё нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
|
||||
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||
|
||||
#### Scenario: Задачи с таким идентификатором нет
|
||||
|
||||
- **GIVEN** отправитель предъявил сессию
|
||||
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
||||
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
# storage Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change pocketbase-storage. Update Purpose after archive.
|
||||
|
||||
Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение
|
||||
схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность
|
||||
хранилища и панель владельца.
|
||||
|
||||
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`.
|
||||
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
|
||||
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
|
||||
2026-08-11, а удаление приносит задача `delete-record`.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Сервис поднимается на чистом каталоге данных
|
||||
|
||||
@@ -91,7 +100,22 @@ MUST завести свою схему и принимать записи об
|
||||
### Requirement: Файл отдаётся ссылкой
|
||||
|
||||
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
|
||||
записи. Отданный файл MUST совпадать с принятым по длине.
|
||||
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
|
||||
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
|
||||
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
|
||||
|
||||
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||||
узнанный отправитель берёт у хранилища, предъявив сессию, — и правилом просмотра
|
||||
коллекции. Правило MUST пускать всякого узнанного: незаданное означает «только
|
||||
владелец панели», и тогда файла не получит и вошедший. Сужения по владельцу
|
||||
здесь нет — его заводит отдельная задача.
|
||||
|
||||
Отсюда порядок для потребителя: сессия → токен файла → ссылка с этим токеном.
|
||||
Браузер с одной лишь кукой файла не получит, и это свойство хранилища, а не
|
||||
недосмотр.
|
||||
|
||||
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
|
||||
хранилища, а не по ссылке.
|
||||
|
||||
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
|
||||
|
||||
@@ -101,6 +125,10 @@ MUST завести свою схему и принимать записи об
|
||||
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
|
||||
бессрочно.
|
||||
|
||||
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
|
||||
требует ещё и сессии, а строка журнала со ссылкой по-прежнему собирала бы
|
||||
половину ключа.
|
||||
|
||||
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
||||
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
|
||||
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
|
||||
@@ -112,9 +140,22 @@ MUST завести свою схему и принимать записи об
|
||||
#### Scenario: Файл забирают по ссылке
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **WHEN** ссылку на файл запрашивают
|
||||
- **AND** забирающий предъявил сессию и взял по ней токен файла
|
||||
- **WHEN** ссылку на файл запрашивают с этим токеном
|
||||
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||||
|
||||
#### Scenario: Без сессии файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **WHEN** ссылку на файл запрашивают без сессии
|
||||
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||||
|
||||
#### Scenario: Конвейер читает файл без сессии
|
||||
|
||||
- **GIVEN** запись принята и ждёт расшифровки
|
||||
- **WHEN** шаг конвейера берётся за неё
|
||||
- **THEN** файл читается из файловой системы хранилища и шаг проходит
|
||||
|
||||
#### Scenario: Ссылка ведёт в никуда
|
||||
|
||||
- **WHEN** запрашивают ссылку на запись, которой нет
|
||||
|
||||
@@ -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** он завершается отказом и называет место, где число не нашлось
|
||||
Executable
+243
@@ -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
|
||||
@@ -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
@@ -11,6 +11,25 @@
|
||||
Секция одна — полок домена у проекта нет, и делить очередь на две
|
||||
значило бы держать два порядка вместо одного.
|
||||
|
||||
**Чем очередь упорядочена на этом этапе — от базы к деталям.**
|
||||
Сначала то, на чём стоит остальное: проверки, которым можно верить,
|
||||
владелец записи, единый контракт API, покрытый тестами конвейер, — и
|
||||
только потом экраны и возможности поверх них. Порядок расставлен на
|
||||
груминге 2026-08-12 и держится, пока сервис не собран целиком:
|
||||
задача, взятая раньше своего основания, стоит дважды — сперва её
|
||||
пишут, потом переписывают под появившееся основание.
|
||||
|
||||
Отсюда правило для **новых** записей. Заведённая по ходу работы —
|
||||
интейком, урожаем ревью, разбором находок — задача встаёт в конец
|
||||
очереди машинально, и это **не** её место, а отсутствие места.
|
||||
Слой ей назначает человек на ближайшем груминге: ниже того, чего она
|
||||
требует, и выше того, что требует её. Причина уезжает в запись
|
||||
(`move --after <слаг> --reason`).
|
||||
|
||||
Разведки это касается вдвойне: её исход — новые задачи, и слой они
|
||||
наследуют не от разведки, а от того, что трогают. Разведка о конвейере
|
||||
может принести задачу основания, которой место в голове очереди.
|
||||
|
||||
Тип записи стоит первым полем меты и решает, что у неё может быть:
|
||||
✨ `feature` 🐞 `fix` 🧹 `chore` 🔬 `research`
|
||||
|
||||
@@ -20,45 +39,63 @@
|
||||
|
||||
## Очередь
|
||||
|
||||
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
|
||||
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
|
||||
- [✨ Пускать в приложение только после входа через OIDC](items/oidc-login.md) — HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору.
|
||||
- [🧹 Ронять гейт на изменённой функции, которую не выполняет ни один тест](items/gate-changed-lines-coverage.md) — Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
|
||||
- [🧹 Поднимать сервис локально без действующего токена бота](items/local-run-without-telegram-token.md) — Адаптер Telegram проверяет токен обращением к Telegram и роняет старт, а боевым токеном запускаться запрещено: проверить поведение живым прогоном не может ни одна задача.
|
||||
- [🐞 Убрать код провайдера из журнала запросов хранилища](items/provider-code-out-of-storage-log.md) — Строка запроса с кодом входа целиком уезжает в таблицу _logs и лежит там пять суток, хотя спека access требует, чтобы код в журнал не попадал.
|
||||
- [🐞 Вести учёт употреблённых состояний входа на сервере](items/server-side-login-state.md) — Одноразовость возврата держится на уборке куки, то есть на браузере: сервер не помнит, какие состояния уже потрачены.
|
||||
- [🧹 Строить адрес входа из настроек коллекции, а не из конфига](items/login-url-from-collection-settings.md) — Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
|
||||
- [🔬 Четыре недоказанные гипотезы о поверхности входа](items/login-surface-hypotheses.md) — Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было.
|
||||
- [🧹 Назвать в необратимом, что откат кода не откатывает шаг схемы](items/rollback-does-not-undo-schema-step.md) — Откат бинаря оставляет применённый шаг схемы в силе, и на этом строятся решения о выкладке: сегодня об этом не сказано нигде.
|
||||
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
|
||||
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
|
||||
- [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
|
||||
- [✨ Привязать запись к владельцу и отдавать только свои](items/record-ownership.md) — У задачи и файла нет владельца, поэтому знание UUID задачи и есть право её читать.
|
||||
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
|
||||
- [✨ Свести приём и чтение записей к одному контракту для приложения](items/json-api-for-spa.md) — Сегодняшний API отвечает 404 на любую ошибку чтения и 500 на любую ошибку приёма: строить на нём экраны нечем.
|
||||
- [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны.
|
||||
- [✨ Пускать скрипты в API по личным токенам](items/api-tokens.md) — Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
|
||||
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
|
||||
- [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто.
|
||||
- [🧹 Задать таймауты обращениям к внешним сервисам](items/external-call-timeouts.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет таймаута: молчащий собеседник держит шаг конвейера до истечения часового захвата.
|
||||
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Половина сделана 2026-08-13 — контекст доходит до внешних вызовов, а прерванный шаг оставляет задачу на повтор и не тратит попытку, — но осталось то, ради чего задача заводилась: хранилище контекста не принимает ни одним методом, и бюджет мягкой остановки не замерен.
|
||||
- [🐞 Убирать записанный файл, когда приём отказал на середине](items/orphan-file-on-failed-intake.md) — Отказ чтения метаданных и отказ записи на диск оставляют файл в каталоге хранения без задачи и без учёта: сопоставить его не с чем, удалять приходится руками.
|
||||
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
|
||||
- [✨ Собрать каркас приложения и раздать его из бинарника](items/spa-skeleton.md) — Экранов нет и собирать их нечем: ни сборки фронтенда, ни раздачи статики в проекте не существует.
|
||||
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
|
||||
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
|
||||
- [✨ Удалять запись со всеми уровнями текста по требованию владельца](items/delete-record.md) — Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
|
||||
- [✨ Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage.
|
||||
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
|
||||
- [✨ Узнавать уже загруженный файл по хеш-сумме](items/dedup-by-content-hash.md) — Один и тот же файл, отправленный дважды, распознаётся дважды и оплачивается дважды: приём не смотрит на содержимое вовсе.
|
||||
- [✨ Принимать до десяти файлов одной загрузкой](items/multi-file-upload.md) — Приём берёт один файл в запросе, а с телефона выбирают пачку сразу: десять записей значат десять заходов на экран загрузки.
|
||||
- [✨ Показывать ход загрузки записи на экране](items/upload-progress.md) — Гигабайтный файл уходит на сервер молча: до ответа сервера экран не отличает идущую загрузку от зависшей.
|
||||
- [✨ Сделать приложение устанавливаемым на телефон](items/installable-pwa.md) — Приложение, живущее вкладкой браузера, теряется среди прочих: ярлыка на экране у него нет.
|
||||
- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
|
||||
- [✨ Удалять запись со всеми уровнями текста по требованию владельца](items/delete-record.md) — Ни файлы, ни расшифровки не удаляются вовсе: убрать запись сегодня можно только руками в базе и в каталоге на сервере.
|
||||
- [✨ Сделать экран настроек и хранить настройки по пользователю](items/settings-screen.md) — Настроек у пользователя нет вовсе: уровни текста и канал уведомлений задаются общим конфигом сервиса.
|
||||
- [✨ Считать заголовок, темы и пересказ внешней моделью](items/llm-insights-adapter.md) — Расшифровка доходит стеной текста: ни заголовка, ни тем, ни пересказа сервис не считает, и клиента языковой модели в нём нет.
|
||||
- [✨ Отдавать вычитанный текст рядом с сырым](items/literary-text-level.md) — Сырая расшифровка идёт без знаков препинания, с повторами и словами-паразитами: читать её подряд тяжело, а другого уровня текста нет.
|
||||
- [✨ Показывать заголовок в списке, отбирать список по темам и считать токены](items/insights-visible-in-list.md) — Заголовок, темы и пересказ считаются, но список по-прежнему показывает первые слова расшифровки и не отбирается ничем, а расход на модель не виден числом.
|
||||
- [✨ Считать только те уровни текста, что включены у владельца записи](items/settings-applied-in-pipeline.md) — Дом настроек есть, а конвейер их не читает: выключенный уровень всё равно уходит платной модели, и настройка ничего не экономит.
|
||||
- [✨ Отправлять готовый текст через apprise и ntfy](items/ntfy-delivery.md) — Пользователь веба узнаёт о готовности только опросом с открытого экрана.
|
||||
- [✨ Слать готовый текст на почту из учётной записи](items/email-notification.md) — Адрес почты приходит вместе с входом через OIDC, но почтового отправителя в сервисе нет.
|
||||
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
|
||||
- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
|
||||
- [🔬 Потолки SpeechKit по длине записи и по формату](items/speechkit-limits.md) — Потолок длины записи и перечень принимаемых форматов неизвестны, а цель про долгие записи без них не начинается.
|
||||
- [🔬 Потолки приёма, конвертации и заливки по длине записи](items/intake-limits-measure.md) — Из пяти звеньев задача speechkit-limits замерила только модель распознавания: где отваливается шестичасовая запись до неё, неизвестно.
|
||||
- [✨ Отклонять на приёме запись сверх потолка](items/reject-oversized-recording.md) — Запись сверх потолка принимается молча и висит в конвейере до истечения часового захвата, а человек всё это время ждёт текста.
|
||||
- [✨ Резать длинную запись на фрагменты и продолжать с места остановки](items/long-audio-chunking.md) — Шаг конвейера повторяется целиком: перезапуск на пятом часу шестичасовой записи начинает распознавание заново и оплачивает его второй раз.
|
||||
- [✨ Отдавать текст в сотни килобайт файлом, а не сотней сообщений](items/long-text-delivery.md) — Отправитель Telegram режет текст по 4000 знаков: расшифровка шестичасовой записи придёт сотней сообщений подряд.
|
||||
- [🔬 Загрузка большого файла частями](items/chunked-upload-choice.md) — Гигабайтный файл едет одним запросом, и обрыв на девяноста процентах начинает его заново.
|
||||
- [🧹 Покрыть тестами разбор вывода ffprobe](items/metaviewer-adapter-tests.md) — Проверки приёма перестали звать настоящий ffprobe 2026-08-11, а своего теста у адаптера метаданных нет: разбор JSON и отличие «программы нет в PATH» от «обработка отказала» не проверяет ничто.
|
||||
- [🧹 Покрыть тестами шаги конвейера и захват задачи](items/pipeline-step-tests.md) — Тестовых файлов в проекте два, и оба мимо конвейера: потеря ссылки на файл, двойной ответ пользователю и гонка при захвате не поймаются ничем.
|
||||
- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом.
|
||||
- [🧹 Переименовать образец конфига в config.example.toml](items/config-example-toml.md) — Конвенция называет config.dist.toml объявленным расхождением, но тут же пишет это имя как правило — документ противоречит сам себе, а образец расходится с конвенцией.
|
||||
- [🔬 Перечень форматов, которые конвейер принимает на самом деле](items/audio-format-coverage-measure.md) — Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
|
||||
- [✨ Принимать видео и брать из него звуковую дорожку](items/video-audio-track-intake.md) — Запись семейного архива приходит видеофайлом, а приём смотрит на аудио: человеку приходится доставать дорожку самому.
|
||||
- [🔬 Стоит ли брать OpenTelemetry вместо голого Prometheus](items/opentelemetry-fit.md) — Метрик одиннадцать штук на пять счётчиков, трассировки нет вовсе: путь одной записи по конвейеру собирается только чтением логов глазами.
|
||||
- [🧹 Обновить Go до 1.26 и сверять версию шагом гейта](items/go-1-26-upgrade.md) — Модуль объявляет go 1.25.0, образ собирается на golang:1.25-alpine, на машине разработки стоит 1.26.5, и расхождение этих чисел не ловит ни один шаг гейта: разъехавшийся Dockerfile прошёл весь конвейер зелёным.
|
||||
- [🧹 Ловить уязвимости в зависимостях шагом гейта](items/gate-dependency-vulnerabilities.md) — govulncheck находит две достижимые уязвимости в клиентах Yandex, а ни гейт, ни список «чего в гейте нет» о нём не знают: узнать о третьей будет неоткуда.
|
||||
- [🧹 Считать покрытие изменённых строк шагом гейта](items/gate-changed-lines-coverage.md) — Свойство «изменённое место покрыто хоть одним тестом» записано в docs/review.md, но не механизировано: за две задачи подряд непокрытые шаги ловили руками.
|
||||
- [🧹 Прервать шаг конвейера отменой контекста](items/context-cancel-in-pipeline.md) — Воркер читает ctx только между итерациями: остановка контейнера ждёт конца шага, а на занятом писателе один запрос к хранилищу держится до 9,5 секунды при мягком таймауте в 5.
|
||||
- [🧹 Разобрать мелочи слоя хранилища](items/storage-layer-nits.md) — Три мелочи ниже потолка триажа: цикл воркера пишет потерю захвата уровнем ERROR и считает её отказом, тип ошибки заведён там, где конвенция просит sentinel, а FileName несёт два разных смысла.
|
||||
- [✨ Собирать путь одной записи по конвейеру запросом](items/job-path-by-request.md) — Звенья пути связаны только идентификатором задачи в строках журнала: чтобы понять, где запись провела минуты, владелец читает логи контейнера глазами.
|
||||
- [✨ Считать вызовы, отказы и длительность по каждому внешнему сервису](items/external-service-metrics.md) — Ни у Telegram, ни у Object Storage, ни у SpeechKit нет ни одной метрики: отказ внешнего сервиса виден только строкой в журнале контейнера.
|
||||
- [✨ Показывать метрикой задачу, застрявшую в состоянии](items/stalled-pipeline-metric.md) — Вставший конвейер неотличим от простоя: возраст задачи в состоянии не считается, и очередь без движения выглядит как отсутствие работы.
|
||||
- [✨ Оповещать владельца об отказе, не дожидаясь жалобы](items/owner-alerting.md) — Об отказе владелец узнаёт от пользователя: правил оповещения нет ни на одной метрике, а метрики читают глазами.
|
||||
- [🔬 Уведомление SpeechKit о готовности вместо опроса](items/speechkit-callback-fit.md) — Шаг проверки дёргает операцию раз в 5 секунд всё время распознавания: часовая запись даёт порядка 720 обращений к платному сервису вместо одного ответа.
|
||||
- [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись.
|
||||
- [✨ Проигрывать загруженную запись на экране записи](items/play-recording-in-app.md) — Послушать загруженное приложение не даёт, а самой копии для этого у задачи нет: указатель на файл перезаписывается на каждом шаге конвейера и у готовой задачи ведёт на объект в Object Storage.
|
||||
- [✨ Считать объём, минуты и расход по каждому пользователю](items/usage-accounting.md) — Ни объём, ни длительность, ни обращения к платным сервисам никуда не записываются: восстановить расход задним числом не из чего.
|
||||
- [✨ Сделать страницу статистики для владельца](items/admin-stats-screen.md) — Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
|
||||
- [🧹 Закрепить версию рантайм-базы образа](items/pin-runtime-image-base.md) — Финальный слой Dockerfile собирается на alpine:latest, а task image идёт с --pull, поэтому два образа из одного коммита с разницей в неделю несут разный ffmpeg — регрессия конвертации после такой пересборки выглядит как задачи в failed при пустом диффе репозитория, и откат на прежний коммит её не чинит.
|
||||
- [🧹 Настроить конвейер ревью по итогам прогона go-1-26-upgrade](items/review-config-from-go-upgrade.md) — Прогон вскрыл две прорехи настройки: «Типовые узлы» знают только рантайм и не знают рода «проверочный шаг набора проверок», а «Триггеры метки» не видят оси «изменение трогает канон» — и именно она дала обе блокирующие находки.
|
||||
- [🐞 Починить срок сессии, который ставит откат шага входа](items/rollback-restores-wrong-session-duration.md) — Константа defaultAuthTokenDuration в шаге 202608120001 названа умолчанием библиотеки, но 1209600 — это 14 суток, а умолчание PocketBase 432000, пять суток: откат объявляет возврат к умолчанию и ставит срок вдвое больше выбранных владельцем семи.
|
||||
- [🧹 Проверить шаг гейта migrations так же, как шаг сверки версий Go](items/migrations-step-norm-and-tests.md) — Шаг охраняет critical-инвариант «применённый шаг схемы не переписывается», но своих проверок не имеет: дрейф шаблона имени, переезд каталога или потеря grep в конвейере оставят его вечно зелёным, и это не заметит ничто.
|
||||
- [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта.
|
||||
- [🔬 Шаги гейта, у которых правило может потерять предмет](items/gate-steps-subject-guard.md) — У шага migrations страж предмета есть, у шагов docs, tasks и openspec неизвестно: они зовут чужие скрипты из плагинов, и правило, потерявшее файлы, зеленело бы молча.
|
||||
- [🧹 Свести шесть расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; четыре из них в architecture.md, и по ним читатель строит решения о выкладке и о периметре.
|
||||
- [🔬 Квота по общему размеру загруженного на пользователя](items/per-user-size-quota.md) — Паспорт и security.md запрещают отказы по квоте пользователю, а заметка владельца просит квоту по умолчанию 5 ГБ — открытое противоречие с границей домена, которое владелец решил не разбирать сейчас.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Входящие
|
||||
|
||||
Сырые заметки до разбора. Разбирает владелец; разобранное уезжает задачами и
|
||||
здесь не остаётся.
|
||||
+3
-3
@@ -25,15 +25,15 @@
|
||||
- [🎯 Сервисом пользуются несколько человек, и записи одного не видны другому](items/multi-user.md) — У задачи нет владельца, а HTTP API открыт наружу без аутентификации: пригласить второго человека сейчас значит открыть ему чужие расшифровки.
|
||||
- [🎯 Записи загружаются и читаются в приложении, которое ставится на телефон](items/web-access.md) — Сегодня записи принимает только бот и голый HTTP API без интерфейса: отдать сервис человеку, у которого нет Telegram, нечем.
|
||||
- [🎯 Загрузка большого файла доходит до сервиса и не повторяется впустую](items/upload-reliability.md) — Приём рассчитан на голосовое в пару мегабайт: обрыв на середине гигабайтного файла начинает загрузку заново, а один и тот же файл распознаётся повторно за наши деньги.
|
||||
- [🎯 Человек убирает свою запись из архива вместе со всеми текстами](items/data-ownership.md) — Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
|
||||
- [🎯 Пользователь настраивает, что сервис делает с его записями](items/user-settings.md) — Уровни текста считает платная модель, а уведомления приходят одним общим способом: отказаться от лишнего и выбрать свой канал пользователю нечем.
|
||||
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
|
||||
- [🎯 Пользователь узнаёт о готовности текста, не держа приложение открытым](items/ready-notification.md) — Расшифровка занимает минуты, и всё это время человек либо смотрит на экран с опросом статуса, либо забывает вернуться.
|
||||
|
||||
## Направления
|
||||
|
||||
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
|
||||
- [🎯 Запись длиной до шести часов доходит до текста](items/long-recordings.md) — Потолок не замерен ни на одном звене: Telegram не отдаёт больше 20 МиБ, границы модели deferred-general неизвестны, а перезапуск на середине начинает распознавание заново.
|
||||
- [🎯 Приложение показывает, о чём запись, не читая её целиком](items/text-insights.md) — Расшифровка часового разговора — это стена текста: найти в списке нужную запись и вспомнить, о чём она, сегодня нечем.
|
||||
- [🎯 Человек убирает свою запись из архива вместе со всеми текстами](items/data-ownership.md) — Хранение бессрочное, а способа убрать запись нет ни одного: ошибочно загруженный файл и разговор, который человек не хочет держать у нас, остаются навсегда.
|
||||
- [🎯 Принимается запись любого формата, включая дорожку из видео](items/any-audio-source.md) — Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
|
||||
|
||||
## Сопровождение
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ✨ Сделать страницу статистики для владельца
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь
|
||||
- **Категория:** Очередь — Страница показывает собранный учёт: без учёта показывать нечего.
|
||||
- **Зачем:** Собранный учёт читается только запросом к базе руками: ни страницы, ни признака владельца в приложении нет.
|
||||
- **Теги:** goal:usage-stats
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# 🎯 Принимается запись любого формата, включая дорожку из видео
|
||||
|
||||
- **Тип:** goal
|
||||
- **Секция:** Направления
|
||||
- **Секция:** Направления — Перечень форматов не замерен, и потолок длины у видео тот же, что у долгих записей: тянется следом за ними.
|
||||
- **Зачем:** Конвертер вызывается одной командой ffmpeg, проверенной на голосовых Telegram; что он берёт помимо них, никто не мерил.
|
||||
- **Теги:** decomposed
|
||||
|
||||
Человек отдаёт файл, не думая о том, что внутри: аудио любого распространённого
|
||||
контейнера или видео, из которого нужна только речь. Подготовка на стороне
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ✨ Пускать скрипты в API по личным токенам
|
||||
|
||||
- **Тип:** feature
|
||||
- **Категория:** Очередь
|
||||
- **Категория:** Очередь — Второй способ представиться ставится на готовые владельца и контракт, иначе форма ошибки переписывается дважды.
|
||||
- **Зачем:** Вход через OIDC закрывает API целиком, а скрипту браузерная сессия недоступна: автоматизировать загрузку станет нечем.
|
||||
- **Теги:** goal:multi-user
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
- таблица токенов: владелец, имя, отпечаток, время выпуска и последнего
|
||||
обращения, и её миграция;
|
||||
- эндпоинты выпуска, перечня и отзыва токена;
|
||||
- экран настроек — место, где токен выпускают и отзывают;
|
||||
- `docs/security.md` — второй способ представиться и хранение отпечатка;
|
||||
- `README.md` — пример вызова API скриптом.
|
||||
|
||||
@@ -38,4 +37,6 @@
|
||||
|
||||
Учётные записи по-прежнему заводит Authelia — свою регистрацию не делаем.
|
||||
Сроков жизни и областей действия у токена не заводим: он даёт права владельца
|
||||
целиком. Берётся после `oidc-login`: до неё представляться некому.
|
||||
целиком. Берётся после `oidc-login`: до неё представляться некому. Экрана выпуска здесь
|
||||
нет — приложения ещё не существует, токен выпускается запросом к API; место
|
||||
токена на экране настроек заводит `settings-screen`.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 🔬 Перечень форматов, которые конвейер принимает на самом деле
|
||||
|
||||
- **Тип:** research
|
||||
- **Категория:** Очередь — Форматы: сначала замер того, что конвейер берёт на самом деле.
|
||||
- **Зачем:** Команда ffmpeg проверена на голосовых Telegram, а что она берёт помимо них, не мерил никто: перечень выведен из документации, а не из прогона.
|
||||
- **Теги:** goal:any-audio-source
|
||||
|
||||
Двигает пункты 1 и 4 «Завершения» цели: перечень принимаемых форматов замерен и
|
||||
записан, а расхождение `ogg/vorbis` против заявленного SpeechKit `OGG_OPUS`
|
||||
разобрано.
|
||||
|
||||
## Вопрос
|
||||
|
||||
Какие форматы доходят до текста целиком, какие ломаются на конвертации, какие —
|
||||
на распознавании, и чем на самом деле кодирует конвертер: `vorbis` или `opus`.
|
||||
|
||||
## Куда ляжет ответ
|
||||
|
||||
- `docs/research/audio-formats.md` — таблица «формат на входе → исход», с
|
||||
командой замера и версией ffmpeg, на которой он сделан;
|
||||
- расхождение `ogg/vorbis` против `OGG_OPUS`: строка о том, устранено оно или
|
||||
проверенно безвредно, и чем это подтверждено;
|
||||
- форматы, которые принять нельзя, — задачей об отказе на приёме, с провенансом
|
||||
этой разведки.
|
||||
|
||||
## Рамки
|
||||
|
||||
Замер идёт на своих файлах во временном каталоге и на подставном распознавателе
|
||||
`internal/adapter/recognizer/memory.go`; прогон на реальных ключах Yandex
|
||||
запрещён — там, где без настоящего SpeechKit не обойтись, ответ берётся из
|
||||
задачи `speechkit-limits`, а не оплачивается заново. Видеофайлы здесь только
|
||||
измеряются, приём их заводит `video-audio-track-intake`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user