feat(teams): Team core — the reconciler, the four authority paths, and the impersonation controls #151
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 teamsDb = require('./teams.db')
|
||||||
const teamProvider = require('./teamProvider')
|
const teamProvider = require('./teamProvider')
|
||||||
|
const moderation = require('./teamModeration.model')
|
||||||
const { slugify, uniqueSlug } = require('./teamSlug')
|
const { slugify, uniqueSlug } = require('./teamSlug')
|
||||||
const settings = require('../settings/settings.model')
|
const settings = require('../settings/settings.model')
|
||||||
const log = require('../../utils/logger')('teams')
|
const log = require('../../utils/logger')('teams')
|
||||||
@@ -95,17 +96,23 @@ function backoffSeconds(consecutiveFailures, intervalS) {
|
|||||||
// ── Applying one Team ──────────────────────────────────────────────────────
|
// ── 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 whatever the screening says, and hidden if it matched. Core
|
||||||
* the row is created either way, because core cannot refuse a name — the guild
|
* cannot refuse a name: the guild already exists in the game and core is a mirror
|
||||||
* already exists in the game and core is a mirror of it, not an authority over it.
|
* 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) {
|
async function createTeam(moduleId, team) {
|
||||||
const taken = await teamsDb.slugsLike(slugify(team.name) || 'team')
|
const taken = await teamsDb.slugsLike(slugify(team.name) || 'team')
|
||||||
const slug = uniqueSlug(team.name, taken)
|
const slug = uniqueSlug(team.name, taken)
|
||||||
const id = await teamsDb.insertTeam({ moduleId, slug, ...team })
|
const screened = await moderation.screenForCreate(team.name)
|
||||||
log.info('team created', { moduleId, externalId: team.externalId, name: team.name, slug })
|
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
|
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)
|
await teamsDb.recordSuccess(moduleId)
|
||||||
log.info('reconcile complete', { trigger: reason, created, renamed, archived, rosters, total: answer.teams.length })
|
log.info('reconcile complete', {
|
||||||
return { ok: true, created, renamed, archived, rosters }
|
trigger: reason, created, renamed, archived, rosters, rehidden, total: answer.teams.length,
|
||||||
|
})
|
||||||
|
return { ok: true, created, renamed, archived, rosters, rehidden }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The public entry points ────────────────────────────────────────────────
|
// ── The public entry points ────────────────────────────────────────────────
|
||||||
|
|||||||
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 }
|
||||||
192
server/test/reservedNames.test.js
Normal file
192
server/test/reservedNames.test.js
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
// 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)
|
||||||
|
})
|
||||||
293
server/test/teamModeration.test.js
Normal file
293
server/test/teamModeration.test.js
Normal 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 moderator’s 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 requester’s 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/)
|
||||||
|
})
|
||||||
@@ -11,6 +11,7 @@ const assert = require('node:assert/strict')
|
|||||||
|
|
||||||
const registries = require('../src/modules/registries')
|
const registries = require('../src/modules/registries')
|
||||||
const teamsDb = require('../src/model/teams/teams.db')
|
const teamsDb = require('../src/model/teams/teams.db')
|
||||||
|
const moderation = require('../src/model/teams/teamModeration.model')
|
||||||
const settings = require('../src/model/settings/settings.model')
|
const settings = require('../src/model/settings/settings.model')
|
||||||
const teamSync = require('../src/model/teams/teamSync.model')
|
const teamSync = require('../src/model/teams/teamSync.model')
|
||||||
|
|
||||||
@@ -169,6 +170,18 @@ function stubDb() {
|
|||||||
s.pending_empty_since = since
|
s.pending_empty_since = since
|
||||||
store.sync.set(moduleId, s)
|
store.sync.set(moduleId, s)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Reserved-name screening is its own unit (teamModeration.test.js). Stubbed
|
||||||
|
// here so these tests stay about the reconciler — and because the real calls
|
||||||
|
// read settings, which means a live database connection this suite must never
|
||||||
|
// make. `screened` records that the reconciler asked, which is the integration
|
||||||
|
// point worth asserting from this side.
|
||||||
|
store.screened = []
|
||||||
|
patch(moderation, 'screenForCreate', async (name) => {
|
||||||
|
store.screened.push(name)
|
||||||
|
return { hidden: false }
|
||||||
|
})
|
||||||
|
patch(moderation, 'rescreen', async () => 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A provider whose answers the test controls. Defaults are authoritative and
|
// A provider whose answers the test controls. Defaults are authoritative and
|
||||||
@@ -612,6 +625,58 @@ test('a name with nothing URL-safe in it still gets an address', async () => {
|
|||||||
assert.equal(store.teams[0].name, '★☆★', 'the identity keeps what the player typed')
|
assert.equal(store.teams[0].name, '★☆★', 'the identity keeps what the player typed')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Screening is on the create path, and on every run ──────────────────────
|
||||||
|
|
||||||
|
test('every newly created team has its name screened', async () => {
|
||||||
|
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin'), team('g2', 'The Silver Hand')] }) })
|
||||||
|
await teamSync.reconcileNow('test')
|
||||||
|
assert.deepEqual(store.screened, ['Admin', 'The Silver Hand'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a renamed team is screened again under its new name', async () => {
|
||||||
|
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Ordinary')] }) })
|
||||||
|
await teamSync.reconcileNow('setup')
|
||||||
|
|
||||||
|
registries._reset()
|
||||||
|
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }) })
|
||||||
|
await teamSync.reconcileNow('rename')
|
||||||
|
assert.deepEqual(store.screened, ['Ordinary', 'Admin'], 'a rename is a create, so it screens')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a hidden team is still created and still syncs its roster', async () => {
|
||||||
|
// Hide, never reject: the Team works completely for its own members. The people
|
||||||
|
// in it are not being punished for a name their leader chose.
|
||||||
|
patch(moderation, 'screenForCreate', async () => ({
|
||||||
|
hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin',
|
||||||
|
}))
|
||||||
|
provide({
|
||||||
|
getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }),
|
||||||
|
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
|
||||||
|
})
|
||||||
|
await teamSync.reconcileNow('test')
|
||||||
|
|
||||||
|
assert.equal(store.teams[0].hidden, 1)
|
||||||
|
assert.equal(store.teams[0].hidden_term, 'admin')
|
||||||
|
assert.equal(activeMembers(1).length, 2, 'suppression is a public-surface rule, not a shutdown')
|
||||||
|
assert.equal(store.teams[0].member_count, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a successful run re-screens the names no human has ruled on', async () => {
|
||||||
|
let called = 0
|
||||||
|
patch(moderation, 'rescreen', async () => { called += 1; return 0 })
|
||||||
|
provide()
|
||||||
|
await teamSync.reconcileNow('test')
|
||||||
|
assert.equal(called, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a refused run does not re-screen — it does nothing at all', async () => {
|
||||||
|
let called = 0
|
||||||
|
patch(moderation, 'rescreen', async () => { called += 1; return 0 })
|
||||||
|
provide({ getTeams: async () => ({ ok: false, reason: 'down' }) })
|
||||||
|
await teamSync.reconcileNow('test')
|
||||||
|
assert.equal(called, 0)
|
||||||
|
})
|
||||||
|
|
||||||
// ── Events (§2.3) ──────────────────────────────────────────────────────────
|
// ── Events (§2.3) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('an unknown event kind is rejected', async () => {
|
test('an unknown event kind is rejected', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user