1import * as cfAccounts from "@distilled.cloud/cloudflare/accounts";
2import * as cfMemberships from "@distilled.cloud/cloudflare/memberships";
3import * as CfCredentialsModule from "@distilled.cloud/cloudflare/Credentials";
4import * as Clock from "effect/Clock";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Match from "effect/Match";
9import * as Redacted from "effect/Redacted";
10import * as Schema from "effect/Schema";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12import * as HttpClient from "effect/http/HttpClient";
13import {
14 AuthError,
15 AuthProviderLayer,
16 NeedsReauth,
17 reconfigureHint,
18 refreshHint,
19 type ConfigureField,
20 type ConfigureMethod,
21 type ProviderDetails,
22} from "../../Auth/AuthProvider.ts";
23import {
24 storedSecret,
25 storedValueText,
26 validateFieldValues,
27} from "../../Auth/StoredAuthProvider.ts";
28import { CredentialsStore, displayRedacted } from "../../Auth/Credentials.ts";
29import { withProfileCredentialsLock } from "../../Auth/Lock.ts";
30import {
31 getEnvRedacted,
32 getEnvRequired,
33 mapPromptCancellation,
34} from "../../Auth/Env.ts";
35import { browserOAuth } from "../../Auth/BrowserOAuth.ts";
36import * as Interaction from "../../Interaction.ts";
37import { CREDENTIALS_FILE as STATE_STORE_CREDENTIALS_FILE } from "../StateStore/CredentialsFile.ts";
38import * as OAuthClient from "./OAuthClient.ts";
39import {
40 CLOUDFLARE_AUTH_PROVIDER_NAME,
41 CloudflareAuthConfigSchema,
42 validateAccountId,
43 validateAccountIdField,
44 type CloudflareAuthConfig,
45 type CloudflareResolvedCredentials,
46} from "./AuthConfig.ts";
47import {
48 ALL_SCOPE_IDS,
49 BASIC_SCOPES,
50 customOAuthScopeDefaults,
51 OAUTH_SCOPE_GROUPS,
52 OAUTH_SCOPE_NAMES,
53 partitionOAuthScopes,
54} from "./OAuthScopes.ts";
55
56const 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
74const 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 */
101const 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
131const 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
162const 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
173const 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 */
184const 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 */
201const configureMethods: ReadonlyArray<ConfigureMethod> = [
202 { method: "stored", fields: storedFields },
203];
204
205const 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 */
257export 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);