From 8d964d623c95c9a867d5ae238a751e1921c2a111 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 2 Nov 2026 15:05:07 -0500 Subject: [PATCH 1/3] fix(shared): saving one source control token no longer drops the other hosts' tokens An older client sends the whole token map when it saves or removes a token, and the server replaced the stored map with it, wiping every other host's token and reference. Tokens now merge per host, so a host missing from the patch keeps what is stored. Signed-off-by: Yordis Prieto --- packages/shared/src/serverSettings.test.ts | 14 ++++++++++++ packages/shared/src/serverSettings.ts | 25 ++++++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index a897f3d427a3..0311fb8b1978 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -127,6 +127,20 @@ describe("serverSettings helpers", () => { }); }); + it("keeps a source control host's saved tokens when a patch sets another host's token", () => { + const reference = { provider: "vault", reference: "github/token" }; + const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + sourceControlHosts: { github: { tokens: { "github.com": reference } } }, + }); + const patched = applyServerSettingsPatch(saved, { + sourceControlHosts: { github: { tokens: { "ghe.example": "x" } } }, + }); + expect(patched.sourceControlHosts.github?.tokens).toEqual({ + "github.com": reference, + "ghe.example": "x", + }); + }); + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { const project = { id: ProjectId.make("project-actions"), scripts: [] }; const action = { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index e936c9f83acb..150b9e9d96fa 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -23,6 +23,7 @@ import { withIdleCompaction, } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; @@ -530,10 +531,26 @@ export function applyServerSettingsPatch( sourceControlHosts: { ...current.sourceControlHosts, ...Object.fromEntries( - Object.entries(sourceControlHostsPatch).map(([kind, fields]) => [ - kind, - { ...current.sourceControlHosts[kind], ...fields }, - ]), + Object.entries(sourceControlHostsPatch).map(([kind, fields]) => { + const saved = current.sourceControlHosts[kind]; + return [ + kind, + { + ...saved, + ...fields, + // Older clients send the whole token map; a host missing from it keeps its saved + // token. An empty map still clears them all. + ...(Predicate.isObject(fields.tokens) && Object.keys(fields.tokens).length > 0 + ? { + tokens: { + ...(Predicate.isObject(saved?.tokens) ? saved.tokens : {}), + ...fields.tokens, + }, + } + : {}), + }, + ]; + }), ), }, } From 8b437c4aa9d4fe527847542d47f3818a15407afc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 2 Nov 2026 16:22:57 -0500 Subject: [PATCH 2/3] fix(web): sign-in text no longer promises a saved token is always used A saved token reference falls back to the sign-in when the vault cannot be read, so the text now says the token is used while it can be read. Signed-off-by: Yordis Prieto --- .../src/components/settings/SourceControlAppSignInSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx index df73fa3b24b4..54b0018a249c 100644 --- a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx +++ b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx @@ -171,7 +171,7 @@ export function SourceControlAppSignInSettings({ /> ) : tokenOverrides ? ( - "Signed in, but the token saved below is used until you remove it." + "Signed in, but the token saved below is used while it can be read." ) : ( "Signed in" ); From 65d2ad1996210b163bd5c825ea0864b4ec7fb562 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 2 Nov 2026 17:46:26 -0500 Subject: [PATCH 3/3] fix(shared): token merge treats host names the way secret storage does Signed-off-by: Yordis Prieto --- packages/contracts/src/settings.ts | 2 +- packages/shared/src/serverSettings.test.ts | 4 ++++ packages/shared/src/serverSettings.ts | 13 +++++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index f291cec115f7..fd735a615980 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1696,7 +1696,7 @@ export const ServerSettingsPatch = Schema.Struct({ otlpLogsUrl: Schema.optionalKey(TrimmedString), }), ), - /** Merges per host, replacing patched fields whole; redaction markers retain secrets, empty or omitted secrets remove them. */ + /** Merges per host, replacing patched fields whole; redaction markers retain secrets, empty secrets remove them. A non-empty token map keeps the saved tokens of hosts it omits; an empty map clears them all. */ sourceControlHosts: Schema.optionalKey(Schema.Record(Schema.String, SourceControlHostSettings)), // Replace whole maps to avoid half-merged driver configs. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 0311fb8b1978..cfbc9339e4ee 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -139,6 +139,10 @@ describe("serverSettings helpers", () => { "github.com": reference, "ghe.example": "x", }); + const recased = applyServerSettingsPatch(saved, { + sourceControlHosts: { github: { tokens: { " GitHub.com ": "ghp_new" } } }, + }); + expect(recased.sourceControlHosts.github?.tokens).toEqual({ "github.com": "ghp_new" }); }); it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 150b9e9d96fa..0db213a90f7b 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -227,6 +227,15 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +// Secret persistence keys tokens by trimmed, lowercased host, so merge on the same key. +const byHost = (tokens: unknown) => + Object.fromEntries( + Object.entries(Predicate.isObject(tokens) ? tokens : {}).map(([host, token]) => [ + host.trim().toLowerCase(), + token, + ]), + ); + const knownProjectOverrideKeys = new Set(Object.keys(ProjectSettingsOverrides.fields)); function pickOverrideKeys(entry: ProjectSettingsOverrides | undefined, known: boolean) { @@ -543,8 +552,8 @@ export function applyServerSettingsPatch( ...(Predicate.isObject(fields.tokens) && Object.keys(fields.tokens).length > 0 ? { tokens: { - ...(Predicate.isObject(saved?.tokens) ? saved.tokens : {}), - ...fields.tokens, + ...byHost(saved?.tokens), + ...byHost(fields.tokens), }, } : {}),