From 7cdcc9cc4a25fd60dbbb40259978b1c09dd89935 Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:00:19 +0800 Subject: [PATCH 01/12] fix: expire authenticated SSE connections --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index 9dbd11d..fee475d 100644 --- a/server.js +++ b/server.js @@ -53,7 +53,7 @@ function sameBoard(a, b) { return a.every((row, y) => row.every((value, x) => va async function handler(req, res) { const url = new URL(req.url, `http://${req.headers.host}`); - if (req.method === 'GET' && url.pathname === '/events') { const user = requireUser(req, res, url); if (!user) return; res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }); res.write('event: connected\ndata: {}\n\n'); const client = { res, user }; clients.add(client); res.on('close', () => clients.delete(client)); return; } + if (req.method === 'GET' && url.pathname === '/events') { const user = requireUser(req, res, url); if (!user) return; res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }); res.write('event: connected\ndata: {}\n\n'); const client = { res, user }; clients.add(client); client.expiryTimer = setTimeout(() => { clients.delete(client); if (!res.writableEnded) { res.write('event: auth-expired\\ndata: {}\\n\\n'); res.end(); } }, Math.max(0, user.exp - Date.now())); client.expiryTimer.unref(); res.on('close', () => { clients.delete(client); clearTimeout(client.expiryTimer); }); return; } if (req.method === 'POST' && url.pathname === '/api/auth/anonymous') { const input = await body(req); const nickname = String(input.nickname || '棋手').trim().slice(0, 30) || '棋手'; const user = { id: id('usr'), nickname, exp: Date.now() + 7 * 864e5 }; return json(res, 201, { token: sign(user), user: { id: user.id, nickname } }); } if (!url.pathname.startsWith('/api/')) return serveStatic(url.pathname, res); const user = requireUser(req, res, url); if (!user) return; From bacbff4d7390abdca416e1312ca46b271db96ef6 Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:00:23 +0800 Subject: [PATCH 02/12] fix: prompt users to sign in after SSE auth expires --- app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.js b/app.js index 82592df..1bf00f5 100644 --- a/app.js +++ b/app.js @@ -30,7 +30,7 @@ function applyMove(board,x,y,color){if(board[y][x]!==0)return false;board[y][x]= async function restoreSessions(){const {sessions}=await api('/api/sessions');if(!sessions.length)return;renderGame(sessions[0].session,sessions[0].color);} async function loadApplications(roomId) { try { const { applications }=await api(`/api/rooms/${roomId}/applications`); $('#owner-applications').hidden=false; appsEl.replaceChildren(...applications.map(a=>{const el=document.createElement('div');el.textContent=`${a.nickname} `; for(const action of ['approve','reject']){const b=document.createElement('button');b.textContent=action==='approve'?'同意':'拒绝';b.onclick=()=>handleApplication(roomId,a.id,action);el.append(b);}return el;})); } catch(e) { message(e.message); } } async function handleApplication(roomId, appId, action) { try { const result=await api(`/api/rooms/${roomId}/applications/${appId}/${action}`,{method:'POST',body:'{}'}); if(action==='approve') { renderGame(result.session, 1); $('#owner-applications').hidden=true; } else loadApplications(roomId); } catch(e){message(e.message);} } -function connectEvents() { if(stream) stream.close(); stream=new EventSource(`/events?token=${encodeURIComponent(token)}`); stream.onopen=()=>$('#connection-status').textContent='实时连接已建立'; stream.onerror=()=>$('#connection-status').textContent='实时连接断开,正在重连…'; stream.addEventListener('room-list-changed',()=>loadRooms().catch(e=>message(e.message))); stream.addEventListener('application-arrived',e=>{const d=JSON.parse(e.data);message('收到新的加入申请。');loadApplications(d.roomId);}); stream.addEventListener('application-approved',e=>{const d=JSON.parse(e.data); api(`/api/sessions/${d.session.id}`).then(r=>renderGame(r.session,r.color));}); stream.addEventListener('application-rejected',()=>message('你的申请被拒绝,房间仍在招募中。',$('#lobby-status'))); +function connectEvents() { if(stream) stream.close(); stream=new EventSource(`/events?token=${encodeURIComponent(token)}`); stream.onopen=()=>$('#connection-status').textContent='实时连接已建立'; stream.onerror=()=>$('#connection-status').textContent='实时连接断开,正在重连…'; stream.addEventListener('auth-expired',()=>{stream?.close();stream=null;token=null;sessionStorage.removeItem('weiqi-token');$('#identity').hidden=false;$('#lobby').hidden=true;message('登录已过期,请重新登录。');}); stream.addEventListener('room-list-changed',()=>loadRooms().catch(e=>message(e.message))); stream.addEventListener('application-arrived',e=>{const d=JSON.parse(e.data);message('收到新的加入申请。');loadApplications(d.roomId);}); stream.addEventListener('application-approved',e=>{const d=JSON.parse(e.data); api(`/api/sessions/${d.session.id}`).then(r=>renderGame(r.session,r.color));}); stream.addEventListener('application-rejected',()=>message('你的申请被拒绝,房间仍在招募中。',$('#lobby-status'))); stream.addEventListener('game-state',e=>{const d=JSON.parse(e.data);if(d.session.id===game.sessionId) renderGame(d.session);}); } $('#sign-in').onclick=async()=>{try{const d=await jsonResponse(await fetch('/api/auth/anonymous',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({nickname:$('#nickname').value})}));token=d.token;sessionStorage.setItem('weiqi-token',token);$('#identity').hidden=true;$('#lobby').hidden=false;connectEvents();loadRooms();restoreSessions().catch(e=>message(e.message));}catch(e){message(e.message);}}; $('#create-room-form').onsubmit=async e=>{e.preventDefault();try{const room=(await api('/api/rooms',{method:'POST',body:JSON.stringify({title:$('#room-title').value,introduction:$('#room-introduction').value,boardSize:Number($('#board-size').value),timeSettings:{totalMinutes:Number($('#game-minutes').value),moveSeconds:Number($('#move-seconds').value)}})})).room;message(`房间已创建,正在招募:${room.title}`,$('#lobby-status'));loadApplications(room.id);}catch(err){message(err.message,$('#lobby-status'));}}; From 10b008d0baa9d758c3533e7b397508a2f52ed6ed Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:00:26 +0800 Subject: [PATCH 03/12] test: cover SSE authentication expiry --- test/server.test.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/server.test.js b/test/server.test.js index 11c9cc7..c6e7858 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -2,6 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const http = require('node:http'); +const crypto = require('node:crypto'); process.env.DATA_FILE = '/tmp/wei-qi-test-rooms.json'; const { handler, resetForTest } = require('../server'); function request(server, method, pathname, token, body) { return new Promise((resolve, reject) => { const req = http.request({ port: server.address().port, method, path: pathname, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body ? { 'content-type':'application/json' } : {}) } }, res => { let raw=''; res.on('data', c=>raw+=c);res.on('end',()=>resolve({status:res.statusCode,body:JSON.parse(raw)})); });req.on('error',reject);if(body)req.end(JSON.stringify(body));else req.end(); }); } @@ -202,3 +203,34 @@ test('SSE clients receive subsequent room-list events', async () => { await new Promise(resolve => server.close(resolve)); } }); + + +test('SSE connections close when their authentication token expires', async () => { + resetForTest(); + const server = http.createServer(handler); + await new Promise(resolve => server.listen(0, resolve)); + let stream; + try { + const user = { id: 'usr_expiring', nickname: '过期测试', exp: Date.now() + 150 }; + const body = Buffer.from(JSON.stringify(user)).toString('base64url'); + const secret = process.env.AUTH_SECRET || 'replace-this-development-secret'; + const signature = crypto.createHmac('sha256', secret).update(body).digest('base64url'); + const token = body + '.' + signature; + let received = ''; + const ended = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('SSE connection stayed open after token expiry')), 1500); + stream = http.get({ port: server.address().port, path: '/events?token=' + encodeURIComponent(token) }, response => { + response.setEncoding('utf8'); + response.on('data', chunk => { received += chunk; }); + response.on('end', () => { clearTimeout(timeout); resolve(); }); + }); + stream.on('error', err => { clearTimeout(timeout); reject(err); }); + }); + await ended; + assert.match(received, /event: connected/); + assert.match(received, /event: auth-expired/); + } finally { + if (stream) stream.destroy(); + await new Promise(resolve => server.close(resolve)); + } +}); From 8813f604986863b2dba98fdea85eea8f35ab1a80 Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:00:36 +0800 Subject: [PATCH 04/12] fix: emit a valid SSE expiry event --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index fee475d..b483105 100644 --- a/server.js +++ b/server.js @@ -53,7 +53,7 @@ function sameBoard(a, b) { return a.every((row, y) => row.every((value, x) => va async function handler(req, res) { const url = new URL(req.url, `http://${req.headers.host}`); - if (req.method === 'GET' && url.pathname === '/events') { const user = requireUser(req, res, url); if (!user) return; res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }); res.write('event: connected\ndata: {}\n\n'); const client = { res, user }; clients.add(client); client.expiryTimer = setTimeout(() => { clients.delete(client); if (!res.writableEnded) { res.write('event: auth-expired\\ndata: {}\\n\\n'); res.end(); } }, Math.max(0, user.exp - Date.now())); client.expiryTimer.unref(); res.on('close', () => { clients.delete(client); clearTimeout(client.expiryTimer); }); return; } + if (req.method === 'GET' && url.pathname === '/events') { const user = requireUser(req, res, url); if (!user) return; res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }); res.write('event: connected\ndata: {}\n\n'); const client = { res, user }; clients.add(client); client.expiryTimer = setTimeout(() => { clients.delete(client); if (!res.writableEnded) { res.write('event: auth-expired\ndata: {}\n\n'); res.end(); } }, Math.max(0, user.exp - Date.now())); client.expiryTimer.unref(); res.on('close', () => { clients.delete(client); clearTimeout(client.expiryTimer); }); return; } if (req.method === 'POST' && url.pathname === '/api/auth/anonymous') { const input = await body(req); const nickname = String(input.nickname || '棋手').trim().slice(0, 30) || '棋手'; const user = { id: id('usr'), nickname, exp: Date.now() + 7 * 864e5 }; return json(res, 201, { token: sign(user), user: { id: user.id, nickname } }); } if (!url.pathname.startsWith('/api/')) return serveStatic(url.pathname, res); const user = requireUser(req, res, url); if (!user) return; From 8d4a754909aaffbf743dc485550a7c58d8934863 Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:01:37 +0800 Subject: [PATCH 05/12] fix: recover login UI for expired saved tokens --- app.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index 1bf00f5..e4db378 100644 --- a/app.js +++ b/app.js @@ -11,7 +11,7 @@ async function jsonResponse(response) { try { data = JSON.parse(text); } catch { throw new Error('服务器返回了无法读取的数据,请稍后重试。'); } } - if (!response.ok) throw new Error(data?.error?.message || `请求失败(${response.status})。`); + if (!response.ok) { const error = new Error(data?.error?.message || `请求失败(${response.status})。`); error.status = response.status; throw error; } if (data === null) throw new Error('服务器未返回预期的数据,请稍后重试。'); return data; } @@ -30,12 +30,13 @@ function applyMove(board,x,y,color){if(board[y][x]!==0)return false;board[y][x]= async function restoreSessions(){const {sessions}=await api('/api/sessions');if(!sessions.length)return;renderGame(sessions[0].session,sessions[0].color);} async function loadApplications(roomId) { try { const { applications }=await api(`/api/rooms/${roomId}/applications`); $('#owner-applications').hidden=false; appsEl.replaceChildren(...applications.map(a=>{const el=document.createElement('div');el.textContent=`${a.nickname} `; for(const action of ['approve','reject']){const b=document.createElement('button');b.textContent=action==='approve'?'同意':'拒绝';b.onclick=()=>handleApplication(roomId,a.id,action);el.append(b);}return el;})); } catch(e) { message(e.message); } } async function handleApplication(roomId, appId, action) { try { const result=await api(`/api/rooms/${roomId}/applications/${appId}/${action}`,{method:'POST',body:'{}'}); if(action==='approve') { renderGame(result.session, 1); $('#owner-applications').hidden=true; } else loadApplications(roomId); } catch(e){message(e.message);} } -function connectEvents() { if(stream) stream.close(); stream=new EventSource(`/events?token=${encodeURIComponent(token)}`); stream.onopen=()=>$('#connection-status').textContent='实时连接已建立'; stream.onerror=()=>$('#connection-status').textContent='实时连接断开,正在重连…'; stream.addEventListener('auth-expired',()=>{stream?.close();stream=null;token=null;sessionStorage.removeItem('weiqi-token');$('#identity').hidden=false;$('#lobby').hidden=true;message('登录已过期,请重新登录。');}); stream.addEventListener('room-list-changed',()=>loadRooms().catch(e=>message(e.message))); stream.addEventListener('application-arrived',e=>{const d=JSON.parse(e.data);message('收到新的加入申请。');loadApplications(d.roomId);}); stream.addEventListener('application-approved',e=>{const d=JSON.parse(e.data); api(`/api/sessions/${d.session.id}`).then(r=>renderGame(r.session,r.color));}); stream.addEventListener('application-rejected',()=>message('你的申请被拒绝,房间仍在招募中。',$('#lobby-status'))); +function handleAuthExpired(){if(stream){stream.close();stream=null;}token=null;sessionStorage.removeItem('weiqi-token');$('#identity').hidden=false;$('#lobby').hidden=true;message('登录已过期,请重新登录。');} +function connectEvents() { if(stream) stream.close(); stream=new EventSource(`/events?token=${encodeURIComponent(token)}`); stream.onopen=()=>$('#connection-status').textContent='实时连接已建立'; stream.onerror=()=>$('#connection-status').textContent='实时连接断开,正在重连…'; stream.addEventListener('auth-expired',handleAuthExpired); stream.addEventListener('room-list-changed',()=>loadRooms().catch(e=>message(e.message))); stream.addEventListener('application-arrived',e=>{const d=JSON.parse(e.data);message('收到新的加入申请。');loadApplications(d.roomId);}); stream.addEventListener('application-approved',e=>{const d=JSON.parse(e.data); api(`/api/sessions/${d.session.id}`).then(r=>renderGame(r.session,r.color));}); stream.addEventListener('application-rejected',()=>message('你的申请被拒绝,房间仍在招募中。',$('#lobby-status'))); stream.addEventListener('game-state',e=>{const d=JSON.parse(e.data);if(d.session.id===game.sessionId) renderGame(d.session);}); } $('#sign-in').onclick=async()=>{try{const d=await jsonResponse(await fetch('/api/auth/anonymous',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({nickname:$('#nickname').value})}));token=d.token;sessionStorage.setItem('weiqi-token',token);$('#identity').hidden=true;$('#lobby').hidden=false;connectEvents();loadRooms();restoreSessions().catch(e=>message(e.message));}catch(e){message(e.message);}}; $('#create-room-form').onsubmit=async e=>{e.preventDefault();try{const room=(await api('/api/rooms',{method:'POST',body:JSON.stringify({title:$('#room-title').value,introduction:$('#room-introduction').value,boardSize:Number($('#board-size').value),timeSettings:{totalMinutes:Number($('#game-minutes').value),moveSeconds:Number($('#move-seconds').value)}})})).room;message(`房间已创建,正在招募:${room.title}`,$('#lobby-status'));loadApplications(room.id);}catch(err){message(err.message,$('#lobby-status'));}}; canvas.onclick=async e=>{if(!game.state||game.state.finished)return;if(game.state.current!==game.color)return message('尚未轮到你落子。');const rect=canvas.getBoundingClientRect(),pad=20,cell=(canvas.width-pad*2)/(game.boardSize-1),scaleX=canvas.width/rect.width,scaleY=canvas.height/rect.height,x=Math.round(((e.clientX-rect.left)*scaleX-pad)/cell),y=Math.round(((e.clientY-rect.top)*scaleY-pad)/cell);if(x<0||y<0||x>=game.boardSize||y>=game.boardSize||game.state.board[y][x])return message('该位置不能落子。');const state=structuredClone(game.state);if(!applyMove(state.board,x,y,game.color))return message('该位置是禁入点。');state.current=game.color===1?2:1;state.passes=0;try{await api(`/api/sessions/${game.sessionId}/state`,{method:'PUT',body:JSON.stringify({state})});}catch(err){message(err.message);}}; $('#pass').onclick=async()=>{if(!game.state||game.state.current!==game.color)return;const state=structuredClone(game.state);state.current=game.color===1?2:1;state.passes++;try{await api(`/api/sessions/${game.sessionId}/state`,{method:'PUT',body:JSON.stringify({state})});}catch(e){message(e.message);}}; -if(token){$('#identity').hidden=true;$('#lobby').hidden=false;connectEvents();loadRooms().catch(()=>sessionStorage.removeItem('weiqi-token'));restoreSessions().catch(()=>sessionStorage.removeItem('weiqi-token')); } +if(token){$('#identity').hidden=true;$('#lobby').hidden=false;connectEvents();loadRooms().catch(e=>{if(e.status===401)handleAuthExpired();else message(e.message,$('#lobby-status'));});restoreSessions().catch(e=>{if(e.status===401)handleAuthExpired();else message(e.message);}); } $('#resign').onclick=async()=>{if(!game.sessionId||!game.state||game.state.finished)return;if(!confirm('确定认输并结束本局对战吗?'))return;try{const result=await api(`/api/sessions/${game.sessionId}/resign`,{method:'POST',body:'{}'});renderGame(result.session,game.color);}catch(e){message(e.message);}}; From ab8ee9352790ba50a8d498c613907888c55f58ed Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:07:25 +0800 Subject: [PATCH 06/12] fix: handle SSE token expiry after disconnect --- app.js | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index 087930f..4506a87 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,26 @@ const canvas = document.querySelector('#board'), ctx = canvas.getContext('2d'); const statusEl = document.querySelector('#status'), roomList = document.querySelector('#room-list'), appsEl = document.querySelector('#applications'); const game = { sessionId: null, color: null, boardSize: 19, state: null, timer: null, players: null }; -let token = sessionStorage.getItem('weiqi-token'), stream; +let token = sessionStorage.getItem('weiqi-token'), stream, authExpiryTimer; +function tokenExpiry(tokenValue) { + try { + const encoded = tokenValue.split('.')[0].replace(/-/g, '+').replace(/_/g, '/'); + const bytes = Uint8Array.from(atob(encoded), character => character.charCodeAt(0)); + const payload = JSON.parse(new TextDecoder().decode(bytes)); + return Number.isFinite(payload.exp) ? payload.exp : null; + } catch { return null; } +} +function scheduleAuthExpiry() { + clearTimeout(authExpiryTimer); + const expiry = tokenExpiry(token); + if (expiry === null) return; + const remaining = expiry - Date.now(); + if (remaining <= 0) { handleAuthExpired(); return; } + authExpiryTimer = setTimeout(() => { + if (Date.now() >= expiry) handleAuthExpired(); + else scheduleAuthExpiry(); + }, Math.min(remaining, 2147483647)); +} const $ = (s) => document.querySelector(s); function message(text, target = statusEl) { target.textContent = text; } async function jsonResponse(response) { @@ -30,8 +49,8 @@ function applyMove(board,x,y,color){if(board[y][x]!==0)return false;board[y][x]= async function restoreSessions(){const {sessions}=await api('/api/sessions');if(!sessions.length)return;renderGame(sessions[0].session,sessions[0].color);} async function loadApplications(roomId) { try { const { applications }=await api(`/api/rooms/${roomId}/applications`); $('#owner-applications').hidden=false; appsEl.replaceChildren(...applications.map(a=>{const el=document.createElement('div');el.textContent=`${a.nickname} `; for(const action of ['approve','reject']){const b=document.createElement('button');b.textContent=action==='approve'?'同意':'拒绝';b.onclick=()=>handleApplication(roomId,a.id,action);el.append(b);}return el;})); } catch(e) { message(e.message); } } async function handleApplication(roomId, appId, action) { try { const result=await api(`/api/rooms/${roomId}/applications/${appId}/${action}`,{method:'POST',body:'{}'}); if(action==='approve') { renderGame(result.session, 1); $('#owner-applications').hidden=true; } else loadApplications(roomId); } catch(e){message(e.message);} } -function handleAuthExpired(){if(stream){stream.close();stream=null;}token=null;sessionStorage.removeItem('weiqi-token');$('#identity').hidden=false;$('#lobby').hidden=true;message('登录已过期,请重新登录。');} -function connectEvents() { if(stream) stream.close(); stream=new EventSource(`/events?token=${encodeURIComponent(token)}`); stream.onopen=()=>$('#connection-status').textContent='实时连接已建立'; stream.onerror=()=>$('#connection-status').textContent='实时连接断开,正在重连…'; stream.addEventListener('auth-expired',handleAuthExpired); stream.addEventListener('room-list-changed',()=>loadRooms().catch(e=>message(e.message))); stream.addEventListener('application-arrived',e=>{const d=JSON.parse(e.data);message('收到新的加入申请。');loadApplications(d.roomId);}); stream.addEventListener('application-approved',e=>{const d=JSON.parse(e.data); api(`/api/sessions/${d.session.id}`).then(r=>renderGame(r.session,r.color));}); stream.addEventListener('application-rejected',()=>message('你的申请被拒绝,房间仍在招募中。',$('#lobby-status'))); +function handleAuthExpired(){clearTimeout(authExpiryTimer);authExpiryTimer=null;if(stream){stream.close();stream=null;}token=null;sessionStorage.removeItem('weiqi-token');$('#identity').hidden=false;$('#lobby').hidden=true;message('登录已过期,请重新登录。');} +function connectEvents() { scheduleAuthExpiry(); if(stream) stream.close(); stream=new EventSource(`/events?token=${encodeURIComponent(token)}`); stream.onopen=()=>$('#connection-status').textContent='实时连接已建立'; stream.onerror=()=>$('#connection-status').textContent='实时连接断开,正在重连…'; stream.addEventListener('auth-expired',handleAuthExpired); stream.addEventListener('room-list-changed',()=>loadRooms().catch(e=>message(e.message))); stream.addEventListener('application-arrived',e=>{const d=JSON.parse(e.data);message('收到新的加入申请。');loadApplications(d.roomId);}); stream.addEventListener('application-approved',e=>{const d=JSON.parse(e.data); api(`/api/sessions/${d.session.id}`).then(r=>renderGame(r.session,r.color));}); stream.addEventListener('application-rejected',()=>message('你的申请被拒绝,房间仍在招募中。',$('#lobby-status'))); stream.addEventListener('game-state',e=>{const d=JSON.parse(e.data);if(d.session.id===game.sessionId) renderGame(d.session);}); } $('#sign-in').onclick=async()=>{try{const d=await jsonResponse(await fetch('/api/auth/anonymous',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({nickname:$('#nickname').value})}));token=d.token;sessionStorage.setItem('weiqi-token',token);$('#identity').hidden=true;$('#lobby').hidden=false;connectEvents();loadRooms();restoreSessions().catch(e=>message(e.message));}catch(e){message(e.message);}}; $('#create-room-form').onsubmit=async e=>{e.preventDefault();try{const room=(await api('/api/rooms',{method:'POST',body:JSON.stringify({title:$('#room-title').value,introduction:$('#room-introduction').value,boardSize:Number($('#board-size').value),timeSettings:{totalMinutes:Number($('#game-minutes').value),moveSeconds:Number($('#move-seconds').value)}})})).room;message(`房间已创建,正在招募:${room.title}`,$('#lobby-status'));loadApplications(room.id);}catch(err){message(err.message,$('#lobby-status'));}}; From d0e36965d43755f7dda3fef76d6058823d8fe918 Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:07:45 +0800 Subject: [PATCH 07/12] test: cover expired SSE token reconnect --- test/server.test.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/server.test.js b/test/server.test.js index c6e7858..169c388 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -205,6 +205,41 @@ test('SSE clients receive subsequent room-list events', async () => { }); + +test('an expired SSE token is rejected when reconnecting after disconnect', async () => { + resetForTest(); + const server = http.createServer(handler); + await new Promise(resolve => server.listen(0, resolve)); + let stream; + try { + const user = { id: 'usr_reconnect_expired', nickname: '断线重连测试', exp: Date.now() + 250 }; + const body = Buffer.from(JSON.stringify(user)).toString('base64url'); + const secret = process.env.AUTH_SECRET || 'replace-this-development-secret'; + const signature = crypto.createHmac('sha256', secret).update(body).digest('base64url'); + const token = body + '.' + signature; + let received = ''; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('SSE connection did not open')), 1000); + stream = http.get({ port: server.address().port, path: '/events?token=' + encodeURIComponent(token) }, response => { + response.setEncoding('utf8'); + response.on('data', chunk => { + received += chunk; + if (received.includes('event: connected')) { clearTimeout(timeout); resolve(); } + }); + }); + stream.on('error', err => { clearTimeout(timeout); reject(err); }); + }); + stream.destroy(); + await new Promise(resolve => setTimeout(resolve, Math.max(0, user.exp - Date.now() + 25))); + const reconnect = await request(server, 'GET', '/events?token=' + encodeURIComponent(token)); + assert.equal(reconnect.status, 401); + assert.equal(reconnect.body.error.code, 'UNAUTHENTICATED'); + } finally { + if (stream) stream.destroy(); + await new Promise(resolve => server.close(resolve)); + } +}); + test('SSE connections close when their authentication token expires', async () => { resetForTest(); const server = http.createServer(handler); From 46358475bb20421183984c6e204ec47e8803ae59 Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:08:21 +0800 Subject: [PATCH 08/12] test: cover client sign-in after disconnected token expiry --- test/server.test.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/server.test.js b/test/server.test.js index 169c388..33ccdf3 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -3,6 +3,9 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const http = require('node:http'); const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); process.env.DATA_FILE = '/tmp/wei-qi-test-rooms.json'; const { handler, resetForTest } = require('../server'); function request(server, method, pathname, token, body) { return new Promise((resolve, reject) => { const req = http.request({ port: server.address().port, method, path: pathname, headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body ? { 'content-type':'application/json' } : {}) } }, res => { let raw=''; res.on('data', c=>raw+=c);res.on('end',()=>resolve({status:res.statusCode,body:JSON.parse(raw)})); });req.on('error',reject);if(body)req.end(JSON.stringify(body));else req.end(); }); } @@ -206,6 +209,38 @@ test('SSE clients receive subsequent room-list events', async () => { + +test('client returns to sign-in when token expires without an active SSE stream', async () => { + const elements = new Map(); + const getElement = selector => { + if (!elements.has(selector)) elements.set(selector, { + hidden: false, textContent: '', disabled: false, onclick: null, onsubmit: null, + replaceChildren() {}, append() {}, addEventListener() {}, + getContext() { return {}; } + }); + return elements.get(selector); + }; + const storage = new Map(); + const context = vm.createContext({ + document: { querySelector: getElement }, + sessionStorage: { + getItem: key => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: key => storage.delete(key) + }, + atob, TextDecoder, Uint8Array, setTimeout, clearTimeout, Date, Math, JSON, console + }); + const source = fs.readFileSync(path.join(__dirname, '..', 'app.js'), 'utf8'); + vm.runInContext(source, context, { filename: 'app.js' }); + const payload = Buffer.from(JSON.stringify({ id: 'usr_client_expiry', nickname: '过期用户', exp: Date.now() + 80 })).toString('base64url'); + vm.runInContext(`token = ${JSON.stringify(payload + '.signature')}; sessionStorage.setItem('weiqi-token', token); scheduleAuthExpiry();`, context); + await new Promise(resolve => setTimeout(resolve, 180)); + assert.equal(storage.has('weiqi-token'), false); + assert.equal(getElement('#identity').hidden, false); + assert.equal(getElement('#lobby').hidden, true); + assert.equal(getElement('#status').textContent, '登录已过期,请重新登录。'); +}); + test('an expired SSE token is rejected when reconnecting after disconnect', async () => { resetForTest(); const server = http.createServer(handler); From 36aa5ebdc4528460ddd3fd14ec8e91ab04fbafb0 Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:08:49 +0800 Subject: [PATCH 09/12] test: fix client expiry regression harness --- test/server.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/server.test.js b/test/server.test.js index 33ccdf3..cb1b3c2 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -233,7 +233,7 @@ test('client returns to sign-in when token expires without an active SSE stream' const source = fs.readFileSync(path.join(__dirname, '..', 'app.js'), 'utf8'); vm.runInContext(source, context, { filename: 'app.js' }); const payload = Buffer.from(JSON.stringify({ id: 'usr_client_expiry', nickname: '过期用户', exp: Date.now() + 80 })).toString('base64url'); - vm.runInContext(`token = ${JSON.stringify(payload + '.signature')}; sessionStorage.setItem('weiqi-token', token); scheduleAuthExpiry();`, context); + vm.runInContext("token = " + JSON.stringify(payload + '.signature') + "; sessionStorage.setItem('weiqi-token', token); scheduleAuthExpiry();", context); await new Promise(resolve => setTimeout(resolve, 180)); assert.equal(storage.has('weiqi-token'), false); assert.equal(getElement('#identity').hidden, false); From d390e64cde7eb1fa8cf91ef4906a1c73c701804a Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:09:13 +0800 Subject: [PATCH 10/12] fix: avoid SSE connection after expired startup token --- app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.js b/app.js index 4506a87..9c89daf 100644 --- a/app.js +++ b/app.js @@ -50,7 +50,7 @@ async function restoreSessions(){const {sessions}=await api('/api/sessions');if( async function loadApplications(roomId) { try { const { applications }=await api(`/api/rooms/${roomId}/applications`); $('#owner-applications').hidden=false; appsEl.replaceChildren(...applications.map(a=>{const el=document.createElement('div');el.textContent=`${a.nickname} `; for(const action of ['approve','reject']){const b=document.createElement('button');b.textContent=action==='approve'?'同意':'拒绝';b.onclick=()=>handleApplication(roomId,a.id,action);el.append(b);}return el;})); } catch(e) { message(e.message); } } async function handleApplication(roomId, appId, action) { try { const result=await api(`/api/rooms/${roomId}/applications/${appId}/${action}`,{method:'POST',body:'{}'}); if(action==='approve') { renderGame(result.session, 1); $('#owner-applications').hidden=true; } else loadApplications(roomId); } catch(e){message(e.message);} } function handleAuthExpired(){clearTimeout(authExpiryTimer);authExpiryTimer=null;if(stream){stream.close();stream=null;}token=null;sessionStorage.removeItem('weiqi-token');$('#identity').hidden=false;$('#lobby').hidden=true;message('登录已过期,请重新登录。');} -function connectEvents() { scheduleAuthExpiry(); if(stream) stream.close(); stream=new EventSource(`/events?token=${encodeURIComponent(token)}`); stream.onopen=()=>$('#connection-status').textContent='实时连接已建立'; stream.onerror=()=>$('#connection-status').textContent='实时连接断开,正在重连…'; stream.addEventListener('auth-expired',handleAuthExpired); stream.addEventListener('room-list-changed',()=>loadRooms().catch(e=>message(e.message))); stream.addEventListener('application-arrived',e=>{const d=JSON.parse(e.data);message('收到新的加入申请。');loadApplications(d.roomId);}); stream.addEventListener('application-approved',e=>{const d=JSON.parse(e.data); api(`/api/sessions/${d.session.id}`).then(r=>renderGame(r.session,r.color));}); stream.addEventListener('application-rejected',()=>message('你的申请被拒绝,房间仍在招募中。',$('#lobby-status'))); +function connectEvents() { scheduleAuthExpiry(); if(!token) return; if(stream) stream.close(); stream=new EventSource(`/events?token=${encodeURIComponent(token)}`); stream.onopen=()=>$('#connection-status').textContent='实时连接已建立'; stream.onerror=()=>$('#connection-status').textContent='实时连接断开,正在重连…'; stream.addEventListener('auth-expired',handleAuthExpired); stream.addEventListener('room-list-changed',()=>loadRooms().catch(e=>message(e.message))); stream.addEventListener('application-arrived',e=>{const d=JSON.parse(e.data);message('收到新的加入申请。');loadApplications(d.roomId);}); stream.addEventListener('application-approved',e=>{const d=JSON.parse(e.data); api(`/api/sessions/${d.session.id}`).then(r=>renderGame(r.session,r.color));}); stream.addEventListener('application-rejected',()=>message('你的申请被拒绝,房间仍在招募中。',$('#lobby-status'))); stream.addEventListener('game-state',e=>{const d=JSON.parse(e.data);if(d.session.id===game.sessionId) renderGame(d.session);}); } $('#sign-in').onclick=async()=>{try{const d=await jsonResponse(await fetch('/api/auth/anonymous',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({nickname:$('#nickname').value})}));token=d.token;sessionStorage.setItem('weiqi-token',token);$('#identity').hidden=true;$('#lobby').hidden=false;connectEvents();loadRooms();restoreSessions().catch(e=>message(e.message));}catch(e){message(e.message);}}; $('#create-room-form').onsubmit=async e=>{e.preventDefault();try{const room=(await api('/api/rooms',{method:'POST',body:JSON.stringify({title:$('#room-title').value,introduction:$('#room-introduction').value,boardSize:Number($('#board-size').value),timeSettings:{totalMinutes:Number($('#game-minutes').value),moveSeconds:Number($('#move-seconds').value)}})})).room;message(`房间已创建,正在招募:${room.title}`,$('#lobby-status'));loadApplications(room.id);}catch(err){message(err.message,$('#lobby-status'));}}; From bbeff66ebb1d2a9cac544a7647872dd5b9f09761 Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:09:28 +0800 Subject: [PATCH 11/12] test: cover expired token on client startup --- test/server.test.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/server.test.js b/test/server.test.js index cb1b3c2..ebd20e9 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -241,6 +241,39 @@ test('client returns to sign-in when token expires without an active SSE stream' assert.equal(getElement('#status').textContent, '登录已过期,请重新登录。'); }); + +test('client does not open SSE with an already-expired stored token', async () => { + const elements = new Map(); + const getElement = selector => { + if (!elements.has(selector)) elements.set(selector, { + hidden: false, textContent: '', disabled: false, onclick: null, onsubmit: null, + replaceChildren() {}, append() {}, addEventListener() {}, + getContext() { return {}; } + }); + return elements.get(selector); + }; + const storage = new Map(); + let openedStreams = 0; + const context = vm.createContext({ + document: { querySelector: getElement }, + sessionStorage: { + getItem: key => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: key => storage.delete(key) + }, + EventSource: function () { openedStreams++; this.close = () => {}; this.addEventListener = () => {}; }, + atob, TextDecoder, Uint8Array, setTimeout, clearTimeout, Date, Math, JSON, console + }); + const source = fs.readFileSync(path.join(__dirname, '..', 'app.js'), 'utf8'); + vm.runInContext(source, context, { filename: 'app.js' }); + const payload = Buffer.from(JSON.stringify({ id: 'usr_already_expired', nickname: '过期用户', exp: Date.now() - 100 })).toString('base64url'); + vm.runInContext("token = " + JSON.stringify(payload + '.signature') + "; sessionStorage.setItem('weiqi-token', token); connectEvents();", context); + assert.equal(openedStreams, 0); + assert.equal(storage.has('weiqi-token'), false); + assert.equal(getElement('#identity').hidden, false); + assert.equal(getElement('#lobby').hidden, true); +}); + test('an expired SSE token is rejected when reconnecting after disconnect', async () => { resetForTest(); const server = http.createServer(handler); From 3f6df4c6abc2cabff2ef32684f83b9388ed1971f Mon Sep 17 00:00:00 2001 From: lijiashu1 Date: Tue, 20 Oct 2026 18:09:53 +0800 Subject: [PATCH 12/12] test: stabilize client token expiry timer assertion --- test/server.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/server.test.js b/test/server.test.js index ebd20e9..dc46c21 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -232,9 +232,9 @@ test('client returns to sign-in when token expires without an active SSE stream' }); const source = fs.readFileSync(path.join(__dirname, '..', 'app.js'), 'utf8'); vm.runInContext(source, context, { filename: 'app.js' }); - const payload = Buffer.from(JSON.stringify({ id: 'usr_client_expiry', nickname: '过期用户', exp: Date.now() + 80 })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ id: 'usr_client_expiry', nickname: '过期用户', exp: Date.now() + 300 })).toString('base64url'); vm.runInContext("token = " + JSON.stringify(payload + '.signature') + "; sessionStorage.setItem('weiqi-token', token); scheduleAuthExpiry();", context); - await new Promise(resolve => setTimeout(resolve, 180)); + await new Promise(resolve => setTimeout(resolve, 450)); assert.equal(storage.has('weiqi-token'), false); assert.equal(getElement('#identity').hidden, false); assert.equal(getElement('#lobby').hidden, true);