← 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
A agents/instagram-agent/probe-owned-pages.js
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 →