From 1904b7edb27da3c8479d3fc4e5600b66c94b0b02 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Thu, 2 Jul 2026 01:36:59 -0700 Subject: [PATCH] fix(view): unusable-board reads surface as JSON 400, not a text/plain 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /features and /features/{fid} were the only store-touching routes outside _guard — on an unusable board (no repo bound, no .beads, br missing) the BoardError escaped as FastAPI's text/plain 500, which the board view could only render as WebKit's 'SyntaxError: The string did not match the expected pattern' (r.json() on a non-JSON body). Hit on a manager-tier agent with project_board enabled but no repo bound. - api: wrap both GET routes in _guard like every other route — the view now receives the actionable BoardError message as JSON 400. - view: api() parses defensively — a JSON error body surfaces its detail, a non-JSON body its HTTP status; load()'s catch renders that instead of a raw parse error (previously a 400 would have silently rendered an EMPTY board: r.features || [] swallowed the detail). Co-Authored-By: Claude Fable 5 --- api.py | 8 ++++++-- board_view.py | 9 ++++++++- tests/test_api.py | 20 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/api.py b/api.py index d4f5193..345d894 100644 --- a/api.py +++ b/api.py @@ -187,11 +187,15 @@ async def _create_milestone(body: dict = Body(...)): # ── features ────────────────────────────────────────────────────────────── @router.get("/features") async def _features(state: str | None = None): - return {"features": store().list_features(state=state)} + # _guard, like every other store-touching route: an unusable board (no repo + # bound, no .beads, br missing) must reach the view as JSON 400 with the + # actionable BoardError message — an escaped BoardError is a text/plain 500 + # the view can only render as a JSON-parse error. + return _guard(lambda: {"features": store().list_features(state=state)}) @router.get("/features/{fid}") async def _feature(fid: str): - f = store().get_feature(fid) + f = _guard(lambda: store().get_feature(fid)) if f is None: raise HTTPException(404, f"unknown feature {fid!r}") return f diff --git a/board_view.py b/board_view.py index 20bf75a..6788c97 100644 --- a/board_view.py +++ b/board_view.py @@ -102,7 +102,14 @@ in_progress:"var(--pl-color-accent)", in_review:"var(--pl-color-status-info)", done:"var(--pl-color-fg-muted)", blocked:"var(--pl-color-status-error)"}; // Slug-aware authed fetch via the kit (rules 2+3) — pass a bare /api/... path. -const api = (p) => kit.apiFetch(p).then(r => r.json()); +// Errors become READABLE: a JSON error body surfaces its `detail` (the actionable +// BoardError message), a non-JSON body its HTTP status — never a raw parse error. +const api = async (p) => { + const r = await kit.apiFetch(p); + const d = await r.json().catch(() => { throw new Error("HTTP " + r.status + " (non-JSON response)"); }); + if (!r.ok) throw new Error(d.detail || "HTTP " + r.status); + return d; +}; const $ = (id) => document.getElementById(id); const esc = (s) => (s||"").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); diff --git a/tests/test_api.py b/tests/test_api.py index 19a4774..0bcee67 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -136,6 +136,26 @@ def test_data_routes_live_on_the_gated_prefix(monkeypatch): assert c.get("/plugins/project_board/features").status_code == 404 +def test_unusable_board_reads_surface_as_json_400_not_500(monkeypatch): + """An unusable board (no repo bound, no .beads, br missing) raises BoardError + on ANY read — that must reach the view as JSON 400 carrying the actionable + message, not escape as a text/plain 500 the page can only show as a + JSON-parse error.""" + + class BrokenStore(FakeStore): + def list_features(self, state=None): + raise BoardError("repo '.' has no beads workspace — set project_board.repo") + + def get_feature(self, fid): + raise BoardError("repo '.' has no beads workspace — set project_board.repo") + + c = _client(monkeypatch, BrokenStore()) + for path in ("/api/plugins/project_board/features", "/api/plugins/project_board/features/bd-1"): + r = c.get(path) + assert r.status_code == 400, path + assert "beads workspace" in r.json()["detail"], path + + # ── CRUD + the Ready gate surfacing as 400 ──────────────────────────────────────