// One-time local OAuth2 PKCE helper. Mints a correctly-scoped X refresh token and // seeds it (+ user id) into D1. Zero dependencies — Node 18+ built-ins only. // // Prereq: add this exact Callback URL to your X app's user-auth settings: // http://127.0.0.1:8788/callback // // Run: node scripts/mint-token.mjs (reads X_CLIENT_ID/SECRET from env or .dev.vars.example) import http from "node:http"; import crypto from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; const root = new URL("..", import.meta.url); const envText = (() => { try { return readFileSync(new URL(".dev.vars.example", root), "utf8"); } catch { return ""; } })(); const cfg = (k) => process.env[k] ?? envText.match(new RegExp(`^${k}="?([^"\\n]+)"?`, "m"))?.[1]; const CLIENT_ID = cfg("X_CLIENT_ID"); const CLIENT_SECRET = cfg("X_CLIENT_SECRET"); if (!CLIENT_ID || !CLIENT_SECRET) { console.error("Missing X_CLIENT_ID / X_CLIENT_SECRET (env or .dev.vars.example)."); process.exit(1); } const PORT = 8788; const REDIRECT = `http://127.0.0.1:${PORT}/callback`; const SCOPES = "tweet.read users.read bookmark.read offline.access"; const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const verifier = b64url(crypto.randomBytes(32)); const challenge = b64url(crypto.createHash("sha256").update(verifier).digest()); const state = b64url(crypto.randomBytes(16)); const authUrl = "https://twitter.com/i/oauth2/authorize?" + new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT, scope: SCOPES, state, code_challenge: challenge, code_challenge_method: "S256", }); writeFileSync(new URL(".authorize_url.txt", root), authUrl); console.log(`\nCallback URL to register on your X app:\n ${REDIRECT}\n`); console.log(`Open this URL and Authorize:\n ${authUrl}\n`); const done = (code) => setTimeout(() => { server.close(); process.exit(code); }, 300); const server = http.createServer(async (req, res) => { const u = new URL(req.url, REDIRECT); if (u.pathname !== "/callback") { res.writeHead(404); res.end(); return; } const code = u.searchParams.get("code"); if (u.searchParams.get("state") !== state || !code) { res.writeHead(400); res.end("bad state/code"); return; } try { const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"); const tokRes = await fetch("https://api.x.com/2/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${basic}` }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT, code_verifier: verifier, client_id: CLIENT_ID }), }); const tok = await tokRes.json(); if (!tok.access_token || !tok.refresh_token) throw new Error("token exchange failed: " + JSON.stringify(tok)); if (!String(tok.scope || "").includes("bookmark.read")) throw new Error("scope missing bookmark.read: " + tok.scope); const me = await (await fetch("https://api.x.com/2/users/me", { headers: { Authorization: `Bearer ${tok.access_token}` } })).json(); const userId = me?.data?.id; if (!userId) throw new Error("/users/me failed: " + JSON.stringify(me)); const sql = `INSERT INTO meta (key,value) VALUES ('refresh_token','${tok.refresh_token}') ON CONFLICT(key) DO UPDATE SET value=excluded.value;` + `INSERT INTO meta (key,value) VALUES ('user_id','${userId}') ON CONFLICT(key) DO UPDATE SET value=excluded.value;` + `DELETE FROM meta WHERE key='last_sync';`; execFileSync("npx", ["wrangler", "d1", "execute", "x-bookmarks-db", "--remote", "--command", sql], { stdio: "inherit", cwd: new URL(root).pathname }); res.writeHead(200, { "Content-Type": "text/html" }); res.end(`
scope: ${tok.scope}
Seeded into D1 — close this tab.
`); console.log(`\n✅ scope: ${tok.scope}\n✅ user id: ${userId}\n✅ seeded refresh_token + user_id into D1.`); done(0); } catch (e) { res.writeHead(500); res.end(String(e?.message || e)); console.error("\n❌ " + (e?.message || e)); done(1); } }); server.listen(PORT, "127.0.0.1", () => console.log(`Waiting for authorization on ${REDIRECT} ...`));