constitution · epochs · watch · epoch 3

comparison

c_e2ee16c7ada5 (tommy-mor) vs c_94135a1c4c58 (tommy-mor)

download prompt · raw event · cmp_cb737f7647b476

council reasoning

~anthropic/claude-sonnet-latest · winner B · 45:55 · permalink

Side B fixes concrete security bugs: fail-closed vote resolution (previously any missing/expired session silently fell back to anonymous voting), gating mock OAuth behind an env flag, adding Secure cookie flags over HTTPS, closing an open-redirect vector in return_to sanitization, and pinning the `durable` git dependency to a rev instead of a floating branch. Side A is a large, mostly mechanical reorg (splitting canonical_path/identity modules, dropping `@` from wire form) that improves clarity but is lower-stakes churn compared to B's genuine auth-hardening fixes.

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

B’s fail-closed vote auth (no anon fallback), mock-OAuth env gate, Secure cookies, and tighter return_to sanitization are concrete security/correctness fixes with durable effect; A mostly relocates path/identity helpers and propagates a wire-format/@ convention change across many call sites. A’s module split and optional delegate are useful structure, but B delivers more necessary lasting value per change.

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

Side A introduces a lasting architectural separation by extracting `canonical_path.rs` and `identity.rs`, removing identity rewriting from the reducer, standardizing stored/wire identities without `@`, making delegates optional, and updating APIs and tests to enforce validated stored-form identities. Side B contains worthwhile security hardening—failing closed on unauthenticated voting, gating mock OAuth, adding Secure cookie handling, and tightening redirect validation—but its scope is narrower than A's broad cleanup of identity handling and normalization invariants across the project.

sides

A — c_e2ee16c7ada5 (tommy-mor)

message

[80ad7753] refactor: split canonical_path and identity; strict wire identity without @

- Add canonical_path.rs (tag + item URL normalization) and identity.rs
  (parse_username/parse_agent; reject @ in API input).
- Slim events.rs to event types only; reducer applies no identity rewriting.
- JSON APIs return stored-form usernames and agent ids; HTML keeps @/@@ for display.
- Optional delegate on ingest; CLI and tests use naked uuid:rig:model.

Made-with: Cursor

diff preview

diff --git a/cli/src/main.rs b/cli/src/main.rs
index 5ac8e289f2b7a366b5959d9338d02f9b546f408a..630c5dea1f78c0ec9bc53e6b96234a0dc75bb705 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -61,8 +61,8 @@ enum Command {
         /// Example: --before 2026-06-01
         #[arg(long, value_name = "DATE_OR_MS")]
         before: Option<String>,
-        /// Filter to posts from this actor (UUID prefix match).
-        /// Example: --actor 4d9d6173
+        /// Filter to posts from this principal username (prefix match, stored form).
+        /// Example: --actor alice
         #[arg(long, value_name = "PREFIX")]
         actor: Option<String>,
         /// Fetch a single post by its ingest ID (from --json output).
@@ -75,9 +75,8 @@ enum Command {
     ///
     /// SYNTAX:
     ///
-    /// Actor (required, once per document):
-    ///   @<uuid>:<rig>:<model>
-    ///   Example: @7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet
+    /// Identity: human comes from the bearer token; optional AI delegate from `--delegate`
+    /// (`uuid:rig:provider/model`). The document body is DSL only (items, votes, prose) — no `@` lines.
     ///
     /// Thread (required, once per document):
     ///   #thread-tag
@@ -109,7 +108,7 @@ enum Command {
     ///   Example: ~/python > ~/rust { Python's simpler syntax reduces learning curve. }
     ///
     /// Prose (optional, anywhere):
-    ///   Any line that doesn't start with @, #, or ~ is prose.
+    ///   Any line that doesn't start with # or ~ (or `http`) is prose.
     ///   Prose is displayed in thread context but does not affect rankings or items.
     ///   Use prose to write blog posts, reasoning, or notes within your ingest.
     ///
@@ -125,8 +124,7 @@ enum Command {
     /// EXAMPLES:
     ///
     ///   # From heredoc (recommended for agents)
-    ///   npx slugsocial ingest << 'EOF'
-    ///   @7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet
+    ///   npx slugsocial ingest --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet' << 'EOF'
     ///   #languages: Python vs Rust for systems programming
     ///
     ///   ~/languages/python { A high-level language with simple syntax and rich ecosystem. }
@@ -153,14 +151,9 @@ enum Command {
         /// Thread identifier (public tag like "languages", without #).
         #[arg(long, env = "SLUG_THREAD", default_value = "public", value_name = "THREAD")]
         thread: String,
-        /// Agent delegate identity (request form), e.g. @@uuid:rig:provider/model
-        #[arg(
-            long,
-            env = "SLUG_DELEGATE",
-            default_value = "@@00000000-0000-0000-0000-000000000000:cli:local/dev",
-            value_name = "DELEGATE"
-        )]
-        delegate: String,
+        /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests.
+        #[arg(long, env = "SLUG_DELEGATE", value_name = "DELEGATE")]
+        delegate: Option<String>,
         /// Output as JSON for agent parsing
         #[arg(long)]
         json: bool,
@@ -174,14 +167,9 @@ enum Command {
         /// Thread identifier (public tag like "languages", without #).
         #[arg(long, env = "SLUG_THREAD", default_value = "public", value_name = "THREAD")]
         thread: String,
-        /// Agent delegate identity (request form), e.g. @@uuid:rig:provider/model
-        #[arg(
-            long,
-            env = "SLUG_DELEGATE",
-            default_value = "@@00000000-0000-0000-0000-000000000000:cli:local/dev",
-            value_name = "DELEGATE"
-        )]
-        delegate: String,
+        /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests.
+        #[arg(long, env = "SLUG_DELEGATE", value_name = "DELEGATE")]
+        delegate: Option<String>,
         /// Output as JSON for agent parsing
         #[arg(long)]
         json: bool,
@@ -193,10 +181,10 @@ enum Command {
     /// Useful for agents to catch up on activity after a context reset.
     ///
     /// Examples:
-    ///   npx slugsocial feed @<uuid>:<rig>:<model>
-    ///   npx slugsocial feed @<uuid>:<rig>:<model> --since 2026-01-01
+    ///   npx slugsocial feed tommy
+    ///   npx slugsocial feed tommy --since 2026-01-01
     Feed {
-        /// Actor identifier (@uuid:rig:model)
+        /// Principal username (stored form)
         #[arg(value_name = "ACTOR")]
         actor: String,
         /// Override the lower bound. Accepts Unix ms or YYYY-MM-DD.
@@ -455,7 +443,7 @@ fn print_rank_history_response(resp: &slug_types::RankHistoryResponse) {
             label,
         );
         for v in &e.caused_by {
-            println!("    {} {} {} {}", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!("  (@{})", a)).unwrap_or_default());
+            println!("    {} {} {} {}", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!("  ({})", a)).unwrap_or_default());
             if !v.body.is_empty() {
                 println!("      {}", v.body.lines().next().unwrap_or(&v.body).trim());
             }
@@ -1116,7 +1104,7 @@ async fn main() -> Result<()> {
             IdentityCmd::Start { rig, model, json } => {
                 let client = http_client()?;
                 let uuid = uuid::Uuid::new_v4().to_string();
-                let delegate = format!("@@{}:{}:{}", uuid, rig, model);
+                let delegate = format!("{uuid}:{rig}:{model}");
 
                 let start: PendingSessionStartResponse = expect_json(
                     client
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index cb0faa29b834931e2c2b2f5c174c875e2e2e9346..995ce4a61d29b024c399c656134f541ecfd880cf 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -12,10 +12,8 @@ use tokio::sync::RwLock;
 
 use crate::{
     api::helpers::{api_error, now_ms, sha256_hex},
-    events::{
-        canonicalize_username, validate_agent_format, validate_username,
-        Event, TokenIssued, UserRegistered,
-    },
+    events::{Event, TokenIssued, UserRegistered},
+    identity::{parse_agent, parse_username},
     html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},
     state::{AppState, PendingSession},
 };
@@ -77,9 +75,9 @@ fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result<
     Ok(username)
 }
 
-fn issue_token_for_user(username: &str) -> (String, TokenIssued, String) {
-    // Returns: (bearer, event, canonical_username)
-    let canonical_user = canonicalize_username(username);
+/// `stored_username` must already be in persisted shape (lowercase slug, no `@`).
+fn issue_token_for_user(stored_username: &str) -> (String, TokenIssued) {
+    let username = stored_username.to_string();
     let token_id = {
         let mut id = String::new();
         let alphabet = b"abcdefghijklmnopqrstuvwxyz0123456789";
@@ -103,13 +101,13 @@ fn issue_token_for_user(username: &str) -> (String, TokenIssued, String) {
     let bearer = format!("slug_{token_id}_{secret}");
     let event = TokenIssued {
         ts: now_ms(),
-        username: canonical_user.clone(),
+        username: username.clone(),
         token_id,
         token_hash,
         salt,
         issued_via: "oauth".to_string(),
     };
-    (bearer, event, canonical_user)
+    (bearer, event)
 }
 
 #[derive(Debug, Deserialize)]
@@ -207,7 +205,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
         s.provider = Some("google".to_string());
         s.provider_id = Some(sub.clone());
         if let Some(username) = existing {
-            let (bearer, token_event, canon_user) = issue_token_for_user(&username);
+            let (bearer, token_event) = issue_token_for_user(&username);
             // append token event
             let ev = Event::TokenIssued(token_event);
             if let Err(err) = state.event_log.append(&ev).await {
@@ -217,7 +215,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
                 let mut reduced = reduced_arc.write().await;
                 reduced.apply_event(ev);
             }
-            s.complete = Some((canon_user, bearer));
+            s.complete = Some((username, bearer));
             return Redirect::temporary(&format!("{public_url}/auth/complete")).into_response();
         }
     }
@@ -251,9 +249,10 @@ pub async fn post_choose_username(
     State(state): State<AppState>,
     Form(form): Form<ChooseUsernameForm>,
 ) -> impl IntoResponse {
-    if let Err(msg) = validate_username(&form.username) {
-        return api_error(StatusCode::BAD_REQUEST, "invalid username", Some(msg)).into_response();
-    }
+    let canon_user = match parse_username(&form.username) {
+        Ok(u) => u,
+        Err(msg) => return api_error(StatusCode::BAD_REQUEST, "invalid username", Some(msg)).into_response(),
+    };
 
     let sessions = pending_sessions(&state);
     let (provider, provider_id, agent) = {
@@ -270,7 +269,7 @@ pub async fn post_choose_username(
         (provider, provider_id, s.agent.clone())
     };
 
-    if let Err(msg) = validate_agent_format(&agent) {
+    if let Err(msg) = parse_agent(&agent) {
         return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
     }
 
@@ -280,7 +279,7 @@ pub async fn post_choose_username(
     if reduced.users_by_provider.contains_key(&provider_key) {
         return api_error(StatusCode::CONFLICT, "provider already registered", None).into_response();
     }
-    if reduced.users_by_provider.values().any(|u| u == &canonicalize_username(&form.username)) {
+    if reduced.users_by_provider.values().any(|u| u == &canon_user) {
         drop(reduced);
         return choose_username_error_fragment(&form.session, "that username is taken — try another").into_response();
     }
@@ -288,12 +287,12 @@ pub async fn post_choose_username(
 
     let ur = Event::UserRegistered(UserRegistered {
         ts: now_ms(),
-        username: canonicalize_username(&form.username),
+        username: canon_user.clone(),
         provider: provider.to_lowercase(),
         provider_id: provider_id.clone(),
     });
 
-    let (bearer, ti, canon_user) = issue_token_for_user(&form.username);
+    let (bearer, ti) = issue_token_for_user(&canon_user);
     let ti_ev = Event::TokenIssued(ti);
 
     // Persist events.
@@ -325,15 +324,18 @@ pub async fn post_pending_session(
     State(state): State<AppState>,
     Json(req): Json<PendingSessionStartRequest>,
 ) -> impl IntoResponse {
-    if let Err(msg) = validate_agent_format(&req.agent) {
-        return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
-    }
+    let agent_naked = match parse_agent(&req.agent) {
+        Ok(a) => a,
+        Err(msg) => {
+            return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
+        }
+    };
     let session = format!("p_{}", uuid::Uuid::new_v4().simple());
     let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
     let login_url = format!("{public_url}/auth/login?session={}", urlencoding::encode(&session));
     let poll_url = format!("/api/v0/pending-session/{}", session);
     let s = PendingSession {
-        agent: req.agent.clone(),
+        agent: agent_naked,
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
@@ -359,7 +361,7 @@ pub async fn get_pending_session(
         return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
     };
     let (complete, user, token) = match &s.complete {
-        Some((u, t)) => (true, Some(format!("@{}", u)), Some(t.clone())),
+        Some((u, t)) => (true, Some(u.clone()), Some(t.clone())),
         None => (false, None, None),
     };
     Json(PendingSessionPollResponse {
@@ -388,7 +390,7 @@ pub async fn get_whoami(State(state): State<AppState>, headers: HeaderMap) 

… preview truncated; 62,772 characters omitted

download full diff A

B — c_94135a1c4c58 (tommy-mor)

message

[880eb778] Harden auth: fail-closed votes, mock OAuth gate, Secure cookies.

Also show the current alias in the top nav and pin durable by rev.

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

diff preview

diff --git a/AGENTS.md b/AGENTS.md
index babb889d6fbfb1fa7176c9e6b7544ae17b61dd2e..6e0fd8ebb65d665c9c1438e3275971d62b98fd95 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,11 +10,11 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki
 
 - **Bootstrap script**: `./scripts/cursor-env-install.sh` (also run via `.cursor/environment.json` on Cloud Agent boot) installs Playwright Chromium, Babashka, bbin, `clj-paren-repair`, and warms the RocksDB build.
 - **Rust 1.88+** is required (`rust-toolchain.toml`). The Cloud Dockerfile and `cursor-env-install.sh` install **rustup** 1.88.0 first so `cargo` works while Playwright/Clojure bootstrap continues. Do not rely on `/usr/local/cargo` (often missing or stale).
-- **RocksDB / `durable`**: Ubuntu’s default `c++` is often **clang** without libc++ headers. Set **`CXX=g++`** and **`RUSTFLAGS="-C linker=g++"`** (or `CC=gcc`) before `cargo build` / `cargo test` — both are set in the bootstrap script and `.cursor/environment.json`.
+- **RocksDB / `durable`**: `durable` is an external git dependency (`tommy-mor/durable`, pinned by rev in `server/Cargo.toml`). Ubuntu’s default `c++` is often **clang** without libc++ headers. Set **`CXX=g++`** and **`RUSTFLAGS="-C linker=g++"`** (or `CC=gcc`) before `cargo build` / `cargo test` — both are set in the bootstrap script and `.cursor/environment.json`.
 - **System packages** for builds: `build-essential`, `g++`, `clang`, `libclang-dev`, `pkg-config`, `libssl-dev`, `openjdk-21-jre-headless` (for `reqwest` / OpenSSL, `librocksdb-sys`, `zstd-sys` / bindgen, and **bbin** / Clojure JVM). The bootstrap sets **`JAVA_HOME`** when Java is present.
 - **Clojure CLI 1.12.0.1530** (used in CI): install from https://clojure.org/guides/install_clojure — needed for `./scripts/clj-test.sh` / Kaocha tests.
 - **Babashka / bbin / clj-paren-repair**: installed by `cursor-env-install.sh` into `~/.local/bin` (bb tasks in `bb.edn`, delimiter repair for Clojure edits).
-- **Playwright** (Spel browser tests in `test/vote_compare.clj`): Chromium via `clojure -M -e "(com.microsoft.playwright.CLI/main ...)"` — run once after clone or use the bootstrap script.
+- **Playwright** (Spel browser tests in `test/vote_compare.clj` / `test/auth_login.clj`): Chromium via `clojure -M -e "(com.microsoft.playwright.CLI/main ...)"` — run once after clone or use the bootstrap script.
 
 ### Commands (see also `TEST.sh`)
 
@@ -34,13 +34,17 @@ Environment variables (defaults in `server/src/state.rs`):
 - `PORT` — default `8080`
 - `SORTER2_DATA_DIR` — default `./data` (created on startup)
 - `SORTER2_EVENT_LOG` — default `{data_dir}/events.jsonl`
+- `SORTER2_BASE_URL` — public origin (also drives Secure cookies when `https://`)
+- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth (optional; login disabled if unset)
+- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` (tests only)
 
 Health check: `GET /healthz` → `ok`.
 
-Core UI flow: `POST /ui` with form field `__rpc__` (JSON). Example vote:
+Core UI flow: `POST /ui` with form field `__rpc__` (JSON). Votes require a session cookie (sign in via `/login`). Example vote:
 
 ```bash
 curl -sf -X POST http://127.0.0.1:8080/ui \
+  --cookie "sorter2_session=..." \
   --data-urlencode '__rpc__={"action":"record_vote","a":"alpha","b":"beta","ratio_left":2,"ratio_right":1}'
 ```
 
diff --git a/Cargo.lock b/Cargo.lock
index aa02997ad85777195f135bfd9456bcee0fc9a590..1f8690f3e486d099577a32c2ece48caf57ea7160 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -414,7 +414,7 @@ dependencies = [
 [[package]]
 name = "durable"
 version = "0.2.0"
-source = "git+https://github.com/tommy-mor/durable.git?branch=main#a6c14eaa809693140eea0c22b07ef24d8e74adaf"
+source = "git+https://github.com/tommy-mor/durable.git?rev=a6c14eaa809693140eea0c22b07ef24d8e74adaf#a6c14eaa809693140eea0c22b07ef24d8e74adaf"
 dependencies = [
  "ciborium",
  "durable-derive",
@@ -426,7 +426,7 @@ dependencies = [
 [[package]]
 name = "durable-derive"
 version = "0.2.0"
-source = "git+https://github.com/tommy-mor/durable.git?branch=main#a6c14eaa809693140eea0c22b07ef24d8e74adaf"
+source = "git+https://github.com/tommy-mor/durable.git?rev=a6c14eaa809693140eea0c22b07ef24d8e74adaf#a6c14eaa809693140eea0c22b07ef24d8e74adaf"
 dependencies = [
  "proc-macro2",
  "quote",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index dfa39beddecfa37dcdeaa602cb30f4b547528fbb..bd88687fb0ba47d68f2c08eb5e11d0e08b7c4398 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -25,7 +25,7 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] }
 rand = "0.8"
 urlencoding = "2"
 url = "2"
-durable = { git = "https://github.com/tommy-mor/durable.git", branch = "main" }
+durable = { git = "https://github.com/tommy-mor/durable.git", rev = "a6c14eaa809693140eea0c22b07ef24d8e74adaf" }
 
 [dev-dependencies]
 reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index b86581b1f337650564274254d840e8a75b49524d..9da62ffbed07eb28729aa3160bf33b07ce0d7945 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -71,10 +71,16 @@ pub async fn post_ui_html(
                 return resp;
             }
             let parent = parent_from_scope(&scope);
-            let actor = resolve_vote_actor(
+            let actor = match resolve_vote_actor(
                 state.projection_store.db(),
                 session_id_from_jar(&jar).as_deref(),
-            );
+            ) {
+                Ok(actor) => actor,
+                Err(_) => {
+                    return vote_auth_redirect(&state, &jar)
+                        .unwrap_or_else(|| login_redirect_js().into_response());
+                }
+            };
             if let Err(e) = state
                 .record_vote(&parent, &a, &b, ratio_left, ratio_right, &actor)
                 .await
diff --git a/server/src/auth/config.rs b/server/src/auth/config.rs
index a1f042c655bf3e5234eeb87a7d889f64592807fb..a5976af9a52ea207b35ae87bd1fe927c47a477ca 100644
--- a/server/src/auth/config.rs
+++ b/server/src/auth/config.rs
@@ -1,9 +1,42 @@
 pub const AUTH_RETURN_COOKIE: &str = "sorter2_auth_return";
 
+/// Allow `mock_user` on `/auth/github` (test harness only).
+pub fn mock_oauth_allowed() -> bool {
+    matches!(
+        std::env::var("SORTER2_ALLOW_MOCK_OAUTH").as_deref(),
+        Ok("1") | Ok("true") | Ok("TRUE")
+    )
+}
+
+/// Set the Secure flag on auth cookies when serving over HTTPS.
+pub fn cookies_secure() -> bool {
+    std::env::var("SORTER2_BASE_URL")
+        .map(|u| u.starts_with("https://"))
+        .unwrap_or(false)
+}
+
 pub fn sanitize_return_to(raw: &str) -> String {
     let s = raw.trim();
-    if s.is_empty() || !s.starts_with('/') || s.starts_with("//") {
+    if s.is_empty() || !s.starts_with('/') || s.starts_with("//") || s.starts_with("/\\") {
+        return "/".to_string();
+    }
+    // Reject scheme-relative and protocol-smuggling forms.
+    if s.contains("://") || s.contains('\\') {
         return "/".to_string();
     }
     s.to_string()
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn sanitize_return_to_blocks_open_redirects() {
+        assert_eq!(sanitize_return_to(""), "/");
+        assert_eq!(sanitize_return_to("//evil.com"), "/");
+        assert_eq!(sanitize_return_to("/\\evil.com"), "/");
+        assert_eq!(sanitize_return_to("https://evil.com"), "/");
+        assert_eq!(sanitize_return_to("/vote?parent=x"), "/vote?parent=x");
+    }
+}
diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs
index 5ed535ba199fa736f0048c32623b14c3b1e5de2d..d4a85ef52c15dc35148e4c743f0d646cbbdb056d 100644
--- a/server/src/auth/mod.rs
+++ b/server/src/auth/mod.rs
@@ -26,7 +26,7 @@ use crate::{
     ui_action::UI_RPC_FIELD,
 };
 
-pub use session::{resolve_vote_actor, session_id_from_jar, VoteActor};
+pub use session::{nav_pseudonym, resolve_vote_actor, session_id_from_jar, VoteActor};
 
 pub fn base_url_from_env(port: u16) -> String {
     std::env::var("SORTER2_BASE_URL")
@@ -168,6 +168,10 @@ pub async fn login_page(
         "login · sorter2",
         login_body(session.as_ref(), &aliases, &providers),
         state.views.get_views("/login"),
+        session
+            .as_ref()
+            .filter(|s| !s.pseudonym.trim().is_empty())
+            .map(|s| s.pseudonym.as_str()),
     );
     (jar, Html(markup.into_string())).into_response()
 }
@@ -222,6 +226,7 @@ pub async fn alias_page(
             "choose alias · sorter2",
             body,
             state.views.get_views("/login/alias"),
+            None,
         )
         .into_string(),
     )
@@ -237,7 +242,12 @@ pub async fn github_start(
         .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
     let return_to = return_from_query_or_jar(&jar, query.return_to.as_deref());
     let state_token = session::new_oauth_state();
-    let url = oauth::authorize_url(&cfg, &state_token, query.mock_user.as_deref());
+    let mock_user = if config::mock_oauth_allowed() {
+        query.mock_user.as_deref()
+    } else {
+        None
+    };
+    let url = oauth::authorize_url(&cfg, &state_token, mock_user);
     let jar = jar
         .add(session::oauth_state_cookie_value(&state_token))
         .add(session::auth_return_cookie_value(&return_to));
diff --git a/server/src/auth/session.rs b/server/src/auth/session.rs
index 09659240b9455c6fca12db5652e1d31cf8c2acfc..41df030ded3abcafc0ab3887ab769adf103f9aa0 100644
--- a/server/src/auth/session.rs
+++ b/server/src/auth/session.rs
@@ -5,7 +5,7 @@ use durable::{Db, Durability};
 use rand::Rng;
 
 use crate::{
-    auth::config::AUTH_RETURN_COOKIE,
+    auth::config::{self, AUTH_RETURN_COOKIE},
     fetch::now_ms,
     identity::{DEFAULT_ACTOR_UUID, DEFAULT_PSEUDONYM},
     storage_dto::{SessionDataV1, SESSION_DATA_VERSION},
@@ -37,6 +37,7 @@ pub struct VoteActor {
 }
 
 impl VoteActor {
+    /// Test / bench helper: seed votes as the default pseudonym without a session.
     pub fn anon() -> Self {
         Self {
             pseudonym: DEFAULT_PSEUDONYM.to_string(),
@@ -70,20 +71,51 @@ fn hex_encode(bytes: &[u8]) -> String {
     bytes.iter().map(|b| format!("{b:02x}")).collect()
 }
 
-pub fn resolve_vote_actor(db: &Db, session_id: Option<&str>) -> VoteActor {
-    let Some(session_id) = session_id else {
-        return VoteActor::anon();
-    };
-    let Ok(Some(session)) = load_session(db, session_id) else {
-        return VoteActor::anon();
-    };
-    if session.expires_at <= now_ms() {
-        return VoteActor::anon();
+fn build_cookie(name: &'static str, value: String) -> Cookie<'static> {
+    let mut builder = Cookie::build((name, value))
+        .http_only(true)
+        .same_site(SameSite::Lax)
+        .path("/");
+    if config::cookies_secure() {
+        builder = builder.secure(true);
+    }
+    builder.build()
+}
+
+fn clear_cookie(name: &'static str) -> Cookie<'static> {
+    let mut builder = Cookie::build((name, ""))
+        .http_only(true)
+        .same_site(SameSite::Lax)
+        .path("/")
+        .removal();
+    if config::cookies_secure() {
+        builder = builder.secure(true);
+    }
+    builder.build()
+}
+
+/// Resolve the vote actor from a live session. Fail-closed: never falls back to anon.
+pub fn resolve_vote_actor(db: &Db, session_id: Option<&str>) -> Result<VoteActor, &'static str> {
+    let session_id = session_id.ok_or("sign in to vote")?;
+    let session = load_valid_session(db, session_id).ok_or("session expired")?;
+    if !session_has_pseudonym(&session) {
+        return Err("choose an alias first");
     }
     let trust_weight = user_trust_weight(db, &session.uuid).unwrap_or(1.0);
-    VoteActor {
+    Ok(VoteActor {
         pseudonym: session.current_pseudonym,
         trust_weight,
+    })
+}
+
+/// Display name for the top nav, if any session is active.
+pub fn nav_pseudonym(db: &Db, jar: &CookieJar) -> Option<String> {
+    let sessio

… preview truncated; 10,040 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.