ADR 0050 — Учётные данные всегда зашифрованы; проверка становится явным действием
Контекст
ADR 0004 установил для токенов git, что регистрация проверяет учётные данные против provider до шифрования и сохранения — недействительный токен даёт ошибку, а не сохраняется, чтобы провалиться позже. Фаза 11a скопировала тот же порядок для ключей LLM.
Порядок казался благоразумным. В реальном использовании он дал худший из возможных исходов.
Owner попытался зарегистрировать ключ OpenRouter на экране настроек.
Шесть кликов, шесть POST /users/me/credentials, шесть 422. С его стороны
кнопка Сохранить ничего не делала — и это был второй дефект, в web
(handleSave без try/catch, с ApiError, утекающим в
window.onunhandledrejection, который только логирует). Но даже с сообщением на экране
дизайн уже был неправильным, по трём причинам, которые проявляются только вместе:
- Ничего не записывается, а ключ не восстановить. Поле только для записи, и экран никогда не показывает снова то, что было введено. Отказ оставляет пользователя без учётных данных и без текста для исправления — он снова открывает экран в том же состоянии, что и раньше, потеряв вставленное.
- Регистрация судит по неполной информации. Проверка проваливается из-за недействительного ключа,
ключа без баланса, сети, таймаута, DNS. Все они становятся
одним и тем же
422в неправильный момент: в момент сохранения. Вопрос «хорош ли этот ключ?» — не то же самое, что «хочу ли я сохранить этот ключ?», и их связывание заставляет второй зависеть от того, есть ли у первого ответ прямо сейчас. - Обещание экрана становится невыполнимым. «Только для записи, никогда не показывается снова» означает, что никто — даже владелец — не может проверить, что сохранено. Если сохранение может молча провалиться, не остаётся никакого способа узнать, в каком состоянии находятся учётные данные.
Была ещё и необоснованная асимметрия: единственной задокументированной причиной того, что
POST /users/me/git-credentials существует отдельно от
POST /users/me/credentials, была как раз обязательная проверка.
Решение
Сохранить и проверить — два разных вопроса. Регистрация только сохраняет; проверка — собственное действие над уже записанными учётными данными.
Действует для обоих семейств — ключа LLM и токена git. Это одна и та же таблица
(user_credentials), тот же механизм envelope encryption и то же
обещание пользователю; обращаться с ними по-разному дало бы два экрана с разными
правилами для одного объекта.
-
UpsertUserCredentialUseCaseиRegisterGitCredentialUseCaseтеряют tester. Остаются две строки: зашифровать и записать. Ключ, который provider отклонил бы, записывается так же — регистрация не судит. -
TestStoredCredentialUseCase(новый,application/use-cases/credentials/) читает конверт черезfindSecretByUserAndProvider, расшифровывает, вызывает правильный tester (git или LLM, диспетчеризуемыйisGitCredentialProvider) и возвращает только вердикт. Открытый текст существует внутри метода и не пересекает никакой границы. -
У результата ТРИ состояния, а не два:
когда почему okprovider принял — recusadoprovider отклонил несёт его причину ( 401, таймаут, нет баланса) — полезную диагностикуnao_suportadoнет проверенного endpoint проверки ollama,anthropic,openaiТретье состояние — не украшение. Tester LLM является NO-OP для providers без проверенного endpoint, и при бинарном результате они вернулись бы как
ok: экран утверждал бы, что ключ проверен, когда его никто не проверял. Это то же правило capability из ADR 0041 — объявляется только доказанное. Поэтому port получилsupports(): без него молчаниеtest()двусмысленно. -
POST /users/me/credentials/:provider/test— 200 при всех трёх результатах, потому что запрос обработан;recusado— это результат, а не ошибка протокола. 404, когда учётных данных нет: тогда проверять нечего. -
Плохой ключ перестал быть HTTP-исключением.
LLMCredentialConnectionTestFailedErrorиGitCredentialConnectionTestFailedErrorпродолжают существовать и бросаться tester'ами, но теперь перехватываются сценарием использования. Они ушли из двух@Catch(LlmBindingErrorFilter,GitProviderErrorFilter): фильтр, который не может сработать, — мёртвое правило, тот же критерий, который docmap применяет к glob, не совпадающему ни с одним файлом. -
Один потолок длины, и он — защита, а не проверка формата.
CREDENCIAL_COMPRIMENTO_MAXIMO = 512в обоих DTO, той же природы, что@MaxLengthпароля (domain/auth/password-policy.ts): маршрут шифрует, а шифрование копирует вход. Значение намеренно с запасом. Соблазн, после того как усечённый ключ был молча записан, — затягивать потолок, пока он не начнёт «проверять» ключ, — а это воссоздало бы шлюз через другую дверь. Настоящие учётные данные девяти providers — от ~26 символов (glpat-) до ~164 (project key OpenAI); потолок, близкий к реальному размеру, отклонил бы регистрацию хорошего ключа и плохо постарел бы, когда provider удлинит формат. Ключ наполовину по-прежнему принимается, и разоблачает его маршрут проверки. -
Экран предлагает то, что можно предложить. Проверить сохранённое нельзя, поэтому предлагается заменить (поле теперь видимо и при сохранённых учётных данных — раньше нужно было сначала удалить) и проверить. И каждый путь получил
try/catchс toast: именно их отсутствие превращало объяснённый 422 в немой экран.
Последствия
Что улучшается. Учётные данные всегда существуют после регистрации, поэтому всегда
есть что исправить, заменить или проверить. Диагностика стала лучше, чем была:
раньше 422 говорил «не вышло»; теперь recusado несёт фразу provider.
У маршрута git больше нет особой причины существовать отдельно — он остаётся
отдельным лишь из-за формата тела, что записано в controller.
Что ухудшается, и это принято. Недействительный ключ может оставаться сохранённым, пока кто-то
его не проверит или пока не провалится первое реальное использование. Это намеренно: ошибка
первого использования нормализуется по code со времён ADR 0041, а альтернатива —
отклонять запись — это то, что этот ADR отменяет. Smoke каждого provider
по-прежнему доказывает настоящий ключ против настоящего API, теперь новым путём.
Что не меняется. Envelope encryption, user_credentials, проекция без
секрета, RBAC маршрутов и правило, что открытый текст никогда не возвращается ни в одном
ответе.
Что остаётся на потом
- Проверять в момент использования и показывать. Сегодня отказ в
chat/syncпоявляется как ошибка хода; можно было бы помечать учётные данные как подозрительные на экране. Для этого нужно сохраняемое состояние на учётные данные — это не этот ADR. openai/anthropicбез проверенного endpoint проверки остаются вnao_suportado. Закрывается чтением официальной документации каждого и добавлением base URL в карту tester, со smoke, который докажет.
Ссылается на ADR 0004, порядок которого этот инвертирует, и на ADR 0041, от которого наследует правило объявлять только доказанное. Ни один из них не редактируется.