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:
212
server/src/utils/reservedNames.js
Normal file
212
server/src/utils/reservedNames.js
Normal file
@@ -0,0 +1,212 @@
|
||||
// ── Reserved-name screening ────────────────────────────────────────────────
|
||||
//
|
||||
// The one place untrusted game data becomes a public page (TEAMS.md §2.8).
|
||||
//
|
||||
// A Team's name is written by a player, inside the game, with no review, and the
|
||||
// platform then turns it into a public page, a URL, a nav-reachable entity and
|
||||
// eventually a Discord channel name. Someone naming their guild "Admin",
|
||||
// "Moderator" or "<Brand> Staff" gets an official-looking page on the operator's
|
||||
// own site for free, by typing a name into a guild stone.
|
||||
//
|
||||
// **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 puts it in a review queue, and it keeps
|
||||
// working completely for its own members — the people in it are not being
|
||||
// punished for a name their leader chose.
|
||||
//
|
||||
// That asymmetry is what lets this matcher be conservative without being clever:
|
||||
// **a false positive costs a human glance, a false negative costs an impersonated
|
||||
// staff page.**
|
||||
//
|
||||
// NOT `filter_words`. That table exists but is bot-owned (its own pool, never
|
||||
// read by the website — MODERATION_APPEALS.md §2), and it is a profanity filter,
|
||||
// which is a different question with a different answer. Reusing it would cross
|
||||
// an ownership boundary to get the wrong list.
|
||||
//
|
||||
// Also NOT `auth/usernamePolicy.js`'s RESERVED_USERNAMES. That list answers
|
||||
// "may someone register under this handle", matched exactly against a whole
|
||||
// username; this one answers "does this phrase impersonate authority", matched
|
||||
// word by word inside a name that is usually several words long. Sharing them
|
||||
// would give each question the other's answer — "Support" is a fine guild name
|
||||
// and an unacceptable username.
|
||||
|
||||
const brand = require('../config/brand')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const log = require('./logger')('teams')
|
||||
|
||||
// The `users.role` enum plus the words people actually use for those roles. Kept
|
||||
// here rather than derived from the enum alone, because 'gm' and 'staff' are not
|
||||
// roles in the database and are exactly what a would-be impersonator reaches for.
|
||||
const ROLE_TERMS = [
|
||||
'admin', 'editor', 'moderator', 'player',
|
||||
'staff', 'administrator', 'mod', 'owner', 'gm',
|
||||
]
|
||||
|
||||
// Impersonating the software project is as much a problem as impersonating the
|
||||
// operator. Stored in its correct two-word form; §2.8.2's whitespace-insensitive
|
||||
// comparison is what also catches RunicGateway, runic-gateway and Runic_Gateway.
|
||||
const PROJECT_TERMS = ['Runic Gateway']
|
||||
|
||||
const OPERATOR_TERMS_KEY = 'teams_reserved_terms'
|
||||
|
||||
/**
|
||||
* Case-fold, strip punctuation, collapse repeats and whitespace.
|
||||
*
|
||||
* Repeated characters are squeezed so "Adminnn" folds to "admin". Deliberately
|
||||
* NO leet-speak folding in v1 (`4dm1n`): it multiplies false positives, and the
|
||||
* consequence of a miss is a Team hidden by a human rather than a breach.
|
||||
*/
|
||||
function normalise(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]+/g, ' ')
|
||||
.replace(/(.)\1{1,}/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const words = (value) => (value ? value.split(' ') : [])
|
||||
|
||||
/**
|
||||
* The words of a name, plus the acronyms its punctuation was hiding.
|
||||
*
|
||||
* "G.M." normalises to `g m`, and neither token is the reserved term `gm` — so a
|
||||
* run of two or more single-letter words is ALSO offered as one joined token.
|
||||
* "GM" is a live impersonation vector on a game server, and spelling it with dots
|
||||
* is the obvious way around a word-level check.
|
||||
*
|
||||
* The individual letters are kept as well as the joined form, so this only ever
|
||||
* adds matches. And the join is deliberately not the whole-name condensation used
|
||||
* for multi-word terms: condensing every name would let a single-word term match
|
||||
* inside an ordinary word again, which is the substring matching this whole design
|
||||
* refuses.
|
||||
*/
|
||||
function tokens(normalised) {
|
||||
const list = words(normalised)
|
||||
const out = [...list]
|
||||
let run = []
|
||||
const flush = () => {
|
||||
if (run.length > 1) out.push(run.join(''))
|
||||
run = []
|
||||
}
|
||||
for (const word of list) {
|
||||
if (word.length === 1) run.push(word)
|
||||
else flush()
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-word term matches a name word, or that word's singular.
|
||||
*
|
||||
* A guild called "Moderators" impersonates staff exactly as much as one called
|
||||
* "Moderator", and a check that misses the plural misses the more natural name of
|
||||
* the two. Only a trailing `s` is stripped, and only when the remainder is the
|
||||
* whole term — so "Nomads" still does not match "mod" and "Playerless" still does
|
||||
* not match "player".
|
||||
*/
|
||||
const wordMatches = (word, term) =>
|
||||
word === term || (word.length > 1 && word.endsWith('s') && word.slice(0, -1) === term)
|
||||
|
||||
/**
|
||||
* Every reserved term for this deployment, resolved AT CHECK TIME.
|
||||
*
|
||||
* Never baked in: the brand is runtime configuration, so a deployment that
|
||||
* renames itself must be protected under its new name without a redeploy.
|
||||
*
|
||||
* A settings read that fails must not open the gate, so a failure falls back to
|
||||
* the static terms rather than to an empty list — screening fewer terms is bad,
|
||||
* screening none is the whole hole.
|
||||
*/
|
||||
async function reservedTerms() {
|
||||
const terms = [...ROLE_TERMS, ...PROJECT_TERMS]
|
||||
|
||||
try {
|
||||
const instanceName = await settings.getInstanceName()
|
||||
if (instanceName) terms.push(instanceName)
|
||||
} catch (err) {
|
||||
log.warn('could not resolve the instance name for reserved-name screening', { message: err.message })
|
||||
}
|
||||
|
||||
if (brand.name) terms.push(brand.name)
|
||||
if (brand.shortName) terms.push(brand.shortName)
|
||||
|
||||
try {
|
||||
const extra = await settings.get(OPERATOR_TERMS_KEY)
|
||||
if (extra) terms.push(...String(extra).split(',').map((t) => t.trim()).filter(Boolean))
|
||||
} catch (err) {
|
||||
log.warn('could not read operator reserved terms', { message: err.message })
|
||||
}
|
||||
|
||||
// De-duplicated on the normalised form: the brand and an operator term are
|
||||
// frequently the same word, and reporting the same match twice is noise in a
|
||||
// review queue.
|
||||
const seen = new Set()
|
||||
return terms.filter((term) => {
|
||||
const key = normalise(term)
|
||||
if (!key || seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `name` contain `term`?
|
||||
*
|
||||
* Whole WORDS, after normalisation — never substrings. Core already has the
|
||||
* precedent and the scar tissue for this: scripts/checkModuleIdentifiers.js
|
||||
* tokenises and compares word by word precisely so `defaultImage` does not match
|
||||
* "ultIma". The same discipline applies for the same reason — a substring match
|
||||
* flags "Badminton" for containing "admin", and a check that cries wolf is a
|
||||
* check people switch off.
|
||||
*
|
||||
* A MULTI-WORD term is additionally compared with the whitespace removed on both
|
||||
* sides, so "Runic Gateway" matches "RunicGateway". Without that the whole-word
|
||||
* rule fails on exactly the case that matters: the condensed form is a SINGLE
|
||||
* word and could never match a two-word term — and it is the form an impersonator
|
||||
* would reach for, because it is what the Gitea org and every URL already use.
|
||||
*
|
||||
* The widening applies only to terms containing whitespace, which keeps it away
|
||||
* from the single-word terms where whole-word matching is doing the false-positive
|
||||
* work. A two-word term is specific enough that running its letters together
|
||||
* cannot collide with ordinary vocabulary.
|
||||
*/
|
||||
function matches(nameWords, condensedName, term) {
|
||||
const normalisedTerm = normalise(term)
|
||||
if (!normalisedTerm) return false
|
||||
const termWords = words(normalisedTerm)
|
||||
|
||||
if (termWords.length === 1) return nameWords.some((w) => wordMatches(w, termWords[0]))
|
||||
|
||||
// A multi-word term matches as a consecutive run of words …
|
||||
for (let i = 0; i + termWords.length <= nameWords.length; i++) {
|
||||
if (termWords.every((w, j) => nameWords[i + j] === w)) return true
|
||||
}
|
||||
// … or as its condensed form appearing as a whole word in the condensed name.
|
||||
const condensedTerm = termWords.join('')
|
||||
return condensedName.includes(condensedTerm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen a name. Returns `{ reserved, term }` — `term` is the term that matched,
|
||||
* in its stored form, which is what the review queue shows a human.
|
||||
*/
|
||||
async function screen(name) {
|
||||
const normalised = normalise(name)
|
||||
if (!normalised) return { reserved: false, term: null }
|
||||
|
||||
const nameWords = tokens(normalised)
|
||||
// The condensed name is the whole thing with spaces removed, so a multi-word
|
||||
// term can be found inside a run-together name.
|
||||
const condensed = words(normalised).join('')
|
||||
|
||||
for (const term of await reservedTerms()) {
|
||||
if (matches(nameWords, condensed, term)) return { reserved: true, term }
|
||||
}
|
||||
return { reserved: false, term: null }
|
||||
}
|
||||
|
||||
module.exports = { screen, normalise, reservedTerms, ROLE_TERMS, PROJECT_TERMS, OPERATOR_TERMS_KEY }
|
||||
Reference in New Issue
Block a user