From 4007821a827d0e1fc15e6ea6933e38772ae4f5e3 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Tue, 3 Nov 2026 08:26:59 -0600 Subject: [PATCH] =?UTF-8?q?id:=20world=20directory=20feed=20=E2=80=94=20pr?= =?UTF-8?q?esence=20counts=20+=20public=20directory=20frame=20(#812)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorldInfo gains presence (live session lease count per world, hub list()); directory_json emits it — a count only, never an identity (#812 shape ruling 2026-10-29). - New public ClientFrame::Directory: lists worlds with no admin token; the reply is directory metadata only (names, loaded, default, presence counts), creates/opens nothing. Admin ListWorlds unchanged. - WorldClient::directory() + shared worlds-reply parser; list_worlds now also returns presence. - id world list: --admin-token optional; without it the CLI reads the public directory. Plain row shape unchanged (#3089 contract). - Web world explorer renders from directory_json (shared serializer), showing presence counts. - Tests: directory_json shape asserts presence; new session test proves the directory frame answers without an admin token. --- pkgs/id/src/cli.rs | 7 ++++-- pkgs/id/src/commands/world.rs | 15 ++++++++--- pkgs/id/src/web/home.rs | 40 +++++++++++++++++++----------- pkgs/id/src/world_hub.rs | 36 ++++++++++++++++++++------- pkgs/id/src/world_net.rs | 40 +++++++++++++++++++++++++----- pkgs/id/src/world_session/mod.rs | 18 ++++++++++++++ pkgs/id/src/world_session/tests.rs | 19 ++++++++++++++ pkgs/id/src/world_session/types.rs | 8 ++++++ 8 files changed, 149 insertions(+), 34 deletions(-) diff --git a/pkgs/id/src/cli.rs b/pkgs/id/src/cli.rs index 1008b0700..9a3d86a89 100644 --- a/pkgs/id/src/cli.rs +++ b/pkgs/id/src/cli.rs @@ -2177,13 +2177,16 @@ pub enum WorldCommand { #[arg(long)] allow_partial: bool, }, - /// List the worlds a host serves (needs the host's admin token). + /// List the worlds a host serves. Without `--admin-token` this reads + /// the host's public directory (#812: names, loaded state, presence + /// counts); with it, the admin listing is used. List { /// The host's node ID (64 hex characters). node: String, /// Admin secret configured with `serve --world-admin-token`. + /// Optional: omit it to read the public directory. #[arg(long, env = "ID_WORLD_ADMIN_TOKEN", hide_env_values = true)] - admin_token: String, + admin_token: Option, /// Print one JSON object instead of tab-separated lines. #[arg(long)] json: bool, diff --git a/pkgs/id/src/commands/world.rs b/pkgs/id/src/commands/world.rs index a11dab8a2..f9b4ad2f1 100644 --- a/pkgs/id/src/commands/world.rs +++ b/pkgs/id/src/commands/world.rs @@ -682,14 +682,23 @@ pub async fn cmd_world(command: WorldCommand) -> Result<()> { no_relay, } => { let (endpoint, mut client) = connect(&node, &addrs, no_relay, None).await?; - let result = client.list_worlds(&admin_token).await; + // With an admin token this is the admin listing; without one + // it is the host's public directory (#812) — same reply shape. + let result = match &admin_token { + Some(token) => client.list_worlds(token).await, + None => client.directory().await, + }; client.close(); endpoint.close().await; let (default, worlds) = result?; if json { let worlds: Vec = worlds .into_iter() - .map(|(name, open)| crate::world_hub::WorldInfo { name, open }) + .map(|(name, open, presence)| crate::world_hub::WorldInfo { + name, + open, + presence, + }) .collect(); println!( "{}", @@ -698,7 +707,7 @@ pub async fn cmd_world(command: WorldCommand) -> Result<()> { ))? ); } else { - for (name, open) in worlds { + for (name, open, _presence) in worlds { println!("{}", world_list_plain_row(&name, &default, open)); } } diff --git a/pkgs/id/src/web/home.rs b/pkgs/id/src/web/home.rs index 6df44371c..9f3ea2fcd 100644 --- a/pkgs/id/src/web/home.rs +++ b/pkgs/id/src/web/home.rs @@ -188,20 +188,32 @@ pub(super) fn render_home( ), ); } else { - for world in worlds { - let name = html_escape(&world.name); - let href = urlencoding::encode(&world.name); - let state = if world.open { - "open" - } else { - "stored; opens on selection" - }; - let _ = std::fmt::Write::write_fmt( - &mut html, - format_args!( - "
  • {name} {state}
  • " - ), - ); + // The explorer renders from the shared directory serializer + // (#812): the same shape the CLI's `id world list --json` prints. + let directory = crate::world_hub::directory_json(worlds, default_world); + if let Some(entries) = directory["worlds"].as_array() { + for entry in entries { + let raw_name = entry["name"].as_str().unwrap_or_default(); + let name = html_escape(raw_name); + let href = urlencoding::encode(raw_name); + let state = if entry["loaded"].as_bool().unwrap_or(false) { + "open" + } else { + "stored; opens on selection" + }; + let presence = entry["presence"].as_u64().unwrap_or(0); + let presence_note = if presence > 0 { + format!(" {presence} active") + } else { + String::new() + }; + let _ = std::fmt::Write::write_fmt( + &mut html, + format_args!( + "
  • {name} {state}{presence_note}
  • " + ), + ); + } } } html.push_str("

    Unavailable worlds are not listed; selecting a listed world reports if it cannot be opened.

    "); diff --git a/pkgs/id/src/world_hub.rs b/pkgs/id/src/world_hub.rs index 36124cf06..ca9a1c816 100644 --- a/pkgs/id/src/world_hub.rs +++ b/pkgs/id/src/world_hub.rs @@ -140,6 +140,10 @@ pub struct WorldInfo { /// directory JSON ([`directory_json`]) names this field `loaded`, for what it /// means; `open` remains the session-wire name for this struct. pub open: bool, + /// How many sessions are live on this world right now (the hub's lease + /// count for the world; 0 for a stored world). This is a count only: the + /// directory never names who is present (#812 shape ruling, 2026-10-29). + pub presence: usize, } /// The JSON shape of a world directory listing: the shared serializer for the @@ -147,11 +151,12 @@ pub struct WorldInfo { /// and the web UI's directory surface is expected to consume the same shape. /// /// The shape is `{ "default": , "worlds": [{ "name": ..., "loaded": ..., -/// "default": ... }] }`. The per-world field for [`WorldInfo::open`] is named -/// `loaded`, because that is what the value means — the world is currently open -/// in memory — and not open-to-join: a world with `loaded: false` is stored and -/// opens when selected. No field here claims presence or joinability; presence -/// counts are a separate, undesigned part of #812. +/// "default": ..., "presence": ... }] }`. The per-world field for +/// [`WorldInfo::open`] is named `loaded`, because that is what the value +/// means — the world is currently open in memory — and not open-to-join: a +/// world with `loaded: false` is stored and opens when selected. `presence` +/// is the count of sessions live on the world right now (#812): a count +/// only, never an identity — who is present is not directory data. #[must_use] pub fn directory_json(worlds: &[WorldInfo], default: &str) -> serde_json::Value { let worlds: Vec = worlds @@ -161,6 +166,7 @@ pub fn directory_json(worlds: &[WorldInfo], default: &str) -> serde_json::Value "name": world.name, "loaded": world.open, "default": world.name == default, + "presence": world.presence, }) }) .collect(); @@ -671,25 +677,32 @@ impl WorldHub { /// Worlds in storage plus any open ones, sorted by name. #[must_use] pub fn list(&self) -> Vec { - let mut names: std::collections::BTreeMap = self + let mut names: std::collections::BTreeMap = self .inner .opener .as_ref() .map(|opener| opener.list()) .unwrap_or_default() .into_iter() - .map(|name| (name, false)) + .map(|name| (name, (false, 0))) .collect(); if let Ok(slots) = self.inner.slots.lock() { for (name, slot) in slots.iter() { if slot.service.get().is_some() { - names.insert(name.clone(), true); + names.insert( + name.clone(), + (true, slot.leases.load(Ordering::Acquire)), + ); } } } names .into_iter() - .map(|(name, open)| WorldInfo { name, open }) + .map(|(name, (open, presence))| WorldInfo { + name, + open, + presence, + }) .collect() } @@ -1262,10 +1275,12 @@ mod tests { WorldInfo { name: "lobby".to_owned(), open: true, + presence: 2, }, WorldInfo { name: "attic".to_owned(), open: false, + presence: 0, }, ]; let value = directory_json(&worlds, "lobby"); @@ -1275,6 +1290,8 @@ mod tests { assert_eq!(list[0]["name"], serde_json::json!("lobby")); assert_eq!(list[0]["loaded"], serde_json::json!(true)); assert_eq!(list[0]["default"], serde_json::json!(true)); + assert_eq!(list[0]["presence"], serde_json::json!(2)); + assert_eq!(list[1]["presence"], serde_json::json!(0)); assert_eq!(list[1]["name"], serde_json::json!("attic")); assert_eq!(list[1]["loaded"], serde_json::json!(false)); assert_eq!(list[1]["default"], serde_json::json!(false)); @@ -1284,6 +1301,7 @@ mod tests { assert!(entry["name"].is_string()); assert!(entry["loaded"].is_boolean()); assert!(entry["default"].is_boolean()); + assert!(entry["presence"].is_u64()); } // An empty directory is a default with no worlds. let value = directory_json(&[], "lobby"); diff --git a/pkgs/id/src/world_net.rs b/pkgs/id/src/world_net.rs index c8e68a1e7..e3e13a9e1 100644 --- a/pkgs/id/src/world_net.rs +++ b/pkgs/id/src/world_net.rs @@ -1036,7 +1036,8 @@ impl WorldClient { .context("host sent no isolation") } - /// List the host's worlds as `(default, [(name, open)])`. + /// List the host's worlds as `(default, [(name, open, presence)])`, + /// where `presence` is the count of sessions live on the world. /// /// # Errors /// @@ -1044,12 +1045,35 @@ impl WorldClient { pub async fn list_worlds( &mut self, admin_token: &str, - ) -> Result<(String, Vec<(String, bool)>)> { + ) -> Result<(String, Vec<(String, bool, usize)>)> { self.send_json(&serde_json::json!({ "type": "list_worlds", "admin_token": admin_token, })) .await?; + self.read_worlds_reply("host refused to list worlds").await + } + + /// Read the host's public world directory (#812) as + /// `(default, [(name, open, presence)])`. No admin token: the + /// directory is public metadata — names, loaded state, and + /// presence counts, never identities. + /// + /// # Errors + /// + /// Fails with the host's message if the directory is refused. + pub async fn directory(&mut self) -> Result<(String, Vec<(String, bool, usize)>)> { + self.send_json(&serde_json::json!({ + "type": "directory", + })) + .await?; + self.read_worlds_reply("host refused the directory").await + } + + async fn read_worlds_reply( + &mut self, + refuse_message: &str, + ) -> Result<(String, Vec<(String, bool, usize)>)> { let reply = self .recv_json() .await? @@ -1057,9 +1081,7 @@ impl WorldClient { if reply["type"] != "worlds" { bail!( "{}", - reply["message"] - .as_str() - .unwrap_or("host refused to list worlds") + reply["message"].as_str().unwrap_or(refuse_message) ); } let default = reply["default"].as_str().unwrap_or_default().to_owned(); @@ -1067,7 +1089,13 @@ impl WorldClient { .as_array() .context("worlds reply had no list")? .iter() - .filter_map(|w| Some((w["name"].as_str()?.to_owned(), w["open"].as_bool()?))) + .filter_map(|w| { + Some(( + w["name"].as_str()?.to_owned(), + w["open"].as_bool()?, + w["presence"].as_u64().unwrap_or(0) as usize, + )) + }) .collect(); Ok((default, worlds)) } diff --git a/pkgs/id/src/world_session/mod.rs b/pkgs/id/src/world_session/mod.rs index c9fedcf8f..952f84d94 100644 --- a/pkgs/id/src/world_session/mod.rs +++ b/pkgs/id/src/world_session/mod.rs @@ -923,6 +923,22 @@ pub async fn run_session_with(hub: &WorldHub, io: &mut I, config: }; // Hub-level frames are about the server, not one world. match &frame { + ClientFrame::Directory {} => { + // The public directory (#812): names, loaded state, default, + // and presence counts only — no admin token, no identities, + // and listing never opens a world. + let worlds = hub.list(); + let _ = send_json( + &unattributed, + io, + &ServerFrame::Worlds { + default: hub.default_world(), + worlds, + }, + ) + .await; + return; + } ClientFrame::ListWorlds { admin_token } => { if admin_token.len() > MAX_ADMIN_TOKEN_BYTES || !hub.admin_ok(admin_token) { let _ = send_error(&unattributed, io, "admin denied").await; @@ -1537,6 +1553,7 @@ async fn run_world_session(service: &WorldService, io: &mut I, fra } ClientFrame::InstallChunk { .. } | ClientFrame::InstallEnd + | ClientFrame::Directory { .. } | ClientFrame::ListWorlds { .. } | ClientFrame::CreateWorld { .. } | ClientFrame::SetIsolation { .. } @@ -2827,6 +2844,7 @@ async fn handle_client_frame( } ClientFrame::Join { .. } | ClientFrame::Invite { .. } + | ClientFrame::Directory { .. } | ClientFrame::ListWorlds { .. } | ClientFrame::CreateWorld { .. } | ClientFrame::SetIsolation { .. } diff --git a/pkgs/id/src/world_session/tests.rs b/pkgs/id/src/world_session/tests.rs index c3ee15c61..158cd24c7 100644 --- a/pkgs/id/src/world_session/tests.rs +++ b/pkgs/id/src/world_session/tests.rs @@ -1588,6 +1588,25 @@ mod tests { assert_eq!(listed["worlds"][0]["name"], "arena"); } + #[tokio::test] + async fn directory_frame_lists_worlds_without_an_admin_token() { + let (hub, _) = memory_hub(crate::world_hub::HubLimits::default()); + let mut admin = Peer::connect_hub(hub.clone(), SessionConfig::default()); + admin + .say(serde_json::json!({"type":"create_world","admin_token":"admin","world":"arena"})) + .await; + assert_eq!(admin.next().await.unwrap()["type"], "world_created"); + + // No token at all: the directory is public metadata (#812). + let mut guest = Peer::connect_hub(hub, SessionConfig::default()); + guest.say(serde_json::json!({"type":"directory"})).await; + let listed = guest.next().await.unwrap(); + assert_eq!(listed["type"], "worlds"); + assert_eq!(listed["default"], "lobby"); + assert_eq!(listed["worlds"][0]["name"], "arena"); + assert_eq!(listed["worlds"][0]["presence"], 0); + } + #[tokio::test] async fn unauthenticated_frames_cannot_create_or_probe_worlds() { let (hub, opener) = memory_hub(crate::world_hub::HubLimits::default()); diff --git a/pkgs/id/src/world_session/types.rs b/pkgs/id/src/world_session/types.rs index 276fc3674..a4491f589 100644 --- a/pkgs/id/src/world_session/types.rs +++ b/pkgs/id/src/world_session/types.rs @@ -138,6 +138,14 @@ pub(crate) enum ClientFrame { #[serde(default)] role: Option, }, + /// Public: list the worlds this server hosts, as the browsable + /// directory (#812). No admin token: the reply carries directory + /// metadata only — world names, loaded state, the default, and a + /// per-world presence *count*. It never names who is present, and + /// it neither creates nor opens any world. The admin `ListWorlds` + /// frame remains for operators; this is the deliberate public + /// boundary for the directory feed. + Directory {}, /// Admin: list the worlds this server hosts. ListWorlds { admin_token: String,