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-.', } } 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(), } }