Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/hubspot

src/setup.ts

218 lines · 7.1 KB · TypeScript
import type { HubSpotAuthMode } from './types.ts'
import { requireString } from './types.ts'

export const HUBSPOT_API_BASE_URL = 'https://api.hubapi.com'
export const HUBSPOT_AUTHORIZE_URL = 'https://app.hubspot.com/oauth/authorize'
export const HUBSPOT_TOKEN_URL = 'https://api.hubapi.com/oauth/v3/token'
export const HUBSPOT_REQUIRED_HOSTS = ['api.hubapi.com', 'api.hubspot.com'] as const
export const HUBSPOT_REQUIRED_HOST = 'api.hubapi.com'
export const HUBSPOT_OAUTH_APP_URL =
	'https://developers.hubspot.com/docs/apps/developer-platform/build-apps/create-an-app'
export const HUBSPOT_PRIVATE_APP_URL =
	'https://developers.hubspot.com/docs/apps/legacy-apps/private-apps/overview'
export const HUBSPOT_SCOPES_URL =
	'https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/scopes'
export const HUBSPOT_CALLBACK_URL = 'https://kody.codes/connect/oauth'
export const DEFAULT_INTEGRATION_NAME = 'hubspot'
export const DEFAULT_TOKEN_SECRET = 'hubspotPrivateAppToken'

export const DEFAULT_OAUTH_SCOPES = [
	'oauth',
	'crm.objects.contacts.read',
	'crm.objects.contacts.write',
	'crm.objects.companies.read',
	'crm.objects.companies.write',
	'crm.objects.deals.read',
	'crm.objects.deals.write',
	'tickets',
] as const

export const OPTIONAL_OAUTH_SCOPES = ['crm.objects.custom.read'] as const

export const HUBSPOT_SCOPES = {
	oauth: 'Required base scope for every HubSpot OAuth app.',
	'crm.objects.contacts.read': 'List, get, and search contacts.',
	'crm.objects.contacts.write': 'Create or update contacts.',
	'crm.objects.companies.read': 'List, get, and search companies.',
	'crm.objects.companies.write': 'Create or update companies.',
	'crm.objects.deals.read': 'List, get, and search deals.',
	'crm.objects.deals.write': 'Create or update deals.',
	tickets: 'List, get, search, create, or update tickets.',
	'crm.objects.custom.read':
		'Read custom CRM objects (Enterprise). Pass as optional_scope so free/Pro portals can still connect.',
} as const

export type HubSpotScope = keyof typeof HUBSPOT_SCOPES

export type HubSpotOperation =
	| 'account.read'
	| 'contacts.read'
	| 'contacts.write'
	| 'companies.read'
	| 'companies.write'
	| 'deals.read'
	| 'deals.write'
	| 'tickets.read'
	| 'tickets.write'
	| 'custom.read'
	| 'unknownRead'
	| 'unknownMutation'

export function oauthConnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	const name = requireString(provider, 'provider')
	const params = new URLSearchParams({
		provider: name,
		authorizeUrl: HUBSPOT_AUTHORIZE_URL,
		tokenUrl: HUBSPOT_TOKEN_URL,
		apiBaseUrl: HUBSPOT_API_BASE_URL,
		scopes: DEFAULT_OAUTH_SCOPES.join(' '),
		flow: 'confidential',
		pkce: 'false',
		allowedHosts: HUBSPOT_REQUIRED_HOSTS.join(','),
		dashboardUrl: HUBSPOT_OAUTH_APP_URL,
		extraAuthorizeParams: `optional_scope=${OPTIONAL_OAUTH_SCOPES.join(' ')}`,
	})
	return `https://kody.codes/connect/oauth?${params.toString()}`
}

export function privateAppSetupUrl(secretName: string = DEFAULT_TOKEN_SECRET): string {
	const name = requireString(secretName, 'secretName')
	const params = new URLSearchParams({
		name,
		description: 'HubSpot private app access token',
		allowedHosts: HUBSPOT_REQUIRED_HOSTS.join(','),
		scope: 'user',
	})
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

export function reconnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string {
	return `https://kody.codes/connect/oauth?provider=${encodeURIComponent(provider)}`
}

export function scopeForOperation(operation: HubSpotOperation): HubSpotScope {
	switch (operation) {
		case 'account.read':
			return 'oauth'
		case 'contacts.read':
			return 'crm.objects.contacts.read'
		case 'contacts.write':
			return 'crm.objects.contacts.write'
		case 'companies.read':
			return 'crm.objects.companies.read'
		case 'companies.write':
			return 'crm.objects.companies.write'
		case 'deals.read':
			return 'crm.objects.deals.read'
		case 'deals.write':
			return 'crm.objects.deals.write'
		case 'tickets.read':
			return 'tickets'
		case 'tickets.write':
			return 'tickets'
		case 'custom.read':
			return 'crm.objects.custom.read'
		case 'unknownRead':
			return 'oauth'
		case 'unknownMutation':
			return 'oauth'
		default: {
			const exhaustive: never = operation
			throw new Error(`Unsupported HubSpot operation: ${String(exhaustive)}`)
		}
	}
}

export function nextStepForScope(
	scope: HubSpotScope,
	options: {
		authMode: HubSpotAuthMode
		integrationName: string
		secretName: string
	},
): string {
	switch (options.authMode) {
		case 'oauth':
			return [
				`Reconnect the "${options.integrationName}" OAuth integration and include the "${scope}" scope.`,
				reconnectUrl(options.integrationName),
				`Full BYO connect URL: ${oauthConnectUrl(options.integrationName)}`,
				`Scope reference: ${HUBSPOT_SCOPES_URL}`,
			].join(' ')
		case 'privateApp':
			return [
				`Create a HubSpot private app with the "${scope}" scope, then save the token as ${options.secretName}.`,
				HUBSPOT_PRIVATE_APP_URL,
				privateAppSetupUrl(options.secretName),
			].join(' ')
		default: {
			const exhaustive: never = options.authMode
			throw new Error(`Unsupported HubSpot auth mode: ${String(exhaustive)}`)
		}
	}
}

export function missingCredentialsMessage(options: {
	integrationName: string
	secretName: string
}): string {
	return [
		'HubSpot credentials are missing.',
		'OAuth (recommended for multi-account use): create a public/private OAuth app at',
		HUBSPOT_OAUTH_APP_URL,
		`with redirect URI ${HUBSPOT_CALLBACK_URL}, then connect:`,
		oauthConnectUrl(options.integrationName),
		'Private app token (fastest for one portal): create a private app at',
		HUBSPOT_PRIVATE_APP_URL,
		'then save the token (do not paste the value in chat):',
		privateAppSetupUrl(options.secretName),
		`Required API hosts: ${HUBSPOT_REQUIRED_HOSTS.join(', ')}.`,
	].join(' ')
}

export function isMutatingMethod(method: string): boolean {
	const normalized = method.toUpperCase()
	return (
		normalized === 'POST' ||
		normalized === 'PUT' ||
		normalized === 'PATCH' ||
		normalized === 'DELETE'
	)
}

function objectOperationFromSlug(
	slug: string,
	mutating: boolean,
): HubSpotOperation | null {
	switch (slug) {
		case 'contacts':
			return mutating ? 'contacts.write' : 'contacts.read'
		case 'companies':
			return mutating ? 'companies.write' : 'companies.read'
		case 'deals':
			return mutating ? 'deals.write' : 'deals.read'
		case 'tickets':
			return mutating ? 'tickets.write' : 'tickets.read'
		default:
			return null
	}
}

export function inferOperationFromPath(
	method: string,
	path: string,
	explicit?: HubSpotOperation,
): HubSpotOperation {
	if (explicit) return explicit
	const mutating = isMutatingMethod(method) && !/\/search(?:\/|$|\?)/.test(path)
	if (/\/account-info(?:\/|$)/.test(path) || /\/integrations\/v1\/me(?:\/|$)/.test(path)) {
		return mutating ? 'unknownMutation' : 'account.read'
	}
	const objectMatch = path.match(/\/(?:crm\/(?:v3\/)?objects(?:\/\d{4}-\d{2})?|crm\/objects\/\d{4}-\d{2})\/([^/?]+)/)
	if (objectMatch) {
		const mapped = objectOperationFromSlug(objectMatch[1] ?? '', mutating)
		if (mapped) return mapped
		return mutating ? 'unknownMutation' : 'custom.read'
	}
	return mutating ? 'unknownMutation' : 'unknownRead'
}