Files
plebandClaude Opus 5.5 a759308711 Add archive exporter for the personal BeatLeader mirror
scripts/archive is a CLI that exports BeatLeader and ScoreSaber scores,
replays, score stats, leaderboard snapshots and BeatSaver map zips into an
append-only archive. Anchor the .gitignore 'archive' rule to the repo root
so it no longer hides scripts/archive. Record phase 1 results in the plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:30:02 -08:00

193 lines
7.5 KiB
TypeScript

// BeatLeader OAuth 2.0 token chain owned by the archive sync.
//
// This chain is separate from plebsaber.stream's own login: BeatLeader rotates
// the refresh token on every refresh and invalidates the old one, so two
// consumers sharing one chain would break each other.
import * as fs from 'node:fs';
import * as http from 'node:http';
import { randomUUID } from 'node:crypto';
import { BL_API, TOKENS_FILE, USER_AGENT, clientCredentials, writeFileAtomic } from './config.ts';
const AUTHORIZE_URL = `${BL_API}/oauth2/authorize`;
const TOKEN_URL = `${BL_API}/oauth2/token`;
const SCOPES = 'profile offline_access';
// Registered redirect URIs on the OAuth app (verified 2026-11-08):
// https://plebsaber.stream/auth/beatleader/callback
// http://localhost:5173/auth/beatleader/callback
const LOOPBACK_PORT = 5173;
const LOOPBACK_REDIRECT = `http://localhost:${LOOPBACK_PORT}/auth/beatleader/callback`;
const REFRESH_TOKEN_LIFETIME_MS = 14 * 24 * 3600 * 1000;
export type TokenStore = {
access_token: string;
refresh_token: string;
/** epoch ms */
access_expires_at: number;
/** epoch ms, estimated from BeatLeader's 14-day refresh token lifetime */
refresh_expires_at: number;
/** epoch ms of the last token grant/refresh */
updated_at: number;
player_id?: string;
player_name?: string;
};
type TokenResponse = { access_token: string; refresh_token?: string; expires_in?: number; token_type?: string };
export function readTokens(): TokenStore | null {
try {
return JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8')) as TokenStore;
} catch {
return null;
}
}
function saveTokens(res: TokenResponse, previous: TokenStore | null, identity?: { id: string; name?: string }): TokenStore {
if (!res.refresh_token) {
throw new Error('Token response has no refresh_token; is offline_access granted?');
}
const now = Date.now();
const store: TokenStore = {
access_token: res.access_token,
refresh_token: res.refresh_token,
access_expires_at: now + (res.expires_in ?? 3600) * 1000,
refresh_expires_at: now + REFRESH_TOKEN_LIFETIME_MS,
updated_at: now,
player_id: identity?.id ?? previous?.player_id,
player_name: identity?.name ?? previous?.player_name
};
// Persist the rotated refresh token before anything uses the new access token.
writeFileAtomic(TOKENS_FILE, JSON.stringify(store, null, 2) + '\n', 0o600);
return store;
}
async function tokenRequest(params: Record<string, string>): Promise<TokenResponse> {
const { clientId, clientSecret } = clientCredentials();
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': USER_AGENT },
body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, ...params })
});
const text = await res.text();
if (!res.ok) throw new Error(`Token request failed: HTTP ${res.status} ${text.slice(0, 300)}`);
return JSON.parse(text) as TokenResponse;
}
export async function fetchIdentity(accessToken: string): Promise<{ id: string; name?: string }> {
const res = await fetch(`${BL_API}/oauth2/identity`, {
headers: { Authorization: `Bearer ${accessToken}`, 'User-Agent': USER_AGENT }
});
if (!res.ok) throw new Error(`Identity request failed: HTTP ${res.status}`);
const json = (await res.json()) as { id?: string; name?: string };
if (!json.id) throw new Error('Identity response has no id');
return { id: json.id, name: json.name };
}
/** Wait for the OAuth redirect on the loopback port, or for a pasted redirect URL on stdin. */
function waitForCode(state: string): Promise<string> {
return new Promise((resolve, reject) => {
let done = false;
const finish = (err: Error | null, code?: string) => {
if (done) return;
done = true;
server.close();
process.stdin.pause();
if (err) reject(err);
else resolve(code!);
};
const fromUrl = (raw: string): string => {
const url = new URL(raw, LOOPBACK_REDIRECT);
const error = url.searchParams.get('error');
if (error) throw new Error(`Authorization denied: ${error} ${url.searchParams.get('error_description') ?? ''}`);
const code = url.searchParams.get('code');
if (!code) throw new Error('Redirect has no code parameter');
if (url.searchParams.get('state') !== state) throw new Error('State mismatch; start the login again');
return code;
};
const server = http.createServer((req, res) => {
if (!req.url?.startsWith('/auth/beatleader/callback')) {
res.writeHead(404).end();
return;
}
try {
const code = fromUrl(req.url);
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('plebleader archive: BeatLeader authorization received. You can close this tab.\n');
finish(null, code);
} catch (err) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`plebleader archive: ${(err as Error).message}\n`);
finish(err as Error);
}
});
server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${LOOPBACK_PORT} is busy (is \`vite dev\` running?). Paste the redirected URL below instead.`);
} else {
finish(err);
}
});
server.listen(LOOPBACK_PORT, 'localhost');
// Fallback: paste the full redirected URL (e.g. when the browser is on another machine).
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk: string) => {
const line = chunk.trim();
if (!line) return;
try {
finish(null, fromUrl(line));
} catch (err) {
console.error((err as Error).message);
}
});
process.stdin.resume();
});
}
export async function login(): Promise<TokenStore> {
const { clientId } = clientCredentials();
const state = randomUUID();
const url = new URL(AUTHORIZE_URL);
url.searchParams.set('client_id', clientId);
url.searchParams.set('scope', SCOPES);
url.searchParams.set('response_type', 'code');
url.searchParams.set('redirect_uri', LOOPBACK_REDIRECT);
url.searchParams.set('prompt', 'consent');
url.searchParams.set('state', state);
console.log('Open this URL in a browser on this machine and approve access:\n');
console.log(` ${url.toString()}\n`);
console.log(`Listening on ${LOOPBACK_REDIRECT}`);
console.log('If the browser is on another machine, paste the URL it was redirected to (the page will fail to load) and press Enter.\n');
const code = await waitForCode(state);
const res = await tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: LOOPBACK_REDIRECT });
// Save immediately so the refresh token survives even if the identity lookup fails.
let store = saveTokens(res, readTokens());
const identity = await fetchIdentity(store.access_token);
store = { ...store, player_id: identity.id, player_name: identity.name };
writeFileAtomic(TOKENS_FILE, JSON.stringify(store, null, 2) + '\n', 0o600);
return store;
}
export async function refresh(): Promise<TokenStore> {
const current = readTokens();
if (!current?.refresh_token) {
throw new Error(`No refresh token in ${TOKENS_FILE}. Run \`auth login\` first.`);
}
try {
const res = await tokenRequest({ grant_type: 'refresh_token', refresh_token: current.refresh_token });
return saveTokens(res, current);
} catch (err) {
throw new Error(`${(err as Error).message}\nThe refresh chain is broken. Run \`node scripts/archive/cli.ts auth login\` to seed it again.`);
}
}
/** A valid access token, refreshing (and rotating the stored refresh token) when needed. */
export async function getAccessToken(minValidityMs = 5 * 60 * 1000): Promise<string> {
const current = readTokens();
if (current && current.access_expires_at - Date.now() > minValidityMs) return current.access_token;
return (await refresh()).access_token;
}