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