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 {...@@ -9,6 +9,7 @@ import {
9 resolveProviderInstanceEnabled,9 resolveProviderInstanceEnabled,
10 ServerSettings,10 ServerSettings,
11 ServerSettingsPatch,11 ServerSettingsPatch,
12 UsageLimitSourceId,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
13import { createModelSelection } from "@t3tools/shared/model";14import { createModelSelection } from "@t3tools/shared/model";
14import { assert, it } from "@effect/vitest";15import { assert, it } from "@effect/vitest";
...@@ -1440,6 +1441,156 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1440,6 +1441,156 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1440 }).pipe(Effect.provide(layerServerSettings())),1441 }).pipe(Effect.provide(layerServerSettings())),
1441 );1442 );
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
1443 it.effect("stores sensitive provider instance environment values outside settings.json", () =>1594 it.effect("stores sensitive provider instance environment values outside settings.json", () =>
1444 Effect.gen(function* () {1595 Effect.gen(function* () {
1445 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;1596 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
...@@ -1714,7 +1865,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1714,7 +1865,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1714 ? Effect.fail(writeFailure)1865 ? Effect.fail(writeFailure)
1715 : fileSystem.rename(fromPath, toPath),1866 : fileSystem.rename(fromPath, toPath),
1716 });1867 });
1717 const instanceId = ProviderInstanceId.make("codex_write_failure");1868 const instanceId = ProviderInstanceId.make("antigravity_write_failure");
1718 const layerSettings = layerServerSettings().pipe(1869 const layerSettings = layerServerSettings().pipe(
1719 Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, failingFileSystem)),1870 Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, failingFileSystem)),
1720 );1871 );
...@@ -1726,9 +1877,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1726,9 +1877,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1726 operation: "upsert",1877 operation: "upsert",
1727 instanceId,1878 instanceId,
1728 instance: {1879 instance: {
1729 driver: ProviderDriverKind.make("codex"),1880 driver: ProviderDriverKind.make("antigravity"),
1730 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }],1881 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }],
1731 config: {},1882 config: { apiKey: "config-kept" },
1732 },1883 },
1733 });1884 });
17341885
...@@ -1738,9 +1889,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1738,9 +1889,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1738 operation: "upsert",1889 operation: "upsert",
1739 instanceId,1890 instanceId,
1740 instance: {1891 instance: {
1741 driver: ProviderDriverKind.make("codex"),1892 driver: ProviderDriverKind.make("antigravity"),
1742 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }],1893 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }],
1743 config: {},1894 config: { apiKey: "config-new" },
1744 },1895 },
1745 })1896 })
1746 .pipe(Effect.result);1897 .pipe(Effect.result);
...@@ -1750,6 +1901,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1750,6 +1901,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1750 ?.value,1901 ?.value,
1751 "sk-kept",1902 "sk-kept",
1752 );1903 );
1904 assert.propertyVal(
1905 (yield* serverSettings.getSettings).providerInstances[instanceId]?.config,
1906 "apiKey",
1907 "config-kept",
1908 );
17531909
1754 const failed = yield* serverSettings1910 const failed = yield* serverSettings
1755 .updateProviderInstance({ operation: "remove", instanceId })1911 .updateProviderInstance({ operation: "remove", instanceId })
...@@ -1760,6 +1916,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1760,6 +1916,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1760 ?.value,1916 ?.value,
1761 "sk-kept",1917 "sk-kept",
1762 );1918 );
1919 assert.propertyVal(
1920 (yield* serverSettings.getSettings).providerInstances[instanceId]?.config,
1921 "apiKey",
1922 "config-kept",
1923 );
1763 }).pipe(Effect.provide(layerSettings));1924 }).pipe(Effect.provide(layerSettings));
1764 }),1925 }),
1765 );1926 );
...@@ -1841,9 +2002,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1841,9 +2002,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1841 yield* serverSettings.updateSettings({2002 yield* serverSettings.updateSettings({
1842 providerInstances: {2003 providerInstances: {
1843 [instanceId]: {2004 [instanceId]: {
1844 driver: ProviderDriverKind.make("codex"),2005 driver: ProviderDriverKind.make("antigravity"),
1845 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }],2006 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-kept", sensitive: true }],
1846 config: {},2007 config: { apiKey: "config-kept" },
1847 },2008 },
1848 },2009 },
1849 });2010 });
...@@ -1853,9 +2014,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1853,9 +2014,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1853 .updateSettings({2014 .updateSettings({
1854 providerInstances: {2015 providerInstances: {
1855 [instanceId]: {2016 [instanceId]: {
1856 driver: ProviderDriverKind.make("codex"),2017 driver: ProviderDriverKind.make("antigravity"),
1857 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }],2018 environment: [{ name: "OPENROUTER_API_KEY", value: "sk-new", sensitive: true }],
1858 config: {},2019 config: { apiKey: "config-new" },
1859 },2020 },
1860 },2021 },
1861 })2022 })
...@@ -1868,6 +2029,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1868,6 +2029,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1868 ?.value,2029 ?.value,
1869 "sk-kept",2030 "sk-kept",
1870 );2031 );
2032 assert.propertyVal(
2033 (yield* serverSettings.getSettings).providerInstances[instanceId]?.config,
2034 "apiKey",
2035 "config-kept",
2036 );
1871 }).pipe(Effect.provide(layerSettings));2037 }).pipe(Effect.provide(layerSettings));
1872 },2038 },
1873 );2039 );
apps/server/src/serverSettings.ts+111-355
...@@ -17,8 +17,6 @@ import {...@@ -17,8 +17,6 @@ import {
17 ProjectScript,17 ProjectScript,
18 ProjectSettingsOverrides,18 ProjectSettingsOverrides,
19 type ProviderInstanceConfig,19 type ProviderInstanceConfig,
20 type ProviderInstanceEnvironmentVariable,
21 type UsageLimitSourceConfig,
22 type ProviderInstanceMutation,20 type ProviderInstanceMutation,
23 defaultInstanceIdForDriver,21 defaultInstanceIdForDriver,
24 isProviderDriverKind,22 isProviderDriverKind,
...@@ -64,12 +62,11 @@ import {...@@ -64,12 +62,11 @@ import {
64import * as ServerSecretStore from "./auth/ServerSecretStore.ts";62import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
65import {63import {
66 readSecretFieldValues,64 readSecretFieldValues,
67 SOURCE_CONTROL_HOST_SECRET_FIELDS,
68 type SourceControlHostSecretSlot,
69 sourceControlHostSecretName,65 sourceControlHostSecretName,
70 writeSecretFieldValues,
71} from "./sourceControl/sourceControlHostSecrets.ts";66} from "./sourceControl/sourceControlHostSecrets.ts";
7267
68import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts";
69
73export {70export {
74 resolveReviewGuideModelSelection,71 resolveReviewGuideModelSelection,
75 resolveSourceControlWriterModelSelection,72 resolveSourceControlWriterModelSelection,
...@@ -147,106 +144,12 @@ const normalizeServerSettings = (...@@ -147,106 +144,12 @@ const normalizeServerSettings = (
147 ),144 ),
148 );145 );
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
224export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings {147export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings {
225 const providerInstances = Object.fromEntries(148 return mapSettingsSecrets(settings, (secret) =>
226 Object.entries(settings.providerInstances).map(([instanceId, instance]) => [149 secret.sensitive
227 instanceId,150 ? { value: "", redacted: secret.redacted || secret.value.length > 0 }
228 instance.environment151 : { value: secret.value, redacted: false },
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),
248 );152 );
249 return { ...settings, providerInstances, usageLimitSources, sourceControlHosts };
250}153}
251154
252export function applyProviderInstanceMutation(155export function applyProviderInstanceMutation(
...@@ -781,35 +684,36 @@ const make = Effect.gen(function* () {...@@ -781,35 +684,36 @@ const make = Effect.gen(function* () {
781 );684 );
782685
783 /**686 /**
784 * Moves source control host secrets hand-edited into settings.json into the secret store as687 * Migrate plaintext schema-declared credentials on load. Failed moves stay usable from
785 * they load, so plaintext does not stay on disk. If the store is unavailable, the secret keeps688 * the file and are retried next time; client redaction does not depend on migration.
786 * working from the file and the move is retried on the next load.
787 */689 */
788 const moveInlineSourceControlHostSecrets = (settings: ServerSettings) =>690 const moveInlineSettingsSecrets = (settings: ServerSettings) =>
789 Effect.gen(function* () {691 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 });
790 const moved = new Set<string>();699 const moved = new Set<string>();
791 for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) {700 for (const secret of pending) {
792 if (slot.value.length === 0 || slot.value === SECRET_REDACTED) continue;
793 const stored = yield* secretStore701 const stored = yield* secretStore
794 .set(sourceControlHostSecretName(slot), textEncoder.encode(slot.value))702 .set(secret.secretName, textEncoder.encode(secret.value))
795 .pipe(703 .pipe(
796 Effect.as(true),704 Effect.as(true),
797 Effect.catch(() =>705 Effect.catch(() =>
798 Effect.logWarning("failed to move a source control secret into the secret store", {706 Effect.logWarning("failed to move a settings secret into the secret store").pipe(
799 kind: slot.kind,707 Effect.as(false),
800 field: slot.field,708 ),
801 }).pipe(Effect.as(false)),
802 ),709 ),
803 );710 );
804 if (stored) moved.add(sourceControlHostSecretName(slot));711 if (stored) moved.add(secret.secretName);
805 }712 }
806 if (moved.size === 0) return settings;713 if (moved.size === 0) return settings;
807 return {714 return mapSettingsSecrets(settings, (secret) =>
808 ...settings,715 moved.has(secret.secretName) ? { value: "", redacted: true } : secret,
809 sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) =>716 );
810 moved.has(sourceControlHostSecretName(slot)) ? SECRET_REDACTED : value,
811 ),
812 };
813 });717 });
814718
815 const loadSettingsFromDisk = Effect.gen(function* () {719 const loadSettingsFromDisk = Effect.gen(function* () {
...@@ -934,7 +838,7 @@ const make = Effect.gen(function* () {...@@ -934,7 +838,7 @@ const make = Effect.gen(function* () {
934 : { settings: folded, secretMoves: [] };838 : { settings: folded, secretMoves: [] };
935 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.839 // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.
936 const migrated = settingsFileTrusted840 const migrated = settingsFileTrusted
937 ? yield* moveInlineSourceControlHostSecrets(sourceControlMigration.settings)841 ? yield* moveInlineSettingsSecrets(sourceControlMigration.settings)
938 : folded;842 : folded;
939 // A file still carrying the retired `providers`, `bitbucket`, or `github` keys is rewritten843 // A file still carrying the retired `providers`, `bitbucket`, or `github` keys is rewritten
940 // once so the migrated values persist and the old keys disappear.844 // once so the migrated values persist and the old keys disappear.
...@@ -962,96 +866,49 @@ const make = Effect.gen(function* () {...@@ -962,96 +866,49 @@ const make = Effect.gen(function* () {
962866
963 const getSettingsFromCache = Cache.get(settingsCache, cacheKey);867 const getSettingsFromCache = Cache.get(settingsCache, cacheKey);
964868
965 const materializeProviderEnvironmentSecrets = (869 const materializeSettingsSecrets = (
966 settings: ServerSettings,870 settings: ServerSettings,
967 ): Effect.Effect<ServerSettings, ServerSettingsError> =>871 ): Effect.Effect<ServerSettings, ServerSettingsError> =>
968 Effect.gen(function* () {872 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 }
1022 const storedSecrets = new Map<string, string>();873 const storedSecrets = new Map<string, string>();
1023 for (const slot of sourceControlHostSecretSlots(settings.sourceControlHosts)) {874 const pending = new Map<string, SettingsSecret>();
1024 if (slot.value !== SECRET_REDACTED) continue;875 mapSettingsSecrets(settings, (secret) => {
1025 const secret = yield* secretStore876 if (secret.sensitive && secret.redacted) pending.set(secret.secretName, secret);
1026 .get(sourceControlHostSecretName(slot))877 return secret;
1027 .pipe(878 });
1028 Effect.mapError(879 for (const secret of pending.values()) {
1029 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),880 const value = yield* secretStore.get(secret.secretName).pipe(
1030 ),881 Effect.mapError(
1031 );882 (cause) =>
883 new ServerSettingsError({
884 settingsPath,
885 operation: "read-secret",
886 providerInstanceId: secret.providerInstanceId,
887 environmentVariable: secret.environmentVariable,
888 cause,
889 }),
890 ),
891 );
1032 storedSecrets.set(892 storedSecrets.set(
1033 sourceControlHostSecretName(slot),893 secret.secretName,
1034 Option.isSome(secret) ? textDecoder.decode(secret.value) : "",894 Option.isSome(value) ? textDecoder.decode(value.value) : "",
1035 );895 );
1036 }896 }
1037 return {897 return mapSettingsSecrets(settings, (secret) => ({
1038 ...settings,898 value:
1039 providerInstances: providerInstances as ServerSettings["providerInstances"],899 secret.sensitive && secret.redacted
1040 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],900 ? (storedSecrets.get(secret.secretName) ?? "")
1041 sourceControlHosts: mapSlotValues(settings.sourceControlHosts, (slot, value) =>901 : secret.value,
1042 value === SECRET_REDACTED902 redacted: secret.sensitive && secret.kind === "environment" && secret.redacted,
1043 ? (storedSecrets.get(sourceControlHostSecretName(slot)) ?? "")903 }));
1044 : value,
1045 ),
1046 };
1047 });904 });
1048905
1049 const materializeChanges = (changes: Stream.Stream<ServerSettings>) =>906 const materializeChanges = (changes: Stream.Stream<ServerSettings>) =>
1050 changes.pipe(907 changes.pipe(
1051 Stream.mapEffect((settings) =>908 Stream.mapEffect((settings) =>
1052 materializeProviderEnvironmentSecrets(settings).pipe(909 materializeSettingsSecrets(settings).pipe(
1053 Effect.catch((error: ServerSettingsError) =>910 Effect.catch((error: ServerSettingsError) =>
1054 Effect.logWarning("failed to materialize provider environment secrets", {911 Effect.logWarning("failed to materialize settings secrets", {
1055 operation: error.operation,912 operation: error.operation,
1056 providerInstanceId: error.providerInstanceId,913 providerInstanceId: error.providerInstanceId,
1057 environmentVariable: error.environmentVariable,914 environmentVariable: error.environmentVariable,
...@@ -1072,162 +929,65 @@ const make = Effect.gen(function* () {...@@ -1072,162 +929,65 @@ const make = Effect.gen(function* () {
1072 | { readonly kind: "remove"; readonly operation: "remove-secret" | "remove-stale-secret" }929 | { readonly kind: "remove"; readonly operation: "remove-secret" | "remove-stale-secret" }
1073 );930 );
1074931
1075 const persistProviderEnvironmentSecrets = (current: ServerSettings, next: ServerSettings) =>932 const persistSettingsSecrets = (current: ServerSettings, next: ServerSettings) =>
1076 Effect.sync(() => {933 Effect.sync(() => {
1077 const providerInstances: Record<string, ProviderInstanceConfig> = {934 const previous = new Map<string, SettingsSecret>();
1078 ...next.providerInstances,935 mapSettingsSecrets(current, (secret) => {
1079 };936 // Duplicate environment names follow the provider's last-value-wins behavior.
937 previous.set(secret.secretName, secret);
938 return secret;
939 });
1080 const changes: SecretChange[] = [];940 const changes: SecretChange[] = [];
1081941 const nextSecretNames = new Set<string>();
1082 const nextSecretKeys = new Set<string>();942 const settings = mapSettingsSecrets(next, (secret) => {
1083 for (const [instanceId, instance] of Object.entries(next.providerInstances)) {943 const metadata = {
1084 if (!instance.environment) continue;944 secretName: secret.secretName,
1085 const environment: ProviderInstanceEnvironmentVariable[] = [];945 ...(secret.providerInstanceId !== undefined
1086 for (const variable of instance.environment) {946 ? { providerInstanceId: secret.providerInstanceId }
1087 const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name });947 : {}),
1088 if (!variable.sensitive) {948 ...(secret.environmentVariable !== undefined
1089 changes.push({949 ? { environmentVariable: secret.environmentVariable }
1090 kind: "remove",950 : {}),
1091 secretName,951 };
1092 operation: "remove-secret",952 if (!secret.sensitive) {
1093 providerInstanceId: instanceId,953 changes.push({ ...metadata, kind: "remove", operation: "remove-secret" });
1094 environmentVariable: variable.name,954 return { value: secret.value, redacted: false };
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 });
1156 }955 }
1157 }956 const old = previous.get(secret.secretName);
1158957 const inlineValue =
1159 const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};958 old?.sensitive && !old.redacted && old.value.length > 0 ? old.value : undefined;
1160 for (const [sourceId, source] of Object.entries(next.usageLimitSources)) {959 const value = secret.redacted ? inlineValue : secret.value;
1161 const secretName = usageLimitSourceSecretName(sourceId);960 if (value === undefined) {
1162 if (source.managementKey === SECRET_REDACTED) {961 nextSecretNames.add(secret.secretName);
1163 usageLimitSources[sourceId] = source;962 return { value: "", redacted: true };
1164 continue;
1165 }963 }
1166 if (source.managementKey.length === 0) {964 if (value.length === 0) {
1167 changes.push({ kind: "remove", secretName, operation: "remove-secret" });965 changes.push({ ...metadata, kind: "remove", operation: "remove-secret" });
1168 usageLimitSources[sourceId] = source;966 return { value: "", redacted: false };
1169 continue;
1170 }967 }
1171 changes.push({968 nextSecretNames.add(secret.secretName);
1172 kind: "write",969 changes.push({ ...metadata, kind: "write", value: textEncoder.encode(value) });
1173 secretName,970 return { value: "", redacted: true };
1174 value: textEncoder.encode(source.managementKey),971 });
1175 });972 for (const secret of previous.values()) {
1176 usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED };973 if (!secret.sensitive || nextSecretNames.has(secret.secretName)) continue;
1177 }974 if (changes.some((change) => change.secretName === secret.secretName)) continue;
1178 for (const sourceId of Object.keys(current.usageLimitSources)) {
1179 if (sourceId in next.usageLimitSources) continue;
1180 changes.push({975 changes.push({
1181 kind: "remove",976 kind: "remove",
1182 secretName: usageLimitSourceSecretName(sourceId),977 secretName: secret.secretName,
1183 operation: "remove-stale-secret",978 operation: "remove-stale-secret",
979 ...(secret.providerInstanceId !== undefined
980 ? { providerInstanceId: secret.providerInstanceId }
981 : {}),
982 ...(secret.environmentVariable !== undefined
983 ? { environmentVariable: secret.environmentVariable }
984 : {}),
1184 });985 });
1185 }986 }
1186987 return { settings, changes };
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 };
1228 });988 });
1229989
1230 const rollbackProviderEnvironmentSecretWrites = (990 const rollbackSettingsSecretWrites = (
1231 writes: ReadonlyArray<{991 writes: ReadonlyArray<{
1232 readonly secretName: string;992 readonly secretName: string;
1233 readonly previousValue: Option.Option<Uint8Array>;993 readonly previousValue: Option.Option<Uint8Array>;
...@@ -1243,7 +1003,7 @@ const make = Effect.gen(function* () {...@@ -1243,7 +1003,7 @@ const make = Effect.gen(function* () {
1243 : secretStore.remove(write.secretName)1003 : secretStore.remove(write.secretName)
1244 ).pipe(1004 ).pipe(
1245 Effect.catch((cause) =>1005 Effect.catch((cause) =>
1246 Effect.logWarning("failed to roll back provider environment secret", {1006 Effect.logWarning("failed to roll back settings secret", {
1247 providerInstanceId: write.providerInstanceId,1007 providerInstanceId: write.providerInstanceId,
1248 environmentVariable: write.environmentVariable,1008 environmentVariable: write.environmentVariable,
1249 cause,1009 cause,
...@@ -1253,14 +1013,14 @@ const make = Effect.gen(function* () {...@@ -1253,14 +1013,14 @@ const make = Effect.gen(function* () {
1253 { discard: true },1013 { discard: true },
1254 );1014 );
12551015
1256 const applyProviderEnvironmentSecretChanges = (changes: ReadonlyArray<SecretChange>) => {1016 const applySettingsSecretChanges = (changes: ReadonlyArray<SecretChange>) => {
1257 const applied: Array<{1017 const applied: Array<{
1258 readonly secretName: string;1018 readonly secretName: string;
1259 readonly previousValue: Option.Option<Uint8Array>;1019 readonly previousValue: Option.Option<Uint8Array>;
1260 readonly providerInstanceId?: string;1020 readonly providerInstanceId?: string;
1261 readonly environmentVariable?: string;1021 readonly environmentVariable?: string;
1262 }> = [];1022 }> = [];
1263 const rollback = Effect.suspend(() => rollbackProviderEnvironmentSecretWrites(applied));1023 const rollback = Effect.suspend(() => rollbackSettingsSecretWrites(applied));
1264 return Effect.forEach(1024 return Effect.forEach(
1265 changes,1025 changes,
1266 (change) =>1026 (change) =>
...@@ -1310,16 +1070,12 @@ const make = Effect.gen(function* () {...@@ -1310,16 +1070,12 @@ const make = Effect.gen(function* () {
1310 Effect.gen(function* () {1070 Effect.gen(function* () {
1311 const current = yield* getSettingsFromCache;1071 const current = yield* getSettingsFromCache;
1312 const updated = yield* update(current);1072 const updated = yield* update(current);
1313 const persisted = yield* persistProviderEnvironmentSecrets(current, updated);1073 const persisted = yield* persistSettingsSecrets(current, updated);
1314 const next = yield* normalizeServerSettings(persisted.settings);1074 const next = yield* normalizeServerSettings(persisted.settings);
1315 const materialized = yield* Effect.uninterruptibleMask(() =>1075 const materialized = yield* Effect.uninterruptibleMask(() =>
1316 Effect.gen(function* () {1076 Effect.gen(function* () {
1317 const rollbackSecretChanges = yield* applyProviderEnvironmentSecretChanges(1077 const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes);
1318 persisted.changes,1078 const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next));
1319 );
1320 const materializedExit = yield* Effect.exit(
1321 materializeProviderEnvironmentSecrets(next),
1322 );
1323 if (Exit.isFailure(materializedExit)) {1079 if (Exit.isFailure(materializedExit)) {
1324 yield* rollbackSecretChanges;1080 yield* rollbackSecretChanges;
1325 return yield* Effect.failCause(materializedExit.cause);1081 return yield* Effect.failCause(materializedExit.cause);
...@@ -1341,7 +1097,7 @@ const make = Effect.gen(function* () {...@@ -1341,7 +1097,7 @@ const make = Effect.gen(function* () {
1341 const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) =>1097 const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) =>
1342 writeSemaphore.withPermits(1)(1098 writeSemaphore.withPermits(1)(
1343 getSettingsFromCache.pipe(1099 getSettingsFromCache.pipe(
1344 Effect.flatMap(materializeProviderEnvironmentSecrets),1100 Effect.flatMap(materializeSettingsSecrets),
1345 Effect.map(resolveTextGenerationProvider),1101 Effect.map(resolveTextGenerationProvider),
1346 Effect.flatMap(use),1102 Effect.flatMap(use),
1347 ),1103 ),
...@@ -1457,7 +1213,7 @@ const make = Effect.gen(function* () {...@@ -1457,7 +1213,7 @@ const make = Effect.gen(function* () {
1457 start,1213 start,
1458 ready: Deferred.await(startedDeferred),1214 ready: Deferred.await(startedDeferred),
1459 getSettings: getSettingsFromCache.pipe(1215 getSettings: getSettingsFromCache.pipe(
1460 Effect.flatMap(materializeProviderEnvironmentSecrets),1216 Effect.flatMap(materializeSettingsSecrets),
1461 Effect.map(resolveTextGenerationProvider),1217 Effect.map(resolveTextGenerationProvider),
1462 ),1218 ),
1463 updateSettings: (patch) =>1219 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 @@...@@ -7,12 +7,10 @@
7 *7 *
8 * @module sourceControl/sourceControlHostSecrets8 * @module sourceControl/sourceControlHostSecrets
9 */9 */
10import { secretSettingsFields } from "@t3tools/contracts";
10import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition";11import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition";
11import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition";12import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition";
12import {13import type { SourceControlClientDefinition } from "@t3tools/source-control-core/client/definition";
13 secretSourceControlHostSettingsFields,
14 type SourceControlClientDefinition,
15} from "@t3tools/source-control-core/client/definition";
16import * as Forgejo from "@t3tools/source-control-forgejo/client/definition";14import * as Forgejo from "@t3tools/source-control-forgejo/client/definition";
17import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition";15import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition";
18import * as GitHub from "@t3tools/source-control-github/client/definition";16import * as GitHub from "@t3tools/source-control-github/client/definition";
...@@ -33,9 +31,7 @@ export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap<...@@ -33,9 +31,7 @@ export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap<
33 ReadonlyArray<string>31 ReadonlyArray<string>
34> = new Map(32> = new Map(
35 definitions.flatMap((definition) => {33 definitions.flatMap((definition) => {
36 const fields = definition.settings34 const fields = definition.settings ? secretSettingsFields(definition.settings) : [];
37 ? secretSourceControlHostSettingsFields(definition.settings)
38 : [];
39 return fields.length > 0 ? [[definition.kind, fields] as const] : [];35 return fields.length > 0 ? [[definition.kind, fields] as const] : [];
40 }),36 }),
41);37);
apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx+2-3
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 DialogTitle,13 DialogTitle,
14} from "../ui/dialog";14} from "../ui/dialog";
15import { Input } from "../ui/input";15import { Input } from "../ui/input";
16import { SecretInput } from "./SecretInput";
16import { Label } from "../ui/label";17import { Label } from "../ui/label";
1718
18/**19/**
...@@ -118,10 +119,8 @@ export function AddUsageLimitSourceDialog({...@@ -118,10 +119,8 @@ export function AddUsageLimitSourceDialog({
118 </div>119 </div>
119 <div className="grid gap-1.5">120 <div className="grid gap-1.5">
120 <Label htmlFor="usage-source-key">Management key</Label>121 <Label htmlFor="usage-source-key">Management key</Label>
121 <Input122 <SecretInput
122 id="usage-source-key"123 id="usage-source-key"
123 type="password"
124 autoComplete="off"
125 value={managementKey}124 value={managementKey}
126 onChange={(event) => setManagementKey(event.target.value)}125 onChange={(event) => setManagementKey(event.target.value)}
127 />126 />
apps/web/src/components/settings/GitHubTokenSettings.tsx+3-4
...@@ -8,6 +8,7 @@ import { serverEnvironment } from "../../state/server";...@@ -8,6 +8,7 @@ import { serverEnvironment } from "../../state/server";
8import { useAtomCommand } from "../../state/use-atom-command";8import { useAtomCommand } from "../../state/use-atom-command";
9import { Button, InlineButton } from "../ui/button";9import { Button, InlineButton } from "../ui/button";
10import { Input } from "../ui/input";10import { Input } from "../ui/input";
11import { SecretInput } from "./SecretInput";
11import { Label } from "../ui/label";12import { Label } from "../ui/label";
1213
13const DEFAULT_HOST = "github.com";14const DEFAULT_HOST = "github.com";
...@@ -103,12 +104,10 @@ export function GitHubTokenSettings({...@@ -103,12 +104,10 @@ export function GitHubTokenSettings({
103 </div>104 </div>
104 <div className="grid gap-1.5">105 <div className="grid gap-1.5">
105 <Label htmlFor={`github-token-${environmentId}`}>Token</Label>106 <Label htmlFor={`github-token-${environmentId}`}>Token</Label>
106 <Input107 <SecretInput
107 id={`github-token-${environmentId}`}108 id={`github-token-${environmentId}`}
108 type="password"
109 autoComplete="off"
110 size="sm"109 size="sm"
111 placeholder={isSaved ? "Stored secret, enter a new value to replace" : "Not set"}110 configured={isSaved}
112 value={draft}111 value={draft}
113 onChange={(event) => setDraft(event.target.value)}112 onChange={(event) => setDraft(event.target.value)}
114 />113 />
apps/web/src/components/settings/ProviderInstanceCard.tsx+49-28
...@@ -44,6 +44,7 @@ import { Badge } from "../ui/badge";...@@ -44,6 +44,7 @@ import { Badge } from "../ui/badge";
44import { Button } from "../ui/button";44import { Button } from "../ui/button";
45import { MorphIcon } from "~/components/MorphIcon";45import { MorphIcon } from "~/components/MorphIcon";
46import { DraftInput } from "../ui/draft-input";46import { DraftInput } from "../ui/draft-input";
47import { SecretInput } from "./SecretInput";
47import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";48import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
48import { Switch } from "../ui/switch";49import { Switch } from "../ui/switch";
49import { stackedThreadToast, toastManager } from "../ui/toast";50import { stackedThreadToast, toastManager } from "../ui/toast";
...@@ -263,9 +264,6 @@ function ProviderEnvironmentFieldRow(props: {...@@ -263,9 +264,6 @@ function ProviderEnvironmentFieldRow(props: {
263}) {264}) {
264 const inputId = `${props.idPrefix}-environment-${props.field.name}`;265 const inputId = `${props.idPrefix}-environment-${props.field.name}`;
265 const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? "");266 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
270 return (268 return (
271 <SettingsRow269 <SettingsRow
...@@ -273,17 +271,29 @@ function ProviderEnvironmentFieldRow(props: {...@@ -273,17 +271,29 @@ function ProviderEnvironmentFieldRow(props: {
273 description={props.field.description}271 description={props.field.description}
274 control={272 control={
275 <div className="flex w-full min-w-0 items-center gap-2 @min-[32rem]/settings-row:w-56">273 <div className="flex w-full min-w-0 items-center gap-2 @min-[32rem]/settings-row:w-56">
276 <DraftInput274 {props.field.sensitive === false ? (
277 id={inputId}275 <DraftInput
278 size="sm"276 id={inputId}
279 className="min-w-0 flex-1"277 size="sm"
280 type={props.field.sensitive === false ? undefined : "password"}278 className="min-w-0 flex-1"
281 autoComplete="off"279 autoComplete="off"
282 value={value}280 value={value}
283 onCommit={(next) => props.onCommit(props.field, next)}281 onCommit={(next) => props.onCommit(props.field, next)}
284 placeholder={placeholder}282 placeholder={props.field.placeholder}
285 spellCheck={false}283 spellCheck={false}
286 />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 )}
287 {props.variable ? (297 {props.variable ? (
288 <Button298 <Button
289 type="button"299 type="button"
...@@ -410,20 +420,31 @@ export function ProviderEnvironmentSection(props: {...@@ -410,20 +420,31 @@ export function ProviderEnvironmentSection(props: {
410 <span className="hidden text-xs text-muted-foreground sm:inline" aria-hidden>420 <span className="hidden text-xs text-muted-foreground sm:inline" aria-hidden>
411 =421 =
412 </span>422 </span>
413 <DraftInput423 {variable.sensitive ? (
414 size="sm"424 <SecretInput
415 font="mono"425 size="sm"
416 className="min-w-0 flex-1"426 font="mono"
417 value={variable.valueRedacted ? "" : variable.value}427 className="min-w-0 flex-1"
418 onCommit={(value) => updateVariable(variable.id, { value })}428 value={variable.valueRedacted ? "" : variable.value}
419 type={variable.sensitive ? "password" : undefined}429 onCommit={(value) => updateVariable(variable.id, { value })}
420 autoComplete="off"430 placeholder="value"
421 placeholder={431 configured={variable.valueRedacted === true}
422 variable.valueRedacted ? "Stored secret, enter a new value to replace" : "value"432 spellCheck={false}
423 }433 aria-label={`Environment variable value ${index + 1}`}
424 spellCheck={false}434 />
425 aria-label={`Environment variable value ${index + 1}`}435 ) : (
426 />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 )}
427 <Tooltip>448 <Tooltip>
428 <TooltipTrigger449 <TooltipTrigger
429 render={450 render={
apps/web/src/components/settings/ProviderSettingsForm.tsx+28-28
...@@ -3,11 +3,12 @@...@@ -3,11 +3,12 @@
3import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";3import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
6import type {6import {
7 ProviderSettingsFormAnnotation,7 isSecretSetting,
8 ProviderSettingsFormControl,8 type ProviderSettingsFormAnnotation,
9 ProviderSettingsFormOption,9 type ProviderSettingsFormControl,
10 ProviderSettingsFormSchemaAnnotation,10 type ProviderSettingsFormOption,
11 type ProviderSettingsFormSchemaAnnotation,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
12import { PlusIcon, XIcon } from "lucide-react";13import { PlusIcon, XIcon } from "lucide-react";
1314
...@@ -15,6 +16,7 @@ import { cn } from "../../lib/utils";...@@ -15,6 +16,7 @@ import { cn } from "../../lib/utils";
15import { Button } from "../ui/button";16import { Button } from "../ui/button";
16import { DraftInput } from "../ui/draft-input";17import { DraftInput } from "../ui/draft-input";
17import { Input } from "../ui/input";18import { Input } from "../ui/input";
19import { SecretInput } from "./SecretInput";
18import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";20import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
19import { Switch } from "../ui/switch";21import { Switch } from "../ui/switch";
20import { Textarea } from "../ui/textarea";22import { Textarea } from "../ui/textarea";
...@@ -131,7 +133,7 @@ export function deriveProviderSettingsFields(...@@ -131,7 +133,7 @@ export function deriveProviderSettingsFields(
131 ? { placeholder: formAnnotation.placeholder }133 ? { placeholder: formAnnotation.placeholder }
132 : {}),134 : {}),
133 clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit",135 clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit",
134 ...(formAnnotation.secret ? { secret: true } : {}),136 ...(isSecretSetting(fieldSchema) ? { secret: true } : {}),
135 ...(formAnnotation.control === "switch"137 ...(formAnnotation.control === "switch"
136 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }138 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }
137 : {}),139 : {}),
...@@ -255,19 +257,6 @@ function readProviderConfigString(config: unknown, key: string): string {...@@ -255,19 +257,6 @@ function readProviderConfigString(config: unknown, key: string): string {
255 return typeof value === "string" ? value : "";257 return typeof value === "string" ? value : "";
256}258}
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
271function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean {260function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean {
272 if (config === null || typeof config !== "object") return defaultValue;261 if (config === null || typeof config !== "object") return defaultValue;
273 const value = (config as Record<string, unknown>)[key];262 const value = (config as Record<string, unknown>)[key];
...@@ -423,6 +412,16 @@ function ProviderSettingsFieldRow({...@@ -423,6 +412,16 @@ function ProviderSettingsFieldRow({
423 placeholder={field.placeholder}412 placeholder={field.placeholder}
424 spellCheck={false}413 spellCheck={false}
425 />414 />
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 />
426 ) : (425 ) : (
427 <DraftInput426 <DraftInput
428 id={inputId}427 id={inputId}
...@@ -431,10 +430,9 @@ function ProviderSettingsFieldRow({...@@ -431,10 +430,9 @@ function ProviderSettingsFieldRow({
431 className="w-full max-w-full @min-[32rem]/settings-row:w-56"430 className="w-full max-w-full @min-[32rem]/settings-row:w-56"
432 type={field.control === "password" ? "password" : undefined}431 type={field.control === "password" ? "password" : undefined}
433 autoComplete={field.control === "password" ? "off" : undefined}432 autoComplete={field.control === "password" ? "off" : undefined}
434 {...secretFieldInputProps(field, value)}433 value={readProviderConfigString(value, field.key)}
434 placeholder={field.placeholder}
435 onCommit={(next) => {435 onCommit={(next) => {
436 // An untouched saved secret commits empty; only a typed value replaces it.
437 if (field.secret && next === "") return;
438 onChange(nextProviderConfigWithFieldValue(value, field, next));436 onChange(nextProviderConfigWithFieldValue(value, field, next));
439 }}437 }}
440 spellCheck={false}438 spellCheck={false}
...@@ -515,27 +513,29 @@ function ProviderSettingsFieldRow({...@@ -515,27 +513,29 @@ function ProviderSettingsFieldRow({
515 }513 }
516514
517 const type = field.control === "password" ? "password" : undefined;515 const type = field.control === "password" ? "password" : undefined;
516 const BufferedInput = field.secret ? SecretInput : DraftInput;
517 const ControlledInput = field.secret ? SecretInput : Input;
518 return (518 return (
519 <FieldFrame variant={variant}>519 <FieldFrame variant={variant}>
520 <label htmlFor={inputId} className={cn(variant === "card" && "block")}>520 <label htmlFor={inputId} className={cn(variant === "card" && "block")}>
521 {label}521 {label}
522 {variant === "card" ? (522 {variant === "card" ? (
523 <DraftInput523 <BufferedInput
524 id={inputId}524 id={inputId}
525 size="sm"525 size="sm"
526 className="mt-1.5"526 className="mt-1.5"
527 type={type}527 type={field.secret ? undefined : type}
528 autoComplete={field.control === "password" ? "off" : undefined}528 autoComplete={!field.secret && field.control === "password" ? "off" : undefined}
529 value={readProviderConfigString(value, field.key)}529 value={readProviderConfigString(value, field.key)}
530 onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))}530 onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))}
531 placeholder={field.placeholder}531 placeholder={field.placeholder}
532 spellCheck={false}532 spellCheck={false}
533 />533 />
534 ) : (534 ) : (
535 <Input535 <ControlledInput
536 id={inputId}536 id={inputId}
537 type={type}537 type={field.secret ? undefined : type}
538 autoComplete={field.control === "password" ? "off" : undefined}538 autoComplete={!field.secret && field.control === "password" ? "off" : undefined}
539 value={readProviderConfigString(value, field.key)}539 value={readProviderConfigString(value, field.key)}
540 onChange={(event) =>540 onChange={(event) =>
541 onChange(nextProviderConfigWithFieldValue(value, field, event.target.value))541 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 {...@@ -551,15 +551,19 @@ export interface ProviderSettingsFormAnnotation {
551 readonly placeholder?: string | undefined;551 readonly placeholder?: string | undefined;
552 readonly hidden?: boolean | undefined;552 readonly hidden?: boolean | undefined;
553 readonly clearWhenEmpty?: "omit" | "persist" | undefined;553 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;
559 /** Choices for a `select` control. The first entry is the default. */554 /** Choices for a `select` control. The first entry is the default. */
560 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;555 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
561}556}
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
563export interface ProviderSettingsFormSchemaAnnotation {567export interface ProviderSettingsFormSchemaAnnotation {
564 readonly order?: readonly string[] | undefined;568 readonly order?: readonly string[] | undefined;
565}569}
...@@ -567,12 +571,24 @@ export interface ProviderSettingsFormSchemaAnnotation {...@@ -567,12 +571,24 @@ export interface ProviderSettingsFormSchemaAnnotation {
567declare module "effect/Schema" {571declare module "effect/Schema" {
568 namespace Annotations {572 namespace Annotations {
569 interface Annotations {573 interface Annotations {
574 /** Store this credential separately and expose only whether it is configured to clients. */
575 readonly settingsSecret?: boolean | undefined;
570 readonly providerSettingsForm?: ProviderSettingsFormAnnotation | undefined;576 readonly providerSettingsForm?: ProviderSettingsFormAnnotation | undefined;
571 readonly providerSettingsFormSchema?: ProviderSettingsFormSchemaAnnotation | undefined;577 readonly providerSettingsFormSchema?: ProviderSettingsFormSchemaAnnotation | undefined;
572 }578 }
573 }579 }
574}580}
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
576export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract<592export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract<
577 keyof Fields,593 keyof Fields,
578 string594 string
...@@ -749,10 +765,11 @@ export const AntigravitySettings = makeProviderSettingsSchema(...@@ -749,10 +765,11 @@ export const AntigravitySettings = makeProviderSettingsSchema(
749 }),765 }),
750 ),766 ),
751 apiKey: TrimmedString.pipe(767 apiKey: TrimmedString.pipe(
768 secretSetting,
752 Schema.withDecodingDefault(Effect.succeed("")),769 Schema.withDecodingDefault(Effect.succeed("")),
753 Schema.annotateKey({770 Schema.annotateKey({
754 title: "API key",771 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.",
756 providerSettingsForm: {773 providerSettingsForm: {
757 control: "password",774 control: "password",
758 placeholder: "Optional",775 placeholder: "Optional",
...@@ -804,7 +821,7 @@ export const UsageLimitSourceConfig = Schema.Struct({...@@ -804,7 +821,7 @@ export const UsageLimitSourceConfig = Schema.Struct({
804 kind: Schema.Literal("cliproxy"),821 kind: Schema.Literal("cliproxy"),
805 label: Schema.optional(TrimmedNonEmptyString),822 label: Schema.optional(TrimmedNonEmptyString),
806 url: TrimmedNonEmptyString,823 url: TrimmedNonEmptyString,
807 managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),824 managementKey: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("")), secretSetting),
808 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),825 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
809});826});
810export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;827export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;
packages/source-control-bitbucket/src/client/definition.ts+4-2
...@@ -39,7 +39,8 @@ export const settings = makeProviderSettingsSchema(...@@ -39,7 +39,8 @@ export const settings = makeProviderSettingsSchema(
39 title: "Access token",39 title: "Access token",
40 description:40 description:
41 "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.",41 "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" },
43 }),44 }),
44 email: text({45 email: text({
45 title: "Atlassian account email",46 title: "Atlassian account email",
...@@ -50,7 +51,8 @@ export const settings = makeProviderSettingsSchema(...@@ -50,7 +51,8 @@ export const settings = makeProviderSettingsSchema(
50 title: "API token",51 title: "API token",
51 description:52 description:
52 "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.",53 "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" },
54 }),56 }),
55 },57 },
56 { order: ["accessToken", "email", "apiToken"] },58 { order: ["accessToken", "email", "apiToken"] },
packages/source-control-core/src/client/definition.ts+1-15
...@@ -144,7 +144,7 @@ export interface SourceControlClientDefinition {...@@ -144,7 +144,7 @@ export interface SourceControlClientDefinition {
144 /**144 /**
145 * What the environment saves for this host under `settings.sourceControlHosts[kind]`, rendered145 * What the environment saves for this host under `settings.sourceControlHosts[kind]`, rendered
146 * as a form in Source Control settings and read by the host's server package. Fields carry146 * 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` is147 * `providerSettingsForm` annotations, as agent provider settings do; a `secretSetting` is
148 * kept in the server's secret store. Omitted for a host with nothing to configure.148 * kept in the server's secret store. Omitted for a host with nothing to configure.
149 */149 */
150 readonly settings?: SourceControlHostSettingsSchema;150 readonly settings?: SourceControlHostSettingsSchema;
...@@ -322,17 +322,3 @@ export function readSourceControlHostSettings<S extends SourceControlHostSetting...@@ -322,17 +322,3 @@ export function readSourceControlHostSettings<S extends SourceControlHostSetting
322 if (fallback._tag === "Some") return fallback.value;322 if (fallback._tag === "Some") return fallback.value;
323 throw new Error("A source control host settings schema must decode an empty object.");323 throw new Error("A source control host settings schema must decode an empty object.");
324}324}
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 @@...@@ -5,6 +5,7 @@
5 */5 */
6import {6import {
7 makeProviderSettingsSchema,7 makeProviderSettingsSchema,
8 secretSetting,
8 SourceControlProviderKind,9 SourceControlProviderKind,
9 TrimmedString,10 TrimmedString,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
...@@ -25,12 +26,13 @@ const KIND = SourceControlProviderKind.make("gitcafe");...@@ -25,12 +26,13 @@ const KIND = SourceControlProviderKind.make("gitcafe");
25/** A git.cafe token, used before `CAFE_TOKEN` and the `cafe` login. */26/** A git.cafe token, used before `CAFE_TOKEN` and the `cafe` login. */
26export const settings = makeProviderSettingsSchema({27export const settings = makeProviderSettingsSchema({
27 token: TrimmedString.pipe(28 token: TrimmedString.pipe(
29 secretSetting,
28 Schema.withDecodingDefault(Effect.succeed("")),30 Schema.withDecodingDefault(Effect.succeed("")),
29 Schema.annotateKey({31 Schema.annotateKey({
30 title: "Token",32 title: "Token",
31 description:33 description:
32 "A git.cafe token, used before CAFE_TOKEN and the cafe login, so GitCafe works without the CLI signed in.",34 "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" },
34 }),36 }),
35 ),37 ),
36});38});
packages/source-control-github/src/client/definition.ts+3-1
...@@ -6,6 +6,7 @@...@@ -6,6 +6,7 @@
6import {6import {
7 GitHubHost,7 GitHubHost,
8 makeProviderSettingsSchema,8 makeProviderSettingsSchema,
9 secretSetting,
9 pullRequestHostOf,10 pullRequestHostOf,
10 SourceControlProviderKind,11 SourceControlProviderKind,
11 TrimmedNonEmptyString,12 TrimmedNonEmptyString,
...@@ -45,7 +46,8 @@ export const settings = makeProviderSettingsSchema({...@@ -45,7 +46,8 @@ export const settings = makeProviderSettingsSchema({
45 ),46 ),
46 tokens: Schema.Record(GitHubHost, TrimmedString).pipe(47 tokens: Schema.Record(GitHubHost, TrimmedString).pipe(
47 Schema.withDecodingDefault(Effect.succeed({})),48 Schema.withDecodingDefault(Effect.succeed({})),
48 Schema.annotateKey({ providerSettingsForm: { hidden: true, secret: true } }),49 secretSetting,
50 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
49 ),51 ),
50});52});
51export type GitHubSettings = typeof settings.Type;53export type GitHubSettings = typeof settings.Type;