Open History to see the last commit
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import * as SchemaAST from "effect/SchemaAST";
4
5import { TrimmedString } from "./baseSchemas.ts";
6import { SecretReference } from "./secretReference.ts";
7
8/**
9 * A credential setting: the literal, which is kept in the secret store and redacted for
10 * clients, or the secret reference to read it from, which is not itself a secret and is
11 * stored and shown as written. Typing a settings field with it is the whole declaration:
12 * forms offer a secret provider for it and the server reads the secret for its consumer.
13 */
14export const SettingsSecretValue = Schema.Union([TrimmedString, SecretReference]).pipe(
15 Schema.annotateKey({ settingsSecret: true, settingsSecretReference: true }),
16);
17export type SettingsSecretValue = typeof SettingsSecretValue.Type;
18
19export const isSecretReference = Schema.is(SecretReference);
20
21/** The literal a credential setting holds, or `""` when it names a secret instead. */
22export function settingsSecretLiteral(value: SettingsSecretValue | undefined): string {
23 return typeof value === "string" ? value : "";
24}
25
26export class SettingsSecretUnresolvedError extends Schema.TaggedError<SettingsSecretUnresolvedError>()(
27 "SettingsSecretUnresolvedError",
28 { provider: Schema.String },
29) {
30 override get message(): string {
31 return `Could not read the credential from the secret provider "${this.provider}". Unlock or fix it in Settings, then try again.`;
32 }
33}
34
35/**
36 * The literal a credential setting holds once its secret is resolved. A reference still in place
37 * could not be read, and fails instead of passing for a missing token, so callers that would fall
38 * back to another credential cannot silently switch accounts.
39 */
40export const requireSettingsSecretLiteral = (
41 value: SettingsSecretValue | undefined,
42 secretProviders: Readonly<Record<string, { readonly label: string } | undefined>>,
43): Effect.Effect<string, SettingsSecretUnresolvedError> =>
44 isSecretReference(value)
45 ? Effect.fail(
46 new SettingsSecretUnresolvedError({
47 provider:
48 (Object.hasOwn(secretProviders, value.provider)
49 ? secretProviders[value.provider]?.label.trim()
50 : undefined) || value.provider,
51 }),
52 )
53 : Effect.succeed(value ?? "");
54
55export function acceptsSecretReference(schema: Schema.Top): boolean {
56 return Schema.resolveAnnotationsKey(schema)?.settingsSecretReference === true;
57}
58
59const declaresSecretReference = (ast: SchemaAST.AST) =>
60 ast.context?.annotations?.settingsSecretReference === true;
61
62interface SecretReferenceField {
63 readonly field: string;
64 readonly keyed: boolean;
65}
66
67/**
68 * Fields of a settings struct typed `SettingsSecretValue`, or a record of it such as one value
69 * per host; any other schema has none.
70 */
71function secretReferenceSettingsFields(schema: Schema.Top): ReadonlyArray<SecretReferenceField> {
72 if (!SchemaAST.isObjects(schema.ast)) return [];
73 return schema.ast.propertySignatures.flatMap<SecretReferenceField>(({ name, type }) => {
74 if (typeof name !== "string") return [];
75 if (declaresSecretReference(type)) return [{ field: name, keyed: false }];
76 return SchemaAST.isObjects(type) &&
77 type.indexSignatures.some((signature) => declaresSecretReference(signature.type))
78 ? [{ field: name, keyed: true }]
79 : [];
80 });
81}
82
83/** Where a reference sits: a field, or one key of a record field. */
84export interface SettingsSecretSlot {
85 readonly field: string;
86 readonly key: string | undefined;
87}
88
89interface SettingsSecretReference extends SettingsSecretSlot {
90 readonly reference: SecretReference;
91}
92
93/** Each declared reference slot of `settings` that holds a reference, in field order. */
94export function secretReferencesInSettings(
95 schema: Schema.Top,
96 settings: unknown,
97): ReadonlyArray<SettingsSecretReference> {
98 if (settings === null || typeof settings !== "object") return [];
99 const values = settings as Readonly<Record<string, unknown>>;
100 return secretReferenceSettingsFields(schema).flatMap<SettingsSecretReference>(
101 ({ field, keyed }) => {
102 const value = values[field];
103 if (!keyed)
104 return isSecretReference(value) ? [{ field, key: undefined, reference: value }] : [];
105 if (value === null || typeof value !== "object") return [];
106 return Object.entries(value).flatMap(([key, entry]) =>
107 isSecretReference(entry) ? [{ field, key, reference: entry }] : [],
108 );
109 },
110 );
111}
112
113/** `settings` with each slot set to its value, keeping the other keys of a record field. */
114export function withSettingsSecrets<Settings extends Readonly<Record<string, unknown>>>(
115 settings: Settings,
116 slots: ReadonlyArray<SettingsSecretSlot & { readonly value: string }>,
117): Settings {
118 const next: Record<string, unknown> = { ...settings };
119 for (const { field, key, value } of slots) {
120 next[field] = key === undefined ? value : { ...(next[field] as object), [key]: value };
121 }
122 return next as Settings;
123}