Skip to main content

max / makenotwork

Add livechat crate scaffold Shared live chat for Multithreaded forums and MNW stream pages. This is the vocabulary and the host seams only; hub and SSE transport follow. Bounded retention is enforced in the type: Retention has private fields and one fallible constructor, so "keep forever" is not expressible. MessageBody::parse is the only route to the send path, counting chars rather than bytes so the 500 limit means the same thing in any script. RoomState::Closed is distinct from resolve() returning None, letting a host tell "chat is off here" apart from "no such room". MessageId is i64 rather than UUID: the reconnect cursor needs a total order and chat is log-shaped. async_trait rather than native AFIT because the futures need Send to spawn onto tokio. tests/implementable.rs implements all four traits with an in-memory host that is neither a community nor a stream, checking the seams are general and usable behind dyn. Design: wiki livechat-design Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 23:44 UTC
Commit: e45245c943c31466ae5a20ed9e5ebf5cc7db0f33
Parent: a9bade6
11 files changed, +1345 insertions, -0 deletions
@@ -0,0 +1,325 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "async-trait"
7 + version = "0.1.91"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
10 + dependencies = [
11 + "proc-macro2",
12 + "quote",
13 + "syn 3.0.0",
14 + ]
15 +
16 + [[package]]
17 + name = "bumpalo"
18 + version = "3.20.3"
19 + source = "registry+https://github.com/rust-lang/crates.io-index"
20 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
21 +
22 + [[package]]
23 + name = "cfg-if"
24 + version = "1.0.4"
25 + source = "registry+https://github.com/rust-lang/crates.io-index"
26 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
27 +
28 + [[package]]
29 + name = "futures-core"
30 + version = "0.3.33"
31 + source = "registry+https://github.com/rust-lang/crates.io-index"
32 + checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
33 +
34 + [[package]]
35 + name = "futures-task"
36 + version = "0.3.33"
37 + source = "registry+https://github.com/rust-lang/crates.io-index"
38 + checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
39 +
40 + [[package]]
41 + name = "futures-util"
42 + version = "0.3.33"
43 + source = "registry+https://github.com/rust-lang/crates.io-index"
44 + checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
45 + dependencies = [
46 + "futures-core",
47 + "futures-task",
48 + "pin-project-lite",
49 + "slab",
50 + ]
51 +
52 + [[package]]
53 + name = "getrandom"
54 + version = "0.4.3"
55 + source = "registry+https://github.com/rust-lang/crates.io-index"
56 + checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
57 + dependencies = [
58 + "cfg-if",
59 + "libc",
60 + "r-efi",
61 + ]
62 +
63 + [[package]]
64 + name = "itoa"
65 + version = "1.0.18"
66 + source = "registry+https://github.com/rust-lang/crates.io-index"
67 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
68 +
69 + [[package]]
70 + name = "js-sys"
71 + version = "0.3.103"
72 + source = "registry+https://github.com/rust-lang/crates.io-index"
73 + checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
74 + dependencies = [
75 + "cfg-if",
76 + "futures-util",
77 + "wasm-bindgen",
78 + ]
79 +
80 + [[package]]
81 + name = "libc"
82 + version = "0.2.186"
83 + source = "registry+https://github.com/rust-lang/crates.io-index"
84 + checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
85 +
86 + [[package]]
87 + name = "livechat"
88 + version = "0.1.0"
89 + dependencies = [
90 + "async-trait",
91 + "serde",
92 + "serde_json",
93 + "thiserror",
94 + "tokio",
95 + "uuid",
96 + ]
97 +
98 + [[package]]
99 + name = "memchr"
100 + version = "2.8.3"
101 + source = "registry+https://github.com/rust-lang/crates.io-index"
102 + checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
103 +
104 + [[package]]
105 + name = "once_cell"
106 + version = "1.21.4"
107 + source = "registry+https://github.com/rust-lang/crates.io-index"
108 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
109 +
110 + [[package]]
111 + name = "pin-project-lite"
112 + version = "0.2.17"
113 + source = "registry+https://github.com/rust-lang/crates.io-index"
114 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
115 +
116 + [[package]]
117 + name = "proc-macro2"
118 + version = "1.0.106"
119 + source = "registry+https://github.com/rust-lang/crates.io-index"
120 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
121 + dependencies = [
122 + "unicode-ident",
123 + ]
124 +
125 + [[package]]
126 + name = "quote"
127 + version = "1.0.46"
128 + source = "registry+https://github.com/rust-lang/crates.io-index"
129 + checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
130 + dependencies = [
131 + "proc-macro2",
132 + ]
133 +
134 + [[package]]
135 + name = "r-efi"
136 + version = "6.0.0"
137 + source = "registry+https://github.com/rust-lang/crates.io-index"
138 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
139 +
140 + [[package]]
141 + name = "rustversion"
142 + version = "1.0.23"
143 + source = "registry+https://github.com/rust-lang/crates.io-index"
144 + checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
145 +
146 + [[package]]
147 + name = "serde"
148 + version = "1.0.229"
149 + source = "registry+https://github.com/rust-lang/crates.io-index"
150 + checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
151 + dependencies = [
152 + "serde_core",
153 + "serde_derive",
154 + ]
155 +
156 + [[package]]
157 + name = "serde_core"
158 + version = "1.0.229"
159 + source = "registry+https://github.com/rust-lang/crates.io-index"
160 + checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
161 + dependencies = [
162 + "serde_derive",
163 + ]
164 +
165 + [[package]]
166 + name = "serde_derive"
167 + version = "1.0.229"
168 + source = "registry+https://github.com/rust-lang/crates.io-index"
169 + checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
170 + dependencies = [
171 + "proc-macro2",
172 + "quote",
173 + "syn 3.0.0",
174 + ]
175 +
176 + [[package]]
177 + name = "serde_json"
178 + version = "1.0.150"
179 + source = "registry+https://github.com/rust-lang/crates.io-index"
180 + checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
181 + dependencies = [
182 + "itoa",
183 + "memchr",
184 + "serde",
185 + "serde_core",
186 + "zmij",
187 + ]
188 +
189 + [[package]]
190 + name = "slab"
191 + version = "0.4.12"
192 + source = "registry+https://github.com/rust-lang/crates.io-index"
193 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
194 +
195 + [[package]]
196 + name = "syn"
197 + version = "2.0.119"
198 + source = "registry+https://github.com/rust-lang/crates.io-index"
199 + checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
200 + dependencies = [
201 + "proc-macro2",
202 + "quote",
203 + "unicode-ident",
204 + ]
205 +
206 + [[package]]
207 + name = "syn"
208 + version = "3.0.0"
209 + source = "registry+https://github.com/rust-lang/crates.io-index"
210 + checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
211 + dependencies = [
212 + "proc-macro2",
213 + "quote",
214 + "unicode-ident",
215 + ]
216 +
217 + [[package]]
218 + name = "thiserror"
219 + version = "2.0.19"
220 + source = "registry+https://github.com/rust-lang/crates.io-index"
221 + checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
222 + dependencies = [
223 + "thiserror-impl",
224 + ]
225 +
226 + [[package]]
227 + name = "thiserror-impl"
228 + version = "2.0.19"
229 + source = "registry+https://github.com/rust-lang/crates.io-index"
230 + checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
231 + dependencies = [
232 + "proc-macro2",
233 + "quote",
234 + "syn 3.0.0",
235 + ]
236 +
237 + [[package]]
238 + name = "tokio"
239 + version = "1.53.0"
240 + source = "registry+https://github.com/rust-lang/crates.io-index"
241 + checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
242 + dependencies = [
243 + "pin-project-lite",
244 + "tokio-macros",
245 + ]
246 +
247 + [[package]]
248 + name = "tokio-macros"
249 + version = "2.7.1"
250 + source = "registry+https://github.com/rust-lang/crates.io-index"
251 + checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
252 + dependencies = [
253 + "proc-macro2",
254 + "quote",
255 + "syn 2.0.119",
256 + ]
257 +
258 + [[package]]
259 + name = "unicode-ident"
260 + version = "1.0.24"
261 + source = "registry+https://github.com/rust-lang/crates.io-index"
262 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
263 +
264 + [[package]]
265 + name = "uuid"
266 + version = "1.24.0"
267 + source = "registry+https://github.com/rust-lang/crates.io-index"
268 + checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
269 + dependencies = [
270 + "getrandom",
271 + "js-sys",
272 + "serde_core",
273 + "wasm-bindgen",
274 + ]
275 +
276 + [[package]]
277 + name = "wasm-bindgen"
278 + version = "0.2.126"
279 + source = "registry+https://github.com/rust-lang/crates.io-index"
280 + checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
281 + dependencies = [
282 + "cfg-if",
283 + "once_cell",
284 + "rustversion",
285 + "wasm-bindgen-macro",
286 + "wasm-bindgen-shared",
287 + ]
288 +
289 + [[package]]
290 + name = "wasm-bindgen-macro"
291 + version = "0.2.126"
292 + source = "registry+https://github.com/rust-lang/crates.io-index"
293 + checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
294 + dependencies = [
295 + "quote",
296 + "wasm-bindgen-macro-support",
297 + ]
298 +
299 + [[package]]
300 + name = "wasm-bindgen-macro-support"
301 + version = "0.2.126"
302 + source = "registry+https://github.com/rust-lang/crates.io-index"
303 + checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
304 + dependencies = [
305 + "bumpalo",
306 + "proc-macro2",
307 + "quote",
308 + "syn 2.0.119",
309 + "wasm-bindgen-shared",
310 + ]
311 +
312 + [[package]]
313 + name = "wasm-bindgen-shared"
314 + version = "0.2.126"
315 + source = "registry+https://github.com/rust-lang/crates.io-index"
316 + checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
317 + dependencies = [
318 + "unicode-ident",
319 + ]
320 +
321 + [[package]]
322 + name = "zmij"
323 + version = "1.0.23"
324 + source = "registry+https://github.com/rust-lang/crates.io-index"
325 + checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,14 @@
1 + [package]
2 + name = "livechat"
3 + version = "0.1.0"
4 + edition = "2024"
5 +
6 + [dependencies]
7 + async-trait = "0.1"
8 + serde = { version = "1", features = ["derive"] }
9 + thiserror = "2"
10 + uuid = { version = "1", features = ["v4", "serde"] }
11 +
12 + [dev-dependencies]
13 + serde_json = "1"
14 + tokio = { version = "1", features = ["macros", "rt"] }
@@ -0,0 +1,47 @@
1 + //! Crate error type.
2 + //!
3 + //! [`ChatError::Host`] is the seam for the host app: a trait impl that fails in
4 + //! the database or in its own authz layer boxes that error rather than the crate
5 + //! trying to name every failure either consumer might have.
6 +
7 + use std::time::Duration;
8 +
9 + /// Anything that can go wrong in the crate or in a host's trait impl.
10 + #[derive(Debug, thiserror::Error)]
11 + pub enum ChatError {
12 + #[error("retention exceeds the crate ceiling (asked for {max_age:?} / {max_messages} messages)")]
13 + RetentionTooLong {
14 + max_age: Duration,
15 + max_messages: usize,
16 + },
17 +
18 + #[error("retention must be non-zero in both bounds")]
19 + RetentionTooShort,
20 +
21 + #[error("message is {len} characters, over the {max} character limit")]
22 + MessageTooLong { len: usize, max: usize },
23 +
24 + #[error("message is empty")]
25 + MessageEmpty,
26 +
27 + #[error("invalid send nonce")]
28 + InvalidNonce,
29 +
30 + #[error("room is closed")]
31 + RoomClosed,
32 +
33 + #[error("room is read-only")]
34 + RoomReadOnly,
35 +
36 + /// An error from the host app's trait impl: a query failure, a session
37 + /// lookup, anything the crate has no vocabulary for.
38 + #[error("host error: {0}")]
39 + Host(#[source] Box<dyn std::error::Error + Send + Sync>),
40 + }
41 +
42 + impl ChatError {
43 + /// Wrap a host-side error.
44 + pub fn host<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
45 + Self::Host(Box::new(err))
46 + }
47 + }
@@ -0,0 +1,108 @@
1 + //! What the server sends down the stream.
2 + //!
3 + //! Deliberately four variants. Presence, typing, join, and leave are all absent
4 + //! by decision: typing in particular is the largest driver of event volume, and
5 + //! both consumers run under a hard 512M cgroup cap where an OOM restarts the
6 + //! whole site.
7 +
8 + use serde::{Deserialize, Serialize};
9 +
10 + use crate::ids::{MessageId, UserId};
11 + use crate::message::Message;
12 +
13 + /// A single frame on the stream.
14 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15 + #[serde(tag = "type", rename_all = "snake_case")]
16 + pub enum ChatEvent {
17 + /// A new message.
18 + Message(Message),
19 +
20 + /// One message removed, by its author or by a moderator.
21 + Delete { id: MessageId },
22 +
23 + /// Every message from one author removed at once, following a ban.
24 + ///
25 + /// One event rather than N `Delete`s: a ban is issued on a hot path and the
26 + /// deletion is a single statement keyed by (room, author), so fanning it out
27 + /// per message would put an unbounded burst through every open connection
28 + /// for no gain.
29 + Purge { author_id: UserId },
30 +
31 + /// The connection fell behind and messages were dropped from the broadcast
32 + /// buffer. The client re-fetches from its cursor.
33 + ///
34 + /// Never silently swallowed. SyncKit's SSE can ignore lag because its clients
35 + /// re-pull on their own schedule; a chat client that ignored it would show a
36 + /// room with a hole in it and no indication anything was missing.
37 + Gap,
38 + }
39 +
40 + impl ChatEvent {
41 + /// The SSE event name for this frame.
42 + pub fn name(&self) -> &'static str {
43 + match self {
44 + Self::Message(_) => "message",
45 + Self::Delete { .. } => "delete",
46 + Self::Purge { .. } => "purge",
47 + Self::Gap => "gap",
48 + }
49 + }
50 +
51 + /// The cursor a client should record after handling this frame, if any.
52 + ///
53 + /// Only `Message` advances the cursor. A deletion is not a position in the
54 + /// room, and reconnecting past a `Gap` is exactly what the cursor is for.
55 + pub fn cursor(&self) -> Option<MessageId> {
56 + match self {
57 + Self::Message(m) => Some(m.id),
58 + _ => None,
59 + }
60 + }
61 + }
62 +
63 + #[cfg(test)]
64 + mod tests {
65 + use super::*;
66 + use crate::ids::RoomId;
67 + use uuid::Uuid;
68 +
69 + fn message(id: i64) -> Message {
70 + Message {
71 + id: MessageId(id),
72 + room_id: RoomId(Uuid::nil()),
73 + author_id: UserId(Uuid::nil()),
74 + body_html: "hi".into(),
75 + created_at: 0,
76 + nonce: None,
77 + }
78 + }
79 +
80 + #[test]
81 + fn only_messages_advance_the_cursor() {
82 + assert_eq!(
83 + ChatEvent::Message(message(7)).cursor(),
84 + Some(MessageId(7))
85 + );
86 + assert_eq!(ChatEvent::Delete { id: MessageId(7) }.cursor(), None);
87 + assert_eq!(
88 + ChatEvent::Purge {
89 + author_id: UserId(Uuid::nil())
90 + }
91 + .cursor(),
92 + None
93 + );
94 + assert_eq!(ChatEvent::Gap.cursor(), None);
95 + }
96 +
97 + #[test]
98 + fn serializes_tagged_for_the_client() {
99 + let json = serde_json::to_string(&ChatEvent::Delete { id: MessageId(3) }).unwrap();
100 + assert!(json.contains(r#""type":"delete""#), "{json}");
101 + }
102 +
103 + #[test]
104 + fn nonce_is_omitted_when_absent() {
105 + let json = serde_json::to_string(&message(1)).unwrap();
106 + assert!(!json.contains("nonce"), "{json}");
107 + }
108 + }
@@ -0,0 +1,96 @@
1 + //! Identifier newtypes shared by both consumers.
2 + //!
3 + //! Both apps key users and rooms by UUID (Multithreaded's `users.mnw_account_id`
4 + //! and `communities.id`; the MNW server's UUID domain entities), so the crate can
5 + //! name those concretely without constraining either schema.
6 + //!
7 + //! Messages are the exception. `MessageId` is a monotonic `i64` rather than a
8 + //! UUID because the reconnect cursor (`?after=<id>`) needs a total order, and
9 + //! because chat messages are log-shaped, which is the case the MNW server already
10 + //! spells `BIGINT GENERATED ALWAYS AS IDENTITY`.
11 +
12 + use serde::{Deserialize, Serialize};
13 + use uuid::Uuid;
14 +
15 + macro_rules! uuid_id {
16 + ($name:ident, $doc:literal) => {
17 + #[doc = $doc]
18 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19 + #[serde(transparent)]
20 + pub struct $name(pub Uuid);
21 +
22 + impl From<Uuid> for $name {
23 + fn from(id: Uuid) -> Self {
24 + Self(id)
25 + }
26 + }
27 +
28 + impl From<$name> for Uuid {
29 + fn from(id: $name) -> Self {
30 + id.0
31 + }
32 + }
33 +
34 + impl std::fmt::Display for $name {
35 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 + self.0.fmt(f)
37 + }
38 + }
39 + };
40 + }
41 +
42 + uuid_id!(
43 + RoomId,
44 + "A chat room. One per community in Multithreaded; one per stream in MNW."
45 + );
46 + uuid_id!(UserId, "A chat participant, in the host app's user namespace.");
47 +
48 + /// A single message, ordered. Doubles as the reconnect cursor.
49 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
50 + #[serde(transparent)]
51 + pub struct MessageId(pub i64);
52 +
53 + impl std::fmt::Display for MessageId {
54 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 + self.0.fmt(f)
56 + }
57 + }
58 +
59 + /// Client-generated send nonce, echoed back so an optimistically rendered
60 + /// message reconciles instead of appearing twice.
61 + ///
62 + /// Client-supplied and therefore untrusted: it is opaque to the server, capped
63 + /// in length, and only ever compared for equality. It never reaches a query.
64 + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
65 + #[serde(transparent)]
66 + pub struct Nonce(pub String);
67 +
68 + impl Nonce {
69 + /// Longest accepted nonce. A client only needs enough to disambiguate its own
70 + /// in-flight sends; anything longer is someone probing.
71 + pub const MAX_LEN: usize = 64;
72 +
73 + /// Returns `None` if the nonce is empty or over [`Nonce::MAX_LEN`].
74 + pub fn parse(raw: impl Into<String>) -> Option<Self> {
75 + let raw = raw.into();
76 + (!raw.is_empty() && raw.len() <= Self::MAX_LEN).then_some(Self(raw))
77 + }
78 + }
79 +
80 + #[cfg(test)]
81 + mod tests {
82 + use super::*;
83 +
84 + #[test]
85 + fn nonce_rejects_empty_and_overlong() {
86 + assert!(Nonce::parse("").is_none());
87 + assert!(Nonce::parse("a").is_some());
88 + assert!(Nonce::parse("x".repeat(Nonce::MAX_LEN)).is_some());
89 + assert!(Nonce::parse("x".repeat(Nonce::MAX_LEN + 1)).is_none());
90 + }
91 +
92 + #[test]
93 + fn message_ids_order_by_value() {
94 + assert!(MessageId(1) < MessageId(2));
95 + }
96 + }
@@ -0,0 +1,56 @@
1 + //! Live chat shared by Multithreaded forums and MNW stream pages.
2 + //!
3 + //! Design + decisions: maintainer wiki.
4 + //! <!-- wiki: livechat-design -->
5 + //!
6 + //! A thin crate. It owns the message and event vocabulary, bounded retention,
7 + //! and (as they land) the hub and SSE transport. It owns no schema, no authz, and
8 + //! no moderation policy: those arrive through four traits the host implements, so
9 + //! Multithreaded can gate on `CommunityScope` and the MNW server on
10 + //! `SubscriptionGate` without either concept appearing here.
11 + //!
12 + //! # Shape
13 + //!
14 + //! SSE downstream, plain `POST` upstream. Not a WebSocket. The upstream volume is
15 + //! one request per message typed, so bidirectionality buys nothing, and a `POST`
16 + //! passes through the CSRF, rate-limit, and session middleware both apps already
17 + //! run. It also avoids adding a `connect-src` CSP directive on the Multithreaded
18 + //! side, which currently has none.
19 + //!
20 + //! # Ephemeral, but written down
21 + //!
22 + //! Chat in both consumers expires. Messages are stored anyway, for two reasons:
23 + //! a deploy is a symlink swap plus a restart with no connection draining, so
24 + //! every client reconnects and needs its backlog replayed from a cursor; and
25 + //! moderation has to be able to reach a message that already scrolled past.
26 + //!
27 + //! [`Retention`] makes the bound un-opt-out-able. There is no `Option`, no
28 + //! "forever" sentinel, and no public field: the only constructor rejects anything
29 + //! over the crate ceilings.
30 + //!
31 + //! # Not here, on purpose
32 + //!
33 + //! No presence, no typing indicators, no edit path, and no link previews. The
34 + //! first two are the largest drivers of event volume against a hard 512M cgroup
35 + //! cap where an OOM restarts the whole site. Edit is surface with no benefit in a
36 + //! stream nobody re-reads. Link previews would point a server-side fetcher at
37 + //! unreviewed user input at chat volume, which is a much hotter SSRF surface than
38 + //! the forum-post path it would borrow.
39 +
40 + mod error;
41 + mod event;
42 + mod ids;
43 + mod message;
44 + mod retention;
45 + mod room;
46 + mod traits;
47 +
48 + pub use error::ChatError;
49 + pub use event::ChatEvent;
50 + pub use ids::{MessageId, Nonce, RoomId, UserId};
51 + pub use message::{MAX_MESSAGE_LEN, Message, MessageBody};
52 + pub use retention::{MAX_AGE_CEILING, MAX_MESSAGES_CEILING, Retention};
53 + pub use room::{Room, RoomState};
54 + pub use traits::{
55 + ChatAuthz, ChatIdentity, ChatModeration, ChatRooms, DenyReason, Identity, WriteAccess,
56 + };
@@ -0,0 +1,115 @@
1 + //! Message body validation and the stored message shape.
2 +
3 + use serde::{Deserialize, Serialize};
4 +
5 + use crate::error::ChatError;
6 + use crate::ids::{MessageId, Nonce, RoomId, UserId};
7 +
8 + /// Longest message a room accepts.
9 + ///
10 + /// Enforced here, on the server, as the authority. The client enforces the same
11 + /// number for feedback while typing, but that is a courtesy and not a control.
12 + pub const MAX_MESSAGE_LEN: usize = 500;
13 +
14 + /// A validated message body.
15 + ///
16 + /// Constructing one is the only way to reach the send path, so "did anyone check
17 + /// the length" is not a question a caller can get wrong.
18 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19 + #[serde(transparent)]
20 + pub struct MessageBody(String);
21 +
22 + impl MessageBody {
23 + /// Trims surrounding whitespace, then rejects empty or overlong bodies.
24 + ///
25 + /// Length is counted in `chars`, not bytes, so the limit means the same thing
26 + /// to someone typing in a script that is not Latin.
27 + ///
28 + /// # Errors
29 + ///
30 + /// [`ChatError::MessageEmpty`] or [`ChatError::MessageTooLong`].
31 + pub fn parse(raw: &str) -> Result<Self, ChatError> {
32 + let trimmed = raw.trim();
33 + if trimmed.is_empty() {
34 + return Err(ChatError::MessageEmpty);
35 + }
36 + let len = trimmed.chars().count();
37 + if len > MAX_MESSAGE_LEN {
38 + return Err(ChatError::MessageTooLong {
39 + len,
40 + max: MAX_MESSAGE_LEN,
41 + });
42 + }
43 + Ok(Self(trimmed.to_owned()))
44 + }
45 +
46 + pub fn as_str(&self) -> &str {
47 + &self.0
48 + }
49 + }
50 +
51 + /// A message as stored and as broadcast.
52 + ///
53 + /// `body_html` is rendered once at insert by the host, through docengine's chat
54 + /// preset, and never re-rendered: there is no edit path anywhere in the system.
55 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56 + pub struct Message {
57 + pub id: MessageId,
58 + pub room_id: RoomId,
59 + pub author_id: UserId,
60 + pub body_html: String,
61 + /// Unix seconds. Wall clock is adequate: a single process assigns every id,
62 + /// and `MessageId` is what actually orders the room.
63 + pub created_at: i64,
64 + /// Echoed to the sender so an optimistically rendered message reconciles.
65 + /// Absent for every other recipient and for backlog replay.
66 + #[serde(skip_serializing_if = "Option::is_none")]
67 + pub nonce: Option<Nonce>,
68 + }
69 +
70 + #[cfg(test)]
71 + mod tests {
72 + use super::*;
73 +
74 + #[test]
75 + fn trims_then_rejects_empty() {
76 + assert!(matches!(
77 + MessageBody::parse(" \n\t "),
78 + Err(ChatError::MessageEmpty)
79 + ));
80 + assert!(matches!(MessageBody::parse(""), Err(ChatError::MessageEmpty)));
81 + }
82 +
83 + #[test]
84 + fn trims_surrounding_whitespace() {
85 + assert_eq!(MessageBody::parse(" hi ").unwrap().as_str(), "hi");
86 + }
87 +
88 + #[test]
89 + fn boundary_is_inclusive() {
90 + let at = "x".repeat(MAX_MESSAGE_LEN);
91 + assert!(MessageBody::parse(&at).is_ok());
92 +
93 + let over = "x".repeat(MAX_MESSAGE_LEN + 1);
94 + assert!(matches!(
95 + MessageBody::parse(&over),
96 + Err(ChatError::MessageTooLong { len, max })
97 + if len == MAX_MESSAGE_LEN + 1 && max == MAX_MESSAGE_LEN
98 + ));
99 + }
100 +
101 + #[test]
102 + fn length_counts_chars_not_bytes() {
103 + // Four bytes each: a body at the char limit is well over it in bytes,
104 + // and must still be accepted.
105 + let emoji_free_multibyte = "\u{1F00}".repeat(MAX_MESSAGE_LEN);
106 + assert!(emoji_free_multibyte.len() > MAX_MESSAGE_LEN);
107 + assert!(MessageBody::parse(&emoji_free_multibyte).is_ok());
108 + }
109 +
110 + #[test]
111 + fn length_is_measured_after_trimming() {
112 + let padded = format!(" {} ", "x".repeat(MAX_MESSAGE_LEN));
113 + assert!(MessageBody::parse(&padded).is_ok());
114 + }
115 + }
@@ -0,0 +1,143 @@
1 + //! Bounded retention. There is deliberately no way to express "keep forever".
2 + //!
3 + //! Chat in both consumers is ephemeral: messages are written down only so that a
4 + //! reconnect after a deploy is lossless and so moderation can reach a message
5 + //! that already scrolled past. Neither app is a chat archive.
6 + //!
7 + //! That intent is enforced in the type rather than in a comment. [`Retention`]
8 + //! has no `Option`, no sentinel, and no public fields; the only constructor
9 + //! rejects anything above the crate ceilings. An app cannot configure its way to
10 + //! unbounded growth, and a future caller cannot reintroduce "forever" without
11 + //! deleting this module's constructor.
12 +
13 + use std::time::{Duration, SystemTime};
14 +
15 + use crate::error::ChatError;
16 +
17 + /// Longest retention any room may request.
18 + pub const MAX_AGE_CEILING: Duration = Duration::from_secs(30 * 24 * 60 * 60);
19 +
20 + /// Most messages any room may hold.
21 + pub const MAX_MESSAGES_CEILING: usize = 20_000;
22 +
23 + /// How long a room keeps messages, and how many.
24 + ///
25 + /// Whichever bound is hit first wins: a busy room is trimmed by count long
26 + /// before `max_age` elapses, and a quiet one expires by age while well under
27 + /// `max_messages`.
28 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29 + pub struct Retention {
30 + max_age: Duration,
31 + max_messages: usize,
32 + }
33 +
34 + impl Retention {
35 + /// Both bounds are required and both are checked against the crate ceilings.
36 + ///
37 + /// # Errors
38 + ///
39 + /// [`ChatError::RetentionTooLong`] if either bound exceeds its ceiling, or
40 + /// [`ChatError::RetentionTooShort`] if either is zero. A zero bound is
41 + /// rejected rather than treated as "disabled", because a room that discards
42 + /// every message the instant it arrives is a configuration mistake, not a
43 + /// policy.
44 + pub fn new(max_age: Duration, max_messages: usize) -> Result<Self, ChatError> {
45 + if max_age.is_zero() || max_messages == 0 {
46 + return Err(ChatError::RetentionTooShort);
47 + }
48 + if max_age > MAX_AGE_CEILING || max_messages > MAX_MESSAGES_CEILING {
49 + return Err(ChatError::RetentionTooLong {
50 + max_age,
51 + max_messages,
52 + });
53 + }
54 + Ok(Self {
55 + max_age,
56 + max_messages,
57 + })
58 + }
59 +
60 + /// Multithreaded's default for a newly enabled forum room: 7 days, 5000
61 + /// messages.
62 + pub fn forum_default() -> Self {
63 + Self {
64 + max_age: Duration::from_secs(7 * 24 * 60 * 60),
65 + max_messages: 5_000,
66 + }
67 + }
68 +
69 + /// The MNW server's default for stream chat: 24 hours past the stream, 5000
70 + /// messages. A creator may raise it, still bounded by the ceilings.
71 + pub fn stream_default() -> Self {
72 + Self {
73 + max_age: Duration::from_secs(24 * 60 * 60),
74 + max_messages: 5_000,
75 + }
76 + }
77 +
78 + pub fn max_age(&self) -> Duration {
79 + self.max_age
80 + }
81 +
82 + pub fn max_messages(&self) -> usize {
83 + self.max_messages
84 + }
85 +
86 + /// When a message written at `now` should expire.
87 + ///
88 + /// Stored on the row at insert so the sweep is a single indexed delete rather
89 + /// than a join against room policy. Shortening a room's retention is
90 + /// therefore a recompute `UPDATE` over that room, which is what makes a
91 + /// shortened window apply to messages already sent.
92 + pub fn expires_at(&self, now: SystemTime) -> SystemTime {
93 + now + self.max_age
94 + }
95 + }
96 +
97 + #[cfg(test)]
98 + mod tests {
99 + use super::*;
100 +
101 + #[test]
102 + fn rejects_anything_above_the_ceilings() {
103 + assert!(matches!(
104 + Retention::new(MAX_AGE_CEILING + Duration::from_secs(1), 100),
105 + Err(ChatError::RetentionTooLong { .. })
106 + ));
107 + assert!(matches!(
108 + Retention::new(Duration::from_secs(60), MAX_MESSAGES_CEILING + 1),
109 + Err(ChatError::RetentionTooLong { .. })
110 + ));
111 + }
112 +
113 + #[test]
114 + fn accepts_exactly_the_ceilings() {
115 + assert!(Retention::new(MAX_AGE_CEILING, MAX_MESSAGES_CEILING).is_ok());
116 + }
117 +
118 + #[test]
119 + fn rejects_zero_bounds() {
120 + assert!(matches!(
121 + Retention::new(Duration::ZERO, 100),
122 + Err(ChatError::RetentionTooShort)
123 + ));
124 + assert!(matches!(
125 + Retention::new(Duration::from_secs(60), 0),
126 + Err(ChatError::RetentionTooShort)
127 + ));
128 + }
129 +
130 + #[test]
131 + fn defaults_are_within_the_ceilings() {
132 + for r in [Retention::forum_default(), Retention::stream_default()] {
133 + assert!(Retention::new(r.max_age(), r.max_messages()).is_ok());
134 + }
135 + }
136 +
137 + #[test]
138 + fn expiry_is_insert_time_plus_max_age() {
139 + let r = Retention::forum_default();
140 + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
141 + assert_eq!(r.expires_at(now), now + r.max_age());
142 + }
143 + }
@@ -0,0 +1,63 @@
1 + //! Room identity and lifecycle.
2 +
3 + use crate::ids::RoomId;
4 + use crate::retention::Retention;
5 +
6 + /// Whether a room accepts reads and writes right now.
7 + ///
8 + /// The host maps its own state onto this. Multithreaded: an active community
9 + /// with chat enabled is [`RoomState::Open`]; `Frozen` and `Archived` communities
10 + /// are [`RoomState::ReadOnly`], matching how threads already behave; a community
11 + /// with `chat_policy = off` is [`RoomState::Closed`].
12 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
13 + pub enum RoomState {
14 + /// Reads and writes, subject to per-user authz.
15 + Open,
16 + /// Reads only. The room and its backlog stay visible; nobody can send.
17 + ReadOnly,
18 + /// The room does not exist as far as the outside is concerned. Callers should
19 + /// render a 404, not a disabled input: a closed room has no route, no hub
20 + /// entry, and no UI affordance.
21 + Closed,
22 + }
23 +
24 + impl RoomState {
25 + pub fn allows_reads(&self) -> bool {
26 + matches!(self, Self::Open | Self::ReadOnly)
27 + }
28 +
29 + pub fn allows_writes(&self) -> bool {
30 + matches!(self, Self::Open)
31 + }
32 + }
33 +
34 + /// A resolved room.
35 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 + pub struct Room {
37 + pub id: RoomId,
38 + pub state: RoomState,
39 + pub retention: Retention,
40 + }
41 +
42 + #[cfg(test)]
43 + mod tests {
44 + use super::*;
45 +
46 + #[test]
47 + fn closed_rooms_allow_nothing() {
48 + assert!(!RoomState::Closed.allows_reads());
49 + assert!(!RoomState::Closed.allows_writes());
50 + }
51 +
52 + #[test]
53 + fn read_only_rooms_read_but_do_not_write() {
54 + assert!(RoomState::ReadOnly.allows_reads());
55 + assert!(!RoomState::ReadOnly.allows_writes());
56 + }
57 +
58 + #[test]
59 + fn open_rooms_allow_both() {
60 + assert!(RoomState::Open.allows_reads());
61 + assert!(RoomState::Open.allows_writes());
62 + }
63 + }
@@ -0,0 +1,188 @@
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 + }
@@ -0,0 +1,190 @@
1 + //! A throwaway in-memory host, implementing all four traits.
2 + //!
3 + //! This is not a test of behavior. It is a test that the trait shapes can
4 + //! actually be satisfied: that the signatures compose, that the lifetimes work
5 + //! through `async_trait`, and that a host can carry its own state. The design
6 + //! calls for `ChatRooms` to be provably general rather than shaped around one
7 + //! consumer, and the only way to know that is to have a second implementor that
8 + //! is nothing like a community or a stream.
9 + //!
10 + //! When Multithreaded's real impl lands, this stays. If a signature change breaks
11 + //! it, that is the signal that the change leaked host-specific assumptions into
12 + //! the crate.
13 +
14 + use std::collections::HashMap;
15 + use std::sync::Mutex;
16 + use std::time::Duration;
17 +
18 + use async_trait::async_trait;
19 + use livechat::{
20 + ChatAuthz, ChatError, ChatIdentity, ChatModeration, ChatRooms, DenyReason, Identity, MessageId,
21 + Retention, Room, RoomId, RoomState, UserId, WriteAccess,
22 + };
23 + use uuid::Uuid;
24 +
25 + #[derive(Default)]
26 + struct Host {
27 + banned: Mutex<Vec<UserId>>,
28 + purged: Mutex<Vec<(RoomId, UserId)>>,
29 + log: Mutex<Vec<String>>,
30 + }
31 +
32 + fn room(state: RoomState) -> Room {
33 + Room {
34 + id: RoomId(Uuid::nil()),
35 + state,
36 + retention: Retention::forum_default(),
37 + }
38 + }
39 +
40 + #[async_trait]
41 + impl ChatRooms for Host {
42 + async fn resolve(&self, key: &str) -> Result<Option<Room>, ChatError> {
43 + Ok(match key {
44 + "open" => Some(room(RoomState::Open)),
45 + "frozen" => Some(room(RoomState::ReadOnly)),
46 + "disabled" => Some(room(RoomState::Closed)),
47 + _ => None,
48 + })
49 + }
50 + }
51 +
52 + #[async_trait]
53 + impl ChatAuthz for Host {
54 + async fn can_read(&self, _viewer: Option<UserId>, room: &Room) -> Result<bool, ChatError> {
55 + Ok(room.state.allows_reads())
56 + }
57 +
58 + async fn can_write(&self, user: UserId, room: &Room) -> Result<WriteAccess, ChatError> {
59 + if !room.state.allows_writes() {
60 + return Ok(WriteAccess::Deny(DenyReason::NotAMember));
61 + }
62 + if self.banned.lock().unwrap().contains(&user) {
63 + return Ok(WriteAccess::Deny(DenyReason::Banned));
64 + }
65 + Ok(WriteAccess::Allow)
66 + }
67 +
68 + async fn is_moderator(&self, _user: UserId, _room: &Room) -> Result<bool, ChatError> {
69 + Ok(true)
70 + }
71 + }
72 +
73 + #[async_trait]
74 + impl ChatIdentity for Host {
75 + async fn identify(&self, users: &[UserId]) -> Result<HashMap<UserId, Identity>, ChatError> {
76 + Ok(users
77 + .iter()
78 + .map(|u| {
79 + (
80 + *u,
81 + Identity {
82 + display_name: u.to_string(),
83 + avatar_url: None,
84 + flair: None,
85 + },
86 + )
87 + })
88 + .collect())
89 + }
90 + }
91 +
92 + #[async_trait]
93 + impl ChatModeration for Host {
94 + async fn delete_message(
95 + &self,
96 + _actor: UserId,
97 + _room: &Room,
98 + _message: MessageId,
99 + ) -> Result<(), ChatError> {
100 + Ok(())
101 + }
102 +
103 + async fn purge_user(
104 + &self,
105 + _actor: UserId,
106 + room: &Room,
107 + target: UserId,
108 + ) -> Result<u64, ChatError> {
109 + self.purged.lock().unwrap().push((room.id, target));
110 + Ok(3)
111 + }
112 +
113 + async fn timeout_user(
114 + &self,
115 + _actor: UserId,
116 + _room: &Room,
117 + _target: UserId,
118 + _duration: Duration,
119 + ) -> Result<(), ChatError> {
120 + Ok(())
121 + }
122 +
123 + async fn ban_user(
124 + &self,
125 + _actor: UserId,
126 + _room: &Room,
127 + target: UserId,
128 + _reason: Option<&str>,
129 + ) -> Result<(), ChatError> {
130 + self.banned.lock().unwrap().push(target);
131 + Ok(())
132 + }
133 +
134 + async fn log_action(
135 + &self,
136 + _actor: UserId,
137 + _room_id: RoomId,
138 + action: &str,
139 + _detail: Option<&str>,
140 + ) -> Result<(), ChatError> {
141 + self.log.lock().unwrap().push(action.to_owned());
142 + Ok(())
143 + }
144 + }
145 +
146 + /// The traits must be usable behind a trait object, not only as generics. Both
147 + /// hosts will hang these off application state that is cloned per request.
148 + fn as_dyn(host: &Host) -> (&dyn ChatRooms, &dyn ChatAuthz, &dyn ChatModeration) {
149 + (host, host, host)
150 + }
151 +
152 + #[tokio::test]
153 + async fn a_host_unlike_either_consumer_can_satisfy_every_trait() {
154 + let host = Host::default();
155 + let (rooms, authz, moderation) = as_dyn(&host);
156 + let user = UserId(Uuid::new_v4());
157 +
158 + assert!(rooms.resolve("nope").await.unwrap().is_none());
159 +
160 + let open = rooms.resolve("open").await.unwrap().unwrap();
161 + assert!(authz.can_write(user, &open).await.unwrap().is_allowed());
162 +
163 + // A disabled room resolves as Closed rather than absent, so the host can tell
164 + // "chat is off here" apart from "no such room".
165 + let closed = rooms.resolve("disabled").await.unwrap().unwrap();
166 + assert_eq!(closed.state, RoomState::Closed);
167 + assert!(!authz.can_read(Some(user), &closed).await.unwrap());
168 +
169 + // Read-only rooms still serve their backlog.
170 + let frozen = rooms.resolve("frozen").await.unwrap().unwrap();
171 + assert!(authz.can_read(Some(user), &frozen).await.unwrap());
172 + assert!(!authz.can_write(user, &frozen).await.unwrap().is_allowed());
173 +
174 + // Ban then purge, which is the pairing the design calls for.
175 + moderation.ban_user(user, &open, user, None).await.unwrap();
176 + let removed = moderation.purge_user(user, &open, user).await.unwrap();
177 + assert_eq!(removed, 3);
178 + assert_eq!(
179 + authz.can_write(user, &open).await.unwrap(),
180 + WriteAccess::Deny(DenyReason::Banned)
181 + );
182 + }
183 +
184 + #[tokio::test]
185 + async fn identify_is_batched() {
186 + let host = Host::default();
187 + let users: Vec<UserId> = (0..3).map(|_| UserId(Uuid::new_v4())).collect();
188 + let resolved = host.identify(&users).await.unwrap();
189 + assert_eq!(resolved.len(), 3);
190 + }