// ── 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, }