Skip to content

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

Package listing

@kody/meta

src/scopes.ts

268 lines · 10.2 KB · TypeScript
export const GRAPH_API_VERSION = 'v26.0'
export const GRAPH_API_BASE_URL = 'https://graph.facebook.com/' + GRAPH_API_VERSION
export const GRAPH_HOST = 'graph.facebook.com'
export const GRAPH_INSTAGRAM_HOST = 'graph.instagram.com'
export const GRAPH_VIDEO_HOST = 'graph-video.facebook.com'
export const FACEBOOK_AUTHORIZE_HOST = 'www.facebook.com'
export const FACEBOOK_AUTHORIZE_URL = 'https://www.facebook.com/' + GRAPH_API_VERSION + '/dialog/oauth'
export const FACEBOOK_TOKEN_URL = 'https://graph.facebook.com/' + GRAPH_API_VERSION + '/oauth/access_token'
export const META_APP_DASHBOARD_URL = 'https://developers.facebook.com/apps'
export const META_SYSTEM_USERS_URL = 'https://business.facebook.com/settings/system-users'
export const KODY_OAUTH_REDIRECT_URI = 'https://kody.codes/connect/oauth'

export const PRODUCT_SCOPES = {
	profile: ['public_profile'],
	email: ['email'],
	pagesList: ['pages_show_list'],
	pagesRead: ['pages_read_engagement', 'pages_read_user_content'],
	pagesWrite: ['pages_manage_posts', 'pages_manage_engagement', 'pages_manage_metadata'],
	pagesMessaging: ['pages_messaging'],
	insights: ['read_insights'],
	business: ['business_management'],
	instagramRead: ['instagram_basic', 'pages_show_list'],
	instagramPublish: ['instagram_content_publish', 'instagram_basic', 'pages_show_list'],
	instagramComments: ['instagram_manage_comments', 'instagram_basic'],
	instagramInsights: ['instagram_manage_insights', 'instagram_basic'],
	instagramMessages: ['instagram_manage_messages', 'instagram_basic'],
	whatsappManage: ['whatsapp_business_management', 'business_management'],
	whatsappSend: ['whatsapp_business_messaging', 'whatsapp_business_management'],
} as const

export const SMOKE_TEST_SCOPES = [...PRODUCT_SCOPES.profile, ...PRODUCT_SCOPES.email]

export const ALL_PRODUCT_SCOPES = [
	...PRODUCT_SCOPES.profile,
	...PRODUCT_SCOPES.email,
	...PRODUCT_SCOPES.pagesList,
	...PRODUCT_SCOPES.pagesRead,
	...PRODUCT_SCOPES.pagesWrite,
	...PRODUCT_SCOPES.pagesMessaging,
	...PRODUCT_SCOPES.insights,
	...PRODUCT_SCOPES.business,
	...PRODUCT_SCOPES.instagramRead,
	...PRODUCT_SCOPES.instagramPublish,
	...PRODUCT_SCOPES.instagramComments,
	...PRODUCT_SCOPES.instagramInsights,
	...PRODUCT_SCOPES.instagramMessages,
	...PRODUCT_SCOPES.whatsappManage,
	...PRODUCT_SCOPES.whatsappSend,
]

const UNIQUE_ALL_SCOPES = [...new Set(ALL_PRODUCT_SCOPES)]

export const REQUIRED_HOSTS = [GRAPH_HOST, GRAPH_INSTAGRAM_HOST, GRAPH_VIDEO_HOST]

export type GraphMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'

export type ScopeNeed = {
	product: string
	scopes: string[]
	setupNote: string
}

function pathOf(path: string): string {
	return path.split('?')[0] || path
}

const KNOWN_SCOPES = [
	'public_profile',
	'email',
	'pages_show_list',
	'pages_read_engagement',
	'pages_read_user_content',
	'pages_manage_posts',
	'pages_manage_engagement',
	'pages_manage_metadata',
	'pages_messaging',
	'read_insights',
	'business_management',
	'instagram_basic',
	'instagram_content_publish',
	'instagram_manage_comments',
	'instagram_manage_insights',
	'instagram_manage_messages',
	'whatsapp_business_management',
	'whatsapp_business_messaging',
] as const

/**
 * Infer the Meta permissions a call typically needs so 403s can name
 * the missing permission and the reconnect / secret setup step.
 */
export function inferRequiredScopes(method: GraphMethod, path: string): ScopeNeed {
	const p = pathOf(path)
	const write = method !== 'GET'

	if (/\/messages$/.test(p) && /\/[0-9]+\/messages$/.test(p)) {
		return {
			product: 'WhatsApp Cloud messages',
			scopes: write ? [...PRODUCT_SCOPES.whatsappSend] : [...PRODUCT_SCOPES.whatsappManage],
			setupNote: write
				? 'Add whatsapp_business_messaging on the Meta app (or assign the WhatsApp asset to the system user), then reconnect.'
				: 'Add whatsapp_business_management and assign the WhatsApp Business account to the token.',
		}
	}
	if (/message_templates|\/phone_numbers|whatsapp_business_accounts/.test(p)) {
		return {
			product: 'WhatsApp Cloud',
			scopes: write ? [...PRODUCT_SCOPES.whatsappSend] : [...PRODUCT_SCOPES.whatsappManage],
			setupNote:
				'Add whatsapp_business_management (and whatsapp_business_messaging to send) on the Meta app, or generate a system user token with the WhatsApp asset assigned.',
		}
	}
	if (/\/media_publish$|\/media$/.test(p) && write) {
		return {
			product: 'Instagram publish',
			scopes: [...PRODUCT_SCOPES.instagramPublish],
			setupNote: 'Add instagram_content_publish and instagram_basic, then reconnect. The Instagram professional account must be linked to a Page the token can access.',
		}
	}
	if (/\/comments/.test(p)) {
		return {
			product: 'Instagram comments',
			scopes: write ? [...PRODUCT_SCOPES.instagramComments] : [...PRODUCT_SCOPES.instagramRead],
			setupNote: write
				? 'Add instagram_manage_comments and instagram_basic, then reconnect.'
				: 'Add instagram_basic (and instagram_manage_comments to moderate), then reconnect.',
		}
	}
	if (/\/insights/.test(p)) {
		return {
			product: 'Insights',
			scopes: /instagram|ig_/.test(p) ? [...PRODUCT_SCOPES.instagramInsights] : [...PRODUCT_SCOPES.insights, ...PRODUCT_SCOPES.pagesRead],
			setupNote: 'Add read_insights (Pages) or instagram_manage_insights (Instagram), then reconnect.',
		}
	}
	if (/\/media\b|instagram_business_account/.test(p)) {
		return {
			product: 'Instagram',
			scopes: write ? [...PRODUCT_SCOPES.instagramPublish] : [...PRODUCT_SCOPES.instagramRead],
			setupNote: 'Add instagram_basic and pages_show_list, then reconnect. Discover Instagram accounts from Pages — this package never hard-codes an account id.',
		}
	}
	if (/\/feed$|\/posts$|\/published_posts$/.test(p)) {
		return {
			product: 'Facebook Page posts',
			scopes: write ? [...PRODUCT_SCOPES.pagesWrite] : [...PRODUCT_SCOPES.pagesRead, ...PRODUCT_SCOPES.pagesList],
			setupNote: write
				? 'Add pages_manage_posts (and pages_show_list), then reconnect. Publishing uses a Page token derived from the connected user or system user — pass the Page id, never a hard-coded brand page.'
				: 'Add pages_read_engagement and pages_show_list, then reconnect.',
		}
	}
	if (/\/accounts$/.test(p) || /\/me\/accounts/.test(p)) {
		return {
			product: 'Facebook Pages',
			scopes: [...PRODUCT_SCOPES.pagesList],
			setupNote: 'Add pages_show_list, then reconnect. Extra accounts use provider=meta-<purpose>.',
		}
	}
	if (/\/conversations|\/messages/.test(p)) {
		return {
			product: 'Page messaging',
			scopes: [...PRODUCT_SCOPES.pagesMessaging],
			setupNote: 'Add pages_messaging, then reconnect.',
		}
	}
	if (/\/businesses/.test(p)) {
		return {
			product: 'Meta Business',
			scopes: [...PRODUCT_SCOPES.business],
			setupNote: 'Add business_management, then reconnect. System users need the Business asset assigned in Business settings.',
		}
	}
	if (p === '/me' || p.startsWith('/me/')) {
		return {
			product: 'Meta profile',
			scopes: [...PRODUCT_SCOPES.profile],
			setupNote: 'public_profile is granted with Facebook Login. Reconnect at /connect/oauth if the token is missing.',
		}
	}
	return {
		product: 'Meta Graph',
		scopes: [...PRODUCT_SCOPES.profile],
		setupNote: 'Reconnect the Meta OAuth integration with the permission this Graph API documents, or use a system user token that has the asset assigned.',
	}
}

export function scopesMentionedInMessage(message: string | null): string[] {
	if (!message) return []
	const found: string[] = []
	for (const scope of KNOWN_SCOPES) {
		if (message.includes(scope)) found.push(scope)
	}
	return found
}

export function buildMetaConnectUrl(input: {
	provider?: string
	scopes?: readonly string[]
} = {}): string {
	const provider = input.provider ?? 'meta'
	const scopes = [...(input.scopes ?? SMOKE_TEST_SCOPES)]
	const url = new URL(KODY_OAUTH_REDIRECT_URI)
	url.searchParams.set('provider', provider)
	url.searchParams.set('authorizeUrl', FACEBOOK_AUTHORIZE_URL)
	url.searchParams.set('tokenUrl', FACEBOOK_TOKEN_URL)
	url.searchParams.set('flow', 'confidential')
	url.searchParams.set('scopes', scopes.join(','))
	url.searchParams.set('scopeSeparator', ',')
	url.searchParams.set('allowedHosts', REQUIRED_HOSTS.join(','))
	url.searchParams.set('apiBaseUrl', GRAPH_API_BASE_URL)
	url.searchParams.set('dashboardUrl', META_APP_DASHBOARD_URL)
	url.searchParams.set('extraAuthorizeParams', JSON.stringify({ auth_type: 'rerequest' }))
	return url.toString()
}

export function reconnectUrl(integrationName: string, scopes?: readonly string[]): string {
	if (!scopes || scopes.length === 0) {
		return 'https://kody.codes/connect/oauth?provider=' + encodeURIComponent(integrationName)
	}
	return buildMetaConnectUrl({ provider: integrationName, scopes: [...new Set([...SMOKE_TEST_SCOPES, ...scopes])] })
}

export function systemUserSecretUrl(secretName = 'metaSystemUserToken'): string {
	const url = new URL('https://kody.codes/account/secrets/new')
	url.searchParams.set('name', secretName)
	url.searchParams.set(
		'description',
		'Meta Business system user token for Facebook Pages, Instagram, and WhatsApp Cloud. Paste only on this form.',
	)
	url.searchParams.set('allowedHosts', REQUIRED_HOSTS.join(','))
	url.searchParams.set('scope', 'user')
	return url.toString()
}

export function setupUrls(integrationName = 'meta', secretName = 'metaSystemUserToken') {
	return {
		redirectUri: KODY_OAUTH_REDIRECT_URI,
		oauthConnectUrl: buildMetaConnectUrl({ provider: integrationName, scopes: UNIQUE_ALL_SCOPES }),
		oauthSmokeConnectUrl: buildMetaConnectUrl({ provider: integrationName }),
		oauthReconnectUrl: reconnectUrl(integrationName),
		systemUserSecretUrl: systemUserSecretUrl(secretName),
		dashboardUrl: META_APP_DASHBOARD_URL,
		systemUsersUrl: META_SYSTEM_USERS_URL,
	}
}

/**
 * Scope catalog and prefilled /connect/oauth plus /account/secrets/new URLs.
 * @example
 * import scopes from 'kody:@kody/meta/scopes'
 * const { smokeTestConnectUrl } = scopes()
 */
export default function scopes() {
	return {
		auth: ['oauth', 'system-user'],
		notApiKey: true,
		notBotToken: true,
		graphVersion: GRAPH_API_VERSION,
		redirectUri: KODY_OAUTH_REDIRECT_URI,
		requiredHosts: [...REQUIRED_HOSTS],
		smokeTestScopes: [...SMOKE_TEST_SCOPES],
		allProductScopes: UNIQUE_ALL_SCOPES,
		productScopes: PRODUCT_SCOPES,
		smokeTestConnectUrl: buildMetaConnectUrl(),
		allProductsConnectUrl: buildMetaConnectUrl({ scopes: UNIQUE_ALL_SCOPES }),
		systemUserSecretUrl: systemUserSecretUrl(),
	}
}