You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit 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 Side A — unified diff (full patch): 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'; Side B — contributor: tommy-mor Side B — commit 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) Side B — unified diff (full patch): 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; }