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; }