From e90060287e6f5b0fcfde35979eb5d7a5bbe3a32f Mon Sep 17 00:00:00 2001 From: dmh <5150636+dmh@users.noreply.github.com> Date: Wed, 17 Jan 2024 17:43:13 +0200 Subject: [PATCH] [TASK] add add error handler for access-key missing access scopes --- lib/hubspot/auth/auth.js | 23 ++++--- lib/hubspot/auth/env.js | 71 +++++++++++-------- lib/hubspot/auth/scopes.js | 136 +++++++++++++++++++++++++++++++++++++ lib/hubspot/fetch.js | 3 + lib/hubspot/hubdb/hubdb.js | 3 + lib/hubspot/lighthouse.js | 3 + lib/hubspot/upload.js | 3 + lib/hubspot/validate.js | 2 + lib/hubspot/watch.js | 2 + lib/types/types.js | 8 ++- 10 files changed, 211 insertions(+), 43 deletions(-) create mode 100644 lib/hubspot/auth/scopes.js diff --git a/lib/hubspot/auth/auth.js b/lib/hubspot/auth/auth.js index 5c92453..aa73f13 100644 --- a/lib/hubspot/auth/auth.js +++ b/lib/hubspot/auth/auth.js @@ -5,7 +5,7 @@ import { getAccessToken } from '@hubspot/local-dev-lib/personalAccessKey' import { validateConfig, getAndLoadConfigIfNeeded, writeConfig, setConfig, createEmptyConfigFile, deleteConfigFile } from '@hubspot/local-dev-lib/config' import chalk from 'chalk' import ora from 'ora' -import { getAuthDataFromEnvFile } from './env.js' +import { getAuthDataFromEnvFile, getAuthDataFromEnvVars } from './env.js' import { savedConsoleDebug } from '../../utils/console.js' /** @@ -32,7 +32,8 @@ async function getAccessTokenData (accessKey) { spinner.fail() await deleteConfigFile() if (error.cause.response.statusText === 'Unauthorized') { - console.error(`${chalk.red.bold('[Error]')} ${error.cause.response.statusText}. Check the personal access key and try again.`) + const msg = error.cause.response.data.message ? error.cause.response.data.message : 'Check the personal access key and try again.' + console.error(`${chalk.red.bold('[Error]')} ${error.cause.response.statusText}. ${msg}`) } else { console.error(error) } @@ -41,40 +42,40 @@ async function getAccessTokenData (accessKey) { } /** - * #### Load and validate Hubspot config + * #### Load and validate Hubspot auth config * @async * @param {string} themeName - theme name - * @returns {Promise} AccessToken data + * @returns {Promise} Hubspot auth config */ async function loadAuthConfig (themeName) { if (process.env.HUBSPOT_PORTAL_ID && process.env.HUBSPOT_PERSONAL_ACCESS_KEY) { // get access token data from Hubspot - const accessTokenData = await getAccessTokenData(process.env.HUBSPOT_PERSONAL_ACCESS_KEY) + const authConfig = await getAuthDataFromEnvVars() // check if the portal ID and personal access key match - if (accessTokenData.portalId !== Number(process.env.HUBSPOT_PORTAL_ID)) { + if (authConfig.portals[0].portalId !== Number(process.env.HUBSPOT_PORTAL_ID)) { console.error(`${chalk.red.bold('[Error]')} Portal ID and Personal Access Key do not match`) process.exit(1) } console.debug = () => {} // load Hubspot auth config - const hubAuthConfig = await getAndLoadConfigIfNeeded({ silenceErrors: false, useEnv: true }) + await getAndLoadConfigIfNeeded({ silenceErrors: false, useEnv: true }) console.debug = savedConsoleDebug await validateConfig() - return hubAuthConfig + return authConfig } else { // get auth data from .env file - const authDataFromEnvFile = await getAuthDataFromEnvFile(themeName) + const authConfig = await getAuthDataFromEnvFile(themeName) console.debug = () => {} await createEmptyConfigFile() console.debug = savedConsoleDebug // load Hubspot auth config - const hubAuthConfig = await setConfig(authDataFromEnvFile) + await setConfig(authConfig) await validateConfig() console.debug = () => {} // add auth data to hubspot.config.yml file await writeConfig() console.debug = savedConsoleDebug - return hubAuthConfig + return authConfig } } diff --git a/lib/hubspot/auth/env.js b/lib/hubspot/auth/env.js index 3f4bd75..5c9ab62 100644 --- a/lib/hubspot/auth/env.js +++ b/lib/hubspot/auth/env.js @@ -108,53 +108,64 @@ function getPortalsNames () { } /** - * #### Generate Hubspot auth config based on PERSONAL_ACCESS_KEY and chosen portal name + * #### Generate Hubspot auth config based on .env file * @async * @private * @param {string} themeName - theme name - * @returns {Promise}> } Hubspot portal auth config + * @returns {Promise}> } Hubspot portal auth config */ -async function generateAuthConfig (themeName) { +async function getAuthDataFromEnvFile (themeName) { + await initEnvFile() const portalName = await choosePortal(getPortalsNames(), themeName) - const dotenvConfig = dotenv.config().parsed + const dotenvConfig = dotenv.config().parsed || {} let envPortalName = '' for (const env in dotenvConfig) { if (env.replace('hub_', '') === portalName) { envPortalName = env } } - if (dotenvConfig) { - const accessTokenData = await getAccessTokenData(dotenvConfig[envPortalName]) - return { - defaultPortal: portalName, - allowUsageTracking: false, - portals: [ - { - name: portalName, - portalId: accessTokenData.portalId, - env: 'prod', - authType: 'personalaccesskey', - personalAccessKey: dotenvConfig[envPortalName] - } - ] - } + const accessTokenData = await getAccessTokenData(dotenvConfig[envPortalName]) + return { + defaultPortal: portalName, + allowUsageTracking: false, + portals: [ + { + name: portalName, + portalId: accessTokenData.portalId, + env: 'prod', + authType: 'personalaccesskey', + personalAccessKey: dotenvConfig[envPortalName], + scopeGroups: accessTokenData.scopeGroups + } + ] } } /** - * #### Get auth data from .env file + * #### Generate Hubspot auth config from environment variables + * @description Generate Hubspot auth config from environment variables (HUBSPOT_PORTAL_ID, HUBSPOT_PERSONAL_ACCESS_KEY) * @async - * @param {string} themeName - theme name - * @returns {Promise} Hubspot auth config + * @returns {Promise} AccessToken data */ -async function getAuthDataFromEnvFile (themeName) { - try { - await initEnvFile() - const authData = await generateAuthConfig(themeName) - return authData - } catch (error) { - console.error(error) +async function getAuthDataFromEnvVars () { + const portalId = process.env.HUBSPOT_PORTAL_ID + const accessKey = process.env.HUBSPOT_PERSONAL_ACCESS_KEY + if (!portalId || !accessKey) { + process.exit(0) + } + const accessTokenData = await getAccessTokenData(accessKey) + return { + allowUsageTracking: false, + portals: [ + { + portalId: parseInt(portalId), + env: 'prod', + authType: 'personalaccesskey', + personalAccessKey: accessKey, + scopeGroups: accessTokenData.scopeGroups + } + ] } } -export { getAuthDataFromEnvFile } +export { getAuthDataFromEnvFile, getAuthDataFromEnvVars } diff --git a/lib/hubspot/auth/scopes.js b/lib/hubspot/auth/scopes.js new file mode 100644 index 0000000..5f76365 --- /dev/null +++ b/lib/hubspot/auth/scopes.js @@ -0,0 +1,136 @@ +import chalk from 'chalk' +import * as TYPES from '../../types/types.js' // eslint-disable-line no-unused-vars + +/** + * @ignore + * @typedef {TYPES.HUBSPOT_AUTH_CONFIG} HUBSPOT_AUTH_CONFIG {@link HUBSPOT_AUTH_CONFIG} + */ + +/** + * #### scope details + * @typedef {Object} SCOPE_DETAILS + * @property {boolean} selected - is selected + * @property {string} title - scope title + * @property {string} description - scope description + */ + +/** + * #### scopes + * @typedef {Object} SCOPES + * @property {SCOPE_DETAILS} cms_pages - View and download website data. + * @property {SCOPE_DETAILS} crm_objects - Read data from HubSpot objects in the CRM. + * @property {SCOPE_DETAILS} custom_objects - Create, delete, or make changes to custom objects in the CRM. + * @property {SCOPE_DETAILS} design_manager - Upload and download templates, modules, and other files that developers need to write the code for websites and emails. + * @property {SCOPE_DETAILS} developer_projects - Upload and download developer projects. + * @property {SCOPE_DETAILS} file_manager - Upload and download files that can be used across HubSpot tools when creating content. + * @property {SCOPE_DETAILS} graphql_data_fetching - Execute GraphQL queries and fetch GraphQL Schema. + * @property {SCOPE_DETAILS} hubdb - Create, update, and delete HubDB tables. + * @property {SCOPE_DETAILS} serverless_functions - View logs, manage secrets, and interact with other serverless functionality. + */ + +/** + * #### parse scopes + * @private + * @param {HUBSPOT_AUTH_CONFIG} config - hubspot authentication config + * @returns {SCOPES} scopes + */ +function parseScopes (config) { + /** + * @type {SCOPES} + */ + const scopes = { + cms_pages: { + selected: false, + title: 'CMS Pages', + description: 'View and download website data.' + }, + crm_objects: { + selected: false, + title: 'CRM Objects', + description: 'Read data from HubSpot objects in the CRM.' + }, + custom_objects: { + selected: false, + title: 'Custom Objects', + description: 'Create, delete, or make changes to custom objects in the CRM.' + }, + design_manager: { + selected: false, + title: 'Design Manager', + description: 'Upload and download templates, modules, and other files that developers need to write the code for websites and emails.' + }, + developer_projects: { + selected: false, + title: 'Developer Projects', + description: 'Upload and download developer projects.' + }, + file_manager: { + selected: false, + title: 'File Manager', + description: 'Upload and download files that can be used across HubSpot tools when creating content.' + }, + graphql_data_fetching: { + selected: false, + title: 'GraphQL Data Fetching', + description: 'Execute GraphQL queries and fetch GraphQL Schema.' + }, + hubdb: { + selected: false, + title: 'HubDB', + description: 'Create, update, and delete HubDB tables.' + }, + serverless_functions: { + selected: false, + title: 'Serverless functions', + description: 'View logs, manage secrets, and interact with other serverless functionality.' + } + } + + if (config.portals[0].scopeGroups.includes('cms.pages.landing_pages.read' && 'cms.pages.site_pages.read')) { + scopes.cms_pages.selected = true + } + if (config.portals[0].scopeGroups.includes('crm.objects.companies.read' && 'crm.objects.contacts.read' && 'crm.objects.deals.read' && 'crm.objects.owners.read' && 'crm.schemas.companies.read' && 'crm.schemas.contacts.read' && 'crm.schemas.deals.read')) { + scopes.crm_objects.selected = true + } + if (config.portals[0].scopeGroups.includes('crm.objects.custom.read' && 'crm.objects.custom.write' && 'crm.schemas.custom.read' && 'crm.schemas.custom.write')) { + scopes.custom_objects.selected = true + } + if (config.portals[0].scopeGroups.includes('cms.source_code.write' && 'cms.source_code.read')) { + scopes.design_manager.selected = true + } + if (config.portals[0].scopeGroups.includes('developer.projects.write')) { + scopes.developer_projects.selected = true + } + if (config.portals[0].scopeGroups.includes('files')) { + scopes.file_manager.selected = true + } + if (config.portals[0].scopeGroups.includes('collector.graphql_query.execute' && 'collector.graphql_schema.read')) { + scopes.graphql_data_fetching.selected = true + } + if (config.portals[0].scopeGroups.includes('hubdb')) { + scopes.hubdb.selected = true + } + if (config.portals[0].scopeGroups.includes('cms.functions.read' && 'cms.functions.write')) { + scopes.serverless_functions.selected = true + } + return scopes +} + +/** + * #### throw error if missing scopes + * @param {HUBSPOT_AUTH_CONFIG} config - hubspot authentication config + * @param {'cms_pages'|'crm_objects'|'custom_objects'|'design_manager'|'developer_projects'|'file_manager'|'graphql_data_fetching'|'hubdb'|'serverless_functions'} scopeName - scope name + * @returns undefined + */ +function throwErrorIfMissingScope (config, scopeName) { + const scopes = parseScopes(config) + if (!scopes[scopeName].selected) { + const msg = 'This app hasn\'t been granted all required scopes to make this call. Read more about required scopes here: https://developers.hubspot.com/docs/cms/personal-access-key.' + console.error(`${chalk.red.bold('[MISSING_SCOPES]')} ${msg}`) + console.error('All of the following scopes are required:') + console.error(`[${chalk.red(scopes[scopeName].title)}] - ${scopes[scopeName].description}`) + process.exit(1) + } +} + +export { throwErrorIfMissingScope } diff --git a/lib/hubspot/fetch.js b/lib/hubspot/fetch.js index 9df2bd2..0db888f 100644 --- a/lib/hubspot/fetch.js +++ b/lib/hubspot/fetch.js @@ -5,6 +5,7 @@ import logger from '@hubspot/cli-lib/logger.js' import * as ui from '../utils/ui.js' import { savedConsoleDebug } from '../utils/console.js' import { getThemeOptions } from '../utils/options.js' +import { throwErrorIfMissingScope } from './auth/scopes.js' /** * @ignore @@ -34,6 +35,7 @@ async function fetchModules (config, themeName) { const options = { overwrite: true } + throwErrorIfMissingScope(config, 'design_manager') logger.setLogLevel(2) console.debug = () => {} await downloadFileOrFolder({ accountId, src, dest, mode, options }) @@ -63,6 +65,7 @@ async function fetch (config, themeName) { const options = { overwrite: true } + throwErrorIfMissingScope(config, 'design_manager') logger.setLogLevel(2) console.debug = () => {} await downloadFileOrFolder({ accountId, src, dest, mode, options }) diff --git a/lib/hubspot/hubdb/hubdb.js b/lib/hubspot/hubdb/hubdb.js index b53c601..21148d5 100644 --- a/lib/hubspot/hubdb/hubdb.js +++ b/lib/hubspot/hubdb/hubdb.js @@ -11,6 +11,7 @@ import { selectTables, selectFiles, confirmTableOverwrite } from './prompts.js' import ora, { oraPromise } from 'ora' import { getFileList } from '../../utils/fs.js' import { createRequire } from 'module' +import { throwErrorIfMissingScope } from '../auth/scopes.js' const require = createRequire(import.meta.url) const cmslibOptions = getThemeOptions() @@ -30,6 +31,7 @@ async function fetchHubDb (config) { const timeStart = ui.startTask('fetchDb') const dest = `${process.cwd()}/${cmslibOptions.hubdbFolder}` const accountId = config.portals[0].portalId + throwErrorIfMissingScope(config, 'hubdb') console.debug = () => {} // fetch all tables from the account const tables = await fetchTables(accountId) @@ -59,6 +61,7 @@ async function uploadHubDb (config) { const timeStart = ui.startTask('uploadDb') const hubdbFolder = `${process.cwd()}/${cmslibOptions.hubdbFolder}` const accountId = config.portals[0].portalId + throwErrorIfMissingScope(config, 'hubdb') // get all json files from the hubdb folder const files = await getFileList(`${hubdbFolder}/*.json`, { objectMode: true }) if (files === undefined || files.length === 0) { diff --git a/lib/hubspot/lighthouse.js b/lib/hubspot/lighthouse.js index b51dafb..128ec26 100644 --- a/lib/hubspot/lighthouse.js +++ b/lib/hubspot/lighthouse.js @@ -9,6 +9,7 @@ import { savedConsoleDebug } from '../utils/console.js' import { getThemeOptions } from '../utils/options.js' import * as ui from '../utils/ui.js' import { cleanUploadThemeTemplates } from '../hubspot/upload.js' +import { throwErrorIfMissingScope } from './auth/scopes.js' /** * @ignore @@ -27,6 +28,8 @@ import { cleanUploadThemeTemplates } from '../hubspot/upload.js' async function lighthouseScore (config, themeName) { try { // reupload all templates + throwErrorIfMissingScope(config, 'design_manager') + await cleanUploadThemeTemplates(config, themeName) const timeStart = ui.startTask('LighthouseScore') diff --git a/lib/hubspot/upload.js b/lib/hubspot/upload.js index a70e667..56d890d 100644 --- a/lib/hubspot/upload.js +++ b/lib/hubspot/upload.js @@ -10,6 +10,7 @@ import { walk } from '@hubspot/local-dev-lib/fs' import * as ui from '../utils/ui.js' import { savedConsoleDebug } from '../utils/console.js' import { getThemeOptions } from '../utils/options.js' +import { throwErrorIfMissingScope } from './auth/scopes.js' import ora from 'ora' /** @@ -60,6 +61,7 @@ async function uploadTheme (config, themeName) { const dest = themeName const portalId = config.portals[0].portalId + throwErrorIfMissingScope(config, 'design_manager') logger.setLogLevel(2) const uploadableFileList = await getUploadableFileList(src) console.debug = () => {} @@ -95,6 +97,7 @@ async function cleanUploadThemeTemplates (config, themeName) { const dest = `${themeName}/templates` const portalId = config.portals[0].portalId + throwErrorIfMissingScope(config, 'design_manager') logger.setLogLevel(0) const uploadableFileList = await getUploadableFileList(src) console.debug = () => {} diff --git a/lib/hubspot/validate.js b/lib/hubspot/validate.js index a8b95fc..749a608 100644 --- a/lib/hubspot/validate.js +++ b/lib/hubspot/validate.js @@ -6,6 +6,7 @@ import chalk from 'chalk' import ora from 'ora' import * as ui from '../utils/ui.js' import { savedConsoleDebug } from '../utils/console.js' +import { throwErrorIfMissingScope } from './auth/scopes.js' /** * @ignore @@ -174,6 +175,7 @@ async function marketplaceValidate (config, themeName) { const timeStart = ui.startTask('marketplaceValidate') const portalId = config.portals[0].portalId + throwErrorIfMissingScope(config, 'design_manager') const spinner = ora('Request Marketplace validation').start() const validationId = await requestHubspotValidation(portalId, themeName, spinner) await checkHubspotValidationStatus(portalId, validationId, spinner) diff --git a/lib/hubspot/watch.js b/lib/hubspot/watch.js index 3e70743..506d9f8 100644 --- a/lib/hubspot/watch.js +++ b/lib/hubspot/watch.js @@ -5,6 +5,7 @@ import chalk from 'chalk' import watch from '@hubspot/cli-lib/lib/watch.js' import logger from '@hubspot/cli-lib/logger.js' import { getThemeOptions } from '../utils/options.js' +import { throwErrorIfMissingScope } from './auth/scopes.js' /** * @ignore @@ -29,6 +30,7 @@ async function watchHubspotTheme (config, themeName) { const src = `${process.cwd()}/${cmslibOptions.themeFolder}` const dest = themeName const portalId = config.portals[0].portalId + throwErrorIfMissingScope(config, 'design_manager') logger.setLogLevel(4) const watcher = await watch.watch(portalId, src, dest, { mode: cmsMode, diff --git a/lib/types/types.js b/lib/types/types.js index 844f3a0..077acee 100644 --- a/lib/types/types.js +++ b/lib/types/types.js @@ -3,12 +3,15 @@ * @typedef {Object} HUBSPOT_ACCESSTOKEN_SCHEMA * @property {number} portalId * @property {string} accessToken + * @property {string} expiresAt + * @property {Array} scopeGroups + * @property {string} encodedOAuthRefreshToken */ /** * #### Hubspot Auth config schema * @typedef {Object} HUBSPOT_AUTH_CONFIG - * @property {string} defaultPortal + * @property {string} [defaultPortal] * @property {boolean} allowUsageTracking * @property {Array} portals */ @@ -16,11 +19,12 @@ /** * #### Hubspot Auth config portals schema * @typedef {Object} HUBSPOT_AUTH_PORTALS_CONFIG - * @property {string} name + * @property {string} [name] * @property {number} portalId * @property {any} env * @property {string} authType * @property {string} personalAccessKey + * @property {Array} scopeGroups */ /**