Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 9 additions & 15 deletions vela-api/src/membership/federation_knock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<Value>, 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 {
Expand All @@ -490,7 +481,7 @@ fn build_knock_room_state(state: &AppState, room_nid: u64) -> Result<Vec<Value>,
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!({
Expand Down Expand Up @@ -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}"
);
}
}
}
10 changes: 1 addition & 9 deletions vela-api/src/membership/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2252,14 +2252,6 @@ fn build_invite_stripped_state(
room_nid: u64,
room_id: &str,
) -> Result<Vec<Value>, 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)
Expand All @@ -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" {
Expand Down
11 changes: 1 addition & 10 deletions vela-api/src/sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2225,15 +2225,6 @@ fn collect_invite_or_knock_stripped(
room_nid: u64,
room_id: &str,
) -> Result<Vec<Value>, 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)
Expand All @@ -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" {
Expand Down
95 changes: 95 additions & 0 deletions vela-api/tests/invite_stripped_state.rs
Original file line number Diff line number Diff line change
@@ -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:?}"
);
}
49 changes: 49 additions & 0 deletions vela-core/src/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
}
}
Loading