← 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
M agents/instagram-agent/check-link-drift.js
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 →