A room's owner is not a stranger in it

Room version 12 moved the creator out of the power levels: they are named
in the create event, they outrank every number, and they cannot be
demoted - so `users` is empty in a room whose owner has full control.
moho read `users` alone, concluded the owner had nothing, and refused the
very changes the server was happy to accept:

    setMatrixPowerLevel -> "you cannot give somebody a rank above your own (0)"

in a room that account had made itself.

So power is read as the room actually grants it: the creators out of
`m.room.create` - its sender, and version 12's `additional_creators` -
outrank everything where the version says they do, and nothing changes
for a room of version 11 or earlier, where a creator is whoever holds 100
and can be demoted like anybody else. Inventing an owner there would be
just as wrong in the other direction.

Both shapes of create event are read. A sync carries the whole thing,
sender included; asking the server for one piece of state hands back the
content alone, which has no sender - so the sync's copy is preferred and
the fetch is the fallback rather than the other way round.

The member list ranks them accordingly, and a profile says "Owner"
rather than printing the number, which is now larger than any room could
name.

Closes #134.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-06 23:08:56 -04:00
co-authored by Claude Opus 5
parent fe53f24fd5
commit f027f5388e
4 changed files with 220 additions and 6 deletions
+7 -1
View File
@@ -1836,11 +1836,17 @@ pub async fn profile(state: &AppState, account_id: &str, buffer_id: &str, user_i
// Their standing in this room, from what the sync already carries.
if let Some(room_id) = state.runtime.get_matrix_room(buffer_id) {
if let Some(levels) = state.runtime.get_matrix_power_levels(account_id, &room_id) {
let level = moderation::user_power_level(&levels, user_id);
let creators = state.runtime.matrix_room_creators(account_id, &room_id);
let version = state.runtime.matrix_room_version(account_id, &room_id).unwrap_or_default();
let level = moderation::effective_power(&levels, user_id, &creators, &version);
// Matrix's own conventional names for the two ranks anybody
// recognises. A room can set any number, so a level that is
// neither is reported as itself rather than rounded to a word.
let role = match level {
// The room's own creator, who from version 12 outranks every
// number rather than holding a large one - so it is named
// rather than printed, which would be a wall of digits.
moderation::CREATOR_POWER => Some("Owner".to_string()),
100 => Some("Admin".to_string()),
50 => Some("Moderator".to_string()),
0 => None,
+176 -4
View File
@@ -26,10 +26,79 @@ fn threshold(pl: &Value, key: &str, default: i64) -> i64 {
pl[key].as_i64().unwrap_or(default)
}
/// What the power levels say about somebody, taken literally.
///
/// Not the whole answer since room version 12 - see `effective_power`.
pub fn user_power_level(pl: &Value, user_id: &str) -> i64 {
pl["users"][user_id].as_i64().unwrap_or_else(|| threshold(pl, "users_default", 0))
}
/// Whether this room's version gives its creators power of their own.
///
/// Room version 12 moved the creator out of the power levels entirely: they
/// are named in the create event, they outrank every number, and they cannot
/// be demoted - so `users` is empty in a room whose owner has full control,
/// which reads as "nobody has any power" to anything that only looks there.
pub fn creators_outrank_everybody(room_version: &str) -> bool {
room_version.trim().parse::<u32>().map(|v| v >= 12).unwrap_or(false)
}
/// The rank a creator holds: above every number a room can name.
///
/// `i64::MAX` rather than 101 or 150, because the point of it is that no
/// power level can be set high enough to match it.
pub const CREATOR_POWER: i64 = i64::MAX;
/// What somebody can actually do here, creators included.
///
/// The power levels are the whole story in a room of version 11 or earlier,
/// where a creator is simply whoever was given 100 at the start and can be
/// demoted like anybody else. From version 12 the creator is named in the
/// create event instead, and outranks everything - so a room created by this
/// account with an empty `users` map is a room this account owns, not one
/// where it has no power at all.
pub fn effective_power(pl: &Value, user_id: &str, creators: &[String], room_version: &str) -> i64 {
if creators_outrank_everybody(room_version) && creators.iter().any(|c| c == user_id) {
return CREATOR_POWER;
}
user_power_level(pl, user_id)
}
/// Who a room's creators are, out of its `m.room.create` event.
///
/// The sender made it; `additional_creators` is version 12's way of saying
/// somebody else owns it equally. Both outrank everybody.
/// Reads either shape a create event arrives in.
///
/// A sync carries the whole event - sender, content and all - while asking
/// the server for one piece of state hands back the *content* alone. The
/// creator is the sender, which the second shape does not have, so both are
/// looked at and neither is assumed.
pub fn creators_of(create_event: &Value) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut add = |id: Option<&str>| {
if let Some(id) = id.filter(|id| !id.is_empty()) {
if !out.iter().any(|c| c == id) {
out.push(id.to_string());
}
}
};
add(create_event["sender"].as_str());
// Rooms before version 11 named the creator in the content; from 11 the
// sender is it, and from 12 there may be more than one.
add(create_event["content"]["creator"].as_str());
add(create_event["creator"].as_str());
for extra in create_event["content"]["additional_creators"]
.as_array()
.or_else(|| create_event["additional_creators"].as_array())
.into_iter()
.flatten()
{
add(extra.as_str());
}
out
}
fn message_send_threshold(pl: &Value) -> i64 {
pl["events"]["m.room.message"].as_i64().unwrap_or_else(|| threshold(pl, "events_default", 0))
}
@@ -47,8 +116,8 @@ fn power_levels_change_threshold(pl: &Value) -> i64 {
/// *target's*, which this can't check without knowing who - the mutation
/// itself still enforces that server-side regardless, this is only ever
/// used to decide whether to show the option at all.
pub fn permissions_json(pl: &Value, own_user_id: &str) -> Value {
let own_level = user_power_level(pl, own_user_id);
pub fn permissions_json(pl: &Value, own_user_id: &str, creators: &[String], room_version: &str) -> Value {
let own_level = effective_power(pl, own_user_id, creators, room_version);
serde_json::json!({
"canRedactOthers": own_level >= threshold(pl, "redact", 50),
"canKick": own_level >= threshold(pl, "kick", 50),
@@ -64,7 +133,9 @@ pub fn permissions_for_buffer(state: &AppState, account_id: &str, buffer_id: &st
let room_id = state.runtime.get_matrix_room(buffer_id).context("no known room id for this buffer")?;
let account = state.accounts.get_matrix(account_id).context("no such Matrix account")?;
let pl = state.runtime.get_matrix_power_levels(account_id, &room_id).unwrap_or_else(|| serde_json::json!({}));
Ok(permissions_json(&pl, &account.user_id))
let creators = state.runtime.matrix_room_creators(account_id, &room_id);
let version = state.runtime.matrix_room_version(account_id, &room_id).unwrap_or_default();
Ok(permissions_json(&pl, &account.user_id, &creators, &version))
}
async fn homeserver_token_room(state: &AppState, account_id: &str, buffer_id: &str) -> Result<(String, String, String)> {
@@ -149,7 +220,31 @@ pub async fn set_power_level(
// The account id is "matrix:@user:server"; the power levels are keyed by
// the Matrix id itself.
let own_mxid = account_id.strip_prefix("matrix:").unwrap_or(account_id);
let own = user_power_level(&pl, own_mxid);
// Asked of the room rather than of the cache, and including who made it:
// from room version 12 a creator holds no entry in `users` at all, so
// reading power levels alone says the room's own owner has none - which
// refused, here, the very changes the server was happy to accept.
//
// From the sync's own copy first, because that is the whole event and
// carries the sender - the one field that says who made a version 12
// room, and the one field asking the server for a single piece of state
// does not return.
let cached_creators = state.runtime.matrix_room_creators(account_id, &room_id);
let cached_version = state.runtime.matrix_room_version(account_id, &room_id);
let (creators, version) = match (cached_creators.is_empty(), cached_version) {
(false, Some(version)) => (cached_creators, version),
_ => {
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/m.room.create/");
let create = http::get_json(&create_url, &access_token).await.unwrap_or(Value::Null);
let version = create["room_version"]
.as_str()
.or_else(|| create["content"]["room_version"].as_str())
.unwrap_or_default()
.to_string();
(creators_of(&create), version)
}
};
let own = effective_power(&pl, own_mxid, &creators, &version);
if level > own {
anyhow::bail!("you cannot give somebody a rank above your own ({own})");
}
@@ -178,3 +273,80 @@ pub async fn mute_member(state: &AppState, account_id: &str, buffer_id: &str, ta
http::put_json(&state_url, &access_token, pl).await.context("updating power levels")?;
Ok(())
}
#[cfg(test)]
mod creator_tests {
use super::*;
use serde_json::json;
fn create(version: &str, sender: &str, extra: Vec<&str>) -> Value {
json!({
"type": "m.room.create",
"sender": sender,
"content": {
"room_version": version,
"additional_creators": extra,
}
})
}
#[test]
fn a_version_12_room_has_an_owner_the_power_levels_never_mention() {
let create = create("12", "@me:example.org", vec![]);
let creators = creators_of(&create);
// Exactly what a freshly made v12 room looks like: nobody in `users`.
let levels = json!({ "users": {}, "users_default": 0, "state_default": 50 });
assert_eq!(effective_power(&levels, "@me:example.org", &creators, "12"), CREATOR_POWER);
// And it is above anything a room could name.
assert!(effective_power(&levels, "@me:example.org", &creators, "12") > 100);
// Somebody else in that same room is still nobody.
assert_eq!(effective_power(&levels, "@them:example.org", &creators, "12"), 0);
}
#[test]
fn an_older_room_says_what_it_always_said() {
let create = create("10", "@me:example.org", vec![]);
let creators = creators_of(&create);
// Before version 12 the creator is whoever holds 100, and can be
// demoted like anybody - so an empty users map really does mean the
// room has no admin left, and inventing one would be wrong.
let levels = json!({ "users": {}, "users_default": 0 });
assert_eq!(effective_power(&levels, "@me:example.org", &creators, "10"), 0);
let with_admin = json!({ "users": { "@me:example.org": 100 } });
assert_eq!(effective_power(&with_admin, "@me:example.org", &creators, "10"), 100);
}
#[test]
fn a_room_can_be_owned_by_more_than_one_person() {
let create = create("12", "@me:example.org", vec!["@partner:example.org"]);
let creators = creators_of(&create);
assert_eq!(creators.len(), 2);
let levels = json!({ "users": {} });
assert_eq!(effective_power(&levels, "@partner:example.org", &creators, "12"), CREATOR_POWER);
}
#[test]
fn the_permission_gates_follow() {
let create = create("12", "@me:example.org", vec![]);
let creators = creators_of(&create);
let levels = json!({ "users": {}, "state_default": 50, "kick": 50, "ban": 50, "redact": 50 });
let perms = permissions_json(&levels, "@me:example.org", &creators, "12");
// The bug this fixes: the owner of a room being offered nothing.
assert_eq!(perms["canKick"], true);
assert_eq!(perms["canBan"], true);
assert_eq!(perms["canMute"], true);
assert_eq!(perms["canRedactOthers"], true);
let theirs = permissions_json(&levels, "@them:example.org", &creators, "12");
assert_eq!(theirs["canKick"], false);
}
#[test]
fn only_versions_that_have_creator_power_get_it() {
assert!(creators_outrank_everybody("12"));
assert!(creators_outrank_everybody("13"));
assert!(!creators_outrank_everybody("11"));
assert!(!creators_outrank_everybody("10"));
// An unstable room version is not one this rule is known to hold in.
assert!(!creators_outrank_everybody("org.matrix.msc3757.12"));
}
}
+13 -1
View File
@@ -62,6 +62,13 @@ pub async fn process_state_events(state: &AppState, account_id: &str, room_id: &
if let Some(version) = event["content"]["room_version"].as_str() {
state.runtime.set_matrix_room_version(account_id, room_id, version);
}
// And who owns it, which from room version 12 is recorded
// here rather than in the power levels.
state.runtime.set_matrix_room_creators(
account_id,
room_id,
crate::backend::matrix::moderation::creators_of(event),
);
}
// Who is in this room's call. A state event per person, and the
// one thing a client needs to know before it can join one: a
@@ -167,6 +174,11 @@ pub fn emit_matrix_presence(state: &AppState, account_id: &str, room_id: &str, o
let members = state.runtime.get_matrix_room_members(account_id, room_id);
let power_levels = state.runtime.get_matrix_power_levels(account_id, room_id).unwrap_or_else(|| serde_json::json!({}));
// Who made the room, and under which version - because from version 12
// the owner holds no power-level entry at all and would otherwise be
// drawn in the member list as an ordinary member.
let creators = state.runtime.matrix_room_creators(account_id, room_id);
let room_version = state.runtime.matrix_room_version(account_id, room_id).unwrap_or_default();
let member_list: Vec<Value> = members
.iter()
@@ -187,7 +199,7 @@ pub fn emit_matrix_presence(state: &AppState, account_id: &str, room_id: &str, o
// online and offline at all, rather than having to know which
// protocols report presence.
"status": if online { "online" } else { "offline" },
"powerLevel": moderation::user_power_level(&power_levels, user_id),
"powerLevel": moderation::effective_power(&power_levels, user_id, &creators, &room_version),
})
})
.collect();
+24
View File
@@ -432,6 +432,12 @@ pub struct Runtime {
/// refuses that line. Asking for history from a server that never granted
/// it is a command it will answer with an error in the server tab.
irc_caps: Mutex<HashMap<String, std::collections::HashSet<String>>>,
/// Who made each room, by account and room.
///
/// From room version 12 this is where a room's owner is recorded rather
/// than in the power levels, so without it the client cannot tell an
/// owner from a stranger - see `moderation::effective_power`.
matrix_room_creators: Mutex<HashMap<String, Vec<String>>>,
/// Which version each room is, by account and room - see
/// `calls::membership_state_key` for the one decision it feeds.
matrix_room_versions: Mutex<HashMap<String, String>>,
@@ -847,6 +853,7 @@ impl Runtime {
matrix_stickers: Mutex::new(HashMap::new()),
matrix_call_members: Mutex::new(HashMap::new()),
matrix_room_versions: Mutex::new(HashMap::new()),
matrix_room_creators: Mutex::new(HashMap::new()),
matrix_rooms: Mutex::new(HashMap::new()),
matrix_room_names: Mutex::new(HashMap::new()),
matrix_space_parents: Mutex::new(HashMap::new()),
@@ -2939,6 +2946,23 @@ impl Runtime {
self.irc_caps.lock().unwrap().remove(account_id);
}
/// Who made this room, as its create event said.
pub fn matrix_room_creators(&self, account_id: &str, room_id: &str) -> Vec<String> {
self.matrix_room_creators
.lock()
.unwrap()
.get(&format!("{account_id}|{room_id}"))
.cloned()
.unwrap_or_default()
}
pub fn set_matrix_room_creators(&self, account_id: &str, room_id: &str, creators: Vec<String>) {
if creators.is_empty() {
return;
}
self.matrix_room_creators.lock().unwrap().insert(format!("{account_id}|{room_id}"), creators);
}
pub fn matrix_room_version(&self, account_id: &str, room_id: &str) -> Option<String> {
self.matrix_room_versions.lock().unwrap().get(&format!("{account_id}|{room_id}")).cloned()
}