Side A performs a substantive refactor that eliminates a whole duplicate code path (deletes web_post.rs), unifies session resolution and RPC dispatch into a single `/ui` handler, and updates tests/fixtures accordingly to match the new contract — a meaningful architectural consolidation. Side B is a useful but narrower fix (replacing hardcoded hostnames with a shared `get-base-url` helper and switching deploy branch), which improves correctness for multi-environment deploys but touches many files with mechanical, low-complexity changes rather than restructuring core logic.
constitution · epochs · watch · epoch 3
c_effff18688f9 (tommy-mor) vs c_c25451965a7f (tommy-mor)
download prompt · raw event · cmp_da29d50fb4f914
council reasoning
A completes a lasting API consolidation: delete web_post.rs and the /post|/post/check|/post/redact routes, resolve session once via WebSession/resolve_web_session, and route all forum compose/redact through POST /ui + __rpc__ (forms, client check JS, integration/walkthrough tests). B is a real but narrower ops fix—centralize get-base-url/HOSTNAME, point OAuth callbacks and authUtils at that base/origin, and retarget Fly deploys to staging—valuable durability without restructuring product surface area.
Side A performs a substantial architectural consolidation by moving browser posting, checking, and redaction into a single `POST /ui` dispatch, introducing `resolve_web_session` to avoid repeated auth parsing, removing the separate `web_post` module, updating forms and tests to the new `__rpc__` flow, and preserving behavior through shared helpers. Side B is a useful configuration and portability fix—centralizing base URL resolution with `util/get-base-url`, using `window.location.origin`, and updating the deployment workflow—but its scope is limited to redirect/deployment correctness rather than a broad, lasting simplification of the application's request handling.
sides
A — 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 omittedB — c_c25451965a7f (tommy-mor)
message
[6120bd96] fix redirect urls for custom hosts and deploy from staging Use HOSTNAME and window.location.origin instead of hardcoded staging.sorter.social, and trigger fly deploys on pushes to staging. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml
index 3e693915fe6f99a5c2221b2921bf8ebc305180fc..5e11e5c312f62bed34d8cc68bd4a5538f52476b9 100644
--- a/.github/workflows/fly-deploy.yml
+++ b/.github/workflows/fly-deploy.yml
@@ -3,11 +3,11 @@ name: deploy to fly.io
on:
push:
branches:
- - main
+ - staging
workflow_dispatch:
concurrency:
- group: fly-deploy-main
+ group: fly-deploy-staging
cancel-in-progress: true
jobs:
diff --git a/borter/src/app/linear.clj b/borter/src/app/linear.clj
index f77d8a974e0cf05f9c33233bb625bdb190d2007b..5ef0e34cf1d543826675c6facb66aec079b40561 100644
--- a/borter/src/app/linear.clj
+++ b/borter/src/app/linear.clj
@@ -6,6 +6,7 @@
[ring.util.response :as response]
[app.database :as db]
[app.permissions :as perms]
+ [app.util :as util]
[honey.sql.helpers :as h]
[sluj.core :refer [sluj]]))
@@ -13,9 +14,7 @@
(def client-secret (System/getenv "BORTER_LINEAR_CLIENT_SECRET"))
(defn get-hostname []
- (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost))
- "sorter.social" "https://sorter.social/api/linear/callback"
- "http://localhost:3000/api/linear/callback"))
+ (str (util/get-base-url) "/api/linear/callback"))
(defn code->token [code]
(def code code)
diff --git a/borter/src/app/login.clj b/borter/src/app/login.clj
index 5d545db9bef7765ba8b3fe7d00261c8ce84e9e1a..86817f8e94bc174e5b108859cc0b342953ab7acb 100644
--- a/borter/src/app/login.clj
+++ b/borter/src/app/login.clj
@@ -1,6 +1,7 @@
(ns app.login
(:require [crypto.password.bcrypt :as password]
[app.database :as db]
+ [app.util :as util]
[honey.sql.helpers :as h]
[hato.client :as hc]
[clojure.data.json :as json]
@@ -12,10 +13,7 @@
(def environment (or (System/getenv "ENVIRONMENT") "development"))
(defn get-base-url []
- (case environment
- "production" "https://sorter.social"
- "staging" "https://staging.sorter.social"
- "development" "http://localhost:3000")) ; fallback for development
+ (util/get-base-url))
(defn create-email-content
"Creates a standardized email structure with customizable content"
diff --git a/borter/src/app/oauth.clj b/borter/src/app/oauth.clj
index 1166f2ee30c9e813840711c72d1ad8f1c62e1ffe..2cd0c62f1fe267f7f5f725a97bc3ba0d0889517f 100644
--- a/borter/src/app/oauth.clj
+++ b/borter/src/app/oauth.clj
@@ -3,6 +3,7 @@
[clojure.data.json :as json]
[hato.client :as hc]
[app.database :as db]
+ [app.util :as util]
[honey.sql.helpers :as h]
[ring.util.codec :as codec])
(:import [java.util Base64]))
@@ -13,12 +14,7 @@
encoded-bytes))
(defn get-hostname []
- (let [env (System/getenv "ENVIRONMENT")]
- (println "Current environment:" env)
- (case env
- "production" "https://sorter.social"
- "staging" "https://staging.sorter.social"
- "http://localhost:3000")))
+ (util/get-base-url))
(defn get-origin-hostname [req]
(let [origin (get-in req [:headers "origin"])
diff --git a/borter/src/app/spotify.clj b/borter/src/app/spotify.clj
index 3e11713119141812fa2707ec956fb7ed612ee69a..b38d734351a1d4f1e142d93d4435ffe7bd20c02d 100644
--- a/borter/src/app/spotify.clj
+++ b/borter/src/app/spotify.clj
@@ -1,5 +1,6 @@
(ns app.spotify
(:require [app.oauth :as oauth]
+ [app.util :as util]
[hato.client :as hc]
[clojure.data.json :as json]
[ring.util.codec :as codec]
@@ -47,10 +48,7 @@
(defn get-hostname []
- (case (System/getenv "ENVIRONMENT")
- "production" "https://sorter.social"
- "staging" "https://staging.sorter.social"
- "http://localhost:3000"))
+ (util/get-base-url))
(defn create-spotify-tag
"Creates a tag for a Spotify entity (artist, album, track) if it doesn't exist"
diff --git a/borter/src/app/twitter.clj b/borter/src/app/twitter.clj
index ef7b18fa1fcb82bb944b3035ffed44499181237f..b9a3fdccc15d13918a4d11d8c0443de94fd172b4 100644
--- a/borter/src/app/twitter.clj
+++ b/borter/src/app/twitter.clj
@@ -1,6 +1,7 @@
(ns app.twitter
(:require [app.database :as db]
[app.permissions :as perms]
+ [app.util :as util]
[honey.sql.helpers :as h]
[hato.client :as hc]
[clojure.data.json :as json]
@@ -37,9 +38,7 @@
(clojure.string/join "&" (map (fn [[k v]] (str (name k) "=" v)) params)))
(defn get-hostname []
- (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost))
- "sorter.isnt.online" "https://sorter.isnt.online/api/twitter/callback"
- "http://localhost:3000/api/twitter/callback"))
+ (str (util/get-base-url) "/api/twitter/callback"))
(defn encode-b64 [s]
(.encodeToString (java.util.Base64/getEncoder) (.getBytes s)))
diff --git a/borter/src/app/util.clj b/borter/src/app/util.clj
index 384a64306c84aa8288ec44cd5de57edc248a826d..6a8aa0875accb28dc81bf57e645e3961b0a3ace5 100644
--- a/borter/src/app/util.clj
+++ b/borter/src/app/util.clj
@@ -2,6 +2,18 @@
(:require [clojure.string :as string])
(:import [java.net URLEncoder]))
+(defn get-base-url
+ "Public site base URL for redirects and oauth callbacks."
+ []
+ (if-let [hostname (not-empty (System/getenv "HOSTNAME"))]
+ (if (string/starts-with? hostname "http")
+ (string/replace hostname #"/$" "")
+ (str "https://" (string/replace hostname #"/$" "")))
+ (case (or (System/getenv "ENVIRONMENT") "development")
+ "production" "https://sorter.social"
+ "staging" "https://staging.sorter.social"
+ "http://localhost:3000")))
+
(defn urlencode-params [params]
(clojure.string/join "&" (map (fn [[k v]] (str k "=" (URLEncoder/encode (str v) "UTF-8"))) params)))
diff --git a/borter/src/app/youtube.clj b/borter/src/app/youtube.clj
index 647ef7c6d4f2503a63a7c074d46dc815b2a23547..fc9a2f5ed516692f19e06b4c0221f510547cf8c0 100644
--- a/borter/src/app/youtube.clj
+++ b/borter/src/app/youtube.clj
@@ -6,6 +6,7 @@
[ring.util.response :as response]
[app.database :as db]
[app.permissions :as perms]
+ [app.util :as util]
[honey.sql.helpers :as h]
[sluj.core :refer [sluj]]))
@@ -32,9 +33,7 @@
:body (json/read-str {:key-fn keyword})))
(defn get-hostname []
- (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost))
- "localhost" "http://localhost:3000/api/youtube/callback"
- "https://sorter.isnt.online/api/youtube/callback"))
+ (str (util/get-base-url) "/api/youtube/callback"))
diff --git a/forter/src/utils/authUtils.js b/forter/src/utils/authUtils.js
index 145e5db7ad6f7a6439d68c63beb4d07d31b2de08..98ed7fa671f9887f44dc1479d85569951eb4773e 100644
--- a/forter/src/utils/authUtils.js
+++ b/forter/src/utils/authUtils.js
@@ -5,8 +5,11 @@ import { current_session, fetchSession } from "../session";
let lastSyncTime = 0;
const SYNC_THROTTLE_MS = 5000; // Only sync once every 5 seconds
-// Get base URL based on Vite's mode
+// Get base URL based on current origin, with build-mode fallbacks for SSR/build
const getBaseUrl = () => {
+ if (typeof window !== 'undefined' && window.location?.origin) {
+ return window.location.origin;
+ }
switch (import.meta.env.MODE) {
case 'production':
return 'https://sorter.social';
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.