authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 21:54:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 21:54:08-07:00
logd514a8c6176ace9057dc8c7619f0b5f0d81d1bd0
tree310bfe913e79363872fa8bd23bdccfc842c81e02
parent37e2acda5118d13c144efb009d4911ff8f65d6ed
signature Signed by PGP key B5690EEEBB952194

refactor(settings): centralize schema-declared secrets and inputs (#18505)


15 files changed, 658 insertions(+), 460 deletions(-)

apps/server/src/serverSettings.test.ts+175-9
......@@ -9,6 +9,7 @@ import {
99 resolveProviderInstanceEnabled,
1010 ServerSettings,
1111 ServerSettingsPatch,
12 UsageLimitSourceId,
1213} from "@t3tools/contracts";
1314import { createModelSelection } from "@t3tools/shared/model";
1415import { assert, it } from "@effect/vitest";
......@@ -1440,6 +1441,156 @@ it.layer(NodeServices.layer)("server settings", (it) => {
14401441 }).pipe(Effect.provide(layerServerSettings())),
14411442 );
14421443
1444 it.effect(
1445 "uses the same secret lifecycle for provider config, environment, host tokens, and quota keys",
1446 () =>
1447 Effect.gen(function* () {
1448 const service = yield* ServerSettingsModule.ServerSettingsService;
1449 const config = yield* ServerConfig.ServerConfig;
1450 const fs = yield* FileSystem.FileSystem;
1451 const store = yield* ServerSecretStore.ServerSecretStore;
1452 const instanceId = ProviderInstanceId.make("antigravity_secrets");
1453 const saved = yield* service.updateSettings({
1454 providerInstances: {
1455 [instanceId]: {
1456 driver: ProviderDriverKind.make("antigravity"),
1457 config: { apiKey: "config-secret" },
1458 environment: [
1459 { name: "API_TOKEN", value: "env-secret", sensitive: true },
1460 { name: "BASE_URL", value: "https://example.test", sensitive: false },
1461 ],
1462 },
1463 },
1464 sourceControlHosts: {
1465 github: { tokens: { "github.com": "github-secret" } },
1466 bitbucket: { email: "me@example.test", accessToken: "bitbucket-secret" },
1467 gitcafe: { token: "gitcafe-secret" },
1468 },
1469 usageLimitSources: {
1470 [UsageLimitSourceId.make("hub")]: {
1471 kind: "cliproxy",
1472 url: "https://hub.example.test",
1473 managementKey: "quota-secret",
1474 enabled: true,
1475 },
1476 },
1477 });
1478 const values = [
1479 "config-secret",
1480 "env-secret",
1481 "github-secret",
1482 "bitbucket-secret",
1483 "gitcafe-secret",
1484 "quota-secret",
1485 ];
1486 const client = ServerSettingsModule.redactServerSettingsForClient(saved);
1487 const disk = yield* fs.readFileString(config.settingsPath);
1488 for (const value of values) {
1489 assert.include(JSON.stringify(saved), value);
1490 assert.notInclude(JSON.stringify(client), value);
1491 assert.notInclude(disk, value);
1492 }
1493 assert.include(JSON.stringify(client), "https://example.test");
1494 assert.include(JSON.stringify(client), "me@example.test");
1495 const echoed = yield* service.updateSettings({
1496 providerInstances: client.providerInstances,
1497 sourceControlHosts: client.sourceControlHosts,
1498 usageLimitSources: client.usageLimitSources,
1499 });
1500 for (const value of values) assert.include(JSON.stringify(echoed), value);
1501 const renamed = yield* service.updateProviderInstance({
1502 operation: "upsert",
1503 instanceId,
1504 instance: { ...client.providerInstances[instanceId]!, displayName: "Renamed" },
1505 });
1506 assert.propertyVal(
1507 renamed.providerInstances[instanceId]?.config,
1508 "apiKey",
1509 "config-secret",
1510 );
1511
1512 for (const apiKey of ["replacement-secret", ""]) {
1513 const changed = yield* service.updateProviderInstance({
1514 operation: "upsert",
1515 instanceId,
1516 instance: { ...client.providerInstances[instanceId]!, config: { apiKey } },
1517 });
1518 assert.propertyVal(changed.providerInstances[instanceId]?.config, "apiKey", apiKey);
1519 const redacted = ServerSettingsModule.redactServerSettingsForClient(changed);
1520 assert.propertyVal(
1521 redacted.providerInstances[instanceId]?.config,
1522 "apiKey",
1523 apiKey ? "••••••" : "",
1524 );
1525 }
1526 // Restore the value so removing the instance below exercises deletion of both secrets.
1527 yield* service.updateProviderInstance({
1528 operation: "upsert",
1529 instanceId,
1530 instance: saved.providerInstances[instanceId]!,
1531 });
1532
1533 const secretNames = [
1534 "provider-config-YW50aWdyYXZpdHlfc2VjcmV0cw-YW50aWdyYXZpdHk-YXBpS2V5",
1535 "provider-env-YW50aWdyYXZpdHlfc2VjcmV0cw-QVBJX1RPS0VO",
1536 sourceControlHostSecretName({
1537 kind: "github",
1538 field: "tokens",
1539 serverHost: "github.com",
1540 }),
1541 sourceControlHostSecretName({
1542 kind: "bitbucket",
1543 field: "accessToken",
1544 serverHost: null,
1545 }),
1546 sourceControlHostSecretName({ kind: "gitcafe", field: "token", serverHost: null }),
1547 "usage-limit-source-aHVi",
1548 ];
1549 for (const name of secretNames) {
1550 assert.isTrue(Option.isSome(yield* store.get(name)), name);
1551 }
1552 yield* service.updateSettings({
1553 providerInstances: {},
1554 sourceControlHosts: {
1555 github: { tokens: {} },
1556 bitbucket: { accessToken: "" },
1557 gitcafe: { token: "" },
1558 },
1559 usageLimitSources: { [UsageLimitSourceId.make("hub")]: null },
1560 });
1561 for (const name of secretNames) {
1562 assert.isTrue(Option.isNone(yield* store.get(name)));
1563 }
1564 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1565 );
1566
1567 it.effect("moves a saved Antigravity key to the secret store when settings load", () =>
1568 Effect.gen(function* () {
1569 const service = yield* ServerSettingsModule.ServerSettingsService;
1570 const config = yield* ServerConfig.ServerConfig;
1571 const fs = yield* FileSystem.FileSystem;
1572 yield* fs.writeFileString(
1573 config.settingsPath,
1574 JSON.stringify({
1575 providerInstances: {
1576 antigravity: { driver: "antigravity", config: { apiKey: "legacy-api-key" } },
1577 },
1578 }),
1579 );
1580 const saved = yield* service.getSettings;
1581 assert.propertyVal(
1582 saved.providerInstances[ProviderInstanceId.make("antigravity")]?.config,
1583 "apiKey",
1584 "legacy-api-key",
1585 );
1586 assert.notInclude(yield* fs.readFileString(config.settingsPath), "legacy-api-key");
1587 assert.notInclude(
1588 JSON.stringify(ServerSettingsModule.redactServerSettingsForClient(saved)),
1589 "legacy-api-key",
1590 );
1591 }).pipe(Effect.provide(layerServerSettings())),
1592 );
1593
14431594 it.effect("stores sensitive provider instance environment values outside settings.json", () =>
14441595 Effect.gen(function* () {
14451596 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
......@@ -1714,7 +1865,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
17141865 ? Effect.fail(writeFailure)
17151866 : fileSystem.rename(fromPath, toPath),
17161867 });
1717 const instanceId = ProviderInstanceId.make("codex_write_failure");
1868 const instanceId = ProviderInstanceId.make("antigravity_write_failure");
17181869 const layerSettings = layerServerSettings().pipe(
17191870 Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, failingFileSystem)),
17201871 );
......@@ -1726,9 +1877,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
17261877 operation: "upsert",
17271878 instanceId,
17281879 instance: {
1729 driver: ProviderDriverKind.make("codex"),
1880 driver: ProviderDriverKind.make("antigravity"),
17301881 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }],
1731 config: {},
1882 config: { apiKey: "config-kept" },
17321883 },
17331884 });
17341885
......@@ -1738,9 +1889,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
17381889 operation: "upsert",
17391890 instanceId,
17401891 instance: {
1741 driver: ProviderDriverKind.make("codex"),
1892 driver: ProviderDriverKind.make("antigravity"),
17421893 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }],
1743 config: {},
1894 config: { apiKey: "config-new" },
17441895 },
17451896 })
17461897 .pipe(Effect.result);
......@@ -1750,6 +1901,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {
17501901 ?.value,
17511902 "sk-kept",
17521903 );
1904 assert.propertyVal(
1905 (yield* serverSettings.getSettings).providerInstances[instanceId]?.config,
1906 "apiKey",
1907 "config-kept",
1908 );
17531909
17541910 const failed = yield* serverSettings
17551911 .updateProviderInstance({ operation: "remove", instanceId })
......@@ -1760,6 +1916,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {
17601916 ?.value,
17611917 "sk-kept",
17621918 );
1919 assert.propertyVal(
1920 (yield* serverSettings.getSettings).providerInstances[instanceId]?.config,
1921 "apiKey",
1922 "config-kept",
1923 );
17631924 }).pipe(Effect.provide(layerSettings));
17641925 }),
17651926 );
......@@ -1841,9 +2002,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
18412002 yield* serverSettings.updateSettings({
18422003 providerInstances: {
18432004 [instanceId]: {
1844 driver: ProviderDriverKind.make("codex"),
2005 driver: ProviderDriverKind.make("antigravity"),
18452006 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }],
1846 config: {},
2007 config: { apiKey: "config-kept" },
18472008 },
18482009 },
18492010 });
......@@ -1853,9 +2014,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
18532014 .updateSettings({
18542015 providerInstances: {
18552016 [instanceId]: {
1856 driver: ProviderDriverKind.make("codex"),
2017 driver: ProviderDriverKind.make("antigravity"),
18572018 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }],
1858 config: {},
2019 config: { apiKey: "config-new" },
18592020 },
18602021 },
18612022 })
......@@ -1868,6 +2029,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {
18682029 ?.value,
18692030 "sk-kept",
18702031 );
2032 assert.propertyVal(
2033 (yield* serverSettings.getSettings).providerInstances[instanceId]?.config,
2034 "apiKey",
2035 "config-kept",
2036 );
18712037 }).pipe(Effect.provide(layerSettings));
18722038 },
18732039 );
apps/server/src/serverSettings.ts+111-355
......@@ -17,8 +17,6 @@ import {
1717 ProjectScript,
1818 ProjectSettingsOverrides,
1919 type ProviderInstanceConfig,
20 type ProviderInstanceEnvironmentVariable,
21 type UsageLimitSourceConfig,
2220 type ProviderInstanceMutation,
2321 defaultInstanceIdForDriver,
2422 isProviderDriverKind,
......@@ -64,12 +62,11 @@ import {
6462import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
6563import {
6664 readSecretFieldValues,
67 SOURCE_CONTROL_HOST_SECRET_FIELDS,
68 type SourceControlHostSecretSlot,
6965 sourceControlHostSecretName,
70 writeSecretFieldValues,
7166} from "./sourceControl/sourceControlHostSecrets.ts";
7267
68import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts";
69
7370export {
7471 resolveReviewGuideModelSelection,
7572 resolveSourceControlWriterModelSelection,
......@@ -147,106 +144,12 @@ const normalizeServerSettings = (
147144 ),
148145 );
149146
150function providerEnvironmentSecretName(input: {
151 readonly instanceId: string;
152 readonly name: string;
153}): string {
154 return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`;
155}
156
157/**
158 * On disk a hub key or Bitbucket token is replaced by this marker and the
159 * real value lives in the secret store, mirroring provider environment
160 * secrets. A client that sends the marker back means "keep what you have".
161 */
162const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022";
163
164function usageLimitSourceSecretName(sourceId: string): string {
165 return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`;
166}
167
168const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : "");
169
170/** Each host's settings with every secret slot's value replaced by `map(slot, value)`. */
171function mapSlotValues(
172 hosts: ServerSettings["sourceControlHosts"],
173 map: (slot: SourceControlHostSecretSlot, value: string) => string,
174): ServerSettings["sourceControlHosts"] {
175 return Object.fromEntries(
176 Object.entries(hosts).map(([kind, fields]) => {
177 const secretFields = SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? [];
178 if (secretFields.length === 0) return [kind, fields];
179 const next = { ...fields };
180 for (const field of secretFields) {
181 if (!(field in next)) continue;
182 next[field] = writeSecretFieldValues(
183 next[field],
184 readSecretFieldValues(next[field]).map(
185 ([serverHost, value]) => [serverHost, map({ kind, field, serverHost }, value)] as const,
186 ),
187 );
188 }
189 return [kind, next];
190 }),
191 );
192}
193
194/** Every saved secret slot in `hosts`, with its current value. */
195function sourceControlHostSecretSlots(
196 hosts: ServerSettings["sourceControlHosts"],
197): ReadonlyArray<SourceControlHostSecretSlot & { readonly value: string }> {
198 return Object.entries(hosts).flatMap(([kind, fields]) =>
199 (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) =>
200 readSecretFieldValues(fields[field]).map(([serverHost, value]) => ({
201 kind,
202 field,
203 serverHost,
204 value,
205 })),
206 ),
207 );
208}
209
210function redactProviderEnvironmentVariable(
211 variable: ProviderInstanceEnvironmentVariable,
212): ProviderInstanceEnvironmentVariable {
213 if (!variable.sensitive) {
214 const { valueRedacted: _omit, ...rest } = variable;
215 return rest;
216 }
217 return {
218 ...variable,
219 value: "",
220 ...(variable.value.length > 0 || variable.valueRedacted ? { valueRedacted: true } : {}),
221 };
222}
223
224147export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings {
225 const providerInstances = Object.fromEntries(
226 Object.entries(settings.providerInstances).map(([instanceId, instance]) => [
227 instanceId,
228 instance.environment
229 ? {
230 ...instance,
231 environment: instance.environment.map(redactProviderEnvironmentVariable),
232 }
233 : instance,
234 ]),
235 );
236 // The hub key is a bearer secret; clients only need to know one is set.
237 const usageLimitSources = Object.fromEntries(
238 Object.entries(settings.usageLimitSources).map(([id, source]) => [
239 id,
240 {
241 ...source,
242 managementKey: redactSecret(source.managementKey),
243 },
244 ]),
245 );
246 const sourceControlHosts = mapSlotValues(settings.sourceControlHosts, (_slot, value) =>
247 redactSecret(value),
148 return mapSettingsSecrets(settings, (secret) =>
149 secret.sensitive
150 ? { value: "", redacted: secret.redacted || secret.value.length > 0 }
151 : { value: secret.value, redacted: false },
248152 );
249 return { ...settings, providerInstances, usageLimitSources, sourceControlHosts };
250153}
251154
252155export function applyProviderInstanceMutation(
......@@ -781,35 +684,36 @@ const make = Effect.gen(function* () {
781684 );
782685
783686 /**
784 * Moves source control host secrets hand-edited into settings.json into the secret store as
785 * they load, so plaintext does not stay on disk. If the store is unavailable, the secret keeps
786 * working from the file and the move is retried on the next load.
687 * Migrate plaintext schema-declared credentials on load. Failed moves stay usable from
688 * the file and are retried next time; client redaction does not depend on migration.
787689 */
788 const moveInlineSourceControlHostSecrets = (settings: ServerSettings) =>
690 const moveInlineSettingsSecrets = (settings: ServerSettings) =>
789691 Effect.gen(function* () {
692 const pending: SettingsSecret[] = [];
693 mapSettingsSecrets(settings, (secret) => {
694 if (secret.kind === "field" && !secret.redacted && secret.value.length > 0) {
695 pending.push(secret);
696 }
697 return secret;
698 });
790699 const moved = new Set<string>();
791 for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) {
792 if (slot.value.length === 0 || slot.value === SECRET_REDACTED) continue;
700 for (const secret of pending) {
793701 const stored = yield* secretStore
794 .set(sourceControlHostSecretName(slot), textEncoder.encode(slot.value))
702 .set(secret.secretName, textEncoder.encode(secret.value))
795703 .pipe(
796704 Effect.as(true),
797705 Effect.catch(() =>
798 Effect.logWarning("failed to move a source control secret into the secret store", {
799 kind: slot.kind,
800 field: slot.field,
801 }).pipe(Effect.as(false)),
706 Effect.logWarning("failed to move a settings secret into the secret store").pipe(
707 Effect.as(false),
708 ),
802709 ),
803710 );
804 if (stored) moved.add(sourceControlHostSecretName(slot));
711 if (stored) moved.add(secret.secretName);
805712 }
806713 if (moved.size === 0) return settings;
807 return {
808 ...settings,
809 sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) =>
810 moved.has(sourceControlHostSecretName(slot)) ? SECRET_REDACTED : value,
811 ),
812 };
714 return mapSettingsSecrets(settings, (secret) =>
715 moved.has(secret.secretName) ? { value: "", redacted: true } : secret,
716 );
813717 });
814718
815719 const loadSettingsFromDisk = Effect.gen(function* () {
......@@ -934,7 +838,7 @@ const make = Effect.gen(function* () {
934838 : { settings: folded, secretMoves: [] };
935839 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.
936840 const migrated = settingsFileTrusted
937 ? yield* moveInlineSourceControlHostSecrets(sourceControlMigration.settings)
841 ? yield* moveInlineSettingsSecrets(sourceControlMigration.settings)
938842 : folded;
939843 // A file still carrying the retired `providers`, `bitbucket`, or `github` keys is rewritten
940844 // once so the migrated values persist and the old keys disappear.
......@@ -962,96 +866,49 @@ const make = Effect.gen(function* () {
962866
963867 const getSettingsFromCache = Cache.get(settingsCache, cacheKey);
964868
965 const materializeProviderEnvironmentSecrets = (
869 const materializeSettingsSecrets = (
966870 settings: ServerSettings,
967871 ): Effect.Effect<ServerSettings, ServerSettingsError> =>
968872 Effect.gen(function* () {
969 const providerInstances: Record<string, ProviderInstanceConfig> = {
970 ...settings.providerInstances,
971 };
972 for (const [instanceId, instance] of Object.entries(settings.providerInstances)) {
973 if (!instance.environment) continue;
974 const environment: ProviderInstanceEnvironmentVariable[] = [];
975 for (const variable of instance.environment) {
976 if (!variable.sensitive || !variable.valueRedacted) {
977 environment.push(variable);
978 continue;
979 }
980 const secret = yield* secretStore
981 .get(providerEnvironmentSecretName({ instanceId, name: variable.name }))
982 .pipe(
983 Effect.mapError(
984 (cause) =>
985 new ServerSettingsError({
986 settingsPath,
987 operation: "read-secret",
988 providerInstanceId: instanceId,
989 environmentVariable: variable.name,
990 cause,
991 }),
992 ),
993 );
994 environment.push({
995 ...variable,
996 value: Option.isSome(secret) ? textDecoder.decode(secret.value) : "",
997 });
998 }
999 providerInstances[instanceId] = {
1000 ...instance,
1001 environment,
1002 } satisfies ProviderInstanceConfig;
1003 }
1004 const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};
1005 for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) {
1006 if (source.managementKey !== SECRET_REDACTED) {
1007 usageLimitSources[sourceId] = source;
1008 continue;
1009 }
1010 const secret = yield* secretStore
1011 .get(usageLimitSourceSecretName(sourceId))
1012 .pipe(
1013 Effect.mapError(
1014 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),
1015 ),
1016 );
1017 usageLimitSources[sourceId] = {
1018 ...source,
1019 managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "",
1020 };
1021 }
1022873 const storedSecrets = new Map<string, string>();
1023 for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) {
1024 if (slot.value !== SECRET_REDACTED) continue;
1025 const secret = yield* secretStore
1026 .get(sourceControlHostSecretName(slot))
1027 .pipe(
1028 Effect.mapError(
1029 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),
1030 ),
1031 );
874 const pending = new Map<string, SettingsSecret>();
875 mapSettingsSecrets(settings, (secret) => {
876 if (secret.sensitive && secret.redacted) pending.set(secret.secretName, secret);
877 return secret;
878 });
879 for (const secret of pending.values()) {
880 const value = yield* secretStore.get(secret.secretName).pipe(
881 Effect.mapError(
882 (cause) =>
883 new ServerSettingsError({
884 settingsPath,
885 operation: "read-secret",
886 providerInstanceId: secret.providerInstanceId,
887 environmentVariable: secret.environmentVariable,
888 cause,
889 }),
890 ),
891 );
1032892 storedSecrets.set(
1033 sourceControlHostSecretName(slot),
1034 Option.isSome(secret) ? textDecoder.decode(secret.value) : "",
893 secret.secretName,
894 Option.isSome(value) ? textDecoder.decode(value.value) : "",
1035895 );
1036896 }
1037 return {
1038 ...settings,
1039 providerInstances: providerInstances as ServerSettings["providerInstances"],
1040 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
1041 sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) =>
1042 value === SECRET_REDACTED
1043 ? (storedSecrets.get(sourceControlHostSecretName(slot)) ?? "")
1044 : value,
1045 ),
1046 };
897 return mapSettingsSecrets(settings, (secret) => ({
898 value:
899 secret.sensitive && secret.redacted
900 ? (storedSecrets.get(secret.secretName) ?? "")
901 : secret.value,
902 redacted: secret.sensitive && secret.kind === "environment" && secret.redacted,
903 }));
1047904 });
1048905
1049906 const materializeChanges = (changes: Stream.Stream<ServerSettings>) =>
1050907 changes.pipe(
1051908 Stream.mapEffect((settings) =>
1052 materializeProviderEnvironmentSecrets(settings).pipe(
909 materializeSettingsSecrets(settings).pipe(
1053910 Effect.catch((error: ServerSettingsError) =>
1054 Effect.logWarning("failed to materialize provider environment secrets", {
911 Effect.logWarning("failed to materialize settings secrets", {
1055912 operation: error.operation,
1056913 providerInstanceId: error.providerInstanceId,
1057914 environmentVariable: error.environmentVariable,
......@@ -1072,162 +929,65 @@ const make = Effect.gen(function* () {
1072929 | { readonly kind: "remove"; readonly operation: "remove-secret" | "remove-stale-secret" }
1073930 );
1074931
1075 const persistProviderEnvironmentSecrets = (current: ServerSettings, next: ServerSettings) =>
932 const persistSettingsSecrets = (current: ServerSettings, next: ServerSettings) =>
1076933 Effect.sync(() => {
1077 const providerInstances: Record<string, ProviderInstanceConfig> = {
1078 ...next.providerInstances,
1079 };
934 const previous = new Map<string, SettingsSecret>();
935 mapSettingsSecrets(current, (secret) => {
936 // Duplicate environment names follow the provider's last-value-wins behavior.
937 previous.set(secret.secretName, secret);
938 return secret;
939 });
1080940 const changes: SecretChange[] = [];
1081
1082 const nextSecretKeys = new Set<string>();
1083 for (const [instanceId, instance] of Object.entries(next.providerInstances)) {
1084 if (!instance.environment) continue;
1085 const environment: ProviderInstanceEnvironmentVariable[] = [];
1086 for (const variable of instance.environment) {
1087 const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name });
1088 if (!variable.sensitive) {
1089 changes.push({
1090 kind: "remove",
1091 secretName,
1092 operation: "remove-secret",
1093 providerInstanceId: instanceId,
1094 environmentVariable: variable.name,
1095 });
1096 environment.push(redactProviderEnvironmentVariable(variable));
1097 continue;
1098 }
1099
1100 nextSecretKeys.add(secretName);
1101 // Match the provider environment's last-value-wins behavior for duplicate names.
1102 const previous = variable.valueRedacted
1103 ? current.providerInstances[ProviderInstanceId.make(instanceId)]?.environment?.findLast(
1104 (entry) => entry.name === variable.name,
1105 )
1106 : undefined;
1107 const inlineValue =
1108 previous?.sensitive && !previous.valueRedacted && previous.value.length > 0
1109 ? previous.value
1110 : undefined;
1111 const value = inlineValue ?? variable.value;
1112 if (!variable.valueRedacted || inlineValue !== undefined) {
1113 if (value.length > 0) {
1114 changes.push({
1115 kind: "write",
1116 secretName,
1117 value: textEncoder.encode(value),
1118 providerInstanceId: instanceId,
1119 environmentVariable: variable.name,
1120 });
1121 environment.push({ ...variable, value: "", valueRedacted: true });
1122 } else {
1123 changes.push({
1124 kind: "remove",
1125 secretName,
1126 operation: "remove-secret",
1127 providerInstanceId: instanceId,
1128 environmentVariable: variable.name,
1129 });
1130 const { valueRedacted: _omit, ...rest } = variable;
1131 environment.push(rest);
1132 }
1133 continue;
1134 }
1135
1136 environment.push(redactProviderEnvironmentVariable(variable));
1137 }
1138 providerInstances[instanceId] = {
1139 ...instance,
1140 environment,
1141 } satisfies ProviderInstanceConfig;
1142 }
1143
1144 for (const [instanceId, instance] of Object.entries(current.providerInstances)) {
1145 for (const variable of instance.environment ?? []) {
1146 if (!variable.sensitive) continue;
1147 const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name });
1148 if (nextSecretKeys.has(secretName)) continue;
1149 changes.push({
1150 kind: "remove",
1151 secretName,
1152 operation: "remove-stale-secret",
1153 providerInstanceId: instanceId,
1154 environmentVariable: variable.name,
1155 });
941 const nextSecretNames = new Set<string>();
942 const settings = mapSettingsSecrets(next, (secret) => {
943 const metadata = {
944 secretName: secret.secretName,
945 ...(secret.providerInstanceId !== undefined
946 ? { providerInstanceId: secret.providerInstanceId }
947 : {}),
948 ...(secret.environmentVariable !== undefined
949 ? { environmentVariable: secret.environmentVariable }
950 : {}),
951 };
952 if (!secret.sensitive) {
953 changes.push({ ...metadata, kind: "remove", operation: "remove-secret" });
954 return { value: secret.value, redacted: false };
1156955 }
1157 }
1158
1159 const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};
1160 for (const [sourceId, source] of Object.entries(next.usageLimitSources)) {
1161 const secretName = usageLimitSourceSecretName(sourceId);
1162 if (source.managementKey === SECRET_REDACTED) {
1163 usageLimitSources[sourceId] = source;
1164 continue;
956 const old = previous.get(secret.secretName);
957 const inlineValue =
958 old?.sensitive && !old.redacted && old.value.length > 0 ? old.value : undefined;
959 const value = secret.redacted ? inlineValue : secret.value;
960 if (value === undefined) {
961 nextSecretNames.add(secret.secretName);
962 return { value: "", redacted: true };
1165963 }
1166 if (source.managementKey.length === 0) {
1167 changes.push({ kind: "remove", secretName, operation: "remove-secret" });
1168 usageLimitSources[sourceId] = source;
1169 continue;
964 if (value.length === 0) {
965 changes.push({ ...metadata, kind: "remove", operation: "remove-secret" });
966 return { value: "", redacted: false };
1170967 }
1171 changes.push({
1172 kind: "write",
1173 secretName,
1174 value: textEncoder.encode(source.managementKey),
1175 });
1176 usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED };
1177 }
1178 for (const sourceId of Object.keys(current.usageLimitSources)) {
1179 if (sourceId in next.usageLimitSources) continue;
968 nextSecretNames.add(secret.secretName);
969 changes.push({ ...metadata, kind: "write", value: textEncoder.encode(value) });
970 return { value: "", redacted: true };
971 });
972 for (const secret of previous.values()) {
973 if (!secret.sensitive || nextSecretNames.has(secret.secretName)) continue;
974 if (changes.some((change) => change.secretName === secret.secretName)) continue;
1180975 changes.push({
1181976 kind: "remove",
1182 secretName: usageLimitSourceSecretName(sourceId),
977 secretName: secret.secretName,
1183978 operation: "remove-stale-secret",
979 ...(secret.providerInstanceId !== undefined
980 ? { providerInstanceId: secret.providerInstanceId }
981 : {}),
982 ...(secret.environmentVariable !== undefined
983 ? { environmentVariable: secret.environmentVariable }
984 : {}),
1184985 });
1185986 }
1186
1187 // Hand-edited plaintext not yet in the store, so the redaction marker can move it there.
1188 const inlineSecrets = new Map(
1189 sourceControlHostSecretSlots(current.sourceControlHosts).flatMap((slot) =>
1190 slot.value.length > 0 && slot.value !== SECRET_REDACTED
1191 ? [[sourceControlHostSecretName(slot), slot.value] as const]
1192 : [],
1193 ),
1194 );
1195 const nextSecretNames = new Set<string>();
1196 const sourceControlHosts = mapSlotValues(next.sourceControlHosts, (slot, raw) => {
1197 const secretName = sourceControlHostSecretName(slot);
1198 // The marker keeps what is saved; a hand-edited plaintext value moves into the store.
1199 const value = raw === SECRET_REDACTED ? inlineSecrets.get(secretName) : raw;
1200 if (value === undefined) {
1201 nextSecretNames.add(secretName);
1202 return SECRET_REDACTED;
1203 }
1204 if (value.length === 0) {
1205 changes.push({ kind: "remove", secretName, operation: "remove-secret" });
1206 return "";
1207 }
1208 nextSecretNames.add(secretName);
1209 changes.push({ kind: "write", secretName, value: textEncoder.encode(value) });
1210 return SECRET_REDACTED;
1211 });
1212 for (const slot of sourceControlHostSecretSlots(current.sourceControlHosts)) {
1213 const secretName = sourceControlHostSecretName(slot);
1214 if (nextSecretNames.has(secretName)) continue;
1215 if (changes.some((change) => change.secretName === secretName)) continue;
1216 changes.push({ kind: "remove", secretName, operation: "remove-stale-secret" });
1217 }
1218
1219 return {
1220 settings: {
1221 ...next,
1222 providerInstances: providerInstances as ServerSettings["providerInstances"],
1223 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
1224 sourceControlHosts,
1225 },
1226 changes,
1227 };
987 return { settings, changes };
1228988 });
1229989
1230 const rollbackProviderEnvironmentSecretWrites = (
990 const rollbackSettingsSecretWrites = (
1231991 writes: ReadonlyArray<{
1232992 readonly secretName: string;
1233993 readonly previousValue: Option.Option<Uint8Array>;
......@@ -1243,7 +1003,7 @@ const make = Effect.gen(function* () {
12431003 : secretStore.remove(write.secretName)
12441004 ).pipe(
12451005 Effect.catch((cause) =>
1246 Effect.logWarning("failed to roll back provider environment secret", {
1006 Effect.logWarning("failed to roll back settings secret", {
12471007 providerInstanceId: write.providerInstanceId,
12481008 environmentVariable: write.environmentVariable,
12491009 cause,
......@@ -1253,14 +1013,14 @@ const make = Effect.gen(function* () {
12531013 { discard: true },
12541014 );
12551015
1256 const applyProviderEnvironmentSecretChanges = (changes: ReadonlyArray<SecretChange>) => {
1016 const applySettingsSecretChanges = (changes: ReadonlyArray<SecretChange>) => {
12571017 const applied: Array<{
12581018 readonly secretName: string;
12591019 readonly previousValue: Option.Option<Uint8Array>;
12601020 readonly providerInstanceId?: string;
12611021 readonly environmentVariable?: string;
12621022 }> = [];
1263 const rollback = Effect.suspend(() => rollbackProviderEnvironmentSecretWrites(applied));
1023 const rollback = Effect.suspend(() => rollbackSettingsSecretWrites(applied));
12641024 return Effect.forEach(
12651025 changes,
12661026 (change) =>
......@@ -1310,16 +1070,12 @@ const make = Effect.gen(function* () {
13101070 Effect.gen(function* () {
13111071 const current = yield* getSettingsFromCache;
13121072 const updated = yield* update(current);
1313 const persisted = yield* persistProviderEnvironmentSecrets(current, updated);
1073 const persisted = yield* persistSettingsSecrets(current, updated);
13141074 const next = yield* normalizeServerSettings(persisted.settings);
13151075 const materialized = yield* Effect.uninterruptibleMask(() =>
13161076 Effect.gen(function* () {
1317 const rollbackSecretChanges = yield* applyProviderEnvironmentSecretChanges(
1318 persisted.changes,
1319 );
1320 const materializedExit = yield* Effect.exit(
1321 materializeProviderEnvironmentSecrets(next),
1322 );
1077 const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes);
1078 const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next));
13231079 if (Exit.isFailure(materializedExit)) {
13241080 yield* rollbackSecretChanges;
13251081 return yield* Effect.failCause(materializedExit.cause);
......@@ -1341,7 +1097,7 @@ const make = Effect.gen(function* () {
13411097 const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) =>
13421098 writeSemaphore.withPermits(1)(
13431099 getSettingsFromCache.pipe(
1344 Effect.flatMap(materializeProviderEnvironmentSecrets),
1100 Effect.flatMap(materializeSettingsSecrets),
13451101 Effect.map(resolveTextGenerationProvider),
13461102 Effect.flatMap(use),
13471103 ),
......@@ -1457,7 +1213,7 @@ const make = Effect.gen(function* () {
14571213 start,
14581214 ready: Deferred.await(startedDeferred),
14591215 getSettings: getSettingsFromCache.pipe(
1460 Effect.flatMap(materializeProviderEnvironmentSecrets),
1216 Effect.flatMap(materializeSettingsSecrets),
14611217 Effect.map(resolveTextGenerationProvider),
14621218 ),
14631219 updateSettings: (patch) =>
apps/server/src/settingsSecrets.ts created+150
......@@ -0,0 +1,150 @@
1import {
2 AntigravitySettings,
3 ClaudeSettings,
4 CodexSettings,
5 type ServerSettings,
6 UsageLimitSourceConfig,
7 secretSettingsFields,
8} from "@t3tools/contracts";
9import { acpRegistryClient } from "@t3tools/provider-acp-registry/client";
10import { cursorClient } from "@t3tools/provider-cursor/client";
11import { grokClient } from "@t3tools/provider-grok/client";
12import { museClient } from "@t3tools/provider-muse/client";
13import { openCodeClient } from "@t3tools/provider-opencode/client";
14import { piClient } from "@t3tools/provider-pi/client";
15import {
16 readSecretFieldValues,
17 SOURCE_CONTROL_HOST_SECRET_FIELDS,
18 sourceControlHostSecretName,
19 writeSecretFieldValues,
20} from "./sourceControl/sourceControlHostSecrets.ts";
21
22export const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022";
23
24const providerSecretFields = new Map([
25 ["codex", secretSettingsFields(CodexSettings)],
26 ["claudeAgent", secretSettingsFields(ClaudeSettings)],
27 ["antigravity", secretSettingsFields(AntigravitySettings)],
28 ...[cursorClient, grokClient, museClient, openCodeClient, piClient, acpRegistryClient].map(
29 (definition) =>
30 [definition.driverKind, secretSettingsFields(definition.settingsSchema)] as const,
31 ),
32]);
33const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig);
34const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url");
35
36export interface SettingsSecret {
37 readonly secretName: string;
38 readonly kind: "field" | "environment";
39 readonly value: string;
40 readonly redacted: boolean;
41 readonly sensitive: boolean;
42 readonly providerInstanceId?: string;
43 readonly environmentVariable?: string;
44}
45
46type SecretValue = Pick<SettingsSecret, "value" | "redacted">;
47
48/**
49 * Visits credential fields declared by schemas and dynamically sensitive environment variables.
50 * Store keys and client representations stay here; callers supply only the secret operation.
51 */
52export function mapSettingsSecrets(
53 settings: ServerSettings,
54 map: (secret: SettingsSecret) => SecretValue,
55): ServerSettings {
56 const mapFields = (
57 fields: Readonly<Record<string, unknown>>,
58 secretFields: ReadonlyArray<string>,
59 name: (field: string, serverHost: string | null) => string,
60 ) => {
61 const next = { ...fields };
62 for (const field of secretFields) {
63 if (!(field in next)) continue;
64 next[field] = writeSecretFieldValues(
65 next[field],
66 readSecretFieldValues(next[field]).map(([serverHost, value]) => {
67 const result = map({
68 secretName: name(field, serverHost),
69 kind: "field",
70 value,
71 redacted: value === SECRET_REDACTED,
72 sensitive: true,
73 });
74 return [serverHost, result.redacted ? SECRET_REDACTED : result.value] as const;
75 }),
76 );
77 }
78 return next;
79 };
80
81 const providerInstances = Object.fromEntries(
82 Object.entries(settings.providerInstances).map(([instanceId, instance]) => {
83 const config = instance.config;
84 return [
85 instanceId,
86 {
87 ...instance,
88 ...(config !== null && typeof config === "object" && !Array.isArray(config)
89 ? {
90 config: mapFields(
91 config as Record<string, unknown>,
92 providerSecretFields.get(instance.driver) ?? [],
93 (field, serverHost) =>
94 `provider-config-${encode(instanceId)}-${encode(instance.driver)}-${encode(field)}` +
95 (serverHost === null ? "" : `-${encode(serverHost)}`),
96 ),
97 }
98 : {}),
99 ...(instance.environment
100 ? {
101 environment: instance.environment.map((variable) => {
102 const result = map({
103 secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`,
104 kind: "environment",
105 value: variable.value,
106 redacted: variable.valueRedacted === true,
107 sensitive: variable.sensitive,
108 providerInstanceId: instanceId,
109 environmentVariable: variable.name,
110 });
111 const { valueRedacted: _omit, ...rest } = variable;
112 return {
113 ...rest,
114 value: result.value,
115 ...(result.redacted ? { valueRedacted: true } : {}),
116 };
117 }),
118 }
119 : {}),
120 },
121 ];
122 }),
123 );
124
125 const sourceControlHosts = Object.fromEntries(
126 Object.entries(settings.sourceControlHosts).map(([kind, fields]) => [
127 kind,
128 mapFields(fields, SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? [], (field, serverHost) =>
129 sourceControlHostSecretName({ kind, field, serverHost }),
130 ),
131 ]),
132 ) as ServerSettings["sourceControlHosts"];
133
134 const usageLimitSources = Object.fromEntries(
135 Object.entries(settings.usageLimitSources).map(([id, source]) => [
136 id,
137 mapFields(
138 source,
139 usageLimitSecretFields,
140 (field, serverHost) =>
141 `usage-limit-source-${encode(id)}` +
142 (field === "managementKey" && serverHost === null
143 ? ""
144 : `-${encode(field)}${serverHost === null ? "" : `-${encode(serverHost)}`}`),
145 ),
146 ]),
147 ) as ServerSettings["usageLimitSources"];
148
149 return { ...settings, providerInstances, sourceControlHosts, usageLimitSources };
150}
apps/server/src/sourceControl/sourceControlHostSecrets.ts+3-7
......@@ -7,12 +7,10 @@
77 *
88 * @module sourceControl/sourceControlHostSecrets
99 */
10import { secretSettingsFields } from "@t3tools/contracts";
1011import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition";
1112import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition";
12import {
13 secretSourceControlHostSettingsFields,
14 type SourceControlClientDefinition,
15} from "@t3tools/source-control-core/client/definition";
13import type { SourceControlClientDefinition } from "@t3tools/source-control-core/client/definition";
1614import * as Forgejo from "@t3tools/source-control-forgejo/client/definition";
1715import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition";
1816import * as GitHub from "@t3tools/source-control-github/client/definition";
......@@ -33,9 +31,7 @@ export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap<
3331 ReadonlyArray<string>
3432> = new Map(
3533 definitions.flatMap((definition) => {
36 const fields = definition.settings
37 ? secretSourceControlHostSettingsFields(definition.settings)
38 : [];
34 const fields = definition.settings ? secretSettingsFields(definition.settings) : [];
3935 return fields.length > 0 ? [[definition.kind, fields] as const] : [];
4036 }),
4137);
apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx+2-3
......@@ -13,6 +13,7 @@ import {
1313 DialogTitle,
1414} from "../ui/dialog";
1515import { Input } from "../ui/input";
16import { SecretInput } from "./SecretInput";
1617import { Label } from "../ui/label";
1718
1819/**
......@@ -118,10 +119,8 @@ export function AddUsageLimitSourceDialog({
118119 </div>
119120 <div className="grid gap-1.5">
120121 <Label htmlFor="usage-source-key">Management key</Label>
121 <Input
122 <SecretInput
122123 id="usage-source-key"
123 type="password"
124 autoComplete="off"
125124 value={managementKey}
126125 onChange={(event) => setManagementKey(event.target.value)}
127126 />
apps/web/src/components/settings/GitHubTokenSettings.tsx+3-4
......@@ -8,6 +8,7 @@ import { serverEnvironment } from "../../state/server";
88import { useAtomCommand } from "../../state/use-atom-command";
99import { Button, InlineButton } from "../ui/button";
1010import { Input } from "../ui/input";
11import { SecretInput } from "./SecretInput";
1112import { Label } from "../ui/label";
1213
1314const DEFAULT_HOST = "github.com";
......@@ -103,12 +104,10 @@ export function GitHubTokenSettings({
103104 </div>
104105 <div className="grid gap-1.5">
105106 <Label htmlFor={`github-token-${environmentId}`}>Token</Label>
106 <Input
107 <SecretInput
107108 id={`github-token-${environmentId}`}
108 type="password"
109 autoComplete="off"
110109 size="sm"
111 placeholder={isSaved ? "Stored secret, enter a new value to replace" : "Not set"}
110 configured={isSaved}
112111 value={draft}
113112 onChange={(event) => setDraft(event.target.value)}
114113 />
apps/web/src/components/settings/ProviderInstanceCard.tsx+49-28
......@@ -44,6 +44,7 @@ import { Badge } from "../ui/badge";
4444import { Button } from "../ui/button";
4545import { MorphIcon } from "~/components/MorphIcon";
4646import { DraftInput } from "../ui/draft-input";
47import { SecretInput } from "./SecretInput";
4748import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
4849import { Switch } from "../ui/switch";
4950import { stackedThreadToast, toastManager } from "../ui/toast";
......@@ -263,9 +264,6 @@ function ProviderEnvironmentFieldRow(props: {
263264}) {
264265 const inputId = `${props.idPrefix}-environment-${props.field.name}`;
265266 const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? "");
266 const placeholder = props.variable?.valueRedacted
267 ? "Stored secret - enter a new value to replace"
268 : props.field.placeholder;
269267
270268 return (
271269 <SettingsRow
......@@ -273,17 +271,29 @@ function ProviderEnvironmentFieldRow(props: {
273271 description={props.field.description}
274272 control={
275273 <div className="flex w-full min-w-0 items-center gap-2 @min-[32rem]/settings-row:w-56">
276 <DraftInput
277 id={inputId}
278 size="sm"
279 className="min-w-0 flex-1"
280 type={props.field.sensitive === false ? undefined : "password"}
281 autoComplete="off"
282 value={value}
283 onCommit={(next) => props.onCommit(props.field, next)}
284 placeholder={placeholder}
285 spellCheck={false}
286 />
274 {props.field.sensitive === false ? (
275 <DraftInput
276 id={inputId}
277 size="sm"
278 className="min-w-0 flex-1"
279 autoComplete="off"
280 value={value}
281 onCommit={(next) => props.onCommit(props.field, next)}
282 placeholder={props.field.placeholder}
283 spellCheck={false}
284 />
285 ) : (
286 <SecretInput
287 id={inputId}
288 size="sm"
289 className="min-w-0 flex-1"
290 value={value}
291 onCommit={(next) => props.onCommit(props.field, next)}
292 placeholder={props.field.placeholder}
293 configured={props.variable?.valueRedacted === true}
294 spellCheck={false}
295 />
296 )}
287297 {props.variable ? (
288298 <Button
289299 type="button"
......@@ -410,20 +420,31 @@ export function ProviderEnvironmentSection(props: {
410420 <span className="hidden text-xs text-muted-foreground sm:inline" aria-hidden>
411421 =
412422 </span>
413 <DraftInput
414 size="sm"
415 font="mono"
416 className="min-w-0 flex-1"
417 value={variable.valueRedacted ? "" : variable.value}
418 onCommit={(value) => updateVariable(variable.id, { value })}
419 type={variable.sensitive ? "password" : undefined}
420 autoComplete="off"
421 placeholder={
422 variable.valueRedacted ? "Stored secret, enter a new value to replace" : "value"
423 }
424 spellCheck={false}
425 aria-label={`Environment variable value ${index + 1}`}
426 />
423 {variable.sensitive ? (
424 <SecretInput
425 size="sm"
426 font="mono"
427 className="min-w-0 flex-1"
428 value={variable.valueRedacted ? "" : variable.value}
429 onCommit={(value) => updateVariable(variable.id, { value })}
430 placeholder="value"
431 configured={variable.valueRedacted === true}
432 spellCheck={false}
433 aria-label={`Environment variable value ${index + 1}`}
434 />
435 ) : (
436 <DraftInput
437 size="sm"
438 font="mono"
439 className="min-w-0 flex-1"
440 value={variable.valueRedacted ? "" : variable.value}
441 onCommit={(value) => updateVariable(variable.id, { value })}
442 autoComplete="off"
443 placeholder="value"
444 spellCheck={false}
445 aria-label={`Environment variable value ${index + 1}`}
446 />
447 )}
427448 <Tooltip>
428449 <TooltipTrigger
429450 render={
apps/web/src/components/settings/ProviderSettingsForm.tsx+28-28
......@@ -3,11 +3,12 @@
33import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
44import * as Option from "effect/Option";
55import * as Schema from "effect/Schema";
6import type {
7 ProviderSettingsFormAnnotation,
8 ProviderSettingsFormControl,
9 ProviderSettingsFormOption,
10 ProviderSettingsFormSchemaAnnotation,
6import {
7 isSecretSetting,
8 type ProviderSettingsFormAnnotation,
9 type ProviderSettingsFormControl,
10 type ProviderSettingsFormOption,
11 type ProviderSettingsFormSchemaAnnotation,
1112} from "@t3tools/contracts";
1213import { PlusIcon, XIcon } from "lucide-react";
1314
......@@ -15,6 +16,7 @@ import { cn } from "../../lib/utils";
1516import { Button } from "../ui/button";
1617import { DraftInput } from "../ui/draft-input";
1718import { Input } from "../ui/input";
19import { SecretInput } from "./SecretInput";
1820import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
1921import { Switch } from "../ui/switch";
2022import { Textarea } from "../ui/textarea";
......@@ -131,7 +133,7 @@ export function deriveProviderSettingsFields(
131133 ? { placeholder: formAnnotation.placeholder }
132134 : {}),
133135 clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit",
134 ...(formAnnotation.secret ? { secret: true } : {}),
136 ...(isSecretSetting(fieldSchema) ? { secret: true } : {}),
135137 ...(formAnnotation.control === "switch"
136138 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }
137139 : {}),
......@@ -255,19 +257,6 @@ function readProviderConfigString(config: unknown, key: string): string {
255257 return typeof value === "string" ? value : "";
256258}
257259
258/**
259 * A secret field never shows its saved value: it starts empty, and its placeholder says whether
260 * one is saved. Saving one is typing a new value; clearing it is the host form's job.
261 */
262function secretFieldInputProps(field: ProviderSettingsFieldModel, config: unknown) {
263 const saved = readProviderConfigString(config, field.key);
264 if (!field.secret) return { value: saved, placeholder: field.placeholder };
265 return {
266 value: "",
267 placeholder: saved.length > 0 ? "Stored secret, enter a new value to replace" : "Not set",
268 };
269}
270
271260function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean {
272261 if (config === null || typeof config !== "object") return defaultValue;
273262 const value = (config as Record<string, unknown>)[key];
......@@ -423,6 +412,16 @@ function ProviderSettingsFieldRow({
423412 placeholder={field.placeholder}
424413 spellCheck={false}
425414 />
415 ) : field.secret ? (
416 <SecretInput
417 id={inputId}
418 aria-describedby={descriptionId}
419 size="sm"
420 className="w-full max-w-full @min-[32rem]/settings-row:w-56"
421 configured={readProviderConfigString(value, field.key).length > 0}
422 value=""
423 onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))}
424 />
426425 ) : (
427426 <DraftInput
428427 id={inputId}
......@@ -431,10 +430,9 @@ function ProviderSettingsFieldRow({
431430 className="w-full max-w-full @min-[32rem]/settings-row:w-56"
432431 type={field.control === "password" ? "password" : undefined}
433432 autoComplete={field.control === "password" ? "off" : undefined}
434 {...secretFieldInputProps(field, value)}
433 value={readProviderConfigString(value, field.key)}
434 placeholder={field.placeholder}
435435 onCommit={(next) => {
436 // An untouched saved secret commits empty; only a typed value replaces it.
437 if (field.secret && next === "") return;
438436 onChange(nextProviderConfigWithFieldValue(value, field, next));
439437 }}
440438 spellCheck={false}
......@@ -515,27 +513,29 @@ function ProviderSettingsFieldRow({
515513 }
516514
517515 const type = field.control === "password" ? "password" : undefined;
516 const BufferedInput = field.secret ? SecretInput : DraftInput;
517 const ControlledInput = field.secret ? SecretInput : Input;
518518 return (
519519 <FieldFrame variant={variant}>
520520 <label htmlFor={inputId} className={cn(variant === "card" && "block")}>
521521 {label}
522522 {variant === "card" ? (
523 <DraftInput
523 <BufferedInput
524524 id={inputId}
525525 size="sm"
526526 className="mt-1.5"
527 type={type}
528 autoComplete={field.control === "password" ? "off" : undefined}
527 type={field.secret ? undefined : type}
528 autoComplete={!field.secret && field.control === "password" ? "off" : undefined}
529529 value={readProviderConfigString(value, field.key)}
530530 onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))}
531531 placeholder={field.placeholder}
532532 spellCheck={false}
533533 />
534534 ) : (
535 <Input
535 <ControlledInput
536536 id={inputId}
537 type={type}
538 autoComplete={field.control === "password" ? "off" : undefined}
537 type={field.secret ? undefined : type}
538 autoComplete={!field.secret && field.control === "password" ? "off" : undefined}
539539 value={readProviderConfigString(value, field.key)}
540540 onChange={(event) =>
541541 onChange(nextProviderConfigWithFieldValue(value, field, event.target.value))
apps/web/src/components/settings/SecretInput.test.tsx created+63
......@@ -0,0 +1,63 @@
1import { act } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { SecretInput } from "./SecretInput";
6
7vi.mock("../ui/input", () => ({
8 Input: (props: Record<string, unknown>) => <input {...props} />,
9}));
10
11let renderer: ReactTestRenderer;
12
13beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true));
14afterEach(async () => {
15 await act(() => renderer?.unmount());
16 vi.unstubAllGlobals();
17});
18
19async function edit(value: string) {
20 await act(() => renderer.root.findByType("input").props.onFocus());
21 await act(() => renderer.root.findByType("input").props.onChange({ target: { value } }));
22 await act(() => renderer.root.findByType("input").props.onBlur());
23}
24
25describe("SecretInput", () => {
26 it("preserves a configured secret on empty edits and commits a replacement", async () => {
27 const onCommit = vi.fn();
28 await act(() => {
29 renderer = create(<SecretInput configured value="" onCommit={onCommit} />);
30 });
31 await edit("");
32 expect(onCommit).not.toHaveBeenCalled();
33 await edit("replacement");
34 expect(onCommit).toHaveBeenCalledExactlyOnceWith("replacement");
35 expect(renderer.root.findByType("input").props.value).toBe("");
36 });
37
38 it("allows clearing an unsaved draft", async () => {
39 const onCommit = vi.fn();
40 await act(() => {
41 renderer = create(<SecretInput value="draft" onCommit={onCommit} />);
42 });
43 await edit("");
44 expect(onCommit).toHaveBeenCalledExactlyOnceWith("");
45 });
46
47 it("keeps an in-progress replacement when settings refresh", async () => {
48 const onCommit = vi.fn();
49 await act(() => {
50 renderer = create(<SecretInput configured value="" onCommit={onCommit} />);
51 });
52 await act(() => renderer.root.findByType("input").props.onFocus());
53 await act(() =>
54 renderer.root.findByType("input").props.onChange({ target: { value: "replacement" } }),
55 );
56 await act(() => {
57 renderer.update(<SecretInput configured value="" onCommit={onCommit} />);
58 });
59 expect(renderer.root.findByType("input").props.value).toBe("replacement");
60 await act(() => renderer.root.findByType("input").props.onBlur());
61 expect(onCommit).toHaveBeenCalledExactlyOnceWith("replacement");
62 });
63});
apps/web/src/components/settings/SecretInput.tsx created+39
......@@ -0,0 +1,39 @@
1import { DraftInput, type DraftInputProps } from "../ui/draft-input";
2import { Input, type InputProps } from "../ui/input";
3
4type SecretInputProps = Omit<InputProps, "type" | "autoComplete" | "defaultValue" | "value"> & {
5 /** Whether the server already holds a value. Pass only the replacement draft as `value`. */
6 readonly configured?: boolean;
7 readonly value: string;
8 readonly onCommit?: DraftInputProps["onCommit"];
9};
10
11/** Shared presentation for write-only settings. Clearing a saved value belongs to its form. */
12export function SecretInput({
13 configured = false,
14 placeholder,
15 onCommit,
16 ...props
17}: SecretInputProps) {
18 const shared = {
19 ...props,
20 type: "password" as const,
21 autoComplete: "off",
22 spellCheck: false,
23 placeholder: configured
24 ? "Stored secret, enter a new value to replace"
25 : (placeholder ?? "Not set"),
26 };
27 if (onCommit) {
28 return (
29 <DraftInput
30 {...shared}
31 onCommit={(next) => {
32 if (configured && next === "") return;
33 onCommit(next);
34 }}
35 />
36 );
37 }
38 return <Input {...shared} />;
39}
packages/contracts/src/settings.ts+24-7
......@@ -551,15 +551,19 @@ export interface ProviderSettingsFormAnnotation {
551551 readonly placeholder?: string | undefined;
552552 readonly hidden?: boolean | undefined;
553553 readonly clearWhenEmpty?: "omit" | "persist" | undefined;
554 /**
555 * The value is a credential: the server keeps it in its secret store and clients only learn
556 * whether one is saved. Source control host settings honor it; provider settings do not yet.
557 */
558 readonly secret?: boolean | undefined;
559554 /** Choices for a `select` control. The first entry is the default. */
560555 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
561556}
562557
558/** Secret fields declared by a settings schema, shared by persistence and client redaction. */
559export function secretSettingsFields(schema: {
560 readonly fields: Readonly<Record<string, Schema.Top>>;
561}): ReadonlyArray<string> {
562 return Object.entries(schema.fields).flatMap(([field, fieldSchema]) => {
563 return isSecretSetting(fieldSchema) ? [field] : [];
564 });
565}
566
563567export interface ProviderSettingsFormSchemaAnnotation {
564568 readonly order?: readonly string[] | undefined;
565569}
......@@ -567,12 +571,24 @@ export interface ProviderSettingsFormSchemaAnnotation {
567571declare module "effect/Schema" {
568572 namespace Annotations {
569573 interface Annotations {
574 /** Store this credential separately and expose only whether it is configured to clients. */
575 readonly settingsSecret?: boolean | undefined;
570576 readonly providerSettingsForm?: ProviderSettingsFormAnnotation | undefined;
571577 readonly providerSettingsFormSchema?: ProviderSettingsFormSchemaAnnotation | undefined;
572578 }
573579 }
574580}
575581
582/** Mark a string or a record of strings as a credential in its settings schema. */
583export function secretSetting<S extends Schema.Top>(schema: S): S["Rebuild"] {
584 return schema.annotateKey({ settingsSecret: true });
585}
586
587export function isSecretSetting(schema: Schema.Top): boolean {
588 const annotations = Schema.resolveAnnotationsKey(schema) ?? Schema.resolveAnnotations(schema);
589 return annotations?.settingsSecret === true;
590}
591
576592export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract<
577593 keyof Fields,
578594 string
......@@ -749,10 +765,11 @@ export const AntigravitySettings = makeProviderSettingsSchema(
749765 }),
750766 ),
751767 apiKey: TrimmedString.pipe(
768 secretSetting,
752769 Schema.withDecodingDefault(Effect.succeed("")),
753770 Schema.annotateKey({
754771 title: "API key",
755 description: "Gemini or Vertex AI express key. Stored in plain text.",
772 description: "Gemini or Vertex AI express key. Stored separately on the host.",
756773 providerSettingsForm: {
757774 control: "password",
758775 placeholder: "Optional",
......@@ -804,7 +821,7 @@ export const UsageLimitSourceConfig = Schema.Struct({
804821 kind: Schema.Literal("cliproxy"),
805822 label: Schema.optional(TrimmedNonEmptyString),
806823 url: TrimmedNonEmptyString,
807 managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
824 managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), secretSetting),
808825 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
809826});
810827export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;
packages/source-control-bitbucket/src/client/definition.ts+4-2
......@@ -39,7 +39,8 @@ export const settings = makeProviderSettingsSchema(
3939 title: "Access token",
4040 description:
4141 "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.",
42 providerSettingsForm: { control: "password", secret: true },
42 settingsSecret: true,
43 providerSettingsForm: { control: "password" },
4344 }),
4445 email: text({
4546 title: "Atlassian account email",
......@@ -50,7 +51,8 @@ export const settings = makeProviderSettingsSchema(
5051 title: "API token",
5152 description:
5253 "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.",
53 providerSettingsForm: { control: "password", secret: true },
54 settingsSecret: true,
55 providerSettingsForm: { control: "password" },
5456 }),
5557 },
5658 { order: ["accessToken", "email", "apiToken"] },
packages/source-control-core/src/client/definition.ts+1-15
......@@ -144,7 +144,7 @@ export interface SourceControlClientDefinition {
144144 /**
145145 * What the environment saves for this host under `settings.sourceControlHosts[kind]`, rendered
146146 * as a form in Source Control settings and read by the host's server package. Fields carry
147 * `providerSettingsForm` annotations, as agent provider settings do; one marked `secret` is
147 * `providerSettingsForm` annotations, as agent provider settings do; a `secretSetting` is
148148 * kept in the server's secret store. Omitted for a host with nothing to configure.
149149 */
150150 readonly settings?: SourceControlHostSettingsSchema;
......@@ -322,17 +322,3 @@ export function readSourceControlHostSettings<S extends SourceControlHostSetting
322322 if (fallback._tag === "Some") return fallback.value;
323323 throw new Error("A source control host settings schema must decode an empty object.");
324324}
325
326/**
327 * The settings fields a host keeps in the server's secret store: those annotated
328 * `providerSettingsForm.secret`. A secret field holds a string, or a string per server host.
329 */
330export function secretSourceControlHostSettingsFields(
331 schema: SourceControlHostSettingsSchema,
332): ReadonlyArray<string> {
333 return Object.entries(schema.fields).flatMap(([field, fieldSchema]) => {
334 const annotations =
335 Schema.resolveAnnotationsKey(fieldSchema) ?? Schema.resolveAnnotations(fieldSchema);
336 return annotations?.providerSettingsForm?.secret ? [field] : [];
337 });
338}
packages/source-control-gitcafe/src/client/definition.ts+3-1
......@@ -5,6 +5,7 @@
55 */
66import {
77 makeProviderSettingsSchema,
8 secretSetting,
89 SourceControlProviderKind,
910 TrimmedString,
1011} from "@t3tools/contracts";
......@@ -25,12 +26,13 @@ const KIND = SourceControlProviderKind.make("gitcafe");
2526/** A git.cafe token, used before `CAFE_TOKEN` and the `cafe` login. */
2627export const settings = makeProviderSettingsSchema({
2728 token: TrimmedString.pipe(
29 secretSetting,
2830 Schema.withDecodingDefault(Effect.succeed("")),
2931 Schema.annotateKey({
3032 title: "Token",
3133 description:
3234 "A git.cafe token, used before CAFE_TOKEN and the cafe login, so GitCafe works without the CLI signed in.",
33 providerSettingsForm: { control: "password", secret: true },
35 providerSettingsForm: { control: "password" },
3436 }),
3537 ),
3638});
packages/source-control-github/src/client/definition.ts+3-1
......@@ -6,6 +6,7 @@
66import {
77 GitHubHost,
88 makeProviderSettingsSchema,
9 secretSetting,
910 pullRequestHostOf,
1011 SourceControlProviderKind,
1112 TrimmedNonEmptyString,
......@@ -45,7 +46,8 @@ export const settings = makeProviderSettingsSchema({
4546 ),
4647 tokens: Schema.Record(GitHubHost, TrimmedString).pipe(
4748 Schema.withDecodingDefault(Effect.succeed({})),
48 Schema.annotateKey({ providerSettingsForm: { hidden: true, secret: true } }),
49 secretSetting,
50 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
4951 ),
5052});
5153export type GitHubSettings = typeof settings.Type;