Merge pull request 'Codex/twitch oauth' (#3) from codex/twitch-oauth into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-17 12:04:45 -07:00
14 changed files with 593 additions and 55 deletions
+2 -1
View File
@@ -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
@@ -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).
+73 -9
View File
@@ -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
}
}
+34 -5
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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'})}
})
+141
View File
@@ -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)
}
}
}
+8 -6
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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])=>`<div class="stat">${k}<b>${v}</b></div>`).join('')
const map=$('#map');map.style.gridTemplateColumns=`repeat(${state.floor.width},auto)`;map.innerHTML=''
for(let y=0;y<state.floor.height;y++)for(let x=0;x<state.floor.width;x++){const el=document.createElement('div'),tile=state.floor.tiles[y][x];el.className=`tile ${tile}`;if(tile==='exit')el.textContent='▣';const p=state.players.find(p=>p.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=>`<li><small>#${e.sequence}</small> ${escapeHtml(e.message)}</li>`).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)})
+4 -4
View File
@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Twungeon</title><link rel="stylesheet" href="/styles.css"></head>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Twungeon</title><link rel="stylesheet" href="/styles.css"><style>body.extension-mode{background:transparent}body.extension-mode .shell{height:100vh;min-height:0;padding:0;display:block}body.extension-mode .status{height:100%;padding:16px;gap:14px;background:#111315ee}body.extension-mode .game,body.extension-mode .log,body.extension-mode details{display:none}body.extension-mode h1{font-size:22px}body.extension-mode #status{grid-template-columns:repeat(3,1fr)}</style></head>
<body><main class="shell">
<aside class="panel status"><div><p class="eyebrow">Twitch plays together</p><h1>TWUNG<span>EON</span></h1></div><div id="status"></div>
<section id="controller"><h2>Controller</h2><div class="dpad"><button data-command="up"></button><button data-command="left"></button><button data-command="down"></button><button data-command="right"></button></div><div class="actions"><button data-command="attack">Attack</button><button data-command="heal">Heal</button><button data-command="pass">Pass</button></div><p id="disabled"></p></section>
<details><summary>Local viewer login</summary><label>User ID <input id="userId" value="viewer-1"></label><label>Name <input id="displayName" value="Viewer One"></label><button id="spawn">Spawn & bind</button></details>
<section id="controller"><h2>Controller</h2><button id="spawnExtension" hidden>Spawn character</button><div class="dpad"><button data-command="up"></button><button data-command="left"></button><button data-command="down"></button><button data-command="right"></button></div><div class="actions"><button data-command="attack">Attack</button><button data-command="heal">Heal</button><button data-command="pass">Pass</button></div><button id="shareIdentity" hidden>Share Twitch identity</button><p id="disabled"></p></section>
<details><summary>Local viewer login</summary><label>User ID <input id="userId" value="viewer-1"></label><label>Name <input id="displayName" value="Viewer One"></label><button id="spawn">Spawn & bind</button><p><a href="/privacy.html" target="_blank" rel="noopener">Privacy notice</a></p></details>
</aside>
<section class="panel game"><div class="game-head"><div><p class="eyebrow">Shared dungeon</p><h2 id="floor">Floor 1</h2></div><div id="phase" class="phase"></div></div><div id="banner" hidden>Type !spawn to spawn in the Twungeon!</div><div id="map" aria-label="Dungeon map"></div><div class="legend"><span>● Adventurer</span><span>◆ Goblin</span><span>▣ Exit</span></div></section>
<section class="panel log"><div class="log-head"><div><p class="eyebrow">Chronicle</p><h2>Action log</h2></div><span id="connection">Connecting…</span></div><ol id="log"></ol></section>
</main><script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script><script type="module" src="/app.js"></script></body></html>
</main><script src="https://extension-files.twitch.tv/helper/v1/twitch-ext.min.js"></script><script type="module" src="/app.js?v=20260817-spawn"></script></body></html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Twungeon Privacy Notice</title>
<style>
:root{color-scheme:dark}body{max-width:760px;margin:0 auto;padding:32px 20px;background:#0d0f10;color:#f5ecd8;font:16px/1.6 system-ui,sans-serif}h1,h2{line-height:1.2}h2{margin-top:28px;color:#e8b04b}a{color:#e8b04b}small{color:#b8ad98}
</style>
</head>
<body>
<main>
<h1>Twungeon Privacy Notice</h1>
<small>Effective August 17, 2026</small>
<p>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.</p>
<h2>Information Twungeon processes</h2>
<p>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.</p>
<h2>How the information is used</h2>
<p>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.</p>
<h2>Storage and retention</h2>
<p>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.</p>
<h2>Sharing</h2>
<p>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.</p>
<h2>Your choices</h2>
<p>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 <a href="https://www.twitch.tv/labyricorn" rel="noopener">Labyricorn Twitch channel</a>.</p>
<h2>Changes</h2>
<p>This notice may be updated when Twungeon's data practices change. The effective date above identifies the current version.</p>
</main>
</body>
</html>
+40 -9
View File
@@ -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.
+77 -20
View File
@@ -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<TwitchIdentity | null>
createSpawn(twitchUserId: string, externalEventId: string): Promise<SpawnMessage | null>
normalizeSpawn(input: unknown): Promise<SpawnMessage | null>
normalizeRedemption(input: unknown): Promise<ChannelPointRedemption | null>
}
@@ -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<SpawnMessage>{return {type:'spawn-requested',externalEventId,twitchUserId,displayName:twitchUserId,broadcasterId:this.broadcasterId,followerVerified:true}}
async normalizeSpawn(input: any): Promise<SpawnMessage|null> {
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<void>{
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<TwitchIdentity|null>{
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<SpawnMessage|null>{
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<boolean>{
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<SpawnMessage|null>{return null}
async normalizeRedemption():Promise<ChannelPointRedemption|null>{return null}
}
+52
View File
@@ -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<typeof StoredTwitchTokenSchema>
export async function readStoredTwitchToken(path: string): Promise<StoredTwitchToken> {
const value: unknown = JSON.parse(await readFile(path, 'utf8'))
return StoredTwitchTokenSchema.parse(value)
}
export async function storedTwitchTokenExists(path: string): Promise<boolean> {
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<void> {
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)
}
+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')})
})