{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[88577c56] reconfigure\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8..82b2aa51d21ada1d0d849d3ddfc3a81e4241d861 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -9,7 +9,9 @@ use crate::{\n html::{js_string_literal, ranking_panel, JsBuilder},\n parser::parse_reddit_url,\n parser_render::navigate_panel,\n- state::AppState,\n+ path_types::ItemId,\n+ reddit::ensure_partial_tree,\n+ state::{parse_item_param, AppState},\n ui_action::{parse_html_ui_from_form, HtmlUiAction},\n };\n \n@@ -25,6 +27,10 @@ fn ui_js_warn(msg: &str) -> Response {\n .unwrap()\n }\n \n+fn parent_from_scope(scope: &str) -> ItemId {\n+ parse_item_param(scope)\n+}\n+\n pub async fn post_ui_html(\n State(state): State,\n Form(form): Form>,\n@@ -42,24 +48,36 @@ pub async fn post_ui_html(\n ratio_right,\n scope,\n } => {\n+ let parent = parent_from_scope(&scope);\n if let Err(e) = state\n- .record_vote(&scope, &a, &b, ratio_left, ratio_right)\n+ .record_vote(&parent, &a, &b, ratio_left, ratio_right)\n .await\n {\n return ui_js_warn(&e).into_response();\n }\n- let scope = crate::state::normalize_scope(&scope);\n- let groups = state.groups.read().await;\n+ let tree = state.tree.read().await;\n let empty = crate::reducer::GroupState::new();\n- let group = groups.get(&scope).unwrap_or(&empty);\n- let panel = ranking_panel(&scope, group);\n+ let group = tree\n+ .get(&parent)\n+ .map(|n| &n.local_ranking)\n+ .unwrap_or(&empty);\n+ let panel = ranking_panel(&parent, group);\n JsBuilder::new()\n .morph_selector(\"#ranking-panel\", panel)\n .into_response()\n }\n HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {\n- Ok(subreddit) => {\n- let dest = format!(\"/?sub={subreddit}\");\n+ Ok(item) => {\n+ {\n+ let mut tree = state.tree.write().await;\n+ ensure_partial_tree(&mut tree, &item);\n+ }\n+ let _ = state.ensure_node(&item).await;\n+ let dest = if item.is_root() {\n+ \"/\".to_string()\n+ } else {\n+ format!(\"/?item={}\", item.as_str())\n+ };\n JsBuilder::new()\n .raw(&format!(\n \"window.location.href={};\",\ndiff --git a/server/src/events.rs b/server/src/events.rs\nindex a862370fc840ffe02184a11c578e18239cc9474d..ed5be6b13b9d46e838831d6ce0f96f569b401730 100644\n--- a/server/src/events.rs\n+++ b/server/src/events.rs\n@@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize};\n pub enum Event {\n /// Page view recorded (path → counter in views.json).\n ViewRecorded { path: String, ts: i64 },\n- /// Pairwise comparison vote (replayed into the scope's [`crate::reducer::GroupState`] on boot).\n- /// `scope` is the ranking subject (e.g. a subreddit); empty string is the default/global scope.\n+ /// Pairwise comparison vote (replayed into the parent node's [`crate::reducer::GroupState`] on boot).\n+ /// `scope` is the parent [`crate::path_types::ItemId`] string; empty string is the tree root.\n VoteRecorded {\n ts: i64,\n a: String,\n@@ -16,4 +16,6 @@ pub enum Event {\n #[serde(default)]\n scope: String,\n },\n+ /// Register a node path in the fractal tree (no external fetch).\n+ NodeEnsured { id: String },\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 9650d333d29c4ac94ceb407aee3ee00399c7f40b..c973cb718ac74b95570dabea76e24459417790b9 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -12,9 +12,10 @@ use serde::Deserialize;\n use crate::{\n form_template::template_json_compact,\n parser_render::navigate_panel,\n+ path_types::ItemId,\n ranking::{top_bottom, RankedItem},\n- reducer::GroupState,\n- state::{normalize_scope, AppState},\n+ reducer::{GroupState, NodeState},\n+ state::{parse_item_param, AppState},\n ui_action::UI_RPC_FIELD,\n };\n \n@@ -216,6 +217,48 @@ fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str)\n }\n }\n \n+fn item_href(id: &ItemId) -> String {\n+ if id.is_root() {\n+ \"/\".to_string()\n+ } else {\n+ format!(\"/?item={}\", id.as_str())\n+ }\n+}\n+\n+fn segment_label(seg: &str) -> &str {\n+ seg\n+}\n+\n+/// Generic breadcrumb trail from an [`ItemId`] path.\n+pub fn breadcrumb_path(item: &ItemId) -> Markup {\n+ html! {\n+ nav class=\"breadcrumbs\" aria-label=\"Breadcrumb\" {\n+ a href=\"/\" { \"Internet\" }\n+ @for path in item.breadcrumb_paths() {\n+ @let seg = path.segments().last().map_or(\"\", |v| *v);\n+ span class=\"separator\" { \" / \" }\n+ a href=(item_href(&path)) { (segment_label(seg)) }\n+ }\n+ }\n+ }\n+}\n+\n+fn entity_panel(node: &NodeState) -> Markup {\n+ html! {\n+ @if let Some(data) = &node.data {\n+ section id=\"entity-panel\" class=\"demo-panel entity-card\" {\n+ h2 { (data.title) }\n+ @if let Some(author) = &data.author {\n+ p class=\"muted small\" { \"by \" (author) }\n+ }\n+ @if let Some(body) = &data.body_html {\n+ div class=\"entity-body\" { (maud::PreEscaped(body)) }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {\n html! {\n @if !items.is_empty() {\n@@ -224,7 +267,9 @@ fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {\n @for (i, r) in items.iter().enumerate() {\n li {\n span class=\"rank-num\" { (start_rank + i) \". \" }\n- strong { (r.item.as_str()) }\n+ a href=(item_href(&r.item)) {\n+ strong { (display_label(&r.item)) }\n+ }\n span class=\"muted\" {\n \" — \"\n ({ format!(\"{:.1}%\", r.score * 100.0) })\n@@ -236,23 +281,30 @@ fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {\n }\n }\n \n-pub fn ranking_panel(scope: &str, group: &GroupState) -> Markup {\n+fn display_label(id: &ItemId) -> String {\n+ id.segments()\n+ .last()\n+ .map_or(\"Internet\", |v| *v)\n+ .to_string()\n+}\n+\n+pub fn ranking_panel(item: &ItemId, group: &GroupState) -> Markup {\n let total = group.idx_to_item.len();\n let (top, bottom) = top_bottom(group, 8);\n html! {\n section id=\"ranking-panel\" class=\"demo-panel\" {\n h2 {\n \"Ranking\"\n- @if !scope.is_empty() {\n- \" — \" span class=\"scope-name\" { \"r/\" (scope) }\n+ @if !item.is_root() {\n+ \" — \" span class=\"scope-name\" { (item.as_str()) }\n }\n }\n @if total == 0 {\n p class=\"muted\" {\n- @if scope.is_empty() {\n+ @if item.is_root() {\n \"No votes yet — compare two items below.\"\n } @else {\n- \"No votes yet for r/\" (scope) \" — compare two items below to start the ranking.\"\n+ \"No votes yet for \" (item.as_str()) \" — compare two items below to start the ranking.\"\n }\n }\n } @else {\n@@ -266,7 +318,8 @@ pub fn ranking_panel(scope: &str, group: &GroupState) -> Markup {\n }\n }\n \n-pub fn vote_panel(scope: &str) -> Markup {\n+pub fn vote_panel(parent: &ItemId) -> Markup {\n+ let parent_str = parent.as_str();\n let rpc = template_json_compact(&serde_json::json!({\n \"action\": \"record_vote\",\n \"a\": {\"$form\": \"item_a\"},\n@@ -280,16 +333,17 @@ pub fn vote_panel(scope: &str) -> Markup {\n section id=\"vote-panel\" class=\"demo-panel\" {\n h2 { \"Compare\" }\n p class=\"muted small\" {\n- @if scope.is_empty() {\n+ @if parent.is_root() {\n \"Left item wins at 2:1. Votes append to the JSONL log and update rank centrality.\"\n } @else {\n- \"Ranking \" span class=\"scope-name\" { \"r/\" (scope) }\n+ \"Ranking children of \"\n+ span class=\"scope-name\" { (parent_str) }\n \". Left item wins at 2:1; each vote updates this ranking.\"\n }\n }\n form method=\"post\" action=\"/ui\" id=\"vote-form\" {\n input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc);\n- input type=\"hidden\" name=\"scope\" value=(scope);\n+ input type=\"hidden\" name=\"scope\" value=(parent_str);\n div class=\"vote-fields\" {\n label {\n \"Left (wins) \"\n@@ -329,17 +383,30 @@ pub async fn home(\n let views = state.views.get_views(&path);\n let theme = theme_from_jar(&jar);\n let theme_next = theme_next_from_uri(&uri);\n- let scope = normalize_scope(&query_param(&uri, \"sub\").unwrap_or_default());\n \n- let groups = state.groups.read().await;\n- let empty = GroupState::new();\n- let group = groups.get(&scope).unwrap_or(&empty);\n+ let item_raw = query_param(&uri, \"item\")\n+ .or_else(|| query_param(&uri, \"sub\").map(|sub| {\n+ if sub.is_empty() {\n+ String::new()\n+ } else {\n+ format!(\"reddit.com/r/{sub}\")\n+ }\n+ }))\n+ .unwrap_or_default();\n+ let item = parse_item_param(&item_raw);\n+\n+ let tree = state.tree.read().await;\n+ let empty_node = NodeState::default();\n+ let node = tree.get(&item).unwrap_or(&empty_node);\n+ let group = &node.local_ranking;\n \n let body = html! {\n h1 { \"sorter2\" }\n+ (breadcrumb_path(&item))\n (navigate_panel(\"\", None))\n- (vote_panel(&scope))\n- (ranking_panel(&scope, group))\n+ (entity_panel(node))\n+ (vote_panel(&item))\n+ (ranking_panel(&item, group))\n };\n layout(\"sorter2\", body, views, theme, &theme_next)\n }\ndiff --git a/server/src/journal.rs b/server/src/journal.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..b02ca025683621470ffdf8cd85cf9b85c56d024d\n--- /dev/null\n+++ b/server/src/journal.rs\n@@ -0,0 +1,89 @@\n+use std::sync::Arc;\n+\n+use tokio::sync::{mpsc, oneshot, RwLock};\n+\n+use crate::{\n+ event_log::EventLog,\n+ events::Event,\n+ path_types::ItemId,\n+ reducer::{GlobalTree, VoteData},\n+};\n+\n+pub struct JournalCommand {\n+ pub parent: ItemId,\n+ pub vote: VoteData,\n+ pub event: Event,\n+ pub reply: oneshot::Sender>,\n+}\n+\n+#[derive(Clone)]\n+pub struct JournalClient {\n+ tx: mpsc::Sender,\n+}\n+\n+impl JournalClient {\n+ pub fn spawn(tree: Arc>, event_log: Arc) -> Self {\n+ let (tx, rx) = mpsc::channel(64);\n+ tokio::spawn(journal_worker(rx, tree, event_log));\n+ Self { tx }\n+ }\n+\n+ pub async fn record_vote(\n+ &self,\n+ parent: ItemId,\n+ vote: VoteData,\n+ event: Event,\n+ ) -> Result<(), String> {\n+ let (reply, rx) = oneshot::channel();\n+ self.tx\n+ .send(JournalCommand {\n+ parent,\n+ vote,\n+ event,\n+ reply,\n+ })\n+ .await\n+ .map_err(|_| \"journal worker stopped\".to_string())?;\n+ rx.await\n+ .map_err(|_| \"journal worker stopped\".to_string())?\n+ }\n+}\n+\n+async fn journal_worker(\n+ mut rx: mpsc::Receiver,\n+ tree: Arc>,\n+ event_log: Arc,\n+) {\n+ while let Some(first) = rx.recv().await {\n+ let mut batch = vec![first];\n+ while let Ok(more) = rx.try_recv() {\n+ batch.push(more);\n+ }\n+\n+ let mut disk_err: Option = None;\n+ for cmd in &batch {\n+ if let Err(e) = event_log.append(&cmd.event).await {\n+ disk_err = Some(e.to_string());\n+ break;\n+ }\n+ }\n+\n+ if let Some(err) = disk_err {\n+ for cmd in batch {\n+ let _ = cmd.reply.send(Err(err.clone()));\n+ }\n+ continue;\n+ }\n+\n+ {\n+ let mut w = tree.write().await;\n+ for cmd in &batch {\n+ w.apply_vote(&cmd.parent, cmd.vote.clone());\n+ }\n+ }\n+\n+ for cmd in batch {\n+ let _ = cmd.reply.send(Ok(()));\n+ }\n+ }\n+}\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex de8bca48cbf689cad22337883e7791966e7c4919..14e7cfbc38feb5d07aff859b64bce6e2ec45cf91 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -7,8 +7,9 @@ pub mod parser;\n pub mod parser_render;\n pub mod path_types;\n pub mod ranking;\n+pub mod reddit;\n pub mod reducer;\n-pub mod settlement;\n+pub mod journal;\n pub mod state;\n pub mod ui_action;\n pub mod views;\ndiff --git a/server/src/parser.rs b/server/src/parser.rs\nindex 50571a59f0d3ece1e2538f88e00ef46ec40ea545..51aa0e0f982546aab68bd5e1ca0cb726e88b3f50 100644\n--- a/server/src/parser.rs\n+++ b/server/src/parser.rs\n@@ -1,47 +1,18 @@\n-//! Extract a subreddit name from a pasted Reddit URL or path.\n+//! Extract a canonical [`crate::path_types::ItemId`] from a pasted Reddit URL or path.\n \n-pub fn parse_reddit_url(query: &str) -> Result {\n+use crate::path_types::ItemId;\n+\n+pub fn parse_reddit_url(query: &str) -> Result {\n let q = query.trim();\n if q.is_empty() {\n return Err(\"Paste a Reddit URL or r/subreddit path\".into());\n }\n \n- if let Some(sub) = subreddit_after_prefix(q, \"r/\") {\n- return Ok(sub);\n- }\n-\n- if let Some(sub) = subreddit_from_path_segment(q, \"/r/\") {\n- return Ok(sub);\n+ if let Some(id) = ItemId::from_url(q) {\n+ return Ok(id);\n }\n \n- Err(\"Could not find a subreddit in that URL\".into())\n-}\n-\n-fn subreddit_after_prefix(text: &str, prefix: &str) -> Option {\n- let rest = text.strip_prefix(prefix)?;\n- let sub = rest.split(['/', '?', '#']).next()?.trim();\n- valid_subreddit(sub)\n-}\n-\n-fn subreddit_from_path_segment(text: &str, needle: &str) -> Option {\n- let idx = text.find(needle)?;\n- let rest = &text[idx + needle.len()..];\n- let sub = rest.split(['/', '?', '#']).next()?.trim();\n- valid_subreddit(sub)\n-}\n-\n-fn valid_subreddit(name: &str) -> Option {\n- if name.is_empty() {\n- return None;\n- }\n- if name\n- .chars()\n- .all(|c| c.is_ascii_alphanumeric() || c == '_')\n- {\n- Some(name.to_ascii_lowercase())\n- } else {\n- None\n- }\n+ Err(\"Could not parse that Reddit URL\".into())\n }\n \n #[cfg(test)]\n@@ -50,34 +21,37 @@ mod tests {\n \n #[test]\n fn parses_short_path() {\n- assert_eq!(parse_reddit_url(\"r/rust\").unwrap(), \"rust\");\n- }\n-\n- #[test]\n- fn parses_path_with_trailing_slash() {\n- assert_eq!(parse_reddit_url(\"r/rust/\").unwrap(), \"rust\");\n+ assert_eq!(\n+ parse_reddit_url(\"r/rust\").unwrap().as_str(),\n+ \"reddit.com/r/rust\"\n+ );\n }\n \n #[test]\n fn parses_full_url() {\n assert_eq!(\n- parse_reddit_url(\"https://www.reddit.com/r/programming/hot\").unwrap(),\n- \"programming\"\n+ parse_reddit_url(\"https://www.reddit.com/r/programming/hot\")\n+ .unwrap()\n+ .as_str(),\n+ \"reddit.com/r/programming\"\n );\n }\n \n #[test]\n- fn parses_url_without_scheme() {\n+ fn parses_post_url() {\n+ let id = parse_reddit_url(\n+ \"https://old.reddit.com/r/AmItheAsshole/comments/1trnvdl/aita_for_cancelling/\",\n+ )\n+ .unwrap();\n assert_eq!(\n- parse_reddit_url(\"reddit.com/r/AskReddit\").unwrap(),\n- \"askreddit\"\n+ id.as_str(),\n+ \"reddit.com/r/amitheasshole/comments/1trnvdl\"\n );\n }\n \n #[test]\n fn rejects_empty() {\n assert!(parse_reddit_url(\"\").is_err());\n- assert!(parse_reddit_url(\" \").is_err());\n }\n \n #[test]\ndiff --git a/server/src/parser_render.rs b/server/src/parser_render.rs\nindex f2341afe21476b689a536137798d97277211a962..acf2e7403f4238291677ef0c79d5766302ea78cf 100644\n--- a/server/src/parser_render.rs\n+++ b/server/src/parser_render.rs\n@@ -21,7 +21,7 @@ pub fn navigate_panel(query: &str, error: Option<&str>) -> Markup {\n p class=\"muted small\" {\n \"Paste a Reddit URL or \"\n code { \"r/subreddit\" }\n- \" path, then click Go to rank that subreddit.\"\n+ \" path. Breadcrumb links drill down the tree; rankings apply to each node's children.\"\n }\n form method=\"post\" action=\"/ui\" id=\"parser-form\" {\n textarea\ndiff --git a/server/src/path_types.rs b/server/src/path_types.rs\nindex 1cdc96a25b1954f11aaf955203e2b9907b578366..b5c41444f7bcd4d8d289ed3af464bc3f14d0df99 100644\n--- a/server/src/path_types.rs\n+++ b/server/src/path_types.rs\n@@ -1,11 +1,13 @@\n use serde::{Deserialize, Serialize};\n use std::fmt;\n \n-/// Stable item key for votes and rankings (opaque string for now).\n-#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]\n+/// Canonical hierarchical identity for any URL/path in the fractal tree.\n+#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]\n pub struct ItemId(String);\n \n impl ItemId {\n+ /// Parse an already-canonical path (no URL normalization). Empty string is invalid here;\n+ /// use [`Self::root`] for the tree root.\n pub fn parse(s: &str) -> Option {\n let t = s.trim();\n if t.is_empty() {\n@@ -14,13 +16,170 @@ impl ItemId {\n Some(Self(t.to_string()))\n }\n \n+ /// Build an opaque item key (legacy demo votes, non-URL items).\n pub fn opaque(s: impl Into) -> Self {\n Self(s.into())\n }\n \n+ /// Root of the internet tree (empty path).\n+ pub fn root() -> Self {\n+ Self(String::new())\n+ }\n+\n+ pub fn is_root(&self) -> bool {\n+ self.0.is_empty()\n+ }\n+\n pub fn as_str(&self) -> &str {\n &self.0\n }\n+\n+ /// Creates a canonical ID from a raw URL or path. Normalizes domains and\n+ /// trims tracking query params.\n+ pub fn from_url(raw_url: &str) -> Option {\n+ Self::canonicalize(raw_url).map(Self)\n+ }\n+\n+ /// Map legacy scope keys (`\"\"`, `\"rust\"`) to fractal parent nodes.\n+ pub fn from_legacy_scope(raw: &str) -> Self {\n+ let s = raw.trim();\n+ if s.is_empty() {\n+ return Self::root();\n+ }\n+ Self(format!(\"reddit.com/r/{s}\"))\n+ }\n+\n+ /// Extract the parent, e.g. `reddit.com/r/aww/comments/1trnvdl` →\n+ /// `reddit.com/r/aww`.\n+ pub fn parent(&self) -> Option {\n+ if self.0.is_empty() {\n+ return None;\n+ }\n+\n+ let parts: Vec<&str> = self.0.trim_end_matches('/').split('/').collect();\n+ if parts.len() <= 1 {\n+ return None;\n+ }\n+\n+ if self.0.contains(\"/comments/\") {\n+ return Some(Self(parts[..parts.len().saturating_sub(2)].join(\"/\")));\n+ }\n+\n+ Some(Self(parts[..parts.len() - 1].join(\"/\")))\n+ }\n+\n+ pub fn segments(&self) -> Vec<&str> {\n+ self.0.split('/').filter(|s| !s.is_empty()).collect()\n+ }\n+\n+ /// Cumulative paths for breadcrumb rendering, e.g.\n+ /// `reddit.com/r/movies` → `[\"reddit.com\", \"reddit.com/r\", \"reddit.com/r/movies\"]`.\n+ pub fn breadcrumb_paths(&self) -> Vec {\n+ let segs = self.segments();\n+ let mut paths = Vec::with_capacity(segs.len());\n+ let mut current = String::new();\n+ for seg in segs {\n+ if current.is_empty() {\n+ current = seg.to_string();\n+ } else {\n+ current.push('/');\n+ current.push_str(seg);\n+ }\n+ paths.push(ItemId(current.clone()));\n+ }\n+ paths\n+ }\n+\n+ fn canonicalize(raw: &str) -> Option {\n+ let s = raw.trim();\n+ if s.is_empty() {\n+ return None;\n+ }\n+\n+ let owned = if let Some(rest) = s.strip_prefix(\"r/\") {\n+ format!(\"reddit.com/r/{rest}\")\n+ } else if let Some(rest) = s.strip_prefix(\"/r/\") {\n+ format!(\"reddit.com/r/{rest}\")\n+ } else {\n+ s.to_string()\n+ };\n+\n+ let (host_path, _query) = split_query(&owned);\n+ let host_path = host_path.trim_end_matches('/');\n+\n+ let path = if host_path.contains(\"://\") {\n+ parse_url_host_path(host_path)?\n+ } else if host_path.starts_with(\"reddit.com\") || host_path.starts_with(\"www.reddit.com\") {\n+ normalize_reddit_host_path(host_path)\n+ } else if host_path.contains('/') {\n+ host_path.to_string()\n+ } else {\n+ return None;\n+ };\n+\n+ Some(normalize_reddit_path(&path))\n+ }\n+}\n+\n+fn split_query(s: &str) -> (&str, Option<&str>) {\n+ if let Some((path, q)) = s.split_once('?') {\n+ (path, Some(q))\n+ } else {\n+ (s, None)\n+ }\n+}\n+\n+fn parse_url_host_path(url: &str) -> Option {\n+ let rest = url\n+ .strip_prefix(\"https://\")\n+ .or_else(|| url.strip_prefix(\"http://\"))\n+ .unwrap_or(url);\n+ let (host, path) = rest.split_once('/').unwrap_or((rest, \"\"));\n+ let host = normalize_host(host);\n+ if path.is_empty() {\n+ Some(host)\n+ } else {\n+ Some(format!(\"{host}/{path}\"))\n+ }\n+}\n+\n+fn normalize_host(host: &str) -> String {\n+ let h = host\n+ .strip_prefix(\"www.\")\n+ .unwrap_or(host)\n+ .to_ascii_lowercase();\n+ if h == \"old.reddit.com\" || h == \"new.reddit.com\" || h == \"reddit.com\" {\n+ \"reddit.com\".to_string()\n+ } else {\n+ h\n+ }\n+}\n+\n+fn normalize_reddit_host_path(s: &str) -> String {\n+ let (host, path) = s.split_once('/').unwrap_or((s, \"\"));\n+ let host = normalize_host(host);\n+ if path.is_empty() {\n+ host\n+ } else {\n+ format!(\"{host}/{path}\")\n+ }\n+}\n+\n+/// Lowercase subreddit segment, drop listing suffixes, drop title slug after post id.\n+fn normalize_reddit_path(path: &str) -> String {\n+ let mut parts: Vec = path.split('/').map(str::to_string).collect();\n+ if parts.len() >= 3 && parts[1] == \"r\" {\n+ parts[2] = parts[2].to_ascii_lowercase();\n+ }\n+ if let Some(i) = parts.iter().position(|p| p == \"comments\") {\n+ if parts.len() > i + 2 {\n+ parts.truncate(i + 2);\n+ }\n+ } else if parts.len() > 3 && parts.get(1).map(|s| s.as_str()) == Some(\"r\") {\n+ // reddit.com/r/{sub}/hot → reddit.com/r/{sub}\n+ parts.truncate(3);\n+ }\n+ parts.join(\"/\")\n }\n \n impl fmt::Display for ItemId {\n@@ -28,3 +187,69 @@ impl fmt::Display for ItemId {\n f.write_str(&self.0)\n }\n }\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ #[test]\n+ fn from_url_normalizes_reddit_domains() {\n+ let id = ItemId::from_url(\n+ \"https://old.reddit.com/r/AmItheAsshole/comments/1trnvdl/aita_for_cancelling/\",\n+ )\n+ .unwrap();\n+ assert_eq!(\n+ id.as_str(),\n+ \"reddit.com/r/amitheasshole/comments/1trnvdl\"\n+ );\n+ }\n+\n+ #[test]\n+ fn from_url_strips_query() {\n+ let id = ItemId::from_url(\"https://www.reddit.com/r/rust/?sort=top\").unwrap();\n+ assert_eq!(id.as_str(), \"reddit.com/r/rust\");\n+ }\n+\n+ #[test]\n+ fn from_url_short_path() {\n+ assert_eq!(\n+ ItemId::from_url(\"r/rust\").unwrap().as_str(),\n+ \"reddit.com/r/rust\"\n+ );\n+ }\n+\n+ #[test]\n+ fn parent_of_post_is_subreddit() {\n+ let id = ItemId::parse(\"reddit.com/r/aww/comments/1trnvdl\").unwrap();\n+ assert_eq!(\n+ id.parent().unwrap().as_str(),\n+ \"reddit.com/r/aww\"\n+ );\n+ }\n+\n+ #[test]\n+ fn parent_of_subreddit_is_r_segment() {\n+ let id = ItemId::parse(\"reddit.com/r/movies\").unwrap();\n+ assert_eq!(id.parent().unwrap().as_str(), \"reddit.com/r\");\n+ }\n+\n+ #[test]\n+ fn breadcrumb_paths() {\n+ let id = ItemId::parse(\"reddit.com/r/movies\").unwrap();\n+ let crumbs = id.breadcrumb_paths();\n+ let paths: Vec<_> = crumbs.iter().map(|p| p.as_str()).collect();\n+ assert_eq!(\n+ paths,\n+ vec![\"reddit.com\", \"reddit.com/r\", \"reddit.com/r/movies\"]\n+ );\n+ }\n+\n+ #[test]\n+ fn legacy_scope_maps_to_reddit_sub() {\n+ assert_eq!(\n+ ItemId::from_legacy_scope(\"rust\").as_str(),\n+ \"reddit.com/r/rust\"\n+ );\n+ assert!(ItemId::from_legacy_scope(\"\").is_root());\n+ }\n+}\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d203dca09245daf869b3aa942898447700ae69fb\n--- /dev/null\n+++ b/server/src/reddit.rs\n@@ -0,0 +1,21 @@\n+//! Reddit API import (async, decoupled from UI request path).\n+\n+use crate::{\n+ path_types::ItemId,\n+ reducer::{EntityData, GlobalTree},\n+};\n+\n+/// Bootstrap blank nodes along a URL path so breadcrumbs and voting work before fetch.\n+pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {\n+ tree.ensure_path(id);\n+}\n+\n+/// Placeholder for Reddit JSON import. Returns entity data when implemented.\n+pub async fn fetch_reddit_entity(_id: &ItemId) -> Option {\n+ None\n+}\n+\n+/// Apply fetched entity data to a node (called from async worker).\n+pub fn apply_entity(tree: &mut GlobalTree, id: &ItemId, data: EntityData) {\n+ tree.set_entity_data(id, data);\n+}\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 8d28353e1f2a67e71b7d17a2041a0988150f3245..077f700bf00ddefe18ffd004bb5288bdc7c4adaf 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -115,6 +115,96 @@ impl GroupState {\n }\n }\n \n+/// Structured data imported from Reddit or elsewhere.\n+#[derive(Debug, Clone, Serialize, Deserialize)]\n+pub struct EntityData {\n+ pub title: String,\n+ pub author: Option,\n+ pub body_html: Option,\n+ pub thumb_url: Option,\n+}\n+\n+/// One node in the fractal tree: entity + ranked children.\n+#[derive(Debug, Clone, Default)]\n+pub struct NodeState {\n+ pub id: ItemId,\n+ pub data: Option,\n+ pub children: HashSet,\n+ pub local_ranking: GroupState,\n+}\n+\n+impl NodeState {\n+ fn new(id: ItemId) -> Self {\n+ Self {\n+ id,\n+ ..Default::default()\n+ }\n+ }\n+}\n+\n+/// Global fractal graph: every URL is both an item and a ranking scope for its children.\n+#[derive(Default)]\n+pub struct GlobalTree {\n+ pub nodes: HashMap,\n+}\n+\n+impl GlobalTree {\n+ pub fn new() -> Self {\n+ let mut tree = Self::default();\n+ tree.ensure_node(&ItemId::root());\n+ tree\n+ }\n+\n+ pub fn ensure_node(&mut self, id: &ItemId) -> &mut NodeState {\n+ if !self.nodes.contains_key(id) {\n+ self.nodes.insert(id.clone(), NodeState::new(id.clone()));\n+ }\n+ self.nodes.get_mut(id).expect(\"node just inserted\")\n+ }\n+\n+ /// Register a node and wire parent→child links along the canonical path.\n+ pub fn ensure_path(&mut self, id: &ItemId) {\n+ if id.is_root() {\n+ self.ensure_node(id);\n+ return;\n+ }\n+ self.ensure_node(&ItemId::root());\n+ for path in id.breadcrumb_paths() {\n+ self.ensure_node(&path);\n+ if let Some(parent) = path.parent() {\n+ self.ensure_node(&parent);\n+ if let Some(p) = self.nodes.get_mut(&parent) {\n+ p.children.insert(path.clone());\n+ }\n+ } else if let Some(r) = self.nodes.get_mut(&ItemId::root()) {\n+ r.children.insert(path.clone());\n+ }\n+ }\n+ }\n+\n+ pub fn get(&self, id: &ItemId) -> Option<&NodeState> {\n+ self.nodes.get(id)\n+ }\n+\n+ pub fn apply_vote(&mut self, parent: &ItemId, vote: VoteData) {\n+ self.ensure_path(parent);\n+ self.ensure_path(&vote.a);\n+ self.ensure_path(&vote.b);\n+ if let Some(node) = self.nodes.get_mut(parent) {\n+ node.children.insert(vote.a.clone());\n+ node.children.insert(vote.b.clone());\n+ node.local_ranking.apply_vote(vote);\n+ }\n+ }\n+\n+ pub fn set_entity_data(&mut self, id: &ItemId, data: EntityData) {\n+ self.ensure_path(id);\n+ if let Some(node) = self.nodes.get_mut(id) {\n+ node.data = Some(data);\n+ }\n+ }\n+}\n+\n #[cfg(test)]\n mod from_recorded_tests {\n use super::*;\n@@ -125,7 +215,20 @@ mod from_recorded_tests {\n }\n \n #[test]\n- fn rejects_empty() {\n+ fn rejects_empty_pair() {\n assert!(VoteData::from_recorded(1, \"\", \"b\", 2, 1).is_none());\n }\n+\n+ #[test]\n+ fn ensure_path_wires_children() {\n+ let mut tree = GlobalTree::new();\n+ let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ tree.ensure_path(&id);\n+ let root = tree.get(&ItemId::root()).unwrap();\n+ assert!(root.children.contains(&ItemId::parse(\"reddit.com\").unwrap()));\n+ let reddit = tree.get(&ItemId::parse(\"reddit.com\").unwrap()).unwrap();\n+ assert!(reddit.children.contains(&ItemId::parse(\"reddit.com/r\").unwrap()));\n+ let sub = tree.get(&id).unwrap();\n+ assert_eq!(sub.id, id);\n+ }\n }\ndiff --git a/server/src/settlement.rs b/server/src/settlement.rs\ndeleted file mode 100644\nindex 7a44495512b7f788239b8aeefbbd0a83656eeac6..0000000000000000000000000000000000000000\n--- a/server/src/settlement.rs\n+++ /dev/null\n@@ -1,94 +0,0 @@\n-use std::collections::HashMap;\n-use std::sync::Arc;\n-\n-use tokio::sync::{mpsc, oneshot, RwLock};\n-\n-use crate::{\n- event_log::EventLog,\n- events::Event,\n- reducer::{GroupState, VoteData},\n-};\n-\n-/// Per-scope ranking state, keyed by scope (e.g. subreddit; \"\" is the default scope).\n-pub type GroupMap = HashMap;\n-\n-pub struct SettlementCommand {\n- pub scope: String,\n- pub vote: VoteData,\n- pub event: Event,\n- pub reply: oneshot::Sender>,\n-}\n-\n-#[derive(Clone)]\n-pub struct SettlementClient {\n- tx: mpsc::Sender,\n-}\n-\n-impl SettlementClient {\n- pub fn spawn(groups: Arc>, event_log: Arc) -> Self {\n- let (tx, rx) = mpsc::channel(64);\n- tokio::spawn(settlement_worker(rx, groups, event_log));\n- Self { tx }\n- }\n-\n- pub async fn record_vote(\n- &self,\n- scope: String,\n- vote: VoteData,\n- event: Event,\n- ) -> Result<(), String> {\n- let (reply, rx) = oneshot::channel();\n- self.tx\n- .send(SettlementCommand {\n- scope,\n- vote,\n- event,\n- reply,\n- })\n- .await\n- .map_err(|_| \"settlement worker stopped\".to_string())?;\n- rx.await\n- .map_err(|_| \"settlement worker stopped\".to_string())?\n- }\n-}\n-\n-async fn settlement_worker(\n- mut rx: mpsc::Receiver,\n- groups: Arc>,\n- event_log: Arc,\n-) {\n- while let Some(first) = rx.recv().await {\n- let mut batch = vec![first];\n- while let Ok(more) = rx.try_recv() {\n- batch.push(more);\n- }\n-\n- let mut disk_err: Option = None;\n- for cmd in &batch {\n- if let Err(e) = event_log.append(&cmd.event).await {\n- disk_err = Some(e.to_string());\n- break;\n- }\n- }\n-\n- if let Some(err) = disk_err {\n- for cmd in batch {\n- let _ = cmd.reply.send(Err(err.clone()));\n- }\n- continue;\n- }\n-\n- {\n- let mut w = groups.write().await;\n- for cmd in &batch {\n- w.entry(cmd.scope.clone())\n- .or_default()\n- .apply_vote(cmd.vote.clone());\n- }\n- }\n-\n- for cmd in batch {\n- let _ = cmd.reply.send(Ok(()));\n- }\n- }\n-}\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 2d9e5226057f8615897aac48bce947643a230fb4..cc1722f5a5bf4d415f2327ea585c488a15a75592 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -1,4 +1,3 @@\n-use std::collections::HashMap;\n use std::sync::Arc;\n \n use tokio::sync::RwLock;\n@@ -6,14 +5,22 @@ use tokio::sync::RwLock;\n use crate::{\n event_log::EventLog,\n events::Event,\n- reducer::VoteData,\n- settlement::{GroupMap, SettlementClient},\n+ path_types::ItemId,\n+ reducer::{GlobalTree, VoteData},\n+ journal::JournalClient,\n views::ViewStore,\n };\n \n-/// Normalize a raw ranking subject into a scope key: strip an optional `r/`\n-/// prefix, keep only `[a-z0-9_]`, lowercase, and cap the length. Empty string\n-/// is the default/global scope.\n+/// Parse `?item=` query value into a canonical node id.\n+pub fn parse_item_param(raw: &str) -> ItemId {\n+ let s = raw.trim();\n+ if s.is_empty() {\n+ return ItemId::root();\n+ }\n+ ItemId::from_url(s).or_else(|| ItemId::parse(s)).unwrap_or_else(|| ItemId::opaque(s))\n+}\n+\n+/// Legacy: normalize raw ranking subject into a scope key for old event replay.\n pub fn normalize_scope(raw: &str) -> String {\n let s = raw.trim();\n let s = s\n@@ -27,6 +34,14 @@ pub fn normalize_scope(raw: &str) -> String {\n .collect()\n }\n \n+fn parent_from_event_scope(scope: &str) -> ItemId {\n+ if scope.contains('/') {\n+ ItemId::parse(scope).unwrap_or_else(|| ItemId::from_legacy_scope(scope))\n+ } else {\n+ ItemId::from_legacy_scope(scope)\n+ }\n+}\n+\n #[derive(Clone)]\n pub struct AppConfig {\n pub data_dir: String,\n@@ -56,8 +71,8 @@ pub struct AppState {\n pub cfg: Arc,\n pub event_log: Arc,\n pub views: ViewStore,\n- pub groups: Arc>,\n- settlement: SettlementClient,\n+ pub tree: Arc>,\n+ journal: JournalClient,\n }\n \n impl AppState {\n@@ -66,7 +81,7 @@ impl AppState {\n let views_path = format!(\"{}/views.json\", cfg.data_dir);\n let views = ViewStore::new(&views_path);\n \n- let mut groups: GroupMap = HashMap::new();\n+ let mut tree = GlobalTree::new();\n if let Ok((events, _)) = event_log.load_all().await {\n for ev in events {\n match ev {\n@@ -81,29 +96,45 @@ impl AppState {\n if let Some(vote) =\n VoteData::from_recorded(ts, &a, &b, ratio_left, ratio_right)\n {\n- groups.entry(scope).or_default().apply_vote(vote);\n+ let parent = parent_from_event_scope(&scope);\n+ tree.apply_vote(&parent, vote);\n }\n }\n Event::ViewRecorded { .. } => {}\n+ Event::NodeEnsured { id } => {\n+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n+ tree.ensure_path(&parsed);\n+ }\n+ }\n }\n }\n }\n \n- let groups = Arc::new(RwLock::new(groups));\n- let settlement = SettlementClient::spawn(groups.clone(), event_log.clone());\n+ let tree = Arc::new(RwLock::new(tree));\n+ let journal = JournalClient::spawn(tree.clone(), event_log.clone());\n \n Self {\n cfg: Arc::new(cfg),\n event_log,\n views,\n- groups,\n- settlement,\n+ tree,\n+ journal,\n }\n }\n \n+ pub async fn ensure_node(&self, id: &ItemId) -> Result<(), String> {\n+ let event = Event::NodeEnsured {\n+ id: id.as_str().to_string(),\n+ };\n+ self.event_log.append(&event).await.map_err(|e| e.to_string())?;\n+ let mut w = self.tree.write().await;\n+ w.ensure_path(id);\n+ Ok(())\n+ }\n+\n pub async fn record_vote(\n &self,\n- scope: &str,\n+ parent: &ItemId,\n a: &str,\n b: &str,\n ratio_left: i32,\n@@ -113,23 +144,24 @@ impl AppState {\n let vote = VoteData::from_recorded(ts, a, b, ratio_left, ratio_right)\n .ok_or_else(|| \"invalid vote: need two distinct non-empty items\".to_string())?;\n \n- let scope = normalize_scope(scope);\n let event = Event::VoteRecorded {\n ts,\n a: vote.a.as_str().to_string(),\n b: vote.b.as_str().to_string(),\n ratio_left: vote.ratio_left,\n ratio_right: vote.ratio_right,\n- scope: scope.clone(),\n+ scope: parent.as_str().to_string(),\n };\n \n- self.settlement.record_vote(scope, vote, event).await\n+ self.journal\n+ .record_vote(parent.clone(), vote, event)\n+ .await\n }\n }\n \n #[cfg(test)]\n mod tests {\n- use super::normalize_scope;\n+ use super::{normalize_scope, parse_item_param};\n \n #[test]\n fn normalize_scope_strips_prefix_and_lowercases() {\n@@ -138,4 +170,15 @@ mod tests {\n assert_eq!(normalize_scope(\"r/web_dev!!\"), \"web_dev\");\n assert_eq!(normalize_scope(\"\"), \"\");\n }\n+\n+ #[test]\n+ fn parse_item_param_from_url() {\n+ let id = parse_item_param(\"https://reddit.com/r/rust\");\n+ assert_eq!(id.as_str(), \"reddit.com/r/rust\");\n+ }\n+\n+ #[test]\n+ fn parse_item_param_empty_is_root() {\n+ assert!(parse_item_param(\"\").is_root());\n+ }\n }\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex 0e030b3448b8e45acbe49d2de47ea26372445c54..d798874d1d94c0dfee59ec1ff703f9ee6ef432c0 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -18,7 +18,7 @@ pub enum HtmlUiAction {\n b: String,\n ratio_left: i32,\n ratio_right: i32,\n- /// Ranking subject (e.g. a subreddit). Empty string = default/global scope.\n+ /// Parent node [`ItemId`] string; empty = tree root.\n #[serde(default)]\n scope: String,\n },\ndiff --git a/server/static/theme_default.css b/server/static/theme_default.css\nindex 6ad0ac712bbc840bedee60f613794de9385fbbd1..1bf8ac7a25207336c019f93cd4119bd0e06229f0 100644\n--- a/server/static/theme_default.css\n+++ b/server/static/theme_default.css\n@@ -137,6 +137,38 @@ code {\n margin-top: 0.5rem;\n }\n \n+.breadcrumbs {\n+ font-size: 0.875rem;\n+ margin-bottom: 1rem;\n+ color: var(--muted);\n+}\n+\n+.breadcrumbs a {\n+ color: var(--accent);\n+ text-decoration: none;\n+}\n+\n+.breadcrumbs a:hover {\n+ text-decoration: underline;\n+}\n+\n+.breadcrumbs .separator {\n+ color: var(--muted);\n+}\n+\n+.rank-list a {\n+ color: var(--accent);\n+ text-decoration: none;\n+}\n+\n+.rank-list a:hover {\n+ text-decoration: underline;\n+}\n+\n+.entity-card h2 {\n+ margin-top: 0;\n+}\n+\n .scope-name {\n color: var(--accent);\n font-weight: 600;\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex afeeee24e32f2d7f1d852ca96ac799fac8bce665..f7ac9c26bf9f9277ad5f5708603224c095165fb8 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -2,7 +2,7 @@ use std::collections::HashMap;\n use std::net::SocketAddr;\n \n use axum::Router;\n-use sorter2_server::{create_app, create_app_state, state::AppConfig, ui_action::UI_RPC_FIELD};\n+use sorter2_server::{create_app, create_app_state, path_types::ItemId, state::AppConfig, ui_action::UI_RPC_FIELD};\n use tempfile::TempDir;\n use tokio::net::TcpListener;\n \n@@ -63,9 +63,9 @@ async fn post_ui_record_vote_morphs_ranking_and_persists() {\n port: 0,\n };\n let state = create_app_state(cfg).await;\n- let groups = state.groups.read().await;\n- let group = groups.get(\"\").expect(\"default scope group after replay\");\n- let ranked = sorter2_server::ranking::ranked_items(group);\n+ let tree = state.tree.read().await;\n+ let root = tree.get(&ItemId::root()).expect(\"root node after replay\");\n+ let ranked = sorter2_server::ranking::ranked_items(&root.local_ranking);\n assert_eq!(ranked.len(), 2);\n assert_eq!(ranked[0].item.as_str(), \"alpha\");\n }\n@@ -93,5 +93,5 @@ async fn post_ui_parse_query_redirects_to_subreddit() {\n .unwrap();\n \n assert!(body.contains(\"window.location.href\"));\n- assert!(body.contains(\"/?sub=rust\"));\n+ assert!(body.contains(\"/?item=reddit.com/r/rust\"));\n }\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[c3cbcaa7] refactor\n\nSide B — unified diff (full patch):\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex b3631b06153d52f88348fef927e7a024b7b85ad6..cd556eb89e9dd8203eba6c8969ffd144db5d329d 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -71,6 +71,24 @@ pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduce\n verify_token(reduced, c.value()).ok()\n }\n \n+/// Browser session: principal + bearer token string (same shape as CLI session cookie).\n+#[derive(Debug, Clone)]\n+pub struct WebSession {\n+ pub username: String,\n+ pub bearer: String,\n+}\n+\n+/// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar).\n+pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option {\n+ let username = optional_principal(headers, jar, reduced)?;\n+ let bearer = headers\n+ .get(header::AUTHORIZATION)\n+ .and_then(|v| v.to_str().ok())\n+ .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n+ .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()))?;\n+ Some(WebSession { username, bearer })\n+}\n+\n fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {\n let mut res = Response::builder()\n .status(StatusCode::TEMPORARY_REDIRECT)\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex a986f706ea4b261cbaf004c02b4cf84184b41371..4e223a7460997c464706dca850281447bd754ed5 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -4,7 +4,6 @@ mod rpc;\n mod stream;\n mod validate;\n mod ui_html;\n-mod web_post;\n \n pub use auth::{\n get_join_invite,\n@@ -19,7 +18,9 @@ pub use auth::{\n get_web_login,\n get_logout,\n optional_principal,\n+ resolve_web_session,\n session_cookie_header_value,\n+ WebSession,\n SLUG_SESSION_COOKIE,\n };\n \n@@ -35,7 +36,6 @@ pub use stream::{get_html_stream, get_stream};\n pub use validate::{normalize_room_and_thread, validate_ingest_document, ValidatedIngest};\n \n pub use ui_html::post_ui_html;\n-pub use web_post::{check_web_ingest, post_web_ingest, post_web_redact};\n \n #[cfg(test)]\n mod tests {\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 2b40a72059981d558768f73d189b991f3448c257..497f8fdd222a8ec7c3d76b0695e0352e973ca3ba 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -1,25 +1,29 @@\n-//! Single `POST /ui` entry for browser [`crate::html::ui_action::HtmlUiAction`] (JSON in `__rpc__` + holes).\n+//! Single `POST /ui` entry: parse `__rpc__` → [`HtmlUiAction`], resolve [`WebSession`] once, dispatch.\n \n use axum::{\n- body::Body,\n+ body,\n extract::State,\n- http::{header, HeaderMap, StatusCode},\n+ http::{header, HeaderMap, HeaderValue, StatusCode},\n response::{IntoResponse, Response},\n Form,\n };\n use axum_extra::extract::cookie::CookieJar;\n+use slug_types::{RpcBatch, RpcBatchResponse, RpcCommand, RpcResult};\n use std::collections::HashMap;\n \n use crate::{\n api::{\n- auth::optional_principal,\n- web_post::{run_check_web_ingest, run_post_web_ingest, run_post_web_redact, WebPostForm, WebRedactForm},\n+ auth::{resolve_web_session, WebSession},\n+ handle_rpc_batch,\n+ rpc::{rpc_post_redact, rpc_post_with_bearer},\n },\n+ canonical_path::canonicalize_tag,\n html::{\n fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,\n- parse_html_ui_from_form, user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder,\n- ThreadNav,\n+ parse_html_ui_from_form, thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup,\n+ user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav,\n },\n+ reducer::{scope_from_room_wire, ScopeId},\n state::AppState,\n };\n \n@@ -34,6 +38,19 @@ pub async fn post_ui_html(\n Err(e) => return ui_js_warn(&e.to_string()).into_response(),\n };\n \n+ let reduced = state.reduced.read().await;\n+ let session = resolve_web_session(&headers, &jar, &reduced);\n+ drop(reduced);\n+\n+ dispatch_ui_action(&state, session.as_ref(), action).await\n+}\n+\n+/// All UI command logic: HTTP extractors stop above; this only sees [`AppState`], session, and [`HtmlUiAction`].\n+async fn dispatch_ui_action(\n+ state: &AppState,\n+ session: Option<&WebSession>,\n+ action: HtmlUiAction,\n+) -> Response {\n match action {\n HtmlUiAction::PostIngest {\n room,\n@@ -42,47 +59,87 @@ pub async fn post_ui_html(\n error_target,\n form_id,\n } => {\n- run_post_web_ingest(\n- &state,\n- &headers,\n- &jar,\n- WebPostForm {\n- room,\n- thread_tag,\n- text,\n- error_target,\n- form_id,\n- },\n- )\n- .await\n+ let Some(session) = session else {\n+ return js_redirect(\"/login\").into_response();\n+ };\n+ let room = room.trim().to_string();\n+ let thread_tag = thread_tag.trim().to_string();\n+ if text.trim().is_empty() {\n+ return form_js_error(\n+ error_target.as_ref(),\n+ \"empty post\",\n+ \"Write something in the text area (DSL / prose).\",\n+ )\n+ .into_response();\n+ }\n+ match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {\n+ Ok(RpcResult::PostOk { .. }) => {\n+ post_success_response(\n+ state,\n+ &room,\n+ &thread_tag,\n+ error_target.as_ref(),\n+ form_id.as_ref(),\n+ Some(session.username.as_str()),\n+ )\n+ .await\n+ .into_response()\n+ }\n+ Ok(_) => form_js_error(\n+ error_target.as_ref(),\n+ \"unexpected response\",\n+ \"Post did not return PostOk.\",\n+ )\n+ .into_response(),\n+ Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n+ }\n }\n HtmlUiAction::CheckIngest {\n room,\n thread_tag,\n text,\n error_target,\n- form_id,\n+ form_id: _,\n } => {\n- run_check_web_ingest(\n- &state,\n- &headers,\n- &jar,\n- WebPostForm {\n- room,\n- thread_tag,\n- text,\n- error_target,\n- form_id,\n- },\n- )\n- .await\n+ let Some(session) = session else {\n+ return js_redirect(\"/login\").into_response();\n+ };\n+ let room = room.trim().to_string();\n+ let thread_tag = canonicalize_tag(&thread_tag);\n+ if thread_tag.is_empty() {\n+ return form_js_error(\n+ error_target.as_ref(),\n+ \"missing thread tag\",\n+ \"Set a thread tag before posting.\",\n+ )\n+ .into_response();\n+ }\n+ if text.trim().is_empty() {\n+ return js_clear_errors(&form_error_target(error_target.as_ref())).into_response();\n+ }\n+ match rpc_check_with_bearer(state, &session.bearer, room, text.clone()).await {\n+ Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(error_target.as_ref())).into_response(),\n+ Ok(_) => form_js_error(error_target.as_ref(), \"unexpected response\", \"Check did not return CheckOk.\").into_response(),\n+ Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n+ }\n }\n HtmlUiAction::RedactPost { post_id } => {\n- run_post_web_redact(&state, &headers, &jar, WebRedactForm { post_id }).await\n+ let Some(session) = session else {\n+ return js_redirect(\"/login\").into_response();\n+ };\n+ let h = headers_from_bearer(&session.bearer);\n+ match rpc_post_redact(state, &h, post_id).await {\n+ Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),\n+ Ok(_) => (StatusCode::BAD_REQUEST, \"unexpected response\").into_response(),\n+ Err((msg, hint)) => {\n+ let detail = hint.as_deref().unwrap_or(\"\");\n+ js_error(\"#errors\", &msg, detail).into_response()\n+ }\n+ }\n }\n HtmlUiAction::ExpandPublicNewThreadForm => {\n let reduced = state.reduced.read().await;\n- let user = optional_principal(&headers, &jar, &reduced);\n+ let user = session.map(|s| s.username.as_str());\n drop(reduced);\n let markup = if user.is_some() {\n fragment_public_new_thread_form(true)\n@@ -99,18 +156,18 @@ pub async fn post_ui_html(\n return ui_js_warn(\"missing room\").into_response();\n }\n let reduced = state.reduced.read().await;\n- let user = optional_principal(&headers, &jar, &reduced);\n+ let user = session.map(|s| s.username.as_str());\n if !reduced.rooms.contains(&room_wire) {\n drop(reduced);\n return ui_js_warn(\"room not found\").into_response();\n }\n- if !user_can_view_room(&reduced, &room_wire, user.as_deref()) {\n+ if !user_can_view_room(&reduced, &room_wire, user) {\n drop(reduced);\n return ui_js_warn(\"forbidden\").into_response();\n }\n- let can_post = user\n+ let can_post = session\n .as_ref()\n- .map(|u| user_can_post_room(&reduced, &room_wire, u))\n+ .map(|s| user_can_post_room(&reduced, &room_wire, &s.username))\n .unwrap_or(false);\n drop(reduced);\n let Some(nav) = ThreadNav::from_room_id(&room_wire) else {\n@@ -128,12 +185,195 @@ pub async fn post_ui_html(\n }\n }\n \n+fn headers_from_bearer(bearer: &str) -> HeaderMap {\n+ let mut headers = HeaderMap::new();\n+ if let Ok(hv) = HeaderValue::from_str(&format!(\"Bearer {bearer}\")) {\n+ headers.insert(header::AUTHORIZATION, hv);\n+ }\n+ headers\n+}\n+\n+fn post_redirect_location(room: &str, thread_tag: &str) -> String {\n+ let tag = canonicalize_tag(thread_tag);\n+ if room.trim() == \"public\" {\n+ format!(\"/t/{tag}\")\n+ } else {\n+ let room = room.trim();\n+ let Some((a, b)) = room.split_once('/') else {\n+ return \"/\".to_string();\n+ };\n+ format!(\"/r/{a}/{b}/t/{tag}\")\n+ }\n+}\n+\n+fn js_quote(s: &str) -> String {\n+ serde_json::to_string(s).expect(\"js string escaping must succeed\")\n+}\n+\n+fn js_redirect(to: &str) -> Response {\n+ let js = format!(\"window.location = {};\", js_quote(to));\n+ Response::builder()\n+ .status(StatusCode::OK)\n+ .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n+ .body(axum::body::Body::from(js))\n+ .unwrap()\n+}\n+\n+fn js_error(error_target: &str, title: &str, detail: &str) -> Response {\n+ let markup = maud::html! {\n+ div id=(error_target.trim_start_matches('#')) {\n+ p class=\"auth-error\" { (title) }\n+ @if !detail.is_empty() {\n+ pre class=\"muted\" { (detail) }\n+ }\n+ }\n+ };\n+ JsBuilder::new()\n+ .morph_selector(error_target, markup)\n+ .into_response()\n+}\n+\n+fn form_error_target(error_target: Option<&String>) -> String {\n+ error_target\n+ .map(|s| s.as_str())\n+ .filter(|s| !s.trim().is_empty())\n+ .map(|s| {\n+ if s.starts_with('#') {\n+ s.to_string()\n+ } else {\n+ format!(\"#{s}\")\n+ }\n+ })\n+ .unwrap_or_else(|| \"#errors\".to_string())\n+}\n+\n+fn form_js_error(error_target: Option<&String>, title: &str, detail: &str) -> Response {\n+ js_error(&form_error_target(error_target), title, detail)\n+}\n+\n+fn js_clear_errors(error_target: &str) -> Response {\n+ let markup = maud::html! {\n+ div id=(error_target.trim_start_matches('#')) {}\n+ };\n+ JsBuilder::new()\n+ .morph_selector(error_target, markup)\n+ .into_response()\n+}\n+\n+fn empty_error_markup(error_target: &str) -> maud::Markup {\n+ maud::html! {\n+ div id=(error_target.trim_start_matches('#')) {}\n+ }\n+}\n+\n+async fn rpc_check_with_bearer(\n+ state: &AppState,\n+ bearer_token: &str,\n+ room: String,\n+ text: String,\n+) -> Result)> {\n+ let mut headers = HeaderMap::new();\n+ let hv = HeaderValue::from_str(&format!(\"Bearer {bearer_token}\"))\n+ .map_err(|_| (\"invalid session token\".into(), None))?;\n+ headers.insert(header::AUTHORIZATION, hv);\n+\n+ let response = handle_rpc_batch(\n+ State(state.clone()),\n+ headers,\n+ axum::Json(RpcBatch(vec![RpcCommand::Check { room, text }])),\n+ )\n+ .await\n+ .into_response();\n+\n+ let status = response.status();\n+ if !status.is_success() {\n+ return Err((format!(\"rpc check http {}\", status), None));\n+ }\n+\n+ let body = body::to_bytes(response.into_body(), usize::MAX)\n+ .await\n+ .map_err(|e| (e.to_string(), None))?;\n+ let parsed: RpcBatchResponse =\n+ serde_json::from_slice(&body).map_err(|e| (e.to_string(), None))?;\n+ let line = parsed\n+ .results\n+ .into_iter()\n+ .next()\n+ .ok_or_else(|| (\"empty rpc check response\".to_string(), None))?;\n+ if line.ok {\n+ line.result\n+ .ok_or_else(|| (\"missing rpc check result\".to_string(), None))\n+ } else {\n+ Err((\n+ line.error.unwrap_or_else(|| \"check failed\".to_string()),\n+ line.hint,\n+ ))\n+ }\n+}\n+\n+async fn post_success_response(\n+ state: &AppState,\n+ room: &str,\n+ thread_tag: &str,\n+ error_target: Option<&String>,\n+ form_id: Option<&String>,\n+ viewer: Option<&str>,\n+) -> Response {\n+ let error_target = form_error_target(error_target);\n+ let room = room.trim().to_string();\n+ let thread_tag = canonicalize_tag(thread_tag);\n+ let thread_location = post_redirect_location(&room, &thread_tag);\n+ let form_id = form_id.map(|s| s.as_str()).unwrap_or(\"\");\n+ let scope = scope_from_room_wire(&room);\n+ let feed_markup = match &scope {\n+ ScopeId::Public => thread_feed_html(state).await,\n+ ScopeId::Room(_) => thread_feed_html_for_room(state, &room).await,\n+ };\n+ let thread_markup = thread_feed_region_markup(\n+ state,\n+ match &scope {\n+ ScopeId::Public => None,\n+ ScopeId::Room(_) => Some(room.as_str()),\n+ },\n+ &thread_tag,\n+ viewer.as_deref(),\n+ )\n+ .await;\n+ let feed_selector = match &scope {\n+ ScopeId::Public => \"#thread-feed\",\n+ ScopeId::Room(_) => \"#room-thread-feed\",\n+ };\n+\n+ let builder = JsBuilder::new()\n+ .morph_selector(&error_target, empty_error_markup(&error_target))\n+ .morph_selector(feed_selector, feed_markup)\n+ .if_current_path_matches(&thread_location, |builder| {\n+ let builder = builder.morph_selector(\"#thread-feed-region\", thread_markup);\n+ let builder = if !form_id.trim().is_empty() {\n+ builder.qs(&format!(\"#{form_id}\")).reset()\n+ } else {\n+ builder\n+ };\n+ builder\n+ })\n+ .if_current_path_not_matches(&thread_location, |builder| builder.redirect(&thread_location));\n+\n+ builder.into_response()\n+}\n+\n+async fn redact_success_response(state: &AppState) -> Response {\n+ let feed_markup = thread_feed_html(state).await;\n+ JsBuilder::new()\n+ .morph_selector(\"#thread-feed\", feed_markup)\n+ .into_response()\n+}\n+\n fn ui_js_warn(msg: &str) -> Response {\n use crate::html::js_string_literal;\n let js = format!(\"console.warn({});\", js_string_literal(msg));\n Response::builder()\n .status(StatusCode::OK)\n .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n- .body(Body::from(js))\n+ .body(axum::body::Body::from(js))\n .unwrap()\n }\ndiff --git a/server/src/api/web_post.rs b/server/src/api/web_post.rs\ndeleted file mode 100644\nindex 265025f41ff1548d05b2d2d5d84202245388053f..0000000000000000000000000000000000000000\n--- a/server/src/api/web_post.rs\n+++ /dev/null\n@@ -1,362 +0,0 @@\n-use axum::{\n- body,\n- extract::State,\n- http::{header, HeaderMap, HeaderValue, StatusCode},\n- response::{IntoResponse, Response},\n- Form,\n-};\n-use axum_extra::extract::cookie::CookieJar;\n-use serde::Deserialize;\n-use slug_types::{RpcBatch, RpcBatchResponse, RpcCommand, RpcResult};\n-\n-use crate::{\n- api::{\n- auth::{optional_principal, SLUG_SESSION_COOKIE},\n- handle_rpc_batch,\n- rpc::{rpc_post_redact, rpc_post_with_bearer},\n- },\n- canonical_path::canonicalize_tag,\n- html::{thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup, JsBuilder},\n- reducer::{scope_from_room_wire, ScopeId},\n- state::AppState,\n-};\n-\n-#[derive(Debug, Deserialize)]\n-pub struct WebPostForm {\n- pub room: String,\n- pub thread_tag: String,\n- pub text: String,\n- #[serde(default)]\n- pub error_target: Option,\n- #[serde(default)]\n- pub form_id: Option,\n-}\n-\n-#[derive(Debug, Deserialize)]\n-pub struct WebRedactForm {\n- pub post_id: String,\n-}\n-\n-fn post_redirect_location(room: &str, thread_tag: &str) -> String {\n- let tag = canonicalize_tag(thread_tag);\n- if room.trim() == \"public\" {\n- format!(\"/t/{tag}\")\n- } else {\n- let room = room.trim();\n- let Some((a, b)) = room.split_once('/') else {\n- return \"/\".to_string();\n- };\n- format!(\"/r/{a}/{b}/t/{tag}\")\n- }\n-}\n-\n-fn js_quote(s: &str) -> String {\n- serde_json::to_string(s).expect(\"js string escaping must succeed\")\n-}\n-\n-fn js_redirect(to: &str) -> Response {\n- let js = format!(\"window.location = {};\", js_quote(to));\n- Response::builder()\n- .status(StatusCode::OK)\n- .header(header::CONTENT_TYPE, \"text/javascript; charset=utf-8\")\n- .body(axum::body::Body::from(js))\n- .unwrap()\n-}\n-\n-fn js_error(error_target: &str, title: &str, detail: &str) -> Response {\n- let markup = maud::html! {\n- div id=(error_target.trim_start_matches('#')) {\n- p class=\"auth-error\" { (title) }\n- @if !detail.is_empty() {\n- pre class=\"muted\" { (detail) }\n- }\n- }\n- };\n- JsBuilder::new()\n- .morph_selector(error_target, markup)\n- .into_response()\n-}\n-\n-fn form_error_target(form: &WebPostForm) -> String {\n- form.error_target\n- .as_deref()\n- .filter(|s| !s.trim().is_empty())\n- .map(|s| {\n- if s.starts_with('#') {\n- s.to_string()\n- } else {\n- format!(\"#{s}\")\n- }\n- })\n- .unwrap_or_else(|| \"#errors\".to_string())\n-}\n-\n-fn form_js_error(form: &WebPostForm, title: &str, detail: &str) -> Response {\n- js_error(&form_error_target(form), title, detail)\n-}\n-\n-fn js_clear_errors(error_target: &str) -> Response {\n- let markup = maud::html! {\n- div id=(error_target.trim_start_matches('#')) {}\n- };\n- JsBuilder::new()\n- .morph_selector(error_target, markup)\n- .into_response()\n-}\n-\n-fn empty_error_markup(error_target: &str) -> maud::Markup {\n- maud::html! {\n- div id=(error_target.trim_start_matches('#')) {}\n- }\n-}\n-\n-async fn rpc_check_with_bearer(\n- state: &AppState,\n- bearer_token: &str,\n- room: String,\n- text: String,\n-) -> Result)> {\n- let mut headers = HeaderMap::new();\n- let hv = HeaderValue::from_str(&format!(\"Bearer {bearer_token}\"))\n- .map_err(|_| (\"invalid session token\".into(), None))?;\n- headers.insert(header::AUTHORIZATION, hv);\n-\n- let response = handle_rpc_batch(\n- State(state.clone()),\n- headers,\n- axum::Json(RpcBatch(vec![RpcCommand::Check { room, text }])),\n- )\n- .await\n- .into_response();\n-\n- let status = response.status();\n- if !status.is_success() {\n- return Err((format!(\"rpc check http {}\", status), None));\n- }\n-\n- let body = body::to_bytes(response.into_body(), usize::MAX)\n- .await\n- .map_err(|e| (e.to_string(), None))?;\n- let parsed: RpcBatchResponse =\n- serde_json::from_slice(&body).map_err(|e| (e.to_string(), None))?;\n- let line = parsed\n- .results\n- .into_iter()\n- .next()\n- .ok_or_else(|| (\"empty rpc check response\".to_string(), None))?;\n- if line.ok {\n- line.result\n- .ok_or_else(|| (\"missing rpc check result\".to_string(), None))\n- } else {\n- Err((\n- line.error.unwrap_or_else(|| \"check failed\".to_string()),\n- line.hint,\n- ))\n- }\n-}\n-\n-fn post_success_response<'a>(\n- state: &'a AppState,\n- form: &'a WebPostForm,\n- headers: &'a HeaderMap,\n- jar: &'a CookieJar,\n-) -> impl std::future::Future + 'a {\n- async move {\n- let error_target = form_error_target(form);\n- let room = form.room.trim().to_string();\n- let thread_tag = canonicalize_tag(&form.thread_tag);\n- let thread_location = post_redirect_location(&room, &thread_tag);\n- let form_id = form.form_id.as_deref().unwrap_or_default();\n- let scope = scope_from_room_wire(&room);\n- let viewer = {\n- let reduced = state.reduced.read().await;\n- optional_principal(headers, jar, &reduced)\n- };\n- let feed_markup = match &scope {\n- ScopeId::Public => thread_feed_html(state).await,\n- ScopeId::Room(_) => thread_feed_html_for_room(state, &room).await,\n- };\n- let thread_markup = thread_feed_region_markup(\n- state,\n- match &scope {\n- ScopeId::Public => None,\n- ScopeId::Room(_) => Some(room.as_str()),\n- },\n- &thread_tag,\n- viewer.as_deref(),\n- )\n- .await;\n- let feed_selector = match &scope {\n- ScopeId::Public => \"#thread-feed\",\n- ScopeId::Room(_) => \"#room-thread-feed\",\n- };\n-\n- let builder = JsBuilder::new()\n- .morph_selector(&error_target, empty_error_markup(&error_target))\n- .morph_selector(feed_selector, feed_markup)\n- .if_current_path_matches(&thread_location, |builder| {\n- let builder = builder.morph_selector(\"#thread-feed-region\", thread_markup);\n- let builder = if !form_id.trim().is_empty() {\n- builder.qs(&format!(\"#{form_id}\")).reset()\n- } else {\n- builder\n- };\n- builder\n- })\n- .if_current_path_not_matches(&thread_location, |builder| builder.redirect(&thread_location));\n-\n- builder.into_response()\n- }\n-}\n-\n-async fn redact_success_response(state: &AppState) -> Response {\n- let feed_markup = thread_feed_html(state).await;\n- JsBuilder::new()\n- .morph_selector(\"#thread-feed\", feed_markup)\n- .into_response()\n-}\n-\n-pub async fn post_web_redact(\n- State(state): State,\n- headers: HeaderMap,\n- jar: CookieJar,\n- Form(form): Form,\n-) -> impl IntoResponse {\n- run_post_web_redact(&state, &headers, &jar, form).await\n-}\n-\n-/// Shared with [`crate::api::ui_html::post_ui_html`].\n-pub(crate) async fn run_post_web_redact(\n- state: &AppState,\n- headers: &HeaderMap,\n- jar: &CookieJar,\n- form: WebRedactForm,\n-) -> Response {\n- let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(headers, jar, &reduced) else {\n- drop(reduced);\n- return js_redirect(\"/login\").into_response();\n- };\n- drop(reduced);\n-\n- let bearer = headers\n- .get(axum::http::header::AUTHORIZATION)\n- .and_then(|v| v.to_str().ok())\n- .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n- .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()));\n-\n- let Some(_bearer) = bearer else {\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- match rpc_post_redact(state, headers, form.post_id).await {\n- Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),\n- Ok(_) => (StatusCode::BAD_REQUEST, \"unexpected response\").into_response(),\n- Err((msg, hint)) => {\n- let detail = hint.as_deref().unwrap_or(\"\");\n- js_error(\"#errors\", &msg, detail).into_response()\n- }\n- }\n-}\n-\n-pub async fn post_web_ingest(\n- State(state): State,\n- headers: HeaderMap,\n- jar: CookieJar,\n- Form(form): Form,\n-) -> impl IntoResponse {\n- run_post_web_ingest(&state, &headers, &jar, form).await\n-}\n-\n-/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).\n-pub(crate) async fn run_post_web_ingest(\n- state: &AppState,\n- headers: &HeaderMap,\n- jar: &CookieJar,\n- form: WebPostForm,\n-) -> Response {\n- let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(headers, jar, &reduced) else {\n- drop(reduced);\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let bearer = headers\n- .get(axum::http::header::AUTHORIZATION)\n- .and_then(|v| v.to_str().ok())\n- .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n- .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()));\n-\n- drop(reduced);\n-\n- let Some(bearer) = bearer else {\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let room = form.room.trim().to_string();\n- let thread_tag = form.thread_tag.trim().to_string();\n- let text = form.text.clone();\n-\n- if text.trim().is_empty() {\n- return form_js_error(&form, \"empty post\", \"Write something in the text area (DSL / prose).\")\n- .into_response();\n- }\n-\n- match rpc_post_with_bearer(state, &bearer, room.clone(), thread_tag.clone(), text).await {\n- Ok(RpcResult::PostOk { .. }) => post_success_response(state, &form, headers, jar)\n- .await\n- .into_response(),\n- Ok(_) => form_js_error(&form, \"unexpected response\", \"Post did not return PostOk.\").into_response(),\n- Err((msg, hint)) => form_js_error(&form, &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n- }\n-}\n-\n-pub async fn check_web_ingest(\n- State(state): State,\n- headers: HeaderMap,\n- jar: CookieJar,\n- Form(form): Form,\n-) -> impl IntoResponse {\n- run_check_web_ingest(&state, &headers, &jar, form).await\n-}\n-\n-/// Shared with [`crate::api::ui_html::post_ui_html`] (`POST /ui`).\n-pub(crate) async fn run_check_web_ingest(\n- state: &AppState,\n- headers: &HeaderMap,\n- jar: &CookieJar,\n- form: WebPostForm,\n-) -> Response {\n- let reduced = state.reduced.read().await;\n- let Some(_username) = optional_principal(headers, jar, &reduced) else {\n- drop(reduced);\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let bearer = headers\n- .get(axum::http::header::AUTHORIZATION)\n- .and_then(|v| v.to_str().ok())\n- .and_then(|s| s.strip_prefix(\"Bearer \").map(|t| t.trim().to_string()))\n- .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()));\n- drop(reduced);\n-\n- let Some(bearer) = bearer else {\n- return js_redirect(\"/login\").into_response();\n- };\n-\n- let room = form.room.trim().to_string();\n- let thread_tag = canonicalize_tag(&form.thread_tag);\n- if thread_tag.is_empty() {\n- return form_js_error(&form, \"missing thread tag\", \"Set a thread tag before posting.\").into_response();\n- }\n-\n- if form.text.trim().is_empty() {\n- return js_clear_errors(&form_error_target(&form)).into_response();\n- }\n-\n- match rpc_check_with_bearer(state, &bearer, room, form.text.clone()).await {\n- Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(&form)).into_response(),\n- Ok(_) => form_js_error(&form, \"unexpected response\", \"Check did not return CheckOk.\").into_response(),\n- Err((msg, hint)) => form_js_error(&form, &msg, hint.as_deref().unwrap_or(\"\")).into_response(),\n- }\n-}\ndiff --git a/server/src/html/forum.rs b/server/src/html/forum.rs\nindex b1c037f3dd93c7bb96d6624cab6019228432c501..a173e7aea7a81c7cfab5f2f6a5549e0db560fb2b 100644\n--- a/server/src/html/forum.rs\n+++ b/server/src/html/forum.rs\n@@ -17,6 +17,7 @@ use crate::{\n state::AppState,\n timeago,\n };\n+use serde_json::json;\n \n use super::ui_action::{HtmlUiAction, UI_RPC_FIELD};\n \n@@ -182,8 +183,8 @@ fn post_header_row(\n div class=\"ingest-header-row\" {\n (meta)\n @if show_delete {\n- form class=\"post-delete-form\" method=\"POST\" action=\"/post/redact\" {\n- input type=\"hidden\" name=\"post_id\" value=(ing.id);\n+ form class=\"post-delete-form\" method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::RedactPost { post_id: ing.id.clone() }).unwrap());\n button type=\"submit\" class=\"post-delete-btn\" { \"delete\" }\n }\n }\n@@ -427,13 +428,28 @@ fn compose_form(nav: &ThreadNav, thread_tag: &str, show: bool) -> Markup {\n if !show {\n return html! {};\n }\n+ let rpc_post = template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": thread_tag,\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"thread-compose-errors\",\n+ \"form_id\": \"thread-compose-form\",\n+ }))\n+ .unwrap();\n+ let rpc_check = template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": thread_tag,\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"thread-compose-errors\",\n+ \"form_id\": \"thread-compose-form\",\n+ }))\n+ .unwrap();\n html! {\n section class=\"compose\" id=\"thread-compose\" {\n- form id=\"thread-compose-form\" method=\"POST\" action=\"/post\" data-check-action=\"/post/check\" {\n- input type=\"hidden\" name=\"room\" value=(nav.room_wire.clone());\n- input type=\"hidden\" name=\"thread_tag\" value=(thread_tag);\n- input type=\"hidden\" name=\"error_target\" value=\"thread-compose-errors\";\n- input type=\"hidden\" name=\"form_id\" value=\"thread-compose-form\";\n+ form id=\"thread-compose-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(rpc_check) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_post);\n textarea name=\"text\" rows=\"5\" cols=\"80\" placeholder=\"prose or ~/items and votes…\" {}\n p {\n button type=\"submit\" { \"post\" }\n@@ -463,10 +479,22 @@ fn new_thread_form_public(show: bool) -> Markup {\n h3 { \"new public thread\" }\n p class=\"muted\" { \"Set thread tag and body. Example: start with a title line or use the CLI-shaped DSL.\" }\n div id=\"public-new-thread-errors\" {}\n- form id=\"public-new-thread-form\" method=\"POST\" action=\"/post\" data-check-action=\"/post/check\" {\n- input type=\"hidden\" name=\"room\" value=\"public\";\n- input type=\"hidden\" name=\"error_target\" value=\"public-new-thread-errors\";\n- input type=\"hidden\" name=\"form_id\" value=\"public-new-thread-form\";\n+ form id=\"public-new-thread-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"public-new-thread-errors\",\n+ \"form_id\": \"public-new-thread-form\",\n+ })).unwrap()) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"public-new-thread-errors\",\n+ \"form_id\": \"public-new-thread-form\",\n+ })).unwrap());\n label for=\"new-thread-tag\" { \"thread tag\" }\n input type=\"text\" id=\"new-thread-tag\" name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" placeholder=\"my-topic\";\n label for=\"new-thread-text\" { \"text\" }\n@@ -946,10 +974,22 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool) -> Markup {\n section class=\"compose\" id=\"room-new-thread-compose\" hidden {\n h3 { \"new thread in this room\" }\n div id=\"room-new-thread-errors\" {}\n- form id=\"room-new-thread-form\" method=\"POST\" action=\"/post\" data-check-action=\"/post/check\" {\n- input type=\"hidden\" name=\"room\" value=(nav.room_wire.clone());\n- input type=\"hidden\" name=\"error_target\" value=\"room-new-thread-errors\";\n- input type=\"hidden\" name=\"form_id\" value=\"room-new-thread-form\";\n+ form id=\"room-new-thread-form\" method=\"POST\" action=\"/ui\" data-check-action=\"/ui\" data-check-rpc=(template_json_compact(&json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"room-new-thread-errors\",\n+ \"form_id\": \"room-new-thread-form\",\n+ })).unwrap()) {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"room-new-thread-errors\",\n+ \"form_id\": \"room-new-thread-form\",\n+ })).unwrap());\n label for=\"room-new-tag\" { \"thread tag\" }\n input type=\"text\" id=\"room-new-tag\" name=\"thread_tag\" pattern=\"[a-z0-9_\\\\-]{1,64}\" required;\n textarea name=\"text\" rows=\"4\" placeholder=\"First post body…\" required {}\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex a4fd2dd60ac283f7eb9b2e64522501b21d1e27e9..63f723804be511af80b5c13e792109754b606fce 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -373,9 +373,14 @@ script { (maud::PreEscaped(r#\"\n async function runFormCheck(form) {\n const action = form.getAttribute('data-check-action');\n if (!action) return;\n+ const fd = new URLSearchParams(new FormData(form));\n+ const checkRpc = form.getAttribute('data-check-rpc');\n+ if (checkRpc) {\n+ fd.set('__rpc__', checkRpc);\n+ }\n const resp = await fetch(action, {\n method: 'POST',\n- body: new URLSearchParams(new FormData(form)),\n+ body: fd,\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n credentials: 'same-origin',\n });\ndiff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs\nindex 3bc69ecbb7e16a59d2d393b2d59f9cd3fedb12b1..73180737db8ba8d16fb7b42b790e44d2d7b796f5 100644\n--- a/server/src/html/ui_action.rs\n+++ b/server/src/html/ui_action.rs\n@@ -14,7 +14,7 @@ pub const UI_RPC_FIELD: &str = \"__rpc__\";\n #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n #[serde(tag = \"action\", rename_all = \"snake_case\")]\n pub enum HtmlUiAction {\n- /// Same semantics as `POST /post` (forum ingest).\n+ /// Forum ingest via `POST /ui`.\n PostIngest {\n room: String,\n thread_tag: String,\n@@ -24,7 +24,7 @@ pub enum HtmlUiAction {\n #[serde(default)]\n form_id: Option,\n },\n- /// Same as `POST /post/check`.\n+ /// DSL check / validation via `POST /ui`.\n CheckIngest {\n room: String,\n thread_tag: String,\n@@ -34,7 +34,7 @@ pub enum HtmlUiAction {\n #[serde(default)]\n form_id: Option,\n },\n- /// Same as `POST /post/redact`.\n+ /// Author redacts own post via `POST /ui`.\n RedactPost {\n post_id: String,\n },\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 5f5b6b0f01b29350c7415e5801ec5caa74f0b452..c28c2335d23080671e71155ea3fb023f9b8d98c4 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -31,9 +31,6 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/\", get(crate::html::home))\n .route(\"/login\", get(api::get_web_login))\n .route(\"/logout\", get(api::get_logout))\n- .route(\"/post\", post(api::post_web_ingest))\n- .route(\"/post/redact\", post(api::post_web_redact))\n- .route(\"/post/check\", post(api::check_web_ingest))\n .route(\"/ui\", post(api::post_ui_html))\n .route(\"/theme\", post(crate::html::post_theme))\n .route(\"/sse\", get(api::get_html_stream))\n@@ -64,6 +61,7 @@ pub fn create_app(state: AppState) -> Router {\n )\n .route(\"/t/:tag/:index\", get(crate::html::thread_post_view))\n .route(\"/t/:tag\", get(crate::html::thread_view))\n+ // TODO DELETE THESE AND USE __RPC__ PATTERN\n .route(\n \"/r/:room_short/:room_slug/t/:thread_tag/:index/expand\",\n get(crate::html::room_thread_post_expand),\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex d86de2dd446516807a3c8a607aa88787d1b136b0..831722430d6b41308e3f03d485cd62f887ace799 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -21,6 +21,30 @@ fn test_bearer() -> String {\n format!(\"slug_{token_id}_{secret}\")\n }\n \n+/// Compact JSON for `POST /ui` (`HtmlUiAction::PostIngest`).\n+fn ui_post_ingest_rpc(room: &str, thread_tag: &str, text: &str) -> String {\n+ serde_json::json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": room,\n+ \"thread_tag\": thread_tag,\n+ \"text\": text,\n+ })\n+ .to_string()\n+}\n+\n+/// Compact JSON for `POST /ui` (`HtmlUiAction::CheckIngest`).\n+fn ui_check_ingest_rpc(room: &str, thread_tag: &str, text: &str, error_target: &str) -> String {\n+ serde_json::json!({\n+ \"action\": \"check_ingest\",\n+ \"room\": room,\n+ \"thread_tag\": thread_tag,\n+ \"text\": text,\n+ \"error_target\": error_target,\n+ \"form_id\": \"thread-compose-form\",\n+ })\n+ .to_string()\n+}\n+\n /// `commands` is a JSON array of RPC commands (`RpcBatch` is a transparent `Vec`).\n async fn rpc_batch(\n client: &reqwest::Client,\n@@ -343,14 +367,11 @@ async fn test_private_room_thread_urls_use_t_segment() {\n .to_string();\n let (room_short, room_slug) = room_id.split_once('/').unwrap();\n \n+ let rpc = ui_post_ingest_rpc(&room_id, \"main-thread\", \"private post via web\");\n let post = client\n- .post(format!(\"http://{addr}/post\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", room_id.as_str()),\n- (\"thread_tag\", \"main-thread\"),\n- (\"text\", \"private post via web\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\n@@ -404,14 +425,15 @@ async fn test_private_room_post_links_use_private_garden_routes() {\n .to_string();\n let (room_short, room_slug) = room_id.split_once('/').unwrap();\n \n+ let rpc = ui_post_ingest_rpc(\n+ &room_id,\n+ \"garden-thread\",\n+ \"~/secret/item {classified}\\n~/secret/other {other body}\\n~/secret/item 3:1 ~/secret/other {because}\\n\",\n+ );\n let post = client\n- .post(format!(\"http://{addr}/post\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", room_id.as_str()),\n- (\"thread_tag\", \"garden-thread\"),\n- (\"text\", \"~/secret/item {classified}\\n~/secret/other {other body}\\n~/secret/item 3:1 ~/secret/other {because}\\n\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\n@@ -449,15 +471,11 @@ async fn test_post_check_returns_targeted_js_error_for_missing_thread_tag() {\n .unwrap();\n let bearer = test_bearer();\n \n+ let rpc = ui_check_ingest_rpc(\"public\", \"\", \"hello\", \"thread-compose-errors\");\n let resp = client\n- .post(format!(\"http://{addr}/post/check\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", \"public\"),\n- (\"thread_tag\", \"\"),\n- (\"text\", \"hello\"),\n- (\"error_target\", \"thread-compose-errors\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\n@@ -553,14 +571,11 @@ async fn test_sse_stream_emits_evalable_js_after_post() {\n .unwrap();\n assert!(sse_resp.status().is_success());\n \n+ let rpc = ui_post_ingest_rpc(&room_id, \"live-thread\", \"hello over sse\");\n let _post = client\n- .post(format!(\"http://{addr}/post\"))\n+ .post(format!(\"http://{addr}/ui\"))\n .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n- .form(&[\n- (\"room\", room_id.as_str()),\n- (\"thread_tag\", \"live-thread\"),\n- (\"text\", \"hello over sse\"),\n- ])\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n .send()\n .await\n .unwrap();\ndiff --git a/test/walkthrough_fixture.clj b/test/walkthrough_fixture.clj\nindex ec7914464b1a0389e40c5be53af59cf8ed82b09e..4b814822029c913e38151ca53848d1c64fb58373 100644\n--- a/test/walkthrough_fixture.clj\n+++ b/test/walkthrough_fixture.clj\n@@ -52,22 +52,28 @@\n \"~/secret/item {classified}\\n\"\n \"~/secret/other {secondary}\\n\"\n \"~/secret/item 3:1 ~/secret/other {because}\\n\")\n+ rpc (json/generate-string\n+ {:action \"post_ingest\"\n+ :room room-id\n+ :thread_tag \"walkthrough-thread\"\n+ :text wall-text})\n post-resp (oauth/http-post-form\n- (str base-url \"/post\")\n- {:room room-id\n- :thread_tag \"walkthrough-thread\"\n- :text wall-text}\n+ (str base-url \"/ui\")\n+ {:__rpc__ rpc}\n :headers {\"Authorization\" (str \"Bearer \" alice-token)})]\n (assert! (= 200 (:status post-resp)) \"seed post must succeed\"))\n (let [bob-reply (str \"Bob here — reply with another long block so the thread has multiple cards.\\n\\n\"\n \"Paragraph two: repeating slugs ~/secret/item and ~/secret/other for cross-post link styling. \"\n \"If everything wraps cleanly, monospace pre + serif body (in craft theme) should still feel readable.\\n\\n\"\n \"More overflow: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\\n\")\n+ bob-rpc (json/generate-string\n+ {:action \"post_ingest\"\n+ :room room-id\n+ :thread_tag \"walkthrough-thread\"\n+ :text bob-reply})\n bob-post (oauth/http-post-form\n- (str base-url \"/post\")\n- {:room room-id\n- :thread_tag \"walkthrough-thread\"\n- :text bob-reply}\n+ (str base-url \"/ui\")\n+ {:__rpc__ bob-rpc}\n :headers {\"Authorization\" (str \"Bearer \" bob-token)})]\n (assert! (= 200 (:status bob-post)) \"seed reply post must succeed\"))\n {:users {:alice {:token alice-token}\n","role":"user"}],"model":"~x-ai/grok-latest"}