1import { cachedFunction } from "../Util/cached-function.ts";
2import * as Config from "effect/Config";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10
11import * as Semaphore from "effect/Semaphore";
12import { Interaction } from "../Interaction.ts";
13import { UserFacingError } from "../UserFacingError.ts";
14import { withProfileCredentialsLock } from "./Lock.ts";
15
16/**
17 * Canonical web host for OAuth provider-agnostic landing pages
18 * (`/auth/success`, `/auth/error`). The CLI's loopback server 302s the
19 * browser to one of these after handling the OAuth callback. Centralized
20 * here so the redirect target lives in exactly one place across all
21 * provider OAuth clients.
22 */
23export const AUTH_LANDING_HOST = "https://alchemy.run";
24export const AUTH_SUCCESS_URL = `${AUTH_LANDING_HOST}/auth/success`;
25export const AUTH_ERROR_URL = `${AUTH_LANDING_HOST}/auth/error`;
26
27/**
28 * Methods that may drive an interactive flow (prompts, browser-based
29 * OAuth, etc.). A process-wide mutex serializes these across providers
30 * so that, e.g., Cloudflare's `configure` finishes its prompt sequence
31 * before Planetscale's begins — even when the two auth provider Layers
32 * are built in parallel as part of a single `providers()` Layer.
33 *
34 * The interactive renderer enforces per-prompt serialization; this mutex
35 * enforces per-flow serialization so the user sees one provider's prompts
36 * grouped together rather than interleaved.
37 */
38const interactiveMutex = Semaphore.makeUnsafe(1);
39
40export class AuthError extends Schema.TaggedError<AuthError>()("AuthError", {
41 message: Schema.String,
42 cause: Schema.optional(Schema.Defect()),
43}) {
44 readonly [UserFacingError] = true;
45}
46
47/**
48 * Stored credentials exist (or are expected) but cannot be used until the
49 * user re-authenticates: missing values, an expired/rotated
50 * token, or a session the provider can no longer refresh silently. The
51 * profile UI renders this as "needs re-login" instead of a generic error,
52 * and callers match it with `Effect.catchTag("NeedsReauth", ...)` — never
53 * by inspecting the message.
54 */
55export class NeedsReauth extends Schema.TaggedError<NeedsReauth>()(
56 "NeedsReauth",
57 {
58 provider: Schema.String,
59 profile: Schema.String,
60 message: Schema.String,
61 cause: Schema.optional(Schema.Defect()),
62 },
63) {
64 readonly [UserFacingError] = true;
65}
66
67/**
68 * Standard remediation hint appended to stored-credential errors
69 * ("credentials not found", "refresh failed", ...). Deliberately generic —
70 * the explicit command is correct from any surface, including inside the
71 * profile dashboard (where `r` is merely the shortcut for it). Centralized
72 * so the phrasing lives in one place; surface-aware wording can layer on
73 * top later without touching call sites.
74 */
75export const refreshHint = (provider: string, profileName: string): string =>
76 `Run \`alchemy profile refresh --profile ${profileName} --provider ${provider}\`.`;
77
78/** {@link refreshHint}'s sibling for reconfiguration. */
79export const reconfigureHint = (
80 provider: string,
81 profileName: string,
82): string =>
83 `Run \`alchemy profile edit --profile ${profileName} --reconfigure ${provider}\` to reconfigure.`;
84
85export class AuthProviders extends Context.Service<
86 AuthProviders,
87 {
88 [providerName: string]: AuthProvider;
89 }
90>()("AuthProviders") {}
91
92/**
93 * Declares one environment variable a provider's {@link AuthProviderImpl.readEnvironment}
94 * consumes. Profiles are not available in CI — environment variables are the
95 * only CI credential source — so this metadata is the machine-readable
96 * contract for "what must CI set": rendered in docs, surfaced by the CLI,
97 * and available to tooling through the {@link AuthProviders} registry.
98 */
99export const EnvironmentVariable = Schema.Struct({
100 /** Environment variable name, e.g. `CLOUDFLARE_API_TOKEN`. */
101 name: Schema.String,
102 /** What the variable configures and when it applies. */
103 description: Schema.optional(Schema.String),
104 /**
105 * Whether credential resolution fails when neither this variable nor one
106 * of its {@link alternatives} is set. Use `description` to explain
107 * conditional requirements (e.g. "required unless X is set").
108 */
109 required: Schema.Boolean,
110 /** Holds a secret — display surfaces must redact its value. */
111 secret: Schema.optional(Schema.Boolean),
112 /**
113 * Alternative variable names that satisfy the same requirement, in
114 * precedence order after {@link name} (e.g. `AWS_DEFAULT_REGION` for
115 * `AWS_REGION`).
116 */
117 alternatives: Schema.optional(Schema.Array(Schema.String)),
118});
119
120export type EnvironmentVariable = typeof EnvironmentVariable.Type;
121
122const EnvironmentVariables = Schema.Array(EnvironmentVariable);
123
124/** Render a one-line summary of a provider's environment contract. */
125export const describeEnvironment = (
126 environment: ReadonlyArray<EnvironmentVariable>,
127): string =>
128 environment
129 .map((v) => {
130 const names = [v.name, ...(v.alternatives ?? [])].join(" | ");
131 return v.required ? names : `[${names}]`;
132 })
133 .join(", ");
134
135/**
136 * The variable names a provider's environment resolution would consume, or
137 * `undefined` when the declared contract is not fully satisfied (some
138 * required variable has no non-empty value). Reads through the ambient
139 * `ConfigProvider` — the process environment plus `.env` / `--env-file` —
140 * so it sees exactly what `readEnvironment` will. Environment credentials
141 * take precedence over any profile, CI or not; the returned names tell the
142 * user exactly which keys won.
143 */
144export const presentEnvironment = (
145 environment: ReadonlyArray<EnvironmentVariable>,
146) =>
147 Effect.gen(function* () {
148 const used: string[] = [];
149 for (const variable of environment) {
150 let found: string | undefined;
151 for (const name of [variable.name, ...(variable.alternatives ?? [])]) {
152 const value = yield* Config.option(Config.String(name));
153 if (Option.isSome(value) && value.value !== "") {
154 found = name;
155 break;
156 }
157 }
158 if (found !== undefined) used.push(found);
159 else if (variable.required) return undefined;
160 }
161 return used.length > 0 ? used : undefined;
162 });
163
164/**
165 * One rendered line of a provider's credential details: `key: value`.
166 * Values must arrive pre-redacted (see `displayRedacted`) — the display
167 * layer renders them verbatim.
168 */
169export interface ProviderDetailLine {
170 readonly key: string;
171 readonly value: string;
172}
173
174/**
175 * Structured result of {@link AuthProviderImpl.details} — what
176 * `alchemy profile show` and the dashboard render for a connected
177 * provider. Replaces the old `prettyPrint` Console-capture contract.
178 */
179export interface ProviderDetails {
180 readonly lines: ReadonlyArray<ProviderDetailLine>;
181}
182
183/**
184 * One input a provider's flag-driven (non-interactive) configuration
185 * accepts — the machine-readable half of `alchemy profile edit --add
186 * <provider> --method <m> --set <name>=<value>`.
187 */
188export interface ConfigureField {
189 /** `--set` key and, for stored-credential providers, the stored JSON property. */
190 readonly name: string;
191 /** Human prompt label, e.g. "Cloudflare API Token". */
192 readonly label: string;
193 /** Secondary guidance shown beneath the interactive input. */
194 readonly description?: string;
195 /** Masked during prompts and redacted in details. @default false */
196 readonly secret?: boolean;
197 /** May be omitted. @default false */
198 readonly optional?: boolean;
199 readonly placeholder?: string;
200 readonly defaultValue?: string;
201 /** Return an error message for an invalid value, undefined when valid. */
202 readonly validate?: (value: string) => string | undefined;
203}
204
205/**
206 * The flag-driven configuration contract a provider exposes per method:
207 * which `--method` names are accepted and which `--set` fields each one
208 * takes. Interactive-only methods (browser OAuth, SSO) simply don't
209 * appear here.
210 */
211export interface ConfigureMethod {
212 /**
213 * `--method` value. Always the same literal the provider persists as the
214 * config's `method` (`"stored"`, `"sso"`, `"local"`, `"env"`), so the
215 * flag and the credentials file speak one vocabulary.
216 */
217 readonly method: string;
218 readonly fields: ReadonlyArray<ConfigureField>;
219}
220
221export interface AuthProviderImpl<
222 Config extends { method: string } = { method: string },
223 Credentials = unknown,
224 R = never,
225> {
226 /**
227 * Schema for the provider-owned `values` object ({@link Config}). Stored
228 * entries are user-editable JSON that may also come from a newer or
229 * older alchemy, so every load decodes against this schema — an invalid
230 * entry fails with a reconfigure hint instead of reaching provider code
231 * that matches exhaustively on `method`.
232 */
233 readonly configSchema: Schema.Codec<Config>;
234
235 configure(
236 profileName: string,
237 currentConfig?: Config,
238 ): Effect.Effect<Config, AuthError, R | Interaction>;
239
240 /**
241 * Flag-driven configuration for scripts and agents: validated `--set`
242 * values for one of the methods declared in {@link configureMethods}.
243 * Optional — interactive-only providers omit it.
244 */
245 configureWith?(
246 profileName: string,
247 input: {
248 readonly method: string;
249 readonly values: Record<string, string>;
250 },
251 ): Effect.Effect<Config, AuthError, R | Interaction>;
252
253 /**
254 * The methods {@link configureWith} accepts and their fields. Required
255 * whenever `configureWith` is implemented so the CLI can validate and
256 * document the flags.
257 */
258 readonly configureMethods?: ReadonlyArray<ConfigureMethod>;
259
260 login(
261 profileName: string,
262 config: Config,
263 updateConfig?: (config: Config) => Effect.Effect<void, AuthError>,
264 ): Effect.Effect<Config | void, AuthError, R | Interaction>;
265
266 logout(
267 profileName: string,
268 config: Config,
269 ): Effect.Effect<void, AuthError, R | Interaction>;
270
271 /**
272 * Structured credential details for display. Fails with
273 * {@link NeedsReauth} when stored credentials exist but require
274 * re-authentication, so the UI can render "needs re-login" instead of a
275 * generic error.
276 */
277 details(
278 profileName: string,
279 config: Config,
280 updateConfig?: (config: Config) => Effect.Effect<void, AuthError>,
281 ): Effect.Effect<ProviderDetails, AuthError | NeedsReauth, R | Interaction>;
282
283 /**
284 * Resolve credentials from the profile values, silently refreshing when the
285 * provider supports it. MUST be non-interactive — this is the only method
286 * (with {@link readEnvironment}) that child processes exercise, and their
287 * graphs carry no interaction services. When re-authentication is needed,
288 * fail with {@link NeedsReauth} instead of prompting.
289 */
290 read(
291 profileName: string,
292 config: Config,
293 updateConfig?: (config: Config) => Effect.Effect<void, AuthError>,
294 ): Effect.Effect<Credentials, AuthError | NeedsReauth, R>;
295
296 /**
297 * Resolve credentials directly from the process environment for CI.
298 * This never creates, selects, or mutates an Alchemy profile.
299 */
300 readonly readEnvironment?: Effect.Effect<Credentials, AuthError, R>;
301
302 /**
303 * The environment variables {@link readEnvironment} consumes. Required
304 * whenever `readEnvironment` is implemented — profiles do not exist in CI,
305 * so this list is the provider's entire CI configuration contract. Names
306 * only, never values.
307 */
308 readonly environment?: ReadonlyArray<EnvironmentVariable>;
309}
310
311export interface AuthProvider<
312 Config extends { method: string } = { method: string },
313 Credentials = unknown,
314> extends AuthProviderImpl<Config, Credentials> {
315 readonly kind: "AuthProvider";
316 readonly name: string;
317 /** Log each environment contract once per built provider layer. */
318 readonly logEnvironmentCredentials: (
319 used: ReadonlyArray<string>,
320 ) => Effect.Effect<void>;
321 /**
322 * The provider's declared CI environment contract. Empty when the
323 * provider does not support environment credentials.
324 */
325 readonly environment: ReadonlyArray<EnvironmentVariable>;
326 /**
327 * Decode raw provider values against {@link AuthProviderImpl.configSchema}.
328 * Fails with an {@link AuthError} carrying the reconfigure hint, so every
329 * consumer of stored configuration reports invalid entries the same way.
330 */
331 decodeConfig(
332 profileName: string,
333 config: unknown,
334 ): Effect.Effect<Config, AuthError>;
335}
336
337export const AuthProvider =
338 <Config extends { method: string }, Credentials>() =>
339 <R = never, ImplReq = never>(
340 name: string,
341 impl:
342 | AuthProviderImpl<Config, Credentials, R>
343 | Effect.Effect<AuthProviderImpl<Config, Credentials, R>, never, ImplReq>,
344 ) =>
345 Effect.gen(function* () {
346 // FileSystem/Path back the cross-process credentials lock that wraps
347 // `logout`/`read` below, so capture them with the impl's own services.
348 //
349 // `Effect.context()` snapshots the ENTIRE fiber context (the type
350 // parameter only narrows the type), and `Effect.provideContext` makes
351 // the provided side win — so an Interaction that happens to be
352 // ambient at REGISTRATION would silently shadow whatever the caller
353 // provides at call time, making the interactive methods' declared
354 // `Interaction` requirement a dead letter. Omit it from the snapshot:
355 // interactive flows resolve Interaction from the caller, exactly as
356 // their signatures promise (a scripted test Interaction, a
357 // browser-driven Alchemist login).
358 // SAFETY: the assertion restores the pre-omit type — tsc cannot reduce
359 // `Exclude<R, … Exclude<R, Interaction>>` over the generic R. It only
360 // over-promises Interaction, which every registered method signature
361 // re-declares as a requirement anyway, so no call site can rely on the
362 // capture satisfying it.
363 const ctx = Context.omit(Interaction)(
364 yield* Effect.context<
365 FileSystem.FileSystem | Path.Path | R | ImplReq
366 >(),
367 ) as Context.Context<FileSystem.FileSystem | Path.Path | R | ImplReq>;
368 const providers = yield* AuthProviders;
369 const service = yield* Effect.isEffect(impl)
370 ? impl
371 : Effect.succeed(impl);
372 // Validate the declared environment contract at registration so a
373 // malformed declaration fails at layer build (programmer error), not
374 // when a CI run tries to render it.
375 const environment =
376 service.environment === undefined
377 ? []
378 : Schema.decodeUnknownSync(EnvironmentVariables)(service.environment);
379 if (service.readEnvironment !== undefined && environment.length === 0) {
380 return yield* Effect.die(
381 `AuthProvider '${name}' implements readEnvironment but does not ` +
382 "declare its `environment` variables. Declare every variable " +
383 "readEnvironment consumes so CI requirements are discoverable.",
384 );
385 }
386 if (
387 service.configureWith !== undefined &&
388 (service.configureMethods === undefined ||
389 service.configureMethods.length === 0)
390 ) {
391 return yield* Effect.die(
392 `AuthProvider '${name}' implements configureWith but does not ` +
393 "declare `configureMethods`. Declare each accepted --method and " +
394 "its --set fields so the CLI can validate and document them.",
395 );
396 }
397
398 const provider: AuthProvider<Config, Credentials> = {
399 logEnvironmentCredentials: yield* cachedFunction(
400 (used: ReadonlyArray<string>) =>
401 Effect.logInfo(
402 `${name}: using environment variables (${used.join(", ")}) instead of the profile.`,
403 ),
404 { key: ([used]) => JSON.stringify(used.toSorted()) },
405 ),
406 kind: "AuthProvider",
407 name,
408 // configure/login can wait minutes on a browser grant, so they hold
409 // only the process-level interactive mutex — never the per-profile
410 // credentials lock, which would starve every concurrent `read` (its
411 // waiters time out at ~120s). Providers whose login path performs a
412 // rotate-on-use silent refresh wrap that read-refresh-persist
413 // section (and only it) in `withProfileCredentialsLock` themselves;
414 // a post-browser persist is a benign whole-file swap where the last
415 // grant wins.
416 configure: (profileName, currentConfig) =>
417 Semaphore.withPermits(
418 interactiveMutex,
419 1,
420 )(
421 service
422 .configure(profileName, currentConfig)
423 .pipe(Effect.provideContext(ctx)),
424 ),
425 login: (profileName, config, updateConfig) =>
426 Semaphore.withPermits(
427 interactiveMutex,
428 1,
429 )(
430 service
431 .login(profileName, config, updateConfig)
432 .pipe(Effect.provideContext(ctx)),
433 ),
434 logout: (profileName, config) =>
435 withProfileCredentialsLock(
436 profileName,
437 service.logout(profileName, config),
438 ).pipe(Effect.provideContext(ctx)),
439 details: (profileName, config, updateConfig) =>
440 withProfileCredentialsLock(
441 profileName,
442 service.details(profileName, config, updateConfig),
443 ).pipe(Effect.provideContext(ctx)),
444 ...(service.configureWith === undefined
445 ? {}
446 : {
447 configureWith: (
448 profileName: string,
449 input: {
450 readonly method: string;
451 readonly values: Record<string, string>;
452 },
453 ) =>
454 service.configureWith!(profileName, input).pipe(
455 Effect.provideContext(ctx),
456 ),
457 configureMethods: service.configureMethods,
458 }),
459 read: (profileName, config, updateConfig) =>
460 withProfileCredentialsLock(
461 profileName,
462 service.read(profileName, config, updateConfig),
463 ).pipe(Effect.provideContext(ctx)),
464 readEnvironment: service.readEnvironment?.pipe(
465 Effect.provideContext(ctx),
466 ),
467 environment,
468 configSchema: service.configSchema,
469 decodeConfig: (profileName, config) =>
470 Effect.gen(function* () {
471 return yield* Schema.decodeUnknownEffect(service.configSchema)(
472 config,
473 ).pipe(
474 Effect.mapError(
475 (cause) =>
476 new AuthError({
477 message:
478 `Stored ${name} configuration in profile '${profileName}' is not valid ` +
479 `for this version of alchemy. ` +
480 `${reconfigureHint(name, profileName)}`,
481 cause,
482 }),
483 ),
484 );
485 }),
486 };
487
488 providers[name] = provider;
489 });
490
491/**
492 * Build a Layer that registers an AuthProvider into the {@link AuthProviders}
493 * registry when its parent layer is built. Use this from a provider's
494 * top-level `providers()` Layer so that the alchemy CLI can discover the
495 * provider via the registry without forcing credential resolution.
496 */
497export const AuthProviderLayer =
498 <Config extends { method: string }, Credentials>() =>
499 <R = never, ImplReq = never>(
500 name: string,
501 impl:
502 | AuthProviderImpl<Config, Credentials, R>
503 | Effect.Effect<AuthProviderImpl<Config, Credentials, R>, never, ImplReq>,
504 ) =>
505 Layer.effectDiscard(
506 AuthProvider<Config, Credentials>()<R, ImplReq>(name, impl),
507 );
508
509/**
510 * Look up a registered {@link AuthProvider} by name. Fails with
511 * {@link AuthError} if the provider hasn't been registered (typically because
512 * its layer hasn't been built).
513 */
514export const getAuthProvider = <
515 Config extends { method: string } = { method: string },
516 Credentials = unknown,
517>(
518 name: string,
519): Effect.Effect<AuthProvider<Config, Credentials>, AuthError, AuthProviders> =>
520 AuthProviders.use((registry) =>
521 registry[name] == null
522 ? Effect.fail(
523 new AuthError({
524 message: `AuthProvider '${name}' is not registered. Make sure its layer has been provided.`,
525 }),
526 )
527 : Effect.succeed(
528 // SAFETY: registration erases provider-specific generic parameters;
529 // callers restore the contract associated with the requested name.
530 registry[name] as AuthProvider<Config, Credentials>,
531 ),
532 );