From 0657a96795fb80a845b781ce9761bced79326f85 Mon Sep 17 00:00:00 2001 From: Anwar Sadath <37188392+asadath1395@users.noreply.github.com> Date: Tue, 10 Dec 2024 05:48:30 +0530 Subject: [PATCH] feat: Add OAuth support for closecom app linking (#17936) * feat: Add OAuth support for closecom app linking * Fix types --------- Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> --- .env.example | 3 +- .../apps/installation/ConfigureStepCard.tsx | 2 +- packages/app-store/_pages/setup/index.tsx | 1 - packages/app-store/closecom/api/_getAdd.ts | 34 +++- packages/app-store/closecom/api/_postAdd.ts | 45 ----- packages/app-store/closecom/api/_postCheck.ts | 29 ---- packages/app-store/closecom/api/add.ts | 1 - packages/app-store/closecom/api/callback.ts | 88 ++++++++++ packages/app-store/closecom/api/check.ts | 5 - packages/app-store/closecom/api/index.ts | 2 +- packages/app-store/closecom/config.json | 3 +- packages/app-store/closecom/lib/CrmService.ts | 52 ++++-- .../app-store/closecom/pages/setup/index.tsx | 160 ------------------ packages/app-store/closecom/zod.ts | 5 +- packages/lib/CloseCom.ts | 134 +++++++++++++-- packages/prisma/seed-app-store.ts | 7 + turbo.json | 3 +- 17 files changed, 294 insertions(+), 280 deletions(-) delete mode 100644 packages/app-store/closecom/api/_postAdd.ts delete mode 100644 packages/app-store/closecom/api/_postCheck.ts create mode 100644 packages/app-store/closecom/api/callback.ts delete mode 100644 packages/app-store/closecom/api/check.ts delete mode 100644 packages/app-store/closecom/pages/setup/index.tsx diff --git a/.env.example b/.env.example index beea9239e9..e99867e3e1 100644 --- a/.env.example +++ b/.env.example @@ -239,7 +239,8 @@ CLOUDFLARE_TURNSTILE_SECRET= NEXT_PUBLIC_TEAM_IMPERSONATION=false # Close.com internal CRM -CLOSECOM_API_KEY= +CLOSECOM_CLIENT_ID= +CLOSECOM_CLIENT_SECRET= # Sendgrid internal sync service SENDGRID_SYNC_API_KEY= diff --git a/apps/web/components/apps/installation/ConfigureStepCard.tsx b/apps/web/components/apps/installation/ConfigureStepCard.tsx index 7b413b831a..1df0c1490b 100644 --- a/apps/web/components/apps/installation/ConfigureStepCard.tsx +++ b/apps/web/components/apps/installation/ConfigureStepCard.tsx @@ -232,7 +232,7 @@ export const ConfigureStepCard: FC = (props) => { size="md" className="inline-flex justify-center" /> -

{group.slug}

+

{group.slug}

)} import("../../ics-feedcalendar/pages/setup")), zapier: dynamic(() => import("../../zapier/pages/setup")), make: dynamic(() => import("../../make/pages/setup")), - closecom: dynamic(() => import("../../closecom/pages/setup")), sendgrid: dynamic(() => import("../../sendgrid/pages/setup")), stripe: dynamic(() => import("../../stripepayment/pages/setup")), paypal: dynamic(() => import("../../paypal/pages/setup")), diff --git a/packages/app-store/closecom/api/_getAdd.ts b/packages/app-store/closecom/api/_getAdd.ts index 2701c40ce2..9809527fb2 100644 --- a/packages/app-store/closecom/api/_getAdd.ts +++ b/packages/app-store/closecom/api/_getAdd.ts @@ -1,13 +1,33 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import checkSession from "../../_utils/auth"; -import { checkInstalled } from "../../_utils/installation"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { defaultResponder } from "@calcom/lib/server"; -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = checkSession(req); - await checkInstalled("closecom", session.user?.id); +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; - const returnTo = req.query.returnTo; +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session?.user?.id) { + return res.status(401).json({ message: "You must be logged in to do this" }); + } - return res.status(200).json({ url: `/apps/closecom/setup${returnTo ? `?returnTo=${returnTo}` : ""}` }); + const { client_id } = await getAppKeysFromSlug("closecom"); + + if (!client_id || typeof client_id !== "string") + return res.status(400).json({ message: "Close.com client_id missing." }); + + const state = encodeOAuthState(req) ?? ""; + + const params = new URLSearchParams({ + client_id, + redirect_uri: `${WEBAPP_URL}/api/integrations/closecom/callback`, + response_type: "code", + state, + }); + + return res.status(200).json({ + url: `https://app.close.com/oauth2/authorize/?${params.toString()}`, + }); } + +export default defaultResponder(handler); diff --git a/packages/app-store/closecom/api/_postAdd.ts b/packages/app-store/closecom/api/_postAdd.ts deleted file mode 100644 index 8d8c2f746e..0000000000 --- a/packages/app-store/closecom/api/_postAdd.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { symmetricEncrypt } from "@calcom/lib/crypto"; -import { HttpError } from "@calcom/lib/http-error"; -import logger from "@calcom/lib/logger"; -import { defaultResponder } from "@calcom/lib/server"; -import prisma from "@calcom/prisma"; - -import checkSession from "../../_utils/auth"; -import getInstalledAppPath from "../../_utils/getInstalledAppPath"; -import appConfig from "../config.json"; - -export async function getHandler(req: NextApiRequest, res: NextApiResponse) { - const session = checkSession(req); - - const { api_key } = req.body; - if (!api_key) throw new HttpError({ statusCode: 400, message: "No Api Key provoided to check" }); - - const encrypted = symmetricEncrypt(JSON.stringify({ api_key }), process.env.CALENDSO_ENCRYPTION_KEY || ""); - - const data = { - type: appConfig.type, - key: { encrypted }, - userId: session.user?.id, - appId: appConfig.slug, - }; - - try { - await prisma.credential.create({ - data, - select: { - id: true, - }, - }); - } catch (reason) { - logger.error("Could not add Close.com app", reason); - return res.status(500).json({ message: "Could not add Close.com app" }); - } - - return res.status(200).json({ - url: req.query.returnTo ? req.query.returnTo : getInstalledAppPath({ variant: "crm", slug: "closecom" }), - }); -} - -export default defaultResponder(getHandler); diff --git a/packages/app-store/closecom/api/_postCheck.ts b/packages/app-store/closecom/api/_postCheck.ts deleted file mode 100644 index f7e0b58e65..0000000000 --- a/packages/app-store/closecom/api/_postCheck.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import CloseCom from "@calcom/lib/CloseCom"; -import { HttpError } from "@calcom/lib/http-error"; -import { defaultResponder } from "@calcom/lib/server"; - -import checkSession from "../../_utils/auth"; - -export async function getHandler(req: NextApiRequest, res: NextApiResponse) { - const { api_key } = req.body; - if (!api_key) throw new HttpError({ statusCode: 400, message: "No Api Key provoided to check" }); - - checkSession(req); - - const closeCom: CloseCom = new CloseCom(api_key); - - try { - const userInfo = await closeCom.me(); - if (userInfo.first_name) { - return res.status(200).end(); - } else { - return res.status(404).end(); - } - } catch (e) { - return res.status(500).json({ message: e }); - } -} - -export default defaultResponder(getHandler); diff --git a/packages/app-store/closecom/api/add.ts b/packages/app-store/closecom/api/add.ts index 9480fb9259..912126e232 100644 --- a/packages/app-store/closecom/api/add.ts +++ b/packages/app-store/closecom/api/add.ts @@ -2,5 +2,4 @@ import { defaultHandler } from "@calcom/lib/server"; export default defaultHandler({ GET: import("./_getAdd"), - POST: import("./_postAdd"), }); diff --git a/packages/app-store/closecom/api/callback.ts b/packages/app-store/closecom/api/callback.ts new file mode 100644 index 0000000000..2d20cba893 --- /dev/null +++ b/packages/app-store/closecom/api/callback.ts @@ -0,0 +1,88 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; +import appConfig from "../config.json"; + +async function getHandler(req: NextApiRequest, res: NextApiResponse) { + const { code } = req.query; + + const state = decodeOAuthState(req); + + const redirectAfterSuccess = + getSafeRedirectUrl(state?.returnTo) ?? + getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug }); + const redirectAfterSuccessOrError = getSafeRedirectUrl(state?.onErrorReturnTo) ?? redirectAfterSuccess; + if (!code || typeof code !== "string") { + if (state?.onErrorReturnTo || state?.returnTo) { + res.redirect(redirectAfterSuccessOrError); + return; + } + throw new HttpError({ statusCode: 400, message: "`code` must be a string" }); + } + + if (!req.session?.user?.id) { + throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" }); + } + + const { client_id, client_secret } = await getAppKeysFromSlug("closecom"); + + if (!client_id || typeof client_id !== "string") + return res.status(400).json({ message: "Close.com client_id missing." }); + if (!client_secret || typeof client_secret !== "string") + return res.status(400).json({ message: "Close.com client_secret missing." }); + + try { + const response = await fetch("https://api.close.com/oauth2/token/", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id, + client_secret, + grant_type: "authorization_code", + code: code as string, + redirect_uri: `${WEBAPP_URL}/api/integrations/closecom/callback`, + }), + }); + + if (!response.ok) { + throw new Error("Failed to get access token"); + } + + const responseJson = await response.json(); + + const { access_token, refresh_token, expires_in } = responseJson; + const expires_at = Date.now() + expires_in * 1000; + + await prisma.credential.create({ + data: { + type: "closecom_crm", + key: { + access_token, + refresh_token, + expires_at, + }, + userId: req.session.user.id, + appId: "closecom", + }, + }); + + return res.redirect(redirectAfterSuccess); + } catch (error) { + console.error("Error getting close.com access token", error); + return res.redirect(`${redirectAfterSuccessOrError}?error=Error getting close.com access token`); + } +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); diff --git a/packages/app-store/closecom/api/check.ts b/packages/app-store/closecom/api/check.ts deleted file mode 100644 index 97ed132226..0000000000 --- a/packages/app-store/closecom/api/check.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { defaultHandler } from "@calcom/lib/server"; - -export default defaultHandler({ - POST: import("./_postCheck"), -}); diff --git a/packages/app-store/closecom/api/index.ts b/packages/app-store/closecom/api/index.ts index 766afa6405..eb12c1b4ed 100644 --- a/packages/app-store/closecom/api/index.ts +++ b/packages/app-store/closecom/api/index.ts @@ -1,2 +1,2 @@ export { default as add } from "./add"; -export { default as check } from "./check"; +export { default as callback } from "./callback"; diff --git a/packages/app-store/closecom/config.json b/packages/app-store/closecom/config.json index c35465c204..3553973a70 100644 --- a/packages/app-store/closecom/config.json +++ b/packages/app-store/closecom/config.json @@ -13,5 +13,6 @@ "email": "help@cal.com", "description": "Close is the inside sales CRM of choice for startups and SMBs. Make more calls, send more emails and close more deals starting today.", "__createdUsingCli": true, - "isOAuth": false + "isOAuth": true, + "dirName": "closecom" } diff --git a/packages/app-store/closecom/lib/CrmService.ts b/packages/app-store/closecom/lib/CrmService.ts index 4dd17fa421..c29b3128ce 100644 --- a/packages/app-store/closecom/lib/CrmService.ts +++ b/packages/app-store/closecom/lib/CrmService.ts @@ -9,9 +9,23 @@ import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; import type { CRM, ContactCreateInput, CrmEvent, Contact } from "@calcom/types/CrmService"; -const apiKeySchema = z.object({ - encrypted: z.string(), -}); +// Schema that supports both OAuth and API key credentials +const credentialSchema = z + .object({ + encrypted: z.string().optional(), + access_token: z.string().optional(), + refresh_token: z.string().optional(), + expires_at: z.number().optional(), + }) + .refine( + (data) => { + // Either encrypted (API key) or access_token (OAuth) must be present + return !!data.encrypted || !!data.access_token; + }, + { + message: "Either API key or OAuth credentials must be provided", + } + ); const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || ""; @@ -27,10 +41,7 @@ const calComCustomActivityFields: CloseComFieldOptions = [ /** * Authentication - * Close.com requires Basic Auth for any request to their APIs, which is far from - * ideal considering that such a strategy requires generating an API Key by the - * user and input it in our system. A Setup page was created when trying to install - * Close.com App in order to instruct how to create such resource and to obtain it. + * Close.com provides OAuth for authentication. * * Meeting creation * Close.com does not expose a "Meeting" API, it may be available in the future. @@ -55,17 +66,30 @@ export default class CloseComCRMService implements CRM { this.integrationName = "closecom_other_calendar"; this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); - const parsedCredentialKey = apiKeySchema.safeParse(credential.key); + const parsedKey = credentialSchema.safeParse(credential.key); - let decrypted; - if (parsedCredentialKey.success) { - decrypted = symmetricDecrypt(parsedCredentialKey.data.encrypted, CALENDSO_ENCRYPTION_KEY); + if (!parsedKey.success) { + throw new Error( + `Invalid credentials for userId ${credential.userId} and appId ${credential.appId}: ${parsedKey.error}` + ); + } + + // Initialize CloseCom client based on credential type + if (parsedKey.data.encrypted) { + // API key authentication + const decrypted = symmetricDecrypt(parsedKey.data.encrypted, CALENDSO_ENCRYPTION_KEY); const { api_key } = JSON.parse(decrypted); this.closeCom = new CloseCom(api_key); + } else if (parsedKey.data.access_token) { + // OAuth authentication + this.closeCom = new CloseCom(parsedKey.data.access_token, { + refresh_token: parsedKey.data.refresh_token, + expires_at: parsedKey.data.expires_at, + isOAuth: true, + userId: credential.userId!, + }); } else { - throw Error( - `No API Key found for userId ${credential.userId} and appId ${credential.appId}: ${parsedCredentialKey.error}` - ); + throw new Error("No valid authentication method found"); } } diff --git a/packages/app-store/closecom/pages/setup/index.tsx b/packages/app-store/closecom/pages/setup/index.tsx deleted file mode 100644 index be6d19bc35..0000000000 --- a/packages/app-store/closecom/pages/setup/index.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { Toaster } from "react-hot-toast"; -import z from "zod"; - -import { WebAppURL } from "@calcom/lib/WebAppURL"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; -import { Button, Form, showToast, TextField } from "@calcom/ui"; - -const formSchema = z.object({ - api_key: z.string(), -}); - -export default function CloseComSetup() { - const { t } = useLocale(); - const router = useRouter(); - const query = useRouterQuery(); - const [testPassed, setTestPassed] = useState(undefined); - const [testLoading, setTestLoading] = useState(false); - - const form = useForm<{ - api_key: string; - }>({ - resolver: zodResolver(formSchema), - }); - - useEffect(() => { - const timer = setTimeout(() => { - if (testPassed === false) { - setTestPassed(undefined); - } - }, 3000); - return () => clearTimeout(timer); - }, [testPassed]); - - return ( -
-
-
-
- {/* eslint-disable @next/next/no-img-element */} - Apple Calendar -
-
-

{t("provide_api_key")}

- -
- {t("generate_api_key_description", { appName: "Close.com" })}{" "} - - Close.com - - . {t("it_stored_encrypted")} -
-
-
{ - const { returnTo } = query; - const url = new WebAppURL("/api/integrations/closecom/add"); - if (returnTo) url.searchParams.append("returnTo", `${returnTo}`); - const res = await fetch(url.href, { - method: "POST", - body: JSON.stringify(values), - headers: { - "Content-Type": "application/json", - }, - }); - const json = await res.json(); - - if (res.ok) { - router.push(json.url); - } else { - showToast(json.message, "error"); - } - }}> -
- ( - { - onChange(e.target.value); - form.setValue("api_key", e.target.value); - await form.trigger("api_key"); - }} - /> - )} - /> -
-
- - - -
-
-
-
-
-
- -
- ); -} diff --git a/packages/app-store/closecom/zod.ts b/packages/app-store/closecom/zod.ts index b2245912f0..b0d9d08eae 100644 --- a/packages/app-store/closecom/zod.ts +++ b/packages/app-store/closecom/zod.ts @@ -4,4 +4,7 @@ import { eventTypeAppCardZod } from "../eventTypeAppCardZod"; export const appDataSchema = eventTypeAppCardZod; -export const appKeysSchema = z.object({}); +export const appKeysSchema = z.object({ + client_id: z.string(), + client_secret: z.string(), +}); diff --git a/packages/lib/CloseCom.ts b/packages/lib/CloseCom.ts index 350c0da97a..2c3d87499a 100644 --- a/packages/lib/CloseCom.ts +++ b/packages/lib/CloseCom.ts @@ -1,4 +1,5 @@ import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; export type CloseComLead = { companyName?: string | null | undefined; @@ -145,8 +146,6 @@ export type typeCloseComCustomActivityGet = { type CloseComCustomActivityCustomField = `custom.${T}`; -const environmentApiKey = process.env.CLOSECOM_API_KEY || ""; - type CloseComQuery = { negate?: boolean; queries?: CloseComQuery[] | CloseComCondition[]; @@ -186,7 +185,7 @@ export type CloseComCustomFieldCreateResponse = { }; /** - * This class to instance communicating to Close.com APIs requires an API Key. + * This class to instance communicating to Close.com APIs requires an API Key (legacy) or OAuth. * * You can either pass to the constructor an API Key or have one defined as an * environment variable in case the communication to Close.com is just for @@ -194,13 +193,128 @@ export type CloseComCustomFieldCreateResponse = { */ export default class CloseCom { private apiUrl = "https://api.close.com/api/v1"; - private apiKey: string | undefined = undefined; + private api_key: string; + private refresh_token?: string; + private expires_at?: number; + private isOAuth: boolean; private log: typeof logger; + private userId?: number; - constructor(providedApiKey = "") { + constructor( + apiKey: string, + options?: { + refresh_token?: string; + expires_at?: number; + isOAuth?: boolean; + userId?: number; + } + ) { this.log = logger.getSubLogger({ prefix: [`[[lib] close.com`] }); - if (!providedApiKey && !environmentApiKey) throw Error("Close.com Api Key not present"); - this.apiKey = providedApiKey || environmentApiKey; + this.api_key = apiKey; + this.refresh_token = options?.refresh_token; + this.expires_at = options?.expires_at; + this.isOAuth = options?.isOAuth || false; + this.userId = options?.userId; + } + + private async getHeaders() { + if (this.isOAuth) { + // Check if token needs refresh + if (await this.shouldRefreshToken()) { + await this.refreshAccessToken(); + } + return { + Authorization: `Bearer ${this.api_key}`, + "Content-Type": "application/json", + }; + } + + // API key auth + return { + Authorization: `Basic ${Buffer.from(`${this.api_key}:`).toString("base64")}`, + "Content-Type": "application/json", + }; + } + + private async shouldRefreshToken(): Promise { + if (!this.expires_at) return false; + + // Refresh token if it expires in less than 5 minutes + const fiveMinutesInMs = 5 * 60 * 1000; + return Date.now() + fiveMinutesInMs >= this.expires_at; + } + + private async refreshAccessToken() { + if (!this.refresh_token) throw new Error("No refresh token available"); + if (!this.userId) throw new Error("No userId available for token refresh"); + + try { + const response = await fetch("https://api.close.com/oauth2/token/", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: this.refresh_token, + client_id: process.env.CLOSECOM_CLIENT_ID!, + client_secret: process.env.CLOSECOM_CLIENT_SECRET!, + }), + }); + + if (!response.ok) { + throw new Error("Failed to refresh token"); + } + + const data = await response.json(); + + this.api_key = data.access_token; + this.refresh_token = data.refresh_token; + this.expires_at = Date.now() + data.expires_in * 1000; + + try { + const credential = await prisma.credential.findFirst({ + where: { + userId: this.userId, + type: "closecom_crm", + }, + select: { + id: true, + }, + }); + + if (!credential?.id) { + throw new Error("Credential not found"); + } + + await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: this.expires_at, + }, + }, + }); + } catch (error) { + this.log.error("Failed to update credentials in database", error); + } + + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: this.expires_at, + }; + } catch (error) { + this.log.error("Token refresh process failed", { + error, + userId: this.userId, + }); + throw error; + } } public me = async () => { @@ -355,11 +469,7 @@ export default class CloseCom { data?: Record; }) => { this.log.debug(method, urlPath, query, data); - const credentials = Buffer.from(`${this.apiKey}:`).toString("base64"); - const headers = { - Authorization: `Basic ${credentials}`, - "Content-Type": "application/json", - }; + const headers = await this.getHeaders(); const queryString = query ? `?${new URLSearchParams(String(query)).toString()}` : ""; return await fetch(`${this.apiUrl}${urlPath}${queryString}`, { headers, diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index b29853c7d3..db4db4256e 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -385,6 +385,13 @@ export default async function main() { }); } + if (process.env.CLOSECOM_CLIENT_ID && process.env.CLOSECOM_CLIENT_SECRET) { + await createApp("closecom", "closecom", ["crm"], "closecom_crm", { + client_id: process.env.CLOSECOM_CLIENT_ID, + client_secret: process.env.CLOSECOM_CLIENT_SECRET, + }); + } + for (const [, app] of Object.entries(appStoreMetadata)) { if (app.isTemplate && process.argv[2] !== "seed-templates") { continue; diff --git a/turbo.json b/turbo.json index 40b5c45e18..7b549044f5 100644 --- a/turbo.json +++ b/turbo.json @@ -280,7 +280,8 @@ "CALENDSO_ENCRYPTION_KEY", "CALCOM_CREDENTIAL_SYNC_SECRET", "CI", - "CLOSECOM_API_KEY", + "CLOSECOM_CLIENT_ID", + "CLOSECOM_CLIENT_SECRET", "CRON_API_KEY", "CRON_SECRET", "CRON_ENABLE_APP_SYNC",