Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export function SourceControlAppSignInSettings({
/>
</span>
) : 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"
);
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
18 changes: 18 additions & 0 deletions packages/shared/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ 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",
});
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", () => {
const project = { id: ProjectId.make("project-actions"), scripts: [] };
const action = {
Expand Down
34 changes: 30 additions & 4 deletions packages/shared/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -226,6 +227,15 @@ function mergeSettingsEntries<Value>(
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) {
Expand Down Expand Up @@ -530,10 +540,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: {
...byHost(saved?.tokens),
...byHost(fields.tokens),
},
}
: {}),
},
];
}),
),
},
}
Expand Down
Loading