Skip to main content

max / makenotwork

multithreaded: implement the four livechat traits MT's side of the crate seam: ChatRooms, ChatAuthz, ChatIdentity and ChatModeration, plus the process state they hang off. Still no routes. ChatAuthz is the one that mattered. src/routes/scope.rs records the C1 chronic (three ultra-fuzz runs of handlers evaluating predicates against a community the slug did not name), and chat is a new surface on those same URLs. Two structural properties keep it from being the fourth instance. A gate holds the Room resolved from the slug, every predicate reads room.id, and no method takes a community id, so the divergence cannot be expressed. And good standing is now evaluate_write_access, one function that check_write_access renders as a 403 and chat renders as a DenyReason: two renderings, one predicate set, so a check added for the forum reaches chat and the two cannot disagree about who is muted. The gate is per-request because fan_plus reads UserPerks, which lives on the session rather than in the database. It refuses outright when asked about a user it was not built for, rather than answering with the wrong person's perks. Chat moderation reuses the existing stack rather than adding one. A timeout is a mute with an expires_at, a ban is the same community_bans row a forum ban writes, and every action lands in mod_log on the same transaction as the mutation it records, so a purge cannot land without its audit row. Four ModAction variants added; the message id travels in the reason text because target_id is a UUID column and a chat message id is a BIGINT. Room state folds policy, community state and suspension fail-closed: off and suspended close the room, Frozen and Archived go read-only via allows_writes_for_members() rather than a variant match, so a new CommunityState gets the same answer chat and the forum both give. Identity is cached with a TTL and a hard cap, invalidated on rename. SessionUser carries no avatar_url, and the uncached shape is one query per author per reconnecting client at the exact moment every client in the room reconnects at once. Hub and rate limits are stated explicitly against the 512M cgroup cap rather than inherited from the crate defaults, and chat gets its own send budget: USER_POST_RATE_LIMIT (15/60s) governs forum posts and would throttle a normal conversation. 25 trait tests including the full four-policy read/write matrix; 336 integration and 192 unit tests pass, clippy and fmt clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 18:52 UTC
Signed with PGP, not checked
Commit: 4e788e4175a70fe777c225902473f0aa08d78550
Parent: ccd53d5
19 files changed, +1971 insertions, -36 deletions
@@ -107,6 +107,28 @@
107 107 "serde_json",
108 108 ]
109 109
110 + [[package]]
111 + name = "async-stream"
112 + version = "0.3.6"
113 + source = "registry+https://github.com/rust-lang/crates.io-index"
114 + checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
115 + dependencies = [
116 + "async-stream-impl",
117 + "futures-core",
118 + "pin-project-lite",
119 + ]
120 +
121 + [[package]]
122 + name = "async-stream-impl"
123 + version = "0.3.6"
124 + source = "registry+https://github.com/rust-lang/crates.io-index"
125 + checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
126 + dependencies = [
127 + "proc-macro2",
128 + "quote",
129 + "syn",
130 + ]
131 +
110 132 [[package]]
111 133 name = "async-trait"
112 134 version = "0.1.89"
@@ -1968,6 +1990,23 @@
1968 1990 source = "registry+https://github.com/rust-lang/crates.io-index"
1969 1991 checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
1970 1992
1993 + [[package]]
1994 + name = "livechat"
1995 + version = "0.1.0"
1996 + dependencies = [
1997 + "async-stream",
1998 + "async-trait",
1999 + "axum",
2000 + "dashmap",
2001 + "serde",
2002 + "serde_json",
2003 + "thiserror 2.0.18",
2004 + "tokio",
2005 + "tokio-stream",
2006 + "tracing",
2007 + "uuid",
2008 + ]
2009 +
1971 2010 [[package]]
1972 2011 name = "lock_api"
1973 2012 version = "0.4.14"
@@ -2121,9 +2160,11 @@
2121 2160 version = "0.4.1"
2122 2161 dependencies = [
2123 2162 "askama",
2163 + "async-trait",
2124 2164 "axum",
2125 2165 "base64",
2126 2166 "chrono",
2167 + "dashmap",
2127 2168 "docengine",
2128 2169 "dotenvy",
2129 2170 "governor",
@@ -2131,6 +2172,7 @@
2131 2172 "hmac 0.13.0",
2132 2173 "http-body-util",
2133 2174 "include_dir",
2175 + "livechat",
2134 2176 "mt-core",
2135 2177 "mt-db",
2136 2178 "pom-contract",
@@ -3671,6 +3713,7 @@
3671 3713 "futures-core",
3672 3714 "pin-project-lite",
3673 3715 "tokio",
3716 + "tokio-util",
3674 3717 ]
3675 3718
3676 3719 [[package]]
@@ -4819,14 +4862,6 @@
4819 4862 source = "registry+https://github.com/rust-lang/crates.io-index"
4820 4863 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
4821 4864
4822 - [[patch.unused]]
4823 - name = "synckit-client"
4824 - version = "0.8.0"
4825 -
4826 - [[patch.unused]]
4827 - name = "synckit-config"
4828 - version = "0.2.0"
4829 -
4830 4865 [[patch.unused]]
4831 4866 name = "kberg"
4832 4867 version = "0.1.0"
@@ -4834,3 +4869,11 @@
4834 4869 [[patch.unused]]
4835 4870 name = "painhours"
4836 4871 version = "0.1.0"
4872 +
4873 + [[patch.unused]]
4874 + name = "synckit-client"
4875 + version = "0.8.0"
4876 +
4877 + [[patch.unused]]
4878 + name = "synckit-config"
4879 + version = "0.2.0"
@@ -47,6 +47,11 @@
47 47 base64 = "0.22"
48 48 rand = "0.10"
49 49
50 + # Live chat
51 + livechat = { path = "../shared/livechat", features = ["axum"] }
52 + async-trait = "0.1"
53 + dashmap = "6"
54 +
50 55 # S3 storage
51 56 s3-storage = { path = "../shared/s3-storage" }
52 57
@@ -105,6 +110,9 @@
105 110 tower_governor = { workspace = true }
106 111 governor = { workspace = true }
107 112 s3-storage = { workspace = true }
113 + livechat = { workspace = true }
114 + async-trait = { workspace = true }
115 + dashmap = { workspace = true }
108 116 dotenvy = "0.15"
109 117 hex = "0.4"
110 118 hmac = { workspace = true }
@@ -8,6 +8,7 @@
8 8 //! <!-- wiki: mt-overview -->
9 9
10 10 pub mod auth;
11 + pub mod chat;
11 12 pub mod config;
12 13 pub mod csrf;
13 14 pub mod error_page;
@@ -37,4 +38,13 @@
37 38 /// SSRF-safe redirect policy; `LinkPreviewFetcher::Noop` in tests.
38 39 pub link_preview: link_preview::LinkPreviewFetcher,
39 40 pub s3: Option<Arc<storage::S3Storage>>,
41 + /// Live chat: the fan-out hub and the per-(user, room) send budgets.
42 + ///
43 + /// One per process. Rooms are created lazily when someone listens and
44 + /// pruned when the last listener leaves, so a deployment where no community
45 + /// has chat enabled carries an empty map and nothing else.
46 + pub chat: Arc<livechat::Chat>,
47 + /// Display names and avatars for chat, cached with a TTL and a hard cap.
48 + /// Shared with the profile-update path, which invalidates on rename.
49 + pub chat_identities: chat::IdentityCache,
40 50 }
@@ -81,6 +81,8 @@
81 81 multithreaded::link_preview::build_preview_client(),
82 82 ),
83 83 s3,
84 + chat: std::sync::Arc::new(multithreaded::chat::new_chat()),
85 + chat_identities: multithreaded::chat::IdentityCache::new(),
84 86 };
85 87
86 88 // Session store backed by PostgreSQL, on its own small pool. Every authed
@@ -113,6 +113,8 @@
113 113 )),
114 114 None => None,
115 115 },
116 + chat: std::sync::Arc::new(multithreaded::chat::new_chat()),
117 + chat_identities: multithreaded::chat::IdentityCache::new(),
116 118 };
117 119
118 120 // Build the app with a /_test/login route for setting sessions without OAuth
@@ -45,6 +45,8 @@
45 45 http: multithreaded::tls::builder().build().unwrap(),
46 46 link_preview: multithreaded::link_preview::LinkPreviewFetcher::Noop,
47 47 s3: None,
48 + chat: std::sync::Arc::new(multithreaded::chat::new_chat()),
49 + chat_identities: multithreaded::chat::IdentityCache::new(),
48 50 };
49 51
50 52 let app = multithreaded::routes::internal::internal_routes(state);
@@ -4,6 +4,7 @@
4 4 mod auth;
5 5 mod bans;
6 6 mod chat_storage;
7 + mod chat_traits;
7 8 mod community_state;
8 9 mod crud;
9 10 mod csrf;