| 1 | import { cachedFunction } from "../Util/cached-function.ts"; |
| 2 | import * as Config from "effect/Config"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Path from "effect/Path"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | |
| 11 | import * as Semaphore from "effect/Semaphore"; |
| 12 | import { Interaction } from "../Interaction.ts"; |
| 13 | import { UserFacingError } from "../UserFacingError.ts"; |
| 14 | import { 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 | */ |
| 23 | export const AUTH_LANDING_HOST = "https://alchemy.run"; |
| 24 | export const AUTH_SUCCESS_URL = `${AUTH_LANDING_HOST}/auth/success`; |
| 25 | export 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 | */ |
| 38 | const interactiveMutex = Semaphore.makeUnsafe(1); |
| 39 | |
| 40 | export 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 | */ |
| 55 | export 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 | */ |
| 75 | export const refreshHint = (provider: string, profileName: string): string => |
| 76 | `Run \`alchemy profile refresh --profile ${profileName} --provider ${provider}\`.`; |
| 77 | |
| 78 | /** {@link refreshHint}'s sibling for reconfiguration. */ |
| 79 | export const reconfigureHint = ( |
| 80 | provider: string, |
| 81 | profileName: string, |
| 82 | ): string => |
| 83 | `Run \`alchemy profile edit --profile ${profileName} --reconfigure ${provider}\` to reconfigure.`; |
| 84 | |
| 85 | export 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 | */ |
| 99 | export 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 | |
| 120 | export type EnvironmentVariable = typeof EnvironmentVariable.Type; |
| 121 | |
| 122 | const EnvironmentVariables = Schema.Array(EnvironmentVariable); |
| 123 | |
| 124 | /** Render a one-line summary of a provider's environment contract. */ |
| 125 | export 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 | */ |
| 144 | export 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 | */ |
| 169 | export 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 | */ |
| 179 | export 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 | */ |
| 188 | export 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 | */ |
| 211 | export 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 | |
| 221 | export 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 | |
| 311 | export 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 | |
| 337 | export 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 | */ |
| 497 | export 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 | */ |
| 514 | export 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 | ); |