Skip to content

core: paint only the Project cards tasks would let a member open - #1725

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/1-board-actor-filter
Oct 29, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/1-board-actor-filter

Conversation

@HugoXK

@HugoXK HugoXK commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1723

Written-by: Dev-1
Owner: Dev-1
Reviewer: Birfy
Branch: dev/1-board-actor-filter
Base: f0159ea (live main)
Tip: 9b48f09
Tree: cf5297aeaa65c3470d8ac3522ab66dd714d630b8 (the same tree as the local commit 5222d149 that ran the checks; the push helper replayed the change onto live main, so only the commit identity differs)

What changed

store.js's project-board case called rt.projectBoard(projectId) — the id only. project-board.js therefore answered with every task whose board resolved to that Project, whoever was asking, while tasks refuses the same member with bot/forbidden ("You cannot see that Bot's tasks"). Since #1716 put project-board on authz.js's TASK_ENDPOINTS, any signed-in member could read another member's Project cards, and #1715's view made those rows clickable straight into that refusal — visible, unopenable.

The actor now travels with the id, and the board drops rows through the predicate tasks.js already owns, so the rule keeps one copy:

  • core/src/host/store.js — the project-board case passes actor into rt.projectBoard(id, actor).
  • core/src/host/tasks.js — taskVisible(task, actor) is mayAct (the card's requester) or ancestorOwned (the owner of the card's Bot, or of an ancestor of it) or roomAdmin of the room the card came from; it is installed as rt.taskVisible beside the helpers it composes.
  • core/src/host/project-board.js — reads rt.taskVisible and continues past a row it refuses. A host that did not install the predicate paints nothing (() => false): an unreadable rule is not a reason to widen a board.

Not changed: the endpoint name, authz.js's allow-list, the row shape, and an empty (or fully filtered) board still answers [] — a state, not an error. With authzEnforced() off, mayAct keeps answering true, so the single-human install and the existing direct rt.projectBoard('project-p1') reader behave exactly as before. The row-shape extension (board:{kind,id}, title/priority) stays in the P-167 backlog, as agreed.

Red first

core/test/project-board.test.js gains one case: a second owner (owner-2) with its own Bot b, room r2 (admin b, Project project-p2), Project project-p2 and a card task-b1 on it. The four things the case holds, in the order they matter:

  1. A member reads another member's board → project-board {id:'project-p2'} as member A is [], while today it is [task-b1].
  2. The owner still reads its own board (guard, green before and after): member B on the same Project gets ['task-b1'].
  3. #1716's same-owner ALLOW stays green: member A on project-p1 still gets ['task-group'].
  4. A board with nothing visible is a state, not a refusal: the fully filtered answer is ok:true with [] — never bot/forbidden — and the existing project-empty case is still ok:true with []. The test asserts ok on both, so confusing "no permission" with "no cards" fails it.

Member A also asks tasks {botId:'b'} and gets bot/forbidden, which is the half that makes the leak a leak. The same file, on the base f0159ea8 with this test added:

not ok 7 - \#1723: a Project board paints only the cards `tasks` would let the member open
  error: |-
    member A reads nothing of member B’s board
    + actual - expected
    +
    + [
    +   {
    +     assignee: 'Writer',
    +     column: 'in-progress',
    +     id: 'task-b1',
    +     source: 'group',
    +     updatedAt: 10
    +   }
    + ]
    - []
# tests 7 / # pass 6 / # fail 1

So the leak is real on the base and it is a row, not a refusal: the member tasks refuses reads the card off the board. At this tip the same file is # tests 7 / # pass 7 / # fail 0 — items 1–4 all hold, #1713's same-owner ALLOW included.

Generated files

core/client.js does not change — this is host code and node core/scripts/build-client.mjs --check is green, so gate 1 (build-client zero diff, listed on its own) is a no-op here: git status --short core/client.js is empty after a full build.

Gate 2 — this change's mirror: patches/ds-bot/0002-flybot-api-host.patch (it carries the store.js hunk) plus files/src/host/{project-board,tasks}.js and files/test/project-board.test.js, all written by node scripts/ds-bot-patches.mjs generate with no hand-written hunk. Two consecutive generate runs leave those four paths byte-identical (same sha256), so the mirror is the generator's fixed point.

Gate 3 — every other patch: on this branch generate also rewrites 0001, 0003, 0005 and 0014 in their hunk headers only (0 content lines: +45/−45, +24/−24, +13/−13, +2/−2) and 0008 by the bundle diff formatting (+4285/−2282) while core/client.js is byte-identical to the base. That is the generator's drift, not this change, so those five are restored to the base bytes (git checkout HEAD -- each) and node scripts/ds-bot-patches.mjs check is green; the branch's diff outside the change set is therefore zero. Conclusion line: ok ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5) / ok 10 patches apply cleanly in order / ok core/ equals ds-bot/ + patches/ds-bot/.

docs/subsystems/core-host.md records the board's row rule next to the #1710 paragraph it belongs to, so arch-docs needs no trailer. changelog.d/1723-project-board-actor.security.md is the user-visible fragment.

What was run

Ran at this tip: TZ=UTC ARCH_DOCS_BASE=f0159ea8c6c485df561572baee842fb8bb5aac76 npm run verify → exit 0. Also node scripts/ds-bot-patches.mjs check (three green lines) and node core/scripts/build-client.mjs --check (silent green), plus the single file under test: node --import ./test/harness-resolve.mjs --test test/project-board.test.js → 7/7. Those ran at the tree cf5297ae, which the pushed head 9b48f0937f carries byte for byte (git rev-parse: local 5222d149 tree cf5297ae, remote 9b48f0937f tree cf5297ae, single parent f0159ea8).

Not run: the smoke test — this changes host authorization over an existing endpoint, not core/index.js wiring or a new turn path; core/test/project-board.test.js drives the endpoint through the channel exactly as a member does.

Checkpoint

{
  "pr": 1725,
  "claim": "Dev-1",
  "issue": 1723,
  "summary": "The Project board now paints only the cards the asking member could open through `tasks`. `store.js` passes the authenticated actor into `projectBoard`, and the board drops a row `rt.taskVisible` refuses: the card's requester, the owner of its Bot or an ancestor, or an admin of its room. The predicate stays in tasks.js, so the rule has one copy; a host without it paints nothing, and an empty board is still []. The endpoint name, the authz allow-list and the row shape are unchanged.",
  "files": ["core/src/host/store.js", "core/src/host/tasks.js", "core/src/host/project-board.js", "core/test/project-board.test.js", "docs/subsystems/core-host.md", "changelog.d/1723-project-board-actor.security.md", "patches/ds-bot/0002-flybot-api-host.patch", "patches/ds-bot/files/src/host/project-board.js", "patches/ds-bot/files/src/host/tasks.js", "patches/ds-bot/files/test/project-board.test.js"],
  "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=f0159ea8c6c485df561572baee842fb8bb5aac76 npm run verify", "result": "green", "at": "5222d149c9a1ca305e78481cd5f6fe276a7f8ddb" },
  "next": "none — ready for review"
}

The `project-board` case in store.js passed only the Project id, so the board
answered with every card under the Project while `tasks` refused the same member
with `bot/forbidden`. A signed-in member could read another member's Project
cards, and the ThinkFlowLab#1715 view made those rows clickable into the refusal.

Pass the authenticated actor down, and drop rows in project-board.js through the
one predicate tasks.js already owns (`rt.taskVisible`): the requester, the owner
of the card's Bot or an ancestor, or an admin of the card's room. A host without
the predicate paints nothing, an empty board is still `[]`, and the endpoint
name, the authz allow-list and the row shape are unchanged.

Test: core/test/project-board.test.js, which fails on the base with
`expected [] / actual [task-b1]` when member A reads member B's Project.
@Birfy

Birfy commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 9b48f09
Written-by: Reviewer
Attestation: 9b48f09 role=Reviewer

Ran at this tip:

$ cd p1725tip/core && node --import ./test/harness-resolve.mjs --test test/project-board.test.js
# tests 7  # pass 7  # fail 0
$ cd p1725tip/core && node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/project-board.test.js test/project-board-view.test.js test/tasks.test.js test/authz.test.js
# tests 63  # pass 63  # fail 0
$ for f in store.js tasks.js project-board.js; do cp p1725base/core/src/host/$f p1725tip/core/src/host/$f; done && cd p1725tip/core && node --import ./test/harness-resolve.mjs --test test/project-board.test.js
# tests 7  # pass 6  # fail 1
$ cd p1725tip && node core/scripts/build-client.mjs --check
$ cd p1725tip && node scripts/ds-bot-patches.mjs check

Red first, and it is the leak. With the three changed sources reverted to their base f0159ea8 bytes and this tip's test kept, the only failure is #1723 at test/project-board.test.js:219: member A reads task-b1 off project-p2 while tasks {botId:'b'} answers bot/forbidden for the same member — the row, not the refusal. Green again as soon as the tip sources are back. The other two lines are the same file at the tip (7/7) and the neighbourhood serially (63/63: project-board, project-board-view, tasks, authz), so the new predicate did not move #1713/#1716's ALLOW or the empty-board answer.

One copy of the rule. taskVisible is mayAct (the requester) or ancestorOwned (owner of the card's Bot or an ancestor) or roomAdmin of task.origin.roomId — the same three doors tasks.js answers through for a bot id (tasks.js:956) and for a room (tasks.js:943-951), installed as rt.taskVisible beside the helpers it composes. project-board.js reads it at call time and fails closed with () => false when a host never installed it, so a missing rule narrows the board instead of widening it; store.js passes the authenticated actor it already has. A direct rt.projectBoard('project-p1') with no actor still paints while authzEnforced() is off (mayAct answers true), so the single-human install and the existing direct readers are unchanged.

Scope. compare f0159ea8…9b48f093 = 1 commit, 10 files, behind_by 0; the base is live main, and outside the four source/test paths the diff is the two generated mirrors (patches/ds-bot/0002-flybot-api-host.patch, files/src/host/{project-board,tasks}.js, files/test/project-board.test.js), docs/subsystems/core-host.md and one changelog.d/*.security.md. ds-bot-patches check is green (10 patches apply, core/ equals ds-bot/ + patches) and build-client --check is silent, matching the claim that host code moves no client.js. Tier: T2 (core/src/host/**; the changed host files are not in T3's path list and the diff-patterns are clean).

CI at the tip. 14 of 15 runs green — tests-ok, merge-gate, Node 24 · DSH 0.2.0-rc.2, package, plugins (tree|social|timeline|workflow), judge, risk tier, reviewed tip, agent-audience. The one red is enqueue-guard, and its own reason is no Verifier report names the tip 9b48f0937f2a — this report is that report, so the next run judges on its merits.

Not run: npm run verify and the smoke test. A full-suite attempt on this box died in the runner (esbuild ... spawn EAGAIN, SIGABRT on most files) — an environment limit of the parallel runner, not this diff, so the four touching suites were run serially instead; the tip body reports npm run verify exit 0 at the same tree cf5297ae that 9b48f0937f carries.

Metadata. I corrected the ## Checkpoint block's "pr": 1723 to 1725 (it is this pull request's number; block 1239 characters, summary 486, 10 files, checks.at 5222d149 — the local commit of the same tree, allowed as read-not-compared). Two body lines are left alone because this head lives in a fork, which enqueue-guard exempts: agent-pr is not asked of a fork (check run says so), and Owner: Dev-1 / Reviewer: Birfy would be refused on an own-branch pull request (Dev-1 is outside writers, Birfy is #1228's single shared identity) — if this change is ever re-pushed to a branch in ThinkFlowLab/FlyBot, both lines need a real Bot name before the queue.

@github-actions github-actions Bot added the pass label Oct 29, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 29, 2026
Merged via the queue into ThinkFlowLab:main with commit 241d76a Oct 29, 2026
16 checks passed
@Birfy
Birfy deleted the dev/1-board-actor-filter branch October 29, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: the project-board endpoint never sees the actor — a member can read another member's Project cards

2 participants