Skip to content

feat(web): vouch review-ui — browser-based review console (mvp slice)#195

Open
plind-junior wants to merge 2 commits into
mainfrom
feat/194-review-ui
Open

feat(web): vouch review-ui — browser-based review console (mvp slice)#195
plind-junior wants to merge 2 commits into
mainfrom
feat/194-review-ui

Conversation

@plind-junior

@plind-junior plind-junior commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

mvp slice of #194: a fastapi + jinja viewport over the existing proposals + audit surface. ships behind a new [web] optional extra; localhost-only. every approve / reject routes through vouch.proposals.approve / vouch.proposals.reject, so the audit-log entry is identical regardless of whether the action came from vouch approve (cli) or the browser. closes the part of #194 that doesn't depend on the http-transport (#1) or multi-dim-scopes (#2) work.

What changed

  • src/vouch/web/__init__.py — package shim, raises a clean ImportError line if the [web] extra is missing.
  • src/vouch/web/server.py — fastapi app factory + routes (/, /claim/<id>, POST /approve/<id>, POST /reject/<id>, /audit, /api/pending, /healthz). audit-log writes go through the shared proposals.* code path; no parallel data path.
  • src/vouch/web/templates/{base,queue,claim,audit}.html — jinja templates. no js framework; plain form-post approve/reject works without javascript.
  • src/vouch/web/static/app.css — hand-rolled monograph-adjacent styling, single sheet, no tailwind.
  • src/vouch/cli.pyvouch review-ui subcommand. binds 127.0.0.1:7780 by default, refuses any non-loopback bind in this mvp slice (bearer-auth lands alongside chore(deps): bump actions/setup-python from 5 to 6 #1).
  • pyproject.toml — adds the [web] extra (fastapi, jinja2, python-multipart, uvicorn); httpx added to [dev] so the test client works in ci; mypy overrides for the new optional imports.
  • tests/test_web.py — 15 tests: queue render, empty state, claim detail, 404 path, approve round-trip (asserts the durable claim lands + the proposal.claim.approve audit entry), reject lands proposal.claim.reject with reason, missing-reason rejected, plain-form-post flow without js, audit timeline view, healthz, bring-up error if no .vouch/, and --bind 0.0.0.0:... is refused.

Why

the review gate is vouch's load-bearing primitive but only the terminal can drive it today; that doesn't scale past a single reviewer or a ~20-item queue. this gives reviewers a browser-shaped surface without rebuilding any of the underlying gate logic — same code path, same audit entries, same forbidden-self-approval guard.

scope was deliberately kept small (no websocket, no bearer-auth, no spa) because the full design depends on transport features that haven't shipped. follow-ups stack cleanly on top of this slice.

Tests

$ .venv/bin/python -m pytest tests/ -q --ignore=tests/embeddings
.........................sssss.......................................... [ 67%]
..................................                                       [100%]

$ .venv/bin/python -m mypy src
Success: no issues found in 31 source files

$ .venv/bin/python -m ruff check src tests
All checks passed!

wheel build confirmed the templates + static dir ship in vouch_kb-0.1.0-py3-none-any.whl.

Not included (follow-ups)

Closes #194 (mvp slice — follow-ups tracked in subsequent PRs).

Summary by CodeRabbit

  • New Features

    • Added vouch review-ui command and browser-based review console for managing proposals
    • Review UI: queue of pending proposals, proposal detail view with full payload, approve/reject actions, and audit timeline showing reviewer identity
  • Chores

    • Added a web optional dependency group and expanded dev extras for web/testing
    • Updated type-checking config to ignore missing web-related imports
  • Tests

    • Added integration tests covering UI pages, approve/reject flows, audit, and CLI bind validation

mvp slice of #194: a fastapi + jinja viewport over the existing
proposals + audit surface. every approve / reject goes through
proposals.approve / proposals.reject so the audit-log entry is
identical to the cli code path. localhost-only; bearer-auth +
websocket sync land alongside the http-transport feature.

ships behind the [web] optional extra so the base install stays
lean. wheel includes the templates and static directory.
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 36f5540e-73be-4594-b153-68b66eaf5197

📥 Commits

Reviewing files that changed from the base of the PR and between f4c5c64 and 21432e8.

📒 Files selected for processing (1)
  • pyproject.toml
🚧 Files skipped from review as they are similar to previous changes (1)
  • pyproject.toml

📝 Walkthrough

Walkthrough

Adds a localhost-only browser review console: a new vouch review-ui CLI starts a FastAPI app that renders queues, claim details, approve/reject flows, audit timeline, a JSON pending API, templates, CSS, mypy overrides, and integration tests.

Changes

Review Console Web Layer

Layer / File(s) Summary
Dependencies and tooling setup
pyproject.toml
Project configuration adds web extra with fastapi, jinja2, python-multipart, uvicorn; extends dev with httpx; adds mypy overrides for web deps.
CLI command entry point
src/vouch/cli.py
New review-ui command validates localhost-only --bind, loads and creates the FastAPI app, checks for uvicorn, optionally opens the browser, and starts the ASGI server.
Web app factory and dependency validation
src/vouch/web/__init__.py
Provides create_app() that enforces web extra presence (reports missing packages) and delegates to build_app.
FastAPI server and request handlers
src/vouch/web/server.py
build_app() resolves KB root, constructs KBStore, configures Jinja2 and static files, and registers routes: GET /healthz, GET /, GET /claim/{id}, POST /approve/{id}, POST /reject/{id}, GET /audit, and GET /api/pending.
Jinja2 templates for UI views
src/vouch/web/templates/*
Base layout, queue, claim detail, and audit templates render HTML views and form workflows for the review console.
CSS styling and design system
src/vouch/web/static/app.css
Theme variables, layout/masthead, queue/audit components, badges, action buttons, form controls, payload presentation, code blocks, and colophon styling.
Integration test suite
tests/test_web.py
Covers queue/claim rendering, approve/reject end-to-end flows with audit assertions, audit timeline, health check, create_app bring-up errors, and CLI bind validation.

Sequence Diagram

sequenceDiagram
  participant Browser
  participant FastAPI
  participant KBStore
  participant ProposalsMod
  participant AuditLog
  Browser->>FastAPI: GET /
  FastAPI->>KBStore: load pending proposals
  FastAPI->>Browser: render queue.html
  Browser->>FastAPI: POST /approve/{id}
  FastAPI->>ProposalsMod: approve(proposal_id, reviewer)
  ProposalsMod->>KBStore: update proposal status
  ProposalsMod->>AuditLog: write review gate event
  FastAPI->>Browser: redirect to /
  Browser->>FastAPI: GET /audit
  FastAPI->>AuditLog: read review events
  FastAPI->>Browser: render audit.html
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • vouchdev/vouch#111: Approval route interactions and proposals.approve refactor that affect web approve behavior.

Poem

🐇 I hopped to a repo and found a new view,
A tiny FastAPI console for reviewers like you.
Queues, claims, and audits served up with care,
Forms that still work when JavaScript's not there.
Localhost-only, simple, and spry — a rabbit's review debut.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(web): vouch review-ui — browser-based review console (mvp slice)' directly and clearly describes the main change: adding a browser-based review console as a web feature, with the MVP scope explicitly noted.
Linked Issues check ✅ Passed The PR implements all primary coding objectives from #194: FastAPI + Jinja2 web layer with queue/claim/audit views, form-post approve/reject routing through existing code paths, CLI command, optional [web] extra, localhost-only enforcement, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes align with #194 scope: web views, CLI command, optional dependencies, and tests. Out-of-scope items (WebSocket sync, bearer-auth, additional views, keyboard shortcuts) are correctly deferred; no unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/194-review-ui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
pyproject.toml (1)

34-34: ⚡ Quick win

Consider adding an upper version bound for python-multipart.

The dependency python-multipart>=0.0.9 has no upper bound, which may introduce breaking changes if the library releases a new major version. Consider constraining it similarly to the other web dependencies, e.g., python-multipart>=0.0.9,<1.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyproject.toml` at line 34, The dependency declaration for python-multipart
lacks an upper bound; update the pyproject.toml dependency entry for
"python-multipart" to add a safe upper bound (e.g., change
"python-multipart>=0.0.9" to "python-multipart>=0.0.9,<1") so it follows the
same version constraint pattern as the other web dependencies and prevents
unexpected breaking changes from a future major release.
src/vouch/web/__init__.py (1)

15-31: ⚡ Quick win

Consider checking for python-multipart in _require_web_extra.

The function validates fastapi and jinja2, but python-multipart is also a required dependency for FastAPI's Form handling (used in the approve/reject endpoints). If it's missing, users will encounter a less clear runtime error when posting forms. Adding a check here would provide earlier, clearer feedback.

♻️ Proposed addition
     try:
         import jinja2  # noqa: F401
     except ImportError:
         missing.append("jinja2")
+    try:
+        import multipart  # noqa: F401
+    except ImportError:
+        missing.append("python-multipart")
     if missing:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/web/__init__.py` around lines 15 - 31, The _require_web_extra
function currently checks for fastapi and jinja2 but not python-multipart, which
FastAPI needs for Form handling in the approve/reject endpoints; add a
try/except ImportError block in _require_web_extra that attempts to import
multipart (e.g. import multipart  # noqa: F401) and on ImportError append
"python-multipart" (or "python-multipart (multipart)") to the missing list so
the raised ImportError message will list that dependency as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pyproject.toml`:
- Around line 105-111: The mypy overrides block in pyproject.toml omits jinja2,
so mypy fails when scanning src/vouch/web/__init__.py; update the
[[tool.mypy.overrides]] entry that currently lists ["fastapi", "fastapi.*",
"uvicorn", "uvicorn.*", "starlette", "starlette.*"] to also include "jinja2" and
"jinja2.*" (i.e., add jinja2 to the module list for the mypy overrides) so
missing-imports for the templating package are ignored during type checking.

In `@src/vouch/cli.py`:
- Around line 808-828: The host allowlist check fails for bracketed IPv6
literals like "[::1]:7780"; after splitting bind into host and port_str (the
variables in this snippet), strip surrounding brackets from host if host starts
with "[" and ends with "]" (i.e., host = host[1:-1]) before comparing against
("127.0.0.1", "localhost", "::1") so bracketed IPv6 localhost is accepted.

In `@src/vouch/web/server.py`:
- Around line 42-46: _update the _whoami function to match the CLI's fallback
chain: use VOUCH_AGENT if set, otherwise VOUCH_USER, and finally fall back to
the system user via getpass.getuser(); locate the _whoami() function in
server.py and replace the current single-env fallback ("web-reviewer") with the
same sequence used by the CLI (VOUCH_AGENT or VOUCH_USER or getpass.getuser())
so audit-log attribution is consistent across web and CLI surfaces.

In `@src/vouch/web/templates/queue.html`:
- Around line 26-32: The approve/reject POST forms lack CSRF protection; add a
server-generated CSRF token to the template forms (e.g. include a hidden input
named csrf_token inside the forms that submit to /approve/{{ item.id }} and
/reject/{{ item.id }}) and ensure the POST handlers that process these routes
(the approve and reject request handlers) validate the token on every
state-changing request (or alternatively perform strict Origin/Referer checks)
before performing the action; update the template rendering code to supply the
token (e.g. csrf_token) and update the approve/reject handler functions to
reject requests with missing/invalid tokens.

In `@tests/test_web.py`:
- Line 96: The assertion uses a weak OR that often passes; replace it with a
concrete check for the rationale marker from the seeded proposal by asserting
the exact rationale key or value in the response body (e.g., assert
'"rationale":' and/or the seeded rationale string is present or absent as
appropriate). Locate the assertion that inspects r.text (the response object
named r) and change it to assert the specific rationale JSON key/value for the
seeded proposal (or assert its absence) so the test verifies rationale rendering
deterministically.

---

Nitpick comments:
In `@pyproject.toml`:
- Line 34: The dependency declaration for python-multipart lacks an upper bound;
update the pyproject.toml dependency entry for "python-multipart" to add a safe
upper bound (e.g., change "python-multipart>=0.0.9" to
"python-multipart>=0.0.9,<1") so it follows the same version constraint pattern
as the other web dependencies and prevents unexpected breaking changes from a
future major release.

In `@src/vouch/web/__init__.py`:
- Around line 15-31: The _require_web_extra function currently checks for
fastapi and jinja2 but not python-multipart, which FastAPI needs for Form
handling in the approve/reject endpoints; add a try/except ImportError block in
_require_web_extra that attempts to import multipart (e.g. import multipart  #
noqa: F401) and on ImportError append "python-multipart" (or "python-multipart
(multipart)") to the missing list so the raised ImportError message will list
that dependency as well.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ccca1784-3ec1-4398-9ad3-a486a478708c

📥 Commits

Reviewing files that changed from the base of the PR and between 3beb821 and f4c5c64.

📒 Files selected for processing (10)
  • pyproject.toml
  • src/vouch/cli.py
  • src/vouch/web/__init__.py
  • src/vouch/web/server.py
  • src/vouch/web/static/app.css
  • src/vouch/web/templates/audit.html
  • src/vouch/web/templates/base.html
  • src/vouch/web/templates/claim.html
  • src/vouch/web/templates/queue.html
  • tests/test_web.py

Comment thread pyproject.toml
Comment thread src/vouch/cli.py
Comment on lines +808 to +828
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

# The full design (issue #194) refuses 0.0.0.0 without --auth bearer.
# The MVP slice does not ship the Bearer-auth layer yet (depends on
# the HTTP-transport feature), so we refuse non-localhost binds
# outright — a clearer error than silently exposing an unauthenticated
# approve surface on the network.
if host not in ("127.0.0.1", "localhost", "::1"):
raise click.ClickException(
f"--bind {bind!r}: review-ui is localhost-only in the MVP slice. "
"Bearer-auth + non-loopback binding land alongside the HTTP "
"transport feature."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle IPv6 localhost addresses with brackets.

The current parsing uses rpartition(":") which correctly extracts the port, but IPv6 addresses may be provided with brackets (e.g., [::1]:7780). In that case, host would be "[::1]" (with brackets) and would not match the allowlist check at line 823 that expects "::1".

🛡️ Proposed fix to strip brackets from IPv6 addresses
     host, _, port_str = bind.rpartition(":")
+    # Strip brackets from IPv6 addresses like [::1]:7780
+    if host.startswith("[") and host.endswith("]"):
+        host = host[1:-1]
     try:
         port = int(port_str)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/cli.py` around lines 808 - 828, The host allowlist check fails for
bracketed IPv6 literals like "[::1]:7780"; after splitting bind into host and
port_str (the variables in this snippet), strip surrounding brackets from host
if host starts with "[" and ends with "]" (i.e., host = host[1:-1]) before
comparing against ("127.0.0.1", "localhost", "::1") so bracketed IPv6 localhost
is accepted.

Comment thread src/vouch/web/server.py
Comment on lines +42 to +46
def _whoami() -> str:
"""Reviewer identity. Without the Bearer-auth layer from #1 this falls
back to the same env var the CLI uses, so audit-log attribution stays
consistent across surfaces."""
return os.environ.get("VOUCH_AGENT", "web-reviewer")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make _whoami() consistent with the CLI version for audit-log attribution.

The comment states that reviewer identity should match the CLI behavior for consistent audit logs, but the implementation differs:

  • CLI (lines 75-84 in cli.py): VOUCH_AGENT or VOUCH_USER or getpass.getuser()
  • Web (line 46): VOUCH_AGENT or fallback "web-reviewer"

If VOUCH_USER is set but VOUCH_AGENT is not, the CLI will use VOUCH_USER while the web UI will use "web-reviewer", breaking attribution consistency.

🔧 Proposed fix to match CLI behavior
+import getpass
+
 def _whoami() -> str:
     """Reviewer identity. Without the Bearer-auth layer from `#1` this falls
     back to the same env var the CLI uses, so audit-log attribution stays
     consistent across surfaces."""
-    return os.environ.get("VOUCH_AGENT", "web-reviewer")
+    return (
+        os.environ.get("VOUCH_AGENT")
+        or os.environ.get("VOUCH_USER")
+        or getpass.getuser()
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _whoami() -> str:
"""Reviewer identity. Without the Bearer-auth layer from #1 this falls
back to the same env var the CLI uses, so audit-log attribution stays
consistent across surfaces."""
return os.environ.get("VOUCH_AGENT", "web-reviewer")
import getpass
def _whoami() -> str:
"""Reviewer identity. Without the Bearer-auth layer from `#1` this falls
back to the same env var the CLI uses, so audit-log attribution stays
consistent across surfaces."""
return (
os.environ.get("VOUCH_AGENT")
or os.environ.get("VOUCH_USER")
or getpass.getuser()
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/web/server.py` around lines 42 - 46, _update the _whoami function
to match the CLI's fallback chain: use VOUCH_AGENT if set, otherwise VOUCH_USER,
and finally fall back to the system user via getpass.getuser(); locate the
_whoami() function in server.py and replace the current single-env fallback
("web-reviewer") with the same sequence used by the CLI (VOUCH_AGENT or
VOUCH_USER or getpass.getuser()) so audit-log attribution is consistent across
web and CLI surfaces.

Comment on lines +26 to +32
<form method="post" action="/approve/{{ item.id }}" class="inline">
<button type="submit" class="approve">approve</button>
</form>
<form method="post" action="/reject/{{ item.id }}" class="inline">
<input type="text" name="reason" placeholder="reason (required)" required>
<button type="submit" class="reject">reject</button>
</form>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add CSRF protection for approve/reject form posts.

At Line 26 and Line 29, these state-changing POST forms can be triggered cross-site from a malicious page targeting localhost. Please enforce CSRF protection (token validation and/or strict Origin/Referer checks) before accepting approve/reject actions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/web/templates/queue.html` around lines 26 - 32, The approve/reject
POST forms lack CSRF protection; add a server-generated CSRF token to the
template forms (e.g. include a hidden input named csrf_token inside the forms
that submit to /approve/{{ item.id }} and /reject/{{ item.id }}) and ensure the
POST handlers that process these routes (the approve and reject request
handlers) validate the token on every state-changing request (or alternatively
perform strict Origin/Referer checks) before performing the action; update the
template rendering code to supply the token (e.g. csrf_token) and update the
approve/reject handler functions to reject requests with missing/invalid tokens.

Comment thread tests/test_web.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tighten this assertion; current check is effectively non-diagnostic.

At Line 96, assert "rationale" not in r.text or "agent-A" in r.text will usually pass regardless of rationale rendering because agent-A appears in normal metadata. Assert a concrete rationale-specific marker instead (e.g., absence/presence of the rationale term/value for the seeded proposal).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_web.py` at line 96, The assertion uses a weak OR that often
passes; replace it with a concrete check for the rationale marker from the
seeded proposal by asserting the exact rationale key or value in the response
body (e.g., assert '"rationale":' and/or the seeded rationale string is present
or absent as appropriate). Locate the assertion that inspects r.text (the
response object named r) and change it to assert the specific rationale JSON
key/value for the seeded proposal (or assert its absence) so the test verifies
rationale rendering deterministically.

dripsmvcp added a commit to dripsmvcp/vouch that referenced this pull request Jun 10, 2026
…urces, auth, pagination)

Completes the browser review console started by the MVP slice (vouchdev#195) so it
satisfies every acceptance criterion in vouchdev#194. Still a pure viewport: every
approve / reject / contradict routes through vouch.proposals / vouch.lifecycle,
so the audit log is identical to the CLI. Zero new on-disk schema.

Adds on top of the MVP slice:

- WebSocket realtime sync (/ws): a single channel per KB broadcasts a refresh
  signal after every mutation; a second reviewer's queue updates in <1s
  (measured ~50ms). Frame is a signal, not data — clients re-fetch through the
  same routes, so there's one rendering path. Each send is bounded by a
  per-client timeout so one slow/dead socket can't stall the decision handler.
- Server-side pagination at the storage layer: only the requested page of
  proposal files is parsed (proven by test: 500 pending -> 50 parsed). First
  page renders in ~30ms (was ~260ms loading the whole queue), under the budget.
  A corrupt proposal file is skipped + logged, not 500'd.
- /session/<id> (proposals grouped by agent run) and /sources/<id> (reverse
  index: which durable claims cite a source).
- /contradict gate action; keyboard shortcuts (j/k/a/r/?) and live-refresh as a
  progressive-enhancement layer — every action is still a plain form POST that
  works with JavaScript disabled.

Auth (team mode):
- A non-loopback bind requires --auth (a literal token, 'generate', or 'env');
  loopback stays tokenless. Reviewer identity comes from the token label and is
  recorded in the audit log.
- Credentials: Authorization: Bearer header (CLI/API) or an HttpOnly,
  SameSite=Strict cookie (browser). A ?token= query param is a one-time GET
  bootstrap only — moved into the cookie and 303-redirected away so the bare
  token never lingers in a URL or access log. Token comparison is constant-time
  (secrets.compare_digest), HTTP and WebSocket alike. JS never touches the token.

Tests / CI:
- tests/test_web_e2e.py (the file vouchdev#194's acceptance names): approve flow end to
  end asserting the audit.log.jsonl entry; ws sync (with <1s timing assertion),
  session/source views, deterministic pagination (parses one page of 500) +
  malformed-file resilience, served-static-asset check, and the auth-hardening
  cases (cookie bootstrap, constant-time, wrong-cookie/ws rejection).
- tests/test_web.py updated for the paginated /api/pending envelope and the
  auth-required-for-non-loopback behaviour; both web test modules importorskip
  the [web] extra so they skip cleanly without it.
- CI installs .[dev,web] so the web suite actually runs (it imports fastapi).
- pyproject: websockets added to the [web] extra (uvicorn needs it for /ws).
- docs/review-ui.md documents the surface incl. the auth model.

All web assets ship inside the wheel (verified by building it). Full suite +
mypy src + ruff clean. Hardening verified live against a real uvicorn server.

Closes vouchdev#194.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dripsmvcp added a commit to dripsmvcp/vouch that referenced this pull request Jun 10, 2026
…urces, auth, pagination)

Completes the browser review console started by the MVP slice (vouchdev#195) so it
satisfies every acceptance criterion in vouchdev#194. Still a pure viewport: every
approve / reject / contradict routes through vouch.proposals / vouch.lifecycle,
so the audit log is identical to the CLI. Zero new on-disk schema.

Adds on top of the MVP slice:

- WebSocket realtime sync (/ws): a single channel per KB broadcasts a refresh
  signal after every mutation; a second reviewer's queue updates in <1s
  (measured ~50ms). Frame is a signal, not data — clients re-fetch through the
  same routes, so there's one rendering path. Each send is bounded by a
  per-client timeout so one slow/dead socket can't stall the decision handler.
- Server-side pagination at the storage layer: only the requested page of
  proposal files is parsed (proven by test: 500 pending -> 50 parsed). First
  page renders in ~30ms (was ~260ms loading the whole queue), under the budget.
  A corrupt proposal file is skipped + logged, not 500'd.
- /session/<id> (proposals grouped by agent run) and /sources/<id> (reverse
  index: which durable claims cite a source).
- /contradict gate action; keyboard shortcuts (j/k/a/r/?) and live-refresh as a
  progressive-enhancement layer — every action is still a plain form POST that
  works with JavaScript disabled.

Auth (team mode):
- A non-loopback bind requires --auth (a literal token, 'generate', or 'env');
  loopback stays tokenless. Reviewer identity comes from the token label and is
  recorded in the audit log.
- Credentials: Authorization: Bearer header (CLI/API) or an HttpOnly,
  SameSite=Strict cookie (browser). A ?token= query param is a one-time GET
  bootstrap only — moved into the cookie and 303-redirected away so the bare
  token never lingers in a URL or access log. Token comparison is constant-time
  (secrets.compare_digest), HTTP and WebSocket alike. JS never touches the token.

Tests / CI:
- tests/test_web_e2e.py (the file vouchdev#194's acceptance names): approve flow end to
  end asserting the audit.log.jsonl entry; ws sync (with <1s timing assertion),
  session/source views, deterministic pagination (parses one page of 500) +
  malformed-file resilience, served-static-asset check, and the auth-hardening
  cases (cookie bootstrap, constant-time, wrong-cookie/ws rejection).
- tests/test_web.py updated for the paginated /api/pending envelope and the
  auth-required-for-non-loopback behaviour; both web test modules importorskip
  the [web] extra so they skip cleanly without it.
- CI installs .[dev,web] so the web suite actually runs (it imports fastapi).
- pyproject: websockets added to the [web] extra (uvicorn needs it for /ws).
- docs/review-ui.md documents the surface incl. the auth model.

All web assets ship inside the wheel (verified by building it). Full suite +
mypy src + ruff clean. Hardening verified live against a real uvicorn server.

Closes vouchdev#194.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ci installs the [dev] extra only (no [web]), so jinja2 isn't on the
python path when mypy scans src/vouch/web/__init__.py — the guard
import inside _require_web_extra() trips import-not-found even though
the runtime story (raise ImportError if missing) is correct.

local mypy missed it because my venv has jinja2 installed for the
test suite; ci doesn't. add jinja2 to the same override block that
already covers fastapi/uvicorn/starlette.
@plind-junior

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 21432e89c8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/test_web.py

import pytest
from click.testing import CliRunner
from fastapi.testclient import TestClient

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep web tests collectible under the dev install

In the GitHub CI test job I checked, the install step uses pip install -e '.[dev]' and then runs python -m pytest, but [dev] now only adds httpx while fastapi remains only in the [web] extra. Because this new test module imports fastapi.testclient during collection, a normal dev/CI install without [web] fails before running any tests with ModuleNotFoundError. Either include the web extra dependencies in the dev/CI install or skip these tests when the web stack is absent.

Useful? React with 👍 / 👎.

Comment thread src/vouch/web/server.py
"""Reviewer identity. Without the Bearer-auth layer from #1 this falls
back to the same env var the CLI uses, so audit-log attribution stays
consistent across surfaces."""
return os.environ.get("VOUCH_AGENT", "web-reviewer")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the same fallback identity as the CLI

When a human files a proposal via the CLI without VOUCH_AGENT, proposed_by is VOUCH_USER or the OS user, but the web UI falls back to the literal web-reviewer. In that default local workflow, approving from vouch review-ui no longer matches the proposer and bypasses proposals.approve's forbidden-self-approval guard, recording the wrong actor in the audit log. Mirror the CLI fallback or require an explicit reviewer so self-approval remains blocked.

Useful? React with 👍 / 👎.

Comment thread src/vouch/web/server.py
proposals_mod.approve(
store, proposal_id, approved_by=_whoami(), reason=reason
)
except (proposals_mod.ProposalError, ArtifactNotFoundError) as e:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle invalidated proposals without a 500

For proposals that were valid when filed but become invalid before approval, such as a cited source/evidence or a page's referenced claim being deleted, proposals_mod.approve() can raise ValueError from the storage put_* methods. This handler only catches ProposalError and ArtifactNotFoundError, so the browser path returns an internal server error while the CLI's _cli_errors reports a clean user error for the same condition. Include ValueError in the handled errors.

Useful? React with 👍 / 👎.

Comment thread src/vouch/web/server.py
Comment on lines +115 to +116
@app.post("/approve/{proposal_id}")
def approve(proposal_id: str, reason: str | None = Form(default=None)) -> Any:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add CSRF protection to mutation posts

Because these approve/reject routes are unauthenticated and accept plain form POSTs, a malicious web page can submit a cross-site form to a locally running review UI if it has or can infer a proposal id, causing an approval or rejection under the user's local reviewer identity. Localhost binding does not stop browser CSRF; add an origin/token check for these mutation routes while keeping the no-JS form flow.

Useful? React with 👍 / 👎.

Comment thread src/vouch/cli.py
Comment on lines +854 to +858
url = f"http://{host}:{port}/"
click.echo(f"vouch review-ui running at {url}")
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
else:
click.echo(f"vouch review-ui running at http://{host}:{port}/")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Bracket IPv6 loopback addresses in generated URLs

When the accepted loopback bind is ::1:7780, this interpolation prints and opens http://::1:7780/, which URL parsers treat as an invalid authority instead of the IPv6 literal host. Users binding to IPv6 loopback need http://[::1]:7780/ (and the same formatting in the no-open branch) for the browser URL to work.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: vouch review-ui — browser-based review console [VEP]

1 participant