Skip to main content

mail4agent_server/
rooms.rs

1//! Room and membership decisions over an open messenger connection.
2//! The builder supplies user ids, mxids, and display names, and enforces
3//! entitlements before it calls in. Nothing here authenticates, bills, or
4//! wakes a socket.
5
6use std::collections::HashSet;
7
8use rusqlite::Connection;
9
10use crate::error::MatrixError;
11use crate::store::{HistoryVisibility, JoinRule, Membership, PowerAction, Room, RoomKind};
12
13// ============================================================================
14// Request bodies
15// ============================================================================
16
17#[derive(serde::Deserialize, Default)]
18pub struct CreateRoomRequest {
19    #[serde(default)]
20    pub visibility: Option<String>,
21    #[serde(default)]
22    pub is_direct: bool,
23    #[serde(default)]
24    pub invite: Vec<String>,
25    #[serde(default)]
26    pub name: Option<String>,
27    #[serde(default)]
28    pub topic: Option<String>,
29    #[serde(default)]
30    pub power_level_content_override: Option<serde_json::Value>,
31    /// No room-alias namespace exists at all (plan §10 item 4) — a non-empty
32    /// value is refused outright rather than silently dropped, so a client
33    /// never believes an alias was actually set.
34    #[serde(default)]
35    pub room_alias_name: Option<String>,
36    /// Only `type: "m.space"` is honored; any other `type` is refused.
37    #[serde(default)]
38    pub creation_content: Option<serde_json::Value>,
39    // `preset`/`initial_state` are intentionally NOT fields here: every
40    // bootstrap state event is server-derived from `kind` (§5), never
41    // client-declared, and serde ignores unrecognized JSON keys by default
42    // (no `deny_unknown_fields`) — so a client that still sends them (many
43    // Matrix SDKs send `preset` unconditionally) is silently unaffected
44    // rather than needing a field this struct would otherwise never read.
45}
46
47
48#[derive(serde::Deserialize)]
49pub struct UserIdBody {
50    pub user_id: String,
51}
52
53
54#[derive(serde::Deserialize)]
55pub struct KickBanBody {
56    pub user_id: String,
57    #[serde(default)]
58    pub reason: Option<String>,
59}
60
61
62#[derive(serde::Deserialize, Default)]
63pub struct MembersQuery {
64    pub membership: Option<String>,
65    /// A sync token (`next_batch`) or a bare stream position.
66    pub at: Option<String>,
67}
68
69
70// ============================================================================
71// Pure decision functions — no DB, no `MatrixCaller`, unit-tested directly
72// ============================================================================
73
74/// Kind derivation (plan P5 correction): `is_direct: true` requires EXACTLY
75/// ONE `invite[]` entry (the peer; the creator is the other member) or
76/// refuses with `M_INVALID_PARAM`; otherwise `visibility == "public"` ->
77/// `channel`; otherwise `group`.
78pub fn derive_room_kind(is_direct: bool, invite_count: usize, visibility_public: bool) -> Result<RoomKind, MatrixError> {
79    if is_direct {
80        return if invite_count == 1 {
81            Ok(RoomKind::Dm)
82        } else {
83            Err(MatrixError::invalid_param("is_direct requires exactly one invite target"))
84        };
85    }
86    if visibility_public {
87        return Ok(RoomKind::Channel);
88    }
89    Ok(RoomKind::Group)
90}
91
92
93/// `content` with the user's `displayname` stamped in. The server owns this
94/// field of every `join`/`invite` member event it writes (the Matrix
95/// convention): a client names a DM after the peer's member-event
96/// `displayname` and lists members by it, falling back to the mxid localpart
97/// (the 32-hex public id) when it is absent. An empty `displayname` (no
98/// identity row behind the user) leaves the field out rather than stamping
99/// an empty name.
100pub fn with_displayname(mut content: serde_json::Value, displayname: &str) -> serde_json::Value {
101    if !displayname.is_empty() {
102        content["displayname"] = serde_json::Value::String(displayname.to_string());
103    }
104    content
105}
106
107
108/// The `content` of a `join` `m.room.member` event for a user whose
109/// effective label is `displayname` (see [`with_displayname`]).
110/// `pub(crate)` — the legacy-DM migration's bootstrap writes the same event.
111pub fn join_member_content(displayname: &str) -> serde_json::Value {
112    with_displayname(serde_json::json!({ "membership": "join" }), displayname)
113}
114
115
116/// The `content` of the invitee's `m.room.member` invite event, carrying the
117/// invitee's own effective label as `displayname`. A DM invite also carries
118/// `is_direct: true` (the Matrix convention): the invitee's client reads it
119/// from `invite_state` to classify the room as a DM and record it in
120/// `m.direct`; without it the room would surface as a group. Every other
121/// kind carries the membership and the `displayname` only.
122pub fn invite_member_content(kind: RoomKind, displayname: &str) -> serde_json::Value {
123    let mut content = with_displayname(serde_json::json!({ "membership": "invite" }), displayname);
124    if kind == RoomKind::Dm {
125        content["is_direct"] = serde_json::Value::Bool(true);
126    }
127    content
128}
129
130
131/// A joined user's own `m.room.member` update (`PUT state`, the profile-update
132/// exception in [`check_state_event_type_allowed`]) with `displayname`
133/// forced to the effective label: the nick is the source of truth (`PUT
134/// /profile/{userId}/displayname` is refused for the same reason), so a
135/// client-chosen name is overwritten, and dropped when there is no label to
136/// stamp instead. Every other field of the client's content is kept.
137pub fn stamp_own_member_displayname(content: &str, displayname: &str) -> Result<String, MatrixError> {
138    let mut value: serde_json::Value = serde_json::from_str(content)?;
139    let Some(fields) = value.as_object_mut() else {
140        return Err(MatrixError::invalid_param("m.room.member content must be an object"));
141    };
142    if displayname.is_empty() {
143        fields.remove("displayname");
144    } else {
145        fields.insert("displayname".to_string(), serde_json::Value::String(displayname.to_string()));
146    }
147    Ok(value.to_string())
148}
149
150
151/// The creator's `join` and every invitee's `invite` `m.room.member` state
152/// events of a fresh room, each stamped with its own user's effective label
153/// (`invitees` is `(mxid, label)` per invited user). The creator's event
154/// comes first: `m.room.power_levels` must follow the member events it
155/// refers to (see [`crate::store::create_room_with_state`]).
156pub fn bootstrap_member_events(kind: RoomKind, creator: (i64, &str, &str), invitees: &[(String, String)]) -> Vec<crate::store::NewStateEvent> {
157    let (creator_user_id, creator_mxid, creator_label) = creator;
158    let mut events = vec![state_event("m.room.member", creator_mxid, creator_user_id, join_member_content(creator_label))];
159    for (mxid, label) in invitees {
160        events.push(state_event("m.room.member", mxid, creator_user_id, invite_member_content(kind, label)));
161    }
162    events
163}
164
165
166/// `join_rule`, `history_visibility`, `is_encrypted` for a fresh room.
167/// DM and group are E2E (`shared`). A Channel is PUBLIC PLAINTEXT
168/// (`world_readable`, not encrypted; project decision): its posts live
169/// in the separate public store ([`crate::public_channels`]), never in the
170/// closed `events` table and never under [`crate::retention`]. Rooms that
171/// were already created encrypted stay encrypted.
172/// `pub(crate)` — also the P12 legacy-DM migration's bootstrap builder.
173pub fn room_kind_settings(kind: RoomKind) -> (JoinRule, HistoryVisibility, bool) {
174    match kind {
175        RoomKind::Dm => (JoinRule::Invite, HistoryVisibility::Shared, true),
176        RoomKind::Group => (JoinRule::Invite, HistoryVisibility::Shared, true),
177        RoomKind::Channel => (JoinRule::Public, HistoryVisibility::WorldReadable, false),
178    }
179}
180
181
182/// `power_level_content_override` validation (P5 correction): accepted with
183/// ONLY the `invite` key (value `0` or `50`), and only for `group` rooms
184/// ("members can add people," §5). Any other key, an out-of-range value, or
185/// use on a non-group room is refused with `M_INVALID_PARAM`. Returns the
186/// override value to apply (`None` if the object was absent/empty).
187pub fn validate_power_level_override(kind: RoomKind, override_value: &Option<serde_json::Value>) -> Result<Option<i64>, MatrixError> {
188    let Some(value) = override_value else { return Ok(None) };
189    let obj = value.as_object().ok_or_else(|| MatrixError::invalid_param("power_level_content_override must be an object"))?;
190    if obj.keys().any(|k| k != "invite") {
191        return Err(MatrixError::invalid_param("power_level_content_override only accepts the 'invite' key"));
192    }
193    let Some(invite_value) = obj.get("invite") else { return Ok(None) };
194    if kind != RoomKind::Group {
195        return Err(MatrixError::invalid_param("power_level_content_override is only accepted for group rooms"));
196    }
197    match invite_value.as_i64() {
198        Some(0) => Ok(Some(0)),
199        Some(50) => Ok(Some(50)),
200        _ => Err(MatrixError::invalid_param("power_level_content_override.invite must be 0 or 50")),
201    }
202}
203
204
205/// `power_levels.invite` for a fresh room (plan §5): `dm`/`channel` are
206/// fixed at `50`; `group` defaults to `50` unless
207/// [`validate_power_level_override`] returned an explicit override.
208/// `pub(crate)` — also used by the P12 legacy-DM migration.
209pub fn invite_power_level(kind: RoomKind, override_value: Option<i64>) -> i64 {
210    match kind {
211        RoomKind::Dm | RoomKind::Channel => 50,
212        RoomKind::Group => override_value.unwrap_or(50),
213    }
214}
215
216
217/// The `m.room.power_levels` content for a fresh room (plan §5): the
218/// creator at `100`, every other field at the table's fixed defaults except
219/// `events_default` (raised to `50` for a `channel` — announcement-only).
220/// `pub(crate)` — reused verbatim by the P12 legacy-DM migration, so a
221/// migrated room's power levels are byte-for-byte what a native `createRoom`
222/// call would have produced.
223pub fn power_levels_content(creator_mxid: &str, kind: RoomKind, invite_level: i64) -> serde_json::Value {
224    let events_default = if kind == RoomKind::Channel { 50 } else { 0 };
225    serde_json::json!({
226        "users": { creator_mxid: 100 },
227        "users_default": 0,
228        "events_default": events_default,
229        "state_default": 50,
230        "ban": 50,
231        "kick": 50,
232        "redact": 50,
233        "invite": invite_level,
234    })
235}
236
237
238/// `PUT state/{eventType}/{stateKey}`'s `m.room.encryption` rule: refuse
239/// changing/removing an EXISTING encryption event. Enabling encryption on
240/// a still-unencrypted room (including a public channel) is allowed — all
241/// room kinds are E2E. `join_rule` is retained for call-site compatibility.
242pub fn check_encryption_state_change(event_type: &str, join_rule: JoinRule, already_encrypted: bool) -> Result<(), MatrixError> {
243    let _ = join_rule;
244    if event_type != "m.room.encryption" {
245        return Ok(());
246    }
247    if already_encrypted {
248        return Err(MatrixError::forbidden("encryption cannot be changed once set"));
249    }
250    Ok(())
251}
252
253
254/// `PUT state` must never touch `m.room.create` or `m.room.member` (manager
255/// review, 2026-09-24 — a real gap: the generic `PUT state/{type}/{key}`
256/// route had no type-specific refusal at all, so a Member with enough
257/// `state_default` power could rewrite `m.room.create`'s content, or
258/// silently kick/invite/ban a target by PUTting their `m.room.member`
259/// event directly, bypassing every kick/ban/invite-specific rule this
260/// module otherwise enforces (target-level checks, txn-dedup, the right
261/// wake set, ...). The ONE exception: a joined user updating their OWN
262/// `m.room.member` event while its `membership` stays `join` (a profile
263/// update; [`put_state_inner`] then forces its `displayname` to the nick's
264/// effective label via [`stamp_own_member_displayname`], so the client's own
265/// name never sticks) — membership itself only ever changes through
266/// join/leave/invite/kick/ban.
267pub fn check_state_event_type_allowed(event_type: &str, state_key: &str, caller_mxid: &str, content_str: &str) -> Result<(), MatrixError> {
268    if event_type == "m.room.create" {
269        return Err(MatrixError::forbidden("m.room.create cannot be modified"));
270    }
271    if event_type == "m.space.child" || event_type == "m.space.parent" {
272        let value: serde_json::Value = serde_json::from_str(content_str)?;
273        crate::spaces::validate_space_state(event_type, &value)?;
274    }
275    if event_type == "m.room.member" {
276        let membership: Option<String> = serde_json::from_str::<serde_json::Value>(content_str)
277            .ok()
278            .and_then(|v| v.get("membership").and_then(|m| m.as_str()).map(str::to_string));
279        let is_own_join_update = state_key == caller_mxid && membership.as_deref() == Some("join");
280        if !is_own_join_update {
281            return Err(MatrixError::forbidden(
282                "m.room.member only changes via join/leave/invite/kick/ban, except your own profile update while still joined",
283            ));
284        }
285    }
286    Ok(())
287}
288
289
290/// `(lo, hi)`-ordered pair key for a DM between two internal user ids — the
291/// same trick `dm_conversations` already uses (`dm_db.rs`), reused here for
292/// `rooms.dm_pair_key`. `pub(crate)` — also used by the P12 legacy-DM
293/// migration.
294pub fn dm_pair_key(a: i64, b: i64) -> String {
295    let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
296    format!("{lo}:{hi}")
297}
298
299
300/// `pub(crate)` — also used by the P12 legacy-DM migration to build its own
301/// bootstrap state events through the same helper `create_room` uses.
302pub fn state_event(event_type: &str, state_key: &str, sender_user_id: i64, content: serde_json::Value) -> crate::store::NewStateEvent {
303    crate::store::NewStateEvent {
304        event_id: crate::store::new_event_id(),
305        sender_user_id,
306        event_type: event_type.to_string(),
307        state_key: state_key.to_string(),
308        content: content.to_string(),
309    }
310}
311
312
313// ============================================================================
314// DB-only gate/read helpers — `&Connection`, no `MatrixCaller`
315// ============================================================================
316
317/// `m.room.power_levels`' content, or the empty object (every threshold then
318/// resolves to its Matrix default via [`crate::store::user_level`]/
319/// [`crate::store::event_level`]) if the room has never had one applied.
320pub fn power_levels_of(conn: &Connection, room_id: &str) -> Result<serde_json::Value, MatrixError> {
321    match crate::store::current_state_event(conn, room_id, "m.room.power_levels", "")? {
322        Some(event) => Ok(serde_json::from_str(&event.content)?),
323        None => Ok(serde_json::json!({})),
324    }
325}
326
327
328/// The plan §4 "Member" gate: a `room_members` row with `membership='join'`.
329pub fn require_member(membership: Option<Membership>) -> Result<(), MatrixError> {
330    match membership {
331        Some(Membership::Join) => Ok(()),
332        _ => Err(MatrixError::forbidden("not a member of this room")),
333    }
334}
335
336
337pub fn require_power(power_levels: &serde_json::Value, mxid: &str, action: PowerAction) -> Result<(), MatrixError> {
338    if crate::store::can(power_levels, action, mxid) {
339        Ok(())
340    } else {
341        Err(MatrixError::forbidden("insufficient power level for this action"))
342    }
343}
344
345
346/// kick/ban/unban's own gate (manager review, 2026-09-24): beyond the flat
347/// [`require_power`] threshold, the caller's level must be STRICTLY
348/// GREATER than the target's current level — see
349/// [`crate::store::can_act_on`]'s own doc for why a level-50 admin must
350/// never be able to touch a level-100 owner or an equal-level peer.
351pub fn require_power_over_target(power_levels: &serde_json::Value, caller_mxid: &str, target_mxid: &str, action: PowerAction, self_leave: bool) -> Result<(), MatrixError> {
352    if crate::store::can_act_on(power_levels, action, caller_mxid, target_mxid, self_leave) {
353        Ok(())
354    } else {
355        Err(MatrixError::forbidden("insufficient power level for this action"))
356    }
357}
358
359
360/// The plan §4 "PubRead" gate: Member, OR any signed-in caller when the
361/// room is a public, world-readable channel.
362pub fn require_pub_read(conn: &Connection, room: &Room, user_id: i64) -> Result<(), MatrixError> {
363    let membership = crate::store::room_member(conn, &room.id, user_id)?.map(|m| m.membership);
364    if matches!(membership, Some(Membership::Join)) {
365        return Ok(());
366    }
367    if room.join_rule == JoinRule::Public && room.history_visibility == HistoryVisibility::WorldReadable {
368        return Ok(());
369    }
370    Err(MatrixError::forbidden("no read access to this room"))
371}
372
373
374/// Every user id currently `join`ed OR `invite`d in `room_id` — the wake
375/// fan-out set for a write that affects the whole room (plan §3.1/§4:
376/// "wakes every joined AND invited member"). `pub(crate)` (not `pub(super)`)
377/// so both `routes::matrix::messaging` (P6) and `routes::dm`'s legacy-DM
378/// bridge (P15, outside this module tree) reuse the SAME wake-set
379/// computation rather than a second copy — see each module's own doc.
380pub fn member_and_invited_ids(conn: &Connection, room_id: &str) -> rusqlite::Result<HashSet<i64>> {
381    let mut ids = HashSet::new();
382    for member in crate::store::room_members(conn, room_id, Some(Membership::Join))? {
383        ids.insert(member.user_id);
384    }
385    for member in crate::store::room_members(conn, room_id, Some(Membership::Invite))? {
386        ids.insert(member.user_id);
387    }
388    Ok(ids)
389}
390
391
392/// The DM-reuse lookup (plan P5 correction): if `pair_key` currently names a
393/// room, return its id when it is STILL a live DM for both `user_a` and
394/// `user_b` (both `join`/`invite`, neither `leave`d out); otherwise free the
395/// pair key on that dead room (`rooms.dm_pair_key` is `UNIQUE`, so a fresh
396/// room reusing this pair key needs the slot released first) and return
397/// `None`, telling the caller to mint a new room. `pub(crate)` — the legacy-DM
398/// migration adopts a live native DM through the same lookup.
399pub fn find_reusable_dm_room(conn: &Connection, pair_key: &str, user_a: i64, user_b: i64) -> rusqlite::Result<Option<String>> {
400    let Some(existing) = crate::store::room_by_dm_pair_key(conn, pair_key)? else {
401        return Ok(None);
402    };
403    let members = crate::store::room_members(conn, &existing.id, None)?;
404    let alive = |uid: i64| members.iter().any(|m| m.user_id == uid && matches!(m.membership, Membership::Join | Membership::Invite));
405    if alive(user_a) && alive(user_b) {
406        return Ok(Some(existing.id));
407    }
408    crate::store::clear_dm_pair_key(conn, &existing.id)?;
409    Ok(None)
410}
411
412
413// ============================================================================
414// DB-only action cores — `&mut Connection`, no `MatrixCaller`, unit-tested
415// directly against an in-memory `matrix_store` fixture
416// ============================================================================
417
418/// The invitee of [`apply_invite`]: the resolved internal user id and the
419/// effective label to stamp into their invite event as `displayname`.
420#[derive(Debug, Clone, Copy)]
421pub struct InviteTarget<'a> {
422    pub user_id: i64,
423    pub displayname: &'a str,
424}
425
426/// The invite action's whole DB-side decision + write (plan §4's `invite`
427/// row: Member + PowerCheck(invite), refuses an already-invited/joined or
428/// banned target), used by both the real handler (after resolving
429/// `target_user_id` via [`resolve_target_user`], which needs the identity
430/// connection this function deliberately does NOT take) and this module's
431/// own tests. Returns the wake set (every joined+invited member, including
432/// the new invitee).
433///
434/// The invite event carries the invitee's `displayname` (`target`), resolved
435/// by the caller from the identity database BEFORE it takes the messenger
436/// connection this function writes through.
437pub fn apply_invite(
438    conn: &mut Connection,
439    room_id: &str,
440    caller_user_id: i64,
441    caller_mxid: &str,
442    target: InviteTarget<'_>,
443    now: &str,
444    origin_ts: i64,
445) -> Result<HashSet<i64>, MatrixError> {
446    let target_user_id = target.user_id;
447    let room = crate::store::get_room(conn, room_id)?.ok_or_else(|| MatrixError::not_found("no such room"))?;
448    let caller_membership = crate::store::room_member(conn, room_id, caller_user_id)?.map(|m| m.membership);
449    require_member(caller_membership)?;
450    let power_levels = power_levels_of(conn, room_id)?;
451    require_power(&power_levels, caller_mxid, PowerAction::Invite)?;
452
453    let target_membership = crate::store::room_member(conn, room_id, target_user_id)?.map(|m| m.membership);
454    match target_membership {
455        Some(Membership::Join) | Some(Membership::Invite) => return Err(MatrixError::invalid_param("user is already invited or joined")),
456        Some(Membership::Ban) => return Err(MatrixError::forbidden("user is banned from this room")),
457        Some(Membership::Leave) | None => {}
458    }
459
460    let target_mxid = crate::store::mxid_of(conn, target_user_id)?.ok_or_else(MatrixError::internal)?;
461    crate::store::apply_state_event(
462        conn,
463        &crate::store::StateEventWrite {
464            event_id: &crate::store::new_event_id(),
465            room_id,
466            sender_user_id: caller_user_id,
467            event_type: "m.room.member",
468            state_key: &target_mxid,
469            content: &invite_member_content(room.kind, target.displayname).to_string(),
470            origin_server_ts: origin_ts,
471            now,
472        },
473    )?;
474
475    let mut ids = member_and_invited_ids(conn, room_id)?;
476    ids.insert(target_user_id);
477    Ok(ids)
478}
479
480/// What state a kick/ban/unban target must currently be in for the action
481/// to proceed. `pub(super)` — also `routes::matrix::moderation`'s (P11) own
482/// room-power fallback path reuses this exact rule set, so a kick/ban/unban
483/// reached through `/api/matrix-admin/.../moderate` never drifts from the
484/// same action reached through this module's own `/kick`/`/ban`/`/unban`.
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub enum TargetStateRule {
487    /// kick: the target must currently be an active member (`join`/`invite`).
488    MustBeActiveMember,
489    /// ban: any current state is fine (banning a non-member pre-empts a
490    /// future join attempt).
491    Any,
492    /// unban: the target must currently be `ban`ned.
493    MustBeBanned,
494}
495
496
497pub fn check_target_state_rule(rule: TargetStateRule, membership: Option<Membership>) -> Result<(), MatrixError> {
498    match rule {
499        TargetStateRule::MustBeActiveMember => {
500            if matches!(membership, Some(Membership::Join) | Some(Membership::Invite)) {
501                Ok(())
502            } else {
503                Err(MatrixError::not_found("user is not a member of this room"))
504            }
505        }
506        TargetStateRule::MustBeBanned => {
507            if membership == Some(Membership::Ban) {
508                Ok(())
509            } else {
510                Err(MatrixError::invalid_param("user is not banned"))
511            }
512        }
513        TargetStateRule::Any => Ok(()),
514    }
515}
516
517
518/// The pieces [`apply_membership_power_action`] needs beyond the caller and
519/// the connection — grouped so the function itself stays under a plain
520/// four-parameter signature instead of needing
521/// `#[allow(clippy::too_many_arguments)]`. `pub(super)` alongside
522/// [`apply_membership_power_action`] — see that function's own doc for why.
523pub struct MembershipPowerAction<'a> {
524    pub room_id: &'a str,
525    pub action: PowerAction,
526    pub target_user_id: i64,
527    pub target_rule: TargetStateRule,
528    pub new_membership: Membership,
529    pub reason: Option<&'a str>,
530}
531
532/// The shared core of kick/ban/unban (plan §4: Member + PowerCheck(kick/
533/// ban)): checks the caller's own membership+power, checks
534/// [`TargetStateRule`] against the target's current membership, then writes
535/// the target's new `m.room.member` state event. `target_user_id` is
536/// already resolved (identity-DB work happens in the handler, not here —
537/// see the module doc's "testable cores" note). Returns the wake set
538/// (post-write joined+invited members plus the target, even though the
539/// target may no longer be one of those after this write).
540///
541/// `pub(super)` — `routes::matrix::moderation`'s (P11) room-power fallback
542/// gate ("otherwise the caller needs the room's own power," plan §4's
543/// `/moderate` row) reuses this verbatim rather than re-deriving the
544/// `can_act_on` target-level rule a second time, matching this module's own
545/// precedent for [`member_and_invited_ids`] (reused by `messaging.rs`).
546pub fn apply_membership_power_action(
547    conn: &mut Connection,
548    caller_user_id: i64,
549    caller_mxid: &str,
550    action: MembershipPowerAction<'_>,
551    now: &str,
552    origin_ts: i64,
553) -> Result<HashSet<i64>, MatrixError> {
554    let room_id = action.room_id;
555    let caller_membership = crate::store::room_member(conn, room_id, caller_user_id)?.map(|m| m.membership);
556    require_member(caller_membership)?;
557    let power_levels = power_levels_of(conn, room_id)?;
558
559    let target_mxid = crate::store::mxid_of(conn, action.target_user_id)?.ok_or_else(MatrixError::internal)?;
560    let self_leave = action.new_membership == Membership::Leave;
561    require_power_over_target(&power_levels, caller_mxid, &target_mxid, action.action, self_leave)?;
562
563    let target_membership = crate::store::room_member(conn, room_id, action.target_user_id)?.map(|m| m.membership);
564    check_target_state_rule(action.target_rule, target_membership)?;
565
566    let mut content = serde_json::json!({ "membership": action.new_membership.as_str() });
567    if let Some(reason) = action.reason {
568        content["reason"] = serde_json::Value::String(reason.to_string());
569    }
570    crate::store::apply_state_event(conn, &crate::store::StateEventWrite { event_id: &crate::store::new_event_id(), room_id, sender_user_id: caller_user_id, event_type: "m.room.member", state_key: &target_mxid, content: &content.to_string(), origin_server_ts: origin_ts, now })?;
571
572    let mut ids = member_and_invited_ids(conn, room_id)?;
573    ids.insert(action.target_user_id);
574    Ok(ids)
575}
576
577/// `POST /join`'s write for an already-invited or newly-public-eligible
578/// caller (idempotent-join and ban checks happen in the handler, before the
579/// policy hook — see [`join_room`]). Returns the post-join wake set. The join
580/// event carries the caller's effective label as `displayname`, resolved by
581/// the caller BEFORE it takes the messenger connection this writes through.
582pub fn apply_join(
583    conn: &mut Connection,
584    room_id: &str,
585    caller_user_id: i64,
586    caller_mxid: &str,
587    displayname: &str,
588    now: &str,
589    origin_ts: i64,
590) -> Result<HashSet<i64>, MatrixError> {
591    crate::store::apply_state_event(
592        conn,
593        &crate::store::StateEventWrite {
594            event_id: &crate::store::new_event_id(),
595            room_id,
596            sender_user_id: caller_user_id,
597            event_type: "m.room.member",
598            state_key: caller_mxid,
599            content: &join_member_content(displayname).to_string(),
600            origin_server_ts: origin_ts,
601            now,
602        },
603    )?;
604    Ok(member_and_invited_ids(conn, room_id)?)
605}
606
607
608/// `POST /leave`'s write (manager review, 2026-09-24: the plan's plain
609/// "Member" gate table entry undersold this — Matrix's own spec behaviour
610/// for `/leave` is "leave OR reject a pending invite," so an INVITED, not
611/// yet joined, caller must be accepted too). Wake set is captured BEFORE
612/// the leave lands, so the leaver's own other devices are included, and —
613/// since [`member_and_invited_ids`] already covers every joined+invited
614/// member — the inviter (who must be a joined member to have sent the
615/// invite at all) is naturally woken as well.
616pub fn apply_leave(conn: &mut Connection, room_id: &str, caller_user_id: i64, caller_mxid: &str, now: &str, origin_ts: i64) -> Result<HashSet<i64>, MatrixError> {
617    let caller_membership = crate::store::room_member(conn, room_id, caller_user_id)?.map(|m| m.membership);
618    if !matches!(caller_membership, Some(Membership::Join) | Some(Membership::Invite)) {
619        return Err(MatrixError::forbidden("not a member or invitee of this room"));
620    }
621    let wake_ids = member_and_invited_ids(conn, room_id)?;
622    crate::store::apply_state_event(
623        conn,
624        &crate::store::StateEventWrite {
625            event_id: &crate::store::new_event_id(),
626            room_id,
627            sender_user_id: caller_user_id,
628            event_type: "m.room.member",
629            state_key: caller_mxid,
630            content: &serde_json::json!({ "membership": "leave" }).to_string(),
631            origin_server_ts: origin_ts,
632            now,
633        },
634    )?;
635    Ok(wake_ids)
636}
637
638
639/// Caller-supplied invitee. The mxid is already stored on `matrix_users`;
640/// `displayname` is whatever the builder wants stamped into the member event.
641pub struct RoomInvitee<'a> {
642    pub user_id: i64,
643    pub displayname: &'a str,
644}
645
646/// Inputs for [`apply_create_room`]. Identity, entitlement, and the HTTP
647/// body parse stay with the builder. Invitees are already integer user ids.
648pub struct RoomCreate<'a> {
649    pub creator_user_id: i64,
650    pub creator_mxid: &'a str,
651    pub creator_displayname: &'a str,
652    pub is_direct: bool,
653    pub invitees: &'a [RoomInvitee<'a>],
654    pub visibility_public: bool,
655    pub power_level_content_override: Option<serde_json::Value>,
656    pub name: Option<&'a str>,
657    pub topic: Option<&'a str>,
658    /// `creation_content.type`; only `m.space` is accepted (a domain).
659    pub room_type: Option<&'a str>,
660    /// The room this one replaces (`room_id`, last `event_id`): written into the create event.
661    pub predecessor: Option<(&'a str, &'a str)>,
662}
663
664pub enum RoomCreation {
665    Reused(String),
666    Created { room_id: String, notify_user_ids: HashSet<i64> },
667}
668
669/// Create a room, or reuse the live DM for the same pair.
670/// Returns the user ids the builder may notify. Does not wake anyone.
671pub fn apply_create_room(
672    conn: &mut Connection,
673    req: RoomCreate<'_>,
674    now: &str,
675    origin_ts: i64,
676) -> Result<RoomCreation, MatrixError> {
677    if req.invitees.iter().any(|invitee| invitee.user_id == req.creator_user_id) {
678        return Err(MatrixError::invalid_param("cannot invite yourself"));
679    }
680    let kind = derive_room_kind(req.is_direct, req.invitees.len(), req.visibility_public)?;
681    let invite_override = validate_power_level_override(kind, &req.power_level_content_override)?;
682    if kind == RoomKind::Dm {
683        let peer_id = req.invitees[0].user_id;
684        let pair_key = dm_pair_key(req.creator_user_id, peer_id);
685        if let Some(existing) = find_reusable_dm_room(conn, &pair_key, req.creator_user_id, peer_id)? {
686            return Ok(RoomCreation::Reused(existing));
687        }
688    }
689
690    let room_id = crate::store::new_room_id();
691    let (join_rule, history_visibility, is_encrypted) = room_kind_settings(kind);
692    let invite_level = invite_power_level(kind, invite_override);
693    let mut invitees = Vec::with_capacity(req.invitees.len());
694    for invitee in req.invitees {
695        let mxid = crate::store::mxid_of(conn, invitee.user_id)?.ok_or_else(MatrixError::internal)?;
696        invitees.push((mxid, invitee.displayname.to_string()));
697    }
698    let mut state_events = vec![state_event(
699        "m.room.create",
700        "",
701        req.creator_user_id,
702        {
703            let mut create = serde_json::json!({ "room_version": crate::store::MATRIX_ROOM_VERSION });
704            if let Some(t) = req.room_type {
705                create["type"] = serde_json::json!(t);
706            }
707            if let Some((room, event)) = req.predecessor {
708                create["predecessor"] = serde_json::json!({ "room_id": room, "event_id": event });
709            }
710            create
711        },
712    )];
713    state_events.extend(bootstrap_member_events(
714        kind,
715        (req.creator_user_id, req.creator_mxid, req.creator_displayname),
716        &invitees,
717    ));
718    state_events.push(state_event(
719        "m.room.power_levels",
720        "",
721        req.creator_user_id,
722        power_levels_content(req.creator_mxid, kind, invite_level),
723    ));
724    state_events.push(state_event(
725        "m.room.join_rules",
726        "",
727        req.creator_user_id,
728        serde_json::json!({ "join_rule": join_rule.as_str() }),
729    ));
730    state_events.push(state_event(
731        "m.room.history_visibility",
732        "",
733        req.creator_user_id,
734        serde_json::json!({ "history_visibility": history_visibility.as_str() }),
735    ));
736    if let Some(name) = req.name {
737        state_events.push(state_event(
738            "m.room.name",
739            "",
740            req.creator_user_id,
741            serde_json::json!({ "name": name }),
742        ));
743    }
744    if let Some(topic) = req.topic {
745        state_events.push(state_event(
746            "m.room.topic",
747            "",
748            req.creator_user_id,
749            serde_json::json!({ "topic": topic }),
750        ));
751    }
752    if is_encrypted {
753        state_events.push(state_event(
754            "m.room.encryption",
755            "",
756            req.creator_user_id,
757            serde_json::json!({ "algorithm": "m.megolm.v1.aes-sha2" }),
758        ));
759    }
760    let dm_pair = (kind == RoomKind::Dm).then(|| dm_pair_key(req.creator_user_id, req.invitees[0].user_id));
761    let bootstrap = crate::store::RoomBootstrap {
762        room_id: &room_id,
763        kind,
764        creator_user_id: req.creator_user_id,
765        created_at: now,
766        is_encrypted,
767        join_rule,
768        history_visibility,
769        dm_pair_key: dm_pair.as_deref(),
770        legacy_dm_id: None,
771    };
772    crate::store::create_room_with_state(conn, bootstrap, &state_events, origin_ts)?;
773    let mut notify_user_ids: HashSet<i64> = req.invitees.iter().map(|invitee| invitee.user_id).collect();
774    notify_user_ids.insert(req.creator_user_id);
775    Ok(RoomCreation::Created { room_id, notify_user_ids })
776}
777
778/// Replace a room by a new one (the way a legacy room moves to the current room version, hash ids
779/// included when the server makes them): the new room gets the old one's kind, name, topic, type,
780/// avatar, join rules, history visibility, power levels and (for a space) its children, its create
781/// event names the old room as `predecessor`, and the old room gets a tombstone. Direct rooms are
782/// not upgraded (a pair has one direct room). Needs power 100 or the room's creator.
783pub fn apply_upgrade(conn: &mut Connection, old_room: &str, caller_user_id: i64, caller_mxid: &str, caller_label: &str, now: &str, origin_ts: i64) -> Result<(String, HashSet<i64>), MatrixError> {
784    let room = crate::store::get_room(conn, old_room)?.ok_or_else(|| MatrixError::not_found("no such room"))?;
785    let member = crate::store::room_member(conn, old_room, caller_user_id)?;
786    require_member(member.as_ref().map(|m| m.membership))?;
787    let powerful = room.creator_user_id == caller_user_id || member.and_then(|m| m.power_level).unwrap_or(0) >= 100;
788    if !powerful {
789        return Err(MatrixError::forbidden("only the room's creator or an admin can upgrade it"));
790    }
791    if room.kind == RoomKind::Dm {
792        return Err(MatrixError::forbidden("a direct room is not upgraded"));
793    }
794    if crate::store::current_state_event(conn, old_room, "m.room.tombstone", "")?.is_some() {
795        return Err(MatrixError::forbidden("this room has already been upgraded"));
796    }
797    let content_of = |conn: &Connection, ty: &str| -> Result<Option<serde_json::Value>, MatrixError> {
798        Ok(match crate::store::current_state_event(conn, old_room, ty, "")? {
799            Some(e) => Some(serde_json::from_str(&e.content)?),
800            None => None,
801        })
802    };
803    let name = content_of(conn, "m.room.name")?.and_then(|c| c.get("name").and_then(|v| v.as_str()).map(str::to_string));
804    let topic = content_of(conn, "m.room.topic")?.and_then(|c| c.get("topic").and_then(|v| v.as_str()).map(str::to_string));
805    let room_type = content_of(conn, "m.room.create")?.and_then(|c| c.get("type").and_then(|v| v.as_str()).map(str::to_string)).filter(|t| t == "m.space");
806    let last: String = conn
807        .query_row("SELECT event_id FROM events WHERE room_id = ?1 ORDER BY stream_id DESC LIMIT 1", [old_room], |r| r.get(0))
808        .map_err(|_| MatrixError::internal())?;
809    let creation = apply_create_room(
810        conn,
811        RoomCreate {
812            creator_user_id: caller_user_id,
813            creator_mxid: caller_mxid,
814            creator_displayname: caller_label,
815            is_direct: false,
816            invitees: &[],
817            visibility_public: room.kind == RoomKind::Channel,
818            power_level_content_override: None,
819            name: name.as_deref(),
820            topic: topic.as_deref(),
821            room_type: room_type.as_deref(),
822            predecessor: Some((old_room, &last)),
823        },
824        now,
825        origin_ts,
826    )?;
827    let RoomCreation::Created { room_id: new_room, mut notify_user_ids } = creation else { return Err(MatrixError::internal()) };
828    // State that carries over. A failure to copy an optional piece does not undo the upgrade.
829    for ty in ["m.room.avatar", "m.room.guest_access", "m.room.join_rules", "m.room.power_levels"] {
830        if let Some(c) = content_of(conn, ty)? {
831            let _ = apply_put_state(conn, &new_room, caller_user_id, caller_mxid, ty, "", &c.to_string(), now, origin_ts);
832        }
833    }
834    if room_type.is_some() {
835        for e in crate::store::current_state_all(conn, old_room)? {
836            if e.event_type == "m.space.child" {
837                if let Some(k) = &e.state_key {
838                    let _ = apply_put_state(conn, &new_room, caller_user_id, caller_mxid, "m.space.child", k, &e.content, now, origin_ts);
839                }
840            }
841        }
842    }
843    // Aliases move to the new room.
844    let _ = conn.execute("UPDATE room_aliases SET room_id = ?1 WHERE room_id = ?2", rusqlite::params![new_room, old_room]);
845    let (_, wake) = apply_put_state(
846        conn,
847        old_room,
848        caller_user_id,
849        caller_mxid,
850        "m.room.tombstone",
851        "",
852        &serde_json::json!({ "body": "This room has been replaced", "replacement_room": new_room }).to_string(),
853        now,
854        origin_ts,
855    )?;
856    notify_user_ids.extend(wake);
857    Ok((new_room, notify_user_ids))
858}
859
860pub enum JoinDecision {
861    AlreadyJoined,
862    Joined(HashSet<i64>),
863}
864
865/// Join gate without an entitlement check. A banned user is refused.
866/// A private room still requires an invite. The builder stamps `displayname`.
867pub fn decide_and_apply_join(
868    conn: &mut Connection,
869    room_id: &str,
870    caller_user_id: i64,
871    caller_mxid: &str,
872    displayname: &str,
873    now: &str,
874    origin_ts: i64,
875) -> Result<JoinDecision, MatrixError> {
876    let room = crate::store::get_room(conn, room_id)?.ok_or_else(|| MatrixError::not_found("no such room"))?;
877    let current = crate::store::room_member(conn, room_id, caller_user_id)?.map(|member| member.membership);
878    match current {
879        Some(Membership::Join) => return Ok(JoinDecision::AlreadyJoined),
880        Some(Membership::Ban) => return Err(MatrixError::forbidden("banned from this room")),
881        Some(Membership::Invite) => {}
882        Some(Membership::Leave) | None => {
883            if room.join_rule != JoinRule::Public && !crate::spaces::restricted_allows(conn, room_id, caller_user_id)? {
884                return Err(MatrixError::forbidden("no invitation to this room"));
885            }
886        }
887    }
888    let ids = apply_join(conn, room_id, caller_user_id, caller_mxid, displayname, now, origin_ts)?;
889    Ok(JoinDecision::Joined(ids))
890}
891
892/// Write one state event. `content` is already final: the builder stamps
893/// `displayname` on an own-member profile update before calling in.
894/// Returns `(event_id, user ids the builder may notify)`.
895pub fn apply_put_state(
896    conn: &mut Connection,
897    room_id: &str,
898    sender_user_id: i64,
899    sender_mxid: &str,
900    event_type: &str,
901    state_key: &str,
902    content: &str,
903    now: &str,
904    origin_ts: i64,
905) -> Result<(String, HashSet<i64>), MatrixError> {
906    if content.len() > crate::store::MATRIX_EVENT_CONTENT_MAX_BYTES {
907        return Err(MatrixError::invalid_param("event content too large"));
908    }
909    let room = crate::store::get_room(conn, room_id)?.ok_or_else(|| MatrixError::not_found("no such room"))?;
910    let caller_membership = crate::store::room_member(conn, room_id, sender_user_id)?.map(|member| member.membership);
911    require_member(caller_membership)?;
912    check_state_event_type_allowed(event_type, state_key, sender_mxid, content)?;
913    let already_encrypted = crate::store::current_state_event(conn, room_id, "m.room.encryption", "")?.is_some();
914    check_encryption_state_change(event_type, room.join_rule, already_encrypted)?;
915    let power_levels = power_levels_of(conn, room_id)?;
916    if crate::store::user_level(&power_levels, sender_mxid) < crate::store::event_level(&power_levels, event_type, true) {
917        return Err(MatrixError::forbidden("insufficient power level to set this state event"));
918    }
919    if event_type == "m.room.power_levels" {
920        let new_power_levels: serde_json::Value = serde_json::from_str(content)?;
921        crate::store::validate_power_levels_change(&power_levels, &new_power_levels, sender_mxid).map_err(MatrixError::forbidden)?;
922    }
923    let event = crate::store::apply_state_event(
924        conn,
925        &crate::store::StateEventWrite {
926            event_id: &crate::store::new_event_id(),
927            room_id,
928            sender_user_id,
929            event_type,
930            state_key,
931            content,
932            origin_server_ts: origin_ts,
933            now,
934        },
935    )?;
936    let notify = member_and_invited_ids(conn, room_id)?;
937    Ok((event.event_id, notify))
938}
939
940/// Forget a room the caller has already left.
941pub fn apply_forget(conn: &Connection, room_id: &str, user_id: i64) -> Result<(), MatrixError> {
942    let deleted = crate::store::forget_membership(conn, room_id, user_id)?;
943    if deleted == 0 {
944        return Err(MatrixError::forbidden("must have left the room before forgetting it"));
945    }
946    Ok(())
947}
948
949
950/// Enable Megolm on every still-plaintext room: write `m.room.encryption`
951/// if missing, flip `rooms.is_encrypted`, and normalize
952/// `history_visibility` to `shared` (public vs private = join_rule only).
953/// Past timeline plaintext rows stay as historical `m.room.message`; new
954/// sends must be encrypted. Idempotent. Returns how many rooms were changed.
955pub fn migrate_plaintext_rooms_to_encrypted(
956    conn: &mut Connection,
957    now: &str,
958    origin_ts: i64,
959) -> Result<usize, MatrixError> {
960    let plaintext: Vec<(String, i64, String)> = {
961        let mut stmt = conn
962            // Public plaintext channels are intentionally not encrypted; leave them.
963            .prepare("SELECT id, creator_user_id, history_visibility FROM rooms WHERE is_encrypted = 0 AND kind != 'channel'")
964            .map_err(|e| MatrixError::unknown(e.to_string()))?;
965        let rows = stmt
966            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
967            .map_err(|e| MatrixError::unknown(e.to_string()))?;
968        rows.collect::<Result<Vec<_>, _>>()
969            .map_err(|e| MatrixError::unknown(e.to_string()))?
970    };
971    let mut changed = 0usize;
972    for (room_id, creator_user_id, hv) in plaintext {
973        let already = crate::store::current_state_event(conn, &room_id, "m.room.encryption", "")?.is_some();
974        if !already {
975            let event_id = crate::store::new_event_id();
976            crate::store::apply_state_event(
977                conn,
978                &crate::store::StateEventWrite {
979                    event_id: &event_id,
980                    room_id: &room_id,
981                    sender_user_id: creator_user_id,
982                    event_type: "m.room.encryption",
983                    state_key: "",
984                    content: r#"{"algorithm":"m.megolm.v1.aes-sha2"}"#,
985                    origin_server_ts: origin_ts,
986                    now,
987                },
988            )?;
989        }
990        if hv == HistoryVisibility::WorldReadable.as_str() {
991            let event_id = crate::store::new_event_id();
992            crate::store::apply_state_event(
993                conn,
994                &crate::store::StateEventWrite {
995                    event_id: &event_id,
996                    room_id: &room_id,
997                    sender_user_id: creator_user_id,
998                    event_type: "m.room.history_visibility",
999                    state_key: "",
1000                    content: r#"{"history_visibility":"shared"}"#,
1001                    origin_server_ts: origin_ts,
1002                    now,
1003                },
1004            )?;
1005            conn.execute(
1006                "UPDATE rooms SET history_visibility = ?1 WHERE id = ?2",
1007                rusqlite::params![HistoryVisibility::Shared.as_str(), room_id],
1008            )
1009            .map_err(|e| MatrixError::unknown(e.to_string()))?;
1010        }
1011        conn.execute(
1012            "UPDATE rooms SET is_encrypted = 1 WHERE id = ?1",
1013            rusqlite::params![room_id],
1014        )
1015        .map_err(|e| MatrixError::unknown(e.to_string()))?;
1016        changed += 1;
1017    }
1018    Ok(changed)
1019}
1020
1021/// Drop empty `legacy_dm_message_map` (P12 scaffold). Refuses if any rows remain.
1022pub fn drop_legacy_dm_scaffold_if_empty(conn: &Connection) -> Result<bool, MatrixError> {
1023    let count: i64 = conn
1024        .query_row(
1025            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='legacy_dm_message_map'",
1026            [],
1027            |row| row.get(0),
1028        )
1029        .map_err(|e| MatrixError::unknown(e.to_string()))?;
1030    if count == 0 {
1031        return Ok(false);
1032    }
1033    let rows: i64 = conn
1034        .query_row("SELECT COUNT(*) FROM legacy_dm_message_map", [], |row| row.get(0))
1035        .map_err(|e| MatrixError::unknown(e.to_string()))?;
1036    if rows > 0 {
1037        return Err(MatrixError::forbidden("legacy_dm_message_map still has rows; refuse drop"));
1038    }
1039    conn.execute_batch("DROP TABLE IF EXISTS legacy_dm_message_map;")
1040        .map_err(|e| MatrixError::unknown(e.to_string()))?;
1041    Ok(true)
1042}
1043
1044#[cfg(test)]
1045mod messenger_model_tests {
1046    use super::*;
1047    use crate::store::{self, HistoryVisibility, JoinRule, RoomKind};
1048    use rusqlite::Connection;
1049
1050    const T0: &str = "2026-10-06T00:00:00+00:00";
1051
1052    fn test_conn() -> Connection {
1053        let conn = Connection::open_in_memory().expect("in-memory sqlite");
1054        store::create_matrix_schema(&conn).expect("schema");
1055        conn
1056    }
1057
1058    #[test]
1059    fn dm_and_group_are_encrypted_channel_is_public_plaintext() {
1060        let (jr, hv, enc) = room_kind_settings(RoomKind::Dm);
1061        assert_eq!(jr, JoinRule::Invite);
1062        assert_eq!(hv, HistoryVisibility::Shared);
1063        assert!(enc);
1064
1065        let (jr, hv, enc) = room_kind_settings(RoomKind::Group);
1066        assert_eq!(jr, JoinRule::Invite);
1067        assert_eq!(hv, HistoryVisibility::Shared);
1068        assert!(enc);
1069
1070        let (jr, hv, enc) = room_kind_settings(RoomKind::Channel);
1071        assert_eq!(jr, JoinRule::Public);
1072        assert_eq!(hv, HistoryVisibility::WorldReadable);
1073        assert!(!enc, "public channels are plaintext in the public store");
1074    }
1075
1076    #[test]
1077    fn encryption_may_be_enabled_on_a_public_room_but_never_changed() {
1078        check_encryption_state_change("m.room.encryption", JoinRule::Public, false).expect("enable ok");
1079        let err = check_encryption_state_change("m.room.encryption", JoinRule::Public, true).unwrap_err();
1080        assert!(format!("{err:?}").contains("changed") || format!("{err:?}").to_lowercase().contains("forbidden"));
1081    }
1082
1083    #[test]
1084    fn public_channel_join_without_invite_private_group_requires_invite() {
1085        let mut conn = test_conn();
1086        store::ensure_matrix_user(&conn, 1, "alice000000000000000000000000001", T0).expect("alice");
1087        let bob_mxid = store::ensure_matrix_user(&conn, 2, "bob00000000000000000000000000002", T0).expect("bob");
1088
1089        let channel = "!chan:example.org";
1090        let (jr, hv, enc) = room_kind_settings(RoomKind::Channel);
1091        store::create_room(&conn, channel, RoomKind::Channel, 1, T0, enc, jr, hv, None, None).expect("channel");
1092        match decide_and_apply_join(&mut conn, channel, 2, &bob_mxid, "bob", T0, 1_000).expect("join") {
1093            JoinDecision::Joined(_) => {}
1094            JoinDecision::AlreadyJoined => panic!("expected fresh join"),
1095        }
1096
1097        let group = "!grp:example.org";
1098        let (jr, hv, enc) = room_kind_settings(RoomKind::Group);
1099        store::create_room(&conn, group, RoomKind::Group, 1, T0, enc, jr, hv, None, None).expect("group");
1100        match decide_and_apply_join(&mut conn, group, 2, &bob_mxid, "bob", T0, 2_000) {
1101            Err(err) => {
1102                let msg = format!("{err:?}").to_lowercase();
1103                assert!(msg.contains("invitation") || msg.contains("forbidden"), "{msg}");
1104            }
1105            Ok(_) => panic!("stranger must not join a private group without invite"),
1106        }
1107    }
1108
1109    #[test]
1110    fn migration_encrypts_plaintext_group_but_leaves_public_channel_alone() {
1111        let mut conn = test_conn();
1112        store::ensure_matrix_user(&conn, 1, "alice000000000000000000000000001", T0).expect("alice");
1113        let channel = "!oldchan:example.org";
1114        store::create_room(&conn, channel, RoomKind::Channel, 1, T0, false, JoinRule::Public, HistoryVisibility::WorldReadable, None, None).expect("channel");
1115        let group = "!oldgrp:example.org";
1116        store::create_room(&conn, group, RoomKind::Group, 1, T0, false, JoinRule::Invite, HistoryVisibility::Shared, None, None).expect("group");
1117        let n = migrate_plaintext_rooms_to_encrypted(&mut conn, T0, 1_000).expect("migrate");
1118        assert_eq!(n, 1, "only the group is rewritten");
1119        let room = store::get_room(&conn, group).expect("get").expect("exists");
1120        assert!(room.is_encrypted);
1121        let chan = store::get_room(&conn, channel).expect("get").expect("exists");
1122        assert!(!chan.is_encrypted, "public channel stays plaintext");
1123        assert_eq!(chan.history_visibility, HistoryVisibility::WorldReadable);
1124        assert!(store::current_state_event(&conn, channel, "m.room.encryption", "").expect("state").is_none());
1125        let n2 = migrate_plaintext_rooms_to_encrypted(&mut conn, T0, 2_000).expect("idempotent");
1126        assert_eq!(n2, 0);
1127    }
1128
1129    #[test]
1130    fn drop_legacy_dm_scaffold_when_empty() {
1131        let conn = test_conn();
1132        // Schema no longer creates the table; simulate an old DB.
1133        conn.execute_batch(
1134            "CREATE TABLE legacy_dm_message_map (
1135                legacy_message_id INTEGER PRIMARY KEY,
1136                event_id TEXT NOT NULL
1137            );",
1138        )
1139        .expect("old table");
1140        assert!(drop_legacy_dm_scaffold_if_empty(&conn).expect("drop"));
1141        assert!(!drop_legacy_dm_scaffold_if_empty(&conn).expect("already gone"));
1142    }
1143}