/** * CDP Edge — Meta Graph API: versão única e gestão do access token. * * Token: o secret `META_ACCESS_TOKEN` é o padrão. Um token gravado no KV * (via POST /admin/meta-token) tem prioridade, permitindo trocar/renovar sem * `wrangler secret put` nem redeploy. Com META_APP_ID + META_APP_SECRET o cron * semanal renova sozinho tokens de usuário de longa duração antes de expirarem. */ import { Env } from '../types.js'; export const DEFAULT_GRAPH_VERSION = 'v26.0'; const KV_KEY = 'meta:access_token'; const CACHE_TTL_MS = 60_000; const REFRESH_WINDOW_S = 10 * 24 * 3600; // renova quando faltam < 10 dias interface StoredToken { token: string; updated_at: string; source: 'admin' | 'auto-refresh'; } let cached: { token: string | undefined; at: number } | null = null; export function graphBase(env: Env): string { const v = env.META_GRAPH_VERSION; return `https://graph.facebook.com/${v && /^v\d+\.\d+$/.test(v) ? v : DEFAULT_GRAPH_VERSION}`; } async function readStored(env: Env): Promise { if (!env.GEO_CACHE) return null; try { const raw = await env.GEO_CACHE.get(KV_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as StoredToken; return parsed?.token ? parsed : null; } catch { return null; } } /** Token em uso: KV (se houver) → secret META_ACCESS_TOKEN. */ export async function getMetaToken(env: Env): Promise { if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.token; const stored = await readStored(env); const token = stored?.token || env.META_ACCESS_TOKEN || undefined; cached = { token, at: Date.now() }; return token; } export async function setMetaToken(env: Env, token: string, source: StoredToken['source'] = 'admin'): Promise { if (!env.GEO_CACHE) throw new Error('KV (GEO_CACHE) não configurado'); const record: StoredToken = { token, updated_at: new Date().toISOString(), source }; await env.GEO_CACHE.put(KV_KEY, JSON.stringify(record)); cached = null; } export async function clearMetaToken(env: Env): Promise { if (env.GEO_CACHE) await env.GEO_CACHE.delete(KV_KEY); cached = null; } /** Testa se o token acessa o Pixel/dataset (o mesmo recurso que a CAPI usa). */ export async function validateMetaToken(env: Env, token: string): Promise<{ ok: boolean; detail: string }> { if (!env.META_PIXEL_ID) return { ok: false, detail: 'META_PIXEL_ID não configurado' }; try { const res = await fetch(`${graphBase(env)}/${env.META_PIXEL_ID}?fields=id,name`, { headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json() as any; if (!res.ok) return { ok: false, detail: data?.error?.message || `HTTP ${res.status}` }; return { ok: true, detail: `acesso ao pixel ${data.id}${data.name ? ` (${data.name})` : ''}` }; } catch (err: any) { return { ok: false, detail: err?.message || String(err) }; } } interface TokenInfo { is_valid?: boolean; type?: string; expires_at?: number; // 0 = não expira data_access_expires_at?: number; scopes?: string[]; } async function debugToken(env: Env, token: string): Promise { const appToken = env.META_APP_ID && env.META_APP_SECRET ? `${env.META_APP_ID}|${env.META_APP_SECRET}` : token; try { const res = await fetch(`${graphBase(env)}/debug_token?input_token=${encodeURIComponent(token)}`, { headers: { Authorization: `Bearer ${appToken}` }, }); if (!res.ok) return null; return ((await res.json()) as any)?.data || null; } catch { return null; } } /** Estado do token sem nunca devolver o valor completo. */ export async function getMetaTokenStatus(env: Env): Promise> { const stored = await readStored(env); const token = stored?.token || env.META_ACCESS_TOKEN; if (!token) return { configured: false }; const info = await debugToken(env, token); const exp = info?.expires_at; return { configured: true, source: stored ? `kv (${stored.source})` : 'secret', token_hint: `…${token.slice(-4)}`, updated_at: stored?.updated_at, valid: info?.is_valid, type: info?.type, expires_at: exp === undefined ? undefined : exp === 0 ? 'nunca' : new Date(exp * 1000).toISOString(), data_access_expires_at: info?.data_access_expires_at ? new Date(info.data_access_expires_at * 1000).toISOString() : undefined, auto_refresh: Boolean(env.META_APP_ID && env.META_APP_SECRET), }; } /** * Renova o token (fb_exchange_token) se expirar em < 10 dias. * Tokens de System User sem expiração (expires_at = 0) são ignorados. * Requer META_APP_ID e META_APP_SECRET. */ export async function refreshMetaTokenIfNeeded(env: Env, force = false): Promise<{ refreshed: boolean; reason: string }> { if (!env.META_APP_ID || !env.META_APP_SECRET) return { refreshed: false, reason: 'META_APP_ID/META_APP_SECRET não configurados' }; if (!env.GEO_CACHE) return { refreshed: false, reason: 'KV (GEO_CACHE) não configurado' }; const token = await getMetaToken(env); if (!token) return { refreshed: false, reason: 'sem token' }; const info = await debugToken(env, token); if (!info) return { refreshed: false, reason: 'debug_token indisponível' }; if (info.is_valid === false) return { refreshed: false, reason: 'token já inválido — gere um novo e envie em POST /admin/meta-token' }; if (info.expires_at === 0 && !force) return { refreshed: false, reason: 'token não expira' }; const nowS = Math.floor(Date.now() / 1000); if (!force && info.expires_at && info.expires_at - nowS > REFRESH_WINDOW_S) { return { refreshed: false, reason: 'ainda longe de expirar' }; } try { const qs = new URLSearchParams({ grant_type: 'fb_exchange_token', client_id: env.META_APP_ID, client_secret: env.META_APP_SECRET, fb_exchange_token: token, }); const res = await fetch(`${graphBase(env)}/oauth/access_token?${qs}`); const data = await res.json() as any; if (!res.ok || !data?.access_token) { return { refreshed: false, reason: data?.error?.message || `HTTP ${res.status}` }; } await setMetaToken(env, data.access_token, 'auto-refresh'); return { refreshed: true, reason: 'token renovado' }; } catch (err: any) { return { refreshed: false, reason: err?.message || String(err) }; } }