Side A delivers substantive, tested improvements: safer OAuth redirect handling, richer GitHub resolver output with pagination and readable bodies, a real 'suggest next pair' algorithm with tests, and UI/CSS polish tied to those features. Side B ('nice') is a minor, low-effort commit that mostly renames a package, tweaks .gitignore, and arguably regresses UX by replacing a multiline textarea with a single-line input, offering little lasting value.
constitution · epochs · watch · epoch 3
c_99071b4accf5 (tommy-mor) vs c_841186378da0 (tommy-mor)
download prompt · raw event · cmp_2a9741cd273322
council reasoning
Side A delivers lasting product value: safer auth next-redirects, GitHub resolver pagination/commits/releases with readable bodies and status morphs, smart next-pair suggestion in vote-compare (plus UI and tests). Side B is mostly local tooling/CSS churn (gitignore, env rename, button styles) and a minor input tweak, with little durable design impact.
Side A delivers substantial functional improvements across the application: it adds safe local post-login redirects, fixes the vote-compare flow by suggesting and updating the next pair after voting, improves GitHub resolver behavior with pagination, commits/releases support and readable imported content, and adds corresponding tests. Side B is mostly development/configuration and UI styling changes (renaming the watched package, ignoring a local data directory, changing a textarea to a text input, and CSS tweaks), with little lasting impact on core functionality.
sides
A — c_99071b4accf5 (tommy-mor)
message
[e8c85249] Fix GitHub resolver and vote compare flow (#149) * Fix GitHub resolver and vote compare flow Co-authored-by: tommy <thmorriss@gmail.com> * Fix vote compare next pair helper Co-authored-by: tommy <thmorriss@gmail.com> * Extend GitHub resolver and vote pair updates Co-authored-by: tommy <thmorriss@gmail.com> * Fix resolver browser refresh coverage Co-authored-by: tommy <thmorriss@gmail.com> * Stabilize GitHub resolver browser test Co-authored-by: tommy <thmorriss@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
diff preview
diff --git a/agents.md b/agents.md
index c66f33789441eea193b4354fce4c03b7fffdd639..7508234d9b04223d0e64cfe69fedbebd06a256b5 100644
--- a/agents.md
+++ b/agents.md
@@ -36,12 +36,18 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma
- **`HtmlUiAction` / `POST /ui`** (`server/src/html/ui_action.rs`, `server/src/api/ui_html.rs`): **Browser session** (cookie) UI commands. Payload is `__rpc__` + form fields. Most responses are **JS morphs**; some actions return **HTTP redirects** (see below).
+- **Do not add one-off POST routes** for browser mutations. New browser actions belong in **`HtmlUiAction`** behind **`POST /ui`**; new programmatic verbs belong in **`RpcCommand`** behind **`POST /api/v0/rpc`**. Ordinary shareable pages remain normal **`GET`** routes.
+
- **Non-morph `POST /ui` responses:** **`SetGardenPin`** returns **`303 See Other`** and **`Set-Cookie`** (same as **`POST /theme`**). Garden pin/unpin is a normal **`<form method="POST" action="/ui" data-navigate="full">`** — browser navigation applies cookies reliably (see **`test/browser_garden_pin.clj`**). Each **`__rpc__`** payload includes **`form_action: "/ui"`**; **`post_ui_html`** rejects mismatches to bind tokens to the UI endpoint.
-- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-compare-preview`** (new ingest card), **`#vote-edge-history-region`** (recomputed **`<ul>`** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.
+- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`<ul>`** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.
+
+- **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only.
- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.
+- **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection.
+
**Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate.
---
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index c9c4082f251838b5ac46de7ce48392c136883d55..88bb3335e4873643530351266d213f258da5893e 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -8,7 +8,10 @@ use axum::{
use axum_extra::extract::cookie::CookieJar;
use base64::Engine;
use serde::Deserialize;
-use slug_types::{PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse, WhoamiResponse};
+use slug_types::{
+ PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse,
+ WhoamiResponse,
+};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::{oneshot, RwLock};
@@ -17,7 +20,8 @@ use crate::{
events::{Event, TokenIssued},
html::{
auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,
- choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder,
+ choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri,
+ JsBuilder,
},
identity::{parse_agent, parse_username},
reducer::ReducerState,
@@ -36,12 +40,26 @@ pub const SLUG_SESSION_COOKIE: &str = "slug_session";
/// `Set-Cookie` header value (full attribute string).
pub fn session_cookie_header_value(bearer: &str) -> HeaderValue {
- let s = format!(
- "{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
- );
+ let s =
+ format!("{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000");
HeaderValue::from_str(&s).expect("session cookie value must be ASCII")
}
+fn safe_local_redirect(raw: Option<&str>) -> Option<String> {
+ let s = raw?.trim();
+ if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 {
+ Some(s.to_string())
+ } else {
+ None
+ }
+}
+
+fn redirect_query(next: Option<&str>) -> String {
+ safe_local_redirect(next)
+ .map(|n| format!("&next={}", urlencoding::encode(&n)))
+ .unwrap_or_default()
+}
+
fn js_form_error_fragment(session: &str, error: &str) -> Response {
JsBuilder::new()
.id("choose-username-form")
@@ -49,11 +67,11 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response {
.into_response()
}
-fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {
+fn js_signed_in_fragment(bearer: &str, jar: &CookieJar, redirect_to: &str) -> Response {
let mut response = JsBuilder::new()
.id("choose-username-form")
.morph_inner(auth_signed_in_fragment())
- .redirect("/auth/complete")
+ .redirect(redirect_to)
.into_response();
let headers = response.headers_mut();
headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));
@@ -64,7 +82,11 @@ fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {
}
/// Resolve the signed-in username from `Authorization: Bearer` or `slug_session` cookie.
-pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option<String> {
+pub fn optional_principal(
+ headers: &HeaderMap,
+ jar: &CookieJar,
+ reduced: &ReducerState,
+) -> Option<String> {
if let Ok(u) = verify_bearer_principal(headers, reduced) {
return Some(u);
}
@@ -80,7 +102,11 @@ pub struct WebSession {
}
/// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar).
-pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option<WebSession> {
+pub fn resolve_web_session(
+ headers: &HeaderMap,
+ jar: &CookieJar,
+ reduced: &ReducerState,
+) -> Option<WebSession> {
let username = optional_principal(headers, jar, reduced)?;
let bearer = headers
.get(header::AUTHORIZATION)
@@ -90,7 +116,12 @@ pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduc
Some(WebSession { username, bearer })
}
-fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {
+fn redirect_with_session_cookie(
+ public_url: &str,
+ path_and_query: &str,
+ bearer: &str,
+ jar: &CookieJar,
+) -> Response {
let mut res = Response::builder()
.status(StatusCode::TEMPORARY_REDIRECT)
.header(header::LOCATION, format!("{public_url}{path_and_query}"))
@@ -112,21 +143,32 @@ fn pending_sessions(state: &AppState) -> Arc<RwLock<HashMap<String, PendingSessi
/// Safe here because the token was received directly from Google's token endpoint over TLS.
fn extract_jwt_sub(jwt: &str) -> Option<String> {
let payload_b64 = jwt.split('.').nth(1)?;
- let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
+ let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
+ .decode(payload_b64)
+ .ok()?;
let v: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
v.get("sub")?.as_str().map(|s| s.to_string())
}
pub(crate) fn parse_bearer(headers: &HeaderMap) -> Result<String, (StatusCode, String)> {
let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
- return Err((StatusCode::UNAUTHORIZED, "missing Authorization header".to_string()));
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ "missing Authorization header".to_string(),
+ ));
};
let Ok(s) = value.to_str() else {
- return Err((StatusCode::UNAUTHORIZED, "invalid Authorization header".to_string()));
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ "invalid Authorization header".to_string(),
+ ));
};
let s = s.trim();
let Some(rest) = s.strip_prefix("Bearer ") else {
- return Err((StatusCode::UNAUTHORIZED, "Authorization must be Bearer".to_string()));
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ "Authorization must be Bearer".to_string(),
+ ));
};
Ok(rest.trim().to_string())
}
@@ -140,7 +182,10 @@ pub fn verify_bearer_principal(
verify_token(reduced, &bearer)
}
-pub(crate) fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result<String, (StatusCode, String)> {
+pub(crate) fn verify_token(
+ reduced: &crate::reducer::ReducerState,
+ bearer: &str,
+) -> Result<String, (StatusCode, String)> {
// slug_<token_id>_<secret>
let Some(rest) = bearer.strip_prefix("slug_") else {
return Err((StatusCode::UNAUTHORIZED, "invalid token format".to_string()));
@@ -199,9 +244,25 @@ pub(crate) fn issue_token_for_user(stored_username: &str) -> (String, TokenIssue
#[derive(Debug, Deserialize)]
pub struct AuthLoginQuery {
pub session: String,
+ #[serde(default)]
+ pub next: Option<String>,
+ #[serde(default)]
+ pub redirect: Option<String>,
}
-pub async fn get_join_invite(Path(token): Path<String>, State(state): State<AppState>) -> impl IntoResponse {
+#[derive(Debug, Deserialize)]
+pub struct JoinInviteQuery {
+ #[serde(default)]
+ pub next: Option<String>,
+ #[serde(default)]
+ pub redirect: Option<String>,
+}
+
+pub async fn get_join_invite(
+ Path(token): Path<String>,
+ Query(q): Query<JoinInviteQuery>,
+ State(state): State<AppState>,
+) -> impl IntoResponse {
let token = token.trim().to_string();
if token.is_empty() {
return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response();
@@ -219,37 +280,57 @@ pub async fn get_join_invite(Path(token): Path<String>, State(state): State<AppS
}
let session = format!("p_{}", uuid::Uuid::new_v4().simple());
+ let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()));
let s = PendingSession {
agent: INVITE_BROWSER_AGENT.to_string(),
created_ts: now_ms(),
provider: None,
provider_id: None,
redeem_invite: Some(token),
+ redirect_next: redirect_next.clone(),
complete: None,
};
- state.pending_sessi
… preview truncated; 86,121 characters omittedB — c_841186378da0 (tommy-mor)
message
[e8fdeacf] nice
diff preview
diff --git a/.gitignore b/.gitignore
index 73e8f22cf0d39c706e7cdce5e39f1903a0f9181b..4c7073f9fac0c30fd2050d79a60ef447af58ebeb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@
.DS_Store
data/
repomix-output.xml
+dev-data/
diff --git a/bb.edn b/bb.edn
index 1e8ec120921761a9a4f28f36578efb1567407cb3..43cc60c9461466e0f7cb08cfea5c600edf95a662 100644
--- a/bb.edn
+++ b/bb.edn
@@ -9,11 +9,10 @@
:task (do
(deref (p/process ["mkdir" "-p" "dev-data"] {:inherit true}))
(deref (p/process ["cargo" "watch"
- "-x" "run -p server"
+ "-x" "run -p sorter2-server"
"-w" "server/"]
{:inherit true
:env (merge (into {} (System/getenv))
- {"SLUG_DATA_DIR" "dev-data"
- "SLUG_KEYS" "dev:dev"
- "PORT" "8080"
- "RUST_LOG" "info"})})))}}}
+ {"SORTER2_DATA_DIR" "dev-data"
+ "PORT" "8080"
+ "RUST_LOG" "info"})})))}}}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 322dffefeaa3b8560c1c2b2f70ba8e5a8c555022..0acba3ac745f23be473243e0a9b9cc6e57e8d86f 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -232,7 +232,8 @@ pub fn input_panel(query: &str, error: Option<&str>) -> Markup {
html! {
section id="parser-panel" class="demo-panel" {
form method="post" action="/ui" id="parser-form" {
- textarea
+ input
+ type="text"
name="query"
id="parser-input"
rows="3"
diff --git a/server/static/sorter.css b/server/static/sorter.css
index 04f4fabdea3de7c3bc54805d0d3d9e0cea6eeefd..a648e88a57290346b1069868134a212b180def52 100644
--- a/server/static/sorter.css
+++ b/server/static/sorter.css
@@ -34,29 +34,20 @@ body {
font-size: 0.75rem;
}
-.demo-panel {
- max-width: 40rem;
- margin: 4rem auto 2rem;
- padding: 2rem;
- background: var(--panel);
- border: 1px solid var(--border);
- border-radius: 8px;
-}
-
-.demo-panel h1 {
- margin-top: 0;
-}
-
.btn-primary {
background: var(--accent);
color: #0f1115;
- border: none;
+ border-style: outset;
+ border-width: 3px;
padding: 0.5rem 1rem;
- border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
+.btn-primary:active {
+ border-style: inset;
+}
+
.btn-primary:hover {
filter: brightness(1.1);
}
@@ -103,11 +94,15 @@ code {
color: var(--muted);
}
+#parser-panel {
+ display: flex;
+ flex-direction: column;
+}
+
#parser-input {
- width: 100%;
+ width: 90%;
padding: 0.5rem;
border: 1px solid var(--border);
- border-radius: 4px;
background: var(--bg);
color: var(--fg);
font-size: 1rem;
@@ -115,7 +110,12 @@ code {
resize: vertical;
}
+#parser-input:focus-visible {
+ outline: 0;
+}
+
#parser-form .btn-primary {
+ width: 10%;
margin-top: 0.5rem;
}
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.