From 616e4593fea7b8f7e93bfba7b81e851d50cf7c1e Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:25:44 +1100 Subject: [PATCH] fix(server): saving settings keeps retired Bitbucket and GitHub keys until their tokens move Co-Authored-By: Claude Opus 5.5 --- apps/server/src/serverSettings.test.ts | 8 ++++- apps/server/src/serverSettings.ts | 46 ++++++++++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 8d50f0b597b57..990cc635532ba 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1614,6 +1614,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" yield* Effect.exit(serverSettings.getSettings); assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), legacy); + + yield* serverSettings.updateSettings({ defaultAutoPull: true }); + + const written = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath)); + assert.deepEqual(written.bitbucket, JSON.parse(legacy).bitbucket); + assert.equal(written.defaultAutoPull, true); }).pipe(Effect.provide(layerSettings)); }); @@ -1646,7 +1652,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" assert.deepEqual(loaded.github?.tokens, { "github.com": "ghp_saved" }); assert.deepEqual(loaded.github?.hosts, { "github.com": { account: "work" } }); const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.notInclude(raw, '"bitbucket":{"email"'); + assert.doesNotHaveAnyKeys(JSON.parse(raw), ["bitbucket", "github"]); assert.include(raw, "sourceControlHosts"); assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 1ead9e27bfcf5..5564dfcabd09a 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -68,7 +68,13 @@ export { } from "@t3tools/shared/serverSettings"; const encodeServerSettings = Schema.encodeEffect(ServerSettings); -const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); +const RetiredSourceControlFields = { + bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}; +const encodeServerSettingsJson = Schema.encodeUnknownEffect( + fromJsonStringPretty(Schema.Struct({ ...ServerSettings.fields, ...RetiredSourceControlFields })), +); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const textEncoder = new TextEncoder(); @@ -353,8 +359,7 @@ const decodePersistedServerSettingsExit = Schema.decodeUnknownExit(PersistedServ const LegacyProviderSettingsJson = fromLenientJson( Schema.Struct({ providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), - bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), - github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + ...RetiredSourceControlFields, }), ); const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson); @@ -695,8 +700,21 @@ const make = Effect.gen(function* () { }); const writeSettingsAtomically = Effect.fnUntraced( - function* (settings: ServerSettings) { - const stripped = stripDefaultServerSettings(settings, managed.defaults) ?? {}; + function* ( + settings: ServerSettings, + retiredSourceControl: Parameters[1] = {}, + ) { + const stripped = Object.assign( + {}, + stripDefaultServerSettings(settings, managed.defaults), + // Retired keys whose secrets could not be copied yet stay for the next load to migrate. + Object.fromEntries( + Object.entries(retiredSourceControl).filter( + ([kind, value]) => + value !== undefined && settings.sourceControlHosts[kind] === undefined, + ), + ), + ); const sparseSettingsJson = yield* encodeServerSettingsJson( managed.keepStored === undefined ? stripped @@ -877,6 +895,8 @@ const make = Effect.gen(function* () { settingsFileTrusted && legacySecretsCopied && (legacySourceControl.bitbucket !== undefined || legacySourceControl.github !== undefined); + const retiredSourceControl = + settingsFileTrusted && !legacySecretsCopied ? legacySourceControl : {}; const sourceControlMigration = hasLegacySourceControl ? migrateLegacySourceControlSettings(folded, legacySourceControl) : { settings: folded, secretMoves: [] }; @@ -888,12 +908,12 @@ const make = Effect.gen(function* () { (settingsFileTrusted && legacyProviders !== undefined) || hasLegacySourceControl ) { - yield* writeSettingsAtomically(migrated); + yield* writeSettingsAtomically(migrated, retiredSourceControl); for (const move of sourceControlMigration.secretMoves) { yield* secretStore.remove(move.from).pipe(Effect.ignore); } } - return { settings: migrated, parseFailure }; + return { settings: migrated, parseFailure, retiredSourceControl }; }); const settingsCache = yield* Cache.makeWith((_key: typeof cacheKey) => loadSettingsFromDisk, { @@ -1134,7 +1154,7 @@ const make = Effect.gen(function* () { ): Effect.Effect => writeSemaphore.withPermits(1)( Effect.gen(function* () { - const { settings: current, parseFailure } = yield* reloadSettings; + const { settings: current, parseFailure, retiredSourceControl } = yield* reloadSettings; if (Option.isSome(parseFailure)) { return yield* new ServerSettingsError({ settingsPath, @@ -1182,7 +1202,9 @@ const make = Effect.gen(function* () { yield* rollbackSecretChanges; return yield* Effect.failCause(materializedExit.cause); } - const writeExit = yield* Effect.exit(writeSettingsAtomically(next)); + const writeExit = yield* Effect.exit( + writeSettingsAtomically(next, retiredSourceControl), + ); if (Exit.isFailure(writeExit)) { yield* rollbackSecretChanges; return yield* Effect.failCause(writeExit.cause); @@ -1190,7 +1212,11 @@ const make = Effect.gen(function* () { return materializedExit.value; }).pipe(Effect.ensuring(invalidateMaterialized)), ); - yield* Cache.set(settingsCache, cacheKey, { settings: next, parseFailure: Option.none() }); + yield* Cache.set(settingsCache, cacheKey, { + settings: next, + parseFailure: Option.none(), + retiredSourceControl, + }); const resolved = toEffective(materializedNext); materialized = { source: next, value: resolved }; yield* emitChange(next);