feat: connect Twungeon to live Twitch

This commit is contained in:
2026-08-17 12:02:35 -07:00
parent 5bc70dd9b2
commit df984690ce
13 changed files with 523 additions and 55 deletions
+3 -1
View File
@@ -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/))
})
+48
View File
@@ -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'})
})
})
+4
View File
@@ -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')})
})