| author | |
| committer | |
| log | 5da55957d48dbd79c1a05b72bdf2479ae0956b1c |
| tree | d553050ad91d20574eaa54406e01e53ca06e5c9a |
| parent | e518866d28603da273a9357516dd1aef919f1353 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 28 | 28 | import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; |
| 29 | 29 | import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; |
| 30 | 30 | import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; |
| 31 | import * as ServerSettings from "../src/serverSettings.ts"; | |
| 31 | 32 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| 32 | 33 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; |
| 33 | 34 | import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; |
| ... | ... | @@ -155,7 +156,8 @@ await Effect.runPromise( |
| 155 | 156 | GitLabCli.layer, |
| 156 | 157 | ForgejoCli.layer, |
| 157 | 158 | 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())), | |
| 159 | 161 | ), |
| 160 | 162 | ), |
| 161 | 163 | Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), |
apps/server/src/serverSettings.test.ts+136| ... | ... | @@ -44,6 +44,20 @@ const makeServerSettingsLayer = () => |
| 44 | 44 | ), |
| 45 | 45 | ); |
| 46 | 46 | |
| 47 | /** Like `makeServerSettingsLayer`, but also exposes the secret store for assertions. */ | |
| 48 | const 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 | ||
| 47 | 61 | const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => |
| 48 | 62 | Layer.succeed( |
| 49 | 63 | ServerSecretStore.ServerSecretStore, |
| ... | ... | @@ -1278,6 +1292,128 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1278 | 1292 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 1279 | 1293 | ); |
| 1280 | 1294 | |
| 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 | ||
| 1281 | 1417 | it.effect("materializes provider secrets for terminal environment resolution", () => |
| 1282 | 1418 | Effect.gen(function* () { |
| 1283 | 1419 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
apps/server/src/serverSettings.ts+89-12| ... | ... | @@ -140,16 +140,24 @@ function providerEnvironmentSecretName(input: { |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | 142 | /** |
| 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". | |
| 146 | 146 | */ |
| 147 | const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; | |
| 147 | const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; | |
| 148 | 148 | |
| 149 | 149 | function usageLimitSourceSecretName(sourceId: string): string { |
| 150 | 150 | return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; |
| 151 | 151 | } |
| 152 | 152 | |
| 153 | const BITBUCKET_SECRET_NAMES = { | |
| 154 | accessToken: "bitbucket-access-token", | |
| 155 | apiToken: "bitbucket-api-token", | |
| 156 | } as const; | |
| 157 | const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const; | |
| 158 | ||
| 159 | const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); | |
| 160 | ||
| 153 | 161 | function redactProviderEnvironmentVariable( |
| 154 | 162 | variable: ProviderInstanceEnvironmentVariable, |
| 155 | 163 | ): ProviderInstanceEnvironmentVariable { |
| ... | ... | @@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS |
| 182 | 190 | id, |
| 183 | 191 | { |
| 184 | 192 | ...source, |
| 185 | managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "", | |
| 193 | managementKey: redactSecret(source.managementKey), | |
| 186 | 194 | }, |
| 187 | 195 | ]), |
| 188 | 196 | ); |
| 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 }; | |
| 190 | 203 | } |
| 191 | 204 | |
| 192 | 205 | export class ServerSettingsService extends Context.Service< |
| ... | ... | @@ -555,6 +568,35 @@ const make = Effect.gen(function* () { |
| 555 | 568 | ), |
| 556 | 569 | ); |
| 557 | 570 | |
| 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 | ||
| 558 | 600 | const loadSettingsFromDisk = Effect.gen(function* () { |
| 559 | 601 | let settings = DEFAULT_SERVER_SETTINGS; |
| 560 | 602 | let persisted: typeof PersistedOptionalProviderSettings.Type = {}; |
| ... | ... | @@ -639,10 +681,12 @@ const make = Effect.gen(function* () { |
| 639 | 681 | const folded = settingsFileTrusted |
| 640 | 682 | ? foldLegacyProjectSettings(loaded, legacyProjectRows) |
| 641 | 683 | : 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); | |
| 644 | 688 | } |
| 645 | return folded; | |
| 689 | return migrated; | |
| 646 | 690 | }); |
| 647 | 691 | |
| 648 | 692 | const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({ |
| ... | ... | @@ -693,7 +737,7 @@ const make = Effect.gen(function* () { |
| 693 | 737 | } |
| 694 | 738 | const usageLimitSources: Record<string, UsageLimitSourceConfig> = {}; |
| 695 | 739 | for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { |
| 696 | if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) { | |
| 740 | if (source.managementKey !== SECRET_REDACTED) { | |
| 697 | 741 | usageLimitSources[sourceId] = source; |
| 698 | 742 | continue; |
| 699 | 743 | } |
| ... | ... | @@ -709,10 +753,23 @@ const make = Effect.gen(function* () { |
| 709 | 753 | managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", |
| 710 | 754 | }; |
| 711 | 755 | } |
| 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 | } | |
| 712 | 768 | return { |
| 713 | 769 | ...settings, |
| 714 | 770 | providerInstances: providerInstances as ServerSettings["providerInstances"], |
| 715 | 771 | usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], |
| 772 | bitbucket, | |
| 716 | 773 | }; |
| 717 | 774 | }); |
| 718 | 775 | |
| ... | ... | @@ -829,7 +886,7 @@ const make = Effect.gen(function* () { |
| 829 | 886 | const usageLimitSources: Record<string, UsageLimitSourceConfig> = {}; |
| 830 | 887 | for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { |
| 831 | 888 | const secretName = usageLimitSourceSecretName(sourceId); |
| 832 | if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) { | |
| 889 | if (source.managementKey === SECRET_REDACTED) { | |
| 833 | 890 | usageLimitSources[sourceId] = source; |
| 834 | 891 | continue; |
| 835 | 892 | } |
| ... | ... | @@ -843,7 +900,7 @@ const make = Effect.gen(function* () { |
| 843 | 900 | secretName, |
| 844 | 901 | value: textEncoder.encode(source.managementKey), |
| 845 | 902 | }); |
| 846 | usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED }; | |
| 903 | usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED }; | |
| 847 | 904 | } |
| 848 | 905 | for (const sourceId of Object.keys(current.usageLimitSources)) { |
| 849 | 906 | if (sourceId in next.usageLimitSources) continue; |
| ... | ... | @@ -854,11 +911,31 @@ const make = Effect.gen(function* () { |
| 854 | 911 | }); |
| 855 | 912 | } |
| 856 | 913 | |
| 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 | ||
| 857 | 933 | return { |
| 858 | 934 | settings: { |
| 859 | 935 | ...next, |
| 860 | 936 | providerInstances: providerInstances as ServerSettings["providerInstances"], |
| 861 | 937 | usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], |
| 938 | bitbucket, | |
| 862 | 939 | }, |
| 863 | 940 | changes, |
| 864 | 941 | }; |
apps/server/src/sourceControl/BitbucketApi.test.ts+76-1| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | |
| 17 | 17 | import { GitCommandError } from "@t3tools/contracts"; |
| 18 | 18 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 19 | import * as ServerSettings from "../serverSettings.ts"; | |
| 19 | 20 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 20 | 21 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 21 | 22 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; |
| ... | ... | @@ -64,6 +65,7 @@ function makeLayer(input: { |
| 64 | 65 | request: HttpClientRequest.HttpClientRequest, |
| 65 | 66 | ) => HttpClientError.HttpClientError; |
| 66 | 67 | readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>; |
| 68 | readonly env?: Record<string, string>; | |
| 67 | 69 | }) { |
| 68 | 70 | const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => |
| 69 | 71 | input.requestFailure |
| ... | ... | @@ -150,7 +152,7 @@ function makeLayer(input: { |
| 150 | 152 | Layer.provide( |
| 151 | 153 | ConfigProvider.layer( |
| 152 | 154 | ConfigProvider.fromEnv({ |
| 153 | env: { | |
| 155 | env: input.env ?? { | |
| 154 | 156 | T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", |
| 155 | 157 | T3CODE_BITBUCKET_EMAIL: "user@example.com", |
| 156 | 158 | T3CODE_BITBUCKET_API_TOKEN: "token", |
| ... | ... | @@ -158,6 +160,7 @@ function makeLayer(input: { |
| 158 | 160 | }), |
| 159 | 161 | ), |
| 160 | 162 | ), |
| 163 | Layer.provideMerge(ServerSettings.layerTest()), | |
| 161 | 164 | Layer.provideMerge(NodeServices.layer), |
| 162 | 165 | ); |
| 163 | 166 | |
| ... | ... | @@ -509,6 +512,78 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => |
| 509 | 512 | }).pipe(Effect.provide(layer)); |
| 510 | 513 | }); |
| 511 | 514 | |
| 515 | it.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 | ||
| 545 | it.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 | ||
| 565 | it.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 | ||
| 512 | 587 | it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { |
| 513 | 588 | const transportCause = new Error("socket reset by peer"); |
| 514 | 589 | let requestFailure: HttpClientError.HttpClientError | undefined; |
apps/server/src/sourceControl/BitbucketApi.ts+79-21| ... | ... | @@ -7,8 +7,10 @@ import * as Layer from "effect/Layer"; |
| 7 | 7 | import * as Option from "effect/Option"; |
| 8 | 8 | import * as Schema from "effect/Schema"; |
| 9 | 9 | import { |
| 10 | DEFAULT_SERVER_SETTINGS, | |
| 10 | 11 | NonNegativeInt, |
| 11 | 12 | TrimmedNonEmptyString, |
| 13 | type BitbucketSettings, | |
| 12 | 14 | type SourceControlProviderAuth, |
| 13 | 15 | type SourceControlRepositoryCloneUrls, |
| 14 | 16 | type SourceControlRepositoryVisibility, |
| ... | ... | @@ -28,6 +30,7 @@ import { |
| 28 | 30 | } from "./bitbucketPullRequests.ts"; |
| 29 | 31 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; |
| 30 | 32 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 33 | import * as ServerSettings from "../serverSettings.ts"; | |
| 31 | 34 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 32 | 35 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 33 | 36 | import { retryAtFromHeader } from "./SourceControlRateLimit.ts"; |
| ... | ... | @@ -537,24 +540,64 @@ function repositoryOwnerName(repositoryName: string): string { |
| 537 | 540 | return repositoryName.split("/")[0]?.trim() || "bitbucket"; |
| 538 | 541 | } |
| 539 | 542 | |
| 540 | function authFromConfig( | |
| 541 | config: Config.Success<typeof BitbucketApiEnvConfig>, | |
| 542 | ): SourceControlProviderAuth { | |
| 543 | if (Option.isSome(config.accessToken)) { | |
| 543 | type 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 | */ | |
| 551 | const HEADER_SAFE = /^[\x21-\x7e]+$/u; | |
| 552 | ||
| 553 | function 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 | */ | |
| 571 | function 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 | ||
| 585 | function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth { | |
| 586 | if (credential?.kind === "access-token") { | |
| 544 | 587 | return { |
| 545 | 588 | status: "unknown", |
| 546 | 589 | account: Option.none(), |
| 547 | 590 | host: Option.some("bitbucket.org"), |
| 548 | detail: Option.some("Bitbucket access token is configured."), | |
| 591 | detail: Option.some("An access token is configured."), | |
| 549 | 592 | }; |
| 550 | 593 | } |
| 551 | 594 | |
| 552 | if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { | |
| 595 | if (credential?.kind === "api-token") { | |
| 553 | 596 | return { |
| 554 | 597 | status: "unknown", |
| 555 | account: config.email, | |
| 598 | account: Option.some(credential.email), | |
| 556 | 599 | host: Option.some("bitbucket.org"), |
| 557 | detail: Option.some("Bitbucket API token is configured."), | |
| 600 | detail: Option.some("An API token is configured."), | |
| 558 | 601 | }; |
| 559 | 602 | } |
| 560 | 603 | |
| ... | ... | @@ -563,7 +606,7 @@ function authFromConfig( |
| 563 | 606 | account: Option.none(), |
| 564 | 607 | host: Option.some("bitbucket.org"), |
| 565 | 608 | 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.", | |
| 567 | 610 | ), |
| 568 | 611 | }; |
| 569 | 612 | } |
| ... | ... | @@ -612,6 +655,7 @@ function responseError( |
| 612 | 655 | /** @public Service construction is part of the canonical Effect module API. */ |
| 613 | 656 | export const make = Effect.gen(function* () { |
| 614 | 657 | const config = yield* BitbucketApiEnvConfig; |
| 658 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 615 | 659 | const httpClient = yield* HttpClient.HttpClient; |
| 616 | 660 | const fileSystem = yield* FileSystem.FileSystem; |
| 617 | 661 | const git = yield* GitVcsDriver.GitVcsDriver; |
| ... | ... | @@ -619,15 +663,27 @@ export const make = Effect.gen(function* () { |
| 619 | 663 | |
| 620 | 664 | const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; |
| 621 | 665 | |
| 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 | ); | |
| 631 | 687 | |
| 632 | 688 | const decodeResponse = <S extends Schema.Top>( |
| 633 | 689 | operation: BitbucketApiOperation, |
| ... | ... | @@ -654,7 +710,8 @@ export const make = Effect.gen(function* () { |
| 654 | 710 | request: HttpClientRequest.HttpClientRequest, |
| 655 | 711 | schema: S, |
| 656 | 712 | ): 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), | |
| 658 | 715 | Effect.mapError( |
| 659 | 716 | (cause) => |
| 660 | 717 | new BitbucketRequestError({ |
| ... | ... | @@ -847,7 +904,8 @@ export const make = Effect.gen(function* () { |
| 847 | 904 | input.body === undefined |
| 848 | 905 | ? base |
| 849 | 906 | : 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), | |
| 851 | 909 | Effect.mapError( |
| 852 | 910 | (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), |
| 853 | 911 | ), |
| ... | ... | @@ -913,7 +971,7 @@ export const make = Effect.gen(function* () { |
| 913 | 971 | host: Option.some("bitbucket.org"), |
| 914 | 972 | detail: Option.none<string>(), |
| 915 | 973 | })), |
| 916 | Effect.orElseSucceed(() => authFromConfig(config)), | |
| 974 | Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), | |
| 917 | 975 | ), |
| 918 | 976 | listPullRequests: (input) => |
| 919 | 977 | resolveRepository(input).pipe( |
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+1-2| ... | ... | @@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () { |
| 192 | 192 | type: "api", |
| 193 | 193 | kind: "bitbucket", |
| 194 | 194 | 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.", | |
| 197 | 196 | probeAuth: bitbucket.probeAuth, |
| 198 | 197 | } satisfies SourceControlApiDiscoverySpec; |
| 199 | 198 | }); |
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+1-1| ... | ... | @@ -423,7 +423,7 @@ it.effect("reports implemented tools separately from locally available executabl |
| 423 | 423 | account: Option.none(), |
| 424 | 424 | host: Option.some("bitbucket.org"), |
| 425 | 425 | 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.", | |
| 427 | 427 | ), |
| 428 | 428 | }), |
| 429 | 429 | }, |
apps/web/src/components/settings/BitbucketCredentialsSettings.tsx created+223| ... | ... | @@ -0,0 +1,223 @@ |
| 1 | import type { BitbucketSettings, EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { ExternalLinkIcon } from "lucide-react"; | |
| 3 | import { useState } from "react"; | |
| 4 | ||
| 5 | import { useEnvironmentSettings } from "../../hooks/useSettings"; | |
| 6 | import { serverEnvironment } from "../../state/server"; | |
| 7 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 8 | import { Button, InlineButton } from "../ui/button"; | |
| 9 | import { Input } from "../ui/input"; | |
| 10 | import { Label } from "../ui/label"; | |
| 11 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; | |
| 12 | ||
| 13 | type CredentialMethod = "access-token" | "api-token"; | |
| 14 | ||
| 15 | const 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 | ||
| 40 | function 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. */ | |
| 47 | function 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 | */ | |
| 76 | export 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 { |
| 56 | 56 | JujutsuIcon, |
| 57 | 57 | type Icon, |
| 58 | 58 | } from "../Icons"; |
| 59 | import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; | |
| 59 | 60 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 60 | 61 | import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; |
| 61 | 62 | import { |
| ... | ... | @@ -234,7 +235,9 @@ function itemSummary({ |
| 234 | 235 | ); |
| 235 | 236 | } |
| 236 | 237 | |
| 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") { | |
| 238 | 241 | return <span>Available. {item.installHint}</span>; |
| 239 | 242 | } |
| 240 | 243 | |
| ... | ... | @@ -277,7 +280,11 @@ function DiscoveryItemRow({ |
| 277 | 280 | const searchTargetId = useSettingsSearchTargetId(); |
| 278 | 281 | |
| 279 | 282 | 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 | ) { | |
| 281 | 288 | setIsExpanded(true); |
| 282 | 289 | } |
| 283 | 290 | }, [item.kind, searchTargetId]); |
| ... | ... | @@ -586,7 +593,18 @@ export function SourceControlSettingsPanel() { |
| 586 | 593 | headerAction={hasVersionControlSystems ? null : scanButton} |
| 587 | 594 | > |
| 588 | 595 | {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> | |
| 590 | 608 | ))} |
| 591 | 609 | </SettingsSection> |
| 592 | 610 | ) : null} |
apps/web/src/components/settings/settingsSearch.ts+8| ... | ... | @@ -695,6 +695,14 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 695 | 695 | environmentOnly: true, |
| 696 | 696 | scope: "environment-defaults", |
| 697 | 697 | }, |
| 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 | }, | |
| 698 | 706 | { |
| 699 | 707 | id: "source-control-writing-style", |
| 700 | 708 | title: "Source control writing style", |
docs/user/source-control.md+17-10| ... | ... | @@ -47,23 +47,29 @@ glab auth login |
| 47 | 47 | |
| 48 | 48 | ### Bitbucket |
| 49 | 49 | |
| 50 | Set an access token in the server's environment: | |
| 50 | Open **Settings → Source Control**, expand **Bitbucket**, and choose how to sign in: | |
| 51 | 51 | |
| 52 | ```bash | |
| 53 | export 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`). | |
| 55 | 57 | |
| 56 | Or use an Atlassian account email and API token with read/write access to repositories and pull | |
| 57 | requests, plus user read access (`read:user:bitbucket`): | |
| 58 | Choose **Save**; the change applies right away, and replaces any credential saved with the other | |
| 59 | method. Credentials are saved on the environment's server, so select a remote environment to | |
| 60 | configure it. Saved tokens can't be viewed again; enter a new one to replace it, or choose | |
| 61 | **Remove**. | |
| 62 | ||
| 63 | If no credentials are saved, T3 Code falls back to these variables in the server's environment. | |
| 64 | Restart the server after changing them: | |
| 58 | 65 | |
| 59 | 66 | ```bash |
| 67 | export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" | |
| 68 | # or | |
| 60 | 69 | export T3CODE_BITBUCKET_EMAIL="you@example.com" |
| 61 | 70 | export T3CODE_BITBUCKET_API_TOKEN="your-token" |
| 62 | 71 | ``` |
| 63 | 72 | |
| 64 | The access token takes precedence if both are configured. Restart the server after changing these | |
| 65 | variables. | |
| 66 | ||
| 67 | 73 | ### Azure DevOps |
| 68 | 74 | |
| 69 | 75 | 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. |
| 138 | 144 | ## Troubleshooting |
| 139 | 145 | |
| 140 | 146 | - **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. | |
| 142 | 149 | - **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0. |
| 143 | 150 | - **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS |
| 144 | 151 | 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< |
| 1277 | 1277 | }, |
| 1278 | 1278 | bitbucket: { |
| 1279 | 1279 | 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.", | |
| 1281 | 1281 | 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.", | |
| 1283 | 1283 | }, |
| 1284 | 1284 | }; |
| 1285 | 1285 |
packages/contracts/src/settings.ts+22| ... | ... | @@ -901,6 +901,19 @@ export const UsageLimitSourceConfig = Schema.Struct({ |
| 901 | 901 | }); |
| 902 | 902 | export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; |
| 903 | 903 | |
| 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 | */ | |
| 910 | export 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 | }); | |
| 915 | export type BitbucketSettings = typeof BitbucketSettings.Type; | |
| 916 | ||
| 904 | 917 | export const ObservabilitySettings = Schema.Struct({ |
| 905 | 918 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 906 | 919 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| ... | ... | @@ -1277,6 +1290,7 @@ export const ServerSettings = Schema.Struct({ |
| 1277 | 1290 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1278 | 1291 | ), |
| 1279 | 1292 | observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 1293 | bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 1280 | 1294 | // Keyed by a user-chosen id so a source keeps its rows across edits. Entries |
| 1281 | 1295 | // this build cannot decode round-trip untouched, as provider instances do. |
| 1282 | 1296 | usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( |
| ... | ... | @@ -1538,6 +1552,14 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1538 | 1552 | otlpLogsUrl: Schema.optionalKey(TrimmedString), |
| 1539 | 1553 | }), |
| 1540 | 1554 | ), |
| 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 | ), | |
| 1541 | 1563 | providers: Schema.optionalKey( |
| 1542 | 1564 | Schema.Struct({ |
| 1543 | 1565 | codex: Schema.optionalKey(CodexSettingsPatch), |