← back to Ig Token Wizard
server.js
470 lines
// ─────────────────────────────────────────────────────────────────────────
// ig-token-wizard — local click-through wizard that produces the
// IG_USER_ID + IG_ACCESS_TOKEN that wallco.ai's reel maker needs to publish
// to Instagram.
//
// TWO paths:
// • "Continue with Facebook" — the wizard hosts the OAuth login itself.
// You paste only App ID + App Secret, click one blue button, approve in
// Meta's dialog, and the wizard captures + exchanges the token for you.
// • Manual paste — paste App ID + Secret + a short-lived Explorer token.
//
// Either way the wizard runs: code/short-token → long-lived exchange →
// Page lookup → IG-account resolution → verification.
//
// Zero dependencies. Binds loopback only (127.0.0.1 + ::1). Tokens are never
// written to disk; App ID/Secret live in memory only for the OAuth round-trip.
// node server.js → http://localhost:9939
// ─────────────────────────────────────────────────────────────────────────
'use strict';
const http = require('http');
const crypto = require('crypto');
const PORT = process.env.PORT || 9939;
const API_VER = 'v19.0';
const GRAPH = 'https://graph.facebook.com/' + API_VER;
const OAUTH_DIALOG = 'https://www.facebook.com/' + API_VER + '/dialog/oauth';
const PERMS = 'instagram_basic,instagram_content_publish,pages_show_list,pages_read_engagement,business_management';
// In-memory session store for the OAuth round-trip. Keyed by a cookie value.
// Entries expire after 15 minutes. Never persisted.
const sessions = new Map();
function newSession(data) {
const id = crypto.randomBytes(18).toString('hex');
sessions.set(id, Object.assign({ ts: Date.now() }, data));
return id;
}
function getSession(id) {
const s = id && sessions.get(id);
if (!s) return null;
if (Date.now() - s.ts > 15 * 60 * 1000) { sessions.delete(id); return null; }
return s;
}
setInterval(() => {
const cutoff = Date.now() - 15 * 60 * 1000;
for (const [k, v] of sessions) if (v.ts < cutoff) sessions.delete(k);
}, 5 * 60 * 1000).unref();
// ── 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;
}
// Exchange an OAuth `code` (from the login dialog) for a short-lived token.
async function codeToToken({ appId, appSecret, redirectUri, code }) {
const j = await graph('/oauth/access_token'
+ '?client_id=' + encodeURIComponent(appId)
+ '&client_secret=' + encodeURIComponent(appSecret)
+ '&redirect_uri=' + encodeURIComponent(redirectUri)
+ '&code=' + encodeURIComponent(code));
if (!j.access_token) throw new Error('Meta did not return an access token from the login code.');
return j.access_token;
}
// Run the whole short-token → IG-credentials chain.
async function resolveCredentials({ appId, appSecret, shortToken }) {
// 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.');
// List Facebook Pages. A 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.');
}
// 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 };
}
// ── tiny helpers ───────────────────────────────────────────────────────────
function send(res, code, type, body, extraHeaders) {
res.writeHead(code, Object.assign(
{ 'Content-Type': type, 'Cache-Control': 'no-store' }, extraHeaders || {}));
res.end(body);
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g,
c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
}
function parseCookies(req) {
const out = {};
(req.headers.cookie || '').split(';').forEach(p => {
const i = p.indexOf('='); if (i < 0) return;
out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
});
return out;
}
// The redirect URI Meta must have whitelisted — derived from the Host the
// browser actually used, so it always matches.
function redirectUri(req) {
const host = (req.headers.host || ('localhost:' + PORT)).trim();
return 'http://' + host + '/oauth/callback';
}
// ── HTTP server ────────────────────────────────────────────────────────────
const requestHandler = async (req, res) => {
const u = new URL(req.url, 'http://x');
const path = u.pathname;
// Home page.
if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
return send(res, 200, 'text/html; charset=utf-8', renderPage(redirectUri(req)));
}
// Step A of OAuth: stash App ID/Secret in a session, hand back a cookie.
if (req.method === 'POST' && path === '/api/config') {
let body = '';
req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
req.on('end', () => {
try {
const { appId, appSecret } = JSON.parse(body || '{}');
if (!appId || !appSecret) {
return send(res, 400, 'application/json',
JSON.stringify({ ok: false, error: 'Enter both App ID and App Secret.' }));
}
const state = crypto.randomBytes(12).toString('hex');
const sid = newSession({
appId: String(appId).trim(), appSecret: String(appSecret).trim(), state,
});
send(res, 200, 'application/json', JSON.stringify({ ok: true }),
{ 'Set-Cookie': 'igw_sess=' + sid + '; Path=/; HttpOnly; SameSite=Lax' });
} catch (e) {
send(res, 200, 'application/json', JSON.stringify({ ok: false, error: e.message }));
}
});
return;
}
// Step B: bounce the browser to Meta's login dialog.
if (req.method === 'GET' && path === '/oauth/start') {
const s = getSession(parseCookies(req).igw_sess);
if (!s) return send(res, 200, 'text/html; charset=utf-8',
resultPage('Session expired', '<p>Your session timed out. Go back and click “Continue with Facebook” again.</p>'));
const url = OAUTH_DIALOG
+ '?client_id=' + encodeURIComponent(s.appId)
+ '&redirect_uri=' + encodeURIComponent(redirectUri(req))
+ '&scope=' + encodeURIComponent(PERMS)
+ '&state=' + encodeURIComponent(s.state)
+ '&response_type=code';
return send(res, 302, 'text/plain', 'redirecting', { 'Location': url });
}
// Step C: Meta redirects back here with ?code=… — exchange + resolve.
if (req.method === 'GET' && path === '/oauth/callback') {
const s = getSession(parseCookies(req).igw_sess);
const code = u.searchParams.get('code');
const state = u.searchParams.get('state');
const err = u.searchParams.get('error_description') || u.searchParams.get('error');
if (err) return send(res, 200, 'text/html; charset=utf-8',
resultPage('Login cancelled', '<p>' + esc(err) + '</p>'));
if (!s) return send(res, 200, 'text/html; charset=utf-8',
resultPage('Session expired', '<p>Start over — click “Continue with Facebook” again.</p>'));
if (!code || state !== s.state) return send(res, 200, 'text/html; charset=utf-8',
resultPage('Could not verify the login', '<p>The login response did not match. Start over.</p>'));
try {
const shortToken = await codeToToken({
appId: s.appId, appSecret: s.appSecret,
redirectUri: redirectUri(req), code,
});
const { accounts } = await resolveCredentials({
appId: s.appId, appSecret: s.appSecret, shortToken,
});
sessions.delete(parseCookies(req).igw_sess);
return send(res, 200, 'text/html; charset=utf-8',
resultPage('Choose your Instagram account', accountsHtml(accounts)));
} catch (e) {
return send(res, 200, 'text/html; charset=utf-8',
resultPage('Something went wrong', '<div class="err">' + esc(e.message) + '</div>'
+ '<p class="note">Most common cause: the redirect URI is not whitelisted. '
+ 'In your Meta app → Facebook Login → Settings, add exactly:<br><code>'
+ esc(redirectUri(req)) + '</code></p>'));
}
}
// Manual-paste fallback.
if (req.method === 'POST' && path === '/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');
};
// Listen on BOTH loopback addresses — IPv4 127.0.0.1 and IPv6 ::1 — so the
// wizard is reachable whether the browser resolves "localhost" to v4 or v6.
for (const host of ['127.0.0.1', '::1']) {
http.createServer(requestHandler).listen(PORT, host, () => {
const shown = host.includes(':') ? '[' + host + ']' : host;
console.log('ig-token-wizard → http://' + shown + ':' + PORT);
});
}
// ── shared CSS ─────────────────────────────────────────────────────────────
const CSS = `
: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:5px 0}
code{background:#f0ebdf;padding:1px 5px;border-radius:4px;font-size:12px;word-break:break-all}
.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:disabled{opacity:.45;cursor:not-allowed}
.btn.fb{background:#1877f2;color:#fff;font-size:15px;padding:13px 26px}
.btn.gold{background:var(--gold);color:#1a1408}
details{margin-top:10px}
summary{cursor:pointer;font-size:13px;color:var(--soft)}
.acct{border:1px solid var(--line);border-radius:9px;padding:12px 14px;margin:8px 0;background:var(--bg)}
.acct.no{opacity:.65}
.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.55;margin-top:10px}
a{color:#1877f2}
`;
// ── result / callback page ─────────────────────────────────────────────────
function accountsHtml(accounts) {
if (!accounts || !accounts.length) return '<div class="err">No Facebook Pages found on this account.</div>';
let html = '<p class="hint">Copy the two lines for the account wallco posts from, and paste them to Claude.</p>';
accounts.forEach((a, i) => {
if (a.igLinked) {
const lines = 'IG_USER_ID=' + a.igUserId + '\nIG_ACCESS_TOKEN=' + a.igAccessToken;
html += '<div class="acct"><b>@' + esc(a.igUsername || 'instagram') + '</b>'
+ (a.followers != null ? ' · ' + a.followers.toLocaleString() + ' followers' : '')
+ '<div class="meta">via Page: ' + esc(a.pageName) + '</div>'
+ '<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" data-copy="' + esc(lines) + '">Copy both lines</button></div>';
} else {
html += '<div class="acct no"><b>' + esc(a.pageName) + '</b>'
+ '<div class="meta">No Instagram account linked'
+ (a.error ? ' — ' + esc(a.error) : '') + '</div></div>';
}
});
html += '<p class="note">These run entirely on your machine. The Page token is long-lived '
+ '(no 60-day clock). Don’t paste them anywhere public.</p>';
return html;
}
function resultPage(title, inner) {
return '<!doctype html><html lang="en"><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
+ '<title>' + esc(title) + ' — IG Token Wizard</title><style>' + CSS + '</style></head><body>'
+ '<div class="wrap"><h1>' + esc(title) + '</h1>' + inner
+ '<p style="margin-top:22px"><a href="/">← Back to the wizard</a></p></div>'
+ '<script>document.addEventListener("click",function(e){'
+ 'var b=e.target.closest("[data-copy]");if(!b)return;'
+ 'navigator.clipboard.writeText(b.getAttribute("data-copy")).then(function(){'
+ 'var t=b.textContent;b.textContent="Copied \\u2713";setTimeout(function(){b.textContent=t;},1800);});'
+ '});</script></body></html>';
}
// ── home page ──────────────────────────────────────────────────────────────
function renderPage(redirUri) {
return `<!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>${CSS}</style></head><body>
<div class="wrap">
<h1>Instagram Token Wizard</h1>
<p class="sub">Hooks wallco.ai up to publish reels & stories to Instagram. Paste two values, click one blue button, approve in Meta’s pop-up — the wizard does the rest. Runs entirely on your machine.</p>
<div class="step">
<h2><span class="n">1</span> One-time Meta setup</h2>
<ol>
<li>wallco’s Instagram must be a <b>Business/Creator</b> account <b>linked to a Facebook Page</b>.</li>
<li>At <a href="https://developers.facebook.com/apps" target="_blank" rel="noopener noreferrer">developers.facebook.com/apps</a> open (or create, type <b>Business</b>) your app. Add two products: <b>Instagram Graph API</b> and <b>Facebook Login</b>.</li>
<li>In <b>Facebook Login → Settings</b>, under <b>Valid OAuth Redirect URIs</b>, paste exactly this and save:
<br><code>${esc(redirUri)}</code></li>
<li>From <b>Settings → Basic</b>, copy your <b>App ID</b> and <b>App Secret</b> (click “Show”).</li>
</ol>
<p class="hint">You only do this once. After it’s saved, every future token is just the blue button below.</p>
</div>
<div class="step">
<h2><span class="n">2</span> Connect</h2>
<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">
<button class="btn fb" id="fb">Continue with Facebook</button>
<div class="spin" id="spin">Saving…</div>
<div id="err"></div>
<p class="note">Clicking the button sends you to Meta’s own login page. Approve the requested permissions and pick wallco’s Page — you land back here with the credentials.</p>
<details>
<summary>Or paste a short-lived token manually (advanced)</summary>
<label for="mAppId">App ID</label>
<input id="mAppId" autocomplete="off">
<label for="mAppSecret">App Secret</label>
<input id="mAppSecret" autocomplete="off">
<label for="mToken">Short-lived token (from the <a href="https://developers.facebook.com/tools/explorer/" target="_blank" rel="noopener noreferrer">Graph API Explorer</a>)</label>
<textarea id="mToken" autocomplete="off" placeholder="EAAG..."></textarea>
<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>
<button class="btn primary" id="run">Get credentials from token</button>
<div class="spin" id="mspin">Talking to Meta…</div>
<div id="merr"></div>
<div id="mout"></div>
</details>
</div>
</div>
<script>
var $=function(id){return document.getElementById(id);};
// OAuth path.
$('fb').addEventListener('click',function(){
var appId=$('appId').value.trim(), appSecret=$('appSecret').value.trim();
$('err').innerHTML='';
if(!appId||!appSecret){ $('err').innerHTML='<div class="err">Enter both App ID and App Secret.</div>'; return; }
$('fb').disabled=true; $('spin').style.display='block';
fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({appId:appId,appSecret:appSecret})})
.then(function(r){return r.json();})
.then(function(j){
if(!j.ok){ $('fb').disabled=false; $('spin').style.display='none';
$('err').innerHTML='<div class="err">'+esc(j.error||'error')+'</div>'; return; }
window.location='/oauth/start';
})
.catch(function(e){ $('fb').disabled=false; $('spin').style.display='none';
$('err').innerHTML='<div class="err">'+esc(e.message)+'</div>'; });
});
// Manual paste path.
$('run').addEventListener('click',function(){
var appId=$('mAppId').value.trim(), appSecret=$('mAppSecret').value.trim(), token=$('mToken').value.trim();
$('merr').innerHTML=''; $('mout').innerHTML='';
if(!appId||!appSecret||!token){ $('merr').innerHTML='<div class="err">Fill in all three fields.</div>'; return; }
$('run').disabled=true; $('mspin').style.display='block';
fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({appId:appId,appSecret:appSecret,shortToken:token})})
.then(function(r){return r.json();})
.then(function(j){
$('run').disabled=false; $('mspin').style.display='none';
if(!j.ok){ $('merr').innerHTML='<div class="err">'+esc(j.error||'error')+'</div>'; return; }
renderAccounts(j.accounts||[]);
})
.catch(function(e){ $('run').disabled=false; $('mspin').style.display='none';
$('merr').innerHTML='<div class="err">'+esc(e.message)+'</div>'; });
});
function renderAccounts(list){
var out=$('mout'); out.innerHTML='';
if(!list.length){ out.innerHTML='<div class="err">No Facebook Pages found.</div>'; return; }
list.forEach(function(a){
var d=document.createElement('div'); d.className='acct'+(a.igLinked?'':' no');
if(a.igLinked){
var lines='IG_USER_ID='+a.igUserId+'\\nIG_ACCESS_TOKEN='+a.igAccessToken;
d.innerHTML='<b>@'+esc(a.igUsername||'instagram')+'</b>'+
(a.followers!=null?' · '+a.followers.toLocaleString()+' followers':'')+
'<div class="meta">via Page: '+esc(a.pageName)+'</div>'+
'<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" data-copy="'+esc(lines)+'">Copy both lines</button>';
} else {
d.innerHTML='<b>'+esc(a.pageName)+'</b><div class="meta">No Instagram account linked'+
(a.error?' — '+esc(a.error):'')+'</div>';
}
out.appendChild(d);
});
}
document.addEventListener('click',function(e){
var b=e.target.closest('[data-copy]'); if(!b) return;
navigator.clipboard.writeText(b.getAttribute('data-copy')).then(function(){
var t=b.textContent; b.textContent='Copied \\u2713';
setTimeout(function(){ b.textContent=t; },1800);
});
});
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){
return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); }
</script>
</body></html>`;
}