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