feat(teams): reserved-name screening, auto-hide, and the admin-approval gate
The one place untrusted game data becomes a public page (docs/website/TEAMS.md
§2.8), and the gate on releasing it (§2.9).
A Team's name is written by a player, in the game, with no review, and this
platform turns it into a public page, a URL and eventually a Discord channel
name. Someone naming their guild "Admin" or "<Brand> Staff" gets an
official-looking page on the operator's own site for free.
Hide, never reject. Core cannot refuse a name -- the guild already exists in the
game and core is a mirror of it, not an authority over it. A match hides the Team
from public surfaces and files it in a review queue, and it keeps working
completely for its own members: their forum, their grants, their notifications.
The people in it are not being punished for a name their leader chose.
That asymmetry -- a false positive costs a human glance, a false negative costs
an impersonated staff page -- is what lets the matcher be conservative. It is not
licence to be sloppy the other way: a check that fires on "Badminton" gets
switched off, and then the real cost is paid in full. So matching is whole WORDS
after normalisation, never substrings, following the precedent
scripts/checkModuleIdentifiers.js set for exactly this reason.
Three matcher gaps found by writing the tests, all real impersonation vectors:
- "Guild of Moderators" did not match `moderator`. Only a trailing s off the
WHOLE term is stripped, so "Nomads" still does not match `mod`.
- "G.M." normalises to two single-letter words and matched nothing. A run of
two or more single-letter words is now also offered joined. Deliberately not
a whole-name condensation, which would re-admit substring matching.
- The multi-word condensed form was already handled and is what makes
"RunicGateway" match the two-word term -- the form an impersonator would
reach for, since it is what the Gitea org and every URL use.
Terms resolve at CHECK time, never baked in, so renaming a deployment protects
the new name without a redeploy. A failed settings read falls back to the static
role and project terms rather than to an empty list: screening fewer terms is
bad, screening none is the whole hole.
Re-screening runs on every reconcile, over names no human has ruled on. Names are
immutable per row, so it only ever changes an outcome when the TERM LIST changed
-- an operator adding one, or a rename -- which is exactly what a create-time-only
check would miss forever. `name_reviewed_at` is what makes a staff decision
sticky; without it an override would be undone every fifteen minutes.
The gate is scoped to three actions because they publish untrusted game-sourced
strings, and to nothing else. Ordinary forum grants, leadership overrides,
archives and forum moderation still apply immediately and are audited. A
moderator initiating one files a pending request; an admin applies at once.
Never four-eyes on admins: users.role defaults to admin and `npm run seed`
creates exactly one, so most deployments have precisely one and a second-approver
rule would wedge them with no way out.
Hiding is deliberately NOT gated. Publishing untrusted data needs a second pair
of eyes; withdrawing it needs to be possible at once, by whoever is on duty.
Two concurrency details worth the review: a decision moves the row out of
`pending` under a guard and applies its effect only if the row actually moved,
so two admins clicking approve cannot double-apply or overwrite each other's
record; and a JSON payload is parsed defensively, because the driver returns
JSON columns already parsed on some versions and as a string on others.
Screening is stubbed in the reconciler's own tests -- it is a separate unit, and
the real call reads settings, which this suite must never do against a live
database. That was caught the hard way: the suite went from 11s to hanging, and
the cause was the reconciler reaching a dead pool through the new call.
44 tests in the reconciler file (up from 39), 19 for the matcher, 25 for the
gate. Full suite 877 passed, 0 failed.
Refs docs/website/TEAMS.md §2.8, §2.9, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
123
server/src/model/teams/teamModeration.db.js
Normal file
123
server/src/model/teams/teamModeration.db.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// SQL for the reserved-name review queue and the §2.9 approval queue.
|
||||
|
||||
const { query } = require('../../utils/db')
|
||||
|
||||
// ── The hide/display state on `teams` ──────────────────────────────────────
|
||||
|
||||
async function setHidden(teamId, { hidden, reason, term }) {
|
||||
await query(
|
||||
'UPDATE teams SET hidden = ?, hidden_reason = ?, hidden_term = ? WHERE id = ?',
|
||||
[hidden ? 1 : 0, hidden ? reason : null, hidden ? term || null : null, teamId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a human has decided about this name.
|
||||
*
|
||||
* What makes a staff decision STICKY (§2.8.3). Re-screening runs on every sync,
|
||||
* and without this stamp an operator adding a reserved term — or simply renaming
|
||||
* the deployment — would re-hide a Team staff had already allowed, every fifteen
|
||||
* minutes, forever.
|
||||
*/
|
||||
async function markNameReviewed(teamId) {
|
||||
await query('UPDATE teams SET name_reviewed_at = NOW() WHERE id = ?', [teamId])
|
||||
}
|
||||
|
||||
async function setDisplayNameOverride(teamId, displayName) {
|
||||
await query('UPDATE teams SET display_name_override = ? WHERE id = ?', [displayName, teamId])
|
||||
}
|
||||
|
||||
/** Active teams whose name has never been screened by a human. */
|
||||
async function unreviewedActive(moduleId) {
|
||||
return query(
|
||||
`SELECT id, name, hidden, hidden_reason FROM teams
|
||||
WHERE module_id = ? AND status = 'active' AND name_reviewed_at IS NULL`,
|
||||
[moduleId],
|
||||
)
|
||||
}
|
||||
|
||||
/** The reserved-name review queue (§2.8.3). */
|
||||
async function reviewQueue() {
|
||||
return query(
|
||||
`SELECT id, name, slug, hidden_term, display_name_override, member_count, created_at
|
||||
FROM teams
|
||||
WHERE status = 'active' AND hidden = 1 AND hidden_reason = 'reserved_name' AND name_reviewed_at IS NULL
|
||||
ORDER BY created_at DESC`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── team_moderation_requests (§2.9) ────────────────────────────────────────
|
||||
|
||||
const REQUEST_COLUMNS = `
|
||||
id, team_id, action, payload, reason, requested_by, requested_username, requested_at,
|
||||
status, decided_by, decided_username, decided_at, decision_note`
|
||||
|
||||
async function insertRequest({ teamId, action, payload, reason, requestedBy, requestedUsername }) {
|
||||
const res = await query(
|
||||
`INSERT INTO team_moderation_requests
|
||||
(team_id, action, payload, reason, requested_by, requested_username)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[teamId, action, payload == null ? null : JSON.stringify(payload), reason, requestedBy, requestedUsername],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
async function findRequest(id) {
|
||||
const rows = await query(`SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests WHERE id = ?`, [id])
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** The approval queue. Decided rows are kept — see §2.9 — so `status` is a filter. */
|
||||
async function listRequests({ status = 'pending', limit = 100 } = {}) {
|
||||
const params = []
|
||||
let sql = `SELECT r.${REQUEST_COLUMNS.trim().split(/,\s*/).join(', r.')},
|
||||
t.name AS team_name, t.slug AS team_slug
|
||||
FROM team_moderation_requests r JOIN teams t ON t.id = r.team_id`
|
||||
if (status !== 'all') {
|
||||
sql += ' WHERE r.status = ?'
|
||||
params.push(status)
|
||||
}
|
||||
sql += ' ORDER BY r.requested_at DESC, r.id DESC LIMIT ?'
|
||||
params.push(limit)
|
||||
return query(sql, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a request, but only if it is still pending.
|
||||
*
|
||||
* The `status = 'pending'` guard is the concurrency control: two admins opening
|
||||
* the same queue and both clicking approve would otherwise each apply the action,
|
||||
* and the second would overwrite the first's record of who decided it. The caller
|
||||
* applies the effect only when this reports a row was actually moved.
|
||||
*/
|
||||
async function decideRequest(id, { status, decidedBy, decidedUsername, note }) {
|
||||
const res = await query(
|
||||
`UPDATE team_moderation_requests
|
||||
SET status = ?, decided_by = ?, decided_username = ?, decided_at = NOW(), decision_note = ?
|
||||
WHERE id = ? AND status = 'pending'`,
|
||||
[status, decidedBy, decidedUsername, note, id],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
/** Pending requests for one team — shown on its admin page so a second is not filed. */
|
||||
async function pendingForTeam(teamId) {
|
||||
return query(
|
||||
`SELECT ${REQUEST_COLUMNS} FROM team_moderation_requests
|
||||
WHERE team_id = ? AND status = 'pending' ORDER BY requested_at`,
|
||||
[teamId],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setHidden,
|
||||
markNameReviewed,
|
||||
setDisplayNameOverride,
|
||||
unreviewedActive,
|
||||
reviewQueue,
|
||||
insertRequest,
|
||||
findRequest,
|
||||
listRequests,
|
||||
decideRequest,
|
||||
pendingForTeam,
|
||||
}
|
||||
239
server/src/model/teams/teamModeration.model.js
Normal file
239
server/src/model/teams/teamModeration.model.js
Normal file
@@ -0,0 +1,239 @@
|
||||
// ── Impersonation controls, and the approval gate on them ──────────────────
|
||||
//
|
||||
// TEAMS.md §2.8–§2.9. Two things live here:
|
||||
//
|
||||
// 1. **Auto-hide**, which turns a reserved-name match into a suppressed Team
|
||||
// and a review queue entry rather than into a refusal. Core cannot refuse a
|
||||
// name — the guild exists in the game and core is a mirror of it.
|
||||
//
|
||||
// 2. **The approval gate**, which is scoped to the three actions that RELEASE
|
||||
// untrusted game-sourced strings onto public surfaces, and to nothing else.
|
||||
//
|
||||
// **The gate's scope is the part most likely to be misread.** It is not a general
|
||||
// staff-approval workflow. Ordinary forum grants, leadership overrides, archives
|
||||
// and forum moderation all still apply immediately and are audited, exactly as
|
||||
// before. Three actions are gated, and the question that admits a fourth is
|
||||
// always the same one: *does this publish untrusted game data?*
|
||||
//
|
||||
// - clearing a reserved_name hide — publishes a name that tripped the list
|
||||
// - setting a display_name_override — substitutes free text into the same
|
||||
// public surfaces
|
||||
// - un-hiding a staff-hidden Team — reverses a deliberate suppression
|
||||
//
|
||||
// **Moderator-initiated, admin-approved — never four-eyes on admins.** `users.role`
|
||||
// defaults to admin and `npm run seed` creates exactly one, so most deployments
|
||||
// have precisely one admin. A rule requiring a second would wedge them with no
|
||||
// way out, which is a worse failure than the one it guards against.
|
||||
|
||||
const moderationDb = require('./teamModeration.db')
|
||||
const teamsDb = require('./teams.db')
|
||||
const reservedNames = require('../../utils/reservedNames')
|
||||
const activity = require('../activity/activity.model')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
|
||||
const GATED_ACTIONS = ['unhide', 'display_name_override', 'clear_display_name_override']
|
||||
|
||||
const isAdmin = (actor) => Boolean(actor) && actor.role === 'admin'
|
||||
|
||||
/**
|
||||
* Screen a name and return the columns a create should carry.
|
||||
*
|
||||
* Never throws: screening reads settings, and a database hiccup during a
|
||||
* reconcile must not stop a Team being created. It fails OPEN on the create — the
|
||||
* Team appears — because the re-screen on the next sync will catch it, and a
|
||||
* reconcile that aborts halfway is worse than a name that is public for one
|
||||
* interval. That is a deliberate trade and it is the reason re-screening exists
|
||||
* at all rather than being a create-time-only check.
|
||||
*/
|
||||
async function screenForCreate(name) {
|
||||
try {
|
||||
const { reserved, term } = await reservedNames.screen(name)
|
||||
if (!reserved) return { hidden: false }
|
||||
log.warn('team auto-hidden: its name matched a reserved term', { name, term })
|
||||
return { hidden: true, hiddenReason: 'reserved_name', hiddenTerm: term }
|
||||
} catch (err) {
|
||||
log.error('reserved-name screening failed; the team is created unscreened', {
|
||||
name, message: err.message,
|
||||
})
|
||||
return { hidden: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-screen every active Team whose name no human has ruled on.
|
||||
*
|
||||
* Names are immutable per row, so this only ever changes an outcome when the TERM
|
||||
* LIST changed — an operator adding a term, or the deployment being renamed. That
|
||||
* is precisely the case a create-time-only check would miss forever.
|
||||
*
|
||||
* A Team staff have already decided about is skipped, and that stickiness is the
|
||||
* point: without it, an override would be undone on the next sweep.
|
||||
*/
|
||||
async function rescreen(moduleId) {
|
||||
let hidden = 0
|
||||
try {
|
||||
const rows = await moderationDb.unreviewedActive(moduleId)
|
||||
for (const row of rows) {
|
||||
if (row.hidden) continue
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { reserved, term } = await reservedNames.screen(row.name)
|
||||
if (!reserved) continue
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await moderationDb.setHidden(row.id, { hidden: true, reason: 'reserved_name', term })
|
||||
hidden += 1
|
||||
log.warn('team hidden by a re-screen: the reserved terms changed', { id: row.id, name: row.name, term })
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('re-screening failed', { message: err.message })
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
// ── The three gated actions ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply a gated action, or file it for approval.
|
||||
*
|
||||
* The role check is answered LIVE against the database on every request by core's
|
||||
* admin middleware, so "is this caller an admin" is not read from a token claim
|
||||
* that a demotion would not have invalidated.
|
||||
*/
|
||||
async function requestOrApply({ req, actor, teamId, action, payload, reason }) {
|
||||
if (!GATED_ACTIONS.includes(action)) throw new Error(`not a gated action: "${action}"`)
|
||||
const team = await teamsDb.findById(teamId)
|
||||
if (!team) return { ok: false, status: 404, error: 'team not found' }
|
||||
|
||||
if (!isAdmin(actor)) {
|
||||
const id = await moderationDb.insertRequest({
|
||||
teamId, action, payload, reason, requestedBy: actor.id, requestedUsername: actor.username,
|
||||
})
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.moderation.request',
|
||||
detail: `${actor.username} (#${actor.id}) requested "${action}" on team "${team.name}" (#${teamId})`
|
||||
+ `${reason ? `: "${reason}"` : ''}`,
|
||||
})
|
||||
return { ok: true, pending: true, requestId: id }
|
||||
}
|
||||
|
||||
await applyAction({ req, actor, team, action, payload, reason })
|
||||
return { ok: true, pending: false }
|
||||
}
|
||||
|
||||
/** The effect itself. Reached by an admin directly, or by an approval. */
|
||||
async function applyAction({ req, actor, team, action, payload, reason }) {
|
||||
switch (action) {
|
||||
case 'unhide':
|
||||
await moderationDb.setHidden(team.id, { hidden: false })
|
||||
// A human has now ruled on this name, so no later sweep re-hides it.
|
||||
await moderationDb.markNameReviewed(team.id)
|
||||
break
|
||||
case 'display_name_override':
|
||||
await moderationDb.setDisplayNameOverride(team.id, payload.displayName)
|
||||
await moderationDb.markNameReviewed(team.id)
|
||||
break
|
||||
case 'clear_display_name_override':
|
||||
await moderationDb.setDisplayNameOverride(team.id, null)
|
||||
break
|
||||
default:
|
||||
throw new Error(`not a gated action: "${action}"`)
|
||||
}
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: `team.${action}`,
|
||||
detail: `${actor.username} (#${actor.id}) applied "${action}" to team "${team.name}" (#${team.id})`
|
||||
+ `${payload && payload.displayName ? ` as "${payload.displayName}"` : ''}`
|
||||
+ `${reason ? `: "${reason}"` : ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a Team. NOT gated — suppression is always safe (§2.11).
|
||||
*
|
||||
* The asymmetry is the whole design: publishing untrusted data needs a second
|
||||
* pair of eyes, and withdrawing it needs to be possible at once, by whoever is
|
||||
* on duty.
|
||||
*/
|
||||
async function hide({ req, actor, teamId, reason }) {
|
||||
const team = await teamsDb.findById(teamId)
|
||||
if (!team) return { ok: false, status: 404, error: 'team not found' }
|
||||
await moderationDb.setHidden(teamId, { hidden: true, reason: 'staff' })
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.hide',
|
||||
detail: `${actor.username} (#${actor.id}) hid team "${team.name}" (#${teamId})`
|
||||
+ `${reason ? `: "${reason}"` : ''}`,
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Admin only.
|
||||
*
|
||||
* The effect is applied only when the row actually moved out of `pending`, so two
|
||||
* admins deciding the same request race safely: the second is told it was already
|
||||
* decided rather than applying the action a second time.
|
||||
*/
|
||||
async function decide({ req, actor, requestId, status, note }) {
|
||||
if (!isAdmin(actor)) return { ok: false, status: 403, error: 'only an admin may decide a request' }
|
||||
if (!['approved', 'rejected'].includes(status)) {
|
||||
return { ok: false, status: 400, error: 'status must be approved or rejected' }
|
||||
}
|
||||
|
||||
const request = await moderationDb.findRequest(requestId)
|
||||
if (!request) return { ok: false, status: 404, error: 'request not found' }
|
||||
if (request.status !== 'pending') {
|
||||
return { ok: false, status: 409, error: `request is already ${request.status}` }
|
||||
}
|
||||
|
||||
const moved = await moderationDb.decideRequest(requestId, {
|
||||
status, decidedBy: actor.id, decidedUsername: actor.username, note,
|
||||
})
|
||||
if (!moved) return { ok: false, status: 409, error: 'request was decided by someone else' }
|
||||
|
||||
const team = await teamsDb.findById(request.team_id)
|
||||
if (status === 'approved' && team) {
|
||||
await applyAction({
|
||||
req,
|
||||
actor,
|
||||
team,
|
||||
action: request.action,
|
||||
payload: parsePayload(request.payload),
|
||||
reason: request.reason,
|
||||
})
|
||||
}
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: `team.moderation.${status}`,
|
||||
detail: `${actor.username} (#${actor.id}) ${status} request #${requestId} `
|
||||
+ `("${request.action}" on team #${request.team_id}, asked by ${request.requested_username || 'a deleted user'})`
|
||||
+ `${note ? `: "${note}"` : ''}`,
|
||||
})
|
||||
return { ok: true, applied: status === 'approved' }
|
||||
}
|
||||
|
||||
// The driver returns JSON columns already parsed on some versions and as a string
|
||||
// on others, so this normalises rather than assuming either.
|
||||
function parsePayload(payload) {
|
||||
if (payload == null) return {}
|
||||
if (typeof payload === 'object') return payload
|
||||
try {
|
||||
return JSON.parse(payload)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
screenForCreate,
|
||||
rescreen,
|
||||
requestOrApply,
|
||||
hide,
|
||||
decide,
|
||||
reviewQueue: moderationDb.reviewQueue,
|
||||
listRequests: moderationDb.listRequests,
|
||||
pendingForTeam: moderationDb.pendingForTeam,
|
||||
GATED_ACTIONS,
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
const teamsDb = require('./teams.db')
|
||||
const teamProvider = require('./teamProvider')
|
||||
const moderation = require('./teamModeration.model')
|
||||
const { slugify, uniqueSlug } = require('./teamSlug')
|
||||
const settings = require('../settings/settings.model')
|
||||
const log = require('../../utils/logger')('teams')
|
||||
@@ -95,17 +96,23 @@ function backoffSeconds(consecutiveFailures, intervalS) {
|
||||
// ── Applying one Team ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the row for a Team core has not seen, deriving its slug.
|
||||
* Create the row for a Team core has not seen, deriving its slug and screening
|
||||
* its name against the reserved list (§2.8).
|
||||
*
|
||||
* Screening the name against the reserved list happens here in a later commit;
|
||||
* the row is created either way, because core cannot refuse a name — the guild
|
||||
* already exists in the game and core is a mirror of it, not an authority over it.
|
||||
* The row is created whatever the screening says, and hidden if it matched. Core
|
||||
* cannot refuse a name: the guild already exists in the game and core is a mirror
|
||||
* of it, not an authority over it. A hidden Team is absent from public surfaces
|
||||
* and completely functional for its own members — the people in it are not being
|
||||
* punished for a name their leader chose.
|
||||
*/
|
||||
async function createTeam(moduleId, team) {
|
||||
const taken = await teamsDb.slugsLike(slugify(team.name) || 'team')
|
||||
const slug = uniqueSlug(team.name, taken)
|
||||
const id = await teamsDb.insertTeam({ moduleId, slug, ...team })
|
||||
log.info('team created', { moduleId, externalId: team.externalId, name: team.name, slug })
|
||||
const screened = await moderation.screenForCreate(team.name)
|
||||
const id = await teamsDb.insertTeam({ moduleId, slug, ...team, ...screened })
|
||||
log.info('team created', {
|
||||
moduleId, externalId: team.externalId, name: team.name, slug, hidden: Boolean(screened.hidden),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -291,9 +298,17 @@ async function runOnce(reason) {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-screen the names no human has ruled on. Names are immutable per row, so
|
||||
// this only changes an outcome when the reserved TERMS changed — an operator
|
||||
// adding one, or the deployment being renamed — which is exactly the case a
|
||||
// create-time-only check would miss forever.
|
||||
const rehidden = await moderation.rescreen(moduleId)
|
||||
|
||||
await teamsDb.recordSuccess(moduleId)
|
||||
log.info('reconcile complete', { trigger: reason, created, renamed, archived, rosters, total: answer.teams.length })
|
||||
return { ok: true, created, renamed, archived, rosters }
|
||||
log.info('reconcile complete', {
|
||||
trigger: reason, created, renamed, archived, rosters, rehidden, total: answer.teams.length,
|
||||
})
|
||||
return { ok: true, created, renamed, archived, rosters, rehidden }
|
||||
}
|
||||
|
||||
// ── The public entry points ────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user