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