| 1 | import * as cfAccounts from "@distilled.cloud/cloudflare/accounts"; |
| 2 | import * as cfMemberships from "@distilled.cloud/cloudflare/memberships"; |
| 3 | import * as CfCredentialsModule from "@distilled.cloud/cloudflare/Credentials"; |
| 4 | import * as Clock from "effect/Clock"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Match from "effect/Match"; |
| 9 | import * as Redacted from "effect/Redacted"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 12 | import * as HttpClient from "effect/http/HttpClient"; |
| 13 | import { |
| 14 | AuthError, |
| 15 | AuthProviderLayer, |
| 16 | NeedsReauth, |
| 17 | reconfigureHint, |
| 18 | refreshHint, |
| 19 | type ConfigureField, |
| 20 | type ConfigureMethod, |
| 21 | type ProviderDetails, |
| 22 | } from "../../Auth/AuthProvider.ts"; |
| 23 | import { |
| 24 | storedSecret, |
| 25 | storedValueText, |
| 26 | validateFieldValues, |
| 27 | } from "../../Auth/StoredAuthProvider.ts"; |
| 28 | import { CredentialsStore, displayRedacted } from "../../Auth/Credentials.ts"; |
| 29 | import { withProfileCredentialsLock } from "../../Auth/Lock.ts"; |
| 30 | import { |
| 31 | getEnvRedacted, |
| 32 | getEnvRequired, |
| 33 | mapPromptCancellation, |
| 34 | } from "../../Auth/Env.ts"; |
| 35 | import { browserOAuth } from "../../Auth/BrowserOAuth.ts"; |
| 36 | import * as Interaction from "../../Interaction.ts"; |
| 37 | import { CREDENTIALS_FILE as STATE_STORE_CREDENTIALS_FILE } from "../StateStore/CredentialsFile.ts"; |
| 38 | import * as OAuthClient from "./OAuthClient.ts"; |
| 39 | import { |
| 40 | CLOUDFLARE_AUTH_PROVIDER_NAME, |
| 41 | CloudflareAuthConfigSchema, |
| 42 | validateAccountId, |
| 43 | validateAccountIdField, |
| 44 | type CloudflareAuthConfig, |
| 45 | type CloudflareResolvedCredentials, |
| 46 | } from "./AuthConfig.ts"; |
| 47 | import { |
| 48 | ALL_SCOPE_IDS, |
| 49 | BASIC_SCOPES, |
| 50 | customOAuthScopeDefaults, |
| 51 | OAUTH_SCOPE_GROUPS, |
| 52 | OAUTH_SCOPE_NAMES, |
| 53 | partitionOAuthScopes, |
| 54 | } from "./OAuthScopes.ts"; |
| 55 | |
| 56 | const options: Array<{ |
| 57 | value: CloudflareAuthConfig["method"]; |
| 58 | label: string; |
| 59 | description?: string; |
| 60 | }> = [ |
| 61 | { |
| 62 | value: "oauth", |
| 63 | label: "OAuth", |
| 64 | description: |
| 65 | "recommended — browser-based login with automatic token refresh", |
| 66 | }, |
| 67 | { |
| 68 | value: "stored", |
| 69 | label: "API Token or API Key", |
| 70 | description: "use an API token or global API key", |
| 71 | }, |
| 72 | ]; |
| 73 | |
| 74 | const withOAuthCredentials = <A, E, R>( |
| 75 | accessToken: string, |
| 76 | effect: Effect.Effect< |
| 77 | A, |
| 78 | E, |
| 79 | R | CfCredentialsModule.Credentials | HttpClient.HttpClient |
| 80 | >, |
| 81 | ): Effect.Effect<A, E, R> => |
| 82 | Effect.provide( |
| 83 | effect, |
| 84 | Layer.mergeAll( |
| 85 | CfCredentialsModule.fromOAuth({ |
| 86 | load: Effect.succeed({ accessToken }), |
| 87 | refresh: () => |
| 88 | Effect.die("refresh not expected during account selection"), |
| 89 | }), |
| 90 | FetchHttpClient.layer, |
| 91 | ), |
| 92 | ); |
| 93 | |
| 94 | /** |
| 95 | * Accounts visible to a user-scoped OAuth token. `GET /accounts` returns an |
| 96 | * EMPTY list for these tokens (it only lists accounts an account-scoped |
| 97 | * credential can read), so memberships are the authoritative account list — |
| 98 | * the same endpoint wrangler uses. `GET /accounts` remains as a fallback for |
| 99 | * tokens authorized without `memberships.read` (custom scope selections). |
| 100 | */ |
| 101 | const listVisibleAccounts: Effect.Effect< |
| 102 | ReadonlyArray<{ id: string; name: string }>, |
| 103 | unknown, |
| 104 | CfCredentialsModule.Credentials | HttpClient.HttpClient |
| 105 | > = Effect.gen(function* () { |
| 106 | const listMemberships = yield* cfMemberships.listMemberships; |
| 107 | const membershipAccounts = yield* listMemberships({}).pipe( |
| 108 | Effect.map((response) => |
| 109 | response.result.flatMap((membership) => |
| 110 | membership.account != null && |
| 111 | (membership.status == null || membership.status === "accepted") |
| 112 | ? [membership.account] |
| 113 | : [], |
| 114 | ), |
| 115 | ), |
| 116 | Effect.catch((error) => |
| 117 | Effect.logDebug("Cloudflare: listing memberships failed", error).pipe( |
| 118 | Effect.as([] as Array<{ id: string; name: string }>), |
| 119 | ), |
| 120 | ), |
| 121 | ); |
| 122 | if (membershipAccounts.length > 0) return membershipAccounts; |
| 123 | const listAccounts = yield* cfAccounts.listAccounts; |
| 124 | const response = yield* listAccounts({}); |
| 125 | yield* Effect.logDebug( |
| 126 | `Cloudflare: memberships listed 0 accounts; /accounts listed ${response.result.length}`, |
| 127 | ); |
| 128 | return response.result; |
| 129 | }); |
| 130 | |
| 131 | const selectAccount = (accessToken: string) => |
| 132 | Effect.gen(function* () { |
| 133 | const interaction = Interaction.accessors; |
| 134 | const accounts = yield* listVisibleAccounts; |
| 135 | if (accounts.length === 0) { |
| 136 | return yield* new AuthError({ |
| 137 | message: |
| 138 | "No Cloudflare accounts are visible to this credential. " + |
| 139 | "Ensure the authorized OAuth scopes include 'memberships.read'.", |
| 140 | }); |
| 141 | } |
| 142 | const [account] = accounts; |
| 143 | if (accounts.length === 1 && account !== undefined) { |
| 144 | yield* interaction.output.info( |
| 145 | `Using Cloudflare account ${account.name}.`, |
| 146 | ); |
| 147 | return account.id; |
| 148 | } |
| 149 | return yield* interaction.prompt |
| 150 | .select({ |
| 151 | message: "Select a Cloudflare account", |
| 152 | searchable: true, |
| 153 | options: accounts.map((a) => ({ |
| 154 | value: a.id, |
| 155 | label: a.name, |
| 156 | description: a.id, |
| 157 | })), |
| 158 | }) |
| 159 | .pipe(mapPromptCancellation); |
| 160 | }).pipe((e) => withOAuthCredentials(accessToken, e)); |
| 161 | |
| 162 | const promptAccountId = () => |
| 163 | Effect.gen(function* () { |
| 164 | const interaction = yield* Interaction.Interaction; |
| 165 | return yield* interaction.prompt |
| 166 | .text({ |
| 167 | message: "Cloudflare Account ID", |
| 168 | validate: validateAccountIdField, |
| 169 | }) |
| 170 | .pipe(mapPromptCancellation); |
| 171 | }); |
| 172 | |
| 173 | const accountIdField: ConfigureField = { |
| 174 | name: "accountId", |
| 175 | label: "Cloudflare Account ID", |
| 176 | validate: validateAccountIdField, |
| 177 | }; |
| 178 | |
| 179 | /** |
| 180 | * `--set` fields for `--method stored`. The persisted `credentialType` is |
| 181 | * inferred from which secret is supplied: `apiToken` alone, or `apiKey` |
| 182 | * together with `email`. |
| 183 | */ |
| 184 | const storedFields: ReadonlyArray<ConfigureField> = [ |
| 185 | { |
| 186 | name: "apiToken", |
| 187 | label: "Cloudflare API Token", |
| 188 | description: "Pass either apiToken, or apiKey together with email.", |
| 189 | secret: true, |
| 190 | optional: true, |
| 191 | }, |
| 192 | { name: "apiKey", label: "Cloudflare API Key", secret: true, optional: true }, |
| 193 | { name: "email", label: "Cloudflare Email", optional: true }, |
| 194 | accountIdField, |
| 195 | ]; |
| 196 | |
| 197 | /** |
| 198 | * Flag-driven configuration methods. OAuth is deliberately absent — it is |
| 199 | * interactive-only (browser grant). |
| 200 | */ |
| 201 | const configureMethods: ReadonlyArray<ConfigureMethod> = [ |
| 202 | { method: "stored", fields: storedFields }, |
| 203 | ]; |
| 204 | |
| 205 | const promptOAuthScopes = (currentConfig?: CloudflareAuthConfig) => |
| 206 | Effect.gen(function* () { |
| 207 | const interaction = yield* Interaction.Interaction; |
| 208 | const mode = yield* interaction.prompt |
| 209 | .select({ |
| 210 | message: "Cloudflare OAuth scopes", |
| 211 | initialValue: "all" as const, |
| 212 | options: [ |
| 213 | { |
| 214 | value: "all" as const, |
| 215 | label: "All Scopes", |
| 216 | description: "authorize every available Cloudflare permission", |
| 217 | }, |
| 218 | { |
| 219 | value: "basic" as const, |
| 220 | label: "Basic Scopes", |
| 221 | description: "covers typical Alchemy use cases", |
| 222 | }, |
| 223 | { |
| 224 | value: "custom" as const, |
| 225 | label: "Custom Scopes", |
| 226 | description: "choose individual permissions", |
| 227 | }, |
| 228 | ], |
| 229 | }) |
| 230 | .pipe(mapPromptCancellation); |
| 231 | if (mode === "basic") return [...BASIC_SCOPES]; |
| 232 | if (mode === "all") return [...ALL_SCOPE_IDS]; |
| 233 | return yield* interaction.prompt |
| 234 | .multiSelect({ |
| 235 | message: "Select OAuth scopes", |
| 236 | initialValues: customOAuthScopeDefaults(currentConfig), |
| 237 | searchable: true, |
| 238 | descriptionPlacement: "inline", |
| 239 | options: OAUTH_SCOPE_GROUPS.flatMap((group) => |
| 240 | group.scopes.map((value) => ({ |
| 241 | value, |
| 242 | label: OAUTH_SCOPE_NAMES[value], |
| 243 | description: value, |
| 244 | group: group.label, |
| 245 | })), |
| 246 | ), |
| 247 | required: true, |
| 248 | }) |
| 249 | .pipe(mapPromptCancellation); |
| 250 | }); |
| 251 | |
| 252 | /** |
| 253 | * Layer that registers the Cloudflare {@link AuthProvider} into the |
| 254 | * {@link AuthProviders} registry when built. Include this in the Cloudflare |
| 255 | * `providers()` layer so the alchemy CLI can discover it. |
| 256 | */ |
| 257 | export const CloudflareAuth = AuthProviderLayer< |
| 258 | CloudflareAuthConfig, |
| 259 | CloudflareResolvedCredentials |
| 260 | >()( |
| 261 | CLOUDFLARE_AUTH_PROVIDER_NAME, |
| 262 | Effect.gen(function* () { |
| 263 | const interaction = Interaction.accessors; |
| 264 | const store = yield* CredentialsStore; |
| 265 | |
| 266 | const oauthLogin = (_profileName: string, scopes: string[]) => |
| 267 | Effect.gen(function* () { |
| 268 | const authorization = yield* OAuthClient.authorize([ |
| 269 | ...scopes, |
| 270 | "offline_access", |
| 271 | ]); |
| 272 | |
| 273 | const credentials = yield* browserOAuth({ |
| 274 | provider: "Cloudflare", |
| 275 | url: authorization.url, |
| 276 | callback: OAuthClient.callback(authorization), |
| 277 | exchange: (input) => |
| 278 | OAuthClient.exchangeCallbackInput(input, authorization), |
| 279 | }); |
| 280 | yield* interaction.output.success( |
| 281 | "Connected to Cloudflare with OAuth.", |
| 282 | ); |
| 283 | return credentials; |
| 284 | }); |
| 285 | |
| 286 | const loginStored = Effect.fn(function* (profileName: string) { |
| 287 | const credentialType = yield* interaction.prompt |
| 288 | .select({ |
| 289 | message: "Cloudflare credential type", |
| 290 | options: [ |
| 291 | { |
| 292 | value: "apiToken" as const, |
| 293 | label: "API Token", |
| 294 | description: "recommended", |
| 295 | }, |
| 296 | { value: "apiKey" as const, label: "API Key + Email" }, |
| 297 | ], |
| 298 | }) |
| 299 | .pipe(mapPromptCancellation); |
| 300 | |
| 301 | return yield* Match.value(credentialType).pipe( |
| 302 | Match.when("apiToken", () => |
| 303 | Effect.gen(function* () { |
| 304 | const apiToken = yield* interaction.prompt |
| 305 | .password({ |
| 306 | message: "Cloudflare API Token", |
| 307 | validate: (v) => (v.length === 0 ? "Required" : undefined), |
| 308 | }) |
| 309 | .pipe(mapPromptCancellation, Effect.map(Redacted.make)); |
| 310 | const accountId = yield* promptAccountId(); |
| 311 | |
| 312 | yield* interaction.output.success("Cloudflare: credentials saved."); |
| 313 | return { |
| 314 | method: "stored" as const, |
| 315 | credentialType: "apiToken" as const, |
| 316 | apiToken: Redacted.value(apiToken), |
| 317 | accountId, |
| 318 | }; |
| 319 | }), |
| 320 | ), |
| 321 | Match.when("apiKey", () => |
| 322 | Effect.gen(function* () { |
| 323 | const apiKey = yield* interaction.prompt |
| 324 | .password({ |
| 325 | message: "Cloudflare API Key", |
| 326 | validate: (v) => (v.length === 0 ? "Required" : undefined), |
| 327 | }) |
| 328 | .pipe(mapPromptCancellation, Effect.map(Redacted.make)); |
| 329 | |
| 330 | const email = yield* interaction.prompt |
| 331 | .text({ |
| 332 | message: "Cloudflare Email", |
| 333 | validate: (v) => (v.length === 0 ? "Required" : undefined), |
| 334 | }) |
| 335 | .pipe(mapPromptCancellation, Effect.map(Redacted.make)); |
| 336 | const accountId = yield* promptAccountId(); |
| 337 | |
| 338 | yield* interaction.output.success("Cloudflare: credentials saved."); |
| 339 | return { |
| 340 | method: "stored" as const, |
| 341 | credentialType: "apiKey" as const, |
| 342 | apiKey: Redacted.value(apiKey), |
| 343 | email: Redacted.value(email), |
| 344 | accountId, |
| 345 | }; |
| 346 | }), |
| 347 | ), |
| 348 | Match.exhaustive, |
| 349 | ); |
| 350 | }); |
| 351 | |
| 352 | const configureOAuth = Effect.fn(function* ( |
| 353 | profileName: string, |
| 354 | currentConfig?: CloudflareAuthConfig, |
| 355 | ) { |
| 356 | const scopes = yield* promptOAuthScopes(currentConfig); |
| 357 | |
| 358 | const oauthCreds = yield* oauthLogin(profileName, [...scopes]); |
| 359 | |
| 360 | const accountId = yield* selectAccount( |
| 361 | Redacted.value(oauthCreds.access), |
| 362 | ).pipe( |
| 363 | // Keep AuthError messages intact — they carry the actionable |
| 364 | // diagnosis (e.g. "no accounts visible"); only wrap raw API errors. |
| 365 | Effect.mapError((e) => |
| 366 | e instanceof AuthError |
| 367 | ? e |
| 368 | : new AuthError({ |
| 369 | message: "Cloudflare: could not list accounts", |
| 370 | cause: e, |
| 371 | }), |
| 372 | ), |
| 373 | ); |
| 374 | |
| 375 | return { |
| 376 | method: "oauth" as const, |
| 377 | scopes: [...scopes], |
| 378 | accountId, |
| 379 | clientId: oauthCreds.clientId, |
| 380 | access: Redacted.value(oauthCreds.access), |
| 381 | refresh: Redacted.value(oauthCreds.refresh), |
| 382 | expires: oauthCreds.expires, |
| 383 | }; |
| 384 | }); |
| 385 | |
| 386 | const configureInteractive = ( |
| 387 | profileName: string, |
| 388 | currentConfig?: CloudflareAuthConfig, |
| 389 | ) => |
| 390 | interaction.prompt |
| 391 | .select({ |
| 392 | message: "Cloudflare authentication method", |
| 393 | options, |
| 394 | }) |
| 395 | .pipe( |
| 396 | Effect.flatMap((method) => |
| 397 | Match.value(method).pipe( |
| 398 | Match.when("oauth", () => |
| 399 | configureOAuth(profileName, currentConfig), |
| 400 | ), |
| 401 | Match.when("stored", () => loginStored(profileName)), |
| 402 | Match.exhaustive, |
| 403 | ), |
| 404 | ), |
| 405 | ); |
| 406 | |
| 407 | const configureCredentials = ( |
| 408 | profileName: string, |
| 409 | currentConfig?: CloudflareAuthConfig, |
| 410 | ) => |
| 411 | Effect.gen(function* () { |
| 412 | const config = yield* configureInteractive(profileName, currentConfig); |
| 413 | // Re-configuring auth may point this profile at a different |
| 414 | // Cloudflare account. The cached state-store credentials |
| 415 | // (`~/.alchemy/credentials/{profile}/cloudflare-state-store.json`) |
| 416 | // are minted per-account, so drop them here; the next deploy |
| 417 | // re-derives them against the freshly-configured account. |
| 418 | yield* store |
| 419 | .delete(profileName, STATE_STORE_CREDENTIALS_FILE) |
| 420 | .pipe(Effect.ignore); |
| 421 | return config; |
| 422 | }).pipe( |
| 423 | // AuthError messages are already user-facing; re-wrapping them in a |
| 424 | // generic banner is what hid "no accounts visible" behind |
| 425 | // "failed to configure credentials". |
| 426 | Effect.mapError((e) => |
| 427 | e instanceof AuthError |
| 428 | ? e |
| 429 | : new AuthError({ |
| 430 | message: "failed to configure credentials", |
| 431 | cause: e, |
| 432 | }), |
| 433 | ), |
| 434 | ); |
| 435 | |
| 436 | const resolveCredentials = ( |
| 437 | profileName: string, |
| 438 | config: CloudflareAuthConfig, |
| 439 | updateConfig?: ( |
| 440 | config: CloudflareAuthConfig, |
| 441 | ) => Effect.Effect<void, AuthError>, |
| 442 | ) => |
| 443 | Effect.gen(function* () { |
| 444 | const reauth = refreshHint(CLOUDFLARE_AUTH_PROVIDER_NAME, profileName); |
| 445 | return yield* Match.value(config).pipe( |
| 446 | Match.when({ method: "stored", credentialType: "apiToken" }, (c) => |
| 447 | validateAccountId( |
| 448 | c.accountId, |
| 449 | `stored for profile '${profileName}'`, |
| 450 | ).pipe( |
| 451 | Effect.map((accountId) => ({ |
| 452 | type: "apiToken" as const, |
| 453 | apiToken: Redacted.make(c.apiToken), |
| 454 | accountId, |
| 455 | source: { type: "stored" as const }, |
| 456 | })), |
| 457 | ), |
| 458 | ), |
| 459 | Match.when({ method: "stored", credentialType: "apiKey" }, (c) => |
| 460 | validateAccountId( |
| 461 | c.accountId, |
| 462 | `stored for profile '${profileName}'`, |
| 463 | ).pipe( |
| 464 | Effect.map((accountId) => ({ |
| 465 | type: "apiKey" as const, |
| 466 | apiKey: Redacted.make(c.apiKey), |
| 467 | email: Redacted.make(c.email), |
| 468 | accountId, |
| 469 | source: { type: "stored" as const }, |
| 470 | })), |
| 471 | ), |
| 472 | ), |
| 473 | Match.when({ method: "oauth" }, (cfg) => |
| 474 | Effect.gen(function* () { |
| 475 | if (!("scopes" in cfg)) { |
| 476 | return yield* Effect.fail( |
| 477 | new NeedsReauth({ |
| 478 | provider: CLOUDFLARE_AUTH_PROVIDER_NAME, |
| 479 | profile: profileName, |
| 480 | message: `Cloudflare OAuth scopes need to be selected. ${reauth}`, |
| 481 | }), |
| 482 | ); |
| 483 | } |
| 484 | const accountId = yield* validateAccountId( |
| 485 | cfg.accountId, |
| 486 | `configured for profile '${profileName}'`, |
| 487 | ); |
| 488 | const creds: OAuthClient.OAuthCredentials = { |
| 489 | type: "oauth", |
| 490 | clientId: cfg.clientId, |
| 491 | access: Redacted.make(cfg.access), |
| 492 | refresh: Redacted.make(cfg.refresh), |
| 493 | expires: cfg.expires, |
| 494 | scopes: cfg.scopes, |
| 495 | }; |
| 496 | if (!OAuthClient.usesCurrentClient(creds)) { |
| 497 | return yield* Effect.fail( |
| 498 | new NeedsReauth({ |
| 499 | provider: CLOUDFLARE_AUTH_PROVIDER_NAME, |
| 500 | profile: profileName, |
| 501 | message: `Cloudflare OAuth credentials for profile '${profileName}' were issued to an incompatible OAuth client and have been removed. ${reauth}`, |
| 502 | }), |
| 503 | ); |
| 504 | } |
| 505 | // Refresh proactively if the token has expired (or is within |
| 506 | // 10s of expiring). Persist the refreshed creds so subsequent |
| 507 | // resolves don't repeat the round-trip. |
| 508 | const now = yield* Clock.currentTimeMillis; |
| 509 | const fresh = |
| 510 | creds.expires > now + 10_000 |
| 511 | ? creds |
| 512 | : yield* OAuthClient.refresh(creds).pipe( |
| 513 | Effect.mapError( |
| 514 | (e) => |
| 515 | new NeedsReauth({ |
| 516 | provider: CLOUDFLARE_AUTH_PROVIDER_NAME, |
| 517 | profile: profileName, |
| 518 | message: `Cloudflare OAuth refresh failed. ${reauth}`, |
| 519 | cause: e, |
| 520 | }), |
| 521 | ), |
| 522 | ); |
| 523 | if (fresh !== creds) { |
| 524 | yield* ( |
| 525 | updateConfig?.({ |
| 526 | method: "oauth", |
| 527 | accountId, |
| 528 | clientId: fresh.clientId, |
| 529 | access: Redacted.value(fresh.access), |
| 530 | refresh: Redacted.value(fresh.refresh), |
| 531 | expires: fresh.expires, |
| 532 | scopes: fresh.scopes, |
| 533 | }) ?? Effect.void |
| 534 | ); |
| 535 | } |
| 536 | return { |
| 537 | type: "oauth" as const, |
| 538 | accessToken: fresh.access, |
| 539 | expires: fresh.expires, |
| 540 | accountId, |
| 541 | source: { type: "oauth" as const }, |
| 542 | }; |
| 543 | }), |
| 544 | ), |
| 545 | Match.exhaustive, |
| 546 | ); |
| 547 | }); |
| 548 | |
| 549 | const readEnvironment = Effect.gen(function* () { |
| 550 | const accountId = yield* getEnvRequired("CLOUDFLARE_ACCOUNT_ID").pipe( |
| 551 | Effect.flatMap((id) => |
| 552 | validateAccountId(id, "from CLOUDFLARE_ACCOUNT_ID"), |
| 553 | ), |
| 554 | ); |
| 555 | const apiToken = yield* getEnvRedacted("CLOUDFLARE_API_TOKEN"); |
| 556 | if (apiToken) { |
| 557 | return { |
| 558 | type: "apiToken" as const, |
| 559 | apiToken, |
| 560 | accountId, |
| 561 | source: { type: "env" as const }, |
| 562 | }; |
| 563 | } |
| 564 | const apiKey = yield* getEnvRedacted("CLOUDFLARE_API_KEY"); |
| 565 | const email = |
| 566 | (yield* getEnvRedacted("CLOUDFLARE_EMAIL")) ?? |
| 567 | (yield* getEnvRedacted("CLOUDFLARE_ACCOUNT_EMAIL")); |
| 568 | if (apiKey && email) { |
| 569 | return { |
| 570 | type: "apiKey" as const, |
| 571 | apiKey, |
| 572 | email, |
| 573 | accountId, |
| 574 | source: { type: "env" as const }, |
| 575 | }; |
| 576 | } |
| 577 | return yield* new AuthError({ |
| 578 | message: |
| 579 | "Cloudflare CI credentials not found. Set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY with CLOUDFLARE_EMAIL/CLOUDFLARE_ACCOUNT_EMAIL.", |
| 580 | }); |
| 581 | }); |
| 582 | |
| 583 | const logout = (profileName: string, config: CloudflareAuthConfig) => |
| 584 | Match.value(config) |
| 585 | .pipe( |
| 586 | Match.when({ method: "stored" }, () => Effect.void), |
| 587 | Match.when({ method: "oauth" }, (config) => { |
| 588 | if (!("scopes" in config)) return Effect.void; |
| 589 | const credentials: OAuthClient.OAuthCredentials = { |
| 590 | type: "oauth", |
| 591 | clientId: config.clientId, |
| 592 | access: Redacted.make(config.access), |
| 593 | refresh: Redacted.make(config.refresh), |
| 594 | expires: config.expires, |
| 595 | scopes: config.scopes, |
| 596 | }; |
| 597 | return OAuthClient.usesCurrentClient(credentials) |
| 598 | ? OAuthClient.revoke(credentials).pipe( |
| 599 | Effect.catchTag("OAuthError", (err) => |
| 600 | interaction.output.warning( |
| 601 | `Cloudflare: could not revoke OAuth token: ${err.errorDescription}`, |
| 602 | ), |
| 603 | ), |
| 604 | ) |
| 605 | : Effect.void; |
| 606 | }), |
| 607 | Match.exhaustive, |
| 608 | ) |
| 609 | // The cached state-store credentials are derived from the account we |
| 610 | // just logged out of, so drop them regardless of auth method. |
| 611 | .pipe( |
| 612 | Effect.andThen( |
| 613 | store |
| 614 | .delete(profileName, STATE_STORE_CREDENTIALS_FILE) |
| 615 | .pipe(Effect.ignore), |
| 616 | ), |
| 617 | ); |
| 618 | |
| 619 | const login = ( |
| 620 | profileName: string, |
| 621 | config: CloudflareAuthConfig, |
| 622 | updateConfig?: ( |
| 623 | config: CloudflareAuthConfig, |
| 624 | ) => Effect.Effect<void, AuthError>, |
| 625 | ) => |
| 626 | Match.value(config) |
| 627 | .pipe( |
| 628 | Match.when({ method: "stored" }, (config) => Effect.succeed(config)), |
| 629 | Match.when({ method: "oauth" }, (c) => |
| 630 | "scopes" in c |
| 631 | ? Effect.gen(function* () { |
| 632 | const creds: OAuthClient.OAuthCredentials = { |
| 633 | type: "oauth", |
| 634 | clientId: c.clientId, |
| 635 | access: Redacted.make(c.access), |
| 636 | refresh: Redacted.make(c.refresh), |
| 637 | expires: c.expires, |
| 638 | scopes: c.scopes, |
| 639 | }; |
| 640 | const reconfigure = reconfigureHint( |
| 641 | CLOUDFLARE_AUTH_PROVIDER_NAME, |
| 642 | profileName, |
| 643 | ); |
| 644 | // Any path that falls back to a full browser login rebuilds the |
| 645 | // authorize URL from the profile's stored scopes. Those scopes |
| 646 | // may predate the current OAuth client (or a catalog change), and |
| 647 | // one unknown scope makes the whole authorize URL invalid — so |
| 648 | // sanitize before generating a URL, never after it fails. |
| 649 | const fullLogin = Effect.gen(function* () { |
| 650 | const { valid, dropped } = partitionOAuthScopes(c.scopes); |
| 651 | if (valid.length === 0) { |
| 652 | return yield* Effect.fail( |
| 653 | new AuthError({ |
| 654 | message: |
| 655 | `The OAuth scopes stored for profile '${profileName}' are no longer offered by Alchemy's Cloudflare OAuth client. ` + |
| 656 | `Scopes must be picked again. ${reconfigure}`, |
| 657 | }), |
| 658 | ); |
| 659 | } |
| 660 | const refreshed = yield* ( |
| 661 | dropped.length === 0 |
| 662 | ? Effect.void |
| 663 | : interaction.output.warning( |
| 664 | `Cloudflare: dropping ${dropped.length} stored scope${dropped.length === 1 ? "" : "s"} no longer offered by the current OAuth client (${dropped.join(", ")}). ` + |
| 665 | `Scopes must be picked again. ${reconfigure}`, |
| 666 | ) |
| 667 | ).pipe(Effect.andThen(oauthLogin(profileName, valid))); |
| 668 | return { |
| 669 | ...c, |
| 670 | scopes: valid, |
| 671 | clientId: refreshed.clientId, |
| 672 | access: Redacted.value(refreshed.access), |
| 673 | refresh: Redacted.value(refreshed.refresh), |
| 674 | expires: refreshed.expires, |
| 675 | }; |
| 676 | }); |
| 677 | |
| 678 | // The silent refresh rotates a single-use refresh token, so |
| 679 | // its read-refresh-persist section runs under the profile |
| 680 | // lock — a concurrent `read` refreshing the same token would |
| 681 | // double-spend it. The lock is held only for this API |
| 682 | // round-trip, never across the browser wait below. |
| 683 | const outcome = |
| 684 | creds.type === "oauth" && |
| 685 | OAuthClient.usesCurrentClient(creds) |
| 686 | ? yield* withProfileCredentialsLock( |
| 687 | profileName, |
| 688 | interaction.output |
| 689 | .info("Cloudflare: refreshing OAuth credentials...") |
| 690 | .pipe( |
| 691 | Effect.andThen(OAuthClient.refresh(creds)), |
| 692 | Effect.flatMap((credentials) => { |
| 693 | const config = { |
| 694 | ...c, |
| 695 | clientId: credentials.clientId, |
| 696 | access: Redacted.value(credentials.access), |
| 697 | refresh: Redacted.value(credentials.refresh), |
| 698 | expires: credentials.expires, |
| 699 | scopes: credentials.scopes, |
| 700 | }; |
| 701 | return ( |
| 702 | updateConfig?.(config) ?? Effect.void |
| 703 | ).pipe( |
| 704 | Effect.as({ |
| 705 | type: "refreshed" as const, |
| 706 | config, |
| 707 | }), |
| 708 | ); |
| 709 | }), |
| 710 | Effect.tap(() => |
| 711 | interaction.output.success( |
| 712 | "Cloudflare: OAuth credentials refreshed.", |
| 713 | ), |
| 714 | ), |
| 715 | Effect.catchTag("OAuthError", () => |
| 716 | Effect.succeed({ type: "browser" as const }), |
| 717 | ), |
| 718 | ), |
| 719 | ) |
| 720 | : yield* Effect.gen(function* () { |
| 721 | if (creds.type === "oauth") { |
| 722 | yield* interaction.output.warning( |
| 723 | "Cloudflare: removed OAuth credentials issued to the previous client.", |
| 724 | ); |
| 725 | } |
| 726 | return { type: "browser" as const }; |
| 727 | }); |
| 728 | if (outcome.type === "browser") { |
| 729 | return yield* fullLogin; |
| 730 | } |
| 731 | return outcome.config; |
| 732 | }) |
| 733 | : configureOAuth(profileName, c), |
| 734 | ), |
| 735 | Match.exhaustive, |
| 736 | ) |
| 737 | .pipe( |
| 738 | // A blanket mapError must never swallow the NeedsReauth tag — |
| 739 | // the profile UI matches on it to render "needs re-login". |
| 740 | Effect.mapError((e) => |
| 741 | e instanceof NeedsReauth |
| 742 | ? e |
| 743 | : new AuthError({ message: "login failed", cause: e }), |
| 744 | ), |
| 745 | ); |
| 746 | |
| 747 | const details = ( |
| 748 | profileName: string, |
| 749 | config: CloudflareAuthConfig, |
| 750 | updateConfig?: ( |
| 751 | config: CloudflareAuthConfig, |
| 752 | ) => Effect.Effect<void, AuthError>, |
| 753 | ) => |
| 754 | Effect.all([ |
| 755 | resolveCredentials(profileName, config, updateConfig), |
| 756 | Clock.currentTimeMillis, |
| 757 | ]).pipe( |
| 758 | Effect.map(([creds, now]) => { |
| 759 | const source = { |
| 760 | key: "source", |
| 761 | value: |
| 762 | "details" in creds.source && creds.source.details |
| 763 | ? `${creds.source.type} - ${creds.source.details}` |
| 764 | : creds.source.type, |
| 765 | }; |
| 766 | return { |
| 767 | lines: Match.value(creds).pipe( |
| 768 | Match.when({ type: "apiToken" }, (c) => [ |
| 769 | { key: "apiToken", value: displayRedacted(c.apiToken, 9) }, |
| 770 | { key: "accountId", value: c.accountId }, |
| 771 | source, |
| 772 | ]), |
| 773 | Match.when({ type: "apiKey" }, (c) => [ |
| 774 | { key: "apiKey", value: displayRedacted(c.apiKey) }, |
| 775 | { key: "email", value: displayRedacted(c.email) }, |
| 776 | { key: "accountId", value: c.accountId }, |
| 777 | source, |
| 778 | ]), |
| 779 | Match.when({ type: "oauth" }, (c) => { |
| 780 | const remainingMs = c.expires - now; |
| 781 | const expiresAt = new Date(c.expires).toISOString(); |
| 782 | const expiresStr = |
| 783 | remainingMs <= 0 |
| 784 | ? `expired (${expiresAt})` |
| 785 | : `in ${Duration.format(Duration.millis(remainingMs))} (${expiresAt})`; |
| 786 | return [ |
| 787 | { key: "accessToken", value: displayRedacted(c.accessToken) }, |
| 788 | { key: "expires", value: expiresStr }, |
| 789 | { key: "accountId", value: c.accountId }, |
| 790 | source, |
| 791 | ]; |
| 792 | }), |
| 793 | Match.exhaustive, |
| 794 | ), |
| 795 | }; |
| 796 | }), |
| 797 | ); |
| 798 | |
| 799 | /** |
| 800 | * Persist flag-provided stored credentials (`--method stored`). Writes |
| 801 | * the same `cloudflare-stored` file the interactive stored path writes; |
| 802 | * OAuth is interactive-only and not accepted here. |
| 803 | */ |
| 804 | const configureWith = ( |
| 805 | profileName: string, |
| 806 | input: { |
| 807 | readonly method: string; |
| 808 | readonly values: Record<string, string>; |
| 809 | }, |
| 810 | ): Effect.Effect<CloudflareAuthConfig, AuthError> => { |
| 811 | const persist = (config: CloudflareAuthConfig) => |
| 812 | store |
| 813 | .delete(profileName, STATE_STORE_CREDENTIALS_FILE) |
| 814 | .pipe(Effect.ignore, Effect.as(config)); |
| 815 | if (input.method !== "stored") { |
| 816 | return Effect.fail( |
| 817 | new AuthError({ |
| 818 | message: `Cloudflare: unknown method '${input.method}'. Valid methods: stored. (OAuth is interactive-only.)`, |
| 819 | }), |
| 820 | ); |
| 821 | } |
| 822 | return validateFieldValues( |
| 823 | CLOUDFLARE_AUTH_PROVIDER_NAME, |
| 824 | storedFields, |
| 825 | input.values, |
| 826 | ).pipe( |
| 827 | Effect.flatMap((values) => { |
| 828 | const accountId = (storedValueText(values.accountId) ?? "") |
| 829 | .trim() |
| 830 | .toLowerCase(); |
| 831 | const apiToken = storedSecret(values.apiToken); |
| 832 | const apiKey = storedSecret(values.apiKey); |
| 833 | const email = storedValueText(values.email); |
| 834 | if ( |
| 835 | apiToken !== undefined && |
| 836 | apiKey === undefined && |
| 837 | email === undefined |
| 838 | ) { |
| 839 | return persist({ |
| 840 | method: "stored", |
| 841 | credentialType: "apiToken", |
| 842 | apiToken: Redacted.value(apiToken), |
| 843 | accountId, |
| 844 | }); |
| 845 | } |
| 846 | if ( |
| 847 | apiToken === undefined && |
| 848 | apiKey !== undefined && |
| 849 | email !== undefined |
| 850 | ) { |
| 851 | return persist({ |
| 852 | method: "stored", |
| 853 | credentialType: "apiKey", |
| 854 | apiKey: Redacted.value(apiKey), |
| 855 | email, |
| 856 | accountId, |
| 857 | }); |
| 858 | } |
| 859 | return Effect.fail( |
| 860 | new AuthError({ |
| 861 | message: |
| 862 | "Cloudflare: pass either --set apiToken=<token>, or both --set apiKey=<key> and --set email=<email>.", |
| 863 | }), |
| 864 | ); |
| 865 | }), |
| 866 | ); |
| 867 | }; |
| 868 | |
| 869 | return { |
| 870 | configSchema: CloudflareAuthConfigSchema, |
| 871 | configure: configureCredentials, |
| 872 | configureWith, |
| 873 | configureMethods, |
| 874 | logout, |
| 875 | login, |
| 876 | details, |
| 877 | read: resolveCredentials, |
| 878 | readEnvironment, |
| 879 | environment: [ |
| 880 | { |
| 881 | name: "CLOUDFLARE_ACCOUNT_ID", |
| 882 | required: true, |
| 883 | description: "Account the stack deploys into.", |
| 884 | }, |
| 885 | { |
| 886 | name: "CLOUDFLARE_API_TOKEN", |
| 887 | required: true, |
| 888 | secret: true, |
| 889 | alternatives: ["CLOUDFLARE_API_KEY"], |
| 890 | description: |
| 891 | "API token (preferred). Not consulted when unset and CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL are provided instead.", |
| 892 | }, |
| 893 | { |
| 894 | name: "CLOUDFLARE_API_KEY", |
| 895 | required: false, |
| 896 | secret: true, |
| 897 | description: |
| 898 | "Global API key; used with CLOUDFLARE_EMAIL when no API token is set.", |
| 899 | }, |
| 900 | { |
| 901 | name: "CLOUDFLARE_EMAIL", |
| 902 | required: false, |
| 903 | alternatives: ["CLOUDFLARE_ACCOUNT_EMAIL"], |
| 904 | description: "Account email paired with CLOUDFLARE_API_KEY.", |
| 905 | }, |
| 906 | ], |
| 907 | }; |
| 908 | }), |
| 909 | ); |