[object Object]

← back to Ig Token Wizard

Instagram token wizard — short-lived Meta token to IG creds

fd6e74190625cdf92aef9f06de912a5fc676a82d · 2026-05-19 08:46:27 -0700 · Steve

Local click-through wizard for wallco.ai reel publishing. Runs the
long-lived exchange + Page lookup + IG-account resolution + verify
chain so Steve only pastes 3 values. Zero deps, localhost-only,
tokens never written to disk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit fd6e74190625cdf92aef9f06de912a5fc676a82d
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 08:46:27 2026 -0700

    Instagram token wizard — short-lived Meta token to IG creds
    
    Local click-through wizard for wallco.ai reel publishing. Runs the
    long-lived exchange + Page lookup + IG-account resolution + verify
    chain so Steve only pastes 3 values. Zero deps, localhost-only,
    tokens never written to disk.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .gitignore |   5 ++
 server.js  | 283 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 288 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ff2422c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..0cbf48e
--- /dev/null
+++ b/server.js
@@ -0,0 +1,283 @@
+// ─────────────────────────────────────────────────────────────────────────
+// ig-token-wizard — local click-through wizard that turns a short-lived Meta
+// token into the IG_USER_ID + IG_ACCESS_TOKEN that wallco.ai's reel maker
+// needs to publish to Instagram.
+//
+// You still do Step 1 in your browser (generate a short-lived token in Meta's
+// Graph API Explorer — that needs your Facebook login, nothing can automate
+// it). The wizard does Steps 2-5: long-lived exchange, page lookup, IG-account
+// resolution, and a verification call.
+//
+// Zero dependencies. Binds 127.0.0.1 only — tokens never leave this machine
+// and are never written to disk.
+//   node server.js   →   http://127.0.0.1:9939
+// ─────────────────────────────────────────────────────────────────────────
+'use strict';
+const http = require('http');
+
+const PORT = process.env.PORT || 9939;
+const GRAPH = 'https://graph.facebook.com/v19.0';
+
+const PERMS = 'instagram_basic,instagram_content_publish,pages_show_list,pages_read_engagement,business_management';
+
+// ── Graph API helpers ──────────────────────────────────────────────────────
+async function graph(pathQuery) {
+  const r = await fetch(GRAPH + pathQuery);
+  const j = await r.json().catch(() => ({}));
+  if (j && j.error) {
+    const e = new Error(j.error.message || 'Graph API error');
+    e.graph = j.error;
+    throw e;
+  }
+  return j;
+}
+
+// Run the whole short-token → IG-credentials chain.
+async function resolveCredentials({ appId, appSecret, shortToken }) {
+  // Step 2 — exchange short-lived user token for a long-lived one.
+  const ex = await graph('/oauth/access_token?grant_type=fb_exchange_token'
+    + '&client_id=' + encodeURIComponent(appId)
+    + '&client_secret=' + encodeURIComponent(appSecret)
+    + '&fb_exchange_token=' + encodeURIComponent(shortToken));
+  const longUserToken = ex.access_token;
+  if (!longUserToken) throw new Error('No long-lived token returned — check App ID / Secret / token.');
+
+  // Step 3 — list Facebook Pages. Each page token derived from a long-lived
+  // user token is itself long-lived / effectively non-expiring.
+  const pagesRes = await graph('/me/accounts?fields=name,access_token,id&access_token='
+    + encodeURIComponent(longUserToken));
+  const pages = pagesRes.data || [];
+  if (!pages.length) {
+    throw new Error('No Facebook Pages found on this account. The Instagram account '
+      + 'must be a Business/Creator account linked to a Facebook Page.');
+  }
+
+  // Steps 4 & 5 — for each page, resolve the linked IG account + verify it.
+  const accounts = [];
+  for (const pg of pages) {
+    try {
+      const det = await graph('/' + pg.id + '?fields=name,instagram_business_account'
+        + '&access_token=' + encodeURIComponent(pg.access_token));
+      const ig = det.instagram_business_account;
+      if (!ig || !ig.id) {
+        accounts.push({ pageName: pg.name, pageId: pg.id, igLinked: false });
+        continue;
+      }
+      let username = null, followers = null;
+      try {
+        const v = await graph('/' + ig.id + '?fields=username,followers_count'
+          + '&access_token=' + encodeURIComponent(pg.access_token));
+        username = v.username || null;
+        followers = (typeof v.followers_count === 'number') ? v.followers_count : null;
+      } catch (_) { /* verification is best-effort */ }
+      accounts.push({
+        pageName: pg.name, pageId: pg.id, igLinked: true,
+        igUserId: ig.id, igUsername: username, followers,
+        igAccessToken: pg.access_token,
+      });
+    } catch (e) {
+      accounts.push({ pageName: pg.name, pageId: pg.id, igLinked: false, error: e.message });
+    }
+  }
+  return { accounts };
+}
+
+// ── HTTP server ────────────────────────────────────────────────────────────
+function send(res, code, type, body) {
+  res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
+  res.end(body);
+}
+
+const server = http.createServer(async (req, res) => {
+  if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
+    return send(res, 200, 'text/html; charset=utf-8', PAGE);
+  }
+  if (req.method === 'POST' && req.url === '/api/run') {
+    let body = '';
+    req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
+    req.on('end', async () => {
+      try {
+        const { appId, appSecret, shortToken } = JSON.parse(body || '{}');
+        if (!appId || !appSecret || !shortToken) {
+          return send(res, 400, 'application/json',
+            JSON.stringify({ ok: false, error: 'Fill in App ID, App Secret, and the short-lived token.' }));
+        }
+        const out = await resolveCredentials({
+          appId: String(appId).trim(),
+          appSecret: String(appSecret).trim(),
+          shortToken: String(shortToken).trim(),
+        });
+        send(res, 200, 'application/json', JSON.stringify({ ok: true, ...out }));
+      } catch (e) {
+        send(res, 200, 'application/json', JSON.stringify({ ok: false, error: e.message }));
+      }
+    });
+    return;
+  }
+  send(res, 404, 'text/plain', 'not found');
+});
+
+server.listen(PORT, '127.0.0.1', () => {
+  console.log('ig-token-wizard → http://127.0.0.1:' + PORT + '  (localhost only)');
+});
+
+// ── The page ───────────────────────────────────────────────────────────────
+const PAGE = `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Instagram Token Wizard — wallco.ai</title>
+<style>
+  :root{--bg:#faf8f3;--ink:#2a2014;--soft:#6b5e4a;--faint:#9b8f78;--line:#ddd3bf;
+        --gold:#c9a14b;--card:#fff;--sans:'Helvetica Neue',Arial,sans-serif;--serif:Georgia,serif}
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 var(--sans)}
+  .wrap{max-width:680px;margin:0 auto;padding:36px 22px 90px}
+  h1{font:300 32px/1.15 var(--serif);margin:0 0 6px}
+  .sub{color:var(--soft);font-size:14px;margin:0 0 26px}
+  .step{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:20px 22px;margin:16px 0}
+  .step h2{font:400 18px var(--serif);margin:0 0 4px;display:flex;align-items:center;gap:9px}
+  .n{display:inline-flex;width:24px;height:24px;border-radius:50%;background:var(--ink);color:var(--bg);
+     font:600 12px var(--sans);align-items:center;justify-content:center;flex:none}
+  .hint{color:var(--faint);font-size:12.5px;margin:2px 0 12px}
+  ol{margin:8px 0 12px;padding-left:20px;font-size:13.5px;color:var(--soft)}
+  ol li{margin:4px 0}
+  code{background:#f0ebdf;padding:1px 5px;border-radius:4px;font-size:12px}
+  .perms{display:flex;flex-wrap:wrap;gap:5px;margin:8px 0}
+  .perms span{background:#f0ebdf;border:1px solid var(--line);border-radius:5px;padding:3px 8px;font-size:11.5px}
+  label{display:block;font-size:12px;color:var(--soft);margin:11px 0 4px;font-weight:600}
+  input,textarea{width:100%;padding:9px 12px;border:1px solid var(--line);border-radius:7px;
+     font:13px var(--sans);background:var(--bg)}
+  textarea{resize:vertical;min-height:62px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px}
+  .btn{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:8px;cursor:pointer;
+       font:600 14px var(--sans);padding:12px 24px;margin-top:14px}
+  .btn.primary{background:var(--ink);color:var(--bg)}
+  .btn.primary:disabled{opacity:.45;cursor:not-allowed}
+  .btn.gold{background:var(--gold);color:#1a1408}
+  .ext{display:inline-block;background:#1877f2;color:#fff;text-decoration:none;border-radius:7px;
+       padding:9px 16px;font:600 13px var(--sans);margin:4px 0}
+  #out{margin-top:8px}
+  .acct{border:1px solid var(--line);border-radius:9px;padding:12px 14px;margin:8px 0;background:var(--bg)}
+  .acct.linked{cursor:pointer}
+  .acct.linked:hover{border-color:var(--gold);background:#fffdf6}
+  .acct.no{opacity:.6}
+  .acct b{font-size:14px}
+  .acct .meta{font-size:12px;color:var(--faint)}
+  .err{color:#b4453a;font-size:13px;background:#fbeae8;border:1px solid #e8c4bf;border-radius:8px;
+       padding:10px 13px;margin-top:10px}
+  .result{background:#1f1808;color:#f4ece0;border-radius:10px;padding:16px 18px;margin-top:12px}
+  .result h3{margin:0 0 10px;font:400 16px var(--serif);color:#f4ece0}
+  .cred{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;background:#000;border-radius:6px;
+        padding:11px 13px;word-break:break-all;line-height:1.7;margin:8px 0}
+  .ok{color:#7fd99a}
+  .spin{display:none;font-size:13px;color:var(--soft);margin-top:10px}
+  .note{font-size:11.5px;color:var(--faint);line-height:1.5;margin-top:10px}
+</style></head><body>
+<div class="wrap">
+  <h1>Instagram Token Wizard</h1>
+  <p class="sub">Turns a short-lived Meta token into the two credentials wallco.ai needs to publish reels &amp; stories to Instagram. Runs entirely on your machine — tokens are never saved or sent anywhere but Meta.</p>
+
+  <div class="step">
+    <h2><span class="n">1</span> Get a short-lived token (in your browser)</h2>
+    <p class="hint">This one step needs your Facebook login — nothing can automate it. It takes ~30 seconds.</p>
+    <ol>
+      <li>Make sure wallco's Instagram is a <b>Business/Creator</b> account <b>linked to a Facebook Page</b>.</li>
+      <li>At <code>developers.facebook.com</code> create an App (type <b>Business</b>) and add the <b>Instagram Graph API</b> product. Note its <b>App ID</b> + <b>App Secret</b> (Settings → Basic).</li>
+      <li>Open the Graph API Explorer, pick your app, tick these permissions, then click <b>Generate Access Token</b>:</li>
+    </ol>
+    <div class="perms">
+      <span>instagram_basic</span><span>instagram_content_publish</span><span>pages_show_list</span>
+      <span>pages_read_engagement</span><span>business_management</span>
+    </div>
+    <a class="ext" href="https://developers.facebook.com/tools/explorer/" target="_blank" rel="noopener">Open Graph API Explorer ↗</a>
+    <p class="hint">Copy the token it shows — it expires in ~1 hour, which is plenty.</p>
+  </div>
+
+  <div class="step">
+    <h2><span class="n">2</span> Paste your three values</h2>
+    <p class="hint">The wizard does the rest — long-lived exchange, Page lookup, IG-account resolution, verification.</p>
+    <label for="appId">App ID</label>
+    <input id="appId" autocomplete="off" placeholder="e.g. 1234567890123456">
+    <label for="appSecret">App Secret</label>
+    <input id="appSecret" autocomplete="off" placeholder="32-character secret from Settings → Basic">
+    <label for="shortToken">Short-lived access token (from the Explorer)</label>
+    <textarea id="shortToken" autocomplete="off" placeholder="EAAG..."></textarea>
+    <button class="btn primary" id="run">Get my Instagram credentials</button>
+    <div class="spin" id="spin">Talking to Meta… (a few seconds)</div>
+    <div id="err"></div>
+  </div>
+
+  <div class="step" id="step3" style="display:none">
+    <h2><span class="n">3</span> Pick the Instagram account</h2>
+    <p class="hint">Click the wallco account to get its final credentials.</p>
+    <div id="out"></div>
+  </div>
+
+  <div id="final"></div>
+</div>
+<script>
+  var ACCOUNTS = [];
+  var $ = function(id){ return document.getElementById(id); };
+
+  $('run').addEventListener('click', function(){
+    var appId=$('appId').value.trim(), appSecret=$('appSecret').value.trim(), shortToken=$('shortToken').value.trim();
+    $('err').innerHTML=''; $('final').innerHTML=''; $('step3').style.display='none';
+    if(!appId||!appSecret||!shortToken){ $('err').innerHTML='<div class="err">Fill in all three fields.</div>'; return; }
+    $('run').disabled=true; $('spin').style.display='block';
+    fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({appId:appId,appSecret:appSecret,shortToken:shortToken})})
+    .then(function(r){return r.json();})
+    .then(function(j){
+      $('run').disabled=false; $('spin').style.display='none';
+      if(!j.ok){ $('err').innerHTML='<div class="err">'+esc(j.error||'Something went wrong')+'</div>'; return; }
+      ACCOUNTS=j.accounts||[];
+      renderAccounts();
+    })
+    .catch(function(e){
+      $('run').disabled=false; $('spin').style.display='none';
+      $('err').innerHTML='<div class="err">'+esc(e.message)+'</div>';
+    });
+  });
+
+  function renderAccounts(){
+    $('step3').style.display='block';
+    var out=$('out'); out.innerHTML='';
+    if(!ACCOUNTS.length){ out.innerHTML='<div class="err">No Facebook Pages found.</div>'; return; }
+    ACCOUNTS.forEach(function(a,i){
+      var d=document.createElement('div');
+      if(a.igLinked){
+        d.className='acct linked';
+        d.innerHTML='<b>@'+esc(a.igUsername||'instagram')+'</b>'+
+          (a.followers!=null?' &middot; '+a.followers.toLocaleString()+' followers':'')+
+          '<div class="meta">via Page: '+esc(a.pageName)+'</div>';
+        d.addEventListener('click',function(){ showFinal(a); });
+      } else {
+        d.className='acct no';
+        d.innerHTML='<b>'+esc(a.pageName)+'</b><div class="meta">No Instagram account linked'+
+          (a.error?' — '+esc(a.error):'')+'</div>';
+      }
+      out.appendChild(d);
+    });
+  }
+
+  function showFinal(a){
+    var lines='IG_USER_ID='+a.igUserId+'\\nIG_ACCESS_TOKEN='+a.igAccessToken;
+    $('final').innerHTML=
+      '<div class="result"><h3>✓ Credentials for @'+esc(a.igUsername||'')+'</h3>'+
+      '<div class="cred">IG_USER_ID=<span class="ok">'+esc(a.igUserId)+'</span><br>'+
+      'IG_ACCESS_TOKEN=<span class="ok">'+esc(a.igAccessToken)+'</span></div>'+
+      '<button class="btn gold" id="copy">Copy both lines</button>'+
+      '<p class="note">Paste these two lines to Claude — they\\'ll be routed to the instagram-agent '+
+      'and the wallco.ai reel maker goes live. This Page token is long-lived (doesn\\'t expire on a '+
+      '60-day clock). Don\\'t paste these anywhere public.</p></div>';
+    $('copy').addEventListener('click',function(){
+      navigator.clipboard.writeText(lines).then(function(){
+        $('copy').textContent='Copied ✓';
+        setTimeout(function(){ $('copy').textContent='Copy both lines'; },1800);
+      });
+    });
+    $('final').scrollIntoView({behavior:'smooth'});
+  }
+
+  function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){
+    return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
+</script>
+</body></html>`;

(oldest)  ·  back to Ig Token Wizard  ·  Wizard: listen on both IPv4 and IPv6 loopback so localhost r 069360c →