399 lines
16 KiB
JavaScript
399 lines
16 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';
|
|
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() });
|
|
$('#agent-digest').textContent = result.sha256;
|
|
setMessage($('#agent-message'), `Agent accepted | ${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);
|
|
});
|
|
}
|
|
|
|
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);
|
|
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_uploaded') refreshUsers();
|
|
});
|
|
}
|
|
|
|
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';
|
|
meta.textContent = user.has_agent ? `Agent | ${user.agent_sha256 || 'uploaded'}` : 'No agent';
|
|
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(); |