← back to Norma

agents/instagram-agent/build-registry.js

387 lines

#!/usr/bin/env node
/**
 * build-registry.js — regenerate accounts.json from the live Meta token.
 *
 * Walks GET /me/accounts (all Facebook Pages the token administers), keeps the
 * ones with a linked Instagram business account, and writes a registry mapping
 * handle → { ig_user_id, page_id, page_name }. Every entry is postable with the
 * shared never-expiring META_ACCESS_TOKEN + graph.facebook.com (Facebook-Login
 * flow), which is what Norma's _ig-api already uses when IG_GRAPH_HOST is set.
 *
 * Re-run this after linking a new IG account in Meta Business Suite and the new
 * handle appears. It is NOT self-healing on its own -- no LaunchAgent runs it;
 * a human has to.
 *
 * BLIND SPOT THIS FILE COMPENSATES FOR (TK-11383):
 *   GET /me/accounts is filtered by the `pages_show_list` GRANULAR scope, which
 *   on this token is a FIXED allowlist of 80 Page IDs. A Page created after that
 *   grant is INVISIBLE here even when it is fully IG-linked -- so this script
 *   used to DROP such an account with no output at all, and the runner would see
 *   a plausible total and never learn anything was missing. Silent omission is
 *   the false-green class (CLAUDE.md TK-11431 rule 1: an unmeasured input is
 *   never PASS). So every run now ALSO reads the uncapped `business_management`
 *   owned_pages/client_pages edge and reports, loudly and in the written
 *   registry, any IG-linked owned Page that /me/accounts could not see.
 *
 *   Those accounts are REPORTED, not enrolled. accounts.json drives
 *   com.steve.dw-ig-cadence, which posts PUBLICLY without --dry, so adding an
 *   account is a gated decision -- pass --include-unlisted to actually enroll
 *   them once Steve has approved it.
 *
 * Usage:  node build-registry.js                    # writes accounts.json
 *         node build-registry.js --print            # print, don't write
 *         node build-registry.js --include-unlisted # GATED: also enroll the
 *                                                   # allowlist-invisible ones
 *
 * Token resolution (first hit wins):
 *   process.env.META_ACCESS_TOKEN  →  ~/Projects/secrets-manager/.env
 */

const fs = require('fs');
const path = require('path');
const os = require('os');

const GRAPH = process.env.IG_GRAPH_HOST || 'https://graph.facebook.com';
const VERSION = process.env.IG_GRAPH_VERSION || 'v21.0';

function readSecretsToken() {
  if (process.env.META_ACCESS_TOKEN) return process.env.META_ACCESS_TOKEN;
  try {
    const envPath = path.join(os.homedir(), 'Projects/secrets-manager/.env');
    const line = fs.readFileSync(envPath, 'utf8')
      .split('\n').find((l) => l.startsWith('META_ACCESS_TOKEN='));
    if (line) return line.slice('META_ACCESS_TOKEN='.length).trim();
  } catch { /* ignore */ }
  return '';
}

/**
 * Find IG-linked Pages that GET /me/accounts could NOT see.
 *
 * `business_management` is granted target=ALL on this token (no allowlist), so
 * {business}/owned_pages sees every owned Page and its IG link. Returns:
 *   { measured: true,  pages: [...] }  -> the edge was read; the list is complete
 *   { measured: false, error: '...' }  -> the edge could NOT be read
 * The caller must treat measured:false as NOT-MEASURED, never as "none found".
 */
async function findUnlistedLinkedPages(token, seenPageIds) {
  const q = encodeURIComponent(token);
  const getAll = async (url) => {
    const out = [];
    while (url) {
      const r = await fetch(url);
      const j = await r.json();
      if (j.error) throw new Error(j.error.message);
      out.push(...(j.data || []));
      url = (j.paging && j.paging.next) || null;
    }
    return out;
  };

  let businesses;
  try {
    businesses = await getAll(`${GRAPH}/${VERSION}/me/businesses?fields=id,name&limit=100&access_token=${q}`);
  } catch (e) {
    return { measured: false, error: `me/businesses: ${e.message}` };
  }

  const found = [];
  const seenIg = new Set();
  let edgesRead = 0;
  let edgesFailed = 0;
  for (const b of businesses) {
    for (const edge of ['owned_pages', 'client_pages']) {
      let pages;
      try {
        pages = await getAll(
          `${GRAPH}/${VERSION}/${b.id}/${edge}`
          + `?fields=id,name,instagram_business_account{id,username}&limit=200&access_token=${q}`,
        );
        edgesRead += 1;
      } catch {
        // An edge we cannot read is UNKNOWN, not empty. Count it so the caller
        // can downgrade the result instead of reporting a confident zero.
        edgesFailed += 1;
        continue;
      }
      for (const p of pages) {
        const ig = p.instagram_business_account;
        if (!ig || !ig.id) continue;          // not IG-linked -> not enrollable
        if (seenPageIds.has(p.id)) continue;  // already visible via me/accounts
        if (seenIg.has(ig.id)) continue;      // same Page reachable twice
        seenIg.add(ig.id);
        found.push({
          username: ig.username || null,
          ig_user_id: ig.id,
          page_id: p.id,
          page_name: p.name,
          business: b.name,
        });
      }
    }
  }
  if (edgesRead === 0) return { measured: false, error: 'no business page edge was readable' };
  return { measured: true, partial: edgesFailed > 0, edges_failed: edgesFailed, pages: found };
}

async function main() {
  const token = readSecretsToken();
  if (!token) { console.error('No META_ACCESS_TOKEN found (env or secrets-manager/.env).'); process.exit(1); }

  let url = `${GRAPH}/${VERSION}/me/accounts`
    + `?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(token)}`;
  const linked = [];
  const unlinked = [];
  const seenPageIds = new Set();
  let pages = 0;

  while (url) {
    const r = await fetch(url);
    const j = await r.json();
    if (j.error) throw new Error(`me/accounts failed: ${j.error.message}`);
    for (const p of j.data || []) {
      pages += 1;
      seenPageIds.add(p.id);
      const ig = p.instagram_business_account;
      if (ig && ig.id) {
        linked.push({ username: ig.username || null, ig_user_id: ig.id, page_id: p.id, page_name: p.name });
      } else {
        unlinked.push(p.name);
      }
    }
    url = (j.paging && j.paging.next) || null;
  }

  // Optional: enrich each account with live follower/post counts (--stats).
  // Costs one Graph GET per account, so it's opt-in, not on every rebuild.
  const withStats = process.argv.includes('--stats');
  const statsFor = async (igId) => {
    if (!withStats) return {};
    try {
      const r = await fetch(`${GRAPH}/${VERSION}/${igId}?fields=followers_count,media_count&access_token=${encodeURIComponent(token)}`);
      const j = await r.json();
      return j.error ? {} : { followers_count: j.followers_count, media_count: j.media_count };
    } catch { return {}; }
  };

  // registry: keyed by handle (lowercased, no @) → account record
  const accounts = {};
  for (const a of linked.sort((x, y) => (x.username || '').localeCompare(y.username || ''))) {
    const key = (a.username || a.page_id).toLowerCase();
    accounts[key] = {
      handle: a.username,
      ig_user_id: a.ig_user_id,
      page_id: a.page_id,
      page_name: a.page_name,
      graph_host: GRAPH,
      graph_version: VERSION,
      ...(await statsFor(a.ig_user_id)),
      // token omitted → resolver uses the shared META_ACCESS_TOKEN
    };
  }

  // ---- Honour the explicit enrollment HOLD list ----------------------------
  // accounts.json drives com.steve.dw-ig-cadence, which posts PUBLICLY. A handle
  // can be fully IG-linked and still be deliberately NOT enrolled (designerschat:
  // link done, enrollment reverted by Steve in e0dcc47). Nothing enforced that,
  // so a plain rebuild silently re-enrolled it and the next cadence run would
  // have posted. The decision now lives in enrollment-hold.json.
  const includeHeld = process.argv.includes('--include-held');
  let holdMap = {};
  let holdReadError = null;
  try {
    holdMap = JSON.parse(fs.readFileSync(path.join(__dirname, 'enrollment-hold.json'), 'utf8')).hold || {};
  } catch (e) {
    // Fail CLOSED on a malformed file, fail OPEN only when it genuinely is absent.
    if (e.code !== 'ENOENT') {
      console.error(`FAILED: enrollment-hold.json is unreadable (${e.message}).`);
      console.error('  Refusing to build: a lost hold list silently enrolls held accounts into a PUBLIC cadence.');
      process.exit(1);
    }
    holdReadError = 'enrollment-hold.json not present';
    // Missing (vs corrupt) is fail-OPEN by necessity — an old checkout predating
    // the file must still build — but it is NOT silent: warn loudly, because
    // `rm enrollment-hold.json` would otherwise disable this guard quietly. The
    // hard backstop for the one account that matters (designerschat) is a `skip`
    // in account-themes.json, which daily-cadence.js (the actual publisher)
    // honours regardless of this file (Kimi review, TK-11383).
    console.error('WARNING: enrollment-hold.json is absent — the builder-side hold guard is OFF.');
    console.error('  The publisher-side skip in account-themes.json still applies; restore this file to re-arm the builder guard.');
  }
  // Hold keys are normalized to lowercase because `accounts` is keyed by
  // (username || page_id).toLowerCase(). Matching a denylist case-SENSITIVELY
  // against a lowercased map is fail-OPEN: one capital letter in
  // enrollment-hold.json ("BeverlyAndHillsDesigns") would match nothing, delete
  // nothing, and print nothing -- the guard would be silently off. A denylist
  // must never be disarmed by a typo it does not report.
  {
    const norm = {};
    const odd = [];
    for (const k of Object.keys(holdMap)) {
      const lk = String(k).toLowerCase();
      if (lk !== k) odd.push(k);
      norm[lk] = holdMap[k];
    }
    if (odd.length) console.error(`NOTICE: enrollment-hold.json key(s) not lowercase, normalized for matching: ${odd.join(', ')}`);
    holdMap = norm;
  }

  // Applied TWICE -- once here over the /me/accounts set, and again after the
  // unlisted (business-edge) merge below. Applying it only here was a real hole:
  // the hold filtered the me/accounts-derived set, then --include-unlisted added
  // accounts straight into the same object WITHOUT consulting holdMap, so a
  // handle that was both held AND outside the pages_show_list allowlist was
  // enrolled by the very flag an operator would reach for. Verified empirically
  // before the fix: with beverlyandhillsdesigns + filthyrichlivingcom on the hold
  // list, `--print --include-unlisted` still emitted both into the registry.
  const held = [];
  const heldSeen = new Set();
  // Hold keys are hand-typed JSON; `accounts` keys are lowercased. Normalise so a
  // capitalised or padded entry cannot silently no-op.
  const holdByHandle = {};
  for (const [k, v] of Object.entries(holdMap)) holdByHandle[String(k).trim().toLowerCase()] = v;
  // ig_user_id is the IMMUTABLE identifier and the one the publisher actually posts
  // to (post-to.js: POST /{ig-user-id}/media_publish). A handle-only hold silently
  // stops matching if the owner renames the account -- and, separately, the unlisted
  // merge keys an account by page_id whenever the Graph edge returns username:null
  // (`username: ig.username || null`), which a handle lookup can never match. Match
  // all three ways so neither drift nor a null username can slip a held account through.
  const holdByIgId = {};
  for (const [k, v] of Object.entries(holdByHandle)) if (v && v.ig_user_id) holdByIgId[String(v.ig_user_id)] = k;
  const applyHold = () => {
    for (const [key, acct] of Object.entries(accounts)) {
      const byKey = holdByHandle[key];
      const byHandle = acct && acct.handle ? holdByHandle[String(acct.handle).trim().toLowerCase()] : null;
      const igKey = acct && acct.ig_user_id ? holdByIgId[String(acct.ig_user_id)] : null;
      const entry = byKey || byHandle || (igKey ? holdByHandle[igKey] : null);
      if (!entry) continue;
      const name = igKey || (byKey ? key : String(acct.handle).trim().toLowerCase());
      if (!heldSeen.has(name)) {
        heldSeen.add(name);
        held.push({ handle: name, reason: entry.reason || null, ticket: entry.ticket || null,
                    matched_by: byKey ? 'registry key' : (byHandle ? 'handle field' : 'ig_user_id') });
      }
      if (!includeHeld) delete accounts[key];
    }
  };
  applyHold();

  // ---- Cross-check the uncapped business edge for accounts /me/accounts hid ----
  const includeUnlisted = process.argv.includes('--include-unlisted');
  const unlistedResult = await findUnlistedLinkedPages(token, seenPageIds);
  const unlisted = unlistedResult.measured ? unlistedResult.pages : [];

  if (includeUnlisted && unlistedResult.measured) {
    for (const a of unlisted) {
      const key = (a.username || a.page_id).toLowerCase();
      if (accounts[key]) continue;
      accounts[key] = {
        handle: a.username,
        ig_user_id: a.ig_user_id,
        page_id: a.page_id,
        page_name: a.page_name,
        graph_host: GRAPH,
        graph_version: VERSION,
        enrolled_via: 'business_management edge (--include-unlisted)',
        ...(await statsFor(a.ig_user_id)),
      };
    }
  }

  // Re-apply after the unlisted merge so --include-unlisted cannot bypass the hold.
  applyHold();

  // Enrollment is REPORTED from actual membership in `accounts`, never asserted
  // from the flag that was passed. Before this, `enrolled: includeUnlisted` was
  // written into the registry for every unlisted page -- so a handle that was
  // held out by applyHold() was still recorded, durably, as enrolled:true. The
  // flag says what was REQUESTED; only `accounts` says what HAPPENED.
  const isEnrolled = (k) => Object.prototype.hasOwnProperty.call(accounts, String(k).toLowerCase());
  if (held.length) {
    console.error(`${includeHeld ? 'NOTICE' : 'HOLD'}: ${held.length} IG-linked handle(s) are on the enrollment hold list:`);
    for (const h of held) console.error(`  @${h.handle}${h.ticket ? ` [${h.ticket}]` : ''} — ${h.reason || 'no reason recorded'}`);
    console.error(includeHeld
      ? '  --include-held was passed: they ARE enrolled in this registry.'
      : '  Excluded from this registry. Pass --include-held to enroll (starts a PUBLIC cadence — Steve\'s call).');
  }

  // A hold that matches nothing is NOT necessarily a bug -- an account outside the
  // pages_show_list allowlist legitimately matches nothing on a plain build -- but it
  // must never be invisible, because a typo'd or renamed entry looks exactly the same
  // and offers zero protection. Report it as ARMED rather than failing.
  const standby = Object.keys(holdByHandle).filter((h) => !heldSeen.has(h));
  if (standby.length) {
    console.error(`HOLD ARMED (matched nothing in this build, still enforced if they appear): ${standby.map((h) => '@' + h).join(', ')}`);
  }

  const registry = {
    generated_at: new Date().toISOString(),
    token_source: 'META_ACCESS_TOKEN (shared, never-expiring)',
    pages_total: pages,
    accounts_postable: Object.keys(accounts).length,
    pages_without_ig: unlinked.sort(),
    // Recorded in the file itself so the omission cannot be lost with the
    // terminal scrollback. null => the business edge could NOT be read, which
    // is NOT-MEASURED and must never be read as "none".
    unlisted_ig_linked: unlistedResult.measured
      ? unlisted.map((a) => ({ handle: a.username, ig_user_id: a.ig_user_id, page_id: a.page_id, page_name: a.page_name, enrolled: isEnrolled(a.username || a.page_id) }))
      : null,
    unlisted_scan_error: unlistedResult.measured ? null : unlistedResult.error,
    held_out: held.map((h) => ({ ...h, enrolled: isEnrolled(h.handle) })),
    hold_list_status: holdReadError || 'enrollment-hold.json read ok',
    accounts,
  };

  // Loud, unmissable: a silent drop is the bug this block exists to kill.
  if (!unlistedResult.measured) {
    console.error(`WARNING: could not read the business page edge (${unlistedResult.error}).`);
    console.error('  NOT-MEASURED: there may be IG-linked Pages missing from this registry. Do not read this as "none".');
  } else if (unlisted.length) {
    console.error(`WARNING: ${unlisted.length} IG-linked Page(s) are invisible to GET /me/accounts (outside the pages_show_list allowlist):`);
    for (const a of unlisted) console.error(`  @${a.username || a.ig_user_id}  (page ${a.page_id} "${a.page_name}", business ${a.business})`);
    if (includeUnlisted) {
      const merged = unlisted.filter((a) => isEnrolled(a.username || a.page_id)).length;
      const heldBack = unlisted.length - merged;
      console.error(`  --include-unlisted was passed: ${merged} of ${unlisted.length} ARE enrolled in this registry.`);
      if (heldBack) console.error(`  The other ${heldBack} were kept OUT by the enrollment hold above (--include-unlisted does not override it).`);
    } else {
      console.error('  These are REPORTED ONLY and are NOT in this registry. Enrolling them starts a PUBLIC daily cadence,');
      console.error('  so it needs Steve\'s approval; re-run with --include-unlisted once approved.');
    }
    if (unlistedResult.partial) console.error(`  (note: ${unlistedResult.edges_failed} business edge(s) were unreadable; this list may be incomplete)`);
  }

  if (process.argv.includes('--print')) {
    console.log(JSON.stringify(registry, null, 2));
    return;
  }
  // LAST LINE OF DEFENCE, at the only boundary that matters. The original bug and the
  // --include-unlisted bypass were the SAME shape: a guard that ran before a later
  // mutation. Re-applying the guard after each merge only works if every future merge
  // site remembers to. This assertion does not care -- it re-checks the fully assembled
  // object immediately before it is serialised, so any future code path that adds a held
  // account crashes here instead of quietly publishing it.
  if (!includeHeld) {
    const leaked = Object.entries(accounts)
      .filter(([key, acct]) => holdByHandle[key]
        || (acct && acct.handle && holdByHandle[String(acct.handle).trim().toLowerCase()])
        || (acct && acct.ig_user_id && holdByIgId[String(acct.ig_user_id)]))
      .map(([key]) => key);
    if (leaked.length) {
      console.error(`FAILED: ${leaked.length} held account(s) survived to the write boundary: ${leaked.join(', ')}`);
      console.error('  Refusing to write accounts.json — this file drives a PUBLIC posting cadence.');
      process.exit(1);
    }
  }

  const out = path.join(__dirname, 'accounts.json');
  fs.writeFileSync(out, JSON.stringify(registry, null, 2));
  console.log(`Wrote ${out}`);
  console.log(`  postable IG accounts: ${registry.accounts_postable}`);
  console.log(`  pages without linked IG: ${registry.pages_without_ig.length}`);
  console.log(`  IG-linked but outside the allowlist: ${
    registry.unlisted_ig_linked === null ? 'NOT MEASURED' : `${registry.unlisted_ig_linked.length}${includeUnlisted ? ' (enrolled)' : ' (reported, NOT enrolled)'}`}`);
}

main().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });