generated from nhcarrigan/template
922dee415a
### Explanation Makes my life so much easier. ### Issue _No response_ ### Attestations - [x] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [x] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [x] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [x] I have pinned the dependencies to a specific patch version. ### Style - [x] I have run the linter and resolved any errors. - [x] My pull request uses an appropriate title, matching the conventional commit standards. - [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning Minor - My pull request introduces a new non-breaking feature. Reviewed-on: #8 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
605 lines
21 KiB
JavaScript
605 lines
21 KiB
JavaScript
/**
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*
|
|
* Simple local server to authenticate with Threads (via Meta/Facebook) and obtain an Access Token.
|
|
* Run with: node threadsAuth.js
|
|
* Make sure to set THREADS_APP_ID and THREADS_APP_SECRET environment variables.
|
|
*
|
|
* Note: You need an Instagram Business Account linked to your Threads profile.
|
|
* The OAuth flow goes through Facebook's endpoints (Meta's unified platform) but uses
|
|
* Threads-specific app credentials.
|
|
*/
|
|
|
|
import http from "http";
|
|
import { URL } from "url";
|
|
|
|
const PORT = 3001; // Different port from Facebook auth
|
|
// Threads API requires HTTPS for OAuth redirects
|
|
// For local development, use ngrok: ngrok http 3001
|
|
// Then set THREADS_REDIRECT_URI to your ngrok HTTPS URL
|
|
const REDIRECT_URI =`https://local3001.nhcarrigan.com/callback`;
|
|
|
|
/**
|
|
* Creates the Threads OAuth authorization URL.
|
|
* Threads uses its own OAuth endpoint: threads.net/oauth/authorize
|
|
* @param {string} appId - The Threads App ID.
|
|
* @returns {string} The authorization URL.
|
|
*/
|
|
const getAuthUrl = (appId) => {
|
|
const params = new URLSearchParams({
|
|
client_id: appId,
|
|
redirect_uri: REDIRECT_URI,
|
|
scope: "threads_basic,threads_content_publish",
|
|
response_type: "code",
|
|
});
|
|
return `https://threads.net/oauth/authorize?${params.toString()}`;
|
|
};
|
|
|
|
/**
|
|
* Exchanges an authorization code for an access token.
|
|
* Threads uses its own token endpoint: graph.threads.net/oauth/access_token
|
|
* @param {string} code - The authorization code from Threads.
|
|
* @param {string} appId - The Threads App ID.
|
|
* @param {string} appSecret - The Threads App Secret.
|
|
* @returns {Promise<{access_token: string, user_id?: number}>} The access token response.
|
|
*/
|
|
const exchangeCodeForToken = async (code, appId, appSecret) => {
|
|
const params = new URLSearchParams({
|
|
client_id: appId,
|
|
client_secret: appSecret,
|
|
redirect_uri: REDIRECT_URI,
|
|
code: code,
|
|
grant_type: "authorization_code",
|
|
});
|
|
|
|
const response = await fetch(
|
|
`https://graph.threads.net/oauth/access_token`,
|
|
{
|
|
body: params,
|
|
method: "POST",
|
|
},
|
|
);
|
|
return await response.json();
|
|
};
|
|
|
|
/**
|
|
* Exchanges a short-lived token for a long-lived token.
|
|
* @param {string} shortLivedToken - The short-lived access token.
|
|
* @param {string} appId - The Threads App ID.
|
|
* @param {string} appSecret - The Threads App Secret.
|
|
* @returns {Promise<{access_token: string, expires_in?: number}>} The long-lived token response.
|
|
*/
|
|
const exchangeForLongLivedToken = async (shortLivedToken, appId, appSecret) => {
|
|
const params = new URLSearchParams({
|
|
grant_type: "fb_exchange_token",
|
|
client_id: appId,
|
|
client_secret: appSecret,
|
|
fb_exchange_token: shortLivedToken,
|
|
});
|
|
|
|
const response = await fetch(
|
|
`https://graph.facebook.com/v21.0/oauth/access_token?${params.toString()}`,
|
|
);
|
|
return await response.json();
|
|
};
|
|
|
|
/**
|
|
* Gets the user's Instagram Business Accounts.
|
|
* @param {string} accessToken - The user access token.
|
|
* @returns {Promise<Array>} Array of Instagram Business Accounts.
|
|
*/
|
|
const getInstagramAccounts = async (accessToken) => {
|
|
const response = await fetch(
|
|
`https://graph.facebook.com/v21.0/me/accounts?fields=instagram_business_account&access_token=${accessToken}`,
|
|
);
|
|
const data = await response.json();
|
|
const accounts = [];
|
|
|
|
if (data.data) {
|
|
for (const page of data.data) {
|
|
if (page.instagram_business_account) {
|
|
const igAccountResponse = await fetch(
|
|
`https://graph.facebook.com/v21.0/${page.instagram_business_account.id}?fields=id,username,threads_profile&access_token=${accessToken}`,
|
|
);
|
|
const igAccount = await igAccountResponse.json();
|
|
if (igAccount.threads_profile) {
|
|
accounts.push({
|
|
instagramAccountId: igAccount.id,
|
|
username: igAccount.username,
|
|
threadsProfileId: igAccount.threads_profile.id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return accounts;
|
|
};
|
|
|
|
/**
|
|
* Sends an HTML response.
|
|
* @param {http.ServerResponse} res - The HTTP response object.
|
|
* @param {number} statusCode - The HTTP status code.
|
|
* @param {string} html - The HTML content to send.
|
|
*/
|
|
const sendHtml = (res, statusCode, html) => {
|
|
res.writeHead(statusCode, { "Content-Type": "text/html" });
|
|
res.end(html);
|
|
};
|
|
|
|
/**
|
|
* Sends a JSON response.
|
|
* @param {http.ServerResponse} res - The HTTP response object.
|
|
* @param {number} statusCode - The HTTP status code.
|
|
* @param {object} data - The JSON data to send.
|
|
*/
|
|
const sendJson = (res, statusCode, data) => {
|
|
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(data, null, 2));
|
|
};
|
|
|
|
const appId = process.env.THREADS_APP_ID?.trim();
|
|
const appSecret = process.env.THREADS_APP_SECRET?.trim();
|
|
|
|
if (!appId || !appSecret) {
|
|
console.error(
|
|
"Error: THREADS_APP_ID and THREADS_APP_SECRET environment variables must be set.",
|
|
);
|
|
console.error(
|
|
"Example: THREADS_APP_ID=your_app_id THREADS_APP_SECRET=your_secret node threadsAuth.js",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Validate App ID format (should be numeric)
|
|
if (!/^\d+$/.test(appId)) {
|
|
console.error(
|
|
`Error: THREADS_APP_ID does not appear to be valid. Got: "${appId}"`,
|
|
);
|
|
console.error(
|
|
"App ID should be a numeric string. Make sure you're using 'op run' to resolve 1Password references.",
|
|
);
|
|
console.error(
|
|
"Run: pnpm threadsAuth (or: op run --env-file=./prod.env -- node threadsAuth.js)",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
|
|
// Root route - show auth link
|
|
if (url.pathname === "/") {
|
|
const authUrl = getAuthUrl(appId);
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Threads Token Generator</title>
|
|
<style>
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
max-width: 800px;
|
|
margin: 50px auto;
|
|
padding: 20px;
|
|
background: #f5f5f5;
|
|
}
|
|
.container {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
h1 {
|
|
color: #000;
|
|
margin-top: 0;
|
|
}
|
|
.button {
|
|
display: inline-block;
|
|
background: #000;
|
|
color: white;
|
|
padding: 12px 24px;
|
|
text-decoration: none;
|
|
border-radius: 6px;
|
|
font-weight: 600;
|
|
margin-top: 20px;
|
|
}
|
|
.button:hover {
|
|
background: #333;
|
|
}
|
|
.info {
|
|
background: #e3f2fd;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
margin-top: 20px;
|
|
border-left: 4px solid #1877f2;
|
|
}
|
|
.warning {
|
|
background: #fff3e0;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
margin-top: 20px;
|
|
border-left: 4px solid #ff9800;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🔐 Threads Token Generator</h1>
|
|
<p>Click the button below to authenticate with Meta/Facebook and get your Threads Access Token.</p>
|
|
<a href="${authUrl}" class="button">Authenticate with Meta</a>
|
|
<div class="info">
|
|
<strong>Note:</strong> You need:
|
|
<ul>
|
|
<li>An Instagram Business Account</li>
|
|
<li>A Threads profile linked to that Instagram account</li>
|
|
<li>Admin access to a Facebook Page connected to your Instagram Business Account</li>
|
|
</ul>
|
|
</div>
|
|
<div class="warning">
|
|
<strong>⚠️ Important:</strong> Your Threads app must have:
|
|
<ul>
|
|
<li>Threads API product added</li>
|
|
<li><code>threads_basic</code> and <code>threads_content_publish</code> permissions approved</li>
|
|
<li>Valid OAuth Redirect URI: <code>${REDIRECT_URI}</code></li>
|
|
</ul>
|
|
</div>
|
|
${REDIRECT_URI.startsWith("http://") ? `
|
|
<div class="warning" style="background: #ffebee; border-left-color: #d32f2f;">
|
|
<strong>🔒 HTTPS Required:</strong> Threads API requires HTTPS for OAuth redirects!
|
|
<ul>
|
|
<li>Install cloudflared: <code>brew install cloudflared</code> or download from <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/" target="_blank">cloudflare.com</a></li>
|
|
<li>Run: <code>cloudflared tunnel --url http://localhost:${PORT}</code></li>
|
|
<li>Copy the HTTPS URL (e.g., https://abc123.trycloudflare.com)</li>
|
|
<li>Set environment variable: <code>THREADS_REDIRECT_URI=https://abc123.trycloudflare.com/callback</code></li>
|
|
<li>Add the HTTPS URL to your Threads app's Valid OAuth Redirect URIs</li>
|
|
<li>Restart this server</li>
|
|
</ul>
|
|
</div>
|
|
` : ""}
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
return sendHtml(res, 200, html);
|
|
}
|
|
|
|
// Callback route - handle OAuth callback
|
|
if (url.pathname === "/callback") {
|
|
// Threads appends #_ to the redirect URI - strip it from the URL
|
|
let code = url.searchParams.get("code");
|
|
const error = url.searchParams.get("error");
|
|
const errorReason = url.searchParams.get("error_reason");
|
|
const errorDescription = url.searchParams.get("error_description");
|
|
|
|
// Debug: Log the full callback URL to see what Threads is sending
|
|
console.log(`\n🔍 Callback received:`);
|
|
console.log(` Full URL: ${url.href}`);
|
|
console.log(` Expected redirect URI: ${REDIRECT_URI}`);
|
|
console.log(` Error: ${error || "none"}`);
|
|
console.log(` Error reason: ${errorReason || "none"}`);
|
|
console.log(` Error description: ${errorDescription || "none"}\n`);
|
|
|
|
// If code is in the hash (after #_), extract it
|
|
if (!code && url.hash) {
|
|
const hashParams = new URLSearchParams(url.hash.substring(1));
|
|
code = hashParams.get("code");
|
|
}
|
|
|
|
if (error) {
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Authentication Error</title>
|
|
<style>
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
max-width: 800px;
|
|
margin: 50px auto;
|
|
padding: 20px;
|
|
background: #f5f5f5;
|
|
}
|
|
.container {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
.error {
|
|
color: #d32f2f;
|
|
background: #ffebee;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
border-left: 4px solid #d32f2f;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>❌ Authentication Error</h1>
|
|
<div class="error">
|
|
<p><strong>Error:</strong> ${error}</p>
|
|
<p><strong>Error Reason:</strong> ${errorReason || "N/A"}</p>
|
|
<p><strong>Error Description:</strong> ${errorDescription || "N/A"}</p>
|
|
<p><strong>Full Callback URL:</strong> <code style="word-break: break-all;">${url.href}</code></p>
|
|
<p><strong>Expected Redirect URI:</strong> <code>${REDIRECT_URI}</code></p>
|
|
</div>
|
|
<p><a href="/">Try again</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
return sendHtml(res, 400, html);
|
|
}
|
|
|
|
if (!code) {
|
|
return sendHtml(
|
|
res,
|
|
400,
|
|
"<h1>Error</h1><p>No authorization code received.</p><a href='/'>Try again</a>",
|
|
);
|
|
}
|
|
|
|
try {
|
|
// Step 1: Exchange code for access token
|
|
const tokenResponse = await exchangeCodeForToken(code, appId, appSecret);
|
|
|
|
if (tokenResponse.error_type || tokenResponse.error_message) {
|
|
throw new Error(
|
|
tokenResponse.error_message || "Failed to exchange code for token",
|
|
);
|
|
}
|
|
|
|
if (!tokenResponse.access_token) {
|
|
throw new Error(
|
|
"No access token received. Response: " + JSON.stringify(tokenResponse),
|
|
);
|
|
}
|
|
|
|
const accessToken = tokenResponse.access_token;
|
|
const userId = tokenResponse.user_id;
|
|
|
|
// Step 2: Get Instagram Business Account ID
|
|
// The user_id from Threads token exchange is the Instagram Business Account ID
|
|
// We can also verify this by calling the Threads API
|
|
const accounts = [];
|
|
if (userId) {
|
|
// Try to get account info from Threads API
|
|
try {
|
|
const accountInfoResponse = await fetch(
|
|
`https://graph.threads.net/v1.0/${userId}?fields=id,username&access_token=${accessToken}`,
|
|
);
|
|
if (accountInfoResponse.ok) {
|
|
const accountInfo = await accountInfoResponse.json();
|
|
accounts.push({
|
|
instagramAccountId: userId.toString(),
|
|
username: accountInfo.username || "unknown",
|
|
threadsProfileId: userId.toString(), // Threads Profile ID is same as Instagram Business Account ID
|
|
});
|
|
} else {
|
|
// Fallback: use the user_id as Instagram Business Account ID
|
|
accounts.push({
|
|
instagramAccountId: userId.toString(),
|
|
username: "unknown",
|
|
threadsProfileId: userId.toString(),
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// Fallback: use the user_id as Instagram Business Account ID
|
|
accounts.push({
|
|
instagramAccountId: userId.toString(),
|
|
username: "unknown",
|
|
threadsProfileId: userId.toString(),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (accounts.length === 0) {
|
|
return sendHtml(
|
|
res,
|
|
200,
|
|
`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>No Threads Accounts Found</title>
|
|
<style>
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
max-width: 800px;
|
|
margin: 50px auto;
|
|
padding: 20px;
|
|
background: #f5f5f5;
|
|
}
|
|
.container {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>⚠️ No Threads Accounts Found</h1>
|
|
<p>You don't have access to any Instagram Business Accounts with Threads profiles, or your Facebook Page isn't connected to an Instagram Business Account.</p>
|
|
<p><a href="/">Try again</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`,
|
|
);
|
|
}
|
|
|
|
// Display results
|
|
const accountsHtml = accounts
|
|
.map(
|
|
(account) => `
|
|
<div style="background: #f5f5f5; padding: 15px; margin: 10px 0; border-radius: 6px;">
|
|
<h3>@${account.username}</h3>
|
|
<p><strong>Instagram Business Account ID:</strong> <code>${account.instagramAccountId}</code></p>
|
|
<p><strong>Threads Profile ID:</strong> <code>${account.threadsProfileId}</code></p>
|
|
<p><strong>Access Token:</strong></p>
|
|
<textarea readonly style="width: 100%; padding: 10px; font-family: monospace; border: 1px solid #ddd; border-radius: 4px; background: white;" rows="3">${accessToken}</textarea>
|
|
<p><strong>Note:</strong> Threads access tokens are short-lived. You may need to refresh them periodically.</p>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Success! Your Threads Tokens</title>
|
|
<style>
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
max-width: 900px;
|
|
margin: 50px auto;
|
|
padding: 20px;
|
|
background: #f5f5f5;
|
|
}
|
|
.container {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
.success {
|
|
background: #e8f5e9;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
border-left: 4px solid #4caf50;
|
|
margin-bottom: 20px;
|
|
}
|
|
h1 {
|
|
color: #4caf50;
|
|
margin-top: 0;
|
|
}
|
|
code {
|
|
background: #f5f5f5;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
.warning {
|
|
background: #fff3e0;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
border-left: 4px solid #ff9800;
|
|
margin-top: 20px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>✅ Success!</h1>
|
|
<div class="success">
|
|
<p><strong>Your Threads Access Tokens:</strong></p>
|
|
<p>Copy these values and add them to your environment variables.</p>
|
|
</div>
|
|
${accountsHtml}
|
|
<div class="warning">
|
|
<p><strong>⚠️ Important:</strong></p>
|
|
<ul>
|
|
<li>Store these tokens securely (like your other API credentials)</li>
|
|
<li>Add the access token to your environment variables as <code>THREADS_ACCESS_TOKEN</code></li>
|
|
<li>Add the Instagram Business Account ID as <code>THREADS_INSTAGRAM_ACCOUNT_ID</code></li>
|
|
<li>Add the Threads Profile ID as <code>THREADS_PROFILE_ID</code> (usually same as Instagram Account ID)</li>
|
|
<li>Threads tokens are short-lived and may need to be refreshed periodically</li>
|
|
</ul>
|
|
</div>
|
|
<p><a href="/">Start over</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
|
|
return sendHtml(res, 200, html);
|
|
} catch (error) {
|
|
const html = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Error</title>
|
|
<style>
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
max-width: 800px;
|
|
margin: 50px auto;
|
|
padding: 20px;
|
|
background: #f5f5f5;
|
|
}
|
|
.container {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
.error {
|
|
color: #d32f2f;
|
|
background: #ffebee;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
border-left: 4px solid #d32f2f;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>❌ Error</h1>
|
|
<div class="error">
|
|
<p><strong>Error:</strong> ${error.message}</p>
|
|
</div>
|
|
<p><a href="/">Try again</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
return sendHtml(res, 500, html);
|
|
}
|
|
}
|
|
|
|
// 404
|
|
sendHtml(res, 404, "<h1>Not Found</h1><p><a href='/'>Go home</a></p>");
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`\n🚀 Threads Auth Server running at http://localhost:${PORT}`);
|
|
console.log(`\n📋 Make sure you've set:`);
|
|
console.log(` - THREADS_APP_ID`);
|
|
console.log(` - THREADS_APP_SECRET`);
|
|
|
|
if (REDIRECT_URI.startsWith("http://")) {
|
|
console.log(`\n🔒 HTTPS REQUIRED: Threads API requires HTTPS for OAuth redirects!`);
|
|
console.log(`\n Current redirect URI: ${REDIRECT_URI}`);
|
|
console.log(`\n To fix:`);
|
|
console.log(` 1. Install cloudflared: brew install cloudflared`);
|
|
console.log(` 2. Run: cloudflared tunnel --url http://localhost:${PORT}`);
|
|
console.log(` 3. Copy the HTTPS URL (e.g., https://abc123.trycloudflare.com)`);
|
|
console.log(` 4. Set: THREADS_REDIRECT_URI=https://abc123.trycloudflare.com/callback`);
|
|
console.log(` 5. Add the HTTPS URL to your Threads app's Valid OAuth Redirect URIs`);
|
|
console.log(` 6. Restart this server`);
|
|
} else {
|
|
console.log(`\n✅ Using HTTPS redirect URI: ${REDIRECT_URI}`);
|
|
}
|
|
|
|
console.log(`\n🔗 Open http://localhost:${PORT} in your browser to start!`);
|
|
console.log(`\n⚠️ Make sure your Threads app has:`);
|
|
console.log(` - Threads API product added`);
|
|
console.log(` - threads_basic and threads_content_publish permissions`);
|
|
console.log(` - OAuth Redirect URI: ${REDIRECT_URI}`);
|
|
console.log(` - Client OAuth Login: ON`);
|
|
console.log(` - Web OAuth Login: ON`);
|
|
console.log(`\n💡 Note: OAuth flow uses Threads-specific endpoints`);
|
|
console.log(`\n🔍 Debug info:`);
|
|
console.log(` - Redirect URI: ${REDIRECT_URI}`);
|
|
console.log(` - URL-encoded: ${encodeURIComponent(REDIRECT_URI)}`);
|
|
console.log(` - Make sure this EXACTLY matches what's in your Threads app settings\n`);
|
|
});
|
|
|