tasks: закрыта задача oidc-login, заведён урожай ревью

This commit is contained in:
av
2026-08-12 18:10:59 +03:00
parent c44f0e7582
commit ddc34b3182
10 changed files with 341 additions and 68 deletions
@@ -0,0 +1,48 @@
# 🐞 Починить путь миграций в настройке сверки документов
- **Тип:** fix
- **Категория:** Очередь
- **Зачем:** Ключ migrations указывает на каталог migrations/, которого в репозитории нет: шаг гейта зелен при изменённой миграции и нетронутом database.md, а конвенции числят этот род механизированным.
Найдено ревью задачи `oidc-login` 2026-08-12, отчёт триажа —
[review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
раздел «Promote candidates».
Проверка сверяет изменённые файлы с префиксом `migrations/`, а шаги схемы лежат
в `internal/adapter/repo/pocketbase/`. Совпадений не бывает никогда, значит шаг
проходит зелёным всегда. Оракул сегодняшнего состояния:
`git ls-files | grep -c "^migrations/"` отдаёт `0`.
Цена уже заплачена дважды: задача `oidc-login` изменила шаг схемы и не тронула
`docs/database.md`, и гейт этого не заметил — расхождение нашёл человек на
ревью. Так же провалится всякая следующая миграция.
Развилка внутри задачи: либо поправить путь, либо снять пометку
«механизировано» в `docs/conventions/README.md` и отдать род человеку. Второе
дешевле, но тогда проверять его будет некому.
## Воспроизведение
1. Изменить любой файл шагов схемы в `internal/adapter/repo/pocketbase/`.
2. `docs/database.md` не трогать.
3. Прогнать `task docs BASE=origin/master`.
4. Шаг проходит зелёным, хотя должен назвать расхождение. Сегодняшнее состояние
настройки видно командой `git ls-files | grep -c "^migrations/"` — она отдаёт
`0`, то есть каталога с таким именем в репозитории нет.
## Затрагивает
- `docs/.docs.json`, ключ `migrations`;
- `docs/conventions/README.md`, таблица «Механизировано», строка про миграцию;
- шаг `docs` в `Taskfile.yml` — его исход меняется.
## Критерии приёмки
- Изменённый шаг схемы при нетронутом `docs/database.md` роняет шаг гейта.
Оракул — правка любого файла шагов схемы без правки схемы в документах, затем
`task docs BASE=origin/master`: ненулевой код возврата.
- Изменённый шаг схемы вместе с правкой `docs/database.md` шаг гейта проходит.
Оракул — то же с обеими правками: код возврата 0.
- Строка «Механизировано» в `docs/conventions/README.md` соответствует тому, что
проверка делает на самом деле. Оракул — чтение таблицы против исхода первых
двух проверок.
@@ -0,0 +1,35 @@
# 🧹 Поднимать сервис локально без действующего токена бота
- **Тип:** chore
- **Категория:** Очередь
- **Зачем:** Адаптер Telegram проверяет токен обращением к Telegram и роняет старт, а боевым токеном запускаться запрещено: проверить поведение живым прогоном не может ни одна задача.
Замечено при попытке проверить вход вживую в задаче `oidc-login` 2026-08-12;
подтверждено прогоном: с выдуманным токеном старт кончается отказом создания
отправителя раньше, чем поднимается HTTP-сервер.
Отсюда следствие, которое стоит дороже самого неудобства: **поведенческая
верификация живым запуском недоступна проекту вовсе**. Всякая задача, меняющая
наблюдаемое поведение, проверяется только тестами, а «поднять и посмотреть»
остаётся человеку с боевым конфигом.
Запрет запускаться боевым токеном снимать не надо: второй процесс с тем же
токеном перехватывает обновления у работающего.
## Затрагивает
- создание отправителя Telegram при старте в `main.go`;
- секция `[telegram]` конфига и её образец;
- раздел «Запреты» в `CLAUDE.md` — строка про боевой токен остаётся, но рядом
появляется способ поднять сервис без него;
- `docs/review.md`, подраздел «Недоступно проверке»: строка про недоступность
живого прогона снимается или сужается.
## Критерии приёмки
- Сервис поднимается с пустым токеном бота: HTTP отвечает, воркеры идут, бот не
создан. Оракул — запуск с конфигом без токена и запрос `GET /health`: код 200.
- Отсутствие бота названо в журнале один раз при старте, а не молчанием. Оракул —
тот же запуск: в выводе есть строка о том, что бот не поднят и почему.
- Поведение с настоящим токеном не изменилось. Оракул — тест на создание
отправителя с непустым токеном: прежний путь сохранён.
+44
View File
@@ -0,0 +1,44 @@
# 🔬 Четыре недоказанные гипотезы о поверхности входа
- **Тип:** research
- **Категория:** Очередь
- **Зачем:** Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было.
Провенанс — отчёт триажа ревью задачи `oidc-login` 2026-08-12,
[review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
раздел «Гипотезы без доказательства». Каждая либо становится задачей, либо
закрывается с причиной; сегодня они не то и не другое.
## Вопрос
Работает ли хоть один из четырёх путей на самом деле, и если да — чего стоит
каждый?
1. **Выход по чужой ссылке.** Адрес выхода сессии не требует и на запросе с
чужого сайта отвечает успехом, убирая куку. Применит ли браузер эту куку при
ограничении `SameSite=Lax` — по коду не выяснить. Если применит, человека
выкидывает молча, а унесённое значение остаётся годным.
2. **Слияние двух учётных записей провайдера с одной почтой.** Обмен ищет запись
по неизменяемому признаку провайдера, а не найдя — по адресу почты. Выдаст ли
Authelia двум разным субъектам один адрес, зависит от её настройки.
3. **Поле снимка в ответе провайдера тянет данные наружу.** Оно сопоставлено
файловому полю учётной записи, и библиотека скачивает названный там адрес —
до потолка размера записи. Шлёт ли Authelia это поле и кто им управляет,
неизвестно.
4. **Анонимный запрос подтверждения почты.** Адрес отвечает успехом и заставляет
сервис слать почту. Почта не настроена, и потолка числа запросов нет.
## Куда ляжет ответ
- подтверждённый путь — задачей в беклоге, с провенансом этой разведки;
- опровергнутый — строкой в `docs/security.md`, раздел «Что вне модели» либо
«Что разграничивает доступ», чтобы следующее ревью не открывало его заново;
- то, что зависит от настройки Authelia, — строкой там же, с указанием, какая
именно настройка это решает.
## Рамки
Смотрим только четыре названных пути. Первый требует настоящего браузера, второй
и третий — настоящей Authelia либо её настройки из `pet-project-server`;
четвёртый воспроизводится своим прогоном без внешних систем и потому берётся
первым. Прогонов на боевом контуре не делаем.
@@ -0,0 +1,38 @@
# 🧹 Строить адрес входа из настроек коллекции, а не из конфига
- **Тип:** chore
- **Категория:** Очередь
- **Зачем:** Первая половина входа собрана руками из конфига и на настройки провайдера не смотрит, вторая берётся из коллекции: обновление библиотеки изменит только вторую половину.
Найдено ревью задачи `oidc-login` 2026-08-12, отчёт триажа —
[review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт срезанного потолком под номером 7.
Сегодня адрес согласия собирается своим кодом: состав запрашиваемых сведений,
способ проверочного кода и признак ответа записаны у нас, а обмен кода берёт
настройки провайдера из коллекции хранилища. Библиотека умеет собирать этот
адрес сама — она делает это своим обработчиком способов входа.
Цена расхождения отложенная: адреса провайдера и идентификатор клиента получают
второго потребителя мимо единственного места, где настройки живут, а правка
провайдера в панели на начало входа не влияет вовсе. Обновление библиотеки,
тронувшее форму запроса согласия, доедет до половины протокола и разойдётся
молча — отказом на живой выкладке, которого нечем воспроизвести.
## Затрагивает
- эндпоинт `GET /auth/login`, где сегодня адрес согласия собирается вручную;
- поля `auth_url` и `client_id` в секции `[auth]` конфига и их проброс —
часть из них перестаёт быть нужной приложению;
- дельта-спека `access`, требование «Вход через внешнего провайдера» — состав
запрашиваемых сведений и способ проверочного кода перестают быть нашими.
## Критерии приёмки
- Адрес согласия строится из настроек коллекции: правка провайдера в панели
меняет адрес, куда уводит вход. Оракул — тест: сменить настройки провайдера в
хранилище и убедиться, что адрес перенаправления изменился.
- Проверочный код и состав запрашиваемых сведений берутся у библиотеки, а не
записаны у нас. Оракул — чтение кода: своих литералов состава больше нет.
- Вход по-прежнему проходит целиком. Оракул — существующий тест входа через
подставного провайдера остаётся зелёным.
-67
View File
@@ -1,67 +0,0 @@
# ✨ Пускать в приложение только после входа через OIDC
- **Тип:** feature
- **Категория:** Очередь
- **Зачем:** HTTP API открыт наружу без аутентификации: любой из интернета заводит задачи за наши деньги и читает чужие расшифровки по идентификатору.
- **Теги:** goal:multi-user
Двигает пункты 1 и 3 «Завершения» цели: неаутентифицированный запрос к записям
не проходит ни к странице, ни к API; вход идёт через OIDC у Authelia, а выход из
сессии работает.
Провайдер — Authelia по OIDC. Своей регистрации и своих паролей не делаем, это
граница из [паспорта](../../docs/passport.md). Разграничения записей по владельцу
здесь ещё нет: после входа видно всё, что видно сейчас, — этим занимается
`record-ownership`.
**Ответ провайдера разбирает PocketBase, а не наш код** — решено 2026-08-11
([adr](../../docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)).
У её коллекции пользователей настраивается провайдер `oidc` с адресами Authelia,
и учётные записи заводятся сами; проверено на версии 0.39.10,
[docs/research/pocketbase.md](../../docs/research/pocketbase.md). Отсюда порядок:
задача идёт после `pocketbase-storage`, до неё настраивать нечего.
## Затрагивает
- настройка провайдера `oidc` у коллекции пользователей PocketBase: адреса
Authelia, идентификатор клиента, секрет, соответствие полей учётной записи;
- эндпоинты входа и выхода, которые PocketBase приносит своими;
- `POST /api/audio` и `GET /api/status/:id` — оба уходят за аутентификацию;
- `GET /health` и `GET /metrics` — остаются открытыми и сессии не требуют;
- хранение сессии: её ведёт PocketBase, и решить надо, чем она предъявляется
приложению;
- секция конфигурации под провайдера: адрес, идентификатор клиента, секрет;
- `docs/security.md` — периметр меняется, и первая его строка перестаёт быть
верной;
- `config.dist.toml` и `internal/config`.
## Критерии приёмки
- Запрос к `POST /api/audio` и `GET /api/status/:id` без сессии получает отказ, а
не заводит задачу и не отдаёт текст. Оракул — тест на обоих эндпоинтах без
куки: код ответа 401 либо 302 на вход, тело без данных задачи. Тот же тест
проверяет вторую сторону границы: `GET /health` и `GET /metrics` без куки
отвечают 200.
- Сессия переживает перезапуск приложения. Оракул — тест: запрос с прежней кукой
после пересоздания сервера проходит.
- Выход из сессии закрывает доступ. Оракул — тест: после выхода тот же запрос
получает отказ.
- Секрет провайдера не попадает ни в лог, ни в ответ. Оракул — тест на отсутствие
значения секрета в записанном выводе логгера.
- Первая строка `docs/security.md` описывает новый периметр. Оракул — `task
gate`, шаг `docs.py check`.
## Рамки
Владельца у записи здесь не заводим и выборку не сужаем: после входа видно
столько же, сколько сейчас. Инвариант «бот отвечает только тем, кто в белом
списке» не трогаем — он живёт до `telegram-account-link`. Панель администратора
тоже не трогаем: наружу её закрывает Authelia на обратном прокси, а это работа
выкладки.
`GET /health` и `GET /metrics` за аутентификацию не уходят — решено 2026-08-12.
Сессии нет ни у пробы здоровья, ни у сборщика Prometheus, и вход по личным
токенам эта задача не заводит. Наружу их закрывает то же правило обратного
прокси, что и панель администратора, — работа выкладки. Пока правило не
поставлено, `/metrics` отдаёт наружу объёмы работы сервиса: число задач, размеры
и длительности записей; содержимого расшифровок в них нет.
@@ -0,0 +1,44 @@
# 🐞 Убрать код провайдера из журнала запросов хранилища
- **Тип:** fix
- **Категория:** Очередь
- **Зачем:** Строка запроса с кодом входа целиком уезжает в таблицу _logs и лежит там пять суток, хотя спека access требует, чтобы код в журнал не попадал.
Найдено ревью задачи `oidc-login` 2026-08-12, отчёт триажа —
[review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт срезанного потолком под номером 2.
Наш собственный журнал чист — код туда не пишет ни одна строка приложения.
Пишет его слой хранилища: он логирует всякий запрос вместе со строкой запроса,
а адрес возврата несёт код параметром. Тест на отсутствие значений в журнале
этого не видит, потому что смотрит только в наш логгер.
Код провайдера одноразовый и живёт минуты, поэтому это не захват сессии, а
расхождение написанного со сделанным: комментарий в коде и норма спеки
утверждают, что код в журнал не идёт.
## Воспроизведение
1. Поднять сервис с настроенным провайдером.
2. Пройти вход и вернуться на адрес возврата.
3. Открыть журнал запросов хранилища — панель, `GET /api/logs` либо файл базы.
4. В строке запроса и в поле `url` виден код провайдера целиком.
## Затрагивает
- адрес возврата `GET /auth/callback` и слой журналирования запросов хранилища;
- настройка срока хранения журнала запросов (сегодня умолчание, пять суток);
- дельта-спека `access`, требование «Значение, дающее доступ, не печатается» —
либо норма выполняется, либо изъятие называется поимённо;
- `docs/security.md`, перечень мест, где оседает чувствительное.
## Критерии приёмки
- После входа код провайдера не встречается в журнале запросов хранилища.
Оракул — тест: пройти вход подставным провайдером, затем отобрать записи
журнала и убедиться, что значения кода в них нет.
- Строка о запросе к адресу возврата в журнале остаётся: пропажа самого следа
не годится, прослеживаемость входа нужна. Оракул — тот же тест: запись о
запросе есть, кода в ней нет.
- Норма и код сошлись: либо спека выполняется буквально, либо в ней названо
изъятие с ценой. Оракул — чтение требования против исхода первого теста.
@@ -0,0 +1,36 @@
# 🧹 Судить ответ в тестах по готовому ответу
- **Тип:** chore
- **Категория:** Очередь
- **Зачем:** Проверка, читающая изменяемую карту заголовков обработчика, зелена при неработающем коде: класс всплыл трижды, последний раз на уборке куки входа.
Найдено ревью задачи `oidc-login` 2026-08-12, отчёт триажа —
[review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
раздел «Promote candidates», первый пункт.
Инструмент проверки устроен зеркально настоящему серверу: у сервера заголовки
фиксируются в момент, когда ответ начинают писать, а у него живая карта остаётся
доступной и после. Проверка, читающая живую карту, видит то, чего клиент не
получит, и остаётся зелёной при любой регрессии в этом месте.
Класс повторяется третий раз — записи журнала дефектов от 2026-08-10, 2026-08-11
и 2026-08-12, — и все три раза стоил зелёного гейта при неработающем поведении.
Отсюда продвижение: правило в конвенции плюс механизация, а после механизации проза
из конвенции убирается.
## Затрагивает
- `docs/conventions/` — новая запись либо раздел существующей: чем судят ответ;
- `docs/conventions/README.md`, перечень механизированного;
- набор шагов `task gate` — место, где живёт проверка правила;
- существующие тесты обработчиков: те, что читают живую карту заголовков.
## Критерии приёмки
- Правило записано в конвенциях одной формулировкой, и названо место
механизации. Оракул — чтение `docs/conventions/README.md`: строка есть, ссылка
ведёт в существующее место.
- Проверка, читающая живую карту заголовков, роняет гейт. Оракул — завести такую
строку в любом тесте и прогнать гейт: ненулевой код возврата.
- Проза из конвенции убрана после механизации, а не осталась дублем. Оракул —
чтение записи: правило названо один раз, дальше ссылка на механизацию.
@@ -0,0 +1,34 @@
# 🧹 Назвать в необратимом, что откат кода не откатывает шаг схемы
- **Тип:** chore
- **Категория:** Очередь
- **Зачем:** Откат бинаря оставляет применённый шаг схемы в силе, и на этом строятся решения о выкладке: сегодня об этом не сказано нигде.
Найдено ревью задачи `oidc-login` 2026-08-12, отчёт триажа —
[review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
раздел «Promote candidates».
Проверено прогоном: шаг схемы применили новой ревизией, затем подняли хранилище
кодом прежней — все правки шага остались на месте. Хранилище считает применённое
по имени файла и о шагах, которых не знает, не догадывается. Обратный шаг
запускается только руками отдельной командой.
Сегодня в разделе «Необратимое» сказано, что применённая миграция не
переписывается. Не сказано главного для выкладки: **откат кода её тоже не
отменяет**, и версия схемы после отката остаётся новее версии бинаря. Задача
`oidc-login` оставила после себя ровно такой случай — защищённое поле файла
переживает откат, а прежний код токена для него не запрашивает.
## Затрагивает
- `CLAUDE.md`, раздел «Необратимое» — строка про применённую миграцию;
- `docs/architecture.md`, раздел эксплуатации — что происходит при откате
выкладки.
## Критерии приёмки
- В «Необратимом» сказано, что откат кода не отменяет применённый шаг схемы, и
названо следствие: схема остаётся новее бинаря. Оракул — чтение раздела.
- Названо, чем откат схемы делается на самом деле, если он всё же нужен. Оракул —
чтение той же строки: команда или «руками, отдельным шагом» с адресом.
- Гейт зелёный. Оракул — `task gate`.
+54
View File
@@ -0,0 +1,54 @@
# 🐞 Вести учёт употреблённых состояний входа на сервере
- **Тип:** fix
- **Категория:** Очередь
- **Зачем:** Одноразовость возврата держится на уборке куки, то есть на браузере: сервер не помнит, какие состояния уже потрачены.
Найдено ревью задачи `oidc-login` 2026-08-12, отчёт триажа —
[review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
остаток пункта 4.
Носитель состояния здесь — кука, которую сервис ставит на время входа: в ней
лежат выданное состояние и проверочный код, и по ней сверяется возврат.
Спека требует, чтобы состояние было одноразовым: возврат с уже употреблённым
отвергается наравне с невыданным. Сегодня это выполняется тем, что носитель
состояния убирается у браузера на возврате — и для обычного человека этого
достаточно: второй раз тот же адрес возврата сверку не пройдёт.
Чего это не закрывает: тот, кто носитель контролирует, поставит его себе заново
и повторит возврат. Отказ тогда наступит только потому, что код у провайдера
одноразовый, — то есть гарантия перенесена на внешнюю систему, чего норма не
допускает.
Цена сегодняшнего состояния невелика, поэтому задача и отложена: код живёт
минуты, а вход у провайдера всё равно нужен. Цена решения — своё хранение
состояний со сроком жизни и его чистка.
## Воспроизведение
1. Пройти вход до конца: получить сессию по возврату от провайдера.
2. Поставить носитель состояния заново — тем же значением, которое сервис выдавал
на первом шаге.
3. Повторить тот же запрос возврата.
4. Сверка состояния проходит, и запрос уходит в обмен. Отказ наступает только
потому, что код у провайдера одноразовый, — то есть одноразовость держит
внешняя система, а не сервис.
## Затрагивает
- обработчики начала входа и возврата;
- место хранения употреблённых состояний: своя коллекция хранилища либо память
процесса — выбор входит в задачу;
- дельта-спека `access`, требование «Вход через внешнего провайдера» — сценарий
«Возврат нельзя переиграть» получает настоящий оракул.
## Критерии приёмки
- Повторный возврат с тем же состоянием отвергается, даже если носитель
восстановлен вручную. Оракул — тест: пройти вход, затем повторить тот же
возврат с заново поставленным носителем; сессия не открывается.
- Состояния не копятся без предела. Оракул — тест либо чтение кода: у записи
состояния есть срок жизни, и просроченные убираются.
- Вход по-прежнему проходит целиком. Оракул — существующий тест входа через
подставного провайдера остаётся зелёным.