[object Object]

← back to Norma

fix: extend dw-ig-link-drift with business_management edge for linked-but-not-granted pages (TK-11604)

414dbe27afd4cf20cfa88e9f7615dfc3b9c2f4ed · 2026-09-13 04:05:27 -0700 · steve@designerwallcoverings.com

check-link-drift.js was blind to IG accounts whose Facebook Pages are owned by
a Business Manager but not in the app's pages_show_list allowlist (~80 Page IDs
from when the grant was made). Any Page created after that grant was invisible to
/me/accounts and therefore not diffed, producing a silent NOT-MEASURED false-green.

This mirrors the approach already proven in probe-owned-pages.js (TK-11383):
  - Pass 1 (unchanged): /me/accounts — app-granted pages
  - Pass 2 (new): /me/businesses → owned_pages + client_pages via business_management
    scope, which is granted target=ALL with no allowlist

BM-discovered accounts are merged into `live` (deduped), marked
via_business_management:true, and surfaced in the linked-not-enrolled list.
The BM pass fails gracefully: if the scope is absent or an edge errors, it logs
and continues — the /me/accounts result is still reported, never silently skipped.

New report fields: biz_mgmt_checked, biz_mgmt_new_accounts, biz_mgmt_error.

Files touched

Diff

commit 414dbe27afd4cf20cfa88e9f7615dfc3b9c2f4ed
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Sun Sep 13 04:05:27 2026 -0700

    fix: extend dw-ig-link-drift with business_management edge for linked-but-not-granted pages (TK-11604)
    
    check-link-drift.js was blind to IG accounts whose Facebook Pages are owned by
    a Business Manager but not in the app's pages_show_list allowlist (~80 Page IDs
    from when the grant was made). Any Page created after that grant was invisible to
    /me/accounts and therefore not diffed, producing a silent NOT-MEASURED false-green.
    
    This mirrors the approach already proven in probe-owned-pages.js (TK-11383):
      - Pass 1 (unchanged): /me/accounts — app-granted pages
      - Pass 2 (new): /me/businesses → owned_pages + client_pages via business_management
        scope, which is granted target=ALL with no allowlist
    
    BM-discovered accounts are merged into `live` (deduped), marked
    via_business_management:true, and surfaced in the linked-not-enrolled list.
    The BM pass fails gracefully: if the scope is absent or an edge errors, it logs
    and continues — the /me/accounts result is still reported, never silently skipped.
    
    New report fields: biz_mgmt_checked, biz_mgmt_new_accounts, biz_mgmt_error.
---
 agents/instagram-agent/check-link-drift.js | 105 ++++++++++++++++++++++++-----
 1 file changed, 90 insertions(+), 15 deletions(-)

diff --git a/agents/instagram-agent/check-link-drift.js b/agents/instagram-agent/check-link-drift.js
index 42e7cbe..8a137c7 100755
--- a/agents/instagram-agent/check-link-drift.js
+++ b/agents/instagram-agent/check-link-drift.js
@@ -33,32 +33,43 @@ function token() {
   try {
     const line = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8')
       .split('\n').find((l) => l.startsWith('META_ACCESS_TOKEN='));
-    if (line) return line.slice('META_ACCESS_TOKEN='.length).trim();
+    if (line) return line.slice('META_ACCESS_TOKEN='.length).trim().replace(/^['"]|['"]$/g, '');
   } catch { /* ignore */ }
   return '';
 }
 
+// Fetch all pages of a paginated Graph API endpoint, throwing on API errors.
+async function getAll(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 ? j.paging.next : null;
+  }
+  return out;
+}
+
 (async () => {
   const t = token();
   if (!t) { console.error('UNKNOWN: no META_ACCESS_TOKEN (env or secrets-manager/.env)'); process.exit(2); }
 
-  let url = `${GRAPH}/${VERSION}/me/accounts`
-    + `?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(t)}`;
   const live = new Map();
   let pagesSeen = 0;
+
+  // --- Pass 1: /me/accounts (pages_show_list-gated, may be capped at a fixed allowlist) ---
   try {
-    while (url) {
-      const r = await fetch(url);
-      const j = await r.json();
-      if (j.error) throw new Error(j.error.message);
-      for (const p of j.data || []) {
-        pagesSeen += 1;
-        const ig = p.instagram_business_account;
-        if (ig && ig.id && ig.username) {
-          live.set(ig.username, { ig_user_id: ig.id, page_id: p.id, page_name: p.name });
-        }
+    const pages = await getAll(
+      `${GRAPH}/${VERSION}/me/accounts`
+      + `?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(t)}`,
+    );
+    for (const p of pages) {
+      pagesSeen += 1;
+      const ig = p.instagram_business_account;
+      if (ig && ig.id && ig.username) {
+        live.set(ig.username, { ig_user_id: ig.id, page_id: p.id, page_name: p.name });
       }
-      url = j.paging && j.paging.next ? j.paging.next : null;
     }
   } catch (e) {
     // Never report "in sync" off a failed read — that would be a false PASS.
@@ -66,6 +77,57 @@ function token() {
     process.exit(2);
   }
 
+  // --- Pass 2: business_management edge (TK-11604) ---
+  // /me/accounts is gated by pages_show_list, which on this token is capped at a fixed
+  // allowlist of ~80 Page IDs. Any Page created AFTER the grant is invisible — even if
+  // it is IG-linked and should be posting. The business_management scope is granted with
+  // target=ALL (no allowlist), so GET /{business}/owned_pages + client_pages sees every
+  // owned Page and its IG link. We merge any accounts NOT already in the /me/accounts
+  // result into `live` so the drift detector catches those invisible-to-pass-1 accounts.
+  //
+  // Measurement vs posting: this pass can MEASURE link state but cannot obtain a Page
+  // access token for Pages outside the allowlist — POSTING still requires the explicit
+  // pages_read_engagement grant (Steve's step). Measurement blindness: solved here.
+  //
+  // Falls gracefully: if business_management scope is absent or the BM edge fails we
+  // log it but do NOT abort — the /me/accounts pass result is still reported.
+  let bizMgmtChecked = false;
+  let bizMgmtError = null;
+  let bizMgmtNewAccounts = 0;
+  try {
+    const q = encodeURIComponent(t);
+    const businesses = await getAll(`${GRAPH}/${VERSION}/me/businesses?fields=id,name&limit=100&access_token=${q}`);
+    bizMgmtChecked = true;
+    for (const biz of businesses) {
+      for (const edge of ['owned_pages', 'client_pages']) {
+        let pages = [];
+        try {
+          pages = await getAll(
+            `${GRAPH}/${VERSION}/${encodeURIComponent(biz.id)}/${edge}`
+            + `?fields=name,instagram_business_account{id,username}&limit=200&access_token=${q}`,
+          );
+        } catch { continue; } // an edge we cannot read is skipped, not asserted empty
+        for (const p of pages) {
+          pagesSeen += 1;
+          const ig = p.instagram_business_account;
+          if (ig && ig.id && ig.username && !live.has(ig.username)) {
+            // This account is IG-linked but was INVISIBLE to /me/accounts — the blind spot.
+            live.set(ig.username, {
+              ig_user_id: ig.id, page_id: p.id, page_name: p.name,
+              via_business_management: true,
+            });
+            bizMgmtNewAccounts += 1;
+          }
+        }
+      }
+    }
+  } catch (e) {
+    // BM edge failure is NOT fatal — we still report on the /me/accounts pass.
+    // bizMgmtChecked stays false if we never got past the /me/businesses call.
+    bizMgmtError = e.message;
+  }
+  // --- end business_management edge ---
+
   let disk;
   try {
     disk = JSON.parse(fs.readFileSync(path.join(HERE, 'accounts.json'), 'utf8')).accounts || {};
@@ -90,15 +152,28 @@ function token() {
     verdict: drift ? 'DRIFT' : 'IN_SYNC',
     status: drift ? 'WARN' : 'PASS',   // fleet-health-rollup vocabulary
     activate_command: drift ? 'node build-registry.js   # GATED: enrolling widens live public posting scope' : null,
+    // business_management edge results (TK-11604)
+    biz_mgmt_checked: bizMgmtChecked,
+    biz_mgmt_new_accounts: bizMgmtNewAccounts,
+    biz_mgmt_error: bizMgmtError || null,
   };
 
   if (JSON_OUT) { console.log(JSON.stringify(report, null, 2)); }
   else {
     console.log(`IG link drift check — ${report.verdict}`);
     console.log(`  pages on token: ${pagesSeen} · live postable: ${liveHandles.length} · enrolled: ${diskHandles.length}`);
+    if (bizMgmtChecked) {
+      console.log(`  business_management edge: checked · ${bizMgmtNewAccounts} new account(s) found beyond /me/accounts`);
+    } else if (bizMgmtError) {
+      console.log(`  business_management edge: SKIPPED (${bizMgmtError}) — /me/accounts only`);
+    }
     if (newlyLinked.length) {
       console.log(`  LINKED BUT NOT ENROLLED (${newlyLinked.length}):`);
-      for (const h of newlyLinked) console.log(`    + @${h}  (Page "${live.get(h).page_name}", ig ${live.get(h).ig_user_id})`);
+      for (const h of newlyLinked) {
+        const a = live.get(h);
+        const bm = a.via_business_management ? '  [via business_management — not in /me/accounts allowlist]' : '';
+        console.log(`    + @${h}  (Page "${a.page_name}", ig ${a.ig_user_id})${bm}`);
+      }
     }
     if (lostLink.length) {
       console.log(`  ENROLLED BUT LINK LOST (${lostLink.length}):`);

← 4aca47c TK-11383: read-only probe that sees Pages outside the pages_  ·  back to Norma  ·  TK-11383: make build-registry.js stop silently changing who 123570c →