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>
193 lines
7.5 KiB
TypeScript
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;
|
|
}
|