{"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[674964ef] refactor: Deref for href newtypes, CanonicalItemUrl through resolve_item\n\n- Implement Deref for GardenItemUrl, ForumThreadUrl, TildeOntologyPath\n- resolve_item returns CanonicalItemUrl; validate uses HashSet\n- compute_scope_rank_changes keys are CanonicalItemUrl; pair RPC uses Vec pool\n- pick_random_distinct_canonical; connectivity stats on &[CanonicalItemUrl]\n- Global rank unranked uses stored ids before GardenItemUrl mapping\n\nMade-with: Cursor\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs\nindex 03b3e77911ccd662bec8635345dafe2593cf242e..1b291db83df7364a026f2e147e0a29a70a399371 100644\n--- a/server/src/api/helpers.rs\n+++ b/server/src/api/helpers.rs\n@@ -30,13 +30,13 @@ pub fn now_ms() -> i64 {\n t.as_millis() as i64\n }\n \n-/// Resolve an item path as a first-class canonical path.\n-pub fn resolve_item(item: &str) -> Result {\n+/// Resolve DSL/user input to a stored canonical item id.\n+pub fn resolve_item(item: &str) -> Result {\n let canonical = canonicalize_item(item);\n if canonical.is_empty() {\n return Err(format!(\"empty item path: `{}`\", item));\n }\n- Ok(canonical)\n+ Ok(CanonicalItemUrl(canonical))\n }\n \n pub fn parse_parent_specs(parent: Option<&String>) -> Vec {\n@@ -94,7 +94,7 @@ pub fn paginate_rankings(\n (out_components, out_unranked)\n }\n \n-pub fn pick_random_distinct(items: &[String]) -> Option<(String, String)> {\n+pub fn pick_random_distinct_canonical(items: &[CanonicalItemUrl]) -> Option<(CanonicalItemUrl, CanonicalItemUrl)> {\n use rand::seq::SliceRandom;\n if items.len() < 2 {\n return None;\n@@ -123,15 +123,12 @@ pub fn is_pair_voted(group: &crate::reducer::GroupState, a: &str, b: &str) -> bo\n group.voted_pairs.contains(&(i, j))\n }\n \n-pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[String]) -> ConnectivityStats {\n+pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[CanonicalItemUrl]) -> ConnectivityStats {\n let n = pool.len();\n \n let global_idxs: Vec> = pool\n .iter()\n- .map(|it| {\n- let key = CanonicalItemUrl(it.clone());\n- group.item_to_idx.get(&key).copied()\n- })\n+ .map(|it| group.item_to_idx.get(it).copied())\n .collect();\n let present: Vec = global_idxs.iter().filter_map(|x| *x).collect();\n \ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex cf22cb0129366c3aed031bc86f3197a4321cb806..a10ce662105cff8fad949c6b83f7035ce79bed18 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -24,7 +24,7 @@ pub use auth::{\n \n pub use helpers::{\n api_error, compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings,\n- parse_parent_specs, pick_random_distinct, resolve_item, sha256_hex, vote_touches_path,\n+ parse_parent_specs, pick_random_distinct_canonical, resolve_item, sha256_hex, vote_touches_path,\n };\n \n pub use rpc::handle_rpc_batch;\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 5f7d50188f1381267402f2e57e671234ef5db2fd..de0955d0887d32740e1fd18365205c5bdb53c247 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -29,7 +29,7 @@ use crate::{\n use super::auth::verify_bearer_principal;\n use super::helpers::{\n compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, parse_parent_specs,\n- pick_random_distinct, resolve_item, vote_touches_path,\n+ pick_random_distinct_canonical, resolve_item, vote_touches_path,\n };\n use super::validate::{normalize_room_and_thread, validate_ingest_document};\n \n@@ -146,21 +146,21 @@ fn authorize_room_read(reduced: &ReducerState, headers: &HeaderMap, room: &str)\n }\n \n fn compute_scope_rank_changes(\n- parent: &str,\n+ parent: &CanonicalItemUrl,\n before: &crate::scope_rank::ChildrenRankings,\n after: &crate::scope_rank::ChildrenRankings,\n room_wire: &str,\n ) -> Option {\n- fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> {\n+ fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> {\n let mut map = HashMap::new();\n for comp in &rankings.component_rankings {\n let total = comp.ranked.len();\n for (i, item) in comp.ranked.iter().enumerate() {\n- map.insert(item.item.as_str().to_string(), Some(RankPosition { rank: i + 1, of: total }));\n+ map.insert(item.item.clone(), Some(RankPosition { rank: i + 1, of: total }));\n }\n }\n for item in &rankings.unranked_items {\n- map.insert(item.as_str().to_string(), None);\n+ map.insert(item.clone(), None);\n }\n map\n }\n@@ -168,7 +168,7 @@ fn compute_scope_rank_changes(\n let before_pos = build_positions(before);\n let after_pos = build_positions(after);\n \n- let all_items: std::collections::BTreeSet = before_pos.keys().cloned()\n+ let all_items: std::collections::BTreeSet = before_pos.keys().cloned()\n .chain(after_pos.keys().cloned())\n .collect();\n \n@@ -184,7 +184,7 @@ fn compute_scope_rank_changes(\n };\n if changed {\n changes.push(RankChange {\n- item: GardenItemUrl::from_storage_str(&item, room_wire),\n+ item: GardenItemUrl::from_stored(&item, room_wire),\n before: b,\n after: a,\n });\n@@ -203,11 +203,7 @@ fn compute_scope_rank_changes(\n });\n \n Some(ScopeRankChanges {\n- parent: if parent.is_empty() {\n- \"/\".to_string()\n- } else {\n- GardenItemUrl::from_storage_str(parent, room_wire).into_inner()\n- },\n+ parent: GardenItemUrl::from_stored(parent, room_wire).into_inner(),\n changes,\n })\n }\n@@ -473,8 +469,8 @@ async fn rpc_post(\n for s in &v.doc.statements {\n if let dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = a.parent() { parents.insert(p); }\n+ if let Some(p) = b.parent() { parents.insert(p); }\n }\n }\n }\n@@ -525,7 +521,7 @@ async fn rpc_post(\n .filter_map(|p| {\n let before = pre_rankings.get(p)?;\n let after = crate::scope_rank::build_children_rankings(content, p);\n- compute_scope_rank_changes(p.as_str(), before, &after, &room_key)\n+ compute_scope_rank_changes(p, before, &after, &room_key)\n })\n .collect();\n if v.is_empty() { None } else { Some(v) }\n@@ -638,8 +634,8 @@ async fn rpc_check(\n for s in &v.doc.statements {\n if let dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = a.parent() { parents.insert(p); }\n+ if let Some(p) = b.parent() { parents.insert(p); }\n }\n }\n }\n@@ -961,7 +957,7 @@ fn rpc_search(reduced: &ReducerState, q: &str, limit: usize, principal: Option<&\n async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Result {\n let scope = scope_from_room_wire(&room);\n let reduced_arc = state.reduced.clone();\n- let pool: Vec = {\n+ let pool: Vec = {\n let reduced = reduced_arc.read().await;\n let content = content_for_room(&reduced, &room);\n let tmp = if parent_path.trim().is_empty() {\n@@ -970,12 +966,11 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n Some(parent_path.clone())\n };\n let specs = parse_parent_specs(tmp.as_ref());\n- let raw_pool: Vec = if specs.is_empty() {\n+ if specs.is_empty() {\n content.ranking_group.idx_to_item.clone()\n } else {\n crate::scope_rank::resolve_scope(content, &specs)\n- };\n- raw_pool.into_iter().map(|it| it.0).collect()\n+ }\n };\n if pool.len() < 2 {\n return Err((\n@@ -983,31 +978,30 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n Some(\"add items via ingest\".into()),\n ));\n }\n- let selected: Option<(String, String)> = {\n+ let selected: Option<(CanonicalItemUrl, CanonicalItemUrl)> = {\n let mut reduced = reduced_arc.write().await;\n let content = reduced.content.entry(scope.clone()).or_default();\n let group = &mut content.ranking_group;\n if group.idx_to_item.is_empty() {\n- pick_random_distinct(&pool)\n+ pick_random_distinct_canonical(&pool)\n } else {\n let mut rng = rand::thread_rng();\n- let idxs: Vec = pool.iter()\n- .filter_map(|it| {\n- let key = CanonicalItemUrl(it.clone());\n- group.item_to_idx.get(&key).copied()\n- })\n+ let idxs: Vec = pool\n+ .iter()\n+ .filter_map(|it| group.item_to_idx.get(it).copied())\n .collect();\n let ranked = ranked_items_subset(group, &idxs, 10000, 1e-8);\n- let ranked_set: HashSet = ranked.iter().map(|r| r.item.as_str().to_string()).collect();\n- let unsorted: Vec = pool.iter()\n+ let ranked_set: HashSet = ranked.iter().map(|r| r.item.clone()).collect();\n+ let unsorted: Vec = pool\n+ .iter()\n .filter(|it| !ranked_set.contains(*it))\n .cloned()\n .collect();\n- let mut pick: Option<(String, String)> = None;\n+ let mut pick: Option<(CanonicalItemUrl, CanonicalItemUrl)> = None;\n if !unsorted.is_empty() {\n if let Some(left) = unsorted.choose(&mut rng).cloned() {\n- let mut candidates: Vec = if !ranked.is_empty() {\n- ranked.iter().map(|r| r.item.as_str().to_string()).collect()\n+ let mut candidates: Vec = if !ranked.is_empty() {\n+ ranked.iter().map(|r| r.item.clone()).collect()\n } else {\n pool.clone()\n };\n@@ -1021,21 +1015,21 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n let a = ranked[i].item.as_str();\n let b = ranked[i + 1].item.as_str();\n if a != b && !is_pair_voted(group, a, b) {\n- pick = Some((a.to_string(), b.to_string()));\n+ pick = Some((ranked[i].item.clone(), ranked[i + 1].item.clone()));\n break;\n }\n }\n if pick.is_none() {\n for _ in 0..64 {\n let (Some(a), Some(b)) = (pool.choose(&mut rng).cloned(), pool.choose(&mut rng).cloned()) else { break; };\n- if a != b && !is_pair_voted(group, &a, &b) {\n+ if a != b && !is_pair_voted(group, a.as_str(), b.as_str()) {\n pick = Some((a, b));\n break;\n }\n }\n }\n }\n- pick.or_else(|| pick_random_distinct(&pool))\n+ pick.or_else(|| pick_random_distinct_canonical(&pool))\n }\n };\n let Some((left, right)) = selected else {\n@@ -1043,8 +1037,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n };\n let reduced = reduced_arc.read().await;\n let content = content_for_room(&reduced, &room);\n- let left_key = CanonicalItemUrl(left.clone());\n- let right_key = CanonicalItemUrl(right.clone());\n+ let left_key = left.clone();\n+ let right_key = right.clone();\n let lb = content.item_bodies.get(&left_key).cloned();\n let rb = content.item_bodies.get(&right_key).cloned();\n let th: Vec = content\n@@ -1058,8 +1052,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n .collect();\n let cs = compute_connectivity_stats(&content.ranking_group, &pool);\n Ok(RpcResult::Pair(PairResponse {\n- left: GardenItemUrl::from_storage_str(&left, &room),\n- right: GardenItemUrl::from_storage_str(&right, &room),\n+ left: GardenItemUrl::from_stored(&left, &room),\n+ right: GardenItemUrl::from_stored(&right, &room),\n left_body: lb,\n right_body: rb,\n threads: th,\n@@ -1554,7 +1548,7 @@ pub async fn handle_rpc_batch(\n for r in items {\n let pct = want_percent.then(|| ((r.score - bot) / range * 100.0).clamp(0.0, 100.0));\n ranked.push(RankRow {\n- item: GardenItemUrl::from_storage_str(r.item.as_str(), &room),\n+ item: GardenItemUrl::from_stored(&r.item, &room),\n score: r.score,\n percent: pct,\n });\n@@ -1562,11 +1556,11 @@ pub async fn handle_rpc_batch(\n }\n \n let ranked_total = ranked.len();\n- let mut unranked: Vec = content\n+ let mut unranked: Vec = content\n .items\n .iter()\n .filter(|it| !group.item_to_idx.contains_key(*it))\n- .map(|it| it.as_str().to_string())\n+ .cloned()\n .collect();\n unranked.sort();\n let unranked_total = unranked.len();\n@@ -1574,7 +1568,7 @@ pub async fn handle_rpc_batch(\n let page: Vec = ranked\n .into_iter()\n .chain(unranked.into_iter().map(|it| RankRow {\n- item: GardenItemUrl::from_storage_str(&it, &room),\n+ item: GardenItemUrl::from_stored(&it, &room),\n score: 0.0,\n percent: want_percent.then_some(0.0),\n }))\n@@ -1722,7 +1716,7 @@ pub async fn handle_rpc_batch(\n .filter(|p| !parents.contains(p.as_str()))\n .map(|p| GardenItemUrl::from_stored(p, &room))\n .collect();\n- paths.sort_by(|a, b| a.as_str().cmp(b.as_str()));\n+ paths.sort();\n line_ok(RpcResult::Leaves(LeavesResponse { paths }))\n }\n },\ndiff --git a/server/src/api/validate.rs b/server/src/api/validate.rs\nindex a51c783ee9785569b5a44c0b1572471fe00d174b..3c7aa80fd485cf247231fe5c5c7e6fea62f22534 100644\n--- a/server/src/api/validate.rs\n+++ b/server/src/api/validate.rs\n@@ -52,7 +52,7 @@ pub fn validate_ingest_document(\n };\n \n let ts = super::helpers::now_ms();\n- let mut defined_in_doc: HashSet = HashSet::new();\n+ let mut defined_in_doc: HashSet = HashSet::new();\n \n for s in &doc.statements {\n match s {\n@@ -66,14 +66,14 @@ pub fn validate_ingest_document(\n let Some(body_text) = body else {\n return Err((\n StatusCode::BAD_REQUEST,\n- format!(\"item missing body: {}\", GardenItemUrl::from_storage_str(&item, room_wire)),\n+ format!(\"item missing body: {}\", GardenItemUrl::from_stored(&item, room_wire)),\n Some(\"items must be declared with bodies, e.g. `~/path/item { ... }`\".to_string()),\n ));\n };\n if body_text.trim().is_empty() {\n return Err((\n StatusCode::BAD_REQUEST,\n- format!(\"item body is empty: {}\", GardenItemUrl::from_storage_str(&item, room_wire)),\n+ format!(\"item body is empty: {}\", GardenItemUrl::from_stored(&item, room_wire)),\n Some(\"write at least one sentence inside `{ ... }`\".to_string()),\n ));\n }\n@@ -102,11 +102,8 @@ pub fn validate_ingest_document(\n };\n let missing: Vec = [&a, &b]\n .into_iter()\n- .filter(|it| {\n- let key = CanonicalItemUrl((*it).clone());\n- !defined_in_doc.contains(*it) && !item_exists(&key)\n- })\n- .map(|it| GardenItemUrl::from_storage_str(it, room_wire).into_inner())\n+ .filter(|it| !defined_in_doc.contains(*it) && !item_exists(it))\n+ .map(|it| GardenItemUrl::from_stored(it, room_wire).into_inner())\n .collect();\n if !missing.is_empty() {\n return Err((\n@@ -120,11 +117,8 @@ pub fn validate_ingest_document(\n }\n let missing_body: Vec = [&a, &b]\n .into_iter()\n- .filter(|it| {\n- let key = CanonicalItemUrl((*it).clone());\n- !defined_in_doc.contains(*it) && !body_exists(&key)\n- })\n- .map(|it| GardenItemUrl::from_storage_str(it, room_wire).into_inner())\n+ .filter(|it| !defined_in_doc.contains(*it) && !body_exists(it))\n+ .map(|it| GardenItemUrl::from_stored(it, room_wire).into_inner())\n .collect();\n if !missing_body.is_empty() {\n return Err((\ndiff --git a/server/src/html/editor.rs b/server/src/html/editor.rs\nindex ecdd226b1b17d5f11d1b79f99add5759ef918281..26d648d7c8d6e5d53ce5ed7a051b44856fca2fcf 100644\n--- a/server/src/html/editor.rs\n+++ b/server/src/html/editor.rs\n@@ -130,8 +130,8 @@ pub async fn editor_check(\n for s in &v.doc.statements {\n if let crate::dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = crate::path_types::CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = crate::path_types::CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = a.parent() { parents.insert(p); }\n+ if let Some(p) = b.parent() { parents.insert(p); }\n }\n }\n }\ndiff --git a/types/src/paths.rs b/types/src/paths.rs\nindex 2950a7502255927583fbacdfd2adb700f0b0c221..839684dfa0ecdd6c2572e2d8132ecee4d5c41bf8 100644\n--- a/types/src/paths.rs\n+++ b/types/src/paths.rs\n@@ -3,6 +3,7 @@\n \n use std::borrow::Borrow;\n use std::fmt;\n+use std::ops::Deref;\n \n use serde::{Deserialize, Serialize};\n \n@@ -319,6 +320,14 @@ impl fmt::Display for GardenItemUrl {\n }\n }\n \n+impl Deref for GardenItemUrl {\n+ type Target = str;\n+\n+ fn deref(&self) -> &Self::Target {\n+ &self.0\n+ }\n+}\n+\n fn garden_href_string(item: &str, room_wire: &str) -> String {\n let room = room_wire.trim();\n if room.is_empty() || room == \"public\" {\n@@ -386,6 +395,14 @@ impl fmt::Display for ForumThreadUrl {\n }\n }\n \n+impl Deref for ForumThreadUrl {\n+ type Target = str;\n+\n+ fn deref(&self) -> &Self::Target {\n+ &self.0\n+ }\n+}\n+\n /// `~/a/b` style path for list UIs (paths index `path` field).\n #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]\n #[serde(transparent)]\n@@ -412,6 +429,14 @@ impl fmt::Display for TildeOntologyPath {\n }\n }\n \n+impl Deref for TildeOntologyPath {\n+ type Target = str;\n+\n+ fn deref(&self) -> &Self::Target {\n+ &self.0\n+ }\n+}\n+\n #[cfg(test)]\n mod tests {\n use super::*;\n@@ -452,6 +477,13 @@ mod tests {\n assert_eq!(c.tilde_segments(), Vec::<&str>::new());\n }\n \n+ #[test]\n+ fn garden_item_url_deref_to_str() {\n+ let g = GardenItemUrl::from_storage_str(\"https://slug.social/~/x\", \"public\");\n+ let s: &str = &*g;\n+ assert_eq!(s, \"https://slug.social/~/x\");\n+ }\n+\n #[test]\n fn garden_public_passthrough_https() {\n let u = \"https://slug.social/~/a/b\";\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[6120bd96] fix redirect urls for custom hosts and deploy from staging\n\nUse HOSTNAME and window.location.origin instead of hardcoded staging.sorter.social, and trigger fly deploys on pushes to staging.\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml\nindex 3e693915fe6f99a5c2221b2921bf8ebc305180fc..5e11e5c312f62bed34d8cc68bd4a5538f52476b9 100644\n--- a/.github/workflows/fly-deploy.yml\n+++ b/.github/workflows/fly-deploy.yml\n@@ -3,11 +3,11 @@ name: deploy to fly.io\n on:\n push:\n branches:\n- - main\n+ - staging\n workflow_dispatch:\n \n concurrency:\n- group: fly-deploy-main\n+ group: fly-deploy-staging\n cancel-in-progress: true\n \n jobs:\ndiff --git a/borter/src/app/linear.clj b/borter/src/app/linear.clj\nindex f77d8a974e0cf05f9c33233bb625bdb190d2007b..5ef0e34cf1d543826675c6facb66aec079b40561 100644\n--- a/borter/src/app/linear.clj\n+++ b/borter/src/app/linear.clj\n@@ -6,6 +6,7 @@\n [ring.util.response :as response]\n [app.database :as db]\n [app.permissions :as perms]\n+ [app.util :as util]\n [honey.sql.helpers :as h]\n [sluj.core :refer [sluj]]))\n \n@@ -13,9 +14,7 @@\n (def client-secret (System/getenv \"BORTER_LINEAR_CLIENT_SECRET\"))\n \n (defn get-hostname []\n- (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost))\n- \"sorter.social\" \"https://sorter.social/api/linear/callback\"\n- \"http://localhost:3000/api/linear/callback\"))\n+ (str (util/get-base-url) \"/api/linear/callback\"))\n \n (defn code->token [code]\n (def code code)\ndiff --git a/borter/src/app/login.clj b/borter/src/app/login.clj\nindex 5d545db9bef7765ba8b3fe7d00261c8ce84e9e1a..86817f8e94bc174e5b108859cc0b342953ab7acb 100644\n--- a/borter/src/app/login.clj\n+++ b/borter/src/app/login.clj\n@@ -1,6 +1,7 @@\n (ns app.login\n (:require [crypto.password.bcrypt :as password]\n [app.database :as db]\n+ [app.util :as util]\n [honey.sql.helpers :as h]\n [hato.client :as hc]\n [clojure.data.json :as json]\n@@ -12,10 +13,7 @@\n (def environment (or (System/getenv \"ENVIRONMENT\") \"development\"))\n \n (defn get-base-url []\n- (case environment\n- \"production\" \"https://sorter.social\"\n- \"staging\" \"https://staging.sorter.social\"\n- \"development\" \"http://localhost:3000\")) ; fallback for development\n+ (util/get-base-url))\n \n (defn create-email-content\n \"Creates a standardized email structure with customizable content\"\ndiff --git a/borter/src/app/oauth.clj b/borter/src/app/oauth.clj\nindex 1166f2ee30c9e813840711c72d1ad8f1c62e1ffe..2cd0c62f1fe267f7f5f725a97bc3ba0d0889517f 100644\n--- a/borter/src/app/oauth.clj\n+++ b/borter/src/app/oauth.clj\n@@ -3,6 +3,7 @@\n [clojure.data.json :as json]\n [hato.client :as hc]\n [app.database :as db]\n+ [app.util :as util]\n [honey.sql.helpers :as h]\n [ring.util.codec :as codec])\n (:import [java.util Base64]))\n@@ -13,12 +14,7 @@\n encoded-bytes))\n \n (defn get-hostname []\n- (let [env (System/getenv \"ENVIRONMENT\")]\n- (println \"Current environment:\" env)\n- (case env\n- \"production\" \"https://sorter.social\"\n- \"staging\" \"https://staging.sorter.social\"\n- \"http://localhost:3000\")))\n+ (util/get-base-url))\n \n (defn get-origin-hostname [req]\n (let [origin (get-in req [:headers \"origin\"])\ndiff --git a/borter/src/app/spotify.clj b/borter/src/app/spotify.clj\nindex 3e11713119141812fa2707ec956fb7ed612ee69a..b38d734351a1d4f1e142d93d4435ffe7bd20c02d 100644\n--- a/borter/src/app/spotify.clj\n+++ b/borter/src/app/spotify.clj\n@@ -1,5 +1,6 @@\n (ns app.spotify\n (:require [app.oauth :as oauth]\n+ [app.util :as util]\n [hato.client :as hc]\n [clojure.data.json :as json]\n [ring.util.codec :as codec]\n@@ -47,10 +48,7 @@\n \n \n (defn get-hostname []\n- (case (System/getenv \"ENVIRONMENT\")\n- \"production\" \"https://sorter.social\"\n- \"staging\" \"https://staging.sorter.social\"\n- \"http://localhost:3000\"))\n+ (util/get-base-url))\n \n (defn create-spotify-tag\n \"Creates a tag for a Spotify entity (artist, album, track) if it doesn't exist\"\ndiff --git a/borter/src/app/twitter.clj b/borter/src/app/twitter.clj\nindex ef7b18fa1fcb82bb944b3035ffed44499181237f..b9a3fdccc15d13918a4d11d8c0443de94fd172b4 100644\n--- a/borter/src/app/twitter.clj\n+++ b/borter/src/app/twitter.clj\n@@ -1,6 +1,7 @@\n (ns app.twitter\n (:require [app.database :as db]\n [app.permissions :as perms]\n+ [app.util :as util]\n [honey.sql.helpers :as h]\n [hato.client :as hc]\n [clojure.data.json :as json]\n@@ -37,9 +38,7 @@\n (clojure.string/join \"&\" (map (fn [[k v]] (str (name k) \"=\" v)) params)))\n \n (defn get-hostname []\n- (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost))\n- \"sorter.isnt.online\" \"https://sorter.isnt.online/api/twitter/callback\"\n- \"http://localhost:3000/api/twitter/callback\"))\n+ (str (util/get-base-url) \"/api/twitter/callback\"))\n \n (defn encode-b64 [s]\n (.encodeToString (java.util.Base64/getEncoder) (.getBytes s)))\ndiff --git a/borter/src/app/util.clj b/borter/src/app/util.clj\nindex 384a64306c84aa8288ec44cd5de57edc248a826d..6a8aa0875accb28dc81bf57e645e3961b0a3ace5 100644\n--- a/borter/src/app/util.clj\n+++ b/borter/src/app/util.clj\n@@ -2,6 +2,18 @@\n (:require [clojure.string :as string])\n (:import [java.net URLEncoder]))\n \n+(defn get-base-url\n+ \"Public site base URL for redirects and oauth callbacks.\"\n+ []\n+ (if-let [hostname (not-empty (System/getenv \"HOSTNAME\"))]\n+ (if (string/starts-with? hostname \"http\")\n+ (string/replace hostname #\"/$\" \"\")\n+ (str \"https://\" (string/replace hostname #\"/$\" \"\")))\n+ (case (or (System/getenv \"ENVIRONMENT\") \"development\")\n+ \"production\" \"https://sorter.social\"\n+ \"staging\" \"https://staging.sorter.social\"\n+ \"http://localhost:3000\")))\n+\n (defn urlencode-params [params]\n (clojure.string/join \"&\" (map (fn [[k v]] (str k \"=\" (URLEncoder/encode (str v) \"UTF-8\"))) params)))\n \ndiff --git a/borter/src/app/youtube.clj b/borter/src/app/youtube.clj\nindex 647ef7c6d4f2503a63a7c074d46dc815b2a23547..fc9a2f5ed516692f19e06b4c0221f510547cf8c0 100644\n--- a/borter/src/app/youtube.clj\n+++ b/borter/src/app/youtube.clj\n@@ -6,6 +6,7 @@\n [ring.util.response :as response]\n [app.database :as db]\n [app.permissions :as perms]\n+ [app.util :as util]\n [honey.sql.helpers :as h]\n [sluj.core :refer [sluj]]))\n \n@@ -32,9 +33,7 @@\n :body (json/read-str {:key-fn keyword})))\n \n (defn get-hostname []\n- (case (.getCanonicalHostName (java.net.InetAddress/getLocalHost))\n- \"localhost\" \"http://localhost:3000/api/youtube/callback\"\n- \"https://sorter.isnt.online/api/youtube/callback\"))\n+ (str (util/get-base-url) \"/api/youtube/callback\"))\n \n \n \ndiff --git a/forter/src/utils/authUtils.js b/forter/src/utils/authUtils.js\nindex 145e5db7ad6f7a6439d68c63beb4d07d31b2de08..98ed7fa671f9887f44dc1479d85569951eb4773e 100644\n--- a/forter/src/utils/authUtils.js\n+++ b/forter/src/utils/authUtils.js\n@@ -5,8 +5,11 @@ import { current_session, fetchSession } from \"../session\";\n let lastSyncTime = 0;\n const SYNC_THROTTLE_MS = 5000; // Only sync once every 5 seconds\n \n-// Get base URL based on Vite's mode\n+// Get base URL based on current origin, with build-mode fallbacks for SSR/build\n const getBaseUrl = () => {\n+ if (typeof window !== 'undefined' && window.location?.origin) {\n+ return window.location.origin;\n+ }\n switch (import.meta.env.MODE) {\n case 'production':\n return 'https://sorter.social';\n","role":"user"}],"model":"~x-ai/grok-latest"}