constitution · epochs · watch · epoch 3

comparison

c_939729b4d9a6 (tommy-mor) vs c_effff18688f9 (tommy-mor)

download prompt · raw event · cmp_b21dd01cb025bd

council reasoning

~anthropic/claude-sonnet-latest · winner B · 6:4 · permalink

Side B is a genuine refactor that consolidates duplicated web_post.rs logic into ui_html.rs, removes now-dead /post routes, introduces a cleaner WebSession abstraction, and updates tests/fixtures accordingly—reducing code duplication and clarifying the dispatch path. Side A adds a real feature (vote page, typed form holes) but is messier: it bundles unrelated dependency additions (rand, urlencoding, zerocopy chain) into Cargo.lock, references an undefined vote module in the diff, and mixes CSS/JS additions with backend logic without clear architectural improvement.

~x-ai/grok-latest · winner A · 2:1 · permalink

A adds lasting product capability (child vote CTA, /vote flow, preference slider JS/CSS) plus reusable typed `$form:i32` substitution and a `next` redirect on record_vote, with tests. B is a worthwhile consolidation—folding web_post into POST /ui, WebSession, and __rpc__ forms—but mostly relocates existing ingest/check/redact behavior rather than net-new design or fixes.

openai/gpt-chat-latest · winner B · 3:1 · permalink

Side B performs a substantial architectural refactor by consolidating web posting, checking, and redaction into the unified `/ui` RPC flow, introducing shared `WebSession` resolution, removing the duplicated `web_post.rs` implementation, and updating forms, routes, and tests accordingly. Side A adds useful functionality such as typed `i32` form holes, vote navigation, and UI support, but much of the patch is dependency updates and incremental feature work rather than the broad simplification and maintenance improvement delivered by Side B.

sides

A — c_939729b4d9a6 (tommy-mor)

message

[7964b28f] fix

diff preview

diff --git a/Cargo.lock b/Cargo.lock
index 8c43fb75c472b102e6e1d3b837dce3355be898f2..e55d87f32ab32064686431c7082ef8c9ca872d63 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -923,6 +923,15 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
 
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
 [[package]]
 name = "prettyplease"
 version = "0.2.37"
@@ -980,6 +989,36 @@ version = "6.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
 
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
 [[package]]
 name = "regex-automata"
 version = "0.4.14"
@@ -1270,6 +1309,7 @@ dependencies = [
  "dotenvy",
  "futures-util",
  "maud",
+ "rand",
  "reqwest",
  "serde",
  "serde_json",
@@ -1280,6 +1320,7 @@ dependencies = [
  "tower-http 0.5.2",
  "tracing",
  "tracing-subscriber",
+ "urlencoding",
 ]
 
 [[package]]
@@ -1656,6 +1697,12 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
 [[package]]
 name = "utf8_iter"
 version = "1.0.4"
@@ -2052,6 +2099,26 @@ dependencies = [
  "synstructure",
 ]
 
+[[package]]
+name = "zerocopy"
+version = "0.8.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "zerofrom"
 version = "0.1.8"
diff --git a/server/Cargo.toml b/server/Cargo.toml
index c940acb687fb141d21760a3d6656172013cf6f41..6fb7bf52fa58f46f5e8fb0f7fd395247b57506d7 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -20,6 +20,8 @@ reqwest = { version = "0.12", features = ["json"] }
 dotenvy = "0.15"
 async-stream = "0.3"
 futures-util = { version = "0.3", default-features = false, features = ["std"] }
+rand = "0.8"
+urlencoding = "2"
 
 [dev-dependencies]
 reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index e649a7d192feade465e19ce6187a829f6ec74372..06001212820101e0cc953d3687dea64f85e60787 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -47,6 +47,7 @@ pub async fn post_ui_html(
             ratio_left,
             ratio_right,
             scope,
+            next,
         } => {
             let parent = parent_from_scope(&scope);
             if let Err(e) = state
@@ -56,9 +57,19 @@ pub async fn post_ui_html(
                 return ui_js_warn(&e).into_response();
             }
             let tree = state.tree.read().await;
+            if !next.trim().is_empty() {
+                drop(tree);
+                return JsBuilder::new()
+                    .raw(&format!(
+                        "window.location.href={};",
+                        js_string_literal(next.trim())
+                    ))
+                    .into_response();
+            }
             let empty = crate::reducer::NodeState::default();
             let node = tree.get(&parent).unwrap_or(&empty);
             let panel = ranking_panel(&parent, node, &tree);
+            drop(tree);
             JsBuilder::new()
                 .morph_selector("#ranking-panel", panel)
                 .into_response()
@@ -126,6 +137,7 @@ mod tests {
                 ratio_left: 3,
                 ratio_right: 1,
                 scope: String::new(),
+                next: String::new(),
             }
         );
     }
diff --git a/server/src/form_template.rs b/server/src/form_template.rs
index b9bc3982a125e94e67c99175ea9055979541abba..bd4a7195a6eaabfed55333bab5640708ea108b81 100644
--- a/server/src/form_template.rs
+++ b/server/src/form_template.rs
@@ -7,19 +7,30 @@ pub fn template_json_compact<T: Serialize>(v: &T) -> serde_json::Result<String>
     serde_json::to_string(v)
 }
 
-/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted
-/// string for `key` (empty if missing). Other keys are unchanged.
+/// Recursively walk the JSON AST and replace form holes with submitted values.
+///
+/// - `{"$form": "key"}` → string (empty if missing)
+/// - `{"$form:i32": "key"}` → JSON number (0 if missing or unparseable)
 pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap<String, String>) {
     match val {
         Value::Object(map) => {
             if map.len() == 1 {
-                if let Some(Value::String(field_name)) = map.get("$form") {
-                    let submitted = form_data
-                        .get(field_name.as_str())
-                        .map(|s| s.as_str())
-                        .unwrap_or("");
-                    *val = Value::String(submitted.to_string());
-                    return;
+                if let Some((hole_key, Value::String(field_name))) = map.iter().next() {
+                    if let Some(form_type) = hole_key.strip_prefix("$form") {
+                        let submitted = form_data
+                            .get(field_name.as_str())
+                            .map(|s| s.as_str())
+                            .unwrap_or("");
+                        *val = match form_type {
+                            "" => Value::String(submitted.to_string()),
+                            ":i32" => {
+                                let n: i32 = submitted.trim().parse().unwrap_or(0);
+                                Value::Number(n.into())
+                            }
+                            _ => Value::String(submitted.to_string()),
+                        };
+                        return;
+                    }
                 }
             }
             for v in map.values_mut() {
@@ -62,6 +73,45 @@ mod tests {
         text: String,
     }
 
+    #[test]
+    fn i32_holes_become_numbers() {
+        let json = r#"{
+            "ratio_left": {"$form:i32": "ratio_left"},
+            "ratio_right": {"$form:i32": "ratio_right"}
+        }"#;
+        let mut form = HashMap::new();
+        form.insert("ratio_left".into(), "75".into());
+        form.insert("ratio_right".into(), "25".into());
+        let v = fill_template_from_form(json, &form).unwrap();
+        assert_eq!(v["ratio_left"], 75);
+        assert_eq!(v["ratio_right"], 25);
+
+        #[derive(Debug, Deserialize, PartialEq, Eq)]
+        struct Ratios {
+            ratio_left: i32,
+            ratio_right: i32,
+        }
+        let r: Ratios = serde_json::from_value(v).unwrap();
+        assert_eq!(
+            r,
+            Ratios {
+                ratio_left: 75,
+                ratio_right: 25,
+            }
+        );
+    }
+
+    #[test]
+    fn i32_hole_missing_or_bad_defaults_to_zero() {
+        let json = r#"{"n": {"$form:i32": "missing"}}"#;
+        let v = fill_template_from_form(json, &HashMap::new()).unwrap();
+        assert_eq!(v["n"], 0);
+        let mut form = HashMap::new();
+        form.insert("missing".into(), "nope".into());
+        let v = fill_template_from_form(json, &form).unwrap();
+        assert_eq!(v["n"], 0);
+    }
+
     #[test]
     fn holes_become_strings() {
         let json = r#"{
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 3bf9fc7e92e50beed24e2c25106a77421038c90b..61cdbc094819ddedb755572c59456ec0d6617619 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -20,6 +20,8 @@ use crate::{
     ui_action::UI_RPC_FIELD,
 };
 
+pub mod vote;
+
 const SORTER_CSS: &str = include_str!("../../static/sorter.css");
 const SORTER_UI_JS: &str = include_str!("../../static/sorter_ui.js");
 
@@ -131,7 +133,7 @@ fn layout(title: &str, body: Markup, views: u64) -> Markup {
     }
 }
 
-fn item_href(id: &ItemId) -> String {
+pub(crate) fn item_href(id: &ItemId) -> String {
     id.browse_href()
 }
 
@@ -309,11 +311,23 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {
     let empty_node = NodeState::default();
     let node = tree.get(&item).unwrap_or(&empty_node);
 
+    let child_count = node.children.len();
+    let vote_link = if child_count >= 2 {
+        Some(vote::vote_href(&item))
+    } else {
+        None
+    };
+
     let body = html! {
         h1 { "sorter" }
         (input_panel("", None))
         (breadcrumb_path(&item))
         (entity_section(&item, node, false))
+        @if let Some(href) = vote_link {
+            p class="vote-cta" {
+                a class="btn-primary" href=(href) data-testid="vote-children" { "Vote on children" }
+            }
+        }
         (ranking_panel(&item, node, &tree))
     };
     layout("sorter2", body, views)
diff --git a/server/src/lib.rs b/server/src/lib.rs
index da5f3ebecec1794b05a2a69cc78379551b2ad769..7f7e28c8ac3758de87f1f8e073b24be4132d38da 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,6 +4,7 @@ pub mod events;
 pub mod fetch;
 pub mod form_template;
 pub mod html;
+pub mod pair;
 pub mod parser;
 pub mod path_types;
 pub mod ranking;
@@ -33,6 +34,7 @@ pub fn create_app(state: AppState) -> Router {
         .route("/static/:filename", get(crate::html::serve_static))
         .route("/~/*item_path", get(crate::html::browse))
         .route("/", get(crate::html::home))
+        .route("/vote", get(crate::html::vote::vote_page))
         .route("/ui", post(crate::api::ui_html::post_ui_html))
         .with_state(state)
         .layer(TraceLayer::new_for_http())
diff --git a/server/src/ui_action.rs b/server/src/ui_action.rs
index 2047713762c932ac9bc325fe15624f2aecb3364d..53581e1362bfcc5dfb4ae3069c41ea6f7be41437 100644
--- a/server/src/ui_action.rs
+++ b/server/src/ui_action.rs
@@ -31,6 +31,9 @@ pub enum HtmlUiAction {
         /// Parent node [`ItemId`] string; empty = tree root.
         #[serde(default)]
         scope: String,
+        /// After vote, navigate here (vote compare page).
+        #[serde(default)]
+        next: String,
     },
     /// Parse pasted Reddit URL/path; redirect to subreddit ranking on success.
     ParseQuery {
@@ -70,6 +73,36 @@ pub fn parse_html_ui_from_form(
 mod tests {
     use super::*;
 
+    #[test]
+    fn record_vote_round_trip_with_typed_ratio_holes() {
+        let template = serde_json::json!({
+            "action": "record_vote",
+            "a": "x",
+            "b": "y",
+            "ratio_left": {"$form:i32": "ratio_left"},
+            "ratio_right": {"$form:i32": "ratio_right"},
+            "scope": "parent",
+        });
+        let mut form = HashMap::new();
+        form.insert(
+            UI_RPC_FIELD.to_string(),
+            serde_json::to_string(&template).unwrap(),
+        );
+        form.insert("ratio_left".into(), "60".into());
+        form.

… preview truncated; 4,992 characters omitted

download full diff A

B — c_effff18688f9 (tommy-mor)

message

[c3cbcaa7] refactor

diff preview

diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index b3631b06153d52f88348fef927e7a024b7b85ad6..cd556eb89e9dd8203eba6c8969ffd144db5d329d 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -71,6 +71,24 @@ pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduce
     verify_token(reduced, c.value()).ok()
 }
 
+/// Browser session: principal + bearer token string (same shape as CLI session cookie).
+#[derive(Debug, Clone)]
+pub struct WebSession {
+    pub username: String,
+    pub bearer: String,
+}
+
+/// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar).
+pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option<WebSession> {
+    let username = optional_principal(headers, jar, reduced)?;
+    let bearer = headers
+        .get(header::AUTHORIZATION)
+        .and_then(|v| v.to_str().ok())
+        .and_then(|s| s.strip_prefix("Bearer ").map(|t| t.trim().to_string()))
+        .or_else(|| jar.get(SLUG_SESSION_COOKIE).map(|c| c.value().to_string()))?;
+    Some(WebSession { username, bearer })
+}
+
 fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {
     let mut res = Response::builder()
         .status(StatusCode::TEMPORARY_REDIRECT)
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index a986f706ea4b261cbaf004c02b4cf84184b41371..4e223a7460997c464706dca850281447bd754ed5 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -4,7 +4,6 @@ mod rpc;
 mod stream;
 mod validate;
 mod ui_html;
-mod web_post;
 
 pub use auth::{
     get_join_invite,
@@ -19,7 +18,9 @@ pub use auth::{
     get_web_login,
     get_logout,
     optional_principal,
+    resolve_web_session,
     session_cookie_header_value,
+    WebSession,
     SLUG_SESSION_COOKIE,
 };
 
@@ -35,7 +36,6 @@ pub use stream::{get_html_stream, get_stream};
 pub use validate::{normalize_room_and_thread, validate_ingest_document, ValidatedIngest};
 
 pub use ui_html::post_ui_html;
-pub use web_post::{check_web_ingest, post_web_ingest, post_web_redact};
 
 #[cfg(test)]
 mod tests {
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 2b40a72059981d558768f73d189b991f3448c257..497f8fdd222a8ec7c3d76b0695e0352e973ca3ba 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -1,25 +1,29 @@
-//! Single `POST /ui` entry for browser [`crate::html::ui_action::HtmlUiAction`] (JSON in `__rpc__` + holes).
+//! Single `POST /ui` entry: parse `__rpc__` → [`HtmlUiAction`], resolve [`WebSession`] once, dispatch.
 
 use axum::{
-    body::Body,
+    body,
     extract::State,
-    http::{header, HeaderMap, StatusCode},
+    http::{header, HeaderMap, HeaderValue, StatusCode},
     response::{IntoResponse, Response},
     Form,
 };
 use axum_extra::extract::cookie::CookieJar;
+use slug_types::{RpcBatch, RpcBatchResponse, RpcCommand, RpcResult};
 use std::collections::HashMap;
 
 use crate::{
     api::{
-        auth::optional_principal,
-        web_post::{run_check_web_ingest, run_post_web_ingest, run_post_web_redact, WebPostForm, WebRedactForm},
+        auth::{resolve_web_session, WebSession},
+        handle_rpc_batch,
+        rpc::{rpc_post_redact, rpc_post_with_bearer},
     },
+    canonical_path::canonicalize_tag,
     html::{
         fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup,
-        parse_html_ui_from_form, user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder,
-        ThreadNav,
+        parse_html_ui_from_form, thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup,
+        user_can_post_room, user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav,
     },
+    reducer::{scope_from_room_wire, ScopeId},
     state::AppState,
 };
 
@@ -34,6 +38,19 @@ pub async fn post_ui_html(
         Err(e) => return ui_js_warn(&e.to_string()).into_response(),
     };
 
+    let reduced = state.reduced.read().await;
+    let session = resolve_web_session(&headers, &jar, &reduced);
+    drop(reduced);
+
+    dispatch_ui_action(&state, session.as_ref(), action).await
+}
+
+/// All UI command logic: HTTP extractors stop above; this only sees [`AppState`], session, and [`HtmlUiAction`].
+async fn dispatch_ui_action(
+    state: &AppState,
+    session: Option<&WebSession>,
+    action: HtmlUiAction,
+) -> Response {
     match action {
         HtmlUiAction::PostIngest {
             room,
@@ -42,47 +59,87 @@ pub async fn post_ui_html(
             error_target,
             form_id,
         } => {
-            run_post_web_ingest(
-                &state,
-                &headers,
-                &jar,
-                WebPostForm {
-                    room,
-                    thread_tag,
-                    text,
-                    error_target,
-                    form_id,
-                },
-            )
-            .await
+            let Some(session) = session else {
+                return js_redirect("/login").into_response();
+            };
+            let room = room.trim().to_string();
+            let thread_tag = thread_tag.trim().to_string();
+            if text.trim().is_empty() {
+                return form_js_error(
+                    error_target.as_ref(),
+                    "empty post",
+                    "Write something in the text area (DSL / prose).",
+                )
+                .into_response();
+            }
+            match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {
+                Ok(RpcResult::PostOk { .. }) => {
+                    post_success_response(
+                        state,
+                        &room,
+                        &thread_tag,
+                        error_target.as_ref(),
+                        form_id.as_ref(),
+                        Some(session.username.as_str()),
+                    )
+                    .await
+                    .into_response()
+                }
+                Ok(_) => form_js_error(
+                    error_target.as_ref(),
+                    "unexpected response",
+                    "Post did not return PostOk.",
+                )
+                .into_response(),
+                Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(),
+            }
         }
         HtmlUiAction::CheckIngest {
             room,
             thread_tag,
             text,
             error_target,
-            form_id,
+            form_id: _,
         } => {
-            run_check_web_ingest(
-                &state,
-                &headers,
-                &jar,
-                WebPostForm {
-                    room,
-                    thread_tag,
-                    text,
-                    error_target,
-                    form_id,
-                },
-            )
-            .await
+            let Some(session) = session else {
+                return js_redirect("/login").into_response();
+            };
+            let room = room.trim().to_string();
+            let thread_tag = canonicalize_tag(&thread_tag);
+            if thread_tag.is_empty() {
+                return form_js_error(
+                    error_target.as_ref(),
+                    "missing thread tag",
+                    "Set a thread tag before posting.",
+                )
+                .into_response();
+            }
+            if text.trim().is_empty() {
+                return js_clear_errors(&form_error_target(error_target.as_ref())).into_response();
+            }
+            match rpc_check_with_bearer(state, &session.bearer, room, text.clone()).await {
+                Ok(RpcResult::CheckOk { .. }) => js_clear_errors(&form_error_target(error_target.as_ref())).into_response(),
+                Ok(_) => form_js_error(error_target.as_ref(), "unexpected response", "Check did not return CheckOk.").into_response(),
+                Err((msg, hint)) => form_js_error(error_target.as_ref(), &msg, hint.as_deref().unwrap_or("")).into_response(),
+            }
         }
         HtmlUiAction::RedactPost { post_id } => {
-            run_post_web_redact(&state, &headers, &jar, WebRedactForm { post_id }).await
+            let Some(session) = session else {
+                return js_redirect("/login").into_response();
+            };
+            let h = headers_from_bearer(&session.bearer);
+            match rpc_post_redact(state, &h, post_id).await {
+                Ok(RpcResult::RedactPostOk {}) => redact_success_response(state).await.into_response(),
+                Ok(_) => (StatusCode::BAD_REQUEST, "unexpected response").into_response(),
+                Err((msg, hint)) => {
+                    let detail = hint.as_deref().unwrap_or("");
+                    js_error("#errors", &msg, detail).into_response()
+                }
+            }
         }
         HtmlUiAction::ExpandPublicNewThreadForm => {
             let reduced = state.reduced.read().await;
-            let user = optional_principal(&headers, &jar, &reduced);
+            let user = session.map(|s| s.username.as_str());
             drop(reduced);
             let markup = if user.is_some() {
                 fragment_public_new_thread_form(true)
@@ -99,18 +156,18 @@ pub async fn post_ui_html(
                 return ui_js_warn("missing room").into_response();
             }
             let reduced = state.reduced.read().await;
-            let user = optional_principal(&headers, &jar, &reduced);
+            let user = session.map(|s| s.username.as_str());
             if !reduced.rooms.contains(&room_wire) {
                 drop(reduced);
                 return ui_js_warn("room not found").into_response();
             }
-            if !user_can_view_room(&reduced, &room_wire, user.as_deref()) {
+            if !user_can_view_room(&reduced, &room_wire, user) {
                 drop(reduced);
                 return ui_js_warn("forbidden").into_response();
             }
-            let can_post = user
+            let can_post = session
                 .as_ref()
-                .map(|u| user_can_post_room(&reduced, &room_wire, u))
+                .map(|s| user_can_post_room(&reduced, &room_wire, &s.username))
                 .unwrap_or(false);
             drop(reduced);
             let Some(nav) = ThreadNav::from_room_id(&room_wire) else {
@@ -128,12 +185,195 @@ pub async fn post_ui_html(
     }
 }
 
+fn headers_from_bearer(bearer: &str) -> HeaderMap {
+    let mut headers = HeaderMap::new();
+    if let Ok(hv) = HeaderValue::from_str(&format!("Bearer {bearer}")) {
+        headers.insert(header::AUTHORIZATION, hv);
+    }
+    headers
+}
+
+fn post_redirect_location(room: &str, thread_tag: &str) -> String {
+    let tag = canonicalize_tag(thread_tag);
+    if room.trim() == "public" {
+        format!("/t/{tag}")
+    } else {
+        let room = room.trim();
+        let Some((a, b)) = room.split_once('/') else {
+            return "/".to_string();
+        };
+        format!("/r/{a}/{b}/t/{tag}")
+    }
+}
+
+fn js_quote(s: &str) -> String {
+    serde_json::to_string(s).expect("js string escaping must succeed")
+}
+
+fn js_redirect(to: &str) -> Response {
+    let js = format!("window.location = {};", js_quote(to));
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "text/javascript; charset=utf-8")
+        .body(axum::body::Body::from(js))
+        .unwrap()
+}
+
+fn js_error(error_target: &str, title: &str, detail: &str) -> Response {
+    let markup = maud::html! {
+        div id=(error_target.trim_start_matches('#')) {
+            p class="auth-error" { (title) }
+            @if !detail.is_empty() {
+                pre class="muted" { (detail) }
+            }
+        }
+    };
+    JsBuilder::new()
+        .morph_selector(error_targe

… preview truncated; 33,445 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.