Side B fixes a real, systemic bug (hardcoded staging hostnames breaking OAuth callbacks and redirects across multiple services) by consolidating logic into a single util.get-base-url helper, reducing duplication and future maintenance burden across 6+ files. Side A is a legitimate but narrow UI feature (clickable unpin icon) with reasonable test coverage, but it's more localized and lower-impact than fixing broken cross-service redirect infrastructure.
constitution · epochs · watch · epoch 3
c_7ec4b410de02 (tommy-mor) vs c_c25451965a7f (tommy-mor)
download prompt · raw event · cmp_1e5ba6168051e6
council reasoning
B fixes a systemic correctness issue by replacing duplicated hardcoded host cases (oauth/linear/spotify/twitter/youtube/login plus frontend authUtils) with a shared HOSTNAME-aware get-base-url and window.location.origin, and wires deploy to staging—lasting multi-host infra. A is a solid but localized UX win: the ranked-list pin becomes an unpin POST form with theme CSS and an extended browser test, reusing an existing flow rather than foundation design.
Side B consolidates base-URL generation into a shared util function, replaces multiple hardcoded host/environment checks across OAuth and callback flows with a single HOSTNAME-aware implementation, and updates the frontend to use window.location.origin, improving portability for custom hosts. Side A adds a useful UI enhancement by making pinned child-group icons submit the existing unpin flow and backs it with browser tests, but its impact is confined to a specific interaction rather than infrastructure used across many authentication and redirect paths.
sides
A — c_7ec4b410de02 (tommy-mor)
message
[02cd761c] Make ranked child group pin icons clickable to unpin. Lets users clear the garden pin from the ranked child groups list via the same POST /ui set_garden_pin flow as the HUD and item header. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/html/garden/pin.rs b/server/src/html/garden/pin.rs
index 1820d7fe7ee167ecbf5ad5124f23b2ab92ffb01f..b7ea92bbe67d6d499915edb607a3a0b36178d7f6 100644
--- a/server/src/html/garden/pin.rs
+++ b/server/src/html/garden/pin.rs
@@ -101,6 +101,7 @@ pub(super) fn child_row_pin_or_vote(
row_item: &ItemId,
pinned_room_and_item: Option<&(String, ItemId)>,
scope_content: &ContentState,
+ next_path: &str,
) -> maud::Markup {
let pin_matches_scope = pinned_room_and_item
.map(|(r, _)| r == nav.room_wire.as_str())
@@ -108,12 +109,25 @@ pub(super) fn child_row_pin_or_vote(
let pinned_item = pinned_room_and_item
.filter(|_| pin_matches_scope)
.map(|(_, i)| i);
+ let unpin_rpc = template_json_compact(&json!({
+ "action": "set_garden_pin",
+ "clear": true,
+ "room_wire": "",
+ "next": next_path,
+ "form_action": "/ui",
+ }))
+ .expect("unpin rpc json");
html! {
@if let Some(pi) = pinned_item {
span class="ont-garden-child-actions" data-garden-room=(nav.room_wire.as_str()) {
@if pi == row_item {
- span class="ont-garden-pinned-here" title="Pinned" aria-label="Pinned" { "📌" }
+ form method="POST" action="/ui" data-navigate="full" class="ont-garden-pin-form" {
+ input type="hidden" name=(UI_RPC_FIELD) value=(unpin_rpc);
+ button type="submit" class="ont-garden-pin-ico ont-garden-pin-ico-active" title="Unpin" aria-label="Unpin from HUD" {
+ span class="ont-garden-pin-glyph" aria-hidden="true" { "📌" }
+ }
+ }
} @else {
@let nv = edge_vote_count_for_pair(scope_content, pi, row_item);
@let tip = format!(
diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs
index bd8cbce059ee789de6e8dc9b6f69f54beee2f994..7bfaadc0786b734db8973c53b82296a188d26e22 100644
--- a/server/src/html/garden/render.rs
+++ b/server/src/html/garden/render.rs
@@ -216,7 +216,7 @@ pub(super) async fn render_scope_view(
@let item_url = item_href(r.item.as_str(), &nav);
@let score_str = format!("{:.3}", r.score);
li data-garden-item=(r.item.as_str()) {
- (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), scope_content))
+ (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), scope_content, &next_for_pin))
a class="item-link" href=(item_url) { code { (item_display_path(r.item.as_str())) } }
span class="ont-rank-score" { (score_str) }
}
@@ -232,7 +232,7 @@ pub(super) async fn render_scope_view(
ul class="ont-group-list" {
@for name in &model.child_rankings.unranked_items {
li data-garden-item=(name.as_str()) {
- (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), scope_content))
+ (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), scope_content, &next_for_pin))
@let href = item_href(name.as_str(), &nav);
a class="item-link" href=(href) { code { (item_display_path(name.as_str())) } }
}
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index 2dabf3a7094cecd89d0c9674d27efe30cd01c010..b846ab86cb6eb5fb56be87efb054fb8b18082583 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -891,8 +891,7 @@ button.ont-pin-btn-active {
vertical-align: middle;
}
button.ont-garden-pin-ico,
-a.ont-garden-vote-ico,
-span.ont-garden-pinned-here {
+a.ont-garden-vote-ico {
font-size: 13px;
line-height: 1;
padding: 2px 6px;
@@ -910,6 +909,11 @@ span.ont-garden-pinned-here {
align-items: center;
justify-content: center;
}
+form.ont-garden-pin-form {
+ display: inline;
+ margin: 0;
+ padding: 0;
+}
a.ont-garden-vote-ico {
gap: 4px;
}
@@ -925,10 +929,14 @@ a.ont-garden-vote-ico:hover {
span.ont-garden-vote-glyph {
line-height: 1;
}
-span.ont-garden-pinned-here {
+button.ont-garden-pin-ico-active {
border-color: var(--link);
color: var(--signal);
- cursor: default;
+ background: color-mix(in srgb, var(--link) 12%, var(--g3));
+}
+button.ont-garden-pin-ico-active:hover {
+ color: var(--signal);
+ background: color-mix(in srgb, var(--link) 18%, var(--g4));
}
button.ont-garden-pin-ico:focus-visible {
outline: 2px solid var(--link);
diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css
index bdd9031b82154a5019782e6dcb8bf7589fafce09..5d4954812ad43f2ee7e63c71c7f0ae0b3e3fc95b 100644
--- a/server/static/theme_retro_craft.css
+++ b/server/static/theme_retro_craft.css
@@ -711,8 +711,7 @@ body.view-ontology span.ont-garden-vote-glyph {
line-height: 1;
}
body.view-ontology button.ont-garden-pin-ico,
-body.view-ontology a.ont-garden-vote-ico,
-body.view-ontology span.ont-garden-pinned-here {
+body.view-ontology a.ont-garden-vote-ico {
font-size: 0.95rem;
line-height: 1;
padding: 0.1rem 0.35rem;
@@ -732,10 +731,14 @@ body.view-ontology a.ont-garden-vote-ico:hover {
border-color: #a68e6b;
background: #ebe6dc;
}
-body.view-ontology span.ont-garden-pinned-here {
+body.view-ontology button.ont-garden-pin-ico-active {
border-color: #1a4a8c;
background: color-mix(in srgb, #1a4a8c 10%, #f7f3eb);
- cursor: default;
+ color: #0d2d5c;
+}
+body.view-ontology button.ont-garden-pin-ico-active:hover {
+ border-color: #a68e6b;
+ background: color-mix(in srgb, #1a4a8c 14%, #ebe6dc);
}
body.view-ontology form.ont-garden-pin-form {
display: inline;
diff --git a/test/browser_garden_pin.clj b/test/browser_garden_pin.clj
index d4fa9ed60b45e872ec60e29307cfa61bcd9dd6b2..579976f4c2493a149f9eba081862ca07919f4fe9 100644
--- a/test/browser_garden_pin.clj
+++ b/test/browser_garden_pin.clj
@@ -58,10 +58,11 @@
(let [alice-token (oauth/fetch-bearer-token! base-url :username "alice")
thread-tag "browser-garden-pin"
raw (str "# " thread-tag "\n\n"
- "~/gp-pin-a {alpha}\n"
- "~/gp-pin-b {beta}\n"
+ "~/gp-parent {parent}\n"
+ "~/gp-parent/pin-a {alpha}\n"
+ "~/gp-parent/pin-b {beta}\n"
"{pin test vote}\n"
- "~/gp-pin-a 2:1 ~/gp-pin-b\n")
+ "~/gp-parent/pin-a 2:1 ~/gp-parent/pin-b\n")
post-resp (oauth/http-post-json
(str base-url "/api/v0/rpc")
[{"Post" {"room" "public"
@@ -77,23 +78,43 @@
(core/with-page [pg (core/new-page-from-context ctx)]
(page/navigate pg (str base-url "/login"))
(is (wait-for-text pg "body" "@alice" 15000) "alice session after login")
- (page/navigate pg (str base-url "/~/gp-pin-a"))
- (is (wait-for-text pg ".ont-item-shell" "~/gp-pin-a" 15000) "on item a page")
+ (page/navigate pg (str base-url "/~/gp-parent/pin-a"))
+ (is (wait-for-text pg ".ont-item-shell" "~/gp-parent/pin-a" 15000) "on item a page")
;; Native form POST /ui (data-navigate=full) — not fetch/eval
(locator/click (page/locator pg ".ont-item-pin-zone form.ont-pin-form button[type=submit]"))
- (is (wait-for-text pg "#slug-pin-hud" "gp-pin-a" 15000)
+ (is (wait-for-text pg "#slug-pin-hud" "gp-parent/pin-a" 15000)
"HUD shows pinned item label after redirect")
- (page/navigate pg (str base-url "/~/gp-pin-b"))
- (is (wait-for-text pg ".ont-item-shell" "~/gp-pin-b" 15000) "on item b page")
+ (page/navigate pg (str base-url "/~/gp-parent/pin-b"))
+ (is (wait-for-text pg ".ont-item-shell" "~/gp-parent/pin-b" 15000) "on item b page")
(is (wait-for-text pg "a.ont-vote-compare-btn" "vote" 10000)
"vote link visible vs pinned item")
- ;; HUD clears pin (POST set_garden_pin clear), not navigate to item
+ ;; Unpin from ranked child groups on parent page
+ (page/navigate pg (str base-url "/~/gp-parent"))
+ (is (wait-for-text pg ".ont-tab-panel-children" "ranked child groups" 15000)
+ "parent page shows ranked child groups")
+ (is (wait-for-text pg ".ont-ranking-list button.ont-garden-pin-ico-active" "📌" 10000)
+ "pinned row shows active pin in ranked list")
+ (locator/click (page/locator pg ".ont-ranking-list button.ont-garden-pin-ico-active"))
+ (is (wait-for-absence-substr pg "#slug-pin-hud" "gp-parent/pin-a" 15000)
+ "HUD clears after unpin from ranked child list")
+ (is (wait-for-absence-substr pg ".ont-ranking-list" "ont-garden-vote-ico" 10000)
+ "vote icons removed from ranked list after unpin")
+ (page/navigate pg (str base-url "/~/gp-parent/pin-b"))
+ (is (wait-for-text pg ".ont-item-shell" "~/gp-parent/pin-b" 15000) "on item b page again")
+ (is (wait-for-absence-substr pg "body" "ont-vote-compare-btn" 15000)
+ "compare vote CTA removed after ranked-list unpin")
+ ;; HUD unpin still works from item page context
+ (page/navigate pg (str base-url "/~/gp-parent/pin-a"))
+ (locator/click (page/locator pg ".ont-item-pin-zone form.ont-pin-form button[type=submit]"))
+ (is (wait-for-text pg "#slug-pin-hud" "gp-parent/pin-a" 15000)
+ "re-pin from item page for HUD unpin test")
+ (page/navigate pg (str base-url "/~/gp-parent/pin-b"))
(locator/click (page/locator pg "#slug-pin-hud button.slug-pin-hud-unpin-btn"))
- (is (wait-for-absence-substr pg "#slug-pin-hud" "gp-pin-a" 15000)
+ (is (wait-for-absence-substr pg "#slug-pin-hud" "gp-parent/pin-a" 15000)
"HUD clears after unpin from HUD button")
(is (wait-for-absence-substr pg "body" "ont-vote-compare-btn" 15000)
"compare vote CTA removed after HUD unpin on same reload")
- (is (str/includes? (or (page/url pg) "") "/~/gp-pin-b")
+ (is (str/includes? (or (page/url pg) "") "/~/gp-parent/pin-b")
"still on item b after HUD unpin"))))))
(finally
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.