Skip to content

fix(moderation): let an appointed moderator see what they moderate - #117

Merged
jouwdan merged 1 commit into
mainfrom
claude/mei52-moderator-visibility
Aug 16, 2026
Merged

fix(moderation): let an appointed moderator see what they moderate#117
jouwdan merged 1 commit into
mainfrom
claude/mei52-moderator-visibility

Conversation

@jouwdan

@jouwdan jouwdan commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes MEI-52 (audit finding A6).

The bug

Authorizer.canForum grants content.viewUnapproved and content.viewDeleted when target.isForumModerator === true (packages/authorization/src/authorizer.ts). Every contentScope call site built its target from { forumId, forum } alone, so that flag was never set and the branch was dead code:

  • apps/community/app/(board)/[slug]/page.tsx — the forum listing
  • apps/community/app/(board)/thread/[slug]/page.tsx — the thread's posts, and its generateMetadata
  • apps/community/app/api/v1/[...path]/route.tsthreadScope, and GET /forums/:forumId/threads

A moderator appointed on a forum — not an administrator, not a super-moderator, not in a group carrying canViewUnapproved/canViewDeleted — therefore saw neither held nor soft-deleted threads or posts anywhere. The moderation queue's links landed on pages that filtered the item out, and the inline Approve/Restore controls rendered with no selectable rows, because moderator rights were computed with appointments elsewhere on those same pages (the thread page's tools, the listing's inline rights). The only view of the content was the queue's 300-character excerpt.

The fix

Two methods on Authorizer, so the resolution exists once:

  • moderatorTargetIn(actor, forumId, forum) — resolves the actor's standing in that one forum into a ModeratedTarget (moderatorRights + isForumModerator).
  • contentScopeIn(actor, forumId, forum) — derives the visibility scope from that target.

Both pages already resolved the same target a few lines further down for their moderation tools, so they now resolve it before the scope and pass the one target to both — no extra queries. The thread page's generateMetadata and the two REST sites call contentScopeIn directly. moderatorTargetFor in src/server/modcp.ts keeps its signature and callers and delegates to moderatorTargetIn, so post-scope.ts is unchanged.

Guests and ordinary members are untouched by construction: moderatorRightsIn returns early with no rights for a null userId and grants nothing without a covering appointment, and no rights means PUBLIC_CONTENT. The group-column path (forum.canViewUnapproved) and the administrator/super-moderator bypasses are unchanged.

Out of scope, deliberately: the board-wide aggregates (search.ts, stats.ts, board-latest.tsx, presence.ts, discovery.ts) build a cross-forum staff scope rather than a per-forum one, and are left alone.

The caching rule in docs/nextjs-conventions.md holds — every touched read already takes an actor and runs per request; none is inside a cached region, and no-request-state-in-cache passes.

Validation

  • packages/authorization/src/content-scope.test.ts — nine cases following the thread-tool-rights.test.ts pattern: an appointee's scope carries unapproved and deleted in the forum they moderate; the same appointee gets PUBLIC_CONTENT in a forum they do not moderate; the appointment reaches a subforum only when it cascades; any right at all widens the scope, not just canApproveContent; an ordinary member (including one whose group holds an appointment elsewhere) and a guest stay on PUBLIC_CONTENT; the group-column path and the staff bypasses still widen; a banned member gets nothing.
  • Every new test was proven able to fail, in four passes: stripping the moderator info from the target (the original bug) reddens the three positive cases; making isForumModerator unconditionally true reddens the four negative cases; dropping the forum.canView* half of the branch reddens the group-column case; removing the banned check and the staff bypasses reddens the last two. Restored after each.
  • pnpm verify — exit 0 (311 files, 5726 tests; only the two pre-existing warnings).
  • DATA_SOURCE=fixture pnpm build — exit 0, required because the diff touches app/. No client component was added or changed; the one "use client" under app/ is still error.tsx.
  • pnpm test:e2e not run — fixed ports are shared with sibling agents.

No documentation change: nothing under docs/ claimed anything about who the content scope admits, so no doc statement moves with this behaviour. The moderation sections of docs/mybb-parity.md were checked and describe the queue and inline tools rather than visibility.

🤖 Generated with Claude Code


Generated by Claude Code

The Authorizer grants content.viewUnapproved and content.viewDeleted to a
forum's own moderator, but every content-scope call site built its target
from { forumId, forum } alone, so target.isForumModerator was never set and
that branch could not fire. A moderator appointed on a forum — not an admin,
not a super-moderator, not in a group carrying the columns — saw neither held
nor soft-deleted content anywhere: the queue's links landed on pages that
filtered the item out, and the inline approve and restore controls rendered
with nothing to select.

Authorizer.moderatorTargetIn resolves the actor's standing in one forum into
a target, and contentScopeIn derives the visibility scope from it. The forum
listing and thread pages already resolve that target for their moderation
tools, so they now resolve it before the scope and pass the same one to both;
the thread page's metadata and the two REST call sites take contentScopeIn.
moderatorTargetFor keeps its callers and delegates to the one implementation.

Guests and ordinary members are untouched — moderatorRightsIn returns no
rights without an appointment, and no rights means the public scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kP43A5shJWmAKqkBovhaF
@jouwdan
jouwdan merged commit ed24a52 into main Aug 16, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants