From b0da0a34d25c59958fd22d7dd34c4d846fd9c31b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 14:18:24 -0400 Subject: [PATCH 01/10] feat(server): read Bitbucket and GitCafe credentials from a secret provider Signed-off-by: Yordis Prieto --- .../server/src/secrets/settingsSecretValue.ts | 12 +++ apps/server/src/serverSettings.test.ts | 29 +++++++ apps/server/src/settingsSecrets.ts | 3 +- .../sourceControl/ServerSourceControlHost.ts | 24 +++++- .../sourceControlHostSecrets.test.ts | 51 ++++++++++++ .../sourceControl/sourceControlHostSecrets.ts | 30 ++++++- .../settings/ProviderInstanceCard.tsx | 2 +- .../settings/ProviderSettingsForm.test.ts | 28 ++++++- .../settings/ProviderSettingsForm.tsx | 79 ++++++++++++++++++- .../components/settings/SecretSourceInput.tsx | 19 +++-- .../settings/SourceControlHostSettings.tsx | 5 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/settingsSecretValue.ts | 19 +++++ .../src/server/BitbucketApi.ts | 7 +- .../src/shared/definition.ts | 19 +++-- .../src/server/GitCafeCredentials.ts | 11 ++- .../src/shared/definition.ts | 4 +- 17 files changed, 315 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/secrets/settingsSecretValue.ts create mode 100644 apps/server/src/sourceControl/sourceControlHostSecrets.test.ts create mode 100644 packages/contracts/src/settingsSecretValue.ts diff --git a/apps/server/src/secrets/settingsSecretValue.ts b/apps/server/src/secrets/settingsSecretValue.ts new file mode 100644 index 0000000000000..5b8370111a2b7 --- /dev/null +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -0,0 +1,12 @@ +import { isSecretReference, type SettingsSecretValue } from "@t3tools/contracts"; +import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as Effect from "effect/Effect"; + +/** An unreadable secret comes back as `""`, so a credential that is missing behaves as unset. */ +export const resolveSettingsSecret = ( + resolver: SecretResolver.SecretResolver["Service"], + value: SettingsSecretValue, +): Effect.Effect => + !isSecretReference(value) + ? Effect.succeed(value) + : resolver.read([value]).pipe(Effect.map(([secret]) => secret ?? "")); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 0ae14fae5da7a..9c090ff320ce1 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1536,6 +1536,35 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); + it.effect("keeps a credential reference as written and drops the literal it replaces", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const reference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Bitbucket/token", + }; + const stored = (kind: string, field: string) => + secrets.get(sourceControlHostSecretName({ kind, field, serverHost: null })); + + yield* serverSettings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "bb-access" } }, + }); + assert.isTrue(Option.isSome(yield* stored("bitbucket", "accessToken"))); + + const saved = yield* serverSettings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: reference } }, + }); + assert.deepEqual(saved.sourceControlHosts.bitbucket?.accessToken, reference); + assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken"))); + assert.deepEqual( + ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts.bitbucket + ?.accessToken, + reference, + ); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + it.effect("keeps the retired keys for another load when a token cannot be copied", () => { const cause = new ServerSecretStore.SecretStoreReadError({ resource: "bitbucket-access-token", diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 3697c877edc22..3d4daa659e58a 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -6,6 +6,7 @@ import { type ServerSettings, SimlockDeviceHostConfig, UsageLimitSourceConfig, + isSecretReference, secretSettingsFields, } from "@t3tools/contracts"; import { acpRegistryClient } from "@t3tools/provider-acp-registry/client"; @@ -60,7 +61,7 @@ export function mapSettingsSecrets( ) => { const next = { ...fields }; for (const field of secretFields) { - if (!(field in next)) continue; + if (!(field in next) || isSecretReference(next[field])) continue; next[field] = writeSecretFieldValues( next[field], readSecretFieldValues(next[field]).map(([serverHost, value]) => { diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts index 73e79a8cc49f3..2f269c3e98a51 100644 --- a/apps/server/src/sourceControl/ServerSourceControlHost.ts +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -1,11 +1,15 @@ +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; +import { resolveSettingsSecret } from "../secrets/settingsSecretValue.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { resolveSourceControlHostSecrets } from "./sourceControlHostSecrets.ts"; export const layer = Layer.effect( SourceControlHost.SourceControlHost, @@ -14,8 +18,24 @@ export const layer = Layer.effect( const process = yield* VcsProcess.VcsProcess; const git = yield* GitVcsDriver.GitVcsDriver; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + const secrets = yield* SecretResolver.SecretResolver; return SourceControlHost.SourceControlHost.of({ - settings: { get: serverSettings.getSettings, update: serverSettings.updateSettings }, + settings: { + get: serverSettings.getSettings.pipe( + Effect.flatMap((settings) => + resolveSourceControlHostSecrets(settings.sourceControlHosts, (source) => + resolveSettingsSecret(secrets, source), + ).pipe( + Effect.map((sourceControlHosts) => + sourceControlHosts === settings.sourceControlHosts + ? settings + : { ...settings, sourceControlHosts }, + ), + ), + ), + ), + update: serverSettings.updateSettings, + }, process: { run: process.run }, git: { execute: git.execute, @@ -35,4 +55,4 @@ export const layer = Layer.effect( }, }); }), -); +).pipe(Layer.provide(SecretResolverLayer.layer)); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts new file mode 100644 index 0000000000000..82e67228f7226 --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -0,0 +1,51 @@ +import { assert, it } from "@effect/vitest"; +import { SecretProviderId } from "@t3tools/contracts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { resolveSettingsSecret } from "../secrets/settingsSecretValue.ts"; +import { resolveSourceControlHostSecrets } from "./sourceControlHostSecrets.ts"; + +const source = (reference: string) => ({ + provider: SecretProviderId.make("homelab"), + reference, +}); + +const secretsByReference = new Map([["bitbucket#token", "bb_from_provider"]]); + +const layerResolver = Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => + Effect.succeed(references.map((reference) => secretsByReference.get(reference.reference))), + invalidate: () => Effect.void, +}); + +const fakeResolver = SecretResolver.SecretResolver.use((resolver) => Effect.succeed(resolver)); + +it.effect("replaces credential references with the secrets they name", () => + Effect.gen(function* () { + const resolver = yield* fakeResolver; + const hosts = { + bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, + gitcafe: { token: source("missing#token") }, + }; + const resolved = yield* resolveSourceControlHostSecrets(hosts, (value) => + resolveSettingsSecret(resolver, value), + ); + assert.deepEqual(resolved, { + bitbucket: { email: "me@example.test", accessToken: "bb_from_provider" }, + gitcafe: { token: "" }, + }); + }).pipe(Effect.provide(layerResolver)), +); + +it.effect("hands back the same settings when no credential names a secret", () => + Effect.gen(function* () { + const resolver = yield* fakeResolver; + const hosts = { gitcafe: { token: "literal" } }; + const resolved = yield* resolveSourceControlHostSecrets(hosts, (value) => + resolveSettingsSecret(resolver, value), + ); + assert.strictEqual(resolved, hosts); + }).pipe(Effect.provide(layerResolver)), +); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 46c96ca5b7f05..ce93d238196c7 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -1,4 +1,5 @@ -import { secretSettingsFields } from "@t3tools/contracts"; +import { isSecretReference, secretSettingsFields, type SecretReference } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; @@ -30,6 +31,7 @@ export function readSecretFieldValues( value: unknown, ): ReadonlyArray { if (typeof value === "string") return [[null, value]]; + if (isSecretReference(value)) return []; if (value === null || typeof value !== "object" || Array.isArray(value)) return []; return Object.entries(value).flatMap(([serverHost, entry]) => typeof entry === "string" ? [[serverHost.trim().toLowerCase(), entry] as const] : [], @@ -41,5 +43,31 @@ export function writeSecretFieldValues( values: ReadonlyArray, ): unknown { if (typeof original === "string") return values[0]?.[1] ?? ""; + if (isSecretReference(original)) return original; return Object.fromEntries(values.map(([serverHost, value]) => [serverHost ?? "", value])); } + +type SourceControlHostsSettings = Readonly>>>; + +/** Replaces each secret provider reference in a credential field with the secret it names. */ +export function resolveSourceControlHostSecrets( + hosts: SourceControlHostsSettings, + resolve: (source: SecretReference) => Effect.Effect, +): Effect.Effect { + const pending = Object.entries(hosts).flatMap(([kind, fields]) => + (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) => { + const value = fields[field]; + return isSecretReference(value) ? [{ kind, field, source: value }] : []; + }), + ); + if (pending.length === 0) return Effect.succeed(hosts); + return Effect.forEach(pending, ({ kind, field, source }) => + resolve(source).pipe(Effect.map((value) => ({ kind, field, value }))), + ).pipe( + Effect.map((fields) => { + const next: Record>> = { ...hosts }; + for (const { kind, field, value } of fields) next[kind] = { ...next[kind], [field]: value }; + return next; + }), + ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index d8b5c71fc0f98..63ade9fc40c76 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -487,7 +487,7 @@ export function ProviderEnvironmentSection(props: {
{nameInput} - {source === null ? null :
{source}
} + {source} {removeButton}
diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 0d408e59fee84..e49aa5599e0d8 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { makeProviderSettingsSchema, ProviderDriverKind, TrimmedString } from "@t3tools/contracts"; +import { + makeProviderSettingsSchema, + ProviderDriverKind, + SecretProviderId, + TrimmedString, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { providerClients } from "./providerDriverMeta"; @@ -56,6 +61,27 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ experimental: false }); }); + + it("stores a secret reference as written", () => { + const reference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Bitbucket/token", + }; + + const next = nextProviderConfigWithFieldValue( + { email: "me@example.com" }, + { + key: "accessToken", + control: "password", + label: "Access token", + secret: true, + clearWhenEmpty: "omit", + }, + reference, + ); + + expect(next).toEqual({ email: "me@example.com", accessToken: reference }); + }); }); describe("reconcileRowsDraft", () => { diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index a36f93fec7cdb..15a0563442bd7 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -2,11 +2,14 @@ import { useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + isSecretReference, isSecretSetting, + type EnvironmentId, type ProviderSettingsFormAnnotation, type ProviderSettingsFormControl, type ProviderSettingsFormOption, type ProviderSettingsFormSchemaAnnotation, + type SecretReference, } from "@t3tools/contracts"; import { PlusIcon, XIcon } from "lucide-react"; @@ -15,6 +18,7 @@ import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; import { SecretInput } from "./SecretInput"; +import { SecretSourceInput, useSecretSources } from "./SecretSourceInput"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; @@ -281,11 +285,16 @@ function readProviderConfigBoolean(config: unknown, key: string, defaultValue = export function nextProviderConfigWithFieldValue( config: unknown, field: ProviderSettingsFieldModel, - value: string | boolean, + value: string | boolean | SecretReference, ): Record | undefined { const base: Record = config !== null && typeof config === "object" ? { ...(config as Record) } : {}; + if (typeof value === "object") { + base[field.key] = value; + return base; + } + if (typeof value === "boolean") { const emptyBooleanValue = field.defaultBooleanValue ?? false; if (field.clearWhenEmpty === "omit" && value === emptyBooleanValue) { @@ -311,6 +320,61 @@ interface ProviderSettingsFormProps { readonly idPrefix: string; readonly variant: "card" | "dialog" | "settings"; readonly onChange: (nextConfig: Record | undefined) => void; + /** Lets secret fields in the settings variant read from a secret provider on this environment. */ + readonly environmentId?: EnvironmentId | undefined; +} + +function SecretSettingsControl({ + field, + value, + inputId, + descriptionId, + environmentId, + onChange, +}: { + readonly field: ProviderSettingsFieldModel; + readonly value: unknown; + readonly inputId: string; + readonly descriptionId: string | undefined; + readonly environmentId: EnvironmentId; + readonly onChange: ProviderSettingsFormProps["onChange"]; +}) { + const sources = useSecretSources(environmentId); + const saved = + value !== null && typeof value === "object" + ? (value as Record)[field.key] + : undefined; + const reference = isSecretReference(saved) ? saved : undefined; + const configured = reference !== undefined || (typeof saved === "string" && saved.length > 0); + return ( +
+ onChange(nextProviderConfigWithFieldValue(value, field, next))} + onSecretChange={(secret) => + onChange(nextProviderConfigWithFieldValue(value, field, secret ?? "")) + } + /> + {configured ? ( + + ) : null} +
+ ); } function ProviderSettingsSelect({ @@ -370,6 +434,7 @@ interface ProviderSettingsFieldRowProps { readonly idPrefix: string; readonly variant: ProviderSettingsFormProps["variant"]; readonly onChange: ProviderSettingsFormProps["onChange"]; + readonly environmentId: EnvironmentId | undefined; } function ProviderSettingsFieldRow({ @@ -378,6 +443,7 @@ function ProviderSettingsFieldRow({ idPrefix, variant, onChange, + environmentId, }: ProviderSettingsFieldRowProps) { const inputId = `${idPrefix}-${field.key}`; const descriptionClassName = @@ -424,6 +490,15 @@ function ProviderSettingsFieldRow({ placeholder={field.placeholder} spellCheck={false} /> + ) : field.secret && environmentId !== undefined ? ( + ) : field.secret ? (
))} {isLocalAcp ? : null} diff --git a/apps/web/src/components/settings/SecretSourceInput.tsx b/apps/web/src/components/settings/SecretSourceInput.tsx index 092e59b9dcd0f..2fed001b09e91 100644 --- a/apps/web/src/components/settings/SecretSourceInput.tsx +++ b/apps/web/src/components/settings/SecretSourceInput.tsx @@ -44,6 +44,10 @@ export function useSecretSources(environmentId: EnvironmentId): ReadonlyArray ({ id, kind: config.kind, label: config.label })); } +function SourceIcon(props: { readonly icon: ComponentType<{ readonly className?: string }> }) { + return ; +} + function SourceLabel(props: { readonly icon: ComponentType<{ readonly className?: string }>; readonly label: string; @@ -105,13 +109,14 @@ export function SecretSourceInput({ setPendingProvider(next === secret?.provider ? null : next); }} > - + - {provider === null ? ( - - ) : ( - - )} + @@ -178,7 +183,7 @@ export function SecretSourceInput({ if (source === null) return valueField; return (
-
{source}
+ {source} {valueField}
); diff --git a/apps/web/src/components/settings/SourceControlHostSettings.tsx b/apps/web/src/components/settings/SourceControlHostSettings.tsx index 97e4f8d9953d8..34c04c51104c4 100644 --- a/apps/web/src/components/settings/SourceControlHostSettings.tsx +++ b/apps/web/src/components/settings/SourceControlHostSettings.tsx @@ -48,10 +48,13 @@ export function SourceControlHostSettings({ value={saved ?? {}} idPrefix={`source-control-${definition.kind}-${environmentId}`} variant="settings" + environmentId={environmentId} onChange={(next) => { const fields = next ?? {}; const changed = Object.fromEntries( - Object.entries(fields).filter(([key, value]) => saved?.[key] !== value), + Object.entries(fields).filter( + ([key, value]) => JSON.stringify(saved?.[key]) !== JSON.stringify(value), + ), ); const cleared = Object.keys(saved ?? {}).filter((key) => !(key in fields)); void save({ ...changed, ...Object.fromEntries(cleared.map((key) => [key, ""])) }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 30bf25598d55d..f00976d243b37 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -68,4 +68,5 @@ export * from "./rpc.ts"; export * from "./worktreeSetup.ts"; export * from "./secretRequest.ts"; export * from "./secretReference.ts"; +export * from "./settingsSecretValue.ts"; export * from "./clientRpcPermissions.ts"; diff --git a/packages/contracts/src/settingsSecretValue.ts b/packages/contracts/src/settingsSecretValue.ts new file mode 100644 index 0000000000000..7104e7522ac65 --- /dev/null +++ b/packages/contracts/src/settingsSecretValue.ts @@ -0,0 +1,19 @@ +import * as Schema from "effect/Schema"; + +import { TrimmedString } from "./baseSchemas.ts"; +import { SecretReference } from "./secretReference.ts"; + +/** + * A credential setting: the literal, which is kept in the secret store and redacted for + * clients, or the secret reference to read it from, which is not itself a secret and is + * stored and shown as written. + */ +export const SettingsSecretValue = Schema.Union([TrimmedString, SecretReference]); +export type SettingsSecretValue = typeof SettingsSecretValue.Type; + +export const isSecretReference = Schema.is(SecretReference); + +/** The literal a credential setting holds, or `""` when it names a secret instead. */ +export function settingsSecretLiteral(value: SettingsSecretValue | undefined): string { + return typeof value === "string" ? value : ""; +} diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 863b206d95578..9f6de0e96d757 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -9,6 +9,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { NonNegativeInt, + settingsSecretLiteral, SourceControlProviderKind, TrimmedNonEmptyString, type SourceControlProviderAuth, @@ -557,7 +558,11 @@ function resolveCredential( env: Config.Success, ): BitbucketCredential | null { return ( - credentialFrom(settings) ?? + credentialFrom({ + accessToken: settingsSecretLiteral(settings.accessToken), + email: settings.email, + apiToken: settingsSecretLiteral(settings.apiToken), + }) ?? credentialFrom({ accessToken: Option.getOrElse(env.accessToken, () => ""), email: Option.getOrElse(env.email, () => ""), diff --git a/packages/source-control-bitbucket/src/shared/definition.ts b/packages/source-control-bitbucket/src/shared/definition.ts index a8e786cb0ba6e..86522f4d8c960 100644 --- a/packages/source-control-bitbucket/src/shared/definition.ts +++ b/packages/source-control-bitbucket/src/shared/definition.ts @@ -1,5 +1,6 @@ import { makeProviderSettingsSchema, + SettingsSecretValue, SourceControlProviderKind, TrimmedString, } from "@t3tools/contracts"; @@ -40,26 +41,32 @@ const text = (annotations: Schema.Annotations.Key) => Schema.annotateKey(annotations), ); +const secret = (annotations: Schema.Annotations.Key) => + SettingsSecretValue.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + ...annotations, + settingsSecret: true, + providerSettingsForm: { control: "password" }, + }), + ); + export const settings = makeProviderSettingsSchema( { - accessToken: text({ + accessToken: secret({ title: "Access token", description: "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.", - settingsSecret: true, - providerSettingsForm: { control: "password" }, }), email: text({ title: "Atlassian account email", description: "With an API token instead of an access token.", providerSettingsForm: { placeholder: "you@example.com" }, }), - apiToken: text({ + apiToken: secret({ title: "API token", description: "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", - settingsSecret: true, - providerSettingsForm: { control: "password" }, }), }, { order: ["accessToken", "email", "apiToken"] }, diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts index bf0eb2eda4dff..7cf2a32009ef3 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -1,3 +1,4 @@ +import { settingsSecretLiteral } from "@t3tools/contracts"; import { normalizeHost } from "@t3tools/source-control-core/server/pullRequestUtils"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -131,10 +132,12 @@ export const make = Effect.gen(function* () { : sourceControlHost.settings.get.pipe( Effect.map( (settings) => - readSourceControlHostSettings( - GitCafeClient.settings, - settings.sourceControlHosts[GitCafeClient.definition.kind], - ).token || null, + settingsSecretLiteral( + readSourceControlHostSettings( + GitCafeClient.settings, + settings.sourceControlHosts[GitCafeClient.definition.kind], + ).token, + ) || null, ), Effect.orElseSucceed(() => null), ); diff --git a/packages/source-control-gitcafe/src/shared/definition.ts b/packages/source-control-gitcafe/src/shared/definition.ts index a7b22261b2b4b..fce36d4a0b52d 100644 --- a/packages/source-control-gitcafe/src/shared/definition.ts +++ b/packages/source-control-gitcafe/src/shared/definition.ts @@ -1,8 +1,8 @@ import { makeProviderSettingsSchema, secretSetting, + SettingsSecretValue, SourceControlProviderKind, - TrimmedString, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -34,7 +34,7 @@ function parseChangeRequestUrl(url: URL) { const KIND = SourceControlProviderKind.make("gitcafe"); export const settings = makeProviderSettingsSchema({ - token: TrimmedString.pipe( + token: SettingsSecretValue.pipe( secretSetting, Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ From 768311b2e434a514407bf22354517655d1bca8a9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 15:09:26 -0400 Subject: [PATCH 02/10] refactor(web): compare source control fields structurally Signed-off-by: Yordis Prieto --- .../src/components/settings/SourceControlHostSettings.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/SourceControlHostSettings.tsx b/apps/web/src/components/settings/SourceControlHostSettings.tsx index 34c04c51104c4..92e62a7bc302c 100644 --- a/apps/web/src/components/settings/SourceControlHostSettings.tsx +++ b/apps/web/src/components/settings/SourceControlHostSettings.tsx @@ -1,5 +1,6 @@ import type { EnvironmentId } from "@t3tools/contracts"; import type { SourceControlDefinition } from "@t3tools/client-runtime/source-control-clients"; +import * as Equal from "effect/Equal"; import { useState } from "react"; import { useEnvironmentSettings } from "../../hooks/useSettings"; @@ -52,9 +53,7 @@ export function SourceControlHostSettings({ onChange={(next) => { const fields = next ?? {}; const changed = Object.fromEntries( - Object.entries(fields).filter( - ([key, value]) => JSON.stringify(saved?.[key]) !== JSON.stringify(value), - ), + Object.entries(fields).filter(([key, value]) => !Equal.equals(saved?.[key], value)), ); const cleared = Object.keys(saved ?? {}).filter((key) => !(key in fields)); void save({ ...changed, ...Object.fromEntries(cleared.map((key) => [key, ""])) }); From 5b4af8790f6892980174f0eacfbce5c31ba09890 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 15:18:37 -0400 Subject: [PATCH 03/10] fix(server): keep GitHub settings reads from waiting on other hosts' secret providers Signed-off-by: Yordis Prieto --- .../server/src/secrets/settingsSecretValue.ts | 11 +++--- .../sourceControl/ServerSourceControlHost.ts | 22 ++++++----- .../sourceControl/sourceControlHostSecrets.ts | 39 +++++++++---------- .../src/server/BitbucketApi.ts | 2 +- .../src/server/SourceControlHost.ts | 6 +++ .../src/server/GitCafeCredentials.ts | 2 +- .../src/TestSourceControlHost.ts | 2 +- 7 files changed, 45 insertions(+), 39 deletions(-) diff --git a/apps/server/src/secrets/settingsSecretValue.ts b/apps/server/src/secrets/settingsSecretValue.ts index 5b8370111a2b7..cc78d0d6aef42 100644 --- a/apps/server/src/secrets/settingsSecretValue.ts +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -3,10 +3,11 @@ import type * as SecretResolver from "@t3tools/secret-provider-core/server/Secre import * as Effect from "effect/Effect"; /** An unreadable secret comes back as `""`, so a credential that is missing behaves as unset. */ -export const resolveSettingsSecret = ( +export const resolveSettingsSecret = Effect.fn("SettingsSecrets.resolve")(function* ( resolver: SecretResolver.SecretResolver["Service"], value: SettingsSecretValue, -): Effect.Effect => - !isSecretReference(value) - ? Effect.succeed(value) - : resolver.read([value]).pipe(Effect.map(([secret]) => secret ?? "")); +) { + if (!isSecretReference(value)) return value; + const [secret] = yield* resolver.read([value]); + return secret ?? ""; +}); diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts index 2f269c3e98a51..d34f77107ef67 100644 --- a/apps/server/src/sourceControl/ServerSourceControlHost.ts +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -21,19 +21,21 @@ export const layer = Layer.effect( const secrets = yield* SecretResolver.SecretResolver; return SourceControlHost.SourceControlHost.of({ settings: { - get: serverSettings.getSettings.pipe( - Effect.flatMap((settings) => - resolveSourceControlHostSecrets(settings.sourceControlHosts, (source) => - resolveSettingsSecret(secrets, source), - ).pipe( - Effect.map((sourceControlHosts) => - sourceControlHosts === settings.sourceControlHosts - ? settings - : { ...settings, sourceControlHosts }, + get: serverSettings.getSettings, + getWithSecrets: (kind) => + serverSettings.getSettings.pipe( + Effect.flatMap((settings) => + resolveSourceControlHostSecrets(settings.sourceControlHosts, kind, (source) => + resolveSettingsSecret(secrets, source), + ).pipe( + Effect.map((sourceControlHosts) => + sourceControlHosts === settings.sourceControlHosts + ? settings + : { ...settings, sourceControlHosts }, + ), ), ), ), - ), update: serverSettings.updateSettings, }, process: { run: process.run }, diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index ce93d238196c7..a4dad0f82b590 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -49,25 +49,22 @@ export function writeSecretFieldValues( type SourceControlHostsSettings = Readonly>>>; -/** Replaces each secret provider reference in a credential field with the secret it names. */ -export function resolveSourceControlHostSecrets( - hosts: SourceControlHostsSettings, - resolve: (source: SecretReference) => Effect.Effect, -): Effect.Effect { - const pending = Object.entries(hosts).flatMap(([kind, fields]) => - (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) => { +/** Replaces each secret provider reference in one host kind's credential fields with the secret it names. */ +export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.resolveSecrets")( + function* ( + hosts: SourceControlHostsSettings, + kind: string, + resolve: (source: SecretReference) => Effect.Effect, + ) { + const fields = hosts[kind] ?? {}; + const pending = (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) => { const value = fields[field]; - return isSecretReference(value) ? [{ kind, field, source: value }] : []; - }), - ); - if (pending.length === 0) return Effect.succeed(hosts); - return Effect.forEach(pending, ({ kind, field, source }) => - resolve(source).pipe(Effect.map((value) => ({ kind, field, value }))), - ).pipe( - Effect.map((fields) => { - const next: Record>> = { ...hosts }; - for (const { kind, field, value } of fields) next[kind] = { ...next[kind], [field]: value }; - return next; - }), - ); -} + return isSecretReference(value) ? [{ field, source: value }] : []; + }); + if (pending.length === 0) return hosts; + const resolved = yield* Effect.forEach(pending, ({ field, source }) => + resolve(source).pipe(Effect.map((value) => [field, value] as const)), + ); + return { ...hosts, [kind]: { ...fields, ...Object.fromEntries(resolved) } }; + }, +); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 9f6de0e96d757..3bd86f505ec1f 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -653,7 +653,7 @@ export const make = Effect.gen(function* () { const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; - const currentCredential = host.settings.get.pipe( + const currentCredential = host.settings.getWithSecrets(BitbucketClient.definition.kind).pipe( Effect.map((settings) => resolveCredential( readSourceControlHostSettings( diff --git a/packages/source-control-core/src/server/SourceControlHost.ts b/packages/source-control-core/src/server/SourceControlHost.ts index 79efb54b26a5d..5d5f72dd04447 100644 --- a/packages/source-control-core/src/server/SourceControlHost.ts +++ b/packages/source-control-core/src/server/SourceControlHost.ts @@ -68,6 +68,12 @@ export class SourceControlHost extends Context.Service< readonly settings: { /** Read fresh on each call to pick up credential changes. */ readonly get: Effect.Effect; + /** + * Like `get`, with each secret provider reference in one host kind's credentials replaced + * by the secret it names. An unreadable reference reads as unset. Only a reader of that + * kind should call it: resolving can wait on a secret provider. + */ + readonly getWithSecrets: (kind: string) => Effect.Effect; /** For credentials the host obtains itself, such as a sign-in it refreshes. */ readonly update: ( patch: ServerSettingsPatch, diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts index 7cf2a32009ef3..7823924a4ad69 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -129,7 +129,7 @@ export const make = Effect.gen(function* () { const fromSettings = (host: string) => normalizeHost(host) !== DEFAULT_HOST ? Effect.succeed(null) - : sourceControlHost.settings.get.pipe( + : sourceControlHost.settings.getWithSecrets(GitCafeClient.definition.kind).pipe( Effect.map( (settings) => settingsSecretLiteral( diff --git a/packages/source-control-testing/src/TestSourceControlHost.ts b/packages/source-control-testing/src/TestSourceControlHost.ts index 3f1ef6c2b4c2b..37d491d8cc52d 100644 --- a/packages/source-control-testing/src/TestSourceControlHost.ts +++ b/packages/source-control-testing/src/TestSourceControlHost.ts @@ -58,7 +58,7 @@ export const layer = ( const update = (patch: ServerSettingsPatch) => Ref.updateAndGet(settings, (current) => applyServerSettingsPatch(current, patch)); const host = SourceControlHost.SourceControlHost.of({ - settings: { get: Ref.get(settings), update }, + settings: { get: Ref.get(settings), getWithSecrets: () => Ref.get(settings), update }, process: { run: options.process?.run ?? From 8ca44fb0a69383be336db024389af21b49d6a667 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 15:18:54 -0400 Subject: [PATCH 04/10] test(server): drop trivial secret reference cases and cover host-scoped resolution Signed-off-by: Yordis Prieto --- apps/server/src/serverSettings.test.ts | 48 +++++++---------- .../sourceControlHostSecrets.test.ts | 53 +++++++++++-------- .../settings/ProviderSettingsForm.test.ts | 28 +--------- 3 files changed, 52 insertions(+), 77 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 9c090ff320ce1..ef8b4d6afae79 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1533,36 +1533,26 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); - }).pipe(Effect.provide(layerServerSettingsWithSecrets())), - ); - - it.effect("keeps a credential reference as written and drops the literal it replaces", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const secrets = yield* ServerSecretStore.ServerSecretStore; - const reference = { - provider: SecretProviderId.make("personal"), - reference: "op://Private/Bitbucket/token", - }; - const stored = (kind: string, field: string) => - secrets.get(sourceControlHostSecretName({ kind, field, serverHost: null })); - - yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: "bb-access" } }, - }); - assert.isTrue(Option.isSome(yield* stored("bitbucket", "accessToken"))); - const saved = yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: reference } }, - }); - assert.deepEqual(saved.sourceControlHosts.bitbucket?.accessToken, reference); - assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken"))); - assert.deepEqual( - ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts.bitbucket - ?.accessToken, - reference, - ); - }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + const reference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Bitbucket/token", + }; + yield* serverSettings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: "bb-access" } }, + }); + assert.isTrue(Option.isSome(yield* stored("bitbucket", "accessToken", null))); + const referenced = yield* serverSettings.updateSettings({ + sourceControlHosts: { bitbucket: { accessToken: reference } }, + }); + assert.deepEqual(referenced.sourceControlHosts.bitbucket?.accessToken, reference); + assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null))); + assert.deepEqual( + ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts + .bitbucket?.accessToken, + reference, + ); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); it.effect("keeps the retired keys for another load when a token cannot be copied", () => { diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index 82e67228f7226..92862035a5503 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -22,30 +22,41 @@ const layerResolver = Layer.succeed(SecretResolver.SecretResolver, { const fakeResolver = SecretResolver.SecretResolver.use((resolver) => Effect.succeed(resolver)); -it.effect("replaces credential references with the secrets they name", () => - Effect.gen(function* () { - const resolver = yield* fakeResolver; - const hosts = { - bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, - gitcafe: { token: source("missing#token") }, - }; - const resolved = yield* resolveSourceControlHostSecrets(hosts, (value) => - resolveSettingsSecret(resolver, value), - ); - assert.deepEqual(resolved, { - bitbucket: { email: "me@example.test", accessToken: "bb_from_provider" }, - gitcafe: { token: "" }, - }); - }).pipe(Effect.provide(layerResolver)), +const hosts = { + bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, + gitcafe: { token: source("missing#token") }, + github: { tokens: { "github.com": "ghp_literal" } }, +}; + +const countingResolver = Effect.gen(function* () { + const resolver = yield* fakeResolver; + const reads: Array = []; + const resolve = (value: Parameters[1]) => + Effect.sync(() => { + if (typeof value !== "string") reads.push(value.reference); + }).pipe(Effect.andThen(resolveSettingsSecret(resolver, value))); + return { reads, resolve }; +}); + +it.effect( + "replaces only the requested kind's credential references with the secrets they name", + () => + Effect.gen(function* () { + const { reads, resolve } = yield* countingResolver; + const resolved = yield* resolveSourceControlHostSecrets(hosts, "bitbucket", resolve); + assert.deepEqual(resolved, { + ...hosts, + bitbucket: { email: "me@example.test", accessToken: "bb_from_provider" }, + }); + assert.deepEqual(reads, ["bitbucket#token"]); + }).pipe(Effect.provide(layerResolver)), ); -it.effect("hands back the same settings when no credential names a secret", () => +it.effect("leaves the resolver unused for a kind that holds no reference", () => Effect.gen(function* () { - const resolver = yield* fakeResolver; - const hosts = { gitcafe: { token: "literal" } }; - const resolved = yield* resolveSourceControlHostSecrets(hosts, (value) => - resolveSettingsSecret(resolver, value), - ); + const { reads, resolve } = yield* countingResolver; + const resolved = yield* resolveSourceControlHostSecrets(hosts, "github", resolve); assert.strictEqual(resolved, hosts); + assert.deepEqual(reads, []); }).pipe(Effect.provide(layerResolver)), ); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index e49aa5599e0d8..0d408e59fee84 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { - makeProviderSettingsSchema, - ProviderDriverKind, - SecretProviderId, - TrimmedString, -} from "@t3tools/contracts"; +import { makeProviderSettingsSchema, ProviderDriverKind, TrimmedString } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { providerClients } from "./providerDriverMeta"; @@ -61,27 +56,6 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ experimental: false }); }); - - it("stores a secret reference as written", () => { - const reference = { - provider: SecretProviderId.make("personal"), - reference: "op://Private/Bitbucket/token", - }; - - const next = nextProviderConfigWithFieldValue( - { email: "me@example.com" }, - { - key: "accessToken", - control: "password", - label: "Access token", - secret: true, - clearWhenEmpty: "omit", - }, - reference, - ); - - expect(next).toEqual({ email: "me@example.com", accessToken: reference }); - }); }); describe("reconcileRowsDraft", () => { From 868b87fd0c0e1c4959edee42fdd0f8e4d440a2ea Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 15:28:21 -0400 Subject: [PATCH 05/10] fix(server): let the Settings refresh retry source control host secrets that failed or rotated Signed-off-by: Yordis Prieto --- apps/server/src/server.ts | 2 + .../sourceControlHostSecrets.test.ts | 55 ++++++++++++++++++- .../sourceControl/sourceControlHostSecrets.ts | 12 ++++ apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 12 ++++ 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 741fc73103b7d..452addfb4a667 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -64,6 +64,7 @@ import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts" import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts"; import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControl/sourceControlDefinitions.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; +import * as SecretResolver from "./secrets/SecretResolver.ts"; import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -628,6 +629,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerOrchestrationApplication), Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), + Layer.provideMerge(SecretResolver.layer), Layer.provideMerge(layerServerSettings), Layer.provideMerge(layerSourceControlProviderRegistry), Layer.provideMerge(layerGit), diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index 92862035a5503..6cc06be4f9ccf 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -5,7 +5,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { resolveSettingsSecret } from "../secrets/settingsSecretValue.ts"; -import { resolveSourceControlHostSecrets } from "./sourceControlHostSecrets.ts"; +import { + collectSourceControlHostSecretReferences, + resolveSourceControlHostSecrets, +} from "./sourceControlHostSecrets.ts"; const source = (reference: string) => ({ provider: SecretProviderId.make("homelab"), @@ -60,3 +63,53 @@ it.effect("leaves the resolver unused for a kind that holds no reference", () => assert.deepEqual(reads, []); }).pipe(Effect.provide(layerResolver)), ); + +it("collects the references held in credential fields and skips literal values", () => { + assert.deepEqual(collectSourceControlHostSecretReferences(hosts), [ + source("missing#token"), + source("bitbucket#token"), + ]); + assert.deepEqual( + collectSourceControlHostSecretReferences({ + bitbucket: { accessToken: "literal" }, + github: { tokens: { "github.com": "ghp_literal" } }, + }), + [], + ); +}); + +it.effect("reads a collected reference again once it is invalidated", () => + Effect.gen(function* () { + let current = "stale"; + const cache = new Map(); + const resolver = SecretResolver.SecretResolver.of({ + read: (references) => + Effect.sync(() => + references.map((reference) => { + const hit = cache.get(reference.reference); + if (hit !== undefined) return hit; + cache.set(reference.reference, current); + return current; + }), + ), + invalidate: (references) => + Effect.sync(() => { + for (const reference of references ?? []) cache.delete(reference.reference); + }), + }); + const read = () => + resolveSourceControlHostSecrets(hosts, "bitbucket", (reference) => + resolveSettingsSecret(resolver, reference), + ); + assert.deepEqual((yield* read()).bitbucket, { + email: "me@example.test", + accessToken: "stale", + }); + current = "rotated"; + yield* resolver.invalidate(collectSourceControlHostSecretReferences(hosts)); + assert.deepEqual((yield* read()).bitbucket, { + email: "me@example.test", + accessToken: "rotated", + }); + }), +); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index a4dad0f82b590..b3c221c65db89 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -49,6 +49,18 @@ export function writeSecretFieldValues( type SourceControlHostsSettings = Readonly>>>; +/** Every secret provider reference held in any host kind's credential fields. */ +export function collectSourceControlHostSecretReferences( + hosts: SourceControlHostsSettings, +): ReadonlyArray { + return [...SOURCE_CONTROL_HOST_SECRET_FIELDS].flatMap(([kind, fields]) => + fields.flatMap((field) => { + const value = hosts[kind]?.[field]; + return isSecretReference(value) ? [value] : []; + }), + ); +} + /** Replaces each secret provider reference in one host kind's credential fields with the secret it names. */ export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.resolveSecrets")( function* ( diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2c6adce82f390..f226ef3bd3ce9 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1,3 +1,4 @@ +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -528,6 +529,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(SecretResolver.SecretResolver)({}), Layer.mock(GuestShareService.GuestShareService)({}), Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({}), Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index befad768da3e5..8d3722cef18ec 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -180,7 +180,9 @@ import { makeProviderInstallation } from "./provider/providerInstallation.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as ServerSettings from "./serverSettings.ts"; +import { collectSourceControlHostSecretReferences } from "./sourceControl/sourceControlHostSecrets.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -1335,6 +1337,7 @@ export const layerWsRpc = ( const storageCleanup = yield* StorageCleanup.StorageCleanup; const databaseStorage = yield* DatabaseStorage.DatabaseStorage; const serverSettings = yield* ServerSettings.ServerSettingsService; + const secretResolver = yield* SecretResolver.SecretResolver; const peerLinks = yield* PeerLinks.PeerLinks; const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; @@ -2309,6 +2312,15 @@ export const layerWsRpc = ( if (input.instanceId === undefined) { yield* usageLimitSources.refresh; } + if (input.reloadSecrets === true && input.instanceId === undefined) { + const references = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => + collectSourceControlHostSecretReferences(settings.sourceControlHosts), + ), + Effect.orElseSucceed(() => []), + ); + yield* secretResolver.invalidate(references); + } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, From c4f5992e9915f5e8e4077cf510717af324c2b7a4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 15:36:41 -0400 Subject: [PATCH 06/10] test(server): cover source control credentials whose secret cannot be read Signed-off-by: Yordis Prieto --- .../src/sourceControl/sourceControlHostSecrets.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index 6cc06be4f9ccf..6eebbf0ae0423 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -55,6 +55,15 @@ it.effect( }).pipe(Effect.provide(layerResolver)), ); +it.effect("treats a reference the provider cannot read as an unset credential", () => + Effect.gen(function* () { + const { reads, resolve } = yield* countingResolver; + const resolved = yield* resolveSourceControlHostSecrets(hosts, "gitcafe", resolve); + assert.deepEqual(resolved.gitcafe, { token: "" }); + assert.deepEqual(reads, ["missing#token"]); + }).pipe(Effect.provide(layerResolver)), +); + it.effect("leaves the resolver unused for a kind that holds no reference", () => Effect.gen(function* () { const { reads, resolve } = yield* countingResolver; From 72a1e3f9ee941769a67cc01a4260ad2b05f4be56 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 16:00:41 -0400 Subject: [PATCH 07/10] fix(server): a Settings refresh clears every cached secret when settings cannot be read Signed-off-by: Yordis Prieto --- .../server/src/secrets/settingsSecretValue.ts | 20 ++++++++++++++++++- .../sourceControlHostSecrets.test.ts | 16 ++++++++++++--- apps/server/src/ws.ts | 10 ++-------- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/server/src/secrets/settingsSecretValue.ts b/apps/server/src/secrets/settingsSecretValue.ts index cc78d0d6aef42..3f20f64aa4c39 100644 --- a/apps/server/src/secrets/settingsSecretValue.ts +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -1,7 +1,13 @@ -import { isSecretReference, type SettingsSecretValue } from "@t3tools/contracts"; +import { + isSecretReference, + type ServerSettings, + type SettingsSecretValue, +} from "@t3tools/contracts"; import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; +import { collectSourceControlHostSecretReferences } from "../sourceControl/sourceControlHostSecrets.ts"; + /** An unreadable secret comes back as `""`, so a credential that is missing behaves as unset. */ export const resolveSettingsSecret = Effect.fn("SettingsSecrets.resolve")(function* ( resolver: SecretResolver.SecretResolver["Service"], @@ -11,3 +17,15 @@ export const resolveSettingsSecret = Effect.fn("SettingsSecrets.resolve")(functi const [secret] = yield* resolver.read([value]); return secret ?? ""; }); + +/** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ +export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* ( + resolver: SecretResolver.SecretResolver["Service"], + settings: Effect.Effect, E>, +) { + const references = yield* settings.pipe( + Effect.map((current) => collectSourceControlHostSecretReferences(current.sourceControlHosts)), + Effect.orElseSucceed(() => undefined), + ); + yield* resolver.invalidate(references); +}); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index 6eebbf0ae0423..d7883668c4d41 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -4,7 +4,10 @@ import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretReso import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { resolveSettingsSecret } from "../secrets/settingsSecretValue.ts"; +import { + invalidateSettingsSecrets, + resolveSettingsSecret, +} from "../secrets/settingsSecretValue.ts"; import { collectSourceControlHostSecretReferences, resolveSourceControlHostSecrets, @@ -103,7 +106,8 @@ it.effect("reads a collected reference again once it is invalidated", () => ), invalidate: (references) => Effect.sync(() => { - for (const reference of references ?? []) cache.delete(reference.reference); + if (references === undefined) cache.clear(); + else for (const reference of references) cache.delete(reference.reference); }), }); const read = () => @@ -115,10 +119,16 @@ it.effect("reads a collected reference again once it is invalidated", () => accessToken: "stale", }); current = "rotated"; - yield* resolver.invalidate(collectSourceControlHostSecretReferences(hosts)); + yield* invalidateSettingsSecrets(resolver, Effect.succeed({ sourceControlHosts: hosts })); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "rotated", }); + current = "rotated again"; + yield* invalidateSettingsSecrets(resolver, Effect.fail("settings unreadable")); + assert.deepEqual((yield* read()).bitbucket, { + email: "me@example.test", + accessToken: "rotated again", + }); }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8d3722cef18ec..6513350f323ad 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -182,7 +182,7 @@ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as ServerSettings from "./serverSettings.ts"; -import { collectSourceControlHostSecretReferences } from "./sourceControl/sourceControlHostSecrets.ts"; +import { invalidateSettingsSecrets } from "./secrets/settingsSecretValue.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -2313,13 +2313,7 @@ export const layerWsRpc = ( yield* usageLimitSources.refresh; } if (input.reloadSecrets === true && input.instanceId === undefined) { - const references = yield* serverSettings.getSettings.pipe( - Effect.map((settings) => - collectSourceControlHostSecretReferences(settings.sourceControlHosts), - ), - Effect.orElseSucceed(() => []), - ); - yield* secretResolver.invalidate(references); + yield* invalidateSettingsSecrets(secretResolver, serverSettings.getSettings); } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined ? providerRegistry.refreshWorkspaceSnapshot({ From 6bc122971418b8be92a757c602ce4b0995bc403f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 16:36:32 -0400 Subject: [PATCH 08/10] fix(secrets): a secret already read no longer waits behind another provider's unlock Signed-off-by: Yordis Prieto --- .../src/server/SecretResolver.test.ts | 43 +++++++-- .../src/server/SecretResolver.ts | 89 +++++++++++-------- 2 files changed, 92 insertions(+), 40 deletions(-) diff --git a/packages/secret-provider-core/src/server/SecretResolver.test.ts b/packages/secret-provider-core/src/server/SecretResolver.test.ts index d3c44aa14eaf7..573fcfa4f655a 100644 --- a/packages/secret-provider-core/src/server/SecretResolver.test.ts +++ b/packages/secret-provider-core/src/server/SecretResolver.test.ts @@ -6,7 +6,9 @@ import { type SecretReference, type ServerSettings, } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -26,7 +28,10 @@ const fake = (account: string) => ({ }); const ref = (reference: string, provider = personal): SecretReference => ({ provider, reference }); -function fakeDriver(answer: (account: string, reference: string) => string | undefined) { +function fakeDriver( + answer: (account: string, reference: string) => string | undefined, + wait: (references: ReadonlyArray) => Effect.Effect = () => Effect.void, +) { const reads: Array<{ readonly account: string; readonly references: ReadonlyArray }> = []; const driver = defineSecretProviderDriver({ kind: FAKE_KIND, @@ -39,10 +44,14 @@ function fakeDriver(answer: (account: string, reference: string) => string | und invalidReferenceMessage: "Use fake://item.", }), read: ({ account }, references) => - Effect.sync(() => { - reads.push({ account, references }); - return references.map((reference) => answer(account, reference)); - }), + wait(references).pipe( + Effect.andThen( + Effect.sync(() => { + reads.push({ account, references }); + return references.map((reference) => answer(account, reference)); + }), + ), + ), }); return { driver, reads }; } @@ -87,6 +96,30 @@ describe("SecretResolver", () => { }).pipe(Effect.provide(resolverLayer(driver))); }); + it.effect("answers a cached reference while another reference waits on its provider", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const unlock = yield* Deferred.make(); + const { driver } = fakeDriver( + () => "sk-live", + (references) => + references.includes("fake://slow") + ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(unlock))) + : Effect.void, + ); + yield* Effect.gen(function* () { + const resolver = yield* SecretResolver.SecretResolver; + yield* resolver.read([ref("fake://cached")]); + const slow = yield* Effect.forkChild(resolver.read([ref("fake://slow")])); + yield* Deferred.await(started); + + assert.deepStrictEqual(yield* resolver.read([ref("fake://cached")]), ["sk-live"]); + yield* Deferred.succeed(unlock, undefined); + assert.deepStrictEqual(yield* Fiber.join(slow), ["sk-live"]); + }).pipe(Effect.provide(resolverLayer(driver))); + }), + ); + it.effect("invalidates every reference when none are named", () => { const { driver, reads } = fakeDriver(() => "sk-live"); return Effect.gen(function* () { diff --git a/packages/secret-provider-core/src/server/SecretResolver.ts b/packages/secret-provider-core/src/server/SecretResolver.ts index 28f5816f953b9..d7b68c316a8a1 100644 --- a/packages/secret-provider-core/src/server/SecretResolver.ts +++ b/packages/secret-provider-core/src/server/SecretResolver.ts @@ -71,44 +71,63 @@ export const make = Effect.fn("SecretResolver.make")(function* (options: { : { driver, config: config.value, configKey: JSON.stringify(config.value) }; }; - const read: SecretResolver["Service"]["read"] = Effect.fn("SecretResolver.read")( - function* (references) { - const providers = yield* options.secretProviders; - const plans = references.map((reference) => planFor(providers, reference)); + const isCached = (reference: SecretReference, plan: ReadablePlan) => + cache.get(cacheKey(reference))?.config === plan.configKey; - const wanted = new Map< - string, - { readonly plan: ReadablePlan; readonly byReference: Map> } - >(); - references.forEach((reference, index) => { - const plan = plans[index]; - if (plan === undefined || cache.get(cacheKey(reference))?.config === plan.configKey) return; - const group = wanted.get(reference.provider) ?? { plan, byReference: new Map() }; - group.byReference.set(reference.reference, [ - ...(group.byReference.get(reference.reference) ?? []), - reference, - ]); - wanted.set(reference.provider, group); - }); - for (const { plan, byReference } of wanted.values()) { - const values = yield* plan.driver.read(plan.config, [...byReference.keys()]); - [...byReference.values()].forEach((sameReference, index) => { - for (const reference of sameReference) { - cache.set(cacheKey(reference), { config: plan.configKey, value: values[index] }); + const answer = ( + references: ReadonlyArray, + plans: ReadonlyArray, + ) => + references.map((reference, index) => { + const plan = plans[index]; + return plan !== undefined && isCached(reference, plan) + ? cache.get(cacheKey(reference))?.value + : undefined; + }); + + // Cache hits answer without the lock, so a read waiting on an unlock never stalls a cached one. + const read: SecretResolver["Service"]["read"] = Effect.fn("SecretResolver.read")(function* ( + references, + ) { + const providers = yield* options.secretProviders; + const plans = references.map((reference) => planFor(providers, reference)); + const isMissing = (reference: SecretReference, index: number) => { + const plan = plans[index]; + return plan !== undefined && !isCached(reference, plan); + }; + if (!references.some(isMissing)) return answer(references, plans); + + return yield* semaphore.withPermits(1)( + Effect.gen(function* () { + const wanted = new Map< + string, + { + readonly plan: ReadablePlan; + readonly byReference: Map>; } + >(); + references.forEach((reference, index) => { + const plan = plans[index]; + if (plan === undefined || !isMissing(reference, index)) return; + const group = wanted.get(reference.provider) ?? { plan, byReference: new Map() }; + group.byReference.set(reference.reference, [ + ...(group.byReference.get(reference.reference) ?? []), + reference, + ]); + wanted.set(reference.provider, group); }); - } - - return references.map((reference, index) => { - const cached = cache.get(cacheKey(reference)); - return plans[index] !== undefined && cached?.config === plans[index].configKey - ? cached.value - : undefined; - }); - }, - (effect) => semaphore.withPermits(1)(effect), - Effect.provideContext(context), - ); + for (const { plan, byReference } of wanted.values()) { + const values = yield* plan.driver.read(plan.config, [...byReference.keys()]); + [...byReference.values()].forEach((sameReference, index) => { + for (const reference of sameReference) { + cache.set(cacheKey(reference), { config: plan.configKey, value: values[index] }); + } + }); + } + return answer(references, plans); + }), + ); + }, Effect.provideContext(context)); const invalidate: SecretResolver["Service"]["invalidate"] = (references) => semaphore.withPermits(1)( From bf47f29008b246f15fd9a645a9bf6abf20268c9c Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 16:53:18 -0400 Subject: [PATCH 09/10] test(web): office preview test no longer races on a shared response body Signed-off-by: Yordis Prieto --- .../files/AttachmentFilePreview.browser.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 7492313f3c7e8..f82c49f80980b 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -1930,13 +1930,13 @@ it("reloads a workspace Word file after a preview remount", async () => { it("clears an Office metadata error when the same URL becomes previewable", async () => { const src = "data:application/octet-stream,invalid-office-file"; - let resolveResponse!: (response: Response) => void; - const response = new Promise((resolve) => { - resolveResponse = resolve; + let resolveBody!: (body: string) => void; + const body = new Promise((resolve) => { + resolveBody = resolve; }); using _fetch = stubGlobal( "fetch", - vi.fn(() => response), + vi.fn(() => body.then((text) => new Response(text))), ); await using screen = await render( , @@ -1949,7 +1949,7 @@ it("clears an Office metadata error when the same URL becomes previewable", asyn expect(screen.container.querySelector('[aria-label="Loading file"]')).not.toBeNull(), ); expect(screen.container.textContent).not.toContain("under 50 MB"); - resolveResponse(new Response("invalid-office-file")); + resolveBody("invalid-office-file"); await vi.waitFor(() => expect(screen.container.textContent).toContain("not a readable ZIP"), { timeout: 10_000, }); From e4b7869ae21ea1f3d4b248b82d2f7c561793c428 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:26:42 -0700 Subject: [PATCH 10/10] refactor(settings): typing a field SettingsSecretValue is the whole secret reference declaration The settings form offered a secret provider wherever a caller passed an environment and a field was a stored secret, and the server resolved references only where a reader chose getWithSecrets. A field could take a reference in the form that its schema or its reader rejected. SettingsSecretValue now carries the declaration. The form offers a secret provider for exactly the fields typed with it, on every settings form, and the server resolves those fields through one helper wherever a host kind's or a provider instance's settings reach their consumer, so a new field needs only its type. Co-Authored-By: Claude Opus 5.5 --- .../provider/ProviderInstanceRegistry.test.ts | 28 ++++- .../src/provider/ProviderInstanceRegistry.ts | 112 ++++++++++++------ .../server/src/secrets/settingsSecretValue.ts | 41 +++---- .../sourceControl/ServerSourceControlHost.ts | 5 +- .../sourceControlHostSecrets.test.ts | 58 ++++----- .../sourceControl/sourceControlHostSecrets.ts | 55 ++++++--- apps/server/src/ws.ts | 2 +- .../settings/AddProviderInstanceDialog.tsx | 2 + .../settings/ProviderInstanceCard.tsx | 1 + .../settings/ProviderSettingsForm.test.ts | 19 ++- .../settings/ProviderSettingsForm.tsx | 8 +- packages/contracts/src/settings.ts | 2 + packages/contracts/src/settingsSecretValue.ts | 36 +++++- .../src/shared/definition.ts | 6 +- .../src/shared/definition.ts | 2 - 15 files changed, 241 insertions(+), 136 deletions(-) diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index dc9c94e854f68..83eeee1e92310 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -15,6 +15,7 @@ import { type ResolvedProviderInstanceEnvironment, ProviderInstanceId, SecretProviderId, + SettingsSecretValue, type SecretReference, } from "@t3tools/contracts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; @@ -668,11 +669,13 @@ describe("ProviderInstanceRegistry secret environment", () => { function setup( initial: Record, configSchema: AnyProviderDriver["configSchema"] = Schema.Unknown, + config: unknown = {}, ) { const vault = new Map(Object.entries(initial)); const reads: Array> = []; const invalidated: Array> = []; const created: Array = []; + const configs: Array = []; const driver: AnyProviderDriver = { driverKind, metadata: { displayName: "Fake" }, @@ -681,6 +684,7 @@ describe("ProviderInstanceRegistry secret environment", () => { create: (input) => Effect.sync(() => { created.push(input.environment); + configs.push(input.config); return { instanceId: input.instanceId, enabled: input.enabled } as ProviderInstance; }), }; @@ -703,7 +707,7 @@ describe("ProviderInstanceRegistry secret environment", () => { const configMap: ProviderInstanceConfigMap = { [instanceId]: { driver: driverKind, - config: {}, + config, environment: [ { name: "OPENAI_API_KEY", value: secret, sensitive: true }, { name: "BASE_URL", value: "https://example.test", sensitive: false }, @@ -721,7 +725,7 @@ describe("ProviderInstanceRegistry secret environment", () => { yield* PubSub.take(changes); return built; }); - return { vault, reads, invalidated, created, make, makeGated, gate }; + return { vault, reads, invalidated, created, configs, make, makeGated, gate }; } it.effect("starts an instance with the secret its environment points at", () => { @@ -787,6 +791,26 @@ describe("ProviderInstanceRegistry secret environment", () => { }).pipe(Effect.scoped); }); + it.effect("reads a config field typed SettingsSecretValue and rebuilds when it rotates", () => { + const apiKey: SecretReference = { ...secret, reference: "op://Private/Gateway/key" }; + const fixture = setup( + { [secret.reference]: "sk-live", [apiKey.reference]: "gw-live" }, + Schema.Struct({ apiKey: SettingsSecretValue, model: Schema.String }), + { apiKey, model: "m" }, + ); + return Effect.gen(function* () { + const { mutator } = yield* fixture.make; + expect(fixture.reads.at(-1)).toEqual([secret.reference, apiKey.reference]); + + fixture.vault.set(apiKey.reference, "gw-rotated"); + yield* mutator.reloadSecrets(); + expect(fixture.configs).toEqual([ + { apiKey: "gw-live", model: "m" }, + { apiKey: "gw-rotated", model: "m" }, + ]); + }).pipe(Effect.scoped); + }); + it.effect("keeps a working instance live when a reload cannot read its secret", () => { const fixture = setup({ [secret.reference]: "sk-live" }); return Effect.gen(function* () { diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 70352c5ef8593..406bc1b9afc8d 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -7,6 +7,7 @@ import { type ProviderDriverKind, type ResolvedProviderInstanceEnvironment, type SecretReference, + secretReferencesInSettings, type ServerProvider, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -42,11 +43,16 @@ export class ProviderInstanceRegistry extends Context.Service< } >()("t3/provider/ProviderInstanceRegistry") {} +interface InstanceSecrets { + readonly environment: ResolvedProviderInstanceEnvironment; + readonly config: Readonly>; +} + interface LiveEntry { readonly instance: ProviderInstance; readonly scope: Scope.Closeable; readonly entry: ProviderInstanceConfig; - readonly environment: ResolvedProviderInstanceEnvironment; + readonly secrets: InstanceSecrets; } interface RegistryState { @@ -61,8 +67,18 @@ const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boole const secretReferencesIn = (environment: ProviderInstanceEnvironment): Array => environment.flatMap((variable) => (typeof variable.value === "string" ? [] : [variable.value])); -const secretReferencesOf = (entry: ProviderInstanceConfig): ReadonlyArray => - secretReferencesIn(entry.environment ?? []); +const configSecretReferences = ( + driver: AnyProviderDriver | undefined, + entry: ProviderInstanceConfig, +) => (driver === undefined ? [] : secretReferencesInSettings(driver.configSchema, entry.config)); + +const secretReferencesOf = ( + driversById: ReadonlyMap>, + entry: ProviderInstanceConfig, +): ReadonlyArray => [ + ...secretReferencesIn(entry.environment ?? []), + ...configSecretReferences(driversById.get(entry.driver), entry).map(({ reference }) => reference), +]; const withoutSecretReferences = ( environment: ProviderInstanceEnvironment, @@ -71,25 +87,38 @@ const withoutSecretReferences = ( typeof variable.value === "string" ? [{ ...variable, value: variable.value }] : [], ); -const readSecretEnvironment = Effect.fn("ProviderInstanceRegistry.readSecretEnvironment")( - function* (environment: ProviderInstanceEnvironment) { - const references = secretReferencesIn(environment); - const values = - references.length === 0 ? [] : yield* (yield* SecretResolver.SecretResolver).read(references); - const unreadable: Array = []; - let index = 0; - const resolved: ResolvedProviderInstanceEnvironment = environment.flatMap((variable) => { - if (typeof variable.value === "string") return [{ ...variable, value: variable.value }]; - const secret = values[index++]; - if (secret === undefined) { - unreadable.push(variable.name); - return []; - } - return [{ ...variable, value: secret, sensitive: true }]; - }); - return { environment: resolved, unreadable }; - }, -); +const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecrets")(function* ( + driver: AnyProviderDriver | undefined, + entry: ProviderInstanceConfig, +) { + const variables = entry.environment ?? []; + const configFields = configSecretReferences(driver, entry); + const references = [ + ...secretReferencesIn(variables), + ...configFields.map(({ reference }) => reference), + ]; + const values = + references.length === 0 ? [] : yield* (yield* SecretResolver.SecretResolver).read(references); + const unreadable: Array = []; + let index = 0; + const environment: ResolvedProviderInstanceEnvironment = variables.flatMap((variable) => { + if (typeof variable.value === "string") return [{ ...variable, value: variable.value }]; + const secret = values[index++]; + if (secret === undefined) { + unreadable.push(variable.name); + return []; + } + return [{ ...variable, value: secret, sensitive: true }]; + }); + const config: Record = {}; + for (const { field } of configFields) { + const secret = values[index++]; + if (secret === undefined) unreadable.push(field); + else config[field] = secret; + } + const secrets: InstanceSecrets = { environment, config }; + return { secrets, unreadable }; +}); /** Conflicting saved flags must preserve any explicit disable. */ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { @@ -164,7 +193,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* 0) { + if (deferSecrets && enabled && secretReferencesOf(driversById, entry).length > 0) { return { kind: "unavailable" as const, snapshot: yield* buildUnavailableProviderSnapshot({ @@ -176,9 +205,12 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* 0) { @@ -202,9 +234,12 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { const kept = previousEntries.has(instanceId) && !replacedIds.has(instanceId); return kept || !(yield* isEntryEnabled(driversById, entry)) ? [] - : secretReferencesOf(entry); + : secretReferencesOf(driversById, entry); }), ).pipe(Effect.map((perEntry) => perEntry.flat())); if (references.length > 0 && !deferSecrets) { @@ -408,13 +443,13 @@ export const makeProviderInstanceRegistry = Effect.fn( const configMap = yield* Ref.get(configMapRef); const targets = yield* Effect.filter(Object.entries(configMap), ([rawInstanceId, entry]) => (instanceId === undefined || rawInstanceId === instanceId) && - secretReferencesOf(entry).length > 0 + secretReferencesOf(driversById, entry).length > 0 ? isEntryEnabled(driversById, entry) : Effect.succeed(false), ); if (targets.length === 0) return; const resolver = yield* SecretResolver.SecretResolver; - const references = targets.flatMap(([, entry]) => secretReferencesOf(entry)); + const references = targets.flatMap(([, entry]) => secretReferencesOf(driversById, entry)); yield* resolver.invalidate(references); yield* resolver.read(references); const live = yield* Ref.get(entries); @@ -422,9 +457,12 @@ export const makeProviderInstanceRegistry = Effect.fn( for (const [rawInstanceId, entry] of targets) { const current = live.get(ProviderInstanceId.make(rawInstanceId)); if (current === undefined) continue; - const { environment, unreadable } = yield* readSecretEnvironment(entry.environment ?? []); + const { secrets, unreadable } = yield* readInstanceSecrets( + driversById.get(entry.driver), + entry, + ); // A failed re-read keeps the working instance and the value it already holds. - if (unreadable.length === 0 && !Equal.equals(environment, current.environment)) { + if (unreadable.length === 0 && !Equal.equals(secrets, current.secrets)) { rebuild.add(current.instance.instanceId); } } @@ -442,10 +480,12 @@ export const makeProviderInstanceRegistry = Effect.fn( ), ); const startupReferences = yield* Effect.filter(Object.entries(input.configMap), ([, entry]) => - secretReferencesOf(entry).length > 0 + secretReferencesOf(driversById, entry).length > 0 ? isEntryEnabled(driversById, entry) : Effect.succeed(false), - ).pipe(Effect.map((targets) => targets.flatMap(([, entry]) => secretReferencesOf(entry)))); + ).pipe( + Effect.map((targets) => targets.flatMap(([, entry]) => secretReferencesOf(driversById, entry))), + ); if (startupReferences.length > 0) { // The read waits outside the lock so other mutations are not held for a vault prompt; a // configuration saved meanwhile has already reconciled itself. diff --git a/apps/server/src/secrets/settingsSecretValue.ts b/apps/server/src/secrets/settingsSecretValue.ts index 3f20f64aa4c39..b17458e2d8f13 100644 --- a/apps/server/src/secrets/settingsSecretValue.ts +++ b/apps/server/src/secrets/settingsSecretValue.ts @@ -1,31 +1,20 @@ -import { - isSecretReference, - type ServerSettings, - type SettingsSecretValue, -} from "@t3tools/contracts"; +import { secretReferencesInSettings } from "@t3tools/contracts"; import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; +import type * as Schema from "effect/Schema"; -import { collectSourceControlHostSecretReferences } from "../sourceControl/sourceControlHostSecrets.ts"; - -/** An unreadable secret comes back as `""`, so a credential that is missing behaves as unset. */ -export const resolveSettingsSecret = Effect.fn("SettingsSecrets.resolve")(function* ( - resolver: SecretResolver.SecretResolver["Service"], - value: SettingsSecretValue, -) { - if (!isSecretReference(value)) return value; - const [secret] = yield* resolver.read([value]); - return secret ?? ""; -}); - -/** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ -export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* ( - resolver: SecretResolver.SecretResolver["Service"], - settings: Effect.Effect, E>, -) { - const references = yield* settings.pipe( - Effect.map((current) => collectSourceControlHostSecretReferences(current.sourceControlHosts)), - Effect.orElseSucceed(() => undefined), +/** + * Replaces each reference in the fields `schema` declares as `SettingsSecretValue` with the secret + * it names, read in one batch. An unreadable secret comes back as `""`, so it behaves as unset. + */ +export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(function* < + Settings extends Readonly>, +>(resolver: SecretResolver.SecretResolver["Service"], schema: Schema.Top, settings: Settings) { + const pending = secretReferencesInSettings(schema, settings); + if (pending.length === 0) return settings; + const values = yield* resolver.read(pending.map(({ reference }) => reference)); + const resolved = Object.fromEntries( + pending.map(({ field }, index) => [field, values[index] ?? ""] as const), ); - yield* resolver.invalidate(references); + return { ...settings, ...resolved }; }); diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts index d34f77107ef67..00b648d0f0364 100644 --- a/apps/server/src/sourceControl/ServerSourceControlHost.ts +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -4,7 +4,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SecretResolverLayer from "../secrets/SecretResolver.ts"; -import { resolveSettingsSecret } from "../secrets/settingsSecretValue.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -25,9 +24,7 @@ export const layer = Layer.effect( getWithSecrets: (kind) => serverSettings.getSettings.pipe( Effect.flatMap((settings) => - resolveSourceControlHostSecrets(settings.sourceControlHosts, kind, (source) => - resolveSettingsSecret(secrets, source), - ).pipe( + resolveSourceControlHostSecrets(secrets, settings.sourceControlHosts, kind).pipe( Effect.map((sourceControlHosts) => sourceControlHosts === settings.sourceControlHosts ? settings diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index d7883668c4d41..bfbe4a1c8ac94 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -2,14 +2,10 @@ import { assert, it } from "@effect/vitest"; import { SecretProviderId } from "@t3tools/contracts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { - invalidateSettingsSecrets, - resolveSettingsSecret, -} from "../secrets/settingsSecretValue.ts"; import { collectSourceControlHostSecretReferences, + invalidateSettingsSecrets, resolveSourceControlHostSecrets, } from "./sourceControlHostSecrets.ts"; @@ -20,60 +16,57 @@ const source = (reference: string) => ({ const secretsByReference = new Map([["bitbucket#token", "bb_from_provider"]]); -const layerResolver = Layer.succeed(SecretResolver.SecretResolver, { - read: (references) => - Effect.succeed(references.map((reference) => secretsByReference.get(reference.reference))), - invalidate: () => Effect.void, -}); - -const fakeResolver = SecretResolver.SecretResolver.use((resolver) => Effect.succeed(resolver)); - const hosts = { bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, gitcafe: { token: source("missing#token") }, github: { tokens: { "github.com": "ghp_literal" } }, }; -const countingResolver = Effect.gen(function* () { - const resolver = yield* fakeResolver; +function countingResolver() { const reads: Array = []; - const resolve = (value: Parameters[1]) => - Effect.sync(() => { - if (typeof value !== "string") reads.push(value.reference); - }).pipe(Effect.andThen(resolveSettingsSecret(resolver, value))); - return { reads, resolve }; -}); + const resolver = SecretResolver.SecretResolver.of({ + read: (references) => + Effect.sync(() => + references.map((reference) => { + reads.push(reference.reference); + return secretsByReference.get(reference.reference); + }), + ), + invalidate: () => Effect.void, + }); + return { reads, resolver }; +} it.effect( "replaces only the requested kind's credential references with the secrets they name", () => Effect.gen(function* () { - const { reads, resolve } = yield* countingResolver; - const resolved = yield* resolveSourceControlHostSecrets(hosts, "bitbucket", resolve); + const { reads, resolver } = countingResolver(); + const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "bitbucket"); assert.deepEqual(resolved, { ...hosts, bitbucket: { email: "me@example.test", accessToken: "bb_from_provider" }, }); assert.deepEqual(reads, ["bitbucket#token"]); - }).pipe(Effect.provide(layerResolver)), + }), ); it.effect("treats a reference the provider cannot read as an unset credential", () => Effect.gen(function* () { - const { reads, resolve } = yield* countingResolver; - const resolved = yield* resolveSourceControlHostSecrets(hosts, "gitcafe", resolve); + const { reads, resolver } = countingResolver(); + const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "gitcafe"); assert.deepEqual(resolved.gitcafe, { token: "" }); assert.deepEqual(reads, ["missing#token"]); - }).pipe(Effect.provide(layerResolver)), + }), ); it.effect("leaves the resolver unused for a kind that holds no reference", () => Effect.gen(function* () { - const { reads, resolve } = yield* countingResolver; - const resolved = yield* resolveSourceControlHostSecrets(hosts, "github", resolve); + const { reads, resolver } = countingResolver(); + const resolved = yield* resolveSourceControlHostSecrets(resolver, hosts, "github"); assert.strictEqual(resolved, hosts); assert.deepEqual(reads, []); - }).pipe(Effect.provide(layerResolver)), + }), ); it("collects the references held in credential fields and skips literal values", () => { @@ -110,10 +103,7 @@ it.effect("reads a collected reference again once it is invalidated", () => else for (const reference of references) cache.delete(reference.reference); }), }); - const read = () => - resolveSourceControlHostSecrets(hosts, "bitbucket", (reference) => - resolveSettingsSecret(resolver, reference), - ); + const read = () => resolveSourceControlHostSecrets(resolver, hosts, "bitbucket"); assert.deepEqual((yield* read()).bitbucket, { email: "me@example.test", accessToken: "stale", diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index b3c221c65db89..cf86ade6b0eee 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -1,6 +1,15 @@ -import { isSecretReference, secretSettingsFields, type SecretReference } from "@t3tools/contracts"; +import { + isSecretReference, + secretReferencesInSettings, + secretSettingsFields, + type SecretReference, + type ServerSettings, +} from "@t3tools/contracts"; +import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Effect from "effect/Effect"; +import { resolveSettingsSecrets } from "../secrets/settingsSecretValue.ts"; + import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< @@ -49,34 +58,44 @@ export function writeSecretFieldValues( type SourceControlHostsSettings = Readonly>>>; +const hostSettingsSchemas = new Map( + sourceControlDefinitions.definitions.flatMap((definition) => + definition.settings ? [[definition.kind as string, definition.settings] as const] : [], + ), +); + /** Every secret provider reference held in any host kind's credential fields. */ export function collectSourceControlHostSecretReferences( hosts: SourceControlHostsSettings, ): ReadonlyArray { - return [...SOURCE_CONTROL_HOST_SECRET_FIELDS].flatMap(([kind, fields]) => - fields.flatMap((field) => { - const value = hosts[kind]?.[field]; - return isSecretReference(value) ? [value] : []; - }), + return [...hostSettingsSchemas].flatMap(([kind, schema]) => + secretReferencesInSettings(schema, hosts[kind]).map(({ reference }) => reference), ); } /** Replaces each secret provider reference in one host kind's credential fields with the secret it names. */ export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.resolveSecrets")( - function* ( + function* ( + resolver: SecretResolver.SecretResolver["Service"], hosts: SourceControlHostsSettings, kind: string, - resolve: (source: SecretReference) => Effect.Effect, ) { - const fields = hosts[kind] ?? {}; - const pending = (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) => { - const value = fields[field]; - return isSecretReference(value) ? [{ field, source: value }] : []; - }); - if (pending.length === 0) return hosts; - const resolved = yield* Effect.forEach(pending, ({ field, source }) => - resolve(source).pipe(Effect.map((value) => [field, value] as const)), - ); - return { ...hosts, [kind]: { ...fields, ...Object.fromEntries(resolved) } }; + const schema = hostSettingsSchemas.get(kind); + const fields = hosts[kind]; + if (schema === undefined || fields === undefined) return hosts; + const resolved = yield* resolveSettingsSecrets(resolver, schema, fields); + return resolved === fields ? hosts : { ...hosts, [kind]: resolved }; }, ); + +/** Settings that cannot be read clear every cached secret, so a refresh never keeps a stale one. */ +export const invalidateSettingsSecrets = Effect.fn("SettingsSecrets.invalidate")(function* ( + resolver: SecretResolver.SecretResolver["Service"], + settings: Effect.Effect, E>, +) { + const references = yield* settings.pipe( + Effect.map((current) => collectSourceControlHostSecretReferences(current.sourceControlHosts)), + Effect.orElseSucceed(() => undefined), + ); + yield* resolver.invalidate(references); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6513350f323ad..38a10ba5a8803 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -182,7 +182,7 @@ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as ServerSettings from "./serverSettings.ts"; -import { invalidateSettingsSecrets } from "./secrets/settingsSecretValue.ts"; +import { invalidateSettingsSecrets } from "./sourceControl/sourceControlHostSecrets.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 3097cb70a9fbf..0c9eee9386c73 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -533,6 +533,7 @@ export function AddProviderInstanceDialog({ idPrefix="add-provider-acpRegistry-manual" variant="settings" onChange={setConfigDraft} + environmentId={environmentId} /> {isLocalAcp ? ( ) : !isAcpRegistry && wizardStep === 2 ? ( diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 63ade9fc40c76..21fe0fad2d086 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1196,6 +1196,7 @@ export function ProviderInstanceCard({ idPrefix={`provider-instance-${instanceId}`} variant="settings" onChange={updateConfig} + environmentId={environmentId} /> ) : ( { - it("keeps every masked field as a stored secret", () => { + it("keeps every masked field as a stored secret and offers a reference only where typed so", () => { const settingsSchema = makeProviderSettingsSchema({ token: TrimmedString.pipe( Schema.annotateKey({ providerSettingsForm: { control: "password" } }), ), + apiKey: SettingsSecretValue, }); - expect(deriveProviderSettingsFields({ settingsSchema })).toMatchObject([ - { key: "token", secret: true }, + expect( + deriveProviderSettingsFields({ settingsSchema }).map( + ({ key, secret, acceptsSecretReference }) => ({ key, secret, acceptsSecretReference }), + ), + ).toEqual([ + { key: "token", secret: true, acceptsSecretReference: undefined }, + { key: "apiKey", secret: true, acceptsSecretReference: true }, ]); }); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 15a0563442bd7..fbb1ac1503e89 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + acceptsSecretReference, isSecretReference, isSecretSetting, type EnvironmentId, @@ -38,6 +39,8 @@ export interface ProviderSettingsFieldModel { readonly clearWhenEmpty: "omit" | "persist"; /** Kept in the server's secret store: a saved value arrives as `SECRET_REDACTED` and never shows. */ readonly secret?: boolean | undefined; + /** Typed `SettingsSecretValue`: may name a secret provider reference instead of a value. */ + readonly acceptsSecretReference?: boolean | undefined; readonly defaultBooleanValue?: boolean | undefined; readonly options?: ReadonlyArray | undefined; @@ -135,6 +138,7 @@ export function deriveProviderSettingsFields( : {}), clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", ...(isSecretSetting(fieldSchema) ? { secret: true } : {}), + ...(acceptsSecretReference(fieldSchema) ? { acceptsSecretReference: true } : {}), ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } : {}), @@ -320,7 +324,7 @@ interface ProviderSettingsFormProps { readonly idPrefix: string; readonly variant: "card" | "dialog" | "settings"; readonly onChange: (nextConfig: Record | undefined) => void; - /** Lets secret fields in the settings variant read from a secret provider on this environment. */ + /** Where the secret providers offered for `SettingsSecretValue` fields are configured. */ readonly environmentId?: EnvironmentId | undefined; } @@ -490,7 +494,7 @@ function ProviderSettingsFieldRow({ placeholder={field.placeholder} spellCheck={false} /> - ) : field.secret && environmentId !== undefined ? ( + ) : field.acceptsSecretReference && environmentId !== undefined ? ( { + if (!SchemaAST.isObjects(schema.ast)) return []; + return schema.ast.propertySignatures.flatMap((property) => + property.type.context?.annotations?.settingsSecretReference === true && + typeof property.name === "string" + ? [property.name] + : [], + ); +} + +/** Each declared reference field of `settings` that holds a reference, in field order. */ +export function secretReferencesInSettings( + schema: Schema.Top, + settings: unknown, +): ReadonlyArray<{ readonly field: string; readonly reference: SecretReference }> { + if (settings === null || typeof settings !== "object") return []; + const values = settings as Readonly>; + return secretReferenceSettingsFields(schema).flatMap((field) => { + const value = values[field]; + return isSecretReference(value) ? [{ field, reference: value }] : []; + }); +} diff --git a/packages/source-control-bitbucket/src/shared/definition.ts b/packages/source-control-bitbucket/src/shared/definition.ts index 86522f4d8c960..4aa22cec41a80 100644 --- a/packages/source-control-bitbucket/src/shared/definition.ts +++ b/packages/source-control-bitbucket/src/shared/definition.ts @@ -44,11 +44,7 @@ const text = (annotations: Schema.Annotations.Key) => const secret = (annotations: Schema.Annotations.Key) => SettingsSecretValue.pipe( Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - ...annotations, - settingsSecret: true, - providerSettingsForm: { control: "password" }, - }), + Schema.annotateKey({ ...annotations, providerSettingsForm: { control: "password" } }), ); export const settings = makeProviderSettingsSchema( diff --git a/packages/source-control-gitcafe/src/shared/definition.ts b/packages/source-control-gitcafe/src/shared/definition.ts index fce36d4a0b52d..3749063ce7c1b 100644 --- a/packages/source-control-gitcafe/src/shared/definition.ts +++ b/packages/source-control-gitcafe/src/shared/definition.ts @@ -1,6 +1,5 @@ import { makeProviderSettingsSchema, - secretSetting, SettingsSecretValue, SourceControlProviderKind, } from "@t3tools/contracts"; @@ -35,7 +34,6 @@ const KIND = SourceControlProviderKind.make("gitcafe"); export const settings = makeProviderSettingsSchema({ token: SettingsSecretValue.pipe( - secretSetting, Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ title: "Token",