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