| author | |
| committer | |
| log | d514a8c6176ace9057dc8c7619f0b5f0d81d1bd0 |
| tree | 310bfe913e79363872fa8bd23bdccfc842c81e02 |
| parent | 37e2acda5118d13c144efb009d4911ff8f65d6ed |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 13 | import { createModelSelection } from "@t3tools/shared/model"; | 14 | import { createModelSelection } from "@t3tools/shared/model"; |
| 14 | import { assert, it } from "@effect/vitest"; | 15 | import { 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 | ); |
| 1442 | 1443 | ||
| 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 | }); |
| 1734 | 1885 | ||
| ... | @@ -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 | ); | ||
| 1753 | 1909 | ||
| 1754 | const failed = yield* serverSettings | 1910 | 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 { |
| 64 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 62 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 65 | import { | 63 | import { |
| 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"; |
| 72 | 67 | ||
| 68 | import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts"; | ||
| 69 | |||
| 73 | export { | 70 | export { |
| 74 | resolveReviewGuideModelSelection, | 71 | resolveReviewGuideModelSelection, |
| 75 | resolveSourceControlWriterModelSelection, | 72 | resolveSourceControlWriterModelSelection, |
| ... | @@ -147,106 +144,12 @@ const normalizeServerSettings = ( | ... | @@ -147,106 +144,12 @@ const normalizeServerSettings = ( |
| 147 | ), | 144 | ), |
| 148 | ); | 145 | ); |
| 149 | 146 | ||
| 150 | function 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 | */ | ||
| 162 | const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; | ||
| 163 | |||
| 164 | function usageLimitSourceSecretName(sourceId: string): string { | ||
| 165 | return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; | ||
| 166 | } | ||
| 167 | |||
| 168 | const 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)`. */ | ||
| 171 | function 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. */ | ||
| 195 | function 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 | |||
| 210 | function 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 | |||
| 224 | export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { | 147 | export 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.environment | 151 | : { 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 | } |
| 251 | 154 | ||
| 252 | export function applyProviderInstanceMutation( | 155 | export function applyProviderInstanceMutation( |
| ... | @@ -781,35 +684,36 @@ const make = Effect.gen(function* () { | ... | @@ -781,35 +684,36 @@ const make = Effect.gen(function* () { |
| 781 | ); | 684 | ); |
| 782 | 685 | ||
| 783 | /** | 686 | /** |
| 784 | * Moves source control host secrets hand-edited into settings.json into the secret store as | 687 | * 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 keeps | 688 | * 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* secretStore | 701 | 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 | }); |
| 814 | 718 | ||
| 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 = settingsFileTrusted | 840 | 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 rewritten | 843 | // 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* () { |
| 962 | 866 | ||
| 963 | const getSettingsFromCache = Cache.get(settingsCache, cacheKey); | 867 | const getSettingsFromCache = Cache.get(settingsCache, cacheKey); |
| 964 | 868 | ||
| 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* secretStore | 876 | 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_REDACTED | 902 | redacted: secret.sensitive && secret.kind === "environment" && secret.redacted, |
| 1043 | ? (storedSecrets.get(sourceControlHostSecretName(slot)) ?? "") | 903 | })); |
| 1044 | : value, | ||
| 1045 | ), | ||
| 1046 | }; | ||
| 1047 | }); | 904 | }); |
| 1048 | 905 | ||
| 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 | ); |
| 1074 | 931 | ||
| 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[] = []; |
| 1081 | 941 | 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); |
| 1158 | 957 | 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 | } |
| 1186 | 987 | 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 | }); |
| 1229 | 989 | ||
| 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 | ); |
| 1255 | 1015 | ||
| 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 @@ | ||
| 1 | import { | ||
| 2 | AntigravitySettings, | ||
| 3 | ClaudeSettings, | ||
| 4 | CodexSettings, | ||
| 5 | type ServerSettings, | ||
| 6 | UsageLimitSourceConfig, | ||
| 7 | secretSettingsFields, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; | ||
| 10 | import { cursorClient } from "@t3tools/provider-cursor/client"; | ||
| 11 | import { grokClient } from "@t3tools/provider-grok/client"; | ||
| 12 | import { museClient } from "@t3tools/provider-muse/client"; | ||
| 13 | import { openCodeClient } from "@t3tools/provider-opencode/client"; | ||
| 14 | import { piClient } from "@t3tools/provider-pi/client"; | ||
| 15 | import { | ||
| 16 | readSecretFieldValues, | ||
| 17 | SOURCE_CONTROL_HOST_SECRET_FIELDS, | ||
| 18 | sourceControlHostSecretName, | ||
| 19 | writeSecretFieldValues, | ||
| 20 | } from "./sourceControl/sourceControlHostSecrets.ts"; | ||
| 21 | |||
| 22 | export const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; | ||
| 23 | |||
| 24 | const 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 | ]); | ||
| 33 | const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); | ||
| 34 | const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); | ||
| 35 | |||
| 36 | export 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 | |||
| 46 | type 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 | */ | ||
| 52 | export 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/sourceControlHostSecrets | 8 | * @module sourceControl/sourceControlHostSecrets |
| 9 | */ | 9 | */ |
| 10 | import { secretSettingsFields } from "@t3tools/contracts"; | ||
| 10 | import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; | 11 | import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; |
| 11 | import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; | 12 | import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; |
| 12 | import { | 13 | import type { SourceControlClientDefinition } from "@t3tools/source-control-core/client/definition"; |
| 13 | secretSourceControlHostSettingsFields, | ||
| 14 | type SourceControlClientDefinition, | ||
| 15 | } from "@t3tools/source-control-core/client/definition"; | ||
| 16 | import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; | 14 | import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; |
| 17 | import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; | 15 | import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; |
| 18 | import * as GitHub from "@t3tools/source-control-github/client/definition"; | 16 | import * 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.settings | 34 | 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"; |
| 15 | import { Input } from "../ui/input"; | 15 | import { Input } from "../ui/input"; |
| 16 | import { SecretInput } from "./SecretInput"; | ||
| 16 | import { Label } from "../ui/label"; | 17 | import { Label } from "../ui/label"; |
| 17 | 18 | ||
| 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 | <Input | 122 | <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"; |
| 8 | import { useAtomCommand } from "../../state/use-atom-command"; | 8 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 9 | import { Button, InlineButton } from "../ui/button"; | 9 | import { Button, InlineButton } from "../ui/button"; |
| 10 | import { Input } from "../ui/input"; | 10 | import { Input } from "../ui/input"; |
| 11 | import { SecretInput } from "./SecretInput"; | ||
| 11 | import { Label } from "../ui/label"; | 12 | import { Label } from "../ui/label"; |
| 12 | 13 | ||
| 13 | const DEFAULT_HOST = "github.com"; | 14 | const 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 | <Input | 107 | <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"; |
| 44 | import { Button } from "../ui/button"; | 44 | import { Button } from "../ui/button"; |
| 45 | import { MorphIcon } from "~/components/MorphIcon"; | 45 | import { MorphIcon } from "~/components/MorphIcon"; |
| 46 | import { DraftInput } from "../ui/draft-input"; | 46 | import { DraftInput } from "../ui/draft-input"; |
| 47 | import { SecretInput } from "./SecretInput"; | ||
| 47 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 48 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 48 | import { Switch } from "../ui/switch"; | 49 | import { Switch } from "../ui/switch"; |
| 49 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 50 | import { 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; | ||
| 269 | 267 | ||
| 270 | return ( | 268 | return ( |
| 271 | <SettingsRow | 269 | <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 | <DraftInput | 274 | {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 | <Button | 298 | <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 | <DraftInput | 423 | {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 | <TooltipTrigger | 449 | <TooltipTrigger |
| 429 | render={ | 450 | render={ |
apps/web/src/components/settings/ProviderSettingsForm.tsx+28-28| ... | @@ -3,11 +3,12 @@ | ... | @@ -3,11 +3,12 @@ |
| 3 | import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 3 | import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 6 | import type { | 6 | import { |
| 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"; |
| 12 | import { PlusIcon, XIcon } from "lucide-react"; | 13 | import { PlusIcon, XIcon } from "lucide-react"; |
| 13 | 14 | ||
| ... | @@ -15,6 +16,7 @@ import { cn } from "../../lib/utils"; | ... | @@ -15,6 +16,7 @@ import { cn } from "../../lib/utils"; |
| 15 | import { Button } from "../ui/button"; | 16 | import { Button } from "../ui/button"; |
| 16 | import { DraftInput } from "../ui/draft-input"; | 17 | import { DraftInput } from "../ui/draft-input"; |
| 17 | import { Input } from "../ui/input"; | 18 | import { Input } from "../ui/input"; |
| 19 | import { SecretInput } from "./SecretInput"; | ||
| 18 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 20 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 19 | import { Switch } from "../ui/switch"; | 21 | import { Switch } from "../ui/switch"; |
| 20 | import { Textarea } from "../ui/textarea"; | 22 | import { 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 | } |
| 257 | 259 | ||
| 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 | */ | ||
| 262 | function 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 | |||
| 271 | function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean { | 260 | function 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 | <DraftInput | 426 | <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 | } |
| 516 | 514 | ||
| 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 | <DraftInput | 523 | <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 | <Input | 535 | <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 @@ | ||
| 1 | import { act } from "react"; | ||
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { SecretInput } from "./SecretInput"; | ||
| 6 | |||
| 7 | vi.mock("../ui/input", () => ({ | ||
| 8 | Input: (props: Record<string, unknown>) => <input {...props} />, | ||
| 9 | })); | ||
| 10 | |||
| 11 | let renderer: ReactTestRenderer; | ||
| 12 | |||
| 13 | beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true)); | ||
| 14 | afterEach(async () => { | ||
| 15 | await act(() => renderer?.unmount()); | ||
| 16 | vi.unstubAllGlobals(); | ||
| 17 | }); | ||
| 18 | |||
| 19 | async 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 | |||
| 25 | describe("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 @@ | ||
| 1 | import { DraftInput, type DraftInputProps } from "../ui/draft-input"; | ||
| 2 | import { Input, type InputProps } from "../ui/input"; | ||
| 3 | |||
| 4 | type 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. */ | ||
| 12 | export 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 | } |
| 562 | 557 | ||
| 558 | /** Secret fields declared by a settings schema, shared by persistence and client redaction. */ | ||
| 559 | export 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 | |||
| 563 | export interface ProviderSettingsFormSchemaAnnotation { | 567 | export 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 { |
| 567 | declare module "effect/Schema" { | 571 | declare 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 | } |
| 575 | 581 | ||
| 582 | /** Mark a string or a record of strings as a credential in its settings schema. */ | ||
| 583 | export function secretSetting<S extends Schema.Top>(schema: S): S["Rebuild"] { | ||
| 584 | return schema.annotateKey({ settingsSecret: true }); | ||
| 585 | } | ||
| 586 | |||
| 587 | export function isSecretSetting(schema: Schema.Top): boolean { | ||
| 588 | const annotations = Schema.resolveAnnotationsKey(schema) ?? Schema.resolveAnnotations(schema); | ||
| 589 | return annotations?.settingsSecret === true; | ||
| 590 | } | ||
| 591 | |||
| 576 | export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract< | 592 | export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract< |
| 577 | keyof Fields, | 593 | keyof Fields, |
| 578 | string | 594 | 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 | }); |
| 810 | export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; | 827 | export 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]`, rendered | 145 | * 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 carry | 146 | * 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 |
| 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 | */ | ||
| 330 | export 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 | */ |
| 6 | import { | 6 | import { |
| 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. */ |
| 26 | export const settings = makeProviderSettingsSchema({ | 27 | export 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 @@ |
| 6 | import { | 6 | import { |
| 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 | }); |
| 51 | export type GitHubSettings = typeof settings.Type; | 53 | export type GitHubSettings = typeof settings.Type; |