Share admin dashboard views through isolated read-only visitor access

This commit is contained in:
Codex
2026-09-22 20:18:00 -04:00
parent 7e19d82a02
commit 210b715a6f
6 changed files with 152 additions and 26 deletions
+2
View File
File diff suppressed because one or more lines are too long
+19 -18
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
'use client';
import Home from '../page';
export default function Visitor(){return <Home readOnly/>;}
+23 -2
View File
@@ -11,8 +11,8 @@ observed bot parties; guild goals and progress; confirmed social activity.
Only autonomous bot-to-bot reply transcripts are public. Human conversations, Only autonomous bot-to-bot reply transcripts are public. Human conversations,
whispers, memories, account/character IDs, tokens, configuration and PC telemetry whispers, memories, account/character IDs, tokens, configuration and PC telemetry
are excluded. All text is rendered with textContent, never HTML interpolation. are excluded. All text is rendered with textContent, never HTML interpolation.
The HTTP service allows only explicit static paths and `/api/realm`, `/health`, The HTTP service allows explicit public assets and read-only projections described
`/robots.txt`. Mutation methods return 405; admin paths return 404. below. Mutation methods return 405; administrative command paths return 404.
## Install and publish ## Install and publish
@@ -58,3 +58,24 @@ After changing spectator source or assets, restart its service:
```sh ```sh
systemctl --user restart azerothai-spectator systemctl --user restart azerothai-spectator
``` ```
## Shared dashboard (visitor edition)
The public root now serves the application's `/visitor` route, which uses the
same React dashboard and CSS as the administrator. Navigation, filters, profiles,
recipe details and the world monitor remain usable. Editing forms and GM inputs
are disabled; the action dispatcher also refuses writes in visitor mode.
The public server independently rejects every mutation method, including server
actions, and never proxies a viewer-selected backend route or request headers.
`/api/state` is an explicit public projection shaped for the shared UI. Bot memory
views contain confirmed shared social history, not private player conversations
or operator-injected memories. The event stream is a polling view of that public
history. `/api/health`, `/api/world-monitor` and `/api/operations` return selected
read-only indicators. Native account identifiers, raw command queues, credentials,
endpoint URLs and private maintenance logs are omitted. Built JavaScript/CSS/font
assets are served from `dist/client` with traversal and source-map requests denied.
Build with `pnpm build` before restarting `azerothai-runtime` and
`azerothai-spectator`. The existing Funnel address and private admin address stay
separate. No cache actions are added.
+88 -3
View File
@@ -64,6 +64,58 @@ def project(state):
'parties': list(parties.values()), 'guilds': guilds, 'feed': feed[-60:][::-1]} '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: class Snapshot:
def __init__(self): def __init__(self):
self.lock = threading.Lock() self.lock = threading.Lock()
@@ -81,7 +133,8 @@ class Snapshot:
raw = response.read(8 * 1024 * 1024 + 1) raw = response.read(8 * 1024 * 1024 + 1)
if len(raw) > 8 * 1024 * 1024: if len(raw) > 8 * 1024 * 1024:
raise ValueError('State too large') 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 self.failed = False
except Exception: except Exception:
self.failed = True self.failed = True
@@ -119,14 +172,46 @@ class Handler(BaseHTTPRequestHandler):
self.send_header('X-Frame-Options', 'DENY') self.send_header('X-Frame-Options', 'DENY')
self.send_header('Referrer-Policy', 'no-referrer') self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('X-Robots-Tag', 'noindex, nofollow') 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() self.end_headers()
if self.command != 'HEAD': if self.command != 'HEAD':
self.wfile.write(body) self.wfile.write(body)
def do_GET(self): def do_GET(self):
path = self.path.split('?', 1)[0] 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] filename, content_type = ASSETS[path]
self.reply(200, (ROOT / filename).read_bytes(), content_type) self.reply(200, (ROOT / filename).read_bytes(), content_type)
elif path == '/api/realm': elif path == '/api/realm':
+17 -3
View File
@@ -3,6 +3,7 @@ import importlib.util
import json import json
import threading import threading
import unittest import unittest
from unittest.mock import patch
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -22,7 +23,11 @@ class SpectatorTests(unittest.TestCase):
('social-1', ['A', 'B'], 'SYNTHETIC_PROMPT'), ('social-1', ['A', 'B'], 'SYNTHETIC_PROMPT'),
('social-1:reply', ['A', 'B'], 'Hello friend'), ('social-1:reply', ['A', 'B'], 'Hello friend'),
('social-2:reply', ['HumanPlayer', 'A'], 'NOT_BOT_CHAT')]] ('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) serialized = json.dumps(projected)
for private in ['PRIVATE_MEMORY', 'PRIVATE_WHISPER', 'SYNTHETIC_PROMPT', 'NOT_BOT_CHAT', 'HumanPlayer', 'SECRET', 'accountId', 'internal-guid']: for private in ['PRIVATE_MEMORY', 'PRIVATE_WHISPER', 'SYNTHETIC_PROMPT', 'NOT_BOT_CHAT', 'HumanPlayer', 'SECRET', 'accountId', 'internal-guid']:
self.assertNotIn(private, serialized) self.assertNotIn(private, serialized)
@@ -35,18 +40,27 @@ class SpectatorTests(unittest.TestCase):
worker.start() worker.start()
base = 'http://127.0.0.1:' + str(server.server_port) base = 'http://127.0.0.1:' + str(server.server_port)
try: 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: with urllib.request.urlopen(base + route) as response:
self.assertEqual(response.status, 200) self.assertEqual(response.status, 200)
self.assertIn("default-src 'none'", response.headers['Content-Security-Policy']) 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: with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(base + path) urllib.request.urlopen(base + path)
self.assertEqual(error.exception.code, 404) 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']: for method in ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']:
with self.assertRaises(urllib.error.HTTPError) as error: with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}')) urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}'))
self.assertEqual(error.exception.code, 405) self.assertEqual(error.exception.code, 405)
error.exception.close()
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()