diff --git a/.golangci.yml b/.golangci.yml index bfa842f..5423abb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -64,8 +64,9 @@ linters: msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)' # Конфигурация приезжает из TOML. Перечислены все способы прочитать # окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv` - # одной правкой. Окружение читает только godotenv в main.go — он кладёт - # .env в окружение процесса, а не в настройки. + # одной правкой. Окружение читает только godotenv в точке входа + # `cmd/transcriber` — он кладёт .env в окружение процесса, а не в + # настройки. # # Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и # `os.Stdout.WriteString` — первый аргумент по имени функции не судится. diff --git a/CLAUDE.md b/CLAUDE.md index 3c5a338..9daddfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,8 @@ go test ./... # в гейте идёт с -race, и там нуже go vet ./... gofmt -l . golangci-lint run -go run . -c config.toml # флаг -c или --config, по умолчанию config.toml +go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml +go run ./cmd/oidcstub # подставной провайдер OIDC для локального входа task front # приложение: зависимости, Biome, юнит-тесты, сборка task image # docker-образ; тег и раскладка — docs/architecture.md task gate # весь набор проверок разом @@ -234,6 +235,12 @@ Node на машину **не ставится**: шаг сборки прило ссылки. Расшифровка при выдуманных ключах не работает: её подменяют `internal/adapter/recognizer/memory.go`. Подробности строками в `config.example.toml`. + **Войти при выдуманных адресах нельзя** — они никуда не ведут, а сервис без + входа не отдаёт ничего. Вместо провайдера поднимается заглушка + `cmd/oidcstub` — она отвечает на `/authorize`, `/token` и `/userinfo`, а + проверок не делает никаких; + значения `[auth]` под неё стоят строками в `config.example.toml`. Ключи + боевого провайдера на машине разработчика при этом по-прежнему не нужны. - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — подставляй `internal/adapter/recognizer/memory.go`. diff --git a/Dockerfile b/Dockerfile index 1600210..a765f5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,15 @@ # Приложение собирается до бинарника: вшивание требует готового каталога. # Имя этого образа — единственное; шаг набора проверок берёт его отсюда же, # чтобы версия сборочного окружения не жила вторым числом в Taskfile.yml. -FROM docker.io/library/node:24-alpine AS front-build +# +# Образ на glibc, а не на alpine, и разница здесь не в весе: musl шлёт запросы +# `A` и `AAAA` разом и ждёт **оба** ответа, а DNS-сервер, который на `AAAA` +# молчит, оставляет его без адреса вовсе — при живом `A`. `npm` на такой отказ +# уходит в повторы с нарастающей паузой на каждом пакете, и сборка не краснеет, +# а **висит**. Библиотека glibc довольствуется полученным `A` и собирает. +# Ступень сборочная: в готовый образ её слои не едут, и лишний вес остаётся +# ценой одной сборки, а не размером выкладки. +FROM docker.io/library/node:24 AS front-build WORKDIR /web @@ -38,7 +46,9 @@ COPY . . COPY --from=front-build /web/embed/dist ./web/embed/dist # Build the application -RUN CGO_ENABLED=0 go build -o transcriber . +# Собирается одна точка входа из cmd/, а не весь пакет: соседний cmd/oidcstub — +# подставной провайдер для локального запуска, и в образе ему делать нечего. +RUN CGO_ENABLED=0 go build -o transcriber ./cmd/transcriber # ---------------- # Production stage diff --git a/README.md b/README.md index 822f317..4d1e49e 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ ``` 4. Запустите приложение: ```bash - go run . -c config.toml + go run ./cmd/transcriber -c config.toml ``` Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен @@ -45,6 +45,15 @@ [docs/security.md](docs/security.md), «Что разграничивает доступ»; известные прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md). +Локально провайдера нет, и войти при выдуманных адресах нельзя — вместо него +поднимается заглушка: + +```bash +go run ./cmd/oidcstub +``` + +Значения `[auth]` под неё стоят строками в `config.example.toml`. + ## Деплой Деплой запускается из `pet-project-server`: @@ -82,7 +91,9 @@ inv pl -- transcriber ``` transcriber/ -├── main.go # Точка входа: конфиг, миграции, сборка зависимостей, запуск +├── cmd/ +│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск +│ └── oidcstub/ # Подставной провайдер OIDC для локального входа ├── internal/ │ ├── entity/ # Модели: задача, файл, результат распознавания │ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок diff --git a/cmd/oidcstub/main.go b/cmd/oidcstub/main.go new file mode 100644 index 0000000..48071b5 --- /dev/null +++ b/cmd/oidcstub/main.go @@ -0,0 +1,171 @@ +// Команда oidcstub — подставной провайдер OIDC для локального запуска. +// +// Сервис держит вход через внешнего провайдера, и без него в приложение не +// попасть вовсе: секция `[auth]` обязательна, а выдуманные адреса разбираются +// как ссылки, но никуда не ведут. Настоящая Authelia — отдельный сервис, который +// надо развернуть и настроить, а ключи боевого провайдера на машине разработчика +// лежать не должны. +// +// Заглушка отвечает на `/authorize`, `/token` и `/userinfo`, и этого хватает: +// `user_info_url` — обязательный ключ конфига, а при заполненном адресе сведений +// библиотека хранилища берёт их обычным запросом с предъявленным токеном и +// `id_token` не смотрит вовсе. Поэтому здесь нет ни ключей подписи, ни документа +// обнаружения. +// +// Проверок она не делает никаких — ни секрета клиента, ни проверочного кода +// PKCE, ни выданного токена. Вход у неё один и заранее известный: она нужна, +// чтобы дойти до куки сессии, а не чтобы изображать провайдера. По той же +// причине слушает она только петлевой адрес. +// +// Запуск: +// +// go run ./cmd/oidcstub +// go run ./cmd/oidcstub -sub local-2 +// +// Другое значение `-sub` заводит второго вошедшего, и почта переезжает вместе с +// ним: без своей почты второй вход отвергается вовсе. Хранилище ищет учётную +// запись сперва по признаку провайдера, а не найдя — по адресу почты, и на +// найденной записи признак уникален. Второй `sub` при общей почте пришёл бы к той +// же записи и получил бы отказ уникальности — вход отвечал бы 401, а причина +// осталась бы в журнале хранилища строкой про связь. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "time" +) + +// authCode — код, который заглушка отдаёт на возврате. Значение постоянное: +// одноразовость кода держит провайдер, а здесь её изображать не для кого. +const authCode = "local-code" + +// accessToken — то, что уезжает в обмен и приходит обратно заголовком запроса +// сведений о вошедшем. Заглушка его не сверяет. +const accessToken = "local-token" + +// readHeaderTimeout — потолок чтения заголовков. Заглушка стоит на петлевом +// адресе, но сервер без единого срока держит подвисшее соединение вечно. +const readHeaderTimeout = 5 * time.Second + +func main() { + port := flag.Int("port", 9000, "Порт заглушки") + sub := flag.String("sub", "local-1", "Идентификатор вошедшего: разные значения дают разных владельцев записей") + name := flag.String("name", "Локальный", "Имя вошедшего") + email := flag.String("email", "", "Почта вошедшего; пустое значение даёт @example.com") + flag.Parse() + + // Почта выводится из `sub`, а не стоит своим умолчанием: общая почта у двух + // разных `sub` приводит второй вход к первой записи, а признак провайдера на + // ней уникален — второй вошедший получал бы 401 вместо своей учётной записи. + // Обещание «разные `-sub` дают разных владельцев» держится этой строкой. + if *email == "" { + *email = *sub + "@example.com" + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + mux := http.NewServeMux() + mux.HandleFunc("/authorize", authorize(logger)) + mux.HandleFunc("/token", token(logger)) + mux.HandleFunc("/userinfo", userInfo(logger, *sub, *name, *email)) + + // Петлевой адрес, а не все — заглушка пускает кого угодно кем угодно, и в + // чужой сети это дыра, а не удобство. + addr := fmt.Sprintf("127.0.0.1:%d", *port) + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: readHeaderTimeout, + } + + logger.Info("oidc stub started", "addr", addr, "sub", *sub) + + if err := server.ListenAndServe(); err != nil { + logger.Error("oidc stub stopped", "error", err) + os.Exit(1) + } +} + +// authorize уводит браузер обратно на адрес возврата с готовым кодом. +// +// Настоящий провайдер спросил бы здесь имя и пароль; заглушка не спрашивает +// ничего и возвращает сразу — в этом вся её работа. +func authorize(logger *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + target, err := url.Parse(query.Get("redirect_uri")) + if err != nil || target.Host == "" { + logger.Error("authorize rejected", "reason", "redirect_uri is missing or malformed") + http.Error(w, "redirect_uri не задан или не разбирается", http.StatusBadRequest) + return + } + + // Состояние возвращается тем же значением, каким пришло: сервис сверяет + // его со своей кукой и без совпадения отвергает возврат. + back := target.Query() + back.Set("code", authCode) + if state := query.Get("state"); state != "" { + back.Set("state", state) + } + target.RawQuery = back.Encode() + + logger.Info("authorize passed", "redirect_host", target.Host) + + http.Redirect(w, r, target.String(), http.StatusFound) + } +} + +// token отдаёт токен в обмен на код. Ни код, ни секрет клиента, ни проверочный +// код PKCE не сверяются. +func token(logger *slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "обмен идёт методом POST", http.StatusMethodNotAllowed) + return + } + + // `id_token` не отдаётся намеренно: подписать его нечем, а библиотека + // хранилища его и не смотрит — `user_info_url` стоит в перечне + // обязательных ключей конфига, и без него сервис не поднимается вовсе. + writeJSON(logger, w, map[string]any{ + "access_token": accessToken, + "token_type": "Bearer", + "expires_in": 3600, + }) + } +} + +// userInfo отдаёт сведения о вошедшем. +// +// `email_verified` обязано быть истинным: без него библиотека адрес почты в +// учётную запись не запишет, и запись заведётся без него. +func userInfo(logger *slog.Logger, sub, name, email string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + writeJSON(logger, w, map[string]any{ + "sub": sub, + "name": name, + "preferred_username": sub, + "email": email, + "email_verified": true, + }) + } +} + +// writeJSON отвечает разметкой JSON. Отказ записи идёт в журнал: ответ к этому +// моменту уже начат, и сказать о нём спрашивающему нечем. +func writeJSON(logger *slog.Logger, w http.ResponseWriter, body map[string]any) { + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(body); err != nil { + logger.Error("response write failed", "error", err) + } +} diff --git a/journal_route_test.go b/cmd/transcriber/journal_route_test.go similarity index 100% rename from journal_route_test.go rename to cmd/transcriber/journal_route_test.go diff --git a/main.go b/cmd/transcriber/main.go similarity index 100% rename from main.go rename to cmd/transcriber/main.go diff --git a/config.example.toml b/config.example.toml index aba410d..45acdd9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -85,3 +85,31 @@ redirect_url = "https://transcriber.example.com/auth/callback" # Признак `Secure` у куки сессии. Умолчание true; false только для локального # запуска по http://localhost, где браузер такую куку не сохранит secure_cookie = true + +# Локальный вход без Authelia. +# +# Провайдер на машине разработчика не поднимается, а ключи боевого на ней лежать +# не должны — вместо провайдера идёт заглушка `cmd/oidcstub`. Она отвечает на +# `/authorize`, `/token` и `/userinfo` и не сверяет ни секрет клиента, ни +# проверочный код PKCE: вход у неё один и заранее известный. Поднимается +# отдельным процессом: +# +# go run ./cmd/oidcstub +# +# Замени секцию `[auth]` выше на эти значения — вход пойдёт настоящим путём, +# вплоть до куки сессии и заведённой учётной записи в панели: +# +# auth_url = "http://localhost:9000/authorize" +# token_url = "http://localhost:9000/token" +# user_info_url = "http://localhost:9000/userinfo" +# client_id = "transcriber" +# client_secret = "local" +# redirect_url = "http://localhost:8080/auth/callback" +# secure_cookie = false +# +# Другое значение `-sub` заводит второго вошедшего: это неизменяемый признак, +# по которому обмен ищет учётную запись. Почта переезжает вместе с ним сама — +# заглушка выводит её из `-sub`, — и это обязательно: при общей почте второй вход +# приходит к первой записи и получает отказ. +# +# go run ./cmd/oidcstub -sub local-2 diff --git a/docs/architecture.md b/docs/architecture.md index 680037d..bf05e74 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,8 +77,8 @@ [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается в работу»; здесь это принцип письма шага, а не описание поведения. - **Ядро зависит от интерфейсов.** `internal/service` знает только - `internal/contract`; ffmpeg, Yandex и хранилище подставляются в - `main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и + `internal/contract`; ffmpeg, Yandex и хранилище подставляются в точке входа + `cmd/transcriber`. Правило механизировано тестами-сканерами `internal/archrules`, и они же держат обратные направления: транспорты не знают друг о друге, адаптер не знает ни ядра, ни транспортов. *Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http` @@ -219,6 +219,17 @@ не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс работает под непривилегированным пользователем `transcriber`. +Ступень бинарника собирает **одну** точку входа — `./cmd/transcriber`, а не весь +пакет: рядом в `cmd/` живёт `oidcstub`, подставной провайдер OIDC для локального +входа, и в образе ему делать нечего. + +Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес: +у musl запрос имени идёт `A` и `AAAA` разом и ждёт **оба** ответа, поэтому +DNS-сервер, молчащий на `AAAA`, оставляет установщика пакетов без адреса при +живом `A`. Установщик уходит в повторы с нарастающей паузой на каждом пакете, и +сборка не краснеет, а **висит** — исход хуже красного. Слои этой ступени в +рабочий слой не едут, поэтому её вес остаётся ценой одной сборки. + **По весу финальный образ от ступени приложения не растёт вовсе:** она отдаёт следующей только собранное, а сама в рабочий слой не копируется. Вшитое приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не diff --git a/docs/conventions/config.md b/docs/conventions/config.md index a7393f3..89f1217 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -5,12 +5,12 @@ **Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту. Главные: комментариями снабжена половина полей; единого места проверки на старте -нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в `main.go`, а пустые +нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в точке входа, а пустые ключи `[yandex]` ловит конструктор распознавателя. **Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml` ([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки -приезжают из TOML»; `godotenv` в `main.go` по-прежнему загружает `.env`, но кладёт +приезжают из TOML»; `godotenv` в `cmd/transcriber` по-прежнему загружает `.env`, но кладёт его в окружение процесса, а не в настройки приложения. ## Принципы @@ -18,7 +18,7 @@ - **Конфигурация — только TOML.** Переменные окружения для конфигурации **не используем**: окружение наследуется дочерними процессами и видно через `/proc//environ` — для секретов это слабее файла под `0600`. - *Расхождение:* `main.go` зовёт `godotenv.Load()` и молча продолжает без файла. + *Расхождение:* `cmd/transcriber` зовёт `godotenv.Load()` и молча продолжает без файла. - Грузим **один раз при старте** в одну типизированную структуру `Config` (под-структуры по секциям). Дальше по коду читаем только её — чтения файла в прикладном коде нет, только загрузчик `internal/config`. @@ -62,6 +62,12 @@ force_shutdown_timeout = # ждать остановки ворке говорит, какой формы значение здесь ждут. Пустым оставлен только `client_secret` — он и есть секрет. +Там же, комментарием под секцией, стоит **второй набор значений `[auth]` — под +подставной провайдер `cmd/oidcstub`**. Они — не живая форма, и это намеренно: +образец описывает боевую выкладку, а локальный вход — способ до неё дойти, и два +рабочих набора в одном файле читались бы как выбор без указания, какой из них +чей. + ## Поля по дискриминатору `type` Когда набор полей секции зависит от поля-дискриминатора `type` (выбор одного из @@ -130,7 +136,7 @@ TOML. Пустые ключи Yandex ловятся в конструкторе входом 2026-08-14: секции больше нет, и своей проверки у неё тоже. Секция `[auth]` — первая, у которой проверка своя и стоит на старте: -`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет +`AuthConfig.Validate()` зовётся из `cmd/transcriber` сразу после загрузки и роняет процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index 8304256..674f355 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -31,7 +31,10 @@ OpenSpec. {"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137} ``` -*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`. +*Расхождение:* текстовый обработчик ставят **оба** пакета `cmd/` — +`slog.NewTextHandler(os.Stdout, …)` и в `transcriber`, и в `oidcstub`. У второго +это выбор, а не долг: его вывод читает человек в терминале, и разбирать его +отбором никто не станет. ## Сообщение @@ -78,7 +81,7 @@ OpenSpec. - `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и завершаем процесс с ненулевым кодом. -*Расхождение:* уровень зашит константой в `main.go`, `DEBUG` включить нечем. +*Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем. Пустой прогон воркера не логируется вовсе — и это правилу не противоречит. ## Время @@ -208,7 +211,7 @@ Object Storage и опрос операции не логируются ника `INFO` они не пишутся. Расхождения здесь больше нет: слой журналирования запросов свой, -`main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics` +`cmd/transcriber`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics` идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе. Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден diff --git a/docs/research/pocketbase.md b/docs/research/pocketbase.md index 67fc157..cc3986c 100644 --- a/docs/research/pocketbase.md +++ b/docs/research/pocketbase.md @@ -169,6 +169,20 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн **Куки библиотека не читает вовсе** — сессию берёт только заголовком `Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`). +**Учётную запись обмен ищет двумя способами подряд, а связь с провайдером +уникальна.** Сперва — по неизменяемому признаку провайдера, а не найдя — по +адресу почты (`apis/record_auth_with_oauth2.go`, ветка `case authUser.Email != +""` → `FindAuthRecordByEmail`). Найденной записи он пытается добавить связь, а на +ней стоит уникальный индекс +`idx_externalAuths_record_provider (collectionRef, recordRef, provider)`. Отсюда +исход, обратный ожидаемому: два разных признака провайдера с **одной** почтой не +сливаются в одного владельца молча — второй вход отвергается, обмен отдаёт `400`, +сервис — `401` со строкой `Failed to exchange provider code`, а настоящая причина +остаётся в журнале хранилища строкой `failed to save linked rel: … Value must be +unique`. Дописано 2026-08-15 задачей про заглушку OIDC; получено прогоном против +временного каталога — чтение исходников давало ту же цепочку, но противоположную +развязку. + **Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию `MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять diff --git a/docs/review.md b/docs/review.md index 83ca04d..e970a6f 100644 --- a/docs/review.md +++ b/docs/review.md @@ -119,6 +119,18 @@ ### Типовые ложноположительные +- **«Хранилище молча сливает две учётные записи с одной почтой в одного + владельца».** Для версии v0.39.10 неверно, и неверна именно развязка. Первая + половина цепочки настоящая: обмен ищет запись по признаку провайдера, а не + найдя — по адресу почты, и приходит к чужой записи. Но повесить на неё второй + признак он не может — уникальный индекс + `idx_externalAuths_record_provider (collectionRef, recordRef, provider)` связь + отвергает, обмен отдаёт `400`, а сервис — `401` со строкой + `Failed to exchange provider code`. Отказ **громкий**, тихого слияния владельцев + не происходит, и ложно-зелёной проверки разграничения такой дефект не даёт. + Проверено прогоном 2026-08-15 (задача про заглушку OIDC); найдено чтением + исходников библиотеки, опровергнуто запуском — то есть цена гипотезы, добытой + без прогона, здесь и измерена. - **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не логирует его и не считает в метрику. Норма записана требованием @@ -159,6 +171,14 @@ Форма: `<тема>: <вопрос> (<откуда>)`. +- `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть + в проверках. Подставных провайдера OIDC в репозитории теперь два — `cmd/oidcstub` + и `fakeProvider` в `internal/controller/http/login_test.go`, — с теми же + адресами и той же посылкой про `email_verified`, и они уже разошлись в мелочи + (`token_type` «bearer» против «Bearer»). Тем же вопросом судится подставной + распознаватель. Записанной конвенции о единственном доме подставных внешних + собеседников у проекта нет, поэтому спрашивать надо, а не считать нарушением + (ревью задачи про заглушку OIDC, 2026-08-15). - `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до внешнего собеседника и это держат правила `noctx` и `contextcheck` ([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний @@ -312,6 +332,27 @@ API и имя не откатываются обратной правкой по истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не оракул, и выдумывать оракул задним числом нельзя. +## 2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью] + +- **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа; + доккоммент пакета, подсказка флага `-sub` и проза `config.example.toml` +- **Симптом:** рецепт «второй вошедший получается сменой `-sub`» записан в трёх + местах и в короткой форме не работал вовсе. Заглушка отдавала обоим `sub` одну + и ту же почту умолчанием, вход отвечал `401`, а причина оставалась строкой в + журнале хранилища +- **Причина:** обмен ищет учётную запись сперва по признаку провайдера, а не + найдя — по адресу почты. Второй `sub` при общей почте приходил к первой записи, + а признак провайдера на записи уникален — `idx_externalAuths_record_provider` — + и связь отвергалась. Умолчание почты стояло своим значением вместо выведенного + из `sub` +- **Почему не поймали раньше:** рецепт проверяли **длинной** формой, где почта + задана флагом явно. Короткую не гонял никто, хотя записана она первой и берут + читатели именно её +- **Что меняем:** проверять ту форму рецепта, которая записана **короче всех**. + Оракул — прогон именно её: два входа подряд разными `-sub` без прочих флагов, + затем счёт записей в коллекции пользователей. Само умолчание почты теперь + выводится из `-sub` + ## 2026-08-15 — своя раздача статики потеряла отказ от записи успеха [пойман ревью] - **Где:** `internal/controller/http/webapp.go`, регистрация корневого маршрута; diff --git a/docs/security.md b/docs/security.md index 24ee416..98fd496 100644 --- a/docs/security.md +++ b/docs/security.md @@ -335,6 +335,13 @@ Storage, оттуда его читает SpeechKit. Третий путь — - **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и Ansible — не наша граница. +- **Машина разработчика и то, что он на ней поднимает.** С 2026-08-15 в + репозитории лежит `cmd/oidcstub` — подставной провайдер OIDC, который выдаёт + сессию всякому спросившему и не сверяет ни секрета клиента, ни проверочного + кода PKCE. Двух вещей это не отменяет, и обе проверяемы: в образ он не едет + (ступень собирает `./cmd/transcriber` поимённо), а слушает петлевой адрес. + Периметра выкладки заглушка поэтому не касается; кто поднял её у себя в чужой + сети, отвечает за это сам. - **Злоупотребление со стороны пользователя из белого списка.** Приглашённому доверяем полностью. - **Достоверность расшифровки.** Подмена или искажение текста на стороне diff --git a/internal/archrules/arch_test.go b/internal/archrules/arch_test.go index 376e536..e1059f5 100644 --- a/internal/archrules/arch_test.go +++ b/internal/archrules/arch_test.go @@ -28,7 +28,7 @@ const ( ) // Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а -// ffmpeg, Yandex и хранилище подставляются в `main.go` +// ffmpeg, Yandex и хранилище подставляются в точке входа `cmd/transcriber` // (docs/architecture.md, «Принципы»). const core = "internal/service" @@ -78,7 +78,7 @@ func TestЯдроНеЗнаетОбАдаптерах(t *testing.T) { if strings.HasPrefix(imp, adapterPrefix) { t.Errorf( "%s импортирует адаптер %s: ядро зависит от интерфейсов "+ - "internal/contract, а реализацию подставляет main.go", + "internal/contract, а реализацию подставляет cmd/transcriber", core, imp, ) } diff --git a/tasks/BACKLOG.md b/tasks/BACKLOG.md index 5980cc8..7df3962 100644 --- a/tasks/BACKLOG.md +++ b/tasks/BACKLOG.md @@ -52,7 +52,7 @@ - [🔬 Четыре недоказанные гипотезы о поверхности входа](items/login-surface-hypotheses.md) — Ревью назвало четыре пути, которых не смогло ни подтвердить, ни опровергнуть: браузера и живого провайдера в прогоне не было. - [🐞 Починить срок сессии, который ставит откат шага входа](items/rollback-restores-wrong-session-duration.md) — Константа defaultAuthTokenDuration в шаге 202608120001 названа умолчанием библиотеки, но 1209600 — это 14 суток, а умолчание PocketBase 432000, пять суток: откат объявляет возврат к умолчанию и ставит срок вдвое больше выбранных владельцем семи. - [🔬 Адрес объекта в тексте отказа SpeechKit](items/speechkit-error-text-leak.md) — Текст отказа операции приходит от Yandex и уезжает в журнал и в колонку error_text: если он несёт URI объекта, из журнала снова собирается ссылка на чужую запись. -- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом. +- [🧹 Разобрать мелочи http-транспорта](items/http-transport-nits.md) — Маршруты зарегистрированы дважды, и переименование пути в cmd/transcriber проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом. - [🧹 Запретить обращаться к Bot API мимо клиента бота](items/bot-api-only-through-bot-client.md) — Чистка отказа от адреса с токеном живёт в клиенте; свой http.Client в транспорте вернёт утечку молча — правило noctx такую подмену не ловит, а класс уже стоил одного дефекта. - [🧹 Свести пять расхождений между документами канона](items/docs-consistency-2026-08-13.md) — Сверка 2026-08-13 нашла шесть мест, где два документа отвечают на один вопрос по-разному; одно сведено при повышении раскладки, а три из пяти оставшихся стоят в architecture.md, и по ним читатель строит решения о выкладке и о периметре. - [✨ Сопоставить пользователя Telegram с учётной записью](items/telegram-account-link.md) — Белый список сверяется с именем пользователя Telegram, которое владелец меняет в любой момент, а записи из бота ни с кем не связаны. diff --git a/tasks/items/http-transport-nits.md b/tasks/items/http-transport-nits.md index 6ce0472..92da023 100644 --- a/tasks/items/http-transport-nits.md +++ b/tasks/items/http-transport-nits.md @@ -2,15 +2,15 @@ - **Тип:** chore - **Категория:** Очередь — Поднято до контракта API: маршруты объявлены дважды, и переименование пути проходит тесты зелёным — переписывать контроллер под слепыми проверками нельзя. -- **Зачем:** Маршруты зарегистрированы дважды, и переименование пути в main.go проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом. +- **Зачем:** Маршруты зарегистрированы дважды, и переименование пути в cmd/transcriber проходит проверки зелёным; обработчик пишет в журнал через стандартный log и дублирует запись, уже сделанную сервисом. - **Теги:** review-2026-08-11 Два дефекта, найденные ревью change `2026-08-11-fix-http-handler-tests`. Оба в `internal/controller/http`, мерджатся одним заходом. -**Маршруты зарегистрированы дважды.** `main.go` объявляет пути приёма и опроса, +**Маршруты зарегистрированы дважды.** `cmd/transcriber` объявляет пути приёма и опроса, и сборка теста объявляет их же своей копией. Проверки ходят по копии, поэтому -переименование пути в `main.go` их не роняет — а публичный контракт HTTP API +переименование пути в `cmd/transcriber` их не роняет — а публичный контракт HTTP API проект объявил необратимым. Проверено мутацией на прогоне триажа: замена `api.POST("/audio", …)` на `api.POST("/upload", …)` оставляет тесты зелёными. Нашёл проход `review-specs`. @@ -23,7 +23,7 @@ ## Затрагивает -- `internal/controller/http` и `main.go` — регистрация маршрутов приёма и +- `internal/controller/http` и `cmd/transcriber` — регистрация маршрутов приёма и опроса, сегодня продублированная в обоих местах; - `internal/controller/http/transcribe.go` — журнальная строка обработчика; - `internal/controller/http/transcribe_test.go` — сборка теста, держащая свою diff --git a/tasks/items/journal-fields-bounded-by-service.md b/tasks/items/journal-fields-bounded-by-service.md index db852f5..a9ea710 100644 --- a/tasks/items/journal-fields-bounded-by-service.md +++ b/tasks/items/journal-fields-bounded-by-service.md @@ -41,7 +41,7 @@ file name too long"`, и поля `file_ext` в ней нет вовсе. ## Затрагивает -- слой журнала запроса в `main.go` и приведение пути `journalRoute`; +- слой журнала запроса в `cmd/transcriber` и приведение пути `journalRoute`; - отказ подготовки рабочей копии в `internal/service`, шаг приёма записи; - `docs/conventions/logging.md` — правило о длине поля журнала; - умолчание `MaxHeaderBytes` у сервера: сегодня оно единственное неназванное diff --git a/tasks/items/response-code-in-journal.md b/tasks/items/response-code-in-journal.md index d1066a6..24b3987 100644 --- a/tasks/items/response-code-in-journal.md +++ b/tasks/items/response-code-in-journal.md @@ -37,7 +37,7 @@ POST /app/audiorecords без сессии → 401 ... http.status_code=0 ## Затрагивает -- слой журнала запроса в `main.go`: место, где снимается код ответа; +- слой журнала запроса в `cmd/transcriber`: место, где снимается код ответа; - `docs/conventions/logging.md` — словарь полей запроса. ## Критерии приёмки