From 8b1d14dceac8f9645f70cab106a045ed64bcd71c Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Mon, 17 Aug 2026 14:49:22 -0700 Subject: [PATCH] Add private admin console and disconnect handling --- .env.example | 6 +++ README.md | 2 + apps/backend/src/admin.ts | 66 +++++++++++++++++++++++++++++++++ apps/backend/src/config.ts | 19 +++++++++- apps/backend/src/server.ts | 22 +++++++++-- docs/admin-panel.md | 38 +++++++++++++++++++ packages/domain/src/game.ts | 36 +++++++++++++++--- tests/domain/config.test.ts | 1 + tests/domain/game.test.ts | 3 ++ tests/integration/admin.test.ts | 17 +++++++++ 10 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 apps/backend/src/admin.ts create mode 100644 docs/admin-panel.md create mode 100644 tests/integration/admin.test.ts diff --git a/.env.example b/.env.example index 0833fff..a3b8582 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,9 @@ TWITCH_CHANNEL_LOGIN= TWITCH_EXTENSION_SECRET= TWITCH_OAUTH_REDIRECT_URI=https://twungeon.example/oauth/callback TWITCH_TOKEN_FILE=/var/lib/twungeon/twitch-token.json + +# Private operator panel. The listener and request filter are restricted to 10.138.0.0/16. +ADMIN_ENABLED=false +ADMIN_HOST=10.138.4.44 +ADMIN_PORT=3001 +ADMIN_PASSWORD= diff --git a/README.md b/README.md index 2682f63..a8ab8da 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ npm run build Production startup uses `npm run build` followed by `npm start`. Configuration is described in [.env.example](.env.example) and [docs/twitch-setup.md](docs/twitch-setup.md). +The optional private operator console is documented in +[docs/admin-panel.md](docs/admin-panel.md). ## Planning documents diff --git a/apps/backend/src/admin.ts b/apps/backend/src/admin.ts new file mode 100644 index 0000000..77a9e7b --- /dev/null +++ b/apps/backend/src/admin.ts @@ -0,0 +1,66 @@ +import { timingSafeEqual } from 'node:crypto' +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import type { Game } from '../../../packages/domain/src/index.js' + +export interface AdminStatus { + uptimeSeconds:number + twitchReady:boolean + twitchMode:string + oauthConfigured:boolean + oauthAuthorized:boolean +} + +export interface AdminServerDependencies { + game:Game + password:string + csrfToken:string + status():Promise + onStateChange():void + isAllowedAddress?(address:string|undefined):boolean +} + +const escapeHtml=(value:unknown)=>String(value).replace(/[&<>"']/g,character=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]!)) +const privateAddress=(address:string|undefined)=>Boolean(address?.replace(/^::ffff:/,'').startsWith('10.138.')) +const safeEqual=(left:string,right:string)=>{const a=Buffer.from(left),b=Buffer.from(right);return a.length===b.length&&timingSafeEqual(a,b)} +function authenticated(req:IncomingMessage,password:string):boolean { + const header=req.headers.authorization + if(!header?.startsWith('Basic '))return false + try{const [username,...parts]=Buffer.from(header.slice(6),'base64').toString('utf8').split(':');return username==='admin'&&safeEqual(parts.join(':'),password)}catch{return false} +} +async function formBody(req:IncomingMessage):Promise{const chunks:Buffer[]=[];for await(const chunk of req){chunks.push(Buffer.from(chunk));if(chunks.reduce((size,item)=>size+item.length,0)>16_384)throw new Error('Request too large')}return new URLSearchParams(Buffer.concat(chunks).toString('utf8'))} +function headers(res:ServerResponse,status:number,type:string){res.writeHead(status,{'content-type':type,'cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",'x-content-type-options':'nosniff','x-frame-options':'DENY','referrer-policy':'no-referrer'})} +function redirect(res:ServerResponse,notice:string){res.writeHead(303,{location:`/?notice=${encodeURIComponent(notice)}`,'cache-control':'no-store'});res.end()} + +export function createAdminServer(deps:AdminServerDependencies){ + if(!deps.password)throw new Error('Admin password is required') + return createServer(async(req,res)=>{ + try{ + if(!(deps.isAllowedAddress??privateAddress)(req.socket.remoteAddress)){headers(res,403,'text/plain; charset=utf-8');return res.end('Private Twungeon network access required.')} + if(!authenticated(req,deps.password)){res.writeHead(401,{'www-authenticate':'Basic realm="Twungeon Admin", charset="UTF-8"','cache-control':'no-store'});return res.end('Authentication required.')} + const url=new URL(req.url??'/','http://admin.local') + if(req.method==='GET'&&url.pathname==='/health'){headers(res,200,'application/json');return res.end(JSON.stringify({status:'ok',...(await deps.status())}))} + if(req.method==='GET'&&url.pathname==='/'){headers(res,200,'text/html; charset=utf-8');return res.end(await render(deps,url.searchParams.get('notice')))} + if(req.method==='POST'&&url.pathname==='/action'){ + const form=await formBody(req) + if(!safeEqual(form.get('_csrf')??'',deps.csrfToken)){headers(res,403,'text/plain; charset=utf-8');return res.end('Invalid form token.')} + const action=form.get('action'),userId=form.get('userId')??'' + let notice='Unknown action.' + if(action==='disconnect')notice=deps.game.disconnect(userId)?'Player disconnected and AutoGuarded.':'Player was already disconnected or missing.' + else if(action==='remove')notice=deps.game.removePlayer(userId)?'Player removed.':'Player not found.' + else if(action==='force-phase')notice=deps.game.forceEndPlayerPhase()?'Player phase ended.':'There is no active player phase.' + else if(action==='reset-run'){deps.game.resetRunByAdmin();notice='Run reset.'} + else {headers(res,400,'text/plain; charset=utf-8');return res.end(notice)} + deps.onStateChange();return redirect(res,notice) + } + headers(res,404,'text/plain; charset=utf-8');res.end('Not found.') + }catch(error){console.error(JSON.stringify({level:'error',component:'admin',message:error instanceof Error?error.message:'Unexpected error'}));headers(res,500,'text/plain; charset=utf-8');res.end('Admin request failed.')} + }) +} + +async function render(deps:AdminServerDependencies,notice:string|null):Promise{ + const state=deps.game.snapshot(),status=await deps.status(),phase=state.phase.kind==='player'?`${state.phase.kind} · ${Math.max(0,Math.ceil((state.phase.deadlineAt-state.serverTime)/1000))}s remaining`:state.phase.kind + const token=escapeHtml(deps.csrfToken) + const playerRows=state.players.map(player=>`${escapeHtml(player.displayName)}${escapeHtml(player.twitchUserId)}${player.connectionState}${player.lifeState}${player.hp}${player.ap}${player.guard}
`).join('')||'No players are currently in the dungeon.' + const logs=state.actionLog.slice(-40).reverse().map(entry=>`${escapeHtml(entry.occurredAt)}${escapeHtml(entry.type)}${escapeHtml(entry.message)}`).join('') + return `Twungeon Admin

Twungeon Admin

Private operator console · refreshes every 15 seconds

${notice?`

${escapeHtml(notice)}

`:''}
Run
Floor ${state.floorNumber}
Phase
${escapeHtml(phase)}
Players
${state.players.length}
Twitch
${status.twitchReady?'Ready':'Not ready'}
OAuth
${status.oauthAuthorized?'Authorized':status.oauthConfigured?'Not authorized':'Not configured'}
Uptime
${Math.floor(status.uptimeSeconds/60)}m

Controls

Players

${playerRows}
NameTwitch IDConnectionLifeHPAPGuardActions

Recent action log

${logs}
TimeEventMessage
` +} diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index b739aff..6117836 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -11,6 +11,10 @@ export interface Config { twitchTokenFile: string extensionSecret: string oauthConfigured: boolean + adminEnabled: boolean + adminHost: string + adminPort: number + adminPassword: string } export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { @@ -24,11 +28,20 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { 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 adminEnabled = env.ADMIN_ENABLED === 'true' + const adminHost = env.ADMIN_HOST ?? '10.138.4.44' + const adminPort = Number(env.ADMIN_PORT ?? 3001) + const adminPassword = env.ADMIN_PASSWORD ?? '' const missing: string[] = [] if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error('PORT must be an integer from 1 to 65535') } + if (!Number.isInteger(adminPort) || adminPort < 1 || adminPort > 65535 || adminPort===port) { + throw new Error('ADMIN_PORT must be an integer from 1 to 65535 and differ from PORT') + } + if(adminEnabled && !/^10\.138\.\d{1,3}\.\d{1,3}$/.test(adminHost))throw new Error('ADMIN_HOST must be an address on 10.138.0.0/16') + if(adminEnabled && adminPassword.length<16)throw new Error('ADMIN_PASSWORD must contain at least 16 characters when admin is enabled') if (!resurrectionRewardId.trim()) { throw new Error('CHANNEL_POINTS_RESURRECTION_REWARD_ID is required') } @@ -69,6 +82,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { twitchRedirectUri, twitchTokenFile, extensionSecret: env.TWITCH_EXTENSION_SECRET ?? '', - oauthConfigured + oauthConfigured, + adminEnabled, + adminHost, + adminPort, + adminPassword } } diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index 0827cdf..63c3967 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -1,4 +1,5 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomBytes } from 'node:crypto' import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,6 +10,7 @@ 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' +import { createAdminServer } from './admin.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}) @@ -23,7 +25,18 @@ function html(res:ServerResponse,status:number,title:string,message:string){cons async function body(req:IncomingMessage):Promise{const chunks:Buffer[]=[];for await(const chunk of req)chunks.push(Buffer.from(chunk));if(chunks.reduce((n,b)=>n+b.length,0)>64_000)throw new Error('Request too large');return JSON.parse(Buffer.concat(chunks).toString('utf8')||'{}')} function bearer(req:IncomingMessage):string|null{const h=req.headers.authorization;return h?.startsWith('Bearer ')?h.slice(7):null} function sessionUser(token:string|null):string|null{if(!token)return null;const session=sessions.get(token);if(!session||session.expiresAt<=Date.now()){if(session)sessions.delete(token);return null}return session.userId} +function connectedClientCount(userId:string):number{let count=0;for(const clientUserId of clients.values())if(clientUserId===userId)count++;return count} +function authenticateClient(ws:WebSocket,userId:string|null):boolean{ + const previousUserId=clients.get(ws)??null + if(previousUserId===userId)return false + clients.set(ws,userId) + let changed=false + if(previousUserId && connectedClientCount(previousUserId)===0)changed=game.disconnect(previousUserId)||changed + if(userId)changed=game.bindExtension(userId,true)||changed + return changed +} function broadcast(){const sequence=game.snapshot().nextEventSequence-1;for(const [ws,userId] of clients)if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({type:'snapshot',sequence,state:userId?game.personalizedSnapshot(userId):game.snapshot()}))} +export const adminServer=config.adminEnabled?createAdminServer({game,password:config.adminPassword,csrfToken:randomBytes(32).toString('base64url'),onStateChange:broadcast,status:async()=>{const oauthStatus=oauth?await oauth.status():{configured:false,authorized:false};return{uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchReady:twitch.ready,twitchMode:config.twitchEnabled?'configured':'synthetic',oauthConfigured:oauthStatus.configured,oauthAuthorized:oauthStatus.authorized}}}):null export const server=createServer(async(req,res)=>{ try{ @@ -53,7 +66,7 @@ export const server=createServer(async(req,res)=>{ if(req.method==='POST'&&url.pathname==='/api/extension/session'){ const data=await body(req) as any,identity=await twitch.verifyExtensionToken(String(data.token??'')) if(!identity)return json(res,401,{error:'UNAUTHENTICATED'}) - const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId) + const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId,connectedClientCount(identity.twitchUserId)>0) return json(res,200,{token,twitchUserId:identity.twitchUserId,state:game.personalizedSnapshot(identity.twitchUserId)}) } if(req.method==='POST'&&url.pathname==='/api/extension/spawn'){ @@ -78,7 +91,10 @@ export const server=createServer(async(req,res)=>{ }) const wss=new WebSocketServer({noServer:true}) server.on('upgrade',(req,socket,head)=>{const url=new URL(req.url??'/',config.publicBaseUrl);if(url.pathname!=='/ws'){socket.destroy();return}wss.handleUpgrade(req,socket,head,ws=>wss.emit('connection',ws,req))}) -wss.on('connection',ws=>{clients.set(ws,null);ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.snapshot()}));ws.on('message',raw=>{try{const message=JSON.parse(raw.toString());if(message.type==='authenticate'){const userId=sessionUser(typeof message.token==='string'?message.token:null);clients.set(ws,userId);if(userId)ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.personalizedSnapshot(userId)}))}}catch{/* ignore malformed client messages */}});ws.on('close',()=>clients.delete(ws))}) +wss.on('connection',ws=>{clients.set(ws,null);ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.snapshot()}));ws.on('message',raw=>{try{const message=JSON.parse(raw.toString());if(message.type==='authenticate'){const userId=sessionUser(typeof message.token==='string'?message.token:null),changed=authenticateClient(ws,userId);if(changed)broadcast();else if(userId)ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.personalizedSnapshot(userId)}))}}catch{/* ignore malformed client messages */}});ws.on('close',()=>{const userId=clients.get(ws)??null;clients.delete(ws);if(userId && connectedClientCount(userId)===0 && game.disconnect(userId))broadcast()})}) setInterval(()=>{const before=game.snapshot().nextEventSequence;game.tick();if(game.snapshot().nextEventSequence!==before)broadcast()},250).unref() if(twitch instanceof LiveTwitchAdapter)void twitch.start({onSpawn:message=>{game.spawn(message);broadcast()},onRedemption:message=>{game.resurrect(message);broadcast()},onConnection:ready=>console.log(JSON.stringify({level:'info',component:'twitch-adapter',ready}))}).catch(error=>console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Connection failed'}))) -if(process.env.NODE_ENV!=='test')server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`)) +if(process.env.NODE_ENV!=='test'){ + server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`)) + adminServer?.listen(config.adminPort,config.adminHost,()=>console.log(`Twungeon admin listening on http://${config.adminHost}:${config.adminPort}`)) +} diff --git a/docs/admin-panel.md b/docs/admin-panel.md new file mode 100644 index 0000000..533ad91 --- /dev/null +++ b/docs/admin-panel.md @@ -0,0 +1,38 @@ +# Private admin panel + +Twungeon can expose a separate server-rendered operator console on the private +`10.138.0.0/16` network. It is disabled by default and is never served from the +public application listener. + +Configure the service environment: + +```dotenv +ADMIN_ENABLED=true +ADMIN_HOST=10.138.4.44 +ADMIN_PORT=3001 +ADMIN_PASSWORD=use-a-unique-random-password-of-at-least-16-characters +``` + +Restart Twungeon, then open `http://10.138.4.44:3001` from a device on the +private network. Authenticate with username `admin` and the configured +password. + +The process binds the panel to the configured `10.138.x.x` interface and also +rejects request source addresses outside `10.138.0.0/16`. Keep a host firewall +rule in place as a third boundary. For UFW, the intended policy is: + +```bash +ufw allow from 10.138.0.0/16 to 10.138.4.44 port 3001 proto tcp +``` + +Do not publish or reverse-proxy this port. Basic authentication protects the +panel from other private-network users, while per-process form tokens protect +state-changing requests from cross-site submission. Because the listener uses +plain HTTP, use it only on the trusted private network; add internal TLS before +using it across an untrusted or shared network. + +The panel displays service readiness, OAuth status, the current run and phase, +all players, and the latest 40 action-log entries. Operators can disconnect a +player into AutoGuard, remove a character, end the active player phase, or reset +the run. All mutations pass through the authoritative game core and broadcast +the resulting snapshot to connected viewers. diff --git a/packages/domain/src/game.ts b/packages/domain/src/game.ts index 74ffb62..fa0796d 100644 --- a/packages/domain/src/game.ts +++ b/packages/domain/src/game.ts @@ -42,7 +42,26 @@ export class Game { const player=this.state.players[twitchUserId]; if(!player) return false player.extensionBound=true; player.connectionState=connected?'connected':'disconnected'; return true } - disconnect(twitchUserId: string): void { const p=this.state.players[twitchUserId]; if(p) p.connectionState='disconnected' } + disconnect(twitchUserId: string): boolean { + const player=this.state.players[twitchUserId] + if(!player || player.connectionState==='disconnected') return false + player.connectionState='disconnected' + if(this.state.phase.kind==='player' && player.lifeState==='alive' && player.ap>0) this.autoGuard(player) + this.finishPlayerPhaseIfReady() + return true + } + removePlayer(twitchUserId:string):boolean { + const player=this.state.players[twitchUserId] + if(!player)return false + delete this.state.players[twitchUserId] + if(this.state.goblin.targetPlayerId===twitchUserId){this.state.goblin.mode='returning';this.state.goblin.targetPlayerId=null} + this.log('player-removed',twitchUserId,null,`${player.displayName} was removed by an administrator.`) + if(Object.keys(this.state.players).length===0)this.state.phase={kind:'dormant'} + else this.finishPlayerPhaseIfReady() + return true + } + forceEndPlayerPhase():boolean {if(this.state.phase.kind!=='player')return false;this.endPlayerPhase();return true} + resetRunByAdmin():void {this.resetRun('admin-reset','An administrator reset the run.')} spawn(message: SpawnMessage): SpawnResult { if(this.externalEvents.has(message.externalEventId)) return {accepted:false,reason:'DUPLICATE',message:'That spawn request was already handled.'} @@ -127,9 +146,12 @@ export class Game { private startPlayerPhase(): void { const living=Object.values(this.state.players).filter(p=>p.lifeState==='alive') if(living.length===0){this.state.phase={kind:'dormant'};return} - for(const p of Object.values(this.state.players)){p.guard=0;p.eligibleThisPhase=p.lifeState==='alive';p.ap=p.lifeState==='alive'?2:0} const now=this.deps.clock.now(), duration=Math.min(living.length*25_000,120_000) this.state.phase={kind:'player',phaseId:this.deps.ids.next('phase'),startedAt:now,deadlineAt:now+duration,initialEligiblePlayerIds:living.map(p=>p.twitchUserId)} + for(const p of Object.values(this.state.players)){ + p.guard=0;p.eligibleThisPhase=p.lifeState==='alive';p.ap=p.lifeState==='alive'?2:0 + if(p.lifeState==='alive' && p.extensionBound && p.connectionState==='disconnected') this.autoGuard(p) + } this.log('player-phase-started',null,null,`Player Phase begins: ${duration/1000} seconds.`) } private finishPlayerPhaseIfReady(): void { @@ -139,10 +161,14 @@ export class Game { } private endPlayerPhase(): void { if(this.state.phase.kind!=='player') return - for(const p of Object.values(this.state.players)){if(p.lifeState==='alive')p.guard=p.ap;p.ap=0;p.eligibleThisPhase=false} + for(const p of Object.values(this.state.players)){if(p.lifeState==='alive')p.guard+=p.ap;p.ap=0;p.eligibleThisPhase=false} this.state.phase={kind:'enemy',phaseId:this.deps.ids.next('phase')};this.log('enemy-phase-started',null,null,'Enemy Phase begins.') this.runGoblin(); if(this.state.phase.kind==='enemy') this.startPlayerPhase() } + private autoGuard(player:PlayerState):void { + player.guard+=player.ap;player.ap=0;player.eligibleThisPhase=false + this.log('player-auto-guarded',player.twitchUserId,null,`${player.displayName} is disconnected and converts unused AP to Guard.`) + } private runGoblin(): void { if(this.state.goblin.mode==='dead' || this.state.goblin.mode==='guarding') return let ap=2 @@ -176,10 +202,10 @@ export class Game { const floor=this.deps.generateFloor(`${this.state.runId}-floor-${this.state.floorNumber}`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor) this.restorePlayers();this.log('floor-advanced',null,null,`The party reaches Floor ${this.state.floorNumber}!`);this.startPlayerPhase() } - private resetRun(): void { + private resetRun(logType='party-wipe',message='The party has fallen. A new run begins on Floor 1.'): void { this.state.phase={kind:'transition',reason:'party-wipe'};this.state.runId=this.deps.ids.next('run');this.state.floorNumber=1 const floor=this.deps.generateFloor(`${this.state.runId}-floor-1`);this.state.floor=floor;this.state.goblin=this.newGoblin(floor) - this.restorePlayers();this.log('party-wipe',null,null,'The party has fallen. A new run begins on Floor 1.');this.startPlayerPhase() + this.restorePlayers();this.log(logType,null,null,message);this.startPlayerPhase() } private restorePlayers(): void { for(const p of Object.values(this.state.players)){p.hp=3;p.lifeState='alive';p.ap=0;p.guard=0;p.healAvailable=true;p.diedOnFloor=null;p.eligibleThisPhase=false;p.participatingFloor=this.state.floorNumber;p.position=copy(this.state.floor.spawnTiles[0]!)} } private nextPathStep(start:TilePosition,goal:TilePosition):TilePosition|null { diff --git a/tests/domain/config.test.ts b/tests/domain/config.test.ts index 1c581e9..b556c5f 100644 --- a/tests/domain/config.test.ts +++ b/tests/domain/config.test.ts @@ -6,4 +6,5 @@ describe('Channel Points configuration',()=>{ 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/)) + it('requires a private host and strong password for the admin listener',()=>{expect(()=>loadConfig({ADMIN_ENABLED:'true',ADMIN_PASSWORD:'short'})).toThrow(/ADMIN_PASSWORD/);expect(()=>loadConfig({ADMIN_ENABLED:'true',ADMIN_PASSWORD:'long-enough-password',ADMIN_HOST:'0.0.0.0'})).toThrow(/ADMIN_HOST/);expect(loadConfig({ADMIN_ENABLED:'true',ADMIN_PASSWORD:'long-enough-password'})).toMatchObject({adminEnabled:true,adminHost:'10.138.4.44',adminPort:3001})}) }) diff --git a/tests/domain/game.test.ts b/tests/domain/game.test.ts index a968a37..4576172 100644 --- a/tests/domain/game.test.ts +++ b/tests/domain/game.test.ts @@ -17,6 +17,9 @@ describe('authoritative game core',()=>{ it('AT-010 prevents duplicate characters',()=>{const {game}=harness();expect(spawn(game).accepted).toBe(true);expect(spawn(game,'u1','One','spawn-2').reason).toBe('DUPLICATE');expect(game.snapshot().players).toHaveLength(1)}) it('AT-014 spends AP once and deduplicates request IDs',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');const env=envelope(game,{type:'pass'});expect(game.command('u1',true,env).accepted).toBe(true);expect(game.snapshot().players[0]?.ap).toBe(1);expect(game.command('u1',true,env).reason).toBe('DUPLICATE');expect(game.snapshot().players[0]?.ap).toBe(1)}) it('AT-017 ends early after the initial eligible set spends AP',()=>{const {game}=harness([1,1]);spawn(game);game.bindExtension('u1');game.command('u1',true,envelope(game,{type:'pass'},'p1'));game.command('u1',true,envelope(game,{type:'pass'},'p2'));expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players[0]?.ap).toBe(2)}) + it('AutoGuards a player immediately when their last connection closes',()=>{const {game}=harness();spawn(game);spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');(game as any).state.phase={kind:'dormant'};(game as any).startPlayerPhase();expect(game.disconnect('u2')).toBe(true);expect(game.snapshot().players.find(p=>p.twitchUserId==='u2')).toMatchObject({connectionState:'disconnected',ap:0,guard:2,eligibleThisPhase:false});expect(game.snapshot().phase.kind).toBe('player')}) + it('does not wait for a disconnected player in later phases',()=>{const {game}=harness([1,1]);spawn(game);spawn(game,'u2','Two');game.bindExtension('u1');game.bindExtension('u2');game.disconnect('u2');game.command('u1',true,envelope(game,{type:'pass'},'p1'));game.command('u1',true,envelope(game,{type:'pass'},'p2'));expect(game.snapshot().phase.kind).toBe('player');expect(game.snapshot().players.find(p=>p.twitchUserId==='u1')).toMatchObject({ap:2,connectionState:'connected'});expect(game.snapshot().players.find(p=>p.twitchUserId==='u2')).toMatchObject({ap:0,guard:2,connectionState:'disconnected'})}) + it('supports administrative player removal, phase completion, and run reset',()=>{const {game}=harness();spawn(game);spawn(game,'u2','Two');const firstRun=game.snapshot().runId;expect(game.removePlayer('u2')).toBe(true);expect(game.removePlayer('missing')).toBe(false);expect(game.snapshot().players.map(p=>p.twitchUserId)).toEqual(['u1']);expect(game.forceEndPlayerPhase()).toBe(true);game.resetRunByAdmin();expect(game.snapshot().runId).not.toBe(firstRun);expect(game.snapshot().actionLog.at(-2)?.type).toBe('admin-reset')}) it('rejects stale and deadline-boundary commands without AP loss',()=>{const {game,setTime}=harness();spawn(game);game.bindExtension('u1');const env=envelope(game,{type:'pass'});const phase=game.snapshot().phase;if(phase.kind!=='player')throw new Error();setTime(phase.deadlineAt);expect(game.command('u1',true,env).reason).toBe('DEADLINE_PASSED');expect(game.snapshot().players[0]?.ap).toBe(2);expect(game.command('u1',true,{...env,requestId:'stale',runId:'old'}).reason).toBe('STALE_RUN')}) it('AT-018 heals once and rejects invalid repeat with no AP loss',()=>{const {game}=harness();spawn(game);game.bindExtension('u1');(game as any).state.players.u1.hp=1;expect(game.command('u1',true,envelope(game,{type:'heal-self'},'h1')).accepted).toBe(true);expect(game.snapshot().players[0]).toMatchObject({hp:3,healAvailable:false,ap:1});expect(game.command('u1',true,envelope(game,{type:'heal-self'},'h2')).reason).toBe('HEAL_USED');expect(game.snapshot().players[0]?.ap).toBe(1)}) it('AT-018 attacks adjacent Goblin, aggroes on miss, and kills on hits',()=>{const {game}=harness([1,0,0]);spawn(game);game.bindExtension('u1');const internal=(game as any).state;internal.players.u1.position={x:internal.goblin.position.x-1,y:internal.goblin.position.y};expect(game.command('u1',true,envelope(game,{type:'attack',targetId:'goblin'},'a1')).accepted).toBe(true);expect(game.snapshot().goblin).toMatchObject({hp:2,mode:'pursuing',targetPlayerId:'u1'});expect(game.command('u1',true,envelope(game,{type:'attack',targetId:'goblin'},'a2')).accepted).toBe(true);expect(game.snapshot().goblin.hp).toBeLessThanOrEqual(1)}) diff --git a/tests/integration/admin.test.ts b/tests/integration/admin.test.ts new file mode 100644 index 0000000..5f96809 --- /dev/null +++ b/tests/integration/admin.test.ts @@ -0,0 +1,17 @@ +import { once } from 'node:events' +import { afterEach, describe, expect, it } from 'vitest' +import { createAdminServer } from '../../apps/backend/src/admin.js' +import { Game, type Clock, type IdGenerator, type RandomProvider } from '../../packages/domain/src/index.js' +import { generateFloor } from '../../packages/dungeon-generator/src/index.js' + +const servers:ReturnType[]=[] +afterEach(async()=>{for(const server of servers.splice(0))if(server.listening){server.close();await once(server,'close')}}) +function harness(allowed=true){let id=0;const clock:Clock={now:()=>1_000},random:RandomProvider={next:()=>0},ids:IdGenerator={next:prefix=>`${prefix}-${++id}`};const game=new Game({clock,random,ids,generateFloor,resurrectionRewardId:'resurrection'},'admin-test');game.spawn({type:'spawn-requested',externalEventId:'spawn',twitchUserId:'u1',displayName:'One',broadcasterId:'b',followerVerified:true});game.bindExtension('u1');const server=createAdminServer({game,password:'test-admin-password',csrfToken:'test-csrf-token',isAllowedAddress:()=>allowed,onStateChange:()=>{},status:async()=>({uptimeSeconds:10,twitchReady:true,twitchMode:'synthetic',oauthConfigured:false,oauthAuthorized:false})});servers.push(server);return{game,server}} +async function start(server:ReturnType){server.listen(0,'127.0.0.1');await once(server,'listening');const address=server.address();if(!address||typeof address==='string')throw new Error('No admin address');return`http://127.0.0.1:${address.port}`} +const authorization=`Basic ${Buffer.from('admin:test-admin-password').toString('base64')}` + +describe('private admin panel',()=>{ + it('rejects non-private clients before authentication',async()=>{const {server}=harness(false),base=await start(server);expect((await fetch(base,{headers:{authorization}})).status).toBe(403)}) + it('requires authentication and does not expose its password',async()=>{const {server}=harness(),base=await start(server);expect((await fetch(base)).status).toBe(401);const page=await (await fetch(base,{headers:{authorization}})).text();expect(page).toContain('Twungeon Admin');expect(page).toContain('One');expect(page).not.toContain('test-admin-password')}) + it('requires CSRF tokens and applies administrative actions',async()=>{const {game,server}=harness(),base=await start(server);const invalid=await fetch(`${base}/action`,{method:'POST',headers:{authorization,'content-type':'application/x-www-form-urlencoded'},body:'action=disconnect&userId=u1'});expect(invalid.status).toBe(403);const valid=await fetch(`${base}/action`,{method:'POST',headers:{authorization,'content-type':'application/x-www-form-urlencoded'},body:new URLSearchParams({_csrf:'test-csrf-token',action:'disconnect',userId:'u1'}),redirect:'manual'});expect(valid.status).toBe(303);expect(game.snapshot().players[0]).toMatchObject({connectionState:'disconnected',ap:0,guard:2})}) +})