ADR 0004 — Registration of the user's git credentials
Context
Item 3 of Phase 2 (see CLAUDE.md): the user's git credentials (GitHub/GitLab
tokens used by GithubProvider/GitlabProvider to operate on their
behalf) need to live in the same user_credentials table as the LLM API
keys (Phase 1), with the same envelope encryption, and with a mandatory
connection test at registration time.
Two shape questions:
user_credentials.providerwas already anllm_providerenum (ollama/anthropic/...). A git token isn't an LLM provider — wideningllm_providerwould mix two domains into a single enum also used bymodels/token_usage(genuinely LLM-only). Reusinggit_provider(used byproject_git_connections) doesn't work either: it includes'local', which makes no sense as a credential provider (there's no token for local git).- An invalid/revoked token is only discovered when it's actually used — without a connection test, registration "succeeds" and the failure only surfaces at the first Gitflow bootstrap, much later and more costly to diagnose.
Decision
Dedicated enum credential_provider (migration 0007), with
CredentialProviderName = LLMProviderName | GitCredentialProviderName
in packages/shared — a union of the two domains only in the type used
by UserCredentialRepository, without mixing the database enums.
GitCredentialProviderName is Extract<GitProviderName, 'github' | 'gitlab'> — it derives from the git provider enum instead of
duplicating the list, but structurally excludes 'local'.
SYNCHRONOUS and MANDATORY connection test before encrypting/persisting.
RegisterGitCredentialUseCase.execute (new) calls
GitCredentialConnectionTester.test(provider, token) first; only if
that resolves is the token encrypted
(EncryptionService.encrypt) and written
(UserCredentialRepository.upsert). On a failure, nothing is written —
see GitCredentialConnectionTestFailedError in
domain/git/git-errors.ts. The real implementation
(GitCredentialConnectionTesterImpl) makes the cheapest possible call
on each API to confirm the token authenticates:
GET /user (Octokit users.getAuthenticated) on GitHub,
GET /user (Gitbeaker Users.showCurrentUser) on GitLab — neither
attempts to list repos or anything that depends on scope beyond basic
authentication.
PAT always via token: in Gitbeaker, never oauthToken:. GitLab
validates the two differently (PRIVATE-TOKEN header vs.
Authorization: Bearer) — they aren't interchangeable. oauthToken is
reserved for the project-level OAuth flow
(project_git_connections, Phase 2 item 4+), which this session doesn't
change.
Dedicated endpoint only for registration; GET/DELETE reused.
POST /users/me/git-credentials (new,
GitCredentialsController) is the only path in this session, because
only registration needs the synchronous connection test. Listing and
deletion already existed in CredentialsController (Phase 1, LLM) over
the same table/repository — UserCredentialRepository was widened from
LLMProviderName to CredentialProviderName (it was the loose
LLMProviderName left over that broke the build, see delete() in
user-credential.repository.ts) instead of duplicating
list/delete for git. No @RequireRole on the new controller: it's
about the authenticated user's own credential, the same pattern as the
equivalent LLM endpoint.
Connection failure maps to 422, not 400 or 409
(git-provider-error.filter.ts): it's neither malformed payload (400)
nor a conflict with existing state (409) — it's a semantically invalid
entity (a token that never authenticated), the textbook 422 case.
Consequences
- Registering a git credential now makes a synchronous network call to the provider's API before responding — the endpoint is slower than a "blind" registration, deliberately (see Decision).
UserCredentialRepository(and its migration0007) now serves two domains (LLM and git) through the same table/type enum — any future credential provider (git or otherwise) enters throughCredentialProviderName, not a new enum.- The connection test isn't retried (
GitCredentialConnectionTesterdoesn't usewithRetry, see 0003) — a transient network failure during registration requires the user to try again manually; acceptable because it's a one-off interactive action, not a background operation.