| 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,6 +28,7 @@ import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; |
| 28 | import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; | 28 | import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; |
| 29 | import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; | 29 | import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; |
| 30 | import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; | 30 | import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; |
| 31 | import * as ServerSettings from "../src/serverSettings.ts"; | ||
| 31 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; | 32 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| 32 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; | 33 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; |
| 33 | import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; | 34 | import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; |
| ... | @@ -155,7 +156,8 @@ await Effect.runPromise( | ... | @@ -155,7 +156,8 @@ await Effect.runPromise( |
| 155 | GitLabCli.layer, | 156 | GitLabCli.layer, |
| 156 | ForgejoCli.layer, | 157 | ForgejoCli.layer, |
| 157 | AzureDevOpsCli.layer, | 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 | Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), | 163 | Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), |
apps/server/src/serverSettings.test.ts+136| ... | @@ -44,6 +44,20 @@ const makeServerSettingsLayer = () => | ... | @@ -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 | const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => | 61 | const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => |
| 48 | Layer.succeed( | 62 | Layer.succeed( |
| 49 | ServerSecretStore.ServerSecretStore, | 63 | ServerSecretStore.ServerSecretStore, |
| ... | @@ -1278,6 +1292,128 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -1278,6 +1292,128 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1278 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 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 | it.effect("materializes provider secrets for terminal environment resolution", () => | 1417 | it.effect("materializes provider secrets for terminal environment resolution", () => |
| 1282 | Effect.gen(function* () { | 1418 | Effect.gen(function* () { |
| 1283 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 1419 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
apps/server/src/serverSettings.ts+89-12| ... | @@ -140,16 +140,24 @@ function providerEnvironmentSecretName(input: { | ... | @@ -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 | 143 | * On disk a hub key or Bitbucket token is replaced by this marker and the |
| 144 | * the secret store, mirroring provider environment secrets. A client that | 144 | * real value lives in the secret store, mirroring provider environment |
| 145 | * sends the marker back means "keep what you have". | 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 | function usageLimitSourceSecretName(sourceId: string): string { | 149 | function usageLimitSourceSecretName(sourceId: string): string { |
| 150 | return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; | 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 | function redactProviderEnvironmentVariable( | 161 | function redactProviderEnvironmentVariable( |
| 154 | variable: ProviderInstanceEnvironmentVariable, | 162 | variable: ProviderInstanceEnvironmentVariable, |
| 155 | ): ProviderInstanceEnvironmentVariable { | 163 | ): ProviderInstanceEnvironmentVariable { |
| ... | @@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS | ... | @@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS |
| 182 | id, | 190 | id, |
| 183 | { | 191 | { |
| 184 | ...source, | 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 | export class ServerSettingsService extends Context.Service< | 205 | export class ServerSettingsService extends Context.Service< |
| ... | @@ -555,6 +568,35 @@ const make = Effect.gen(function* () { | ... | @@ -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 | const loadSettingsFromDisk = Effect.gen(function* () { | 600 | const loadSettingsFromDisk = Effect.gen(function* () { |
| 559 | let settings = DEFAULT_SERVER_SETTINGS; | 601 | let settings = DEFAULT_SERVER_SETTINGS; |
| 560 | let persisted: typeof PersistedOptionalProviderSettings.Type = {}; | 602 | let persisted: typeof PersistedOptionalProviderSettings.Type = {}; |
| ... | @@ -639,10 +681,12 @@ const make = Effect.gen(function* () { | ... | @@ -639,10 +681,12 @@ const make = Effect.gen(function* () { |
| 639 | const folded = settingsFileTrusted | 681 | const folded = settingsFileTrusted |
| 640 | ? foldLegacyProjectSettings(loaded, legacyProjectRows) | 682 | ? foldLegacyProjectSettings(loaded, legacyProjectRows) |
| 641 | : loaded; | 683 | : loaded; |
| 642 | if (folded !== loaded) { | 684 | // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. |
| 643 | yield* writeSettingsAtomically(folded); | 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 | const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({ | 692 | const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({ |
| ... | @@ -693,7 +737,7 @@ const make = Effect.gen(function* () { | ... | @@ -693,7 +737,7 @@ const make = Effect.gen(function* () { |
| 693 | } | 737 | } |
| 694 | const usageLimitSources: Record<string, UsageLimitSourceConfig> = {}; | 738 | const usageLimitSources: Record<string, UsageLimitSourceConfig> = {}; |
| 695 | for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { | 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 | usageLimitSources[sourceId] = source; | 741 | usageLimitSources[sourceId] = source; |
| 698 | continue; | 742 | continue; |
| 699 | } | 743 | } |
| ... | @@ -709,10 +753,23 @@ const make = Effect.gen(function* () { | ... | @@ -709,10 +753,23 @@ const make = Effect.gen(function* () { |
| 709 | managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", | 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 | return { | 768 | return { |
| 713 | ...settings, | 769 | ...settings, |
| 714 | providerInstances: providerInstances as ServerSettings["providerInstances"], | 770 | providerInstances: providerInstances as ServerSettings["providerInstances"], |
| 715 | usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], | 771 | usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], |
| 772 | bitbucket, | ||
| 716 | }; | 773 | }; |
| 717 | }); | 774 | }); |
| 718 | 775 | ||
| ... | @@ -829,7 +886,7 @@ const make = Effect.gen(function* () { | ... | @@ -829,7 +886,7 @@ const make = Effect.gen(function* () { |
| 829 | const usageLimitSources: Record<string, UsageLimitSourceConfig> = {}; | 886 | const usageLimitSources: Record<string, UsageLimitSourceConfig> = {}; |
| 830 | for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { | 887 | for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { |
| 831 | const secretName = usageLimitSourceSecretName(sourceId); | 888 | const secretName = usageLimitSourceSecretName(sourceId); |
| 832 | if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) { | 889 | if (source.managementKey === SECRET_REDACTED) { |
| 833 | usageLimitSources[sourceId] = source; | 890 | usageLimitSources[sourceId] = source; |
| 834 | continue; | 891 | continue; |
| 835 | } | 892 | } |
| ... | @@ -843,7 +900,7 @@ const make = Effect.gen(function* () { | ... | @@ -843,7 +900,7 @@ const make = Effect.gen(function* () { |
| 843 | secretName, | 900 | secretName, |
| 844 | value: textEncoder.encode(source.managementKey), | 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 | for (const sourceId of Object.keys(current.usageLimitSources)) { | 905 | for (const sourceId of Object.keys(current.usageLimitSources)) { |
| 849 | if (sourceId in next.usageLimitSources) continue; | 906 | if (sourceId in next.usageLimitSources) continue; |
| ... | @@ -854,11 +911,31 @@ const make = Effect.gen(function* () { | ... | @@ -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 | return { | 933 | return { |
| 858 | settings: { | 934 | settings: { |
| 859 | ...next, | 935 | ...next, |
| 860 | providerInstances: providerInstances as ServerSettings["providerInstances"], | 936 | providerInstances: providerInstances as ServerSettings["providerInstances"], |
| 861 | usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], | 937 | usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], |
| 938 | bitbucket, | ||
| 862 | }, | 939 | }, |
| 863 | changes, | 940 | changes, |
| 864 | }; | 941 | }; |
apps/server/src/sourceControl/BitbucketApi.test.ts+76-1| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | ||
| 17 | import { GitCommandError } from "@t3tools/contracts"; | 17 | import { GitCommandError } from "@t3tools/contracts"; |
| 18 | import * as BitbucketApi from "./BitbucketApi.ts"; | 18 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 19 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 19 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 20 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 20 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | 21 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 21 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; | 22 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; |
| ... | @@ -64,6 +65,7 @@ function makeLayer(input: { | ... | @@ -64,6 +65,7 @@ function makeLayer(input: { |
| 64 | request: HttpClientRequest.HttpClientRequest, | 65 | request: HttpClientRequest.HttpClientRequest, |
| 65 | ) => HttpClientError.HttpClientError; | 66 | ) => HttpClientError.HttpClientError; |
| 66 | readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>; | 67 | readonly git?: Partial<GitVcsDriver.GitVcsDriver["Service"]>; |
| 68 | readonly env?: Record<string, string>; | ||
| 67 | }) { | 69 | }) { |
| 68 | const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => | 70 | const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => |
| 69 | input.requestFailure | 71 | input.requestFailure |
| ... | @@ -150,7 +152,7 @@ function makeLayer(input: { | ... | @@ -150,7 +152,7 @@ function makeLayer(input: { |
| 150 | Layer.provide( | 152 | Layer.provide( |
| 151 | ConfigProvider.layer( | 153 | ConfigProvider.layer( |
| 152 | ConfigProvider.fromEnv({ | 154 | ConfigProvider.fromEnv({ |
| 153 | env: { | 155 | env: input.env ?? { |
| 154 | T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", | 156 | T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", |
| 155 | T3CODE_BITBUCKET_EMAIL: "user@example.com", | 157 | T3CODE_BITBUCKET_EMAIL: "user@example.com", |
| 156 | T3CODE_BITBUCKET_API_TOKEN: "token", | 158 | T3CODE_BITBUCKET_API_TOKEN: "token", |
| ... | @@ -158,6 +160,7 @@ function makeLayer(input: { | ... | @@ -158,6 +160,7 @@ function makeLayer(input: { |
| 158 | }), | 160 | }), |
| 159 | ), | 161 | ), |
| 160 | ), | 162 | ), |
| 163 | Layer.provideMerge(ServerSettings.layerTest()), | ||
| 161 | Layer.provideMerge(NodeServices.layer), | 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,6 +512,78 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => |
| 509 | }).pipe(Effect.provide(layer)); | 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 | it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { | 587 | it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { |
| 513 | const transportCause = new Error("socket reset by peer"); | 588 | const transportCause = new Error("socket reset by peer"); |
| 514 | let requestFailure: HttpClientError.HttpClientError | undefined; | 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,8 +7,10 @@ import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 9 | import { | 9 | import { |
| 10 | DEFAULT_SERVER_SETTINGS, | ||
| 10 | NonNegativeInt, | 11 | NonNegativeInt, |
| 11 | TrimmedNonEmptyString, | 12 | TrimmedNonEmptyString, |
| 13 | type BitbucketSettings, | ||
| 12 | type SourceControlProviderAuth, | 14 | type SourceControlProviderAuth, |
| 13 | type SourceControlRepositoryCloneUrls, | 15 | type SourceControlRepositoryCloneUrls, |
| 14 | type SourceControlRepositoryVisibility, | 16 | type SourceControlRepositoryVisibility, |
| ... | @@ -28,6 +30,7 @@ import { | ... | @@ -28,6 +30,7 @@ import { |
| 28 | } from "./bitbucketPullRequests.ts"; | 30 | } from "./bitbucketPullRequests.ts"; |
| 29 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | 31 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; |
| 30 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | 32 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 33 | import * as ServerSettings from "../serverSettings.ts"; | ||
| 31 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 34 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 32 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; | 35 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 33 | import { retryAtFromHeader } from "./SourceControlRateLimit.ts"; | 36 | import { retryAtFromHeader } from "./SourceControlRateLimit.ts"; |
| ... | @@ -537,24 +540,64 @@ function repositoryOwnerName(repositoryName: string): string { | ... | @@ -537,24 +540,64 @@ function repositoryOwnerName(repositoryName: string): string { |
| 537 | return repositoryName.split("/")[0]?.trim() || "bitbucket"; | 540 | return repositoryName.split("/")[0]?.trim() || "bitbucket"; |
| 538 | } | 541 | } |
| 539 | 542 | ||
| 540 | function authFromConfig( | 543 | type BitbucketCredential = |
| 541 | config: Config.Success<typeof BitbucketApiEnvConfig>, | 544 | | { readonly kind: "access-token"; readonly accessToken: string } |
| 542 | ): SourceControlProviderAuth { | 545 | | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; |
| 543 | if (Option.isSome(config.accessToken)) { | 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 | return { | 587 | return { |
| 545 | status: "unknown", | 588 | status: "unknown", |
| 546 | account: Option.none(), | 589 | account: Option.none(), |
| 547 | host: Option.some("bitbucket.org"), | 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 | return { | 596 | return { |
| 554 | status: "unknown", | 597 | status: "unknown", |
| 555 | account: config.email, | 598 | account: Option.some(credential.email), |
| 556 | host: Option.some("bitbucket.org"), | 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,7 +606,7 @@ function authFromConfig( |
| 563 | account: Option.none(), | 606 | account: Option.none(), |
| 564 | host: Option.some("bitbucket.org"), | 607 | host: Option.some("bitbucket.org"), |
| 565 | detail: Option.some( | 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,6 +655,7 @@ function responseError( |
| 612 | /** @public Service construction is part of the canonical Effect module API. */ | 655 | /** @public Service construction is part of the canonical Effect module API. */ |
| 613 | export const make = Effect.gen(function* () { | 656 | export const make = Effect.gen(function* () { |
| 614 | const config = yield* BitbucketApiEnvConfig; | 657 | const config = yield* BitbucketApiEnvConfig; |
| 658 | const serverSettings = yield* ServerSettings.ServerSettingsService; | ||
| 615 | const httpClient = yield* HttpClient.HttpClient; | 659 | const httpClient = yield* HttpClient.HttpClient; |
| 616 | const fileSystem = yield* FileSystem.FileSystem; | 660 | const fileSystem = yield* FileSystem.FileSystem; |
| 617 | const git = yield* GitVcsDriver.GitVcsDriver; | 661 | const git = yield* GitVcsDriver.GitVcsDriver; |
| ... | @@ -619,15 +663,27 @@ export const make = Effect.gen(function* () { | ... | @@ -619,15 +663,27 @@ export const make = Effect.gen(function* () { |
| 619 | 663 | ||
| 620 | const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; | 664 | const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; |
| 621 | 665 | ||
| 622 | const withAuth = (request: HttpClientRequest.HttpClientRequest) => { | 666 | // Read on every request so credentials saved in settings apply without a restart. |
| 623 | if (Option.isSome(config.accessToken)) { | 667 | const currentCredential = serverSettings.getSettings.pipe( |
| 624 | return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); | 668 | Effect.map((settings) => resolveCredential(settings.bitbucket, config)), |
| 625 | } | 669 | Effect.catch((error) => |
| 626 | if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { | 670 | // No cause: a settings decode error can quote a hand-edited token. |
| 627 | return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); | 671 | Effect.logWarning("failed to read Bitbucket credentials from settings", { |
| 628 | } | 672 | operation: error.operation, |
| 629 | return request; | 673 | }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), |
| 630 | }; | 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 | const decodeResponse = <S extends Schema.Top>( | 688 | const decodeResponse = <S extends Schema.Top>( |
| 633 | operation: BitbucketApiOperation, | 689 | operation: BitbucketApiOperation, |
| ... | @@ -654,7 +710,8 @@ export const make = Effect.gen(function* () { | ... | @@ -654,7 +710,8 @@ export const make = Effect.gen(function* () { |
| 654 | request: HttpClientRequest.HttpClientRequest, | 710 | request: HttpClientRequest.HttpClientRequest, |
| 655 | schema: S, | 711 | schema: S, |
| 656 | ): Effect.Effect<S["Type"], BitbucketApiError, S["DecodingServices"]> => | 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 | Effect.mapError( | 715 | Effect.mapError( |
| 659 | (cause) => | 716 | (cause) => |
| 660 | new BitbucketRequestError({ | 717 | new BitbucketRequestError({ |
| ... | @@ -847,7 +904,8 @@ export const make = Effect.gen(function* () { | ... | @@ -847,7 +904,8 @@ export const make = Effect.gen(function* () { |
| 847 | input.body === undefined | 904 | input.body === undefined |
| 848 | ? base | 905 | ? base |
| 849 | : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); | 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 | Effect.mapError( | 909 | Effect.mapError( |
| 852 | (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), | 910 | (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), |
| 853 | ), | 911 | ), |
| ... | @@ -913,7 +971,7 @@ export const make = Effect.gen(function* () { | ... | @@ -913,7 +971,7 @@ export const make = Effect.gen(function* () { |
| 913 | host: Option.some("bitbucket.org"), | 971 | host: Option.some("bitbucket.org"), |
| 914 | detail: Option.none<string>(), | 972 | detail: Option.none<string>(), |
| 915 | })), | 973 | })), |
| 916 | Effect.orElseSucceed(() => authFromConfig(config)), | 974 | Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), |
| 917 | ), | 975 | ), |
| 918 | listPullRequests: (input) => | 976 | listPullRequests: (input) => |
| 919 | resolveRepository(input).pipe( | 977 | resolveRepository(input).pipe( |
apps/server/src/sourceControl/BitbucketSourceControlProvider.ts+1-2| ... | @@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () { | ... | @@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () { |
| 192 | type: "api", | 192 | type: "api", |
| 193 | kind: "bitbucket", | 193 | kind: "bitbucket", |
| 194 | label: "Bitbucket", | 194 | label: "Bitbucket", |
| 195 | installHint: | 195 | installHint: "Add a Bitbucket token in Settings → Source Control.", |
| 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).", | ||
| 197 | probeAuth: bitbucket.probeAuth, | 196 | probeAuth: bitbucket.probeAuth, |
| 198 | } satisfies SourceControlApiDiscoverySpec; | 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,7 +423,7 @@ it.effect("reports implemented tools separately from locally available executabl |
| 423 | account: Option.none(), | 423 | account: Option.none(), |
| 424 | host: Option.some("bitbucket.org"), | 424 | host: Option.some("bitbucket.org"), |
| 425 | detail: Option.some( | 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,6 +56,7 @@ import { |
| 56 | JujutsuIcon, | 56 | JujutsuIcon, |
| 57 | type Icon, | 57 | type Icon, |
| 58 | } from "../Icons"; | 58 | } from "../Icons"; |
| 59 | import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings"; | ||
| 59 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | 60 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 60 | import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; | 61 | import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; |
| 61 | import { | 62 | import { |
| ... | @@ -234,7 +235,9 @@ function itemSummary({ | ... | @@ -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 | return <span>Available. {item.installHint}</span>; | 241 | return <span>Available. {item.installHint}</span>; |
| 239 | } | 242 | } |
| 240 | 243 | ||
| ... | @@ -277,7 +280,11 @@ function DiscoveryItemRow({ | ... | @@ -277,7 +280,11 @@ function DiscoveryItemRow({ |
| 277 | const searchTargetId = useSettingsSearchTargetId(); | 280 | const searchTargetId = useSettingsSearchTargetId(); |
| 278 | 281 | ||
| 279 | useEffect(() => { | 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 | setIsExpanded(true); | 288 | setIsExpanded(true); |
| 282 | } | 289 | } |
| 283 | }, [item.kind, searchTargetId]); | 290 | }, [item.kind, searchTargetId]); |
| ... | @@ -586,7 +593,18 @@ export function SourceControlSettingsPanel() { | ... | @@ -586,7 +593,18 @@ export function SourceControlSettingsPanel() { |
| 586 | headerAction={hasVersionControlSystems ? null : scanButton} | 593 | headerAction={hasVersionControlSystems ? null : scanButton} |
| 587 | > | 594 | > |
| 588 | {result.sourceControlProviders.map((item) => ( | 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 | </SettingsSection> | 609 | </SettingsSection> |
| 592 | ) : null} | 610 | ) : null} |
apps/web/src/components/settings/settingsSearch.ts+8| ... | @@ -695,6 +695,14 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -695,6 +695,14 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 695 | environmentOnly: true, | 695 | environmentOnly: true, |
| 696 | scope: "environment-defaults", | 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 | id: "source-control-writing-style", | 707 | id: "source-control-writing-style", |
| 700 | title: "Source control writing style", | 708 | title: "Source control writing style", |
docs/user/source-control.md+17-10| ... | @@ -47,23 +47,29 @@ glab auth login | ... | @@ -47,23 +47,29 @@ glab auth login |
| 47 | 47 | ||
| 48 | ### Bitbucket | 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 | 52 | - **Access token**: a token created for one repository, project, or workspace. It can only reach |
| 53 | export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" | 53 | what it was created for. |
| 54 | ``` | 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 | 58 | Choose **Save**; the change applies right away, and replaces any credential saved with the other |
| 57 | requests, plus user read access (`read:user:bitbucket`): | 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 | ```bash | 66 | ```bash |
| 67 | export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" | ||
| 68 | # or | ||
| 60 | export T3CODE_BITBUCKET_EMAIL="you@example.com" | 69 | export T3CODE_BITBUCKET_EMAIL="you@example.com" |
| 61 | export T3CODE_BITBUCKET_API_TOKEN="your-token" | 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 | ### Azure DevOps | 73 | ### Azure DevOps |
| 68 | 74 | ||
| 69 | Install [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/), add the DevOps extension, and sign in: | 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,7 +144,8 @@ does not show its diff, so marks are made and read on web and desktop. |
| 138 | ## Troubleshooting | 144 | ## Troubleshooting |
| 139 | 145 | ||
| 140 | - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, | 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 | - **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0. | 149 | - **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0. |
| 143 | - **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS | 150 | - **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS |
| 144 | remotes can require separate setup from the hosting provider's API access. | 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,9 +1277,9 @@ const PROVIDER_REQUIREMENT: Partial< |
| 1277 | }, | 1277 | }, |
| 1278 | bitbucket: { | 1278 | bitbucket: { |
| 1279 | missing: | 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 | unauthenticated: | 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,6 +901,19 @@ export const UsageLimitSourceConfig = Schema.Struct({ |
| 901 | }); | 901 | }); |
| 902 | export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; | 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 | export const ObservabilitySettings = Schema.Struct({ | 917 | export const ObservabilitySettings = Schema.Struct({ |
| 905 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | 918 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 906 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | 919 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| ... | @@ -1277,6 +1290,7 @@ export const ServerSettings = Schema.Struct({ | ... | @@ -1277,6 +1290,7 @@ export const ServerSettings = Schema.Struct({ |
| 1277 | Schema.withDecodingDefault(Effect.succeed({})), | 1290 | Schema.withDecodingDefault(Effect.succeed({})), |
| 1278 | ), | 1291 | ), |
| 1279 | observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 1292 | observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 1293 | bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | ||
| 1280 | // Keyed by a user-chosen id so a source keeps its rows across edits. Entries | 1294 | // Keyed by a user-chosen id so a source keeps its rows across edits. Entries |
| 1281 | // this build cannot decode round-trip untouched, as provider instances do. | 1295 | // this build cannot decode round-trip untouched, as provider instances do. |
| 1282 | usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( | 1296 | usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( |
| ... | @@ -1538,6 +1552,14 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -1538,6 +1552,14 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1538 | otlpLogsUrl: Schema.optionalKey(TrimmedString), | 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 | providers: Schema.optionalKey( | 1563 | providers: Schema.optionalKey( |
| 1542 | Schema.Struct({ | 1564 | Schema.Struct({ |
| 1543 | codex: Schema.optionalKey(CodexSettingsPatch), | 1565 | codex: Schema.optionalKey(CodexSettingsPatch), |