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