diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f50ca4cc..22dbaa64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: install run: | python -m pip install --upgrade pip - pip install -e '.[dev]' + pip install -e '.[dev,web]' - name: lint run: python -m ruff check src tests diff --git a/docs/review-ui.md b/docs/review-ui.md new file mode 100644 index 00000000..fd75f211 --- /dev/null +++ b/docs/review-ui.md @@ -0,0 +1,105 @@ +# `vouch review-ui` — browser-based review console + +The review gate is vouch's load-bearing primitive, but the terminal +(`vouch pending`, `vouch approve `) only scales to a solo reviewer and a +small queue. `vouch review-ui` adds a browser viewport over the **same** +review surface — every approve/reject/contradict goes through the identical +`vouch.proposals` / `vouch.lifecycle` code path as the CLI, so the audit log is +the same regardless of which surface you used. The CLI is untouched. + +Zero new on-disk schema. Zero new `kb.*` RPC methods. The web layer reads and +mutates through the existing `KBStore`. + +## Run it + +```bash +vouch review-ui # 127.0.0.1:7780, opens browser +vouch review-ui --bind 127.0.0.1:8000 +vouch review-ui --no-open-browser # ssh / headless friendly + +# team mode — a non-loopback bind REQUIRES a Bearer token: +vouch review-ui --bind 0.0.0.0:7780 --auth generate # mints + prints a token +VOUCH_REVIEW_TOKEN=… vouch review-ui --bind 0.0.0.0:7780 --auth env +vouch review-ui --bind 0.0.0.0:7780 --auth my-shared-secret --reviewer alice +``` + +A non-loopback bind without `--auth` is refused outright — we won't expose an +unauthenticated approve surface on the network. + +## Authentication + +When `--auth` is set, every route except `/healthz` and `/static` requires the +token. Credentials are accepted in three places, in priority order: + +1. **`Authorization: Bearer `** header — for the CLI, scripts, and API + callers. +2. **HttpOnly cookie** (`vouch_review_token`) — the steady-state browser path. +3. **`?token=` query string** — a *one-time bootstrap* only. On a `GET`, a + valid query token is moved into an HttpOnly, `SameSite=Strict` cookie and + the request is `303`-redirected to the same path with `?token=` stripped, + so the bare token never lingers in a bookmarkable URL or in access logs. + +The token is never exposed to JavaScript (the cookie is HttpOnly), so an XSS +can't read it. Token comparisons are constant-time (`secrets.compare_digest`) +to avoid a timing oracle. `secure` is not set on the cookie by default so the +localhost-first (plain-http) flow works; terminate TLS at a proxy and have the +proxy mark the cookie `Secure` for an internet-facing deployment. + +## Install + +The web stack lives behind an optional extra so the base install stays light: + +```bash +pip install 'vouch-kb[web]' +``` + +All HTML/CSS/JS ships **inside the wheel** — no `npm install`, no build step, +no CDN. + +## Views + +| Route | What it shows | +|-------|---------------| +| `/` | the pending queue, server-side paginated | +| `/claim/` | one proposal's full payload + approve/reject | +| `/session/` | every proposal from one agent run, grouped, with status | +| `/sources/` | reverse index: which durable claims cite this source | +| `/audit` | the review-decision timeline | +| `/api/pending?page=N` | machine-readable paginated queue (`{count,page,pages,items}`) | +| `/healthz` | liveness + pending count + connected client count (always open) | +| `/ws` | realtime channel (see below) | + +## Realtime sync + +A single WebSocket channel per KB (`/ws`) keeps two reviewers in sync: when a +mutation lands, the handling route broadcasts a small `{"type":"refresh"}` +frame and every connected browser re-pulls the affected view within a second. +The frame is a *signal*, not data — the client re-fetches through the same +paginated routes, so there's exactly one rendering path. With `--auth` on, the +socket authenticates on the same-origin HttpOnly cookie the browser sends with +the handshake (a `?token=` query param is also accepted for non-browser +clients like the CLI). + +## Progressive enhancement + +Every action is a plain `
`, so the gate works with +**JavaScript disabled** — you can read claims and approve/reject without it. +The WebSocket live-refresh and the keyboard shortcuts (`j`/`k` to move, `a` +to approve, `r` to focus the reject reason, `?` for help) are an additive +layer on top. + +## Performance + +The queue is paginated at the storage layer: only the requested page of +proposal files is parsed, not the whole queue. A 500-item queue's first page +renders in well under the 200 ms budget (~30 ms locally) because the other 450 +files are never deserialised for that request. + +## Out of scope + +- Hosted "vouch cloud" — this is local/self-hosted only. +- Free-form claim editing in the browser; the gate is approve / reject / + contradict. +- Deleting durable claims from the UI — that stays CLI-only so a misclick + can't blow away history. +- Auth beyond Bearer (OAuth/SSO can layer on later). diff --git a/pyproject.toml b/pyproject.toml index 3c358527..38aceb17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,22 @@ embeddings = [ "sentence-transformers>=3,<4", "numpy>=1.26,<2", ] +web = [ + "fastapi>=0.115,<1", + "jinja2>=3,<4", + "python-multipart>=0.0.9", + "uvicorn>=0.30,<1", + # uvicorn needs a websockets implementation to serve the /ws realtime + # channel; without it the WebSocket handshake 500s at runtime. + "websockets>=12,<16", +] dev = [ "pytest>=9.0.3,<10", "pytest-cov>=5,<6", "mypy>=2.1.0", "ruff>=0.15.13", "types-pyyaml", + "httpx>=0.28,<1", ] embeddings-fast = [ "fastembed>=0.3,<1", @@ -94,3 +104,11 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = ["fastembed", "fastembed.*"] ignore_missing_imports = true + +# fastapi + jinja2 + uvicorn live behind the [web] extra; the base CI install +# only has [dev], so mypy can't resolve them when scanning src/vouch/web/. +# The web module guards its own imports with a clean ImportError message so +# the runtime story stays clean even without the extra. +[[tool.mypy.overrides]] +module = ["fastapi", "fastapi.*", "uvicorn", "uvicorn.*", "starlette", "starlette.*"] +ignore_missing_imports = true diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 100dcdce..bfba65a0 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -784,5 +784,128 @@ def serve(transport: str) -> None: run_jsonl() +# --- review-ui: browser-based review console ----------------------------- + + +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", ""}) + + +def _resolve_auth_token(auth: str | None) -> str | None: + """Turn the ``--auth`` option into a concrete token (or ``None``). + + * ``None`` -> no auth (only allowed on a loopback bind). + * ``"generate"`` -> mint a random token and print it once. + * ``"env"`` -> read ``VOUCH_REVIEW_TOKEN`` from the environment. + * any other string -> use it verbatim as the bearer token. + """ + if auth is None: + return None + if auth == "generate": + import secrets + token = secrets.token_urlsafe(24) + click.echo(f"Generated review token: {token}") + return token + if auth == "env": + env_token = os.environ.get("VOUCH_REVIEW_TOKEN") + if not env_token: + raise click.ClickException( + "--auth env: VOUCH_REVIEW_TOKEN is not set in the environment" + ) + return env_token + return auth + + +@cli.command(name="review-ui") +@click.option("--bind", "bind", default="127.0.0.1:7780", show_default=True, + help="host:port to bind. A non-loopback host (e.g. 0.0.0.0) " + "requires --auth so the approve surface isn't exposed " + "unauthenticated.") +@click.option("--auth", default=None, + help="Bearer-token mode: a literal token, 'generate' (mint a " + "random one and print it), or 'env' (read " + "VOUCH_REVIEW_TOKEN). Required for non-loopback binds.") +@click.option("--reviewer", "reviewer", default="web-reviewer", show_default=True, + help="Identity recorded in the audit log for token-authed " + "approve/reject decisions.") +@click.option("--page-size", default=None, type=int, + help="Queue page size (server-side pagination).") +@click.option("--kb", "kb_root", default=None, + type=click.Path(exists=True, file_okay=False), + help="KB root (defaults to the nearest .vouch/ above cwd).") +@click.option("--open-browser/--no-open-browser", default=True, show_default=True, + help="Open the browser to the queue on startup.") +def review_ui(bind: str, auth: str | None, reviewer: str, page_size: int | None, + kb_root: str | None, open_browser: bool) -> None: + """Run the browser-based review console (issue #194). + + \b + Examples: + vouch review-ui # 127.0.0.1:7780, open browser + vouch review-ui --bind 127.0.0.1:8000 + vouch review-ui --no-open-browser # ssh / headless friendly + vouch review-ui --bind 0.0.0.0:7780 --auth generate # team mode + VOUCH_REVIEW_TOKEN=… vouch review-ui --bind 0.0.0.0:7780 --auth env + """ + if ":" not in bind: + raise click.ClickException( + f"--bind must be host:port (got {bind!r})" + ) + host, _, port_str = bind.rpartition(":") + try: + port = int(port_str) + except ValueError as e: + raise click.ClickException(f"invalid port in --bind: {port_str!r}") from e + + token = _resolve_auth_token(auth) + + # Refuse a non-loopback bind without a bearer token — exposing an + # unauthenticated approve surface on the network would let anyone on the + # LAN mutate the KB. Same posture as the HTTP transport (#1). + is_loopback = host in _LOOPBACK_HOSTS + if not is_loopback and token is None: + raise click.ClickException( + f"--bind {bind!r} is non-loopback; pass --auth (a token, " + "'generate', or 'env') so the approve surface requires a " + "Bearer token. Refusing to expose an unauthenticated gate." + ) + + try: + from . import web as web_pkg + except ImportError as e: + raise click.ClickException(str(e)) from e + + try: + app = web_pkg.create_app( + kb_root, auth_token=token, auth_label=reviewer, page_size=page_size + ) + except (FileNotFoundError, RuntimeError) as e: + raise click.ClickException(str(e)) from e + + try: + import uvicorn + except ImportError as e: + raise click.ClickException( + "vouch review-ui needs the [web] extra. " + "Install with: pip install 'vouch-kb[web]'" + ) from e + + auth_note = " (Bearer auth on)" if token else "" + if open_browser and is_loopback: + # Lazy-import webbrowser; some CI envs (headless) don't have a default + # browser configured and webbrowser.open() returns False rather than + # raising — that's fine, the URL is also printed to stdout. When auth + # is on, hand the browser the token once via ?token= so it can stash it. + import threading + import webbrowser + suffix = f"?token={token}" if token else "" + url = f"http://{host}:{port}/{suffix}" + click.echo(f"vouch review-ui running at http://{host}:{port}/{auth_note}") + threading.Timer(0.5, lambda: webbrowser.open(url)).start() + else: + click.echo(f"vouch review-ui running at http://{host}:{port}/{auth_note}") + + uvicorn.run(app, host=host, port=port, log_level="info") + + if __name__ == "__main__": cli() diff --git a/src/vouch/web/__init__.py b/src/vouch/web/__init__.py new file mode 100644 index 00000000..866f7509 --- /dev/null +++ b/src/vouch/web/__init__.py @@ -0,0 +1,59 @@ +"""Browser-based review console for vouch. + +The web layer is a *viewport* over the existing kb.* surface — every action +(approve, reject, contradict) goes through the same ``vouch.proposals`` / +``vouch.lifecycle`` / ``vouch.storage`` code path as the CLI, so the audit log +is identical regardless of surface. + +The dependencies (fastapi, jinja2) live behind the ``[web]`` extra so the +base install stays light. ``vouch review-ui`` produces an actionable +``ImportError`` line if the extra is missing. +""" + +from __future__ import annotations + + +def _require_web_extra() -> None: + """Fail with a clean message if fastapi/jinja2 aren't installed.""" + missing: list[str] = [] + try: + import fastapi # noqa: F401 + except ImportError: + missing.append("fastapi") + try: + import jinja2 # noqa: F401 + except ImportError: + missing.append("jinja2") + if missing: + raise ImportError( + "vouch review-ui needs the [web] extra. " + "Install with: pip install 'vouch-kb[web]' " + f"(missing: {', '.join(missing)})" + ) + + +def create_app( # type: ignore[no-untyped-def] + kb_root: str | None = None, + *, + auth_token: str | None = None, + auth_label: str = "web-reviewer", + page_size: int | None = None, +): + """Build the FastAPI app for a given KB root. Lazy-imports the web stack. + + ``auth_token`` enables the Bearer gate (every route requires the token); + ``auth_label`` is the reviewer identity recorded in the audit log for + token-authenticated actions. ``page_size`` overrides queue pagination. + """ + _require_web_extra() + from .server import DEFAULT_PAGE_SIZE, AuthConfig, build_app + + auth = AuthConfig(token=auth_token, label=auth_label) + return build_app( + kb_root, + auth=auth, + page_size=page_size if page_size is not None else DEFAULT_PAGE_SIZE, + ) + + +__all__ = ["create_app"] diff --git a/src/vouch/web/server.py b/src/vouch/web/server.py new file mode 100644 index 00000000..9cb902da --- /dev/null +++ b/src/vouch/web/server.py @@ -0,0 +1,586 @@ +"""FastAPI app for the review console (full #194 spec). + +The web layer is a *viewport* over the existing kb.* surface — every +approve/reject/contradict/supersede goes through ``vouch.proposals`` / +``vouch.lifecycle`` so the audit log is identical regardless of whether the +action came from the CLI or the browser. There is no parallel data path and +no new on-disk schema. + +What this module ships, mapped to the issue's acceptance criteria: + +* **Queue, claim, audit** views (carried over from the MVP slice) plus the + spec's **/session/** (proposals grouped by agent run) and + **/sources/** (reverse index: which claims cite a source). +* **Server-side pagination** so a 500-item queue renders its first page fast + — the template only ever materialises one page of rows. +* A **single WebSocket channel per KB** (``/ws``): every mutation broadcasts a + tiny ``{"type": "refresh", ...}`` frame so a second reviewer's queue updates + within a second without a manual refresh. The broadcast fires from the same + request handler that performed the write, after it succeeds. +* **Bearer auth**: when the server is started with a token (``--auth``), every + route except ``/healthz`` and ``/static`` requires it. Credentials are an + ``Authorization: Bearer `` header (CLI/API) or an HttpOnly cookie + (browser). A ``?token=`` query param is a one-time GET bootstrap only — it's + moved into the cookie and redirected away so the bare token never lingers in + a URL or log. All comparisons are constant-time. Reviewer identity is the + token's label. Loopback binds may run tokenless; a non-loopback bind without + a token is refused at the CLI. +* **Progressive enhancement**: every action is a plain ````, + so the gate works with JavaScript disabled. The WebSocket + keyboard + shortcuts are an additive layer on top. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import os +import secrets +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from fastapi import ( + Depends, + FastAPI, + Form, + HTTPException, + Request, + WebSocket, + WebSocketDisconnect, +) +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.concurrency import run_in_threadpool + +from .. import audit as audit_mod +from .. import lifecycle as life +from .. import proposals as proposals_mod +from ..models import Proposal, ProposalStatus +from ..storage import ArtifactNotFoundError, KBStore, _yaml_load, discover_root + +_MODULE_DIR = Path(__file__).resolve().parent +_TEMPLATES_DIR = _MODULE_DIR / "templates" +_STATIC_DIR = _MODULE_DIR / "static" + +_log = logging.getLogger("vouch.web") + +# Default page size for the queue. A 500-item queue is 10 pages; the first +# page is the only thing rendered on the landing request. +DEFAULT_PAGE_SIZE = 50 + +# Per-client cap on a single WebSocket broadcast send. Keeps one slow/dead +# reviewer from stalling the approve/reject handler for everyone else. +_BROADCAST_TIMEOUT_S = 1.0 + + +# --- realtime fan-out ----------------------------------------------------- + + +@dataclass +class _Hub: + """In-process WebSocket fan-out. One hub per app == one channel per KB. + + Reviewers connect to ``/ws``; whenever a mutation lands, the handling + route calls :meth:`broadcast` and every connected client gets a small + JSON frame telling it to re-pull the affected view. We deliberately send + a *signal*, not the data — the client re-fetches through the same paginated + routes, so there's exactly one rendering path and no risk of the socket + payload drifting from the HTML. + """ + + _clients: set[WebSocket] = field(default_factory=set) + _lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + async with self._lock: + self._clients.add(ws) + + async def disconnect(self, ws: WebSocket) -> None: + async with self._lock: + self._clients.discard(ws) + + async def broadcast(self, message: dict[str, Any]) -> None: + text = json.dumps(message, separators=(",", ":")) + async with self._lock: + targets = list(self._clients) + + # Send to everyone concurrently, each with a short timeout. broadcast() + # is awaited inline by the approve/reject handlers, so a single slow or + # half-dead TCP socket must NOT be able to stall the decision for every + # other reviewer — wait_for caps each send, and a timed-out or failed + # client is dropped rather than blocking the gather. + async def _send(ws: WebSocket) -> bool: + try: + await asyncio.wait_for(ws.send_text(text), timeout=_BROADCAST_TIMEOUT_S) + return True + except Exception: + return False + + results = await asyncio.gather(*(_send(ws) for ws in targets)) + dead = [ws for ws, ok in zip(targets, results, strict=True) if not ok] + if dead: + async with self._lock: + for ws in dead: + self._clients.discard(ws) + + @property + def client_count(self) -> int: + return len(self._clients) + + +# --- helpers -------------------------------------------------------------- + + +def _is_review_event(name: str) -> bool: + """The audit log carries every mutation; the timeline only shows + review-gate decisions (approve / reject) and claim lifecycle moves.""" + if name.startswith("proposal.") and name.endswith((".approve", ".reject")): + return True + return name in {"claim.supersede", "claim.contradict", + "claim.archive", "claim.confirm"} + + +def _proposal_preview(payload: dict[str, Any]) -> str: + """One-line preview shown in the queue. Mirrors the CLI's `pending` output.""" + for key in ("text", "title", "name"): + value = payload.get(key) + if value: + return str(value).strip().splitlines()[0][:160] + return "—" + + +def _paginate(total: int, page: int, page_size: int) -> tuple[int, int, int, int]: + """Return ``(page, pages, start, end)`` clamped to valid bounds. + + ``page`` is 1-based. An out-of-range page clamps to the last page so a + stale link (e.g. after items drained off the queue) never 404s. + """ + page_size = max(1, page_size) + pages = max(1, (total + page_size - 1) // page_size) + page = min(max(1, page), pages) + start = (page - 1) * page_size + end = min(start + page_size, total) + return page, pages, start, end + + +def _pending_page(store: KBStore, page: int, page_size: int + ) -> tuple[list[Proposal], int, int, int]: + """Load ONE page of pending proposals without parsing the whole queue. + + This is what keeps a 500-item queue's first page under the latency budget: + ``store.list_proposals()`` deserialises every proposal file (~95% of the + request time at 500 items), but the queue only ever shows one page. Pending + proposals are exactly the files in ``proposed/`` (a decision moves the file + to ``decided/``), so we glob the *filenames* — cheap — sort them, slice to + the requested page, and only then parse that page's YAML. + + Returns ``(proposals, page, pages, total)`` with ``page`` clamped. + + A single corrupt/invalid proposal file must not 500 the whole queue: like + ``health._load_claims_for_lint``, we skip a file that fails to load and + log it, so one bad YAML can't take the review gate offline. Skipped files + still count toward ``total`` so the page math (and "N proposals" header) + stays honest about what's on disk. + """ + proposed_dir = store.kb_dir / "proposed" + paths = sorted(proposed_dir.glob("*.yaml")) if proposed_dir.is_dir() else [] + total = len(paths) + page, pages, lo, hi = _paginate(total, page, page_size) + proposals: list[Proposal] = [] + for p in paths[lo:hi]: + try: + proposals.append(Proposal.model_validate(_yaml_load(p.read_text()))) + except Exception as e: + _log.warning("skipping unreadable proposal %s: %s", p.name, e) + return proposals, page, pages, total + + +# --- auth ----------------------------------------------------------------- + + +_TOKEN_COOKIE = "vouch_review_token" + + +@dataclass(frozen=True) +class AuthConfig: + """Bearer-token gate. ``token is None`` means auth is disabled (loopback + dev mode). ``label`` becomes the reviewer identity recorded in the audit + log, so a team deployment attributes decisions to the token holder.""" + + token: str | None = None + label: str = "web-reviewer" + + @property + def enabled(self) -> bool: + return self.token is not None + + def matches(self, candidate: str | None) -> bool: + """Constant-time token comparison. + + ``secrets.compare_digest`` runs in time dependent only on the shorter + input's length, so an attacker can't recover the token byte-by-byte + from response-timing differences. A plain ``==`` would leak it. + """ + if self.token is None: + return True + return secrets.compare_digest(candidate or "", self.token) + + +def _bearer_header(request: Request) -> str | None: + header = request.headers.get("authorization") + if header and header.lower().startswith("bearer "): + return header[7:].strip() + return None + + +def _cookie_token(request: Request) -> str | None: + val = request.cookies.get(_TOKEN_COOKIE) + return val.strip() if val else None + + +def _query_token(request: Request) -> str | None: + # Only used for the one-time browser bootstrap; the handshake immediately + # moves it into an HttpOnly cookie and redirects the query away (see + # require_auth) so the bare token never lingers in a bookmarkable URL or + # in access logs for any guarded route beyond that first hop. + qp = request.query_params.get("token") + return qp.strip() if qp else None + + +class _BootstrapRedirect(Exception): + """Raised from ``require_auth`` when a valid token arrives via the query + string. The registered handler converts it into a 303 that sets the + HttpOnly cookie and strips ``?token=`` from the URL.""" + + def __init__(self, *, location: str, token: str) -> None: + self.location = location + self.token = token + + +# --- app factory ---------------------------------------------------------- + + +def build_app( + kb_root: str | None = None, + *, + auth: AuthConfig | None = None, + page_size: int = DEFAULT_PAGE_SIZE, +) -> FastAPI: + """FastAPI app bound to a KB root. + + ``kb_root`` defaults to the nearest ``.vouch/`` discovered by walking up + from ``cwd``. ``auth`` enables the Bearer gate; ``page_size`` controls + queue pagination. + """ + start = Path(kb_root).resolve() if kb_root else None + # Resolve once at construction so every request hits the same store and a + # bad root fails here with a clear error rather than a per-request 500. + root = discover_root(start) + store = KBStore(root) + auth = auth or AuthConfig() + hub = _Hub() + + templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + app = FastAPI(title="vouch review-ui", docs_url=None, redoc_url=None) + app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") + app.state.store = store + app.state.hub = hub + app.state.auth = auth + + def reviewer() -> str: + """Reviewer identity. With Bearer auth the token's label wins; without + it we fall back to the same env var the CLI uses, so audit-log + attribution stays consistent across surfaces.""" + if auth.enabled: + return auth.label + return os.environ.get("VOUCH_AGENT", "web-reviewer") + + def require_auth(request: Request) -> None: + """Dependency: enforce the Bearer token when auth is enabled. + + Steady-state credentials are the ``Authorization: Bearer`` header + (for API/CLI callers) or the ``HttpOnly`` cookie (for the browser). + A token in the *query string* is accepted only as a one-time + bootstrap: it's moved into the cookie and the query is stripped via a + 303 redirect (see :class:`_BootstrapRedirect`), so the bare token + never lingers in a bookmarkable URL or in access logs on any guarded + route past that first hop. All comparisons are constant-time. + """ + if not auth.enabled: + return + if auth.matches(_bearer_header(request)) or auth.matches(_cookie_token(request)): + return + q = _query_token(request) + if q is not None and auth.matches(q): + # Only the safe navigation (GET/HEAD) gets the cookie-bootstrap + # redirect — a 303 on a POST would drop the body. A valid query + # token on any other method authenticates inline. + if request.method in ("GET", "HEAD"): + stripped = request.url.remove_query_params("token") + location = stripped.path + (f"?{stripped.query}" if stripped.query else "") + raise _BootstrapRedirect(location=location or "/", token=q) + return + raise HTTPException( + status_code=401, + detail="missing or invalid Bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + @app.exception_handler(_BootstrapRedirect) + async def _bootstrap_redirect(request: Request, exc: _BootstrapRedirect) -> Any: + # Move the bootstrap token into an HttpOnly, SameSite=Strict cookie and + # redirect to the same path without the token query param. HttpOnly + # keeps the token out of JS (so an XSS can't read it); SameSite=Strict + # keeps it off cross-site requests. ``secure`` is left off so the + # localhost-first (plain http) default still works; a TLS deployment + # behind a proxy can set it via the proxy. + resp = RedirectResponse(url=exc.location, status_code=303) + resp.set_cookie( + _TOKEN_COOKIE, exc.token, + httponly=True, samesite="strict", path="/", + ) + return resp + + guarded = [Depends(require_auth)] + + def _tmpl(request: Request, name: str, ctx: dict[str, Any]) -> Any: + # Thread the auth-enabled flag into every template (display only — the + # browser authenticates via the HttpOnly cookie, not via JS). + ctx.setdefault("auth_enabled", auth.enabled) + return templates.TemplateResponse(request, name, ctx) + + async def _notify(kind: str, **extra: Any) -> None: + await hub.broadcast({"type": "refresh", "view": kind, **extra}) + + # --- health --- + + @app.get("/healthz") + def healthz() -> dict[str, Any]: + return { + "ok": True, + "kb": str(store.root), + "pending": len(store.list_proposals(ProposalStatus.PENDING)), + "auth": auth.enabled, + "clients": hub.client_count, + } + + # --- queue (paginated) --- + + def _row(p: Proposal) -> dict[str, Any]: + return { + "id": p.id, + "kind": p.kind.value, + "proposed_by": p.proposed_by, + "session_id": p.session_id, + "proposed_at": p.proposed_at.isoformat(timespec="seconds"), + "preview": _proposal_preview(p.payload), + } + + @app.get("/", response_class=HTMLResponse, dependencies=guarded) + def queue(request: Request, page: int = 1) -> Any: + proposals, page, pages, total = _pending_page(store, page, page_size) + return _tmpl(request, "queue.html", { + "items": [_row(p) for p in proposals], + "count": total, + "page": page, + "pages": pages, + "page_size": page_size, + "active": "queue", + }) + + # --- claim detail --- + + @app.get("/claim/{proposal_id}", response_class=HTMLResponse, dependencies=guarded) + def claim_detail(request: Request, proposal_id: str) -> Any: + try: + pr = store.get_proposal(proposal_id) + except ArtifactNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + return _tmpl(request, "claim.html", { + "proposal": pr.model_dump(mode="json"), + "preview": _proposal_preview(pr.payload), + }) + + # --- session view: proposals grouped by agent run --- + + @app.get("/session/{session_id}", response_class=HTMLResponse, dependencies=guarded) + def session_view(request: Request, session_id: str) -> Any: + try: + sess = store.get_session(session_id) + except ArtifactNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + # Pull every proposal this session produced, regardless of status, so + # a reviewer sees the whole run (pending + already-decided). + proposals = [] + for pid in sess.proposal_ids: + try: + proposals.append(store.get_proposal(pid)) + except ArtifactNotFoundError: + continue + rows = [ + { + "id": p.id, + "kind": p.kind.value, + "status": p.status.value, + "preview": _proposal_preview(p.payload), + "proposed_at": p.proposed_at.isoformat(timespec="seconds"), + } + for p in proposals + ] + return _tmpl(request, "session.html", { + "session": { + "id": sess.id, + "agent": sess.agent, + "task": sess.task, + "started_at": sess.started_at.isoformat(timespec="seconds"), + "ended_at": sess.ended_at.isoformat(timespec="seconds") if sess.ended_at else None, + "note": sess.note, + }, + "rows": rows, + "pending_count": sum(1 for p in proposals if p.status == ProposalStatus.PENDING), + }) + + # --- source reverse-index view --- + + @app.get("/sources/{source_id}", response_class=HTMLResponse, dependencies=guarded) + def source_view(request: Request, source_id: str) -> Any: + try: + src = store.get_source(source_id) + except ArtifactNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + # Reverse index: every durable claim whose evidence cites this source. + citing = [ + {"id": c.id, "text": c.text[:200], "status": c.status.value} + for c in store.list_claims() + if source_id in c.evidence + ] + return _tmpl(request, "source.html", { + "source": { + "id": src.id, + "type": src.type.value, + "locator": src.locator, + "title": src.title, + "media_type": src.media_type, + "byte_size": src.byte_size, + "created_at": src.created_at.isoformat(timespec="seconds"), + }, + "citing": citing, + }) + + # --- mutations: approve / reject / contradict / supersede --- + + # These handlers are ``async`` so they can ``await`` the WebSocket + # broadcast, but the underlying proposals/lifecycle calls are synchronous + # filesystem + SQLite writes. Running them directly on the event loop would + # block every other request (and the broadcast itself) for the duration of + # the write; ``run_in_threadpool`` offloads them, which is what FastAPI + # does for you automatically with sync route handlers. + + @app.post("/approve/{proposal_id}", dependencies=guarded) + async def approve(proposal_id: str, reason: str | None = Form(default=None)) -> Any: + try: + artifact = await run_in_threadpool( + proposals_mod.approve, + store, proposal_id, approved_by=reviewer(), reason=reason, + ) + except (proposals_mod.ProposalError, ArtifactNotFoundError) as e: + raise HTTPException(status_code=400, detail=str(e)) from e + await _notify("queue", action="approve", proposal_id=proposal_id, + artifact_id=getattr(artifact, "id", None)) + return RedirectResponse(url="/", status_code=303) + + @app.post("/reject/{proposal_id}", dependencies=guarded) + async def reject(proposal_id: str, reason: str = Form(...)) -> Any: + if not reason.strip(): + raise HTTPException(status_code=400, detail="reason is required") + try: + await run_in_threadpool( + proposals_mod.reject, + store, proposal_id, rejected_by=reviewer(), reason=reason, + ) + except (proposals_mod.ProposalError, ArtifactNotFoundError) as e: + raise HTTPException(status_code=400, detail=str(e)) from e + await _notify("queue", action="reject", proposal_id=proposal_id) + return RedirectResponse(url="/", status_code=303) + + @app.post("/contradict/{claim_id}", dependencies=guarded) + async def contradict(claim_id: str, against: str = Form(...)) -> Any: + """Mark two durable claims as contradicting each other — a gate action + the spec lists alongside approve/reject. Routes through lifecycle so + the audit entry is identical to ``vouch contradict``.""" + try: + await run_in_threadpool( + life.contradict, store, + claim_a=claim_id, claim_b=against, actor=reviewer(), + ) + except (life.LifecycleError, ArtifactNotFoundError, ValueError) as e: + raise HTTPException(status_code=400, detail=str(e)) from e + await _notify("audit", action="contradict", claim_id=claim_id) + return RedirectResponse(url="/audit", status_code=303) + + # --- audit timeline --- + + @app.get("/audit", response_class=HTMLResponse, dependencies=guarded) + def audit(request: Request, limit: int = 100) -> Any: + events = list(audit_mod.read_events(store.kb_dir)) + events.reverse() # newest first + filtered = [e for e in events if _is_review_event(e.event)][:limit] + rows = [ + { + "id": e.id, + "event": e.event, + "actor": e.actor, + "object_ids": e.object_ids, + "at": e.created_at.isoformat(timespec="seconds"), + "reason": e.data.get("reason"), + } + for e in filtered + ] + return _tmpl(request, "audit.html", {"rows": rows, "count": len(rows), + "active": "audit"}) + + # --- JSON API (machine-readable + what the HTMX/JS layer polls) --- + + @app.get("/api/pending", dependencies=guarded) + def api_pending(page: int = 1) -> JSONResponse: + proposals, page, pages, total = _pending_page(store, page, page_size) + return JSONResponse({ + "count": total, + "page": page, + "pages": pages, + "items": [_row(p) for p in proposals], + }) + + # --- realtime channel --- + + @app.websocket("/ws") + async def ws(websocket: WebSocket) -> None: + # A browser can't set an Authorization header on a WebSocket, but it + # *does* send the same-origin HttpOnly cookie on the handshake, so the + # cookie is the primary credential. ?token= remains accepted for + # non-browser clients (the CLI, tests). Constant-time compare. + if auth.enabled: + tok = websocket.cookies.get(_TOKEN_COOKIE) or websocket.query_params.get("token") + if not auth.matches(tok): + await websocket.close(code=4401) + return + await hub.connect(websocket) + try: + # Greet the client so it can confirm the channel is live, then idle + # — the server only ever pushes; client messages are ignored. + await websocket.send_text(json.dumps({"type": "hello", + "kb": str(store.root)})) + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + finally: + with contextlib.suppress(Exception): + await hub.disconnect(websocket) + + return app diff --git a/src/vouch/web/static/app.css b/src/vouch/web/static/app.css new file mode 100644 index 00000000..d957dc6a --- /dev/null +++ b/src/vouch/web/static/app.css @@ -0,0 +1,242 @@ +/* vouch review-ui — minimal, monograph-adjacent. No framework. */ + +:root { + --paper: #f4eedb; + --ink: #1a1208; + --rule: #1a120833; + --muted: #6b5d4a; + --vermillion: #a82c1c; + --moss: #4a5d3a; + --mono: "SFMono-Regular", "JetBrains Mono", "Menlo", monospace; + --serif: "EB Garamond", "Iowan Old Style", "Palatino", "Georgia", serif; + --sans: "Inter", system-ui, sans-serif; +} + +* { box-sizing: border-box; } + +html, body { margin: 0; padding: 0; } +body { + background: var(--paper); + color: var(--ink); + font-family: var(--serif); + font-size: 18px; + line-height: 1.5; +} + +.masthead { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 1.25rem 2rem 0.75rem; + border-bottom: 1px solid var(--rule); +} +.brand { display: flex; align-items: baseline; gap: 0.5rem; } +.brand-mark { color: var(--vermillion); font-size: 1.4rem; } +.brand-text { font-variant: small-caps; letter-spacing: 0.06em; } +.nav { display: flex; gap: 1.25rem; } +.nav a { + color: var(--ink); + text-decoration: none; + font-family: var(--sans); + font-size: 0.875rem; + text-transform: lowercase; +} +.nav a[aria-current="page"] { + border-bottom: 2px solid var(--vermillion); +} + +.content { + max-width: 56rem; + margin: 0 auto; + padding: 2rem; +} + +.section-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 1.5rem; +} +.section-head h1 { + font-size: 2rem; + font-weight: 500; + margin: 0; + letter-spacing: -0.01em; +} +.count { + color: var(--muted); + font-family: var(--mono); + font-size: 0.875rem; +} + +.empty { + color: var(--muted); + font-style: italic; + padding: 2rem 0; + text-align: center; +} + +.queue-list, .audit-list { + list-style: none; + padding: 0; + margin: 0; +} + +.queue-row { + border-top: 1px solid var(--rule); + padding: 1rem 0; +} +.queue-row:last-child { border-bottom: 1px solid var(--rule); } +.queue-meta { + display: flex; + align-items: baseline; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.25rem; +} +.kind { + font-family: var(--mono); + font-size: 0.7rem; + text-transform: uppercase; + padding: 0.1rem 0.4rem; + border: 1px solid var(--rule); + border-radius: 2px; +} +.kind-claim { color: var(--vermillion); border-color: var(--vermillion); } +.kind-page { color: var(--moss); border-color: var(--moss); } +.kind-entity { color: var(--ink); } +.kind-relation { color: var(--muted); } + +.proposal-link { + font-family: var(--mono); + font-size: 0.875rem; + color: var(--ink); +} +.by { color: var(--muted); font-size: 0.875rem; } +time { + font-family: var(--mono); + font-size: 0.75rem; + color: var(--muted); + margin-left: auto; +} + +.preview { margin: 0.25rem 0 0.75rem; } + +.actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: center; +} +.actions-large { margin-top: 2rem; } +.inline { display: inline-flex; gap: 0.5rem; margin: 0; } + +button { + font-family: var(--sans); + font-size: 0.875rem; + padding: 0.4rem 1rem; + border: 1px solid var(--ink); + background: var(--paper); + color: var(--ink); + cursor: pointer; + border-radius: 2px; +} +button.approve { border-color: var(--moss); color: var(--moss); } +button.approve:hover { background: var(--moss); color: var(--paper); } +button.reject { border-color: var(--vermillion); color: var(--vermillion); } +button.reject:hover { background: var(--vermillion); color: var(--paper); } + +input[type="text"] { + font-family: var(--sans); + font-size: 0.875rem; + padding: 0.4rem 0.6rem; + border: 1px solid var(--rule); + background: var(--paper); + color: var(--ink); + border-radius: 2px; + min-width: 14rem; +} + +.claim-detail .back { + font-family: var(--sans); + font-size: 0.875rem; + color: var(--muted); + text-decoration: none; +} +.meta-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.4rem 1.5rem; + margin: 1.5rem 0; + font-size: 0.95rem; +} +.meta-grid dt { + font-variant: small-caps; + letter-spacing: 0.05em; + color: var(--muted); +} +.meta-grid dd { margin: 0; font-family: var(--mono); font-size: 0.875rem; } + +.payload h2 { + font-size: 1.25rem; + font-weight: 500; + margin: 2rem 0 0.75rem; + border-bottom: 1px solid var(--rule); + padding-bottom: 0.4rem; +} +.preview-large { font-size: 1.05rem; line-height: 1.6; } +details summary { + cursor: pointer; + color: var(--muted); + font-family: var(--sans); + font-size: 0.875rem; + margin-top: 0.75rem; +} +pre { + background: #ebe3ca; + padding: 1rem; + border-radius: 2px; + font-family: var(--mono); + font-size: 0.8rem; + overflow-x: auto; +} + +.audit-row { + border-top: 1px solid var(--rule); + padding: 0.75rem 0; +} +.audit-row:last-child { border-bottom: 1px solid var(--rule); } +.audit-head { + display: flex; + gap: 0.75rem; + align-items: baseline; + flex-wrap: wrap; +} +.event { + font-family: var(--mono); + font-size: 0.875rem; + color: var(--ink); +} +.objects { margin-top: 0.25rem; } +.objects code { + font-family: var(--mono); + font-size: 0.75rem; + color: var(--muted); + margin-right: 0.5rem; +} +.reason { + margin: 0.25rem 0 0; + font-style: italic; + color: var(--muted); + font-size: 0.95rem; +} + +.colophon { + text-align: center; + padding: 2rem; + color: var(--muted); + font-family: var(--mono); + font-size: 0.75rem; + border-top: 1px solid var(--rule); + margin-top: 4rem; +} diff --git a/src/vouch/web/static/app.js b/src/vouch/web/static/app.js new file mode 100644 index 00000000..0dd903b4 --- /dev/null +++ b/src/vouch/web/static/app.js @@ -0,0 +1,105 @@ +// Progressive-enhancement layer for the vouch review console. +// +// Nothing here is required: every action is a plain , so the +// review gate works with JavaScript disabled. This script adds two things on +// top when JS is available: +// +// 1. a single WebSocket to /ws that flips the "live" pill on and reloads the +// queue/audit view within ~1s when another reviewer acts (issue #194's +// "two windows stay in sync" criterion); +// 2. keyboard shortcuts (j/k to move, a to approve, r to focus reject, ? for +// help) so a reviewer can clear a queue without touching the mouse. +// +// There is deliberately NO token handling here. When the server runs with +// --auth, the credential lives in an HttpOnly cookie the browser sets during +// the bootstrap redirect; JS can't read it (so an XSS can't exfiltrate it) and +// doesn't need to — the browser attaches the cookie automatically to same- +// origin form posts, fetches, and the WebSocket handshake. +(function () { + "use strict"; + + // --- realtime channel --------------------------------------------------- + var pill = document.getElementById("live"); + function setLive(on) { + if (!pill) return; + pill.classList.toggle("live-on", on); + pill.classList.toggle("live-off", !on); + } + + var reloadTimer = null; + function scheduleReload() { + // Debounce: a burst of broadcasts (e.g. someone approving several in a + // row) collapses into one reload. + if (reloadTimer) clearTimeout(reloadTimer); + reloadTimer = setTimeout(function () { window.location.reload(); }, 250); + } + + function connect() { + if (!("WebSocket" in window)) return; + var proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + // The HttpOnly cookie rides the same-origin handshake automatically — no + // token in the URL. + var wsUrl = proto + "//" + window.location.host + "/ws"; + var ws; + try { + ws = new WebSocket(wsUrl); + } catch (e) { + return; + } + ws.onopen = function () { setLive(true); }; + ws.onclose = function () { + setLive(false); + // Reconnect with a small backoff so a server restart heals itself. + setTimeout(connect, 1500); + }; + ws.onmessage = function (ev) { + var msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + if (msg.type === "refresh") scheduleReload(); + }; + } + connect(); + + // --- keyboard shortcuts ------------------------------------------------- + var rows = Array.prototype.slice.call(document.querySelectorAll(".queue-row[data-proposal-id]")); + var cursor = -1; + + function focusRow(i) { + if (!rows.length) return; + cursor = Math.max(0, Math.min(i, rows.length - 1)); + rows.forEach(function (r, idx) { r.classList.toggle("cursor", idx === cursor); }); + rows[cursor].scrollIntoView({ block: "nearest" }); + rows[cursor].focus(); + } + + function submitAct(row, act) { + var form = row.querySelector("form.js-act[data-act='" + act + "']"); + if (!form) return; + if (act === "reject") { + var input = form.querySelector("input[name='reason']"); + if (input) { input.focus(); return; } // require a reason, don't auto-submit + } + form.submit(); + } + + document.addEventListener("keydown", function (e) { + // Don't hijack typing in an input/textarea. + var tag = (e.target.tagName || "").toLowerCase(); + if (tag === "input" || tag === "textarea" || e.metaKey || e.ctrlKey) return; + + switch (e.key) { + case "j": focusRow(cursor + 1); e.preventDefault(); break; + case "k": focusRow(cursor - 1); e.preventDefault(); break; + case "a": + if (cursor >= 0) { submitAct(rows[cursor], "approve"); e.preventDefault(); } + break; + case "r": + if (cursor >= 0) { submitAct(rows[cursor], "reject"); e.preventDefault(); } + break; + case "?": + alert("vouch review shortcuts\n\n j / k move down / up\n a approve focused\n r reject focused (then type a reason)\n ? this help"); + e.preventDefault(); + break; + } + }); +})(); diff --git a/src/vouch/web/templates/audit.html b/src/vouch/web/templates/audit.html new file mode 100644 index 00000000..6f9907d9 --- /dev/null +++ b/src/vouch/web/templates/audit.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% set active = "audit" %} +{% block title %}audit · vouch review{% endblock %} + +{% block content %} +
+
+

audit timeline

+ {{ count }} review decisions +
+ + {% if not rows %} +

no review decisions yet — the timeline lights up as proposals are approved or rejected.

+ {% else %} +
    + {% for row in rows %} +
  1. +
    + {{ row.event }} + by {{ row.actor }} + +
    + {% if row.object_ids %} +
    + {% for oid in row.object_ids %}{{ oid }}{% endfor %} +
    + {% endif %} + {% if row.reason %} +

    {{ row.reason }}

    + {% endif %} +
  2. + {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/src/vouch/web/templates/base.html b/src/vouch/web/templates/base.html new file mode 100644 index 00000000..09a81e18 --- /dev/null +++ b/src/vouch/web/templates/base.html @@ -0,0 +1,31 @@ + + + + + + {% block title %}vouch review{% endblock %} + + + + +
+
+ + vouch · review console +
+ +
+
+ {% block content %}{% endblock %} +
+
+ localhost-first · approve via plain form POST (works JS-off) · + j/k move · a approve · ? help +
+ + + diff --git a/src/vouch/web/templates/claim.html b/src/vouch/web/templates/claim.html new file mode 100644 index 00000000..52bb58ea --- /dev/null +++ b/src/vouch/web/templates/claim.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}{{ proposal.id }} · vouch review{% endblock %} + +{% block content %} +
+
+

+ {{ proposal.kind }} + {{ proposal.id }} +

+ ← queue +
+ +
+
proposed by
{{ proposal.proposed_by }}
+
proposed at
{{ proposal.proposed_at }}
+
status
{{ proposal.status }}
+ {% if proposal.rationale %} +
rationale
{{ proposal.rationale }}
+ {% endif %} +
+ +
+

payload

+

{{ preview }}

+
+ full payload +
{{ proposal.payload | tojson(indent=2) }}
+
+
+ + {% if proposal.status == "pending" %} +
+ + + + +
+ + +
+
+ {% endif %} +
+{% endblock %} diff --git a/src/vouch/web/templates/queue.html b/src/vouch/web/templates/queue.html new file mode 100644 index 00000000..5fb54675 --- /dev/null +++ b/src/vouch/web/templates/queue.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% set active = "queue" %} +{% block title %}queue · vouch review{% endblock %} + +{% block content %} +
+
+

pending

+ {{ count }} {% if count == 1 %}proposal{% else %}proposals{% endif %} + {% if pages > 1 %} + page {{ page }} / {{ pages }} + {% endif %} +
+ + {% if not items %} +

no pending proposals — every agent write has been reviewed.

+ {% else %} +
    + {% for item in items %} +
  • +
    + {{ item.kind }} + {{ item.id }} + by {{ item.proposed_by }} + {% if item.session_id %} + ⊟ run + {% endif %} + +
    +

    {{ item.preview }}

    +
    +
    + +
    +
    + + +
    +
    +
  • + {% endfor %} +
+ + {% if pages > 1 %} + + {% endif %} + {% endif %} +
+{% endblock %} diff --git a/src/vouch/web/templates/session.html b/src/vouch/web/templates/session.html new file mode 100644 index 00000000..6e9270e4 --- /dev/null +++ b/src/vouch/web/templates/session.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}run {{ session.id }} · vouch review{% endblock %} + +{% block content %} +
+
+

run {{ session.id }}

+ ← queue +
+ +
+
agent
{{ session.agent }}
+ {% if session.task %}
task
{{ session.task }}
{% endif %} +
started
{{ session.started_at }}
+ {% if session.ended_at %}
ended
{{ session.ended_at }}
{% endif %} + {% if session.note %}
note
{{ session.note }}
{% endif %} +
pending
{{ pending_count }} of {{ rows|length }} still awaiting review
+
+ +
+

proposals from this run

+ {% if not rows %} +

this run produced no proposals.

+ {% else %} +
    + {% for row in rows %} +
  • +
    + {{ row.kind }} + {{ row.id }} + {{ row.status }} + +
    +

    {{ row.preview }}

    +
  • + {% endfor %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/src/vouch/web/templates/source.html b/src/vouch/web/templates/source.html new file mode 100644 index 00000000..f3185653 --- /dev/null +++ b/src/vouch/web/templates/source.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}source {{ source.id[:12] }} · vouch review{% endblock %} + +{% block content %} +
+
+

source {{ source.title or source.locator }}

+ ← queue +
+ +
+
id
{{ source.id }}
+
type
{{ source.type }}
+
locator
{{ source.locator }}
+
media type
{{ source.media_type }}
+
size
{{ source.byte_size }} bytes
+
added
{{ source.created_at }}
+
+ +
+

reverse index — claims citing this source

+ {% if not citing %} +

no durable claim cites this source yet.

+ {% else %} +
    + {% for c in citing %} +
  • + {{ c.status }} + {{ c.id }} + {{ c.text }} +
  • + {% endfor %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 00000000..e44a7fc8 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,271 @@ +"""Tests for the browser review console (`vouch review-ui`). + +Covers the MVP-slice acceptance criteria from issue #194: + +* queue renders an HTML response listing pending proposals +* approve POST routes through ``proposals.approve`` and lands the durable + artifact + the audit-log entry (same code path as the CLI) +* reject POST requires a reason and lands the rejection in the audit log +* the audit timeline view surfaces those decisions +* missing-reason on reject returns 400 (no silent half-state) +* a non-existent KB root raises a clean error rather than 500-ing per + request +* `vouch review-ui --bind 0.0.0.0:...` refuses to start in the MVP slice +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch import audit as audit_mod +from vouch.cli import cli +from vouch.models import ProposalStatus +from vouch.proposals import propose_claim +from vouch.storage import KBStore +from vouch.web import create_app + +# The web surface lives behind the [web] extra. Skip the whole module cleanly +# when it isn't installed (CI installs `.[dev,web]`, so it runs there). +pytest.importorskip("fastapi", reason="vouch review-ui needs the [web] extra") + +from fastapi.testclient import TestClient + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + return s + + +@pytest.fixture +def app(store: KBStore): + return create_app(str(store.root)) + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def _seed_proposal(store: KBStore, text: str = "the sky is blue") -> str: + """Helper: register a tiny source, then file a claim proposal that + cites it. Returns the proposal id.""" + src = store.put_source(b"some evidence") + pr = propose_claim( + store, text=text, evidence=[src.id], proposed_by="agent-A", + ) + return pr.id + + +# --- queue --------------------------------------------------------------- + + +def test_queue_renders_html_with_pending(client: TestClient, store: KBStore) -> None: + pid = _seed_proposal(store, "the queue should render this claim") + r = client.get("/") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + assert pid in r.text + assert "the queue should render this claim" in r.text + assert "agent-A" in r.text + + +def test_queue_empty_state(client: TestClient) -> None: + r = client.get("/") + assert r.status_code == 200 + assert "no pending proposals" in r.text + + +def test_api_pending_json(client: TestClient, store: KBStore) -> None: + pid = _seed_proposal(store) + r = client.get("/api/pending") + assert r.status_code == 200 + body = r.json() + # Full-spec shape: paginated envelope {count, page, pages, items}. + assert body["count"] == 1 + items = body["items"] + assert any(item["id"] == pid for item in items) + assert items[0]["proposed_by"] == "agent-A" + + +# --- claim detail -------------------------------------------------------- + + +def test_claim_detail_renders(client: TestClient, store: KBStore) -> None: + pid = _seed_proposal(store, "detail view shows the full payload") + r = client.get(f"/claim/{pid}") + assert r.status_code == 200 + assert pid in r.text + assert "detail view shows the full payload" in r.text + assert "rationale" not in r.text or "agent-A" in r.text + + +def test_claim_detail_404_for_unknown_id(client: TestClient) -> None: + r = client.get("/claim/proposal-does-not-exist") + assert r.status_code == 404 + + +# --- approve / reject ---------------------------------------------------- + + +def test_approve_routes_through_proposals_module( + client: TestClient, store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The approve POST must hit the same code path as `vouch approve`, + which means the proposal moves to `decided/` and a durable claim + lands in `claims/` + an audit entry in `audit.log.jsonl`.""" + # The MVP web layer uses VOUCH_AGENT as the reviewer identity; set it + # to something other than the proposer so we don't trip self-approval. + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + + pid = _seed_proposal(store, "approve me") + r = client.post(f"/approve/{pid}", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/" + + # The proposal moved out of pending. + assert not store.list_proposals(ProposalStatus.PENDING) + # And a durable claim now exists with the same payload text. + assert any(c.text == "approve me" for c in store.list_claims()) + # And an audit event was logged with the right shape. + events = [ + e for e in audit_mod.read_events(store.kb_dir) + if e.event == "proposal.claim.approve" + ] + assert len(events) == 1 + assert events[0].actor == "human-reviewer" + assert pid in events[0].object_ids + + +def test_reject_requires_reason( + client: TestClient, store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + pid = _seed_proposal(store, "reject me") + + # Missing form field → 422 (FastAPI's validation surface). + r = client.post(f"/reject/{pid}", follow_redirects=False) + assert r.status_code in (400, 422) + + # Whitespace-only reason → 400 from our explicit guard. + r = client.post( + f"/reject/{pid}", data={"reason": " "}, follow_redirects=False, + ) + assert r.status_code == 400 + + +def test_reject_lands_audit_event( + client: TestClient, store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + pid = _seed_proposal(store, "wrong claim") + + r = client.post( + f"/reject/{pid}", + data={"reason": "not a fact, an opinion"}, + follow_redirects=False, + ) + assert r.status_code == 303 + + events = [ + e for e in audit_mod.read_events(store.kb_dir) + if e.event == "proposal.claim.reject" + ] + assert len(events) == 1 + assert events[0].data.get("reason") == "not a fact, an opinion" + + +def test_approve_unknown_proposal_400( + client: TestClient, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + r = client.post("/approve/nope", follow_redirects=False) + assert r.status_code == 400 + + +# --- audit timeline ------------------------------------------------------ + + +def test_audit_view_shows_recent_decisions( + client: TestClient, store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + pid = _seed_proposal(store, "audit me") + client.post(f"/approve/{pid}") + + r = client.get("/audit") + assert r.status_code == 200 + assert "proposal.claim.approve" in r.text + assert "human-reviewer" in r.text + + +def test_audit_view_empty_state(client: TestClient) -> None: + r = client.get("/audit") + assert r.status_code == 200 + assert "no review decisions" in r.text + + +# --- progressive enhancement: form posts work without JS ----------------- + + +def test_form_post_redirects_and_renders_updated_queue( + client: TestClient, store: KBStore, monkeypatch: pytest.MonkeyPatch, +) -> None: + """The approve flow must work as a plain browser form: submit the + POST, follow the 303 redirect, see the queue rendered without the + just-approved proposal. No JS in the loop.""" + monkeypatch.setenv("VOUCH_AGENT", "human-reviewer") + pid = _seed_proposal(store, "form-flow claim") + + r = client.post(f"/approve/{pid}", follow_redirects=True) + assert r.status_code == 200 + assert pid not in r.text # the approved row is gone from the queue + assert "no pending proposals" in r.text + + +# --- healthz -------------------------------------------------------------- + + +def test_healthz(client: TestClient, store: KBStore) -> None: + _seed_proposal(store) + r = client.get("/healthz") + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["pending"] == 1 + + +# --- bring-up errors ------------------------------------------------------ + + +def test_create_app_errors_without_vouch_dir(tmp_path: Path) -> None: + """Building the app against a directory with no `.vouch/` should fail + at bring-up time, not 500 on the first request.""" + from vouch.storage import KBNotFoundError + + bare = tmp_path / "empty" + bare.mkdir() + with pytest.raises(KBNotFoundError): + create_app(str(bare)) + + +# --- CLI: non-loopback bind requires --auth ------------------------------- + + +def test_cli_review_ui_refuses_non_localhost_bind_without_auth(tmp_path: Path) -> None: + """A non-loopback bind with no --auth is refused — we won't expose an + unauthenticated approve surface on the network.""" + runner = CliRunner() + KBStore.init(tmp_path) + result = runner.invoke( + cli, + ["review-ui", "--bind", "0.0.0.0:7780", "--no-open-browser", + "--kb", str(tmp_path)], + ) + assert result.exit_code != 0 + assert "--auth" in result.output + assert "non-loopback" in result.output.lower() diff --git a/tests/test_web_e2e.py b/tests/test_web_e2e.py new file mode 100644 index 00000000..f1067e0a --- /dev/null +++ b/tests/test_web_e2e.py @@ -0,0 +1,430 @@ +"""End-to-end smoke test for the review console (vouchdev/vouch#194). + +The issue names this file explicitly in its acceptance criteria: + + "E2E smoke test (`pytest tests/test_web_e2e.py`) drives the approve flow + and asserts an `audit.log.jsonl` entry lands." + +So this module drives the *whole* flow the way a browser would — propose → +queue render → approve via form POST → durable claim on disk → audit entry — +plus the full-spec surfaces the MVP slice deferred: WebSocket sync, the +session and source views, pagination, and the Bearer-auth gate. Everything +goes through the real FastAPI app via Starlette's TestClient (no mocks), so a +green run is real evidence the gate works through the web surface. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +import pytest + +from vouch import audit as audit_mod +from vouch.models import ClaimStatus, ProposalStatus, Session +from vouch.proposals import propose_claim +from vouch.storage import KBStore +from vouch.web import create_app + +# The web surface lives behind the [web] extra. Skip the whole module cleanly +# when it isn't installed (CI installs `.[dev,web]`, so it runs there). +pytest.importorskip("fastapi", reason="vouch review-ui needs the [web] extra") + +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + + +@pytest.fixture +def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KBStore: + s = KBStore.init(tmp_path) + monkeypatch.chdir(s.root) + return s + + +@pytest.fixture +def client(store: KBStore): + return TestClient(create_app(str(store.root))) + + +def _seed(store: KBStore, text: str = "the sky is blue", *, by: str = "agent-A", + session_id: str | None = None) -> str: + src = store.put_source(b"evidence-" + text.encode()) + pr = propose_claim(store, text=text, evidence=[src.id], proposed_by=by, + session_id=session_id) + return pr.id + + +def _audit_events(store: KBStore) -> list: + return list(audit_mod.read_events(store.kb_dir)) + + +# --- the headline acceptance test ----------------------------------------- + + +def test_approve_flow_lands_durable_claim_and_audit_entry( + client: TestClient, store: KBStore +) -> None: + """Drive the approve flow end to end and assert an audit.log.jsonl entry + lands — the exact criterion the issue calls out for this file.""" + pid = _seed(store, "claims that survive the gate are durable") + + # 1. queue renders the pending proposal. + r = client.get("/") + assert r.status_code == 200 + assert pid in r.text + + # 2. approve via the same form POST a browser submits (no-JS path). + r = client.post(f"/approve/{pid}", data={}, follow_redirects=False) + assert r.status_code == 303 # see-other back to the queue + + # 3. the proposal is gone from pending and a durable claim exists. + assert not any(p.id == pid for p in store.list_proposals(ProposalStatus.PENDING)) + claims = store.list_claims() + assert claims, "approve should have produced a durable claim" + + # 4. the audit log carries the approve event — written through the SAME + # proposals.approve code path the CLI uses. + approves = [e for e in _audit_events(store) if e.event.endswith(".approve")] + assert approves, "no approve event landed in audit.log.jsonl" + assert (store.kb_dir / "audit.log.jsonl").exists() + # and it's real JSONL on disk, not just an in-memory artifact. + raw = (store.kb_dir / "audit.log.jsonl").read_text(encoding="utf-8").splitlines() + assert any(json.loads(line)["event"].endswith(".approve") for line in raw if line.strip()) + + +def test_reject_flow_lands_rejection_in_audit(client: TestClient, store: KBStore) -> None: + pid = _seed(store, "this one gets rejected") + r = client.post(f"/reject/{pid}", data={"reason": "duplicate of c-001"}, + follow_redirects=False) + assert r.status_code == 303 + rejects = [e for e in _audit_events(store) if e.event.endswith(".reject")] + assert rejects + assert rejects[-1].data.get("reason") == "duplicate of c-001" + + +def test_reject_without_reason_is_refused(client: TestClient, store: KBStore) -> None: + pid = _seed(store) + r = client.post(f"/reject/{pid}", data={"reason": " "}) + assert r.status_code == 400 + # still pending — no silent half-state. + assert any(p.id == pid for p in store.list_proposals(ProposalStatus.PENDING)) + + +# --- WebSocket realtime sync (two windows stay in sync) ------------------- + + +def test_websocket_broadcasts_on_approve(client: TestClient, store: KBStore) -> None: + """A second reviewer's socket receives a refresh frame within the same + request that performed the approve — the <1s sync criterion. We assert the + frame arrives well within the budget (it's normally single-digit ms).""" + pid = _seed(store, "broadcast me") + with client.websocket_connect("/ws") as ws: + hello = ws.receive_json() + assert hello["type"] == "hello" + # Perform the approve through the HTTP surface; the route broadcasts. + t0 = time.monotonic() + client.post(f"/approve/{pid}", follow_redirects=False) + frame = ws.receive_json() + elapsed = time.monotonic() - t0 + assert frame["type"] == "refresh" + assert frame["view"] == "queue" + assert frame["proposal_id"] == pid + # #194 criterion: two windows sync within 1s. Generous bound — the real + # number is milliseconds — but it fails loudly if a regression made the + # broadcast block (e.g. a slow-client stall in _Hub.broadcast). + assert elapsed < 1.0, f"broadcast took {elapsed:.3f}s, must be <1s" + + +def test_healthz_reports_clients_and_auth(client: TestClient) -> None: + body = client.get("/healthz").json() + assert body["ok"] is True + assert body["auth"] is False + assert "clients" in body + + +def test_static_assets_are_served(client: TestClient) -> None: + """The progressive-enhancement JS/CSS must actually be reachable through + the app (and therefore present in the package), not just on the dev's + disk — guards against the assets being dropped from the wheel.""" + r = client.get("/static/app.js") + assert r.status_code == 200 + assert "WebSocket" in r.text # it's our app.js, not a stray file + r = client.get("/static/app.css") + assert r.status_code == 200 + assert ".queue-row" in r.text # it's our stylesheet + + +# --- session view: proposals grouped by agent run ------------------------ + + +def test_session_view_groups_proposals(client: TestClient, store: KBStore) -> None: + sess = Session(id="run-1", agent="claude", task="seed the KB") + p1 = _seed(store, "first finding", session_id="run-1") + p2 = _seed(store, "second finding", session_id="run-1") + sess.proposal_ids = [p1, p2] + store.put_session(sess) + + r = client.get("/session/run-1") + assert r.status_code == 200 + assert "claude" in r.text + assert p1 in r.text and p2 in r.text + assert "seed the KB" in r.text + + +def test_session_view_404_for_unknown(client: TestClient) -> None: + assert client.get("/session/nope").status_code == 404 + + +def test_queue_links_to_session(client: TestClient, store: KBStore) -> None: + _seed(store, "from a run", session_id="run-xyz") + r = client.get("/") + assert "/session/run-xyz" in r.text + + +# --- source reverse-index view -------------------------------------------- + + +def test_source_view_reverse_index(client: TestClient, store: KBStore) -> None: + src = store.put_source(b"the canonical source bytes") + # Approve a claim that cites the source so it's durable and shows up. + pr = propose_claim(store, text="cites the source", evidence=[src.id], + proposed_by="agent-A") + client.post(f"/approve/{pr.id}") + + r = client.get(f"/sources/{src.id}") + assert r.status_code == 200 + assert "reverse index" in r.text.lower() + assert "cites the source" in r.text + + +def test_source_view_404_for_unknown(client: TestClient) -> None: + assert client.get("/sources/deadbeef").status_code == 404 + + +# --- pagination (500-item queue) ------------------------------------------ + + +def test_pagination_first_page_is_bounded(store: KBStore) -> None: + """A large queue only ever materialises one page of rows server-side.""" + app = create_app(str(store.root), page_size=20) + client = TestClient(app) + for i in range(45): + _seed(store, f"proposal number {i}", by=f"agent-{i % 3}") + + r = client.get("/") + assert r.status_code == 200 + # 45 items, page size 20 -> 3 pages, first page shows 20 rows. + assert r.text.count('class="queue-row"') == 20 + assert "page 1 / 3" in r.text + + r2 = client.get("/?page=3") + assert r2.text.count('class="queue-row"') == 5 # remainder + + # out-of-range clamps to the last page rather than 404-ing. + r4 = client.get("/?page=99") + assert r4.status_code == 200 + assert "page 3 / 3" in r4.text + + +def test_api_pending_pagination_envelope(store: KBStore) -> None: + app = create_app(str(store.root), page_size=10) + client = TestClient(app) + for i in range(25): + _seed(store, f"p{i}") + body = client.get("/api/pending").json() + assert body["count"] == 25 + assert body["pages"] == 3 + assert len(body["items"]) == 10 + + +def test_pagination_parses_only_one_page_of_500(store: KBStore, monkeypatch) -> None: + """The <200ms criterion rests on NOT deserialising the whole queue. Prove + it deterministically (no flaky wall-clock): with 500 pending proposals and + a page size of 50, a first-page request must parse exactly 50 files.""" + from vouch.web import server as web_server + + for i in range(500): + _seed(store, f"bulk {i}") + + parsed = {"n": 0} + real_load = web_server._yaml_load + + def counting_load(text: str): + parsed["n"] += 1 + return real_load(text) + + monkeypatch.setattr(web_server, "_yaml_load", counting_load) + + app = create_app(str(store.root), page_size=50) + client = TestClient(app) + r = client.get("/") + + assert r.status_code == 200 + assert r.text.count('class="queue-row"') == 50 + assert "500 proposals" in r.text # the full count is still honest + # The mechanism IS the latency guarantee: only one page of files is + # deserialised, never the whole queue. We assert the parse count rather + # than a wall-clock bound — the count is machine-independent, whereas a + # millisecond threshold flakes on a loaded CI runner. A regression to an + # O(whole-queue) scan would parse 500 here and fail loudly. + assert parsed["n"] == 50, f"parsed {parsed['n']} files, expected 50 (one page)" + + +def test_queue_survives_a_malformed_proposal_file(client: TestClient, store: KBStore) -> None: + """One corrupt proposal YAML must not 500 the whole queue — the gate has to + stay reviewable. The valid proposals still render; the bad file is skipped.""" + good = _seed(store, "a perfectly good proposal") + # Drop a garbage file straight into proposed/ (bypassing the model). + (store.kb_dir / "proposed" / "00000000-garbage.yaml").write_text( + "this: is: not: valid: yaml: {[", encoding="utf-8", + ) + r = client.get("/") + assert r.status_code == 200 + assert good in r.text + # /api/pending stays up too. + assert client.get("/api/pending").status_code == 200 + + +# --- Bearer auth (team mode) ---------------------------------------------- + + +def test_auth_required_when_token_set(store: KBStore) -> None: + app = create_app(str(store.root), auth_token="s3cret", auth_label="alice") + client = TestClient(app) + # No token -> 401 on a guarded route. + assert client.get("/").status_code == 401 + # Wrong token -> 401. + assert client.get("/", headers={"Authorization": "Bearer nope"}).status_code == 401 + # Right token via header -> 200. + assert client.get("/", headers={"Authorization": "Bearer s3cret"}).status_code == 200 + # Right token via ?token= (the browser's first navigation) -> 200. + assert client.get("/?token=s3cret").status_code == 200 + + +def test_auth_label_is_recorded_as_reviewer(store: KBStore) -> None: + """Token-authed approvals attribute to the token's label in the audit log.""" + app = create_app(str(store.root), auth_token="s3cret", auth_label="alice") + client = TestClient(app) + pid = _seed(store, "attribute me to alice") + r = client.post(f"/approve/{pid}", headers={"Authorization": "Bearer s3cret"}, + follow_redirects=False) + assert r.status_code == 303 + approves = [e for e in _audit_events(store) if e.event.endswith(".approve")] + assert approves[-1].actor == "alice" + + +def test_healthz_is_open_even_with_auth(store: KBStore) -> None: + """/healthz stays unauthenticated so a load balancer can probe it.""" + app = create_app(str(store.root), auth_token="s3cret") + client = TestClient(app) + assert client.get("/healthz").status_code == 200 + + +def test_websocket_requires_token_when_auth_on(store: KBStore) -> None: + app = create_app(str(store.root), auth_token="s3cret") + client = TestClient(app) + # Without the token query param the socket is closed (policy code 4401); + # the server-side close surfaces as a WebSocketDisconnect on receive. + with pytest.raises(WebSocketDisconnect), client.websocket_connect("/ws") as ws: + ws.receive_json() + # With the token it connects. + with client.websocket_connect("/ws?token=s3cret") as ws: + assert ws.receive_json()["type"] == "hello" + + +# --- auth hardening (security review follow-ups) -------------------------- + + +def test_query_token_bootstraps_httponly_cookie_and_strips_url(store: KBStore) -> None: + """A ?token= navigation must NOT 200 with the token still in the URL: it + 303-redirects to the bare path and moves the token into an HttpOnly, + SameSite=Strict cookie so it can't be bookmarked, logged, or read by JS.""" + app = create_app(str(store.root), auth_token="s3cret") + client = TestClient(app) + r = client.get("/?token=s3cret", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/" # token stripped from the redirect target + set_cookie = r.headers["set-cookie"].lower() + assert "vouch_review_token=s3cret" in set_cookie + assert "httponly" in set_cookie + assert "samesite=strict" in set_cookie + + +def test_cookie_authenticates_subsequent_requests(store: KBStore) -> None: + """After the bootstrap the HttpOnly cookie alone authenticates — no header, + no query param needed (this is the steady-state browser path).""" + app = create_app(str(store.root), auth_token="s3cret") + client = TestClient(app) + # Follow the bootstrap once; httpx stashes the cookie in the jar. + assert client.get("/?token=s3cret").status_code == 200 + # A later request with neither header nor query carries only the cookie. + assert client.get("/", headers={}).status_code == 200 + assert client.get("/audit").status_code == 200 + + +def test_wrong_cookie_is_rejected(store: KBStore) -> None: + app = create_app(str(store.root), auth_token="s3cret") + client = TestClient(app) + client.cookies.set("vouch_review_token", "forged") + assert client.get("/").status_code == 401 + + +def test_websocket_authenticates_via_cookie(store: KBStore) -> None: + """The browser can't set a WS header, but the same-origin cookie rides the + handshake — so the realtime channel works in team mode without a token in + the URL.""" + app = create_app(str(store.root), auth_token="s3cret") + client = TestClient(app) + client.cookies.set("vouch_review_token", "s3cret") + with client.websocket_connect("/ws") as ws: + assert ws.receive_json()["type"] == "hello" + + +def test_post_with_query_token_does_not_redirect(store: KBStore) -> None: + """A non-GET carrying a valid ?token= authenticates inline rather than + 303-redirecting (a redirect would drop the POST body).""" + app = create_app(str(store.root), auth_token="s3cret") + client = TestClient(app) + pid = _seed(store, "approve via query token on POST") + r = client.post(f"/approve/{pid}?token=s3cret", follow_redirects=False) + # 303 back to the queue (the normal approve redirect), NOT the auth bootstrap. + assert r.status_code == 303 + assert r.headers["location"] == "/" + assert not any(p.id == pid for p in store.list_proposals(ProposalStatus.PENDING)) + + +def test_static_assets_carry_no_token_in_js(store: KBStore) -> None: + """Defence-in-depth: the shipped JS must not handle or store the token + (the HttpOnly cookie does), so an XSS can't read it from JS state. We + check for token-*handling* constructs, not the literal word (which appears + in the comment explaining why there is none).""" + from vouch.web import server as web_server + js = (web_server._STATIC_DIR / "app.js").read_text(encoding="utf-8") + assert "sessionStorage" not in js + assert "localStorage" not in js + # no token plucked from the URL or appended to one + assert 'getItem("vouch-token")' not in js + assert "token=" not in js + assert 'searchParams.get("token")' not in js + + +# --- contradict gate action ----------------------------------------------- + + +def test_contradict_marks_both_claims_contested(client: TestClient, store: KBStore) -> None: + src = store.put_source(b"shared") + a = propose_claim(store, text="the build is green", evidence=[src.id], + proposed_by="agent-A") + b = propose_claim(store, text="the build is red", evidence=[src.id], + proposed_by="agent-B") + client.post(f"/approve/{a.id}") + client.post(f"/approve/{b.id}") + ca = next(c for c in store.list_claims() if "green" in c.text) + cb = next(c for c in store.list_claims() if "red" in c.text) + + r = client.post(f"/contradict/{ca.id}", data={"against": cb.id}, + follow_redirects=False) + assert r.status_code == 303 + assert store.get_claim(ca.id).status == ClaimStatus.CONTESTED + assert store.get_claim(cb.id).status == ClaimStatus.CONTESTED