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 body?: unknown headers?: Record /** 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 } } /** * Authenticated Meta Graph API request using the saved Facebook/Instagram integration. * Prefer this core escape hatch when a dedicated Meta helper does not cover the path you need. * * @example * import graphRequest from 'kody:@kody/meta/core' * const me = await graphRequest({ path: '/me', query: { fields: 'id,name' } }) */ 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 { 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 { 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(params: GraphRequestParams): Promise { 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 API request using the saved Facebook/Instagram integration. * Prefer this core escape hatch when a dedicated Meta helper does not cover the path you need. * * @param input - Graph path, method, query/body, and optional integration alias. * @returns Parsed Graph JSON for the requested path. * * @example * import graphRequest from 'kody:@kody/meta/core' * const me = await graphRequest({ path: '/me', query: { fields: 'id,name' } }) */ export default async function graphRequestEntrypoint( input: Parameters[0], ) { return graphRequest(input) } export function graphItems(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( params: MetaAccountParams & { pageId: string }, run: (pageAccessToken: string | undefined) => Promise, ): Promise { if (resolveAuthMode(params) === 'system-user') { return run(undefined) } try { const page = await graphRequest({ ...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) }