diff --git a/CLAUDE.md b/CLAUDE.md index 9be0c77..9af3b14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр ## Стек -Go 1.26 (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`. @@ -87,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 @@ -117,13 +117,19 @@ task gate # весь набор проверок разом найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно: четвёртый шаг с собственной семантикой сделал бы это утверждение неверным. Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая - `shell`, `dockerfile` и `vulns`: недостающий инструмент — отказ окружения, код - 3. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`, + `tests`, `migrations`, `shell`, `dockerfile` и `vulns`: недостающий инструмент + — отказ окружения, код 3. У `tests` это отсутствие CGO или компилятора C, без + которых не работает детектор гонок — тесты он в этом случае всё равно гоняет, + без `-race`, и краснеет уже после них. У `migrations` код 3 — неразрешимая + база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 — + переписанный шаг схемы. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`, `golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой». Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на любой отказ шага выходит с 201, а код шага печатает строкой («exit status 3»), поэтому словарь читается по коду скрипта. - **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`, + гонка, найденная детектором (`go test -race`), переписанный применённый шаг + схемы, неотформатированный файл, находка `golangci-lint`, расхождение объявленных версий Go, дрейф раскладки документов, дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода diff --git a/docs/architecture.md b/docs/architecture.md index 6d9215f..baf74c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,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`, модель @@ -118,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` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — | diff --git a/docs/conventions/go-linters.md b/docs/conventions/go-linters.md index 0521411..f96fead 100644 --- a/docs/conventions/go-linters.md +++ b/docs/conventions/go-linters.md @@ -40,7 +40,11 @@ Go-проект как есть. Своё здесь — перечень пра версий Go поведение нормировано отдельно, спекой [toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка проекта, у которой есть своя capability, и потому единственная, чьи сценарии - проверяются построчно (`scripts/check_go_version_test.go`). + проверяются построчно (`scripts/check_go_version_test.go`). Второй самодельный + шаг — `migrations` — нормы не имеет: он проверен мутацией на трёх исходах + (переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у + него нет, и дрейф его собственного шаблона имени никто не поймает. Это + объявленный долг, а не умолчание. ## Лестница механизации @@ -104,6 +108,9 @@ Go-проект как есть. Своё здесь — перечень пра | Сравнение ошибок через `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` | ### Структура и границы @@ -115,6 +122,14 @@ Go-проект как есть. Своё здесь — перечень пра | Адаптер не знает ни ядра, ни транспортов | `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` неотличимо | + ### Время, вывод, конфигурация | Правило | Где механизировано | @@ -130,6 +145,9 @@ Go-проект как есть. Своё здесь — перечень пра | --- | --- | | Проверка судит ответ по готовому ответу (`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 @@ -146,6 +164,7 @@ Go-проект как есть. Своё здесь — перечень пра | Правило | Где механизировано | | --- | --- | +| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `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` | @@ -166,6 +185,7 @@ Go-проект как есть. Своё здесь — перечень пра | Правило о заголовках вне `*_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 не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели | @@ -185,6 +205,15 @@ Go-проект как есть. Своё здесь — перечень пра - вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`: `forbidigo` судит по имени вызванной функции, а не по её первому аргументу; - проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`; +- **отсутствие** контекста у сигнатуры: `contextcheck` ловит обрыв цепочки — + `context.Background()` там, где контекст был доводом, — но метод, у которого + довода нет вовсе, правилу не виден. Первый проброс контекста в новый адаптер + остаётся человеку; +- шаг `migrations` судит только те шаги схемы, которые **есть в базе диффа**: у + добавленного после неё файла статус `A`, и правка такого файла законна — он + ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило + молчит на всём каталоге, и на подозрении база задаётся руками + (`task migrations BASE=`); - направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно — `controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и есть роутер этого хранилища. Изъятие названо в @@ -223,5 +252,11 @@ Go-проект как есть. Своё здесь — перечень пра принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны они в [../review.md](../review.md) (журнал 2026-08-11 про недостижимую норму, 2026-08-13 про обходимый текстовый запрет). + + **Мутация ставится по одному нарушению на строку.** `golangci-lint` печатает + с одной строки исходника **одну** находку (умолчание `uniq-by-line`), и + мутация, задевшая сразу два правила, покажет только первое: так молчали + `sqlclosecheck` и `rowserrcheck` на пробе, где та же строка уже краснела от + `noctx`. Проверять правило пробой, где оно единственное нарушенное. 5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её заменило. diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index 7adeefc..56f6c42 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -241,10 +241,22 @@ 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`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост diff --git a/docs/review.md b/docs/review.md index fe4cd76..3bbac45 100644 --- a/docs/review.md +++ b/docs/review.md @@ -116,12 +116,18 @@ Форма: `<тема>: <вопрос> (<провенанс>)`. -- `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). @@ -240,6 +246,74 @@ 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/…`). Инвариант «секрет не покидает + конфиг» помечен 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`, чтение потока diff --git a/docs/security.md b/docs/security.md index d5326b2..b603084 100644 --- a/docs/security.md +++ b/docs/security.md @@ -267,8 +267,16 @@ Telegram отправителю. приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки типа файла не идёт. -Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL -нигде не логируется. +Токен бота стоит в пути **каждого** обращения к Bot API (`bot/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`. ## Что вне модели