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