Skip to main content

mail4agent_server/
spaces.rs

1//! Domains with sub-rooms, the Matrix way: a domain is a Space (a room whose
2//! `m.room.create` carries `type: m.space`), a sub-room is linked by an
3//! `m.space.child` state event in the space (state_key = child room id,
4//! content `via` = servers) and, optionally, a back-link `m.space.parent`
5//! in the child. Everything here is derived from ordinary state events, so no
6//! extra table exists and federation can later carry it unchanged.
7//!
8//! A space holds no chat. Child rooms keep their own store: DM/group stay in
9//! the closed ciphertext pump, channels/forum in the public plaintext store.
10//! Hierarchy is metadata only and is never purged.
11
12use rusqlite::Connection;
13use serde_json::{json, Value};
14
15use crate::error::MatrixError;
16use crate::store::{self, HistoryVisibility, JoinRule, Membership};
17
18/// `m.room.create` `type` of a domain.
19pub const SPACE_TYPE: &str = "m.space";
20
21/// Rejects malformed `m.space.child` / `m.space.parent` content. Empty `{}`
22/// is a removal and always valid; otherwise `via` must be a non-empty list of
23/// strings, `order` (if any) a short string of printable ASCII, `suggested`
24/// (if any) a bool.
25pub fn validate_space_state(event_type: &str, content: &Value) -> Result<(), MatrixError> {
26    if event_type != "m.space.child" && event_type != "m.space.parent" {
27        return Ok(());
28    }
29    let Some(obj) = content.as_object() else {
30        return Err(MatrixError::invalid_param("space link content must be an object"));
31    };
32    if obj.is_empty() {
33        return Ok(());
34    }
35    let via_ok = obj.get("via").and_then(Value::as_array).is_some_and(|a| !a.is_empty() && a.iter().all(|v| v.as_str().is_some_and(|s| !s.is_empty())));
36    if !via_ok {
37        return Err(MatrixError::invalid_param("via must be a non-empty list of server names"));
38    }
39    if let Some(order) = obj.get("order") {
40        let ok = order.as_str().is_some_and(|s| s.len() <= 50 && s.bytes().all(|b| (0x20..=0x7e).contains(&b)));
41        if !ok {
42            return Err(MatrixError::invalid_param("order must be a string of at most 50 printable ASCII characters"));
43        }
44    }
45    if obj.get("suggested").is_some_and(|v| !v.is_boolean()) {
46        return Err(MatrixError::invalid_param("suggested must be a boolean"));
47    }
48    Ok(())
49}
50
51pub(crate) fn state_content(conn: &Connection, room_id: &str, event_type: &str, key: &str) -> Result<Option<Value>, MatrixError> {
52    Ok(store::current_state_event(conn, room_id, event_type, key)?.and_then(|e| serde_json::from_str(&e.content).ok()))
53}
54
55/// `type` from the room's `m.room.create`.
56pub fn room_type(conn: &Connection, room_id: &str) -> Result<Option<String>, MatrixError> {
57    Ok(state_content(conn, room_id, "m.room.create", "")?.and_then(|c| c.get("type").and_then(Value::as_str).map(str::to_string)))
58}
59
60/// Whether `m.room.join_rules` is `restricted` (or knock_restricted) and one
61/// of its `allow` spaces has `user_id` joined. Join rule for restricted rooms
62/// is read from state because the `rooms.join_rule` column only knows
63/// invite/public.
64pub fn restricted_allows(conn: &Connection, room_id: &str, user_id: i64) -> Result<bool, MatrixError> {
65    let Some(rules) = state_content(conn, room_id, "m.room.join_rules", "")? else { return Ok(false) };
66    if !matches!(rules.get("join_rule").and_then(Value::as_str), Some("restricted" | "knock_restricted")) {
67        return Ok(false);
68    }
69    for allow in rules.get("allow").and_then(Value::as_array).into_iter().flatten() {
70        if allow.get("type").and_then(Value::as_str) != Some("m.room_membership") {
71            continue;
72        }
73        if let Some(space) = allow.get("room_id").and_then(Value::as_str) {
74            if store::room_member(conn, space, user_id)?.is_some_and(|m| m.membership == Membership::Join) {
75                return Ok(true);
76            }
77        }
78    }
79    Ok(false)
80}
81
82pub(crate) fn join_rule_wire(conn: &Connection, room: &store::Room) -> Result<String, MatrixError> {
83    Ok(state_content(conn, &room.id, "m.room.join_rules", "")?
84        .and_then(|c| c.get("join_rule").and_then(Value::as_str).map(str::to_string))
85        .unwrap_or_else(|| match room.join_rule {
86            JoinRule::Public => "public".to_string(),
87            JoinRule::Invite => "invite".to_string(),
88        }))
89}
90
91pub(crate) fn caller_may_see(conn: &Connection, room: &store::Room, user_id: i64) -> Result<bool, MatrixError> {
92    if store::room_member(conn, &room.id, user_id)?.is_some_and(|m| matches!(m.membership, Membership::Join | Membership::Invite)) {
93        return Ok(true);
94    }
95    if room.join_rule == JoinRule::Public && room.history_visibility == HistoryVisibility::WorldReadable {
96        return Ok(true);
97    }
98    if room.join_rule == JoinRule::Public {
99        return Ok(true);
100    }
101    restricted_allows(conn, &room.id, user_id)
102}
103
104pub(crate) fn joined_count(conn: &Connection, room_id: &str) -> Result<i64, MatrixError> {
105    let mut n = 0;
106    for event in store::current_state_all(conn, room_id)? {
107        if event.event_type == "m.room.member" {
108            let c: Value = serde_json::from_str(&event.content)?;
109            if c.get("membership").and_then(Value::as_str) == Some("join") {
110                n += 1;
111            }
112        }
113    }
114    Ok(n)
115}
116
117/// Valid children of `room_id`, ordered per the spec: `order` ascending
118/// (rooms without one last), then origin_server_ts, then room id.
119fn children(conn: &Connection, room_id: &str, suggested_only: bool) -> Result<Vec<(String, Value, i64, String)>, MatrixError> {
120    let mut out = Vec::new();
121    for event in store::current_state_all(conn, room_id)? {
122        if event.event_type != "m.space.child" {
123            continue;
124        }
125        let content: Value = serde_json::from_str(&event.content)?;
126        let via_ok = content.get("via").and_then(Value::as_array).is_some_and(|a| !a.is_empty());
127        if !via_ok {
128            continue;
129        }
130        if suggested_only && content.get("suggested").and_then(Value::as_bool) != Some(true) {
131            continue;
132        }
133        let sender = store::mxid_of(conn, event.sender_user_id)?.unwrap_or_default();
134        out.push((event.state_key.clone().unwrap_or_default(), json!({
135            "type": "m.space.child",
136            "state_key": event.state_key,
137            "content": content,
138            "sender": sender,
139            "origin_server_ts": event.origin_server_ts,
140        }), event.origin_server_ts, content.get("order").and_then(Value::as_str).unwrap_or("\u{10ffff}").to_string()));
141    }
142    out.sort_by(|a, b| a.3.cmp(&b.3).then(a.2.cmp(&b.2)).then(a.0.cmp(&b.0)));
143    Ok(out)
144}
145
146/// `GET /rooms/{roomId}/hierarchy` (CS API v1). Breadth-first from `root`,
147/// each room once, only rooms the caller may see, `max_depth` levels below the
148/// root. `from` is an opaque offset token (the page cut is deterministic for a
149/// stable tree). `Ok(None)` when the root itself is not visible.
150pub fn hierarchy(
151    conn: &Connection,
152    caller_user_id: i64,
153    root: &str,
154    suggested_only: bool,
155    limit: usize,
156    max_depth: Option<usize>,
157    from: usize,
158) -> Result<Option<Value>, MatrixError> {
159    let Some(root_room) = store::get_room(conn, root)? else { return Ok(None) };
160    if !caller_may_see(conn, &root_room, caller_user_id)? {
161        return Ok(None);
162    }
163    let mut seen = std::collections::HashSet::new();
164    let mut queue = std::collections::VecDeque::new();
165    queue.push_back((root.to_string(), 0usize));
166    seen.insert(root.to_string());
167    let mut rooms = Vec::new();
168    while let Some((room_id, depth)) = queue.pop_front() {
169        let Some(room) = store::get_room(conn, &room_id)? else { continue };
170        if !caller_may_see(conn, &room, caller_user_id)? {
171            continue;
172        }
173        let kids = children(conn, &room_id, suggested_only)?;
174        let is_space = room_type(conn, &room_id)?.as_deref() == Some(SPACE_TYPE);
175        let name = state_content(conn, &room_id, "m.room.name", "")?.and_then(|c| c.get("name").and_then(Value::as_str).map(str::to_string));
176        let topic = state_content(conn, &room_id, "m.room.topic", "")?.and_then(|c| c.get("topic").and_then(Value::as_str).map(str::to_string));
177        let mut entry = json!({
178            "room_id": room_id,
179            "num_joined_members": joined_count(conn, &room_id)?,
180            "world_readable": room.history_visibility == HistoryVisibility::WorldReadable,
181            "guest_can_join": false,
182            "join_rule": join_rule_wire(conn, &room)?,
183            "children_state": kids.iter().map(|k| k.1.clone()).collect::<Vec<_>>(),
184        });
185        let obj = entry.as_object_mut().expect("object");
186        if let Some(n) = name { obj.insert("name".into(), json!(n)); }
187        if let Some(t) = topic { obj.insert("topic".into(), json!(t)); }
188        if is_space { obj.insert("room_type".into(), json!(SPACE_TYPE)); }
189        rooms.push(entry);
190        if max_depth.is_none_or(|m| depth < m) {
191            for (child, ..) in &kids {
192                if seen.insert(child.clone()) {
193                    queue.push_back((child.clone(), depth + 1));
194                }
195            }
196        }
197    }
198    let total = rooms.len();
199    let page: Vec<Value> = rooms.into_iter().skip(from).take(limit).collect();
200    let mut resp = json!({ "rooms": page });
201    if from + limit < total {
202        resp["next_batch"] = json!(format!("h{}", from + limit));
203    }
204    Ok(Some(resp))
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn space_link_validation() {
213        assert!(validate_space_state("m.space.child", &json!({})).is_ok());
214        assert!(validate_space_state("m.space.child", &json!({"via": ["a.example"], "order": "01", "suggested": true})).is_ok());
215        assert!(validate_space_state("m.space.child", &json!({"via": []})).is_err());
216        assert!(validate_space_state("m.space.parent", &json!({"nope": 1})).is_err());
217        assert!(validate_space_state("m.space.child", &json!({"via": ["a"], "order": "é"})).is_err());
218        assert!(validate_space_state("m.room.name", &json!("anything")).is_ok());
219    }
220
221    use crate::rooms::{apply_create_room, apply_put_state, decide_and_apply_join, RoomCreate, RoomCreation};
222
223    const NOW: &str = "2026-10-09T00:00:00+00:00";
224
225    fn conn() -> Connection {
226        let c = Connection::open_in_memory().unwrap();
227        store::create_matrix_schema(&c).unwrap();
228        store::ensure_matrix_user(&c, 1, "alice000000000000000000000000001", NOW).unwrap();
229        store::ensure_matrix_user(&c, 2, "bob00000000000000000000000000002", NOW).unwrap();
230        c
231    }
232
233    fn make(c: &mut Connection, public: bool, name: &str, room_type: Option<&str>) -> String {
234        let mxid = store::mxid_of(c, 1).unwrap().unwrap();
235        match apply_create_room(
236            c,
237            RoomCreate {
238                creator_user_id: 1,
239                creator_mxid: &mxid,
240                creator_displayname: "alice",
241                is_direct: false,
242                invitees: &[],
243                visibility_public: public,
244                power_level_content_override: None,
245                name: Some(name),
246                topic: None,
247                room_type,
248                predecessor: None,
249            },
250            NOW,
251            1,
252        )
253        .unwrap()
254        {
255            RoomCreation::Created { room_id, .. } => room_id,
256            RoomCreation::Reused(_) => unreachable!(),
257        }
258    }
259
260    fn put(c: &mut Connection, room: &str, ty: &str, key: &str, content: Value) {
261        let mxid = store::mxid_of(c, 1).unwrap().unwrap();
262        apply_put_state(c, room, 1, &mxid, ty, key, &content.to_string(), NOW, 2).unwrap();
263    }
264
265    #[test]
266    fn domain_with_subrooms_hierarchy_and_visibility() {
267        let mut c = conn();
268        let domain = make(&mut c, true, "acme", Some(SPACE_TYPE));
269        let chan = make(&mut c, true, "announce", None);
270        let grp = make(&mut c, false, "ops", None);
271        assert_eq!(room_type(&c, &domain).unwrap().as_deref(), Some(SPACE_TYPE));
272        assert_eq!(room_type(&c, &chan).unwrap(), None);
273        put(&mut c, &domain, "m.space.child", &chan, json!({"via": ["localhost"], "order": "a"}));
274        put(&mut c, &domain, "m.space.child", &grp, json!({"via": ["localhost"], "order": "b", "suggested": true}));
275        put(&mut c, &chan, "m.space.parent", &domain, json!({"via": ["localhost"], "canonical": true}));
276        // Alice (member of all): sees all three, ordered, space typed.
277        let h = hierarchy(&c, 1, &domain, false, 50, None, 0).unwrap().unwrap();
278        let ids: Vec<&str> = h["rooms"].as_array().unwrap().iter().map(|r| r["room_id"].as_str().unwrap()).collect();
279        assert_eq!(ids, vec![domain.as_str(), chan.as_str(), grp.as_str()]);
280        assert_eq!(h["rooms"][0]["room_type"], SPACE_TYPE);
281        assert_eq!(h["rooms"][0]["children_state"].as_array().unwrap().len(), 2);
282        // Bob (nobody): sees the public domain and public channel, not the closed group.
283        let h = hierarchy(&c, 2, &domain, false, 50, None, 0).unwrap().unwrap();
284        let ids: Vec<&str> = h["rooms"].as_array().unwrap().iter().map(|r| r["room_id"].as_str().unwrap()).collect();
285        assert_eq!(ids, vec![domain.as_str(), chan.as_str()]);
286        // suggested_only, max_depth 0, pagination.
287        assert_eq!(hierarchy(&c, 1, &domain, true, 50, None, 0).unwrap().unwrap()["rooms"].as_array().unwrap().len(), 2);
288        assert_eq!(hierarchy(&c, 1, &domain, false, 50, Some(0), 0).unwrap().unwrap()["rooms"].as_array().unwrap().len(), 1);
289        let p = hierarchy(&c, 1, &domain, false, 2, None, 0).unwrap().unwrap();
290        assert_eq!(p["next_batch"], "h2");
291        assert_eq!(hierarchy(&c, 1, &domain, false, 2, None, 2).unwrap().unwrap()["rooms"].as_array().unwrap().len(), 1);
292        // A removed child (empty content) drops out.
293        put(&mut c, &domain, "m.space.child", &chan, json!({}));
294        assert_eq!(hierarchy(&c, 1, &domain, false, 50, None, 0).unwrap().unwrap()["rooms"].as_array().unwrap().len(), 2);
295    }
296
297    #[test]
298    fn restricted_room_admits_space_members_only() {
299        let mut c = conn();
300        let domain = make(&mut c, true, "acme", Some(SPACE_TYPE));
301        let grp = make(&mut c, false, "ops", None);
302        put(&mut c, &grp, "m.room.join_rules", "", json!({"join_rule": "restricted", "allow": [{"type": "m.room_membership", "room_id": domain}]}));
303        let bob = store::mxid_of(&c, 2).unwrap().unwrap();
304        // Bob is not in the domain yet: refused.
305        assert!(decide_and_apply_join(&mut c, &grp, 2, &bob, "bob", NOW, 3).is_err());
306        // Public domain: bob joins it, then the restricted room opens.
307        decide_and_apply_join(&mut c, &domain, 2, &bob, "bob", NOW, 4).unwrap();
308        assert!(restricted_allows(&c, &grp, 2).unwrap());
309        decide_and_apply_join(&mut c, &grp, 2, &bob, "bob", NOW, 5).unwrap();
310    }
311}