From 15c8f58d082d09eaa99856027033654c1cd25784 Mon Sep 17 00:00:00 2001 From: sufforest Date: Fri, 3 Jul 2026 08:32:58 -0700 Subject: [PATCH] fix: include topic and encryption in invite/knock stripped state An invite's stripped state (invite_room_state, and the invitee's /sync invite_state) omitted m.room.topic and m.room.encryption, so an invitee couldn't see the room topic or the "encrypted" badge before joining. The knock bundle was missing the topic too. Three copies of the type list had drifted independently; unify them on vela_core::events::INVITE_STRIPPED_STATE_TYPES with the full CS-API recommended set so they can't diverge again. --- vela-api/src/membership/federation_knock.rs | 24 ++---- vela-api/src/membership/mod.rs | 10 +-- vela-api/src/sync/mod.rs | 11 +-- vela-api/tests/invite_stripped_state.rs | 95 +++++++++++++++++++++ vela-core/src/events/mod.rs | 49 +++++++++++ 5 files changed, 155 insertions(+), 34 deletions(-) create mode 100644 vela-api/tests/invite_stripped_state.rs 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}" + ); + } + } +}