feat: connect Twungeon to live Twitch
This commit is contained in:
@@ -1,10 +1,74 @@
|
||||
export interface Config { port:number; publicBaseUrl:string; resurrectionRewardId:string; twitchEnabled:boolean; broadcasterId:string; channelLogin:string; twitchClientId:string; twitchAccessToken:string; extensionSecret:string }
|
||||
export function loadConfig(env:NodeJS.ProcessEnv=process.env):Config{
|
||||
const port=Number(env.PORT??3000),twitchEnabled=env.TWITCH_ENABLED==='true',resurrectionRewardId=env.CHANNEL_POINTS_RESURRECTION_REWARD_ID??'local-resurrection'
|
||||
const missing:string[]=[]
|
||||
if(!Number.isInteger(port)||port<1||port>65535)throw new Error('PORT must be an integer from 1 to 65535')
|
||||
if(!resurrectionRewardId.trim())throw new Error('CHANNEL_POINTS_RESURRECTION_REWARD_ID is required')
|
||||
if(twitchEnabled)for(const key of ['TWITCH_CLIENT_ID','TWITCH_CLIENT_SECRET','TWITCH_BROADCASTER_ID','TWITCH_CHANNEL_LOGIN','TWITCH_BOT_ACCESS_TOKEN','TWITCH_EXTENSION_SECRET','CHANNEL_POINTS_RESURRECTION_REWARD_ID'])if(!env[key])missing.push(key)
|
||||
if(missing.length)throw new Error(`Missing required Twitch configuration: ${missing.join(', ')}`)
|
||||
return {port,publicBaseUrl:env.PUBLIC_BASE_URL??`http://localhost:${port}`,resurrectionRewardId,twitchEnabled,broadcasterId:env.TWITCH_BROADCASTER_ID??'local-broadcaster',channelLogin:env.TWITCH_CHANNEL_LOGIN??'local-channel',twitchClientId:env.TWITCH_CLIENT_ID??'',twitchAccessToken:env.TWITCH_BOT_ACCESS_TOKEN??'',extensionSecret:env.TWITCH_EXTENSION_SECRET??''}
|
||||
export interface Config {
|
||||
port: number
|
||||
publicBaseUrl: string
|
||||
resurrectionRewardId: string
|
||||
twitchEnabled: boolean
|
||||
broadcasterId: string
|
||||
channelLogin: string
|
||||
twitchClientId: string
|
||||
twitchClientSecret: string
|
||||
twitchRedirectUri: string
|
||||
twitchTokenFile: string
|
||||
extensionSecret: string
|
||||
oauthConfigured: boolean
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
const port = Number(env.PORT ?? 3000)
|
||||
const publicBaseUrl = env.PUBLIC_BASE_URL ?? `http://localhost:${port}`
|
||||
const twitchEnabled = env.TWITCH_ENABLED === 'true'
|
||||
const resurrectionRewardId = env.CHANNEL_POINTS_RESURRECTION_REWARD_ID ?? 'local-resurrection'
|
||||
const twitchClientId = env.TWITCH_CLIENT_ID ?? ''
|
||||
const twitchClientSecret = env.TWITCH_CLIENT_SECRET ?? ''
|
||||
const channelLogin = env.TWITCH_CHANNEL_LOGIN ?? 'local-channel'
|
||||
const twitchRedirectUri = env.TWITCH_OAUTH_REDIRECT_URI ?? `${publicBaseUrl}/oauth/callback`
|
||||
const twitchTokenFile = env.TWITCH_TOKEN_FILE ?? '/var/lib/twungeon/twitch-token.json'
|
||||
const oauthConfigured = Boolean(twitchClientId && twitchClientSecret && env.TWITCH_CHANNEL_LOGIN)
|
||||
const missing: string[] = []
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('PORT must be an integer from 1 to 65535')
|
||||
}
|
||||
if (!resurrectionRewardId.trim()) {
|
||||
throw new Error('CHANNEL_POINTS_RESURRECTION_REWARD_ID is required')
|
||||
}
|
||||
try {
|
||||
new URL(publicBaseUrl)
|
||||
new URL(twitchRedirectUri)
|
||||
} catch {
|
||||
throw new Error('PUBLIC_BASE_URL and TWITCH_OAUTH_REDIRECT_URI must be valid absolute URLs')
|
||||
}
|
||||
if (Boolean(twitchClientId) !== Boolean(twitchClientSecret)) {
|
||||
throw new Error('TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET must be configured together')
|
||||
}
|
||||
if (twitchEnabled) {
|
||||
for (const key of [
|
||||
'TWITCH_CLIENT_ID',
|
||||
'TWITCH_CLIENT_SECRET',
|
||||
'TWITCH_BROADCASTER_ID',
|
||||
'TWITCH_CHANNEL_LOGIN',
|
||||
'TWITCH_EXTENSION_SECRET',
|
||||
'CHANNEL_POINTS_RESURRECTION_REWARD_ID'
|
||||
]) {
|
||||
if (!env[key]) missing.push(key)
|
||||
}
|
||||
}
|
||||
if (missing.length) {
|
||||
throw new Error(`Missing required Twitch configuration: ${missing.join(', ')}`)
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
publicBaseUrl,
|
||||
resurrectionRewardId,
|
||||
twitchEnabled,
|
||||
broadcasterId: env.TWITCH_BROADCASTER_ID ?? 'local-broadcaster',
|
||||
channelLogin,
|
||||
twitchClientId,
|
||||
twitchClientSecret,
|
||||
twitchRedirectUri,
|
||||
twitchTokenFile,
|
||||
extensionSecret: env.TWITCH_EXTENSION_SECRET ?? '',
|
||||
oauthConfigured
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,18 @@ import { Game } from '../../../packages/domain/src/index.js'
|
||||
import { generateFloor } from '../../../packages/dungeon-generator/src/index.js'
|
||||
import { LiveTwitchAdapter, SyntheticTwitchAdapter } from '../../../packages/twitch-adapter/src/index.js'
|
||||
import { loadConfig } from './config.js'
|
||||
import { TwitchOAuthService } from './twitchOAuth.js'
|
||||
|
||||
const config=loadConfig(), startedAt=Date.now();let id=0
|
||||
const game=new Game({clock:{now:()=>Date.now()},random:{next:()=>Math.random()},ids:{next:p=>`${p}-${++id}`},generateFloor,resurrectionRewardId:config.resurrectionRewardId})
|
||||
const twitch=config.twitchEnabled?new LiveTwitchAdapter({clientId:config.twitchClientId,accessToken:config.twitchAccessToken,broadcasterId:config.broadcasterId,channelLogin:config.channelLogin,extensionSecret:config.extensionSecret,resurrectionRewardId:config.resurrectionRewardId}):new SyntheticTwitchAdapter(config.broadcasterId)
|
||||
const oauth=config.oauthConfigured?new TwitchOAuthService({clientId:config.twitchClientId,clientSecret:config.twitchClientSecret,redirectUri:config.twitchRedirectUri,expectedLogin:config.channelLogin,tokenFile:config.twitchTokenFile}):null
|
||||
const twitch=config.twitchEnabled?new LiveTwitchAdapter({clientId:config.twitchClientId,clientSecret:config.twitchClientSecret,tokenFile:config.twitchTokenFile,broadcasterId:config.broadcasterId,channelLogin:config.channelLogin,extensionSecret:config.extensionSecret,resurrectionRewardId:config.resurrectionRewardId}):new SyntheticTwitchAdapter(config.broadcasterId)
|
||||
const sessions=new Map<string,{userId:string;expiresAt:number}>()
|
||||
const clients=new Map<WebSocket,string|null>()
|
||||
const publicDir=join(dirname(fileURLToPath(import.meta.url)),'../../stream-view/public')
|
||||
|
||||
function json(res:ServerResponse,status:number,body:unknown){const data=JSON.stringify(body);res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(data)}
|
||||
function html(res:ServerResponse,status:number,title:string,message:string){const escape=(value:string)=>value.replace(/[&<>"']/g,character=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]!));res.writeHead(status,{'content-type':'text/html; charset=utf-8','cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",'x-content-type-options':'nosniff'});res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(title)}</title><body style="font:16px system-ui;max-width:48rem;margin:4rem auto;padding:0 1rem;background:#111;color:#eee"><h1>${escape(title)}</h1><p>${escape(message)}</p></body></html>`)}
|
||||
async function body(req:IncomingMessage):Promise<unknown>{const chunks:Buffer[]=[];for await(const chunk of req)chunks.push(Buffer.from(chunk));if(chunks.reduce((n,b)=>n+b.length,0)>64_000)throw new Error('Request too large');return JSON.parse(Buffer.concat(chunks).toString('utf8')||'{}')}
|
||||
function bearer(req:IncomingMessage):string|null{const h=req.headers.authorization;return h?.startsWith('Bearer ')?h.slice(7):null}
|
||||
function sessionUser(token:string|null):string|null{if(!token)return null;const session=sessions.get(token);if(!session||session.expiresAt<=Date.now()){if(session)sessions.delete(token);return null}return session.userId}
|
||||
@@ -25,7 +28,27 @@ function broadcast(){const sequence=game.snapshot().nextEventSequence-1;for(cons
|
||||
export const server=createServer(async(req,res)=>{
|
||||
try{
|
||||
const url=new URL(req.url??'/',config.publicBaseUrl)
|
||||
if(req.method==='GET'&&url.pathname==='/health')return json(res,200,{status:'ok',ready:twitch.ready,uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchMode:config.twitchEnabled?'configured':'synthetic'})
|
||||
if(req.method==='GET'&&url.pathname==='/health')return json(res,200,{status:'ok',ready:twitch.ready,uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchMode:config.twitchEnabled?'configured':'synthetic',oauthConfigured:config.oauthConfigured})
|
||||
if(req.method==='GET'&&url.pathname==='/oauth/status')return json(res,200,oauth?await oauth.status():{configured:false,authorized:false,redirectUri:config.twitchRedirectUri})
|
||||
if(req.method==='GET'&&url.pathname==='/oauth/login'){
|
||||
if(!oauth)return html(res,503,'Twitch OAuth is not configured','Configure the Twitch Client ID, Client Secret, and channel login on the server first.')
|
||||
try{res.writeHead(302,{location:oauth.startAuthorization(),'cache-control':'no-store'});return res.end()}
|
||||
catch(error){return html(res,429,'Twitch OAuth is temporarily unavailable',error instanceof Error?error.message:'Try again later.')}
|
||||
}
|
||||
if(req.method==='GET'&&url.pathname==='/oauth/callback'){
|
||||
if(!oauth)return html(res,503,'Twitch OAuth is not configured','Configure Twitch OAuth on the server and try again.')
|
||||
const oauthError=url.searchParams.get('error_description')??url.searchParams.get('error')
|
||||
if(oauthError)return html(res,400,'Twitch authorization was declined',oauthError)
|
||||
try{
|
||||
const result=await oauth.completeAuthorization(url.searchParams.get('code')??'',url.searchParams.get('state')??'')
|
||||
console.log(JSON.stringify({level:'info',component:'twitch-oauth',message:'Broadcaster authorization stored',userId:result.userId,login:result.login,scopes:result.scopes}))
|
||||
return html(res,200,'Twungeon is authorized',`Authorized Twitch broadcaster ${result.login} (${result.userId}). You may close this page and continue setup.`)
|
||||
}catch(error){
|
||||
const message=error instanceof Error?error.message:'Twitch authorization failed.'
|
||||
console.error(JSON.stringify({level:'error',component:'twitch-oauth',message}))
|
||||
return html(res,400,'Twitch authorization failed',message)
|
||||
}
|
||||
}
|
||||
if(req.method==='GET'&&url.pathname==='/api/state')return json(res,200,game.snapshot())
|
||||
if(req.method==='POST'&&url.pathname==='/api/extension/session'){
|
||||
const data=await body(req) as any,identity=await twitch.verifyExtensionToken(String(data.token??''))
|
||||
@@ -33,6 +56,11 @@ export const server=createServer(async(req,res)=>{
|
||||
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId)
|
||||
return json(res,200,{token,twitchUserId:identity.twitchUserId,state:game.personalizedSnapshot(identity.twitchUserId)})
|
||||
}
|
||||
if(req.method==='POST'&&url.pathname==='/api/extension/spawn'){
|
||||
const userId=sessionUser(bearer(req));if(!userId)return json(res,401,{error:'UNAUTHENTICATED'})
|
||||
const message=await twitch.createSpawn(userId,`extension-spawn-${crypto.randomUUID()}`);if(!message)return json(res,503,{error:'TWITCH_UNAVAILABLE'})
|
||||
const result=game.spawn(message);if(result.accepted)broadcast();return json(res,result.accepted?200:409,{...result,state:game.personalizedSnapshot(userId)})
|
||||
}
|
||||
if(req.method==='POST'&&url.pathname==='/api/commands'){
|
||||
const token=bearer(req),userId=sessionUser(token),parsed=CommandEnvelopeSchema.safeParse(await body(req))
|
||||
if(!parsed.success)return json(res,400,{error:'INVALID_COMMAND',issues:parsed.error.issues.map(i=>({path:i.path,message:i.message}))})
|
||||
@@ -40,9 +68,10 @@ export const server=createServer(async(req,res)=>{
|
||||
}
|
||||
if(req.method==='POST'&&url.pathname==='/api/dev/spawn'&&!config.twitchEnabled){const msg=await twitch.normalizeSpawn(await body(req));if(!msg)return json(res,400,{error:'INVALID_SPAWN'});const result=game.spawn(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
|
||||
if(req.method==='POST'&&url.pathname==='/api/dev/redemption'&&!config.twitchEnabled){const msg=await twitch.normalizeRedemption(await body(req));if(!msg)return json(res,400,{error:'INVALID_REDEMPTION'});const result=game.resurrect(msg);if(result.accepted)broadcast();return json(res,result.accepted?200:409,result)}
|
||||
if(req.method==='GET'&&(url.pathname==='/'||url.pathname==='/extension')){const html=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8'});return res.end(html)}
|
||||
if(req.method==='GET'&&url.pathname==='/app.js'){const js=await readFile(join(publicDir,'app.js'));res.writeHead(200,{'content-type':'text/javascript; charset=utf-8'});return res.end(js)}
|
||||
if(req.method==='GET'&&url.pathname==='/styles.css'){const css=await readFile(join(publicDir,'styles.css'));res.writeHead(200,{'content-type':'text/css; charset=utf-8'});return res.end(css)}
|
||||
if(req.method==='GET'&&(url.pathname==='/'||url.pathname==='/extension')){const html=await readFile(join(publicDir,'index.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(html)}
|
||||
if(req.method==='GET'&&url.pathname==='/privacy.html'){const html=await readFile(join(publicDir,'privacy.html'));res.writeHead(200,{'content-type':'text/html; charset=utf-8','cache-control':'no-store'});return res.end(html)}
|
||||
if(req.method==='GET'&&url.pathname==='/app.js'){const js=await readFile(join(publicDir,'app.js'));res.writeHead(200,{'content-type':'text/javascript; charset=utf-8','cache-control':'no-store'});return res.end(js)}
|
||||
if(req.method==='GET'&&url.pathname==='/styles.css'){const css=await readFile(join(publicDir,'styles.css'));res.writeHead(200,{'content-type':'text/css; charset=utf-8','cache-control':'no-store'});return res.end(css)}
|
||||
json(res,404,{error:'NOT_FOUND'})
|
||||
}catch(error){console.error(JSON.stringify({level:'error',message:error instanceof Error?error.message:'Unexpected error'}));json(res,500,{error:'INTERNAL_ERROR'})}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
REQUIRED_TWITCH_SCOPES,
|
||||
storedTwitchTokenExists,
|
||||
writeStoredTwitchToken
|
||||
} from '../../../packages/twitch-adapter/src/tokenStore.js'
|
||||
|
||||
const TokenResponseSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
refresh_token: z.string().min(1),
|
||||
expires_in: z.number().int().nonnegative(),
|
||||
scope: z.array(z.string()),
|
||||
token_type: z.string()
|
||||
})
|
||||
|
||||
const ValidationResponseSchema = z.object({
|
||||
client_id: z.string().min(1),
|
||||
login: z.string().min(1),
|
||||
scopes: z.array(z.string()),
|
||||
user_id: z.string().min(1),
|
||||
expires_in: z.number().int().nonnegative()
|
||||
})
|
||||
|
||||
export interface TwitchOAuthConfig {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
expectedLogin: string
|
||||
tokenFile: string
|
||||
}
|
||||
|
||||
export interface TwitchOAuthResult {
|
||||
login: string
|
||||
userId: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
export interface TwitchOAuthStatus {
|
||||
configured: boolean
|
||||
authorized: boolean
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
export class TwitchOAuthService {
|
||||
private readonly states = new Map<string, number>()
|
||||
|
||||
constructor(
|
||||
private readonly config: TwitchOAuthConfig,
|
||||
private readonly fetchImplementation: typeof fetch = fetch,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
startAuthorization(): string {
|
||||
this.removeExpiredStates()
|
||||
if (this.states.size >= 100) {
|
||||
throw new Error('Too many OAuth attempts are pending. Wait ten minutes and try again.')
|
||||
}
|
||||
const state = randomBytes(32).toString('base64url')
|
||||
this.states.set(state, this.now() + 10 * 60_000)
|
||||
const url = new URL('https://id.twitch.tv/oauth2/authorize')
|
||||
url.searchParams.set('client_id', this.config.clientId)
|
||||
url.searchParams.set('redirect_uri', this.config.redirectUri)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('scope', REQUIRED_TWITCH_SCOPES.join(' '))
|
||||
url.searchParams.set('state', state)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async completeAuthorization(code: string, state: string): Promise<TwitchOAuthResult> {
|
||||
this.removeExpiredStates()
|
||||
const expiresAt = this.states.get(state)
|
||||
this.states.delete(state)
|
||||
if (!expiresAt || expiresAt <= this.now()) {
|
||||
throw new Error('The OAuth state is missing or expired. Start authorization again.')
|
||||
}
|
||||
if (!code) throw new Error('Twitch did not return an authorization code.')
|
||||
|
||||
const tokenResponse = await this.fetchImplementation('https://id.twitch.tv/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: this.config.redirectUri
|
||||
})
|
||||
})
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Twitch token exchange failed with HTTP ${tokenResponse.status}.`)
|
||||
}
|
||||
const token = TokenResponseSchema.parse(await tokenResponse.json())
|
||||
|
||||
const validationResponse = await this.fetchImplementation('https://id.twitch.tv/oauth2/validate', {
|
||||
headers: { authorization: `OAuth ${token.access_token}` }
|
||||
})
|
||||
if (!validationResponse.ok) {
|
||||
throw new Error(`Twitch token validation failed with HTTP ${validationResponse.status}.`)
|
||||
}
|
||||
const validation = ValidationResponseSchema.parse(await validationResponse.json())
|
||||
if (validation.client_id !== this.config.clientId) {
|
||||
throw new Error('Twitch returned a token for a different application.')
|
||||
}
|
||||
if (validation.login.toLowerCase() !== this.config.expectedLogin.toLowerCase()) {
|
||||
throw new Error(`Authorize with the configured broadcaster account: ${this.config.expectedLogin}.`)
|
||||
}
|
||||
const missingScopes = REQUIRED_TWITCH_SCOPES.filter(scope => !validation.scopes.includes(scope))
|
||||
if (missingScopes.length) {
|
||||
throw new Error(`The Twitch token is missing required scopes: ${missingScopes.join(', ')}.`)
|
||||
}
|
||||
|
||||
await writeStoredTwitchToken(this.config.tokenFile, {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
scope: validation.scopes,
|
||||
expiresIn: token.expires_in,
|
||||
obtainmentTimestamp: this.now(),
|
||||
userId: validation.user_id,
|
||||
login: validation.login,
|
||||
clientId: validation.client_id
|
||||
})
|
||||
|
||||
return { login: validation.login, userId: validation.user_id, scopes: validation.scopes }
|
||||
}
|
||||
|
||||
async status(): Promise<TwitchOAuthStatus> {
|
||||
return {
|
||||
configured: true,
|
||||
authorized: await storedTwitchTokenExists(this.config.tokenFile),
|
||||
redirectUri: this.config.redirectUri
|
||||
}
|
||||
}
|
||||
|
||||
private removeExpiredStates(): void {
|
||||
const now = this.now()
|
||||
for (const [state, expiresAt] of this.states) {
|
||||
if (expiresAt <= now) this.states.delete(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user