From b0da0a34d25c59958fd22d7dd34c4d846fd9c31b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 14:18:24 -0400 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 9b70fcca0725ca8a419d671405ff6c8dde529bc4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 14:19:50 -0400 Subject: [PATCH 10/14] feat(server): read source control host tokens from a secret provider Signed-off-by: Yordis Prieto --- apps/server/src/serverSettings.test.ts | 30 ++++++++++++ .../sourceControlHostSecrets.test.ts | 26 ++++++++-- .../sourceControl/sourceControlHostSecrets.ts | 41 ++++++++++++---- .../SourceControlAppSignInSettings.tsx | 4 +- .../settings/SourceControlTokenSettings.tsx | 48 ++++++++++++++----- .../src/shared/definition.ts | 3 +- .../src/server/GitHubCredentials.ts | 8 ++-- .../src/server/GitHubSourceControlProvider.ts | 4 +- 8 files changed, 133 insertions(+), 31 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index ef8b4d6afae79..dd4f90d897322 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1555,6 +1555,36 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); + it.effect("keeps a per-host token 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/GitHub/token", + }; + const stored = (serverHost: string) => + secrets.get(sourceControlHostSecretName({ kind: "github", field: "tokens", serverHost })); + + yield* serverSettings.updateSettings({ + sourceControlHosts: { github: { tokens: { "github.com": "ghp_dotcom" } } }, + }); + assert.isTrue(Option.isSome(yield* stored("github.com"))); + + const saved = yield* serverSettings.updateSettings({ + sourceControlHosts: { + github: { tokens: { "github.com": reference, "ghe.acme.test": "ghp_ghe" } }, + }, + }); + assert.isTrue(Option.isNone(yield* stored("github.com"))); + assert.isTrue(Option.isSome(yield* stored("ghe.acme.test"))); + const tokens = ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts + .github?.tokens as Record; + assert.deepEqual(tokens["github.com"], reference); + assert.notEqual(tokens["ghe.acme.test"], "ghp_ghe"); + }).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/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index d7883668c4d41..f5ef905ead03c 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -18,7 +18,10 @@ const source = (reference: string) => ({ reference, }); -const secretsByReference = new Map([["bitbucket#token", "bb_from_provider"]]); +const secretsByReference = new Map([ + ["github#token", "ghp_from_provider"], + ["bitbucket#token", "bb_from_provider"], +]); const layerResolver = Layer.succeed(SecretResolver.SecretResolver, { read: (references) => @@ -31,6 +34,10 @@ const fakeResolver = SecretResolver.SecretResolver.use((resolver) => Effect.succ const hosts = { bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") }, gitcafe: { token: source("missing#token") }, + github: { tokens: { "github.com": source("github#token"), "ghe.acme.test": "ghp_literal" } }, +}; +const literalHosts = { + gitcafe: { token: "literal" }, github: { tokens: { "github.com": "ghp_literal" } }, }; @@ -67,17 +74,30 @@ it.effect("treats a reference the provider cannot read as an unset credential", }).pipe(Effect.provide(layerResolver)), ); -it.effect("leaves the resolver unused for a kind that holds no reference", () => +it.effect("replaces a reference held in the per-host token record and keeps literal tokens", () => Effect.gen(function* () { const { reads, resolve } = yield* countingResolver; const resolved = yield* resolveSourceControlHostSecrets(hosts, "github", resolve); - assert.strictEqual(resolved, hosts); + assert.deepEqual(resolved, { + ...hosts, + github: { tokens: { "github.com": "ghp_from_provider", "ghe.acme.test": "ghp_literal" } }, + }); + assert.deepEqual(reads, ["github#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(literalHosts, "github", resolve); + assert.strictEqual(resolved, literalHosts); assert.deepEqual(reads, []); }).pipe(Effect.provide(layerResolver)), ); it("collects the references held in credential fields and skips literal values", () => { assert.deepEqual(collectSourceControlHostSecretReferences(hosts), [ + source("github#token"), source("missing#token"), source("bitbucket#token"), ]); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index b3c221c65db89..74e9052908f91 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -44,20 +44,32 @@ export function writeSecretFieldValues( ): unknown { if (typeof original === "string") return values[0]?.[1] ?? ""; if (isSecretReference(original)) return original; - return Object.fromEntries(values.map(([serverHost, value]) => [serverHost ?? "", value])); + const sources = + original !== null && typeof original === "object" && !Array.isArray(original) + ? Object.entries(original).flatMap(([serverHost, entry]) => + isSecretReference(entry) ? [[serverHost.trim().toLowerCase(), entry] as const] : [], + ) + : []; + return Object.fromEntries([ + ...sources, + ...values.map(([serverHost, value]) => [serverHost ?? "", value] as const), + ]); } type SourceControlHostsSettings = Readonly>>>; +const referencesIn = (value: unknown): ReadonlyArray => { + if (isSecretReference(value)) return [value]; + if (value === null || typeof value !== "object" || Array.isArray(value)) return []; + return Object.values(value).filter(isSecretReference); +}; + /** 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] : []; - }), + fields.flatMap((field) => referencesIn(hosts[kind]?.[field])), ); } @@ -69,13 +81,24 @@ export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.reso resolve: (source: SecretReference) => Effect.Effect, ) { const fields = hosts[kind] ?? {}; + const resolveField = (value: unknown): Effect.Effect | null => { + if (isSecretReference(value)) return resolve(value); + if (referencesIn(value).length === 0) return null; + return Effect.forEach( + Object.entries(value as Record), + ([serverHost, entry]) => + isSecretReference(entry) + ? resolve(entry).pipe(Effect.map((secret) => [serverHost, secret] as const)) + : Effect.succeed([serverHost, entry] as const), + ).pipe(Effect.map((resolved) => Object.fromEntries(resolved))); + }; const pending = (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) => { - const value = fields[field]; - return isSecretReference(value) ? [{ field, source: value }] : []; + const resolved = resolveField(fields[field]); + return resolved === null ? [] : [{ field, resolved }]; }); if (pending.length === 0) return hosts; - const resolved = yield* Effect.forEach(pending, ({ field, source }) => - resolve(source).pipe(Effect.map((value) => [field, value] as const)), + const resolved = yield* Effect.forEach(pending, ({ field, resolved }) => + resolved.pipe(Effect.map((value) => [field, value] as const)), ); return { ...hosts, [kind]: { ...fields, ...Object.fromEntries(resolved) } }; }, diff --git a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx index 5662954ef7cd6..df73fa3b24b45 100644 --- a/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx +++ b/apps/web/src/components/settings/SourceControlAppSignInSettings.tsx @@ -101,7 +101,9 @@ export function SourceControlAppSignInSettings({ readHostCredentialSettings(settings, definition.kind), ); const signedIn = (signIns[host] ?? "").length > 0; - const tokenOverrides = (tokens[host] ?? "").length > 0; + const token = tokens[host]; + // A token read from a secret provider overrides the sign-in like a saved one. + const tokenOverrides = typeof token === "string" ? token.length > 0 : token !== undefined; const canSignIn = useAtomValue( sourceControlEnvironment.startSignIn.permissionAtom(environmentId), ); diff --git a/apps/web/src/components/settings/SourceControlTokenSettings.tsx b/apps/web/src/components/settings/SourceControlTokenSettings.tsx index 912de7ad91701..5a78b5f28287d 100644 --- a/apps/web/src/components/settings/SourceControlTokenSettings.tsx +++ b/apps/web/src/components/settings/SourceControlTokenSettings.tsx @@ -1,4 +1,9 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { + isSecretReference, + type EnvironmentId, + type SecretReference, + type SettingsSecretValue, +} from "@t3tools/contracts"; import { ExternalLinkIcon } from "lucide-react"; import { useState } from "react"; @@ -12,8 +17,11 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button, InlineButton } from "../ui/button"; import { Input } from "../ui/input"; -import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; +import { SecretSourceInput, useSecretSources } from "./SecretSourceInput"; + +const hasToken = (token: SettingsSecretValue | undefined) => + typeof token === "string" ? token.length > 0 : token !== undefined; export function SourceControlTokenSettings({ environmentId, @@ -34,15 +42,24 @@ export function SourceControlTokenSettings({ }); const defaultHost = definition.publicHost ?? ""; const [host, setHost] = useState(defaultHost); - const [draft, setDraft] = useState(""); + const secretSources = useSecretSources(environmentId); + const [pending, setPending] = useState<{ + readonly key: string; + readonly draft: string | SecretReference; + } | null>(null); const [saving, setSaving] = useState(false); const normalizedHost = host.trim().toLowerCase(); - const isSaved = (tokens[normalizedHost] ?? "").length > 0; + const savedToken = tokens[normalizedHost]; + const isSaved = hasToken(savedToken); const savedHosts = Object.entries(tokens) - .filter(([, value]) => value.length > 0) + .filter(([, value]) => hasToken(value)) .map(([saved]) => saved); + const sourceKey = JSON.stringify([normalizedHost, savedToken ?? ""]); + const draft = + pending?.key === sourceKey ? pending.draft : isSecretReference(savedToken) ? savedToken : ""; + const submission = typeof draft === "string" ? draft.trim() : draft; - const save = async (target: string, token: string) => { + const save = async (target: string, token: SettingsSecretValue) => { setSaving(true); const result = await updateSettings({ environmentId, @@ -55,7 +72,7 @@ export function SourceControlTokenSettings({ }); setSaving(false); if (result._tag === "Success") { - setDraft(""); + setPending(null); onSaved(); } }; @@ -65,7 +82,7 @@ export function SourceControlTokenSettings({ className="grid gap-3" onSubmit={(event) => { event.preventDefault(); - if (normalizedHost && draft.trim()) void save(normalizedHost, draft.trim()); + if (normalizedHost && submission !== "") void save(normalizedHost, submission); }} > {/* Locked while saving: a successful save clears the draft, which would drop edits made mid-request. */} @@ -106,12 +123,15 @@ export function SourceControlTokenSettings({
- setDraft(event.target.value)} + sources={secretSources} + onChange={(event) => setPending({ key: sourceKey, draft: event.target.value })} + onSecretChange={(secret) => setPending({ key: sourceKey, draft: secret ?? "" })} />
@@ -132,7 +152,11 @@ export function SourceControlTokenSettings({ Remove ) : null} -
diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index e9e68325b7418..61b4752ba6c2c 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -3,6 +3,7 @@ import { type PullRequestReviewVerdict, type RepositoryIdentity, secretSetting, + SettingsSecretValue, SourceControlProviderKind, type SourceControlProviderInfo, TrimmedNonEmptyString, @@ -212,7 +213,7 @@ export const hostCredentialSettingsFields = { Schema.withDecodingDefault(Effect.succeed({})), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), - tokens: Schema.Record(SourceControlHostName, TrimmedString).pipe( + tokens: Schema.Record(SourceControlHostName, SettingsSecretValue).pipe( Schema.withDecodingDefault(Effect.succeed({})), secretSetting, Schema.annotateKey({ providerSettingsForm: { hidden: true } }), diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index 41641790aac52..607e6ca00d1b1 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -16,7 +16,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import { readSourceControlHostSettings } from "@t3tools/source-control-core/shared/definition"; -import type { ServerSettings } from "@t3tools/contracts"; +import { settingsSecretLiteral, type ServerSettings } from "@t3tools/contracts"; import * as GitHubClient from "../shared/definition.ts"; @@ -194,8 +194,10 @@ export const make = Effect.gen(function* () { ); const savedToken = (host: string) => - sourceControlHost.settings.get.pipe( - Effect.map((settings) => readSettings(settings).tokens[host]?.trim() || null), + sourceControlHost.settings.getWithSecrets(GitHubClient.definition.kind).pipe( + Effect.map( + (settings) => settingsSecretLiteral(readSettings(settings).tokens[host]).trim() || null, + ), Effect.orElseSucceed(() => null), ); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index bfc5fc29041ea..7c9af2b400a0b 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -1,4 +1,4 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { settingsSecretLiteral, SourceControlProviderKind } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -237,7 +237,7 @@ export const makeDiscovery = Effect.gen(function* () { }, spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, }); - const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; + const savedToken = settingsSecretLiteral(settings.tokens["github.com"]).trim() !== ""; const signedIn = appSignIn.enabled && !savedToken && (settings.signIns["github.com"] ?? "").trim() !== ""; const variable = environmentTokenVariable(environment); From e71207c301dd29b80881949da960242b068f2cc8 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 17:25:39 -0400 Subject: [PATCH 11/14] fix(github): a token saved as a secret reference is checked against the API during discovery Signed-off-by: Yordis Prieto --- .../src/server/GitHubSourceControlProvider.ts | 9 +++- .../src/server/gitHubDiscovery.test.ts | 51 ++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 7c9af2b400a0b..4bbd0fc50336f 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -1,4 +1,8 @@ -import { settingsSecretLiteral, SourceControlProviderKind } from "@t3tools/contracts"; +import { + isSecretReference, + settingsSecretLiteral, + SourceControlProviderKind, +} from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -237,7 +241,8 @@ export const makeDiscovery = Effect.gen(function* () { }, spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, }); - const savedToken = settingsSecretLiteral(settings.tokens["github.com"]).trim() !== ""; + const token = settings.tokens["github.com"]; + const savedToken = isSecretReference(token) || settingsSecretLiteral(token).trim() !== ""; const signedIn = appSignIn.enabled && !savedToken && (settings.signIns["github.com"] ?? "").trim() !== ""; const variable = environmentTokenVariable(environment); diff --git a/packages/source-control-github/src/server/gitHubDiscovery.test.ts b/packages/source-control-github/src/server/gitHubDiscovery.test.ts index cd8a838b23723..775d5684042bb 100644 --- a/packages/source-control-github/src/server/gitHubDiscovery.test.ts +++ b/packages/source-control-github/src/server/gitHubDiscovery.test.ts @@ -1,9 +1,11 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { ServerSettings, SourceControlProviderKind } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as GitHubCredentials from "./GitHubCredentials.ts"; @@ -79,3 +81,50 @@ it.effect("asks for no credential when the identity resolver left the base URL e assert.deepStrictEqual(hosts, []); }), ); + +const referencedTokenSettings = Schema.decodeSync(ServerSettings)({ + sourceControlHosts: { + github: { tokens: { "github.com": { provider: "vault", reference: "op://v/gh" } } }, + }, +}); + +it.effect("checks a GitHub token saved as a secret reference against the API", () => + Effect.gen(function* () { + const paths: Array = []; + const discovery = yield* GitHubSourceControlProvider.makeDiscovery.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(GitHubRest.GitHubRest)({ + rest: (input) => { + paths.push(input.path); + return Effect.succeed({ + status: 200, + headers: {}, + body: JSON.stringify({ login: "octocat" }), + truncated: false, + invalidUtf8: false, + }); + }, + }), + Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ enabled: false }), + TestSourceControlHost.layer({ + settings: referencedTokenSettings, + process: { + run: () => + Effect.succeed( + TestSourceControlHost.processOutput("", { + exitCode: ChildProcessSpawner.ExitCode(1), + }), + ), + }, + }), + ), + ), + Effect.provideService(HostProcess.Environment, {}), + ); + if (discovery.type !== "managed-cli") return assert.fail("expected a managed CLI"); + const item = yield* discovery.probe("/repo"); + assert.deepStrictEqual(paths, ["user"]); + assert.include(JSON.stringify(item.auth), "token saved in Settings"); + }), +); From 350aba94a9a71da845fd198568782e6fd63660e8 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 17:26:00 -0400 Subject: [PATCH 12/14] test(server): drop source control secret cases other tests already cover Signed-off-by: Yordis Prieto --- apps/server/src/serverSettings.test.ts | 58 +++++++------------ .../sourceControlHostSecrets.test.ts | 14 ----- 2 files changed, 21 insertions(+), 51 deletions(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index dd4f90d897322..ce12569ac1bc5 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1538,53 +1538,37 @@ it.layer(NodeServices.layer)("server settings", (it) => { provider: SecretProviderId.make("personal"), reference: "op://Private/Bitbucket/token", }; + const gitHubReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/GitHub/token", + }; yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: "bb-access" } }, + sourceControlHosts: { + bitbucket: { accessToken: "bb-access" }, + github: { tokens: { "github.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } }, + }, }); assert.isTrue(Option.isSome(yield* stored("bitbucket", "accessToken", null))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "github.com"))); const referenced = yield* serverSettings.updateSettings({ - sourceControlHosts: { bitbucket: { accessToken: reference } }, + sourceControlHosts: { + bitbucket: { accessToken: reference }, + github: { tokens: { "github.com": gitHubReference, "ghe.acme.test": "ghp_ghe" } }, + }, }); 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, - ); + assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com"))); + assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test"))); + const redacted = + ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts; + assert.deepEqual(redacted.bitbucket?.accessToken, reference); + const tokens = redacted.github?.tokens as Record; + assert.deepEqual(tokens["github.com"], gitHubReference); + assert.notEqual(tokens["ghe.acme.test"], "ghp_ghe"); }).pipe(Effect.provide(layerServerSettingsWithSecrets())), ); - it.effect("keeps a per-host token 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/GitHub/token", - }; - const stored = (serverHost: string) => - secrets.get(sourceControlHostSecretName({ kind: "github", field: "tokens", serverHost })); - - yield* serverSettings.updateSettings({ - sourceControlHosts: { github: { tokens: { "github.com": "ghp_dotcom" } } }, - }); - assert.isTrue(Option.isSome(yield* stored("github.com"))); - - const saved = yield* serverSettings.updateSettings({ - sourceControlHosts: { - github: { tokens: { "github.com": reference, "ghe.acme.test": "ghp_ghe" } }, - }, - }); - assert.isTrue(Option.isNone(yield* stored("github.com"))); - assert.isTrue(Option.isSome(yield* stored("ghe.acme.test"))); - const tokens = ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts - .github?.tokens as Record; - assert.deepEqual(tokens["github.com"], reference); - assert.notEqual(tokens["ghe.acme.test"], "ghp_ghe"); - }).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/sourceControl/sourceControlHostSecrets.test.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts index f5ef905ead03c..71971a2f4cf1f 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.test.ts @@ -36,11 +36,6 @@ const hosts = { gitcafe: { token: source("missing#token") }, github: { tokens: { "github.com": source("github#token"), "ghe.acme.test": "ghp_literal" } }, }; -const literalHosts = { - gitcafe: { token: "literal" }, - github: { tokens: { "github.com": "ghp_literal" } }, -}; - const countingResolver = Effect.gen(function* () { const resolver = yield* fakeResolver; const reads: Array = []; @@ -86,15 +81,6 @@ it.effect("replaces a reference held in the per-host token record and keeps lite }).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(literalHosts, "github", resolve); - assert.strictEqual(resolved, literalHosts); - assert.deepEqual(reads, []); - }).pipe(Effect.provide(layerResolver)), -); - it("collects the references held in credential fields and skips literal values", () => { assert.deepEqual(collectSourceControlHostSecretReferences(hosts), [ source("github#token"), From fec8ad3ce0be2854146f59822dfb0c4c4d56041b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 30 Oct 2026 19:03:51 -0400 Subject: [PATCH 13/14] refactor(settings): a record of SettingsSecretValue declares its references too Signed-off-by: Yordis Prieto --- .../src/provider/ProviderInstanceRegistry.ts | 20 +++--- .../server/src/secrets/settingsSecretValue.ts | 10 +-- .../sourceControl/sourceControlHostSecrets.ts | 35 ++-------- packages/contracts/src/settingsSecretValue.ts | 70 +++++++++++++++---- .../src/server/gitHubDiscovery.test.ts | 4 +- 5 files changed, 79 insertions(+), 60 deletions(-) diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index a944f751a1d34..99b80a79b6209 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -9,6 +9,8 @@ import { type SecretReference, secretReferencesInSettings, type ServerProvider, + type SettingsSecretSlot, + withSettingsSecrets, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -45,7 +47,7 @@ export class ProviderInstanceRegistry extends Context.Service< interface InstanceSecrets { readonly environment: ResolvedProviderInstanceEnvironment; - readonly config: Readonly>; + readonly config: ReadonlyArray; } interface LiveEntry { @@ -110,12 +112,12 @@ const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecr } return [{ ...variable, value: secret, sensitive: true }]; }); - const config: Record = {}; - for (const { field } of configFields) { + const config = configFields.flatMap(({ field, key }) => { const secret = values[index++]; - if (secret === undefined) unreadable.push(field); - else config[field] = secret; - } + if (secret !== undefined) return [{ field, key, value: secret }]; + unreadable.push(field); + return []; + }); const secrets: InstanceSecrets = { environment, config }; return { secrets, unreadable }; }); @@ -208,7 +210,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* reference)); - const resolved = Object.fromEntries( - pending.map(({ field }, index) => [field, values[index] ?? ""] as const), + return withSettingsSecrets( + settings, + pending.map((slot, index) => ({ ...slot, value: values[index] ?? "" })), ); - return { ...settings, ...resolved }; }); diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 594e35ea5bb4a..82de25be4de69 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -77,29 +77,9 @@ const hostSettingsSchemas = new Map( export function collectSourceControlHostSecretReferences( hosts: SourceControlHostsSettings, ): ReadonlyArray { - return [...hostSettingsSchemas].flatMap(([kind, schema]) => [ - ...secretReferencesInSettings(schema, hosts[kind]).map(({ reference }) => reference), - ...perHostSecretReferences(kind, hosts[kind]).map(({ reference }) => reference), - ]); -} - -/** References kept per hostname, such as GitHub `tokens`, which the schema walker does not reach. */ -function perHostSecretReferences( - kind: string, - fields: Readonly> | undefined, -): ReadonlyArray<{ - readonly field: string; - readonly serverHost: string; - readonly reference: SecretReference; -}> { - if (fields === undefined) return []; - return (SOURCE_CONTROL_HOST_SECRET_FIELDS.get(kind) ?? []).flatMap((field) => { - const value = fields[field]; - if (value === null || typeof value !== "object" || isSecretReference(value)) return []; - return Object.entries(value).flatMap(([serverHost, reference]) => - isSecretReference(reference) ? [{ field, serverHost, reference }] : [], - ); - }); + 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. */ @@ -113,14 +93,7 @@ export const resolveSourceControlHostSecrets = Effect.fn("SourceControlHost.reso const fields = hosts[kind]; if (schema === undefined || fields === undefined) return hosts; const resolved = yield* resolveSettingsSecrets(resolver, schema, fields); - const pending = perHostSecretReferences(kind, resolved); - if (pending.length === 0) return resolved === fields ? hosts : { ...hosts, [kind]: resolved }; - const values = yield* resolver.read(pending.map(({ reference }) => reference)); - const next: Record = { ...resolved }; - pending.forEach(({ field, serverHost }, index) => { - next[field] = { ...(next[field] as object), [serverHost]: values[index] ?? "" }; - }); - return { ...hosts, [kind]: next }; + return resolved === fields ? hosts : { ...hosts, [kind]: resolved }; }, ); diff --git a/packages/contracts/src/settingsSecretValue.ts b/packages/contracts/src/settingsSecretValue.ts index 101c40998b6f0..97558c82d574f 100644 --- a/packages/contracts/src/settingsSecretValue.ts +++ b/packages/contracts/src/settingsSecretValue.ts @@ -26,26 +26,68 @@ export function acceptsSecretReference(schema: Schema.Top): boolean { return Schema.resolveAnnotationsKey(schema)?.settingsSecretReference === true; } -/** Fields of a settings struct typed `SettingsSecretValue`; any other schema has none. */ -function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray { +const declaresSecretReference = (ast: SchemaAST.AST) => + ast.context?.annotations?.settingsSecretReference === true; + +interface SecretReferenceField { + readonly field: string; + readonly keyed: boolean; +} + +/** + * Fields of a settings struct typed `SettingsSecretValue`, or a record of it such as one value + * per host; any other schema has none. + */ +function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray { if (!SchemaAST.isObjects(schema.ast)) return []; - return schema.ast.propertySignatures.flatMap((property) => - property.type.context?.annotations?.settingsSecretReference === true && - typeof property.name === "string" - ? [property.name] - : [], - ); + return schema.ast.propertySignatures.flatMap(({ name, type }) => { + if (typeof name !== "string") return []; + if (declaresSecretReference(type)) return [{ field: name, keyed: false }]; + return SchemaAST.isObjects(type) && + type.indexSignatures.some((signature) => declaresSecretReference(signature.type)) + ? [{ field: name, keyed: true }] + : []; + }); } -/** Each declared reference field of `settings` that holds a reference, in field order. */ +/** Where a reference sits: a field, or one key of a record field. */ +export interface SettingsSecretSlot { + readonly field: string; + readonly key: string | undefined; +} + +interface SettingsSecretReference extends SettingsSecretSlot { + readonly reference: SecretReference; +} + +/** Each declared reference slot of `settings` that holds a reference, in field order. */ export function secretReferencesInSettings( schema: Schema.Top, settings: unknown, -): ReadonlyArray<{ readonly field: string; readonly reference: SecretReference }> { +): ReadonlyArray { 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 }] : []; - }); + return secretReferenceSettingsFields(schema).flatMap( + ({ field, keyed }) => { + const value = values[field]; + if (!keyed) + return isSecretReference(value) ? [{ field, key: undefined, reference: value }] : []; + if (value === null || typeof value !== "object") return []; + return Object.entries(value).flatMap(([key, entry]) => + isSecretReference(entry) ? [{ field, key, reference: entry }] : [], + ); + }, + ); +} + +/** `settings` with each slot set to its value, keeping the other keys of a record field. */ +export function withSettingsSecrets>>( + settings: Settings, + slots: ReadonlyArray, +): Settings { + const next: Record = { ...settings }; + for (const { field, key, value } of slots) { + next[field] = key === undefined ? value : { ...(next[field] as object), [key]: value }; + } + return next as Settings; } diff --git a/packages/source-control-github/src/server/gitHubDiscovery.test.ts b/packages/source-control-github/src/server/gitHubDiscovery.test.ts index 775d5684042bb..28b7fa4b0420c 100644 --- a/packages/source-control-github/src/server/gitHubDiscovery.test.ts +++ b/packages/source-control-github/src/server/gitHubDiscovery.test.ts @@ -3,6 +3,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -125,6 +126,7 @@ it.effect("checks a GitHub token saved as a secret reference against the API", ( if (discovery.type !== "managed-cli") return assert.fail("expected a managed CLI"); const item = yield* discovery.probe("/repo"); assert.deepStrictEqual(paths, ["user"]); - assert.include(JSON.stringify(item.auth), "token saved in Settings"); + assert.strictEqual(item.auth.status, "authenticated"); + assert.deepStrictEqual(item.auth.account, Option.some("octocat")); }), ); From 805aaa2ea8a09d6f5e382235ae61cbcdb3f1ea31 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 19:18:56 +0000 Subject: [PATCH 14/14] fix(github): report fallback auth for unreadable token references --- .../src/server/GitHubSourceControlProvider.ts | 10 +--- .../src/server/gitHubDiscovery.test.ts | 57 ++++++++++++++----- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index b4ee6cf8cfbf5..129a3888b0714 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -1,8 +1,4 @@ -import { - isSecretReference, - settingsSecretLiteral, - SourceControlProviderKind, -} from "@t3tools/contracts"; +import { settingsSecretLiteral, SourceControlProviderKind } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -229,7 +225,7 @@ export const makeDiscovery = Effect.gen(function* () { probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { const settings = readSourceControlHostSettings( GitHubClient.settings, - yield* sourceControlHost.settings.get.pipe( + yield* sourceControlHost.settings.getWithSecrets(GitHubClient.definition.kind).pipe( Effect.map((current) => current.sourceControlHosts[GitHubClient.definition.kind]), Effect.orElseSucceed(() => undefined), ), @@ -242,7 +238,7 @@ export const makeDiscovery = Effect.gen(function* () { spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, }); const token = settings.tokens["github.com"]; - const savedToken = isSecretReference(token) || settingsSecretLiteral(token).trim() !== ""; + const savedToken = settingsSecretLiteral(token).trim() !== ""; const signedIn = appSignIn.enabled && !savedToken && (settings.signIns["github.com"] ?? "").trim() !== ""; const variable = environmentTokenVariable(environment); diff --git a/packages/source-control-github/src/server/gitHubDiscovery.test.ts b/packages/source-control-github/src/server/gitHubDiscovery.test.ts index 28b7fa4b0420c..b108d71f75584 100644 --- a/packages/source-control-github/src/server/gitHubDiscovery.test.ts +++ b/packages/source-control-github/src/server/gitHubDiscovery.test.ts @@ -8,6 +8,7 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubAppSignIn from "./GitHubAppSignIn.ts"; @@ -83,13 +84,17 @@ it.effect("asks for no credential when the identity resolver left the base URL e }), ); -const referencedTokenSettings = Schema.decodeSync(ServerSettings)({ +const decodeSettings = Schema.decodeSync(ServerSettings); +const referencedTokenSettings = decodeSettings({ sourceControlHosts: { github: { tokens: { "github.com": { provider: "vault", reference: "op://v/gh" } } }, }, }); -it.effect("checks a GitHub token saved as a secret reference against the API", () => +it.effect.each([ + { name: "readable", token: "resolved-token", source: "the token saved in Settings" }, + { name: "unreadable", token: "", source: "GH_TOKEN from the server environment" }, +])("checks the $name GitHub token reference against the API", (scenario) => Effect.gen(function* () { const paths: Array = []; const discovery = yield* GitHubSourceControlProvider.makeDiscovery.pipe( @@ -108,25 +113,49 @@ it.effect("checks a GitHub token saved as a secret reference against the API", ( }, }), Layer.mock(GitHubAppSignIn.GitHubAppSignIn)({ enabled: false }), - TestSourceControlHost.layer({ - settings: referencedTokenSettings, - process: { - run: () => - Effect.succeed( - TestSourceControlHost.processOutput("", { - exitCode: ChildProcessSpawner.ExitCode(1), - }), - ), - }, - }), + Layer.effect( + SourceControlHost.SourceControlHost, + Effect.gen(function* () { + const host = yield* SourceControlHost.SourceControlHost; + return { + ...host, + settings: { + ...host.settings, + getWithSecrets: () => + Effect.succeed( + decodeSettings({ + sourceControlHosts: { + github: { tokens: { "github.com": scenario.token } }, + }, + }), + ), + }, + }; + }), + ).pipe( + Layer.provide( + TestSourceControlHost.layer({ + settings: referencedTokenSettings, + process: { + run: () => + Effect.succeed( + TestSourceControlHost.processOutput("", { + exitCode: ChildProcessSpawner.ExitCode(1), + }), + ), + }, + }), + ), + ), ), ), - Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(HostProcess.Environment, { GH_TOKEN: "environment-token" }), ); if (discovery.type !== "managed-cli") return assert.fail("expected a managed CLI"); const item = yield* discovery.probe("/repo"); assert.deepStrictEqual(paths, ["user"]); assert.strictEqual(item.auth.status, "authenticated"); assert.deepStrictEqual(item.auth.account, Option.some("octocat")); + assert.include(Option.getOrThrow(item.auth.detail), scenario.source); }), );