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,
|
||||
}
|
||||
Reference in New Issue
Block a user