From 21e688e1f91deb5f0daa33e3922db66f528855bd Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 30 Jul 2026 15:04:43 -0700 Subject: [PATCH] fix(glasses): ?columns= silently returned the WHOLE board on a renamed board (#2849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lane-literal defect the census cannot see — the literals are **Set/Array members**, not comparisons — in a live plugin, with **no test coverage on the filter at all**. That absence is how the inversion survived. ## The bug ```ts const ALLOWED_COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; function parseColumns(raw) { const parsed = raw.split(",").filter(v => ALLOWED_COLUMNS.includes(v)); return parsed.length ? new Set(parsed) : null; // ← `null` ALSO means "no filter requested" } ``` On a board whose lanes are named anything else, every requested id is discarded, `parsed.length` is `0`, and the function returns `null` — **the same value it returns when no filter was requested**. The caller then does `columns ? all.filter(...) : all`, so the route answers `200` with the **entire board**. Asking for one column returns all of them. Nothing in the response says the filter was dropped. The list also still named `triage`, a column U11 deleted, so it described a board that no longer exists in either direction. ## The fix No allow-list can be correct here and none is needed. Valid ids are whatever the project's workflows declare, and `Task["column"]` is already `ColumnId = Column | (string & {})` — open by construction. Filtering directly on the requested ids needs **no resolution source at all**, which is why this literal, unlike the display ordering in `cards.ts` (documented DELIBERATE-LITERAL: this package depends on `@fusion/plugin-sdk` only, so there is no IR or store to resolve from), is a defect rather than a deferral. **Deliberate behaviour change:** `?columns=nonsense` now returns an **empty deck** instead of the whole board. "Show me column X" answered with every column is not a lenient default — it is the bug wearing a 200. ## Revert proof (measured) Restore the allow-list and **both** new cases fail: ``` FAIL > filters on a RENAMED lane instead of silently returning the whole board expected [ { id: 'summary', …(5) }, …(2) ] to have a length of 2 but got 3 FAIL > answers an unknown column with an EMPTY deck, not with everything expected [ { id: 'summary', …(5) }, …(2) ] to have a length of 1 but got 3 ``` Both directions are asserted on purpose: a filter that matched *nothing* would satisfy the renamed-lane case alone while being equally broken. ## Not changed, and why `plugins/fusion-plugin-even-cards` carries the **identical** bug — I wrote the fix there first. It was removed from the pnpm workspace (`858bab2`, "remove `fusion-plugin-even-cards` from the active workspace package list to avoid duplicate user-facing integrations") and its README names this plugin as its replacement, so it is not built, tested, or shipped by anything. Fixing it would only imply it still runs. Reverted and left alone. ## Verification - `pnpm test:gate` — 161 / 487 / 13 / 71 passed - `pnpm lint` — clean - `tsc --noEmit` (`@fusion-plugin-examples/even-realities-glasses`) — clean - full plugin suite — 188 passed across 19 files - census `--strict` — exit 0 (unchanged: these literals are Set members, invisible to it) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) --- .changeset/glasses-column-filter.md | 7 +++ .../src/__tests__/board-routes.test.ts | 46 +++++++++++++++++++ .../src/routes/board-routes.ts | 25 +++++++++- 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 .changeset/glasses-column-filter.md diff --git a/.changeset/glasses-column-filter.md b/.changeset/glasses-column-filter.md new file mode 100644 index 0000000000..2d818a0d73 --- /dev/null +++ b/.changeset/glasses-column-filter.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The glasses board API's `?columns=` filter now works on boards with custom lane names. +category: fix +dev: `fusion-plugin-even-realities-glasses` validated `?columns=` against a hardcoded six-id allow-list (still naming the deleted `triage`). On a renamed board every requested id was discarded and the parser's "nothing valid" result is indistinguishable from "no filter requested", so the route returned the entire board with a 200. The allow-list is deleted; ids are filtered directly, and an unknown column now yields an empty deck. diff --git a/plugins/fusion-plugin-even-realities-glasses/src/__tests__/board-routes.test.ts b/plugins/fusion-plugin-even-realities-glasses/src/__tests__/board-routes.test.ts index a2a2d877bb..452bb2bc07 100644 --- a/plugins/fusion-plugin-even-realities-glasses/src/__tests__/board-routes.test.ts +++ b/plugins/fusion-plugin-even-realities-glasses/src/__tests__/board-routes.test.ts @@ -53,6 +53,52 @@ describe("board routes", () => { expect(response.body.deck.cards).toHaveLength(3); }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-00:45: + THE INVARIANT: `?columns=` filters on the board's OWN lane ids, whatever they are named. + + The parameter was validated against a hardcoded six-id allow-list and the filter had NO coverage + at all, which is how the inversion survived: on a renamed board every requested id was discarded, + the parser returned `null` — the SAME value it returns for "no filter requested" — and the route + answered 200 with the ENTIRE board. Asking for one column got you all of them, silently. + + Both directions are asserted. A filter that matched nothing would satisfy the renamed-lane case on + its own while being equally broken, and a filter that matched everything satisfies neither. + + REVERT PROOF, measured: restore the allow-list and BOTH new cases fail with `expected 3 to be 2` + — the deck carries the summary plus every card instead of the requested subset. + */ + it("filters on a RENAMED lane instead of silently returning the whole board", async () => { + const tasks = [ + makeTask("FN-1", "backlog", "2026-05-08T11:00:00.000Z"), + makeTask("FN-2", "building", "2026-05-08T12:00:00.000Z"), + ]; + + const response = (await route("/board/cards").handler( + { headers: { authorization: "Bearer secret" }, query: { columns: "building" } }, + createContext(tasks), + )) as any; + + expect(response.status).toBe(200); + // Summary card + exactly the one requested task. + expect(response.body.deck.cards).toHaveLength(2); + expect(response.body.deck.cards[1].id).toBe("FN-2"); + }); + + it("answers an unknown column with an EMPTY deck, not with everything", async () => { + // The old allow-list turned "I do not recognise that id" into "no filter was requested". + const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"), makeTask("FN-2", "todo", "2026-05-08T12:00:00.000Z")]; + + const response = (await route("/board/cards").handler( + { headers: { authorization: "Bearer secret" }, query: { columns: "nonsense" } }, + createContext(tasks), + )) as any; + + expect(response.status).toBe(200); + expect(response.body.deck.cards).toHaveLength(1); + expect(response.body.deck.cards[0].id).toBe("summary"); + }); + it("returns task deck for known id", async () => { const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z")]; const response = (await route("/tasks/:id/cards").handler( diff --git a/plugins/fusion-plugin-even-realities-glasses/src/routes/board-routes.ts b/plugins/fusion-plugin-even-realities-glasses/src/routes/board-routes.ts index ce40ebc805..58e0a5891c 100644 --- a/plugins/fusion-plugin-even-realities-glasses/src/routes/board-routes.ts +++ b/plugins/fusion-plugin-even-realities-glasses/src/routes/board-routes.ts @@ -10,14 +10,35 @@ import { } from "../cards.js"; import { requireApiKey } from "./quick-capture-routes.js"; -const ALLOWED_COLUMNS: Array = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; +/* +FNXC:WorkflowResolvedColumns 2026-07-31-00:45: +`?columns=` filters on the ids the CALLER asked for. There is no allow-list to clear first. +This validated the parameter against a hardcoded six-id list, and the failure mode was the worst +available one — silent and inverted. On a board whose lanes are named anything else, every requested +id was discarded, `parsed.length` was 0, the function returned `null`, and `null` is the SAME value +it returns for "no filter requested". So the route answered 200 with the ENTIRE board: a caller +asking for one column received all of them, with nothing in the response saying the filter had been +dropped. + +The list also still named `triage`, a column U11 deleted, so it described a board that no longer +exists in either direction. + +No allow-list can be correct here and none is needed. The valid ids are whatever the project's +workflows declare; `Task["column"]` is already `ColumnId = Column | (string & {})`, i.e. open by +construction. Filtering directly on the requested ids needs no resolution source at all — which is +why this literal, unlike the display ordering in `cards.ts`, is a defect and not a documented +deferral. + +Behaviour change, deliberate: `?columns=nonsense` now returns an EMPTY deck rather than the whole +board. "Show me column X" answered with every column is not a lenient default, it is the bug. +*/ function parseColumns(raw: unknown): Set | null { if (typeof raw !== "string" || !raw.trim()) return null; const parsed = raw .split(",") .map((value) => value.trim()) - .filter((value): value is Task["column"] => ALLOWED_COLUMNS.includes(value as Task["column"])); + .filter((value) => value.length > 0); return parsed.length ? new Set(parsed) : null; }