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: [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 A — 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; } Side B — contributor: tommy-mor Side B — commit message: [075d4d37] Fix OAuth test mocks so Clojure E2E auth flows work again. HttpServer handlers were crashing on query parsing and token POSTs, which broke Playwright login; also read alias/history via real CSS selectors. Co-authored-by: Cursor Side B — unified diff (full patch): diff --git a/test/auth_login.clj b/test/auth_login.clj index 6f2f00d7aa7340e63d1ac465b0a2234cec7a983f..aa63b66ccb2471b710ba3d168d0461252b39fc10 100644 --- a/test/auth_login.clj +++ b/test/auth_login.clj @@ -14,27 +14,27 @@ (defn- type-alias! [pg text] (page/evaluate pg (.replace - "(() => { const i = document.getElementById('alias-input'); const f = document.getElementById('alias-check-form'); if (!i || !f) return; + "(() => { const i = document.getElementById('alias-input'); const f = document.getElementById('alias-check-form'); if (!i || !f) return Promise.resolve('missing-form'); i.value = __TEXT__; const cf = document.getElementById('alias-claim-field'); if (cf) cf.value = i.value; return fetch(f.action, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(new FormData(f)).toString() }) .then(function (r) { return r.text(); }) - .then(function (t) { eval(t); }); })()" + .then(function (t) { eval(t); return document.getElementById('alias-status')?.textContent || ''; }); })()" "__TEXT__" - (pr-str text))) - (Thread/sleep 400)) + (pr-str text)))) -(defn- element-text [pg test-id] +(defn- element-text [pg selector] (let [raw (page/evaluate pg - (str "document.querySelector('[data-testid=\"" test-id "\"]')?.textContent || ''"))] + (str "document.querySelector(" (pr-str selector) ")?.textContent || ''"))] (when (string? raw) (str/trim raw)))) (defn- wait-for-text [pg test-id text timeout-ms] - (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (let [deadline (+ (System/currentTimeMillis) timeout-ms) + selector (str "[data-testid=\"" test-id "\"]")] (loop [] - (let [got (or (element-text pg test-id) "")] + (let [got (or (element-text pg selector) "")] (cond (= got text) got (< (System/currentTimeMillis) deadline) (do (Thread/sleep 200) (recur)) diff --git a/test/support/mock_oauth.clj b/test/support/mock_oauth.clj index 5ba7e9be3cf6d2226648e9609a09ed45f306930f..333fe08d56cb06bac2a338cad1c73894d54c4495 100644 --- a/test/support/mock_oauth.clj +++ b/test/support/mock_oauth.clj @@ -7,7 +7,7 @@ (defn- query-param [query key] (when query (some (fn [pair] - (let [[k v] (str/split pair "=" 2)] + (let [[k v] (str/split pair #"=" 2)] (when (= k key) (URLDecoder/decode (or v "") "UTF-8")))) (str/split query #"&")))) @@ -31,11 +31,11 @@ (defn- send-redirect [^HttpExchange ex location] (.set (.getResponseHeaders ex) "Location" location) - (.sendResponseHeaders ex 302 -1) + (.sendResponseHeaders ex 302 0) (.close (.getResponseBody ex))) (defn- read-form [^HttpExchange ex] - (let [body (slurp (.getInputStream ex))] + (let [body (slurp (.getRequestBody ex))] {:code (query-param body "code") :grant (query-param body "grant_type")})) @@ -45,7 +45,7 @@ (str/replace #"^[Bb]earer " ""))) (defn- parse-token-user [token] - (when (str/starts-with? token "mock:") + (when (and token (str/starts-with? token "mock:")) (parse-mock-user (subs token 5)))) (defn- authorize-redirect [exchange query] @@ -55,7 +55,7 @@ user (parse-mock-user mock-user) code (str "mock:" (:id user) ":" (:login user)) loc (str redirect-uri "?code=" (java.net.URLEncoder/encode code "UTF-8") - "&state=" (java.net.URLEncoder/encode state "UTF-8"))] + "&state=" (java.net.URLEncoder/encode (or state "") "UTF-8"))] (send-redirect exchange loc))) (defn start-mock-oauth @@ -65,49 +65,56 @@ handler (proxy [HttpHandler] [] (handle [^HttpExchange exchange] - (let [uri (.getRequestURI exchange) - path (.getPath uri) - query (.getQuery uri) - method (.getRequestMethod exchange)] - (cond - ;; GitHub authorize - (str/ends-with? path "/login/oauth/authorize") - (authorize-redirect exchange query) + (try + (let [uri (.getRequestURI exchange) + path (.getPath uri) + query (.getQuery uri) + method (.getRequestMethod exchange)] + (cond + ;; GitHub authorize + (str/ends-with? path "/login/oauth/authorize") + (authorize-redirect exchange query) - ;; Reddit authorize - (str/ends-with? path "/api/v1/authorize") - (authorize-redirect exchange query) + ;; Reddit authorize + (str/ends-with? path "/api/v1/authorize") + (authorize-redirect exchange query) - ;; GitHub token - (and (= method "POST") (str/ends-with? path "/login/oauth/access_token")) - (let [code (or (:code (read-form exchange)) "mock:1002:newbie")] - (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\"}"))) + ;; GitHub token + (and (= method "POST") (str/ends-with? path "/login/oauth/access_token")) + (let [code (or (:code (read-form exchange)) "mock:1002:newbie")] + (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\"}"))) - ;; Reddit token (client_credentials for import + authorization_code for login) - (and (= method "POST") (str/ends-with? path "/api/v1/access_token")) - (let [form (read-form exchange) - grant (or (:grant form) "") - code (or (:code form) "mock:t2_test:redditor")] - (if (= grant "client_credentials") - (send-json exchange 200 "{\"access_token\":\"app-token\",\"token_type\":\"bearer\",\"expires_in\":3600}") - (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\",\"expires_in\":3600}")))) + ;; Reddit token (client_credentials for import + authorization_code for login) + (and (= method "POST") (str/ends-with? path "/api/v1/access_token")) + (let [form (read-form exchange) + grant (or (:grant form) "") + code (or (:code form) "mock:t2_test:redditor")] + (if (= grant "client_credentials") + (send-json exchange 200 "{\"access_token\":\"app-token\",\"token_type\":\"bearer\",\"expires_in\":3600}") + (send-json exchange 200 (str "{\"access_token\":\"" code "\",\"token_type\":\"bearer\",\"expires_in\":3600}")))) - ;; GitHub user - (= path "/user") - (let [token (bearer-token exchange) - user (or (parse-token-user token) {:id "1002" :login "newbie" :numeric? true})] - (send-json exchange 200 - (str "{\"id\":" (:id user) ",\"login\":\"" (:login user) "\"}"))) + ;; GitHub user + (= path "/user") + (let [token (bearer-token exchange) + user (or (parse-token-user token) {:id "1002" :login "newbie" :numeric? true})] + (send-json exchange 200 + (str "{\"id\":" (:id user) ",\"login\":\"" (:login user) "\"}"))) - ;; Reddit /api/v1/me - (str/ends-with? path "/api/v1/me") - (let [token (bearer-token exchange) - user (or (parse-token-user token) {:id "t2_test" :login "redditor"})] - (send-json exchange 200 - (str "{\"id\":\"" (:id user) "\",\"name\":\"" (:login user) "\"}"))) + ;; Reddit /api/v1/me + (str/ends-with? path "/api/v1/me") + (let [token (bearer-token exchange) + user (or (parse-token-user token) {:id "t2_test" :login "redditor"})] + (send-json exchange 200 + (str "{\"id\":\"" (:id user) "\",\"name\":\"" (:login user) "\"}"))) - :else - (send-json exchange 404 "{\"error\":\"not found\"}")))))] + :else + (send-json exchange 404 "{\"error\":\"not found\"}"))) + (catch Throwable t + (binding [*out* *err*] + (println "mock-oauth handler error:" t)) + (try + (send-json exchange 500 "{\"error\":\"mock-oauth internal\"}") + (catch Throwable _))))))] (.createContext server "/" handler) (.setExecutor server nil) (.start server) diff --git a/test/support/mock_reddit.clj b/test/support/mock_reddit.clj index a630cf0938722193e9af88382d60e777ff371be4..faa27945b6394363914c853627a0bad1e819a5f9 100644 --- a/test/support/mock_reddit.clj +++ b/test/support/mock_reddit.clj @@ -12,7 +12,7 @@ (defn- query-param [query key] (when query (some (fn [pair] - (let [[k v] (str/split pair "=" 2)] + (let [[k v] (str/split pair #"=" 2)] (when (= k key) (URLDecoder/decode (or v "") "UTF-8")))) (str/split query #"&")))) @@ -34,11 +34,11 @@ (defn- send-redirect [^HttpExchange ex location] (.set (.getResponseHeaders ex) "Location" location) - (.sendResponseHeaders ex 302 -1) + (.sendResponseHeaders ex 302 0) (.close (.getResponseBody ex))) (defn- read-form [^HttpExchange ex] - (let [body (slurp (.getInputStream ex))] + (let [body (slurp (.getRequestBody ex))] {:code (query-param body "code") :grant (query-param body "grant_type")})) @@ -48,7 +48,7 @@ (str/replace #"^[Bb]earer " ""))) (defn- parse-token-user [token] - (when (str/starts-with? token "mock:") + (when (and token (str/starts-with? token "mock:")) (parse-mock-user (subs token 5)))) (defn start-mock-reddit @@ -72,7 +72,7 @@ user (parse-mock-user (query-param query "mock_user")) code (str "mock:" (:id user) ":" (:login user)) loc (str redirect-uri "?code=" (java.net.URLEncoder/encode code "UTF-8") - "&state=" (java.net.URLEncoder/encode state "UTF-8"))] + "&state=" (java.net.URLEncoder/encode (or state "") "UTF-8"))] (send-redirect exchange loc)) (and (= method "POST") (str/ends-with? path "/api/v1/access_token"))