flybrain/services/bridge/tools/authorize.mts
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

172 lines
6.7 KiB
TypeScript
Executable file

#!/usr/bin/env -S npx tsx
/**
* One-time interactive authorization-code flow (`docs/design/stage-bridge.md` B1), run by hand
* on the WSL box to produce `tokens.json`. Twitch permits `http://localhost` redirects, so this
* spins up a temporary listener on `http://localhost:3000/callback`, prints the authorize URL to
* open in a browser, and on the callback exchanges the code for tokens via `exchangeCode` and
* writes them into `tokens.json` (atomic, 0600).
*
* Run once per identity — bot and broadcaster are different Twitch accounts, so you log in as
* each in turn:
*
* ```sh
* TWITCH_CLIENT_ID=... TWITCH_CLIENT_SECRET=... \
* npx tsx tools/authorize.mts --role bot --tokens-file /var/lib/flybridge/tokens.json
* TWITCH_CLIENT_ID=... TWITCH_CLIENT_SECRET=... \
* npx tsx tools/authorize.mts --role broadcaster --tokens-file /var/lib/flybridge/tokens.json \
* --with-redemptions --with-predictions
* ```
*
* `--with-redemptions` / `--with-predictions` add the scopes those features need
* (`src/scopes.ts`'s `SCOPE_TABLE`) even though B1 ships with both features off, so the token
* does not need re-authorizing the day they're switched on.
*
* Device-code grant (`https://id.twitch.tv/oauth2/device`) is the documented fallback for
* headless authorization; twurple's first-class support for it is unverified as of this writing,
* so it is not implemented here — do the one-time flow from a machine with a browser instead.
*/
import { createServer } from 'node:http';
import { exchangeCode, getTokenInfo } from '@twurple/auth';
import { atomicWriteJson } from '../src/atomic-file';
import { SCOPE_TABLE, type TokenKind } from '../src/scopes';
import type { StoredToken, TokensFile } from '../src/auth';
import { readFile } from 'node:fs/promises';
const REDIRECT_URI = 'http://localhost:3000/callback';
const CALLBACK_PORT = 3000;
interface CliArgs {
role: TokenKind;
tokensFile: string;
withRedemptions: boolean;
withPredictions: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
role: 'bot',
tokensFile: process.env.TOKENS_FILE ?? '/var/lib/flybridge/tokens.json',
withRedemptions: false,
withPredictions: false,
};
for (let i = 0; i < argv.length; i++) {
switch (argv[i]) {
case '--role': {
const value = argv[++i];
if (value !== 'bot' && value !== 'broadcaster') throw new Error('--role must be "bot" or "broadcaster"');
args.role = value;
break;
}
case '--tokens-file':
args.tokensFile = argv[++i] ?? args.tokensFile;
break;
case '--with-redemptions':
args.withRedemptions = true;
break;
case '--with-predictions':
args.withPredictions = true;
break;
default:
throw new Error(`unknown argument: ${argv[i]}`);
}
}
return args;
}
function scopesForRole(role: TokenKind, withRedemptions: boolean, withPredictions: boolean): string[] {
const scopes = SCOPE_TABLE.filter((req) => req.token === role).filter(
(req) => req.requiredWhen({ featureRedemptions: withRedemptions, featurePredictions: withPredictions }),
).map((req) => req.scope);
return [...new Set(scopes)];
}
function waitForAuthorizationCode(clientId: string, scopes: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const url = new URL(req.url ?? '/', REDIRECT_URI);
if (url.pathname !== '/callback') {
res.writeHead(404).end('not found');
return;
}
const error = url.searchParams.get('error');
if (error) {
res.writeHead(400).end(`authorization failed: ${error}`);
server.close();
reject(new Error(`Twitch returned an error: ${error} (${url.searchParams.get('error_description') ?? ''})`));
return;
}
const code = url.searchParams.get('code');
if (!code) {
res.writeHead(400).end('missing code');
return;
}
res.writeHead(200, { 'content-type': 'text/plain' }).end('Authorized. You can close this tab.');
server.close();
resolve(code);
});
server.listen(CALLBACK_PORT, '127.0.0.1', () => {
const authorizeUrl = new URL('https://id.twitch.tv/oauth2/authorize');
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('scope', scopes.join(' '));
console.log('Open this URL and authorize with the correct Twitch account:\n');
console.log(` ${authorizeUrl.toString()}\n`);
console.log(`Waiting for the callback on ${REDIRECT_URI} ...`);
});
server.once('error', reject);
});
}
async function loadExistingTokens(path: string): Promise<Partial<TokensFile>> {
try {
return JSON.parse(await readFile(path, 'utf8')) as Partial<TokensFile>;
} catch (cause) {
if ((cause as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw cause;
}
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const clientId = process.env.TWITCH_CLIENT_ID;
const clientSecret = process.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error('set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET in the environment before running this tool');
}
const scopes = scopesForRole(args.role, args.withRedemptions, args.withPredictions);
console.log(`Requesting scopes for "${args.role}": ${scopes.join(', ')}`);
const code = await waitForAuthorizationCode(clientId, scopes);
const accessToken = await exchangeCode(clientId, clientSecret, code, REDIRECT_URI);
const info = await getTokenInfo(accessToken.accessToken, clientId);
if (!info.userId) throw new Error('Twitch did not return a user id for this token');
const stored: StoredToken = {
userId: info.userId,
accessToken: accessToken.accessToken,
refreshToken: accessToken.refreshToken,
scope: accessToken.scope,
expiresIn: accessToken.expiresIn,
obtainmentTimestamp: accessToken.obtainmentTimestamp,
};
const existing = await loadExistingTokens(args.tokensFile);
const merged: Partial<TokensFile> = { ...existing, [args.role]: stored };
await atomicWriteJson(args.tokensFile, merged);
console.log(`\nAuthorized as ${info.userName ?? info.userId} (${args.role}).`);
console.log(`Wrote ${args.tokensFile} (0600).`);
if (!merged.bot || !merged.broadcaster) {
const missingRole: TokenKind = args.role === 'bot' ? 'broadcaster' : 'bot';
console.log(`Still missing "${missingRole}" — run this tool again with --role ${missingRole}.`);
}
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});