constitution · epochs · watch · epoch 3

comparison

c_8dc1a8119370 (tommy-mor) vs c_afa638171cf7 (tommy-mor)

download prompt · raw event · cmp_c6db5663d43f72

council reasoning

~anthropic/claude-sonnet-latest · winner B · 70:30 · permalink

Side B adds a real, coherent feature (Reddit OAuth linking with UUID-as-canonical-identity), including conflict handling, trust-weight batching fixes, private linked-provider listing, and updated test harness/mocks—net positive functional and architectural value. Side A mostly deletes a large but working (if arguably over-engineered) parser graph and its tests, replacing it with a much simpler regex-like parser and a paste-and-go UI, which is a reasonable simplification but is largely destructive/refactor-y rather than adding new capability, and it removes test coverage (parser_race.clj) without clear compensating value.

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

B establishes lasting identity architecture: UUID as the sole principal, multi-provider OAuth linking (GitHub + Reddit) with conflict handling, private linked-provider UI, and correct in-batch trust-weight updates—foundational product infrastructure with tests/mocks. A is a strong simplification (replacing an unreliable ~1.8k-line keystroke graph and race plumbing with paste-and-go URL parse + redirect), but it mainly removes a failed navigation experiment rather than adding durable core capability.

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

Side B adds a substantial, durable identity architecture: it introduces Reddit OAuth alongside GitHub, changes authentication so OAuth providers link to a canonical UUID, adds conflict handling, provider-link persistence, private linked-provider views, new routes, projection/storage updates, and trust-weight fixes. Side A simplifies the UI by replacing a fragile autocomplete parser with a paste-and-go flow and removes a large amount of parser complexity, but it also drops interactive parsing functionality in favor of a narrower URL extractor, making it a more localized UX simplification than the foundational authentication and identity work in Side B.

sides

A — c_8dc1a8119370 (tommy-mor)

message

[529cc941] Replace autocomplete parser with paste-and-go navigate.

The keystroke transition graph was unreliable; a textarea plus Go button now parses pasted Reddit URLs and redirects to the subreddit ranking scope.

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

diff preview

diff --git a/AGENTS.md b/AGENTS.md
index 77f2e31d4e860a92a77255ca5106c8b6c4510ee7..36ee4c0ec700bffbe226ee775ba9cf59cf35c770 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,6 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki
 - **Rust 1.88+** is required (some transitive crates need a recent Cargo). The image may ship older `/usr/local/cargo` (1.83); use **rustup** and `rustup default 1.88.0` before building.
 - **System packages** for builds: `pkg-config`, `libssl-dev` (for `reqwest` / OpenSSL in integration tests and release builds).
 - **Clojure CLI 1.12.0.1530** (optional but used in CI): install from https://clojure.org/guides/install_clojure — needed for `./scripts/clj-test.sh` / Kaocha tests.
-- **Playwright browser** for the spel browser test (`test/parser_race.clj`): install once with `clojure -M -e "(com.microsoft.playwright.CLI/main (into-array String [\"install\" \"chromium\" \"--with-deps\"]))"`. The browser binary is cached under `~/.cache/ms-playwright`.
 
 ### Commands (see also `TEST.sh`)
 
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 1339048c955c0a3eb5120381aaf031424cbed540..c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -8,7 +8,7 @@ use std::collections::HashMap;
 use crate::{
     html::{js_string_literal, ranking_panel, JsBuilder},
     parser::parse_reddit_url,
-    parser_render::parser_panel_morph,
+    parser_render::navigate_panel,
     state::AppState,
     ui_action::{parse_html_ui_from_form, HtmlUiAction},
 };
@@ -57,18 +57,23 @@ pub async fn post_ui_html(
                 .morph_selector("#ranking-panel", panel)
                 .into_response()
         }
-        HtmlUiAction::ParseQuery { query } => {
-            let action = parse_reddit_url(&query);
-            let panel = parser_panel_morph(&query, &action);
-            let mut js = JsBuilder::new().morph_selector("#parser-panel", panel);
-            if let Some(comp) = action.primary_completion() {
-                js = js.raw(&format!(
-                    "var __pi=document.getElementById('parser-input'); if(__pi){{__pi.dataset.completion={};}}",
-                    js_string_literal(comp)
-                ));
+        HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {
+            Ok(subreddit) => {
+                let dest = format!("/?sub={subreddit}");
+                JsBuilder::new()
+                    .raw(&format!(
+                        "window.location.href={};",
+                        js_string_literal(&dest)
+                    ))
+                    .into_response()
             }
-            js.into_response()
-        }
+            Err(message) => {
+                let panel = navigate_panel(&query, Some(&message));
+                JsBuilder::new()
+                    .morph_selector("#parser-panel", panel)
+                    .into_response()
+            }
+        },
     }
 }
 
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 94a5cfb561d1c8464bd2782f7ffd4e0965ccf95d..9650d333d29c4ac94ceb407aee3ee00399c7f40b 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -11,8 +11,7 @@ use serde::Deserialize;
 
 use crate::{
     form_template::template_json_compact,
-    parser_action::ParserAction,
-    parser_render::parser_panel,
+    parser_render::navigate_panel,
     ranking::{top_bottom, RankedItem},
     reducer::GroupState,
     state::{normalize_scope, AppState},
@@ -336,10 +335,9 @@ pub async fn home(
     let empty = GroupState::new();
     let group = groups.get(&scope).unwrap_or(&empty);
 
-    let empty_action = ParserAction::suggest(String::new(), None);
     let body = html! {
         h1 { "sorter2" }
-        (parser_panel("", &empty_action))
+        (navigate_panel("", None))
         (vote_panel(&scope))
         (ranking_panel(&scope, group))
     };
diff --git a/server/src/lib.rs b/server/src/lib.rs
index fa423640d598f4ba97a5885d228e78d7b97f7a22..de8bca48cbf689cad22337883e7791966e7c4919 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,7 +4,6 @@ pub mod events;
 pub mod form_template;
 pub mod html;
 pub mod parser;
-pub mod parser_action;
 pub mod parser_render;
 pub mod path_types;
 pub mod ranking;
diff --git a/server/src/parser.rs b/server/src/parser.rs
index ea437c4434045b44cba04a2b3416df850db518e7..50571a59f0d3ece1e2538f88e00ef46ec40ea545 100644
--- a/server/src/parser.rs
+++ b/server/src/parser.rs
@@ -1,1811 +1,87 @@
-use std::collections::HashMap;
-use std::sync::OnceLock;
+//! Extract a subreddit name from a pasted Reddit URL or path.
 
-use crate::parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion};
-
-// --- Core Abstractions ---
-
-/// Unique identifier for nodes in the graph
-type NodeId = &'static str;
-
-/// Pattern matching for edges
-#[derive(Debug, Clone)]
-pub enum EdgePattern {
-    /// Matches exact literal string
-    Literal(&'static str),
-    
-    /// Matches any prefix of a string and suggests the full string
-    /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com"
-    PrefixOf(&'static str),
-    
-    /// Captures a variable segment (e.g., subreddit name, username)
-    Variable(&'static str),
-    
-    /// Matches any string (wildcard)
-    Any,
-}
-
-impl EdgePattern {
-    /// Try to match this pattern against input, return (consumed_chars, captured_value)
-    fn matches(&self, input: &str) -> Option<(usize, Option<String>)> {
-        match self {
-            EdgePattern::Literal(lit) => {
-                if input.starts_with(lit) {
-                    Some((lit.len(), None))
-                } else {
-                    None
-                }
-            }
-            EdgePattern::PrefixOf(target) => {
-                // Check if input is a prefix of target
-                if target.starts_with(input) && !input.is_empty() {
-                    // It's a valid prefix
-                    Some((input.len(), None))
-                } else if input.starts_with(target) {
-                    // Full match
-                    Some((target.len(), None))
-                } else {
-                    None
-                }
-            }
-            EdgePattern::Variable(var_name) => {
-                // Consume until next '/' or end of string
-                let end = input.find('/').unwrap_or(input.len());
-                if end > 0 {
-                    let captured = input[..end].to_string();
-                    // Validate based on variable type
-                    if is_valid_variable(var_name, &captured) {
-                        Some((end, Some(captured)))
-                    } else {
-                        None
-                    }
-                } else {
-                    None
-                }
-            }
-            EdgePattern::Any => {
-                // Match everything until next '/' or end
-                let end = input.find('/').unwrap_or(input.len());
-                if end > 0 {
-                    Some((end, Some(input[..end].to_string())))
-                } else {
-                    None
-                }
-            }
-        }
-    }
-    
-    /// Get the completion suggestion for this pattern
-    fn completion(&self, partial: &str) -> Option<String> {
-        match self {
-            EdgePattern::PrefixOf(target) => {
-                if target.starts_with(partial) && partial != *target {
-                    Some(target.to_string())
-                } else {
-                    None
-                }
-            }
-            _ => None,
-        }
+pub fn parse_reddit_url(query: &str) -> Result<String, String> {
+    let q = query.trim();
+    if q.is_empty() {
+        return Err("Paste a Reddit URL or r/subreddit path".into());
     }
-}
 
-/// Edge in the graph
-pub struct Edge {
-    pattern: EdgePattern,
-    target: NodeId,
-    /// Optional description for autocomplete
-    description: Option<&'static str>,
-}
-
-/// Handler function for generating UI actions (Send + Sync so the graph can live in `OnceLock`).
-type Handler = Box<dyn Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync>;
-
-/// Node in the graph
-pub struct Node {
-    #[allow(dead_code)]
-    id: NodeId,
-    edges: Vec<Edge>,
-    handler: Option<Handler>,
-}
-
-/// The composable parser graph (immutable after `build`).
-pub struct Graph {
-    nodes: HashMap<NodeId, Node>,
-    root: NodeId,
-}
-
-// --- Graph Builder (Fluent API) ---
-
-pub struct GraphBuilder {
-    nodes: HashMap<NodeId, Node>,
-    current_node: Option<NodeId>,
-    root: NodeId,
-}
-
-impl GraphBuilder {
-    pub fn new() -> Self {
-        let mut nodes = HashMap::new();
-        nodes.insert(
-            "root",
-            Node {
-                id: "root",
-                edges: Vec::new(),
-                handler: None,
-            },
-        );
-
-        GraphBuilder {
-            nodes,
-            current_node: Some("root"),
-            root: "root",
-        }
+    if let Some(sub) = subreddit_after_prefix(q, "r/") {
+        return Ok(sub);
     }
 
-    /// Select a node to add edges to
-    pub fn at(mut self, node_id: NodeId) -> Self {
-        self.nodes.entry(node_id).or_insert_with(|| Node {
-            id: node_id,
-            edges: Vec::new(),
-            handler: None,
-        });
-        self.current_node = Some(node_id);
-        self
+    if let Some(sub) = subreddit_from_path_segment(q, "/r/") {
+        return Ok(sub);
     }
-    
-    /// Add an edge from the current node
-    pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {
-        self.edge_with_desc(pattern, target, None)
-    }
-    
-    /// Add an edge with description
-    pub fn edge_with_desc(
-        mut self,
-        pattern: EdgePattern,
-        target: NodeId,
-        desc: Option<&'static str>,
-    ) -> Self {
-        let current = self.current_node.expect("No current node selected");
-
-        self.nodes.entry(target).or_insert_with(|| Node {
-            id: target,
-            edges: Vec::new(),
-            handler: None,
-        });
-
-        if let Some(node) = self.nodes.get_mut(current) {
-            node.edges.push(Edge {
-                pattern,
-                target,
-                description: desc,
-            });
-        }
 
-        self
-    }
-
-    /// Set handler for current node
-    pub fn handler<F>(mut self, handler: F) -> Self
-    where
-        F: Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync + 'static,
-    {
-        let current = self.current_node.expect("No current node selected");
-        if let Some(node) = self.nodes.get_mut(current) {
-            node.handler = Some(Box::new(handler));
-        }
-        self
-    }
-    
-    /// Build the final graph
-    pub fn build(self) -> Graph {
-        Graph {
-            nodes: self.nodes,
-            root: self.root,
-        }
-    }
+    Err("Could not find a subreddit in that URL".into())
 }
 
-// --- Parser Implementation ---
-
-impl Graph {
-    pub fn parse(&self, input: &str) -> ParserAction {
-        let normalized = input.trim().to_lowercase();
-        let mut state = ParserState {
-            input: &normalized,
-            cursor: 0,
-            current_node_id: self.root,
-            context: HashMap::new(),
-            original_query: input.to_string(),
-            current_prefix: String::new(),
-        };
-        
-        self.parse_recursive(&mut state)
-    }
-    
-    fn parse_recursive(&self, state: &mut ParserState) -> ParserAction {
-        let node = self
-            .nodes
-            .get(state.current_node_id)
-            .expect("Node not found in graph");
-
-        // If we've consumed all input, check for handler or suggestions
-        if state.cursor >= state.input.len() {
-            if let Some(handler) = &node.han

… preview truncated; 92,533 characters omitted

download full diff A

B — c_afa638171cf7 (tommy-mor)

message

[52f5c51c] Add Reddit OAuth linking and make UUID the only account identity.

OAuth providers only attach to a session UUID (first link creates the
principal); linked providers stay private on the account page.

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

diff preview

diff --git a/AGENTS.md b/AGENTS.md
index 6e0fd8ebb65d665c9c1438e3275971d62b98fd95..e9cc3173dbeb21ad0fc090ca7b407b027c7820a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,8 +35,11 @@ Environment variables (defaults in `server/src/state.rs`):
 - `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)
+- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth linking (optional)
+- `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` (or `REDDIT_APP_*`) — Reddit API import + OAuth linking (optional)
+- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` and `/auth/reddit` (tests only)
+
+Identity: UUID is canonical. OAuth providers only *link* to a UUID (first link creates the principal). Linked providers are private to the account owner.
 
 Health check: `GET /healthz` → `ok`.
 
diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs
index 5906f93b13853421e96a3c37bc9d8202a47842bf..c706ae8045a811e5941f5f6c72da88f42a403a82 100644
--- a/server/src/auth/mod.rs
+++ b/server/src/auth/mod.rs
@@ -1,4 +1,8 @@
-//! GitHub OAuth login, session cookies, and vote actor resolution.
+//! OAuth linking, session cookies, and vote actor resolution.
+//!
+//! Canonical identity is a UUID. OAuth providers only *link* to that UUID
+//! (first link creates the principal; later links attach while logged in).
+//! Which providers are linked is private to the account owner.
 
 pub mod config;
 pub mod identity;
@@ -22,7 +26,9 @@ use crate::{
     form_template::template_json_compact,
     html::layout,
     state::AppState,
-    storage_schema::{oauth_link_owner, pseudonym_owner, Store, StoreFields},
+    storage_schema::{
+        linked_providers_for_uuid, oauth_link_owner, pseudonym_owner, Store, StoreFields,
+    },
     ui_action::UI_RPC_FIELD,
 };
 
@@ -53,10 +59,12 @@ fn new_actor_uuid() -> String {
 pub struct LoginQuery {
     #[serde(default)]
     pub return_to: Option<String>,
+    #[serde(default)]
+    pub error: Option<String>,
 }
 
 #[derive(Debug, Deserialize)]
-pub struct GitHubStartQuery {
+pub struct OAuthStartQuery {
     #[serde(default)]
     pub return_to: Option<String>,
     #[serde(default)]
@@ -72,15 +80,22 @@ fn return_from_query_or_jar(jar: &CookieJar, query: Option<&str>) -> String {
         .unwrap_or_else(|| "/".to_string())
 }
 
-fn oauth_providers(base_url: &str, return_to: &str) -> Vec<(&'static str, String)> {
+/// Available OAuth link targets: `(provider_key, label, start_href)`.
+fn oauth_providers(base_url: &str, return_to: &str) -> Vec<(&'static str, &'static str, String)> {
     let mut out = Vec::new();
+    let enc = urlencoding::encode(return_to);
     if oauth::GitHubConfig::from_env(base_url).is_some() {
         out.push((
-            "GitHub",
-            format!(
-                "/auth/github?return_to={}",
-                urlencoding::encode(return_to)
-            ),
+            "github",
+            oauth::provider_label("github"),
+            format!("/auth/github?return_to={enc}"),
+        ));
+    }
+    if oauth::RedditConfig::from_env(base_url).is_some() {
+        out.push((
+            "reddit",
+            oauth::provider_label("reddit"),
+            format!("/auth/reddit?return_to={enc}"),
         ));
     }
     out
@@ -125,23 +140,41 @@ fn alias_claim_forms(return_to: &str, submit_label: &str) -> Result<Markup, Stat
     })
 }
 
-fn signed_out_body(providers: &[(&str, String)]) -> Markup {
+fn login_error_message(code: Option<&str>) -> Option<&'static str> {
+    match code {
+        Some("oauth_taken") => {
+            Some("that OAuth account is already linked to a different sorter2 account")
+        }
+        Some("oauth_failed") => Some("OAuth failed — try again"),
+        _ => None,
+    }
+}
+
+fn signed_out_body(
+    providers: &[(&str, &str, String)],
+    error: Option<&str>,
+) -> Markup {
     html! {
         main class="panel login-page" {
             section class="login-section" {
                 h1 { "sign in" }
-                p class="muted" { "link an account to vote under a lasting alias" }
+                p class="muted" {
+                    "link an OAuth account to create your identity, then claim an alias to vote"
+                }
+                @if let Some(msg) = login_error_message(error) {
+                    p class="alias-bad" data-testid="login-error" { (msg) }
+                }
                 @if providers.is_empty() {
                     p class="muted" {
-                        "OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET."
+                        "OAuth is not configured. Set GitHub and/or Reddit client credentials."
                     }
                 } @else {
                     ul class="oauth-provider-list" {
-                        @for (name, href) in providers {
+                        @for (key, label, href) in providers {
                             li {
                                 a href=(href) class="btn-primary oauth-provider"
-                                    data-testid=(format!("oauth-{}", name.to_lowercase())) {
-                                    (format!("Continue with {name}"))
+                                    data-testid=(format!("oauth-{key}")) {
+                                    (format!("Link {label}"))
                                 }
                             }
                         }
@@ -156,7 +189,10 @@ fn signed_out_body(providers: &[(&str, String)]) -> Markup {
 fn account_body(
     actor: &session::SessionActor,
     aliases: &[String],
-    providers: &[(&str, String)],
+    // Provider keys already linked to this UUID (private).
+    linked: &[String],
+    // Providers available to link: not yet attached.
+    unlinkable: &[(&str, &str, String)],
     claim_forms: Markup,
 ) -> Markup {
     let current = actor.pseudonym.trim();
@@ -212,16 +248,29 @@ fn account_body(
                 (claim_forms)
             }
 
-            @if !providers.is_empty() {
-                section class="login-section" {
-                    h2 { "linked sign-in" }
-                    p class="muted small" { "sign in again with the same provider to return to this account" }
+            section class="login-section" {
+                h2 { "linked sign-in" }
+                p class="muted small" {
+                    "private to you — linking more providers raises trust weight without publishing which accounts you use"
+                }
+                @if linked.is_empty() {
+                    p class="muted" data-testid="linked-providers-empty" { "none yet" }
+                } @else {
+                    ul class="linked-provider-list" data-testid="linked-providers" {
+                        @for key in linked {
+                            li data-testid=(format!("linked-{key}")) {
+                                (oauth::provider_label(key))
+                            }
+                        }
+                    }
+                }
+                @if !unlinkable.is_empty() {
                     ul class="oauth-provider-list" {
-                        @for (name, href) in providers {
+                        @for (key, label, href) in unlinkable {
                             li {
                                 a href=(href) class="btn-secondary oauth-provider"
-                                    data-testid=(format!("oauth-relink-{}", name.to_lowercase())) {
-                                    (format!("Re-link {name}"))
+                                    data-testid=(format!("oauth-link-{key}")) {
+                                    (format!("Link {label}"))
                                 }
                             }
                         }
@@ -243,12 +292,21 @@ fn account_body(
 fn login_body(
     session: Option<&session::SessionActor>,
     aliases: &[String],
-    providers: &[(&str, String)],
+    linked: &[String],
+    providers: &[(&str, &str, String)],
     claim_forms: Option<Markup>,
+    error: Option<&str>,
 ) -> Markup {
     match (session, claim_forms) {
-        (Some(actor), Some(forms)) => account_body(actor, aliases, providers, forms),
-        _ => signed_out_body(providers),
+        (Some(actor), Some(forms)) => {
+            let unlinkable: Vec<_> = providers
+                .iter()
+                .filter(|(key, _, _)| !linked.iter().any(|p| p == key))
+                .cloned()
+                .collect();
+            account_body(actor, aliases, linked, &unlinkable, forms)
+        }
+        _ => signed_out_body(providers, error),
     }
 }
 
@@ -268,6 +326,10 @@ pub async fn login_page(
         .as_ref()
         .map(|s| alias_list(db, &s.uuid))
         .unwrap_or_default();
+    let linked = session
+        .as_ref()
+        .map(|s| linked_providers_for_uuid(db, &s.uuid).unwrap_or_default())
+        .unwrap_or_default();
     let providers = oauth_providers(&base_url_from_env(state.cfg.port), &return_to);
 
     let claim_forms = if session.is_some() {
@@ -282,7 +344,14 @@ pub async fn login_page(
         } else {
             "login · sorter2"
         },
-        login_body(session.as_ref(), &aliases, &providers, claim_forms),
+        login_body(
+            session.as_ref(),
+            &aliases,
+            &linked,
+            &providers,
+            claim_forms,
+            query.error.as_deref(),
+        ),
         state.views.get_views("/login"),
         session
             .as_ref()
@@ -302,7 +371,6 @@ pub async fn alias_page(
     let db = state.projection_store.db();
     let session = session::load_valid_session(db, &session_id).ok_or(StatusCode::UNAUTHORIZED)?;
     if session::session_has_pseudonym(&session) {
-        // Already onboarded — manage aliases on the account page.
         return Ok(Redirect::to("/login").into_response());
     }
 
@@ -331,7 +399,7 @@ pub async fn alias_page(
 pub async fn github_start(
     State(state): State<AppState>,
     jar: CookieJar,
-    Query(query): Query<GitHubStartQuery>,
+    Query(query): Query<OAuthStartQuery>,
 ) -> Result<Response, StatusCode> {
     let cfg = oauth::GitHubConfig::from_env(&base_url_from_env(state.cfg.port))
         .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
@@ -342,7 +410,28 @@ pub async fn github_start(
     } else {
         None
     };
-    let url = oauth::authorize_url(&cfg, &state_token, mock_user);
+    let url = oauth::github_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));
+    Ok((jar, Redirect::temporary(&url)).into_response())
+}
+
+pub async fn reddit_start(
+    State(state): State<AppState>,
+    jar: CookieJar,
+    Query(query): Query<OAuthStartQuery>,
+) -> Result<Response, StatusCode> {
+    let cfg = oauth::RedditConfig::from_env(&base_url_from_env(state.cfg.port))
+        .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 mock_user = if config::mock_oauth_allowed() {
+        query.mock_user.as_deref()
+    } else {
+        None
+    };
+    let url = oauth::reddit_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));
@@ -355,6 +444,13 @@ pub struct OAuthCallbackQuery {
     pub state: String,
 }
 
+/// Link `provider:provider_id` to a UUID.
+///
+/// - Logged in + new provider → attach to session UUID
+/// - Logged in + already ours → no-op
+/// - Logged in 

… preview truncated; 29,823 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.