authorgravatar for 30420087+gabrielelpidio@users.noreply.github.comGabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> 2026-09-28 21:38:24-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-28 18:38:24-07:00
log5da55957d48dbd79c1a05b72bdf2479ae0956b1c
treed553050ad91d20574eaa54406e01e53ca06e5c9a
parente518866d28603da273a9357516dd1aef919f1353
signature Signed by PGP key B5690EEEBB952194

feat: save Bitbucket credentials from Source Control settings (#14103)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

13 files changed, 678 insertions(+), 53 deletions(-)

apps/server/scripts/evaluate-thread-titles.ts+3-1
......@@ -28,6 +28,7 @@ import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
2828import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";
2929import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";
3030import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";
31import * as ServerSettings from "../src/serverSettings.ts";
3132import * as VcsProcess from "../src/vcs/VcsProcess.ts";
3233import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
3334import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts";
......@@ -155,7 +156,8 @@ await Effect.runPromise(
155156 GitLabCli.layer,
156157 ForgejoCli.layer,
157158 AzureDevOpsCli.layer,
158 BitbucketApi.layer,
159 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
160 BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())),
159161 ),
160162 ),
161163 Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))),
apps/server/src/serverSettings.test.ts+136
......@@ -44,6 +44,20 @@ const makeServerSettingsLayer = () =>
4444 ),
4545 );
4646
47/** Like `makeServerSettingsLayer`, but also exposes the secret store for assertions. */
48const makeServerSettingsLayerWithSecrets = () =>
49 ServerSettingsModule.layer.pipe(
50 Layer.provideMerge(ServerSecretStore.layer),
51 Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)),
52 Layer.provideMerge(
53 Layer.fresh(
54 ServerConfig.layerTest(process.cwd(), {
55 prefix: "t3code-server-settings-test-",
56 }),
57 ),
58 ),
59 );
60
4761const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) =>
4862 Layer.succeed(
4963 ServerSecretStore.ServerSecretStore,
......@@ -1278,6 +1292,128 @@ it.layer(NodeServices.layer)("server settings", (it) => {
12781292 }).pipe(Effect.provide(makeServerSettingsLayer())),
12791293 );
12801294
1295 it.effect(
1296 "keeps Bitbucket tokens in the secret store and tells clients only that one is set",
1297 () =>
1298 Effect.gen(function* () {
1299 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1300 const secrets = yield* ServerSecretStore.ServerSecretStore;
1301 const serverConfig = yield* ServerConfig.ServerConfig;
1302 const fileSystem = yield* FileSystem.FileSystem;
1303
1304 const saved = yield* serverSettings.updateSettings({
1305 bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" },
1306 });
1307 assert.deepEqual(saved.bitbucket, {
1308 email: "me@example.com",
1309 accessToken: "bb-access",
1310 apiToken: "bb-api",
1311 });
1312
1313 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1314 assert.notInclude(raw, "bb-access");
1315 assert.notInclude(raw, "bb-api");
1316 assert.include(raw, "me@example.com");
1317
1318 const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket;
1319 assert.equal(forClient.email, "me@example.com");
1320 assert.notInclude(forClient.accessToken, "bb-access");
1321 assert.notInclude(forClient.apiToken, "bb-api");
1322 assert.isAbove(forClient.accessToken.length, 0);
1323 assert.isAbove(forClient.apiToken.length, 0);
1324
1325 // A client echoing the redacted values back, or omitting them, keeps the saved tokens.
1326 yield* serverSettings.updateSettings({ bitbucket: forClient });
1327 yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } });
1328 assert.deepEqual((yield* serverSettings.getSettings).bitbucket, {
1329 email: "other@example.com",
1330 accessToken: "bb-access",
1331 apiToken: "bb-api",
1332 });
1333
1334 const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } });
1335 assert.equal(cleared.bitbucket.accessToken, "");
1336 assert.equal(cleared.bitbucket.apiToken, "bb-api");
1337 assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
1338 assert.equal(
1339 ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken,
1340 "",
1341 );
1342 }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())),
1343 );
1344
1345 it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () =>
1346 Effect.gen(function* () {
1347 const serverConfig = yield* ServerConfig.ServerConfig;
1348 const fileSystem = yield* FileSystem.FileSystem;
1349 const secrets = yield* ServerSecretStore.ServerSecretStore;
1350 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1351 // A token was saved, then the user deleted it from settings.json directly.
1352 yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token"));
1353 yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}");
1354
1355 yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true });
1356
1357 assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
1358 }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())),
1359 );
1360
1361 it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () =>
1362 Effect.gen(function* () {
1363 const serverConfig = yield* ServerConfig.ServerConfig;
1364 const fileSystem = yield* FileSystem.FileSystem;
1365 const secrets = yield* ServerSecretStore.ServerSecretStore;
1366 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1367 yield* fileSystem.writeFileString(
1368 serverConfig.settingsPath,
1369 '{"bitbucket":{"accessToken":"hand-edited-token"}}',
1370 );
1371
1372 // Loading alone moves it: no settings update is needed.
1373 const loaded = yield* serverSettings.getSettings;
1374
1375 assert.equal(loaded.bitbucket.accessToken, "hand-edited-token");
1376 assert.notInclude(
1377 yield* fileSystem.readFileString(serverConfig.settingsPath),
1378 "hand-edited-token",
1379 );
1380 const stored = yield* secrets.get("bitbucket-access-token");
1381 assert.equal(
1382 Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null,
1383 "hand-edited-token",
1384 );
1385 }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())),
1386 );
1387
1388 it.effect(
1389 "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker",
1390 () =>
1391 Effect.gen(function* () {
1392 const serverConfig = yield* ServerConfig.ServerConfig;
1393 const fileSystem = yield* FileSystem.FileSystem;
1394 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1395 yield* fileSystem.writeFileString(
1396 serverConfig.settingsPath,
1397 '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}',
1398 );
1399
1400 // The form resends the redacted token when only the email changes.
1401 const forClient = ServerSettingsModule.redactServerSettingsForClient(
1402 yield* serverSettings.getSettings,
1403 ).bitbucket;
1404 const updated = yield* serverSettings.updateSettings({
1405 bitbucket: { email: "new@example.com", apiToken: forClient.apiToken },
1406 });
1407
1408 assert.equal(updated.bitbucket.apiToken, "hand-edited-token");
1409 assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token");
1410 assert.notInclude(
1411 yield* fileSystem.readFileString(serverConfig.settingsPath),
1412 "hand-edited-token",
1413 );
1414 }).pipe(Effect.provide(makeServerSettingsLayer())),
1415 );
1416
12811417 it.effect("materializes provider secrets for terminal environment resolution", () =>
12821418 Effect.gen(function* () {
12831419 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
apps/server/src/serverSettings.ts+89-12
......@@ -140,16 +140,24 @@ function providerEnvironmentSecretName(input: {
140140}
141141
142142/**
143 * On disk the hub key is replaced by this marker and the real value lives in
144 * the secret store, mirroring provider environment secrets. A client that
145 * sends the marker back means "keep what you have".
143 * On disk a hub key or Bitbucket token is replaced by this marker and the
144 * real value lives in the secret store, mirroring provider environment
145 * secrets. A client that sends the marker back means "keep what you have".
146146 */
147const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022";
147const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022";
148148
149149function usageLimitSourceSecretName(sourceId: string): string {
150150 return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`;
151151}
152152
153const BITBUCKET_SECRET_NAMES = {
154 accessToken: "bitbucket-access-token",
155 apiToken: "bitbucket-api-token",
156} as const;
157const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const;
158
159const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : "");
160
153161function redactProviderEnvironmentVariable(
154162 variable: ProviderInstanceEnvironmentVariable,
155163): ProviderInstanceEnvironmentVariable {
......@@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS
182190 id,
183191 {
184192 ...source,
185 managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "",
193 managementKey: redactSecret(source.managementKey),
186194 },
187195 ]),
188196 );
189 return { ...settings, providerInstances, usageLimitSources };
197 const bitbucket = {
198 ...settings.bitbucket,
199 accessToken: redactSecret(settings.bitbucket.accessToken),
200 apiToken: redactSecret(settings.bitbucket.apiToken),
201 };
202 return { ...settings, providerInstances, usageLimitSources, bitbucket };
190203}
191204
192205export class ServerSettingsService extends Context.Service<
......@@ -555,6 +568,35 @@ const make = Effect.gen(function* () {
555568 ),
556569 );
557570
571 /**
572 * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load,
573 * so plaintext does not stay on disk. If the store is unavailable, the token keeps working
574 * from the file and the move is retried on the next load.
575 */
576 const moveInlineBitbucketTokens = (settings: ServerSettings) =>
577 Effect.gen(function* () {
578 const bitbucket = { ...settings.bitbucket };
579 let moved = false;
580 for (const field of BITBUCKET_SECRET_FIELDS) {
581 const value = bitbucket[field];
582 if (value.length === 0 || value === SECRET_REDACTED) continue;
583 const stored = yield* secretStore
584 .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value))
585 .pipe(
586 Effect.as(true),
587 Effect.catch(() =>
588 Effect.logWarning("failed to move a Bitbucket token into the secret store", {
589 field,
590 }).pipe(Effect.as(false)),
591 ),
592 );
593 if (!stored) continue;
594 bitbucket[field] = SECRET_REDACTED;
595 moved = true;
596 }
597 return moved ? { ...settings, bitbucket } : settings;
598 });
599
558600 const loadSettingsFromDisk = Effect.gen(function* () {
559601 let settings = DEFAULT_SERVER_SETTINGS;
560602 let persisted: typeof PersistedOptionalProviderSettings.Type = {};
......@@ -639,10 +681,12 @@ const make = Effect.gen(function* () {
639681 const folded = settingsFileTrusted
640682 ? foldLegacyProjectSettings(loaded, legacyProjectRows)
641683 : loaded;
642 if (folded !== loaded) {
643 yield* writeSettingsAtomically(folded);
684 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.
685 const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded;
686 if (migrated !== loaded) {
687 yield* writeSettingsAtomically(migrated);
644688 }
645 return folded;
689 return migrated;
646690 });
647691
648692 const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({
......@@ -693,7 +737,7 @@ const make = Effect.gen(function* () {
693737 }
694738 const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};
695739 for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) {
696 if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) {
740 if (source.managementKey !== SECRET_REDACTED) {
697741 usageLimitSources[sourceId] = source;
698742 continue;
699743 }
......@@ -709,10 +753,23 @@ const make = Effect.gen(function* () {
709753 managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "",
710754 };
711755 }
756 const bitbucket = { ...settings.bitbucket };
757 for (const field of BITBUCKET_SECRET_FIELDS) {
758 if (bitbucket[field] !== SECRET_REDACTED) continue;
759 const secret = yield* secretStore
760 .get(BITBUCKET_SECRET_NAMES[field])
761 .pipe(
762 Effect.mapError(
763 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),
764 ),
765 );
766 bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : "";
767 }
712768 return {
713769 ...settings,
714770 providerInstances: providerInstances as ServerSettings["providerInstances"],
715771 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
772 bitbucket,
716773 };
717774 });
718775
......@@ -829,7 +886,7 @@ const make = Effect.gen(function* () {
829886 const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};
830887 for (const [sourceId, source] of Object.entries(next.usageLimitSources)) {
831888 const secretName = usageLimitSourceSecretName(sourceId);
832 if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) {
889 if (source.managementKey === SECRET_REDACTED) {
833890 usageLimitSources[sourceId] = source;
834891 continue;
835892 }
......@@ -843,7 +900,7 @@ const make = Effect.gen(function* () {
843900 secretName,
844901 value: textEncoder.encode(source.managementKey),
845902 });
846 usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED };
903 usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED };
847904 }
848905 for (const sourceId of Object.keys(current.usageLimitSources)) {
849906 if (sourceId in next.usageLimitSources) continue;
......@@ -854,11 +911,31 @@ const make = Effect.gen(function* () {
854911 });
855912 }
856913
914 const bitbucket = { ...next.bitbucket };
915 for (const field of BITBUCKET_SECRET_FIELDS) {
916 let value = bitbucket[field];
917 if (value === SECRET_REDACTED) {
918 // The marker keeps what is saved. A plaintext value hand-edited into settings.json
919 // is not in the secret store yet, so move it there instead of dropping it.
920 const inline = current.bitbucket[field];
921 if (inline === SECRET_REDACTED || inline.length === 0) continue;
922 value = inline;
923 }
924 const secretName = BITBUCKET_SECRET_NAMES[field];
925 if (value.length === 0) {
926 changes.push({ kind: "remove", secretName, operation: "remove-secret" });
927 continue;
928 }
929 changes.push({ kind: "write", secretName, value: textEncoder.encode(value) });
930 bitbucket[field] = SECRET_REDACTED;
931 }
932
857933 return {
858934 settings: {
859935 ...next,
860936 providerInstances: providerInstances as ServerSettings["providerInstances"],
861937 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
938 bitbucket,
862939 },
863940 changes,
864941 };
apps/server/src/sourceControl/BitbucketApi.test.ts+76-1
......@@ -16,6 +16,7 @@ import {
1616
1717import { GitCommandError } from "@t3tools/contracts";
1818import * as BitbucketApi from "./BitbucketApi.ts";
19import * as ServerSettings from "../serverSettings.ts";
1920import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
2021import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
2122import type * as VcsDriver from "../vcs/VcsDriver.ts";
......@@ -64,6 +65,7 @@ function makeLayer(input: {
6465 request: HttpClientRequest.HttpClientRequest,
6566 ) => HttpClientError.HttpClientError;
6667 readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>;
68 readonly env?: Record<string, string>;
6769}) {
6870 const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) =>
6971 input.requestFailure
......@@ -150,7 +152,7 @@ function makeLayer(input: {
150152 Layer.provide(
151153 ConfigProvider.layer(
152154 ConfigProvider.fromEnv({
153 env: {
155 env: input.env ?? {
154156 T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0",
155157 T3CODE_BITBUCKET_EMAIL: "user@example.com",
156158 T3CODE_BITBUCKET_API_TOKEN: "token",
......@@ -158,6 +160,7 @@ function makeLayer(input: {
158160 }),
159161 ),
160162 ),
163 Layer.provideMerge(ServerSettings.layerTest()),
161164 Layer.provideMerge(NodeServices.layer),
162165 );
163166
......@@ -509,6 +512,78 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () =>
509512 }).pipe(Effect.provide(layer));
510513});
511514
515it.effect("prefers credentials saved in settings over the environment, without a restart", () => {
516 const { execute, layer } = makeLayer({
517 response: () => Response.json({ username: "bitbucket-user" }),
518 });
519 const lastAuthorization = () => execute.mock.calls.at(-1)?.[0].headers.authorization;
520 const basic = (user: string, password: string) => `Basic ${btoa(`${user}:${password}`)}`;
521
522 return Effect.gen(function* () {
523 const bitbucket = yield* BitbucketApi.BitbucketApi;
524 const settings = yield* ServerSettings.ServerSettingsService;
525
526 yield* bitbucket.probeAuth;
527 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
528
529 yield* settings.updateSettings({
530 bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" },
531 });
532 yield* bitbucket.probeAuth;
533 assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token"));
534
535 yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } });
536 yield* bitbucket.probeAuth;
537 assert.strictEqual(lastAuthorization(), "Bearer saved-access-token");
538
539 yield* settings.updateSettings({ bitbucket: { accessToken: "", apiToken: "" } });
540 yield* bitbucket.probeAuth;
541 assert.strictEqual(lastAuthorization(), basic("user@example.com", "token"));
542 }).pipe(Effect.provide(layer));
543});
544
545it.effect("never puts a saved token that is unsafe for an HTTP header on the wire", () => {
546 const { execute, layer } = makeLayer({
547 response: () => Response.json({ username: "bitbucket-user" }),
548 });
549
550 return Effect.gen(function* () {
551 const bitbucket = yield* BitbucketApi.BitbucketApi;
552 const settings = yield* ServerSettings.ServerSettingsService;
553
554 // Fetch would reject this header with an error quoting the token, and that error reaches
555 // clients. The unusable token is ignored, so the environment credential is used instead.
556 yield* settings.updateSettings({ bitbucket: { accessToken: "saved\ntoken" } });
557 yield* bitbucket.probeAuth;
558 assert.strictEqual(
559 execute.mock.calls.at(-1)?.[0].headers.authorization,
560 `Basic ${btoa("user@example.com:token")}`,
561 );
562 }).pipe(Effect.provide(layer));
563});
564
565it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => {
566 const { layer } = makeLayer({
567 response: () => new Response(null, { status: 401 }),
568 env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" },
569 });
570
571 return Effect.gen(function* () {
572 const bitbucket = yield* BitbucketApi.BitbucketApi;
573 const settings = yield* ServerSettings.ServerSettingsService;
574
575 assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated");
576
577 yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } });
578 assert.deepStrictEqual(yield* bitbucket.probeAuth, {
579 status: "unknown",
580 account: Option.none(),
581 host: Option.some("bitbucket.org"),
582 detail: Option.some("An access token is configured."),
583 });
584 }).pipe(Effect.provide(layer));
585});
586
512587it.effect("preserves the HTTP client failure without deriving the domain message from it", () => {
513588 const transportCause = new Error("socket reset by peer");
514589 let requestFailure: HttpClientError.HttpClientError | undefined;
apps/server/src/sourceControl/BitbucketApi.ts+79-21
......@@ -7,8 +7,10 @@ import * as Layer from "effect/Layer";
77import * as Option from "effect/Option";
88import * as Schema from "effect/Schema";
99import {
10 DEFAULT_SERVER_SETTINGS,
1011 NonNegativeInt,
1112 TrimmedNonEmptyString,
13 type BitbucketSettings,
1214 type SourceControlProviderAuth,
1315 type SourceControlRepositoryCloneUrls,
1416 type SourceControlRepositoryVisibility,
......@@ -28,6 +30,7 @@ import {
2830} from "./bitbucketPullRequests.ts";
2931import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
3032import * as SourceControlProvider from "./SourceControlProvider.ts";
33import * as ServerSettings from "../serverSettings.ts";
3134import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
3235import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
3336import { retryAtFromHeader } from "./SourceControlRateLimit.ts";
......@@ -537,24 +540,64 @@ function repositoryOwnerName(repositoryName: string): string {
537540 return repositoryName.split("/")[0]?.trim() || "bitbucket";
538541}
539542
540function authFromConfig(
541 config: Config.Success<typeof BitbucketApiEnvConfig>,
542): SourceControlProviderAuth {
543 if (Option.isSome(config.accessToken)) {
543type BitbucketCredential =
544 | { readonly kind: "access-token"; readonly accessToken: string }
545 | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string };
546
547/**
548 * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole
549 * header, and that error travels to clients as a cause, so an unusable token is treated as unset.
550 */
551const HEADER_SAFE = /^[\x21-\x7e]+$/u;
552
553function credentialFrom(input: {
554 readonly accessToken: string;
555 readonly email: string;
556 readonly apiToken: string;
557}): BitbucketCredential | null {
558 if (HEADER_SAFE.test(input.accessToken)) {
559 return { kind: "access-token", accessToken: input.accessToken };
560 }
561 if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) {
562 return { kind: "api-token", email: input.email, apiToken: input.apiToken };
563 }
564 return null;
565}
566
567/**
568 * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which
569 * stay as a fallback. Within each source the access token wins.
570 */
571function resolveCredential(
572 settings: BitbucketSettings,
573 env: Config.Success<typeof BitbucketApiEnvConfig>,
574): BitbucketCredential | null {
575 return (
576 credentialFrom(settings) ??
577 credentialFrom({
578 accessToken: Option.getOrElse(env.accessToken, () => ""),
579 email: Option.getOrElse(env.email, () => ""),
580 apiToken: Option.getOrElse(env.apiToken, () => ""),
581 })
582 );
583}
584
585function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth {
586 if (credential?.kind === "access-token") {
544587 return {
545588 status: "unknown",
546589 account: Option.none(),
547590 host: Option.some("bitbucket.org"),
548 detail: Option.some("Bitbucket access token is configured."),
591 detail: Option.some("An access token is configured."),
549592 };
550593 }
551594
552 if (Option.isSome(config.email) && Option.isSome(config.apiToken)) {
595 if (credential?.kind === "api-token") {
553596 return {
554597 status: "unknown",
555 account: config.email,
598 account: Option.some(credential.email),
556599 host: Option.some("bitbucket.org"),
557 detail: Option.some("Bitbucket API token is configured."),
600 detail: Option.some("An API token is configured."),
558601 };
559602 }
560603
......@@ -563,7 +606,7 @@ function authFromConfig(
563606 account: Option.none(),
564607 host: Option.some("bitbucket.org"),
565608 detail: Option.some(
566 "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.",
609 "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.",
567610 ),
568611 };
569612}
......@@ -612,6 +655,7 @@ function responseError(
612655/** @public Service construction is part of the canonical Effect module API. */
613656export const make = Effect.gen(function* () {
614657 const config = yield* BitbucketApiEnvConfig;
658 const serverSettings = yield* ServerSettings.ServerSettingsService;
615659 const httpClient = yield* HttpClient.HttpClient;
616660 const fileSystem = yield* FileSystem.FileSystem;
617661 const git = yield* GitVcsDriver.GitVcsDriver;
......@@ -619,15 +663,27 @@ export const make = Effect.gen(function* () {
619663
620664 const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`;
621665
622 const withAuth = (request: HttpClientRequest.HttpClientRequest) => {
623 if (Option.isSome(config.accessToken)) {
624 return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value));
625 }
626 if (Option.isSome(config.email) && Option.isSome(config.apiToken)) {
627 return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value));
628 }
629 return request;
630 };
666 // Read on every request so credentials saved in settings apply without a restart.
667 const currentCredential = serverSettings.getSettings.pipe(
668 Effect.map((settings) => resolveCredential(settings.bitbucket, config)),
669 Effect.catch((error) =>
670 // No cause: a settings decode error can quote a hand-edited token.
671 Effect.logWarning("failed to read Bitbucket credentials from settings", {
672 operation: error.operation,
673 }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))),
674 ),
675 );
676
677 const withAuth = (request: HttpClientRequest.HttpClientRequest) =>
678 currentCredential.pipe(
679 Effect.map((credential) =>
680 credential === null
681 ? request
682 : credential.kind === "access-token"
683 ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken))
684 : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)),
685 ),
686 );
631687
632688 const decodeResponse = <S extends Schema.Top>(
633689 operation: BitbucketApiOperation,
......@@ -654,7 +710,8 @@ export const make = Effect.gen(function* () {
654710 request: HttpClientRequest.HttpClientRequest,
655711 schema: S,
656712 ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> =>
657 httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe(
713 withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe(
714 Effect.flatMap(httpClient.execute),
658715 Effect.mapError(
659716 (cause) =>
660717 new BitbucketRequestError({
......@@ -847,7 +904,8 @@ export const make = Effect.gen(function* () {
847904 input.body === undefined
848905 ? base
849906 : base.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
850 return httpClient.execute(withAuth(withBody)).pipe(
907 return withAuth(withBody).pipe(
908 Effect.flatMap(httpClient.execute),
851909 Effect.mapError(
852910 (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }),
853911 ),
......@@ -913,7 +971,7 @@ export const make = Effect.gen(function* () {
913971 host: Option.some("bitbucket.org"),
914972 detail: Option.none<string>(),
915973 })),
916 Effect.orElseSucceed(() => authFromConfig(config)),
974 Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))),
917975 ),
918976 listPullRequests: (input) =>
919977 resolveRepository(input).pipe(
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+1-2
......@@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () {
192192 type: "api",
193193 kind: "bitbucket",
194194 label: "Bitbucket",
195 installHint:
196 "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).",
195 installHint: "Add a Bitbucket token in Settings → Source Control.",
197196 probeAuth: bitbucket.probeAuth,
198197 } satisfies SourceControlApiDiscoverySpec;
199198});
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+1-1
......@@ -423,7 +423,7 @@ it.effect("reports implemented tools separately from locally available executabl
423423 account: Option.none(),
424424 host: Option.some("bitbucket.org"),
425425 detail: Option.some(
426 "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.",
426 "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.",
427427 ),
428428 }),
429429 },
apps/web/src/components/settings/BitbucketCredentialsSettings.tsx created+223
......@@ -0,0 +1,223 @@
1import type { BitbucketSettings, EnvironmentId } from "@t3tools/contracts";
2import { ExternalLinkIcon } from "lucide-react";
3import { useState } from "react";
4
5import { useEnvironmentSettings } from "../../hooks/useSettings";
6import { serverEnvironment } from "../../state/server";
7import { useAtomCommand } from "../../state/use-atom-command";
8import { Button, InlineButton } from "../ui/button";
9import { Input } from "../ui/input";
10import { Label } from "../ui/label";
11import { Toggle, ToggleGroup } from "../ui/toggle-group";
12
13type CredentialMethod = "access-token" | "api-token";
14
15const METHODS: Record<
16 CredentialMethod,
17 {
18 readonly label: string;
19 readonly description: string;
20 readonly link: string;
21 readonly linkLabel: string;
22 }
23> = {
24 "access-token": {
25 label: "Access token",
26 description:
27 "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.",
28 link: "https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/",
29 linkLabel: "Learn more",
30 },
31 "api-token": {
32 label: "API token",
33 description:
34 "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.",
35 link: "https://id.atlassian.com/manage-profile/security/api-tokens",
36 linkLabel: "Create an API token",
37 },
38};
39
40function savedMethod(saved: BitbucketSettings): CredentialMethod | null {
41 if (saved.accessToken.length > 0) return "access-token";
42 if (saved.email.length > 0 && saved.apiToken.length > 0) return "api-token";
43 return null;
44}
45
46/** A write-only token field. It never shows the saved token; typing a new one replaces it. */
47function TokenInput({
48 id,
49 isSaved,
50 draft,
51 onDraftChange,
52}: {
53 readonly id: string;
54 readonly isSaved: boolean;
55 readonly draft: string;
56 readonly onDraftChange: (draft: string) => void;
57}) {
58 return (
59 <Input
60 id={id}
61 type="password"
62 autoComplete="off"
63 size="sm"
64 placeholder={isSaved ? "Stored secret, enter a new value to replace" : "Not set"}
65 value={draft}
66 onChange={(event) => onDraftChange(event.target.value)}
67 />
68 );
69}
70
71/**
72 * Bitbucket credentials for one environment: an access token or an Atlassian
73 * account email + API token, never both. Tokens are write-only: the server
74 * keeps them in its secret store and only reports whether each one is set.
75 */
76export function BitbucketCredentialsSettings({
77 environmentId,
78 onSaved,
79}: {
80 readonly environmentId: EnvironmentId;
81 readonly onSaved: () => void;
82}) {
83 const saved = useEnvironmentSettings(environmentId, (settings) => settings.bitbucket);
84 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
85 label: "save Bitbucket credentials",
86 });
87 const [methodChoice, setMethodChoice] = useState<CredentialMethod | null>(null);
88 const [accessToken, setAccessToken] = useState("");
89 const [emailDraft, setEmailDraft] = useState<string | null>(null);
90 const [apiToken, setApiToken] = useState("");
91 const [saving, setSaving] = useState(false);
92 const current = savedMethod(saved);
93 const method = methodChoice ?? current ?? "access-token";
94 const methodIsSaved = current === method;
95 const email = (emailDraft ?? saved.email).trim();
96 const newAccessToken = accessToken.trim();
97 const newApiToken = apiToken.trim();
98 const info = METHODS[method];
99
100 // Saving one method clears the other, so a hidden credential never wins over the visible one.
101 const patch: BitbucketSettings | null =
102 method === "access-token"
103 ? newAccessToken
104 ? { accessToken: newAccessToken, email: "", apiToken: "" }
105 : null
106 : email && (newApiToken || saved.apiToken)
107 ? // Resending the saved token's redacted value keeps it.
108 { accessToken: "", email, apiToken: newApiToken || saved.apiToken }
109 : null;
110 const canSave =
111 patch !== null &&
112 (method === "access-token" || !methodIsSaved || newApiToken !== "" || email !== saved.email);
113
114 const save = async (next: BitbucketSettings) => {
115 setSaving(true);
116 try {
117 const result = await updateSettings({
118 environmentId,
119 input: { patch: { bitbucket: next } },
120 });
121 if (result._tag === "Success") {
122 setAccessToken("");
123 setApiToken("");
124 setEmailDraft(null);
125 onSaved();
126 }
127 } finally {
128 setSaving(false);
129 }
130 };
131
132 return (
133 <form
134 className="grid gap-4"
135 onSubmit={(event) => {
136 event.preventDefault();
137 if (canSave && patch) void save(patch);
138 }}
139 >
140 {/* Locked while saving: a successful save clears the drafts, which would drop edits made mid-request. */}
141 <fieldset disabled={saving} className="contents">
142 <ToggleGroup
143 aria-label="Bitbucket sign-in method"
144 variant="segmented"
145 value={[method]}
146 onValueChange={(next) => {
147 const value = next[0];
148 if (value === "access-token" || value === "api-token") setMethodChoice(value);
149 }}
150 >
151 <Toggle value="access-token">{METHODS["access-token"].label}</Toggle>
152 <Toggle value="api-token">{METHODS["api-token"].label}</Toggle>
153 </ToggleGroup>
154 <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
155 {info.description}{" "}
156 <InlineButton render={<a href={info.link} target="_blank" rel="noreferrer noopener" />}>
157 {info.linkLabel}
158 <ExternalLinkIcon aria-hidden className="size-3" />
159 </InlineButton>
160 </p>
161 {method === "access-token" ? (
162 <div className="grid gap-1.5">
163 <Label htmlFor={`bitbucket-access-token-${environmentId}`}>Access token</Label>
164 <TokenInput
165 id={`bitbucket-access-token-${environmentId}`}
166 isSaved={methodIsSaved}
167 draft={accessToken}
168 onDraftChange={setAccessToken}
169 />
170 </div>
171 ) : (
172 <>
173 <div className="grid gap-1.5">
174 <Label htmlFor={`bitbucket-email-${environmentId}`}>Atlassian account email</Label>
175 <Input
176 id={`bitbucket-email-${environmentId}`}
177 type="email"
178 autoComplete="off"
179 size="sm"
180 placeholder="you@example.com"
181 value={emailDraft ?? saved.email}
182 onChange={(event) => setEmailDraft(event.target.value)}
183 />
184 </div>
185 <div className="grid gap-1.5">
186 <Label htmlFor={`bitbucket-api-token-${environmentId}`}>API token</Label>
187 <TokenInput
188 id={`bitbucket-api-token-${environmentId}`}
189 isSaved={methodIsSaved}
190 draft={apiToken}
191 onDraftChange={setApiToken}
192 />
193 </div>
194 </>
195 )}
196 <div className="flex items-center justify-between gap-3">
197 <p className="text-xs text-muted-foreground">
198 {current === null
199 ? "Without a saved token, the server falls back to its T3CODE_BITBUCKET_* environment variables."
200 : methodIsSaved
201 ? null
202 : `Saving replaces your ${METHODS[current].label.toLowerCase()}.`}
203 </p>
204 <div className="flex shrink-0 gap-2">
205 {current !== null ? (
206 <Button
207 size="xs"
208 variant="outline"
209 disabled={saving}
210 onClick={() => void save({ accessToken: "", email: "", apiToken: "" })}
211 >
212 Remove
213 </Button>
214 ) : null}
215 <Button type="submit" size="xs" disabled={!canSave || saving}>
216 Save
217 </Button>
218 </div>
219 </div>
220 </fieldset>
221 </form>
222 );
223}
apps/web/src/components/settings/SourceControlSettings.tsx+21-3
......@@ -56,6 +56,7 @@ import {
5656 JujutsuIcon,
5757 type Icon,
5858} from "../Icons";
59import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings";
5960import { RedactedSensitiveText } from "./RedactedSensitiveText";
6061import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
6162import {
......@@ -234,7 +235,9 @@ function itemSummary({
234235 );
235236 }
236237
237 if (!item.executable) {
238 // API integrations have no CLI to sign in with; an unverified saved credential falls
239 // through to the "could not verify" detail instead of repeating the setup hint.
240 if (!item.executable && auth.status === "unauthenticated") {
238241 return <span>Available. {item.installHint}</span>;
239242 }
240243
......@@ -277,7 +280,11 @@ function DiscoveryItemRow({
277280 const searchTargetId = useSettingsSearchTargetId();
278281
279282 useEffect(() => {
280 if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) {
283 if (
284 (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) ||
285 (item.kind === "bitbucket" &&
286 searchTargetId === searchableSetting("bitbucket-credentials").id)
287 ) {
281288 setIsExpanded(true);
282289 }
283290 }, [item.kind, searchTargetId]);
......@@ -586,7 +593,18 @@ export function SourceControlSettingsPanel() {
586593 headerAction={hasVersionControlSystems ? null : scanButton}
587594 >
588595 {result.sourceControlProviders.map((item) => (
589 <DiscoveryItemRow key={`provider:${item.kind}`} item={item} />
596 <DiscoveryItemRow key={`provider:${item.kind}`} item={item}>
597 {item.kind === "bitbucket" ? (
598 <SettingsSearchTarget id={searchableSetting("bitbucket-credentials").id}>
599 <BitbucketCredentialsSettings
600 // Drafts belong to one environment; switching must not carry them over.
601 key={environmentId}
602 environmentId={environmentId}
603 onSaved={handleScan}
604 />
605 </SettingsSearchTarget>
606 ) : undefined}
607 </DiscoveryItemRow>
590608 ))}
591609 </SettingsSection>
592610 ) : null}
apps/web/src/components/settings/settingsSearch.ts+8
......@@ -695,6 +695,14 @@ export const SETTINGS_SEARCH_ITEMS = [
695695 environmentOnly: true,
696696 scope: "environment-defaults",
697697 },
698 {
699 id: "bitbucket-credentials",
700 title: "Bitbucket credentials",
701 to: "/settings/source-control",
702 searchTerms: ["bitbucket atlassian access token api token email credentials sign in"],
703 environmentOnly: true,
704 scope: "environment-defaults",
705 },
698706 {
699707 id: "source-control-writing-style",
700708 title: "Source control writing style",
docs/user/source-control.md+17-10
......@@ -47,23 +47,29 @@ glab auth login
4747
4848### Bitbucket
4949
50Set an access token in the server's environment:
50Open **Settings → Source Control**, expand **Bitbucket**, and choose how to sign in:
5151
52```bash
53export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token"
54```
52- **Access token**: a token created for one repository, project, or workspace. It can only reach
53 what it was created for.
54- **API token**: an Atlassian API token for your account, used with your account email. It can
55 reach every repository you can. Give it read/write access to repositories and pull requests, plus
56 user read access (`read:user:bitbucket`).
5557
56Or use an Atlassian account email and API token with read/write access to repositories and pull
57requests, plus user read access (`read:user:bitbucket`):
58Choose **Save**; the change applies right away, and replaces any credential saved with the other
59method. Credentials are saved on the environment's server, so select a remote environment to
60configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose
61**Remove**.
62
63If no credentials are saved, T3 Code falls back to these variables in the server's environment.
64Restart the server after changing them:
5865
5966```bash
67export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token"
68# or
6069export T3CODE_BITBUCKET_EMAIL="you@example.com"
6170export T3CODE_BITBUCKET_API_TOKEN="your-token"
6271```
6372
64The access token takes precedence if both are configured. Restart the server after changing these
65variables.
66
6773### Azure DevOps
6874
6975Install [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.
138144## Troubleshooting
139145
140146- **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket,
141 confirm the running server received the environment variables.
147 check the credentials saved in Settings → Source Control, or confirm the running server received
148 the environment variables.
142149- **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0.
143150- **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS
144151 remotes can require separate setup from the hosting provider's API access.
packages/contracts/src/pullRequest.ts+2-2
......@@ -1277,9 +1277,9 @@ const PROVIDER_REQUIREMENT: Partial<
12771277 },
12781278 bitbucket: {
12791279 missing:
1280 "Bitbucket needs API credentials on the server. Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.",
1280 "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.",
12811281 unauthenticated:
1282 "Bitbucket rejected the configured credentials. Check T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.",
1282 "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.",
12831283 },
12841284};
12851285
packages/contracts/src/settings.ts+22
......@@ -901,6 +901,19 @@ export const UsageLimitSourceConfig = Schema.Struct({
901901});
902902export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;
903903
904/**
905 * Bitbucket API credentials for this environment, used before the
906 * `T3CODE_BITBUCKET_*` environment variables. The tokens live in the server's
907 * secret store; settings and clients only see a redaction marker when one is
908 * set. The access token wins when both kinds are configured.
909 */
910export const BitbucketSettings = Schema.Struct({
911 email: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
912 accessToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
913 apiToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
914});
915export type BitbucketSettings = typeof BitbucketSettings.Type;
916
904917export const ObservabilitySettings = Schema.Struct({
905918 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
906919 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
......@@ -1277,6 +1290,7 @@ export const ServerSettings = Schema.Struct({
12771290 Schema.withDecodingDefault(Effect.succeed({})),
12781291 ),
12791292 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1293 bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
12801294 // Keyed by a user-chosen id so a source keeps its rows across edits. Entries
12811295 // this build cannot decode round-trip untouched, as provider instances do.
12821296 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
......@@ -1538,6 +1552,14 @@ export const ServerSettingsPatch = Schema.Struct({
15381552 otlpLogsUrl: Schema.optionalKey(TrimmedString),
15391553 }),
15401554 ),
1555 /** An empty token clears it; an omitted one keeps what the server has. */
1556 bitbucket: Schema.optionalKey(
1557 Schema.Struct({
1558 email: Schema.optionalKey(TrimmedString),
1559 accessToken: Schema.optionalKey(TrimmedString),
1560 apiToken: Schema.optionalKey(TrimmedString),
1561 }),
1562 ),
15411563 providers: Schema.optionalKey(
15421564 Schema.Struct({
15431565 codex: Schema.optionalKey(CodexSettingsPatch),