Harden hosted relay with access codes + illustrated manual page

--tokens FILE gates session hosting: hosts present an access code, patients
join live sessions freely (no code), un-joined connections can't relay, and
joined members can't spoof state into other rooms. Easy Mode grows a gentle
access-code card (one-computer mode stays free/ungated); advanced panel gets
a token field; patient view shows a waiting note and auto-retries.
manual.html: picture-book how-to, images slot in under img/manual/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-07 15:14:03 +10:00
parent 55d1686d8d
commit eed30a2ba5
7 changed files with 165 additions and 8 deletions

View File

@ -93,6 +93,11 @@ controls".
- **Session IDs are random by default** (e.g. `room-k3x9q2f1`). Anyone on the
same network who knows a session ID can join it and control what the
patient sees — leave the random ID alone and share only the join link.
- **Hosting it on the internet?** Run `server.py --tokens FILE` (one access
code per line, `#` comments allowed). Hosts must present a valid code to
open a session; patients never need one — they can only join a session
that's already live, via the therapist's link. Without a code the app still
works fully in one-computer mode.
- Stop the server with the **Stop Server** button (works from the therapist
machine only) or `Ctrl+C` in the terminal.
- A manual peer-to-peer WebRTC mode exists for networks where the relay is

View File

@ -110,6 +110,21 @@
<button id="openPatientBig" class="bigbtn">🖥️ Open patient screen</button>
<button id="copyLinkBig" class="bigbtn" hidden>🔗 Copy link for patient's phone / tablet</button>
<div id="accessCard" class="card" hidden>
<label class="head">🔑 Access code</label>
<div style="font-size:14px; opacity:.8; margin-bottom:10px">
Linking a second device through this site needs a free access code
(that's how we keep the relay clean). No code? Everything still works
on one computer — just tap "Open patient screen" above.
</div>
<div style="display:flex; gap:10px">
<input id="accessInput" type="text" placeholder="paste your code"
style="flex:1; min-height:48px; font-size:17px; border-radius:10px; border:2px solid #2d3743; background:#222a34; color:#e6edf3; padding:0 12px">
<button id="accessSave" class="chip" style="flex:0 0 auto; padding:0 18px">Save</button>
</div>
</div>
<a class="adv" href="manual.html">📖 How it works (picture guide)</a>
<a class="adv" href="index.html">Advanced controls →</a>
</main>
@ -148,14 +163,34 @@
var ws;
try{ ws = new WebSocket(BLS.relayUrl()) }catch(_){ return }
ws.onopen = function(){
net.ws = ws;
try{ ws.send(JSON.stringify({type:'join', sessionId: net.sid, role:'admin'})) }catch(_){ }
document.getElementById('copyLinkBig').hidden = false;
var token = null; try{ token = localStorage.getItem('bls_relay_token') }catch(_){ }
try{ ws.send(JSON.stringify({type:'join', sessionId: net.sid, role:'admin', token: token || undefined})) }catch(_){ }
};
ws.onclose = function(){ net.ws = null; setTimeout(connect, 3000) };
ws.onmessage = function(ev){
try{
var msg = JSON.parse(ev.data);
if(msg.type === 'joined'){
net.ws = ws;
document.getElementById('copyLinkBig').hidden = false;
document.getElementById('accessCard').hidden = true;
} else if(msg.type === 'join_denied'){
document.getElementById('accessCard').hidden = false;
try{ ws.close() }catch(_){ }
}
}catch(_){ }
};
ws.onclose = function(){ net.ws = null; setTimeout(connect, 5000) };
ws.onerror = function(){ try{ ws.close() }catch(_){ } };
}
connect();
document.getElementById('accessSave').addEventListener('click', function(){
var v = document.getElementById('accessInput').value.trim();
if(!v) return;
try{ localStorage.setItem('bls_relay_token', v) }catch(_){ }
this.textContent = 'Connecting…';
var b = this;
setTimeout(function(){ b.textContent = 'Save' }, 6000);
});
})();
function apply(next){

View File

@ -630,6 +630,7 @@
<div class="connection-inputs">
<input type="text" id="wsUrl" placeholder="ws://192.168.x.x:8787" class="text-input">
<input type="text" id="wsSession" placeholder="auto (random room id)" class="text-input">
<input type="text" id="wsToken" placeholder="access code (hosted relay only)" class="text-input">
</div>
</div>

View File

@ -460,7 +460,7 @@ if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded
})();
if(startRelayBtn){ startRelayBtn.addEventListener('click', function(){ var h = renderOsHelp(); var ok = copyText(h.start); var old = startRelayBtn.textContent; startRelayBtn.textContent = ok? 'Copied!' : 'Copy Start Command'; setTimeout(function(){ startRelayBtn.textContent = old }, 1200) }) }
if(stopRelayBtn){ stopRelayBtn.addEventListener('click', function(){ var url = BLS.relayUrl(); try{ var temp = new WebSocket(url); temp.onopen = function(){ try{ temp.send(JSON.stringify({type:'shutdown'})) }catch(e){} }; setTimeout(function(){ setRelayStatus(false) }, 1500) }catch(e){} }) }
function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim(); if(!sid){ sid = randSid(); wsSession.value = sid; updateJoinLink() } var btn = document.getElementById('wsConnect'); if(btn && btn.disabled){ wsStatus.textContent = 'Relay not running'; return } if(ws){ try{ ws.close() }catch(e){} ws=null } if(!url){ wsStatus.textContent = 'Missing URL'; return } try{ ws = new WebSocket(url) }catch(e){ wsStatus.textContent = 'Error'; return } wsSid = sid; wsStatus.textContent = 'Connecting...'; ws.onopen = function(){ wsConnected = true; wsStatus.textContent = 'Connected'; try{ ws.send(JSON.stringify({type:'join', sessionId:sid, role:'admin'})) }catch(e){} publishState() }; ws.onclose = function(){ wsConnected = false; wsStatus.textContent = 'Disconnected' }; ws.onerror = function(){ wsStatus.textContent = 'Error' }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='joined'){ wsStatus.textContent = 'Connected' } else if(msg && msg.type==='patientViewport'){ var pv = msg.payload||{}; try{ window.PatientViewport = pv }catch(_){ } var r = window.AdminRenderer; if(r){ r.resize(parseInt(pv.canvasW||640,10), parseInt(pv.canvasH||360,10)) } var pvShort = document.getElementById('patientViewportShort'); if(pvShort){ pvShort.textContent = (pv.canvasW||'?')+'×'+(pv.canvasH||'?') } } }catch(e){} } }
function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim(); if(!sid){ sid = randSid(); wsSession.value = sid; updateJoinLink() } var btn = document.getElementById('wsConnect'); if(btn && btn.disabled){ wsStatus.textContent = 'Relay not running'; return } if(ws){ try{ ws.close() }catch(e){} ws=null } if(!url){ wsStatus.textContent = 'Missing URL'; return } try{ ws = new WebSocket(url) }catch(e){ wsStatus.textContent = 'Error'; return } wsSid = sid; wsStatus.textContent = 'Connecting...'; ws.onopen = function(){ wsConnected = true; wsStatus.textContent = 'Connected'; var tokEl = document.getElementById('wsToken'); var tok = (tokEl && (tokEl.value||'').trim()) || ''; if(!tok){ try{ tok = localStorage.getItem('bls_relay_token')||'' }catch(_){ } } else { try{ localStorage.setItem('bls_relay_token', tok) }catch(_){ } } try{ ws.send(JSON.stringify({type:'join', sessionId:sid, role:'admin', token: tok || undefined})) }catch(e){} publishState() }; ws.onclose = function(){ wsConnected = false; wsStatus.textContent = 'Disconnected' }; ws.onerror = function(){ wsStatus.textContent = 'Error' }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='joined'){ wsStatus.textContent = 'Connected' } else if(msg && msg.type==='join_denied'){ wsStatus.textContent = 'Access code required — enter it below'; wsConnected = false } else if(msg && msg.type==='patientViewport'){ var pv = msg.payload||{}; try{ window.PatientViewport = pv }catch(_){ } var r = window.AdminRenderer; if(r){ r.resize(parseInt(pv.canvasW||640,10), parseInt(pv.canvasH||360,10)) } var pvShort = document.getElementById('patientViewportShort'); if(pvShort){ pvShort.textContent = (pv.canvasW||'?')+'×'+(pv.canvasH||'?') } } }catch(e){} } }
function wsDisconnect(){ if(ws){ try{ ws.close() }catch(e){} } ws=null; wsConnected=false; wsStatus.textContent = 'Disconnected' }
function refreshNetMode(){ var m = (netModeSel && netModeSel.value) || 'relay'; if(rtcControls) rtcControls.style.display = (m==='webrtc'? 'flex':'none'); var relayRows = document.querySelectorAll('#osHelp, #wsUrl, #wsSession, #detectIP, #wsConnect, #wsDisconnect'); for(var i=0;i<relayRows.length;i++){ var el=relayRows[i]; if(!el) continue }
var btn = document.getElementById('wsConnect'); if(btn) btn.disabled = (m==='webrtc'); var s = document.getElementById('wsStatus'); if(s && m==='webrtc'){ s.textContent = 'P2P mode' }

View File

@ -187,11 +187,15 @@
var msg = JSON.parse(ev.data);
if(msg && msg.type === 'state'){
handleStateUpdate(msg.payload || {});
} else if(msg && msg.type === 'join_denied'){
// hardened relay: patients can only join a live session
var ov = document.getElementById('overlay');
if(ov){ ov.style.display = 'flex'; ov.innerHTML = '<span>Waiting for your therapist to connect — this will join automatically</span>'; }
}
} catch(e){}
};
ws.onclose = function(){ ws = null; };
ws.onclose = function(){ ws = null; setTimeout(function(){ connectWebSocket(url, sid) }, 5000); };
}
function setupWebRTC(){

89
manual.html Normal file
View File

@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>backnforth — How it works</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%231f6feb'/%3E%3C/svg%3E">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; background: #fdf6ec; color: #26333b;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 640px; margin: 0 auto; padding: 20px 18px 60px; }
h1 { text-align: center; font-size: 30px; margin: 18px 0 4px; color: #1b6e63; }
.sub { text-align: center; font-size: 17px; opacity: .75; margin-bottom: 24px; }
.step { background: #fff; border-radius: 18px; padding: 20px; margin-top: 22px;
box-shadow: 0 3px 14px rgba(38,51,59,.08); }
.step img { width: 100%; border-radius: 12px; display: block; }
.step h2 { font-size: 22px; margin: 14px 0 6px; color: #1b6e63; }
.step .n { display: inline-block; background: #e8734a; color: #fff; border-radius: 50%;
width: 34px; height: 34px; line-height: 34px; text-align: center; font-size: 18px;
margin-right: 8px; }
.step p { font-size: 17px; line-height: 1.6; margin: 6px 0 0; }
.safety { background: #fff3e6; border: 2px solid #e8734a55; }
.go { display: block; text-align: center; background: #1b6e63; color: #fff;
text-decoration: none; font-size: 20px; padding: 18px; border-radius: 14px;
margin-top: 28px; }
.tiny { text-align: center; font-size: 13px; opacity: .6; margin-top: 18px; }
</style>
</head>
<body>
<main>
<h1>backnforth</h1>
<div class="sub">Bilateral stimulation for therapy — on whatever screen you already have.</div>
<div class="step">
<img src="img/manual/01-cover.png" alt="" onerror="this.style.display='none'">
<h2>What is this?</h2>
<p>A gentle moving dot (and optional sound) that a patient follows with
their eyes while you guide the session. It runs in any web browser —
an old laptop, a second-hand phone, anything with a screen.</p>
</div>
<div class="step">
<img src="img/manual/02-open.png" alt="" onerror="this.style.display='none'">
<h2><span class="n">1</span>Open it</h2>
<p>Open the site on your computer. You'll see one big green
<strong>START</strong> button and a few large sliders. That's the whole
cockpit.</p>
</div>
<div class="step">
<img src="img/manual/03-link.png" alt="" onerror="this.style.display='none'">
<h2><span class="n">2</span>Give the patient a screen</h2>
<p>Easiest: tap <strong>Open patient screen</strong> and turn that window
to face them. Or tap <strong>Copy link</strong> and open it on their
phone or tablet — the two screens stay in sync by themselves.</p>
</div>
<div class="step">
<img src="img/manual/04-follow.png" alt="" onerror="this.style.display='none'">
<h2><span class="n">3</span>Press start</h2>
<p>The dot glides left and right. The patient follows it with their
eyes while you lead the session. Sound can tick from side to side too
if you want it.</p>
</div>
<div class="step">
<img src="img/manual/05-adjust.png" alt="" onerror="this.style.display='none'">
<h2><span class="n">4</span>Make it comfortable</h2>
<p>Slide between turtle and rabbit until the pace feels right. Change
the size, the color, the sound — the patient's screen updates
instantly.</p>
</div>
<div class="step safety">
<img src="img/manual/06-care.png" alt="" onerror="this.style.display='none'">
<h2>⚠️ Go gently</h2>
<p>Ask about seizure history before showing moving or pulsing visuals.
Bilateral stimulation can bring up strong feelings — this tool assists
a trained clinician, it isn't therapy by itself. Pause any time; the
stop button is always one tap away.</p>
</div>
<a class="go" href="easy.html">Open backnforth →</a>
<div class="tiny">Free and open source (MIT). Patient data never leaves
your device.</div>
</main>
</body>
</html>

View File

@ -25,6 +25,7 @@ _WS_GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
SESSIONS = {} # session id -> set of live WSHandler
LOCK = threading.Lock()
_SERVERS = []
TOKENS = None # None = open relay; a set = hosts must present a valid access code
def local_ip():
@ -86,12 +87,28 @@ class WSHandler(socketserver.BaseRequestHandler):
return
t = data.get('type')
if t == 'join':
self.session_id = data.get('sessionId') or 'default'
sid = data.get('sessionId') or 'default'
if TOKENS is not None:
# hosts need an access code; patients may only join a LIVE session
# (they got the random room id from their therapist's link)
if data.get('role') == 'patient':
with LOCK:
live = bool(SESSIONS.get(sid))
if not live:
self._send_json({'type': 'join_denied', 'reason': 'session not live'})
return
elif data.get('token') not in TOKENS:
self._send_json({'type': 'join_denied', 'reason': 'access code required'})
return
self.session_id = sid
with LOCK:
SESSIONS.setdefault(self.session_id, set()).add(self)
self._send_json({'type': 'joined', 'sessionId': self.session_id})
elif t in ('state', 'patientViewport'):
sid = data.get('sessionId') or self.session_id or 'default'
if TOKENS is not None and not self.session_id:
return # hardened relay only forwards for joined connections
sid = (self.session_id if TOKENS is not None
else data.get('sessionId') or self.session_id or 'default')
out = json.dumps({'type': t, 'payload': data.get('payload')})
with LOCK:
peers = list(SESSIONS.get(sid, ()))
@ -219,9 +236,15 @@ def _argval(flag, default):
def main():
global TOKENS
os.chdir(os.path.dirname(os.path.abspath(__file__)))
bind = _argval('--bind', '0.0.0.0') # e.g. a tailnet IP behind a reverse proxy
http_port = int(_argval('--http-port', HTTP_PORT))
tokens_file = _argval('--tokens', None) # one access code per line, # comments ok
if tokens_file:
with open(tokens_file, encoding='utf-8') as f:
TOKENS = {l.strip() for l in f if l.strip() and not l.startswith('#')}
print('relay hardened: %d access codes loaded' % len(TOKENS))
socketserver.ThreadingTCPServer.allow_reuse_address = True
ThreadingHTTPServer.allow_reuse_address = True