constitution · epochs · watch · epoch 3

comparison

c_c25451965a7f (tommy-mor) vs c_64faa3bee86f (tommy-mor)

download prompt · raw event · cmp_5898abe4e663cf

council reasoning

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

Side A fixes a real, spreading bug: hardcoded hostnames (staging.sorter.social, InetAddress lookups) across six backend files that would break OAuth callbacks and redirects on custom hosts, consolidating them into a single reusable util.get-base-url with proper HOSTNAME/env fallback plus a matching frontend fix and CI staging trigger. Side B reworks an auth form to avoid a redirect via innerHTML morphing, which is a reasonable UX improvement but is narrower in scope, affects only one flow, and introduces added client-side complexity without fixing a correctness bug of comparable breadth.

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

A fixes real OAuth/callback breakage by replacing duplicated hardcoded host logic across linear/login/oauth/spotify/twitter/youtube with a single HOSTNAME-aware get-base-url (plus window.location.origin on the client) and aligns deploy to staging. B is a solid auth UX improvement (fragment responses + poem morph, layout unification, CSS), but it is polish on one flow rather than correcting broken multi-host redirects project-wide.

openai/gpt-chat-latest · winner A · 3:2 · permalink

Side A introduces a shared `util/get-base-url` used across multiple OAuth/login modules, replacing inconsistent hardcoded hostnames and environment checks with `HOSTNAME`/origin-aware logic, which fixes redirect and callback behavior for custom deployments. It also updates the deployment workflow for the staging branch. Side B improves the auth UX by returning HTML fragments for inline form updates instead of redirects, but its impact is narrower and primarily affects interaction flow rather than deployment correctness or cross-environment reliability.

sides

A — 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';

download full diff A

B — c_64faa3bee86f (tommy-mor)

message

[6b6eb0c3] Auth form: poem JS morphs form innerHTML on response; no redirect

- post_choose_username returns HTML fragments instead of redirects:
  success → auth_signed_in_fragment ("you're signed in — return to your agent")
  error   → choose_username_error_fragment (form re-rendered with error inline)
- Poem JS now reads response body; if non-empty, morphs form innerHTML with it
  (existing ingest forms return empty body, so they're unaffected)
- auth.rs: keep full layout() with poem JS — revert to single layout
- auth-success CSS class added to both themes

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

diff preview

diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index c1194f79fd89fb47fe6b425b494a0ffa667abb2d..cb0faa29b834931e2c2b2f5c174c875e2e2e9346 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -16,7 +16,7 @@ use crate::{
         canonicalize_username, validate_agent_format, validate_username,
         Event, TokenIssued, UserRegistered,
     },
-    html::{auth_complete_page, choose_username_page},
+    html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},
     state::{AppState, PendingSession},
 };
 
@@ -274,8 +274,6 @@ pub async fn post_choose_username(
         return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
     }
 
-    let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
-
     let reduced_arc = state.reduced.clone();
     let reduced = reduced_arc.read().await;
     let provider_key = (provider.to_lowercase(), provider_id.clone());
@@ -284,11 +282,7 @@ pub async fn post_choose_username(
     }
     if reduced.users_by_provider.values().any(|u| u == &canonicalize_username(&form.username)) {
         drop(reduced);
-        return Redirect::to(&format!(
-            "{public_url}/auth/choose-username?session={}&error={}",
-            urlencoding::encode(&form.session),
-            urlencoding::encode("that username is taken — try another"),
-        )).into_response();
+        return choose_username_error_fragment(&form.session, "that username is taken — try another").into_response();
     }
     drop(reduced);
 
@@ -324,7 +318,7 @@ pub async fn post_choose_username(
         s.complete = Some((canon_user.clone(), bearer.clone()));
     }
 
-    Redirect::to(&format!("{public_url}/auth/complete")).into_response()
+    auth_signed_in_fragment().into_response()
 }
 
 pub async fn post_pending_session(
diff --git a/server/src/html/auth.rs b/server/src/html/auth.rs
index 40b1ef30a6c1d4063aa2d8e9c93df8972df4b27c..0a14bdbfb66c65d1bd09993ffd4a7bb6f2fe041e 100644
--- a/server/src/html/auth.rs
+++ b/server/src/html/auth.rs
@@ -1,20 +1,25 @@
-use maud::{html, Markup, DOCTYPE};
+use maud::{html, Markup};
 
-/// Minimal layout for auth pages — no JS interceptor, real form navigation works.
-fn auth_layout(title: &str, body: Markup) -> Markup {
+fn form_inner(session: &str, error: Option<&str>) -> Markup {
     html! {
-        (DOCTYPE)
-        html {
-            head {
-                meta charset="utf-8";
-                meta name="viewport" content="width=device-width, initial-scale=1";
-                title { (title) }
-                link rel="stylesheet" href="/static/theme_default.css";
-            }
-            body class="view-auth" {
-                (body)
-            }
+        input type="hidden" name="session" value=(session);
+        label for="username" { "username" }
+        input
+            type="text"
+            id="username"
+            name="username"
+            placeholder="e.g. alice"
+            pattern="[a-z0-9_\\-]{1,32}"
+            maxlength="32"
+            autocomplete="off"
+            autofocus;
+        p.auth-hint {
+            "lowercase · alphanumeric · hyphens · underscores · max 32"
         }
+        @if let Some(msg) = error {
+            p.auth-error { (msg) }
+        }
+        button type="submit" { "continue" }
     }
 }
 
@@ -28,27 +33,23 @@ pub fn choose_username_page(session: &str, error: Option<&str>) -> Markup {
         h1 { "choose a username" }
         p { "pick a handle for slug.social." }
         form.auth-form method="POST" action="/auth/choose-username" {
-            input type="hidden" name="session" value=(session);
-            label for="username" { "username" }
-            input
-                type="text"
-                id="username"
-                name="username"
-                placeholder="e.g. alice"
-                pattern="[a-z0-9_\\-]{1,32}"
-                maxlength="32"
-                autocomplete="off"
-                autofocus;
-            p.auth-hint {
-                "lowercase · alphanumeric · hyphens · underscores · max 32"
-            }
-            @if let Some(msg) = error {
-                p.auth-error { (msg) }
-            }
-            button type="submit" { "continue" }
+            (form_inner(session, error))
         }
     };
-    auth_layout("join — slug.social", body)
+    super::layout("join — slug.social", "view-auth", body, None)
+}
+
+/// Fragment returned to the poem JS on error — replaces the form's innerHTML.
+pub fn choose_username_error_fragment(session: &str, error: &str) -> Markup {
+    form_inner(session, Some(error))
+}
+
+/// Fragment returned to the poem JS on success — replaces the form's innerHTML.
+pub fn auth_signed_in_fragment() -> Markup {
+    html! {
+        p.auth-success { "you're signed in — return to your agent." }
+        p.auth-hint { "you can close this tab." }
+    }
 }
 
 pub fn auth_complete_page() -> Markup {
@@ -62,5 +63,5 @@ pub fn auth_complete_page() -> Markup {
         p { "Return to your terminal — your agent is polling and will collect your token automatically." }
         p.auth-hint { "You can close this tab." }
     };
-    auth_layout("signed in — slug.social", body)
+    super::layout("signed in — slug.social", "view-auth", body, None)
 }
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 2f16d701962d703db0c859bb586dfc08ec385690..8b48a25cce79f0eefc7e29849e1a667cae4c7986 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -15,7 +15,7 @@ mod search;
 mod tree;
 use breadcrumb_path::OntologyPath;
 
-pub use auth::{auth_complete_page, choose_username_page};
+pub use auth::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page};
 pub use editor::{editor_check, editor_page};
 pub use forum::{index, thread_feed_html, thread_post_expand, thread_post_view, thread_view};
 pub use garden::{garden_index, ontology_path};
@@ -114,6 +114,8 @@ script { (maud::PreEscaped(r#"
                         });
 
                         // Poem: intercept POST forms, send via fetch, await SSE for DOM update.
+                        // If the response body is non-empty HTML, morph the form's innerHTML with it
+                        // (used for inline feedback without a page reload, e.g. auth forms).
                         document.addEventListener('submit', async (e) => {
                             const f = e.target;
                             if (!f || f.tagName !== 'FORM') return;
@@ -121,14 +123,19 @@ script { (maud::PreEscaped(r#"
                             e.preventDefault();
                             const btn = f.querySelector('button[type="submit"], input[type="submit"]');
                             if (btn) { btn.disabled = true; btn.textContent = '…'; }
-                            await fetch(f.action, {
+                            const resp = await fetch(f.action, {
                                 method: 'POST',
                                 body: new URLSearchParams(new FormData(f)),
                                 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                                 credentials: 'same-origin',
                             });
-                            if (btn) { btn.disabled = false; btn.textContent = 'submit'; }
-                            f.reset();
+                            const html = await resp.text();
+                            if (html && html.trim()) {
+                                Idiomorph.morph(f, html, {morphStyle: 'innerHTML'});
+                            } else {
+                                if (btn) { btn.disabled = false; btn.textContent = 'submit'; }
+                                f.reset();
+                            }
                         });
 
                         // Search: debounced fetch + idiomorph.
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index 9e71574da4bed3a0116c347610636780678bd1af..a1d8d1765a7812191edc579125facf1694554c2c 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -250,6 +250,11 @@ p.auth-error {
   font-size: 12px;
   margin: 4px 0 0;
 }
+p.auth-success {
+  color: var(--signal);
+  font-size: 13px;
+  margin: 4px 0 0;
+}
 
 /* ----------------------------------------------------------------
    BUTTONS — raised, press on :active
diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css
index dc9fa4654f529eb1843557fb580cf3982da46901..8ed8fd88efab32b36bd66cf200aa182219b0a8b5 100644
--- a/server/static/theme_retro.css
+++ b/server/static/theme_retro.css
@@ -32,6 +32,7 @@ input[type="text"] {
 input[type="text"]:focus { border-color: #00ff41; }
 p.auth-hint { color: #555; font-family: monospace; font-size: 0.75rem; margin: 0; }
 p.auth-error { color: #ff4444; font-family: monospace; font-size: 0.8rem; margin: 0; }
+p.auth-success { color: #00ff41; font-family: monospace; font-size: 0.8rem; margin: 0; }
 
 /* Ingest form (poem pattern) */
 .ingest-form-wrap { margin-top: 1.5rem; }

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.