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>
193 lines
8.6 KiB
JavaScript
193 lines
8.6 KiB
JavaScript
// Reserved-name screening (docs/website/TEAMS.md §2.8).
|
|
//
|
|
// Two failure modes with very different costs, and the tests are split along
|
|
// that line:
|
|
//
|
|
// - a FALSE NEGATIVE puts an official-looking staff page on the operator's own
|
|
// site, written by whoever typed a name into a guild stone;
|
|
// - a FALSE POSITIVE hides a legitimate guild until a human glances at a queue.
|
|
//
|
|
// The second is cheap and recoverable, which is what lets the matcher be
|
|
// conservative. It is not licence to be sloppy in the other direction: a check
|
|
// that fires on "Badminton" is a check the operator switches off, and then the
|
|
// first cost is paid in full.
|
|
const { test, beforeEach, afterEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const settings = require('../src/model/settings/settings.model')
|
|
const brand = require('../src/config/brand')
|
|
const reserved = require('../src/utils/reservedNames')
|
|
|
|
const saved = []
|
|
function patch(mod, name, fn) {
|
|
saved.push([mod, name, mod[name]])
|
|
mod[name] = fn
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// A deployment with a two-word brand and no operator additions, which is the
|
|
// shape that exercises the condensed-form rule.
|
|
patch(settings, 'getInstanceName', async () => 'UO Mysticmoon')
|
|
patch(settings, 'get', async () => null)
|
|
})
|
|
|
|
afterEach(() => {
|
|
while (saved.length) {
|
|
const [mod, name, fn] = saved.pop()
|
|
mod[name] = fn
|
|
}
|
|
})
|
|
|
|
const isReserved = async (name) => (await reserved.screen(name)).reserved
|
|
const termFor = async (name) => (await reserved.screen(name)).term
|
|
|
|
// ── The names this exists to catch ─────────────────────────────────────────
|
|
|
|
test('bare role names are reserved', async () => {
|
|
for (const name of ['Admin', 'admin', 'ADMIN', 'Moderator', 'Staff', 'Owner', 'GM', 'Administrator']) {
|
|
assert.equal(await isReserved(name), true, `"${name}" must not become a public page`)
|
|
}
|
|
})
|
|
|
|
test('a role word inside a longer name is caught', async () => {
|
|
for (const name of ['The Admin Team', 'Server Staff', 'GM Council', 'Guild of Moderators']) {
|
|
assert.equal(await isReserved(name), true, `"${name}" is the impersonation this exists for`)
|
|
}
|
|
})
|
|
|
|
test('the deployment brand is reserved, in both presentations', async () => {
|
|
assert.equal(await isReserved('UO Mysticmoon'), true)
|
|
assert.equal(await isReserved('UOMysticmoon'), true, 'the condensed form is what an impersonator types')
|
|
assert.equal(await isReserved('uo-mysticmoon'), true)
|
|
assert.equal(await isReserved('UO_MYSTICMOON'), true)
|
|
assert.equal(await isReserved('UOMysticmoon Staff'), true)
|
|
})
|
|
|
|
test('the project name is reserved, in both of its legitimate presentations', async () => {
|
|
// "Runic Gateway" is correct; "RunicGateway" is what the Gitea org and every
|
|
// URL segment use, so it is the form someone would copy.
|
|
assert.equal(await isReserved('Runic Gateway'), true)
|
|
assert.equal(await isReserved('RunicGateway'), true)
|
|
assert.equal(await isReserved('runic-gateway'), true)
|
|
assert.equal(await isReserved('Runic_Gateway'), true)
|
|
assert.equal(await isReserved('RUNIC GATEWAY'), true)
|
|
})
|
|
|
|
test('operator additions are honoured', async () => {
|
|
patch(settings, 'get', async (key) => (key === reserved.OPERATOR_TERMS_KEY ? 'Council, Arbiter' : null))
|
|
assert.equal(await isReserved('The Council'), true)
|
|
assert.equal(await isReserved('Arbiter'), true)
|
|
})
|
|
|
|
test('repeated characters are squeezed', async () => {
|
|
assert.equal(await isReserved('Adminnn'), true)
|
|
assert.equal(await isReserved('Staaaff'), true)
|
|
})
|
|
|
|
test('punctuation between words does not evade the check', async () => {
|
|
assert.equal(await isReserved('[Admin]'), true)
|
|
assert.equal(await isReserved('~*~ Staff ~*~'), true)
|
|
assert.equal(await isReserved('G.M.'), true)
|
|
})
|
|
|
|
test('the matched term is reported, for the review queue', async () => {
|
|
assert.equal(await termFor('The Admin Team'), 'admin')
|
|
assert.equal(await termFor('UOMysticmoon'), 'UO Mysticmoon', 'shown in its stored form, not the input')
|
|
})
|
|
|
|
// ── The names it must NOT catch ────────────────────────────────────────────
|
|
|
|
test('a word merely CONTAINING a reserved term is not reserved', async () => {
|
|
// The scar tissue this rule comes from: checkModuleIdentifiers.js tokenises
|
|
// precisely so `defaultImage` does not match "ultIma".
|
|
for (const name of ['Badminton', 'Badminton Club', 'Modest Proposal', 'Gmork', 'Playerless']) {
|
|
assert.equal(await isReserved(name), false, `"${name}" is a false positive that would discredit the check`)
|
|
}
|
|
})
|
|
|
|
test('ordinary guild names pass', async () => {
|
|
for (const name of [
|
|
'The Silver Hand', 'Knights of the Round', 'Dread Pirates', 'Moonlight Traders',
|
|
'The Guardians', 'Iron Wolves',
|
|
]) {
|
|
assert.equal(await isReserved(name), false, `"${name}" is an ordinary guild`)
|
|
}
|
|
})
|
|
|
|
test('the condensed-form widening applies only to multi-word terms', async () => {
|
|
// Running the letters together is safe for a two-word term because it is
|
|
// specific; doing it for single-word terms is what would re-introduce
|
|
// substring matching through the back door.
|
|
assert.equal(await isReserved('Badminton'), false)
|
|
assert.equal(await isReserved('Grandmaster'), false, 'contains "gm" only as a substring')
|
|
assert.equal(await isReserved('Nomads'), false, 'contains "mod" only as a substring')
|
|
})
|
|
|
|
test('an empty or unusable name is not reserved', async () => {
|
|
for (const name of ['', ' ', null, undefined, '★☆★']) {
|
|
assert.equal(await isReserved(name), false)
|
|
}
|
|
})
|
|
|
|
// ── Resolution is at check time, and fails safe ────────────────────────────
|
|
|
|
test('the brand is resolved at CHECK time, so a rename protects the new name', async () => {
|
|
patch(settings, 'getInstanceName', async () => 'Dragonspire')
|
|
assert.equal(await isReserved('Dragonspire'), true)
|
|
|
|
patch(settings, 'getInstanceName', async () => 'Emberfall')
|
|
assert.equal(await isReserved('Emberfall'), true, 'no redeploy should be needed to protect a new brand')
|
|
})
|
|
|
|
test('a failed settings read falls back to the static terms rather than to none', async () => {
|
|
// Screening fewer terms is bad; screening none is the entire hole.
|
|
patch(settings, 'getInstanceName', async () => { throw new Error('db down') })
|
|
patch(settings, 'get', async () => { throw new Error('db down') })
|
|
|
|
assert.equal(await isReserved('Admin'), true, 'the role list must survive a database outage')
|
|
assert.equal(await isReserved('Runic Gateway'), true)
|
|
})
|
|
|
|
test('BRAND_NAME is covered even when no site_title is set', async () => {
|
|
patch(settings, 'getInstanceName', async () => null)
|
|
assert.equal(await isReserved(brand.name), true)
|
|
})
|
|
|
|
test('the same term resolved twice is listed once', async () => {
|
|
// The brand and an operator term are frequently the same word, and reporting
|
|
// one match twice is noise in a queue a human reads.
|
|
patch(settings, 'getInstanceName', async () => 'Dragonspire')
|
|
patch(settings, 'get', async (key) => (key === reserved.OPERATOR_TERMS_KEY ? 'dragonspire' : null))
|
|
const terms = await reserved.reservedTerms()
|
|
const normalised = terms.map((t) => reserved.normalise(t))
|
|
assert.equal(new Set(normalised).size, normalised.length)
|
|
})
|
|
|
|
test('normalise folds case, diacritics and punctuation', () => {
|
|
assert.equal(reserved.normalise('Ünderdärk!'), 'underdark')
|
|
assert.equal(reserved.normalise(' The Silver Hand '), 'the silver hand')
|
|
assert.equal(reserved.normalise('Adminnn'), 'admin', 'repeats are squeezed on both sides')
|
|
})
|
|
|
|
test('plurals are caught, and near-misses are not', async () => {
|
|
// "Moderators" is the more natural guild name of the two, so missing it would
|
|
// miss the likelier case.
|
|
for (const name of ['Moderators', 'The Admins', 'Guild of Moderators', 'Owners']) {
|
|
assert.equal(await isReserved(name), true, `"${name}" impersonates as much as its singular`)
|
|
}
|
|
// Only a trailing s off the WHOLE term, so an ordinary word whose stem merely
|
|
// contains one does not fire.
|
|
for (const name of ['Nomads', 'Playerless', 'Gods']) {
|
|
assert.equal(await isReserved(name), false, `"${name}" is not a plural of a reserved term`)
|
|
}
|
|
})
|
|
|
|
test('an acronym spelled with punctuation is caught', async () => {
|
|
// "G.M." normalises to two single-letter words, neither of which is the term.
|
|
assert.equal(await isReserved('G.M.'), true)
|
|
assert.equal(await isReserved('G M Council'), true)
|
|
// …but joining single letters must not condense whole names, which would let a
|
|
// single-word term match inside an ordinary word again.
|
|
assert.equal(await isReserved('Badminton'), false)
|
|
})
|