diff --git a/CLAUDE.md b/CLAUDE.md index ebab1ec..8b24210 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,11 +278,9 @@ Node на машину **не ставится**: шаг сборки прило пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков не ставит. Заголовок подставляет сам сервис — настройками, а не вторым процессом: рецепт из трёх правок записан связным блоком в - `config.example.toml`, под перечнем доверенных адресов. Коротко: пара петлевых - адресов в перечень, `[server] debug = true`, раскомментированная секция - `[auth.test_headers]` с ключом `Remote-User`. Приложение при этом открывают по - адресу сервиса, второго порта нет. Заполненная имитация при выключенном - предохранителе роняет старт с именем ключа. Ключей боевого + `config.example.toml`, под перечнем доверенных адресов. Приложение при этом + открывают по адресу сервиса, второго порта нет. Заполненная имитация при + выключенном предохранителе роняет старт с именем ключа. Ключей боевого провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге. - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — diff --git a/README.md b/README.md index 9058e75..b95f5f2 100644 --- a/README.md +++ b/README.md @@ -52,15 +52,10 @@ сервис по своим настройкам. Второго процесса для этого не нужно: приложение открывают по адресу сервиса. -Рецепт — три правки `config.toml` сверху вниз: +Рецепт целиком — связным блоком в `config.example.toml`, под перечнем доверенных +адресов: там названы три правки, принимаемые имена заголовков и цена включения. -1. добавить в перечень доверенных адресов пару петлевых — `127.0.0.1` и `::1`; -2. поставить в секции `[server]` ключ `debug = true`; -3. раскомментировать секцию `[auth.test_headers]` и назвать в ней `Remote-User`. - -Тот же рецепт записан связным блоком в `config.example.toml`, под перечнем -доверенных адресов, — там же названы принимаемые имена заголовков и цена -включения. Заполненная секция имитации при выключенном предохранителе роняет +Заполненная секция имитации при выключенном предохранителе роняет старт с именем ключа: сервис с включённым предохранителем называет пришедшего сам, никого не спросив, и в бою этот ключ стоит `false`. diff --git a/docs/adr/ADR-2026-08-22-login-by-trusted-header.md b/docs/adr/ADR-2026-08-22-login-by-trusted-header.md index c989065..958ef34 100644 --- a/docs/adr/ADR-2026-08-22-login-by-trusted-header.md +++ b/docs/adr/ADR-2026-08-22-login-by-trusted-header.md @@ -1,7 +1,7 @@ # Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC - **Дата:** 2026-08-22 -- **Источник:** openspec/changes/archive/trusted-header-login/design.md +- **Источник:** [../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md](../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md), разделы Р1 и Р3 ## Решение diff --git a/docs/architecture.md b/docs/architecture.md index c43dea3..37bc937 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,13 @@ `trusted-header-login` — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей записи не бывает вовсе — колонка владельца пустого значения не принимает. - Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14. + Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14. Здесь же + изъятие отладочного запуска: при включённом предохранителе `[server] debug` + заголовки входа подставляет сам сервис значениями из `[auth.test_headers]` — с + отказами старта, строкой журнала и закрытым перечнем следствий ключа. Задача + `config-test-headers-login` 2026-08-23; решения — + [ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md) + и [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md). Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде. Задача, которая его трогает, дописывает спеку своей capability. @@ -84,7 +90,7 @@ - **Подставной собеседник в боевом бинарнике объявлен своим ключом.** Дом ему — код или оснастка; в боевом бинарнике он появляется только отдельным решением владельца и только под ключом, названным своим предметом: имитацию заголовков - входа объявляет секция `[auth] test_headers`, подмену распознавания — правка + входа объявляет секция `[auth.test_headers]`, подмену распознавания — правка кода (`internal/adapter/recognizer/memory.go`). Ключ, названный общим словом, обрастает следствиями молча, и выключить его перестаёт означать «сервис ведёт себя как в бою». Предохранитель `[server] debug` вторым именем собеседнику при @@ -161,7 +167,7 @@ | Компонент | Где | Что делает | | --- | --- | --- | -| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Подстановка — звено необязательное: при выключенном предохранителе `[server] debug` и при пустой имитации она в цепочку не встаёт вовсе | +| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Условия, при которых звено подстановки встаёт в цепочку, нормирует [access](../openspec/specs/access/spec.md), «Отладочный запуск называет пришедшего настройками» | | Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен | | Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | @@ -184,7 +190,8 @@ ## Внешние границы и форматы - **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с - `UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением. + `UsePathStyle`. Ключ объекта — имя файла записи, то есть её идентификатор с + расширением; идентификаторы строит `internal/ident` и они ULID, а не UUID. - **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель `deferred-general`, авторизация заголовком `Api-Key`. Распознавание асинхронное: запрос возвращает идентификатор операции, готовность опрашивается @@ -192,6 +199,10 @@ - **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`. - **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не нужен. База и файлы записей лежат под одним каталогом данных. +- **`github.com/BurntSushi/toml`.** Формат единственного источника настроек. + Негодный TOML останавливает старт; незнакомый ключ разбор не судит и молча + отбрасывает — наблюдение и чем оно проверено, в + [research/toml-unknown-keys.md](research/toml-unknown-keys.md). - **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте. Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных @@ -284,8 +295,8 @@ | Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания | | Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository` — `internal/controller/http.TrustedHeaderIdentity` | | Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться | -| Имена заголовков входа | `internal/controller/http.IdentityHeaderNames` вместе с константами рядом — тройка `Remote-*` перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией `[auth] test_headers`: ключ, не совпавший ни с одним, роняет старт | -| Сверка адреса пира с перечнем доверенных | `internal/controller/http`, `identity.go` — `peerAddress` и `isTrusted`. Зовут их двое: узнавание и подстановка заголовков отладочного запуска. Второй сверщик разошёлся бы с первым молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится | +| Имена заголовков входа | `internal/controller/http.IdentityHeaderNames` вместе с константами рядом — тройка `Remote-*` перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией `[auth.test_headers]`; что делает старт с ключом вне набора, нормирует [access](../openspec/specs/access/spec.md), «Настройка, открывающая вход всем, роняет старт» | +| Сверка адреса пира с перечнем доверенных | `internal/controller/http`, `identity.go` — `peerAddress` и `isTrusted`. Зовут их узнавание, подстановка заголовков отладочного запуска и ограничитель частоты. Свой сверщик разошёлся бы с общим молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится | | Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса | Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось. diff --git a/docs/conventions/config.md b/docs/conventions/config.md index eb8400c..1b4c094 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -63,12 +63,10 @@ force_shutdown_timeout = # ждать остановки ворке Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным входом. -Там же, комментарием под секцией, стоит **рецепт локального входа одним связным -блоком**: три правки сверху вниз — пара петлевых адресов в перечень доверенных, -`[server] debug = true`, раскомментированная секция `[auth.test_headers]` с -ключом `Remote-User`. Блок один, а не три комментария по месту: правки связаны -между собой, и применённая порознь любая из них роняет старт либо оставляет -сервис никого не узнающим. Рабочей строкой в образце стоит боевое значение — +Там же, комментарием под секцией, стоит рецепт локального входа **одним связным +блоком**, а не тремя комментариями по месту: правки связаны между собой, и +применённая порознь любая из них роняет старт либо оставляет сервис никого не +узнающим. Рабочей строкой в образце стоит боевое значение — перечень с адресом прокси и `debug = false`, — а секция имитации закомментирована целиком: образец описывает боевую выкладку, а локальный вход — способ до неё дойти, и два рабочих значения в одном файле читались бы как выбор без указания, diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index e83d52e..7e9c6a6 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -87,10 +87,10 @@ stdlib-логом в поток ошибок. Это выбор, а не дол *Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем. Пустой прогон воркера не логируется вовсе — и это правилу не противоречит. -*Изъятие:* строка о подставленных заголовках входа -(`internal/controller/http/substitute.go`) адресована разработчику, а идёт на -`INFO`: `DEBUG` включить нечем, а в бою она не пишется вовсе — подстановку -держит выключенный умолчанием предохранитель `[server] debug`. +*Изъятие:* строка о подставленных заголовках входа адресована разработчику, а +идёт на `INFO` — уровень и его довод нормирует спека +[access](../../openspec/specs/access/spec.md), «Отладочный запуск виден в +журнале». ## Время diff --git a/docs/database.md b/docs/database.md index a5b78a0..798f042 100644 --- a/docs/database.md +++ b/docs/database.md @@ -424,9 +424,8 @@ Crockford. Выдаёт их приложение единой точкой `int и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка — верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он -ограничивает. Сама цепочка читается справа налево с отбрасыванием доверенных -адресов, поэтому дописывающий прокси правилом покрыт; почему так — -[security.md](security.md), «Периметр». +ограничивает. Как читается цепочка — спека +[archive](../openspec/specs/archive/spec.md); здесь только числа бюджета. Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ diff --git a/docs/review.md b/docs/review.md index ba279b9..1dc2d32 100644 --- a/docs/review.md +++ b/docs/review.md @@ -345,7 +345,7 @@ API и имя не откатываются обратной правкой по **Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по - секции `[auth] test_headers` под предохранителем `[server] debug` — прежде + секции `[auth.test_headers]` под предохранителем `[server] debug` — прежде `cmd/devtools proxy`, убранный 2026-08-23, — и живьём проверяются узнавание, заведение учётной записи первым обращением, отказ с недоверенного адреса и отказ старта на пустом перечне. diff --git a/docs/security.md b/docs/security.md index 6676e6f..f4e067b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -85,10 +85,12 @@ Telegram — связи чата с учётной записью сервис **Изъятие из барьера одно — отладочный запуск, и заведено оно 2026-08-23** задачей `config-test-headers-login`. При включённом предохранителе `[server] debug` заголовки входа ставит не прокси, а сам сервис значениями из -секции `[auth] test_headers`: на машине разработчика прокси нет, а браузер +секции `[auth.test_headers]`: на машине разработчика прокси нет, а браузер заголовков не ставит. Узнавание при этом остаётся тем же и подставленного заголовка от пришедшего не отличает — отлаживается боевая ветка. Нормирует -изъятие спека [access](../openspec/specs/access/spec.md). +изъятие спека [access](../openspec/specs/access/spec.md), решение о подстановке +самим сервисом — +[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md). Держится оно тремя вещами, и других нет: умолчание предохранителя — «выключено»; заполненная имитация при выключенном предохранителе роняет старт с @@ -103,19 +105,16 @@ Telegram — связи чата с учётной записью сервис перечне доверенных адресов и назовёт своим именем всякого, чей запрос пришёл через обратный прокси, — то есть всякого, кто пришёл обычным путём. Адресного предохранителя у изъятия нет: требование петлевого перечня рассматривалось и -снято решением владельца на чекпоинте задачи. +снято — [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md). **`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.** -С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка: -иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и -бюджет становится общим на весь сервис. Цепочка читается **справа налево**, -доверенные адреса отбрасываются, и ключом становится первый недоверенный: левым -значением распоряжается сам спрашивающий, а правое приписал ближайший к нам -прокси. Заголовок читается всеми строками, а не одной: цепочка законно приходит -несколькими. Прокси, дописывающий `X-Forwarded-For` к присланному, этим правилом -покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в -отличие от `Remote-*`. Барьером узнавания заголовок при этом не служит: кто -пришёл, решает адрес самого соединения. +Как именно читается цепочка, нормирует спека +[archive](../openspec/specs/archive/spec.md), «Адреса приложения живут своим +пространством». Отсюда периметровое следствие: прокси, дописывающий +`X-Forwarded-For` к присланному, этим правилом покрыт, и требования +«перезаписывать, а не дописывать» у сервиса к нему нет — в отличие от `Remote-*`. +Барьером узнавания заголовок при этом не служит: кто пришёл, решает адрес самого +соединения. **Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.** Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса diff --git a/openspec/specs/access/spec.md b/openspec/specs/access/spec.md index cab38e1..0955138 100644 --- a/openspec/specs/access/spec.md +++ b/openspec/specs/access/spec.md @@ -81,12 +81,10 @@ «Предохранитель отладки включает только подстановку заголовков»; снимать их поодиночке нельзя: барьер держится всеми разом. -Держится изъятие **умолчанием, а не машиной**: предохранитель по умолчанию -выключен, а заполненная имитация без него роняет старт. Боевой перечень -доверенных адресов включению предохранителя не мешает — сервис, поднятый в бою с -включённым предохранителем и заполненной имитацией, назовёт своим именем всякого, -чей запрос пришёл через обратный прокси, то есть всякого, кто пришёл обычным -путём. +Держится изъятие умолчанием предохранителя «выключено» и отказом старта при +заполненной имитации без него. Что остаётся между боевой выкладкой и открытым +входом целиком, включая опору вне репозитория, перечисляет модель угроз — +`docs/security.md`, «Периметр». #### Scenario: Названный провайдером получает доступ @@ -275,14 +273,15 @@ MUST отвечать отказом `401`, когда пришедший не **Изъятие одно — отладочный запуск.** При включённом предохранителе `[server] debug` заголовок входа ставит не прокси, а сам сервис значением из -секции `[auth] test_headers`; узнавание при этом остаётся тем же и подставленного +секции `[auth.test_headers]`; узнавание при этом остаётся тем же и подставленного заголовка от пришедшего не отличает. Условия, при которых источник этот законен, -и проверки старта, которыми он держится, стоят требованиями «Отладочный запуск -называет пришедшего настройками», «Настройка, открывающая вход всем, роняет -старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает -только подстановку заголовков». Держится изъятие умолчанием предохранителя -«выключено» и отказом старта при заполненной имитации без него; боевой перечень -доверенных адресов включению предохранителя не мешает. +и проверки старта, которыми он держится, перечислены требованием «Кого пускать, +решает провайдер». + +Держится изъятие умолчанием предохранителя «выключено» и отказом старта при +заполненной имитации без него. Что остаётся между боевой выкладкой и открытым +входом целиком, включая опору вне репозитория, перечисляет модель угроз — +`docs/security.md`, «Периметр». Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из объявленного перечня доверенных, и адрес этот MUST браться у самого соединения, @@ -591,7 +590,7 @@ MUST не выдавать вовсе — ни куки, ни токена се ### Requirement: Отладочный запуск называет пришедшего настройками Сервис SHALL подставлять запросу заголовки входа значениями из секции -`[auth] test_headers`, когда включён предохранитель `[server] debug`, и MUST +`[auth.test_headers]`, когда включён предохранитель `[server] debug`, и MUST делать это так, чтобы узнавание не отличало подставленный заголовок от поставленного обратным прокси. Ветка кода, которой узнаётся пришедший, обязана быть той же, что работает в бою: отладке подлежит боевой путь, а не его @@ -685,7 +684,7 @@ MUST не выдавать вовсе — ни куки, ни токена се Умолчания названы нормой, а не образцом конфига. Отсутствие ключа `[server] debug` MUST читаться как выключенный предохранитель, а отсутствие секции -`[auth] test_headers` — как пустая секция: конфиг сегодняшнего дня, не тронутый +`[auth.test_headers]` — как пустая секция: конфиг сегодняшнего дня, не тронутый ни на байт, обязан вести себя ровно как вёл. Ошибка разбора значения MUST кончаться отказом старта, а не прочтением «включено». diff --git a/openspec/specs/archive/spec.md b/openspec/specs/archive/spec.md index f4059f4..a8646e8 100644 --- a/openspec/specs/archive/spec.md +++ b/openspec/specs/archive/spec.md @@ -265,7 +265,7 @@ MUST называть в нём потолок размера одной зап #### Scenario: Потолок размера равен тому, которым сервис отвергает -- **GIVEN** человек вошёл и предъявил сессию +- **GIVEN** человек узнан - **WHEN** он спрашивает пределы сервиса - **THEN** потолок размера в ответе равен потолку, которым сервис ограничивает тело запроса приёма diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index e93d017..11e7045 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -302,19 +302,19 @@ MUST ограничивать его длину и MUST убирать из не #### Scenario: Имя доходит до записи -- **GIVEN** отправитель предъявил сессию +- **GIVEN** отправитель узнан - **WHEN** он шлёт запись с именем `разговор.mp3` - **THEN** колонка имени файла у заведённой записи несёт `разговор.mp3` #### Scenario: Заголовок принятой записи пуст -- **GIVEN** отправитель предъявил сессию +- **GIVEN** отправитель узнан - **WHEN** он шлёт запись с именем `разговор.mp3` - **THEN** колонка заголовка у заведённой записи пуста #### Scenario: Длинное и грязное имя приходит обрезанным и очищенным -- **GIVEN** отправитель предъявил сессию +- **GIVEN** отправитель узнан - **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки - **THEN** колонка имени файла несёт имя не длиннее предела - **AND** управляющих знаков в нём нет