constitution · epochs · watch · epoch 3

comparison

c_3e6a3370542e (tommy-mor) vs c_99071b4accf5 (tommy-mor)

download prompt · raw event · cmp_65819d8d8f0cf2

council reasoning

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

Side A is a coherent architectural fix: it replaces ad-hoc/vestigial vote fields with a proper pseudonym→actor-UUID identity model and uuid-keyed vote dedup, rewrites the storage schema and reducer accordingly, and adds targeted tests validating dedup/rollback semantics — a real correctness improvement to the ranking core. Side B is a useful but more diffuse grab-bag of UI/UX fixes (GitHub resolver pagination and readable bodies, vote-compare next-pair suggestion, login redirect handling) that improves surface features without touching the system's core data-integrity model.

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

A redesigns core vote persistence: uuid-keyed dedup with rollback/replace, rankings derived from uuid_votes on load, and a seeded pseudonyms map—replacing blind edge merges and vestigial identity fields. B delivers real product value (safer auth next redirects, richer GitHub resolver + paging/bodies, vote-compare next-pair/nav UX), but it is incremental feature/fix work rather than a lasting storage/identity model change.

openai/gpt-chat-latest · winner A · 4:1 · permalink

Side A introduces a fundamental change to vote storage and replay by replacing incremental edge merges with UUID-keyed deduplicated votes, adding pseudonym-to-UUID resolution, rebuilding rankings from stored votes, updating schemas, and changing reducer logic to replace prior votes from the same actor. Side B contains valuable fixes and UX improvements (GitHub resolver pagination/content, vote compare navigation, safe login redirects, and browser flow fixes), but these are incremental features and bug fixes rather than a lasting redesign of core persistence and ranking behavior.

sides

A — c_3e6a3370542e (tommy-mor)

message

[e52a5895] Replace vestigial vote identity with pseudonym-based uuid dedup.

Store votes in uuid_votes (not blind edge merges), derive rankings on load, and resolve actors via a pseudonyms map seeded at projection open.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/server/src/bin/storage_bench.rs b/server/src/bin/storage_bench.rs
index 3d988416ad36b27a7d3dc84280cfdbdcafa43e69..cc5cd78ba1e02a1e6f92aa503677f2d329bb5993 100644
--- a/server/src/bin/storage_bench.rs
+++ b/server/src/bin/storage_bench.rs
@@ -29,14 +29,7 @@ async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
     for chunk_start in (0..opts.events).step_by(opts.batch_size) {
         let chunk_end = (chunk_start + opts.batch_size).min(opts.events);
         let events = (chunk_start..chunk_end)
-            .map(|i| Event::VoteRecorded {
-                ts: i as i64,
-                a: format!("item-{i}"),
-                b: format!("item-{}", i + 1),
-                ratio_left: 2,
-                ratio_right: 1,
-                scope: String::new(),
-            })
+            .map(|i| Event::vote_recorded(i as i64, format!("item-{i}"), format!("item-{}", i + 1), 2, 1, ""))
             .collect();
         journal.append_many(events).await?;
     }
diff --git a/server/src/event_log.rs b/server/src/event_log.rs
index 2c9290b6fdbf2c2ad1c0f1ffd7374b2d9cc97f36..8e5684cb1378e80c45bcf743d2fdc48402dd4dc8 100644
--- a/server/src/event_log.rs
+++ b/server/src/event_log.rs
@@ -206,14 +206,7 @@ mod tests {
         .unwrap();
         log.append(&sample_record(
             2,
-            Event::VoteRecorded {
-                ts: 1,
-                a: "a".into(),
-                b: "b".into(),
-                ratio_left: 2,
-                ratio_right: 1,
-                scope: String::new(),
-            },
+            Event::vote_recorded(1, "a", "b", 2, 1, ""),
         ))
         .await
         .unwrap();
diff --git a/server/src/events.rs b/server/src/events.rs
index d76c3bb4277216b0d39c9422ba7a50db10a95e05..8a166d49b4f26835fbc2b58cb1f4bdbf002763b8 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -1,7 +1,7 @@
 use serde::{Deserialize, Serialize};
 
 /// Schema version for JSONL log records. Bump when event semantics change.
-pub const CURRENT_LOG_SCHEMA: u32 = 1;
+pub const CURRENT_LOG_SCHEMA: u32 = 2;
 
 /// One JSONL line: schema envelope around a payload event.
 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -51,9 +51,33 @@ pub enum Event {
         b: String,
         ratio_left: i32,
         ratio_right: i32,
-        #[serde(default)]
         scope: String,
+        pseudonym: String,
+        trust_weight: f64,
     },
     /// Register a node path in the fractal tree (no external fetch).
     NodeEnsured { id: String },
 }
+
+impl Event {
+    /// Construct a vote event with the default dev pseudonym (tests and benches).
+    pub fn vote_recorded(
+        ts: i64,
+        a: impl Into<String>,
+        b: impl Into<String>,
+        ratio_left: i32,
+        ratio_right: i32,
+        scope: impl Into<String>,
+    ) -> Self {
+        Self::VoteRecorded {
+            ts,
+            a: a.into(),
+            b: b.into(),
+            ratio_left,
+            ratio_right,
+            scope: scope.into(),
+            pseudonym: crate::identity::DEFAULT_PSEUDONYM.to_string(),
+            trust_weight: 1.0,
+        }
+    }
+}
diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs
index 8cf2b0d4d8bd58cc51cc27c9046fa7f7728a7017..bf82aef3ad9c5f4e9e877c47dab27beb29a80b8f 100644
--- a/server/src/html/vote.rs
+++ b/server/src/html/vote.rs
@@ -336,6 +336,7 @@ pub async fn vote_page(
 #[cfg(test)]
 mod polarity_tests {
     use super::*;
+    use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID};
     use crate::ranking::ranked_items;
     use crate::reducer::GlobalTree;
 
@@ -351,11 +352,11 @@ mod polarity_tests {
         let right = id("right_item");
 
         // Stored a == page left: keep order.
-        let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();
+        let v1 = VoteData::from_event(1, left.as_str(), right.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();
         assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1));
 
         // Stored a == page right: swap so left stays left.
-        let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap();
+        let v2 = VoteData::from_event(2, right.as_str(), left.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();
         assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9));
     }
 
@@ -383,9 +384,9 @@ mod polarity_tests {
         let right = id("right_item");
 
         // Slider dragged left yields e.g. 9:1 with a = left item.
-        let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();
+        let vote = VoteData::from_event(1, left.as_str(), right.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();
         let mut tree = GlobalTree::new();
-        tree.apply_vote(&parent, vote);
+        tree.apply_vote(&parent, vote, TEST_ACTOR_UUID);
 
         let group = &tree.get(&parent).unwrap().local_ranking;
         let ranked = ranked_items(group);
diff --git a/server/src/identity.rs b/server/src/identity.rs
new file mode 100644
index 0000000000000000000000000000000000000000..621d505436912246ec0830ff20c3473f0c4665ce
--- /dev/null
+++ b/server/src/identity.rs
@@ -0,0 +1,35 @@
+//! Actor identity: pseudonym display names mapped to stable UUIDs for vote dedup.
+
+use durable::Db;
+
+use crate::storage_schema::{Store, StoreFields};
+
+/// Default pseudonym until session/auth UI exists.
+pub const DEFAULT_PSEUDONYM: &str = "anon";
+
+/// UUID for the default single-user dev principal.
+pub const DEFAULT_ACTOR_UUID: &str = "00000000-0000-0000-0000-000000000001";
+
+/// UUID for in-memory unit tests.
+pub const TEST_ACTOR_UUID: &str = "00000000-0000-0000-0000-000000000099";
+
+/// Resolve the trust anchor for a pseudonym (must exist in the pseudonyms map).
+pub fn resolve_actor_uuid(db: &Db, pseudonym: &str) -> Result<String, String> {
+    Store::root()
+        .pseudonyms()
+        .key(&pseudonym.to_string())
+        .get(db)
+        .map_err(|e| e.to_string())?
+        .ok_or_else(|| format!("unknown pseudonym: {pseudonym}"))
+}
+
+/// Ensure the default pseudonym → UUID mapping exists (operational seed, not event-logged).
+pub fn seed_default_pseudonym(db: &Db) -> Result<(), durable::Error> {
+    let path = Store::root()
+        .pseudonyms()
+        .key(&DEFAULT_PSEUDONYM.to_string());
+    if path.get(db)?.is_none() {
+        db.run(path.set(&DEFAULT_ACTOR_UUID.to_string()), durable::Durability::SyncWal)?;
+    }
+    Ok(())
+}
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 3dfc7c8acb8ed61bb73ade63e72768e402042cc5..da6e33e3dd6d79967bbee7a2708a7d982c93b88c 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,6 +4,7 @@ pub mod events;
 pub mod fetch;
 pub mod form_template;
 pub mod html;
+pub mod identity;
 pub mod journal;
 pub mod pair;
 pub mod parser;
diff --git a/server/src/pair.rs b/server/src/pair.rs
index 2277e32edf6e0024687d6fe2b9d1a9b84c0b34c8..42a1b1eb2adf16730d34d0fe23c13d5a75d7ba27 100644
--- a/server/src/pair.rs
+++ b/server/src/pair.rs
@@ -360,8 +360,17 @@ impl PairError {
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID};
     use crate::reducer::{GlobalTree, VoteData};
 
+    fn test_vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {
+        VoteData::from_event(ts, a, b, l, r, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap()
+    }
+
+    fn apply(tree: &mut GlobalTree, parent: &ItemId, vote: VoteData) {
+        tree.apply_vote(parent, vote, TEST_ACTOR_UUID);
+    }
+
     fn seed_children(parent: &ItemId, ids: &[&str]) -> GlobalTree {
         let mut tree = GlobalTree::new();
         tree.ensure_path(parent);
@@ -382,22 +391,13 @@ mod tests {
     #[test]
     fn zero_weight_vote_leaves_pair_available_for_suggestion() {
         let parent = ItemId::parse("https://reddit.com/r/rust").unwrap();
-        let mut tree = seed_children(
+        let tree = seed_children(
             &parent,
             &[
                 "https://reddit.com/r/rust/a",
                 "https://reddit.com/r/rust/b",
             ],
         );
-        let noop = VoteData::from_recorded(
-            1,
-            "https://reddit.com/r/rust/a",
-            "https://reddit.com/r/rust/b",
-            0,
-            0,
-        )
-        .unwrap();
-        tree.apply_vote(&parent, noop);
         let group = tree.get(&parent).unwrap().local_ranking.clone();
         let pool = children_of(&tree, &parent);
         assert!(!pair_is_voted(&group, &pool[0], &pool[1]));
@@ -415,9 +415,8 @@ mod tests {
                 "https://reddit.com/r/rust/c",
             ],
         );
-        let vote =
-            VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
-        tree.apply_vote(&parent, vote);
+        let vote = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
+        apply(&mut tree, &parent, vote);
         let group = tree.get(&parent).unwrap().local_ranking.clone();
         let pool = children_of(&tree, &parent);
         let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
@@ -438,12 +437,10 @@ mod tests {
                 "https://reddit.com/r/rust/d",
             ],
         );
-        let ab =
-            VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
-        let cd =
-            VoteData::from_recorded(2, "https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 2, 1).unwrap();
-        tree.apply_vote(&parent, ab);
-        tree.apply_vote(&parent, cd);
+        let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
+        let cd = test_vote(2, "https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 2, 1);
+        apply(&mut tree, &parent, ab);
+        apply(&mut tree, &parent, cd);
         let group = tree.get(&parent).unwrap().local_ranking.clone();
         let pool = children_of(&tree, &parent);
         let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
@@ -468,9 +465,8 @@ mod tests {
                 "https://reddit.com/r/rust/e",
             ],
         );
-        let ab =
-            VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
-        tree.apply_vote(&parent, ab);
+        let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
+        apply(&mut tree, &parent, ab);
         let group = tree.get(&parent).unwrap().local_ranking.clone();
         let pool = children_of(&tree, &parent);
         let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
@@ -498,9 +494,8 @@ mod tests {
                 "https://reddit.com/r/rust/c",
             ],
         );
-        let ab =
-            VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
-        tree.apply_vote(&parent, ab);
+        let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
+        apply(&mut tree, &parent, ab);
         let group = tree.get(&parent).unwrap().local_ranking.clone();
         let pool = children_of(&tree, &parent);
         let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
@@ -524,8 +519,8 @@ mod tests {
             ("https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 3, 1),
             ("https://reddit.com/r/rust/a", "https://reddit.com/r/rust/c", 2, 1),
         ] {
-            let v = VoteData::from_recorded(1, a, b, l, r).unwrap();
-            tree.apply_vote(&parent, v);
+            let v = test_vote(1, a, b, l, r);
+            apply(&mut tree, &parent, v);
         }
         let group = tree.get(&parent).unwrap().local_ranking.clone();
         let pool = children_of(&tree, &parent);
@@ -552,8 +547,8 @@ mod tests {
             ("https://reddi

… preview truncated; 38,467 characters omitted

download full diff A

B — 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 omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.