You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit 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 Side A — unified diff (full patch): 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']}" + percent = 35 + round(35 * ev["step"] / max(ev["total"], 1)) else: label = f"Zip pass {ev['pass']}: {ev['step']}/{ev['total']}" + percent = 70 + round(20 * ev["step"] / max(ev["total"], 1)) + await broadcast_audit( + "progress", label, progress=percent, phase="ranking" + ) await broadcast_js(exec_event(Three[Selector("#emission-status")][MORPH][ ["div#emission-status", label] ])) @@ -1083,6 +1217,12 @@ async def rank_commits(commits: list[dict]): scores = rank_centrality(pairs) ranking = {authors[i]: Decimal(str(scores[i])) for i in range(len(authors))} ranking_rows = sorted(ranking.items(), key=lambda x: x[1], reverse=True) + await broadcast_audit( + "ranking", + "Ranking: " + ", ".join(f"{a} {s:.4f}" for a, s in ranking_rows), + progress=90, + phase="finalizing", + ) await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ ["div.log-ranking", ["b", "Ranking: "], @@ -1104,11 +1244,33 @@ def pool_remaining(events: list) -> Decimal: async def run_emission(epoch_n, boundary_ms): + PROCESS_STATE["running"] = True + await broadcast_audit( + "start", + f"Epoch {epoch_n} emission started", + progress=2, + phase="starting", + ) await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ ["div.log-start", f"⚡ Epoch {epoch_n} emission started"] ])) + await broadcast_audit( + "discovery", + "Fetching configured repositories and snapshotting refs", + progress=8, + phase="discovery", + ) discovery = await discover_repositories(epoch_n, boundary_ms) + await broadcast_audit( + "discovery", + ( + f"Discovered {len(discovery.observations)} new commits; " + f"{len(discovery.commits)} are eligible" + ), + progress=30, + phase="discovery", + ) ranking, models = await rank_commits(discovery.commits) def make_emission(events): @@ -1146,6 +1308,13 @@ async def run_emission(epoch_n, boundary_ms): entry = await store.atomic(make_emission) if entry: + PROCESS_STATE["running"] = False + await broadcast_audit( + "complete", + f"Epoch {entry.epoch} complete; emitted {entry.total_emitted} SLG", + progress=100, + phase="idle", + ) await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ ["div.log-amount", f"Pool {entry.pool_before} → emit {entry.total_emitted} → {entry.pool_after}"] @@ -1189,13 +1358,24 @@ async def distribute_usdc(holdings, treasury_balance): async def epoch_loop(): while True: epoch_n, current_start, next_boundary = current_epoch() + processed = {e.epoch for e in store.read() if isinstance(e, Emission)} + if epoch_n >= 0 and epoch_n not in processed: + try: + await run_emission(epoch_n, current_start) + except Exception as exc: + PROCESS_STATE["running"] = False + await broadcast_audit( + "error", + f"Epoch {epoch_n} failed: {exc}; retrying in 60 seconds", + phase="error", + ) + print(f"epoch {epoch_n} emission failed: {exc}", flush=True) + await asyncio.sleep(60) + continue + now = int(time.time() * 1000) wait_ms = next_boundary - now - if wait_ms <= 0: - processed = {e.epoch for e in store.read() if isinstance(e, Emission)} - if epoch_n not in processed and epoch_n >= 0: - await run_emission(epoch_n, current_start) await asyncio.sleep(60) elif wait_ms < 86_400_000: await broadcast_js(exec_event(Three[Selector("#emission-status")][MORPH][ @@ -1243,6 +1423,36 @@ async def get_ranking(): return {"ranking": latest.ranking, "epoch": latest.epoch} +@app.get("/api/status") +async def get_status(): + events = store.read() + discoveries = [e for e in events if isinstance(e, GitDiscovery)] + emissions = [e for e in events if isinstance(e, Emission)] + return { + **PROCESS_STATE, + "epoch": current_epoch()[0], + "openrouter_configured": bool((OPENROUTER_API_KEY or "").strip()), + "sse_clients": len(SSE_CLIENTS), + "latest_discovery": ( + { + "epoch": discoveries[-1].epoch, + "snapshot_id": discoveries[-1].snapshot_id, + "observations": len(discoveries[-1].observations), + "eligible_commits": len(discoveries[-1].commits), + } + if discoveries else None + ), + "latest_emission": ( + { + "epoch": emissions[-1].epoch, + "total_emitted": emissions[-1].total_emitted, + "ranking": emissions[-1].ranking, + } + if emissions else None + ), + } + + @app.get("/api/contributor/{github_username}") async def get_contributor(github_username: str): history = [ @@ -1306,13 +1516,6 @@ async def test_emit(): # =========================================================================== # §9. SSE — live audit stream of the pairwise voting process -# -# TODO: the /sse emission audit page needs a real SSE-driven UI. votes arrive -# incrementally during rank_commits(), and the client should show a live -# progress bar and per-vote results as they stream in. this requires a -# dedicated page that connects to /sse and updates the DOM on each event -# (council, comparing, vote, ranking, emission_complete). defer until we -# have playwright tests to cover it — the incremental rendering is fiddly. # =========================================================================== @app.get("/sse") @@ -1322,6 +1525,13 @@ async def sse_stream(request: Request): async def generate(): try: + yield _sse_event("audit", { + "id": AUDIT_SEQUENCE, + "timestamp_ms": int(time.time() * 1000), + "kind": "connection", + "message": f"Connected to epoch {current_epoch()[0]}", + **PROCESS_STATE, + }) yield exec_event(Three[Selector("#emission-status")][MORPH][ ["div#emission-status", f"Connected — epoch {current_epoch()[0]}"] ]) @@ -1334,10 +1544,15 @@ async def sse_stream(request: Request): except asyncio.TimeoutError: yield ": keepalive\n\n" finally: - SSE_CLIENTS.remove(queue) + if queue in SSE_CLIENTS: + SSE_CLIENTS.remove(queue) from starlette.responses import StreamingResponse - return StreamingResponse(generate(), media_type="text/event-stream") + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) # =========================================================================== @@ -1359,6 +1574,7 @@ def _page(title: str, body: list) -> HTMLResponse: ["meta", {"charset": "utf-8"}], ["meta", {"name": "viewport", "content": "width=device-width, initial-scale=1"}], ["title", title], + ["style", RawContent(_WATCH_CSS)], ], ["body", body, @@ -1367,6 +1583,387 @@ def _page(title: str, body: list) -> HTMLResponse: ])) +_WATCH_CSS = """ +/* ================================================================ + ZIGGURAT — bevel-first dark theme + --spread (0→1) controls bevel depth. 0 = flat. 1 = full relief. + Light source: top-left. Shadow: bottom-right. + Platforms nest. Each level is raised. Nothing is rounded. + ================================================================ */ + +:root { + color-scheme: dark; + --spread: 1; + + --g0: #080808; + --g1: #131313; + --g2: #1c1c1c; + --g3: #252525; + --g4: #2e2e2e; + --g5: #383838; + + --hi: #5e5e5e; + --lo: #050505; + --bv: calc(var(--spread) * 4px + 1px); + --bv-lg: calc(var(--spread) * 6px + 2px); + + --signal: #f0f0f0; + --prose: #c2c2c2; + --ui: #888; + --meta: #4a4a4a; + --link: #8899ee; + --code-fg: #c8dda0; + + --font-prose: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; + --font-ui: system-ui, -apple-system, sans-serif; + --font-code: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace; +} + +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } + +body { + background: var(--g0); + color: var(--prose); + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.6; + margin: 0 auto; + max-width: 560px; + min-height: 100vh; + padding: 0 16px 48px; +} +main { width: 100%; padding: 18px 0 48px; } + +h1, h2, h3 { + color: var(--signal); + font-size: 11px; + font-weight: bold; + letter-spacing: 0.12em; + margin: 14px 0 6px; + text-transform: uppercase; +} +a { color: var(--link); text-decoration: none; } +a:hover { color: var(--signal); } +.eyebrow { + background: var(--g2); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + color: var(--ui); + font-size: 11px; + letter-spacing: 0.12em; + padding: 4px 10px; + text-transform: uppercase; + width: fit-content; +} + +/* Every dashboard section is a raised platform. */ +.panel { + background: var(--g2); + border: var(--bv-lg) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + margin: 8px 0; + padding: 10px; + width: 100%; +} +.status-row { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: space-between; +} +#process-status { color: var(--signal); font-family: var(--font-code); font-weight: bold; } +.badge { + align-items: center; + background: var(--g3); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + color: var(--ui); + display: inline-flex; + font-size: 11px; + gap: 7px; + padding: 3px 8px; +} +.dot { background: var(--meta); height: 8px; width: 8px; } +.live .dot { background: #7acc7a; } +.warn .dot { background: #cc9955; } + +/* The progress track is inset; its signal is raised inside it. */ +.progress-shell { + background: var(--g1); + border: var(--bv-lg) solid; + border-color: var(--lo) var(--hi) var(--hi) var(--lo); + height: 58px; + margin: 14px 0 10px; + overflow: hidden; + position: relative; +} +#progress-fill { + background: var(--link); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + height: 100%; + transition: width .35s steps(8, end); + width: 0; +} +#progress-label { + color: var(--signal); + display: grid; + font-family: var(--font-code); + font-size: 18px; + font-weight: bold; + inset: 0; + place-items: center; + position: absolute; + text-shadow: 1px 1px var(--lo); +} + +.controls { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; } +button { + background: var(--g5); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + color: var(--signal); + cursor: pointer; + font: inherit; + font-size: 12px; + padding: 4px 10px; +} +button:hover { background: #404040; } +button:active { + background: var(--g4); + border-color: var(--lo) var(--hi) var(--hi) var(--lo); + transform: translate(1px, 1px); +} +button:disabled { cursor: default; opacity: .4; } +.note { color: var(--meta); font-size: 11px; margin: 4px 0; } + +.feed-head { align-items: baseline; display: flex; justify-content: space-between; } +#audit-feed { + background: var(--g1); + border: var(--bv) solid; + border-color: var(--lo) var(--hi) var(--hi) var(--lo); + display: flex; + flex-direction: column; + gap: 5px; + margin-top: 8px; + padding: 6px; +} +.event { + background: var(--g3); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + display: grid; + gap: 6px; + grid-template-columns: 82px 88px 1fr; + padding: 5px 8px; +} +.event[data-kind="error"] { border-left-color: #cc5555; } +.event[data-kind="complete"], .event[data-kind="ranking"] { border-left-color: #7acc7a; } +.event[data-kind="vote"] { border-left-color: var(--link); } +.event time, .event-kind { color: var(--meta); font-family: var(--font-code); font-size: 10px; } +.event-kind { text-transform: uppercase; } +.event-message { color: var(--prose); font-family: var(--font-prose); } + +code { + background: var(--g1); + border: 2px solid; + border-color: var(--lo) var(--hi) var(--hi) var(--lo); + color: var(--code-fg); + font-family: var(--font-code); + font-size: 12px; + padding: 1px 4px; +} + +@media (max-width: 520px) { + .event { grid-template-columns: 72px 1fr; } + .event-message { grid-column: 1 / -1; } +} +""" + + +def _watch_initial_state() -> dict: + events = store.read() + feed = [] + for event_ in events[-40:]: + if isinstance(event_, GitDiscovery): + feed.append({ + "id": f"discovery-{event_.snapshot_id}", + "timestamp_ms": event_.timestamp_ms, + "kind": "discovery", + "message": ( + f"Epoch {event_.epoch}: observed {len(event_.observations)} commits; " + f"{len(event_.commits)} eligible" + ), + }) + elif isinstance(event_, Emission): + feed.append({ + "id": f"emission-{event_.epoch}", + "timestamp_ms": event_.timestamp_ms, + "kind": "complete", + "message": ( + f"Epoch {event_.epoch}: emitted {event_.total_emitted} SLG; " + f"ranking {event_.ranking}" + ), + }) + feed.extend(AUDIT_HISTORY) + return { + "process": dict(PROCESS_STATE), + "openrouter_configured": bool((OPENROUTER_API_KEY or "").strip()), + "epoch": current_epoch()[0], + "feed": feed[-200:], + } + + +_WATCH_JS = """ +const initial = __INITIAL__; +const feed = document.querySelector('#audit-feed'); +const processStatus = document.querySelector('#process-status'); +const connection = document.querySelector('#connection-status'); +const fill = document.querySelector('#progress-fill'); +const progressLabel = document.querySelector('#progress-label'); +const play = document.querySelector('#play'); +const pause = document.querySelector('#pause'); +const seen = new Set(); +let source = null; + +function setProgress(value) { + const n = Math.max(0, Math.min(100, Number(value ?? 0))); + fill.style.width = `${n}%`; + progressLabel.textContent = `${Math.round(n)}%`; + document.querySelector('.progress-shell').setAttribute('aria-valuenow', String(n)); +} + +function addEvent(event) { + const id = String(event.id); + if (seen.has(id)) return; + seen.add(id); + const row = document.createElement('div'); + row.className = 'event'; + row.dataset.kind = event.kind || 'event'; + const when = document.createElement('time'); + when.dateTime = new Date(event.timestamp_ms).toISOString(); + when.textContent = new Date(event.timestamp_ms).toLocaleTimeString(); + const kind = document.createElement('span'); + kind.className = 'event-kind'; + kind.textContent = event.kind || 'event'; + const message = document.createElement('span'); + message.className = 'event-message'; + message.textContent = event.message; + row.append(when, kind, message); + feed.prepend(row); + while (feed.children.length > 200) feed.lastElementChild.remove(); +} + +function applyState(event) { + processStatus.textContent = event.message || 'Waiting for the next epoch'; + setProgress(event.progress); + if (event.kind !== 'connection') addEvent(event); +} + +function connect() { + if (source) return; + source = new EventSource('/sse'); + connection.classList.remove('warn'); + connection.classList.add('live'); + connection.querySelector('span:last-child').textContent = 'connecting'; + play.disabled = true; + pause.disabled = false; + source.onopen = () => { + connection.querySelector('span:last-child').textContent = 'live'; + }; + source.addEventListener('audit', event => applyState(JSON.parse(event.data))); + source.onerror = () => { + connection.classList.remove('live'); + connection.classList.add('warn'); + connection.querySelector('span:last-child').textContent = 'reconnecting'; + }; +} + +function disconnect() { + if (source) source.close(); + source = null; + connection.classList.remove('live'); + connection.classList.add('warn'); + connection.querySelector('span:last-child').textContent = 'paused locally'; + play.disabled = false; + pause.disabled = true; +} + +play.addEventListener('click', connect); +pause.addEventListener('click', disconnect); +initial.feed.forEach(addEvent); +processStatus.textContent = initial.process.message; +setProgress(initial.process.progress); +connect(); +""" + + +@app.get("/watch") +async def watch(): + initial = json.dumps( + _watch_initial_state(), separators=(",", ":") + ).replace(" 0") - ;; 9. SSE connects and sends initial event + ;; 9. watch UI exposes progress, controls, readiness, and live SSE + (println "\nchecking /watch UI…") + (bind watch-html (slurp (str base-url "/watch"))) + (assert! (str/includes? watch-html "role=\"progressbar\"") + "watch page has progress bar") + (assert! (str/includes? watch-html "id=\"play\"") + "watch page has play control") + (assert! (str/includes? watch-html "id=\"pause\"") + "watch page has pause control") + (assert! (str/includes? watch-html "OpenRouter configured") + "watch page reports council readiness") + (bind status-resp (get-json base-url "/api/status")) + (assert! (true? (:openrouter_configured status-resp)) + "status API reports OpenRouter configuration") + + ;; 10. SSE connects and sends initial event (println "\nchecking /sse initial event…") (bind sse-events (read-sse-events (str base-url "/sse") 1 5000)) (assert! (= 1 (count sse-events)) "received 1 SSE event") (assert! (not (str/blank? (first sse-events))) "initial SSE event contains executable audit data") - ;; 10. POST /test/emit — full ranking pipeline hits mocks + ;; 11. POST /test/emit — full ranking pipeline hits mocks (println "\ntriggering /test/emit (epoch 1)…") (bind emit-resp (post-json! base-url "/test/emit")) (assert! (= "emission" (:type emit-resp)) "emit response type is emission") @@ -503,7 +518,7 @@ (bind rank-after (get-json base-url "/api/ranking")) (assert! (= 1 (:epoch rank-after)) "latest ranking is epoch 1") - ;; 11. kill and restart — prove replay determinism + ;; 12. kill and restart — prove replay determinism (println "\nkilling server for replay test…") (.destroyForcibly (:proc server)) (deref server) diff --git a/tests/test_git_discovery.py b/tests/test_git_discovery.py index 0dd31bc42a19bc8c59842dc61f193c595c474659..5a9e167e16ad2ffb25988af85820f6f19e8910cd 100644 --- a/tests/test_git_discovery.py +++ b/tests/test_git_discovery.py @@ -393,7 +393,9 @@ def test_empty_epoch_records_zero_emission_without_burning_pool( monkeypatch.setattr(c, "store", c.JsonlStore(discovery_config / "ledger.jsonl")) async def discover(_epoch, _boundary): - return SimpleNamespace(commits=[], snapshot_id="empty-snapshot") + return SimpleNamespace( + observations=[], commits=[], snapshot_id="empty-snapshot" + ) async def rank(_commits): return {}, [] @@ -412,7 +414,11 @@ def test_emission_distribution_sums_exactly_to_total( monkeypatch.setattr(c, "store", c.JsonlStore(discovery_config / "ledger.jsonl")) async def discover(_epoch, _boundary): - return SimpleNamespace(commits=[{"x": 1}], snapshot_id="ranked-snapshot") + return SimpleNamespace( + observations=[{"x": 1}], + commits=[{"x": 1}], + snapshot_id="ranked-snapshot", + ) async def rank(_commits): return { @@ -454,6 +460,7 @@ def test_any_council_failure_aborts_ranking(monkeypatch): monkeypatch.setattr(c, "fetch_top_models", models) monkeypatch.setattr(c, "llm_pairwise_compare", compare) + monkeypatch.setattr(c, "OPENROUTER_API_KEY", "test-key") commits = [ { "contributor": contributor, @@ -465,3 +472,54 @@ def test_any_council_failure_aborts_ranking(monkeypatch): ] with pytest.raises(RuntimeError, match="council model failed"): asyncio.run(c.rank_commits(commits)) + + +def test_contested_ranking_requires_openrouter_key(monkeypatch): + monkeypatch.setattr(c, "OPENROUTER_API_KEY", "") + commits = [ + { + "contributor": contributor, + "oid": "sha1:" + char * 40, + "message": contributor, + "patch": "patch", + } + for contributor, char in [("alice", "a"), ("bob", "b")] + ] + with pytest.raises(RuntimeError, match="OPENROUTER_API_KEY"): + asyncio.run(c.rank_commits(commits)) + + +def test_watch_page_has_live_controls_progress_and_key_warning( + discovery_config, monkeypatch +): + monkeypatch.setattr(c, "store", c.JsonlStore(discovery_config / "ledger.jsonl")) + monkeypatch.setattr(c, "OPENROUTER_API_KEY", "") + monkeypatch.setattr(c, "current_epoch", lambda: (3, 0, 1)) + response = asyncio.run(c.watch()) + html = response.body.decode() + assert 'role="progressbar"' in html + assert 'id="play"' in html + assert 'id="pause"' in html + assert "new EventSource('/sse')" in html + assert "OpenRouter key missing" in html + + +def test_audit_events_are_json_sse_and_update_process_state(monkeypatch): + clients = [] + history = [] + monkeypatch.setattr(c, "SSE_CLIENTS", clients) + monkeypatch.setattr(c, "AUDIT_HISTORY", history) + queue = asyncio.Queue() + clients.append(queue) + + async def emit(): + event = await c.broadcast_audit( + "progress", "halfway", progress=50, phase="ranking" + ) + return event, await queue.get() + + event, wire = asyncio.run(emit()) + assert event["progress"] == 50 + assert event["phase"] == "ranking" + assert wire.startswith("event: audit\ndata: {") + assert '"message":"halfway"' in wire Side B — contributor: tommy-mor Side B — commit 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 Side B — unified diff (full patch): 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 { + 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 { + let session_id = session_id_from_jar(jar)?; + let session = load_valid_session(db, &session_id)?; + if session_has_pseudonym(&session) { + Some(session.current_pseudonym) + } else { + None } } @@ -151,54 +183,27 @@ pub fn destroy_session(db: &Db, session_id: &str) -> Result<(), String> { } pub fn session_cookie_value(session_id: &str) -> Cookie<'static> { - Cookie::build((SESSION_COOKIE, session_id.to_string())) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .build() + build_cookie(SESSION_COOKIE, session_id.to_string()) } pub fn clear_session_cookie() -> Cookie<'static> { - Cookie::build((SESSION_COOKIE, "")) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .removal() - .build() + clear_cookie(SESSION_COOKIE) } pub fn oauth_state_cookie_value(state: &str) -> Cookie<'static> { - Cookie::build((OAUTH_STATE_COOKIE, state.to_string())) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .build() + build_cookie(OAUTH_STATE_COOKIE, state.to_string()) } pub fn clear_oauth_state_cookie() -> Cookie<'static> { - Cookie::build((OAUTH_STATE_COOKIE, "")) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .removal() - .build() + clear_cookie(OAUTH_STATE_COOKIE) } pub fn auth_return_cookie_value(return_to: &str) -> Cookie<'static> { - Cookie::build((AUTH_RETURN_COOKIE, return_to.to_string())) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .build() + build_cookie(AUTH_RETURN_COOKIE, return_to.to_string()) } pub fn clear_auth_return_cookie() -> Cookie<'static> { - Cookie::build((AUTH_RETURN_COOKIE, "")) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .removal() - .build() + clear_cookie(AUTH_RETURN_COOKIE) } pub fn auth_return_from_jar(jar: &CookieJar) -> Option { @@ -213,28 +218,35 @@ pub fn oauth_state_from_jar(jar: &CookieJar) -> Option { jar.get(OAUTH_STATE_COOKIE).map(|c| c.value().to_string()) } -pub fn actor_uuid_for_vote(db: &Db, session_id: Option<&str>) -> String { - let Some(session_id) = session_id else { - return DEFAULT_ACTOR_UUID.to_string(); - }; - load_session(db, session_id) - .ok() - .flatten() - .filter(|s| s.expires_at > now_ms()) - .map(|s| s.uuid) - .unwrap_or_else(|| DEFAULT_ACTOR_UUID.to_string()) -} - #[cfg(test)] mod tests { use super::*; #[test] - fn missing_session_falls_back_to_anon() { + fn missing_session_is_error() { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open(dir.path()).unwrap(); + assert_eq!(resolve_vote_actor(&db, None).unwrap_err(), "sign in to vote"); + } + + #[test] + fn session_without_pseudonym_is_error() { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open(dir.path()).unwrap(); + let (id, _) = create_session(&db, DEFAULT_ACTOR_UUID, "").unwrap(); + assert_eq!( + resolve_vote_actor(&db, Some(&id)).unwrap_err(), + "choose an alias first" + ); + } + + #[test] + fn session_with_pseudonym_resolves() { let dir = tempfile::tempdir().unwrap(); let db = Db::open(dir.path()).unwrap(); - let actor = resolve_vote_actor(&db, None); - assert_eq!(actor.pseudonym, DEFAULT_PSEUDONYM); + let (id, _) = create_session(&db, DEFAULT_ACTOR_UUID, "alice").unwrap(); + let actor = resolve_vote_actor(&db, Some(&id)).unwrap(); + assert_eq!(actor.pseudonym, "alice"); assert_eq!(actor.trust_weight, 1.0); } } diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 46d18b87f1313bf0aeb29955d18f291961057509..3cc3d7bdf55b5cb5d009600f4ade1fcd201a410b 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -4,11 +4,13 @@ use axum::{ http::{header, StatusCode, Uri}, response::{IntoResponse, Response}, }; +use axum_extra::extract::cookie::CookieJar; use maud::{html, Markup, DOCTYPE}; use std::collections::HashSet; use crate::{ + auth::nav_pseudonym, fetch::html::entity_section, form_template::template_json_compact, path_types::ItemId, @@ -126,7 +128,7 @@ pub fn now_ms() -> i64 { t.as_millis() as i64 } -pub(crate) fn layout(title: &str, body: Markup, views: u64) -> Markup { +pub(crate) fn layout(title: &str, body: Markup, views: u64, nav_user: Option<&str>) -> Markup { let ver = asset_version(); let css_href = format!("/static/sorter.css?v={ver}"); let js_src = format!("/static/sorter_ui.js?v={ver}"); @@ -145,7 +147,15 @@ pub(crate) fn layout(title: &str, body: Markup, views: u64) -> Markup { span class="view-meta muted" { (views) " views" } } nav class="top-nav" { - a href="/login" { "login" } + @if let Some(name) = nav_user { + span class="top-nav-user" data-testid="nav-user" { (name) } + a href="/login" { "account" } + form class="top-nav-logout" method="post" action="/auth/logout" data-navigate="full" { + button type="submit" data-testid="nav-logout" { "log out" } + } + } @else { + a href="/login" data-testid="nav-login" { "login" } + } } div id="errors" {} (body) @@ -481,10 +491,11 @@ pub fn input_panel(query: &str, error: Option<&str>) -> Markup { } } -async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup { +async fn item_page(state: AppState, uri: Uri, item: ItemId, jar: CookieJar) -> Markup { let path = uri.path().to_string(); state.views.increment(path.clone()); let views = state.views.get_views(&path); + let nav_user = nav_pseudonym(state.projection_store.db(), &jar); let tree = state .scope_tree(&item) @@ -513,16 +524,24 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup { (ranking_panel(&item, node, &tree)) } }; - layout("sorter2", body, views) + layout("sorter2", body, views, nav_user.as_deref()) } -pub async fn home(State(state): State, uri: Uri) -> impl IntoResponse { - item_page(state, uri, ItemId::root()).await +pub async fn home( + State(state): State, + jar: CookieJar, + uri: Uri, +) -> impl IntoResponse { + item_page(state, uri, ItemId::root(), jar).await } -pub async fn browse(State(state): State, uri: Uri) -> impl IntoResponse { +pub async fn browse( + State(state): State, + jar: CookieJar, + uri: Uri, +) -> impl IntoResponse { let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root()); - item_page(state, uri, item).await + item_page(state, uri, item, jar).await } #[cfg(test)] diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs index 3aa00c417c89a9cab3417c650b50ed7c73f08e20..cadbec188b17a48a63b269c0e2fa2ea8ffedd7ed 100644 --- a/server/src/html/vote.rs +++ b/server/src/html/vote.rs @@ -4,11 +4,13 @@ use axum::{ extract::{Query, State}, response::{Html, IntoResponse}, }; +use axum_extra::extract::cookie::CookieJar; use maud::{html, Markup}; use serde::Deserialize; use std::collections::HashSet; use crate::{ + auth::nav_pseudonym, fetch::html::entity_section, form_template::template_json_compact, html::{ranking_panel_with_highlights, scope_theme_style, JsBuilder}, @@ -262,6 +264,7 @@ fn suggest_next( pub async fn vote_page( State(state): State, + jar: CookieJar, Query(q): Query, ) -> impl IntoResponse { let parent = parse_item_param(&q.parent); @@ -329,8 +332,9 @@ pub async fn vote_page( let path = format!("/vote?parent={}", urlencoding::encode(parent.as_str())); state.views.increment(path.clone()); let views = state.views.get_views(&path); + let nav_user = nav_pseudonym(state.projection_store.db(), &jar); - Html(layout(&title, body, views).into_string()).into_response() + Html(layout(&title, body, views, nav_user.as_deref()).into_string()).into_response() } #[cfg(test)] diff --git a/server/static/sorter.css b/server/static/sorter.css index e66a1e6c1acc473c8ff1ddb1e16e75a82d33741c..257280b6b490c65222e580a325b77351cac6cc6b 100644 --- a/server/static/sorter.css +++ b/server/static/sorter.css @@ -34,6 +34,47 @@ body { font-size: 0.75rem; } +.top-nav { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.top-nav a { + color: var(--muted); + text-decoration: none; +} + +.top-nav a:hover { + color: var(--fg); +} + +.top-nav-user { + color: var(--fg); + font-weight: 600; +} + +.top-nav-logout { + display: inline; + margin: 0; +} + +.top-nav-logout button { + background: none; + border: none; + padding: 0; + color: var(--muted); + font: inherit; + cursor: pointer; +} + +.top-nav-logout button:hover { + color: var(--fg); +} + .btn-primary { background: var(--accent); color: var(--accent-fg, #0f1115); diff --git a/test/support/harness.clj b/test/support/harness.clj index 3f05951f418258642dcacb4a10ccccc8bfbe8748..4505ece5aa193e826ca61c52f1467d252080d66b 100644 --- a/test/support/harness.clj +++ b/test/support/harness.clj @@ -43,6 +43,7 @@ "SORTER2_VIEWS_LOG" (str data-dir "/views.jsonl") "PORT" (str app-port) "SORTER2_BASE_URL" (str "http://127.0.0.1:" app-port) + "SORTER2_ALLOW_MOCK_OAUTH" "1" "GITHUB_CLIENT_ID" "test-client" "GITHUB_CLIENT_SECRET" "test-secret" "GITHUB_OAUTH_BASE" (str "http://127.0.0.1:" oauth-port)