Skip to content

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

Package listing

@kody/meta

src/core.ts

432 lines · 12.7 KB · TypeScript
import { createAuthenticatedFetch } from 'kody:runtime'
import {
	resolveAuthMode,
	resolveMetaIntegration,
	resolveSystemUserSecret,
	type MetaAccountParams,
	type MetaAuthMode,
} from './accounts.ts'
import {
	GRAPH_API_BASE_URL,
	type GraphMethod,
	inferRequiredScopes,
	reconnectUrl,
	scopesMentionedInMessage,
	setupUrls,
	systemUserSecretUrl,
} from './scopes.ts'
import { stripSecrets } from './validation.ts'

export type JsonObject = { [key: string]: unknown }

export type GraphRequestParams = MetaAccountParams & {
	path: string
	method?: GraphMethod
	query?: Record<string, string | number | boolean | undefined>
	body?: unknown
	headers?: Record<string, string>
	/** Internal: use a short-lived Page token without returning it. */
	pageAccessToken?: string
	/** Internal: keep token fields so Page publishing can use them without returning them. */
	keepTokens?: boolean
}

export class MetaGraphError extends Error {
	readonly status: number
	readonly code: string | number | null
	readonly path: string
	readonly method: GraphMethod
	readonly integrationName: string
	readonly authMode: MetaAuthMode
	readonly missingScopes: string[]
	readonly reconnectUrl: string
	readonly systemUserSecretUrl: string
	readonly details: unknown

	constructor(input: {
		message: string
		status: number
		code?: string | number | null
		path: string
		method: GraphMethod
		integrationName: string
		authMode: MetaAuthMode
		missingScopes: string[]
		reconnectUrl: string
		systemUserSecretUrl: string
		details?: unknown
	}) {
		super(input.message)
		this.name = 'MetaGraphError'
		this.status = input.status
		this.code = input.code ?? null
		this.path = input.path
		this.method = input.method
		this.integrationName = input.integrationName
		this.authMode = input.authMode
		this.missingScopes = input.missingScopes
		this.reconnectUrl = input.reconnectUrl
		this.systemUserSecretUrl = input.systemUserSecretUrl
		this.details = input.details ?? null
	}
}

export function isReadOnlyMethod(method: GraphMethod): boolean {
	switch (method) {
		case 'GET':
			return true
		case 'POST':
		case 'PATCH':
		case 'PUT':
		case 'DELETE':
			return false
		default: {
			const exhaustive: never = method
			return exhaustive
		}
	}
}

export function assertGraphPath(path: string): string {
	if (!path.startsWith('/')) {
		throw new Error("path must be an absolute Meta Graph path such as '/me' or '/me/accounts'.")
	}
	if (!/^\/[A-Za-z0-9/_.~(),'$*=:!@+%-]+$/.test(path.split('?')[0] || path)) {
		throw new Error('path contains characters that are not valid in a Meta Graph path.')
	}
	return path
}

function appendQuery(url: URL, query: GraphRequestParams['query']): void {
	if (!query) return
	for (const [key, value] of Object.entries(query)) {
		if (value === undefined || value === null || value === '') continue
		url.searchParams.set(key, String(value))
	}
}

function graphErrorFields(data: unknown): {
	code: string | number | null
	type: string | null
	message: string | null
} {
	if (!data || typeof data !== 'object') return { code: null, type: null, message: null }
	const record = data as JsonObject
	const error = record.error
	if (error && typeof error === 'object') {
		const nested = error as JsonObject
		return {
			code: typeof nested.code === 'number' || typeof nested.code === 'string' ? nested.code : null,
			type: typeof nested.type === 'string' ? nested.type : null,
			message: typeof nested.message === 'string' ? nested.message : null,
		}
	}
	if (typeof error === 'string') return { code: error, type: null, message: null }
	return { code: null, type: null, message: null }
}

function isInsufficientPermission(
	status: number,
	code: string | number | null,
	type: string | null,
	message: string | null,
): boolean {
	if (status === 403) return true
	if (code === 10 || code === 200 || code === '10' || code === '200') return true
	if (type === 'OAuthException' && /permission|scope|#200|#10/i.test(message ?? '')) return true
	return /requires .+ permission|permission denied|not authorized|insufficient|missing permission|(#200)|(#10)/i.test(
		message ?? '',
	)
}

function buildInsufficientScopeMessage(input: {
	status: number
	method: GraphMethod
	path: string
	integrationName: string
	authMode: MetaAuthMode
	code: string | number | null
	apiMessage: string | null
	missingScopes: string[]
	product: string
	setupNote: string
	reconnect: string
	secretUrl: string
}): string {
	const scopeList = input.missingScopes.join(', ')
	const apiBit = input.apiMessage ? ' Graph said: ' + input.apiMessage : ''
	const codeBit = input.code != null ? ' (' + String(input.code) + ')' : ''
	const authBit =
		input.authMode === 'system-user'
			? 'using system user secret for "' +
				input.integrationName +
				'". Assign the Page / Instagram / WhatsApp asset to that system user, or save a token that already includes ' +
				scopeList +
				' at ' +
				input.secretUrl +
				'.'
			: 'using OAuth integration "' +
				input.integrationName +
				'". This ' +
				input.product +
				' call needs permission' +
				(input.missingScopes.length === 1 ? ' ' : 's ') +
				scopeList +
				'.'
	return (
		'Meta Graph ' +
		input.status +
		codeBit +
		' on ' +
		input.method +
		' ' +
		input.path +
		' ' +
		authBit +
		apiBit +
		' ' +
		input.setupNote +
		' Next step: reconnect OAuth at ' +
		input.reconnect +
		' or save a system user token at ' +
		input.secretUrl +
		'. After Meta app permissions change, the current user token is unchanged until that reconnect finishes. Do not paste tokens into chat.'
	)
}

async function parseBody(response: Response): Promise<unknown> {
	if (response.status === 204) return null
	const text = await response.text()
	if (!text) return null
	try {
		return JSON.parse(text) as unknown
	} catch {
		return text
	}
}

function systemUserPlaceholder(secretName: string): string {
	return 'Bearer {{secret:' + secretName + '}}'
}

async function authorizedFetch(params: {
	authMode: MetaAuthMode
	integrationName: string
	tokenSecret: string
	pageAccessToken?: string
	path: string
	method: GraphMethod
}): Promise<typeof fetch> {
	if (params.pageAccessToken) {
		const token = params.pageAccessToken
		return async (input, init) => {
			const headers = new Headers(init?.headers)
			headers.set('authorization', 'Bearer ' + token)
			return fetch(input, { ...init, headers })
		}
	}
	if (params.authMode === 'system-user') {
		const headerValue = systemUserPlaceholder(params.tokenSecret)
		return async (input, init) => {
			const headers = new Headers(init?.headers)
			headers.set('authorization', headerValue)
			return fetch(input, { ...init, headers })
		}
	}

	try {
		return await createAuthenticatedFetch(params.integrationName)
	} catch (error) {
		const cause = error instanceof Error ? error.message : String(error)
		const reconnect = reconnectUrl(params.integrationName, inferRequiredScopes(params.method, params.path).scopes)
		const secretUrl = systemUserSecretUrl(params.tokenSecret)
		throw new MetaGraphError({
			message:
				'Could not authenticate Meta integration "' +
				params.integrationName +
				'". This package uses Facebook Login OAuth via /connect/oauth or a Business system user token — not an API key or bot token. ' +
				cause +
				' Next step: connect OAuth at ' +
				reconnect +
				' or save a system user token at ' +
				secretUrl +
				'. Redirect URI is https://kody.codes/connect/oauth. Never paste tokens into chat.',
			status: 401,
			code: 'integration_missing',
			path: params.path,
			method: params.method,
			integrationName: params.integrationName,
			authMode: params.authMode,
			missingScopes: inferRequiredScopes(params.method, params.path).scopes,
			reconnectUrl: reconnect,
			systemUserSecretUrl: secretUrl,
			details: { cause },
		})
	}
}

/**
 * Authenticated Meta Graph request for a saved OAuth integration or system user token.
 * Throws MetaGraphError with the missing permission and BYO OAuth connect URL on 403 / permission errors.
 */
export async function graphRequest<T = unknown>(params: GraphRequestParams): Promise<T> {
	const integrationName = resolveMetaIntegration(params)
	const authMode = resolveAuthMode(params)
	const tokenSecret = resolveSystemUserSecret(params)
	const method: GraphMethod = params.method ?? (params.body === undefined ? 'GET' : 'POST')
	const path = assertGraphPath(params.path)
	const url = new URL(GRAPH_API_BASE_URL + path)
	appendQuery(url, params.query)

	const headers = new Headers(params.headers)
	if (!headers.has('accept')) headers.set('accept', 'application/json')
	let body: string | undefined
	if (params.body !== undefined && params.body !== null) {
		if (!headers.has('content-type')) headers.set('content-type', 'application/json')
		body = typeof params.body === 'string' ? params.body : JSON.stringify(params.body)
	}

	const authFetch = await authorizedFetch({
		authMode,
		integrationName,
		tokenSecret,
		pageAccessToken: params.pageAccessToken,
		path,
		method,
	})

	const response = await authFetch(url.toString(), { method, headers, body })
	const raw = await parseBody(response)
	const data = params.keepTokens ? raw : stripSecrets(raw)
	if (response.ok) return data as T

	const { code, type, message } = graphErrorFields(data)
	const inferred = inferRequiredScopes(method, path)
	const mentioned = scopesMentionedInMessage(message)
	const missingScopes = mentioned.length > 0 ? mentioned : inferred.scopes
	const reconnect = reconnectUrl(integrationName, missingScopes)
	const secretUrl = systemUserSecretUrl(tokenSecret)
	const insufficient = isInsufficientPermission(response.status, code, type, message)

	if (insufficient) {
		throw new MetaGraphError({
			message: buildInsufficientScopeMessage({
				status: response.status,
				method,
				path,
				integrationName,
				authMode,
				code,
				apiMessage: message,
				missingScopes,
				product: inferred.product,
				setupNote: inferred.setupNote,
				reconnect,
				secretUrl,
			}),
			status: response.status,
			code,
			path,
			method,
			integrationName,
			authMode,
			missingScopes,
			reconnectUrl: reconnect,
			systemUserSecretUrl: secretUrl,
			details: data,
		})
	}

	throw new MetaGraphError({
		message:
			'Meta Graph ' +
			response.status +
			(code != null ? ' (' + String(code) + ')' : '') +
			' on ' +
			method +
			' ' +
			path +
			' using ' +
			(authMode === 'system-user' ? 'system user token' : 'OAuth integration "' + integrationName + '"') +
			'.' +
			(message ? ' ' + message : '') +
			(response.status === 401
				? ' Reconnect OAuth at ' +
					reconnectUrl(integrationName) +
					' or save a system user token at ' +
					secretUrl +
					'.'
				: ''),
		status: response.status,
		code,
		path,
		method,
		integrationName,
		authMode,
		missingScopes: [],
		reconnectUrl: reconnectUrl(integrationName),
		systemUserSecretUrl: secretUrl,
		details: data,
	})
}

/**
 * Authenticated Meta Graph request.
 * @example
 * import graphRequest from 'kody:@kody/meta/core'
 * const me = await graphRequest({ path: '/me', query: { fields: 'id,name' } })
 */
export default graphRequest

export function graphItems<T>(data: unknown): { items: T[]; next: string | null } {
	if (!data || typeof data !== 'object') return { items: [], next: null }
	const record = data as JsonObject
	const value = record.data
	const paging = record.paging && typeof record.paging === 'object' ? (record.paging as JsonObject) : null
	const next = paging && typeof paging.next === 'string' ? String(paging.next) : null
	return {
		items: Array.isArray(value) ? (value as T[]) : [],
		next,
	}
}

export function requireConfirmOrDryRun(input: {
	dryRun?: boolean
	confirm?: boolean
	action: string
}): { dryRun: true } | { dryRun: false } {
	if (input.dryRun === true) return { dryRun: true }
	if (input.confirm === true) return { dryRun: false }
	throw new Error(
		input.action +
			' mutates Meta (Page / Instagram / WhatsApp) data. Pass dryRun: true to preview, or confirm: true after the user explicitly approved the exact change. Do not send live messages without that confirmation.',
	)
}

/** Fetch a Page access token for publishing without returning the token to callers. */
export async function withPageAccessToken<T>(
	params: MetaAccountParams & { pageId: string },
	run: (pageAccessToken: string | undefined) => Promise<T>,
): Promise<T> {
	if (resolveAuthMode(params) === 'system-user') {
		return run(undefined)
	}
	try {
		const page = await graphRequest<JsonObject>({
			...params,
			path: '/' + encodeURIComponent(params.pageId),
			query: { fields: 'id,access_token' },
			keepTokens: true,
		})
		const token = typeof page.access_token === 'string' ? page.access_token : undefined
		return run(token)
	} catch {
		return run(undefined)
	}
}

export function authSetupHint(params: MetaAccountParams = {}) {
	const integrationName = resolveMetaIntegration(params)
	const tokenSecret = resolveSystemUserSecret(params)
	return setupUrls(integrationName, tokenSecret)
}