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