diff --git a/vela-api/src/membership/federation_knock.rs b/vela-api/src/membership/federation_knock.rs index 74afada..ab55eb2 100644 --- a/vela-api/src/membership/federation_knock.rs +++ b/vela-api/src/membership/federation_knock.rs @@ -466,20 +466,11 @@ pub async fn send_knock_v1( Ok(Json(json!({"knock_room_state": knock_room_state}))) } -/// Spec-defined types that go into knock_room_state. The knocking server -/// uses these to render room chrome (name, avatar, topic) while waiting -/// to be admitted. We also include the knocker's own member event. -const STRIPPED_TYPES: &[&str] = &[ - "m.room.create", - "m.room.name", - "m.room.avatar", - "m.room.canonical_alias", - "m.room.join_rules", - "m.room.encryption", - "m.room.member", -]; - fn build_knock_room_state(state: &AppState, room_nid: u64) -> Result, rocksdb::Error> { + // knock_room_state uses the same recommended stripped-state set as invites + // (`vela_core::events::INVITE_STRIPPED_STATE_TYPES`): the knocking server + // renders room chrome (name, avatar, topic, encrypted badge) while waiting + // to be admitted, plus the knocker's own member event. let nids = state.db.get_all_state_event_nids(room_nid)?; let mut out = Vec::new(); for nid in nids { @@ -490,7 +481,7 @@ fn build_knock_room_state(state: &AppState, room_nid: u64) -> Result, continue; }; let etype = v.event_type().unwrap_or(""); - if !STRIPPED_TYPES.contains(&etype) { + if !vela_core::events::INVITE_STRIPPED_STATE_TYPES.contains(&etype) { continue; } out.push(json!({ @@ -731,7 +722,10 @@ mod tests { ); // Unrelated types not in our test setup; but be sure no invalid leakage. for t in &types { - assert!(STRIPPED_TYPES.contains(t), "unexpected stripped type: {t}"); + assert!( + vela_core::events::INVITE_STRIPPED_STATE_TYPES.contains(t), + "unexpected stripped type: {t}" + ); } } } diff --git a/vela-api/src/membership/mod.rs b/vela-api/src/membership/mod.rs index ff3340a..7a47a46 100644 --- a/vela-api/src/membership/mod.rs +++ b/vela-api/src/membership/mod.rs @@ -2252,14 +2252,6 @@ fn build_invite_stripped_state( room_nid: u64, room_id: &str, ) -> Result, ApiError> { - static STRIPPED_TYPES: &[&str] = &[ - "m.room.create", - "m.room.name", - "m.room.avatar", - "m.room.canonical_alias", - "m.room.join_rules", - "m.room.member", - ]; let state_nids = state .db .get_all_state_event_nids(room_nid) @@ -2277,7 +2269,7 @@ fn build_invite_stripped_state( continue; }; let etype = ev.event_type().unwrap_or(""); - if !STRIPPED_TYPES.contains(&etype) { + if !vela_core::events::INVITE_STRIPPED_STATE_TYPES.contains(&etype) { continue; } if etype == "m.room.create" { diff --git a/vela-api/src/sync/mod.rs b/vela-api/src/sync/mod.rs index fb18799..dca97db 100644 --- a/vela-api/src/sync/mod.rs +++ b/vela-api/src/sync/mod.rs @@ -2225,15 +2225,6 @@ fn collect_invite_or_knock_stripped( room_nid: u64, room_id: &str, ) -> Result, ApiError> { - static STRIPPED_TYPES: &[&str] = &[ - "m.room.create", - "m.room.name", - "m.room.avatar", - "m.room.canonical_alias", - "m.room.join_rules", - "m.room.member", - ]; - let state_nids = state .db .get_all_state_event_nids(room_nid) @@ -2245,7 +2236,7 @@ fn collect_invite_or_knock_stripped( continue; }; let etype = ev.event_type().unwrap_or(""); - if !STRIPPED_TYPES.contains(&etype) { + if !vela_core::events::INVITE_STRIPPED_STATE_TYPES.contains(&etype) { continue; } if etype == "m.room.create" { diff --git a/vela-api/tests/invite_stripped_state.rs b/vela-api/tests/invite_stripped_state.rs new file mode 100644 index 0000000..9eb716a --- /dev/null +++ b/vela-api/tests/invite_stripped_state.rs @@ -0,0 +1,95 @@ +//! An invite's stripped state (`rooms.invite.{id}.invite_state`) must include +//! `m.room.topic` and `m.room.encryption` (CS-API recommended set), so an +//! invitee sees the room topic and the "encrypted" badge before joining. + +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use serde_json::{Value, json}; + +use common::{Harness, read_json}; + +async fn put_state(h: &Harness, token: &str, room: &str, etype: &str, body: Value) -> StatusCode { + h.request( + Request::put(format!("/_matrix/client/v3/rooms/{room}/state/{etype}/")) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .status() +} + +async fn invite(h: &Harness, token: &str, room: &str, user: &str) -> StatusCode { + h.request( + Request::post(format!("/_matrix/client/v3/rooms/{room}/invite")) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(json!({ "user_id": user }).to_string())) + .unwrap(), + ) + .await + .status() +} + +async fn sync(h: &Harness, token: &str) -> Value { + let resp = h + .request( + Request::get("/_matrix/client/v3/sync?timeout=0") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + read_json(resp).await +} + +#[tokio::test] +async fn invite_state_includes_topic_and_encryption() { + let h = Harness::new(); + let (_alice, alice_tok) = h.register("alice", "pw").await; + let (bob, bob_tok) = h.register("bob", "pw").await; + + let room = h.create_room(&alice_tok, json!({})).await; + assert_eq!( + put_state( + &h, + &alice_tok, + &room, + "m.room.topic", + json!({"topic": "hi there"}) + ) + .await, + StatusCode::OK + ); + assert_eq!( + put_state( + &h, + &alice_tok, + &room, + "m.room.encryption", + json!({"algorithm": "m.megolm.v1.aes-sha2"}), + ) + .await, + StatusCode::OK + ); + + assert_eq!(invite(&h, &alice_tok, &room, &bob).await, StatusCode::OK); + + let s = sync(&h, &bob_tok).await; + let events = s["rooms"]["invite"][&room]["invite_state"]["events"] + .as_array() + .unwrap_or_else(|| panic!("no invite_state for {room}: {s}")); + let types: Vec<&str> = events.iter().filter_map(|e| e["type"].as_str()).collect(); + assert!( + types.contains(&"m.room.topic"), + "invite_state must include m.room.topic: {types:?}" + ); + assert!( + types.contains(&"m.room.encryption"), + "invite_state must include m.room.encryption: {types:?}" + ); +} diff --git a/vela-core/src/events/mod.rs b/vela-core/src/events/mod.rs index 92d6c18..8f65ee6 100644 --- a/vela-core/src/events/mod.rs +++ b/vela-core/src/events/mod.rs @@ -7,3 +7,52 @@ pub mod redact; pub mod room_version; pub mod sign; pub mod view; + +/// State event types included in an invite/knock stripped-state bundle +/// (federation `invite_room_state`, and `rooms.invite.*.invite_state` / +/// `rooms.knock.*.knock_state` in `/sync`), per the CS-API recommended set. +/// +/// `m.room.encryption` lets a client show the "encrypted" badge and +/// `m.room.topic` the room topic before the invitee joins; `m.room.create` is +/// required (CS-API v1.16) so a v12 recipient can verify the hash-derived +/// `room_id`. `m.room.member` is not in the spec's recommended list but is +/// carried so the invitee can render who invited them. +/// +/// Single source of truth: the outbound bundle we build and the stripped +/// state a local invitee reads back in `/sync` must agree, or an invitee sees +/// different pre-join chrome depending on which server they're on. +pub const INVITE_STRIPPED_STATE_TYPES: &[&str] = &[ + "m.room.create", + "m.room.name", + "m.room.avatar", + "m.room.topic", + "m.room.canonical_alias", + "m.room.join_rules", + "m.room.encryption", + "m.room.member", +]; + +#[cfg(test)] +mod tests { + use super::INVITE_STRIPPED_STATE_TYPES; + + #[test] + fn stripped_state_covers_the_recommended_set() { + // The CS-API recommended stripped-state types — topic + encryption are + // the ones that were historically missing and drive pre-join chrome. + for t in [ + "m.room.create", + "m.room.name", + "m.room.avatar", + "m.room.topic", + "m.room.canonical_alias", + "m.room.join_rules", + "m.room.encryption", + ] { + assert!( + INVITE_STRIPPED_STATE_TYPES.contains(&t), + "stripped state must include {t}" + ); + } + } +}