1import * as Effect from "effect/Effect";
2import * as Redacted from "effect/Redacted";
3import * as Schema from "effect/Schema";
4import * as Interaction from "../Interaction.ts";
5import {
6 AuthError,
7 AuthProviderLayer,
8 type ConfigureField,
9 type ConfigureMethod,
10 type EnvironmentVariable,
11} from "./AuthProvider.ts";
12import { displayRedacted } from "./Credentials.ts";
13import { mapPromptCancellation } from "./Env.ts";
14
15/**
16 * Collected field values: one string per non-omitted field, keyed by
17 * {@link ConfigureField.name}.
18 */
19export type StoredValue = string | Redacted.Redacted<string>;
20export type StoredValues = Record<string, StoredValue | undefined>;
21
22/** Reveal a collected value only at a validation or I/O boundary. */
23export const storedValueText = (value: StoredValue | undefined) =>
24 value === undefined
25 ? undefined
26 : Redacted.isRedacted(value)
27 ? Redacted.value(value)
28 : value;
29
30/** Preserve or introduce redaction for a collected secret. */
31export const storedSecret = (value: StoredValue | undefined) =>
32 value === undefined
33 ? undefined
34 : Redacted.isRedacted(value)
35 ? value
36 : Redacted.make(value);
37
38/**
39 * Everything needed to generate a complete single-method ("stored")
40 * {@link AuthProviderImpl} for a provider whose credential is a token (or a
41 * small set of fields) the user pastes once: prompts, flag-driven
42 * configuration, schema-validated inline values, login/logout, and
43 * structured details.
44 *
45 * Providers with several auth methods (browser OAuth, SSO) don't fit this
46 * factory — they hand-roll the impl and may still reuse
47 * {@link collectFieldValues} for their token-shaped method.
48 */
49export interface StoredAuthProviderConfig<Resolved> {
50 /** Registry name, e.g. `"Neon"`. */
51 readonly provider: string;
52 /** The fields collected interactively or via `--set`. */
53 readonly fields: ReadonlyArray<ConfigureField>;
54 /**
55 * Derive additional stored values after input collection — e.g. resolve
56 * an account id with an API call using the entered token. Runs for both
57 * the interactive and flag-driven paths, before persistence.
58 */
59 readonly complete?: (
60 values: StoredValues,
61 ) => Effect.Effect<StoredValues, AuthError>;
62 /**
63 * Map validated stored values to the in-memory resolved credentials.
64 * `source` distinguishes profile-stored values from CI env resolution.
65 */
66 readonly toResolved: (values: StoredValues, source: "stored") => Resolved;
67 /** CI resolution from env vars; requires {@link environment}. */
68 readonly readEnvironment?: Effect.Effect<Resolved, AuthError>;
69 readonly environment?: ReadonlyArray<EnvironmentVariable>;
70}
71
72/** Inline-values type for factory-made static-token providers. */
73export type StoredAuthConfig = {
74 readonly method: "stored";
75 readonly [key: string]: StoredValue | undefined;
76};
77
78/**
79 * Validate flag-provided values against the field specs: unknown keys,
80 * missing required fields, and per-field validators all fail with an
81 * actionable {@link AuthError}.
82 */
83export const validateFieldValues = (
84 provider: string,
85 fields: ReadonlyArray<ConfigureField>,
86 values: Record<string, string>,
87): Effect.Effect<StoredValues, AuthError> =>
88 Effect.gen(function* () {
89 const known = new Set(fields.map((field) => field.name));
90 for (const key of Object.keys(values)) {
91 if (!known.has(key)) {
92 return yield* Effect.fail(
93 new AuthError({
94 message:
95 `${provider}: unknown field '${key}'. ` +
96 `Valid fields: ${[...known].join(", ")}.`,
97 }),
98 );
99 }
100 }
101 const collected: StoredValues = {};
102 for (const field of fields) {
103 const raw = values[field.name] ?? field.defaultValue;
104 if (raw === undefined || raw.length === 0) {
105 if (field.optional) continue;
106 return yield* Effect.fail(
107 new AuthError({
108 message: `${provider}: missing required field '${field.name}' (${field.label}). Pass it with --set ${field.name}=<value>.`,
109 }),
110 );
111 }
112 const invalid = field.validate?.(raw);
113 if (invalid !== undefined) {
114 return yield* Effect.fail(
115 new AuthError({
116 message: `${provider}: invalid '${field.name}': ${invalid}`,
117 }),
118 );
119 }
120 collected[field.name] = field.secret ? Redacted.make(raw) : raw;
121 }
122 return collected;
123 });
124
125/**
126 * Prompt for each field in order (secret fields masked, optional fields
127 * skippable with an empty answer). Shared by the factory's interactive
128 * `configure` and by hand-rolled multi-method providers that want the same
129 * behavior for their token method.
130 */
131export const collectFieldValues = (
132 fields: ReadonlyArray<ConfigureField>,
133): Effect.Effect<StoredValues, AuthError, Interaction.Interaction> =>
134 Effect.gen(function* () {
135 const interaction = yield* Interaction.Interaction;
136 const values: StoredValues = {};
137 for (const field of fields) {
138 const description = [
139 field.description,
140 field.optional ? "Optional — press Enter to skip." : undefined,
141 ]
142 .filter((part): part is string => part !== undefined)
143 .join(" ");
144 const validate = (value: string) => {
145 if (value.length === 0) return field.optional ? undefined : "Required";
146 return field.validate?.(value);
147 };
148 const request = field.secret
149 ? interaction.prompt.password({
150 message: field.label,
151 description: description || undefined,
152 placeholder: field.placeholder,
153 validate,
154 })
155 : interaction.prompt.text({
156 message: field.label,
157 description: description || undefined,
158 placeholder: field.placeholder,
159 defaultValue: field.defaultValue,
160 validate,
161 });
162 const answer = yield* request.pipe(mapPromptCancellation);
163 if (answer.length === 0 && field.optional) continue;
164 values[field.name] = field.secret ? Redacted.make(answer) : answer;
165 }
166 return values;
167 });
168
169/**
170 * Build a registration Layer (plus the derived inline-values schema)
171 * for a single-method stored-credential provider.
172 *
173 * ```ts
174 * export const { layer: NeonAuth, storedSchema: NeonStoredCredentials } =
175 * makeStoredAuthProvider({
176 * provider: "Neon",
177 * fields: [{ name: "apiKey", label: "Neon API Key", secret: true }],
178 * toResolved: (values, source) => ({ ... }),
179 * readEnvironment: ...,
180 * environment: [...],
181 * });
182 * ```
183 */
184export const makeStoredAuthProvider = <Resolved>(
185 config: StoredAuthProviderConfig<Resolved>,
186) => {
187 const { provider, fields } = config;
188
189 const fieldSchemas = Object.fromEntries(
190 fields.map((field) => [
191 field.name,
192 field.optional ? Schema.optional(Schema.String) : Schema.String,
193 ]),
194 );
195
196 const storedSchema = Schema.Struct(
197 fieldSchemas,
198 // SAFETY: each generated property schema corresponds exactly to the
199 // ConfigureField entry used to construct StoredValues above.
200 ) as Schema.Codec<StoredValues>;
201
202 const configSchema = Schema.Struct({
203 method: Schema.Literal("stored"),
204 ...fieldSchemas,
205 }) as Schema.Codec<StoredAuthConfig>;
206
207 const layer = AuthProviderLayer<StoredAuthConfig, Resolved>()(
208 provider,
209 Effect.gen(function* () {
210 const interaction = Interaction.accessors;
211 const persist = (_profileName: string, values: StoredValues) =>
212 Effect.gen(function* () {
213 const complete = config.complete?.(values) ?? Effect.succeed(values);
214 const completed = yield* complete;
215 yield* interaction.output.success(`${provider}: credentials saved.`);
216 return {
217 method: "stored" as const,
218 ...Object.fromEntries(
219 Object.entries(completed).map(([key, value]) => [
220 key,
221 storedValueText(value),
222 ]),
223 ),
224 };
225 });
226
227 const configure = (profileName: string) =>
228 collectFieldValues(fields).pipe(
229 Effect.flatMap((values) => persist(profileName, values)),
230 );
231
232 const configureWith = (
233 profileName: string,
234 input: { method: string; values: Record<string, string> },
235 ) =>
236 input.method === "stored"
237 ? validateFieldValues(provider, fields, input.values).pipe(
238 Effect.flatMap((values) => persist(profileName, values)),
239 )
240 : Effect.fail(
241 new AuthError({
242 message: `${provider}: unknown method '${input.method}'. Valid methods: stored.`,
243 }),
244 );
245
246 const read = (_profileName: string, values: StoredAuthConfig) =>
247 Effect.succeed(config.toResolved(values, "stored"));
248
249 const login = (_profileName: string, values: StoredAuthConfig) =>
250 Effect.succeed(values);
251
252 const logout = (_profileName: string, _config: StoredAuthConfig) =>
253 Effect.void;
254
255 const details = (_profileName: string, values: StoredAuthConfig) =>
256 Effect.succeed({
257 lines: fields.flatMap((field) => {
258 const value = values[field.name];
259 if (value === undefined) return [];
260 return [
261 {
262 key: field.name,
263 value: field.secret
264 ? displayRedacted(storedSecret(value) ?? Redacted.make(""))
265 : (storedValueText(value) ?? ""),
266 },
267 ];
268 }),
269 });
270
271 const configureMethods: ReadonlyArray<ConfigureMethod> = [
272 { method: "stored", fields },
273 ];
274
275 return {
276 configSchema,
277 configure,
278 configureWith,
279 configureMethods,
280 login,
281 logout,
282 details,
283 read,
284 readEnvironment: config.readEnvironment,
285 environment: config.environment,
286 };
287 }),
288 );
289
290 return { layer, storedSchema };
291};