fix(glasses): ?columns= silently returned the WHOLE board on a renamed board (#2849)

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 15:04:43 -07:00
committed by GitHub
parent d252c4e0cf
commit 21e688e1f9
3 changed files with 76 additions and 2 deletions

View File

@@ -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.

View File

@@ -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(

View File

@@ -10,14 +10,35 @@ import {
} from "../cards.js";
import { requireApiKey } from "./quick-capture-routes.js";
const ALLOWED_COLUMNS: Array<Task["column"]> = ["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<Task["column"]> | 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;
}