From d043b1ed4e5fe26f32d72d5081f2d0fddca5698b Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 5 Jun 2026 12:17:46 +0200 Subject: [PATCH] fix: rediscover hive Space by hardcoded name, not a room alias Per mara's review: drop the #hive: room alias (special chars) and rediscover the canonical Space by its hardcoded plain name instead. ensure_hive_space dedup is now: 1. room-id file present -> reuse it 2. else scan the admin's joined rooms for the m.space named HIVE_SPACE_NAME ('hive') and adopt the first match (re-persisting the file) -> recovers the existing space after a state wipe instead of creating a duplicate 3. else createRoom (plain name, no alias) find_space_by_name walks /joined_rooms and checks each room's m.room.create type == m.space and m.room.name == 'hive'. No alias, no special-char anchor. server_name is no longer needed by ensure_hive_space (dropped the param). --- hive-c0re/src/matrix.rs | 142 ++++++++++++++++++++++------------------ 1 file changed, 78 insertions(+), 64 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 78739b6a..0f7b54d1 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -45,11 +45,11 @@ const PASSWORD_BYTES: usize = 32; /// first registered user automatically. Not an agent; has no state dir. pub const HIVE_ADMIN_LOCALPART: &str = "hive"; -/// Canonical room-alias localpart for the hive Space (`#hive:`). -/// The Space is anchored to this alias at creation so it stays discoverable -/// even if the persisted room-id file is lost (a full state wipe), preventing -/// duplicate spaces from being created on the next sweep. -pub const HIVE_SPACE_ALIAS_LOCALPART: &str = "hive"; +/// Display name of the hive Space. Plain text, no special characters, so +/// the Space stays rediscoverable by name (no room alias needed) even when +/// the persisted room-id file is lost — preventing duplicate spaces from +/// being created on the next sweep. +pub const HIVE_SPACE_NAME: &str = "hive"; /// Host path for the hive admin matrix access token. Outside every /// purgeable path — not deleted by `destroy --purge` on any agent. @@ -964,95 +964,109 @@ fn persist_space_room_id(room_id: &str) -> Result<()> { Ok(()) } -/// Resolve a room alias (`#name:server`) to its room id via the directory -/// API. Returns `None` on any non-success (e.g. 404 = alias unknown). -async fn resolve_room_alias( - client: &reqwest::Client, - admin_token: &str, - server_name: &str, -) -> Option { - let encoded = format!("%23{HIVE_SPACE_ALIAS_LOCALPART}%3A{server_name}"); - let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded}"); - let resp = client.get(&url).bearer_auth(admin_token).send().await.ok()?; - if !resp.status().is_success() { - return None; - } - let json = resp.json::().await.ok()?; - json["room_id"].as_str().map(ToOwned::to_owned) -} - -/// Best-effort: point the canonical `#hive:server` alias at `room_id`. -/// Idempotent — a 409 (alias already maps here) is treated as success; any -/// other failure is logged at debug and ignored (the room-id file remains -/// the primary anchor). -async fn ensure_room_alias( - client: &reqwest::Client, - admin_token: &str, - server_name: &str, - room_id: &str, -) { - let encoded = format!("%23{HIVE_SPACE_ALIAS_LOCALPART}%3A{server_name}"); - let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded}"); - let resp = client - .put(&url) +/// Scan the admin account's joined rooms for the canonical hive Space: the +/// `m.space` whose name is [`HIVE_SPACE_NAME`]. Returns the first match +/// (deterministic per the homeserver's joined-rooms order) so a lost +/// room-id file recovers the existing space instead of spawning a +/// duplicate. `None` if the homeserver is unreachable or no match exists. +/// +/// Name-based (not alias-based) rediscovery keeps the Space free of any +/// special-char room alias — the hardcoded plain name is the anchor. +async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option { + let joined_url = format!("{MATRIX_HTTP}/_matrix/client/v3/joined_rooms"); + let joined: serde_json::Value = client + .get(&joined_url) .bearer_auth(admin_token) - .json(&serde_json::json!({ "room_id": room_id })) .send() - .await; - match resp { - Ok(r) if r.status().is_success() || r.status() == StatusCode::CONFLICT => {} - Ok(r) => tracing::debug!(status = %r.status(), %room_id, "matrix: set hive space alias non-fatal"), - Err(e) => tracing::debug!(error = ?e, "matrix: set hive space alias failed (non-fatal)"), + .await + .ok()? + .json() + .await + .ok()?; + let rooms = joined["joined_rooms"].as_array()?; + for room in rooms { + let Some(room_id) = room.as_str() else { + continue; + }; + let encoded = encode_room_id_for_url(room_id); + // Must be an m.space (m.room.create `type`). + let create_url = + format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); + let is_space = match client + .get(&create_url) + .bearer_auth(admin_token) + .send() + .await + { + Ok(r) if r.status().is_success() => r + .json::() + .await + .ok() + .is_some_and(|c| c["type"].as_str() == Some("m.space")), + _ => false, + }; + if !is_space { + continue; + } + // …and named HIVE_SPACE_NAME (m.room.name `name`). + let name_url = + format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); + let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { + Ok(r) if r.status().is_success() => r + .json::() + .await + .ok() + .is_some_and(|n| n["name"].as_str() == Some(HIVE_SPACE_NAME)), + _ => false, + }; + if name_matches { + return Some(room_id.to_owned()); + } } + None } /// Create (or recover) the hive Matrix Space and persist its room ID to /// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by -/// `@hive`, anchored to the canonical alias `#hive:`. +/// `@hive`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias). /// /// Dedup strategy (single canonical space): -/// 1. If the room-id file exists, reuse it and heal the alias mapping -/// best-effort (so the alias keeps pointing at the canonical room). -/// 2. Otherwise, resolve `#hive:` — if it exists, adopt it and -/// re-persist the file. This recovers the existing space after a state -/// wipe instead of creating a duplicate. -/// 3. Only if neither yields a room do we `createRoom` (with the alias). +/// 1. If the room-id file exists, reuse it. +/// 2. Otherwise, rediscover by scanning the admin's joined rooms for the +/// `m.space` named [`HIVE_SPACE_NAME`] and adopt it (re-persisting the +/// file). This recovers the existing space after a state wipe instead +/// of creating a duplicate. +/// 3. Only if neither yields a room do we `createRoom`. /// /// # Errors /// /// Returns an error if the homeserver is unreachable, `createRoom` fails, /// or the room-ID file cannot be written. -pub async fn ensure_hive_space( - client: &reqwest::Client, - admin_token: &str, - server_name: &str, -) -> Result { - // 1. Stored room id wins. Heal the alias so it stays discoverable. +pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result { + // 1. Stored room id wins (fast path). if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) { let trimmed = existing.trim().to_owned(); if !trimmed.is_empty() { - ensure_room_alias(client, admin_token, server_name, &trimmed).await; tracing::debug!(room_id = %trimmed, "matrix: hive space already provisioned"); return Ok(trimmed); } } - // 2. No stored id — recover the existing space via its canonical alias + // 2. No stored id — rediscover the existing space by its hardcoded name // before creating a new one (prevents duplicate spaces after a wipe). - if let Some(room_id) = resolve_room_alias(client, admin_token, server_name).await { + if let Some(room_id) = find_space_by_name(client, admin_token).await { persist_space_room_id(&room_id)?; - tracing::info!(%room_id, "matrix: recovered hive space via #hive alias"); + tracing::info!(%room_id, "matrix: recovered hive space by name"); return Ok(room_id); } - // 3. Create the space, anchored to the canonical alias. + // 3. Create the space (plain hardcoded name, no alias). let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); let body = serde_json::json!({ - "name": "hive", + "name": HIVE_SPACE_NAME, "creation_content": { "type": "m.space" }, "preset": "private_chat", "visibility": "private", - "room_alias_name": HIVE_SPACE_ALIAS_LOCALPART, }); let resp = client .post(&url) @@ -1176,7 +1190,7 @@ pub async fn ensure_all() { return; } }; - // server_name first — both the space alias and the agent invites need it. + // server_name first — the agent invites need it (fully-qualified user ids). let server_name = match discover_server_name(&client).await { Ok(s) => s, Err(e) => { @@ -1184,7 +1198,7 @@ pub async fn ensure_all() { return; } }; - let room_id = match ensure_hive_space(&client, &admin_token, &server_name).await { + let room_id = match ensure_hive_space(&client, &admin_token).await { Ok(id) => id, Err(e) => { tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");