From df984690cedf84fc58d3eb6e2000d2df567a8d5d Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Mon, 17 Aug 2026 12:02:35 -0700 Subject: [PATCH 1/2] feat: connect Twungeon to live Twitch --- .env.example | 3 +- apps/backend/src/config.ts | 82 +++++++++++-- apps/backend/src/server.ts | 39 +++++- apps/backend/src/twitchOAuth.ts | 141 ++++++++++++++++++++++ apps/stream-view/public/app.js | 14 ++- apps/stream-view/public/index.html | 8 +- apps/stream-view/public/privacy.html | 37 ++++++ docs/twitch-setup.md | 49 ++++++-- packages/twitch-adapter/src/index.ts | 97 ++++++++++++--- packages/twitch-adapter/src/tokenStore.ts | 52 ++++++++ tests/domain/config.test.ts | 4 +- tests/domain/oauth.test.ts | 48 ++++++++ tests/integration/api.test.ts | 4 + 13 files changed, 523 insertions(+), 55 deletions(-) create mode 100644 apps/backend/src/twitchOAuth.ts create mode 100644 apps/stream-view/public/privacy.html create mode 100644 packages/twitch-adapter/src/tokenStore.ts create mode 100644 tests/domain/oauth.test.ts diff --git a/.env.example b/.env.example index 2c45578..0833fff 100644 --- a/.env.example +++ b/.env.example @@ -9,5 +9,6 @@ TWITCH_CLIENT_ID= TWITCH_CLIENT_SECRET= TWITCH_BROADCASTER_ID= TWITCH_CHANNEL_LOGIN= -TWITCH_BOT_ACCESS_TOKEN= TWITCH_EXTENSION_SECRET= +TWITCH_OAUTH_REDIRECT_URI=https://twungeon.example/oauth/callback +TWITCH_TOKEN_FILE=/var/lib/twungeon/twitch-token.json diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index ae38580..b739aff 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -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 + } } diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index 3e37d81..a51d7d0 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -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() const clients=new Map() 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(`${escape(title)}

${escape(title)}

${escape(message)}

`)} async function body(req:IncomingMessage):Promise{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'})} }) diff --git a/apps/backend/src/twitchOAuth.ts b/apps/backend/src/twitchOAuth.ts new file mode 100644 index 0000000..69cb2bb --- /dev/null +++ b/apps/backend/src/twitchOAuth.ts @@ -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() + + 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 { + 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 { + 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) + } + } +} diff --git a/apps/stream-view/public/app.js b/apps/stream-view/public/app.js index 88de552..26aa355 100644 --- a/apps/stream-view/public/app.js +++ b/apps/stream-view/public/app.js @@ -1,21 +1,23 @@ -let state=null,session=null,userId=null,lastSequence=0,activeSocket=null +let state=null,session=null,userId=null,lastSequence=0,activeSocket=null,extensionIdentityPending=false +const extensionMode=location.pathname==='/extension';document.body.classList.toggle('extension-mode',extensionMode) const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s) const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) -function disabledReason(){if(!userId)return 'Broadcast mode — log in locally to test controls.';const p=state?.players.find(x=>x.twitchUserId===userId);if(!p)return 'Type !spawn in chat first.';if(p.lifeState==='dead')return 'Your character is dead.';if(state.phase.kind!=='player')return 'Wait for Player Phase.';if(!p.ap)return 'No AP remains this phase.';return ''} +function disabledReason(){if(extensionIdentityPending)return 'Share your Twitch identity to enable controls.';if(!userId)return extensionMode?'Waiting for Twitch authorization.':'Broadcast mode — log in locally to test controls.';const p=state?.players.find(x=>x.twitchUserId===userId);if(!p)return extensionMode?'Click Spawn character to join.':'Type !spawn in chat first.';if(p.lifeState==='dead')return 'Your character is dead.';if(state.phase.kind!=='player')return 'Wait for Player Phase.';if(!p.ap)return 'No AP remains this phase.';return ''} function render(){if(!state)return;$('#floor').textContent=`Floor ${state.floorNumber}`;$('#phase').textContent=state.phase.kind;$('#banner').hidden=state.phase.kind!=='dormant' const me=state.players.find(p=>p.twitchUserId===userId),seconds=state.phase.kind==='player'?Math.max(0,Math.ceil((state.phase.deadlineAt-Date.now())/1000)):'—' $('#status').innerHTML=[['Players',state.players.filter(p=>p.lifeState==='alive').length],['Timer',seconds],['HP',me?`${me.hp}/3`:'—'],['AP',me?.ap??'—'],['Guard',me?.guard??'—'],['Heal',me?(me.healAvailable?'Ready':'Used'):'—']].map(([k,v])=>`
${k}${v}
`).join('') const map=$('#map');map.style.gridTemplateColumns=`repeat(${state.floor.width},auto)`;map.innerHTML='' for(let y=0;yp.lifeState==='alive'&&p.position.x===x&&p.position.y===y);const gob=state.goblin.mode!=='dead'&&state.goblin.position.x===x&&state.goblin.position.y===y;if(gob||p){const e=document.createElement('span');e.className=`entity ${gob?'goblin':'player'}`;e.textContent=gob?'◆':'●';e.title=gob?'Goblin':p.displayName;el.append(e)}map.append(el)} $('#log').innerHTML=state.actionLog.slice(-40).map(e=>`
  • #${e.sequence} ${escapeHtml(e.message)}
  • `).join('');$('#log').scrollTop=$('#log').scrollHeight - const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason)) + const reason=disabledReason();$('#disabled').textContent=reason;$$('[data-command]').forEach(b=>b.disabled=Boolean(reason));const spawnButton=$('#spawnExtension');spawnButton.hidden=!extensionMode;spawnButton.disabled=!session||Boolean(me);spawnButton.textContent=me?'Character spawned':'Spawn character' } -async function api(path,options={}){const res=await fetch(path,{...options,headers:{'content-type':'application/json',...(session?{authorization:`Bearer ${session}`}:{})}});const data=await res.json();if(!res.ok)throw new Error(data.message||data.error);return data} +async function api(path,options={}){const res=await fetch(path,{...options,headers:{'content-type':'application/json',...(session?{authorization:`Bearer ${session}`}:{})}});const data=await res.json();if(!res.ok)throw new Error(data.message||data.error||data.reason||'Request failed');return data} async function authorizeExtension(token){const auth=await api('/api/extension/session',{method:'POST',body:JSON.stringify({token})});session=auth.token;userId=auth.twitchUserId;state=auth.state;if(activeSocket?.readyState===WebSocket.OPEN)activeSocket.send(JSON.stringify({type:'authenticate',token:session}));render()} async function spawn(){const id=$('#userId').value.trim(),name=$('#displayName').value.trim();await api('/api/dev/spawn',{method:'POST',body:JSON.stringify({command:'!spawn',externalEventId:crypto.randomUUID(),twitchUserId:id,displayName:name,followerVerified:true})}).catch(e=>{if(!String(e.message).includes('already'))throw e});const auth=await api('/api/extension/session',{method:'POST',body:JSON.stringify({token:`dev:${id}:${name}`})});session=auth.token;userId=id;state=auth.state;render()} +async function spawnFromExtension(){const result=await api('/api/extension/spawn',{method:'POST',body:'{}'});state=result.state;render()} async function command(kind){if(!state||state.phase.kind!=='player')return;const commands={up:{type:'move',direction:'up'},down:{type:'move',direction:'down'},left:{type:'move',direction:'left'},right:{type:'move',direction:'right'},attack:{type:'attack',targetId:'goblin'},heal:{type:'heal-self'},pass:{type:'pass'}};try{await api('/api/commands',{method:'POST',body:JSON.stringify({requestId:crypto.randomUUID(),runId:state.runId,floorId:state.floor.floorId,phaseId:state.phase.phaseId,command:commands[kind]})})}catch(e){$('#disabled').textContent=e.message}} -$('#spawn').onclick=()=>spawn().catch(e=>$('#disabled').textContent=e.message);$$('[data-command]').forEach(b=>b.onclick=()=>command(b.dataset.command)) +$('#spawn').onclick=()=>spawn().catch(e=>$('#disabled').textContent=e.message);$('#spawnExtension').onclick=()=>spawnFromExtension().catch(e=>$('#disabled').textContent=e.message);$$('[data-command]').forEach(b=>b.onclick=()=>command(b.dataset.command));$('#shareIdentity').onclick=()=>window.Twitch?.ext?.actions.requestIdShare() function connect(){const ws=activeSocket=new WebSocket(`${location.protocol==='https:'?'wss':'ws'}://${location.host}/ws`);ws.onopen=()=>{if(session)ws.send(JSON.stringify({type:'authenticate',token:session}));$('#connection').textContent='Live'};ws.onclose=()=>{$('#connection').textContent='Reconnecting…';setTimeout(connect,1000)};ws.onmessage=e=>{const msg=JSON.parse(e.data);if(msg.type!=='snapshot')return;if(lastSequence&&msg.sequence>lastSequence+1){fetch('/api/state').then(r=>r.json()).then(s=>{state=s;lastSequence=s.nextEventSequence-1;render()});return}lastSequence=msg.sequence;state=msg.state;render()}} connect() setInterval(()=>{if(state?.phase.kind==='player')render()},250) -if(window.Twitch?.ext)window.Twitch.ext.onAuthorized(auth=>authorizeExtension(auth.token).catch(e=>$('#disabled').textContent=e.message)) +if(window.Twitch?.ext)window.Twitch.ext.onAuthorized(auth=>{const linked=Boolean(window.Twitch.ext.viewer?.isLinked);extensionIdentityPending=!linked;$('#shareIdentity').hidden=linked;if(!linked){render();return}authorizeExtension(auth.token).catch(e=>$('#disabled').textContent=e.message)}) diff --git a/apps/stream-view/public/index.html b/apps/stream-view/public/index.html index cfb1553..b5d16bc 100644 --- a/apps/stream-view/public/index.html +++ b/apps/stream-view/public/index.html @@ -1,10 +1,10 @@ -Twungeon +Twungeon

    Shared dungeon

    Floor 1

    ● Adventurer◆ Goblin▣ Exit

    Chronicle

    Action log

    Connecting…
      -
      + diff --git a/apps/stream-view/public/privacy.html b/apps/stream-view/public/privacy.html new file mode 100644 index 0000000..df069e3 --- /dev/null +++ b/apps/stream-view/public/privacy.html @@ -0,0 +1,37 @@ + + + + + + Twungeon Privacy Notice + + + +
      +

      Twungeon Privacy Notice

      + Effective August 17, 2026 + +

      Twungeon is an interactive Twitch Extension operated for the Labyricorn channel. This notice explains how Twungeon handles information when viewers participate in the shared dungeon.

      + +

      Information Twungeon processes

      +

      If you choose to share your Twitch identity, Twungeon receives your numeric Twitch user ID, display name, the channel where the Extension is running, and the authorization claims Twitch supplies. During play, Twungeon also processes chat commands, follower-verification results, game actions, and relevant Channel Points redemption identifiers.

      + +

      How the information is used

      +

      The information is used only to connect your chat character to your Extension controls, enforce game ownership and eligibility rules, prevent duplicate actions or redemptions, display the shared game state, and protect the service from unauthorized requests.

      + +

      Storage and retention

      +

      Viewer identities, game state, actions, and redemption deduplication records are held in server memory for the active Twungeon run and are cleared when the service restarts. Twungeon does not place tracking cookies in the Extension and does not use viewer information for advertising or profiling. The broadcaster's OAuth credentials are stored separately and are not viewer data.

      + +

      Sharing

      +

      Twungeon does not sell viewer information. Information is not disclosed to third parties except as necessary to operate the Extension through Twitch and its hosting or network providers, to protect the service, or when required by law. Twitch independently processes information under its own privacy notice.

      + +

      Your choices

      +

      Identity sharing is optional. Without it, Twungeon cannot safely bind Extension controls to a chat character. You can decline Twitch's identity prompt or manage your Extension permissions through Twitch. To ask about this notice or request removal from the current active run, contact the operator through the Labyricorn Twitch channel.

      + +

      Changes

      +

      This notice may be updated when Twungeon's data practices change. The effective date above identifies the current version.

      +
      + + diff --git a/docs/twitch-setup.md b/docs/twitch-setup.md index 0e1ed3e..71067f2 100644 --- a/docs/twitch-setup.md +++ b/docs/twitch-setup.md @@ -10,7 +10,8 @@ provide the variables through your process manager. - A Twitch developer application and a development channel. - A Twitch Extension with identity sharing enabled. Twungeon requires the numeric `user_id`; anonymous or opaque-only viewers fail closed. -- A broadcaster user access token that can read chat and check followers. +- A confidential Twitch developer application whose OAuth callback is the + public Twungeon `/oauth/callback` URL. - The Extension shared secret, copied exactly as the base64 value supplied by the Extension Manager. @@ -20,16 +21,19 @@ rotating tokens or upgrading packages. ## Required environment -Set `TWITCH_ENABLED=true`, then provide: +Provide the following values first. Keep `TWITCH_ENABLED=false` until OAuth, +Extension, and Channel Points setup are complete; then switch it to `true` for +live operation. | Variable | Purpose | | --- | --- | -| `TWITCH_CLIENT_ID` | Developer application/Extension client ID | +| `TWITCH_CLIENT_ID` | Confidential OAuth application client ID | | `TWITCH_CLIENT_SECRET` | Server-only application secret | | `TWITCH_BROADCASTER_ID` | Numeric channel owner ID | | `TWITCH_CHANNEL_LOGIN` | Channel login joined by Twurple chat | -| `TWITCH_BOT_ACCESS_TOKEN` | Broadcaster user access token | | `TWITCH_EXTENSION_SECRET` | Base64 Extension shared secret | +| `TWITCH_OAUTH_REDIRECT_URI` | Exact HTTPS OAuth callback registered with Twitch | +| `TWITCH_TOKEN_FILE` | Restricted file used for access and refresh tokens | | `CHANNEL_POINTS_RESURRECTION_REWARD_ID` | Stable ID of the resurrection custom reward | | `PUBLIC_BASE_URL` | Public HTTPS backend origin | @@ -40,13 +44,40 @@ Twungeon subscribes only to the configured custom reward and uses Twitch's stable redemption ID as the deduplication key. The broadcaster owns the reward cost in Twitch; the backend does not duplicate it. +## Broadcaster OAuth authorization + +1. Register a **Confidential** Twitch application with an exact HTTPS redirect + such as `https://twungeon.example/oauth/callback`. +2. Configure `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET`, + `TWITCH_CHANNEL_LOGIN`, `TWITCH_OAUTH_REDIRECT_URI`, and + `TWITCH_TOKEN_FILE` while leaving `TWITCH_ENABLED=false`. +3. Restart Twungeon and confirm `/oauth/status` reports `configured: true` and + `authorized: false`. +4. Open `/oauth/login` in a browser and authorize using the configured + broadcaster account. Twungeon requests only `chat:read`, + `moderator:read:followers`, and `channel:read:redemptions`. +5. Confirm the callback reports success and `/oauth/status` reports + `authorized: true`. The callback validates the client ID, broadcaster login, + and required scopes before storing the token. +6. Record the numeric broadcaster ID shown by the callback as + `TWITCH_BROADCASTER_ID`. + +The access and refresh tokens are stored atomically at `TWITCH_TOKEN_FILE` with +owner-only permissions. Twurple refreshes the access token when necessary and +Twungeon replaces the stored token without printing either token. The token +directory must be writable only by the Twungeon service account. Never place the +token file inside the repository or a web-served directory. + ## Extension configuration -1. Host the built static files and backend at an HTTPS origin allowed by the - Extension configuration. Twitch embeds the UI in an iframe and supplies the - Extension Helper JWT through `onAuthorized`. -2. Point the viewer/mobile video component to the application root. -3. Enable identity sharing. A JWT without `user_id`, with the wrong +1. Create a Twitch Extension separately from the confidential OAuth + application. In the Extension Manager, use a testing base URI ending in + `/`, select a video component, and set its viewer path to `extension`. +2. Enable **Request Identity Link**. The viewer must click **Share Twitch + identity** in the component; the Extension Helper then invokes + `requestIdShare()` from that user gesture and supplies a new JWT through + `onAuthorized`. +3. A JWT without `user_id`, with the wrong `channel_id`, an expired signature, or an `external` role is rejected. 4. Keep the Extension secret only in the backend environment. It must never be included in the UI bundle or URL. diff --git a/packages/twitch-adapter/src/index.ts b/packages/twitch-adapter/src/index.ts index 49a6b8f..66dde55 100644 --- a/packages/twitch-adapter/src/index.ts +++ b/packages/twitch-adapter/src/index.ts @@ -1,14 +1,18 @@ import type { ChannelPointRedemption, SpawnMessage } from '../../contracts/src/index.js' import { ApiClient } from '@twurple/api' -import { StaticAuthProvider } from '@twurple/auth' +import { RefreshingAuthProvider } from '@twurple/auth' import { ChatClient } from '@twurple/chat' import { EventSubWsListener } from '@twurple/eventsub-ws' import { jwtVerify } from 'jose' +import { readStoredTwitchToken, writeStoredTwitchToken } from './tokenStore.js' + +export { REQUIRED_TWITCH_SCOPES } from './tokenStore.js' export interface TwitchIdentity { twitchUserId: string; displayName: string } export interface TwitchAdapter { readonly ready: boolean verifyExtensionToken(token: string): Promise + createSpawn(twitchUserId: string, externalEventId: string): Promise normalizeSpawn(input: unknown): Promise normalizeRedemption(input: unknown): Promise } @@ -21,6 +25,7 @@ export class SyntheticTwitchAdapter implements TwitchAdapter { const match=/^dev:([^:]+):(.+)$/.exec(token) return match ? {twitchUserId:match[1]!,displayName:match[2]!} : null } + async createSpawn(twitchUserId:string,externalEventId:string):Promise{return {type:'spawn-requested',externalEventId,twitchUserId,displayName:twitchUserId,broadcasterId:this.broadcasterId,followerVerified:true}} async normalizeSpawn(input: any): Promise { if(!input || input.command!=='!spawn' || typeof input.twitchUserId!=='string')return null return {type:'spawn-requested',externalEventId:String(input.externalEventId),twitchUserId:input.twitchUserId,displayName:String(input.displayName??'Adventurer'),broadcasterId:this.broadcasterId,followerVerified:input.followerVerified===true} @@ -31,46 +36,98 @@ export class SyntheticTwitchAdapter implements TwitchAdapter { } } -export interface LiveTwitchConfig { clientId:string; accessToken:string; broadcasterId:string; channelLogin:string; extensionSecret:string; resurrectionRewardId:string } +export interface LiveTwitchConfig { + clientId: string + clientSecret: string + tokenFile: string + broadcasterId: string + channelLogin: string + extensionSecret: string + resurrectionRewardId: string +} export interface TwitchHandlers { onSpawn(message:SpawnMessage):void; onRedemption(message:ChannelPointRedemption):void; onConnection(ready:boolean):void } /** Twurple production adapter. All callbacks are normalized and safe for domain use. */ export class LiveTwitchAdapter implements TwitchAdapter { - private readonly api:ApiClient - private readonly chat:ChatClient - private readonly eventSub:EventSubWsListener + private api: ApiClient | null = null + private chat: ChatClient | null = null + private eventSub: EventSubWsListener | null = null private connected=false - constructor(private readonly config:LiveTwitchConfig){ - const authProvider=new StaticAuthProvider(config.clientId,config.accessToken,['chat:read','moderator:read:followers','channel:read:redemptions']) - this.api=new ApiClient({authProvider}) - this.chat=new ChatClient({authProvider,channels:[config.channelLogin],readOnly:true,rejoinChannelsOnReconnect:true}) - this.eventSub=new EventSubWsListener({apiClient:this.api}) - } + constructor(private readonly config:LiveTwitchConfig) {} get ready(){return this.connected} async start(handlers:TwitchHandlers):Promise{ - this.chat.onConnect(()=>{this.connected=true;handlers.onConnection(true)}) - this.chat.onDisconnect(()=>{this.connected=false;handlers.onConnection(false)}) - this.chat.onMessage(async(_channel,_user,text,msg)=>{ + const storedToken = await readStoredTwitchToken(this.config.tokenFile) + if (storedToken.clientId !== this.config.clientId) { + throw new Error('The stored Twitch token belongs to a different application.') + } + if (storedToken.userId !== this.config.broadcasterId) { + throw new Error('The stored Twitch token does not belong to TWITCH_BROADCASTER_ID.') + } + + const authProvider = new RefreshingAuthProvider({ + clientId: this.config.clientId, + clientSecret: this.config.clientSecret + }) + authProvider.onRefresh((userId, token) => { + void writeStoredTwitchToken(this.config.tokenFile, { + ...token, + refreshToken: token.refreshToken ?? storedToken.refreshToken, + userId, + login: storedToken.login, + clientId: this.config.clientId + }).catch(error => console.error(JSON.stringify({ + level: 'error', + component: 'twitch-token-store', + message: error instanceof Error ? error.message : 'Token persistence failed' + }))) + }) + authProvider.addUser(storedToken.userId, storedToken, ['chat']) + + const api = this.api = new ApiClient({ authProvider }) + const chat = this.chat = new ChatClient({ + authProvider, + channels: [this.config.channelLogin], + readOnly: true, + rejoinChannelsOnReconnect: true + }) + const eventSub = this.eventSub = new EventSubWsListener({ apiClient: api }) + + chat.onConnect(()=>{this.connected=true;handlers.onConnection(true)}) + chat.onDisconnect(()=>{this.connected=false;handlers.onConnection(false)}) + chat.onMessage(async(_channel,_user,text,msg)=>{ try{ if(text.trim()==='!spawn'){ - const follower=await this.api.channels.getChannelFollowers(this.config.broadcasterId,msg.userInfo.userId,{limit:1}) - handlers.onSpawn({type:'spawn-requested',externalEventId:msg.id,twitchUserId:msg.userInfo.userId,displayName:msg.userInfo.displayName,broadcasterId:this.config.broadcasterId,followerVerified:follower.data.length===1}) + const followerVerified=await this.isEligibleFollower(msg.userInfo.userId) + handlers.onSpawn({type:'spawn-requested',externalEventId:msg.id,twitchUserId:msg.userInfo.userId,displayName:msg.userInfo.displayName,broadcasterId:this.config.broadcasterId,followerVerified}) } }catch(error){console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Twitch event failed'}))} }) - this.eventSub.onChannelRedemptionAddForReward(this.config.broadcasterId,this.config.resurrectionRewardId,event=>handlers.onRedemption({type:'channel-point-resurrection-redeemed',externalEventId:event.id,twitchUserId:event.userId,rewardId:event.rewardId})) - this.eventSub.start() - await this.chat.connect() + eventSub.onChannelRedemptionAddForReward(this.config.broadcasterId,this.config.resurrectionRewardId,event=>handlers.onRedemption({type:'channel-point-resurrection-redeemed',externalEventId:event.id,twitchUserId:event.userId,rewardId:event.rewardId})) + eventSub.start() + await chat.connect() } async verifyExtensionToken(token:string):Promise{ try{ + if (!this.api) return null const secret=Buffer.from(this.config.extensionSecret,'base64') const {payload}=await jwtVerify(token,secret,{algorithms:['HS256']}) - if(payload.channel_id!==this.config.broadcasterId || typeof payload.user_id!=='string' || payload.role==='external')return null + if(payload.channel_id!==this.config.broadcasterId || typeof payload.user_id!=='string' || typeof payload.exp!=='number' || payload.role==='external')return null const user=await this.api.users.getUserById(payload.user_id) return {twitchUserId:payload.user_id,displayName:user?.displayName??payload.user_id} }catch{return null} } + async createSpawn(twitchUserId:string,externalEventId:string):Promise{ + if(!this.api)return null + const user=await this.api.users.getUserById(twitchUserId) + if(!user)return null + return {type:'spawn-requested',externalEventId,twitchUserId,displayName:user.displayName,broadcasterId:this.config.broadcasterId,followerVerified:await this.isEligibleFollower(twitchUserId)} + } + private async isEligibleFollower(twitchUserId:string):Promise{ + if(twitchUserId===this.config.broadcasterId)return true + if(!this.api)return false + const follower=await this.api.channels.getChannelFollowers(this.config.broadcasterId,twitchUserId,{limit:1}) + return follower.data.length===1 + } async normalizeSpawn():Promise{return null} async normalizeRedemption():Promise{return null} } diff --git a/packages/twitch-adapter/src/tokenStore.ts b/packages/twitch-adapter/src/tokenStore.ts new file mode 100644 index 0000000..5438468 --- /dev/null +++ b/packages/twitch-adapter/src/tokenStore.ts @@ -0,0 +1,52 @@ +import { randomUUID } from 'node:crypto' +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { z } from 'zod' + +export const REQUIRED_TWITCH_SCOPES = [ + 'chat:read', + 'moderator:read:followers', + 'channel:read:redemptions' +] as const + +const StoredTwitchTokenSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + scope: z.array(z.string()), + expiresIn: z.number().int().nonnegative().nullable(), + obtainmentTimestamp: z.number().int().nonnegative(), + userId: z.string().min(1), + login: z.string().min(1), + clientId: z.string().min(1) +}) + +export type StoredTwitchToken = z.infer + +export async function readStoredTwitchToken(path: string): Promise { + const value: unknown = JSON.parse(await readFile(path, 'utf8')) + return StoredTwitchTokenSchema.parse(value) +} + +export async function storedTwitchTokenExists(path: string): Promise { + try { + await readStoredTwitchToken(path) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } +} + +export async function writeStoredTwitchToken(path: string, token: StoredTwitchToken): Promise { + const validated = StoredTwitchTokenSchema.parse(token) + const directory = dirname(path) + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp` + await mkdir(directory, { recursive: true, mode: 0o700 }) + await writeFile(temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx' + }) + await rename(temporaryPath, path) + await chmod(path, 0o600) +} diff --git a/tests/domain/config.test.ts b/tests/domain/config.test.ts index 570f49a..1c581e9 100644 --- a/tests/domain/config.test.ts +++ b/tests/domain/config.test.ts @@ -3,5 +3,7 @@ import { loadConfig } from '../../apps/backend/src/config.js' describe('Channel Points configuration',()=>{ it('uses a safe local reward ID when Twitch is disabled',()=>expect(loadConfig({TWITCH_ENABLED:'false'}).resurrectionRewardId).toBe('local-resurrection')) - it('requires the reward ID when Twitch is enabled',()=>expect(()=>loadConfig({TWITCH_ENABLED:'true',TWITCH_CLIENT_ID:'id',TWITCH_CLIENT_SECRET:'secret',TWITCH_BROADCASTER_ID:'broadcaster',TWITCH_CHANNEL_LOGIN:'channel',TWITCH_BOT_ACCESS_TOKEN:'token',TWITCH_EXTENSION_SECRET:'extension'})).toThrow(/CHANNEL_POINTS_RESURRECTION_REWARD_ID/)) + it('allows OAuth setup before live Twitch mode is enabled',()=>expect(loadConfig({TWITCH_ENABLED:'false',TWITCH_CLIENT_ID:'id',TWITCH_CLIENT_SECRET:'secret',TWITCH_CHANNEL_LOGIN:'labyricorn',PUBLIC_BASE_URL:'https://twungeon.example'})).toMatchObject({oauthConfigured:true,twitchRedirectUri:'https://twungeon.example/oauth/callback'})) + it('requires paired OAuth client credentials',()=>expect(()=>loadConfig({TWITCH_ENABLED:'false',TWITCH_CLIENT_ID:'id'})).toThrow(/configured together/)) + it('requires the reward ID when Twitch is enabled',()=>expect(()=>loadConfig({TWITCH_ENABLED:'true',TWITCH_CLIENT_ID:'id',TWITCH_CLIENT_SECRET:'secret',TWITCH_BROADCASTER_ID:'broadcaster',TWITCH_CHANNEL_LOGIN:'channel',TWITCH_EXTENSION_SECRET:'extension',CHANNEL_POINTS_RESURRECTION_REWARD_ID:''})).toThrow(/CHANNEL_POINTS_RESURRECTION_REWARD_ID/)) }) diff --git a/tests/domain/oauth.test.ts b/tests/domain/oauth.test.ts new file mode 100644 index 0000000..4925095 --- /dev/null +++ b/tests/domain/oauth.test.ts @@ -0,0 +1,48 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TwitchOAuthService } from '../../apps/backend/src/twitchOAuth.js' +import { readStoredTwitchToken } from '../../packages/twitch-adapter/src/tokenStore.js' + +const directories: string[] = [] +afterEach(async()=>{await Promise.all(directories.splice(0).map(path=>rm(path,{recursive:true,force:true})))}) + +async function harness(scopes=['chat:read','moderator:read:followers','channel:read:redemptions']) { + const directory=await mkdtemp(join(tmpdir(),'twungeon-oauth-'));directories.push(directory) + const tokenFile=join(directory,'token.json') + const fetchMock=vi.fn(async(input:string|URL|Request)=>{ + const url=String(input) + if(url.endsWith('/token'))return new Response(JSON.stringify({access_token:'access',refresh_token:'refresh',expires_in:3600,scope:scopes,token_type:'bearer'}),{status:200,headers:{'content-type':'application/json'}}) + if(url.endsWith('/validate'))return new Response(JSON.stringify({client_id:'client',login:'labyricorn',scopes,user_id:'1234',expires_in:3500}),{status:200,headers:{'content-type':'application/json'}}) + return new Response(null,{status:404}) + }) as typeof fetch + const service=new TwitchOAuthService({clientId:'client',clientSecret:'secret',redirectUri:'https://twungeon.example/oauth/callback',expectedLogin:'labyricorn',tokenFile},fetchMock,()=>1_000) + return {service,tokenFile,fetchMock} +} + +describe('Twitch OAuth',()=>{ + it('uses state, validates the broadcaster, and stores a refreshable token',async()=>{ + const {service,tokenFile,fetchMock}=await harness() + const authorization=new URL(service.startAuthorization()),state=authorization.searchParams.get('state')! + expect(authorization.searchParams.get('redirect_uri')).toBe('https://twungeon.example/oauth/callback') + expect(authorization.searchParams.get('scope')).toContain('moderator:read:followers') + await expect(service.completeAuthorization('code',state)).resolves.toMatchObject({login:'labyricorn',userId:'1234'}) + await expect(readStoredTwitchToken(tokenFile)).resolves.toMatchObject({accessToken:'access',refreshToken:'refresh',userId:'1234',clientId:'client'}) + expect(fetchMock).toHaveBeenCalledTimes(2) + await expect(service.status()).resolves.toMatchObject({configured:true,authorized:true}) + }) + + it('rejects missing state before exchanging a code',async()=>{ + const {service,fetchMock}=await harness() + await expect(service.completeAuthorization('code','unknown')).rejects.toThrow(/state is missing or expired/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects a token missing a required scope',async()=>{ + const {service,tokenFile}=await harness(['chat:read']) + const state=new URL(service.startAuthorization()).searchParams.get('state')! + await expect(service.completeAuthorization('code',state)).rejects.toThrow(/missing required scopes/) + await expect(readStoredTwitchToken(tokenFile)).rejects.toMatchObject({code:'ENOENT'}) + }) +}) diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index 608d639..fda1f46 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -8,8 +8,12 @@ async function post(path:string,value:unknown,token?:string){return fetch(`${bas describe('backend boundary',()=>{ it('AT-001 exposes a secret-free readiness response',async()=>{const data=await fetch(`${base}/health`).then(r=>r.json());expect(data).toMatchObject({status:'ok',ready:true,twitchMode:'synthetic'});expect(JSON.stringify(data)).not.toMatch(/secret|token/i)}) + it('reports OAuth readiness without exposing credentials',async()=>{const data=await fetch(`${base}/oauth/status`).then(r=>r.json());expect(data).toMatchObject({configured:false,authorized:false});expect(JSON.stringify(data)).not.toMatch(/clientId|clientSecret|accessToken|refreshToken/)}) + it('serves a compact uncached Twitch identity-sharing controller',async()=>{const [pageResponse,scriptResponse]=await Promise.all([fetch(`${base}/extension`),fetch(`${base}/app.js?v=20260817-spawn`)]),[html,script]=await Promise.all([pageResponse.text(),scriptResponse.text()]);expect(pageResponse.headers.get('cache-control')).toBe('no-store');expect(scriptResponse.headers.get('cache-control')).toBe('no-store');expect(html).toContain('/app.js?v=20260817-spawn');expect(html).toContain('id="spawnExtension"');expect(html).toContain('id="shareIdentity"');expect(html).toContain('body.extension-mode .game');expect(script).toContain("location.pathname==='/extension'");expect(script).toContain('/api/extension/spawn');expect(script).toContain('viewer?.isLinked');expect(script).toContain('actions.requestIdShare()')}) + it('serves a public privacy notice for identity linking',async()=>{const response=await fetch(`${base}/privacy.html`),html=await response.text();expect(response.status).toBe(200);expect(html).toContain('Twungeon Privacy Notice');expect(html).toContain('numeric Twitch user ID')}) it('AT-005 establishes a session without creating a character',async()=>{const r=await post('/api/extension/session',{token:'dev:nobody:Nobody'});expect(r.status).toBe(200);const data=await r.json();expect(data.state.viewer).toBeNull()}) it('AT-011 binds the same stable chat and Extension identity',async()=>{const id=`viewer-${Date.now()}`;expect((await post('/api/dev/spawn',{command:'!spawn',externalEventId:`e-${id}`,twitchUserId:id,displayName:'Viewer',followerVerified:true})).status).toBe(200);const auth=await (await post('/api/extension/session',{token:`dev:${id}:Viewer`})).json();expect(auth.twitchUserId).toBe(id);expect(auth.state.viewer.extensionBound).toBe(true);const s=auth.state,phase=s.phase;expect(phase.kind).toBe('player');const command=await post('/api/commands',{requestId:`r-${id}`,runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}},auth.token);expect(command.status).toBe(200)}) + it('spawns from an authenticated Extension identity',async()=>{const id=`button-${Date.now()}`,auth=await (await post('/api/extension/session',{token:`dev:${id}:Button Viewer`})).json();expect((await post('/api/extension/spawn',{},auth.token)).status).toBe(200);const state=await fetch(`${base}/api/state`).then(r=>r.json());expect(state.players.some((player:any)=>player.twitchUserId===id)).toBe(true);expect((await post('/api/extension/spawn',{})).status).toBe(401)}) it('AT-012 rejects unauthenticated controls',async()=>{const s=await fetch(`${base}/api/state`).then(r=>r.json()),phase=s.phase;const r=await post('/api/commands',{requestId:'unauth',runId:s.runId,floorId:s.floor.floorId,phaseId:phase.phaseId,command:{type:'pass'}});expect(r.status).toBe(409);expect((await r.json()).reason).toBe('UNAUTHENTICATED')}) it('AT-025 normalizes Channel Points redemptions and rejects unknown owners safely',async()=>{const r=await post('/api/dev/redemption',{externalEventId:'redemption-unknown',twitchUserId:'missing-viewer',rewardId:'local-resurrection'});expect(r.status).toBe(409);expect((await r.json()).reason).toBe('UNKNOWN_PLAYER')}) }) From 9ebd1ea2839ae56c7bc0a6525a43da30b16758bb Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Mon, 17 Aug 2026 12:03:37 -0700 Subject: [PATCH 2/2] docs: record live Twitch concept milestone --- .../live-twitch-concept-achieved/contents.lr | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .labyricorn/devlog/live-twitch-concept-achieved/contents.lr diff --git a/.labyricorn/devlog/live-twitch-concept-achieved/contents.lr b/.labyricorn/devlog/live-twitch-concept-achieved/contents.lr new file mode 100644 index 0000000..bdee26c --- /dev/null +++ b/.labyricorn/devlog/live-twitch-concept-achieved/contents.lr @@ -0,0 +1,70 @@ +_model: devlog-entry +--- +schema_version: 1 +--- +title: Twungeon reaches a live Twitch concept milestone +--- +date: 2026-08-17 +--- +author: Codex and Christopher Chambers +--- +summary: Twungeon moved from a local proof of concept to a live Twitch-connected deployment with broadcaster OAuth, Extension identity sharing, Channel Points resurrection, a focused viewer control panel, and an authenticated spawn button. +--- +tags: implementation, concept validation, deployment, Twitch, Twitch Extension, OAuth, Channel Points, Twurple, Cloudflare, testing +--- +source_commit: df984690cedf84fc58d3eb6e2000d2df567a8d5d +--- +body: + +Twungeon is now running as a live Twitch-connected concept on the +[Labyricorn Twitch channel](https://www.twitch.tv/labyricorn). The deployed +service connects Twurple chat and EventSub to the broadcaster account, verifies +Twitch Extension identity on the server, and serves the Extension through the +public Cloudflare tunnel at `https://twungeon.labyricorn.com`. + +The authentication path required more than supplying a static token. Twungeon +now provides a confidential OAuth authorization-code flow with state +validation, checks that Twitch returned the configured broadcaster and required +scopes, stores access and refresh credentials outside the repository with +restricted permissions, and persists refreshed credentials atomically. The +Extension exchanges its signed Twitch JWT for a short-lived Twungeon session; +the shared Extension secret remains server-side. + +The first setup attempt exposed several integration mismatches. The original +Twitch application had been registered as a public client before the callback +endpoint existed, so live server authorization required a new confidential +application and the exact HTTPS `/oauth/callback` redirect. Enabling identity +linking also made a privacy notice mandatory before the Extension version could +advance. Both requirements are now reflected in the implementation and operator +documentation. + +The first live Extension view rendered the complete broadcast game instead of +a compact control surface. The shared frontend now switches to a controller-only +layout at `/extension`, while `/` remains the full game view used by the stream. +Cloudflare also continued serving an older JavaScript bundle during testing; +static responses now use `Cache-Control: no-store`, and the Extension loads a +versioned script URL to force the current controller code. + +Spawning revealed one final usability gap. Chat `!spawn` was not sufficient for +the broadcaster because a channel owner cannot follow their own channel, and +the Extension offered no direct alternative. The control panel now includes a +**Spawn character** button backed by an authenticated server endpoint. It uses +the verified Twitch viewer ID, applies the normal follower rule, and treats the +configured broadcaster as eligible. The same eligibility correction also +allows broadcaster chat spawning. + +The current deployment reports healthy with the Twitch adapter ready. Viewers +can authorize their identity, spawn from the Extension, and use the movement, +attack, heal, and pass controls for their own character. The configured custom +Channel Points reward drives resurrection through EventSub. The final automated +run passed linting, strict type checking, 24 domain tests, 10 integration and +multi-viewer tests, and the production build. + +This establishes the live interaction concept, but it is not a claim that every +MVP acceptance gate is complete. Broader real-viewer, multi-viewer, usability, +fault-injection, soak, and independent-operator evidence remains to be recorded. +Game state is still intentionally in memory, so restarting the service starts a +new run. + +The live Twitch integration and spawn-control milestone are recorded in +[commit `df984690cedf84fc58d3eb6e2000d2df567a8d5d`](https://git.labyricorn.com/Labyricorn/Twungeon/commit/df984690cedf84fc58d3eb6e2000d2df567a8d5d).