1import * as Effect from "effect/Effect";
2import * as Redacted from "effect/Redacted";
3import * as Schema from "effect/Schema";
4import { AuthError } from "../../Auth/AuthProvider.ts";
5import { profileCommandHint } from "../../Util/interactive.ts";
6
7/** Typed values stored in a Cloudflare provider profile document. */
8export const CloudflareAuthConfigSchema = Schema.Union([
9 Schema.Struct({
10 method: Schema.Literal("stored"),
11 credentialType: Schema.Literal("apiToken"),
12 apiToken: Schema.String,
13 accountId: Schema.String,
14 }),
15 Schema.Struct({
16 method: Schema.Literal("stored"),
17 credentialType: Schema.Literal("apiKey"),
18 apiKey: Schema.String,
19 email: Schema.String,
20 accountId: Schema.String,
21 }),
22 Schema.Struct({
23 method: Schema.Literal("oauth"),
24 scopes: Schema.mutable(Schema.Array(Schema.String)),
25 accountId: Schema.String,
26 clientId: Schema.optional(Schema.String),
27 access: Schema.String,
28 refresh: Schema.String,
29 expires: Schema.Number,
30 }),
31 // Released v0 OAuth grants cannot be reused, but retaining the method lets
32 // `profile refresh` restart OAuth at scope selection.
33 Schema.Struct({
34 method: Schema.Literal("oauth"),
35 }),
36]);
37export type CloudflareAuthConfig = typeof CloudflareAuthConfigSchema.Type;
38
39export type CloudflareResolvedCredentials =
40 | {
41 type: "apiToken";
42 apiToken: Redacted.Redacted<string>;
43 accountId: string;
44 source: {
45 type: CloudflareAuthConfig["method"] | "env";
46 details?: string;
47 };
48 }
49 | {
50 type: "apiKey";
51 apiKey: Redacted.Redacted<string>;
52 email: Redacted.Redacted<string>;
53 accountId: string;
54 source: {
55 type: CloudflareAuthConfig["method"] | "env";
56 details?: string;
57 };
58 }
59 | {
60 type: "oauth";
61 accessToken: Redacted.Redacted<string>;
62 expires: number;
63 accountId: string;
64 source: {
65 type: CloudflareAuthConfig["method"] | "env";
66 details?: string;
67 };
68 };
69
70export const CLOUDFLARE_AUTH_PROVIDER_NAME = "Cloudflare";
71
72/**
73 * Cloudflare account IDs are 32 lowercase hex characters. Placeholder
74 * values ("", "-", "dummy", …) end up interpolated into API paths and
75 * surface as baffling `InvalidRoute: Could not route to
76 * /accounts/<value>/...` errors, so reject them up front with an
77 * actionable message instead.
78 */
79const ACCOUNT_ID_PATTERN = /^[0-9a-f]{32}$/i;
80
81export const validateAccountId = (
82 accountId: string | undefined,
83 source: string,
84): Effect.Effect<string, AuthError> =>
85 Effect.gen(function* () {
86 const trimmed = accountId?.trim() ?? "";
87 const command = yield* profileCommandHint(
88 "alchemy profile edit --reconfigure Cloudflare",
89 );
90 if (trimmed.length === 0) {
91 return yield* Effect.fail(
92 new AuthError({
93 message:
94 `Cloudflare account ID is missing (${source}). ` +
95 `Re-run \`${command}\` and provide your account ID ` +
96 "(found in the Cloudflare dashboard under Workers & Pages → Account details).",
97 }),
98 );
99 }
100 if (!ACCOUNT_ID_PATTERN.test(trimmed)) {
101 return yield* Effect.fail(
102 new AuthError({
103 message:
104 `'${trimmed}' is not a valid Cloudflare account ID (${source}) — expected 32 hex characters. ` +
105 "Copy the account ID from the Cloudflare dashboard (Workers & Pages → Account details) " +
106 `and re-run \`${command}\`.`,
107 }),
108 );
109 }
110 return trimmed.toLowerCase();
111 });
112
113/** Field-level validator reusing {@link ACCOUNT_ID_PATTERN}. */
114export const validateAccountIdField = (v: string): string | undefined =>
115 ACCOUNT_ID_PATTERN.test(v.trim())
116 ? undefined
117 : "Expected a 32-character hex account ID (Workers & Pages → Account details)";