Files
transcriber/openspec/changes/archive/2026-08-12-oidc-login/review/report.md
T
av c44f0e7582 HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой:
  собственную регистрацию, вход по паролю и одноразовый код — без этого
  закрытие приёма обходилось двумя запросами
- продление сессии выключено, срок семь суток: иначе отзыв доступа у
  провайдера до сервиса не доходит никогда
- файл записи отдаётся вошедшему по токену файла — пересмотр
  ADR-2026-08-12-file-link-open-but-not-logged
2026-08-12 17:44:22 +03:00

50 KiB
Raw Blame History

Ревью кода: 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 — откат шага больше не падает на валидации и не возвращает открытую регистрацию;
  • Odocs/database.md приведён к коду, коллекция users описана, три числа внесены в таблицу;
  • Q — уровни журнала разведены по адресату, добавлено поле capability;
  • Rauth.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 lowminor (adversary).
  • Две учётные записи провайдера с одной почтой сливаются в одну нашу. Обмен ищет по sub, не найдя — по почте. Требует, чтобы Authelia выдала двум субъектам один адрес; живого провайдера нет. Confidence medium, без оракула → выше major не поднимается и в основные секции не идёт (adversary).
  • Претензия picture тянет до 32 МиБ на вход. MappedFields.AvatarURL заставляет библиотеку скачать URL из ответа провайдера. Confidence lowminor (adversary).
  • Анонимный POST /api/collections/users/request-verification заставляет сервис слать почту. Почта не настроена, потолка запросов нет. Confidence mediumminor (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=0authToken: (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 открывались.