This commit is contained in:
Steve Biedermann 2026-10-01 21:02:55 +02:00
parent 7b58198d16
commit a18297d1a1
22 changed files with 1409 additions and 241 deletions

18
Cargo.lock generated
View File

@ -11,6 +11,13 @@ dependencies = [
"gimli", "gimli",
] ]
[[package]]
name = "agent-api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.5" version = "1.1.5"
@ -453,6 +460,13 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "core-api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@ -698,6 +712,8 @@ dependencies = [
name = "default-core" name = "default-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"agent-api",
"core-api",
"extism-pdk", "extism-pdk",
"serde", "serde",
"serde_json", "serde_json",
@ -4006,9 +4022,11 @@ dependencies = [
name = "wolloseum" name = "wolloseum"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"agent-api",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"clap", "clap",
"core-api",
"default-core", "default-core",
"extism", "extism",
"rcgen", "rcgen",

View File

@ -4,14 +4,16 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[workspace] [workspace]
members = ["default-core"] members = ["agent-api", "core-api", "default-core"]
exclude = ["example-wasm-agent"] exclude = ["example-wasm-agent"]
resolver = "3" resolver = "3"
[dependencies] [dependencies]
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
agent-api = { path = "agent-api" }
base64 = "0.22" base64 = "0.22"
clap = { version = "4.6.7", features = ["derive", "env"] } clap = { version = "4.6.7", features = ["derive", "env"] }
core-api = { path = "core-api" }
default-core = { path = "default-core" } default-core = { path = "default-core" }
extism = { version = "1.30", default-features = false, features = ["wasmtime-default-features"] } extism = { version = "1.30", default-features = false, features = ["wasmtime-default-features"] }
rcgen = "0.14" rcgen = "0.14"

View File

@ -2,6 +2,8 @@ FROM rust:1-bookworm AS builder
WORKDIR /build WORKDIR /build
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY agent-api ./agent-api
COPY core-api ./core-api
COPY default-core ./default-core COPY default-core ./default-core
COPY src ./src COPY src ./src
COPY web ./web COPY web ./web

View File

@ -36,12 +36,13 @@ Set `WOLLOSEUM_UID` and `WOLLOSEUM_GID` in the environment or a Compose `.env`
file to choose the container process identity. Both default to `1000`. The file to choose the container process identity. Both default to `1000`. The
ownership of the host `./data` tree is changed to these IDs at startup. ownership of the host `./data` tree is changed to these IDs at startup.
The first startup logs the administrator identity ID and the key path inside The first startup logs the administrator identity ID and identity bundle path
the container. Copy the key to a secure host location for admin sign-in: inside the container. Copy the bundle to a secure host location for admin
sign-in:
```sh ```sh
docker compose cp wolloseum:/var/lib/wolloseum/keys/admin-private.pem /secure/path/admin-private.pem docker compose cp wolloseum:/var/lib/wolloseum/keys/admin-identity.json /secure/path/admin-identity.json
chmod 600 /secure/path/admin-private.pem chmod 600 /secure/path/admin-identity.json
``` ```
Back up the key and database volume. To stop the server while preserving data, Back up the key and database volume. To stop the server while preserving data,
@ -51,6 +52,7 @@ well, run `docker compose down --volumes`.
The server bootstraps an administrator keypair on first start: The server bootstraps an administrator keypair on first start:
- `data/keys/admin-private.pem` is the owner-only Ed25519 PKCS#8 private key. - `data/keys/admin-private.pem` is the owner-only Ed25519 PKCS#8 private key.
- `data/keys/admin-identity.json` is the owner-only bundle used by browser sign-in.
- The server prints the corresponding identity ID at startup. - The server prints the corresponding identity ID at startup.
- Keep a secure backup of the private key. The server cannot recover it. - Keep a secure backup of the private key. The server cannot recover it.
@ -68,27 +70,28 @@ Configuration flags and environment variables:
## Web Interfaces ## Web Interfaces
- `/` is the player console. Users sign in with their identity key and upload or replace their agent WASM. - `/` is the player console. Every identity, including moderators and administrators, can act as a player and upload or replace its own agent WASM.
- `/admin` is the administrator console. It creates and removes users, pauses or resumes the simulation, and replaces the core WASM module. - `/admin` is the privileged console. Moderators manage player accounts; administrators can also manage roles, simulation settings and world state, and replace the core WASM module.
- `/viewer` is a public live event viewer. It connects to `/api/events`, tracks event sequence numbers, and restores state from snapshots after reconnecting. - `/viewer` is a public live event viewer. It connects to `/api/events`, tracks event sequence numbers, and restores state from snapshots after reconnecting.
The admin console generates a fresh Ed25519 keypair when creating a player. The admin console generates a fresh Ed25519 keypair when creating a user.
The server receives only the public key. The browser downloads a JSON identity The server receives only the public key. The browser downloads a JSON identity
bundle containing the new identity ID and private key; deliver that bundle to bundle containing the new identity ID and private key; deliver that bundle to
the player through a secure channel. The private key is not stored by the the user through a secure channel. The private key is not stored by the
server. server. Administrators can promote users to moderator or administrator roles.
On the admin page, enter the startup identity ID and select Sign in on either page by selecting the JSON identity bundle. Protect
`data/keys/admin-private.pem`. For an existing installation, select the legacy `data/keys/admin-identity.json` like the private key; the older PEM key remains
admin key and use the identity ID printed by the server. available for CLI compatibility.
## Browser Authentication ## Browser Authentication
1. The browser requests `POST /api/auth/challenge` with `{ "client_id": "..." }`. 1. The browser reads the identity ID and private key from the selected JSON bundle.
2. The server returns a random, short-lived challenge. 2. The browser requests `POST /api/auth/challenge` with `{ "client_id": "..." }`.
3. The browser signs the decoded challenge bytes using Ed25519 and posts the identity ID, challenge, and base64 signature to `POST /api/auth/login`. 3. The server returns a random, short-lived challenge.
4. The server verifies the signature against the stored public key and returns an opaque session token. 4. The browser signs the decoded challenge bytes using Ed25519 and posts the identity ID, challenge, and base64 signature to `POST /api/auth/login`.
5. The browser stores the token and sends it as `Authorization: Bearer <token>` on protected API requests. 5. The server verifies the signature against the stored public key and returns an opaque session token.
6. The browser stores the token and sends it as `Authorization: Bearer <token>` on protected API requests.
Challenges expire after five minutes and are single-use. Session tokens expire Challenges expire after five minutes and are single-use. Session tokens expire
seven days after the last authenticated API interaction. Each authenticated seven days after the last authenticated API interaction. Each authenticated
@ -98,17 +101,21 @@ Revoking an identity by deleting it also removes its sessions.
## API ## API
| Method | Path | Access | Purpose | | Method | Path | Access | Purpose |
| ------ | --------------------- | ---------------- | ------------------------------------------------- | | ------ | --------------------- | ---------------- | --------------------------------------------------------- |
| `POST` | `/api/auth/challenge` | Public | Request an identity challenge | | `POST` | `/api/auth/challenge` | Public | Request an identity challenge |
| `POST` | `/api/auth/login` | Public | Verify a signed challenge and issue a token | | `POST` | `/api/auth/login` | Public | Verify a signed challenge and issue a token |
| `GET` | `/api/me` | Session | Read the signed-in identity and agent status | | `GET` | `/api/me` | Session | Read the signed-in identity and agent status |
| `GET` | `/api/users` | Admin session | List identities and agent digests | | `GET` | `/api/users` | Mod or admin | List identities and agent digests |
| `GET` | `/api/status` | Public | Read the simulation running state | | `GET` | `/api/status` | Public | Read the simulation running state |
| `GET` | `/api/settings` | Admin session | Read simulation calendar and agent activation settings | | `GET` | `/api/settings` | Admin session | Read simulation calendar, activation, and tick interval |
| `PUT` | `/api/settings` | Admin session | Update simulation calendar and agent activation settings | | `PUT` | `/api/settings` | Admin session | Update simulation calendar, activation, and tick interval |
| `GET` | `/api/state` | Admin session | Download the current state bundle |
| `POST` | `/api/state` | Admin session | Validate and restore a state bundle |
| `POST` | `/api/reset_world` | Admin session | Regenerate the world while preserving other records |
| `GET` | `/api/events` | Public WebSocket | Receive snapshots and ordered state events | | `GET` | `/api/events` | Public WebSocket | Receive snapshots and ordered state events |
| `POST` | `/api/create_user` | Admin session | Register a username and base64 Ed25519 public key | | `POST` | `/api/create_user` | Mod or admin | Register a username and base64 Ed25519 public key |
| `POST` | `/api/delete_user` | Admin session | Delete a player and its uploaded agent | | `POST` | `/api/delete_user` | Mod or admin | Delete a player and its uploaded agent |
| `POST` | `/api/set_role` | Admin session | Assign player, moderator, or administrator role |
| `POST` | `/api/start_game` | Admin session | Resume the simulation | | `POST` | `/api/start_game` | Admin session | Resume the simulation |
| `POST` | `/api/stop_game` | Admin session | Pause the simulation | | `POST` | `/api/stop_game` | Admin session | Pause the simulation |
| `POST` | `/api/update_agent` | User session | Queue or replace the caller's next agent WASM | | `POST` | `/api/update_agent` | User session | Queue or replace the caller's next agent WASM |
@ -121,8 +128,24 @@ remain accepted for compatibility with older clients.
Agent uploads replace that player's queued module; the active module remains Agent uploads replace that player's queued module; the active module remains
in use until the selected update boundary. The admin console configures ticks in use until the selected update boundary. The admin console configures ticks
per day, days per week and month, and whether queued agents activate between per day, days per week and month, and whether queued agents activate between
ticks, days, weeks, or months. Defaults are 100 ticks per day, 7 days per week, ticks, days, weeks, or months. Defaults are 86400 ticks per day, 7 days per week,
30 days per month, and activation between ticks. 30 days per month, activation between ticks, and a 100 ms delay between ticks.
The delay can be set from 1 to 60000 milliseconds.
State downloads contain the current world, simulation settings, run state,
user public identities, active and queued agent modules, and core module. They
do not contain private keys or login sessions. Restoring a bundle requires it
to retain the active administrator performing the restore. Resetting creates a
fresh random world while preserving settings, users, agents, and the active
core.
## Rust APIs
The `agent-api` crate defines typed observations, actions, and the `Agent` trait.
The `core-api` crate defines serialized tick input/output and the `Core` trait.
`default-core/` implements `Core`; `example-wasm-agent/` implements `Agent` and
adapts it to the Extism ABI. Both API crates are workspace members and can be
used as dependencies when authoring new implementations.
The server uses the native Rust implementation in `default-core/` until a core The server uses the native Rust implementation in `default-core/` until a core
is uploaded. An uploaded core replaces it and must export an Extism `tick` is uploaded. An uploaded core replaces it and must export an Extism `tick`

7
agent-api/Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[package]
name = "agent-api"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }

38
agent-api/src/lib.rs Normal file
View File

@ -0,0 +1,38 @@
use serde::{Deserialize, Serialize};
#[repr(i32)]
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum ObservedTerrain {
Grass = 0,
Water = 1,
Rock = 2,
Boundary = 3,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum Direction {
North,
East,
South,
West,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum Action {
Move(Direction),
Wait,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AgentObservation {
pub tick: u64,
pub x: u16,
pub y: u16,
pub adjacent: [ObservedTerrain; 4],
}
pub trait Agent {
type Error;
fn decide(&mut self, observation: AgentObservation) -> Result<Action, Self::Error>;
}

7
core-api/Cargo.toml Normal file
View File

@ -0,0 +1,7 @@
[package]
name = "core-api"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }

26
core-api/src/lib.rs Normal file
View File

@ -0,0 +1,26 @@
use std::error::Error;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CoreTickInput<W, A> {
pub world: W,
pub actions: Vec<A>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CoreTickOutput<W> {
pub world: W,
}
pub trait Core {
type World: Serialize + DeserializeOwned;
type Action: Serialize + DeserializeOwned;
type Error: Error + Send + Sync + 'static;
fn tick(
&mut self,
input: CoreTickInput<Self::World, Self::Action>,
) -> Result<CoreTickOutput<Self::World>, Self::Error>;
}

View File

@ -7,6 +7,8 @@ edition = "2024"
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
agent-api = { path = "../agent-api" }
core-api = { path = "../core-api" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]

View File

@ -2,6 +2,7 @@ use std::collections::BTreeMap;
use std::error::Error; use std::error::Error;
use std::fmt; use std::fmt;
pub use agent_api::{Action, AgentObservation, Direction, ObservedTerrain};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const MAX_WORLD_CELLS: usize = 1_048_576; const MAX_WORLD_CELLS: usize = 1_048_576;
@ -13,40 +14,9 @@ pub enum Terrain {
Rock, Rock,
} }
#[repr(i32)]
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum ObservedTerrain {
Grass = 0,
Water = 1,
Rock = 2,
Boundary = 3,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub struct AgentId(pub u64); pub struct AgentId(pub u64);
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum Direction {
North,
East,
South,
West,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum Action {
Move(Direction),
Wait,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AgentObservation {
pub tick: u64,
pub x: u16,
pub y: u16,
pub adjacent: [ObservedTerrain; 4],
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AgentAction { pub struct AgentAction {
pub id: AgentId, pub id: AgentId,
@ -320,16 +290,8 @@ impl World {
} }
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub type CoreTickInput = core_api::CoreTickInput<World, AgentAction>;
pub struct CoreTickInput { pub type CoreTickOutput = core_api::CoreTickOutput<World>;
pub world: World,
pub actions: Vec<AgentAction>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CoreTickOutput {
pub world: World,
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum CoreError { pub enum CoreError {
@ -366,6 +328,22 @@ pub fn run_tick(mut input: CoreTickInput) -> Result<CoreTickOutput, CoreError> {
Ok(CoreTickOutput { world: input.world }) Ok(CoreTickOutput { world: input.world })
} }
#[derive(Default)]
pub struct DefaultCore;
impl core_api::Core for DefaultCore {
type World = World;
type Action = AgentAction;
type Error = CoreError;
fn tick(
&mut self,
input: core_api::CoreTickInput<Self::World, Self::Action>,
) -> Result<core_api::CoreTickOutput<Self::World>, Self::Error> {
run_tick(input)
}
}
struct SplitMix64 { struct SplitMix64 {
state: u64, state: u64,
} }

View File

@ -2,6 +2,13 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "agent-api"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.104" version = "1.0.104"
@ -48,6 +55,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
name = "example-wasm-agent" name = "example-wasm-agent"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"agent-api",
"extism-pdk", "extism-pdk",
"serde", "serde",
] ]

View File

@ -7,6 +7,7 @@ edition = "2021"
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [dependencies]
agent-api = { path = "../agent-api" }
extism-pdk = "1" extism-pdk = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@ -1,8 +1,10 @@
use std::convert::Infallible;
use agent_api::{Action, Agent, AgentObservation, Direction, ObservedTerrain};
use extism_pdk::*; use extism_pdk::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const ABI_VERSION: u16 = 1; const ABI_VERSION: u16 = 1;
const BOUNDARY_TERRAIN: i32 = 3;
#[derive(Deserialize)] #[derive(Deserialize)]
struct DecisionInput { struct DecisionInput {
@ -20,39 +22,63 @@ struct DecisionOutput {
action: i32, action: i32,
} }
struct ExampleAgent;
impl Agent for ExampleAgent {
type Error = Infallible;
fn decide(&mut self, observation: AgentObservation) -> Result<Action, Self::Error> {
let open_directions: Vec<Direction> = observation
.adjacent
.iter()
.enumerate()
.filter(|(_, terrain)| **terrain != ObservedTerrain::Boundary)
.map(|(index, _)| match index {
0 => Direction::North,
1 => Direction::East,
2 => Direction::South,
_ => Direction::West,
})
.collect();
if open_directions.is_empty() {
return Ok(Action::Wait);
}
let seed = deterministic_seed(observation.tick, observation.x, observation.y);
Ok(Action::Move(
open_directions[(seed as usize) % open_directions.len()],
))
}
}
/// Deterministic decision function called once per simulation tick. /// Deterministic decision function called once per simulation tick.
/// /// The private keyless agent API is adapted to the stable Extism JSON ABI here.
/// The host provides the full observation as JSON input and expects a JSON
/// decision back; no filesystem, network, clock, or RNG access is available.
/// Movement here is a reproducible function of (tick, x, y) alone, so replay
/// with the same seed and inputs always yields the same actions.
#[plugin_fn] #[plugin_fn]
pub fn decide(input: Json<DecisionInput>) -> FnResult<Json<DecisionOutput>> { pub fn decide(input: Json<DecisionInput>) -> FnResult<Json<DecisionOutput>> {
let input = input.0; let input = input.0;
let observation = AgentObservation {
// Action codes: 1=North, 2=East, 3=South, 4=West, matching adjacent_terrain order. tick: input.tick,
let open_directions: Vec<i32> = input x: input.x,
.adjacent_terrain y: input.y,
.iter() adjacent: input.adjacent_terrain.map(|terrain| match terrain {
.enumerate() 0 => ObservedTerrain::Grass,
.filter(|(_, terrain)| **terrain != BOUNDARY_TERRAIN) 1 => ObservedTerrain::Water,
.map(|(index, _)| (index + 1) as i32) 2 => ObservedTerrain::Rock,
.collect(); _ => ObservedTerrain::Boundary,
}),
let action = if open_directions.is_empty() { };
0 // Wait let action = match ExampleAgent.decide(observation).unwrap() {
} else { Action::Wait => 0,
let seed = deterministic_seed(input.tick, input.x, input.y); Action::Move(Direction::North) => 1,
open_directions[(seed as usize) % open_directions.len()] Action::Move(Direction::East) => 2,
Action::Move(Direction::South) => 3,
Action::Move(Direction::West) => 4,
}; };
Ok(Json(DecisionOutput { Ok(Json(DecisionOutput {
abi_version: ABI_VERSION, abi_version: ABI_VERSION,
action, action,
})) }))
} }
/// SplitMix64-style mix so the same (tick, x, y) always yields the same choice.
fn deterministic_seed(tick: u64, x: u16, y: u16) -> u64 { fn deterministic_seed(tick: u64, x: u16, y: u16) -> u64 {
let mut state = tick let mut state = tick
.wrapping_mul(0x9E37_79B9_7F4A_7C15) .wrapping_mul(0x9E37_79B9_7F4A_7C15)

View File

@ -1,4 +1,4 @@
use default_core::{CoreTickInput, CoreTickOutput, World, run_tick}; use default_core::{CoreTickInput, CoreTickOutput, DefaultCore, World};
use extism::{Manifest, Plugin, PluginBuilder, Wasm}; use extism::{Manifest, Plugin, PluginBuilder, Wasm};
const CORE_FUEL_LIMIT: u64 = 5_000_000; const CORE_FUEL_LIMIT: u64 = 5_000_000;
@ -35,7 +35,9 @@ impl CoreRuntime {
let expected_seed = input.world.seed(); let expected_seed = input.world.seed();
let expected_dimensions = input.world.dimensions(); let expected_dimensions = input.world.dimensions();
let output = match self { let output = match self {
Self::Native => run_tick(input).map_err(|error| error.to_string())?, Self::Native => {
core_api::Core::tick(&mut DefaultCore, input).map_err(|error| error.to_string())?
}
Self::Wasm(plugin) => { Self::Wasm(plugin) => {
let encoded = serde_json::to_vec(&input).map_err(|error| error.to_string())?; let encoded = serde_json::to_vec(&input).map_err(|error| error.to_string())?;
let response = plugin let response = plugin

View File

@ -1,5 +1,7 @@
pub mod cli; pub mod cli;
mod core_runtime; mod core_runtime;
pub mod server; pub mod server;
pub use agent_api;
pub use core_api;
pub use default_core as simulation; pub use default_core as simulation;
pub mod wasm_agent; pub mod wasm_agent;

File diff suppressed because it is too large Load Diff

View File

@ -112,6 +112,14 @@ impl WasmAgent {
} }
} }
impl agent_api::Agent for WasmAgent {
type Error = WasmAgentError;
fn decide(&mut self, observation: AgentObservation) -> Result<Action, Self::Error> {
WasmAgent::decide(self, observation)
}
}
fn terrain_code(terrain: ObservedTerrain) -> i32 { fn terrain_code(terrain: ObservedTerrain) -> i32 {
terrain as i32 terrain as i32
} }

View File

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wolloseum | Administration</title> <title>Wolloseum | Administration</title>
<link rel="stylesheet" href="/assets/app.css"> <link rel="stylesheet" href="/assets/app.css">
<script>try { var t = localStorage.getItem('wolloseum.theme'); if (t) document.documentElement.dataset.theme = t; } catch (_) { }</script>
<script src="/assets/app.js" defer></script> <script src="/assets/app.js" defer></script>
</head> </head>
@ -14,22 +15,19 @@
<header class="topbar"> <header class="topbar">
<a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a> <a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a>
<nav class="nav"><a href="/">Player</a><a href="/viewer">Live viewer</a><a href="/admin" <nav class="nav"><a href="/">Player</a><a href="/viewer">Live viewer</a><a href="/admin"
aria-current="page">Admin</a></nav> aria-current="page">Admin</a><button id="theme-toggle" class="icon-button secondary button" type="button"
aria-label="Toggle dark mode">&#9789;</button></nav>
</header> </header>
<main> <main>
<section id="login-panel" class="panel login-panel"> <section id="login-panel" class="panel login-panel">
<div class="accent-band"></div> <div class="accent-band"></div>
<div class="panel-body"> <div class="panel-body">
<p class="eyebrow">Restricted console</p> <p class="eyebrow">Privileged access</p>
<h2>Administrator sign in</h2> <h2>Sign in with your identity bundle</h2>
<p class="login-note">Authenticate by signing a fresh server challenge with the admin Ed25519 private key.</p> <p class="login-note">Choose an administrator or moderator JSON bundle to sign in.</p>
<form id="login-form" class="form-grid"> <form id="login-form" class="form-grid">
<div class="field"><label for="identity-id">Admin identity ID</label><input id="identity-id" <input id="identity-bundle" class="hidden" type="file" accept=".json,application/json">
autocomplete="username" spellcheck="false" placeholder="Printed when the server starts" required></div> <div class="actions"><button id="login-button" class="button" type="button">Login</button></div>
<div class="field"><label for="private-key-file">Admin private key</label><input id="private-key-file"
type="file" accept=".pem,.json,application/json" required><span class="help">Use
data/keys/admin-private.pem.</span></div>
<div class="actions"><button class="button" type="submit">Open console</button></div>
<p id="login-message" class="message" role="status"></p> <p id="login-message" class="message" role="status"></p>
</form> </form>
</div> </div>
@ -56,7 +54,7 @@
<form id="user-form" class="inline-form"> <form id="user-form" class="inline-form">
<div class="field"><label for="new-username">Create player</label><input id="new-username" <div class="field"><label for="new-username">Create player</label><input id="new-username"
maxlength="32" pattern="[A-Za-z0-9_\-]+" placeholder="username" required></div> maxlength="32" pattern="[A-Za-z0-9_\-]+" placeholder="username" required></div>
<button class="button" type="submit">Create keypair</button> <button class="button mod-control" type="submit">Create keypair</button>
</form> </form>
<p id="create-user-message" class="message" role="status" style="margin-top:10px"></p> <p id="create-user-message" class="message" role="status" style="margin-top:10px"></p>
<a id="identity-download" class="button secondary hidden" href="#" download>Download identity bundle</a> <a id="identity-download" class="button secondary hidden" href="#" download>Download identity bundle</a>
@ -65,7 +63,7 @@
<div class="empty">Loading identities...</div> <div class="empty">Loading identities...</div>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel admin-only">
<div class="panel-head"> <div class="panel-head">
<h2>Core simulation</h2><span class="panel-kicker">SANDBOX VALIDATED</span> <h2>Core simulation</h2><span class="panel-kicker">SANDBOX VALIDATED</span>
</div> </div>
@ -80,16 +78,23 @@
</section> </section>
</div> </div>
<aside class="side-column"> <aside class="side-column">
<section class="panel"> <section class="panel admin-only">
<div class="panel-head"> <div class="panel-head">
<h2>Simulation</h2><span class="panel-kicker">CONTROL</span> <h2>Simulation</h2><span class="panel-kicker">CONTROL</span>
</div> </div>
<div class="panel-body stack"> <div class="panel-body stack">
<p class="side-note">Changes apply immediately. Current state is also published to the live event <p class="side-note">Changes apply immediately. Current state is also published to the live event
stream.</p><button id="game-control" class="button" type="button">Pause simulation</button> stream.</p><button id="game-control" class="button" type="button">Pause simulation</button>
<button id="reset-world" class="button danger" type="button">Reset world</button>
<div class="actions">
<button id="export-state" class="button secondary" type="button">Download state</button>
<input id="state-file" class="hidden" type="file" accept=".json,application/json">
<button id="import-state" class="button secondary" type="button">Restore state</button>
</div>
<p id="state-message" class="message" role="status"></p>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel admin-only">
<div class="panel-head"> <div class="panel-head">
<h2>Agent updates</h2><span class="panel-kicker">SCHEDULE</span> <h2>Agent updates</h2><span class="panel-kicker">SCHEDULE</span>
</div> </div>
@ -101,6 +106,8 @@
type="number" min="1" max="366" required></div> type="number" min="1" max="366" required></div>
<div class="field"><label for="days-per-month">Days per month</label><input id="days-per-month" <div class="field"><label for="days-per-month">Days per month</label><input id="days-per-month"
type="number" min="1" max="366" required></div> type="number" min="1" max="366" required></div>
<div class="field"><label for="tick-interval-ms">Sleep between ticks (ms)</label><input
id="tick-interval-ms" type="number" min="1" max="60000" required></div>
<div class="field"><label for="agent-activation">Activate queued agents</label><select <div class="field"><label for="agent-activation">Activate queued agents</label><select
id="agent-activation"> id="agent-activation">
<option value="between_ticks">Between ticks</option> <option value="between_ticks">Between ticks</option>
@ -126,7 +133,7 @@
</div> </div>
</section> </section>
</main> </main>
<footer><span>WOLLOSEUM | ADMIN</span><span>Privileged actions require an admin identity</span></footer> <footer><span>WOLLOSEUM | ADMIN</span><span>Moderator user management | Administrator server control</span></footer>
</div> </div>
</body> </body>

View File

@ -16,6 +16,36 @@
--sans: 'Manrope', sans-serif; --sans: 'Manrope', sans-serif;
} }
:root[data-theme="dark"] {
color-scheme: dark;
--ink: #e8efe9;
--muted: #92a299;
--line: #2b3934;
--paper: #11201a;
--white: #152620;
--green: #4fb892;
--green-dark: #39997a;
--lime: #d5ef78;
--coral: #e08871;
--blue: #6fb0c2;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--ink: #e8efe9;
--muted: #92a299;
--line: #2b3934;
--paper: #11201a;
--white: #152620;
--green: #4fb892;
--green-dark: #39997a;
--lime: #d5ef78;
--coral: #e08871;
--blue: #6fb0c2;
}
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -25,9 +55,10 @@ body {
min-height: 100vh; min-height: 100vh;
color: var(--ink); color: var(--ink);
background-color: var(--paper); background-color: var(--paper);
background-image: radial-gradient(#dce4dc 0.7px, transparent 0.7px); background-image: radial-gradient(color-mix(in srgb, var(--line) 70%, transparent) 0.7px, transparent 0.7px);
background-size: 18px 18px; background-size: 18px 18px;
font: 15px/1.55 var(--sans); font: 15px/1.55 var(--sans);
transition: background-color 150ms ease, color 150ms ease;
} }
button, button,
@ -161,13 +192,13 @@ h3 {
width: 7px; width: 7px;
height: 7px; height: 7px;
border-radius: 50%; border-radius: 50%;
background: #98a39d; background: var(--muted);
} }
.status-chip.live .status-dot, .status-chip.live .status-dot,
.status-chip.running .status-dot { .status-chip.running .status-dot {
background: #48a674; background: var(--green);
box-shadow: 0 0 0 3px #48a67424; box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 25%, transparent);
} }
.status-chip.paused .status-dot, .status-chip.paused .status-dot,
@ -254,17 +285,17 @@ h3 {
width: 100%; width: 100%;
min-height: 42px; min-height: 42px;
padding: 9px 11px; padding: 9px 11px;
border: 1px solid #cbd5ce; border: 1px solid var(--line);
border-radius: 4px; border-radius: 4px;
color: var(--ink); color: var(--ink);
background: #fff; background: var(--white);
outline: 0; outline: 0;
} }
.field input:focus, .field input:focus,
.field select:focus { .field select:focus {
border-color: var(--green); border-color: var(--green);
box-shadow: 0 0 0 3px #176b5018; box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 20%, transparent);
} }
.field input[type="file"] { .field input[type="file"] {
@ -308,16 +339,16 @@ h3 {
.button.secondary { .button.secondary {
color: var(--ink); color: var(--ink);
border-color: var(--line); border-color: var(--line);
background: white; background: var(--white);
} }
.button.secondary:hover { .button.secondary:hover {
background: #f2f5f0; background: color-mix(in srgb, var(--white) 85%, var(--ink) 15%);
} }
.button.danger { .button.danger {
border-color: #b64e3d; border-color: var(--coral);
background: #b64e3d; background: var(--coral);
} }
.button:disabled { .button:disabled {
@ -340,7 +371,7 @@ h3 {
} }
.message.error { .message.error {
color: #a43d30; color: var(--coral);
} }
.message.success { .message.success {
@ -358,7 +389,7 @@ h3 {
padding: 16px; padding: 16px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 5px; border-radius: 5px;
background: #fbfcf8; background: color-mix(in srgb, var(--white) 92%, var(--paper) 8%);
} }
.stat-label { .stat-label {
@ -403,13 +434,24 @@ h3 {
align-items: center; align-items: center;
gap: 16px; gap: 16px;
padding: 14px 20px; padding: 14px 20px;
border-bottom: 1px solid #e8ece7; border-bottom: 1px solid var(--line);
} }
.user-row:last-child { .user-row:last-child {
border-bottom: 0; border-bottom: 0;
} }
.user-row select {
min-height: 36px;
max-width: 180px;
padding: 6px 8px;
border: 1px solid var(--line);
border-radius: 4px;
color: var(--ink);
background: var(--white);
font-size: 12px;
}
.user-name { .user-name {
font-weight: 800; font-weight: 800;
} }
@ -451,7 +493,7 @@ h3 {
grid-template-columns: 68px minmax(120px, 180px) minmax(0, 1fr); grid-template-columns: 68px minmax(120px, 180px) minmax(0, 1fr);
gap: 14px; gap: 14px;
padding: 13px 17px; padding: 13px 17px;
border-bottom: 1px solid #e8ece7; border-bottom: 1px solid var(--line);
animation: arrive 180ms ease-out both; animation: arrive 180ms ease-out both;
} }
@ -472,7 +514,7 @@ h3 {
} }
.event-data { .event-data {
color: #53615c; color: color-mix(in srgb, var(--ink) 75%, var(--muted) 25%);
font: 10px/1.5 var(--mono); font: 10px/1.5 var(--mono);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@ -514,7 +556,7 @@ h3 {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 4px; border-radius: 4px;
color: var(--green-dark); color: var(--green-dark);
background: #f5f8f2; background: color-mix(in srgb, var(--white) 90%, var(--green) 10%);
font: 10px/1.5 var(--mono); font: 10px/1.5 var(--mono);
} }
@ -524,7 +566,7 @@ h3 {
.accent-band { .accent-band {
height: 4px; height: 4px;
background: linear-gradient(90deg, var(--green) 0 62%, var(--lime) 62% 82%, #e7a28b 82%); background: linear-gradient(90deg, var(--green) 0 62%, var(--lime) 62% 82%, var(--coral) 82%);
} }
.viewer-head { .viewer-head {

View File

@ -1,7 +1,26 @@
const $ = (selector, root = document) => root.querySelector(selector); const $ = (selector, root = document) => root.querySelector(selector);
const TOKEN_KEY = `wolloseum.session.${document.body?.dataset.page || 'app'}`; const TOKEN_KEY = `wolloseum.session.${document.body?.dataset.page || 'app'}`;
const THEME_KEY = 'wolloseum.theme';
const encoder = new TextEncoder(); const encoder = new TextEncoder();
function currentTheme() {
return document.documentElement.dataset.theme
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem(THEME_KEY, theme);
}
function bootTheme() {
const toggle = $('#theme-toggle');
if (!toggle) return;
const paint = () => { toggle.textContent = currentTheme() === 'dark' ? '\u2600' : '\u263D'; };
toggle.addEventListener('click', () => { setTheme(currentTheme() === 'dark' ? 'light' : 'dark'); paint(); });
paint();
}
function setMessage(element, text, kind = '') { function setMessage(element, text, kind = '') {
if (!element) return; if (!element) return;
element.textContent = text; element.textContent = text;
@ -89,19 +108,17 @@ async function api(path, options = {}) {
} }
async function readCredential(file) { async function readCredential(file) {
const text = await file.text(); let bundle;
try { try { bundle = JSON.parse(await file.text()); }
const bundle = JSON.parse(text); catch (_) { throw new Error('Choose a valid JSON identity bundle.'); }
if (bundle.client_id && bundle.private_key_pem) return bundle; if (!bundle.client_id || !bundle.private_key_pem) {
} catch (_) { } throw new Error('This JSON file is not an identity bundle.');
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 bundle;
return { client_id: identity, private_key_pem: text };
} }
async function signIn() { async function signIn(file) {
const file = $('#private-key-file').files[0]; if (!file) throw new Error('Choose your identity bundle.');
if (!file) throw new Error('Choose your private key file.');
const credential = await readCredential(file); const credential = await readCredential(file);
const encodedKey = credential.private_key_pem.match(/-----BEGIN PRIVATE KEY-----([\s\S]*?)-----END PRIVATE KEY-----/); 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.'); if (!encodedKey) throw new Error('Expected an Ed25519 PKCS#8 private key.');
@ -217,9 +234,10 @@ function bootLogin() {
const account = $('#account-name'); const account = $('#account-name');
const reveal = async identity => { const reveal = async identity => {
const requiredAdmin = page === 'admin'; const requiredAdmin = page === 'admin';
if (identity.is_admin !== requiredAdmin) { if (requiredAdmin && !identity.is_admin && !identity.is_mod) {
throw new Error(requiredAdmin ? 'This key does not belong to an administrator.' : 'Use the administration page to sign in with an admin key.'); throw new Error('This identity does not have moderator or administrator rights.');
} }
window.currentIdentity = identity;
panel.classList.add('hidden'); panel.classList.add('hidden');
app.classList.remove('hidden'); app.classList.remove('hidden');
account.textContent = identity.username; account.textContent = identity.username;
@ -228,22 +246,22 @@ function bootLogin() {
}; };
form.addEventListener('submit', async event => { form.addEventListener('submit', async event => {
event.preventDefault(); event.preventDefault();
const button = $('button[type="submit"]', form); $('#identity-bundle').click();
});
$('#login-button').addEventListener('click', () => $('#identity-bundle').click());
$('#identity-bundle').addEventListener('change', async event => {
const fileInput = event.currentTarget;
const button = $('#login-button');
button.disabled = true; button.disabled = true;
setMessage(message, 'Verifying challenge...'); setMessage(message, 'Verifying challenge...');
try { await reveal(await signIn()); } try { await reveal(await signIn(fileInput.files[0])); }
catch (error) { setMessage(message, error.message, 'error'); } catch (error) { setMessage(message, error.message, 'error'); }
finally { button.disabled = false; } finally {
button.disabled = false;
fileInput.value = '';
}
}); });
$('#logout-button, #admin-logout').addEventListener('click', logout); $('#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)) { if (localStorage.getItem(TOKEN_KEY)) {
api('/api/me').then(reveal).catch(() => localStorage.removeItem(TOKEN_KEY)); api('/api/me').then(reveal).catch(() => localStorage.removeItem(TOKEN_KEY));
} }
@ -290,10 +308,14 @@ function paintGameStatus(running) {
if (copy) copy.textContent = running ? 'RUNNING' : 'PAUSED'; if (copy) copy.textContent = running ? 'RUNNING' : 'PAUSED';
} }
async function bootAdmin() { async function bootAdmin(identity) {
document.querySelectorAll('.admin-only').forEach(element => {
element.classList.toggle('hidden', !identity.is_admin);
});
if (!identity.is_admin) setMessage($('#admin-message'), 'Moderator access | user management only.');
const status = await api('/api/status'); const status = await api('/api/status');
paintGameStatus(status.running); paintGameStatus(status.running);
$('#game-control').addEventListener('click', async () => { if (identity.is_admin) $('#game-control').addEventListener('click', async () => {
const running = $('#game-status-label').textContent === 'RUNNING'; const running = $('#game-status-label').textContent === 'RUNNING';
const button = $('#game-control'); const button = $('#game-control');
button.disabled = true; button.disabled = true;
@ -304,18 +326,24 @@ async function bootAdmin() {
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); } } catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
finally { button.disabled = false; } finally { button.disabled = false; }
}); });
$('#game-control').textContent = status.running ? 'Pause simulation' : 'Resume simulation'; if ($('#game-control')) $('#game-control').textContent = status.running ? 'Pause simulation' : 'Resume simulation';
$('#user-form').addEventListener('submit', createUser); $('#user-form').addEventListener('submit', createUser);
if (identity.is_admin) {
$('#core-form').addEventListener('submit', uploadCore); $('#core-form').addEventListener('submit', uploadCore);
$('#simulation-settings-form').addEventListener('submit', saveSimulationSettings); $('#simulation-settings-form').addEventListener('submit', saveSimulationSettings);
$('#reset-world').addEventListener('click', resetWorld);
$('#export-state').addEventListener('click', exportState);
$('#import-state').addEventListener('click', () => $('#state-file').click());
$('#state-file').addEventListener('change', importState);
await loadSimulationSettings(); await loadSimulationSettings();
}
await refreshUsers(); await refreshUsers();
connectEvents(event => { connectEvents(event => {
if (event.event === 'snapshot' || event.event === 'game_state_changed') { if (event.event === 'snapshot' || event.event === 'game_state_changed') {
paintGameStatus(event.data.running); paintGameStatus(event.data.running);
$('#game-control').textContent = event.data.running ? 'Pause simulation' : 'Resume simulation'; if ($('#game-control')) $('#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(); if (event.event === 'user_created' || event.event === 'user_deleted' || event.event === 'user_role_changed' || event.event === 'agent_queued' || event.event === 'agents_activated') refreshUsers();
}); });
} }
@ -325,6 +353,7 @@ async function loadSimulationSettings() {
$('#ticks-per-day').value = settings.ticks_per_day; $('#ticks-per-day').value = settings.ticks_per_day;
$('#days-per-week').value = settings.days_per_week; $('#days-per-week').value = settings.days_per_week;
$('#days-per-month').value = settings.days_per_month; $('#days-per-month').value = settings.days_per_month;
$('#tick-interval-ms').value = settings.tick_interval_ms;
$('#agent-activation').value = settings.agent_activation; $('#agent-activation').value = settings.agent_activation;
} catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); } } catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); }
} }
@ -338,6 +367,7 @@ async function saveSimulationSettings(event) {
ticks_per_day: Number($('#ticks-per-day').value), ticks_per_day: Number($('#ticks-per-day').value),
days_per_week: Number($('#days-per-week').value), days_per_week: Number($('#days-per-week').value),
days_per_month: Number($('#days-per-month').value), days_per_month: Number($('#days-per-month').value),
tick_interval_ms: Number($('#tick-interval-ms').value),
agent_activation: $('#agent-activation').value, agent_activation: $('#agent-activation').value,
}; };
try { try {
@ -389,7 +419,7 @@ async function refreshUsers() {
const info = document.createElement('div'); const info = document.createElement('div');
const name = document.createElement('div'); const name = document.createElement('div');
name.className = 'user-name'; name.className = 'user-name';
name.textContent = `${user.username}${user.is_admin ? ' | admin' : ''}`; name.textContent = `${user.username}${user.is_admin ? ' | admin' : user.is_mod ? ' | mod' : ''}`;
const id = document.createElement('div'); const id = document.createElement('div');
id.className = 'user-id'; id.className = 'user-id';
id.textContent = user.client_id; id.textContent = user.client_id;
@ -402,7 +432,26 @@ async function refreshUsers() {
meta.textContent = agentStatus.join(' | '); meta.textContent = agentStatus.join(' | ');
info.append(name, id, meta); info.append(name, id, meta);
row.append(info); row.append(info);
if (!user.is_admin) { if (window.currentIdentity?.is_admin && user.username !== 'admin') {
const role = document.createElement('select');
role.setAttribute('aria-label', `${user.username} role`);
[['user', 'Player'], ['mod', 'Moderator'], ['admin', 'Administrator']].forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
role.append(option);
});
role.value = user.is_admin ? 'admin' : user.is_mod ? 'mod' : 'user';
role.addEventListener('change', async () => {
try {
await api('/api/set_role', { method: 'POST', body: JSON.stringify({ client_id: user.client_id, role: role.value }) });
await refreshUsers();
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
});
row.append(role);
}
const mayRemove = !user.is_admin && (window.currentIdentity?.is_admin || !user.is_mod);
if (mayRemove) {
const remove = document.createElement('button'); const remove = document.createElement('button');
remove.className = 'button danger'; remove.className = 'button danger';
remove.type = 'button'; remove.type = 'button';
@ -422,6 +471,40 @@ async function refreshUsers() {
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); } } catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
} }
async function resetWorld() {
if (!confirm('Reset the world? Settings, users, agents, and core will be kept.')) return;
try {
await api('/api/reset_world', { method: 'POST', body: '' });
setMessage($('#state-message'), 'World reset requested.', 'success');
} catch (error) { setMessage($('#state-message'), error.message, 'error'); }
}
async function exportState() {
try {
const state = await api('/api/state');
const url = URL.createObjectURL(new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = 'wolloseum-state.json';
link.click();
URL.revokeObjectURL(url);
setMessage($('#state-message'), 'Simulation state downloaded.', 'success');
} catch (error) { setMessage($('#state-message'), error.message, 'error'); }
}
async function importState(event) {
const file = event.currentTarget.files[0];
if (!file) return;
if (!confirm('Restore this simulation state? Current settings, users, agents, core, and world will be replaced.')) return;
try {
await api('/api/state', { method: 'POST', body: await file.text() });
setMessage($('#state-message'), 'Simulation state restored.', 'success');
await refreshUsers();
await loadSimulationSettings();
} catch (error) { setMessage($('#state-message'), error.message, 'error'); }
finally { event.currentTarget.value = ''; }
}
async function uploadCore(event) { async function uploadCore(event) {
event.preventDefault(); event.preventDefault();
const file = $('#core-file').files[0]; const file = $('#core-file').files[0];
@ -436,5 +519,6 @@ async function uploadCore(event) {
finally { button.disabled = false; } finally { button.disabled = false; }
} }
bootTheme();
if (document.body.dataset.page === 'viewer') bootViewer(); if (document.body.dataset.page === 'viewer') bootViewer();
else bootLogin(); else bootLogin();

View File

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wolloseum | Player</title> <title>Wolloseum | Player</title>
<link rel="stylesheet" href="/assets/app.css"> <link rel="stylesheet" href="/assets/app.css">
<script>try { var t = localStorage.getItem('wolloseum.theme'); if (t) document.documentElement.dataset.theme = t; } catch (_) { }</script>
<script src="/assets/app.js" defer></script> <script src="/assets/app.js" defer></script>
</head> </head>
@ -14,23 +15,20 @@
<header class="topbar"> <header class="topbar">
<a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a> <a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a>
<nav class="nav"><a href="/" aria-current="page">Player</a><a href="/viewer">Live viewer</a><a <nav class="nav"><a href="/" aria-current="page">Player</a><a href="/viewer">Live viewer</a><a
href="/admin">Admin</a></nav> href="/admin">Admin</a><button id="theme-toggle" class="icon-button secondary button" type="button"
aria-label="Toggle dark mode">&#9789;</button></nav>
</header> </header>
<main> <main>
<section id="login-panel" class="panel login-panel"> <section id="login-panel" class="panel login-panel">
<div class="accent-band"></div> <div class="accent-band"></div>
<div class="panel-body"> <div class="panel-body">
<p class="eyebrow">Player access</p> <p class="eyebrow">Identity access</p>
<h2>Sign in with your keypair</h2> <h2>Sign in with your identity bundle</h2>
<p class="login-note">The server issues a one-time challenge. Your private key signs it locally and never <p class="login-note">Your JSON bundle signs a one-time challenge locally. Its private key never leaves this
leaves this browser.</p> browser.</p>
<form id="login-form" class="form-grid"> <form id="login-form" class="form-grid">
<div class="field"><label for="identity-id">Identity ID</label><input id="identity-id" <input id="identity-bundle" class="hidden" type="file" accept=".json,application/json">
autocomplete="username" spellcheck="false" placeholder="SHA-256 public-key fingerprint" required></div> <div class="actions"><button id="login-button" class="button" type="button">Login</button></div>
<div class="field"><label for="private-key-file">Private key or identity bundle</label><input
id="private-key-file" type="file" accept=".pem,.json,application/json" required><span
class="help">Choose the downloaded identity JSON or an Ed25519 PKCS#8 PEM key.</span></div>
<div class="actions"><button class="button" type="submit">Sign in</button></div>
<p id="login-message" class="message" role="status"></p> <p id="login-message" class="message" role="status"></p>
</form> </form>
</div> </div>

View File

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wolloseum | Live viewer</title> <title>Wolloseum | Live viewer</title>
<link rel="stylesheet" href="/assets/app.css"> <link rel="stylesheet" href="/assets/app.css">
<script>try { var t = localStorage.getItem('wolloseum.theme'); if (t) document.documentElement.dataset.theme = t; } catch (_) { }</script>
<script src="/assets/app.js" defer></script> <script src="/assets/app.js" defer></script>
</head> </head>
@ -14,7 +15,8 @@
<header class="topbar"> <header class="topbar">
<a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a> <a class="brand" href="/"><span class="brand-mark">W</span> WOLLOSEUM</a>
<nav class="nav"><a href="/">Player</a><a href="/viewer" aria-current="page">Live viewer</a><a <nav class="nav"><a href="/">Player</a><a href="/viewer" aria-current="page">Live viewer</a><a
href="/admin">Admin</a></nav> href="/admin">Admin</a><button id="theme-toggle" class="icon-button secondary button" type="button"
aria-label="Toggle dark mode">&#9789;</button></nav>
</header> </header>
<main> <main>
<div class="page-head viewer-head"> <div class="page-head viewer-head">