constitution · epochs · watch · epoch 3

comparison

c_7a129e904906 (tommy-mor) vs c_94135a1c4c58 (tommy-mor)

download prompt · raw event · cmp_e2ee36e697710f

council reasoning

~anthropic/claude-sonnet-latest · winner B · 40:60 · permalink

Side B fixes concrete security bugs (votes silently falling back to an anonymous actor instead of failing closed, missing Secure cookie flag, mock-OAuth escape hatch reachable in production, open-redirect edge case, and pinning the durable dependency by rev instead of a mutable branch) with focused tests for each fix. Side A is a large, mostly additive commit (Dockerfile, fly.toml, CI/deploy workflow, a new dashboard UI/CSS/JS) that does contain a couple of real fixes (per-repo object-hash dedup, fail-closed OPENROUTER_API_KEY check), but the bulk of the diff is spectacle-heavy UI/infra rather than correctness-critical logic.

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

A ships production authority (Docker/Fly/CI deploy), a real /watch audit surface with SSE progress state, and behavioral fixes (fail-closed ranking without OpenRouter, epoch retry, safer git auth/object checks) with matching tests—core lasting product capability. B’s fail-closed vote auth, mock-OAuth gate, Secure cookies, and open-redirect hardening are high-value security fixes, but narrower in scope than A’s end-to-end deploy-and-audit system.

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

Side A delivers substantial new operational capability: production deployment (Docker, Fly.io, CI/CD), a live audit dashboard with SSE-backed progress reporting, status APIs, authenticated GitHub access for repository discovery, improved startup/retry behavior, and tests covering the new functionality. Side B contains valuable security hardening—failing closed on unauthenticated voting, Secure cookies, tighter redirect validation, pinned git dependencies, and small UX improvements—but it is a narrower refinement of existing authentication rather than adding comparable long-term platform capability.

sides

A — c_7a129e904906 (tommy-mor)

message

[4e327784] deploy live constitution dashboard

Expose auditable progress and event streaming, configure the production roots and runtime, and make tested main-branch commits the deployment authority.

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

diff preview

diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..c9d63a722beba0a0297fc853089332c460ab78dd
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+.git
+.venv
+.hypothesis
+__pycache__
+tests
+*.json
+*.bsp
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000000000000000000000000000000000000..76dcdf82d53177c1e47d86b23a54523239d232a6
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,49 @@
+name: Test and deploy
+
+on:
+  push:
+    branches: [main]
+
+concurrency:
+  group: production
+  cancel-in-progress: false
+
+permissions:
+  contents: read
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: astral-sh/setup-uv@v6
+        with:
+          enable-cache: true
+
+      - name: Run Python tests
+        run: uv run pytest -q
+
+      - name: Install Babashka
+        run: |
+          curl -fsSL https://raw.githubusercontent.com/babashka/babashka/master/install \
+            | sudo bash -s -- --dir /usr/local/bin
+
+      - name: Run process integration tests
+        run: bb TEST.sh
+
+  deploy:
+    needs: test
+    runs-on: ubuntu-latest
+    environment:
+      name: production
+      url: https://token.slug.social
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: superfly/flyctl-actions/setup-flyctl@master
+
+      - name: Deploy to Fly
+        run: flyctl deploy --remote-only
+        env:
+          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..c9a5c00782371c19ad5ab5c58cf6f5a8ffec0141
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,17 @@
+FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
+
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends git ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY pyproject.toml uv.lock ./
+RUN uv sync --frozen --no-install-project
+
+COPY constitution.py ./
+
+ENV PATH="/app/.venv/bin:${PATH}" \
+    PYTHONUNBUFFERED="1"
+
+EXPOSE 8080
+CMD ["python", "constitution.py"]
diff --git a/constitution.py b/constitution.py
index 4bee9f83663ab7fb36db95129b92b64b4ef57258..a58257e1881b21d1d6fa8e68a3faa222e4f661ef 100644
--- a/constitution.py
+++ b/constitution.py
@@ -24,12 +24,12 @@ A daily GitHub Action backs up the JSONL ledger to the same repo.
 Run: uv run constitution.py
 """
 
-from decimal import Decimal, getcontext
+from decimal import Decimal, getcontext, DefaultContext
 from datetime import datetime, timezone
 from fastapi import FastAPI, Request, Response
 from fastapi.responses import PlainTextResponse, HTMLResponse
 from starlette.middleware.sessions import SessionMiddleware
-import json, time, os, asyncio, httpx, pathlib, subprocess, hashlib, re, fcntl
+import json, time, os, asyncio, httpx, pathlib, subprocess, hashlib, re, fcntl, base64
 import sympy as sp  # type: ignore[reportMissingImports]
 from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
 from evaleval import (
@@ -37,6 +37,7 @@ from evaleval import (
     exec_event, One, Two, Three, Selector, MORPH, PREPEND,
 )
 
+DefaultContext.prec = 50
 getcontext().prec = 50
 
 app = FastAPI()
@@ -129,14 +130,47 @@ OPENROUTER_BASE_URL = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.
 # using the exact same source; their normalized values are committed to every
 # discovery event.
 DEFAULT_REPOSITORIES = [
+    {
+        "id": "constitution",
+        "url": "https://github.com/sortersocial/constitution.git",
+        "refs": ["refs/heads/**"],
+    },
     {
         "id": "slug",
-        "url": "https://github.com/tommy-mor/slug.git",
+        "url": "https://github.com/sortersocial/slug.git",
+        "refs": ["refs/heads/**"],
+    },
+    {
+        "id": "sorter",
+        "url": "https://github.com/sorterisntonline/sorter.git",
+        "refs": ["refs/heads/**"],
+    },
+    {
+        "id": "sorter2",
+        "url": "https://github.com/sortersocial/sorter2.git",
+        "refs": ["refs/heads/**"],
+    },
+    {
+        "id": "sorter-oldest",
+        "url": "https://github.com/tommy-mor/sorter.git",
         "refs": ["refs/heads/**"],
     },
 ]
 DEFAULT_CONTRIBUTORS = {
     "tommy-mor": ["thmorriss@gmail.com"],
+    "christopher-whitman": [
+        "chris@cwwhitman.com",
+        "7566903+cwwhitman@users.noreply.github.com",
+    ],
+    "jake-chvatal": [
+        "jake+github@uln.industries",
+        "jakechvatal@gmail.com",
+        "jake@isnt.online",
+    ],
+    "lara": ["me@lara.lv"],
+    "nat-reid": ["nathanielreid@gmail.com"],
+    "zod": ["jason.p.mcel@gmail.com", "me@zod.tf"],
+    "jovan": ["jovan@slug.social", "jovan@getcivicai.com"],
 }
 
 REPOSITORIES = json.loads(
@@ -147,6 +181,7 @@ CONTRIBUTORS = json.loads(
 )
 GIT_MIRROR_DIR = pathlib.Path(os.environ.get("GIT_MIRROR_DIR", "/data/git"))
 GIT_TIMEOUT_SECONDS = int(os.environ.get("GIT_TIMEOUT_SECONDS", "120"))
+GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
 
 # Council model IDs: slug.social garden rank under this parent (bodies = OpenRouter URLs), then top-up from OpenRouter list.
 SLUG_SOCIAL_BASE_URL = os.environ.get("SLUG_SOCIAL_BASE_URL", "https://slug.social").rstrip("/")
@@ -661,20 +696,30 @@ def _git(repo: pathlib.Path | None, *args: str, input_bytes: bytes | None = None
     if repo is not None:
         command += ["-C", str(repo)]
     command += list(args)
+    git_env = {
+        **os.environ,
+        "GIT_CONFIG_NOSYSTEM": "1",
+        "GIT_CONFIG_GLOBAL": os.devnull,
+        "GIT_NO_REPLACE_OBJECTS": "1",
+        "LC_ALL": "C",
+        "TZ": "UTC",
+    }
+    if GITHUB_TOKEN:
+        credential = base64.b64encode(
+            f"x-access-token:{GITHUB_TOKEN}".encode()
+        ).decode()
+        git_env.update({
+            "GIT_CONFIG_COUNT": "1",
+            "GIT_CONFIG_KEY_0": "http.https://github.com/.extraHeader",
+            "GIT_CONFIG_VALUE_0": f"Authorization: Basic {credential}",
+        })
     try:
         result = subprocess.run(
             command,
             input=input_bytes,
             stdout=subprocess.PIPE,
             stderr=subprocess.PIPE,
-            env={
-                **os.environ,
-                "GIT_CONFIG_NOSYSTEM": "1",
-                "GIT_CONFIG_GLOBAL": os.devnull,
-                "GIT_NO_REPLACE_OBJECTS": "1",
-                "LC_ALL": "C",
-                "TZ": "UTC",
-            },
+            env=git_env,
             timeout=GIT_TIMEOUT_SECONDS,
             check=False,
         )
@@ -844,9 +889,15 @@ def _build_discovery(epoch_n: int, boundary_ms: int, events: list) -> GitDiscove
         canonical_location = min(
             locations[qualified_oid], key=lambda x: (x[0], x[1])
         )
+        # One commit may be reachable from dozens of refs in the same mirror.
+        # Verify its object once per repository, not once per source ref.
+        object_locations = {
+            (str(m), raw_oid): (m, raw_oid)
+            for _, _, m, raw_oid in locations[qualified_oid]
+        }
         object_hashes = {
             hashlib.sha256(_git(m, "cat-file", "commit", raw_oid)).hexdigest()
-            for _, _, m, raw_oid in locations[qualified_oid]
+            for m, raw_oid in object_locations.values()
         }
         if len(object_hashes) != 1:
             raise RuntimeError(f"conflicting Git objects share OID {qualified_oid}")
@@ -994,11 +1045,55 @@ async def discover_repositories(epoch_n: int, boundary_ms: int) -> GitDiscovery:
 
 
 SSE_CLIENTS = []
+AUDIT_HISTORY = []
+AUDIT_SEQUENCE = 0
+PROCESS_STATE = {
+    "running": False,
+    "phase": "idle",
+    "progress": 100,
+    "message": "Waiting for the next epoch",
+}
+
+
+def _sse_event(event_name: str, payload: dict) -> str:
+    return (
+        f"event: {event_name}\n"
+        f"data: {json.dumps(payload, separators=(',', ':'))}\n\n"
+    )
+
+
+async def broadcast_audit(
+    kind: str,
+    message: str,
+    *,
+    progress: int | None = None,
+    phase: str | None = None,
+) -> dict:
+    global AUDIT_SEQUENCE
+    AUDIT_SEQUENCE += 1
+    if progress is not None:
+        PROCESS_STATE["progress"] = max(0, min(100, int(progress)))
+    if phase is not None:
+        PROCESS_STATE["phase"] = phase
+    PROCESS_STATE["message"] = message
+    payload = {
+        "id": AUDIT_SEQUENCE,
+        "timestamp_ms": int(time.time() * 1000),
+        "kind": kind,
+        "message": message,
+        **PROCESS_STATE,
+    }
+    AUDIT_HISTORY.append(payload)
+    del AUDIT_HISTORY[:-200]
+    wire = _sse_event("audit", payload)
+    for queue in list(SSE_CLIENTS):
+        await queue.put(wire)
+    return payload
 
 
 async def broadcast_js(js: str):
     """Send a JS snippet to all connected SSE clients."""
-    for queue in SSE_CLIENTS:
+    for queue in list(SSE_CLIENTS):
         await queue.put(js)
 
 
@@ -1006,10 +1101,29 @@ async def rank_commits(commits: list[dict]):
     if not commits:
         return {}, []
 
-    models = await fetch_top_models(n=3)
     contributors = sorted(set(c["contributor"] for c in commits))
-    if len(contributors) > 1 and not models:
+    if len(contributors) == 1:
+        await broadcast_audit(
+            "ranking",
+            f"Only {contributors[0]} is eligible; rank is 1.0",
+            progress=90,
+            phase="finalizing",
+        )
+        return {contributors[0]: Decimal("1")}, []
+    if not (OPENROUTER_API_KEY or "").strip():
+        raise RuntimeError(
+            "OPENROUTER_API_KEY is required when multiple contributors need ranking"
+        )
+
+    models = await fetch_top_models(n=3)
+    if not models:
         raise RuntimeError("no council models available for contributor ranking")
+    await broadcast_audit(
+        "council",
+        f"Council selected: {', '.join(models)}",
+        progress=35,
+        phase="ranking",
+    )
     await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
         ["div.log-council", f"Council: {', '.join(models)} — {len(commits)} commits"]
     ]))
@@ -1035,6 +1149,11 @@ async def rank_commits(commits: list[dict]):
 
     async def compare_fn(i, j):
         a1, a2 = authors[i], authors[j]
+        await broadcast_audit(
+            "comparison",
+            f"Comparing {a1} with {a2}",
+            phase="ranking",
+        )
         await broadcast_js(exec_event(Three[Selector("#emission-status")][MORPH][
             ["div#emission-status", f"Comparing {a1} vs {a2}…"]
         ]))
@@ -1050,6 +1169,11 @@ async def rank_commits(commits: list[dict]):
                 if winner_weight <= 0 or loser_weight <= 0:
                     raise ValueError("ratio weights must be positive")
                 results.append((w, l, winner_weight, loser_weight))
+                await broadcast_audit(
+                    "vote",
+                    f"{model}: {authors[w]} over {authors[l]} ({result['ratio']})",
+                    phase="ranking",
+                )
                 await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
                     ["div.log-vote",
                         ["span.model", model], " — ",
@@ -1059,6 +1183,11 @@ async def rank_commits(commits: list[dict]):
                     ]
                 ]))
             except Exception as e:
+                await broadcast_audit(
+                    "error",
+                    f"{model} failed: {e}",
+                    phase="error",
+                )
                 await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
                     ["div.log-error", f"⚠ {model}: {e}"]
                 ]))
@@ -1068,8 +1197,13 @@ async def rank_commits(commits: list[dict]):
     async def progress_fn(ev):
         if ev["phase"] == "spanning_tree":
             label = f"Spanning tree: {ev['step']}/{ev['total']}"
+    

… preview truncated; 28,089 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.