Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions apps/server/src/provider/ProviderInstanceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,7 +47,7 @@ export class ProviderInstanceRegistry extends Context.Service<

interface InstanceSecrets {
readonly environment: ResolvedProviderInstanceEnvironment;
readonly config: Readonly<Record<string, string>>;
readonly config: ReadonlyArray<SettingsSecretSlot & { readonly value: string }>;
}

interface LiveEntry {
Expand Down Expand Up @@ -110,12 +112,12 @@ const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecr
}
return [{ ...variable, value: secret, sensitive: true }];
});
const config: Record<string, string> = {};
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 };
});
Expand Down Expand Up @@ -208,7 +210,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* <R
const { secrets, unreadable } = enabled
? yield* readInstanceSecrets(driver, entry)
: {
secrets: { environment: withoutSecretReferences(entry.environment ?? []), config: {} },
secrets: { environment: withoutSecretReferences(entry.environment ?? []), config: [] },
unreadable: [],
};
// Starting with a blank credential would fail later with a less useful error, or fall back to
Expand Down Expand Up @@ -236,9 +238,9 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* <R
environment: secrets.environment,
enabled,
config:
Object.keys(secrets.config).length === 0
secrets.config.length === 0
? typedConfig
: { ...typedConfig, ...secrets.config },
: withSettingsSecrets(typedConfig, secrets.config),
})
.pipe(Effect.provideService(Scope.Scope, childScope), Effect.result);
if (createResult._tag === "Failure") {
Expand Down
10 changes: 5 additions & 5 deletions apps/server/src/secrets/settingsSecretValue.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { secretReferencesInSettings } from "@t3tools/contracts";
import { secretReferencesInSettings, withSettingsSecrets } from "@t3tools/contracts";
import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as Effect from "effect/Effect";
import type * as Schema from "effect/Schema";

/**
* Replaces each reference in the fields `schema` declares as `SettingsSecretValue` with the secret
* Replaces each reference in the slots `schema` declares as `SettingsSecretValue` with the secret
* it names, read in one batch. An unreadable secret comes back as `""`, so it behaves as unset.
*/
export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(function* <
Expand All @@ -13,8 +13,8 @@ export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(funct
const pending = secretReferencesInSettings(schema, settings);
if (pending.length === 0) return settings;
const values = yield* resolver.read(pending.map(({ reference }) => reference));
const resolved = Object.fromEntries(
pending.map(({ field }, index) => [field, values[index] ?? ""] as const),
return withSettingsSecrets(
settings,
pending.map((slot, index) => ({ ...slot, value: values[index] ?? "" })),
);
return { ...settings, ...resolved };
});
28 changes: 21 additions & 7 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1538,20 +1538,34 @@ 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<string, unknown>;
assert.deepEqual(tokens["github.com"], gitHubReference);
assert.notEqual(tokens["ghe.acme.test"], "ghp_ghe");
}).pipe(Effect.provide(layerServerSettingsWithSecrets())),
);

Expand Down
23 changes: 22 additions & 1 deletion apps/server/src/sourceControl/sourceControlHostSecrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ const source = (reference: string) => ({
reference,
});

const secretsByReference = new Map([["bitbucket#token", "bb_from_provider"]]);
const secretsByReference = new Map([
["bitbucket#token", "bb_from_provider"],
["github#token", "ghp_from_provider"],
]);

const perHostHosts = {
github: { tokens: { "github.com": source("github#token"), "ghe.acme.test": "ghp_literal" } },
};

const hosts = {
bitbucket: { email: "me@example.test", accessToken: source("bitbucket#token") },
Expand Down Expand Up @@ -69,11 +76,25 @@ it.effect("leaves the resolver unused for a kind that holds no reference", () =>
}),
);

it.effect("replaces a per-host token reference and keeps the literal beside it", () =>
Effect.gen(function* () {
const { reads, resolver } = countingResolver();
const resolved = yield* resolveSourceControlHostSecrets(resolver, perHostHosts, "github");
assert.deepEqual(resolved, {
github: { tokens: { "github.com": "ghp_from_provider", "ghe.acme.test": "ghp_literal" } },
});
assert.deepEqual(reads, ["github#token"]);
}),
);

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(perHostHosts), [
source("github#token"),
]);
assert.deepEqual(
collectSourceControlHostSecretReferences({
bitbucket: { accessToken: "literal" },
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/sourceControl/sourceControlHostSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,16 @@ 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<Record<string, Readonly<Record<string, unknown>>>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down
48 changes: 36 additions & 12 deletions apps/web/src/components/settings/SourceControlTokenSettings.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -55,7 +72,7 @@ export function SourceControlTokenSettings({
});
setSaving(false);
if (result._tag === "Success") {
setDraft("");
setPending(null);
onSaved();
}
};
Expand All @@ -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. */}
Expand Down Expand Up @@ -106,12 +123,15 @@ export function SourceControlTokenSettings({
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${definition.kind}-token-${environmentId}`}>Token</Label>
<SecretInput
<SecretSourceInput
id={`${definition.kind}-token-${environmentId}`}
size="sm"
configured={isSaved}
aria-label={`${definition.label} token`}
configured={isSaved && !isSecretReference(savedToken)}
value={draft}
onChange={(event) => setDraft(event.target.value)}
sources={secretSources}
onChange={(event) => setPending({ key: sourceKey, draft: event.target.value })}
onSecretChange={(secret) => setPending({ key: sourceKey, draft: secret ?? "" })}
/>
</div>
</div>
Expand All @@ -132,7 +152,11 @@ export function SourceControlTokenSettings({
Remove
</Button>
) : null}
<Button type="submit" size="xs" disabled={!normalizedHost || !draft.trim() || saving}>
<Button
type="submit"
size="xs"
disabled={!normalizedHost || submission === "" || saving}
>
Save
</Button>
</div>
Expand Down
70 changes: 56 additions & 14 deletions packages/contracts/src/settingsSecretValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<SecretReferenceField> {
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<SecretReferenceField>(({ 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<SettingsSecretReference> {
if (settings === null || typeof settings !== "object") return [];
const values = settings as Readonly<Record<string, unknown>>;
return secretReferenceSettingsFields(schema).flatMap((field) => {
const value = values[field];
return isSecretReference(value) ? [{ field, reference: value }] : [];
});
return secretReferenceSettingsFields(schema).flatMap<SettingsSecretReference>(
({ 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 extends Readonly<Record<string, unknown>>>(
settings: Settings,
slots: ReadonlyArray<SettingsSecretSlot & { readonly value: string }>,
): Settings {
const next: Record<string, unknown> = { ...settings };
for (const { field, key, value } of slots) {
next[field] = key === undefined ? value : { ...(next[field] as object), [key]: value };
}
return next as Settings;
}
3 changes: 2 additions & 1 deletion packages/source-control-core/src/shared/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type PullRequestReviewVerdict,
type RepositoryIdentity,
secretSetting,
SettingsSecretValue,
SourceControlProviderKind,
type SourceControlProviderInfo,
TrimmedNonEmptyString,
Expand Down Expand Up @@ -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 } }),
Expand Down
Loading
Loading