diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 78273a9ef790..4c6e04bb510e 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -28,6 +28,7 @@ import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; +import * as ServerSettings from "../src/serverSettings.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; @@ -155,7 +156,8 @@ await Effect.runPromise( GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, - BitbucketApi.layer, + // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. + BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())), ), ), Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index b2caba4a0347..502ae3430e27 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -44,6 +44,20 @@ const makeServerSettingsLayer = () => ), ); +/** Like `makeServerSettingsLayer`, but also exposes the secret store for assertions. */ +const makeServerSettingsLayerWithSecrets = () => + ServerSettingsModule.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-test-", + }), + ), + ), + ); + const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => Layer.succeed( ServerSecretStore.ServerSecretStore, @@ -1278,6 +1292,128 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect( + "keeps Bitbucket tokens in the secret store and tells clients only that one is set", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const saved = yield* serverSettings.updateSettings({ + bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" }, + }); + assert.deepEqual(saved.bitbucket, { + email: "me@example.com", + accessToken: "bb-access", + apiToken: "bb-api", + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "bb-access"); + assert.notInclude(raw, "bb-api"); + assert.include(raw, "me@example.com"); + + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket; + assert.equal(forClient.email, "me@example.com"); + assert.notInclude(forClient.accessToken, "bb-access"); + assert.notInclude(forClient.apiToken, "bb-api"); + assert.isAbove(forClient.accessToken.length, 0); + assert.isAbove(forClient.apiToken.length, 0); + + // A client echoing the redacted values back, or omitting them, keeps the saved tokens. + yield* serverSettings.updateSettings({ bitbucket: forClient }); + yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } }); + assert.deepEqual((yield* serverSettings.getSettings).bitbucket, { + email: "other@example.com", + accessToken: "bb-access", + apiToken: "bb-api", + }); + + const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } }); + assert.equal(cleared.bitbucket.accessToken, ""); + assert.equal(cleared.bitbucket.apiToken, "bb-api"); + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + assert.equal( + ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken, + "", + ); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + // A token was saved, then the user deleted it from settings.json directly. + yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token")); + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + + yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true }); + + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"bitbucket":{"accessToken":"hand-edited-token"}}', + ); + + // Loading alone moves it: no settings update is needed. + const loaded = yield* serverSettings.getSettings; + + assert.equal(loaded.bitbucket.accessToken, "hand-edited-token"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "hand-edited-token", + ); + const stored = yield* secrets.get("bitbucket-access-token"); + assert.equal( + Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null, + "hand-edited-token", + ); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect( + "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker", + () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}', + ); + + // The form resends the redacted token when only the email changes. + const forClient = ServerSettingsModule.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ).bitbucket; + const updated = yield* serverSettings.updateSettings({ + bitbucket: { email: "new@example.com", apiToken: forClient.apiToken }, + }); + + assert.equal(updated.bitbucket.apiToken, "hand-edited-token"); + assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "hand-edited-token", + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("materializes provider secrets for terminal environment resolution", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ed52282c0237..6949a66d981b 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -140,16 +140,24 @@ function providerEnvironmentSecretName(input: { } /** - * On disk the hub key is replaced by this marker and the real value lives in - * the secret store, mirroring provider environment secrets. A client that - * sends the marker back means "keep what you have". + * On disk a hub key or Bitbucket token is replaced by this marker and the + * real value lives in the secret store, mirroring provider environment + * secrets. A client that sends the marker back means "keep what you have". */ -const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; +const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; function usageLimitSourceSecretName(sourceId: string): string { return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; } +const BITBUCKET_SECRET_NAMES = { + accessToken: "bitbucket-access-token", + apiToken: "bitbucket-api-token", +} as const; +const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const; + +const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); + function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { @@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS id, { ...source, - managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "", + managementKey: redactSecret(source.managementKey), }, ]), ); - return { ...settings, providerInstances, usageLimitSources }; + const bitbucket = { + ...settings.bitbucket, + accessToken: redactSecret(settings.bitbucket.accessToken), + apiToken: redactSecret(settings.bitbucket.apiToken), + }; + return { ...settings, providerInstances, usageLimitSources, bitbucket }; } export class ServerSettingsService extends Context.Service< @@ -555,6 +568,35 @@ const make = Effect.gen(function* () { ), ); + /** + * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load, + * so plaintext does not stay on disk. If the store is unavailable, the token keeps working + * from the file and the move is retried on the next load. + */ + const moveInlineBitbucketTokens = (settings: ServerSettings) => + Effect.gen(function* () { + const bitbucket = { ...settings.bitbucket }; + let moved = false; + for (const field of BITBUCKET_SECRET_FIELDS) { + const value = bitbucket[field]; + if (value.length === 0 || value === SECRET_REDACTED) continue; + const stored = yield* secretStore + .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value)) + .pipe( + Effect.as(true), + Effect.catch(() => + Effect.logWarning("failed to move a Bitbucket token into the secret store", { + field, + }).pipe(Effect.as(false)), + ), + ); + if (!stored) continue; + bitbucket[field] = SECRET_REDACTED; + moved = true; + } + return moved ? { ...settings, bitbucket } : settings; + }); + const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let persisted: typeof PersistedOptionalProviderSettings.Type = {}; @@ -639,10 +681,12 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; - if (folded !== loaded) { - yield* writeSettingsAtomically(folded); + // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. + const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; + if (migrated !== loaded) { + yield* writeSettingsAtomically(migrated); } - return folded; + return migrated; }); const settingsCache = yield* Cache.make({ @@ -693,7 +737,7 @@ const make = Effect.gen(function* () { } const usageLimitSources: Record = {}; for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { - if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) { + if (source.managementKey !== SECRET_REDACTED) { usageLimitSources[sourceId] = source; continue; } @@ -709,10 +753,23 @@ const make = Effect.gen(function* () { managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }; } + const bitbucket = { ...settings.bitbucket }; + for (const field of BITBUCKET_SECRET_FIELDS) { + if (bitbucket[field] !== SECRET_REDACTED) continue; + const secret = yield* secretStore + .get(BITBUCKET_SECRET_NAMES[field]) + .pipe( + Effect.mapError( + (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), + ), + ); + bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; + } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + bitbucket, }; }); @@ -829,7 +886,7 @@ const make = Effect.gen(function* () { const usageLimitSources: Record = {}; for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { const secretName = usageLimitSourceSecretName(sourceId); - if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) { + if (source.managementKey === SECRET_REDACTED) { usageLimitSources[sourceId] = source; continue; } @@ -843,7 +900,7 @@ const make = Effect.gen(function* () { secretName, value: textEncoder.encode(source.managementKey), }); - usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED }; + usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED }; } for (const sourceId of Object.keys(current.usageLimitSources)) { if (sourceId in next.usageLimitSources) continue; @@ -854,11 +911,31 @@ const make = Effect.gen(function* () { }); } + const bitbucket = { ...next.bitbucket }; + for (const field of BITBUCKET_SECRET_FIELDS) { + let value = bitbucket[field]; + if (value === SECRET_REDACTED) { + // The marker keeps what is saved. A plaintext value hand-edited into settings.json + // is not in the secret store yet, so move it there instead of dropping it. + const inline = current.bitbucket[field]; + if (inline === SECRET_REDACTED || inline.length === 0) continue; + value = inline; + } + const secretName = BITBUCKET_SECRET_NAMES[field]; + if (value.length === 0) { + changes.push({ kind: "remove", secretName, operation: "remove-secret" }); + continue; + } + changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); + bitbucket[field] = SECRET_REDACTED; + } + return { settings: { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + bitbucket, }, changes, }; diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index b27d56bef399..83965a38b21c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -16,6 +16,7 @@ import { import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; @@ -64,6 +65,7 @@ function makeLayer(input: { request: HttpClientRequest.HttpClientRequest, ) => HttpClientError.HttpClientError; readonly git?: Partial; + readonly env?: Record; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => input.requestFailure @@ -150,7 +152,7 @@ function makeLayer(input: { Layer.provide( ConfigProvider.layer( ConfigProvider.fromEnv({ - env: { + env: input.env ?? { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", T3CODE_BITBUCKET_EMAIL: "user@example.com", T3CODE_BITBUCKET_API_TOKEN: "token", @@ -158,6 +160,7 @@ function makeLayer(input: { }), ), ), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(NodeServices.layer), ); @@ -509,6 +512,78 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => }).pipe(Effect.provide(layer)); }); +it.effect("prefers credentials saved in settings over the environment, without a restart", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + const lastAuthorization = () => execute.mock.calls.at(-1)?.[0].headers.authorization; + const basic = (user: string, password: string) => `Basic ${btoa(`${user}:${password}`)}`; + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); + + yield* settings.updateSettings({ + bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" }, + }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token")); + + yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), "Bearer saved-access-token"); + + yield* settings.updateSettings({ bitbucket: { accessToken: "", apiToken: "" } }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); + }).pipe(Effect.provide(layer)); +}); + +it.effect("never puts a saved token that is unsafe for an HTTP header on the wire", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + // Fetch would reject this header with an error quoting the token, and that error reaches + // clients. The unusable token is ignored, so the environment credential is used instead. + yield* settings.updateSettings({ bitbucket: { accessToken: "saved\ntoken" } }); + yield* bitbucket.probeAuth; + assert.strictEqual( + execute.mock.calls.at(-1)?.[0].headers.authorization, + `Basic ${btoa("user@example.com:token")}`, + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => { + const { layer } = makeLayer({ + response: () => new Response(null, { status: 401 }), + env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated"); + + yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + assert.deepStrictEqual(yield* bitbucket.probeAuth, { + status: "unknown", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some("An access token is configured."), + }); + }).pipe(Effect.provide(layer)); +}); + it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { const transportCause = new Error("socket reset by peer"); let requestFailure: HttpClientError.HttpClientError | undefined; diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 202740680be8..2404e6b497e3 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -7,8 +7,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + DEFAULT_SERVER_SETTINGS, NonNegativeInt, TrimmedNonEmptyString, + type BitbucketSettings, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryVisibility, @@ -28,6 +30,7 @@ import { } from "./bitbucketPullRequests.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { retryAtFromHeader } from "./SourceControlRateLimit.ts"; @@ -537,24 +540,64 @@ function repositoryOwnerName(repositoryName: string): string { return repositoryName.split("/")[0]?.trim() || "bitbucket"; } -function authFromConfig( - config: Config.Success, -): SourceControlProviderAuth { - if (Option.isSome(config.accessToken)) { +type BitbucketCredential = + | { readonly kind: "access-token"; readonly accessToken: string } + | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; + +/** + * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole + * header, and that error travels to clients as a cause, so an unusable token is treated as unset. + */ +const HEADER_SAFE = /^[\x21-\x7e]+$/u; + +function credentialFrom(input: { + readonly accessToken: string; + readonly email: string; + readonly apiToken: string; +}): BitbucketCredential | null { + if (HEADER_SAFE.test(input.accessToken)) { + return { kind: "access-token", accessToken: input.accessToken }; + } + if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) { + return { kind: "api-token", email: input.email, apiToken: input.apiToken }; + } + return null; +} + +/** + * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which + * stay as a fallback. Within each source the access token wins. + */ +function resolveCredential( + settings: BitbucketSettings, + env: Config.Success, +): BitbucketCredential | null { + return ( + credentialFrom(settings) ?? + credentialFrom({ + accessToken: Option.getOrElse(env.accessToken, () => ""), + email: Option.getOrElse(env.email, () => ""), + apiToken: Option.getOrElse(env.apiToken, () => ""), + }) + ); +} + +function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth { + if (credential?.kind === "access-token") { return { status: "unknown", account: Option.none(), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket access token is configured."), + detail: Option.some("An access token is configured."), }; } - if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + if (credential?.kind === "api-token") { return { status: "unknown", - account: config.email, + account: Option.some(credential.email), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket API token is configured."), + detail: Option.some("An API token is configured."), }; } @@ -563,7 +606,7 @@ function authFromConfig( account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", ), }; } @@ -612,6 +655,7 @@ function responseError( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; const git = yield* GitVcsDriver.GitVcsDriver; @@ -619,15 +663,27 @@ export const make = Effect.gen(function* () { const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; - const withAuth = (request: HttpClientRequest.HttpClientRequest) => { - if (Option.isSome(config.accessToken)) { - return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); - } - if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { - return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); - } - return request; - }; + // Read on every request so credentials saved in settings apply without a restart. + const currentCredential = serverSettings.getSettings.pipe( + Effect.map((settings) => resolveCredential(settings.bitbucket, config)), + Effect.catch((error) => + // No cause: a settings decode error can quote a hand-edited token. + Effect.logWarning("failed to read Bitbucket credentials from settings", { + operation: error.operation, + }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), + ), + ); + + const withAuth = (request: HttpClientRequest.HttpClientRequest) => + currentCredential.pipe( + Effect.map((credential) => + credential === null + ? request + : credential.kind === "access-token" + ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken)) + : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)), + ), + ); const decodeResponse = ( operation: BitbucketApiOperation, @@ -654,7 +710,8 @@ export const make = Effect.gen(function* () { request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => - httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( + withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( + Effect.flatMap(httpClient.execute), Effect.mapError( (cause) => new BitbucketRequestError({ @@ -847,7 +904,8 @@ export const make = Effect.gen(function* () { input.body === undefined ? base : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); - return httpClient.execute(withAuth(withBody)).pipe( + return withAuth(withBody).pipe( + Effect.flatMap(httpClient.execute), Effect.mapError( (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), ), @@ -913,7 +971,7 @@ export const make = Effect.gen(function* () { host: Option.some("bitbucket.org"), detail: Option.none(), })), - Effect.orElseSucceed(() => authFromConfig(config)), + Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), ), listPullRequests: (input) => resolveRepository(input).pipe( diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index e27aa4c7dc88..f1a01056fbd0 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () { type: "api", kind: "bitbucket", label: "Bitbucket", - installHint: - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request, repository, and user read scopes).", + installHint: "Add a Bitbucket token in Settings → Source Control.", probeAuth: bitbucket.probeAuth, } satisfies SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 30aa22995b95..820b9a76c9a2 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -423,7 +423,7 @@ it.effect("reports implemented tools separately from locally available executabl account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", ), }), }, diff --git a/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx new file mode 100644 index 000000000000..a423334d1c7f --- /dev/null +++ b/apps/web/src/components/settings/BitbucketCredentialsSettings.tsx @@ -0,0 +1,223 @@ +import type { BitbucketSettings, EnvironmentId } from "@t3tools/contracts"; +import { ExternalLinkIcon } from "lucide-react"; +import { useState } from "react"; + +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button, InlineButton } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; + +type CredentialMethod = "access-token" | "api-token"; + +const METHODS: Record< + CredentialMethod, + { + readonly label: string; + readonly description: string; + readonly link: string; + readonly linkLabel: string; + } +> = { + "access-token": { + label: "Access token", + description: + "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.", + link: "https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/", + linkLabel: "Learn more", + }, + "api-token": { + label: "API token", + description: + "Uses your Atlassian account, so it reaches every repository you can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", + link: "https://id.atlassian.com/manage-profile/security/api-tokens", + linkLabel: "Create an API token", + }, +}; + +function savedMethod(saved: BitbucketSettings): CredentialMethod | null { + if (saved.accessToken.length > 0) return "access-token"; + if (saved.email.length > 0 && saved.apiToken.length > 0) return "api-token"; + return null; +} + +/** A write-only token field. It never shows the saved token; typing a new one replaces it. */ +function TokenInput({ + id, + isSaved, + draft, + onDraftChange, +}: { + readonly id: string; + readonly isSaved: boolean; + readonly draft: string; + readonly onDraftChange: (draft: string) => void; +}) { + return ( + onDraftChange(event.target.value)} + /> + ); +} + +/** + * Bitbucket credentials for one environment: an access token or an Atlassian + * account email + API token, never both. Tokens are write-only: the server + * keeps them in its secret store and only reports whether each one is set. + */ +export function BitbucketCredentialsSettings({ + environmentId, + onSaved, +}: { + readonly environmentId: EnvironmentId; + readonly onSaved: () => void; +}) { + const saved = useEnvironmentSettings(environmentId, (settings) => settings.bitbucket); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "save Bitbucket credentials", + }); + const [methodChoice, setMethodChoice] = useState(null); + const [accessToken, setAccessToken] = useState(""); + const [emailDraft, setEmailDraft] = useState(null); + const [apiToken, setApiToken] = useState(""); + const [saving, setSaving] = useState(false); + const current = savedMethod(saved); + const method = methodChoice ?? current ?? "access-token"; + const methodIsSaved = current === method; + const email = (emailDraft ?? saved.email).trim(); + const newAccessToken = accessToken.trim(); + const newApiToken = apiToken.trim(); + const info = METHODS[method]; + + // Saving one method clears the other, so a hidden credential never wins over the visible one. + const patch: BitbucketSettings | null = + method === "access-token" + ? newAccessToken + ? { accessToken: newAccessToken, email: "", apiToken: "" } + : null + : email && (newApiToken || saved.apiToken) + ? // Resending the saved token's redacted value keeps it. + { accessToken: "", email, apiToken: newApiToken || saved.apiToken } + : null; + const canSave = + patch !== null && + (method === "access-token" || !methodIsSaved || newApiToken !== "" || email !== saved.email); + + const save = async (next: BitbucketSettings) => { + setSaving(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { bitbucket: next } }, + }); + if (result._tag === "Success") { + setAccessToken(""); + setApiToken(""); + setEmailDraft(null); + onSaved(); + } + } finally { + setSaving(false); + } + }; + + return ( +
{ + event.preventDefault(); + if (canSave && patch) void save(patch); + }} + > + {/* Locked while saving: a successful save clears the drafts, which would drop edits made mid-request. */} +
+ { + const value = next[0]; + if (value === "access-token" || value === "api-token") setMethodChoice(value); + }} + > + {METHODS["access-token"].label} + {METHODS["api-token"].label} + +

+ {info.description}{" "} + }> + {info.linkLabel} + + +

+ {method === "access-token" ? ( +
+ + +
+ ) : ( + <> +
+ + setEmailDraft(event.target.value)} + /> +
+
+ + +
+ + )} +
+

+ {current === null + ? "Without a saved token, the server falls back to its T3CODE_BITBUCKET_* environment variables." + : methodIsSaved + ? null + : `Saving replaces your ${METHODS[current].label.toLowerCase()}.`} +

+
+ {current !== null ? ( + + ) : null} + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 10f86084ea20..9fba0edc7d44 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -56,6 +56,7 @@ import { JujutsuIcon, type Icon, } from "../Icons"; +import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; import { @@ -234,7 +235,9 @@ function itemSummary({ ); } - if (!item.executable) { + // API integrations have no CLI to sign in with; an unverified saved credential falls + // through to the "could not verify" detail instead of repeating the setup hint. + if (!item.executable && auth.status === "unauthenticated") { return Available. {item.installHint}; } @@ -277,7 +280,11 @@ function DiscoveryItemRow({ const searchTargetId = useSettingsSearchTargetId(); useEffect(() => { - if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) { + if ( + (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) || + (item.kind === "bitbucket" && + searchTargetId === searchableSetting("bitbucket-credentials").id) + ) { setIsExpanded(true); } }, [item.kind, searchTargetId]); @@ -586,7 +593,18 @@ export function SourceControlSettingsPanel() { headerAction={hasVersionControlSystems ? null : scanButton} > {result.sourceControlProviders.map((item) => ( - + + {item.kind === "bitbucket" ? ( + + + + ) : undefined} + ))} ) : null} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index c9cb76401bd6..4edce393883c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -695,6 +695,14 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "environment-defaults", }, + { + id: "bitbucket-credentials", + title: "Bitbucket credentials", + to: "/settings/source-control", + searchTerms: ["bitbucket atlassian access token api token email credentials sign in"], + environmentOnly: true, + scope: "environment-defaults", + }, { id: "source-control-writing-style", title: "Source control writing style", diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 408810571632..0427f143c7d5 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -47,23 +47,29 @@ glab auth login ### Bitbucket -Set an access token in the server's environment: +Open **Settings → Source Control**, expand **Bitbucket**, and choose how to sign in: -```bash -export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" -``` +- **Access token**: a token created for one repository, project, or workspace. It can only reach + what it was created for. +- **API token**: an Atlassian API token for your account, used with your account email. It can + reach every repository you can. Give it read/write access to repositories and pull requests, plus + user read access (`read:user:bitbucket`). -Or use an Atlassian account email and API token with read/write access to repositories and pull -requests, plus user read access (`read:user:bitbucket`): +Choose **Save**; the change applies right away, and replaces any credential saved with the other +method. Credentials are saved on the environment's server, so select a remote environment to +configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose +**Remove**. + +If no credentials are saved, T3 Code falls back to these variables in the server's environment. +Restart the server after changing them: ```bash +export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" +# or export T3CODE_BITBUCKET_EMAIL="you@example.com" export T3CODE_BITBUCKET_API_TOKEN="your-token" ``` -The access token takes precedence if both are configured. Restart the server after changing these -variables. - ### Azure DevOps Install [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/), add the DevOps extension, and sign in: @@ -138,7 +144,8 @@ does not show its diff, so marks are made and read on web and desktop. ## Troubleshooting - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, - confirm the running server received the environment variables. + check the credentials saved in Settings → Source Control, or confirm the running server received + the environment variables. - **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0. - **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS remotes can require separate setup from the hosting provider's API access. diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 1a3c2d50b209..7a249cefb373 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1277,9 +1277,9 @@ const PROVIDER_REQUIREMENT: Partial< }, bitbucket: { missing: - "Bitbucket needs API credentials on the server. Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.", unauthenticated: - "Bitbucket rejected the configured credentials. Check T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.", + "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.", }, }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e8dc37bfc839..3fd27b7f7379 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -901,6 +901,19 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; +/** + * Bitbucket API credentials for this environment, used before the + * `T3CODE_BITBUCKET_*` environment variables. The tokens live in the server's + * secret store; settings and clients only see a redaction marker when one is + * set. The access token wins when both kinds are configured. + */ +export const BitbucketSettings = Schema.Struct({ + email: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + accessToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + apiToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), +}); +export type BitbucketSettings = typeof BitbucketSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -1277,6 +1290,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), // Keyed by a user-chosen id so a source keeps its rows across edits. Entries // this build cannot decode round-trip untouched, as provider instances do. usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( @@ -1538,6 +1552,14 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), + /** An empty token clears it; an omitted one keeps what the server has. */ + bitbucket: Schema.optionalKey( + Schema.Struct({ + email: Schema.optionalKey(TrimmedString), + accessToken: Schema.optionalKey(TrimmedString), + apiToken: Schema.optionalKey(TrimmedString), + }), + ), providers: Schema.optionalKey( Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch),