локальный вход задаётся конфигом: заголовки подставляет сам сервис

- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
This commit is contained in:
av
2026-08-23 13:12:47 +03:00
parent 75c6f0168a
commit 52fe31319a
35 changed files with 3548 additions and 144 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-23
@@ -0,0 +1,361 @@
## Context
Сервис не ведёт входа сам: кто пришёл, называет обратный прокси заголовками
`Remote-User`, `Remote-Name` и `Remote-Email`, а сервис верит им, когда
соединение открыто с адреса из перечня `[auth] trusted_proxies`. Имена
заголовков объявлены константами транспорта в
`internal/controller/http/identity.go`, и там же записано, что настройкой их не
делают. Барьер описан в `docs/security.md`, «Периметр», и нормирован спекой
`access`.
На машине разработчика прокси нет, браузер заголовков не ставит, и приложение
локально не открылось бы вовсе. На это место сегодня встаёт подставной прокси
`go run ./cmd/devtools proxy`: отдельный процесс, слушающий свой порт, ставящий
три заголовка и переправляющий запрос сервису. Локальный запуск поэтому идёт в
два процесса, приложение открывают по адресу прокси, а перечень доверенных
адресов в конфиге приходится переставлять на петлевой.
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
Изменение переносит подстановку заголовков в сам сервис и закрывает её
предохранителем.
Ограничение, из которого читается всё остальное: **подстановка заголовков — это
настройка, которой сервис называет пришедшего сам, никого не спросив.** Всякий,
до кого она дотянется, получит чужой архив. Дизайн поэтому наполовину состоит из
того, что подстановке запрещено.
## Goals / Non-Goals
**Goals:**
- Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
настроек, и ничем больше.
- Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
- Настройка, при которой отладочный вход становится открытым входом, роняет
старт, а не работает молча.
- Отладочный запуск виден владельцу в журнале.
**Non-Goals:**
- Уровень журнала, тексты внутренних отказов в ответах, подмена распознавателя,
снятие ограничителя частоты, послабления проверок конфига — ничего этого новый
признак не включает (см. решение 7).
- Своего входа, ролей и проверки допуска сервис по-прежнему не заводит:
подстановка называет пришедшего, а не судит его.
- Автотесты и разработческие сценарии, которым нужна учётная запись, продолжают
ставить заголовок сами и в подстановке не нуждаются.
- Граница работы — заголовки входа. Подмена распознавания в конфиг не
переезжает: `internal/adapter/recognizer/memory.go` подставляется правкой
кода, и решением владельца так и остаётся.
## Decisions
### 1. Где подставляются заголовки: отдельный слой перед узнаванием
**Решено:** отдельный слой цепочки корня приложения, стоящий **перед**
`TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
запроса и ничего больше не делает: учётной записи не заводит, отказов не
выдаёт, в контекст не пишет. Узнавание получает запрос, неотличимый от
пришедшего с сервера.
Слой собирается только при включённом предохранителе и непустой имитации. При
выключенном — цепочка та же, что сегодня, без единого лишнего звена.
Рассмотрено и отвергнуто:
- **Подстановка внутри `TrustedHeaderIdentity`** — узнавание получило бы второй
источник значений и ветку, которой в бою нет. Отлаживалась бы не боевая ветка,
а её отладочный двойник, и это ровно то, чего изменение обязано избежать.
- **Подстановка в точке входа, до сборки цепочки** — подставлять нечего: значения
ставятся на запрос, а не на сервис.
- **Слой снаружи ограничителя частоты** — ограничитель считает по адресу
спрашивающего, и заголовки входа его не касаются. Ставить слой снаружи значит
трогать порядок, который сегодня верен, без единой причины.
Что человек увидит иначе: приложение открывается по адресу сервиса, второго
порта нет, и в журнале запроса стоят те же строки узнавания, что на сервере.
### 2. Область слоя: только корень приложения
Слой вешается туда же, где висит узнавание, — на цепочку корня приложения, — и
область его выводится из того же перечня адресов, а не перечисляется вторым
списком. Проба здоровья, метрики и раздача приложения подстановки не видят: там
учётной записи нет и не нужно.
### 3. `debug = false` при непустой имитации: отказ старта
**Решено:** старт роняется с именем ключа. Не предупреждение и не молчаливое
игнорирование.
Причина: состояние «имитация заполнена, предохранитель выключен» не читается
никак, и обе его прочтения — поломка.
- Человек забыл включить предохранитель. Сервис поднимется, никого не узнает, и
человек будет искать поломку во входе, в прокси и в перечне адресов — везде,
кроме одного булева ключа. Предупреждение в журнале его не спасёт: журнал
локального запуска читают тогда, когда уже что-то сломалось.
- Человек забыл убрать имитацию из боевого файла. Тогда в выкладке лежит конфиг,
которому недостаёт одного слова, чтобы открыть архив всем. Отказ старта — тот
случай, когда поломку ловят на порядок раньше, чем она стоит денег.
Рассмотрено и отвергнуто:
- **Молчаливое игнорирование** — прямо против тона проекта: инвариант «Принятая
запись не теряется молча» описывает ту же болезнь на соседнем предмете.
- **Предупреждение в журнале** — оставляет заполненную имитацию в боевом файле
жить сколько угодно.
Обратная сторона: `debug = true` при пустой имитации отказом **не** считается.
Это законный запуск — признак сам по себе ничего не включает.
### 4. `debug = true` в бою: чем опасно и что ограничивает подстановку
Опасность названа моделью угроз прямо: всякий, кто дотянулся до сервиса,
называет себя кем угодно и получает чужой архив. Подстановка делает это хуже
барьера `trusted_proxies` по двум причинам. Первая: подставляется по
**отсутствию** заголовка, то есть запрос, пришедший мимо прокси, — именно тот,
который сегодня остаётся неузнанным, — получит имя. Вторая: требование к Caddy
«заголовки `Remote-*` перезаписывать, а не пропускать» здесь не помогает вовсе,
потому что перезаписывать нечего.
**Решено — одно ограничение: подставленный заголовок проходит тот же барьер,
что и настоящий.** Слой подставляет значения только тогда, когда адрес
соединения попадает в
`trusted_proxies`. Судит он его **той же функцией**, какой судит узнавание:
разбор адреса пира и сверка с перечнем берутся из одного дома
(`internal/controller/http/identity.go`), а не пишутся вторым списком, — так же,
как решение 6 поступает с именами заголовков. Второй сверщик разошёлся бы с
первым молча: узнавание разворачивает IPv4 в оболочке IPv6, и слой, написавший
свою сверку, отличался бы от него ровно на тех адресах, ради которых заводится.
Это не предохранитель, а согласованность с барьером узнавания: от конфига она не
требует ничего и круга тех, кто мог назваться кем угодно, не расширяет — он и
так очерчен перечнем.
**Адресного предохранителя не делаем.** Решение владельца на чекпоинте,
дословно: «предохранитель по адресам не делаем, полагаемся только на параметр
debug». Рассматривалось требование, чтобы при включённом предохранителе перечень
доверенных адресов состоял только из петлевых записей; оно снято вместе с
предикатом «петлевая запись», который заводился ровно ради него.
Что этим куплено: отладочный вход работает **внутри контейнера** — адрес пира
там принадлежит сети докера, и она же стоит в боевом перечне, — а локальный
прогон не переставляет перечень доверенных адресов на петлевой: петлевые записи
добавляются к тем, что в нём уже стоят.
Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, — а
доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
пришедший обычным путём**. Между боевой выкладкой и открытым входом остаётся
три вещи, и других нет: умолчание предохранителя «выключено»; отказ старта при
заполненной имитации без предохранителя (решение 3); боевой конфиг, который
рендерится шаблоном Ansible, а не копируется с машины разработчика.
Что человек увидит иначе: отладочный вход работает везде, где адрес соединения
попадает в перечень доверенных, — и на машине разработчика, и внутри
контейнера. Перечень при этом остаётся тем, что он есть: границей доверия, а не
признаком отладки.
Рассмотрено и отвергнуто:
- **Требовать петлевой перечень при включённом предохранителе** — снято
решением владельца, причина выше. Держалось предикатом «петлевая запись»,
который заводился ради этой одной проверки.
- **Принудительно слушать петлевой адрес при включённом предохранителе** —
меняет поведение молча (адрес прослушивания задан числом порта, и человек
увидел бы «порт занят никем»), и закрывает ровно то, что решение покупает:
внутри контейнера сервис слушает не петлю.
- **Новый ключ `[server] listen`** — публичная поверхность настроек ради
предохранителя, которого решением владельца нет.
- **Вырезать подстановку из боевой сборки тегом сборки** — сборка образа в гейте
не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег, забытый в одной ступени,
дал бы ровно ту тишину, которой избегает пункт 3.
### 5. Уже пришедший заголовок: подставляем только при отсутствии `Remote-User`
**Решено:** решение принимает **наличие `Remote-User`**, а не его значение.
Заголовок есть в любом числе значений — слой не трогает запрос вовсе и передаёт
его дальше как есть. Заголовка нет ни одного — слой ставит всю тройку
`Remote-*`, которой распоряжается: названные имитацией — значением из конфига,
не названные — удаляет.
Почему по наличию, а не по значению: узнавание отвергает запрос с более чем
одним `Remote-User`, и это его главная защита от прокси, который заголовок
добавляет вместо замены. Слой, который «дополнил бы пустое место», сам создал бы
второе значение и превратил бы законный отказ в отладочный проход.
Почему удаляем не названные имитацией: запрос без `Remote-User`, но с
`Remote-Email`, иначе собрал бы личность из конфига и из присланного — логин
свой, почта чужая. Слой владеет тройкой целиком или не трогает её вовсе.
Рассмотрено и отвергнуто:
- **Заменять всегда** — сервис, стирающий сказанное прокси, перестаёт быть
похожим на боевой, и ветки «пришло два значения» и «заголовок с недоверенного
адреса» становятся локально невоспроизводимыми.
- **Подставлять по пустому значению `Remote-User`** — пустое значение штатно
шлёт прокси там, где никого не назвал; подстановка на этом месте назвала бы
человеком того, кому провайдер отказал.
### 6. Имена заголовков: ключами конфига, но перечень порождается константами
Цена названа прямо: сегодня имена — константы транспорта, и у них один дом.
Секция, где имя заголовка служит именем ключа, даёт этим именам **второе
упоминание**, а второе упоминание расходится с первым молча.
**Решено:** имя ключа секции — имя заголовка, но набор принимаемых имён
порождается теми же константами транспорта. Ключ, не совпавший ни с одной из
них, роняет старт и называет принимаемые имена. Сравнение идёт по каноническому
виду имени заголовка, потому что в HTTP имя нечувствительно к регистру, а в TOML
ключ чувствителен.
Так второго **дома** не появляется: дом остаётся один, а конфиг лишь называет
его содержимое, и совпадение сверяет машина, а не внимательность.
Рассмотрено и отвергнуто:
- **Произвольная карта имён** — опечатка `Remote-Usr` даёт «сервис меня не
узнаёт» без единого следа, а выгоды нет: сервис читает ровно три имени и
четвёртое читать не умеет.
- **Свои имена ключей вместо имён заголовков** (`login`, `name`, `email`) — самое
дешёвое для инварианта, но теряет то, ради чего секция заводится: конфиг
перестаёт выглядеть как то, что ставит Caddy, и владелец назвал форму прямо.
Оставлено вариантом на случай, если человек решит платить иначе.
Замечание по форме: в TOML пара пишется через `=`, а не через `:`. Пример
владельца записывается так:
```toml
[auth.test_headers]
Remote-User = "foobar"
Remote-Email = "foobar@example.com"
```
### 7. Что означает `[server] debug` и чего он не включает
Признак означает одно: **этот прогон идёт на машине разработчика, и сервису
позволено подставить то, что в бою даёт окружение.** Сегодня подставляется
ровно одна вещь — заголовки входа.
Чего признак **не** включает и включать не должен без отдельного решения
человека:
- уровень журнала (он зашит и настройкой не является);
- тексты внутренних отказов и следы стека в ответах;
- вывод тел запросов и ответов внешних сервисов;
- снятие или послабление ограничителя частоты;
- подмену распознавателя памятью — это подстановка в коде,
`internal/adapter/recognizer/memory.go`, и настройкой она не делается;
- послабление любой проверки старта;
- открытие любого адреса неузнанному.
Правило для следующей задачи: новое поведение вешается на этот ключ только
решением владельца и получает **свою** строку в спеке `access` или в спеке своей
capability. Ключ — предохранитель с закрытым перечнем следствий, а не режим с
открытым.
### 8. Где живёт проверка
Проверка охватывает две секции разом — `[server] debug` против
`[auth] test_headers` и `[auth] trusted_proxies`, — и потому не помещается ни в
`AuthConfig.Validate()`, ни в проверку секции сервера. **Решено:** метод на
корневой `Config`, названный своим предметом, зовётся из `cmd/transcriber` рядом
с тремя имеющимися проверками. Закрыть расхождение «единого места проверки нет»,
названное в `docs/conventions/config.md`, он не пытается: это отдельная работа.
Рассмотрено и отвергнуто: передавать признак отладки внутрь
`AuthConfig.Validate()` — секция начала бы знать о чужой секции ради одного
булева значения.
### 9. Журнал
- **Старт с включённой подстановкой** — уровень `WARN`, один раз. Адресат —
владелец, и сообщение ровно того рода, который конвенция называет «может стать
проблемой»: сервис называет пришедшего сам. Поля: `capability` `access`,
перечень **имён** подставляемых заголовков. Значений в строке нет: логин — это
ключ к чужому архиву, и спека `access` запрещает его печатать наравне с
адресом почты.
- **Запрос, которому заголовки подставлены** — уровень `DEBUG`. Случай штатный и
частый: так выглядит каждый запрос локального прогона, и `INFO` затопил бы
журнал. Поля те же, что у узнавания: `http.peer_addr`, `capability`,
`transport`.
- **Запрос, которому подставить нельзя** (предохранитель включён, но адрес пира
не доверенный) — уровень `WARN`, своей строкой. Поля: `http.peer_addr`,
`capability`, `transport`; подставляемых значений в ней нет.
Прежняя посылка «узнавание уже пишет здесь предупреждение» неверна, и это
проверено по коду. Ветка узнавания выбирается по числу значений `Remote-User`
**до** сверки адреса пира: заголовка нет — пишется `Debug("Request carries no
login header")`, и запрос уходит дальше. Предупреждение `Login header came
from an untrusted peer` достижимо только тогда, когда заголовок **есть**, а
подстановка работает ровно при его отсутствии. На отказном пути подстановки
предупреждения не бывает никогда.
Без своей строки самый частый локальный отказ остаётся без следа: браузер идёт
на `localhost`, тот разрешается в `::1`, а в `trusted_proxies` по образцу
стоит `127.0.0.1`. Человек видит `401` на всём приложении, заполненную секцию
имитации, включённый предохранитель — и ни одной строки о том, что подстановка
не сработала. Отсюда же требование к образцу конфига: рядом с `127.0.0.1` в
перечне обязан стоять `::1`.
- Строка `Account created from login header` работает как работала: первый
запрос локального прогона заводит учётную запись и виден на `INFO`.
### 10. Подкоманда `cmd/devtools proxy` убирается целиком
**Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
входить локально не остаётся тоже.
Цена названа и принята: поломки контура — два значения `Remote-User`, заголовок
с недоверенного адреса, цепочка `X-Forwarded-For` — местным инструментом больше
не воспроизводятся. Воспроизводит их теперь только автотест, ставящий заголовок
сам.
Уходит вместе с подкомандой: её флаги, шапка пакета `cmd/devtools/main.go` — она
сегодня говорит «подкоманд две» и описывает прокси, — строка подкоманды в
`usage()` и упоминания прокси в документах. Адреса перечислены шагом 4.4
`tasks.md`.
Рассмотрено и отвергнуто владельцем:
- **Оставить как есть** — два способа войти локально, и документация обязана
каждый раз говорить, какой из них чей.
- **Оставить, сузив назначение** до воспроизведения поломок контура — тот же
второй способ, только не названный в инструкции локального запуска.
## Risks / Trade-offs
- **Отладочный вход уезжает в боевой конфиг** → умолчание предохранителя
«выключено» и отказ старта при заполненной имитации без предохранителя
(решение 3). Проверка идёт на старте, до приёма трафика, и ловит она ровно
этот случай — заполненную имитацию при выключенном предохранителе.
- **Разработчик оставил `debug = true` и выложился** → выкладка рендерит конфиг
из шаблона Ansible, а не копирует локальный файл. Другой защиты нет, и это
принято решением владельца (решение 4): боевой сервис с включённым
предохранителем и заполненной имитацией отдаст архив всякому, чей запрос
пришёл через обратный прокси, то есть всякому, кто пришёл обычным путём.
- **Имя заголовка в конфиге разошлось с константой транспорта** → перечень
принимаемых имён порождается константами, неизвестный ключ роняет старт
(решение 6).
- **Поломки контура перестали воспроизводиться локально** → подкоманда `proxy`
убрана (решение 10); остаются автотесты, которые ставят заголовок сами.
- **Ключ `debug` обрастёт чужими следствиями** → закрытый перечень в решении 7 и
правило «новое следствие — своя строка в спеке».
- **Тестовый пользователь копится в базе.** Каждый новый логин в имитации заводит
учётную запись, а удалять учётные записи сервис не умеет. Локальная база
ронется и пересоздаётся свободно; в бою подстановка выключена, а включённая
была бы поломкой много хуже лишней учётной записи.
## Migration Plan
Шага схемы изменение не требует, данных не трогает. Существующий конфиг
продолжает работать без правки: умолчание предохранителя — «выключено», секция
имитации по умолчанию пуста, и цепочка слоёв при этом та же, что сегодня.
Откат — удаление двух ключей из конфига.
## Open Questions
Развилок не осталось: все закрыты владельцем на чекпоинте.
@@ -0,0 +1,74 @@
## Why
Чтобы открыть приложение на своей машине, сегодня мало запустить сервис: рядом
надо поднять второй процесс — подставной обратный прокси из оснастки, — потому
что сервис узнаёт пришедшего по заголовку, а браузер заголовков не ставит. Два
процесса вместо одного, свой порт у каждого и правило «приложение открывают по
адресу прокси, а не по адресу сервиса» — всё это надо помнить и объяснять
каждому, кто пришёл в проект.
Владелец назвал желаемое прямо: один бинарник, а различия между запусками
задаются конфигом. Тестовый прогон должен подниматься тем же
`go run ./cmd/transcriber -c config.toml`, что и всякий другой, и отличаться от
боевого только содержимым файла настроек.
## What Changes
- В секцию `[auth]` добавляется подсекция `test_headers` — имитация заголовков,
которые сервису на сервере ставит обратный прокси. Ключи подсекции задают
значения, которыми сервис назовёт пришедшего сам, когда тот пришёл ни с чем.
- В секцию `[server]` добавляется предохранитель `debug` — признак отладочного
запуска со значением по умолчанию «выключено». Подстановка заголовков работает
только при включённом признаке.
- Старт получает новые правила отказа. Заполненная имитация при выключенном
предохранителе роняет сервис, а не игнорируется молча; имитация, при которой
узнавание не состоится ни при каком запросе — неизвестное имя заголовка,
отсутствующий или негодный `Remote-User`, — роняет его тоже.
- Владелец обязан видеть отладочный запуск в журнале сервиса — предупреждением
при старте и на каждом подставленном запросе.
- Подставной обратный прокси `go run ./cmd/devtools proxy` убирается целиком:
всё, ради чего его поднимали, делает сам сервис, и второго способа входить
локально не остаётся. Решение владельца на чекпоинте; цена — поломки контура
местным инструментом больше не воспроизводятся.
- Образец конфига и памятка описывают локальный вход через настройки вместо
запуска второго процесса.
## Capabilities
### New Capabilities
Новых capability изменение не заводит: предмет прежний — кто пришёл в сервис и
пускают ли его дальше.
### Modified Capabilities
- `access`: узнавание получает второй источник заголовков — настройки сервиса —
и правила, при которых этот источник законен. Требования: подстановка идёт
только при включённом предохранителе, только при отсутствии пришедшего
заголовка, только с адресов, которым сервис и так верит; заполненная имитация
без предохранителя роняет старт; отладочный запуск виден в журнале, а значения
подставленного заголовка в журнале по-прежнему нет.
## Impact
- Настройки: `internal/config` — структуры секций `[server]` и `[auth]`,
умолчания, проверки старта. Имя нового ключа конфига необратимо и решается
человеком.
- Вход: `internal/controller/http` — цепочка слоёв корня приложения и
узнавание по заголовку.
- Точка входа: `cmd/transcriber` — порядок проверок при старте и сборка
цепочки.
- Оснастка: `cmd/devtools` — подкоманда `proxy` удаляется вместе с флагами,
шапкой пакета и строкой `usage()`.
- Документы: `config.example.toml`, `CLAUDE.md`, `README.md`,
`docs/security.md`, `docs/conventions/config.md`, `docs/architecture.md`
везде, где локальный вход описан через второй процесс.
- Граница домена: `docs/passport.md`, «Управление учётными записями». Сегодня
он утверждает, что кто пришёл, сервис не решает никогда, и что исключений у
этого больше нет. Изменение заводит исключение, и паспорт обязан назвать его
одной строкой с границей — предохранитель `[server] debug`, выключенный по
умолчанию, — и ссылкой на спеку `access`.
- Периметр: изменение заводит настройку, которой сервис пускает кого угодно.
Машиной боевая поломка не исключена: сервис с включённым предохранителем и
заполненной имитацией поднимется на любом перечне доверенных адресов. Что
стоит между боевой выкладкой и открытым входом — в `design.md`, решение 4.
@@ -0,0 +1,725 @@
# Триаж ревью — `config-test-headers-login`, 2026-08-23
## Сводка
- **Режим прогона:** по графу. Стадия — ревью кода.
- **База диффа:** `origin/master` совпадает с `HEAD`; предмет ревью — рабочее
дерево, включая неотслеживаемые файлы.
- **Размер:** 16 изменённых файлов + 4 новых (`test_headers.go`,
`test_headers_test.go`, `substitute.go`, `substitute_test.go`), ~240 строк
правок в коде; удалена подкоманда `proxy` пакета `cmd/devtools`.
- **Сложность:** новый слой транспорта, новая подсекция конфига с проверкой
старта, снятая оснастка; трогает барьер входа.
- **Метка:** `large`. Обоснование разметки — своих тем проекта сверх ядра не
заведено, поэтому `basics` не запускается: все темы разобраны именными
проходами.
- **Состояние гейта:** ЗЕЛЁНЫЙ. `task gate BASE=origin/master`, 13 шагов,
`go test -race` с работающим детектором, `golangci-lint` 0 issues,
`govulncheck` без находок. Сообщено проходом `autotests`; триаж гейт
не перезапускал.
- **Находок на входе:** 21 (A1; S1S4; C1C5; R1R3; V1V3 + три свойства
`adversary` без пути; O1). **Осталось в первых двух секциях:** 7.
### План с исходом по каждой теме
| Тема | Дом | Глубина | Кто закрывает | Исход |
|---|---|---|---|---|
| `requirements` | `openspec/specs/access/spec.md` + дельта | разбор | `specs` | **закрыта** проходом `specs`, 4 находки (S1S4) |
| `autotests` | `CLAUDE.md`, «Гейт» | — | `autotests` | **закрыта** проходом `autotests`, 1 находка (A1), гейт зелёный |
| `conventions` | `docs/conventions/` целиком | разбор | `code` | **закрыта** проходом `code`, 5 находок (C1C5) |
| `architecture` | `docs/architecture.md` + источник `docs/passport.md` | доказательство | `architecture` | **закрыта** проходом `architecture`, 3 находки (R1–R3) + 2 замечания «дешевле до мерджа» |
| `security` | `docs/security.md`, «Периметр» | доказательство | `adversary` | **закрыта** проходом `adversary`, 3 находки (V1–V3) + 3 свойства без построенного пути; пути обхода **самой подстановки** не построено (6 отрицательных проб) |
| `operations` | `docs/architecture.md`, «Эксплуатация» + источник `docs/database.md` | доказательство | `ops` | **закрыта** проходом `ops`, 1 находка (O1) + подтверждённый факт по постмортему 2 |
| тема проекта | своих тем нет | — | `basics` не запускался | **дома у темы нет**; `basics` на метке `large` не запускается по плану — это не пропуск |
**Тем без отчёта нет.** Все шесть заявленных домов дали отчёт.
### Сигнал о заниженной метке
**Пришёл от одного прохода, возражений нет.** `review-code` метку `large`
подтвердил и занижения не увидел. `review-basics` на этой метке не запускался,
поэтому второго независимого голоса нет — согласия двух проходов здесь не было
и быть не могло.
### Находка о самом прогоне
**Ни один из шести проходов не объявил свой потолок.** Устав велит называть
строкой, сколько находок показано, каков был потолок и что осталось за срезом;
такой строки нет ни у `specs`, ни у `code`, ни у `architecture`, ни у
`adversary`, ни у `ops`, ни у `autotests`. Это ровно тот повторяющийся пробел,
который записан в [docs/review.md](../../../../docs/review.md) строками 25–32 по
прогону `telegram-enabled-flag`: «находок больше нет» в отчёте прохода
неотличимо от «больше не поместилось». Механизации нет; сверка триажа —
единственное, что осталось, и она этот пробел только называет, а не закрывает.
### Дедупликация
- `A1` + `S2` — одна причина (строка журнала старта строится непроверенной
функцией). Сведены; **понижены**: триаж добыл им однократный оракул прогоном
бинарника, см. ниже.
- `S3` + `O1`**две причины, а не одна**, вопреки подсказке задания: первая —
отброшенные метаданные разбора (`MetaData.Undecoded()`); вторая — зашитый
уровень журнала. Разведены в находки 1 и 2: чинятся порознь, и починка одной
не закрывает другую.
- `S4` + второе звено `O1` — одна причина (зашитый `LevelInfo`). Сведены в
находку 1.
- `R3` + замечание `specs` про два места — одна причина. Сведены в находку 6
вместе с `C1`: обе легли на одну строку `server.go:44`.
- `R1` + `R2` — разные причины в разных домах, сведены в одну находку 7 двумя
названными правками: обе — один абзац, обе про то, что нормативный документ
описывает изъятие мягче, чем оно есть.
- **Согласие проходов приоритет поднимало, `Confidence` — нет.** Ни одна
формулировка ниже не опирается на «подтверждено N проходами».
---
## Блокирует мердж
### 1. Каждый отладочный след нового входа пишется уровнем, который в настоящем прогоне выключен: разработчик получает `401` на всём приложении и пустой журнал
- Файл: `cmd/transcriber/main.go:39-41`; `internal/controller/http/substitute.go:97-99`;
`internal/controller/http/identity.go:106-110`
- Severity: major
- Confidence: high
- Оракул: **свой прогон настоящего бинарника.**
`go build -o $S/transcriber ./cmd/transcriber`, конфиг из `config.example.toml`
с `debug = true`, `trusted_proxies = ["127.0.0.1", "::1"]` и правильной секцией
`[auth.test_headers]`, запрос `curl http://127.0.0.1:18099/app/me``200`.
В журнале — стартовая `WARN "Identity headers are substituted from configuration" headers=[Remote-User]`
и `INFO "Account created from login header"`; **строки
`"Identity headers substituted from configuration"` нет ни одной**.
Второй прогон, тот же конфиг с опечаткой в имени секции → `код=401`, и в
журнале ровно `INFO "Incoming request" ... http.status_code=401` без единой
строки о причине.
Подтверждающая команда:
`grep -rn "\.Debug(" --include=*.go internal/ cmd/ | grep -v _test` → 6 мест;
`grep -rn "LevelDebug\|Level:" --include=*.go cmd/ internal/ | grep -v _test`
→ единственная точка сборки логгера `cmd/transcriber/main.go:40`
`Level: slog.LevelInfo`, ключа конфига у уровня нет, `os.Getenv`/`os.LookupEnv`
в непроверочном коде нет вовсе.
- Последствие: **все шесть отладочных строк сервиса недостижимы в любом
настоящем прогоне** — и обе, которые завела эта задача. Дельта-спека при этом
требует отладочную строку прямо (`specs/access/spec.md:193`: «Запрос, которому
заголовки подставлены, SHALL писаться на отладочном уровне») и в том же файле,
строка 394, сама формулирует цену: «поломка, записанная уровнем, который в бою
выключен, не записана вовсе». Критерий приёмки 9 — различимость «узнан
подстановкой» от «узнан прокси» на уровне запроса — в действительности не
достигнут ничем. Второе звено дороже первого: `identity.go:106` пишет «Request
carries no login header» тоже отладочным уровнем, а путь «заголовка нет,
предохранитель не включён» стал **единственным путём первого локального
запуска у каждого** после удаления подкоманды `devtools proxy`. Человек видит
`401` на всём приложении и пустой журнал — то самое «без единого следа», ради
устранения которого заведены отказы старта.
- Предложение: развилка ниже.
- Найдено проходами: `specs` (S4), `ops` (O1, второе звено); оракул добыт триажем
заново.
- **Действие: развилка.**
> Отладочные строки сервиса не печатаются никогда: уровень журнала зашит
> `LevelInfo` в единственной точке сборки логгера, ключа конфига у него нет.
> Спека этой задачи требует отладочную строку подстановки, и критерий 9 на ней
> стоит. Что делаем?
>
> 1. **Завести ключ уровня журнала** (`[server] log_level` либо уровень,
> выводимый из `[server] debug`). Спека прямо запрещает предохранителю
> трогать уровень журнала, значит это отдельный ключ и правка спеки
> `access` — расширение scope и новое имя ключа конфига, а имя ключа
> `CLAUDE.md` относит к необратимому и требует спросить человека.
> 2. **Поднять уровень двух строк до `Info`** — «подстановка сработала» и
> «заголовка входа нет». Обе штатны и частотны; в бою первая не печатается
> вовсе (предохранитель выключен), вторая станет строкой на каждый
> неузнанный запрос. Правка спеки нужна тоже — она уровень назначает
> поимённо.
> 3. **Снять требование отладочной строки из спеки и критерий 9 вместе с ним**,
> записав строкой цену: наблюдаемости подстановки на уровне запроса у
> сервиса нет, стартовой `WARN`-строки признано достаточно.
### 2. Опечатка в имени самой секции `[auth.test_headers]` не судится ничем: сервис поднимается, никого не узнаёт и следа не оставляет — ровно тот исход, ради которого заведён второй отказ старта
- Файл: `internal/config/config.go:242-256` (метаданные разбора отбрасываются);
`internal/config/test_headers.go:33-43`
- Severity: major
- Confidence: high
- Оракул: **свой прогон, два уровня.**
(1) Модульный: декодер `BurntSushi/toml v1.5.0` на входе
`[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"` даёт
`decode err=<nil>`, `undecoded=[auth.test_headrs auth.test_headrs.Remote-User]`,
`Server.Debug=true len(TestHeaders)=0 substituting=false`,
`ValidateTestHeaders err = <nil>`. Контроль: та же опечатка **внутри** секции
(`Remote-Usr`) ловится —
`auth: секция [auth.test_headers] называет заголовок, которого сервис не читает: Remote-Usr`.
(2) Сквозной: настоящий бинарник на конфиге с `[auth.test_headrs]` поднимается
без единого предупреждения, `curl /app/me``401`, в журнале только
`INFO "Incoming request" ... http.status_code=401`.
- Последствие: `design.md` заявляет защиту словами «опечатка в имени иначе
кончается сервисом, который никого не узнаёт, без единого следа», и защита эта
покрывает опечатку в имени **ключа**, но не в имени **секции**. Разница для
человека нулевая — он ошибается в обеих строках одинаково, — а исход
противоположный: в одном случае отказ старта с именем ключа, в другом
безмолвный `401` на всём приложении. Стоимость несёт каждый первый локальный
запуск, потому что подкоманды `devtools proxy` больше нет и обходного пути не
осталось. Отдельно: та же дыра проглатывает любую опечатку в **любой** секции
конфига, не только в новой.
- Предложение: развилка ниже.
- Найдено проходами: `specs` (S3), `ops` (O1, первое звено); оракул добыт триажем
заново.
- **Действие: развилка.**
> `toml.DecodeFile` возвращает `MetaData`, и в нём лежит перечень ключей,
> которых структура не знает. Сервис его отбрасывает, поэтому опечатка в имени
> секции проходит молча. Что делаем?
>
> 1. **Узкая правка в границах задачи:** при `[server] debug = true` и пустой
> секции имитации ронять старт (или писать `WARN`), если
> `MetaData.Undecoded()` называет что-нибудь под `auth`. Ловит ровно этот
> класс, не трогает остальной конфиг, требует одного нового абзаца в
> дельта-спеке.
> 2. **Правило шире задачи:** судить `MetaData.Undecoded()` на всём конфиге —
> непонятый ключ роняет старт с его именем. Закрывает опечатки во всех
> секциях разом, но это новая норма в `docs/conventions/config.md` и
> отдельный change: сегодняшние конфиги на сервере могут нести ключи,
> которых структура уже не знает (постмортем 2 подтвердил, что откат
> бинарника поверх нового конфига сегодня безопасен **именно** потому, что
> лишние ключи молча игнорируются, — правило это свойство снимет).
> 3. **Ничего не делать в коде**, а записать исход строкой в спеке и в
> `config.example.toml`: «опечатка в имени секции даёт `401` без следа;
> сверяйте имя секции по образцу». Дешевле всего и честнее молчания.
### 3. Спека объявляет матрицу отказов старта полной и называет три случая, а код даёт четвёртый: следующий change снимет его как самодеятельность, и одно из двух значений секции начнёт теряться молча
- Файл: `internal/config/test_headers.go:86-93`;
`openspec/changes/config-test-headers-login/specs/access/spec.md:109`;
`openspec/changes/config-test-headers-login/tasks.md:227-231`
- Severity: major
- Confidence: high
- Оракул: **свой прогон.** Вход
`[server] debug = true` + `[auth.test_headers]` с `"Remote-User" = "one"` и
`"remote-user" = "two"`: декодер даёт `len(c.Auth.TestHeaders) == 2`,
`HeaderSubstitution()` возвращает `map[Remote-User:two]` — **значение `"one"`
потеряно**, — а `ValidateTestHeaders` роняет старт строкой
`auth: в секции [auth.test_headers] два ключа называют один заголовок: имя
заголовка нечувствительно к регистру, и одно из значений потерялось бы молча`.
Дословный текст спеки на строке 109: «Отказом MUST быть **каждый из трёх**
случаев»; сценария про совпадение канонических имён в дельте нет. Дословный
текст `tasks.md:227`: «Матрица отказов старта **полна и тотальна**: у каждой
комбинации новых ключей объявлен исход».
- Последствие: реализатор, читающий спеку как полную матрицу — а `tasks.md`
велит читать её именно так, — снимет четвёртую проверку как не подпёртую
требованием. После снятия конфиг с двумя ключами разного регистра поднимает
сервис, схлопывает их в один и молча берёт тот, который последним лёг в карту:
порядок перебора TOML не гарантирован, и человек получает то одно значение, то
другое от запуска к запуску. Это в точности класс «молчаливая потеря
значения», ради которого проверка и написана. Правка целиком в границах
задачи: дельта-спека — часть этого change.
- Предложение: дописать четвёртый случай в требование «Настройка, открывающая
вход всем, роняет старт» и завести к нему сценарий; поправить `tasks.md:227`
так, чтобы перечисление читалось «предохранитель × (пустая / заполненная /
заполненная негодно / заполненная неоднозначно)».
- Найдено проходом: `specs` (S1); оракул добыт триажем заново.
- **Действие: инлайн.**
---
## Стоит исправить сейчас
### 4. Бинарник читает `.env`, проект называет его секретосодержащим, а `.gitignore` его не игнорирует: первый же ключ, положенный туда, уезжает в git необратимо
- Файл: `.gitignore` (нет записи); `.dockerignore:21`;
`cmd/transcriber/main.go:114-117`
- Severity: major
- Confidence: high
- Оракул: **свои команды.** `git check-ignore -v .env` → пусто, `exit=1` (файл не
игнорируется). `.dockerignore:19-21` — комментарий «Настройки с секретами.
Образ берёт конфиг на сервере, а не из дерева.» и следом строка `.env`.
`cmd/transcriber/main.go:115``godotenv.Load()`. Прогон настоящего бинарника
печатает при каждом старте
`level=WARN msg="Warning: .env file not found, using system environment variables"`.
При этом `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/ | grep -v _test`
**ни одного совпадения**: переменных окружения сервис не читает нигде.
- Последствие: инвариант `CLAUDE.md` — «**Секрет не покидает конфиг.** Ключ
SpeechKit и пара ключей Object Storage не попадают в git… Нарушение
необратимо: утёкший ключ отзывают и меняют вручную во всех местах выкладки.
**critical**». Путь к нарушению короткий и сервис сам его подсказывает:
стартовая `WARN`-строка сообщает владельцу, что `.env` ожидается, тот заводит
файл с ключами — и `git add .` кладёт его в индекс, потому что
`.gitignore` про `.env` не знает. Единственный барьер — `gitleaks git --staged`
на pre-commit, а он судит по образцам и на произвольном ключе SpeechKit молчать
вправе. Сама подсказка при этом ложная: ключи из `.env` не сработали бы, читать
переменные окружения сервису нечем. **`critical` не ставлю:** пути до
настоящего секрета в git я не построил — работающего мотива положить ключ в
`.env` у владельца нет, — значит по контракту выше `major` эта находка не
поднимается.
- Предложение: строку `.env` в `.gitignore` рядом с `config.toml` — это одна
строка и весь инлайн. Мёртвый `godotenv.Load()` вместе с зависимостью и
вводящей в заблуждение `WARN`-строкой уходит задачей: правка не этого change.
- Найдено проходом: `adversary` (свойство без построенного пути); оракул и
переформулировка — триаж (проход утверждал, что `.env` читается, но не заметил,
что читать из него некому).
- **Действие: инлайн** — только строка в `.gitignore`. Снятие `godotenv`
задачей, см. «Урожай».
### 5. Чужой адрес почты, занятый первым пришедшим, навсегда и молча оставляет без почты его настоящего владельца — и `docs/security.md` утверждает обратное
- Файл: `internal/controller/http/identity.go:134-138`;
`internal/adapter/repo/sqlite/identity.go:77-110`; текст
`docs/security.md:71-76`
- Severity: major
- Confidence: high
- Оракул: **свой падающий сценарий, прогнан на настоящей поверхности сервиса**
(`setupEnv` + `AppChain`, база SQLite во временном каталоге, маршрут
`/app/me`). Два шага:
1. Запрос с `Remote-User: squatter` и `Remote-Email: victim@corp.example` с
доверенного адреса → `200`. `SELECT email FROM users WHERE provider_login='squatter'`
`victim@corp.example`.
2. Запрос настоящего владельца: `Remote-User: victim`,
`Remote-Email: victim@corp.example``200`, `Account created from login header`.
`SELECT email ... WHERE provider_login='victim'`**`""`**. Строк журнала о
занятом адресе — ноль.
Второй прогон показывает асимметрию правила прямо: два значения `Remote-User`
`401`; два значения `Remote-Email``200`, и в базу ложится **первое**
(`attacker-first@example.com`).
- Последствие: `docs/security.md:71-74` называет прокси, **добавляющий** заголовок
вместо замены, главной угрозой периметра и пишет: «Половину этой беды сервис
закрывает сам: запрос с двумя значениями `Remote-User` не узнаёт никого».
Утверждение верно ровно для одного заголовка из трёх. `Remote-Name` и
`Remote-Email` берутся `Get()` — первым значением, то есть присланным анонимом,
— и логин от них не защищает. Уникальный индекс `idx_users_email` не даёт
двум записям делить адрес, `EnsureUser` найденную запись не переписывает
никогда, а ветвь отката заводит настоящего владельца через
`insertUser(tx, login, name, "")` **без строки журнала**. Порча тихая и
постоянная: обратного пути у неё в коде нет. **Сегодня цена почти нулевая**
`contract.UserAccount` поля `Email` не имеет вовсе, приложение почту не
показывает, доступ по ней не выдаётся; поэтому не блокирует мердж. Цена
появляется в тот день, когда почту начнут читать (в бэклоге лежит
`api-tokens`), и к этому дню колонка уже будет испорчена молча.
- Предложение: судить `Remote-Name` и `Remote-Email` тем же правилом, что
`Remote-User``len(r.Header.Values(name)) != 1` даёт пустое значение, а не
первое; ветвь отката уникальности почты обязана оставить строку журнала
видимого уровня.
- Найдено проходом: `adversary` (V1); оракул добыт триажем заново, обоими
звеньями.
- **Действие: развилка.**
> Правка меняет боевой путь узнавания, а не отладочную подстановку, — то есть
> выходит за scope этой задачи и требует нового требования в спеке `access`
> (сегодня спека нормирует многозначность только для `Remote-User`). Что
> делаем?
>
> 1. **Взять в этот change:** три строки в `identity.go` + строка журнала в
> ветви отката + абзац в требование спеки. Логика ровно та же, что слой
> подстановки уже применил к себе («Запрос без `Remote-User`, но с
> `Remote-Email` иначе собрал бы личность из двух источников»), — правило
> просто не донесено до соседних двадцати строк.
> 2. **Отдельной задачей**, потому что предмет другой и цена сегодня нулевая:
> почту никто не читает. Тогда `docs/security.md:71-74` надо поправить в
> этом мердже — фраза «половину этой беды сервис закрывает сам» описывает
> периметр точнее, чем он есть, и ссылаться на неё нельзя.
> 3. **Только документ:** записать асимметрию как принятую цену, потребовав от
> прокси перезаписи всех трёх `Remote-*` (требование к контуру уже записано,
> см. «Недоступно проверке»), и колонку почты не трогать.
### 6. Страж, отменяющий слой подстановки, не защищён ни одной проверкой и считает «подставляем ли» вторым выражением: убрать его можно молча, а разойтись он может сам
- Файл: `internal/controller/http/server.go:27-49` (строка 44
`if len(substitution) > 0`); `internal/config/test_headers.go:33-43`;
`internal/controller/http/substitute_test.go:229-246`
- Severity: minor
- Confidence: high
- Оракул: чтение с двумя дословными сопоставлениями.
(1) Комментарий `server.go:30-31` утверждает: «Судит эту пустоту точка входа:
перечень приходит сюда уже готовым», — а строка 44 судит её второй раз своим
выражением. Тождество двух формулировок (`c.Server.Debug && len(TestHeaders) > 0`
против `len(substitution) > 0`) сегодня держится только тем, что
`HeaderSubstitution` возвращает `nil` в ложной ветке; ничем другим оно не
закреплено.
(2) Единственная проверка на пустую имитацию — `TestEmptySectionLeavesHeadersUntouched`
— судит три вещи: код `401`, неизменное число учётных записей и отсутствие в
журнале строки `"Identity headers are not substituted"`. Строка эта пишется
**только** в ветке недоверенного пира (`substitute.go:80`), а запрос проверки
идёт с `trustedPeer`, — значит утверждение истинно при любом исходе. Первые два
утверждения тоже истинны при снятом страже: заголовка `Remote-User` в запросе
нет, слой удалил бы `Remote-Email` и пропустил дальше, узнавание всё равно
ответило бы `401` и записи бы не завело.
Мутационную сверку — снять страж и убедиться, что проверка зеленеет, — не
делал: `CLAUDE.md`, «Запреты», прямо запрещает мутационную сверку оракулов.
- Последствие: сценарий дельта-спеки «Пустая секция имитации ничего не трогает»
не проверен ничем, хотя в наборе проверок выглядит проверенным, — а именно к
этому сценарию спека привязывает своё «поведение, которого в бою нет»
(срезанный `Remote-Email` у запроса без `Remote-User`). Дубль предиката при
этом расходится в первой же правке, дающей непустой признак при пустой карте:
исход будет «старт предупреждает о подстановке, а слоя в цепочке нет» — и
предупреждение соврёт.
- Предложение: `AppChain` принимает вторым значением сам предикат
(`substituting bool`) вместо того, чтобы выводить его из длины карты; проверка
судит **дошедшее до узнавания** — что `Remote-Email`, посланный запросом,
доехал до обработчика нетронутым, — а не журнал.
- Найдено проходами: `code/техника` (C1), `architecture` (R3), `specs`
(замечание про два места).
- **Действие: инлайн.**
### 7. Два нормативных документа описывают изъятие мягче, чем оно есть: паспорт обосновывает его доводом, который в изъятии не действует, а принцип архитектуры запрещает ровно то, что сам приводит примером
- Файл: `docs/passport.md:65-75`; `docs/architecture.md:84-91`
- Severity: minor
- Confidence: high
- Оракул: дословное сопоставление внутри одного изменения.
(1) `docs/passport.md:73` — «подставленное имя проходит то же узнавание, что и
пришедшее, **а кого пускать, по-прежнему решает провайдер**». Дельта-спека в
том же изменении: «Подстановка — это настройка, которой сервис называет
пришедшего сам, **никого не спросив**» (`substitute.go:32-33` теми же словами).
Провайдера в изъятии нет вовсе. Там же паспорт пишет «*Изъятие одно, и оно про
прогон без контура*» — код таким не ограничен: подстановка работает везде, где
адрес пира доверенный, и `docs/security.md:97-102` это признаёт прямо
(«Боевая поломка машиной не исключена»). Два документа одного изменения
расходятся об одном изъятии.
(2) `docs/architecture.md:84-91` — заголовок нормы «Подставной собеседник
живёт **в коде или в оснастке**», тело — «только под ключом, названным своим
предметом, — **не под общим словом вроде «режим отладки»**», и следом
собственным примером норма называет случай, который живёт в боевом бинарнике и
стоит под `[server] debug`. Единственная операционная формулировка изъятия —
закрытый перечень следствий — живёт в спеке, и принцип на неё не ссылается.
- Последствие: `docs/passport.md` — единственный дом границы домена, и `CLAUDE.md`
велит читать его перед задачей. Записанная так граница перестаёт исключать то,
ради чего заведена: следующая задача (в бэклоге лежит `api-tokens`) сможет
сослаться на строку «кого пускать, решает провайдер» как на действующее
ограничение, которого нет. Норма архитектуры, обратно, читается как «шипящая
реализация нарушает наш собственный принцип» — а ключ `[server] debug`
владелец подтвердил дважды и переоткрывать его нельзя; значит расходится норма,
а не код. `docs/review.md` при этом уже объявил пробел закрытым этой нормой,
то есть на неё уже сослались.
- Предложение: в паспорте заменить довод на верный («сервис называет пришедшего
сам; границы домена это не двигает, потому что учётных записей он по-прежнему
не заводит и допуска не проверяет — заведение строки первым обращением
остаётся зеркалированием») и снять слова «оно про прогон без контура». В
`docs/architecture.md` свести принцип к связывающему правилу со ссылкой на
требование спеки (закрытый перечень следствий предохранителя), не повторяя
перечень второй копией.
- Найдено проходом: `architecture` (R1, R2).
- **Действие: инлайн** (две правки, оба файла — документация).
---
## Гипотезы без доказательства
Понижены: оракула на этом прогоне триаж не добыл — по бюджету (одна попытка на
находку ушла на `critical`/`major`) либо по запрету проекта.
### Имя копии в каталоге данных выводится из идентификатора записи одним инкрементом
- Файл: `internal/ident/ident.go:79-99`; `internal/service/transcribe.go:176-201`
- Было: minor / high (`adversary`, V2). **Осталось minor:** оракул у прохода
есть (`TestProbeStorageNameDerivesFromRecordID`, `TestProbeDerivationRate`
49 из 50 приёмов подряд), но триаж его не воспроизводил, а согласие одного
прохода с самим собой подтверждением не является.
- Последствие: инвариант `CLAUDE.md` «Имя файла на диске задаёт сервис, а в
журнал не идёт… строка журнала иначе стала бы бессрочным ключом к чужой
записи» соблюдён по букве и не по назначению: `record_id` и `file_ext`
присутствуют в журнале порознь и дают имя арифметикой. Доступа это не даёт —
карточка и файл сужены владельцем, — поэтому не выше minor.
- Предложение: случайное положительное приращение вместо единицы либо снять
посылку из инварианта. Уходит задачей.
### Управляющий знак в расширении имени даёт отправителю `500`, а владельцу — `ERROR`, неотличимый от аварии хранилища
- Файл: `internal/service/transcribe.go:171-188`;
`internal/adapter/repo/sqlite/file_repo.go:56-67`
- Было: minor / high (`adversary`, V3). **Осталось minor:** падающий тест у
прохода есть, триаж его не воспроизводил.
- Последствие: комментарий `transcribe.go:171-175` объявляет этот исход
недопустимым и ради него заведён `maxExtLen`; разрез сделан по длине и не
сделан по составу знаков. Побочно `newWorkFile` не снимает путь с
`*os.PathError`, и путь во временном каталоге уезжает в строку `ERROR`.
- Предложение: судить состав знаков расширения тем же приёмом, что и длину.
Уходит задачей.
### Текст отказа провайдера уезжает в `error_text` и в журнал целиком, а самый ожидаемый его вид несёт `sourceURI` — бакет и ключ объекта
- Файл: не локализован проходом точнее уровня «ветвь отказа распознавания»
- Было: minor / medium, **оракула нет и быть не может на этом прогоне**:
настоящий SpeechKit нужен, а `CLAUDE.md`, «Запреты», прогон на реальных ключах
запрещает («Yandex Cloud за деньги»). Строка живёт в
`docs/review.md`, «Недоступно проверке», первым списком.
- Последствие (условное): ключ объекта в `error_text` виден владельцу записи
карточкой; инвариант «Содержимое записи остаётся приватным» им прямо не
нарушен, но перечень того, что уходит в текст ошибки, ничем не ограничен.
- Предложение: задачей — ограничить текст внешнего отказа перечнем полей, а не
пересказывать его дословно.
### Требования «прокси обязан дописывать `X-Forwarded-For`» нигде не записано, в отличие от `Remote-*`
- Файл: `docs/security.md:103-113`
- Было: minor / medium (`adversary`). **Оракула нет:** правило живёт в
`files/caddyproxy/Caddyfile.template` репозитория `pet-project-server`, и
`docs/review.md`, «Недоступно проверке», относит поведение обратного прокси к
тому, чего не проверит ни один проход.
- Последствие (условное): `docs/security.md` описывает чтение цепочки
справа налево словами «правое приписал ближайший к нам прокси» — это
предположение о прокси, а не требование к нему. Прокси, который `X-Forwarded-For`
не дописывает вовсе, оставляет ключ бюджета ограничителя целиком в руках
спрашивающего.
- Предложение: задачей — дописать требование к контуру рядом с уже записанным
требованием про `Remote-*`.
---
## Promote candidates
- **Правило «`internal/config` не знает транспорта» механизируемо, но не
механизировано.** Норма записана этим же изменением
(`docs/conventions/config.md`, «Проверка, охватывающая две секции разом»), а
`grep -n "config" internal/archrules/arch_test.go` не даёт ни одного
совпадения: сканера у правила нет. Кандидат — правило `internal/archrules` по
нетестовым файлам пакета. Предложил проход `architecture`.
**Оговорка к механизации:** `internal/config/test_headers_test.go:1-8`
объявлен `package config` и импортирует `internal/controller/http` — то самое
ребро. В рабочем бинарнике его нет, но `go test ./internal/config` линкует всю
поверхность HTTP, а будущая надобность конфига в транспорте даст цикл импорта
на уровне проверок. Правило по нетестовым файлам этот файл пропустит; правило
по всем — уронит гейт до переноса файла во внешний `package config_test`.
- **`errors.Join` в проверках конфига.** `docs/conventions/errors.md`,
«Несколько ошибок», называет проверку конфига поимённо и требует «все проблемы
разом»; `ValidateTestHeaders` возвращается на первом несовпадении, записи
«*Расхождение:*» у пункта нет. Это претензия на правило, а не на этот код:
проверки остальных секций ведут себя так же. Предложил проход `code` (C5).
- **Сверка `toml.MetaData.Undecoded()` как норма конфига.** Вариант 2 развилки
находки 2. Если владелец выберет узкую правку, кандидат остаётся здесь:
непонятый ключ в любой секции сегодня проходит молча.
---
## Границы покрытия
### План: темы, глубины, дома
Воспроизведён таблицей в сводке выше. Дома: `openspec/specs/access/spec.md` +
дельта (`requirements`, разбор); `CLAUDE.md`, «Гейт» (`autotests`);
`docs/conventions/` целиком (`conventions`, разбор); `docs/architecture.md` +
источник `docs/passport.md` (`architecture`, доказательство); `docs/security.md`,
«Периметр» (`security`, доказательство); `docs/architecture.md`, «Эксплуатация» +
источник `docs/database.md` (`operations`, доказательство). **Тема без дома
одна** — «тема проекта»: своих тем сверх ядра проект не завёл, и это состояние
плана, а не пропуск.
### Какие проходы запускались
Метка `large`, режим «по графу». Запускались шесть: `autotests`, `specs`, `code`,
`architecture`, `adversary`, `ops`. Триаж — седьмой.
### Какие не запускались и почему
- `basics`**по метке**: на `large` он не запускается, потому что все темы
ядра разобраны именными проходами, а своих тем у проекта нет. Прямое следствие:
второго независимого голоса о заниженности метки нет — сигнал пришёл только от
`review-code`.
- Проход независимой реализации — снят из конвейера по стоимости.
- Проход про идиоматичность — упразднён.
### Чего каждый запущенный проход не мог проверить в принципе
- `autotests` — судит гейт и покрытие, но не судит, **что** проверяют зелёные
проверки: находка 6 (проверка, которая не может упасть) из его charter'а не
видна, её принёс `code`.
- `specs` — судит соответствие кода дельте и полноту дельты; не судит, работает
ли требование в настоящем прогоне (потому S4 у него остался рассуждением, а
оракул добыл триаж).
- `code` — судит записанные конвенции и технику; периметра и эксплуатации не
касается.
- `architecture` — судит направления зависимостей и нормы домов; карту графа
собирал **вручную**, подготовленной команды у проекта нет.
- `adversary` — строит пути; поведения настоящих внешних собеседников не
проверяет вовсе.
- `ops` — судит эксплуатацию по замерам своего прогона; профиля настоящей
нагрузки у него нет (проект работает на единицах записей в день).
- **Триаж (этот проход)** — ничего нового не находит по определению: работает с
чужими выводами, кода в поисках дефектов не читает. Пропуск любого прохода —
пропуск триажа тоже, и единственное средство против него — поимённая сверка
плана выше.
### Что осталось целиком на человеке
**Не проверит ни один проход** (`docs/review.md`, «Недоступно проверке», первый
список — воспроизводится отдельно от второго намеренно):
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки; утверждения о росте остаются
условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу;
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
сервиса — **от прокси зависит весь барьер**, он обязан заголовки `Remote-*`
перезаписывать, а не пропускать пришедшие; правило живёт в
`pet-project-server`. **Прямо относится к находке 5:** сценарий
дописывающего прокси проверить отсюда нечем, а асимметричную реакцию сервиса
на него — можно, и она проверена;
- `security`: поведение браузера с куками (класс пуст с 2026-08-22, строка стоит
как маркер).
**Перестали проверять сознательно** (второй список, тот же раздел):
- `autotests`: разбор вывода настоящего `ffprobe` — проверки получают
длительность от подставного источника; решение и цена в
`adr/ADR-2026-08-11-stub-adapters-in-tests.md`;
- работа сервиса с настоящими внешними собеседниками. Сам сервис поднять можно и
этот прогон его поднимал; **остаток** — за настоящие SpeechKit и Object
Storage живой прогон не отвечает, ключи выдуманные, распознавание подменяется
в коде. Вход живой прогон с 2026-08-22 проверяет целиком, и раздел уже
переписан этой задачей: подставного прокси нет, заголовок ставит сам сервис.
**Общее, вне обоих списков:** история инцидентов, поведение под реальным
потоком, поведение внешних систем в их версиях, завязка потребителей на текущее
поведение и вопрос «а нужна ли эта функциональность вообще» — ничем из
перечисленного конвейер не занимался.
### Четыре строки, которых не принесёт ни один проход
1. **Решения проекта не сверялись.** `docs/adr.*` — процессный документ, прогон
его не открывает. Расхождение изменения с записанным решением ловит скилл
`av-dev:doc-healthcheck`, а не ревью. В этом изменении есть чему разойтись:
`ADR-2026-08-22-login-by-trusted-header.md` описывает вход, к которому
заведено изъятие, и сверен с ним никем не был.
2. **Записанные наблюдения проекта не использовались.** `docs/research.*` — тоже
процессный. Всякое число в отчёте снято проходом на этом прогоне: бенчмарк
`BenchmarkSubstituteIdentityHeaders` (2972 ns/op, 5718 B/op, 17 allocs/op) —
замер прохода `ops`, покрытие (`test_headers.go` 100 %,
`SubstituteIdentityHeaders` 94,4 %, `SubstitutedHeaderNames` 0,0 %) — вывод
`go tool cover -func` прохода `autotests`, остальные числа — прогоны триажа с
приложенными командами.
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним
проходом.** Различение «идиоматично против распространено» не спрашивает
никто с тех пор, как упразднён проход про идиоматичность.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру; «не
знаю, чего не знаю» на этом прогоне не доставал никто.
Метка — `large`, поэтому пятой строки (сужение `security`/`operations`/
`architecture` до записанных инвариантов) здесь нет: все три темы разобраны
именными проходами на глубине «доказательство».
### Каких документов проекта не хватило
- **`docs/review.md`, «Типовые ложноположительные» — есть и непуст**, прочитан
целиком (5 записей, две из них отменённые намеренно). Ни одна находка этого
прогона под них не подпадает; отсев шёл и по проектному входу, и по общим
критериям.
- **Раздела инвариантов `CLAUDE.md` хватило.** На него опираются находки 4
(«Секрет не покидает конфиг», **critical**, необратимо) и гипотеза про имя
копии («Имя файла на диске задаёт сервис, а в журнал не идёт», **critical**).
Ранжирование по обратимости взято оттуда же, а не выведено из кода.
- **Не хватило: у `[server] debug` нет дома, называющего исчерпывающий перечень
его следствий в одном месте.** Перечень объявлен трижды — комментарием
`internal/config/config.go:65-71`, комментарием `config.example.toml:7-13` и
требованием дельта-спеки, — и три копии уже расходятся: комментарий конфига
называет пять исключённых следствий, образец — четыре (в нём нет «проверок
старта»). Строка о том, где перечень канонический, отсутствует, и триаж
выбирал спеку по общему правилу «спека нормативна», а не по записанному
указателю.
- **Не хватило: `docs/conventions/logging.md` не называет, каким уровнем
пользоваться, когда отладочный недостижим.** Уровень журнала у сервиса зашит,
ключа у него нет, и конвенция про это молчит — поэтому находка 1 подана
развилкой, а не инлайном: своего основания выбрать вариант у триажа нет.
- **Оракул из журнала дефектов не применялся:** ни одна запись журнала
`docs/review.md` не описывает класс, совпадающий с находками этого прогона.
Ближайшая — «ключ бюджета ограничителя выбирал тот, кого ограничивают»
(2026-08-23) — соседствует с гипотезой про `X-Forwarded-For`, но предмет у неё
другой.
### Сработавшие потолки
- **По проходам: ни один не сообщил свой потолок.** Ни `autotests` (1 находка),
ни `specs` (4), ни `code` (5), ни `architecture` (3 + 2 замечания), ни
`adversary` (3 + 3 свойства), ни `ops` (1) не сказали, каков был их предел и
что осталось за срезом. Это находка о прогоне, вынесенная в сводку отдельной
строкой; закрыть её отсюда нечем.
- **Потолок триажа сработал.** На входе 21 находка, в первые две секции влезло
7. **Не влезло и здесь названо поимённо:**
- `A1`/`S2``SubstitutedHeaderNames` не вызвана ни одним тестом
(`go tool cover -func` → 0,0 %). Триаж добыл ей однократный оракул: прогон
настоящего бинарника печатает `headers=[Remote-User]` — имена, не значения,
— то есть **дефекта сегодня нет**, есть отсутствие защиты от регрессии
против MUST NOT дельта-спеки («строка старта MUST не нести значений»).
Понижено до minor, инлайн-правка на один тест, если у оркестратора останется
бюджет;
- `C3` — умолчания `Debug` и `TestHeaders` заданы нулём типа, а не в
`defaultConfig()`. Нарушена записанная конвенция
`docs/conventions/config.md:181-191`: «Умолчания задаются в `defaultConfig()`
Новое поле требует правки обоих мест», и там же форма обязательного поля —
«умолчания нет ни в `defaultConfig()`, ни по нулевому значению типа». Форма
записи необязательного поля стала неотличима от формы обязательного. `nit`,
инлайн;
- `C4``internal/config/test_headers_test.go` объявлен `package config` и
импортирует `internal/controller/http`, ребро запрещено той же правкой в
`docs/conventions/config.md`. `nit`; отражён оговоркой в Promote candidates,
потому что чинить его в одиночку смысла меньше, чем вместе со сканером;
- `C5``ValidateTestHeaders` возвращается на первом несовпадении вместо
`errors.Join`. Уехал в Promote candidates: правило шире этого кода;
- `C2` — комментарий `Dockerfile:49-50` объясняет исключение `cmd/devtools`
словами «оснастка разработчика (подставной прокси)», а подкоманды больше нет;
приёмочный шаг `tasks.md:88` искал следы по строке `devtools proxy` и эту не
поймал. Плюс `internal/controller/http/mounts.go:54-56` перечисляет три звена
цепочки, тогда как `AppChain` одевает четыре. `nit`, инлайн, две строки;
- **расхождение записи имени ключа**: `[auth] test_headers` в дельта-спеке,
`docs/security.md`, `docs/architecture.md` и `docs/review.md` против
`[auth.test_headers]` в `config.example.toml`, `README.md`, `CLAUDE.md`,
`docs/conventions/config.md` и в текстах отказов старта самого кода
(`test_headers.go:16`). Человек, ищущий в конфиге то, что назвал отказ
старта, найдёт вторую форму; первая в файле не встречается вовсе. **Не
находка этого прохода:** `CLAUDE.md`, «Гейт», относит согласованность
документов между собой и с кодом к скиллу `av-dev:doc-healthcheck`. Названо
здесь, чтобы не потерялось;
- **рецепт локального входа записан четырьмя копиями** — `config.example.toml`
(канон), `README.md:48-61`, `CLAUDE.md:276-288`,
`docs/conventions/config.md:66-75`. Замечание `architecture` «дешевле
переделать до мерджа»; по той же причине уходит в `doc-healthcheck`;
- границы прохода `specs`, перенесённые без изменений: значения
`Remote-Name`/`Remote-Email` в секции имитации на старте не судятся ничем
(годность проверяется только у логина); `strings.TrimSpace` над именем ключа
— поведение сверх спеки, последствий у него не найдено.
### Урожай враждебного прохода
Пути обхода **самой подстановки** — предмета этого изменения — не построено:
шесть отрицательных проб (два значения `Remote-User` при включённой подстановке
`401`; чужие `remote-email`/`remote-name` в нижнем регистре на живом сервере →
слой владеет тройкой целиком; сверка адреса идёт той же функцией, что у
узнавания; неразобранный `RemoteAddr` → отказ подстановки; канонизация ключей
против регистра, NBSP, ZWSP → старт падает; у `[server] debug` второго
потребителя нет). Состояния «подстановка работает, а владелец не знает» не
найдено. **Это положительный исход по теме, а не отсутствие работы.**
Построенное легло на соседнюю поверхность. Судьба каждой:
| Находка | Чинится этим мерджем? | Куда |
|---|---|---|
| V1 — почта берётся первым значением, сквоттинг молчит | **развилка**, вариант 1 берёт в мердж | находка 5 |
| `.env` не игнорируется git | **да**, одной строкой | находка 4 (инлайн) |
| `godotenv.Load()` мёртв (ничто не читает переменные окружения), а `WARN` на каждом старте приглашает завести `.env` | нет | **задачей**: снять зависимость `github.com/joho/godotenv` из `go.mod`, четыре строки из `cmd/transcriber/main.go:114-117` и строку `.env` из `.dockerignore` — либо, если переменные окружения нужны, завести им читателя. Оракул для задачи: `grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/ \| grep -v _test` → пусто; прогон бинарника печатает `level=WARN msg="Warning: .env file not found, using system environment variables"` |
| V2 — имя копии выводится из `record_id` инкрементом | нет | **задачей**, гипотеза выше; оракулы прохода названы поимённо |
| V3 — управляющий знак в расширении → `500` и `ERROR` | нет | **задачей**, гипотеза выше |
| Текст отказа провайдера с `sourceURI` в `error_text` | нет | **задачей**, оракула нет и на этом прогоне быть не может (запрет на реальный SpeechKit) |
| Требование к прокси про `X-Forwarded-For` не записано | нет | **задачей** в `docs/security.md`, рядом с уже записанным требованием про `Remote-*` |
Заведение задач — не работа триажа; формулировки выше отданы с оракулами, чтобы
ни одна не потерялась при переносе.
### Оракулы, добытые триажем
Все тесты писались во временные файлы пакетов и **удалены** после прогона;
рабочее дерево на момент сдачи отчёта содержит ровно те изменения, что были на
входе (`git status --short` сверен). Прогоны бинарника шли в каталог скретчпада,
не в `data/`; поднятый сервис остановлен за собой (`pkill`), порт освобождён —
правило `docs/review.md:16-23`.
1. `go test ./internal/config/ -run TestTriage -v` — три случая разбора TOML
(дубль по регистру, опечатка в имени секции, опечатка в имени ключа).
2. `go test ./internal/controller/http/ -run TestTriage -v` — сквозной сквоттинг
почты на настоящей поверхности `AppChain` и асимметрия правила многозначности.
3. `go build -o <скретчпад>/transcriber ./cmd/transcriber` + два прогона
настоящего бинарника на порту 18099 с каталогами данных в скретчпаде: конфиг
с опечаткой в имени секции и конфиг правильный.
4. `git check-ignore -v .env`; `grep -rn "\.Debug(" --include=*.go internal/ cmd/`;
`grep -rn "os.Getenv\|os.LookupEnv" --include=*.go internal/ cmd/`;
`grep -n "config" internal/archrules/arch_test.go`.
Гейт триаж не перезапускал: его исход взят у прохода `autotests` как замер,
снятый на этом прогоне.
@@ -0,0 +1,508 @@
## ADDED Requirements
### Requirement: Отладочный запуск называет пришедшего настройками
Сервис SHALL подставлять запросу заголовки входа значениями из секции
`[auth] test_headers`, когда включён предохранитель `[server] debug`, и MUST
делать это так, чтобы узнавание не отличало подставленный заголовок от
поставленного обратным прокси. Ветка кода, которой узнаётся пришедший, обязана
быть той же, что работает в бою: отладке подлежит боевой путь, а не его
отладочный двойник.
Подстановка MUST происходить только при **отсутствии** заголовка `Remote-User` в
запросе. Заголовок, пришедший в любом числе значений, MUST оставаться нетронутым:
узнавание отвергает запрос с более чем одним значением, и подстановка, дописавшая
второе, превратила бы законный отказ в проход.
Подстановка MUST происходить только тогда, когда адрес соединения попадает в
перечень `[auth] trusted_proxies`, и адрес этот MUST судиться **той же
функцией**, какой судит его узнавание: и разбор адреса пира, и сверка с
перечнем берутся из одного дома, а не пишутся вторым списком. Второй перечень
разошёлся бы с первым молча — так же, как разошлись бы два списка имён
заголовков. Второго барьера у подстановки нет и не заводится: круг тех, кто
вправе назвать пришедшего, уже очерчен этим перечнем.
Подстановка MUST действовать только при **непустой** секции имитации. Пустая
секция значит «подставлять нечего»: цепочка слоёв при ней та же, что при
выключенном предохранителе, и ни одного заголовка запроса слой не трогает.
Иначе включённый предохранитель при пустой секции срезал бы `Remote-Email` у
запроса, пришедшего без `Remote-User`, — поведение, которого в бою нет.
Подставляя, сервис MUST распоряжаться всей тройкой заголовков `Remote-*`
целиком: названные секцией ставятся её значением, не названные удаляются.
Запрос без `Remote-User`, но с `Remote-Email` иначе собрал бы личность из двух
источников — логин свой, почту чужую.
Подстановка MUST действовать только под корнем приложения — там же, где действует
узнавание. Проба здоровья, метрики и раздача приложения её не видят.
#### Scenario: Запрос без заголовка входа получает имя из настроек
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса без единого заголовка `Remote-*`
- **THEN** сервис узнаёт пришедшего под именем из секции
- **AND** учётная запись заводится, если её не было
#### Scenario: Пришедший заголовок не подменяется
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса с заголовком `Remote-User`
- **THEN** узнавание получает значение из запроса, а не из настроек
#### Scenario: Два значения заголовка остаются отказом
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с двумя значениями заголовка `Remote-User`
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Недоверенный адрес имени не получает
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос без заголовка приходит с адреса вне перечня доверенных
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Чужой заголовок почты не смешивается со своим логином
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и не
называет `Remote-Email`
- **WHEN** запрос приходит без `Remote-User`, но с заголовком `Remote-Email`
- **THEN** узнавание получает логин из настроек и не получает адреса почты вовсе
#### Scenario: Пустая секция имитации заголовков не трогает
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без `Remote-User`, но с
заголовком `Remote-Email`
- **THEN** слой заголовков не трогает, и `Remote-Email` доходит до узнавания
нетронутым
#### Scenario: Выключенный предохранитель имён не раздаёт
- **GIVEN** предохранитель выключен, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без заголовка `Remote-User`
- **THEN** запрос остаётся неузнанным
#### Scenario: Наблюдение подстановки не видит
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит на `GET /health` без заголовка
- **THEN** ответ тот же, что и при выключенном предохранителе
### Requirement: Настройка, открывающая вход всем, роняет старт
Сервис SHALL отказываться подниматься на настройках, при которых отладочный вход
становится открытым входом, и MUST называть в отказе имя ключа. Проверка идёт на
старте, до приёма трафика: настройка, отданная на честность выкладки, проверяется
только тем, что чужой архив уже уехал не тому.
Умолчания названы нормой, а не образцом конфига. Отсутствие ключа `[server]
debug` MUST читаться как выключенный предохранитель, а отсутствие секции
`[auth] test_headers` — как пустая секция: конфиг сегодняшнего дня, не тронутый
ни на байт, обязан вести себя ровно как вёл. Ошибка разбора значения MUST
кончаться отказом старта, а не прочтением «включено».
Секция имитации MUST считаться **непустой**, как только в ней назван хотя бы
один ключ, каким бы ни было его значение. `Remote-User = ""` — заполненная
секция, а не пустая: человек, написавший ключ, имитацию завёл, и пустое значение
у него — вторая поломка, а не отсутствие первой.
Отказом MUST быть каждый из четырёх случаев.
Первый — секция имитации заполнена при выключенном предохранителе. Состояние это
не читается никак: либо человек забыл включить предохранитель и будет искать
поломку везде, кроме одного ключа, либо забыл убрать имитацию из боевого файла — и
тогда до открытого архива остаётся одно слово. Молчаливое игнорирование и
предупреждение в журнале оба оставляют вторую поломку жить.
Второй — секция имитации называет ключ, не совпадающий ни с одним заголовком
входа, который сервис читает. Перечень принимаемых имён MUST порождаться теми же
именами заголовков, которыми пользуется узнавание, а не перечисляться вторым
списком; сравнение MUST идти по каноническому виду имени, потому что в HTTP имя
нечувствительно к регистру, а ключ конфига чувствителен. Опечатка в имени иначе
кончается сервисом, который никого не узнаёт, без единого следа.
Третий — секция имитации непуста, а годного `Remote-User` в ней нет. Ключ
логина MUST быть назван: секция, называющая один `Remote-Email`, поднимает
сервис, который подставит почту, удалит логин и не узнает никого. Значение
логина MUST проходить **тот же приём**, каким узнавание судит пришедшее
значение (`internal/entity.AcceptProviderLogin`): пустое, из одних пробельных
знаков, сверх предела длины и с управляющими знаками — отказ старта. Иначе
сервис поднимается, ставит заголовок, получает отказ приёма и отвечает
неузнанным на всё, оставляя за собой одну отладочную строку. Оба состояния — то
самое «не читается никак», ради которого заведён первый случай.
Четвёртый — два ключа секции дают одно каноническое имя заголовка. `Remote-User`
и `remote-user` для TOML — два ключа, для HTTP — одно имя: сервис подставил бы
одно значение, а второе потерял бы молча, и человек искал бы поломку в значении,
которого сервис не читал вовсе. Отказ MUST называть секцию и причину, а значений
MUST не называть.
Включённый предохранитель при пустой секции имитации отказом MUST не быть: сам по
себе он ничего не включает.
#### Scenario: Имитация без предохранителя не поднимается
- **GIVEN** секция имитации заполнена, предохранитель выключен
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Неизвестное имя заголовка не поднимается
- **GIVEN** предохранитель включён, секция имитации называет ключ, которого нет
среди читаемых заголовков входа
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет принимаемые имена
#### Scenario: Имитация без ключа логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет только
`Remote-Email`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя недостающего ключа логина
#### Scenario: Два ключа одного заголовка не поднимаются
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и
`remote-user`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет секцию, в которой два ключа дали одно имя
заголовка
#### Scenario: Негодное значение логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
значением в 300 знаков
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа, а не его значение
#### Scenario: Пустое значение логина считается заполненной имитацией
- **GIVEN** предохранитель выключен, секция имитации называет `Remote-User = ""`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Конфиг без ключа предохранителя ведёт себя как с выключенным
- **GIVEN** конфиг не называет ни `[server] debug`, ни секции имитации
- **WHEN** сервис запускают
- **THEN** сервис поднимается, подстановки не заводит и никого сам не называет
#### Scenario: Предохранитель без имитации поднимается
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис запускают
- **THEN** сервис поднимается
### Requirement: Отладочный запуск виден в журнале
Сервис SHALL сообщать владельцу о включённой подстановке заголовков строкой
журнала при старте и MUST не печатать при этом ни одного подставляемого значения.
Сервис, называющий пришедшего сам, — это ровно то «может стать проблемой», ради
которого заведён предупреждающий уровень: строка старта идёт на `WARN`.
Строка старта MUST нести имена подставляемых заголовков и MUST не нести их
значений. Логин — ключ к чужому архиву, и запрет на печать значения, дающего
доступ, действует на подставленное значение наравне с пришедшим.
Запрос, которому заголовки подставлены, SHALL писаться на уровне `INFO`:
этой строкой «узнан подстановкой» отличается от «узнан прокси». Боевой журнал
она не топит по построению — пишется только там, где подстановка работает, а в
бою предохранитель выключен умолчанием; на отладочном уровне её не увидел бы
никто, потому что настройки под уровень журнала у сервиса нет и он зашит `INFO`.
Строка MUST нести адрес пира и не нести подставленных значений.
Запрос, которому подставить нельзя из-за недоверенного адреса, MUST оставлять
**свою** строку предупреждающим уровнем. Узнавание на этом месте предупреждения
не пишет: подстановка работает при отсутствии `Remote-User`, а отсутствие
заголовка узнавание считает случаем штатным и пишет о нём отладочную строку;
предупреждение о недоверенном адресе оно бережёт для заголовка, который
**пришёл**. Без своей строки самый частый локальный отказ — браузер пришёл
с `::1`, а перечень называет `127.0.0.1` — не отличим от поломки узнавания:
человек видит `401` на всём приложении, заполненную секцию имитации и
включённый предохранитель. Строка MUST нести адрес пира и MUST не нести
подставляемых значений.
#### Scenario: Старт с подстановкой предупреждает владельца
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** сервис поднимается
- **THEN** журнал несёт предупреждение с именами подставляемых заголовков
#### Scenario: Подставленного значения нет в журнале
- **GIVEN** предохранитель включён, секция имитации называет логин и адрес почты
- **WHEN** сервис поднимается и принимает запрос без заголовка
- **THEN** ни логин, ни адрес почты не встречаются ни в одной журнальной записи
#### Scenario: Отказ подстановки на недоверенном адресе виден в журнале
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос без заголовка `Remote-User` приходит с адреса вне перечня
доверенных
- **THEN** журнал несёт предупреждение с адресом пира
- **AND** подставляемых значений в строке нет
#### Scenario: Подстановка на запросе видна при боевом уровне журнала
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос с доверенного адреса получает подставленные заголовки
- **THEN** строка об этом имеет уровень `INFO` и видна журналу, настроенному
по-боевому
### Requirement: Предохранитель отладки включает только подстановку заголовков
Предохранитель `[server] debug` SHALL менять одно поведение сервиса — подстановку
заголовков входа — и MUST не менять никакого другого. Перечень следствий закрыт, и
новое следствие вешается на этот ключ только отдельным требованием спеки: ключ,
названный общим словом, иначе обрастает всем подряд, и выключить его перестаёт
означать «сервис ведёт себя как в бою».
Сам по себе включённый предохранитель не включает и подстановки: она MUST
действовать только при непустой секции имитации, и при пустой цепочка слоёв MUST
быть та же, что при выключенном предохранителе, — требование «Отладочный запуск
называет пришедшего настройками».
Включённый предохранитель MUST не менять уровень журнала, не добавлять в ответы
тексты внутренних отказов и следы стека, не выводить тела запросов и ответов
внешних сервисов, не снимать и не ослаблять ограничитель частоты, не подменять
распознаватель, не ослаблять ни одной проверки старта и не открывать неузнанному
ни одного адреса.
#### Scenario: Включённый предохранитель без имитации ничего не меняет
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис принимает запросы
- **THEN** он ведёт себя ровно так же, как с выключенным предохранителем
#### Scenario: Ограничитель частоты работает и в отладочном запуске
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запросы идут чаще дозволенного
- **THEN** ограничитель частоты режет их так же, как при выключенном
предохранителе
## MODIFIED Requirements
### Requirement: Кого пускать, решает провайдер
Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки
допуска MUST не делать. Кто допущен, определяет правило провайдера на домен
сервиса — настройка выкладки, лежащая вне репозитория.
Требование записано именно как решение с ценой, а не как умолчание: провайдер
общий для контура, и правило, настроенное слишком широко, открывает сервис
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
поэтому граница названа здесь и повторена в модели угроз.
Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только
первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному
значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок
жизни этого значения. Теперь отзыв действует со следующего запроса.
Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на
том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси,
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` доверенным источником становятся настройки сервиса: сервис
называет пришедшего сам, никого не спросив. Это единственное место, где имя
берётся не от провайдера. Требования к нему стоят выше отдельными строками, и
снимать их поодиночке нельзя: барьер держится всеми разом.
Держится изъятие **умолчанием, а не машиной**: предохранитель по умолчанию
выключен, а заполненная имитация без него роняет старт. Боевой перечень
доверенных адресов включению предохранителя не мешает — сервис, поднятый в бою с
включённым предохранителем и заполненной имитацией, назовёт своим именем всякого,
чей запрос пришёл через обратный прокси, то есть всякого, кто пришёл обычным
путём.
#### Scenario: Названный провайдером получает доступ
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
прокси
- **THEN** доступ открывается, а учётная запись заводится, если её не было
- **AND** сервис не спрашивает у заголовков ничего сверх имени, имени для показа
и адреса почты
#### Scenario: Отзыв у провайдера действует со следующего запроса
- **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ
- **WHEN** приходит следующий его запрос
- **THEN** прокси заголовка не ставит, и запрос получает отказ
#### Scenario: Без отладочного запуска имя приходит только от провайдера
- **GIVEN** предохранитель отладки выключен
- **WHEN** запрос приходит с доверенного адреса без заголовка
- **THEN** сервис никого не узнаёт и своего имени пришедшему не назначает
### Requirement: Пришедшего называет доверенный источник
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
не остаётся.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` заголовок входа ставит не прокси, а сам сервис значением из
секции `[auth] test_headers`; узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает. Условия, при которых источник этот законен,
и проверки старта, которыми он держится, стоят требованиями «Отладочный запуск
называет пришедшего настройками», «Настройка, открывающая вход всем, роняет
старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает
только подстановку заголовков». Держится изъятие умолчанием предохранителя
«выключено» и отказом старта при заполненной имитации без него; боевой перечень
доверенных адресов включению предохранителя не мешает.
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
барьер вовсе.
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
требованием учётной записи на адресах приложения.
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает базу, а
на новом имени ещё и пишет в неё; выполненное раньше ограничителя, оно работало
бы на запросах, которые тот уже отверг, и поток отвергнутых обращений заводил бы
учётные записи, которые потом не убираются ничем.
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
Область — корень приложения; она MUST выводиться из объявленного адресного
пространства сервиса, а не перечисляться вторым списком. Прежде область была
шире на один адрес — тот, которым хранилище выдавало короткий токен файла; ни
адреса, ни токена не осталось. Прежде область была и уже: собственную поверхность
хранилища требовалось из неё вычитать, потому что ключ учётной записи лежал в
коллекции обычной колонкой, а правило правки было библиотечным. Поверхности этой
нет, и вычитать больше нечего.
Сужение области закрывает вещь, которая от смены хранилища не зависит: узнавание
MUST не срабатывать на пробе здоровья, на метриках и на ресурсах приложения.
Иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос
с новым именем — записи в неё.
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
без этой нормы все неназванные собрались бы в одну учётную запись с общим
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
бы вход анониму. Значение сверх объявленного предела длины и значение с
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
считает колонка.
Отказ базы при узнавании MUST кончаться отказом сервиса, а не молчаливым
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
настройке журнала: обе строки означают поломку контура, а поломка, записанная
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
которого никто не может войти, не отличит своей поломки (перечень доверенных
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
нести значения заголовка.
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
MUST не выдавать вовсе — ни куки, ни токена сессии, ни короткого токена файла.
Исключений у этого правила больше нет: файл записи отдаётся тому же узнаванию,
что и всё прочее, и отзыв доступа доходит до него сразу.
Смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не
однажды выданный срок.
Собственных токенов сервис не принимает: значения, предъявленного запросом и
дающего доступ помимо заголовка, у него не существует. Прежде такое значение
било заголовок — им пользовался владелец панели; панели нет, и правило приоритета
осталось бы правилом без предмета.
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
адресом его почты.
#### Scenario: Заголовок с доверенного адреса узнаёт человека
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
- **THEN** запрос идёт от имени учётной записи с этим значением
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
- **GIVEN** адреса источника в перечне доверенных нет
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
- **THEN** ответ имеет код `401`
- **AND** учётной записи с этим значением не появляется
#### Scenario: Предъявленного значения сервис не признаёт
- **GIVEN** запрос несёт заголовок `Remote-User` и постороннее значение доступа
в заголовке или в параметре
- **WHEN** сервис решает, кто пришёл
- **THEN** пришедшим считается названный заголовком
#### Scenario: Пустой заголовок не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Два значения одного заголовка не узнают никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Значение сверх предела длины не узнаёт никого
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
- **THEN** ответ имеет код `401`
- **AND** учётной записи не появляется
#### Scenario: Область узнавания — корень приложения
- **GIVEN** сервис поднялся
- **WHEN** смотрят, на каких адресах срабатывает узнавание
- **THEN** это адреса под корнем приложения, и второго списка адресов нет
#### Scenario: Проба здоровья учётной записи не заводит
- **GIVEN** учётной записи с этим значением ещё нет
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
- **THEN** учётной записи не появляется
#### Scenario: Недоверенный источник виден в журнале
- **WHEN** запрос с заголовком приходит с недоверенного адреса
- **THEN** журнал несёт строку об этом исходе с адресом пира
- **AND** значения заголовка в ней нет
#### Scenario: Сервис не ставит браузеру куки
- **GIVEN** адрес источника стоит в перечне доверенных
- **WHEN** запрос к адресу приложения проходит с заголовком
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
#### Scenario: Значения заголовка нет в журнале
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
- **THEN** значение заголовка не встречается ни в одной журнальной записи
@@ -0,0 +1,263 @@
## 0. Чекпоинт человека
Чекпоинт пройден: развилок не осталось, и решения владельца записаны здесь и в
`design.md`.
- [x] 0.1 Имена ключей конфига подтверждены дословно — `[server] debug` и
`[auth.test_headers]` (необратимое: имя ключа конфига)
- [x] 0.2 Форма имён в секции имитации подтверждена: ключ — имя заголовка, набор
принимаемых имён порождается константами транспорта, неизвестный ключ
роняет старт (`design.md`, решение 6)
- [x] 0.3 Адресного предохранителя не делаем — «полагаемся только на параметр
debug». Требование петлевого перечня доверенных адресов при включённом
предохранителе снято вместе с предикатом «петлевая запись»
(`design.md`, решение 4)
- [x] 0.4 Подкоманда `cmd/devtools proxy` убирается целиком (`design.md`,
решение 10)
- [x] 0.5 Критерии приёмки подтверждены владельцем; пункты 2 и 7 переписаны под
решение 0.3
## 1. Настройки
- [x] 1.1 Завести в `ServerConfig` ключ предохранителя с умолчанием «выключено» в
`defaultConfig()`; отсутствие ключа в конфиге читается как «выключено», а
негодное значение роняет старт, а не читается как «включено»
- [x] 1.2 Завести в `AuthConfig` подсекцию имитации заголовков в форме, выбранной
шагом 0.2; умолчание — пустая секция. Непустой секция считается при наличии
хотя бы одного ключа, каким бы ни было его значение: `Remote-User = ""`
заполненная секция
- [x] 1.3 Вывести перечень принимаемых имён заголовков из констант транспорта
`internal/controller/http/identity.go`, а не вторым списком; сравнение по
каноническому виду имени
- [x] 1.4 Завести проверку старта, охватывающую обе секции, методом на корневой
`Config` (`design.md`, решение 8), и звать её из `cmd/transcriber` рядом с
имеющимися проверками
- [x] 1.5 Отказ старта при заполненной имитации и выключенном предохранителе;
текст называет имя ключа предохранителя
- [x] 1.6 Отказ старта при неизвестном имени заголовка в секции; текст называет
принимаемые имена
- [x] 1.7 Отказ старта при непустой секции без годного `Remote-User`: ключ не
назван либо его значение не проходит `internal/entity.AcceptProviderLogin`
(пустое, пробельное, сверх предела длины, с управляющими знаками); текст
называет имя ключа и не называет значения
- [x] 1.8 Включённый предохранитель при пустой секции старт не роняет. Перечень
доверенных адресов старту при этом не судья: боевая запись перечня
включению предохранителя не мешает (решение 0.3)
## 2. Подстановка на входе
- [x] 2.1 Завести слой транспорта, подставляющий заголовки, в
`internal/controller/http`
- [x] 2.2 Поставить слой в цепочку корня приложения между ограничителем частоты и
узнаванием; при выключенном предохранителе и при пустой секции имитации
слой в цепочку не встаёт вовсе — цепочка та же, что сегодня
- [x] 2.3 Подставлять только при отсутствии `Remote-User`; запрос с любым числом
значений проходит нетронутым
- [x] 2.4 Подставлять только при попадании адреса пира в перечень доверенных, и
судить адрес **той же функцией**, какой судит узнавание (`peerAddress` и
сверка с перечнем из `internal/controller/http/identity.go`), а не вторым
списком
- [x] 2.5 Распоряжаться всей тройкой `Remote-*`: названные секцией ставить,
не названные удалять
- [x] 2.6 Проверить, что `TrustedHeaderIdentity` не изменён: отлаживается боевая
ветка узнавания
## 3. Журнал
- [x] 3.1 Предупреждение при старте с включённой подстановкой: имена заголовков,
поле `capability`, без значений
- [x] 3.2 Строка уровня `INFO` на запросе с подставленными заголовками: адрес
пира, `capability`, `transport`, без значений. Уровень боевой, и боевой
журнал строка не топит: пишется она только там, где подстановка работает,
а в бою предохранитель выключен умолчанием
- [x] 3.3 Предупреждение на запросе, которому подставить нельзя из-за
недоверенного адреса: адрес пира, `capability`, `transport`, без
подставляемых значений. Узнавание на этом месте молчит — заголовка нет, и
оно пишет отладочную строку
## 4. Оснастка
- [x] 4.1 Удалить подкоманду `proxy` из `cmd/devtools`: ветку `case "proxy"` в
`main()`, функцию `runProxy` и её флаги `-listen`, `-target`, `-user`,
`-name`, `-email`; неиспользуемые импорты уходят вместе с ней
- [x] 4.2 Править шапку пакета `cmd/devtools/main.go`: она говорит «Подкоманд
две: `proxy` — подставной обратный прокси, `resume` — …» и объясняет, зачем
прокси нужен. Остаётся одна подкоманда. Абзац про имена заголовков
константами вместе с прокси теряет предмет — оснастка их больше не читает
- [x] 4.3 Править `usage()` в `cmd/devtools/main.go`: убрать строку
`proxy подставной обратный прокси…`
- [x] 4.4 Убрать упоминания подкоманды из документов. Адреса — все, что нашёл
`grep -rn "devtools proxy"`, кроме каталога этого change и архива
`openspec/changes/archive/`:
- `README.md`, блок локального запуска («Локально прокси нет…» и команда
`go run ./cmd/devtools proxy`) — содержимое пишет шаг 6.5;
- `CLAUDE.md`, раздел «Команды», строка `go run ./cmd/devtools proxy`, и
раздел «Запреты», абзац «Локальный запуск не ходит наружу» — содержимое
пишет шаг 6.4;
- `config.example.toml`, блок «Локальный вход без Authelia» с двумя
вызовами подкоманды — содержимое пишут шаги 6.1 и 6.2;
- `docs/conventions/config.md`, абзац про закомментированный петлевой
адрес «под подставной прокси `cmd/devtools proxy`» — содержимое пишет
шаг 6.3;
- `docs/architecture.md`, строка таблицы «Оснастка владельца |
`cmd/devtools`»: «Подставной прокси для местного запуска и возврат
остановленной записи в работу» — содержимое пишет шаг 6.7;
- `docs/security.md`, перечень вне периметра, пункт «Машина разработчика и
то, что он на ней поднимает»: «её подкоманда `proxy` встаёт на место
контура» — содержимое пишет шаг 6.6;
- `docs/review.md`, запись о единственном доме подставных внешних
собеседников: «на их месте `cmd/devtools proxy`». Документ процессный, и
запись историческая: правится строкой о том, что прокси убран задачей
`config-test-headers-login`, а не переписывается
## 5. Тесты
- [x] 5.1 Тесты проверки старта по требованию «Настройка, открывающая вход всем,
роняет старт»: три отказа (имитация без предохранителя; неизвестное имя
заголовка; непустая секция без годного `Remote-User` — не названного и в
300 знаков) и законные случаи (предохранитель без имитации; конфиг, не
называющий ни одного нового ключа)
- [x] 5.2 Тесты слоя подстановки по сценариям требования «Отладочный запуск
называет пришедшего настройками», включая два значения `Remote-User`,
недоверенный адрес и чужой `Remote-Email`
- [x] 5.3 Тест: подставленное значение не встречается ни в одной журнальной
записи
- [x] 5.4 Тест: `GET /health` при включённом предохранителе отвечает так же, как
при выключенном
- [x] 5.5 Тест: ограничитель частоты работает при включённом предохранителе
- [x] 5.6 Тест: при включённом предохранителе и пустой секции запрос без
`Remote-User`, но с `Remote-Email` доходит до узнавания нетронутым
- [x] 5.7 Тест: запрос без заголовка с недоверенного адреса при включённом
предохранителе оставляет предупреждение с адресом пира
- [x] 5.8 Тест: конфиг без новых ключей даёт ту же цепочку слоёв и те же ответы,
что до изменения
## 6. Документы
- [x] 6.1 `config.example.toml`: обе новые настройки с комментарием — зачем,
допустимые значения и цена включения. Форма названа прямо: боевой перечень
доверенных адресов остаётся рабочим значением, `[server] debug` стоит рабочей строкой
`false`, секция `[auth.test_headers]` целиком закомментирована, а петлевые
адреса в перечне стоят комментарием **парой**`127.0.0.1` и `::1`:
браузер разрешает `localhost` в IPv6 не реже, чем в IPv4, и перечень без
`::1` даёт неузнанный запрос без единой понятной строки
- [x] 6.2 Записать рецепт локального входа одним связным блоком — списком правок
сверху вниз, а не тремя комментариями по месту: (1) добавить в перечень
доверенных адресов пару петлевых, (2) поставить `debug = true`,
(3) раскомментировать секцию имитации и назвать в ней `Remote-User`. Дом
блока — `config.example.toml`, и та же последовательность повторена ссылкой
в `README.md` и `CLAUDE.md`
- [x] 6.3 `docs/conventions/config.md`: локальный вход описан настройками; абзац
про закомментированный петлевой адрес под подставной прокси приведён в
соответствие с формой шага 6.1 (пара петлевых адресов, закомментированная
секция имитации, рецепт одним блоком) — строкой «*Расхождение:*», если
форма образца расходится с общим правилом секции
- [x] 6.4 `CLAUDE.md`: раздел «Запреты», абзац о локальном запуске — вход идёт
настройками, второго процесса нет; раздел «Команды», строка подкоманды
`proxy` убирается
- [x] 6.5 `README.md`: инструкция локального запуска
- [x] 6.6 `docs/security.md`, «Периметр»: изъятие отладочного запуска, чем оно
держится (умолчание предохранителя, отказ старта при имитации без него,
шаблон выкладки) и что боевая поломка машиной не исключена. Там же —
пункт «Машина разработчика и то, что он на ней поднимает»: подкоманды
`proxy` больше нет
- [x] 6.7 `docs/architecture.md`: строка таблицы про оснастку разработчика (без
прокси) и новый слой в перечне слоёв транспорта
- [x] 6.8 `docs/passport.md`, «Управление учётными записями»: сегодня раздел
утверждает, что кто пришёл, сервис не решает никогда, и что исключений у
этого больше нет. Назвать изъятие одной строкой с его границей —
«держится предохранителем `[server] debug`, выключенным по умолчанию» — и
ссылкой на спеку `access`
- [x] 6.9 Записать норму о подставных собеседниках — решение владельца, работа
сверх прежнего перечня шагов. Одной строкой в документ канона: подставной
собеседник живёт в коде или в оснастке, а в боевом бинарнике появляется
только отдельным решением владельца и только под ключом, названным своим
предметом. Сегодня таких два — подмена распознавания правкой
`internal/adapter/recognizer/memory.go` и имитация заголовков конфигом, —
а записанной нормы нет вовсе, и `docs/review.md` этот пробел отмечает
вопросом темы `operations`. Дом нормы — `docs/architecture.md`, «Принципы»:
она называет, где живёт часть системы, а не как оформляют код. Запись
вопроса в `docs/review.md` правится строкой о закрытом пробеле
## 7. Приёмка
- [x] 7.1 `task gate` зелёный
- [x] 7.2 Проверить руками: сервис поднят с заполненной имитацией, приложение
открыто по адресу сервиса, второго процесса нет
- [x] 7.3 Проверить руками **записанную форму рецепта**: скопировать свежий
`config.example.toml`, применить рецепт шага 6.2 дословно и ничего сверх
него, поднять сервис, открыть приложение. Отказ старта на полпути —
поломка рецепта, а не копии
- [x] 7.4 Критерии приёмки из блока ниже проверены поимённо
## Критерии приёмки
Рубрика ревью дизайна. Проверяется поимённо шагом 7.4.
- [x] 1. **Fail-closed по умолчанию.** Отсутствие новых ключей в существующем
конфиге не меняет ни одного байта поведения; умолчание предохранителя —
«выключено»; ни одна ошибка разбора не даёт «включено». Проверяемо: конфиг
сегодняшнего дня, не тронутый, даёт ту же цепочку слоёв и те же ответы.
- [x] 2. **У каждой комбинации новых ключей назван исход, а боевая поломка
названа поимённо и не выдана за исключённую.** Машина её не исключает:
сервис с включённым предохранителем и заполненной имитацией поднимается на
любом перечне доверенных адресов, боевом в том числе, и называет пришедшего
сам всякому, чей запрос пришёл через обратный прокси. Защита названа
поимённо и целиком — умолчание предохранителя «выключено», отказ старта при
заполненной имитации без предохранителя, боевой конфиг, рендеримый шаблоном
Ansible, — и ни один документ не утверждает, что этого набора хватает на
механическую несовместимость.
- [x] 3. **Подстановка заменяет, а не дополняет.** Слой либо владеет всем
набором заголовков входа целиком, либо не трогает запрос; второго значения
того же заголовка он не создаёт никогда. Проверяемо: запрос с двумя
`Remote-User` остаётся отказом, запрос с чужим `Remote-Email` и без
`Remote-User` не собирает личность из двух источников.
- [x] 4. **Барьер доверенного адреса подставленный заголовок проходит наравне с
пришедшим, и предикат доверия у обоих один дом.** Проверяемо: запрос с
недоверенного адреса не узнаётся при включённом предохранителе так же, как
при выключенном; отбор адреса берётся из той же точки, что и у узнавания,
а не пишется вторым списком.
- [x] 5. **Отлаживается боевая ветка.** Узнавание не получает ни второго
источника значений, ни ветки «если отладка»; к моменту чтения заголовок
неотличим от поставленного прокси. Проверяемо: файл узнавания изменением
не тронут.
- [x] 6. **Область слоя равна области узнавания и выводится из неё, а не
перечисляется вторым списком.** Проверяемо: проба здоровья, метрики и
раздача приложения отвечают одинаково при включённом и выключенном
предохранителе.
- [x] 7. **Матрица отказов старта полна и тотальна: у каждой комбинации новых
ключей назван исход, и «поднялся, но никого не узнаёт» среди исходов
нет.** Отказ идёт до приёма трафика, называет имя ключа и не называет
значения. Проверяемо перечислением: предохранитель × (пустая / заполненная
/ заполненная негодно) имитация, и отдельной строкой — два ключа секции,
дающие одно каноническое имя заголовка: `Remote-User` и `remote-user`
различимы для TOML и неразличимы для HTTP, и одно из значений терялось бы
молча.
- [x] 8. **Настройка, при которой узнавание не может состояться ни при каком
запросе, — отказ старта, а не запуск.** Имитация без ключа логина, пустое,
пробельное, слишком длинное значение и значение с управляющими знаками
судятся на старте тем же правилом, каким узнавание судит пришедшее
значение. Проверяемо: конфиг с логином в 300 знаков даёт отказ старта с
именем ключа, а не сервис, отвечающий отказом на всё.
- [x] 9. **Отладочный запуск громкий и различимый.** Одна строка при старте (без
значений) и различимость «узнан подстановкой» от «узнан прокси» на уровне
запроса. Отдельно — путь, на котором подстановка не сработала: у него
обязан быть след, иначе самый частый локальный отказ (браузер пришёл
`::1`, конфиг называет `127.0.0.1`) не отличим от поломки узнавания.
- [x] 10. **Слой без состояния между запросами: исход запроса — функция конфига
и самого запроса.** Ничего не кэширует, ничего не заводит, в контекст не
пишет; заведение учётной записи остаётся там, где было, и параллельные
первые запросы с одним логином не зависят от порядка. Проверяемо: два
одновременных первых запроса дают одну учётную запись, а не две и не
отказ.
- [x] 11. **Предохранитель — закрытый перечень следствий, а не режим.** Названо,
чего он не включает; новое следствие требует отдельной нормы. Проверяемо:
включённый предохранитель при пустой имитации ведёт себя ровно как
выключенный, включая ограничитель частоты и уровень журнала.
- [x] 12. **Записанный рецепт и объявленная граница совпадают с задуманным.**
Форма, записанная в образце конфига и в памятке, — та самая, которую
проверяют; ни один документ канона не продолжает утверждать отсутствие
механизма, который изменение заводит. Проверяемо: свежая копия
`config.example.toml` доводится до рабочего локального входа ровно теми
правками, что записаны, и не даёт отказа старта на полпути.