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:
2026-08-17 15:08:58 -05:00
parent bfd844e8fb
commit 8fe2e01466
7 changed files with 1147 additions and 8 deletions

View File

@@ -0,0 +1,293 @@
// Auto-hide and the §2.9 approval gate (docs/website/TEAMS.md §2.8§2.9).
//
// The gate's SCOPE is what these tests pin down, and it is the thing most likely
// to be widened by accident. Three actions are gated because they publish
// untrusted game-sourced strings; everything else staff can do still applies at
// once. Gating more would make this a general staff-approval workflow, which is a
// different and much larger idea — and gating admins would wedge the
// single-admin deployments `npm run seed` creates.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const moderationDb = require('../src/model/teams/teamModeration.db')
const teamsDb = require('../src/model/teams/teams.db')
const activity = require('../src/model/activity/activity.model')
const reservedNames = require('../src/utils/reservedNames')
const moderation = require('../src/model/teams/teamModeration.model')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
let db
let logged
const admin = { id: 1, username: 'root', role: 'admin' }
const mod = { id: 2, username: 'mod1', role: 'moderator' }
function stub() {
db = {
teams: new Map([[1, { id: 1, name: 'Admin', slug: 'admin', hidden: 1, hidden_reason: 'reserved_name' }]]),
requests: new Map(),
nextRequestId: 1,
}
logged = []
patch(teamsDb, 'findById', async (id) => db.teams.get(id))
patch(moderationDb, 'setHidden', async (id, { hidden, reason, term }) => {
const t = db.teams.get(id)
Object.assign(t, { hidden: hidden ? 1 : 0, hidden_reason: hidden ? reason : null, hidden_term: hidden ? term : null })
})
patch(moderationDb, 'markNameReviewed', async (id) => { db.teams.get(id).name_reviewed_at = 'now' })
patch(moderationDb, 'setDisplayNameOverride', async (id, value) => {
db.teams.get(id).display_name_override = value
})
patch(moderationDb, 'insertRequest', async (row) => {
const id = db.nextRequestId++
db.requests.set(id, { id, status: 'pending', ...row, payload: row.payload, team_id: row.teamId, requested_username: row.requestedUsername })
return id
})
patch(moderationDb, 'findRequest', async (id) => db.requests.get(id))
patch(moderationDb, 'decideRequest', async (id, { status, decidedUsername }) => {
const r = db.requests.get(id)
if (!r || r.status !== 'pending') return false
Object.assign(r, { status, decided_username: decidedUsername })
return true
})
patch(activity, 'log', async (entry) => { logged.push(entry) })
}
beforeEach(stub)
afterEach(() => {
while (saved.length) {
const [m, name, fn] = saved.pop()
m[name] = fn
}
})
const actions = () => logged.map((l) => l.action)
// ── Auto-hide at create ────────────────────────────────────────────────────
test('a reserved name produces the hide columns a create should carry', async () => {
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'admin' }))
assert.deepEqual(await moderation.screenForCreate('Admin'), {
hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin',
})
})
test('an ordinary name carries nothing', async () => {
patch(reservedNames, 'screen', async () => ({ reserved: false, term: null }))
assert.deepEqual(await moderation.screenForCreate('The Silver Hand'), { hidden: false })
})
test('a screening failure creates the team unscreened rather than aborting the reconcile', async () => {
// A deliberate trade: the re-screen on the next sync catches it, and a
// reconcile that dies halfway through is worse than a name public for one
// interval. It is also why re-screening exists rather than being create-only.
patch(reservedNames, 'screen', async () => { throw new Error('settings unavailable') })
assert.deepEqual(await moderation.screenForCreate('Admin'), { hidden: false })
})
// ── Re-screening ───────────────────────────────────────────────────────────
test('a re-screen hides a team whose name became reserved', async () => {
patch(moderationDb, 'unreviewedActive', async () => [{ id: 1, name: 'Council', hidden: 0 }])
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'Council' }))
assert.equal(await moderation.rescreen('uo'), 1)
assert.equal(db.teams.get(1).hidden, 1)
assert.equal(db.teams.get(1).hidden_term, 'Council')
})
test('a re-screen never re-hides a team staff have already ruled on', async () => {
// unreviewedActive excludes them by definition — the stamp is the mechanism,
// and without it an override would be undone on every sweep.
patch(moderationDb, 'unreviewedActive', async () => [])
patch(reservedNames, 'screen', async () => ({ reserved: true, term: 'admin' }))
assert.equal(await moderation.rescreen('uo'), 0)
})
test('a re-screen skips a team that is already hidden', async () => {
patch(moderationDb, 'unreviewedActive', async () => [{ id: 1, name: 'Admin', hidden: 1 }])
patch(reservedNames, 'screen', async () => { throw new Error('should not be screened again') })
assert.equal(await moderation.rescreen('uo'), 0)
})
test('a failing re-screen does not break the reconcile that called it', async () => {
patch(moderationDb, 'unreviewedActive', async () => { throw new Error('db down') })
assert.equal(await moderation.rescreen('uo'), 0)
})
// ── The gate: moderator asks, admin applies ────────────────────────────────
test('a moderator un-hiding files a pending request and changes nothing public', async () => {
const result = await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'unhide', reason: 'legitimate guild',
})
assert.equal(result.pending, true)
assert.equal(db.teams.get(1).hidden, 1, 'nothing is published until an admin agrees')
assert.equal(db.requests.get(1).status, 'pending')
assert.deepEqual(actions(), ['team.moderation.request'])
})
test('an admin un-hiding applies at once', async () => {
const result = await moderation.requestOrApply({ actor: admin, teamId: 1, action: 'unhide' })
assert.equal(result.pending, false)
assert.equal(db.teams.get(1).hidden, 0)
assert.equal(db.teams.get(1).name_reviewed_at, 'now', 'a human has now ruled on the name')
assert.deepEqual(actions(), ['team.unhide'])
})
test('an admin approving a moderators request publishes it', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide', reason: 'legit' })
assert.equal(db.teams.get(1).hidden, 1)
const result = await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(result.applied, true)
assert.equal(db.teams.get(1).hidden, 0)
assert.deepEqual(actions(), ['team.moderation.request', 'team.unhide', 'team.moderation.approved'])
})
test('a rejected request changes nothing but is kept', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
const result = await moderation.decide({ actor: admin, requestId: 1, status: 'rejected', note: 'no' })
assert.equal(result.applied, false)
assert.equal(db.teams.get(1).hidden, 1)
assert.equal(db.requests.get(1).status, 'rejected', 'the record of a refusal is the part worth having')
assert.equal(actions().includes('team.unhide'), false)
})
test('a moderator may not decide a request', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
const result = await moderation.decide({ actor: mod, requestId: 1, status: 'approved' })
assert.equal(result.ok, false)
assert.equal(result.status, 403)
assert.equal(db.teams.get(1).hidden, 1)
})
test('a request already decided cannot be decided again', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
const second = await moderation.decide({ actor: admin, requestId: 1, status: 'rejected' })
assert.equal(second.ok, false)
assert.equal(second.status, 409)
assert.equal(db.teams.get(1).hidden, 0, 'the first decision stands')
})
test('two admins deciding at once — only one applies', async () => {
// The row moves out of `pending` under a guard, and the effect follows only if
// it actually moved. Without that, both would apply the action and the second
// would overwrite the record of who decided it.
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
let applied = 0
patch(moderationDb, 'setHidden', async () => { applied += 1 })
const [a, b] = await Promise.all([
moderation.decide({ actor: admin, requestId: 1, status: 'approved' }),
moderation.decide({ actor: { ...admin, id: 3, username: 'root2' }, requestId: 1, status: 'approved' }),
])
assert.equal([a.ok, b.ok].filter(Boolean).length, 1)
assert.equal(applied, 1)
})
test('an unknown request and an unknown team are refused, not guessed at', async () => {
assert.equal((await moderation.decide({ actor: admin, requestId: 99, status: 'approved' })).status, 404)
assert.equal((await moderation.requestOrApply({ actor: admin, teamId: 99, action: 'unhide' })).status, 404)
})
test('an invalid decision status is refused', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
assert.equal((await moderation.decide({ actor: admin, requestId: 1, status: 'maybe' })).status, 400)
})
// ── The display-name override, through the same gate ───────────────────────
test('a display name set by an admin applies; by a moderator it waits', async () => {
await moderation.requestOrApply({
actor: admin, teamId: 1, action: 'display_name_override', payload: { displayName: 'The Old Guard' },
})
assert.equal(db.teams.get(1).display_name_override, 'The Old Guard')
db.teams.get(1).display_name_override = null
await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'Sneaky' },
})
assert.equal(db.teams.get(1).display_name_override, null, 'free text into a public surface waits for an admin')
})
test('an approved display-name request carries its payload through', async () => {
await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'The Old Guard' },
})
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(db.teams.get(1).display_name_override, 'The Old Guard')
})
test('a payload stored as a JSON string is parsed on approval', async () => {
// The driver hands JSON columns back parsed on some versions and as a string on
// others; an approval that silently applied `undefined` would be a data loss
// that only shows up on one of them.
await moderation.requestOrApply({
actor: mod, teamId: 1, action: 'display_name_override', payload: { displayName: 'Kept' },
})
db.requests.get(1).payload = JSON.stringify({ displayName: 'Kept' })
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(db.teams.get(1).display_name_override, 'Kept')
})
test('clearing a display name is gated too', async () => {
db.teams.get(1).display_name_override = 'Something'
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'clear_display_name_override' })
assert.equal(db.teams.get(1).display_name_override, 'Something')
await moderation.decide({ actor: admin, requestId: 1, status: 'approved' })
assert.equal(db.teams.get(1).display_name_override, null)
})
// ── Hiding is NOT gated ────────────────────────────────────────────────────
test('a moderator may hide immediately — suppression is always safe', async () => {
db.teams.get(1).hidden = 0
const result = await moderation.hide({ actor: mod, teamId: 1, reason: 'impersonation' })
assert.equal(result.ok, true)
assert.equal(db.teams.get(1).hidden, 1)
assert.equal(db.teams.get(1).hidden_reason, 'staff')
assert.deepEqual(actions(), ['team.hide'])
assert.equal(db.requests.size, 0, 'withdrawing untrusted data must not wait for a second pair of eyes')
})
// ── The gate's scope ───────────────────────────────────────────────────────
test('exactly three actions are gated', () => {
assert.deepEqual(moderation.GATED_ACTIONS, ['unhide', 'display_name_override', 'clear_display_name_override'])
})
test('an action outside the three is rejected rather than quietly gated', async () => {
await assert.rejects(
() => moderation.requestOrApply({ actor: mod, teamId: 1, action: 'archive' }),
/not a gated action/,
)
})
test('every transition writes the audit log', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide', reason: 'legit' })
await moderation.decide({ actor: admin, requestId: 1, status: 'approved', note: 'checked' })
assert.equal(logged.length, 3)
assert.match(logged[0].detail, /mod1 \(#2\) requested "unhide" on team "Admin" \(#1\): "legit"/)
assert.match(logged[2].detail, /root \(#1\) approved request #1/)
assert.match(logged[2].detail, /asked by mod1/)
})
test('the audit trail survives the requesters account being deleted', async () => {
await moderation.requestOrApply({ actor: mod, teamId: 1, action: 'unhide' })
// §2.10: requested_by goes SET NULL and the username snapshot is what keeps the
// record readable.
db.requests.get(1).requested_username = null
await moderation.decide({ actor: admin, requestId: 1, status: 'rejected' })
assert.match(logged[logged.length - 1].detail, /asked by a deleted user/)
})