[object Object]

← back to Norma

TK-11383: read-only probe that sees Pages outside the pages_show_list allowlist

4aca47c789e7df9523ce4e43444e31496d47a68a · 2026-09-13 02:17:02 -0700 · Steve Abrams

check-link-drift.js and probe-unlinked.js both read GET /me/accounts, which is
filtered by the pages_show_list granular scope. On this token that scope is a
FIXED allowlist of 80 Page IDs predating the newer Pages, so any Page created
since is invisible to both tools regardless of its IG-link state. That is
NOT-MEASURED, not "no drift", and it read green for days.

business_management on the same token has target=ALL, so
GET /{business}/owned_pages?fields=instagram_business_account sees every owned
Page and its IG link. probe-owned-pages.js uses that edge and reports, per Page,
whether it is IG-linked and whether it is inside the allowlist -- so the
linked-but-not-app-granted state is now visible instead of silent.

Measurement blindness solved; posting still needs the app-access grant, since
a Page token (?fields=access_token) remains #100-blocked outside the allowlist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDXDvUmj1KEEMzWw76MpFU

Files touched

Diff

commit 4aca47c789e7df9523ce4e43444e31496d47a68a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 02:17:02 2026 -0700

    TK-11383: read-only probe that sees Pages outside the pages_show_list allowlist
    
    check-link-drift.js and probe-unlinked.js both read GET /me/accounts, which is
    filtered by the pages_show_list granular scope. On this token that scope is a
    FIXED allowlist of 80 Page IDs predating the newer Pages, so any Page created
    since is invisible to both tools regardless of its IG-link state. That is
    NOT-MEASURED, not "no drift", and it read green for days.
    
    business_management on the same token has target=ALL, so
    GET /{business}/owned_pages?fields=instagram_business_account sees every owned
    Page and its IG link. probe-owned-pages.js uses that edge and reports, per Page,
    whether it is IG-linked and whether it is inside the allowlist -- so the
    linked-but-not-app-granted state is now visible instead of silent.
    
    Measurement blindness solved; posting still needs the app-access grant, since
    a Page token (?fields=access_token) remains #100-blocked outside the allowlist.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01YDXDvUmj1KEEMzWw76MpFU
---
 agents/instagram-agent/probe-owned-pages.js | 133 ++++++++++++++++++++++++++++
 1 file changed, 133 insertions(+)

diff --git a/agents/instagram-agent/probe-owned-pages.js b/agents/instagram-agent/probe-owned-pages.js
new file mode 100755
index 0000000..5ae4741
--- /dev/null
+++ b/agents/instagram-agent/probe-owned-pages.js
@@ -0,0 +1,133 @@
+#!/usr/bin/env node
+/**
+ * probe-owned-pages.js — READ-ONLY. Enumerate EVERY Page owned by Steve's Meta
+ * Businesses and report each one's IG-link state.
+ *
+ * WHY THIS EXISTS (TK-11383):
+ *   check-link-drift.js and probe-unlinked.js both read `GET /me/accounts`,
+ *   which is filtered by the `pages_show_list` GRANULAR scope. On this token
+ *   that scope is capped at a FIXED allowlist of 80 Page IDs that predates the
+ *   newer Pages — so any Page created after the grant is INVISIBLE to both
+ *   tools, whether or not it is IG-linked. That is not "no drift", it is
+ *   NOT-MEASURED, and it read as green for days.
+ *
+ *   `business_management` on the same token is granted with target=ALL (no
+ *   allowlist). So `GET /{business}/owned_pages?fields=instagram_business_account`
+ *   sees every owned Page and its IG link, including Pages outside the 80.
+ *
+ *   Direct `GET /{page-id}` and `?fields=access_token` still fail (#100) for
+ *   Pages outside the allowlist — they need `pages_read_engagement`, which is
+ *   capped at the same 80. So this probe can MEASURE link state but cannot
+ *   obtain a Page token; POSTING still requires the app-access grant.
+ *   Measurement blindness: solved. Posting: still gated on Steve.
+ *
+ * Usage:  node probe-owned-pages.js            # human-readable
+ *         node probe-owned-pages.js --json     # machine-readable
+ *         node probe-owned-pages.js --unlinked # only Pages with no IG link
+ * Exit:   0 = probed ok · 2 = could not determine (token/API)
+ *
+ * NEVER writes anything. The token stays in memory and is never printed.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const GRAPH = process.env.IG_GRAPH_HOST || 'https://graph.facebook.com';
+const V = process.env.IG_GRAPH_VERSION || 'v21.0';
+const JSON_OUT = process.argv.includes('--json');
+const ONLY_UNLINKED = process.argv.includes('--unlinked');
+
+function token() {
+  if (process.env.META_ACCESS_TOKEN) return process.env.META_ACCESS_TOKEN;
+  try {
+    const p = path.join(os.homedir(), 'Projects/secrets-manager/.env');
+    const line = fs.readFileSync(p, 'utf8').split('\n').find((l) => l.startsWith('META_ACCESS_TOKEN='));
+    if (line) return line.slice('META_ACCESS_TOKEN='.length).trim().replace(/^['"]|['"]$/g, '');
+  } catch { /* ignore */ }
+  return '';
+}
+
+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('probe-owned-pages: no META_ACCESS_TOKEN'); process.exit(2); }
+  const q = encodeURIComponent(t);
+
+  let businesses, allowlist;
+  try {
+    businesses = await getAll(`${GRAPH}/${V}/me/businesses?fields=id,name&limit=100&access_token=${q}`);
+    // The allowlist-limited view, for the explicit visible/invisible contrast.
+    allowlist = new Set((await getAll(
+      `${GRAPH}/${V}/me/accounts?fields=id&limit=100&access_token=${q}`,
+    )).map((p) => p.id));
+  } catch (e) {
+    console.error('probe-owned-pages: could not determine —', e.message);
+    process.exit(2);
+  }
+
+  const rows = [];
+  for (const b of businesses) {
+    for (const edge of ['owned_pages', 'client_pages']) {
+      let pages = [];
+      try {
+        pages = await getAll(
+          `${GRAPH}/${V}/${b.id}/${edge}?fields=id,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) {
+        const ig = p.instagram_business_account || null;
+        rows.push({
+          business: b.name,
+          business_id: b.id,
+          edge,
+          page_id: p.id,
+          page_name: p.name,
+          ig_linked: Boolean(ig),
+          ig_id: ig ? ig.id : null,
+          ig_username: ig ? ig.username || null : null,
+          in_pages_show_list: allowlist.has(p.id),
+        });
+      }
+    }
+  }
+
+  const shown = ONLY_UNLINKED ? rows.filter((r) => !r.ig_linked) : rows;
+  const linked = rows.filter((r) => r.ig_linked);
+  const invisible = rows.filter((r) => !r.in_pages_show_list);
+  const blindSpot = rows.filter((r) => r.ig_linked && !r.in_pages_show_list);
+
+  if (JSON_OUT) {
+    console.log(JSON.stringify({
+      checked_at: new Date().toISOString(),
+      businesses: businesses.length,
+      pages_total: rows.length,
+      pages_ig_linked: linked.length,
+      pages_outside_pages_show_list: invisible.length,
+      linked_but_not_app_granted: blindSpot.length,
+      pages: shown,
+    }, null, 2));
+    process.exit(0);
+  }
+
+  console.log(`owned-pages probe — ${businesses.length} business(es), ${rows.length} page(s), ${linked.length} IG-linked`);
+  console.log(`  outside pages_show_list allowlist (invisible to me/accounts): ${invisible.length}`);
+  console.log(`  IG-LINKED but NOT app-granted (unusable until Steve grants page access): ${blindSpot.length}`);
+  for (const r of shown) {
+    const ig = r.ig_linked ? `@${r.ig_username || r.ig_id}` : '— no IG link —';
+    const vis = r.in_pages_show_list ? '' : '  [NOT in pages_show_list]';
+    console.log(`  ${r.page_id}  ${r.page_name}  ->  ${ig}${vis}`);
+  }
+  process.exit(0);
+})();

← 77e1cb6 auto-data-snapshot: 2026-09-12T19:54:34 (1 data files) — age  ·  back to Norma  ·  fix: extend dw-ig-link-drift with business_management edge f 414dbe2 →