wolloseum/web/app.js

440 lines
18 KiB
JavaScript

const $ = (selector, root = document) => root.querySelector(selector);
const TOKEN_KEY = `wolloseum.session.${document.body?.dataset.page || 'app'}`;
const encoder = new TextEncoder();
function setMessage(element, text, kind = '') {
if (!element) return;
element.textContent = text;
element.className = `message ${kind}`.trim();
}
function fromBase64(value) {
return Uint8Array.from(atob(value), character => character.charCodeAt(0));
}
function readDerElement(bytes, offset) {
const start = offset;
const tag = bytes[offset++];
let length = bytes[offset++];
if (tag === undefined || length === undefined) return null;
if (length & 0x80) {
const lengthBytes = length & 0x7f;
if (!lengthBytes || lengthBytes > 4 || offset + lengthBytes > bytes.length) return null;
length = 0;
for (let index = 0; index < lengthBytes; index++) length = length * 256 + bytes[offset++];
}
const contentStart = offset;
const end = contentStart + length;
if (end > bytes.length) return null;
return { tag, start, contentStart, end };
}
function derLength(length) {
if (length < 0x80) return [length];
const bytes = [];
while (length) {
bytes.unshift(length & 0xff);
length >>>= 8;
}
return [0x80 | bytes.length, ...bytes];
}
function normalizeEd25519Pkcs8(bytes) {
const sequence = readDerElement(bytes, 0);
const version = sequence?.tag === 0x30 && sequence.end === bytes.length
? readDerElement(bytes, sequence.contentStart)
: null;
if (version?.tag !== 0x02 || version.end !== version.contentStart + 1) return bytes;
if (bytes[version.contentStart] === 0) return bytes;
if (bytes[version.contentStart] !== 1) return bytes;
const algorithm = readDerElement(bytes, version.end);
const privateKey = algorithm?.tag === 0x30 ? readDerElement(bytes, algorithm.end) : null;
const publicKey = privateKey?.tag === 0x04 ? readDerElement(bytes, privateKey.end) : null;
if (algorithm?.tag !== 0x30 || privateKey?.tag !== 0x04 || publicKey?.tag !== 0x81 || publicKey.end !== sequence.end) {
return bytes;
}
const body = new Uint8Array(3 + algorithm.end - algorithm.start + privateKey.end - privateKey.start);
body.set([0x02, 0x01, 0x00]);
body.set(bytes.subarray(algorithm.start, algorithm.end), 3);
body.set(bytes.subarray(privateKey.start, privateKey.end), 3 + algorithm.end - algorithm.start);
const encodedLength = derLength(body.length);
const normalized = new Uint8Array(1 + encodedLength.length + body.length);
normalized.set([0x30, ...encodedLength]);
normalized.set(body, 1 + encodedLength.length);
return normalized;
}
function toBase64(value) {
return btoa(String.fromCharCode(...new Uint8Array(value)));
}
function toPem(label, value) {
const base64 = toBase64(value).match(/.{1,64}/g).join('\n');
return `-----BEGIN ${label}-----\n${base64}\n-----END ${label}-----\n`;
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
const token = localStorage.getItem(TOKEN_KEY);
if (token) headers.set('Authorization', `Bearer ${token}`);
if (options.body && typeof options.body === 'string') headers.set('Content-Type', 'application/json');
const response = await fetch(path, { ...options, headers });
if (response.status === 204) return null;
const contentType = response.headers.get('content-type') || '';
const body = contentType.includes('application/json') ? await response.json() : await response.text();
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
return body;
}
async function readCredential(file) {
const text = await file.text();
try {
const bundle = JSON.parse(text);
if (bundle.client_id && bundle.private_key_pem) return bundle;
} catch (_) { }
const identity = $('#identity-id').value.trim();
if (!identity) throw new Error('Enter the identity ID printed by the server or supplied by your administrator.');
return { client_id: identity, private_key_pem: text };
}
async function signIn() {
const file = $('#private-key-file').files[0];
if (!file) throw new Error('Choose your private key file.');
const credential = await readCredential(file);
const encodedKey = credential.private_key_pem.match(/-----BEGIN PRIVATE KEY-----([\s\S]*?)-----END PRIVATE KEY-----/);
if (!encodedKey) throw new Error('Expected an Ed25519 PKCS#8 private key.');
const pkcs8 = normalizeEd25519Pkcs8(fromBase64(encodedKey[1].replace(/\s/g, '')));
let privateKey;
try {
privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
} catch (error) {
if (error.name === 'NotSupportedError') {
throw new Error('This browser does not support Ed25519 Web Crypto. Update the browser and try again.');
}
throw new Error(`Ed25519 key import failed (${error.name}): ${error.message}. Choose data/keys/admin-private.pem or a generated identity JSON bundle.`);
}
const issued = await api('/api/auth/challenge', {
method: 'POST', body: JSON.stringify({ client_id: credential.client_id }),
});
const challenge = fromBase64(issued.challenge);
let signature;
try {
signature = await crypto.subtle.sign('Ed25519', privateKey, challenge);
} catch (error) {
throw new Error(`Ed25519 challenge signing failed (${error.name}): ${error.message}.`);
}
const session = await api('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ client_id: credential.client_id, challenge: issued.challenge, signature: toBase64(signature) }),
});
localStorage.setItem(TOKEN_KEY, session.token);
return api('/api/me');
}
function logout() {
localStorage.removeItem(TOKEN_KEY);
location.reload();
}
function connectEvents(onEvent, onConnection = () => { }) {
let reconnectDelay = 1000;
let socket;
let stopped = false;
const connect = () => {
if (stopped) return;
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(`${scheme}//${location.host}/api/events`);
socket.onopen = () => { reconnectDelay = 1000; onConnection(true); };
socket.onmessage = message => {
try { onEvent(JSON.parse(message.data)); } catch (_) { }
};
socket.onclose = () => {
onConnection(false);
if (!stopped) setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
};
socket.onerror = () => socket.close();
};
connect();
return () => { stopped = true; socket?.close(); };
}
function eventRow(event) {
const row = document.createElement('div');
row.className = 'event-row';
const sequence = document.createElement('span');
sequence.className = 'event-sequence';
sequence.textContent = `#${event.sequence}`;
const name = document.createElement('span');
name.className = 'event-name';
name.textContent = event.event;
const data = document.createElement('span');
data.className = 'event-data';
data.textContent = JSON.stringify(event.data ?? {});
row.append(sequence, name, data);
return row;
}
function bootViewer() {
const list = $('#event-list');
const empty = $('#event-empty');
const status = $('#socket-status');
const sequence = $('#latest-sequence');
const total = $('#event-count');
const search = $('#event-search');
let count = 0;
const events = [];
const redraw = () => {
list.replaceChildren();
const query = search.value.trim().toLowerCase();
const visible = events.filter(event => `${event.event} ${JSON.stringify(event.data)}`.toLowerCase().includes(query));
empty.classList.toggle('hidden', visible.length > 0);
visible.forEach(event => list.append(eventRow(event)));
};
search.addEventListener('input', redraw);
connectEvents(event => {
count += 1;
events.unshift(event);
if (events.length > 250) events.pop();
sequence.textContent = String(event.sequence);
total.textContent = String(count);
redraw();
}, connected => {
status.classList.toggle('live', connected);
status.classList.toggle('offline', !connected);
$('#socket-label').textContent = connected ? 'LIVE' : 'RECONNECTING';
});
}
function bootLogin() {
const form = $('#login-form');
const panel = $('#login-panel');
const message = $('#login-message');
const page = document.body.dataset.page;
const app = page === 'admin' ? $('#admin-app') : $('#user-app');
const account = $('#account-name');
const reveal = async identity => {
const requiredAdmin = page === 'admin';
if (identity.is_admin !== requiredAdmin) {
throw new Error(requiredAdmin ? 'This key does not belong to an administrator.' : 'Use the administration page to sign in with an admin key.');
}
panel.classList.add('hidden');
app.classList.remove('hidden');
account.textContent = identity.username;
if (requiredAdmin) await bootAdmin(identity);
else await bootUser(identity);
};
form.addEventListener('submit', async event => {
event.preventDefault();
const button = $('button[type="submit"]', form);
button.disabled = true;
setMessage(message, 'Verifying challenge...');
try { await reveal(await signIn()); }
catch (error) { setMessage(message, error.message, 'error'); }
finally { button.disabled = false; }
});
$('#logout-button, #admin-logout').addEventListener('click', logout);
$('#private-key-file').addEventListener('change', async event => {
const file = event.currentTarget.files[0];
if (!file || !file.name.endsWith('.json')) return;
try {
const bundle = JSON.parse(await file.text());
if (bundle.client_id) $('#identity-id').value = bundle.client_id;
} catch (_) { }
});
if (localStorage.getItem(TOKEN_KEY)) {
api('/api/me').then(reveal).catch(() => localStorage.removeItem(TOKEN_KEY));
}
}
async function bootUser(identity) {
$('#player-id').textContent = identity.client_id;
$('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded';
$('#queued-agent-digest').textContent = identity.queued_agent_sha256 || 'None';
const status = await api('/api/status');
paintGameStatus(status.running);
$('#agent-form').addEventListener('submit', async event => {
event.preventDefault();
const file = $('#agent-file').files[0];
if (!file) return setMessage($('#agent-message'), 'Choose a compiled WASM module.', 'error');
const button = $('button[type="submit"]', event.currentTarget);
button.disabled = true;
setMessage($('#agent-message'), `Uploading ${file.name}...`);
try {
const result = await api('/api/update_agent', { method: 'POST', body: await file.arrayBuffer() });
$('#queued-agent-digest').textContent = result.sha256;
setMessage($('#agent-message'), `Agent queued | ${result.bytes.toLocaleString()} bytes`, 'success');
} catch (error) { setMessage($('#agent-message'), error.message, 'error'); }
finally { button.disabled = false; }
});
connectEvents(event => {
if (event.event === 'snapshot' || event.event === 'game_state_changed') paintGameStatus(event.data.running);
if (event.event === 'agents_activated') {
api('/api/me').then(identity => {
$('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded';
$('#queued-agent-digest').textContent = identity.queued_agent_sha256 || 'None';
}).catch(error => setMessage($('#agent-message'), error.message, 'error'));
}
});
}
function paintGameStatus(running) {
const chip = $('#game-status');
if (!chip) return;
chip.classList.toggle('running', running);
chip.classList.toggle('paused', !running);
$('#game-status-label').textContent = running ? 'RUNNING' : 'PAUSED';
const copy = $('#game-status-copy');
if (copy) copy.textContent = running ? 'RUNNING' : 'PAUSED';
}
async function bootAdmin() {
const status = await api('/api/status');
paintGameStatus(status.running);
$('#game-control').addEventListener('click', async () => {
const running = $('#game-status-label').textContent === 'RUNNING';
const button = $('#game-control');
button.disabled = true;
try {
await api(running ? '/api/stop_game' : '/api/start_game', { method: 'POST', body: '' });
paintGameStatus(!running);
button.textContent = running ? 'Resume simulation' : 'Pause simulation';
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
finally { button.disabled = false; }
});
$('#game-control').textContent = status.running ? 'Pause simulation' : 'Resume simulation';
$('#user-form').addEventListener('submit', createUser);
$('#core-form').addEventListener('submit', uploadCore);
$('#simulation-settings-form').addEventListener('submit', saveSimulationSettings);
await loadSimulationSettings();
await refreshUsers();
connectEvents(event => {
if (event.event === 'snapshot' || event.event === 'game_state_changed') {
paintGameStatus(event.data.running);
$('#game-control').textContent = event.data.running ? 'Pause simulation' : 'Resume simulation';
}
if (event.event === 'user_created' || event.event === 'user_deleted' || event.event === 'agent_queued' || event.event === 'agents_activated') refreshUsers();
});
}
async function loadSimulationSettings() {
try {
const settings = await api('/api/settings');
$('#ticks-per-day').value = settings.ticks_per_day;
$('#days-per-week').value = settings.days_per_week;
$('#days-per-month').value = settings.days_per_month;
$('#agent-activation').value = settings.agent_activation;
} catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); }
}
async function saveSimulationSettings(event) {
event.preventDefault();
const form = event.currentTarget;
const button = $('button[type="submit"]', form);
button.disabled = true;
const settings = {
ticks_per_day: Number($('#ticks-per-day').value),
days_per_week: Number($('#days-per-week').value),
days_per_month: Number($('#days-per-month').value),
agent_activation: $('#agent-activation').value,
};
try {
await api('/api/settings', { method: 'PUT', body: JSON.stringify(settings) });
setMessage($('#simulation-settings-message'), 'Settings saved.', 'success');
} catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); }
finally { button.disabled = false; }
}
async function createUser(event) {
event.preventDefault();
const username = $('#new-username').value.trim();
const button = $('button[type="submit"]', event.currentTarget);
const downloadLink = $('#identity-download');
if (downloadLink.dataset.objectUrl) URL.revokeObjectURL(downloadLink.dataset.objectUrl);
delete downloadLink.dataset.objectUrl;
downloadLink.removeAttribute('href');
downloadLink.classList.add('hidden');
button.disabled = true;
setMessage($('#create-user-message'), 'Generating identity keypair...');
try {
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const publicKey = await crypto.subtle.exportKey('raw', pair.publicKey);
const privateKey = await crypto.subtle.exportKey('pkcs8', pair.privateKey);
const identity = await api('/api/create_user', {
method: 'POST',
body: JSON.stringify({ username, public_key: toBase64(publicKey) }),
});
const bundle = JSON.stringify({ username, client_id: identity.client_id, private_key_pem: toPem('PRIVATE KEY', privateKey) }, null, 2);
downloadLink.dataset.objectUrl = URL.createObjectURL(new Blob([bundle], { type: 'application/json' }));
downloadLink.href = downloadLink.dataset.objectUrl;
downloadLink.download = `${username}-identity.json`;
downloadLink.classList.remove('hidden');
$('#new-username').value = '';
setMessage($('#create-user-message'), `Created ${username}. Download the private-key bundle before closing this page.`, 'success');
await refreshUsers();
} catch (error) { setMessage($('#create-user-message'), error.message, 'error'); }
finally { button.disabled = false; }
}
async function refreshUsers() {
try {
const users = await api('/api/users');
const list = $('#user-list');
list.replaceChildren();
users.forEach(user => {
const row = document.createElement('div');
row.className = 'user-row';
const info = document.createElement('div');
const name = document.createElement('div');
name.className = 'user-name';
name.textContent = `${user.username}${user.is_admin ? ' | admin' : ''}`;
const id = document.createElement('div');
id.className = 'user-id';
id.textContent = user.client_id;
const meta = document.createElement('div');
meta.className = 'user-meta';
const agentStatus = [
user.has_agent ? `Active | ${user.agent_sha256 || 'uploaded'}` : 'No active agent',
user.queued_agent_sha256 ? `Queued | ${user.queued_agent_sha256}` : '',
].filter(Boolean);
meta.textContent = agentStatus.join(' | ');
info.append(name, id, meta);
row.append(info);
if (!user.is_admin) {
const remove = document.createElement('button');
remove.className = 'button danger';
remove.type = 'button';
remove.textContent = 'Remove';
remove.addEventListener('click', async () => {
if (!confirm(`Remove ${user.username} and their uploaded agent?`)) return;
try {
await api('/api/delete_user', { method: 'POST', body: JSON.stringify({ client_id: user.client_id }) });
await refreshUsers();
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
});
row.append(remove);
}
list.append(row);
});
$('#user-count').textContent = String(users.filter(user => !user.is_admin).length);
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
}
async function uploadCore(event) {
event.preventDefault();
const file = $('#core-file').files[0];
if (!file) return setMessage($('#core-message'), 'Choose a WASM core module.', 'error');
const button = $('button[type="submit"]', event.currentTarget);
button.disabled = true;
setMessage($('#core-message'), `Validating ${file.name}...`);
try {
const result = await api('/api/upload_core', { method: 'POST', body: await file.arrayBuffer() });
setMessage($('#core-message'), `Core module accepted | ${result.sha256}`, 'success');
} catch (error) { setMessage($('#core-message'), error.message, 'error'); }
finally { button.disabled = false; }
}
if (document.body.dataset.page === 'viewer') bootViewer();
else bootLogin();