Skip to main content

max / makenotwork

6.2 KB · 189 lines History Blame Raw
1 //! The seams each host app implements.
2 //!
3 //! The crate knows nothing about communities, streams, subscriptions, or bans.
4 //! Everything app-specific arrives through these four traits, which is what lets
5 //! Multithreaded gate on `CommunityScope` and the MNW server gate on
6 //! `SubscriptionGate` without either concept appearing here.
7 //!
8 //! `async_trait` rather than native AFIT: the returned futures need `Send` to be
9 //! spawned onto tokio, and the MNW server already depends on the crate.
10
11 use std::collections::HashMap;
12 use std::time::Duration;
13
14 use async_trait::async_trait;
15
16 use crate::error::ChatError;
17 use crate::ids::{MessageId, RoomId, UserId};
18 use crate::room::Room;
19
20 /// Resolves an app-specific key to a room.
21 #[async_trait]
22 pub trait ChatRooms: Send + Sync {
23 /// Resolve a host-namespaced key: a community slug in Multithreaded, a stream
24 /// id in the MNW server.
25 ///
26 /// Returns `Ok(None)` when no such room exists. A room whose chat is disabled
27 /// should come back as [`crate::room::RoomState::Closed`] rather than `None`,
28 /// so the host can decide whether "disabled" and "absent" look different.
29 async fn resolve(&self, key: &str) -> Result<Option<Room>, ChatError>;
30 }
31
32 /// Why a write was refused. Carried back so the client can say something
33 /// specific instead of a bare refusal.
34 #[derive(Debug, Clone, PartialEq, Eq)]
35 pub enum DenyReason {
36 /// Not signed in.
37 Anonymous,
38 /// Signed in, but not a member of this community.
39 NotAMember,
40 /// Banned from the room.
41 Banned,
42 /// Muted: still reads, cannot write.
43 Muted,
44 /// Suspended at the platform level.
45 Suspended,
46 /// The room requires a paid tier the user does not have.
47 TierRequired,
48 /// Sending too fast.
49 RateLimited { retry_after: Duration },
50 }
51
52 /// The outcome of a write check.
53 #[derive(Debug, Clone, PartialEq, Eq)]
54 pub enum WriteAccess {
55 Allow,
56 Deny(DenyReason),
57 }
58
59 impl WriteAccess {
60 pub fn is_allowed(&self) -> bool {
61 matches!(self, Self::Allow)
62 }
63 }
64
65 /// Per-user read and write gating.
66 #[async_trait]
67 pub trait ChatAuthz: Send + Sync {
68 /// Whether this viewer may read the room.
69 ///
70 /// `viewer` is `None` for a logged-out request, which is a real case:
71 /// Multithreaded's `public_read` policy shows the room to anyone.
72 async fn can_read(&self, viewer: Option<UserId>, room: &Room) -> Result<bool, ChatError>;
73
74 /// Whether this user may send, and if not, why.
75 async fn can_write(&self, user: UserId, room: &Room) -> Result<WriteAccess, ChatError>;
76
77 /// Whether this user holds moderator powers in the room.
78 async fn is_moderator(&self, user: UserId, room: &Room) -> Result<bool, ChatError>;
79 }
80
81 /// How a user is displayed.
82 #[derive(Debug, Clone, PartialEq, Eq)]
83 pub struct Identity {
84 pub display_name: String,
85 pub avatar_url: Option<String>,
86 /// Role or badge text, if the host shows one.
87 pub flair: Option<String>,
88 }
89
90 /// Display information for participants.
91 #[async_trait]
92 pub trait ChatIdentity: Send + Sync {
93 /// Resolve many users at once.
94 ///
95 /// Batched because backlog replay needs every author in the window, and doing
96 /// that one query at a time is an N+1 on the reconnect path. Neither app gets
97 /// this for free: Multithreaded's `SessionUser` carries no `avatar_url`, so
98 /// this is a join plus a cache.
99 ///
100 /// Users the host cannot resolve are omitted from the map rather than
101 /// erroring. A deleted account should not take down the room.
102 async fn identify(&self, users: &[UserId]) -> Result<HashMap<UserId, Identity>, ChatError>;
103 }
104
105 /// Moderation actions.
106 ///
107 /// There is no edit anywhere in this trait, and that is deliberate: chat is a
108 /// stream nobody re-reads, so an edit path would be surface with no benefit and
109 /// would drag an edit-history table behind it.
110 ///
111 /// Deletion is a real delete, not a tombstone. The row expires anyway, and a
112 /// tombstone would outlive the content it describes, which is the opposite of
113 /// what bounded retention is for.
114 #[async_trait]
115 pub trait ChatModeration: Send + Sync {
116 /// Remove one message. `actor` is the author for a self-delete, or a
117 /// moderator. Implementations must verify the actor is entitled to this
118 /// message rather than trusting the caller.
119 async fn delete_message(
120 &self,
121 actor: UserId,
122 room: &Room,
123 message: MessageId,
124 ) -> Result<(), ChatError>;
125
126 /// Remove every message by `target` in the room's current retention window,
127 /// returning how many rows went.
128 ///
129 /// Issued as part of a ban. Implementations should do this as one statement
130 /// keyed by (room, author); the caller broadcasts a single
131 /// [`crate::event::ChatEvent::Purge`] rather than one event per message.
132 async fn purge_user(
133 &self,
134 actor: UserId,
135 room: &Room,
136 target: UserId,
137 ) -> Result<u64, ChatError>;
138
139 /// Block a user from sending for a bounded period. Reads are unaffected.
140 async fn timeout_user(
141 &self,
142 actor: UserId,
143 room: &Room,
144 target: UserId,
145 duration: Duration,
146 ) -> Result<(), ChatError>;
147
148 /// Ban a user from the room.
149 ///
150 /// The caller pairs this with [`ChatModeration::purge_user`]: a banned user's
151 /// messages in the retention window are the damage, so the ban removes them.
152 async fn ban_user(
153 &self,
154 actor: UserId,
155 room: &Room,
156 target: UserId,
157 reason: Option<&str>,
158 ) -> Result<(), ChatError>;
159
160 /// Record an action in the host's moderation log.
161 ///
162 /// Separate from the actions above so a host can log in the same transaction
163 /// as the mutation, which is what Multithreaded's `mod_log` expects.
164 async fn log_action(
165 &self,
166 actor: UserId,
167 room_id: RoomId,
168 action: &str,
169 detail: Option<&str>,
170 ) -> Result<(), ChatError>;
171 }
172
173 #[cfg(test)]
174 mod tests {
175 use super::*;
176
177 #[test]
178 fn only_allow_is_allowed() {
179 assert!(WriteAccess::Allow.is_allowed());
180 assert!(!WriteAccess::Deny(DenyReason::Muted).is_allowed());
181 assert!(
182 !WriteAccess::Deny(DenyReason::RateLimited {
183 retry_after: Duration::from_secs(3)
184 })
185 .is_allowed()
186 );
187 }
188 }
189