Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions pkgs/id/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Print one JSON object instead of tab-separated lines.
#[arg(long)]
json: bool,
Expand Down
15 changes: 12 additions & 3 deletions pkgs/id/src/commands/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::world_hub::WorldInfo> = 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!(
"{}",
Expand All @@ -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));
}
}
Expand Down
40 changes: 26 additions & 14 deletions pkgs/id/src/web/home.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
"<li><a href=\"{world_route}?world={href}\">{name}</a> <span>{state}</span></li>"
),
);
// 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!(" <span>{presence} active</span>")
} else {
String::new()
};
let _ = std::fmt::Write::write_fmt(
&mut html,
format_args!(
"<li><a href=\"{world_route}?world={href}\">{name}</a> <span>{state}</span>{presence_note}</li>"
),
);
}
}
}
html.push_str("</ul><p>Unavailable worlds are not listed; selecting a listed world reports if it cannot be opened.</p></section>");
Expand Down
36 changes: 27 additions & 9 deletions pkgs/id/src/world_hub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,23 @@ 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
/// directory feed (#812). The CLI's `id world list --json` prints exactly this,
/// and the web UI's directory surface is expected to consume the same shape.
///
/// The shape is `{ "default": <name>, "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<serde_json::Value> = worlds
Expand All @@ -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();
Expand Down Expand Up @@ -671,25 +677,32 @@ impl WorldHub {
/// Worlds in storage plus any open ones, sorted by name.
#[must_use]
pub fn list(&self) -> Vec<WorldInfo> {
let mut names: std::collections::BTreeMap<String, bool> = self
let mut names: std::collections::BTreeMap<String, (bool, usize)> = 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()
}

Expand Down Expand Up @@ -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");
Expand All @@ -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));
Expand All @@ -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");
Expand Down
40 changes: 34 additions & 6 deletions pkgs/id/src/world_net.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1036,38 +1036,66 @@ 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
///
/// Fails with the host's message if the admin token is refused.
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?
.context("host closed without replying")?;
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();
let worlds = reply["worlds"]
.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))
}
Expand Down
18 changes: 18 additions & 0 deletions pkgs/id/src/world_session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,22 @@ pub async fn run_session_with<I: SessionIo>(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;
Expand Down Expand Up @@ -1537,6 +1553,7 @@ async fn run_world_session<I: SessionIo>(service: &WorldService, io: &mut I, fra
}
ClientFrame::InstallChunk { .. }
| ClientFrame::InstallEnd
| ClientFrame::Directory { .. }
| ClientFrame::ListWorlds { .. }
| ClientFrame::CreateWorld { .. }
| ClientFrame::SetIsolation { .. }
Expand Down Expand Up @@ -2827,6 +2844,7 @@ async fn handle_client_frame<I: SessionIo>(
}
ClientFrame::Join { .. }
| ClientFrame::Invite { .. }
| ClientFrame::Directory { .. }
| ClientFrame::ListWorlds { .. }
| ClientFrame::CreateWorld { .. }
| ClientFrame::SetIsolation { .. }
Expand Down
19 changes: 19 additions & 0 deletions pkgs/id/src/world_session/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
8 changes: 8 additions & 0 deletions pkgs/id/src/world_session/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ pub(crate) enum ClientFrame {
#[serde(default)]
role: Option<String>,
},
/// 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,
Expand Down
Loading