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'
}