feat(safety): report, block and hide, MODULE_API 1.21.0
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 6m11s
PR Checks / client-build (pull_request) Successful in 49s

USER_SAFETY.md step 2 (core), with S11-S14 decided while building it.

- Reports: POST /player/reports takes any target type, core's forum
  types or a module's registered ones. One resolver
  (model/safety/targets.js) answers "what is this item, for this
  reader" and 404s an item the reader cannot see. Every report stores
  content_reports.snapshot; the queue shows a module item from it.
- Hide for everyone: POST /admin/moderation/reports/:id/hide hides the
  item through its own moderation (forum hide as staff, or the module's
  hide) and actions the report with actioned_with = 'hide'.
- Blocks (user_blocks) name an item, never a person, and land on the
  author's account first (S12); an unlinked game identity is blocked as
  itself and becomes an account block once linked (S14). Staff cannot
  be blocked; a block on someone later promoted stops applying (S8).
- Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden.
- Forum reads filter blocked authors and hidden items on the server and
  carry canBlock per thread and post.
- MODULE_API 1.21.0: api.registerReportTargets,
  api.registerGameIdentities, ctx.safety.forViewer and
  ctx.safety.staffAmong, so a module filters its own per-viewer feeds on
  the server (S11) and marks lines canBlock (S13).
- Web: Hide for me / Block author in the forum menus, a Blocked section
  on both account pages, Hide for everyone in the Reports queue.
- Tests, OpenAPI, route/guard/engagement manifests regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-30 05:41:00 -05:00
parent 7bd93b8bb0
commit 41a1e84b51
40 changed files with 3974 additions and 56 deletions

View File

@@ -21,7 +21,8 @@
const forumDb = require('./teamForum.db')
const forumSettings = require('./teamForumSettings.model')
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
const { displayAuthor } = require('../../utils/displayAuthor')
const { displayAuthor, STAFF_ROLES } = require('../../utils/displayAuthor')
const safetyDb = require('../safety/userSafety.db')
// Announcements are leader-authored and take no replies; discussion threads are
// member-authored and do. Both have been in the enum since 5a — what phase 5
@@ -158,9 +159,42 @@ function renderPost(row, mode, viewer) {
* same call answers both audiences without a second endpoint that could disagree
* with this one.
*/
async function listThreads(teamId, { canModerate = false, viewer = null, limit = 50, offset = 0 } = {}) {
async function listThreads(teamId, { canModerate = false, viewer = null, filter = null, limit = 50, offset = 0 } = {}) {
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
return rows.map((row) => publicThread(row, viewer))
return rows.filter((row) => !leftOutThread(row, filter)).map((row) => publicThread(row, viewer))
}
// ── one reader's blocks and hides (USER_SAFETY.md §4.1) ───────────────────
//
// `filter` is `userSafety.forumFilter(userId)`: the accounts this reader blocks
// and the threads and posts they hid. Applied on the server, to this reader's
// read only — nothing on disk changes, and nobody else's view does (S9).
/** Does this reader's filter leave this thread out? */
function leftOutThread(row, filter) {
if (!filter) return false
if (filter.hidden.team_forum_thread.has(Number(row.id))) return true
return row.created_by != null && filter.blockedUserIds.has(Number(row.created_by))
}
/**
* May this reader block these authors? Decided here and handed to the client,
* like `canEdit`: not yourself, not a deleted account, and not staff (S8) —
* the menu does not offer what the server would refuse.
*/
async function blockable(viewer, authorIds) {
const me = viewer && viewer.userId
if (!me) return () => false
const ids = [...new Set(authorIds.filter((id) => id != null && id !== me).map(Number))]
const staff = new Set(ids.length ? await safetyDb.staffAmong(ids, STAFF_ROLES) : [])
return (id) => id != null && Number(id) !== Number(me) && !staff.has(Number(id))
}
/** Does this reader's filter leave this post out? */
function leftOutPost(row, filter) {
if (!filter) return false
if (filter.hidden.team_forum_post.has(Number(row.id))) return true
return row.author_user_id != null && filter.blockedUserIds.has(Number(row.author_user_id))
}
/**
@@ -172,7 +206,7 @@ async function listThreads(teamId, { canModerate = false, viewer = null, limit =
* the window to the client and letting it compare timestamps — is the thing
* `editability` exists not to do.
*/
async function getThread(teamId, threadId, { canModerate = false, viewer } = {}) {
async function getThread(teamId, threadId, { canModerate = false, viewer, filter = null } = {}) {
const thread = await forumDb.threadById(threadId)
// The team check is here rather than in the SQL so a thread id from another
// Team reads as "not found" and not as "found, but not yours" — a forum is a
@@ -180,17 +214,23 @@ async function getThread(teamId, threadId, { canModerate = false, viewer } = {})
if (!thread || thread.team_id !== teamId) return null
if (thread.status === 'deleted' && !canModerate) return null
if (thread.status === 'hidden' && !canModerate) return null
// A thread this reader hid, or opened by someone they block, is gone from
// their view — a link to it included.
if (leftOutThread(thread, filter)) return null
const mode = await forumSettings.imageMode()
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
const posts = (await forumDb.postsByThread(threadId, { includeHidden: canModerate }))
.filter((p) => !leftOutPost(p, filter))
const canBlock = await blockable(viewer, [thread.created_by, ...posts.map((p) => p.author_user_id)])
return {
...publicThread(thread, viewer),
canBlock: canBlock(thread.created_by),
// A reply control is offered when the TYPE takes replies and the thread is
// open. Both halves are reported separately (`type`, `locked`) so the UI can
// say which one is why, but the decision itself is made here — a client that
// recomputed it would be a second place for the rule to live.
canReply: REPLYABLE_TYPES.includes(thread.type) && !thread.locked && thread.status === 'visible',
posts: posts.map((p) => renderPost(p, mode, viewer)),
posts: posts.map((p) => ({ ...renderPost(p, mode, viewer), canBlock: canBlock(p.author_user_id) })),
}
}
@@ -442,4 +482,6 @@ module.exports = {
publicThread,
renderPost,
editability,
leftOutThread,
leftOutPost,
}