import { Router } from "express"; import { db, configTable, geminiAccountsTable } from "@workspace/db"; import { eq, asc, isNull, and, notInArray } from "drizzle-orm"; import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto"; import { getTurnstileToken, invalidateTurnstileToken } from "../captcha.js"; import { generateGuardId } from "../guardId.js"; const router = Router(); const TOKEN_KEY = "geminigen_bearer_token"; const REFRESH_TOKEN_KEY = "geminigen_refresh_token"; const USERNAME_KEY = "geminigen_username"; const PASSWORD_KEY = "geminigen_password_enc"; const GEMINIGEN_BASE = "https://api.geminigen.ai/api"; const GEMINIGEN_REFRESH_URL = `${GEMINIGEN_BASE}/refresh-token`; const GEMINIGEN_SIGNIN_URL = `${GEMINIGEN_BASE}/login-v2`; const UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1"; // ── Encryption helpers (AES-256-GCM) ───────────────────────────────────────── function getEncKey(): Buffer { const secret = process.env.SESSION_SECRET || "starforge-default-secret-change-me"; return scryptSync(secret, "starforge-salt", 32); } export function encrypt(text: string): string { const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", getEncKey(), iv); const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); return iv.toString("hex") + ":" + tag.toString("hex") + ":" + enc.toString("hex"); } export function decrypt(encoded: string): string { const [ivHex, tagHex, encHex] = encoded.split(":"); const decipher = createDecipheriv("aes-256-gcm", getEncKey(), Buffer.from(ivHex, "hex")); decipher.setAuthTag(Buffer.from(tagHex, "hex")); return decipher.update(Buffer.from(encHex, "hex")).toString("utf8") + decipher.final("utf8"); } // ── DB helpers ──────────────────────────────────────────────────────────────── async function getConfig(key: string): Promise { const row = await db.select().from(configTable).where(eq(configTable.key, key)).limit(1); return row.length > 0 ? row[0].value : null; } async function setConfig(key: string, value: string): Promise { await db .insert(configTable) .values({ key, value, updatedAt: new Date() }) .onConflictDoUpdate({ target: configTable.key, set: { value, updatedAt: new Date() } }); } // ── Public helpers ──────────────────────────────────────────────────────────── export async function getStoredToken(): Promise { return getConfig(TOKEN_KEY); } export async function getStoredRefreshToken(): Promise { return getConfig(REFRESH_TOKEN_KEY); } /** * On-demand token getter that proactively refreshes if the stored token is * older than 50 minutes. Safe to call in serverless environments where there * is no background refresh loop running. */ export async function getValidBearerToken(): Promise { const rows = await db .select() .from(configTable) .where(eq(configTable.key, TOKEN_KEY)) .limit(1); if (!rows.length) return null; const row = rows[0]; const ageMs = Date.now() - new Date(row.updatedAt).getTime(); const fiftyMinutes = 50 * 60 * 1000; if (ageMs > fiftyMinutes) { console.log("[token] Token age > 50 min, proactively refreshing..."); const fresh = await refreshAccessToken(); if (fresh) return fresh; } return row.value; } export async function getStoredCredentials(): Promise<{ username: string; password: string } | null> { const [username, passwordEnc] = await Promise.all([getConfig(USERNAME_KEY), getConfig(PASSWORD_KEY)]); if (!username || !passwordEnc) return null; try { return { username, password: decrypt(passwordEnc) }; } catch { return null; } } // ── Login with email/password → get fresh access+refresh tokens ─────────────── async function loginWithCredentials(): Promise<{ accessToken: string; refreshToken: string } | null> { const creds = await getStoredCredentials(); if (!creds) return null; try { console.log("[token-refresh] Trying credential re-login via /login-v2..."); // /api/login-v2 requires: username, password, turnstile_token (JSON body) const turnstileToken = await getTurnstileToken(); const resp = await fetch(GEMINIGEN_SIGNIN_URL, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": UA, "x-guard-id": generateGuardId("/api/login-v2", "post"), }, body: JSON.stringify({ username: creds.username, password: creds.password, turnstile_token: turnstileToken }), }); const text = await resp.text(); console.log(`[token-refresh] signin → HTTP ${resp.status}: ${text.slice(0, 200)}`); if (!resp.ok) { invalidateTurnstileToken(); // force fresh token next attempt return null; } const data = JSON.parse(text) as { access_token?: string; refresh_token?: string }; if (!data.access_token) return null; return { accessToken: data.access_token, refreshToken: data.refresh_token || "" }; } catch (e: any) { console.log(`[token-refresh] signin threw: ${e?.message}`); return null; } } // ── Core: refresh access token (with auto-login fallback) ───────────────────── export async function refreshAccessToken(): Promise { const refreshToken = await getStoredRefreshToken(); // ① Try native refresh endpoint if we have a refresh_token if (refreshToken) { try { console.log("[token-refresh] Trying refresh_token endpoint..."); const resp = await fetch(GEMINIGEN_REFRESH_URL, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": UA, "x-guard-id": generateGuardId("/api/refresh-token", "post"), }, body: JSON.stringify({ refresh_token: refreshToken }), }); const text = await resp.text(); console.log(`[token-refresh] refresh → HTTP ${resp.status}: ${text.slice(0, 200)}`); if (resp.ok) { const data = JSON.parse(text) as { access_token?: string; refresh_token?: string }; if (data.access_token) { await setConfig(TOKEN_KEY, data.access_token); if (data.refresh_token) await setConfig(REFRESH_TOKEN_KEY, data.refresh_token); console.log("[token-refresh] Success via refresh_token"); return data.access_token; } } const isExpired = text.includes("REFRESH_TOKEN_EXPIRED") || text.includes("expired"); if (!isExpired) { console.warn("[token-refresh] Refresh failed for unknown reason — won't try credentials"); return null; } console.log("[token-refresh] Refresh token expired — falling back to credential login"); } catch (e: any) { console.log(`[token-refresh] refresh threw: ${e?.message}`); } } else { console.log("[token-refresh] No refresh token stored — trying credential login directly"); } // ② Fallback: login with stored email/password const result = await loginWithCredentials(); if (!result) { console.warn("[token-refresh] All strategies failed — no credentials stored or login failed"); return null; } await setConfig(TOKEN_KEY, result.accessToken); if (result.refreshToken) await setConfig(REFRESH_TOKEN_KEY, result.refreshToken); console.log("[token-refresh] Success via credential re-login"); return result.accessToken; } // ── Token Pool helpers ──────────────────────────────────────────────────────── const GEMINIGEN_REFRESH = "https://api.geminigen.ai/api/refresh-token"; /** Get the least-recently-used active account from the pool. Returns null if pool is empty. */ export async function getPoolToken(excludeIds: number[] = []): Promise<{ token: string; accountId: number } | null> { const query = db .select() .from(geminiAccountsTable) .where( excludeIds.length > 0 ? and(eq(geminiAccountsTable.isActive, true), notInArray(geminiAccountsTable.id, excludeIds)) : eq(geminiAccountsTable.isActive, true) ) .orderBy(asc(geminiAccountsTable.lastUsedAt)) .limit(1); const rows = await query; if (!rows.length) return null; const account = rows[0]; return { token: account.bearerToken, accountId: account.id }; } /** Mark an account as just used (update lastUsedAt). */ export async function markAccountUsed(accountId: number): Promise { await db .update(geminiAccountsTable) .set({ lastUsedAt: new Date() }) .where(eq(geminiAccountsTable.id, accountId)); } /** Try to refresh a specific pool account's token via its refresh_token. Returns new token or null. */ export async function tryRefreshPoolAccount(accountId: number): Promise { const rows = await db.select().from(geminiAccountsTable).where(eq(geminiAccountsTable.id, accountId)).limit(1); if (!rows.length) return null; const account = rows[0]; if (!account.refreshToken) return null; try { const resp = await fetch(GEMINIGEN_REFRESH, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": UA, "x-guard-id": generateGuardId("/api/refresh-token", "post"), }, body: JSON.stringify({ refresh_token: account.refreshToken }), }); if (!resp.ok) return null; const data = await resp.json() as { access_token?: string; refresh_token?: string }; if (!data.access_token) return null; await db.update(geminiAccountsTable).set({ bearerToken: data.access_token, ...(data.refresh_token ? { refreshToken: data.refresh_token } : {}), }).where(eq(geminiAccountsTable.id, accountId)); console.log(`[pool] Account ${accountId} token refreshed`); return data.access_token; } catch { return null; } } /** Disable an account in the pool (e.g., when all refresh strategies fail). */ export async function disablePoolAccount(accountId: number): Promise { await db.update(geminiAccountsTable).set({ isActive: false }).where(eq(geminiAccountsTable.id, accountId)); console.warn(`[pool] Account ${accountId} disabled — token unrecoverable`); } // ── Routes ──────────────────────────────────────────────────────────────────── router.get("/token", async (_req, res) => { const token = await getConfig(TOKEN_KEY); if (!token) return res.json({ configured: false, token: null }); return res.json({ configured: true, token: token.substring(0, 10) + "..." }); }); router.post("/token", async (req, res) => { const { token, refreshToken } = req.body as { token?: string; refreshToken?: string }; if (!token?.trim()) return res.status(400).json({ error: "INVALID_TOKEN", message: "Token is required" }); await setConfig(TOKEN_KEY, token.trim()); if (refreshToken?.trim()) await setConfig(REFRESH_TOKEN_KEY, refreshToken.trim()); res.json({ success: true, message: "Token saved successfully" }); }); router.delete("/token", async (_req, res) => { await db.delete(configTable).where(eq(configTable.key, TOKEN_KEY)); await db.delete(configTable).where(eq(configTable.key, REFRESH_TOKEN_KEY)); res.json({ success: true, message: "Token removed" }); }); export default router;