max / multithreaded
- Co-Authored-By
- Claude Opus 4.6 <noreply@anthropic.com>
27 files changed,
+1421 insertions,
-591 deletions
| @@ -1007,6 +1007,17 @@ | |||
| 1007 | 1007 | "syn", | |
| 1008 | 1008 | ] | |
| 1009 | 1009 | ||
| 1010 | + | [[package]] | |
| 1011 | + | name = "docengine" | |
| 1012 | + | version = "0.3.0" | |
| 1013 | + | dependencies = [ | |
| 1014 | + | "ammonia", | |
| 1015 | + | "pulldown-cmark", | |
| 1016 | + | "regex-lite", | |
| 1017 | + | "serde", | |
| 1018 | + | "uuid", | |
| 1019 | + | ] | |
| 1020 | + | ||
| 1010 | 1021 | [[package]] | |
| 1011 | 1022 | name = "dotenvy" | |
| 1012 | 1023 | version = "0.15.7" | |
| @@ -2070,6 +2081,7 @@ | |||
| 2070 | 2081 | version = "0.3.1" | |
| 2071 | 2082 | dependencies = [ | |
| 2072 | 2083 | "chrono", | |
| 2084 | + | "mt-core", | |
| 2073 | 2085 | "serde", | |
| 2074 | 2086 | "sqlx", | |
| 2075 | 2087 | "tracing", | |
| @@ -2097,16 +2109,17 @@ | |||
| 2097 | 2109 | name = "multithreaded" | |
| 2098 | 2110 | version = "0.3.1" | |
| 2099 | 2111 | dependencies = [ | |
| 2100 | - | "ammonia", | |
| 2101 | 2112 | "askama", | |
| 2102 | 2113 | "aws-config", | |
| 2103 | 2114 | "aws-sdk-s3", | |
| 2104 | 2115 | "axum", | |
| 2105 | 2116 | "base64", | |
| 2106 | 2117 | "chrono", | |
| 2118 | + | "docengine", | |
| 2107 | 2119 | "dotenvy", | |
| 2108 | 2120 | "governor", | |
| 2109 | 2121 | "hex", | |
| 2122 | + | "hmac", | |
| 2110 | 2123 | "http-body-util", | |
| 2111 | 2124 | "mt-core", | |
| 2112 | 2125 | "mt-db", | |
| @@ -2118,6 +2131,7 @@ | |||
| 2118 | 2131 | "serde_json", | |
| 2119 | 2132 | "sha2", | |
| 2120 | 2133 | "sqlx", | |
| 2134 | + | "tagtree", | |
| 2121 | 2135 | "time", | |
| 2122 | 2136 | "tokio", | |
| 2123 | 2137 | "tower", | |
| @@ -3461,6 +3475,10 @@ | |||
| 3461 | 3475 | "syn", | |
| 3462 | 3476 | ] | |
| 3463 | 3477 | ||
| 3478 | + | [[package]] | |
| 3479 | + | name = "tagtree" | |
| 3480 | + | version = "0.3.0" | |
| 3481 | + | ||
| 3464 | 3482 | [[package]] | |
| 3465 | 3483 | name = "tendril" | |
| 3466 | 3484 | version = "0.4.3" |
| @@ -30,6 +30,7 @@ | |||
| 30 | 30 | # HTTP client / crypto | |
| 31 | 31 | reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } | |
| 32 | 32 | sha2 = "0.10" | |
| 33 | + | hmac = "0.12" | |
| 33 | 34 | base64 = "0.22" | |
| 34 | 35 | rand = "0.8" | |
| 35 | 36 | ||
| @@ -48,12 +49,12 @@ | |||
| 48 | 49 | chrono = { version = "0.4", features = ["serde"] } | |
| 49 | 50 | uuid = { version = "1", features = ["v4", "serde"] } | |
| 50 | 51 | pulldown-cmark = "0.12" | |
| 51 | - | ammonia = "4" | |
| 52 | 52 | askama = "0.13" | |
| 53 | 53 | ||
| 54 | 54 | # Internal crates | |
| 55 | 55 | mt-core = { path = "crates/mt-core" } | |
| 56 | 56 | mt-db = { path = "crates/mt-db" } | |
| 57 | + | tagtree = { path = "../tagtree" } | |
| 57 | 58 | ||
| 58 | 59 | [package] | |
| 59 | 60 | name = "multithreaded" | |
| @@ -82,13 +83,15 @@ | |||
| 82 | 83 | base64 = { workspace = true } | |
| 83 | 84 | rand = { workspace = true } | |
| 84 | 85 | pulldown-cmark = { workspace = true } | |
| 85 | - | ammonia = { workspace = true } | |
| 86 | + | docengine = { path = "../docengine", features = ["mentions", "quotes"] } | |
| 87 | + | tagtree = { workspace = true } | |
| 86 | 88 | tower_governor = { workspace = true } | |
| 87 | 89 | governor = { workspace = true } | |
| 88 | 90 | aws-sdk-s3 = { workspace = true } | |
| 89 | 91 | aws-config = { workspace = true } | |
| 90 | 92 | dotenvy = "0.15" | |
| 91 | 93 | hex = "0.4" | |
| 94 | + | hmac = { workspace = true } | |
| 92 | 95 | regex-lite = "0.1" | |
| 93 | 96 | urlencoding = "2" | |
| 94 | 97 | time = "0.3" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Multithreaded — Todo | |
| 2 | 2 | ||
| 3 | - | Done: All pre-beta phases (0-11, 13-24). 222 tests (150 integration + 56 unit lib + 16 unit mt-core). v0.3.0. Audit grade: A (Run 8). Deployed to hetzner+astra (forums.makenot.work). All 20 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set). | |
| 3 | + | Done: All pre-beta phases (0-11, 13-24). 239 tests (167 integration + 56 unit lib + 16 unit mt-core). v0.3.0. Audit grade: A (Run 8). Deployed to hetzner+astra (forums.makenot.work). All 20 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set). | |
| 4 | 4 | ||
| 5 | 5 | Completed work archived in [todo_done.md](todo_done.md). | |
| 6 | 6 | ||
| @@ -12,12 +12,68 @@ | |||
| 12 | 12 | ||
| 13 | 13 | --- | |
| 14 | 14 | ||
| 15 | + | ## Rust Patterns Audit (2026-03-21) | |
| 16 | + | ||
| 17 | + | ### Done | |
| 18 | + | - [x] Create `CommunityRole` enum (Owner/Moderator/Member) replacing string checks (`mt-core/types.rs`, `routes/mod.rs`) | |
| 19 | + | - [x] Create `BanType` enum (Ban/Mute) replacing `&str` parameter (`mt-core/types.rs`, `mt-db/mutations.rs`) | |
| 20 | + | - [x] Create `ModAction` enum replacing raw string mod log actions (`mt-core/types.rs`, `mt-db/mutations.rs`) | |
| 21 | + | - [x] Create `SortColumn`/`SortOrder` enums replacing raw strings (`mt-core/types.rs`, `routes/forum/views.rs`) | |
| 22 | + | - [x] Wrap `create_post` + `last_activity_at` update in a transaction (`mt-db/mutations.rs`) | |
| 23 | + | - [x] Optimize config cloning — clone once at startup, reuse reference | |
| 24 | + | ||
| 25 | + | --- | |
| 26 | + | ||
| 27 | + | ## TagTree Integration (2026-03-21) | |
| 28 | + | ||
| 29 | + | ### Done | |
| 30 | + | - [x] Add `tagtree` workspace dependency | |
| 31 | + | - [x] Replace inline tag slug validation with `tagtree::validate_with()` (TagConfig: max_depth 3, max_length 64) | |
| 32 | + | - [x] Tag slugs now support dot-separated hierarchy (e.g. `rust.async`) | |
| 33 | + | - [x] All 218 tests pass | |
| 34 | + | ||
| 35 | + | --- | |
| 36 | + | ||
| 37 | + | ## Platform Integration (Post-Beta) | |
| 38 | + | ||
| 39 | + | MT becomes the social backbone for MNW. Design doc: `docs/internal/strategy/platform-integration.md`. | |
| 40 | + | ||
| 41 | + | ### Internal API | |
| 42 | + | - [ ] `POST /internal/communities` — create community for an MNW project (HMAC-SHA256 auth) | |
| 43 | + | - [ ] `POST /internal/threads` — create thread linked to MNW item/blog post | |
| 44 | + | - [ ] `POST /internal/posts` — create system post (e.g., "this thread discusses [item]") | |
| 45 | + | - [ ] `GET /internal/threads/{id}/stats` — comment count for embedding in MNW UI | |
| 46 | + | - [ ] Auth middleware: `X-Internal-Signature` header with HMAC-SHA256(timestamp + body, shared_secret) | |
| 47 | + | - [ ] `communities.project_id` nullable FK (links back to MNW project) — migration 021 | |
| 48 | + | - [ ] `threads.external_ref` nullable (stores MNW item/blog ID for linking) — migration 021 | |
| 49 | + | ||
| 50 | + | ### Default Categories | |
| 51 | + | Auto-provisioned when MNW creates a community: | |
| 52 | + | - [ ] Items (comments on items) | |
| 53 | + | - [ ] Blog (comments on blog posts) | |
| 54 | + | - [ ] Devlog (developer updates) | |
| 55 | + | - [ ] Discussion (general) | |
| 56 | + | - [ ] Issues (git issue tracker replacement — see MNW G8-issues) | |
| 57 | + | - [ ] Patches (inbound email patches — see MNW G7B-patches) | |
| 58 | + | - [ ] Crashes (crash reports from DS2) | |
| 59 | + | - [ ] Feedback (user feedback from DS3) | |
| 60 | + | ||
| 61 | + | ### Private Communities (Fan+) | |
| 62 | + | - [ ] Community visibility flag (public/private) | |
| 63 | + | - [ ] Membership gating: restrict join to Fan+ subscribers or item buyers | |
| 64 | + | - [ ] Hidden from public listing, accessible only via direct link or MNW project page | |
| 65 | + | ||
| 66 | + | ### Notification Integration | |
| 67 | + | - [ ] Push mentions, replies, endorsements, flags to MNW notifications API | |
| 68 | + | - [ ] Read state synced with MNW notification center | |
| 69 | + | ||
| 70 | + | --- | |
| 71 | + | ||
| 15 | 72 | ## Deferred (Post-Beta) | |
| 16 | 73 | ||
| 17 | 74 | - [ ] E2E encrypted live chat (OpenMLS integration, WebSocket gateway) | |
| 18 | - | - [ ] Real-time thread updates (WebSocket or SSE for new posts) | |
| 19 | - | - [ ] Community creation by users (currently admin-seeded only) | |
| 20 | - | - [ ] Private communities (invite-only, hidden from listing) | |
| 75 | + | - [ ] Real-time thread updates via shared WebSocket gateway (shared with SyncKit realtime sync — single service) | |
| 76 | + | - [ ] Community creation by users (currently admin-seeded only; MNW auto-provisioning handles project communities) | |
| 21 | 77 | - [ ] Federation (ActivityPub or custom protocol) | |
| 22 | 78 | - [ ] Subcategories / nested categories | |
| 23 | 79 | - [ ] Similar thread detection on new thread creation | |
| @@ -40,7 +96,7 @@ | |||
| 40 | 96 | | Routes | `src/routes/` (mod.rs, forum/{mod,views,actions}.rs, moderation.rs, settings.rs, admin.rs, flagging.rs, tracking.rs, search.rs, uploads.rs) | | |
| 41 | 97 | | Auth (OAuth) | `src/auth.rs` | | |
| 42 | 98 | | CSRF | `src/csrf.rs` | | |
| 43 | - | | Markdown | `src/markdown.rs` | | |
| 99 | + | | Markdown | `docengine` crate (`active/docengine/`) — features: mentions, quotes | | |
| 44 | 100 | | Config | `src/config.rs` | | |
| 45 | 101 | | Seed data | `src/seed.rs` | | |
| 46 | 102 | | Entry point | `src/main.rs` | |
| @@ -1,10 +1,11 @@ | |||
| 1 | 1 | //! Application configuration read from environment variables. | |
| 2 | 2 | ||
| 3 | + | use std::sync::Arc; | |
| 3 | 4 | use uuid::Uuid; | |
| 4 | 5 | ||
| 5 | 6 | #[derive(Clone)] | |
| 6 | 7 | pub struct Config { | |
| 7 | - | pub mnw_base_url: String, | |
| 8 | + | pub mnw_base_url: Arc<str>, | |
| 8 | 9 | pub oauth_client_id: String, | |
| 9 | 10 | pub oauth_redirect_uri: String, | |
| 10 | 11 | pub platform_admin_id: Option<Uuid>, | |
| @@ -13,6 +14,8 @@ | |||
| 13 | 14 | pub cookie_secure: bool, | |
| 14 | 15 | /// S3 storage configuration. None if S3 env vars are missing. | |
| 15 | 16 | pub s3: Option<S3Config>, | |
| 17 | + | /// Shared secret for HMAC-signed internal API requests from MNW. | |
| 18 | + | pub internal_shared_secret: Option<String>, | |
| 16 | 19 | } | |
| 17 | 20 | ||
| 18 | 21 | #[derive(Clone)] | |
| @@ -39,7 +42,8 @@ | |||
| 39 | 42 | pub fn from_env() -> Self { | |
| 40 | 43 | Self { | |
| 41 | 44 | mnw_base_url: std::env::var("MNW_BASE_URL") | |
| 42 | - | .unwrap_or_else(|_| "http://127.0.0.1:3000".to_string()), | |
| 45 | + | .unwrap_or_else(|_| "http://127.0.0.1:3000".to_string()) | |
| 46 | + | .into(), | |
| 43 | 47 | oauth_client_id: std::env::var("OAUTH_CLIENT_ID") | |
| 44 | 48 | .expect("OAUTH_CLIENT_ID must be set"), | |
| 45 | 49 | oauth_redirect_uri: std::env::var("OAUTH_REDIRECT_URI") | |
| @@ -51,6 +55,7 @@ | |||
| 51 | 55 | .map(|v| v != "false") | |
| 52 | 56 | .unwrap_or(true), | |
| 53 | 57 | s3: S3Config::from_env(), | |
| 58 | + | internal_shared_secret: std::env::var("INTERNAL_SHARED_SECRET").ok(), | |
| 54 | 59 | } | |
| 55 | 60 | } | |
| 56 | 61 | } |
| @@ -3,8 +3,8 @@ | |||
| 3 | 3 | pub mod auth; | |
| 4 | 4 | pub mod config; | |
| 5 | 5 | pub mod csrf; | |
| 6 | + | pub mod internal_auth; | |
| 6 | 7 | pub mod link_preview; | |
| 7 | - | pub mod markdown; | |
| 8 | 8 | pub mod routes; | |
| 9 | 9 | pub mod seed; | |
| 10 | 10 | pub mod storage; |
| @@ -86,7 +86,7 @@ | |||
| 86 | 86 | )) | |
| 87 | 87 | .with_secure(state.config.cookie_secure); | |
| 88 | 88 | ||
| 89 | - | let app = multithreaded::routes::forum_routes(state) | |
| 89 | + | let app = multithreaded::routes::forum_routes(state.clone()) | |
| 90 | 90 | .layer(axum::middleware::from_fn(csrf::csrf_middleware)) | |
| 91 | 91 | .layer(session_layer) | |
| 92 | 92 | .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( | |
| @@ -103,6 +103,8 @@ | |||
| 103 | 103 | axum::http::header::X_FRAME_OPTIONS, | |
| 104 | 104 | axum::http::HeaderValue::from_static("DENY"), | |
| 105 | 105 | )) | |
| 106 | + | // Internal API routes — HMAC auth only, no CSRF/session middleware | |
| 107 | + | .merge(multithreaded::routes::internal::internal_routes(state)) | |
| 106 | 108 | .nest_service("/static", ServeDir::new("static")); | |
| 107 | 109 | ||
| 108 | 110 | let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | ||
| 6 | 6 | [dependencies] | |
| 7 | + | mt-core = { workspace = true } | |
| 7 | 8 | sqlx = { workspace = true } | |
| 8 | 9 | chrono = { workspace = true } | |
| 9 | 10 | uuid = { workspace = true } |
| @@ -13,6 +13,8 @@ | |||
| 13 | 13 | use crate::templates::*; | |
| 14 | 14 | use crate::AppState; | |
| 15 | 15 | ||
| 16 | + | use mt_core::types::ModAction; | |
| 17 | + | ||
| 16 | 18 | use super::{log_mod_action, parse_uuid, AdminSearchQuery, SuspendForm}; | |
| 17 | 19 | ||
| 18 | 20 | #[tracing::instrument(skip_all)] | |
| @@ -93,7 +95,7 @@ | |||
| 93 | 95 | ||
| 94 | 96 | log_mod_action( | |
| 95 | 97 | &state.db, None, admin.user_id, | |
| 96 | - | "suspend_community", None, Some(community_id), reason, | |
| 98 | + | ModAction::SuspendCommunity, None, Some(community_id), reason, | |
| 97 | 99 | ).await; | |
| 98 | 100 | ||
| 99 | 101 | Ok(Redirect::to("/_admin?toast=Community+suspended")) | |
| @@ -116,7 +118,7 @@ | |||
| 116 | 118 | ||
| 117 | 119 | log_mod_action( | |
| 118 | 120 | &state.db, None, admin.user_id, | |
| 119 | - | "unsuspend_community", None, Some(community_id), None, | |
| 121 | + | ModAction::UnsuspendCommunity, None, Some(community_id), None, | |
| 120 | 122 | ).await; | |
| 121 | 123 | ||
| 122 | 124 | Ok(Redirect::to("/_admin?toast=Community+unsuspended")) | |
| @@ -141,7 +143,7 @@ | |||
| 141 | 143 | ||
| 142 | 144 | log_mod_action( | |
| 143 | 145 | &state.db, None, admin.user_id, | |
| 144 | - | "suspend_user", Some(user_id), None, reason, | |
| 146 | + | ModAction::SuspendUser, Some(user_id), None, reason, | |
| 145 | 147 | ).await; | |
| 146 | 148 | ||
| 147 | 149 | Ok(Redirect::to("/_admin?toast=User+suspended")) | |
| @@ -164,7 +166,7 @@ | |||
| 164 | 166 | ||
| 165 | 167 | log_mod_action( | |
| 166 | 168 | &state.db, None, admin.user_id, | |
| 167 | - | "unsuspend_user", Some(user_id), None, None, | |
| 169 | + | ModAction::UnsuspendUser, Some(user_id), None, None, | |
| 168 | 170 | ).await; | |
| 169 | 171 | ||
| 170 | 172 | Ok(Redirect::to("/_admin?toast=User+unsuspended")) |