diff --git a/scripts/mint-token.mjs b/scripts/mint-token.mjs new file mode 100644 index 0000000..b0d1645 --- /dev/null +++ b/scripts/mint-token.mjs @@ -0,0 +1,89 @@ +// 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} ...`)); diff --git a/scripts/seed-token.sh b/scripts/seed-token.sh new file mode 100644 index 0000000..9f7bd6d --- /dev/null +++ b/scripts/seed-token.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Seed a fresh X OAuth2 refresh token into D1. Usage: scripts/seed-token.sh