← back to Marketing Command Center

scripts/meta-token-health.js

113 lines

#!/usr/bin/env node
/*
 * meta-token-health.js — canary for the MCC META_ACCESS_TOKEN that powers the
 * Vendor IG panel's Business-Discovery post fetch. Born 2026-06-16 (Officer
 * Council #4): the panel went dark on 2026-06-15 when the token silently
 * expired with zero alert. This GETs /me with the token; on auth failure
 * (code 190 / expired) it alerts (CNCP card + George email). Best-effort reads
 * token expiry for an early <7d warning. Writes a heartbeat stamp on every run
 * (Council #2 lesson) so the meta-watchdog can confirm it actually fired.
 * READ-ONLY: never writes Shopify/DB, never rotates the token (Steve-gated).
 */
const fs = require('fs');
const path = require('path');
const GRAPH = 'https://graph.facebook.com/v23.0';
const ROOT = path.join(__dirname, '..');
const env = (f, k) => { try { return (fs.readFileSync(f, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')) || [])[1]; } catch { return null; } };
const TOKEN = env(path.join(ROOT, '.env'), 'META_ACCESS_TOKEN');
const GEORGE_AUTH = env('/Users/macstudio3/Projects/secrets-manager/.env', 'GEORGE_AUTH');
const STAMP = path.join(ROOT, 'data', 'meta-token-health-latest.json');

async function alert(subject, body) {
  // CNCP parking-lot (local, reliable)
  try {
    await fetch('http://127.0.0.1:3333/api/parking-lot', { method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url: 'http://127.0.0.1:9661/#vendors', note: `⚠️ META token: ${subject} — ${body}` }) });
  } catch {}
  // George email (best-effort). George flipped to HTTPS-only 2026-06-17 — the
  // raw http://<ip> now 400s; use the Tailscale MagicDNS host + raw GEORGE_AUTH
  // + body field, matching the working dw-canary-watchdog sender.
  if (GEORGE_AUTH) {
    try {
      const G = process.env.GEORGE_URL || 'https://kamatera.tail79cb8e.ts.net:9850';
      await fetch(`${G}/api/send`, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8', 'Authorization': GEORGE_AUTH },
        body: JSON.stringify({ to: 'steve@designerwallcoverings.com', subject: 'MCC META token — ' + subject, body }) });
    } catch {}
  }
}

// A genuine, act-on-it expiry (token is really dead — pasting a new one is the fix).
// Meta expiry surfaces as subcode 463/467/460 or an unambiguous message; a bare
// code 190 with a "cannot parse"/"malformed" message is a TRANSIENT Graph blip,
// NOT an expiry (the 2026-08-20 false alarm: same static token failed at 08:05,
// validated at 09:19, .env untouched). Don't prescribe a token-paste for those.
const isTrueExpiry = (err) => {
  const sub = err.error_subcode;
  if (sub === 463 || sub === 467 || sub === 460) return true;
  return /expired|session has expired|been invalidated|revoked|changed your password|re-?authenticate/i.test(err.message || '');
};
// Retry-worthy: transient blips (parse/malformed/rate/5xx, or code 190 that is NOT a true expiry).
const isTransient = (err, httpStatus) => {
  const m = err.message || '';
  if (/cannot parse|malformed|temporarily|try again|unexpected error|please reduce|rate limit|an error occurred/i.test(m)) return true;
  if ([1, 2, 4, 17, 341, 368].includes(err.code)) return true;   // Meta transient/rate classes
  if (httpStatus >= 500) return true;
  if (err.code === 190 && !isTrueExpiry(err)) return true;        // bare 190 w/o expiry signal → treat as blip
  return false;
};
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

(async () => {
  const stamp = (status, detail) => { try { fs.writeFileSync(STAMP, JSON.stringify({ ok: status === 'ok', status, detail, checkedAt: new Date().toISOString() }, null, 2)); } catch {} };
  if (!TOKEN) { console.log('DEAD: no META_ACCESS_TOKEN in .env'); stamp('missing', 'no token in .env'); await alert('NOT SET', 'No META_ACCESS_TOKEN in MCC .env — Vendor IG panel cannot fetch posts.'); process.exit(0); }

  // Check /me with up to 3 attempts. Break early on success or a TRUE expiry
  // (retrying a genuinely dead token is pointless); keep retrying transient blips
  // so a single Graph hiccup no longer pages Steve with a wrong "paste a token".
  let j = null, httpStatus = 0, lastErr = null;
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const r = await fetch(`${GRAPH}/me?fields=id,name&access_token=${encodeURIComponent(TOKEN)}`);
      httpStatus = r.status; j = await r.json();
    } catch (e) { lastErr = { message: e.message, network: true }; j = null; }

    if (j && !j.error) { lastErr = null; break; }              // success
    lastErr = j?.error || lastErr || { message: 'unknown error' };
    if (j?.error && isTrueExpiry(j.error)) break;              // truly dead — stop retrying
    if (attempt < 3) { console.log(`retry ${attempt}: ${lastErr.message}`); await sleep(1500); }
  }

  if (lastErr) {
    const m = lastErr.message || '';
    if (!lastErr.network && lastErr.code !== undefined && isTrueExpiry(lastErr)) {
      // Genuine expiry — the token really is dead; pasting a fresh one is correct.
      console.log('DEAD (expired):', m); stamp('expired', m);
      await alert('EXPIRED / INVALID',
        `${m}\n\nThe Vendor IG panel (last-10 posts) is dark until you paste a fresh long-lived META_ACCESS_TOKEN (scopes: instagram_basic, pages_read_engagement, business_management) → route via the secrets skill into MCC .env, then click Refresh on the panel.`);
    } else {
      // Persisted across retries but NOT an expiry signal → transient Graph error
      // or a consumer problem. Do NOT tell Steve to paste a token (the secret is
      // likely fine). Point at the real check instead.
      console.log('TRANSIENT/UNCLEAR:', m); stamp('transient', m);
      await alert('TRANSIENT ERROR (token likely OK)',
        `Graph returned a non-expiry error 3x: "${m}"\n\nThis is NOT a token expiry — do NOT paste a new token yet. Likely a Meta Graph blip or the MCC consumer (:9661) not running. Verify: re-run scripts/meta-token-health.js, and if it says LIVE the token is fine; if the panel is still dark, restart the MCC server rather than rotating the token.`);
    }
    process.exit(0);
  }

  // token live — best-effort expiry read (debug_token self-inspect; may be unsupported without app token)
  let expiryNote = 'expiry unknown (no app token)';
  try {
    const dr = await fetch(`${GRAPH}/debug_token?input_token=${encodeURIComponent(TOKEN)}&access_token=${encodeURIComponent(TOKEN)}`);
    const dj = await dr.json();
    const exp = dj?.data?.expires_at;
    if (exp && exp > 0) {
      const days = Math.round((exp * 1000 - Date.now()) / 86400000);
      expiryNote = `expires in ~${days}d`;
      if (days <= 7) { stamp('expiring', expiryNote); await alert('EXPIRING SOON', `META_ACCESS_TOKEN ${expiryNote} — refresh it before the Vendor IG panel goes dark.`); console.log('LIVE but', expiryNote); return; }
    }
  } catch {}
  console.log(`LIVE: @${j.name || j.id} OK (${expiryNote})`);
  stamp('ok', `live as ${j.name || j.id}; ${expiryNote}`);
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });