feat(viewer): show Past Runs without email verification

Browsing local scan history no longer requires the work-email + OTP step. The /api/runs list and individual other-run data now unlock on the per-process session capability alone (drops auth.is_verified from the gate); the session-cookie check is preserved so an exposed --host port still cannot enumerate or read history. Email verification remains required only for emailing encrypted reports (auth.py unchanged). Backend-only; the prebuilt SPA renders the unlocked payload, no rebuild needed.

Renames build_runs_payload kwarg verified->unlocked and updates the two viewer integration tests to pin is_verified=False, proving the email term is no longer on the history path.
This commit is contained in:
kent 2026-07-28 12:59:26 +07:00
parent 7e7e3e16db
commit 0a9b730da1
3 changed files with 46 additions and 44 deletions

View file

@ -78,15 +78,17 @@ def run_list_entry(run_dir: Path) -> dict[str, Any]:
}
def build_runs_payload(base_dir: Path, *, verified: bool) -> dict[str, Any]:
"""The /api/runs payload. Gates the run list behind email verification.
def build_runs_payload(base_dir: Path, *, unlocked: bool) -> dict[str, Any]:
"""The /api/runs payload. Gates the run list behind the caller's authorization.
The count is always advertised so the UI can tease the history, but the
entries only appear once the viewer is verified.
``unlocked`` is the caller's access decision (today: holds this process's
session capability). When locked, the count is still advertised so the UI can
tease the history, but the entries only appear once unlocked. Email
verification is no longer part of this decision.
"""
run_dirs = _iter_run_dirs(base_dir)
count = len(run_dirs)
if not verified:
if not unlocked:
return {"locked": True, "count": count, "runs": []}
return {"locked": False, "count": count, "runs": [run_list_entry(d) for d in run_dirs]}
@ -235,13 +237,17 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
# The launched run is always viewable with no verification. The
# cross-run history list (/api/runs) unlocks its entries only for a
# caller that holds this process's session capability *and* is email
# verified, so merely reaching an exposed --host port never leaks the
# run list (the payload still advertises the count as a teaser).
# cross-run history list (/api/runs) unlocks its entries for any
# caller that holds this process's session capability, so merely
# reaching an exposed --host port never leaks the run list (the
# cookie is handed only to the browser this process bootstrapped).
# Email verification is intentionally NOT required here: browsing
# local run history is a local convenience, and gating it behind an
# OTP was unnecessary friction. Verification still guards emailing an
# encrypted report (see _handle_report_send).
if path == "/api/runs":
unlocked = self._has_session() and auth.is_verified()
payload = build_runs_payload(state.base_dir, verified=unlocked)
unlocked = self._has_session()
payload = build_runs_payload(state.base_dir, unlocked=unlocked)
self._send_json(HTTPStatus.OK, payload)
return
if path == "/api/capabilities":
@ -261,17 +267,15 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
return
# The launched run is always viewable. Any *other* run's data is part
# of the gated history: it needs this process's session capability
# (so merely reaching an exposed --host port is not enough) *and*
# email verification -- otherwise knowing a run name would leak its
# metadata, vulnerabilities, report, and transcript.
if run_dir.resolve() != state.run_dir.resolve():
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
if not auth.is_verified():
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
return
# of the history: it needs this process's session capability (so
# merely reaching an exposed --host port is not enough) -- knowing a
# run name is not sufficient to leak its metadata, vulnerabilities,
# report, or transcript. Email verification is intentionally NOT
# required, matching the /api/runs list: once the list is browsable,
# its entries must be openable without an OTP step.
if run_dir.resolve() != state.run_dir.resolve() and not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
if path == "/api/run":
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))

View file

@ -549,15 +549,16 @@ def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.Monkey
httpd.server_close()
def test_historical_run_data_requires_verification(
def test_historical_run_data_requires_session_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
_bundle(tmp_path, monkeypatch)
verified = {"value": False}
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: verified["value"])
# Email verification is pinned False for the whole test to prove it is no
# longer consulted for browsing history: only the session capability is.
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False)
httpd, url, token = serve(launched, open_browser=False)
try:
@ -567,31 +568,27 @@ def test_historical_run_data_requires_verification(
cookie = _session_cookie(url, token)
# A different run needs the session capability first: a cookie-less
# caller is forbidden even once the machine is verified.
verified["value"] = True
# A different run still needs the session capability first: a cookie-less
# caller is forbidden.
assert _get_status(f"{url}/api/run?run=other") == 403
# With the cookie but not verified, the history gate returns 401.
verified["value"] = False
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 401
# With both the cookie and verification, the historical run resolves.
verified["value"] = True
# With the cookie, the historical run resolves even though the machine is
# NOT email-verified -- the email gate has been removed.
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 200
finally:
httpd.shutdown()
httpd.server_close()
def test_runs_list_requires_session_and_verification(
def test_runs_list_requires_session_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
_bundle(tmp_path, monkeypatch)
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True)
# Pinned unverified to prove the list unlocks on the session capability alone.
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False)
def _runs(cookie: str | None) -> dict[str, object]:
headers = {"Cookie": cookie} if cookie else {}
@ -601,14 +598,13 @@ def test_runs_list_requires_session_and_verification(
httpd, url, token = serve(launched, open_browser=False)
try:
# A cookie-less caller (even with the machine verified) only sees the
# teaser count, never the run entries.
# A cookie-less caller only sees the teaser count, never the run entries.
payload = _runs(None)
assert payload["locked"] is True
assert payload["count"] == 2
assert payload["runs"] == []
# With the session cookie and verification, the entries unlock.
# With the session cookie the entries unlock -- no email verification.
payload = _runs(_session_cookie(url, token))
assert payload["locked"] is False
assert {r["name"] for r in payload["runs"]} == {"launched", "other"} # type: ignore[attr-defined]

View file

@ -30,23 +30,25 @@ def _make_run(base: Path, name: str, *, severity: str = "high") -> Path:
return run_dir
def test_runs_payload_locked_when_unverified(tmp_path: Path) -> None:
def test_runs_payload_locked_when_no_session(tmp_path: Path) -> None:
base = tmp_path / "strix_runs"
_make_run(tmp_path, "alpha")
_make_run(tmp_path, "beta")
payload = build_runs_payload(base, verified=False)
# No session capability -> locked, but the count is still advertised.
payload = build_runs_payload(base, unlocked=False)
assert payload["locked"] is True
assert payload["count"] == 2
assert payload["runs"] == []
def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
def test_runs_payload_lists_when_unlocked(tmp_path: Path) -> None:
base = tmp_path / "strix_runs"
_make_run(tmp_path, "alpha", severity="critical")
_make_run(tmp_path, "beta", severity="info")
payload = build_runs_payload(base, verified=True)
# A session-holding caller unlocks the list -- no email verification needed.
payload = build_runs_payload(base, unlocked=True)
assert payload["locked"] is False
assert payload["count"] == 2
assert len(payload["runs"]) == 2
@ -59,7 +61,7 @@ def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
def test_runs_payload_empty_base(tmp_path: Path) -> None:
payload = build_runs_payload(tmp_path / "strix_runs", verified=True)
payload = build_runs_payload(tmp_path / "strix_runs", unlocked=True)
assert payload == {"locked": False, "count": 0, "runs": []}