B fixes a real bug (hardcoded staging hostnames breaking custom-host deploys) by consolidating logic into a shared util function used across six OAuth integration files, and correctly wires up CI deploy triggers—improving deployability and maintainability. A is a decent focused bugfix/cleanup for the vote/compare route (better error handling, dead code removal) but is more narrowly scoped UI polish with smaller lasting architectural impact than B's cross-cutting infrastructure fix.
constitution · epochs · watch · epoch 3
c_11d6a0ec9839 (tommy-mor) vs c_c25451965a7f (tommy-mor)
download prompt · raw event · cmp_09168f4b05eeed
council reasoning
A fixes real vote-path behavior and design: WEB_BROWSER_AGENT is passed out-of-band via delegate_opt instead of DSL body text, sibling pools < 2 no longer silently expand to all items, and dead UI (swap) is removed—plus route/tests stay consistent. B’s HOSTNAME/window.location.origin consolidation and staging deploy trigger are useful ops/portability cleanups, but mostly DRY/config rather than deeper product logic.
Side A fixes multiple user-facing correctness issues in the voting flow: it passes the browser agent as an explicit delegate parameter instead of embedding it in DSL text, prevents an incorrect fallback to all items when a sibling pool has fewer than two candidates by returning no next pair, and updates routing/UI to the simplified `/vote` endpoint while removing the unnecessary swap button. Side B usefully centralizes base URL generation around `HOSTNAME`/`window.location.origin` and updates deployment configuration, but its changes are primarily configuration and URL handling rather than core application behavior.
sides
A — c_11d6a0ec9839 (tommy-mor)
message
[36e92cf2] Fix /vote/compare page: attribution, fallback, swap button, and route rename. - Pass WEB_BROWSER_AGENT as delegate_opt out-of-band (not in DSL body text) - Error instead of falling back to all items when sibling pool < 2 - Remove pointless "swap sides" button - Rename route /vote/compare → /vote Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff preview
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 0b7070a2b7289e5c40778c8d062bd4150285a8df..8e3d8e34922ddaaa89ea699edd99402bd40fdb1b 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -281,6 +281,7 @@ pub async fn rpc_post_with_bearer(
bearer_token: &str,
room: String,
thread_tag: String,
+ delegate_opt: Option<String>,
text: String,
) -> Result<RpcResult, RpcErr> {
use axum::http::{header, HeaderMap, HeaderValue};
@@ -288,7 +289,7 @@ pub async fn rpc_post_with_bearer(
let hv = HeaderValue::from_str(&format!("Bearer {bearer_token}"))
.map_err(|_| ("invalid session token".into(), None))?;
headers.insert(header::AUTHORIZATION, hv);
- rpc_post(state, &headers, room, thread_tag, None, text, false).await
+ rpc_post(state, &headers, room, thread_tag, delegate_opt, text, false).await
}
async fn rpc_check(
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index a13a94767d8905adc09e31effff838e166781b6c..5faa642451d69555cb391974beb0ad22c9355c8c 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -118,6 +118,7 @@ async fn dispatch_ui_action(
&session.bearer,
room.clone(),
thread_tag.clone(),
+ None,
text,
)
.await
@@ -246,8 +247,7 @@ async fn dispatch_ui_action(
}
let text = format!(
- "@{}\n{{\n{}\n}}\n{} {}:{} {}\n",
- crate::api::auth::WEB_BROWSER_AGENT,
+ "{{\n{}\n}}\n{} {}:{} {}\n",
exp,
left_id.as_str(),
rl,
@@ -260,6 +260,7 @@ async fn dispatch_ui_action(
&session.bearer,
room.clone(),
thread_tag.clone(),
+ Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()),
text,
)
.await
diff --git a/server/src/html/forum/nav.rs b/server/src/html/forum/nav.rs
index d33dc50f0b89a5ed7730c3bca204a67f6309388f..ae67f550a4bdf0bbeff58384c29a715015b64bd5 100644
--- a/server/src/html/forum/nav.rs
+++ b/server/src/html/forum/nav.rs
@@ -85,7 +85,7 @@ impl ThreadNav {
format!("{}/{}/{}", self.thread_path_prefix, tag, idx)
}
- /// Empty for public; `/r/:seg` for room — prefix for routes like `/vote/compare`.
+ /// Empty for public; `/r/:seg` for room — prefix for routes like `/vote`.
pub(crate) fn room_path_prefix_for_vote_compare(&self) -> String {
match &self.scope {
ScopeId::Public => String::new(),
diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs
index bdb4008c29ba49d7dc97b1cd2c32d9cdfce1ca04..1d7fc8aa7436bfa9c1186dfd940a8d751ab7e088 100644
--- a/server/src/html/garden/vote.rs
+++ b/server/src/html/garden/vote.rs
@@ -208,7 +208,7 @@ pub(crate) async fn vote_compare_post_success_js(
state: &AppState,
nav: &ThreadNav,
_room_wire: &str,
- thread_tag: &str,
+ _thread_tag: &str,
left: &ItemId,
right: &ItemId,
_post_id: &str,
@@ -218,8 +218,7 @@ pub(crate) async fn vote_compare_post_success_js(
let content = content_for_garden_view(&reduced, &nav.scope());
let edge_history = vote_edge_history_markup(content, left, right);
let next_pair = suggest_next_vote_pair(content, left, right);
- let nav_markup =
- vote_compare_nav_markup(nav, next_pair.as_ref(), left, right, Some(thread_tag));
+ let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref());
drop(reduced);
JsBuilder::new()
.morph_inner_selector("#vote-edge-history-region", edge_history)
@@ -236,7 +235,7 @@ pub(super) fn vote_compare_href(
let left_q = urlencoding::encode(left.as_str());
let right_q = urlencoding::encode(right.as_str());
let base = format!(
- "{}/vote/compare?left={}&right={}",
+ "{}/vote?left={}&right={}",
nav.room_path_prefix_for_vote_compare(),
left_q,
right_q
@@ -251,12 +250,8 @@ pub(super) fn vote_compare_href(
fn vote_compare_nav_markup(
nav: &ThreadNav,
next_pair: Option<&(ItemId, ItemId)>,
- left: &ItemId,
- right: &ItemId,
- thread_override: Option<&str>,
) -> maud::Markup {
let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None));
- let swap_pair_href = vote_compare_href(nav, right, left, thread_override);
html! {
div class="vote-compare-nav" {
@if let Some(href) = &next_pair_href {
@@ -264,7 +259,6 @@ fn vote_compare_nav_markup(
} @else {
span class="vote-compare-next is-disabled" { "no next pair" }
}
- a class="vote-compare-next" href=(swap_pair_href) { "swap sides" }
}
}
}
@@ -274,7 +268,7 @@ pub(super) fn suggest_next_vote_pair(
current_left: &ItemId,
current_right: &ItemId,
) -> Option<(ItemId, ItemId)> {
- let mut pool: Vec<ItemId> = if current_left.parent().as_ref().map(|p| p.as_str())
+ let pool: Vec<ItemId> = if current_left.parent().as_ref().map(|p| p.as_str())
== current_right.parent().as_ref().map(|p| p.as_str())
{
current_left
@@ -291,7 +285,7 @@ pub(super) fn suggest_next_vote_pair(
Vec::new()
};
if pool.len() < 2 {
- pool = content.items.iter().cloned().collect();
+ return None;
}
suggest_next_pair_in_pool(
&content.ranking_group,
@@ -334,7 +328,7 @@ pub struct VoteCompareQuery {
pub thread: Option<String>,
}
-/// Public pairwise vote UI — `/vote/compare?left=&right=&thread=`.
+/// Public pairwise vote UI — `/vote?left=&right=&thread=`.
pub async fn vote_compare_page(
State(state): State<AppState>,
Query(q): Query<VoteCompareQuery>,
@@ -426,7 +420,7 @@ async fn vote_compare_inner(
let next_path = uri
.path_and_query()
.map(|pq| pq.as_str().to_string())
- .unwrap_or_else(|| "/vote/compare".into());
+ .unwrap_or_else(|| "/vote".into());
let rpc_json = template_json_compact(&json!({
"action": "vote_compare_post",
@@ -462,7 +456,7 @@ async fn vote_compare_inner(
Some(&item_bodies_for_cards),
))
}
- (vote_compare_nav_markup(&nav, next_pair.as_ref(), &left, &right, q.thread.as_deref()))
+ (vote_compare_nav_markup(&nav, next_pair.as_ref()))
div id="vote-edge-history-region" {
(edge_history)
}
diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs
index c7b643c8dde37dd1e3001f5ff716fe4f79d24c5e..dd5294202569454acf9f41b458718700bbf96f39 100644
--- a/server/src/html/ui_action.rs
+++ b/server/src/html/ui_action.rs
@@ -38,7 +38,7 @@ pub enum HtmlUiAction {
#[serde(default)]
form_id: Option<String>,
},
- /// Post a pairwise vote from `/vote/compare` (browser compose).
+ /// Post a pairwise vote from `/vote` (browser compose).
VoteComparePost {
room: String,
thread_tag: String,
diff --git a/server/src/lib.rs b/server/src/lib.rs
index ad8e31099c807fb5844acb16cd5086a2f19327a7..e102e73ec167fa35dcc22d4ec8c6f40202982429 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -82,9 +82,9 @@ pub fn create_app(state: AppState) -> Router {
.route("/u/:username", get(crate::html::user_profile_page))
.route("/try", get(crate::html::editor_page))
.route("/try/check", post(crate::html::editor_check))
- .route("/vote/compare", get(crate::html::vote_compare_page))
+ .route("/vote", get(crate::html::vote_compare_page))
.route(
- "/r/:room_key/vote/compare",
+ "/r/:room_key/vote",
get(crate::html::room_vote_compare_page),
)
.route("/~", get(crate::html::garden_index))
diff --git a/server/tests/integration_html.rs b/server/tests/integration_html.rs
index a25a456c7cafd74a69e65e0c2e8ed2bfb2e27498..b3e31376737b2d5aabb739f408984a1f5ae0c974 100644
--- a/server/tests/integration_html.rs
+++ b/server/tests/integration_html.rs
@@ -52,12 +52,12 @@ async fn test_view_counts_increment_and_display() {
.normalized_storage()
.to_storage_string();
let vote_q_right_first = format!(
- "/vote/compare?right={}&left={}",
+ "/vote?right={}&left={}",
urlencoding::encode(&right),
urlencoding::encode(&left)
);
let vote_q_left_first = format!(
- "/vote/compare?left={}&right={}",
+ "/vote?left={}&right={}",
urlencoding::encode(&left),
urlencoding::encode(&right)
);
@@ -148,7 +148,7 @@ https://github.com/ghvotehi/a/issues/10 {\n\
.normalized_storage()
.to_storage_string();
let q = format!(
- "/vote/compare?left={}&right={}",
+ "/vote?left={}&right={}",
urlencoding::encode(&left),
urlencoding::encode(&right)
);
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index 091afd922d28f48ab36aa9b9084aa825036d1f74..23db7b5672418d5bb0ab7529e05ef1f89e59062a 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -110,7 +110,7 @@ async fn test_choose_username_carries_redirect_next() {
.expect("pending session must exist");
pending.provider = Some("google".to_string());
pending.provider_id = Some("google-user-redirect".to_string());
- pending.redirect_next = Some("/vote/compare?left=%7E%2Fa&right=%7E%2Fb".to_string());
+ pending.redirect_next = Some("/vote?left=%7E%2Fa&right=%7E%2Fb".to_string());
}
let choose = client
@@ -122,7 +122,7 @@ async fn test_choose_username_carries_redirect_next() {
assert_eq!(choose.status(), reqwest::StatusCode::OK);
let body = choose.text().await.unwrap();
- assert!(body.contains("window.location = \"/vote/compare?left=%7E%2Fa&right=%7E%2Fb\""));
+ assert!(body.contains("window.location = \"/vote?left=%7E%2Fa&right=%7E%2Fb\""));
}
#[tokio::test]
diff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj
index 7cd1e467fdd8a4480df1be2afd78adb45b9cdc6d..a98e144756421e1fa5064db6a9fc0dd8b08471c7 100644
--- a/test/browser_vote_compare.clj
+++ b/test/browser_vote_compare.clj
@@ -66,7 +66,7 @@
:headers {"Authorization" (str "Bearer " alice-token)})
post-json (json/parse-string (:body post-resp) false)
_ (is (true? (get-in post-json ["results" 0 "ok"])) "seed items + edge vote via rpc")
- cmp-url (str base-url "/vote/compare?left=" (enc left-url) "&right=" (enc third-url))]
+ cmp-url (str base-url "/vote?left=" (enc left-url) "&right=" (enc third-url))]
(core/with-playwright [pw]
(core/with-browser [browser (core/launch-chromium pw {:headless true :channel "chrome"})]
(core/with-context [ctx (core/new-context browser)]
B — 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.