Перейти к основному содержимому

ADR 0050 — Учётные данные всегда зашифрованы; проверка становится явным действием

Контекст​

ADR 0004 установил для токенов git, что регистрация проверяет учётные данные против provider до шифрования и сохранения — недействительный токен даёт ошибку, а не сохраняется, чтобы провалиться позже. Фаза 11a скопировала тот же порядок для ключей LLM.

Порядок казался благоразумным. В реальном использовании он дал худший из возможных исходов.

Owner попытался зарегистрировать ключ OpenRouter на экране настроек. Шесть кликов, шесть POST /users/me/credentials, шесть 422. С его стороны кнопка Сохранить ничего не делала — и это был второй дефект, в web (handleSave без try/catch, с ApiError, утекающим в window.onunhandledrejection, который только логирует). Но даже с сообщением на экране дизайн уже был неправильным, по трём причинам, которые проявляются только вместе:

  1. Ничего не записывается, а ключ не восстановить. Поле только для записи, и экран никогда не показывает снова то, что было введено. Отказ оставляет пользователя без учётных данных и без текста для исправления — он снова открывает экран в том же состоянии, что и раньше, потеряв вставленное.
  2. Регистрация судит по неполной информации. Проверка проваливается из-за недействительного ключа, ключа без баланса, сети, таймаута, DNS. Все они становятся одним и тем же 422 в неправильный момент: в момент сохранения. Вопрос «хорош ли этот ключ?» — не то же самое, что «хочу ли я сохранить этот ключ?», и их связывание заставляет второй зависеть от того, есть ли у первого ответ прямо сейчас.
  3. Обещание экрана становится невыполнимым. «Только для записи, никогда не показывается снова» означает, что никто — даже владелец — не может проверить, что сохранено. Если сохранение может молча провалиться, не остаётся никакого способа узнать, в каком состоянии находятся учётные данные.

Была ещё и необоснованная асимметрия: единственной задокументированной причиной того, что POST /users/me/git-credentials существует отдельно от POST /users/me/credentials, была как раз обязательная проверка.

Решение​

Сохранить и проверить — два разных вопроса. Регистрация только сохраняет; проверка — собственное действие над уже записанными учётными данными.

Действует для обоих семейств — ключа LLM и токена git. Это одна и та же таблица (user_credentials), тот же механизм envelope encryption и то же обещание пользователю; обращаться с ними по-разному дало бы два экрана с разными правилами для одного объекта.

  1. UpsertUserCredentialUseCase и RegisterGitCredentialUseCase теряют tester. Остаются две строки: зашифровать и записать. Ключ, который provider отклонил бы, записывается так же — регистрация не судит.

  2. TestStoredCredentialUseCase (новый, application/use-cases/credentials/) читает конверт через findSecretByUserAndProvider, расшифровывает, вызывает правильный tester (git или LLM, диспетчеризуемый isGitCredentialProvider) и возвращает только вердикт. Открытый текст существует внутри метода и не пересекает никакой границы.

  3. У результата ТРИ состояния, а не два:

    когдапочему
    okprovider принял—
    recusadoprovider отклонилнесёт его причину (401, таймаут, нет баланса) — полезную диагностику
    nao_suportadoнет проверенного endpoint проверкиollama, anthropic, openai

    Третье состояние — не украшение. Tester LLM является NO-OP для providers без проверенного endpoint, и при бинарном результате они вернулись бы как ok: экран утверждал бы, что ключ проверен, когда его никто не проверял. Это то же правило capability из ADR 0041 — объявляется только доказанное. Поэтому port получил supports(): без него молчание test() двусмысленно.

  4. POST /users/me/credentials/:provider/test — 200 при всех трёх результатах, потому что запрос обработан; recusado — это результат, а не ошибка протокола. 404, когда учётных данных нет: тогда проверять нечего.

  5. Плохой ключ перестал быть HTTP-исключением. LLMCredentialConnectionTestFailedError и GitCredentialConnectionTestFailedError продолжают существовать и бросаться tester'ами, но теперь перехватываются сценарием использования. Они ушли из двух @Catch (LlmBindingErrorFilter, GitProviderErrorFilter): фильтр, который не может сработать, — мёртвое правило, тот же критерий, который docmap применяет к glob, не совпадающему ни с одним файлом.

  6. Один потолок длины, и он — защита, а не проверка формата. CREDENCIAL_COMPRIMENTO_MAXIMO = 512 в обоих DTO, той же природы, что @MaxLength пароля (domain/auth/password-policy.ts): маршрут шифрует, а шифрование копирует вход. Значение намеренно с запасом. Соблазн, после того как усечённый ключ был молча записан, — затягивать потолок, пока он не начнёт «проверять» ключ, — а это воссоздало бы шлюз через другую дверь. Настоящие учётные данные девяти providers — от ~26 символов (glpat-) до ~164 (project key OpenAI); потолок, близкий к реальному размеру, отклонил бы регистрацию хорошего ключа и плохо постарел бы, когда provider удлинит формат. Ключ наполовину по-прежнему принимается, и разоблачает его маршрут проверки.

  7. Экран предлагает то, что можно предложить. Проверить сохранённое нельзя, поэтому предлагается заменить (поле теперь видимо и при сохранённых учётных данных — раньше нужно было сначала удалить) и проверить. И каждый путь получил 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, от которого наследует правило объявлять только доказанное. Ни один из них не редактируется.