Share admin dashboard views through isolated read-only visitor access
This commit is contained in:
@@ -64,6 +64,58 @@ def project(state):
|
||||
'parties': list(parties.values()), 'guilds': guilds, 'feed': feed[-60:][::-1]}
|
||||
|
||||
|
||||
def public_state(state):
|
||||
"""Admin-shaped data for the shared read-only UI, with an explicit allowlist."""
|
||||
def pick(obj, keys):
|
||||
return {k: obj[k] for k in keys.split() if k in obj and isinstance(obj[k], (str, int, float, bool))}
|
||||
def recipe(r):
|
||||
return {**pick(r, 'id name description sociability aggression curiosity economy version'),
|
||||
'tags': [text(t, 80) for t in r.get('tags', [])],
|
||||
'activities': [a for a in r.get('activities', []) if a in ['quest','explore','trade','rest','socialize']]}
|
||||
projected = project(state)
|
||||
feed = [{**e, 'id': 'public-'+str(i), 'status': 'confirmed'} for i, e in enumerate(reversed(projected['feed']))]
|
||||
bots = []
|
||||
for b in state.get('bots', []):
|
||||
if not b.get('autonomy', {}).get('managed'):
|
||||
continue
|
||||
bot = pick(b, 'id name role race level zone status tier mortal plotArmorHpFloor simulationBand homeId recipeId recipeVersion goal decisions')
|
||||
bot['personality'] = recipe(b.get('personality', {}))
|
||||
bot['routine'] = {**pick(b.get('routine', {}), 'wakeHour sleepHour'), 'preferredZones': b.get('routine', {}).get('preferredZones', [])}
|
||||
bot['autonomy'] = {'managed': True, **pick(b.get('autonomy', {}), 'observedAt lastIntentAt nextIntentAt')}
|
||||
world = b.get('world') or {}
|
||||
bot['world'] = {**pick(world, 'at team guildId guildName guildRank groupId inCombat x y mapId stationarySince'),
|
||||
'groupNames': [n for n in world.get('groupNames', []) if any(x['name']==n for x in state['bots'] if x.get('autonomy',{}).get('managed'))], 'nearby': []}
|
||||
bot['relationships'] = [pick(r, 'targetId affinity note') for r in b.get('relationships', [])]
|
||||
# Show shared history, never personal whispers or operator-injected memories.
|
||||
bot['memories'] = [{'id': e['id'], 'text': e['text'], 'at': e['at'], 'source': 'observation', 'importance': 5}
|
||||
for e in feed if b['name'] in e['names']]
|
||||
if b.get('death'):
|
||||
bot['death'] = pick(b['death'], 'id at zone level cause killer source verified')
|
||||
if b.get('lastDecision'):
|
||||
bot['lastDecision'] = {'action': b['lastDecision']['action'], 'reason': 'Following the resident’s current activity.', 'goal': bot.get('goal','')}
|
||||
bots.append(bot)
|
||||
settings = state.get('settings') or {}
|
||||
return {'revision': state.get('revision',0), 'sequence': state.get('sequence',0), 'aleRosterAt': state.get('aleRosterAt',0),
|
||||
'bots': bots, 'recipes': [recipe(r) for r in state.get('recipes',[])],
|
||||
'homes': [{**pick(h,'id name zone type capacity storageSlots safety description'), 'ownerIds':h.get('ownerIds',[]), 'residentIds':h.get('residentIds',[])} for h in state.get('homes',[])],
|
||||
'events': [{'id':e['id'],'seq':i,'at':e['at'],'kind':'relationship' if e['kind']=='relationship' else 'lifecycle','message':e['text'],'mode':'azerothcore'} for i,e in enumerate(feed)],
|
||||
'settings': {**pick(settings,'connector provider model'),
|
||||
'brains':pick(settings.get('brains',{}),'enabled residentModel heroModel backgroundUseLLM residentMemoryLimit heroMemoryLimit residentMaxTokens heroMaxTokens'),
|
||||
'hardcore':pick(settings.get('hardcore',{}),'enabled requireVerifiedDeath memorialize'),
|
||||
'population':pick(settings.get('population',{}),'defaultZoneTarget zoneMax serverMax foregroundMax backgroundMax')},
|
||||
'director':{**pick(state.get('director',{}),'experience zone brief'),'playerName':''}, 'population':{}, 'operations':{},
|
||||
'opsRetentionDays':state.get('opsRetentionDays',7),
|
||||
'community':{'feed':feed,'evidence':[],'guilds':[{**g,'id':i} for i,g in enumerate(projected['guilds'])],
|
||||
'partiesEnabled':state.get('community',{}).get('partiesEnabled',True),
|
||||
'guildsEnabled':state.get('community',{}).get('guildsEnabled',True)}}
|
||||
|
||||
|
||||
def local_get(path):
|
||||
opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler({}))
|
||||
with opener.open('http://127.0.0.1:8787'+path, timeout=10) as response:
|
||||
return response.read(8 * 1024 * 1024)
|
||||
|
||||
|
||||
class Snapshot:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
@@ -81,7 +133,8 @@ class Snapshot:
|
||||
raw = response.read(8 * 1024 * 1024 + 1)
|
||||
if len(raw) > 8 * 1024 * 1024:
|
||||
raise ValueError('State too large')
|
||||
self.data = project(json.loads(raw))
|
||||
state = json.loads(raw)
|
||||
self.data = {**project(state), "state": public_state(state)}
|
||||
self.failed = False
|
||||
except Exception:
|
||||
self.failed = True
|
||||
@@ -119,14 +172,46 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_header('X-Frame-Options', 'DENY')
|
||||
self.send_header('Referrer-Policy', 'no-referrer')
|
||||
self.send_header('X-Robots-Tag', 'noindex, nofollow')
|
||||
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
||||
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
||||
self.end_headers()
|
||||
if self.command != 'HEAD':
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split('?', 1)[0]
|
||||
if path in ASSETS:
|
||||
if path in ('/', '/visitor', '/visitor/'):
|
||||
try:
|
||||
self.reply(200, local_get('/visitor'), 'text/html; charset=utf-8')
|
||||
except Exception:
|
||||
self.reply(503, b'{"error":"Realm temporarily unavailable"}')
|
||||
elif path == '/api/state':
|
||||
data = SNAPSHOT.get()
|
||||
self.reply(200 if data else 503, json.dumps(data['state'] if data else {'error':'Realm unavailable'}).encode())
|
||||
elif path == '/api/health':
|
||||
data = SNAPSHOT.get()
|
||||
self.reply(200, json.dumps({'ok':bool(data),'aleConfigured':True,'aleActiveRecently':bool(data and not data['stale']), 'llmConfigured':bool(data and data['state']['settings'].get('provider')!='deterministic')}).encode())
|
||||
elif path == '/api/world-monitor':
|
||||
data = SNAPSHOT.get()
|
||||
at = data['rosterAt'] if data else 0
|
||||
from datetime import datetime, timezone
|
||||
self.reply(200, json.dumps({'ok':bool(data),'ale':{'lastEventAt':datetime.fromtimestamp(at,timezone.utc).isoformat() if at else None,'activeRecently':bool(data and not data['stale'])},'events':[],'commands':{}}).encode())
|
||||
elif path == '/api/operations':
|
||||
try:
|
||||
health = json.loads(local_get('/api/operations'))
|
||||
samples = [{k:v for k,v in row.items() if k in ('host','at','cpuPercent','memoryTotalMB','memoryAvailableMB','diskFreeGB','databaseMB','logsMB','modelAvailable','runtimeService')} for row in health.get('samples',[])]
|
||||
self.reply(200, json.dumps({'samples':samples,'stuck':[{k:v for k,v in row.items() if k in ('name','zone','minutes')} for row in health.get('stuck',[])]}).encode())
|
||||
except Exception:
|
||||
self.reply(503,b'{"error":"Health data unavailable"}')
|
||||
elif path.startswith(('/assets/', '/_vinext/', '/_next/')):
|
||||
# Built public assets only. No source maps, dotfiles or traversal.
|
||||
import mimetypes
|
||||
root = ROOT.parent / 'dist/client'
|
||||
asset = (root / path.lstrip('/')).resolve()
|
||||
if '%' in path or '..' in path or not asset.is_relative_to(root.resolve()) or asset.suffix not in ('.js','.css','.woff','.woff2','.png','.svg','.ico') or not asset.is_file():
|
||||
self.reply(404,b'{"error":"Not found"}')
|
||||
else:
|
||||
self.reply(200,asset.read_bytes(),mimetypes.guess_type(str(asset))[0] or 'application/octet-stream')
|
||||
elif path in ASSETS:
|
||||
filename, content_type = ASSETS[path]
|
||||
self.reply(200, (ROOT / filename).read_bytes(), content_type)
|
||||
elif path == '/api/realm':
|
||||
|
||||
@@ -3,6 +3,7 @@ import importlib.util
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@@ -22,7 +23,11 @@ class SpectatorTests(unittest.TestCase):
|
||||
('social-1', ['A', 'B'], 'SYNTHETIC_PROMPT'),
|
||||
('social-1:reply', ['A', 'B'], 'Hello friend'),
|
||||
('social-2:reply', ['HumanPlayer', 'A'], 'NOT_BOT_CHAT')]]
|
||||
projected = m.project({'bots': bots, 'settings': {'token': 'SECRET'}, 'community': {'feed': feed}})
|
||||
source={'bots': bots, 'settings': {'token': 'SECRET'}, 'community': {'feed': feed}}
|
||||
projected = m.project(source)
|
||||
public = m.public_state(source)
|
||||
for forbidden in ['PRIVATE_MEMORY','PRIVATE_WHISPER','SYNTHETIC_PROMPT','NOT_BOT_CHAT','SECRET','accountId']:
|
||||
self.assertNotIn(forbidden,json.dumps(public))
|
||||
serialized = json.dumps(projected)
|
||||
for private in ['PRIVATE_MEMORY', 'PRIVATE_WHISPER', 'SYNTHETIC_PROMPT', 'NOT_BOT_CHAT', 'HumanPlayer', 'SECRET', 'accountId', 'internal-guid']:
|
||||
self.assertNotIn(private, serialized)
|
||||
@@ -35,18 +40,27 @@ class SpectatorTests(unittest.TestCase):
|
||||
worker.start()
|
||||
base = 'http://127.0.0.1:' + str(server.server_port)
|
||||
try:
|
||||
for route in ['/', '/app.js', '/style.css', '/health']:
|
||||
for route in ['/app.js', '/style.css', '/health']:
|
||||
with urllib.request.urlopen(base + route) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn("default-src 'none'", response.headers['Content-Security-Policy'])
|
||||
for path in ['/api/state', '/api/actions', '/api/ale/operator', '/api/operations', '/.dev.vars', '/../.dev.vars', '/%2e%2e/.dev.vars', '//api/state']:
|
||||
for path in ['/api/actions', '/api/ale/operator', '/.dev.vars', '/../.dev.vars', '/%2e%2e/.dev.vars', '/api/bridge/ale/poll', '/assets/../../.dev.vars', '/assets/private.map']:
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:
|
||||
urllib.request.urlopen(base + path)
|
||||
self.assertEqual(error.exception.code, 404)
|
||||
error.exception.close()
|
||||
with patch.object(m,'local_get',return_value=b'<html>Visitor</html>') as upstream:
|
||||
with urllib.request.urlopen(base+'/') as response:
|
||||
self.assertEqual(response.read(),b'<html>Visitor</html>')
|
||||
upstream.assert_called_once_with('/visitor')
|
||||
with patch.object(m.SNAPSHOT,'get',return_value={'state':{'bots':[]}}):
|
||||
with urllib.request.urlopen(base+'/api/state') as response:
|
||||
self.assertEqual(json.load(response),{'bots':[]})
|
||||
for method in ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']:
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:
|
||||
urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}'))
|
||||
self.assertEqual(error.exception.code, 405)
|
||||
error.exception.close()
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
Reference in New Issue
Block a user